これは、なにをしたくて書いたもの?
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なアプリケーションにしてみましょう。
ちなみに、テンプレートはこのリポジトリ内から選んでいるようですね。
GitHub - serverless/examples: Serverless Examples – A collection of boilerplates and examples of serverless architectures built with the Serverless Framework on AWS Lambda, Microsoft Azure, Google Cloud Functions, and more.
推奨リストがcreate
のドキュメントやヘルプで表示されているようです。
https://github.com/serverless/serverless/blob/v3.21.0/lib/templates/recommended-list/index.js
また、TypeScript向けのプラグインもあるようなのですが、今回はこちらは使わなそうです。
serverless-plugin-typescript
Serverless FrameworkとLocalStack
LocalStackに対してServerless Frameworkを使う場合は、LocalStackが用意しているプラグインを使用します。
Serverless Framework | Docs
こちらを使うことで、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',
},
},
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 { handlerPath } from '@libs/handler-resolver';
export default {
handler: `${handlerPath(__dirname)}/handler.main`,
events: [
{
http: {
method: 'post',
path: 'hello',
request: {
},
},
},
],
};
あと、動作させて思ったのですがレスポンスに含まれる情報が多すぎたので、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!`,
});
};
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',
},
},
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
という環境変数でログレベルを指定できそうだったので
Configuration | Docs
もっともログレベルの低い、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なアプリケーションを動作させて
みました。
ちょっとスキーマまわりが不可解で苦労したのですが、とりあえず動かせました…。
今回はスキーマ自体を外してしまいましたが、修正して動くようにはしたいところですね…そのうち。
いったん、今回の目標は達成です。