CLOVER🍀

That was when it all began.

Serverless Express+TypeScriptを使って、LocalStackにAmazon API Gateway+AWS Lambdaの環境を構成してみる

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

AWS Lambdaで使える、フレームワークをちょっと試してみたいなと思いまして。

Serverless Expressというものがあるみたいなので、ちょっと試してみることにしました。

Serverless Express

Serverless ExpressのGitHubリポジトリは、こちら。

GitHub - vendia/serverless-express: Run Node.js web applications and APIs using existing application frameworks on AWS #serverless technologies such as Lambda, API Gateway, Lambda@Edge, and ALB.

もともとは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 GatewayAWS 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

AWS CLI+LocalStack。

$ 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のサンプルですが、こちらを見ると別ファイルを作成することで
ローカル実行とも両立できそうです。

https://github.com/vendia/serverless-express/tree/v4.5.2/examples/basic-starter-api-gateway-v2-typescript/src

同様に作成して、まずはローカルで動作確認してみましょう。

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 GatewayAWS 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 GatewayAWS 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 GatewayAWS 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

そして、リクエストが来たら情報をラップして、フレームワークの処理に転送します。

github.com

転送方法はフレームワークによって異なりますが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 GatewayAWS LambdaをLocalStackにデプロイして
みました。

Serverless Express自体は、手軽に使えて簡単でした。

なんですけど、Amazon API GatewayAWS Lambdaを使う時に、Serverless Express(というかExpress)を
使ったりするのって、割とふつうなんでしょうか?