これは、なにをしたくて書いたもの?
AWS Lambdaで使える、フレームワークをちょっと試してみたいなと思いまして。
Serverless Expressというものがあるみたいなので、ちょっと試してみることにしました。
Serverless Express
Serverless ExpressのGitHubリポジトリは、こちら。
もともとはawslabsに置かれていたフレームワークのようですが、
AWS Lambda と Amazon API Gateway で Express アプリケーションを実行 | Amazon Web Services ブログ
今はVendiaというところに移されたようです。
The original author of the Serverless Express project, Brett Andrews, joined Vendia recently, and we realized we can do even more in our mission to help customers share code and data effectively by giving back to the open source community by helping the Serverless Express project “graduate" from its initial location in AWS Labs to a permanent place in Vendia's repository.
Vendia — AWS Serverless Express Finds a Loving Home at Vendia
Serverless Expressというものがどういうものかというと、Expressなどを使ったアプリケーションからAmazon API Gateway
およびAWS Lambdaに移行するためのもののようです。
Run REST APIs and other web applications using your existing Node.js application framework (Express, Koa, Hapi, Sails, etc.), on top of AWS Lambda and Amazon API Gateway.
使い方は、こちらのサンプルを参考に。
https://github.com/vendia/serverless-express/tree/v4.5.2/examples
AWS Lambda関数として利用するのは、以下に書かれているようにExpressのインスタンスをserverlessExpress
関数で
包んだものになります。
Minimal Lambda handler wrapper
今回は、こちらを使って簡単なExpressアプリケーションを、LocalStack上のAmazon API Gateway+AWS Lambda環境に
デプロイしてみます。
環境
今回の環境は、こちら。AWS LambdaのNode.jsのバージョンに合わせます。
$ node --version v14.18.2 $ npm --version 6.14.15
LocalStackのバージョン。
$ localstack --version 0.13.0.10
起動。
$ LAMBDA_EXECUTOR=docker-reuse localstack start
Terraform。
$ terraform version Terraform v1.0.11 on linux_amd64
$ awslocal --version aws-cli/2.4.5 Python/3.8.8 Linux/5.4.0-91-generic exe/x86_64.ubuntu.20 prompt/off
アプリケーションの作成
では、デプロイ対象のアプリケーションを作成します。プロジェクトの作成。
$ npm init -y $ npm i -D typescript $ npm i -D -E prettier $ mkdir src
TypeScriptのバージョン。
$ npx tsc --version Version 4.5.2
続いて、ExpressおよびServerless Expressをインストールしていきましょう。
Express自体は、Serverless Expressとは別にインストールする必要があるみたいです。
serverless-express/package.json at v4.5.2 · vendia/serverless-express · GitHub
Expressおよび型宣言のインストール。
$ npm i express $ npm i -D @types/node @types/express @types/aws-lambda
Serverless Expressのインストール。
$ npm i @vendia/serverless-express
フレームワークやライブラリのバージョンは、こちら。
"devDependencies": { "@types/aws-lambda": "^8.10.85", "@types/express": "^4.17.13", "@types/node": "^16.11.11", "prettier": "2.5.1", "typescript": "^4.5.2" }, "dependencies": { "@vendia/serverless-express": "^4.5.2", "express": "^4.17.1" }
tsconfig.json
{ "compilerOptions": { "target": "esnext", "module": "commonjs", "baseUrl": "./src", "outDir": "dist", "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, "noPropertyAccessFromIndexSignature": true, "esModuleInterop": true }, "include": [ "src" ] }
.prettierrc.json
{ "singleQuote": true }
ソースコードを書く際には、Amazon API Gatewayバージョン1向けのサンプルを参考にしていきます。
https://github.com/vendia/serverless-express/tree/v4.5.2/examples/basic-starter-api-gateway-v1
アプリケーション本体は、こちら。
src/app.ts
import express, { Request, Response } from 'express'; import bodyParser from 'body-parser'; export const app = express(); app.use(bodyParser.json()); type Person = { name: string; age: number; }; const users: { [id: string]: Person } = { sazae: { name: 'フグ田サザエ', age: 24, }, hamihei: { name: '磯野海平', age: 54, }, fune: { name: '磯野フネ', age: 50, }, masuo: { name: 'フグ田マスオ', age: 28, }, katsuo: { name: '磯野カツオ', age: 11, }, wakame: { name: '磯野ワカメ', age: 9, }, tarao: { name: 'フグ田タラオ', age: 3, }, }; app.get('/echo', (req: Request, res: Response) => { const message = req.query['message']; res.send(`Hello ${message}!!`); }); app.post('/echo', (req: Request, res: Response) => { const message = req.body['message']; res.send({ message: `Hello ${message}!!` }); }); app.get('/users', (req: Request, res: Response) => { res.send(Object.values(users)); }); app.get('/users/:id', (req: Request, res: Response) => { const id = req.params['id']; res.send(users[id]); });
この部分だけexport
しておきます。
export const app = express();
Lambdaに関数として登録する関数は、こちらに切り出します。
src/lambda.ts
import serverlessExpress from '@vendia/serverless-express'; import { app } from './app'; export const handler = serverlessExpress({ app });
lambda.handler
が、AWS Lambdaに登録する関数になりますね。
また、Amazon API Gatewayバージョン2+TypeScriptのサンプルですが、こちらを見ると別ファイルを作成することで
ローカル実行とも両立できそうです。
同様に作成して、まずはローカルで動作確認してみましょう。
src/app.local.ts
import { app } from './app'; const port = 3000; app.listen(port, () => console.log(`[${new Date().toISOString()}] start server[${port}]`) );
ビルド。
$ npx tsc -p .
起動。
$ node dist/app.local.js [2021-12-05T14:45:37.484Z] start server[3000]
動作確認します。
$ curl localhost:3000/echo?message=Express Hello Express!! $ curl -XPOST -H 'Content-Type: application/json' localhost:3000/echo -d '{"message": "Express"}' {"message":"Hello Express!!"} $ curl localhost:3000/users [{"name":"フグ田サザエ","age":24},{"name":"磯野海平","age":54},{"name":"磯野フネ","age":50},{"name":"フグ田マスオ","age":28},{"name":" 磯野カツオ","age":11},{"name":"磯野ワカメ","age":9},{"name":"フグ田タラオ","age":3}] $ curl localhost:3000/users/katsuo {"name":"磯野カツオ","age":11}
OKですね。
あとは、zipファイルにパッケージングします。
こんな定義をして
"scripts": { "build": "tsc --project .", ... "lambda-package": "npm run build && cp package.json dist && cd dist && npm i --production && zip -r function.zip *" },
パッケージング。
$ npm run lambda-package
これで、デプロイ対象のzipファイルができました。
$ ll dist 合計 748 drwxrwxr-x 3 xxxxx xxxxx 4096 12月 5 23:53 ./ drwxrwxr-x 5 xxxxx xxxxx 4096 12月 5 23:45 ../ -rw-rw-r-- 1 xxxxx xxxxx 1405 12月 5 23:53 app.js -rw-rw-r-- 1 xxxxx xxxxx 226 12月 5 23:53 app.local.js -rw-rw-r-- 1 xxxxx xxxxx 716490 12月 5 23:53 function.zip -rw-rw-r-- 1 xxxxx xxxxx 432 12月 5 23:53 lambda.js drwxrwxr-x 53 xxxxx xxxxx 4096 12月 5 23:53 node_modules/ -rw-rw-r-- 1 xxxxx xxxxx 18575 12月 5 23:53 package-lock.json -rw-rw-r-- 1 xxxxx xxxxx 694 12月 5 23:53 package.json
LocalStackにデプロイする
続いて、Terraformを使ってzipファイルをLocalStackにデプロイします。
こんな感じで、Amazon API Gateway+AWS Lambdaのリソース定義を行います。
main.tf
terraform { required_version = "1.0.11" required_providers { aws = { source = "hashicorp/aws" version = "3.68.0" } } } provider "aws" { access_key = "mock_access_key" region = "us-east-1" s3_force_path_style = true secret_key = "mock_secret_key" skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true endpoints { apigateway = "http://localhost:4566" cloudformation = "http://localhost:4566" cloudwatch = "http://localhost:4566" dynamodb = "http://localhost:4566" es = "http://localhost:4566" firehose = "http://localhost:4566" iam = "http://localhost:4566" kinesis = "http://localhost:4566" lambda = "http://localhost:4566" route53 = "http://localhost:4566" redshift = "http://localhost:4566" s3 = "http://localhost:4566" secretsmanager = "http://localhost:4566" ses = "http://localhost:4566" sns = "http://localhost:4566" sqs = "http://localhost:4566" ssm = "http://localhost:4566" stepfunctions = "http://localhost:4566" sts = "http://localhost:4566" } } ## API Gateway resource "aws_api_gateway_rest_api" "rest_api" { name = "my-rest-api" } resource "aws_api_gateway_resource" "proxy_resource" { rest_api_id = aws_api_gateway_rest_api.rest_api.id parent_id = aws_api_gateway_rest_api.rest_api.root_resource_id path_part = "{proxy+}" } resource "aws_api_gateway_method" "method" { rest_api_id = aws_api_gateway_rest_api.rest_api.id authorization = "NONE" http_method = "ANY" resource_id = aws_api_gateway_resource.proxy_resource.id } resource "aws_api_gateway_integration" "integration" { rest_api_id = aws_api_gateway_rest_api.rest_api.id resource_id = aws_api_gateway_resource.proxy_resource.id http_method = aws_api_gateway_method.method.http_method integration_http_method = "ANY" type = "AWS_PROXY" uri = aws_lambda_function.lambda.invoke_arn } resource "aws_api_gateway_deployment" "deployment" { rest_api_id = aws_api_gateway_rest_api.rest_api.id triggers = { redeployment = sha1(jsonencode([ aws_api_gateway_rest_api.rest_api.body, aws_api_gateway_resource.proxy_resource.id, aws_api_gateway_method.method.id, aws_api_gateway_integration.integration.id, ])) } lifecycle { create_before_destroy = true } depends_on = [aws_api_gateway_integration.integration] } resource "aws_api_gateway_stage" "stage" { deployment_id = aws_api_gateway_deployment.deployment.id rest_api_id = aws_api_gateway_rest_api.rest_api.id stage_name = "Prod" } ## Lambda data "aws_caller_identity" "current" {} data "aws_region" "current" {} resource "aws_lambda_permission" "lambda" { statement_id = "AllowExecutionFromApiGateway" action = "lambda:InvokeFunction" function_name = aws_lambda_function.lambda.function_name principal = "apigateway.amazonaws.com" source_arn = "arn:aws:execute-api:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:${aws_api_gateway_rest_api.rest_api.id}/*/${aws_api_gateway_method.method.http_method}${aws_api_gateway_resource.proxy_resource.path}" } resource "aws_lambda_function" "lambda" { filename = "/path/to/dist/function.zip" function_name = "my_lambda" role = aws_iam_role.lambda_role.arn handler = "lambda.handler" runtime = "nodejs14.x" } # IAM resource "aws_iam_role" "lambda_role" { name = "MyLambdaRole" assume_role_policy = data.aws_iam_policy_document.assume_role.json } data "aws_iam_policy_document" "assume_role" { statement { actions = ["sts:AssumeRole"] effect = "Allow" principals { type = "Service" identifiers = ["lambda.amazonaws.com"] } } }
Amazon API GatewayとAWS Lambdaの統合方法は、プロキシ統合としています。
AWS Lambda関数のリソース定義は、こちらですね。先ほど作成したzipファイルを指定。
resource "aws_lambda_function" "lambda" { filename = "/path/to/dist/function.zip" function_name = "my_lambda" role = aws_iam_role.lambda_role.arn handler = "lambda.handler" runtime = "nodejs14.x" }
init
してapply
します。
$ terraform init $ terraform apply -auto-approve
デプロイが完了したら、REST IDを取得して
$ REST_API_ID=$(awslocal apigateway get-rest-apis --query 'items[0].id' --output text)
動作確認。
$ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/echo?message=Express Hello Express!! $ curl -XPOST -H 'Content-Type: application/json' http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/echo -d '{"message": "Express"}' {"message":"Hello Express!!"} $ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/users [{"name":"フグ田サザエ","age":24},{"name":"磯野海平","age":54},{"name":"磯野フネ","age":50},{"name":"フグ田マスオ","age":28},{"name":" 磯野カツオ","age":11},{"name":"磯野ワカメ","age":9},{"name":"フグ田タラオ","age":3}] $ curl http://localhost:4566/restapis/$REST_API_ID/Prod/_user_request_/users/katsuo {"name":"磯野カツオ","age":11}
OKですね。Amazon API Gateway+AWS Lambdaにデプロイしても、同じように使えました。
もう少し追ってみる
もう少しServerless Expressを追ってみましょう。
ここを見ていると、Amazon API Gatewayバージョン1と2、ALB、Lambda Edge、AWS DynamoDB、Amazon SNS
あたりに対応していそうです。
https://github.com/vendia/serverless-express/blob/v4.5.2/src/event-sources/index.js
サンプルを見ても、各リソースに対するものが置かれていますね。Amazon SNSはなさそうですが…。
https://github.com/vendia/serverless-express/tree/v4.5.2/examples
そして、Serverless ExpressはこのソースコードをAWS Lambdaのハンドラとして登録してからどうやって
フレークワークの処理に乗せているか、という点ですが。
import serverlessExpress from '@vendia/serverless-express'; import { app } from './app'; export const handler = serverlessExpress({ app });
ここで渡したapp
というオブジェクトに対して、使用しているフレームワークの判定を行うようです。
https://github.com/vendia/serverless-express/blob/v4.5.2/src/configure.js#L22
中身はこちら。渡したapp
の情報を見て、フレームワークの種類を判定しようとします。
https://github.com/vendia/serverless-express/blob/v4.5.2/src/frameworks/index.js
そして、リクエストが来たら情報をラップして、フレームワークの処理に転送します。
転送方法はフレームワークによって異なりますがExpressのオブジェクトの場合はこちら。
https://github.com/vendia/serverless-express/blob/v4.5.2/src/frameworks/express.js
Express#handle
を呼び出すようです。
Expressを使う場合についても、いくつかバリエーションがあるみたいですね。
https://github.com/vendia/serverless-express/blob/v4.5.2/src/frameworks/index.js
また、Serverless ExpressをTypeScriptで使うための型宣言を探そうと思ったのですが、見つからず。
どうなっているのかな?と思ってリポジトリを見ていたら、Serverless Framework自体に含まれていました。
まとめ
Serverless Express+TypeScriptを使って、Amazon API Gateway+AWS LambdaをLocalStackにデプロイして
みました。
Serverless Express自体は、手軽に使えて簡単でした。
なんですけど、Amazon API Gateway+AWS Lambdaを使う時に、Serverless Express(というかExpress)を
使ったりするのって、割とふつうなんでしょうか?