これは、なにをしたくて書いたもの?
Serverless FrameworkでTypeScriptなAWS Lambdaアプリケーションがどういう感じなのか見てみたいというのと、それをLocalStackに
デプロイしてみたい、ということでちょっと試してみることにしました。
今回は、Serverless FrameworkでTypeScriptのプロジェクトを作成し、ほぼそのままLocalStackにデプロイするところまでやってみたいと
思います。
Serverless Framework+TypeScript
serverless create
コマンドのドキュメントを見ると、テンプレートにaws-nodejs-typescript
またはaws-alexa-typescript
を指定すれば
良さそうですね。
Serverless Framework Commands - AWS Lambda - Create
今回はaws-nodejs-typescript
を使って、Amazon API Gateway+AWS Lambdaなアプリケーションにしてみましょう。
ちなみに、テンプレートはこのリポジトリ内から選んでいるようですね。
推奨リストがcreate
のドキュメントやヘルプで表示されているようです。
https://github.com/serverless/serverless/blob/v3.21.0/lib/templates/recommended-list/index.js
また、TypeScript向けのプラグインもあるようなのですが、今回はこちらは使わなそうです。
Serverless FrameworkとLocalStack
LocalStackに対してServerless Frameworkを使う場合は、LocalStackが用意しているプラグインを使用します。
こちらを使うことで、serverless deploy
でLocalStackにデプロイするようになります。
では、進めていきましょう。
環境
今回の環境は、こちら。
$ python3 -V Python 3.8.10 $ localstack --version 1.0.4
LocalStackを起動。
$ LAMBDA_EXECUTOR=docker-reuse localstack start
Serverless Framework。
$ serverless --version Framework Core: 3.21.0 Plugin: 6.2.2 SDK: 4.3.2
アプリケーションの作成時に使用する、Node.js環境。
$ node --version v16.16.0 $ npm --version 8.11.0
Serverless Frameworkプロジェクトを作成する
では、アプリケーションを作成していきます。
serverless create
でaws-nodejs-typescript
をテンプレートに指定。
$ serverless create --template aws-nodejs-typescript --path serverless-localstack-typescript
作成されたプロジェクト内に移動。
$ cd serverless-localstack-typescript
どのようなファイルが存在するか、見てみます。
$ tree . ├── README.md ├── package.json ├── serverless.ts ├── src │ ├── functions │ │ ├── hello │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ ├── mock.json │ │ │ └── schema.ts │ │ └── index.ts │ └── libs │ ├── api-gateway.ts │ ├── handler-resolver.ts │ └── lambda.ts ├── tsconfig.json └── tsconfig.paths.json 4 directories, 13 files
各種ファイル。
package.json
{ "name": "serverless-localstack-typescript", "version": "1.0.0", "description": "Serverless aws-nodejs-typescript template", "main": "serverless.ts", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "engines": { "node": ">=14.15.0" }, "dependencies": { "@middy/core": "^2.5.3", "@middy/http-json-body-parser": "^2.5.3" }, "devDependencies": { "@serverless/typescript": "^3.0.0", "@types/aws-lambda": "^8.10.71", "@types/node": "^14.14.25", "esbuild": "^0.14.11", "json-schema-to-ts": "^1.5.0", "serverless": "^3.0.0", "serverless-esbuild": "^1.23.3", "ts-node": "^10.4.0", "tsconfig-paths": "^3.9.0", "typescript": "^4.1.3" }, "author": "The serverless webpack authors (https://github.com/elastic-coders/serverless-webpack)", "license": "MIT" }
tsconfig.json
{ "extends": "./tsconfig.paths.json", "compilerOptions": { "lib": ["ESNext"], "moduleResolution": "node", "noUnusedLocals": true, "noUnusedParameters": true, "removeComments": true, "sourceMap": true, "target": "ES2020", "outDir": "lib" }, "include": ["src/**/*.ts", "serverless.ts"], "exclude": [ "node_modules/**/*", ".serverless/**/*", ".webpack/**/*", "_warmup/**/*", ".vscode/**/*" ], "ts-node": { "require": ["tsconfig-paths/register"] } }
tsconfig.paths.json
{ "compilerOptions": { "baseUrl": ".", "paths": { "@functions/*": ["src/functions/*"], "@libs/*": ["src/libs/*"] } } }
Serverless Frameworkの設定は、TypeScriptで書かれています。
serverless.ts
import type { AWS } from '@serverless/typescript'; import hello from '@functions/hello'; const serverlessConfiguration: AWS = { service: 'serverless-localstack-typescript', frameworkVersion: '3', plugins: ['serverless-esbuild'], provider: { name: 'aws', runtime: 'nodejs14.x', apiGateway: { minimumCompressionSize: 1024, shouldStartNameWithService: true, }, environment: { AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1', NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000', }, }, // import the function via paths functions: { hello }, package: { individually: true }, custom: { esbuild: { bundle: true, minify: false, sourcemap: true, exclude: ['aws-sdk'], target: 'node14', define: { 'require.resolve': undefined }, platform: 'node', concurrency: 10, }, }, }; module.exports = serverlessConfiguration;
TypeScriptのビルドは、esbuildで行うようですね。
アプリケーションのソースコード。
src/functions/index.ts
export { default as hello } from './hello';
src/functions/hello/index.ts
import schema from './schema'; import { handlerPath } from '@libs/handler-resolver'; export default { handler: `${handlerPath(__dirname)}/handler.main`, events: [ { http: { method: 'post', path: 'hello', request: { schemas: { 'application/json': schema, }, }, }, }, ], };
src/functions/hello/handler.ts
import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway'; import { formatJSONResponse } from '@libs/api-gateway'; import { middyfy } from '@libs/lambda'; import schema from './schema'; const hello: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (event) => { return formatJSONResponse({ message: `Hello ${event.body.name}, welcome to the exciting Serverless world!`, event, }); }; export const main = middyfy(hello);
src/functions/hello/schema.ts
export default { type: "object", properties: { name: { type: 'string' } }, required: ['name'] } as const;
src/functions/hello/mock.json
{ "headers": { "Content-Type": "application/json" }, "body": "{\"name\": \"Frederic\"}" }
src/libs/lambda.ts
import middy from "@middy/core" import middyJsonBodyParser from "@middy/http-json-body-parser" export const middyfy = (handler) => { return middy(handler).use(middyJsonBodyParser()) }
src/libs/api-gateway.ts
import type { APIGatewayProxyEvent, APIGatewayProxyResult, Handler } from "aws-lambda" import type { FromSchema } from "json-schema-to-ts"; type ValidatedAPIGatewayProxyEvent<S> = Omit<APIGatewayProxyEvent, 'body'> & { body: FromSchema<S> } export type ValidatedEventAPIGatewayProxyEvent<S> = Handler<ValidatedAPIGatewayProxyEvent<S>, APIGatewayProxyResult> export const formatJSONResponse = (response: Record<string, unknown>) => { return { statusCode: 200, body: JSON.stringify(response) } }
src/libs/handler-resolver.ts
export const handlerPath = (context: string) => { return `${context.split(process.cwd())[1].substring(1).replace(/\\/g, '/')}` };
AWS SDK for JavaScriptや@types/aws-lambda
以外に、middyも含まれていますね。
こんな内容でした。
npmモジュールをインストール。
$ npm i
ここまでひと区切り。
LocalStackにデプロイする
LocalStackのプラグインをインストールします。
$ serverless plugin install -n serverless-localstack
これで、devDependencies
はこのような状態になりました。
"devDependencies": { "@serverless/typescript": "^3.0.0", "@types/aws-lambda": "^8.10.71", "@types/node": "^14.14.25", "esbuild": "^0.14.11", "json-schema-to-ts": "^1.5.0", "serverless": "^3.0.0", "serverless-esbuild": "^1.23.3", "serverless-localstack": "^1.0.0", "ts-node": "^10.4.0", "tsconfig-paths": "^3.9.0", "typescript": "^4.1.3" },
serverless.ts
を修正して、plugins
にserverless-localstack
を追加します。
plugins: ['serverless-esbuild', 'serverless-localstack'],
せっかくなので、Node.jsのバージョンも16.xにしておきましょう。runtime
の部分を修正。
provider: { name: 'aws', runtime: 'nodejs16.x', apiGateway: { minimumCompressionSize: 1024, shouldStartNameWithService: true, },
custom
、esbuild
配下のtarget
も修正しておきます。
custom: { localstack: {}, esbuild: { bundle: true, minify: false, sourcemap: true, exclude: ['aws-sdk'], target: 'node16', define: { 'require.resolve': undefined }, platform: 'node', concurrency: 10, }, },
いったん、動作確認。
$ serverless invoke local --function hello --path src/functions/hello/mock .json Running "serverless" from node_modules Using serverless-localstack { "statusCode": 200, "body": "{\"message\":\"Hello Frederic, welcome to the exciting Serverless world!\",\"event\":{\"headers\":{\"Content-Type\":\"application/json\"},\"body\":{\"name\":\"Frederic\"},\"rawBody\":\"{\\\"name\\\": \\\"Frederic\\\"}\"}}" }
こちらはOKです。
LocalStackにデプロイしてみましょう。
$ serverless deploy
serverless-localstack
を使用し、デプロイが完了したように見えます。
$ serverless deploy Running "serverless" from node_modules Using serverless-localstack Deploying serverless-localstack-typescript to stage dev (us-east-1) Skipping template validation: Unsupported in Localstack ✔ Service deployed to stack serverless-localstack-typescript-dev (13s) endpoint: http://localhost:4566/restapis/s9q8uvgigr/dev/_user_request_ functions: hello: serverless-localstack-typescript-dev-hello (14 kB) Monitor all your API routes with Serverless Console: run "serverless --console"
この時、.serverless
というディレクトリが作成され、中身はこのようになっていました。
$ tree .serverless .serverless ├── cloudformation-template-create-stack.json ├── cloudformation-template-update-stack.json ├── hello.zip └── serverless-state.json 0 directories, 4 files
AWS CloudFormationのテンプレートもありますね。
zipファイルの中身は、TypeScriptからビルドされ、さらにminifyしたJavaScriptですね。
$ unzip -l .serverless/hello.zip Archive: .serverless/hello.zip Length Date Time Name --------- ---------- ----- ---- 18298 1980-01-01 00:00 src/functions/hello/handler.js 23921 1980-01-01 00:00 src/functions/hello/handler.js.map --------- ------- 42219 2 files
では、Amazon API GatewayのREST APIのIDを取得して
$ 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 -d '{"name": "LocalStack"}' {"__type": "InternalError", "message": "exception while calling apigateway with unknown operation: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"}
失敗しました…。
LocakStackのログにも、同じような内容が書かれています…。
2022-08-11T14:58:56.299 ERROR --- [ asgi_gw_1] l.aws.handlers.logging : exception during call chain: Expecting property name enclosed in double quotes: line 1 column 2 (char 1) 2022-08-11T14:58:56.301 INFO --- [ asgi_gw_1] localstack.request.http : POST /restapis/s9q8uvgigr/Prod/_user_request_/hello => 500
いろいろ調べて、どうやらスキーマがわかっていないようだったので、今回は除外することにします。
こちらのファイルから、スキーマに関する部分をコメントアウト。
src/functions/hello/index.ts
//import schema from './schema'; import { handlerPath } from '@libs/handler-resolver'; export default { handler: `${handlerPath(__dirname)}/handler.main`, events: [ { http: { method: 'post', path: 'hello', request: { //schemas: { // 'application/json': schema, //}, }, }, }, ], };
あと、動作させて思ったのですがレスポンスに含まれる情報が多すぎたので、event
は削っておきます。
src/functions/hello/handler.ts
import type { ValidatedEventAPIGatewayProxyEvent } from '@libs/api-gateway'; import { formatJSONResponse } from '@libs/api-gateway'; import { middyfy } from '@libs/lambda'; import schema from './schema'; const hello: ValidatedEventAPIGatewayProxyEvent<typeof schema> = async (event) => { return formatJSONResponse({ message: `Hello ${event.body.name}, welcome to the exciting Serverless world!`, //event, }); }; export const main = middyfy(hello);
これで、再度デプロイすると
$ serverless deploy
今度は動作しました。
$ curl -XPOST -H 'Content-Type: application/json' http://localhost:4566/restapis/$REST_API_ID/dev/_user_request _/hello -d '{"name": "LocalStack"}' {"message":"Hello LocalStack, welcome to the exciting Serverless world!"}
ちょっと苦労したのですが、これで入り口ですね…。
最後に、修正したserverless.ts
ファイル全体を載せておきます。
serverless.ts
import type { AWS } from '@serverless/typescript'; import hello from '@functions/hello'; const serverlessConfiguration: AWS = { service: 'serverless-localstack-typescript', frameworkVersion: '3', plugins: ['serverless-esbuild', 'serverless-localstack'], provider: { name: 'aws', runtime: 'nodejs16.x', apiGateway: { minimumCompressionSize: 1024, shouldStartNameWithService: true, }, environment: { AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1', NODE_OPTIONS: '--enable-source-maps --stack-trace-limit=1000', }, }, // import the function via paths functions: { hello }, package: { individually: true }, custom: { localstack: {}, esbuild: { bundle: true, minify: false, sourcemap: true, exclude: ['aws-sdk'], target: 'node16', define: { 'require.resolve': undefined }, platform: 'node', concurrency: 10, }, }, }; module.exports = serverlessConfiguration;
オマケ:トラブルシュート
最初に遭遇したこちらのエラー、ヒントがなくてかなり厳しかったのですが。
$ curl -H 'Content-Type: application/json' http://localhost:4566/restapis/$REST_API_ID/dev/_user_request_/hello -d '{"name": "LocalStack"}' {"__type": "InternalError", "message": "exception while calling apigateway with unknown operation: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"}
どうも、AWS Lambda関数まで到達していなさそうだったんですよね。
LocalStackのドキュメントを見て、LS_LOG
という環境変数でログレベルを指定できそうだったので
もっともログレベルの低い、trace-internal
を選択。
$ LAMBDA_EXECUTOR=docker-reuse LS_LOG=trace-internal localstack start
これで、LocalStack側はスタックトレースまで出力されるようになりました。
2022-08-11T16:15:17.451 ERROR --- [ asgi_gw_2] l.aws.handlers.logging : exception during call chain Traceback (most recent call last): File "/opt/code/localstack/localstack/aws/chain.py", line 57, in handle handler(self, self.context, response) File "/opt/code/localstack/localstack/aws/handlers/routes.py", line 27, in __call__ router_response = self.router.dispatch(context.request) File "/opt/code/localstack/localstack/http/router.py", line 234, in dispatch return self.dispatcher(request, handler, args) File "/opt/code/localstack/localstack/http/dispatcher.py", line 100, in _dispatch result = endpoint(request, **args) File "/opt/code/localstack/localstack/services/apigateway/router_asf.py", line 134, in invoke_rest_api result = invoke_rest_api_from_request(invocation_context) File "/opt/code/localstack/localstack/services/apigateway/invocations.py", line 241, in invoke_rest_api_from_request return invoke_rest_api(invocation_context) File "/opt/code/localstack/localstack/services/apigateway/invocations.py", line 262, in invoke_rest_api if not validator.is_request_valid(): File "/opt/code/localstack/localstack/services/apigateway/invocations.py", line 92, in is_request_valid is_body_valid = self.validate_body(resource) File "/opt/code/localstack/localstack/services/apigateway/invocations.py", line 117, in validate_body validate(instance=json.loads(self.context.data), schema=json.loads(model["schema"])) File "/usr/local/lib/python3.10/json/__init__.py", line 346, in loads return _default_decoder.decode(s) File "/usr/local/lib/python3.10/json/decoder.py", line 337, in decode obj, end = self.raw_decode(s, idx=_w(s, 0).end()) File "/usr/local/lib/python3.10/json/decoder.py", line 353, in raw_decode obj, end = self.scan_once(s, idx) json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
これでスキーマを読み込んでいる部分が怪しいというのを見つけたので、削除したところ動くようになりました。
File "/opt/code/localstack/localstack/services/apigateway/invocations.py", line 117, in validate_body validate(instance=json.loads(self.context.data), schema=json.loads(model["schema"]))
ここにたどり着くのにだいぶ苦労しました…。
まとめ
Serverless Frameworkを使い、LocalStack上でTypeScriptで作成したAmazon API Gateway+AWS Lambdaなアプリケーションを動作させて
みました。
ちょっとスキーマまわりが不可解で苦労したのですが、とりあえず動かせました…。
今回はスキーマ自体を外してしまいましたが、修正して動くようにはしたいところですね…そのうち。
いったん、今回の目標は達成です。