SAM CLI を使った CDK のローカルテストを試してみた

January 21, 2022

今回やること

CDK で Serverless アプリを開発する際に、Lambda や API の動作検証を行うには一旦デプロイした上で実行する必要がありました。これは比較的規模の大きいパイプラインを使っていたりする場合に結構厄介で、修正する度にデプロイ、パイプラインの実行を待つというプロセスを行わなければいけません。一応、cdk deploy --hostswap を使うことで CloudFormation を介さずに Lambda の API を利用してコードを直接更新することもできますが、手元との環境に差分が出たりと、デプロイ環境を壊しかねないのであまり使いたくありません。
そんな中、今回待望の機能である SAM CLI による CDK アプリのローカルテストが GA されました!

Announcing AWS Serverless Application Model (SAM) CLI support for local testing of AWS Cloud Development Kit (CDK)

早速触ってみたいと思います。

前提条件

試す前に、CDK がインストールされているのは前提としても、その他いくつか前提条件があります。

SAM CLI のインストール

SAM CLI が手元の Macbook にインストールされていなかったのでやっておきます。Homebrew で一発です。

$ brew tap aws/tap
$ brew install aws-sam-cli

インストールされたらバージョンも一応確認しておきます。

$ sam --version
SAM CLI, version 1.37.0

Docker のインストール

SAM CLI でローカルテストを実行するには Docker 環境が必要になります。MacOS の場合は ここ からインストールできます。インストールが完了したら Docker デーモンを起動しておきます。

早速試してみる

それでは実際に試してみましょう。ローカルテストの際は sam local コマンドを利用することになりますが、help を見るとわかるとおり以下のようにいくつかサブコマンドがありそうです。

$ sam local --help
Usage: sam local [OPTIONS] COMMAND [ARGS]...

  Run your Serverless application locally for quick development & testing

Options:
  -h, --help  Show this message and exit.

Commands:
  generate-event  You can use this command to generate sample payloads from...
  invoke          Invokes a local Lambda function once.
  start-api       Sets up a local endpoint you can use to test your API.
                  Supports hot-reloading so you don't need to restart this
                  service when you make changes to your function.

  start-lambda    Starts a local endpoint you can use to invoke your local
                  Lambda functions.

ざっとまとめるとそれぞれ以下の役割を持ってそうです。

コマンド 説明
sam local generate-event Lambda 関数に渡すイベントのテンプレートを標準出力する
sam local invoke Lambda 関数を一度だけ実行する
sam local start-api API テスト用のローカルエンドポイントを作成する
sam local start-lambda Lambda 関数を実行するためのローカルエンドポイントを作成する

sam local generate-event

まずは sam local generate-event から試していきます。help を見ると以下のように色々テンプレートが用意されていそうです。

$ sam local generate-event --help

...

Commands:
  alexa-skills-kit
  alexa-smart-home
  apigateway
  appsync
  batch
  cloudformation
  cloudfront
  cloudwatch
  codecommit
  codepipeline
  cognito
  config
  connect
  dynamodb
  kinesis
  lex
  rekognition
  s3
  sagemaker
  ses
  sns
  sqs
  stepfunctions

試しに API Gateway を出力してみます。

$ sam local generate-event apigateway aws-proxy
{
  "body": "eyJ0ZXN0IjoiYm9keSJ9",
  "resource": "/{proxy+}",
  "path": "/path/to/resource",
  "httpMethod": "POST",
  "isBase64Encoded": true,
  "queryStringParameters": {
    "foo": "bar"
  },
  "multiValueQueryStringParameters": {
    "foo": [
      "bar"
    ]
  },
  "pathParameters": {
    "proxy": "/path/to/resource"
  },
  "stageVariables": {
    "baz": "qux"
  },
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
    "Accept-Encoding": "gzip, deflate, sdch",
    "Accept-Language": "en-US,en;q=0.8",
    "Cache-Control": "max-age=0",
    "CloudFront-Forwarded-Proto": "https",
    "CloudFront-Is-Desktop-Viewer": "true",
    "CloudFront-Is-Mobile-Viewer": "false",
    "CloudFront-Is-SmartTV-Viewer": "false",
    "CloudFront-Is-Tablet-Viewer": "false",
    "CloudFront-Viewer-Country": "US",
    "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Custom User Agent String",
    "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
    "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==",
    "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https"
  },

  ...

  "requestContext": {
    "accountId": "123456789012",
    "resourceId": "123456",
    "stage": "prod",
    "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
    "requestTime": "09/Apr/2015:12:34:56 +0000",
    "requestTimeEpoch": 1428582896000,
    "identity": {
      "cognitoIdentityPoolId": null,
      "accountId": null,
      "cognitoIdentityId": null,
      "caller": null,
      "accessKey": null,
      "sourceIp": "127.0.0.1",
      "cognitoAuthenticationType": null,
      "cognitoAuthenticationProvider": null,
      "userArn": null,
      "userAgent": "Custom User Agent String",
      "user": null
    },
    "path": "/prod/path/to/resource",
    "resourcePath": "/{proxy+}",
    "httpMethod": "POST",
    "apiId": "1234567890",
    "protocol": "HTTP/1.1"
  }
}

これで Lambda 関数に渡すテストイベントもぱぱっと用意できそうですね。

sam local invoke

次は sam local invoke です。sam local generate-event 以外は事前に cdk synth で出力されるテンプレートが必要となります。今回は 前回 作成したテンプレートに含まれる以下の hello.py でテストしようと思います。

lib/lambda/hello.py
def handler(event, context):
  return {
    'statusCode': 200,
    'headers': {
      'Content-Type': 'text/plain'
    },
    'body': 'Hello'
  }

まずは cdk synth します。

$ cdk synth

これにより、アプリ名に応じたテンプレートファイル YourAppName.template.jsoncdk.out/ 配下に生成されていると思います。

$ ls -l cdk.out/
total 536
-rw-r--r--  1 yuk  staff     663 Jan 21 15:15 ReactCDKAppTemplate.assets.json
-rw-r--r--  1 yuk  staff   59915 Jan 21 15:15 ReactCDKAppTemplate.template.json
drwxr-xr-x  6 yuk  staff     192 Jan 21 15:15 assembly-ReactCDKAppTemplate-DeployBackend
drwxr-xr-x  6 yuk  staff     192 Jan 21 15:15 assembly-ReactCDKAppTemplate-DeployFrontend
drwxr-xr-x  3 yuk  staff      96 Jan 21 15:15 asset.0b3f59240ca16c5fc72224f1e8667f294b908b9b305c47894ea3a368e7e27041
-rw-r--r--  1 yuk  staff      20 Jan 21 15:15 cdk.out
-rw-r--r--  1 yuk  staff    9153 Jan 21 15:15 manifest.json
-rw-r--r--  1 yuk  staff  189598 Jan 21 15:15 tree.json

但し、今回利用するのは上記にある ReactCDKAppTemplate.template.json ではありません。これは CDK Pipeline によるパイプライン周りのリソースのみについて記載のあるテンプレートとなり、実際にアプリで利用するリソースについては assembly- から始まるディレクトリに格納されたテンプレートに記述されています。今回の場合は cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ 配下にある、ReactCDKAppTemplateDeployBackend42D3D30F.template.json となります。

$ ls -l cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/
total 72
-rw-r--r--  1 yuk  staff   1321 Jan 21 15:15 ReactCDKAppTemplateDeployBackend42D3D30F.assets.json
-rw-r--r--  1 yuk  staff  18886 Jan 21 15:15 ReactCDKAppTemplateDeployBackend42D3D30F.template.json
-rw-r--r--  1 yuk  staff     20 Jan 21 15:15 cdk.out
-rw-r--r--  1 yuk  staff   6256 Jan 21 15:15 manifest.json

それではまずはイベント無しでテストしてみます。

$ sam local invoke HelloHandler --no-event -t ./cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ReactCDKAppTemplateDeployBackend42D3D30F.template.json 
Invoking hello.handler (python3.9)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-python3.9:rapid-1.37.0-x86_64.

Mounting /Users/yuk/Development/cdk/react-cdk-template/cdk.out/asset.0b3f59240ca16c5fc72224f1e8667f294b908b9b305c47894ea3a368e7e27041 as /var/task:ro,delegated inside runtime container
START RequestId: 42f7106f-9518-4a58-bb7d-ce414191705c Version: $LATEST
END RequestId: 42f7106f-9518-4a58-bb7d-ce414191705c
REPORT RequestId: 42f7106f-9518-4a58-bb7d-ce414191705c  Init Duration: 1.47 ms  Duration: 554.23 ms     Billed Duration: 555 ms Memory Size: 128 MB     Max Memory Used: 128 MB
{"statusCode": 200, "headers": {"Content-Type": "text/plain"}, "body": "Hello"}

いい感じに実行されていそうですね。
それではテストイベントもつけてみましょう。テストイベント用 JSON ファイルを格納するディレクトリを作成し、そこに出力していきます。

$ mkdir localtest
$ sam local generate-event apigateway aws-proxy > ./localtest/apigateway-event.json

また、Lambda 側もテストイベントを出力するように変更を加えておきます。

lib/lambda/hello.py
import json

def handler(event, context):
  print('request {}'.format(json.dumps(event)))
  return {
    'statusCode': 200,
    'headers': {
      'Content-Type': 'text/plain'
    },
    'body': 'Hello'
  }

あとは cdk synth して sam local invoke でテストするだけです。

$ sam local invoke -e ./localtest/apigateway-event.json -t ./cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ReactCDKAppTemplateDeployBackend42D3D30F.template.json 
Invoking hello.handler (python3.9)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-python3.9:rapid-1.37.0-x86_64.

Mounting /Users/yuk/Development/cdk/react-cdk-template/cdk.out/asset.7ecf47ee722d2d5e851f2b2a598aaed93023ca964474a4baf059f4f6b8f73f85 as /var/task:ro,delegated inside runtime container
START RequestId: 978c4f4b-51e4-41c4-8500-054b8aeb8036 Version: $LATEST
request {"body": "eyJ0ZXN0IjoiYm9keSJ9", "resource": "/{proxy+}", "path": "/path/to/resource", "httpMethod": "POST", "isBase64Encoded": true, "queryStringParameters": {"foo": "bar"}, "multiValueQueryStringParameters": {"foo": ["bar"]}, "pathParameters": {"proxy": "/path/to/resource"}, "stageVariables": {"baz": "qux"}, "headers": {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Encoding": "gzip, deflate, sdch", "Accept-Language": "en-US,en;q=0.8", "Cache-Control": "max-age=0", "CloudFront-Forwarded-Proto": "https", "CloudFront-Is-Desktop-Viewer": "true", "CloudFront-Is-Mobile-Viewer": "false", "CloudFront-Is-SmartTV-Viewer": "false", "CloudFront-Is-Tablet-Viewer": "false", "CloudFront-Viewer-Country": "US", "Host": "1234567890.execute-api.us-east-1.amazonaws.com", "Upgrade-Insecure-Requests": "1", "User-Agent": "Custom User Agent String", "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)", "X-Amz-Cf-Id": "cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA==", "X-Forwarded-For": "127.0.0.1, 127.0.0.2", "X-Forwarded-Port": "443", "X-Forwarded-Proto": "https"}, "multiValueHeaders": {"Accept": ["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"], "Accept-Encoding": ["gzip, deflate, sdch"], "Accept-Language": ["en-US,en;q=0.8"], "Cache-Control": ["max-age=0"], "CloudFront-Forwarded-Proto": ["https"], "CloudFront-Is-Desktop-Viewer": ["true"], "CloudFront-Is-Mobile-Viewer": ["false"], "CloudFront-Is-SmartTV-Viewer": ["false"], "CloudFront-Is-Tablet-Viewer": ["false"], "CloudFront-Viewer-Country": ["US"], "Host": ["0123456789.execute-api.us-east-1.amazonaws.com"], "Upgrade-Insecure-Requests": ["1"], "User-Agent": ["Custom User Agent String"], "Via": ["1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)"], "X-Amz-Cf-Id": ["cDehVQoZnx43VYQb9j2-nvCh-9z396Uhbp027Y2JvkCPNLmGJHqlaA=="], "X-Forwarded-For": ["127.0.0.1, 127.0.0.2"], "X-Forwarded-Port": ["443"], "X-Forwarded-Proto": ["https"]}, "requestContext": {"accountId": "123456789012", "resourceId": "123456", "stage": "prod", "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef", "requestTime": "09/Apr/2015:12:34:56 +0000", "requestTimeEpoch": 1428582896000, "identity": {"cognitoIdentityPoolId": null, "accountId": null, "cognitoIdentityId": null, "caller": null, "accessKey": null, "sourceIp": "127.0.0.1", "cognitoAuthenticationType": null, "cognitoAuthenticationProvider": null, "userArn": null, "userAgent": "Custom User Agent String", "user": null}, "path": "/prod/path/to/resource", "resourcePath": "/{proxy+}", "httpMethod": "POST", "apiId": "1234567890", "protocol": "HTTP/1.1"}}
END RequestId: 978c4f4b-51e4-41c4-8500-054b8aeb8036
REPORT RequestId: 978c4f4b-51e4-41c4-8500-054b8aeb8036  Init Duration: 0.97 ms  Duration: 554.80 ms     Billed Duration: 555 ms Memory Size: 128 MB     Max Memory Used: 128 MB
{"statusCode": 200, "headers": {"Content-Type": "text/plain"}, "body": "Hello"}

イベントが渡されているのも確認できますね。

sam local start-api

sam local start-api では作成した API のローカルエンドポイントを生成してくれます。これにより curl やブラウザを用いてリクエストを投げることで API をテストできます。それでは実際にやってみます。

$ sam local start-api -t ./cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ReactCDKAppTemplateDeployBackend42D3D30F.template.json
Mounting HelloHandler2E4FBA4D at http://127.0.0.1:3000/{proxy+} [DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT]
Mounting HelloHandler2E4FBA4D at http://127.0.0.1:3000/ [DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2022-01-21 16:48:09  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)

すると上記のように http://127.0.0.1:3000/ がエンドポイントとして用意されたことがわかるので、実際に API を叩いてみます。

$ curl http://127.0.0.1:3000/
Hello

ちゃんとレスポンスが返却されたのがわかります。また、sam local start-api を実行した端末側にはアクセスログ及び Lambda 関数のログが記録されます。

$ sam local start-api -t ./cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ReactCDKAppTemplateDeployBackend42D3D30F.template.json 

...

Invoking hello.handler (python3.9)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-python3.9:rapid-1.37.0-x86_64.

Mounting /Users/yuk/Development/cdk/react-cdk-template/cdk.out/asset.7ecf47ee722d2d5e851f2b2a598aaed93023ca964474a4baf059f4f6b8f73f85 as /var/task:ro,delegated inside runtime container
START RequestId: bcbd5cbf-8cfe-45dd-a2cb-5e088982cb76 Version: $LATEST
request {"body": null, "headers": {"Accept": "*/*", "Host": "127.0.0.1:3000", "User-Agent": "curl/7.64.1", "X-Forwarded-Port": "3000", "X-Forwarded-Proto": "http"}, "httpMethod": "GET", "isBase64Encoded": false, "multiValueHeaders": {"Accept": ["*/*"], "Host": ["127.0.0.1:3000"], "User-Agent": ["curl/7.64.1"], "X-Forwarded-Port": ["3000"], "X-Forwarded-Proto": ["http"]}, "multiValueQueryStringParameters": null, "path": "/", "pathParameters": null, "queryStringParameters": null, "requestContext": {"accountId": "123456789012", "apiId": "1234567890", "domainName": "127.0.0.1:3000", "extendedRequestId": null, "httpMethod": "GET", "identity": {"accountId": null, "apiKey": null, "caller": null, "cognitoAuthenticationProvider": null, "cognitoAuthenticationType": null, "cognitoIdentityPoolId": null, "sourceIp": "127.0.0.1", "user": null, "userAgent": "Custom User Agent String", "userArn": null}, "path": "/", "protocol": "HTTP/1.1", "requestId": "3fe26cb2-6a4b-4748-bf18-1473de3fbb75", "requestTime": "22/Jan/2022:00:48:09 +0000", "requestTimeEpoch": 1642812489, "resourceId": "123456", "resourcePath": "/", "stage": "prod"}, "resource": "/", "stageVariables": null, "version": "1.0"}
END RequestId: bcbd5cbf-8cfe-45dd-a2cb-5e088982cb76
REPORT RequestId: bcbd5cbf-8cfe-45dd-a2cb-5e088982cb76  Init Duration: 0.95 ms  Duration: 574.24 ms     Billed Duration: 575 ms Memory Size: 128 MB     Max Memory Used: 128 MB
2022-01-21 16:49:47 127.0.0.1 - - [21/Jan/2022 16:49:47] "GET / HTTP/1.1" 200 -

sam local start-lambda

最後は sam local start-lambda です。これは API Gateway で作成した API を介さずに Lambda を実行できるようなエンドポイントを生成するためのコマンドとなります。それでは早速試してみます。

$ sam local start-lambda -t ./cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ReactCDKAppTemplateDeployBackend42D3D30F.template.json 
Starting the Local Lambda Service. You can now invoke your Lambda Functions defined in your template through the endpoint.
2022-01-21 16:59:39  * Running on http://127.0.0.1:3001/ (Press CTRL+C to quit)

これで Lambda 用のエンドポイントができたので、あとは AWS CLI で inovke すれば OK です。

$ aws lambda invoke --function-name HelloHandler --endpoint-url "http://127.0.0.1:3001" --no-verify-ssl out.txt
{
    "StatusCode": 200
}
$ cat out.txt 
{"statusCode": 200, "headers": {"Content-Type": "text/plain"}, "body": "Hello"}

また、sam local start-lambda を実行した端末側でもログが出力されます。

$ sam local start-lambda -t ./cdk.out/assembly-ReactCDKAppTemplate-DeployBackend/ReactCDKAppTemplateDeployBackend42D3D30F.template.json 
Starting the Local Lambda Service. You can now invoke your Lambda Functions defined in your template through the endpoint.
2022-01-21 17:05:42  * Running on http://127.0.0.1:3001/ (Press CTRL+C to quit)
Invoking hello.handler (python3.9)
Skip pulling image and use local one: public.ecr.aws/sam/emulation-python3.9:rapid-1.37.0-x86_64.

Mounting /Users/yuk/Development/cdk/react-cdk-template/cdk.out/asset.7ecf47ee722d2d5e851f2b2a598aaed93023ca964474a4baf059f4f6b8f73f85 as /var/task:ro,delegated inside runtime container
START RequestId: 01d52364-95bd-4be5-995d-b63bad46220c Version: $LATEST
request {}
END RequestId: 01d52364-95bd-4be5-995d-b63bad46220c
REPORT RequestId: 01d52364-95bd-4be5-995d-b63bad46220c  Init Duration: 1.08 ms  Duration: 549.82 ms     Billed Duration: 550 ms Memory Size: 128 MB     Max Memory Used: 128 MB
2022-01-21 17:07:14 127.0.0.1 - - [21/Jan/2022 17:07:14] "POST /2015-03-31/functions/HelloHandler/invocations HTTP/1.1" 200 -

おわりに

いかがでしたでしょうか。個人的には sam local で用意されるサブコマンドはどれも有用だと感じました。これだとデプロイ環境を壊さずに安全に API や Lambda 関数のテストが行えますね。


 © 2023, Dealing with Ambiguity