かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript な Express を aws-seveless-express で Serverless にして AWS Lambda にデプロイするまでの記録

TL;DR

AWS も Lambda も自分で調べて〜なレベル感なのでなんも分かってません。
とりあえずガチャガチャやってみて、動いてるんじゃね?となったので、そこまでもプロセスをまとめただけのエントリーになります。なので、たぶん超長い記事になってると思います。 :pray:

Serverless 化する TypeScript な Express の構成

👇 前回までのあらすじ

構成はこんな感じ

/api
  |- /env
  |    |- .env.dev
  |    |- .env.prod
  |- /src
  |    |- index.ts // Express server root
  |- /webpack // このディレクトリ下に webpack の設定ファイル
  |    |- base.config.js
  |    |- // … 略
  |- package.json
  |- tsconfig.json

AWS Cli でユーザーを設定する

AWS cli をインストール

homebrew でインストールできるっぽい

$ brew install awscli

Docker 上で動かすこともできるみたいだけど、ちょっと面倒そうなので

ユーザーの設定

対話式で default ユーザーの設定ができる

$ aws configure
AWS Access Key ID:
AWS Secret Access Key:
Default region name: 
Default output format: 

AWS 上に作成した IAM ユーザーの AWS Access Key ID, AWS Secret Access Key を指定。
Default region は ap-northeast-1, Default output format は json を指定しました。

~/.aws/credentialsAccess Key ID, Secret Access Key の設定が、~/.aws/config に region と output format の設定のファイルが作成される

~/.aws/credentials

[default]
aws_access_key_id = DEFAULT_USER_ACCESS_KEY_ID
aws_secret_access_key = DEFAULT_USER_SECRET_ACCESS_KEY

~/.aws/config

[default]
region = ap-northeast-1
output = json

このファイルにある [default] が以降で使われる serverless で使われるユーザーの profile 名になる


Serverless パッケージのインストール

$ npm install -g serverless
$ sls -v

ドキュメントにあるように global に serverless コマンド (sls) が使えるようにインストール。
あまり global に色々入れたくないので、local にインストールしても大丈夫なのかな?

aws-serverless-express で Serverless 化する

1つのエンドポイントで全てのパスを受け止めて Lambda 関数にパスして内部で動作している Express でそれぞれのURLに合う処理を行うようにするっぽい cf. AWS LambdaとNuxt.jsでServer Side Renderingする(2020年版) - Sweet Escape

パッケージのインストール

$ npm install --save aws-lambda aws-serverless-express
$ npm install --save-dev @types/aws-lambda @types/aws-serverless-express

serverless.yml

severless コマンドやデプロイする際に使われる設定っぽい。(正確に理解できているわけではない)

service: my-serverless-app

provider:
  name: aws
  runtime: nodejs12.x
  # デフォルトが us-east-1 なのでTOKYOリージョンを指定 
  region: ap-northeast-1
  # --stage オプションの値をセット、オプションがない場合は dev を使用
  stage: ${opt:stage, 'dev'}
  # .aws/credentials の [default] ユーザーを使用
  profile: default

# Lambda の設定
functions:
  app:
    # `./src/handler.ts` の handler 関数をエンドポイントにする
    handler: src/handler.handler
    timeout: 30
    # 全ての URL を受け止めて Lambda に渡す
    events:
      - http: ANY /
      - http: 'ANY {proxy+}'

# デプロイパッケージの設定
package:
  exclude: # デプロイパッケージから除外
    - '**'
  include: #  デプロイパッケージに含める
    - dist/**

TypeScript な Express を webpack でビルドしていたので、package 設定で、serverless.yml のあるディレクトリを全て除外した後に webpack でビルドされる dist ディレクトリ配下のみが対象となる設定にしました。

Lambda 用のエントリーポイントを作成する

API Gateway で受け止めた URL を Lmabda が ./src/hander.tshandler 関数に渡し、この関数を通じて Express アプリにアクセスします。

Express のファイルを分割する

local で作成していた際は app.listen(PORT)localhost を立ち上げてアプリを作成していましたが、Serverless にした際にこの app.listen(PORT)localhost が動作する処理が残っているとエラーが発生して上手く動作しなかったので、local での開発時だけ app.listen(PORT) できるように ./src/index.ts を分割します。

./src/app.ts … local, Serverless 共通で使用する Express を export する

import express from 'express';
const app = express();
const router = express.Router();

// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/', (req: express.Request, res: express.Response) => {
  const { method, path, query } = req;

  return res.send(
    JSON.stringify({
      message: 'Hello World Serverless!',
      method,
      path,
      query,
    }),
  );
});

// Routing
app.use('/', router);

export default app;

./src/index.ts … local 開発時のエントリーポイント

import app from './app';
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`listening on port http://localhost:${PORT}`);
});

app.ts の Express を app.listen() で動かすだけ

Lambda から呼び出される hander 関数を作成

./src/handler.tsAPI Gateway から渡されるイベント URL を hander 関数で受け止めて app.ts の Express に渡す

import { APIGatewayEvent, Context, Handler } from 'aws-lambda';
import * as awsServerlessExpress from 'aws-serverless-express';
import app from './app';
const server = awsServerlessExpress.createServer(app);

export const handler: Handler = (event: APIGatewayEvent, context: Context) => {
  awsServerlessExpress.proxy(server, event, context);
};

serverless-offline で Local でも Serverless で実行できるようにする

パッケージのインストール

$ npm install --save-dev serverless-offline

serverless.yml に下記を追加

plugins:
  - serverless-offline

offline での実行

$ sls offline
…
offline: [HTTP] server ready: http://localhost:3000 🚀

デフォルトの --stage dev の場合は http://localhost:3000/dev が Serverless のエンドポイントになっっている


Lambda で関数が呼べるように webpack する

TypeScript で書いていたので Serverless としてデプロイするにもビルドする必要があるだろうと思い Lambda 用に作成した handler.ts をエントリーポイントにした webpack.config.js を作成してビルドしてみたのですが、通常の webpack でビルドすると出力されるプログラムは !function(){} な無記名関数の中にカプセル化されてしまうようで、serverless-offline で試してみようとしたら Serverless の handler になる関数にアクセスできず 404 が返ってくるだけになってしまいました…

serverless-webpack を使う

Serverless で使えるようにいい感じに webpack してくれるっぽい

パッケージのインストール

$ npm install --save-dev serverless-webpack

serverless.yml を編集

service: my-serverless-app

plugins:
  - serverless-offline
+  - serverless-webpack

+ custom:
+   webpack:
+     webpackConfig: 'webpack.serverless.config.js'
+     includeModules: true

provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  stage: ${opt:stage, 'dev'}
  profile: default

# Lambda の設定
functions:
  app:
    # `./src/handler.ts` の handler 関数をエンドポイントにする
    handler: src/handler.handler
    timeout: 30
    # 全ての URL を受け止めて Lambda に渡す
    events:
      - http: ANY /
      - http: 'ANY {proxy+}'

- # デプロイパッケージの設定
- package:
-   exclude: # デプロイパッケージから除外
-     - '**'
-   include: #  デプロイパッケージに含める
-     - dist/**
  • custom.webpack.webpackConfig … serverless-webpack で使用する webpack の設定ファイル。指定がない場合は webpack.config.js が使用される
  • custom.webpack.includeModules … webpack 側で webpackl-node-externals を使用して node_modules を除外する場合は、includeModulestrue にしておく必要があるみたいです。cf. Serverless Webpack の使い方まとめ - Qiita

serverless-webpack でビルドされたものが sls offline, sls deploy の対象になるようだったので、package の設定は不要なので (たぶん) 削除しました。

severless 用の webpack config ファイルの作成

custom.webpack.webpackConfig で指定した設定ファイル webpack.serverless.config.js を作成します

webpack.serverless.config.js

const webpack = require('webpack');
const slsw = require('serverless-webpack');
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const Dotenv = require('dotenv-webpack');

const enviroment = process.env.NODE_ENV || 'dev';

module.exports = {
  mode: 'production',
  // serverless.yml で handler に指定したファイルが自動的にエントリーポイントに出来るっぽい
  entry: slsw.lib.entries,
  target: 'node',
  externals: [nodeExternals()],
  plugins: [
    new Dotenv({
      path: path.resolve(__dirname, `../env/.env.${enviroment}`),
    }),
  ],
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: 'ts-loader',
        options: {
          configFile: 'tsconfig.json',
        },
      },
    ],
  },
  resolve: {
    extensions: ['.ts', '.js', '.json'],
    alias: {
      '@': path.join(__dirname, '/src/'),
    },
  },
};

serverless-webpack でビルド

# stage prod でビルド
$ sls webpack --stage prod
# stage dev でビルド
$ sls webpack --stage dev

.webpack ディレクトリにビルドされたファイルが、.serverless ディレクトリに zip 化されたファイル (serverless にデプロイされるファイル) が生成されます。


serverless-webpack でビルドする際にも process.env に値を渡したい

$ NODE_ENV=dev sls webpack --stage dev

上記のように NODE_ENV= を先頭につければ sls webpack でも process.env に値を渡すことができましたが、折角 stage オプションがあり二重っぽいので、serverless-webpack でビルドする際は stage オプションを NODE_ENV としてプログラムに渡せるようにしたいと思います。

serverless.yml の値を webpack.config で取得してプログラムに渡す

slsw.lib.serverless.service で webpack の設定で serverless.yml のプロパティにアクセスできるので、serverless.yml に NODE_ENV プロパティを作成して、webpack で process.env にしてプログラムに渡せる方針にします。

serverless.yml stage の値を NODE_ENV プロパティに設定する
provider:
  name: aws
  runtime: nodejs12.x
  region: ap-northeast-1
  stage: ${opt:stage, 'dev'}
  profile: default
+  environment:
+     NODE_ENV: ${self:provider.stage}

--stage オプションで渡された値が provider.environment.NODE_ENV にセットされる

webpack.EnvironmentPlugin を使って provider.environment の値を process.env にマージしてプログラムに渡す

webpack.serverless.config.js

const webpack = require('webpack');
const slsw = require('serverless-webpack');
// …

const enviroment =
  slsw.lib.serverless.service.provider.environment.NODE_ENV ||
  'dev';

module.exports = {
  // …
  plugins: [
    new Dotenv({
      path: path.resolve(__dirname, `../env/.env.${enviroment}`),
    }),
    // serverless.yml の provider.environment の値をオブジェクトとして process.env にマージしてプログラムに渡す
    new webpack.EnvironmentPlugin(
      slsw.lib.serverless.service.provider.environment,
    ),
  ],
  //...
};

これで severless でビルドする際も /src 内のプログラムで local で開発しているときと同様に process.env.NODE_ENV が扱えるようになりました。


Deploy

$ sls deploy --stage prod -v

sls deploy コマンドを実行すると sls webpack が実行された後にビルドされた zip が AWS にデプロイされる。
-v オプションを付けると途中経過がターミナル上で確認できる。

サービスの削除

デプロイしたサービスを削除する場合は sls remove コマンドを実行すれば OK

$ sls remove -v

コマンドでは削除しきれないものもあるらしいので、最終的には AWS のコンソールで確認するのが良さそうです。(理解が浅いがゆえに謎課金が発生するの精神的気に結構辛い〜)


所感

検索 + タブ開きまくりでなんとか TypeScript な Express を Serverless 化して AWS にデプロイ・ローカルでも serverless-offline での実行が出来る状態にすることができました。 AWS なんもわからん + Serverless 初挑戦で理解が足りないことが二重・三重になってしまっていたので、この開発とデプロイができる環境を作ること自体にすごく時間がかかってしまって、肝心のサービスの中身はまだ何も手がつけられていないという状態… 取り敢えずサービスをリリースしたいという目的が優先なら知見のある技術選定でまずはサクッと作ってしまうのが大事だと感じました。

Next Step

  1. ビルドされた zip が node_modules を含んでいて巨大なので serverless-layers を使えば node_modules を s3 に分離してアップロードできそうなので試してみる
  2. local / デプロイした状態で DynamoDB が使えるようにする

[参考]

AWS Lambda実践ガイド (impress top gear)

AWS Lambda実践ガイド (impress top gear)

  • 作者:大澤文孝
  • 発売日: 2017/10/16
  • メディア: 単行本(ソフトカバー)