CLOVER🍀

That was when it all began.

Serverless FrameworkでAWS Lambda関数をローカル環境でエミュレーションで動かす

これは、なにをしたくて書いたもの?

これまでServerless Frameworkを使って作成したサービスをLocalStackにデプロイして動かしていましたが、デプロイせずとも動作確認する
パターンも試しておきたいなと思いまして。

invoke localとgenerate-event

使うのは、こちらの2つのコマンドかなと思います。invoke localgenerate-eventです。

Serverless Framework Commands - AWS Lambda - Invoke Local

Serverless Framework Commands - AWS Lambda - Generate Event

invoke localは、AWS Lambdaをエミュレーションすることでローカルで実行するコマンドです。

Serverless Framework Commands - AWS Lambda - Invoke Local

対みたいなものとして、AWS環境にデプロイされたAWS Lambda関数を呼び出すコマンドとしてinvokeがあります。

Serverless Framework Commands - AWS Lambda - Invoke

invoke localはあくまでエミュレーションであり、完璧ではありません。それでも多くのユーザーの役には立つだろうとしています。
また、contextはモックデータになります。

Please keep in mind, it's not a 100% perfect emulation, there may be some differences, but it works for the vast majority of users. We mock the context with simple mock data.

以下のようにして、ローカルで関数を呼び出します。

$ npx serverless invoke local --function [function-name]

入力となるデータは、以下のパターンで渡せるようです。

## --dataオプション
$ npx serverless invoke local --function [function-name] --data '{"a":"bar"}'


## --raw(JSONデータ以外を渡す場合) + --dataオプション
$ npx serverless invoke local --function [function-name] --raw --data "hello world"


## 標準入力から渡す
$ [data generate command] | npx serverless invoke local --function [function-name]


## --pathオプションでファイルを指定する
$ npx serverless invoke local --function [function-name] --path [JSON file path]

制限事項としては、Node.js、PythonJavaRuby以外のランタイムを使用するか、--dockerオプションを使用するとDockerが必要に
なる点ですね。

AWS - Invoke Local / Examples / Limitations

また、AWS IAMのエミュレーションも完全ではありません。

AWS - Invoke Local / Resource permissions

ここまでで、データがあればAWS Lambda関数をローカルでエミュレーション的に動かせそうなことはわかりました。

ちなみに、Amazon API GatewayAWS Lambdaであれば、似た用途でServerless Offlineというプラグインもあるようです。

GitHub - dherault/serverless-offline: Emulate AWS λ and API Gateway locally when developing your Serverless project

こちらは、またの機会にしておくとします。

あとはAWS Lambdaを呼び出すのに必要なデータの作成ですが、これにはgenerate-eventを使うのが良さそうです。

Serverless Framework Commands - AWS Lambda - Generate Event

generate-eventコマンドは、様々なイベントに対するサンプルのペイロードを作成します。

以下が使えるようです。

これをinvoke localに標準入力として与えるなり、ファイルに保存して使うなりするとよさそうです。

早速試してみましょう。今回は、Amazon API GatewayAmazon SNSの2つのAWS Lambda関数を用意して試してみます。

環境

今回の環境は、こちら。

$ node --version
v16.18.1


$ npm --version
8.19.2

最初に、AWS Lambda関数が作成できているかどうかを確認するのにLocalStackを使うことにします。

$ python3 -V
Python 3.8.10


$ localstack --version
1.3.0

起動。

$ LAMBDA_EXECUTOR=docker-reuse localstack start

動作確認の一部で使用する、AWS CLI

$ awslocal --version
aws-cli/2.9.4 Python/3.9.11 Linux/5.4.0-135-generic exe/x86_64.ubuntu.20 prompt/off

Serverless Frameworkサービスを作成する

それでは、Serverless Frameworkサービスを作成していきます。

npmプロジェクトの作成。

$ npm init -y

Serverless Framworkのインストール。

$ npm i -D serverless


$ npx sls --version
Framework Core: 3.25.1 (local)
Plugin: 6.2.2
SDK: 4.3.2

aws-nodejsテンプレートを使用して、Serverless Frameworkサービスの作成。

$ npx serverless create --template aws-nodejs

TypeScriptや、AWS Lambdaの型定義、Serverless Frameworkのプラグインなどをインストールします。

$ npm i -D typescript
$ npm i -D @types/node@v16
$ npm i -D prettier


$ npm i -D @types/aws-lambda
$ npm i -D serverless-esbuild esbuild
$ npm i -D serverless-localstack

依存関係は、このようになりました。

  "devDependencies": {
    "@types/aws-lambda": "^8.10.109",
    "@types/node": "^16.18.4",
    "esbuild": "^0.15.17",
    "prettier": "^2.8.0",
    "serverless": "^3.25.1",
    "serverless-esbuild": "^1.33.2",
    "serverless-localstack": "^1.0.1",
    "typescript": "^4.9.3"
  }

scriptsの定義。

  "scripts": {
    "typecheck": "tsc --project .",
    "typecheck:watch": "tsc --project . --watch",
    "format": "prettier --write ./**/*.{js,ts}"
  },

各種設定ファイル。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "lib": ["esnext"],
    "noEmit": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": ["./**/*.ts"],
  "exclude": [
    "node_modules/**/*",
    ".serverless/**/*"
  ]
}

.prettierrc.json

{
  "singleQuote": true,
  "printWidth": 120
}

もともと作成されていたソースコードは削除。

$ rm handler.js

Amazon API Gateway向けのAWS Lambda関数を作成。

api-gateway/hello.ts

import { APIGatewayEvent, ProxyResult } from 'aws-lambda';

export const handler = async (event: APIGatewayEvent): Promise<ProxyResult> => {
  const queryString = event.queryStringParameters;

  let queryParam;
  if (queryString != null) {
    queryParam = queryString['queryParam'];
  } else {
    queryParam = '';
  }

  const body = event.body;

  console.log(`queryParam = ${queryParam}, body = ${body}`);

  return {
    statusCode: 200,
    body: JSON.stringify({
      queryParam: queryParam,
      body: body,
    }),
  };
};

Amazon SNSトピックをサブスクライブするAWS Lambda関数。

sns/hello.ts

import { SNSEvent } from 'aws-lambda';

export const handler = async (event: SNSEvent): Promise<void> => {
  for (const record of event.Records) {
    const topicArn = record.Sns.TopicArn;
    const messageId = record.Sns.MessageId;
    const message = record.Sns.Message;

    console.log(`topicArn = ${topicArn}, messageId = ${messageId}, message = ${message}`);
  }
};

Serverless Frameworkの設定。

serverless.yml

service: serverless-invoke-local

frameworkVersion: '3'

provider:
  name: aws
  runtime: nodejs16.x
  stage: dev
  region: us-east-1

package:
  individually: true

functions:
  helloHttp:
    handler: api-gateway/hello.handler
    events:
      - http:
          path: /hello
          method: post
  helloSns:
    handler: sns/hello.handler
    events:
      - sns: my-topic

custom:
  esbuild:
    bundle: true
    target: node16
    platform: node

plugins:
  - serverless-esbuild
  - serverless-localstack

まずは、LocalStackにデプロイして動作確認しておきましょう。

$ npx serverless deploy

確認。

Amazon API GatewayAWS Lambda。

$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'reverse(sort_by(items[], &createdDate))[0].id' --output text)


$ curl -H 'Content-Type: application/json' http://localhost:4566/restapis/$REST_API_ID/dev/_user_request_/hello?queryParam=foo -d '{"message": "Hello World"}'
{"queryParam":"foo","body":"{\"message\": \"Hello World\"}"}

ログ。

START RequestId: b7adf82c-c85f-17f1-f480-98d3aa89311c Version: $LATEST
2022-12-06T15:04:24.545Z        b7adf82c-c85f-17f1-f480-98d3aa89311c    INFO    queryParam = foo, body = {"message": "Hello World"}
END RequestId: b7adf82c-c85f-17f1-f480-98d3aa89311c
REPORT RequestId: b7adf82c-c85f-17f1-f480-98d3aa89311c  Init Duration: 3483.41 ms       Duration: 22.58 ms      Billed Duration: 23 ms  Memory Size: 1536 MB    Max Memory Used: 43 MB

Amazon SNSAWS Lambda。

$ TOPIC_ARN=$(awslocal sns list-topics --query Topics[0].TopicArn --output text)


$ awslocal sns publish --topic-arn $TOPIC_ARN --message 'Hello SNS!!'
{
    "MessageId": "738ee38d-eef0-4523-bde0-5e816b8ea709"
}

ログ。

START RequestId: 676c3213-c3fa-10f3-db33-4a6fc295d60c Version: $LATEST
2022-12-06T15:07:36.575Z        676c3213-c3fa-10f3-db33-4a6fc295d60c    INFO    topicArn = arn:aws:sns:us-east-1:000000000000:my-topic, messageId = 738ee38d-eef0-4523-bde0-5e816b8ea709, message = Hello SNS!!
END RequestId: 676c3213-c3fa-10f3-db33-4a6fc295d60c
REPORT RequestId: 676c3213-c3fa-10f3-db33-4a6fc295d60c  Init Duration: 131.38 ms        Duration: 7.31 ms       Billed Duration: 8 ms   Memory Size: 1536 MB    Max Memory Used: 28 MB

OKですね。

確認は終わったので、LocalStackは停止しておきます。

また、Serverless LocalStackもserverless.ymlから外しておきましょう。

plugins:
  - serverless-esbuild
  # - serverless-localstack

そのままでも問題ないのですが、標準出力に「Using serverless-localstack」という表示がついて回るので、今回は外しておきました。

generate-eventでデータを作成し、invoke localでAWS Lambda関数をローカルで動かしてみる

では、今回の本題であるServerless Frameworkを使ったローカルでのAWS Lambda関数の実行を試してみます。

とりあえずデータがないと始まらないので、generate-eventを動かしてみましょう。

-t(または--type)オプションを使って、生成したいイベントを指定します。

aws:apiGatewayで、Amazon API Gatewayです。

$ npx serverless generate-event -t aws:apiGateway
{"body":"{}","path":"/test/hello","headers":{"Accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8","Accept-Encoding":"gzip, deflate, lzma, sdch, br","Accept-Language":"en-US,en;q=0.8","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":"wt6mne2s9k.execute-api.us-west-2.amazonaws.com","Upgrade-Insecure-Requests":"1","User-Agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48","Via":"1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)","X-Amz-Cf-Id":"nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g==","X-Forwarded-For":"192.168.100.1, 192.168.1.1","X-Forwarded-Port":"443","X-Forwarded-Proto":"https"},"pathParameters":{"proxy":"hello"},"multiValueHeaders":{},"isBase64Encoded":false,"multiValueQueryStringParameters":{},"requestContext":{"accountId":"123456789012","resourceId":"us4z18","stage":"test","requestId":"41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9","identity":{"accessKey":"","apiKeyId":"","cognitoIdentityPoolId":"","accountId":"","cognitoIdentityId":"","caller":"","apiKey":"","sourceIp":"192.168.100.1","cognitoAuthenticationType":"","cognitoAuthenticationProvider":"","userArn":"","userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48","user":""},"path":"","requestTimeEpoch":0,"resourcePath":"/{proxy+}","httpMethod":"GET","apiId":"wt6mne2s9k"},"resource":"/{proxy+}","httpMethod":"GET","queryStringParameters":{"name":"me"},"stageVariables":{"stageVarName":"stageVarValue"}}

aws:snsで、Amazon SNSです。

$ npx serverless generate-event --type aws:sns
{"Records":[{"EventSource":"aws:sns","EventVersion":"1.0","EventSubscriptionArn":"arn:aws:sns:us-east-1:123456789:service-1474781718017-1:fdaa4474-f0ff-4777-b1c4-79b96f5a504f","Sns":{"Type":"Notification","MessageId":"52ed5e3d-5fgf-56bf-923d-0e5c3b503c2a","TopicArn":"arn:aws:sns:us-east-1:123456789:service-1474781718017-1","Subject":"","Message":"{}","Timestamp":"2016-09-25T05:37:51.150Z","SignatureVersion":"1","Signature":"V5QL/dhow62Thr9PXYsoHA7bOsDFkLdWZVd8D6LyptA6mrq0Mvldvj/XNtai3VaPp84G3bD2nQbiuwYbYpu9u9uHZ3PFMAxIcugV0dkOGWmYgKxSjPApItIoAgZyeH0HzcXHPEUXXO5dVT987jZ4eelD4hYLqBwgulSsECO9UDCdCS0frexiBHRGoLbWpX+2Nf2AJAL+olEEAAgxfiPEJ6J1ArzfvTFZXdd4XLAbrQe+4OeYD2dw39GBzGXQZemWDKf4d52kk+SwXY1ngaR4UfExQ10lDpKyfBVkSwroaq0pzbWFaxT2xrKIr4sk2s78BsPk0NBi55xA4k1E4tr9Pg==","SigningCertUrl":"https://sns.us-east-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a0e6b3aafc7f4149a.pem","UnsubscribeUrl":"https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:123456789:service-1474781718017-1:fdaa4474-f0ff-4777-b1c4-79b96f5a504f","MessageAttributes":{}}}]}

サポートしているイベントは、--helpでも確認できます。

$ npx serverless generate-event --help
generate-event                  Generate event
--type / -t (required)          Specify event type. "aws:apiGateway", "aws:sns", "aws:sqs", "aws:dynamo", "aws:kinesis", "aws:cloudWatchLog", "aws:s3", "aws:alexaSmartHome", "aws:alexaSkill", "aws:cloudWatch", "aws:iot", "aws:cognitoUserPool","aws:websocket" are supported.
--body / -b                     Specify the body for the message, request, or stream event.
--help / -h                     Show this message
--version / -v                  Show version info
--verbose                       Show verbose logs
--debug                         Namespace of debug logs to expose (use "*" to display all)

とりあえず、呼び出してみましょう。Amazon API Gatewayから。標準入力として渡してみましょう。

$ npx serverless generate-event -t aws:apiGateway | npx serverless invoke local --function helloHttp
queryParam = undefined, body = {}
{
    "statusCode": 200,
    "body": "{\"body\":\"{}\"}"
}

ローカルで動かしているので、標準出力に書き出したログはそのままコンソールに表示されます。

なんかエスケープが多いような?と思ったのですが、これはAWS Lambda関数の戻り値がそのまま見えているからですね。

curlでLocalStack越しに呼び出した時は、こんな感じでstatusCodeもなかったですし。

$ curl -H 'Content-Type: application/json' http://localhost:4566/restapis/$REST_API_ID/dev/_user_request_/hello?queryParam=foo -d '{"message": "Hello World"}'
{"queryParam":"foo","body":"{\"message\": \"Hello World\"}"}

Amazon SNS

$ npx serverless generate-event --type aws:sns | npx serverless invoke local --function helloSns
topicArn = arn:aws:sns:us-east-1:123456789:service-1474781718017-1, messageId = 52ed5e3d-5fgf-56bf-923d-0e5c3b503c2a, message = {}

こちらは、戻り値はないAWS Lambda関数でした。

どちらもボディを指定していないので、中身が空っぽですね。

追加のオプションとして、-b(または--body)オプションで、ボディを指定することもできます。

$ npx serverless generate-event -t aws:apiGateway -b '{"message": "Hello World"}' | npx serverless invoke local --function helloHttp
queryParam = undefined, body = {"message": "Hello World"}
{
    "statusCode": 200,
    "body": "{\"body\":\"{\\\"message\\\": \\\"Hello World\\\"}\"}"
}


$ npx serverless generate-event --type aws:sns --body 'Hello SNS!!' | npx serverless invoke local --function he
lloSns
topicArn = arn:aws:sns:us-east-1:123456789:service-1474781718017-1, messageId = 52ed5e3d-5fgf-56bf-923d-0e5c3b503c2a, message = Hello SNS!!

OKですね。

あとは、1度ファイルに保存して編集してもよいでしょう。

$ mkdir events
$ npx serverless generate-event -t aws:apiGateway -b '{"message": "Hello World"}' | jq > events/api-gateway.json
$ npx serverless generate-event -t aws:sns -b 'Hello SNS!!' | jq > events/sns.json

データは、jqで整形して出力しました。

ボディについては生成後に修正してもよいのですが、JSON内に文字列として埋め込むことになるので、可能なら-b--body)オプションで
指定して生成した方がよいかもですね。エスケープもしてくれるので。

生成後、少し修正したデータ。

events/api-gateway.json

{
  "body": "{\"message\": \"Hello World\"}",
  "path": "/test/hello",
  "headers": {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
    "Accept-Encoding": "gzip, deflate, lzma, sdch, br",
    "Accept-Language": "en-US,en;q=0.8",
    "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": "wt6mne2s9k.execute-api.us-west-2.amazonaws.com",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48",
    "Via": "1.1 fb7cca60f0ecd82ce07790c9c5eef16c.cloudfront.net (CloudFront)",
    "X-Amz-Cf-Id": "nBsWBOrSHMgnaROZJK1wGCZ9PcRcSpq_oSXZNQwQ10OTZL4cimZo3g==",
    "X-Forwarded-For": "192.168.100.1, 192.168.1.1",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https"
  },
  "pathParameters": {
    "proxy": "hello"
  },
  "multiValueHeaders": {},
  "isBase64Encoded": false,
  "multiValueQueryStringParameters": {},
  "requestContext": {
    "accountId": "123456789012",
    "resourceId": "us4z18",
    "stage": "test",
    "requestId": "41b45ea3-70b5-11e6-b7bd-69b5aaebc7d9",
    "identity": {
      "accessKey": "",
      "apiKeyId": "",
      "cognitoIdentityPoolId": "",
      "accountId": "",
      "cognitoIdentityId": "",
      "caller": "",
      "apiKey": "",
      "sourceIp": "192.168.100.1",
      "cognitoAuthenticationType": "",
      "cognitoAuthenticationProvider": "",
      "userArn": "",
      "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.82 Safari/537.36 OPR/39.0.2256.48",
      "user": ""
    },
    "path": "",
    "requestTimeEpoch": 0,
    "resourcePath": "/{proxy+}",
    "httpMethod": "GET",
    "apiId": "wt6mne2s9k"
  },
  "resource": "/{proxy+}",
  "httpMethod": "GET",
  "queryStringParameters": {
    "queryParam": "foo"
  },
  "stageVariables": {
    "stageVarName": "stageVarValue"
  }
}

events/sns.json

{
  "Records": [
    {
      "EventSource": "aws:sns",
      "EventVersion": "1.0",
      "EventSubscriptionArn": "arn:aws:sns:us-east-1:123456789:service-1474781718017-1:fdaa4474-f0ff-4777-b1c4-79b96f5a504f",
      "Sns": {
        "Type": "Notification",
        "MessageId": "52ed5e3d-5fgf-56bf-923d-0e5c3b503c2a",
        "TopicArn": "arn:aws:sns:us-east-1:123456789:service-1474781718017-1",
        "Subject": "",
        "Message": "Hello SNS!!",
        "Timestamp": "2016-09-25T05:37:51.150Z",
        "SignatureVersion": "1",
        "Signature": "V5QL/dhow62Thr9PXYsoHA7bOsDFkLdWZVd8D6LyptA6mrq0Mvldvj/XNtai3VaPp84G3bD2nQbiuwYbYpu9u9uHZ3PFMAxIcugV0dkOGWmYgKxSjPApItIoAgZyeH0HzcXHPEUXXO5dVT987jZ4eelD4hYLqBwgulSsECO9UDCdCS0frexiBHRGoLbWpX+2Nf2AJAL+olEEAAgxfiPEJ6J1ArzfvTFZXdd4XLAbrQe+4OeYD2dw39GBzGXQZemWDKf4d52kk+SwXY1ngaR4UfExQ10lDpKyfBVkSwroaq0pzbWFaxT2xrKIr4sk2s78BsPk0NBi55xA4k1E4tr9Pg==",
        "SigningCertUrl": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a0e6b3aafc7f4149a.pem",
        "UnsubscribeUrl": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:123456789:service-1474781718017-1:fdaa4474-f0ff-4777-b1c4-79b96f5a504f",
        "MessageAttributes": {}
      }
    }
  ]
}

こちらを使って、invoke localAWS Lambda関数を呼び出してみます。--pathオプションを使います。

$ npx serverless invoke local --function helloHttp --path events/api-gateway.json
queryParam = foo, body = {"message": "Hello World"}
{
    "statusCode": 200,
    "body": "{\"queryParam\":\"foo\",\"body\":\"{\\\"message\\\": \\\"Hello World\\\"}\"}"
}


$ npx serverless invoke local --function helloSns --path events/sns.json
topicArn = arn:aws:sns:us-east-1:123456789:service-1474781718017-1, messageId = 52ed5e3d-5fgf-56bf-923d-0e5c3b503c2a, message = Hello SNS!!

OKですね。

まとめ

Serverless Frameworkで扱っているAWS Lambda関数を、invoke localで呼び出し、そのデータにはgenerate-eventを使うことを
試してみました。

実際に動かすのに必要なものはAWS Lambdaとトリガーとなるリソースだけ、というわけにはなかなかいかないと思いますが、
簡単な動作確認には良いのではないでしょうか?