AWS Lambdaのデプロイパッケージをどの範囲で構築すべきか

サーバーレス開発を行う際にAWS Lambdaのデプロイパッケージをどの様な範囲で構築するべきなのか、Lambdaレイヤーはどう使えば良いのかについて私見をまとめました。
2020.11.16

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

おはようございます、加藤です。AWSで本格的なサーバーレス開発を行う際につまづいたり、悩むポイントとしてLambdaのデプロイパッケージをどのような範囲にすべきかということがあります。デプロイパッケージをどうすべきか私の考えをまとめてみました。
何回か出てくるディレクトリ図については、あくまでをイメージを掴んで貰うためのもので、この通りにディレクトリを構成し動くことを保証するものではありません。具体的なAWS CDKを使った構築方法は来月ぐらいにでも別途ブログにする予定です。

デプロイパッケージとは

デプロイパッケージは、関数のコードと依存関係を含むZIPアーカイブです。コンパイル言語(Go、Javaなど)の場合はコンパイル済み関数のコードを含む必要があります。非コンパイル言語かつ、デプロイパッケージの容量が3MB以下の場合はマネジメントコンソールでデプロイされた関数コードを確認できます。

非コンパイル言語の場合は個々のLambda関数に対してどの範囲をデプロイパッケージとするかを検討する必要があります。これについて紹介するのが本記事の主目的です。

ディレクトリ単位でデプロイパッケージを作成

Lambda関数に対してリポジトリのディレクトリを1:1で対応させるパターンです。この方法はイベントドリブン方式アーキテクチャを構築する場合に向いています。

たとえばS3バケットに動画をアップロードすると文字起こしされたテキストとサムネイルを取得でき、完了時にSlackに通知が飛ぶシステムを想定してみます。これをイベント・ドリブンで構築すると図のようになります。各Lambda関数はS3Putイベントをトリガーに実行され、S3に結果を出力します。

このような構成の場合は図のようなリポジトリ構成になります。各Lambda関数ごとに個別に依存パッケージおよびバイナリを含むように配置しています。デプロイパッケージのサイズを最小化できますが、Lambda関数毎に依存パッケージおよびバイナリの管理が必要になります。ただし、これはLambda関数同士が疎結合ということであって一概にデメリットというわけではありません。

ソースコード全体でデプロイパッケージを作成

すべてのLambda関数にリポジトリのソースコード全体を対応させるパターンです。この方法はリクエスト・リプライ方式アーキテクチャを構築する場合に向いています。

たとえばユーザーとユーザーが所属するサークルという2つのドメインオブジェクトが存在しこれらに対するCRUDを行うRESTful APIを想定してみます。これをリクエスト・リプライ方式かつAPI Gateway&Lambdaで構築すると図のようになります。

このような構成の場合は図のようなリポジトリ構成になります。API全体でデプロイパッケージとしては1つだけ作成しそれをすべてのLambda関数に同じデプロイパッケージを使用します。各Lambda関数の設定でAPIパス・メソッドに応じたハンドラー関数を指定することで適切な動作をさせます。API全体で依存パッケージおよびバイナリの管理を行えば良く、またソースコード内は自由に依存ができますがデプロイパッケージのサイズが最大化してしまいます。図のようにコントローラー、ドメインなどと層を切るアーキテクチャを採用している場合、依存関係を単一ディレクトリに閉じるのは現実的ではなく、この方法でデプロイパッケージを作成する必要があります。

バンドルしてデプロイパッケージを作成

この方法がとれるのは、JavaScript、TypeScriptの様に単一のJavaScriptファイルにまとめる文化がある言語またはコンパイル言語のみです、というかコンパイル言語は必然的にこの方式になります。

ハンドラー関数をエントリーポイントとしてソースコード内、モジュールへのすべての依存を解析し単一のファイルにバンドルし、それをデプロイする方式です。デプロイパッケージのサイズを限界まで小さくでき、依存パッケージの管理はソースコード全体で行えますが、コンパイル言語は当然として、非コンパイル言語であってもマネジメントコンソール上でデバッグを行いにくいという欠点があります。

また、AWS CDKはaws-lambda-nodejsモジュールというParcelを使いバンドルする仕組みを追加したaws-lambdaモジュールをラップしたモジュールがあり、簡単にTypeScriptでLambdaを作れる利点があります。

この方式は先に紹介した2つと比べてアプリケーションの連携方式に依存しないという特徴があります。

Lambdaレイヤーの活用

デプロイパッケージを語る上で外せない要素としてLambdaレイヤーがあります。1つのLambda関数に対してデプロイパッケージは1つしか使用できませんがLambdaレイヤーを使用することで1+5のデプロイパッケージを扱うことができます。一般的に依存パッケージおよびバイナリの更新頻度はソースコードと比べて低いです。なのでこれらをレイヤーとして切り離す事によって、2回目以降のデプロイ時に依存パッケージに変化がなければソースコードのみデプロイすれば良くなりデプロイの高速化が期待できます。

ディレクトリ単位でデプロイする場合に依存パッケージおよびバイナリだけはレイヤーにデプロイすることを前提として共通管理することで管理コストを削減する事もできます。ただし代償として各Lambda関数が同じ依存パッケージを使う際はそれが同じバージョンであることが強制されます。さらにデプロイパッケージと使用するレイヤーの容量が合算されたものがコールドスタートの速度へ影響を与えるので、他のLambda関数のみが依存するパッケージおよびバイナリを含むレイヤーを使用するとレイヤーを使用しなかった場合と比べてコールドスタート時の速度は遅くなります。

考察

リクエスト・リプライ方式のアーキテクチャならばソースコード全体でデプロイパッケージの作成を、イベントドリブン方式のアーキテクチャならばディレクトリ単位でデプロイパッケージの作成を行うのが良いです。

使用しているプログラミング言語のパッケージマネージャーが対応している(YarnのWorkspace機能など)ならばディレクトリ毎に依存パッケージを管理し、対応していないならば1つのLambdaレイヤーにすべての依存パッケージおよびバイナリをデプロイし、それをすべてのLambda関数で使用します。

よりパフォーマンスを向上させたい場合はGoやRustなどのコンパイル言語を使う、つまりバンドルしてデプロイパッケージを作成します。JavaScript、TypeScriptでバンドルするのはAWS CDKでaws-lambda-nodejsモジュールを使用してカジュアルにLambda関数を作成したい場合のみにすべきです、デバッグも行いにくく、かといってコンパイル言語と比べるとかなりのパフォーマンスの差があるからです。

あとがき

どういうときにリクエスト・リプライ方式なのかイベントドリブン方式なのかみたいな話までせっかくなら書きたかったですが、言語化できるほど経験値がなく、またモノリシックかマイクロサービスかみたいな話まで波及して収集がつかなくなるのでここで言及しないことにしました。

「こういうの作りたいけど、どっちが良い?」みたいな疑問があれば、ぜひお気軽にお問い合わせください。また意味がわからないことやツッコミどころがあればTwitter宛にメンション頂ければありがたいです。

デプロイパッケージの話は一度理解してしまえば、簡単なのですがあまり情報が無かったので自分の思考の整理とこれからサーバーレス開発に挑む方のためにまとめてみました。お役に立てれば幸いです。以上でした。