CLOVER🍀

That was when it all began.

Serverless FrameworkをLocalStack+TypeScriptで使ってみる(Amazon API Gateway+AWS Lambda)

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

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 GatewayAWS 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 createaws-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を修正して、pluginsserverless-localstackを追加します。

  plugins: ['serverless-esbuild', 'serverless-localstack'],

せっかくなので、Node.jsのバージョンも16.xにしておきましょう。runtimeの部分を修正。

  provider: {
    name: 'aws',
    runtime: 'nodejs16.x',
    apiGateway: {
      minimumCompressionSize: 1024,
      shouldStartNameWithService: true,
    },

customesbuild配下の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 GatewayREST 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という環境変数でログレベルを指定できそうだったので

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 GatewayAWS Lambdaなアプリケーションを動作させて
みました。

ちょっとスキーマまわりが不可解で苦労したのですが、とりあえず動かせました…。

今回はスキーマ自体を外してしまいましたが、修正して動くようにはしたいところですね…そのうち。

いったん、今回の目標は達成です。