必見の記事

AWS Lambda(Python) の開発環境・テスト・デプロイ・CI 考察

2018.03.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

サーバーレス開発部では、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の設定を行い、ライブラリへのパスを通す設定が必要です。

  1. Project Structure > Project > Project SDK で グローバル の Python を指定してSDKを作成する
  2. Project Structure > SDKs > 1 で設定したPython にて /path/to/python/lib/python3.6 を追加する
  3. Project Structure > Modules > で src フォルダを Sources として、 testフォルダを Tests として設定する
  4. Python ファイルのソースコードを開くと「requirements.txt をインストールするか?」と聞かれるのでインストールする

これでライブラリへのパスが通り、Intellij上で Lambda Function を開発できるようになります。図のようにboto3ライブラリのメソッド候補も利用でき、便利です。

intellij_boto3.png

Unit Test

Lambda Function のロジック部分をテストします。なお、AWS サービスへのアクセス部分は Unit Test として考慮しません。 それらは後続の結合テストで行います。AWS SDK の動作をモック化するライブラリなど使えば可能かもしれませんが、割り切りです。本当はできるならやったほうがいいのはその通りです。

今回は ヒーローの情報を DynamoDB へ保存する際の、 unixtime を計算するロジックを切り出し、それをテストすることにします。

utils.py

from datetime import datetime
import decimal
import json

def epoc_by_second_precision(time: datetime):
    return decimal.Decimal(time.replace(microsecond=0).timestamp())

テストフォルダに pytest のためのファイルを作ります。

test_utils.py

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を利用します。

docker-compose.yaml

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 を実行するにあたり、こちらで用意するものは以下です。

  1. 実行したい Lambda Function
  2. 入力ペイロード
  3. AWS SAM でデプロイするための CloudFormation テンプレート
  4. SAM Local での実行時に使う環境変数の定義JSON

実行したい Lambda Function

API Gateway のリクエストからIDを受け取り、DynamoDB から ヒーローを取り出し、JSONとして返す関数を実装します。

index.py

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のみが関数に渡されると想定し、以下のようにします。

get_payload.json

{
  "id": "test-id"
}

CloudFormation テンプレート

Lambda Function の Handler のパスが正しいことを確認してください。

template_heroes.yaml

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ファイルを用意します。

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 clisam local などコマンドラインでの操作が大部分を占めるため、コマンドラインのテストをサポートする Bats を使ってみることにしました。

integration.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のようなタスクランナーを使って定義すれば、より柔軟性が高くなるかもしれません。ここは私の タスクランナーパワー不足 です。

deploy.sh

#!/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 を使います。

buildspec.yaml

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 のプロジェクトを作ります。

codebuild_project.png

次にビルドを実行します。環境名を blog にしました。CloudFormation のスタック名や Lambda Function の関数名のプレフィックスに環境名がつくようにしています。

codebuild_build.png

実行した結果、CloudFormation の スタックが作成され…

cfn_created.png

Lambda Function も作成されました。

lambda_created.png

追加開発作業

作成した関数をデプロイする環境が整いました。このあとやっていくこととしては、機能を追加して、そしたらデプロイして、動作確認して…という流れを繰り返すことになります。ここでは追加開発を想定し、どのようなフローになるのかみてみます。

関数を追加する場合

例えばヒーロー情報を受け取り、それをDynamoDBへ保存するような関数を追加することを考えます。以下のような作業を行うことになるでしょう。

  1. Lambda Function を追記する
  2. (必要であれば)Unit Test を追加する
  3. template_heroes.yaml に新しい関数を追加する
  4. 結合テストを追加する
  5. CodeBuild を使ってデプロイする

ヒーロー情報を扱うことは変わらないので、 src/functions/heroes/index.py へ追記することにします。DynamoDB へ格納しているだけなので Unit Test は追加しません。

index.py

...前略...
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 に追記します。

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}

結合テストを実行するために、環境設定ファイルと結合テストのファイルへ追記します。

sam-local.json

{
  "GetHeroes": {
    "DYNAMODB_ENDPOINT": "http://localstack:4569/",
    "HERO_TABLE_NAME": "CM-Heroes"
  },
  "PutHeroes": {
    "DYNAMODB_ENDPOINT": "http://localstack:4569/",
    "HERO_TABLE_NAME": "CM-Heroes"
  }
}

integration.bats

...前略...
@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 が正しくデプロイできます。

lambda_putheroes.png

ヒーローの削除や更新を受け付ける関数を作成する際も、同じように追加してやればよさそうです。

リソース/別業務を追加する場合

例えばヒーローが所属する事務所の管理機能を追加することになったとしましょう。ヒーローのテーブルに事務所まで入れてしまうこともできますが、データベースの正規化の観点から別のテーブルに事務所情報を切り出して管理したいとします。このとき、リポジトリではどのような作業を行えばよいでしょうか。ドメイン駆動の視点で、別の業務を追加することになった と考えます。

  1. 新しいフォルダofficesを作成して新しい index.pyをつくる
  2. (必要であれば)Unit Test を追加する
  3. 別の CloudFormation テンプレート、 template_offices_yaml を追加する
  4. 新しい結合テストファイルを作成する
  5. 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 が生成されます。

cfn_office.png

lambda_office.png

まとめ

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サービスを駆使して 一緒に SPA や Lambda を開発しませんか?