🐿

SAM CLI を使ってできるだけローカルでテストしてからデプロイする

2021/12/11に公開

はじめに

この記事は AWS LambdaとServerless Advent Calendar 2021 11 日目の記事です。

AWS SAM CLI を使うと Lambda 関数や API Gateway などのサーバーレスなリソースを CLI 経由で簡単にデプロイできます。また去年の 12 月に AWS Lambda はコンテナイメージをサポートしたので Lambda 関数の開発にコンテナ関連の便利ツールを使うこともできるようになりました。しかしテストを十分にしないでデプロイすると動かずにコンソールからコードをいじったりすることになり(デプロイパッケージがコンテナイメージだとそもそもコードを直接編集できませんが)、せっかく SAM CLI でデプロイを簡単にしているのにもったいないです。この記事では SAM CLI の機能を使ってできるだけローカルでテストを済ませてからデプロイする方法を紹介します。

作るもの

アーキテクチャは上の図のような API Gateway + Lambda + DynamoDB のオーソドックスな構成です。商品名を query string で渡すとイートインかテイクアウトかで税率を変えて税込価格を返してくれる API を作ります。

以下の環境で動作確認しました。

Docker version 20.10.7
SAM CLI, version 1.36.0

やってみる

sam init

sam init で対話的にプロジェクトを初期化できます。また --no-interactive フラグをつけることで必要な引数を渡してプロジェクトを初期化することもできます。対話的に作ると分量が膨らんでしまうので以下のようにプロジェクトを初期化します。

$ sam init --no-interactive --package-type Image --base-image amazon/python3.9-base --name <project name> --app-template hello-world-lambda-image

ちなみにこのテンプレート名に何を選べるかは manifest.json に対応関係がまとまっています。python3.9-base の場合だと Hello World の example と PyTorch や Scikit-learn, Tensorflow, XGBoost を使った機械学習の example から選べます。

このままだと Lambda 関数のコードが置かれているディレクトリが hello_world になっているので名前を変えておきます。最終的なディレクトリ構成はこんな感じにしました。

events # 次で作成するサンプルペイロードをおくところ
items # Lambda 関数のコードをおくところ
  |- Dockerfile
  |- __init__.py
  |- app.py
  |- requirements.txt
tests # テストコードをおくところ
  |- __init__.py
  |- unit
    |- __init__.py
    |- conftest.py
    |- test_handler.py
template.yaml # SAM テンプレート
Dockerfile # ローカルでテストする時に使います

また items/requirements.txt は以下のように X-Ray の SDK を追加しておきます。

requests
aws-xray-sdk

サンプルペイロードの作成

Lambda はイベントに応じてコードを実行するサービスでイベントの詳細はペイロードとして json 形式で渡されます。 sam local generate-event はイベントソースごとにこのサンプルペイロードを生成するコマンドです。ドキュメントに対応しているイベントソースが載っています。API Gateway にも対応していますね。

ドキュメントをみるとコマンドごとに引数を渡せる(例えば S3 がイベントソースならバケット名とか)みたいです。じゃあ API Gateway がイベントソースの時に渡せる引数はなんなのかがぱっと見わからなかったのでソースコードを読んでみました。OSS でコードが公開されているとこういう時に便利ですね。結論を言うと event-mapping.json に対応関係がまとまってました。今回は API Gateway AWS Proxy イベントを使いたいので以下のように json ファイルを生成します。

$ sam local generate-event apigateway aws-proxy --method GET --path items > events/sushi.json

テスト用に以下の 3 パターンのイベントを用意します。

  • そもそも query string がリクエストに含まれていないパターン
  • query string で渡されたメニューが DynamoDB に保存されていないパターン
  • query string で渡されたメニューが DynamoDB に保存されているパターン

ということで生成された json ファイルをもとに三つ json を作ります。

{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "resource": "/{proxy+}",
  "path": "/items",
  "httpMethod": "GET",
  "isBase64Encoded": true,
  "queryStringParameters": {}
}
{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "resource": "/{proxy+}",
  "path": "/items",
  "httpMethod": "GET",
  "isBase64Encoded": true,
  "queryStringParameters": {
    "menu": "pasta"
  }
}
{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "resource": "/{proxy+}",
  "path": "/items",
  "httpMethod": "GET",
  "isBase64Encoded": true,
  "queryStringParameters": {
    "menu": "sushi"
  }
}

テストコードの作成

ローカルでテストするときは moto を使って DynamoDB テーブルをモックすることにします。moto を使うとテストコードの前にデコレータを被せるだけでモックした AWS サービスに対してリクエストを模擬してくれるので便利です。

まず conftest.py を以下のようにして環境変数に DynamoDB テーブル名を登録し、モックした DynamoDB テーブルを作成してデータを登録しておきます。

import os
import boto3
from moto import mock_dynamodb2
import pytest

@pytest.fixture()
def set_environment_variables():
    os.environ['AWS_ACCESS_KEY_ID'] = 'testing'
    os.environ['AWS_SECRET_ACCESS_KEY'] = 'testing'
    os.environ['AWS_SECURITY_TOKEN'] = 'testing'
    os.environ['AWS_SESSION_TOKEN'] = 'testing'
    os.environ['AWS_REGION'] = 'us-east-1'
    os.environ['TABLE_NAME'] = "item-table"

@pytest.fixture()
def set_dynamodb():
    with mock_dynamodb2():
        dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
        table_name = "item-table"
        table = dynamodb.create_table(
            TableName=table_name,
            KeySchema=[{"AttributeName": "Menu", "KeyType": "HASH"}],
            AttributeDefinitions=[
                {"AttributeName": "Menu", "AttributeType": "S"}],
        )
        table.put_item(Item={"Menu": "sushi", "price": 1000, "type": "eatin"})
        table.put_item(
            Item={"Menu": "pizza", "price": 1200, "type": "takeout"})
        yield table

そして Dockerfile ( items/Dockerfile ではなくプロジェクト直下に新しく作ります)を以下のように書いて VSCode Remote Containers で開きます。(VSCode Remote Containers の記事も最近書いたのでよかったら読んでください。)

FROM public.ecr.aws/lambda/python:3.9

COPY items/ items/
RUN python3.9 -m pip install -r items/requirements.txt
RUN python3.9 -m pip install pytest moto autopep8

python3.9 -m pytest するとテストに失敗します。テストが成功するようにコードを書いていきます。最終的に items/app.py はこんな感じになります。

import json
import os
import boto3
from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.core import patch_all

patch_all()

def get_item(menu_name):
    dynamodb = boto3.resource('dynamodb', region_name='us-east-1')
    table_name = os.environ.get('TABLE_NAME')
    table = dynamodb.Table(table_name)
    response = table.get_item(Key={"Menu": menu_name})
    item = response["Item"]
    return item

def calculate_tax_included_price(item):
    tax_rate = 1.0
    price = float(item["price"])
    if item["type"] == "eatin":
        tax_rate = 1.1
    if item["type"] == "takeout":
        tax_rate = 1.08
    return price * tax_rate

def lambda_handler(event, context):
    if event['queryStringParameters'] is None:
        return {
            "statusCode": 400,
            "body": json.dumps(
                {
                    "message": "QueryString is not specified",
                }
            ),
        }
    try:
        menu_name = event['queryStringParameters']['menu']
    except KeyError:
        return {
            "statusCode": 400,
            "body": json.dumps(
                {
                    "message": "QueryString menu is not specified",
                }
            ),
        }
    try:
        item = get_item(menu_name)
        calculated_price = calculate_tax_included_price(item)
        return {
            "statusCode": 200,
            "body": json.dumps(
                {
                    "calculated_price": calculated_price,
                }
            ),
        }
    except KeyError:
        return {
            "statusCode": 404,
            "body": json.dumps(
                {
                    "message": "Provided menu not found",
                }
            ),
        }

最後にテストに通ることを確認します。

テンプレートの作成

template.yaml に DynamoDB テーブル定義を追加します。

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: >
  python3.9
  Sample SAM Template for myapp

Resources:
  GetItemFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      Architectures:
        - x86_64
      Environment:
        Variables:
          TABLE_NAME: !Ref Table
      Tracing: Active
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref Table
      Events:
        RequestItem:
          Type: Api
          Properties:
            Path: /items
            Method: get
    Metadata:
      Dockerfile: Dockerfile
      DockerContext: ./items
      DockerTag: python3.9-v1

  Table:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: Menu
        Type: String
      ProvisionedThroughput:
        ReadCapacityUnits: 5
        WriteCapacityUnits: 5

Outputs:
  MenuApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/items"

  TableName:
    Description: "DynamoDB table name"
    Value: !Ref Table

Lambda 関数の環境変数に DynamoDB テーブルのテーブル名を追加するのと、Lambda 関数に DynamoDB テーブルへの読み込みを許可することを忘れないようにします。SAM の場合 SAM policy templates を使うと簡単に特定のテーブルにだけ Read を許可できるので便利です。

デプロイ

sam build で Lambda 関数のコンテナイメージビルドや template.yaml の変換などを行い sam deploy でクラウド上にデプロイします。まとめて以下のように実行します。

$ sam build && sam deploy --guided

途中色々聞かれます。y/N みたいになってるのはデフォルトだと大文字の方が入力されるよって意味です。大体の質問は Enter を押せばいいです。二点以下の質問に対しては明示的に y と入力して Enter を押します。

# この質問は y と入力して Enter
GetItemFunction may not have authorization defined, Is this okay? [y/N]: y

# しばらくして CloudFormation の changeset が作成されたあとは deploy することに y で返事する
Deploy this changeset? [y/N]: y

CloudFormation outputs from deployed stack
-------------------------------------------------------------------------------------------------
Outputs                                                                                         
-------------------------------------------------------------------------------------------------
Key                 TableName                                                                   
Description         DynamoDB table name                                                         
Value               sam-app-Table-BYDHHMK6YPCO                                                  

Key                 MenuApi                                                                     
Description         API Gateway endpoint URL for Prod stage for Hello World function            
Value               https://z1e2032vcb.execute-api.us-east-1.amazonaws.com/Prod/items           
-------------------------------------------------------------------------------------------------

データの登録

Outputs で DynamoDB テーブル名を出力しているのでこのテーブルにサンプルデータを登録します。

$ aws dynamodb put-item \
    --table-name <TableName> \
    --item \
        '{"Menu": {"S": "sushi"}, "price": {"N": "1000"}, "type": {"S": "eatin"}}'

$ aws dynamodb put-item \
    --table-name <TableName> \
    --item \
        '{"Menu": {"S": "pizza"}, "price": {"N": "1200"}, "type": {"S": "takeout"}}'

API Gateway にリクエスト

Outputs で API Gateway のエンドポイントが表示されているのでそこに query string を添えてリクエストして動作確認します。

$ curl "https://z1e2032vcb.execute-api.us-east-1.amazonaws.com/Prod/items?menu=sushi"
{"calculated_price": 1100.0}                                           
$ curl "https://z1e2032vcb.execute-api.us-east-1.amazonaws.com/Prod/items?menu=pizza"
{"calculated_price": 1296.0}                                     
$ curl "https://z1e2032vcb.execute-api.us-east-1.amazonaws.com/Prod/items?menu=pasta"
{"message": "Provided menu not found"}     

おまけ

Lambda 関数のコードにこの 3 行があることに気づいた人はいるでしょうか?

from aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.core import patch_all

patch_all()

今回 Lambda 関数は X-Ray を使ったトレーシングを有効化している( template.yamlTracing: Active の部分です)のでドキュメントに書いているように X-Ray SDK をインポートして patch_all() するだけで DynamoDB へのリクエストもトレーシングすることができます。X-Ray のコンソールはこんな感じでトレースを見れば詳しいこともわかって便利ですね。

お片付け

sam delete でリソースを削除できます。本当に消していいか聞いてくれてデフォルトだと n になっているので y と明示的に入力して Enter していきます。これで裏で作ってくれていた Lambda 関数コンテナイメージ用の ECR も削除してくれます。

$ sam delete        Are you sure you want to delete the stack sam-app in the region us-east-1 ? [y/N]: y
Are you sure you want to delete the folder sam-app in S3 which contains the artifacts? [y/N]: y
Found ECR Companion Stack sam-app-7427b055-CompanionStack
Do you you want to delete the ECR companion stack sam-app-7427b055-CompanionStack in the region us-east-1 ? [y/N]: y
ECR repository samapp7427b055/getitemfunctionb066e925repo may not be empty. Do you want to delete the repository and all the images in it ? [y/N]: y

おわりに

SAM CLI は他にも sam local invoke を使ってローカルで Lambda 関数を実行できたり sam local start-api を使ってローカルで API サーバーを動かせたりと便利な機能が多いです。Python で Lambda 関数を書くならここで紹介した moto を使ってモックも簡単にできるので、最近 Lambda 関数をちゃんと作るときは SAM CLI + VSCode Remote Containers で開発することが多いです。

Discussion