AWS Lambda(Python) の開発環境・テスト・デプロイ・CI 考察
サーバーレス開発部では、AWS Lambda の Lambda Function をひんぱんに開発します。Lambda Function はその特性上、アプリケーションサーバーを持たず、Function として登録すればすぐに動かせます。また、接続するAWSサービスは多岐に渡り、プログラムから接続するためのSDKも用意されています。
Lambda Function の開発で感じる課題
開発を続ける中でいくつか課題を感じました。最初に思ったのは どこで開発すればいいんだ ということです。AWSのコンソールにいくと、 Lambda Function のためのエディタが埋め込まれており、そこで直接開発することもできます。便利!と思ったのですが、オンラインエディタはショートカットキーの制限などもあり、性に合いませんでした。また、複数の関数を作る場合の共通化や、複数メンバーで開発する場合のバージョン管理など、開発規模に対してスケールしにくいな、とも思います。できればローカルで、慣れ親しんだエディタで、GitHubを使いつつ開発したいという思いです。
次に、ローカルで開発することになった場合、テストやデプロイについても考えなければなりません。Lambda Function をテスト、デプロイする方法については、様々なツールが出回っていて、使い方は調べればわかります。が、Lambda Function の 開発からテストを経てデプロイするという 一本のストーリーをなぞったときに、リポジトリにどう配置するか、どのタイミングで何をさせるか という点については自分たちで考えなければなりません。プロジェクトごとにそれに時間を使うのは少々もったいないです。
開発からデプロイまでリポジトリ単位でやり方を考察
以上の課題を踏まえ、「ローカルで Lambda 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 の ヒーローテーブルに格納するものとします。リポジトリは以下。
*
Python Lambda SAM + SAM Local Project
コーディング作業
すべてはコードを書くところから始めます。いきなりプロジェクトルートにファイルを置いて書き始めるのも良いですが、後々テストやデプロイも行うことになるので少し整理してみます。以下のようにしました。
. ├── 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
を使うことにしました。プロジェクトのルートディレクトリにおいて、
python3 -m venv . source bin/activate.fish # fish shell の場合
これを実行すると仮想環境ができあがります。その後、
pip install -r requirements.txt
を実行することで、プロジェクトに必要なライブラリをインストールできます。逆に、開発する中で新しいライブラリを導入した場合は、
pip install boto3 pip 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 を計算するロジックを切り出し、それをテストすることにします。
from datetime import datetime import decimal import json def epoc_by_second_precision(time: datetime): return decimal.Decimal(time.replace(microsecond=0).timestamp())
テストフォルダに pytest のためのファイルを作ります。
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
その後、テストを実行します。
python -m pytest platform darwin -- Python 3.6.2, pytest-3.4.2, py-1.5.2, pluggy-0.6.0 rootdir: /Users/wada.yusuke/.ghq/github.com/cm-wada-yusuke/python-lambda-template, inifile: collected 1 item test/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を利用します。
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
起動しましょう。
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として返す関数を実装します。
import boto3 import datetime import uuid from builtins import Exception import os from 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のみが関数に渡されると想定し、以下のようにします。
{ "id": "test-id" }
CloudFormation テンプレート
Lambda Function の Handler のパスが正しいことを確認してください。
AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: 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.zip Resources: 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ファイルを用意します。
{ "GetHeroes": { "DYNAMODB_ENDPOINT": "http://localstack:4569/", "HERO_TABLE_NAME": "CM-Heroes" } }
Lambda Function をローカルで実行
準備が整いました。実行します。
sam local invoke \ --docker-network a27c0476cb8e \ -t template_heroes.yaml \ --event test/functions/heroes/examples/get_payload.json \ --env-vars environments/sam-local.json GetHeroes 2018/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 を使ってみることにしました。
#!/usr/bin/env bats . environments/common.sh setup() { 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` ] }
その後、実行します。
bats test/functions/heroes/integration.bats ✓ DynamoDB Get Hero Function response the correct item 1 tests, 0 failures
テストにパスしたという結果が得られました。SAM Local と Bats を組み合わせることで、ローカル環境での結合テストが実行できそうです。
デプロイ
開発とテストができたので、デプロイを考えます。なお、今回は全部入りの ZIP ファイルを作っていまい、それを使って Lambda Function をデプロイする方針とします。必然的に、
- ZIP ファイルを S3 にアップロードする
- Lambda Function をデプロイする CloudFormation スタックを作る
- CloudFormation で Lambda Function をデプロイする
という流れになります。で、今回、これをどうやったかというと、スクリプトによる力技です。Rakeのようなタスクランナーを使って定義すれば、より柔軟性が高くなるかもしれません。ここは私の タスクランナーパワー不足 です。
#!/usr/bin/env bash . environments/common.sh if [ -z $ENV ]; then echo "Set the environment name. Such as test, stg, and prd." 1>&2 exit 1 fi # 必要ファイルを集め、ZIPを作成する pip install -r requirements.txt -t deploy cp -R src deploy cd deploy zip -r source.zip * hash=`openssl md5 source.zip | awk '{print $2}'` echo "source.zip: hash = $hash" filename="${hash}.zip" mv source.zip $filename bucket=$DEPLOY_S3_BUCKET for 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} done cd .. rm -r deploy/
CI / CD
ローカル環境において、テストおよびデプロイができるようになりました。これができたら、追加開発や修正を考慮して、CIツールを利用するのが次の話です。考え方はシンプルで、今までやったことを CIツールの設定ファイルへ書き出すだけです。今回はすべてAWSで完結するので AWS CodeBuild を使います。
version: 0.2 env: 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 は追加しません。
...前略... 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 に追記します。
...前略... 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}
結合テストを実行するために、環境設定ファイルと結合テストのファイルへ追記します。
{ "GetHeroes": { "DYNAMODB_ENDPOINT": "http://localstack:4569/", "HERO_TABLE_NAME": "CM-Heroes" }, "PutHeroes": { "DYNAMODB_ENDPOINT": "http://localstack:4569/", "HERO_TABLE_NAME": "CM-Heroes" } }
...前略... @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 を使ってデプロイする
ソースフォルダに別のリソースとして新しいフォルダoffices
を作ります。事務所につてはここで扱うようにしました。
├── src └── functions ├── heroes │ ├── index.py │ └── utils.py └── offices └── index.py
ヒーローとは別のリソースということを考慮し、template_offices.yaml
を追加することにします。ひとつのテンプレートですべて完結することもできると思いますが、個別にデプロイしたい場合など、利便性と視認性を考慮して分けることにしました。内容は template_heroes.yaml
とほぼおなじで、DynamoDB のテーブル名が違うくらいです。
結合テストも同様に別ファイルとして用意します。.bats
ファイルをさがして、すべてのテストを実行するようにします。
find test -name '*.bats' | xargs bats 1..4 ok 1 DynamoDB Get Hero Function response the correct item ok 2 DynamoDB PUT Hero Function response code is 200 ok 3 DynamoDB Get Office Function response the correct item ok 4 DynamoDB PUT Office Function response code is 200
デプロイすると、CloudFormation の 新しいスタックが作成され、そこから Lambda Function が生成されます。
まとめ
Lambda Function の開発からテスト、追加開発までの流れをなぞりながら、それぞれどういうツールを使っていくかアイデアを示しました。サーバーレスを取り巻く環境は変化が激しく、スタンダードも移り変わっていくでしょう。私も本記事の内容が正解とは思っていませんし、さらに効率の良いやり方を模索してくつもりです。もし良いアイデアやツールがありましたらぜひ教えてください!
自問 QA
余談ですが、記事を書くにあたり、いくつか考えたことを記録しておきます。
AWS SAM のテンプレートには API Gateway や DynamoDB のリソースは含めないの?
含めないことにしました。API Gateway や DyamoDB のリソースにかかわる変更作業は Lambda Function の開発サイクルと大きく異なり、一緒のテンプレートに含めて一緒にデプロイするメリットをあまり感じられなかったためです。APIやデータベースは、個別で用意するか、インフラ用として別の CloudFormation テンプレートを用意するのも手です。
LocalStack でサポートしていないサービスを対象にする Lambda Function だったらどうする? Cognito とか
ローカルでの結合テストが難しいです。この類の Lambda Function は、結合テストを行う歳に実際の AWSサービスを使うよう、エンドポイントを調整する作戦が良いと思います。ローカルで完結できない点は残念ですが、こればかりは仕方ありません。ポジティブに考えれば、 SAM Local によって接続先を自由に調整できるため、ローカルで開発しつつ 接続先の AWSサービスの挙動を確認するという点ではやりやすいと思います。
参考資料
- 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 を開発しませんか?