API Gateway バックエンドのLambdaにおけるジレンマをLayerで解決する

渡辺です。 Developers.IO Cafeのブログドキュメントシリーズ第3弾は、Lambdaの構成に関するトピックです。

Lambdaは小さな独立した処理(プログラム)を実行するには適したサービスです。 一方、多くの関連する処理を行うAPI Gatewayのバックエンドとして利用する場合、必ずしも適したサービスとは言えません。

例えば、一般的なウェブアプリケーションフレームワークでREST APIを構築したとしましょう。 フレームワークは、単一アプリケーションとしてサーバ上で実行されます。 リクエストは、フレームワークでルーティングを解決され、対応する処理(アクション)がキックされます。 前段にELB等のロードバランサが入ることもありますが、雑に言えばこういう仕組みです。

このよう場合、 共通処理を切り出し、各処理から呼び出す構成にする のが一般的です。 そうしなければ、各処理のコードがコピペまみれとなり、開発効率が悪いだけでなく、不具合の温床となります。 仕様変更にも対応が難しいレガシーコードとなるでしょう。

単一Lambdaによるルーティング

Lambdaの場合でも、単一アプリケーションとして実装し、ルーティングして処理を実行されることができます。

これはひとつの解決方法ですが、メリットもありますが、デメリットが多くあります。

共通処理を簡単にかける

なにはともあれ、共通処理を簡単に書けることが最大のメリットです。 単一Lambdaですから当然ですね。 このメリットを得るため、他のデメリットを全て目を瞑るということもあると思います。

ただし、後述するように、現在はLayer機能があるため、共通処理の抽出は難しくなくなりました。 Layerが無かった頃は、インポート可能なモジュールとして共通処理を書くなど工夫が必要でした。

コールドスタート対策となる

Lambdaが単一であれば、すべてのAPIリクエストは同じLambdaコンテナで実行されます。 したがって、コンテナが起動してしまえば、コールドスタートが発生しません。

とはいえ、リクエストが増えてきた時にコンテナはスケールアウトするため、この時にコールドスタートは発生します。 コールドスタートが発生しにくいだけであり、発生した場合は単一アプリケーションの方がコストが大きくなるはずです。

ログが同じCloudWatch Logsストリームに流れてしまう

Lambdaのログは、Lambda単位でCloudWatch Logsに流れます。 つまり、単一Lambdaであれば、同じストリームに永遠と流れ続けてしまいます。 本番環境などで、該当するログを探したい場合、地獄です。

ログの出力先は変更できません。 したがって、ログを他のサービスに流して検索できるようにするなどの対策が必要です。

重量級のLambdaとなってしまう

単一Lambdaで実装する結果、Lambdaは肥大化します。 特に、ある処理でのみ使う大きな外部モジュールの追加などが発生すると、それだけで全体のパフォーマンスに直結するでしょう。 例えば、PDFや画像処理を行うモジュールは一部のAPIでしか使いませんが、全APIがその影響を受けます。 コールドスタート対策としてのデメリットが大きいです。

デプロイが他の開発者にも影響を与えてしまう

インテグレーション環境などに開発者がデプロイする場合、「後勝ち」祭となります。 各APIが独立したLambdaであれば、自分の担当するAPI(Lambda)をアップデートすればいいのですが・・・。 開発スピードに影響があるでしょう。

このようにメリットもありますが、デメリットも多くあるため、単一Lambdaの採用は難しいのが現実です。 というか、単一Lambdaにするならば、Spring Framework + Elastic Beanstalk といったアーキテクチャ選定をすべきです。 なんでもかんでもサーバレスにする必要はありません

Layerで共通処理を行うLambda関数を構成する

大規模なAPIを開発する場合、Layerが導入されるまで、Lambdaのアーキテクチャ選択にジレンマがありました。 Serverlessなどのフレームワークを導入することも選択肢のひとつですが、デプロイ周りの問題があります(これは後日書きたい話)。 モジュールを利用する場合は開発効率にも影響がでます。 とはいえ、Lambdaの実行コストの低さは魅力です。

Layerで解決しましょう!

2018年にリリースされたLayerは、まさにこの問題を解決するためのサービスです。 次のように共通処理をLayerに記述することが簡単になりました!!

Layerの構成

Layerのメリットを最大限に生かすためのLayer構成を考えた結果、次のようになりました。

共通モジュールレイヤーは、moment.jsなどの 軽量で、ほぼ全ての関数で利用するモジュール を収めます。 このレイヤーは、無条件に全Lambda関数に適用するため、各Lamdba関数での追加は不要となります。 各Lambda関数で追加しなければ、関数のサイズも小さくなり、デプロイが高速化されるメリットがあります。

特定関数用モジュールレイヤーは、firebase-admin.jsなどの 重量で、特定のLamdba関数のみで利用するモジュール を収めます。 特定Lamdba関数のみで利用されるとしても、軽量であれば共通モジュールに含めて良いでしょう。 このモジュールの目的は、コールドスタート時のオーバヘッドを少なくするためです。 単一のLamdba関数でしか利用しないならば、関数自体に含めてもいいでしょう(デプロイは重くなります)。

共通処理モジュールレイヤーは、 DynamoDBテーブルアクセス用のモジュールやエラー処理、複数のLambda関数に関連する処理などを記述するレイヤー です。 開発中は、頻繁に更新とデプロイが発生するため、モジュールを一切インポートしていません。

参考として、ある次点でのレイヤーのサイズは以下の通りでした。

  • 共通モジュールレイヤー: 2.2MB
  • 特定関数用モジュールレイヤー: 24MB
  • 共通処理モジュールレイヤー: 112KB

それぞれデプロイ前のzipファイルのサイズです。 各関数のサイズは10KB以下なので、共通処理レイヤーと対象関数だけをデプロイするならば、合計でも150KB以下となっています。 ちょっと修正してデプロイ確認というサイクルで開発を進めるためには、メガ単位のデプロイは辛いのです。

なお、レイヤーはデプロイ毎にバージョニングされるため、例えば次のように共通処理レイヤーのバージョンが500を超えています(笑)

共通モジュールの構成

共通モジュールは、コードを一切含まないため、非常に単純です。

libs
└── nodejs
    ├── node_modules
    └── package.json

package.jsonの中身はこんな感じです。 デプロイ前にnpmコマンドを実行し、node_modulesにモジュールがロードされます。

{
  "dependencies": {
    "accept-language-parser": "^1.5.0",
    "lambda-log": "^2.3.0",
    "moment": "^2.24.0",
    "request": "^2.88.0",
    "string-template": "^1.0.0"
  }
}

Node.jsのレイヤーの場合、nodejsディレクトリ以下にファイルを置かなければならないので注意してください。

共通処理モジュールの構成

共通モジュールは、コードを含み、モジュールとして利用できるように package.json を記述します。 抜粋するとこのような構成です。

cappuccino
└── nodejs
    ├── cappuccino
    │   ├── Orders.js
    │   ├── Walkthroughs.js
    │   ├── entities
    │   │   ├── Items.js
    │   │   └── Users.js
    │   ├── entities.js
    │   ├── errors.js
    │   ├── index.js
    │   ├── log.js
    │   ├── package.json
    │   └── utils.js
    └── package.json

nodejs/package.json

{
  "dependencies": {
    "cappuccino": "file:cappuccino"
  }
}

nodejs/cappuccino/package.json

{
    "name": "cappuccino",
    "version": "1.0.0",
    "description": "local module",
    "main": "index.js",
    "private": true,
    "dependencies": {}
}

nodejs/cappuccino/index.js

const log = require('./log');
module.exports = {
    log: log,
    entities: require('./entities'),
    Orders: require('./Orders'),
    errors: require('./errors'),
    utils: require('./utils')
};

cappuccino(モジュール名)をローカルモジュールとして定義し、nodejs/package.json の dependencies で file:cappuccino としてファイル参照していることがポイントです。

user関数などでは共通モジュールを次のようにして参照できます。

const {  utils, entities, log } = require('cappuccino');

どのようにデプロイするか?

Lambda関数を、APIメソッド毎に作成し、共通処理をLayerにすれば、多くのメリットを享受できます。 デメリットというデメリットはほとんどありません、デプロイさえできれば・・・。

問題は、SAM、Serverlessフレームワーク、CDKといった仕組みでは、こういった柔軟なデプロイを行うことができません。 そもそも設計思想として、根底にCloudFormationがあり、リソース管理のフレームワークなので相性が悪いのです。 デプロイはデプロイに特化したツールを活用するというのが自分の考え方です。

夢を実現出来そうなデプロイツールはApexです。 実際、過去のAlexaプロジェクトや以前のバージョンでのカフェAPIもApexを利用していました。 しかし、アップデートが止まっており、Layerにも対応されていません。 自分で組むしか無さそうです。

AWS CLI/SDK でデプロイする

Apexでの限界を感じたため、最終的に自前でデプロイツールを作る事にしました。 デプロイコマンドなどはAWS CLIやAWS SDKで簡単に実現出来るので、シェルスクリプトやNode.jsなどで実装すれば問題無さそうです。 シェルスクリプトでの例外処理は辛いので、Node.jsでデプロイツールを作成して運用しています。

例えば、dev1環境で、共通処理レイヤー(cappuccino)とuser関数をデプロイするならば、次のように実行しています。

$ cap deploy dev1 cappuccino user
[Deployment]
ENV: dev1
AWS_PROFILE: cap-dev1
  # cleanup
  # Layer published      layer=cappuccino,  version=409
  # Function updated     function=user

数秒でデプロイされ、動作確認ができるため、開発スピードを阻害されません。

まとめ

今回は、API Gatewayのバックエンドとして、Lambda関数のベスト構成について解説しました。 Layerをうまく使うことで、軽量で高速な開発を行うことができるでしょう。

デプロイに関しては、またどこかのエントリーで解説したいと思います。