AWS Lambda(Python) の開発環境・テスト・デプロイ・CI 考察
サーバーレス開発部では、AWS Lambda の Lambda Function をひんぱんに開発します。Lambda Function はその特性上、アプリケーションサーバーを持たず、Function として登録すればすぐに動かせます。また、接続するAWSサービスは多岐に渡り、プログラムから接続するためのSDKも用意されています。
Lambda Function の開発で感じる課題
そのような状況で、私は少し戸惑ってしまいました。最初に思ったのは どこで開発すればいいんだ ということです。AWSのコンソールにいくと、 Lambda Fuction のためのエディタが埋め込まれており、そこで直接開発することもできます。便利!と思ったのですが、オンラインエディタはショートカットキーの制限などもあり、性に合いませんでした。また、環境ごとに複数関数を作る場合や、複数メンバーで開発する場合など、開発規模に対してスケールしにくいな、とも思います。できればローカルで、慣れ親しんだエディタで、GitHubを使いつつ開発したいという思いです。
次に、ローカルで開発することになった場合、テストやデプロイについても考えなければなりません。Lambda Function をテスト、デプロイする方法については、様々なツールが出回っていて、使い方は調べればわかります。が、Lambda Function を 開発してデプロイするという 一本のストーリーをなぞったときに、リポジトリにどう配置するか、どのタイミングで何をさせるか という点については自分たちで考えなければなりません。プロジェクトごとにそれに時間を使うのは少々もったいないです。
開発からデプロイまでリポジトリ単位でやり方を考察
以上の課題を踏まえ、「ローカルで Lamabda Function を開発するときに、開発、テスト、デプロイ、CI/CD を通しでやるリポジトリを一個作ってみよう」というのが本記事の主旨です。なお、Lambda Function 数個で済むようなプロジェクトというよりは、複数のリソースを対象とし、追加開発も想定される比較的規模の大きいプロジェクトを想定しています。ジャストアイデアですので、他にマッチするツールがある、ファイルの配置はこうしたほうがいい、などありましたら意見をください。
ざっくりどうやるか
Lambda Function について、開発やテストをどう行うか、まずは今回考えた方法の概要です。
| 対象 | どうやるか |
|---|---|
| コーディング作業 | Intellij で Python(venv)プロジェクトとして開発。 |
| Unit Test | pytest。ただしAWSサービスへのリクエスト部分は対象としない。 |
| 結合テスト | SAM Local、LocalStack、batsを組み合わせて。 |
| デプロイ | ZIPファイルを使う前提で、スクリプトによるアップロード。デプロイは AWS SAM。 |
| CI/CD | 上記の作業を AWS CodeBuild で行う。 |
| 追加開発作業 | メソッドだけ追加する場合と、リソースレベルで追加する場合について考察。 |
利用したツールとバージョン
| ツール | バージョン |
|---|---|
| Python | 3.6.2 |
| pytest | 3.4.2 |
| aws-cli | 1.11.150 |
| AWS SAM Local | 0.2.8 |
| LocalStack ( Docker ) | latest |
| Bats - Bash Automated Testing System | 0.4.0 |
作るもの
ヒーローを管理する Lambda Function を書きます。ヒーロー情報は DynamoDB の ヒーローテーブルに格納するものとします。
コーディング作業
すべてはコードを書くところから始めます。いきなりプロジェクトルートにファイルを置いて書き始めるのも良いですが、後々テストやデプロイも行うことになるので少し整理してみます。以下のようにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | .├── buildspec.yml├── deploy.sh├── docker-compose.yml├── environments│ ├── common.sh│ └── sam-local.json├── integration_test.sh├── requirements.txt├── src│ └── functions│ └── heroes│ ├── index.py│ └── utils.py├── test│ └── functions└── template_heroes.yaml |
関数本体は src/functions 以下に配置していきます。今回はheroesリソースを操作するということでフォルダをひとつ作り、その下に Lambda Function 用のファイルを置いていきます。
venv 有効化
次に、Python のライブラリをどう管理するかという話です。venv を使うことにしました。プロジェクトのルートディレクトリにおいて、
1 2 | python3 -m venv .source bin/activate.fish # fish shell の場合 |
これを実行すると仮想環境ができあがります。その後、
1 | pip install -r requirements.txt |
を実行することで、プロジェクトに必要なライブラリをインストールできます。逆に、開発する中で新しいライブラリを導入した場合は、
1 2 | pip install boto3pip freeze > requirements.txt |
とすることでプロジェクトに必要なライブラリを記録できます。
Intellij 設定
Intellij で開発するためには、SDKの設定を行い、ライブラリへのパスを通す設定が必要です。
- Project Structure > Project > Project SDK で グローバル の Python を指定してSDKを作成する
- Project Structure > SDKs > 1 で設定したPython にて
/path/to/python/lib/python3.6を追加する - Project Structure > Modules > で src フォルダを Sources として、 testフォルダを Tests として設定する
- Python ファイルのソースコードを開くと「requirements.txt をインストールするか?」と聞かれるのでインストールする
これでライブラリへのパスが通り、Intellij上で Lambda Function を開発できるようになります。図のようにboto3ライブラリのメソッド候補も利用でき、便利です。
Unit Test
Lambda Function のロジック部分をテストします。なお、AWS サービスへのアクセス部分は Unit Test として考慮しません。 それらは後続の結合テストで行います。AWS SDK の動作をモック化するライブラリなど使えば可能かもしれませんが、割り切りです。本当はできるならやったほうがいいのはその通りです。
今回は ヒーローの情報を DynamoDB へ保存する際の、 unixtime を計算するロジックを切り出し、それをテストすることにします。
1 2 3 4 5 6 | from datetime import datetimeimport decimalimport jsondef epoc_by_second_precision(time: datetime): return decimal.Decimal(time.replace(microsecond=0).timestamp()) |
テストフォルダに pytest のためのファイルを作ります。
1 2 3 4 5 6 7 8 | from src.functions.heroes.utils import *def test_epoc_by_second_precision(): tstr = '2012-12-29T13:49:37+0900' tdatetime = datetime.strptime(tstr, '%Y-%m-%dT%H:%M:%S%z') sut = epoc_by_second_precision(tdatetime) assert int(sut) == 1356756577 |
その後、テストを実行します。
1 2 3 4 5 6 7 | python -m pytestplatform darwin -- Python 3.6.2, pytest-3.4.2, py-1.5.2, pluggy-0.6.0rootdir: /Users/wada.yusuke/.ghq/github.com/cm-wada-yusuke/python-lambda-template, inifile:collected 1 itemtest/functions/heroes/test_utils.py . |
無事テストが通りました。この調子で、 Unit Test は AWS サービスへのリクエスト部分と切り離した、純粋なロジック部分だけを対象として書いていきます。
結合テスト
今回一番時間がかかったところです。まず考え方ですが、Lambda Function への入力があったとき、AWSサービスからのデータ取得/データ投入が正しく行われているか という観点でテストします。つまり以下の方針です。
- 入力: モックのペイロードを使います
- 実行: AWS SAM Local を使って Lambda Function を実行します
- 結果: LocalStack でローカルに起動した AWSサービス を使って確認します
SAM Local と LocalStack を利用した Lambda Function の実行については、以下の記事もご参考ください。本記事も同じ方法で行います。
AWS SAM Local と LocalStack を使って ローカルでAWS Lambdaのコードを動かす | Developers.IO
LocalStack の準備
docker-compose.yml を定義します。今回はDynamoDBを利用します。
1 2 3 4 5 6 7 8 9 10 11 12 13 | version: "3.3"services: localstack: container_name: localstack image: localstack/localstack ports: - "8080:8080" - "4569:4569" environment: - SERVICES=dynamodb - DEFAULT_REGION=ap-northeast-1 - DOCKER_HOST=unix:///var/run/docker.sock |
起動しましょう。
1 | docker-compose up -d |
これで LocalStack は準備完了です。簡単…!
AWS SAM Local の準備
SAM Local を使って Lambda Function を実行するにあたり、こちらで用意するものは以下です。
- 実行したい Lambda Function
- 入力ペイロード
- AWS SAM でデプロイするための CloudFormation テンプレート
- SAM Local での実行時に使う環境変数の定義JSON
実行したい Lambda Function
API Gateway のリクエストからIDを受け取り、DynamoDB から ヒーローを取り出し、JSONとして返す関数を実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | import boto3import datetimeimport uuidfrom builtins import Exceptionimport osfrom src.functions.heroes.utils import *DYNAMODB_ENDPOINT = os.getenv('DYNAMODB_ENDPOINT')HERO_TABLE_NAME = os.getenv('HERO_TABLE_NAME')DYNAMO = boto3.resource( 'dynamodb', endpoint_url=DYNAMODB_ENDPOINT)DYNAMODB_TABLE = DYNAMO.Table(HERO_TABLE_NAME)def get(event, context): try: hero_id = event['id'] dynamo_response = DYNAMODB_TABLE.get_item( Key={ 'id': hero_id } ) response = json.dumps(dynamo_response['Item'], cls=DecimalEncoder, ensure_ascii=False) return response except Exception as error: raise error |
入力ペイロード
API Gateway のマッピングテンプレートで抽出したいヒーローIDのみが関数に渡されると想定し、以下のようにします。
1 2 3 | { "id": "test-id"} |
CloudFormation テンプレート
Lambda Function の Handler のパスが正しいことを確認してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | AWSTemplateFormatVersion: '2010-09-09'Transform: AWS::Serverless-2016-10-31Description: Simple CRUD webservice. State is stored in a SimpleTable (DynamoDB) resource.Parameters: Env: Type: String Default: local DynamoDBEndpoint: Type: String Default: https://dynamodb.ap-northeast-1.amazonaws.com/ HeroTableName: Type: String Default: CM-Heroes BucketName: Type: String Default: hero-lambda-deploy CodeKey: Type: String Default: heroes/0000.zipResources: GetHeroes: Type: AWS::Serverless::Function Properties: FunctionName: Fn::Sub: ${Env}-heroes-GetHeroes Handler: src/functions/heroes/index.get Runtime: python3.6 CodeUri: Bucket: !Ref BucketName Key: !Ref CodeKey Policies: AmazonDynamoDBReadOnlyAccess Environment: Variables: ENV: !Ref Env DYNAMODB_ENDPOINT: !Ref DynamoDBEndpoint HERO_TABLE_NAME: Fn::Sub: ${Env}-${HeroTableName} |
SAM Local での実行時に使う環境変数の定義JSON
CloudFormation のテンプレートをみると、DynamoDB のエンドポイントやテーブル名は実際のAWS環境を想定した値になっていますが、ローカルでのテストにおいては LocalStack で起動中のものを利用するため書き換えなければなりません。SAM Local でそのための仕組みが用意されており、環境変数を上書きするためのJSONファイルを用意します。
1 2 3 4 5 6 |
Lambda Function をローカルで実行
準備が整いました。実行します。
1 2 3 4 5 6 7 8 9 10 11 | sam local invoke \--docker-network a27c0476cb8e \-t template_heroes.yaml \--event test/functions/heroes/examples/get_payload.json \--env-vars environments/sam-local.json GetHeroes2018/03/09 11:32:42 Successfully parsed template_heroes.yaml...省略...{"name": "Test-man", "created_at": 1520400553, "id": "test-id", "office": "virtual", "updated_at": 1520400554} |
最後の行で、LocalStack 上の DynamoDB に保存されているデータが取得できていることがわかります。SAM Local と LocalStack で Lambda Function を実行することができました。
テストとして実行するには?
さて、これまでやったことはあくまで Lambda Function をローカルで起動するところまでです。出力が得られたので、想定どおりの値かどうか、テストする必要があります。pytest でテストコードを書くことも考えましたが、aws cli や sam local などコマンドラインでの操作が大部分を閉めるため、コマンドラインのテストをサポートする Bats を使ってみることにしました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | #!/usr/bin/env bats. environments/common.shsetup() { echo "setup" docker_network_id=`docker network ls -q -f NAME=$DOCKER_NAME`}teardown() { echo "teardown"}@test "DynamoDB Get Hero Function response the correct item" { # テストデータを用意 data='{ "id": {"S": "test-id"}, "name": {"S": "Test-man"}, "created_at": {"N": "1520400553"}, "updated_at": {"N": "1520400554"}, "office": {"S": "virtual"} }' expected=`echo "${data}" | jq -r .` # テストデータを LocalStack の DynamoDB に投入 aws --endpoint-url=http://localhost:4569 dynamodb put-item --table-name CM-Heroes --item "${data}" # SAM Local を起動し、Lambda Function の出力を得る actual=`sam local invoke --docker-network ${docker_network_id} -t template_heroes.yaml --event test/functions/heroes/examples/get_payload.json --env-vars environments/sam-local.json GetHeroes | jq -r .` # 出力内容をテスト [ `echo "${actual}" | jq .id` = `echo "${expected}" | jq .id.S` ] [ `echo "${actual}" | jq .name` = `echo "${expected}" | jq .name.S` ] [ `echo "${actual}" | jq .created_at` -eq `echo "${expected}" | jq .created_at.N | bc` ] [ `echo "${actual}" | jq .updated_at` -eq `echo "${expected}" | jq .updated_at.N | bc` ] [ `echo "${actual}" | jq .office` = `echo "${expected}" | jq .office.S` ]} |
その後、実行します。
1 2 3 4 5 | bats test/functions/heroes/integration.bats✓ DynamoDB Get Hero Function response the correct item1 tests, 0 failures |
テストにパスしたという結果が得られました。SAM Local と Bats を組み合わせることで、ローカル環境での結合テストが実行できそうです。
デプロイ
開発とテストができたので、デプロイを考えます。なお、今回は全部入りの ZIP ファイルを作っていまい、それを使って Lambda Function をデプロイする方針とします。必然的に、
- ZIP ファイルを S3 にアップロードする
- Lambda Function をデプロイする CloudFormation スタックを作る
- CloudFormation で Lambda Function をデプロイする
という流れになります。で、今回、これをどうやったかというと、スクリプトによる力技です。Rakeのようなタスクランナーを使って定義すれば、より柔軟性が高くなるかもしれません。ここは私の タスクランナーパワー不足 です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | #!/usr/bin/env bash. environments/common.shif [ -z $ENV ]; then echo "Set the environment name. Such as test, stg, and prd." 1>&2 exit 1fi# 必要ファイルを集め、ZIPを作成するpip install -r requirements.txt -t deploycp -R src deploycd deployzip -r source.zip *hash=`openssl md5 source.zip | awk '{print $2}'`echo "source.zip: hash = $hash"filename="${hash}.zip"mv source.zip $filenamebucket=$DEPLOY_S3_BUCKETfor item in ${FUNCTION_GROUP[@]} ; do # S3 へアップロード s3_keyname="${item}/${filename}" aws s3 cp $filename s3://${bucket}/${item}/ # CloudFormation テンプレート作成 cp ../template_${item}.yaml ./ aws cloudformation package \ --template-file template_${item}.yaml \ --s3-bucket ${bucket} \ --output-template-file packaged-${item}.yaml # CloudFormation によるデプロイ aws cloudformation deploy \ --template-file packaged-${item}.yaml \ --stack-name ${ENV}-${item}-lambda \ --capabilities CAPABILITY_IAM \ --parameter-overrides \ Env=${ENV} \ CodeKey=${s3_keyname}donecd ..rm -r deploy/ |
CI / CD
ローカル環境において、テストおよびデプロイができるようになりました。これができたら、追加開発や修正を考慮して、CIツールを利用するのが次の話です。考え方はシンプルで、今までやったことを CIツールの設定ファイルへ書き出すだけです。今回はすべてAWSで完結するので AWS CodeBuild を使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | version: 0.2env: variables: DOCKER_COMPOSE_VERSION: "1.18.0"phases: install: commands: - sudo apt-get update - sudo apt-get install zip bc - sudo curl -o /usr/local/bin/jq -L https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 && sudo chmod +x /usr/local/bin/jq - curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash - - sudo apt-get install -y nodejs - sudo npm cache clean --force - sudo npm install -g n - sudo n stable - sudo ln -sf /usr/local/bin/node /usr/bin/node - sudo apt-get purge -y nodejs - npm update -g npm - npm -g install --unsafe-perm aws-sam-local - python -m venv . - . bin/activate - pip install --upgrade pip - pip install --upgrade awscli - curl -L "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose && chmod +x /usr/local/bin/docker-compose - git clone https://github.com/sstephenson/bats.git - cd bats - sudo ./install.sh /usr/local - cd ../ pre_build: commands: - pip install -r requirements.txt - docker-compose up -d build: commands: - python -m pytest # Unit Test - ./integration_test.sh # 結合テスト post_build: commands: - ./deploy.sh # ビルド & アップロード & デプロイ |
実際に動かしてみましょう。AWS コンソールから CodeBuild のプロジェクトを作ります。
次にビルドを実行します。環境名を blog にしました。CloudFormation のスタック名や Lambda Function の関数名のプレフィックスに環境名がつくようにしています。
実行した結果、CloudFormation の スタックが作成され…
Lambda Function も作成されました。
追加開発作業
作成した関数をデプロイする環境が整いました。このあとやっていくこととしては、機能を追加して、そしたらデプロイして、動作確認して…という流れを繰り返すことになります。ここでは追加開発を想定し、どのようなフローになるのかみてみます。
関数を追加する場合
例えばヒーロー情報を受け取り、それをDynamoDBへ保存するような関数を追加することを考えます。以下のような作業を行うことになるでしょう。
- Lambda Function を追記する
- (必要であれば)Unit Test を追加する
- template_heroes.yaml に新しい関数を追加する
- 結合テストを追加する
- CodeBuild を使ってデプロイする
ヒーロー情報を扱うことは変わらないので、 src/functions/heroes/index.py へ追記することにします。DynamoDB へ格納しているだけなので Unit Test は追加しません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | ...前略...def put(event, context): try: hero_id = str(uuid.uuid4()) name = event.get('name') office = event.get('office') updated_at = epoc_by_second_precision(datetime.now()) response = DYNAMODB_TABLE.put_item( Item={ 'id': hero_id, 'name': name, 'office': office, 'updated_at': updated_at, 'created_at': updated_at, } ) return response except Exception as error: raise error |
template_heroes.yaml に追記します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | ...前略...PutHeroes: Type: AWS::Serverless::Function Properties: FunctionName: Fn::Sub: ${Env}-heroes-PutHeroes Handler: src/functions/heroes/index.put Runtime: python3.6 CodeUri: Bucket: !Ref BucketName Key: !Ref CodeKey Policies: AmazonDynamoDBFullAccess Environment: Variables: ENV: !Ref Env DYNAMODB_ENDPOINT: !Ref DynamoDBEndpoint HERO_TABLE_NAME: Fn::Sub: ${Env}-${HeroTableName} |
結合テストを実行するために、環境設定ファイルと結合テストのファイルへ追記します。
1 2 3 4 5 6 7 8 9 10 | { "GetHeroes": { "HERO_TABLE_NAME": "CM-Heroes" }, "PutHeroes": { "HERO_TABLE_NAME": "CM-Heroes" }} |
1 2 3 4 5 | ...前略...@test "DynamoDB PUT Hero Function response code is 200" { result=`sam local invoke --docker-network ${docker_network_id} -t template_heroes.yaml --event test/functions/heroes/examples/put_payload.json --env-vars environments/sam-local.json PutHeroes | jq '.ResponseMetadata.HTTPStatusCode'` [ $result -eq 200 ]} |
簡単のために DynamoDB からの PUT結果が 200 であることのみテストしています。
これでデプロイをすると、追加した PutHeroes が正しくデプロイできます。
ヒーローの削除や更新を受け付ける関数を作成する際も、同じように追加してやればよさそうです。
リソース/別業務を追加する場合
例えばヒーローが所属する事務所の管理機能を追加することになったとしましょう。ヒーローのテーブルに事務所まで入れてしまうこともできますが、データーベースの正規化の観点から別のテーブルに事務所情報を切り出して管理したいとします。このとき、リポジトリではどのような作業を行えばよいでしょうか。ドメイン駆動の視点で、別の業務を追加することになった と考えます。
- 新しいフォルダ
officesを作成して新しい index.pyをつくる - (必要であれば)Unit Test を追加する
- 別の CloudFormation テンプレート、
template_offices_yamlを追加する - 新しい結合テストファイルを作成する
- CodeBuild を使ってデプロイする
ソースフォルダに別のリソースとして新しいフォルダを作ります。事務所につてはここで扱うようにしました。
1 2 3 4 5 6 7 | ├── src └── functions ├── heroes │ ├── index.py │ └── utils.py └── offices └── index.py |
ヒーローとは別のリソースということを考慮し、template_offices.yaml を追加することにします。ひとつのテンプレートですべて完結することもできると思いますが、個別にデプロイしたい場合など、利便性と視認性を考慮して分けることにしました。内容は template_heroes.yaml とほぼおなじで、DynamoDB のテーブル名が違うくらいです。
結合テストも同様に別ファイルとして用意します。.batsファイルをさがして、すべてのテストを実行するようにします。
1 2 3 4 5 6 7 | find test -name '*.bats' | xargs bats1..4ok 1 DynamoDB Get Hero Function response the correct itemok 2 DynamoDB PUT Hero Function response code is 200ok 3 DynamoDB Get Office Function response the correct itemok 4 DynamoDB PUT Office Function response code is 200 |
デプロイすると、CloudFormation の 新しいスタックが作成され、そこから Lambda Function が生成されます。
まとめ
Lambda Function の開発からテスト、追加開発までの流れをなぞりながら、それぞれどういうツールを使っていくかアイデアを示しました。サーバーレスを取り巻く環境は変化が激しく、スタンダードも移り変わっていくでしょう。私も本記事の内容が正解とは思っていませんし、さらに効率の良いやり方を模索してくつもりです。もし良いアイデアやツールがありましたらぜひ教えてください!
参考資料
- AWS SAM/CircleCI/LocalStackを利用した実践的なCI/CD – ClassmethodサーバーレスAdvent Calendar 2017 #serverless #adventcalendar #reinvent | Developers.IO
- awslabs/aws-sam-local: AWS SAM Local
is a CLI tool for local development and testing of Serverless applications
- awslabs/serverless-application-model: AWS Serverless Application Model (AWS SAM) prescribes rules for expressing Serverless applications on AWS.
今回のソースコード
サーバーレス開発部では仲間を募集しています!
AWSサービスを駆使して 一緒に SPA や Lambda を開発しませんか?