どうもこんにちは。
最近ずっとAmazon Bedrock AgentCoreやらLambda Durable FunctionsやらCDKを触っていたので、本来の領域であるRuby on Railsを忘れかけていたくらっちです。
といってもRuby on Rails単体での検証とかは他のRubyistの方々の方が詳しかったりするので、自分は結局AWSと絡めた記事になってしまいます。
さてさてということで、以前、Serverless Days 2026というイベントに参加させていただきました。
そこで、「LambdaWebAdapterで実現するWebアプリケーション」というタイトルのお話を聞いて、「これはRailsとサーバーレスの相性の悪さを補うことができる良い手法なのではないか?」と思ったので、色々検証をしてみようと思います。
Lambda Web Adapterとは?
通常のWebフレームワークで作られたHTTPベースのWebアプリケーションを、AWS Lambda上で動かせるようにするツールです。これはAWS公式のOSSツールとして公開されています。以下のソースコードを見ると、Rustで書かれていることがわかります。
Rustは処理速度が速くて、メモリ安全性の高い言語という認識です。C#やC++に近い記述方法だった印象...
CLI系ツールなどでもよく使われている印象です。いつか触ってみたいなぁとは思っています。
話を戻しましょう。
Lambda関数は本来、以下のようなハンドラを用意する必要があります。
def handler(event:, context:)
# event は JSON(API Gateway などの形式)
{ statusCode: 200, body: "..." }
end
RailsのようなHTTPサーバ前提のアプリはこの形に合わないので、そのまま使用することはできません。RailsのようなWebアプリケーションを構築するには、EC2を使ったり、ECSを使ったりするケースがほとんどかなと思います。しかし、「EC2,ECSで構築するほど規模が大きくないんだよなぁ...でもRailsで構築したいなぁ...」っていうケースは少なからずあるのではないかなと思います。
こんな時こそ、Lambda Web Adapterの出番です。
Lambda Web Adapterを使ったWebアプリケーションの処理の流れ
図を使いながら説明します。
図の作成はClaudeさんを使いました。予めご了承...
WebアプリケーションはRails想定しています。
1. ブラウザが関数URLにアクセスする
まず、人間がブラウザを用いて、関数URLにアクセスします。この関数URLはhttpsを想定しています。
2. リクエストがイベントJSONに変換される
Lambda関数は、HTTP通信を受けとることができないので、リクエスト内容がJSONに変換されてLambda関数のランタイムAPIへ送信されます。
3. Lambda Web Adapterがイベントを受け取る
LambdaのランタイムAPIがリクエストを受け取ったら、Lambda Web Adapterへ送信されます。
4. Lambda Web AdapterがJSONをHTTPリクエストに変換し直してRailsサーバーへ送信する
ここがまず大きなポイントです。フェーズ2で「LambdaはJSONしか受け取れないから...」とHTTPリクエストをJSONに変換しましたが、Railsアプリがリクエスト内容を理解するために、変換されたJSONをまたHTTP形式に変換し直すという処理が発生します。
5. Railsアプリケーションがいつも通りの処理を行う
HTTPリクエストを受けたら、通常の動作と同じようにRailsアプリケーションが処理をして、レスポンスを返します。(このレスポンスは、HTTPレスポンス形式です。ボディについてはJSON形式だったり、HTML形式であったり様々です。Railsアプリケーション側のレスポンスによります。)
6. Lambda Web AdapterがRailsからのレスポンスをJSON形式に変換する
ここでまたLambda Web Adapterの出番なんですが、Railsから受け取ったHTTPレスポンスをJSON形式に変換して、LambdaのランタイムAPIへ送信します。
7. 関数URLへレスポンスが返ってくる
LambdaのランタイムAPIが関数URLへレスポンスを返します。ここでLambdaの役目が終了します。Lambda Web Adapterは次のリクエストに備えて待機状態に入ります。
8. 結果がブラウザに届く
最後に、関数URLからブラウザに結果が表示されます。
ここまでが一連の流れです。
Lambda Web Adapterを理解する上で必要なポイント
Lambdaを意識して開発しなくて良い
前述したように、Railsアプリが解読可能な形式に変換する処理をLambda Web Adapterがやってくれているので、Railsを含むWebアプリケーションの構築において、「Lambdaで動かすための設計・実装をしなきゃ」という意識を持つ必要がありません。
普段のWebアプリケーション開発と同じ要領で開発をしていれば良いです。
初回リクエストの応答が遅い
通常のLambda関数と同様に、初回リクエストの時の応答が遅いです。この事象を「コールドスタート」と呼びますね。
コールドスタートのタイミングと理由
コールドスタートは以下のタイミングで起きます。
- 新しいLambdaが最初に実行されるとき
- 長時間使用されなかったLambdaが実行されるとき
その理由は、Lambda関数の起動時に以下の流れで起動処理が行われるからです。
- 実行環境(コンテナ)の準備
- ランタイムの初期化
- Lambda関数コードのロード
Lambda Web Adapterを用いたWebアプリケーションを構築する場合、1の実行環境の準備段階で様々な処理が行われています。以下でそれについて順番に解説します。
1. 実行環境の作成とLambda Web AdapterとRailsアプリケーションの起動
初回起動やスケールアウトのタイミングで、Lambdaは新しい環境が起動します。
このタイミングで、Lambda Web AdapterとRailsアプリケーションが起動します。
また後ほど説明はしますが、Dockerfileに以下のように記述することでLambda Web AdapterとRailsアプリケーションが起動します。
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 \
/lambda-adapter /opt/extensions/lambda-adapter
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]
上記の例ではpumaサーバを起動するようにしていますが、pumaじゃなくても大丈夫です。
後ほど詳しく説明しますので、焦らず...
2. Lambda Web Adapterがヘルスチェックを送り続けて起動を待つ
Railsサーバーが応答するまで、Lambda Web Adapterは10ms間隔でリクエストを叩き続けます。Railsアプリの起動が遅いほど、待つ時間が伸びます。初回起動を早くするためにはここの時間を早くする必要があります。
3. Railsアプリケーションがヘルスチェックに応答したら準備完了
Railsアプリケーションがヘルスチェックに対して100〜499のHTTPステータスを返したら、起動完了とみなします。
起動完了とみなすHTTPステータスの定義はAWS_LWA_READINESS_CHECK_HEALTHY_STATUSという環境変数でコントロールできます。
4. Lambda Web Adapterが次のリクエスト状態に備えて、初回起動完了
Lambda Web Adapterが/nextというパスを呼ぶことで、次のリクエスト状態に入ります。起動時間が10秒を超えそうであれば、AWS_LWA_ASYNC_INITをTrueにすることを検討する必要があります。
HTTP以外のイベントも受信可能
Lambda Web Adapterで動作しているWebアプリケーションは、SQSやEventBridgeなどからのイベントを受信することができます。SQSやEventBridgeからのイベントは、POST /eventsとして転送されます。これはRailsアプリケーションの場合、Controllerを通じて受け取ることが可能です。
SQSからイベントを受け取る例を以下に示します。
1. Lambda が SQS からメッセージを取り出す
イベントソースマッピングを設定しておくと、Lambda関数がSQSキューをポーリングしてメッセージを取りに行きます。
2. メッセージがイベントJSONとして届く
Lambda関数のランタイムAPIにイベントが届きます。
3. Lambda Web AdapterがHTTP以外のイベントだと判定
HTTP形式以外のイベントは、変換対象外として扱われ、スルーされます。
API Gateway・Function URL・ALB の形式でないイベントは、変換対象外として扱われ、スルーされます。
4. イベントJSONをそのままPOST /eventsで転送
中身は変換せず、受け取ったJSONをリクエストボディに入れて送ります。この時、規定パスはPOST /eventsです。
パスはAWS_LWA_PASS_THROUGH_PATHで変更することが可能です。Railsアプリ側で/eventsというパスを使いたい場合には、別のパスを指定する必要があります。
5. Railsがメッセージを処理してレスポンスを返す
リクエストを受け取ったら、Rails側はいつも通りの処理を行って、レスポンスを返します。
通常モードのRailsではCSRF保護に弾かれるので、skip_forgery_protectionのようなメソッドを使って外しておく必要があります。
class EventsController < ApplicationController
skip_forgery_protection # これをつける
def create
records = JSON.parse(request.body.read)["Records"]
# ...
head :ok
end
end
RailsアプリAPIモードでは、CSRF保護が標準でついていないので、意識する必要はありません。
6. 結果を返して完了
処理が完了すると、SQSキューは役目を終えて削除されます。
ただし、既定ではRailsアプリが500HTTPステータスを返しても「成功」扱いになってしまうので、AWS_LWA_ERROR_STATUS_CODESを使ってエラー扱いとするHTTPステータスを定義しておく必要があります。
導入方法
Lambda Web Adapterの導入方法は3つあります。
- コンテナイメージを使用する
- コンテナイメージ+SnapStartを使用する
- zipファイル+Layerを使用する
1. コンテナイメージを使用する
Dockerfileを使った方法です。(DockerじゃなくてもKubernetesでもできます(できるはずです))
ポイントは以下です。
- DockerfileにLambda Web Adapterのイメージを取得するための
FROMを書く - Lambda関数内にLambda Web Adapterのイメージを配置するための
COPYを書く
以下のイメージです。
# Dockerイメージの取得
FROM public.ecr.aws/awsguru/aws-lambda-adapter:1.1.0 AS lambda-adapter
FROM public.ecr.aws/lambda/ruby:3.4 AS runtime
# Lambda Web Adapterの配置場所を指定
COPY --from=lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter
# ...
ENV PORT="8080" AWS_LWA_PORT="8080" AWS_LWA_READINESS_CHECK_PATH="/up"
ENTRYPOINT ["/var/task/run.sh"] # 中身は exec bundle exec puma -C config/puma.rb
Lambda Web Adapterの配置場所を/opt/extensions/と指定するために、以下のコードを記載しています。
COPY --from=lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter
/opt/extensions/にLambda Web Adapterを置くことによって、Lambdaが拡張機能として読み込んで起動してくれます。
2. コンテナイメージ+SnapStartを使用する
これはRailsなどのWebアプリケーションの起動をLambda関数起動時ではなく、デプロイ時に行う方法です。デプロイ時にWebアプリケーションを起動して、スナップショットとして保持しておきます。Lambda関数が呼ばれた時は、スナップショットを使用して立ち上がります。
Dockerfileについては1と同じなんですが、SnapStartの設定を行う必要があります。SnapStartの設定自体は、CLIやCDKなどでデプロイする場合には、以下のようなオプションをつけてデプロイします。
snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS
ただし、注意点があります。
Lambda関数のバージョンを発行する必要がある
以下のように、エイリアスに関数URLをくっつけた形の関数URLを作ってあげる必要があります。
const addVersionAliasAndUrl = (idPrefix: string, fn: lambda.Function): void => {
const alias = new lambda.Alias(this, `${idPrefix}LiveAlias`, {
aliasName: 'live',
version: fn.currentVersion,
});
const fnUrl = alias.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
});
functionUrlOutputs[idPrefix] = fnUrl;
};
Webアプリケーション起動時に作ったものは、すべてスナップショットに入る
このスナップショットには、SecretsManagerやSSM Parameter Storeの値を含む全ての情報が書き込まれます。そのため、Secretsやパラメータのローテーションを行った場合、新しいバージョンとしてデプロイし直す必要があります。
DBの接続情報などもスナップショットに含まれます。
併用できない機能が存在する
SnapStartを有効にした場合、以下のような機能は使用することができません。
- プロビジョニングされた同時実行
- EFS
- 512MBを超えるエフェメラルストレージ
EFSとは、Amazon Elastic File Systemというファイルストレージサービスです。
料金がかかる
スナップショットのキャッシュと復元1回ごとに料金がかかってしまいます。バージョンが有効になっている間は、ずっとスナップショットのキャッシュに料金がかかってしまいます。また、短時間でバージョンを切り替えたとしても、スナップショットのキャッシュは最低でも3時間保持されるため、保持されている間も課金対象となってしまいます。
デプロイに時間がかかる
バージョンを発行するたびにスナップショットを作るため、その分デプロイに時間がかかります。
3. zipファイル+Layerを使用する
この方法は、自分でビルドしてzip化する必要があるので、手元での手間がかかります。また、前述した、SnapStartの設定ができません。
こちらにも注意点があります。
Lambda関数の環境変数にWebアプリケーションで使用する環境変数をすべて定義しておく必要がある
上記のように、Dockerfileを使えば、環境変数はDockerfile内で用意すれば良いのですが、Dockerfileを使わないため、Lambda関数に直接定義する必要があります。
また、この場合、環境変数は合計4KBまでしか設定することができないことに注意が必要です。
サイズに上限がある
zip化されたWebアプリケーションとLambdaレイヤーの合計サイズが250MBを超えるとデプロイできません。
また、zipファイルの容量にも上限があって、S3を経由しない場合は50MBまでしかデプロイできません。S3を経由してデプロイする場合には、zipファイルの上限を考える必要はありません。
デプロイ速度と応答速度を計測してみる
まぁ習うより慣れよです。やってみましょ。
これ以降、Lambda Web Adapterと打つのがめんどくさくて、LWAと記載してたりします。
計測に使用したリポジトリは以下です。
使用する環境/技術について
| 項目 | 値/説明 |
|---|---|
| リージョン | ap-northeast-1(東京) |
| アーキテクチャ | ARN64 |
| Ruby | 3.4.11(Lambda 公式ベースイメージ lambda/ruby:3.4、YJIT 有効でビルドされたもの) |
| Rails | 8.1.4 |
| pg / puma / bootsnap | 1.6.3 / 8.0.2 / 1.26.0 |
| Lambda Web Adapter | 1.1.0( https://github.com/aws/aws-lambda-web-adapter ) |
| DB | Aurora PostgreSQL 17.9、Serverless v2(最小 0 ACU / 最大 2 ACU、自動停止は 300 秒) |
| 入口 | Lambda Function URL(認証なし。検証用) |
| IaC | cdkd 0.291.17(TypeScript の CDK 定義をそのまま使う) |
最初、Railsのバージョンを8.1.3.1で作ろうと思ったんですが、json3.0と組み合わせた時にJSONパースで例外(400)になることを確認しました。この問題は8.1.4で修正されているようでしたので、8.1.4で作りました。
用意したRailsアプリケーションについて
通常モードとAPIモードの2種類で計測をしました。バックエンドの処理はどちらも同じです。
Railsアプリを動かすために使ったライブラリは以下です。
| gem | 用途・注意点 |
|---|---|
aws-sdk-secretsmanager |
初期化時のシークレット取得に使う。require: false にして、取得処理の中でだけ読み込む |
また、rails newを実行するタイミングで、Lambdaで使わないよなぁっていう機能(ライブラリ)は外しました。以下が外した機能です
- Kamal
- Thruster
- Solid Cache・Queue・Cable
- Action Cable
- Active Storage
- Action Mailbox
- Action Text etc...
Rails側の設定としてはこんな感じです。
| 設定 | 内容 | 理由 |
|---|---|---|
| 許可するホスト | Function URLのホスト名(*.lambda-url.ap-northeast-1.on.aws)を許可する |
許可しないと、Hostヘッダの検査でリクエストが拒否されてしまう... |
| ホスト検査とHTTPSリダイレクトの除外 |
/up と /_lwa/ 配下を対象外にする |
LWA の起動確認(/up)とSnapStartのフックは、Lambda内部から127.0.0.1にHTTPで届く。除外しないと拒否されるか、HTTPSにリダイレクトされるので、設定しておく必要がある... |
assume_ssl/force_ssl
|
有効のまま | これを有効にしておくことで、TLSはFunction URL側で完結します |
| キャッシュストア | メモリ上のストアにする | 既定のファイルストアはtmp/cacheに書き込む。Lambdaのアプリ領域は読み取り専用である |
| ジョブの実行方式 | プロセス内の非同期実行で対応 | Solid Queueを使わないため |
| ログ出力 | 標準出力 | CloudWatch Logsに送られる |
| DB 接続 | 接続先・ユーザー・パスワードを環境変数から読む。sslmode=verify-fullにし、RDSのCA証明書をイメージに同梱する |
Aurora側でTLSを必須にしているため |
| Puma | ワーカー0(1プロセス)、スレッド3。Lambda上では再起動用プラグインを読み込まない | LWAは1実行環境に1リクエストずつ送るので、複数プロセスは不要 |
| シークレット取得 | 初期化時にSecretsManagerのBatchGetSecretValueでDB認証情報とSECRET_KEY_BASEをまとめて取得し、環境変数に入れる(config/lambda_env.rb) |
1回のAPI呼び出しで済ませる。所要時間はSDKの読み込みを含めてコンテナで約0.2秒、zipで約0.5秒 |
インフラ構成
これは検証用の構成なので参考にしないでくださいね笑
以下の記事で紹介されていたSkillを使って構成図を作成してみました。非常に良い!!重宝してます!!
起動スクリプト
共通の起動スクリプトを用意して、以下を実行するようにしています。
-
HOMEとbundlerのユーザー領域を/tmp配下にする- Lambdaの実行ユーザーはrootではなく、ホームディレクトリに書き込めるとは限らないため
- bundlerの設定(
BUNDLE_DEPLOYMENT/BUNDLE_PATH/BUNDLE_WITHOUT)とRAILS_ENV・PORTの既定値を入れ、bundle exec pumaを実行する
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
export HOME="${HOME:-/tmp/home}"
export BUNDLE_USER_HOME="${BUNDLE_USER_HOME:-/tmp/bundle_user_home}"
mkdir -p "$HOME" "$BUNDLE_USER_HOME" 2>/dev/null || true
export BOOTSNAP_CACHE_DIR="${BOOTSNAP_CACHE_DIR:-/var/task/tmp/bootsnap-cache}"
export BOOTSNAP_READONLY="${BOOTSNAP_READONLY:-true}"
export BUNDLE_DEPLOYMENT="${BUNDLE_DEPLOYMENT:-true}"
export BUNDLE_PATH="${BUNDLE_PATH:-/var/task/vendor/bundle}"
export BUNDLE_WITHOUT="${BUNDLE_WITHOUT:-development:test}"
export RAILS_ENV="${RAILS_ENV:-production}"
export PORT="${PORT:-8080}"
exec bundle exec puma -C config/puma.rb
パッケージングの処理
Dockerfileを使用する方法
- ビルドも実行もLambda公式ベースイメージ
lambda/ruby:3.4で行う - LWAのバイナリを
/opt/extensions/に置く- ここに置くことによって、Lambdaが拡張機能として自動で起動する
- ビルド時の作業ディレクトリを、実行時と同じ
/var/taskにする- bootsnapのキャッシュは絶対パスをキーにするため
-
bootsnap precompileに加えて、アプリを一度起動してキャッシュを焼き込む-
bootsnap precompileはrequireの解決結果(load path cache)を作らないため
-
- 実行時はbootsnapを読み取り専用モードにする
-
BOOTSNAP_READONLY=trueを設定しておけばOK
-
zipでやる方法
- コンテナ版と同じベースイメージの中でビルドし、
/var/taskの中身をzipにする - ハンドラ名を
run.shにし、環境変数AWS_LAMBDA_EXEC_WRAPPER=/opt/bootstrapを設定する- LWA Layerがこのスクリプトを直接実行するようになる
- Dockerfileでいう
ENVにあたるものがないため、BUNDLE_*・PORT・AWS_LWA_*・BOOTSNAP_*・証明書パスなどを、すべてLambda関数の環境変数で渡す必要がある- bundlerの設定は、ビルド時に環境変数で渡しただけでは設定ファイルに残らないので注意
ここでちょいミスしたのが、 不要ファイルを消すときに rack-testgemまで消してしまうと起動に失敗しました。理由としては、actionpackが実行時にrack-testgemを読み込むためでした。
Lambda と LWA の設定
-
AWS_LWA_PORT=8080、AWS_LWA_READINESS_CHECK_PATH=/upに設定 - タイムアウトは30秒に設定
- マイグレーション関数は300秒に設定
- 実行ロールの権限
- 2つのシークレットに対して
secretsmanager:GetSecretValueを付与 - 合わせて、
secretsmanager:BatchGetSecretValueも付与- これはリソースを絞れないため
"*"を指定する
- これはリソースを絞れないため
- 2つのシークレットに対して
- Function URLには、Function URL経由の呼び出しに限った実行権限を付ける
SnapStartの設定について
- 使えるのはコンテナイメージを使ったLambda関数だけです
- zipのruby3.4マネージドランタイムはSnapStartに対応していない!残念
- LWA 1.1.0がSnapStartの復元の仕組みに対応しているため、イメージに特別なラベルをつける必要はない
- フックとして、環境変数
AWS_LWA_SNAPSTART_BEFORE_CHECKPOINT_PATHとAWS_LWA_SNAPSTART_AFTER_RESTORE_PATHにRails側のパスを指定します- LWA はそのパスに POST を送り、2xx の応答を期待する
- フックのルートは、SnapStart用の環境変数がある関数でだけ有効にした
- LWAは外部からフックのパスに来たリクエストを403で拒否する動きをしました
- 公開バージョンを作るたびにスナップショットが作られ、そのときにRailsの初期化が1回走る
- ログでは、この初期化に約9.4〜10.1秒かかっていることを確認しました
- これによってデプロイに少し時間がかかりそうです
コールドスタートの計測結果
計測方法
計測は「セル」という単位で行いました。1つのセルでは、メモリ量と設定を1つの組み合わせに固定し、対象の関数をひととおり計測します。1セルの手順は次のとおりです。
- 関数の環境変数を1つ変えて再デプロイします。Lambdaに新しいバージョンができ、Function URLがつながるエイリアスもそのバージョンに切り替わるので、すべての実行環境がコールドな状態から始まります。メモリやチューニングの設定も、このデプロイで切り替えます。
- マイグレーション用の関数で
db:migrate:statusを実行し、自動停止しているDBを起こします。DBの復帰待ちを計測に混ぜないためで、DBの停止時の計測(後述の「DBが自動停止しているとき」)だけはこの手順を飛ばします。 - 計測する関数を1本ずつ順番に選び、その関数に
GET /postsを10本同時に送ります。webにはHTMLを、apiにはJSONを要求します。 - PCで、リクエストを送ってから応答を読み終わるまでの時間を測ります。これを応答時間と呼び、ネットワークの往復とTLSの接続を含みます。
- Lambdaの実行ログを取得し、リクエストIDで各リクエストと突き合わせます。初期化時間、SnapStartの復元時間、リクエストの処理時間をログから取り、後述の「1024MBでの内訳」に使うアプリの起動時間のログも同じ実行環境のログから取ります。
- ログに初期化時間か復元時間があること、つまりコールドスタートだったことを確かめて、1リクエスト1行で記録します。
計測したセルは次のとおりです。
| 計測 | 対象の関数と設定 | セル数 | リクエスト数 |
|---|---|---|---|
| メモリ別 | 6関数、512MB・1024MB・1769MB・3008MBを各2回 | 8 | 480 |
| チューニング | コンテナとzipの4関数、1024MB、設定を1つずつ変える | 4 | 160 |
| ASYNC_INITの比較 | コンテナとzipの4関数、1024MB | 1 | 40 |
| DBの停止時(後述) | apiコンテナ、1024MB、別の手順で2つの状態 | なし | 6 |
計測したすべてでHTTP 200ステータスで、どのリクエストのログにも初期化時間か復元時間がありました。つまり全件がコールドスタートです。
メモリ別の初期化時間と応答時間
というわけで実際に計測した結果を示します。
初期化時間(SnapStartは復元時間)です。単位は秒で、各マスは20本の中央値です。
| 関数 | 512MB | 1024MB | 1769MB | 3008MB |
|---|---|---|---|---|
| webコンテナ | 3.03 | 2.89 | 3.02 | 2.86 |
| apiコンテナ | 2.87 | 2.48 | 2.92 | 2.79 |
| web SnapStart(復元) | 0.50 | 0.48 | 0.48 | 0.49 |
| api SnapStart(復元) | 0.47 | 0.45 | 0.49 | 0.46 |
| web zip | 6.76 | 6.70 | 6.55 | 7.00 |
| api zip | 6.74 | 6.64 | 6.74 | 6.68 |
Rails通常モードとAPIモードだと少しだけAPIモードの方が応答早めですねぇ
PCから見た応答時間です。単位は秒で、各マスは20本の中央値です。
| 関数 | 512MB | 1024MB | 1769MB | 3008MB |
|---|---|---|---|---|
| webコンテナ | 4.06 | 3.57 | 3.63 | 3.38 |
| apiコンテナ | 3.67 | 3.04 | 3.37 | 3.28 |
| web SnapStart | 1.61 | 1.15 | 1.04 | 1.11 |
| api SnapStart | 1.24 | 0.99 | 0.99 | 0.94 |
| web zip | 8.32 | 7.70 | 7.38 | 7.96 |
| api zip | 8.00 | 7.50 | 7.45 | 7.48 |
メモリの影響
初期化時間は、メモリを変えてもほぼ一定でした。同じ設定のセル同士でも中央値は最大約0.6秒ぶれており、メモリによる差はその範囲に収まります。初期化フェーズでは、割り当てメモリにかかわらず十分なCPUが与えられているのかもしれません。
初期化直後の最初のリクエストの処理時間は、メモリを増やすほど短くなりました。単位はミリ秒で、値は中央値です。
| 関数 | 512MB | 1024MB | 1769MB | 3008MB |
|---|---|---|---|---|
| webコンテナ | 735 | 366 | 269 | 261 |
| apiコンテナ | 507 | 258 | 208 | 202 |
| web SnapStart | 875 | 497 | 380 | 352 |
| api SnapStart | 573 | 315 | 271 | 245 |
| web zip | 1028 | 499 | 329 | 334 |
| api zip | 693 | 349 | 247 | 235 |
ここでもRails通常モードとAPIモードだと少しだけAPIモードの方が良い結果が出ていそうですねぇ
応答時間に差が出るのは主に512MBと1024MBの間で、1769MB以上ではほとんど変わりません。
1024MBでの内訳
値は中央値です。「Railsの初期化」は、Railsの読み込みを始めてから初期化が終わるまでの時間で、bundlerによるgemの準備とシークレットの取得を含みます。PumaとLWAの起動確認は含みません。
| 関数 | Init/復元 | Railsの初期化 | うちSDKの読み込み | うちシークレット取得 | 最初のリクエスト処理 | 最大メモリ使用量 |
|---|---|---|---|---|---|---|
| webコンテナ | 2.89s | 2.23s | 0.08s | 0.15s | 0.37s | 211MB |
| apiコンテナ | 2.48s | 1.86s | 0.06s | 0.10s | 0.26s | 208MB |
| web SnapStart | 復元0.48s | スナップショット作成時 | なし | なし | 0.50s | 213MB |
| api SnapStart | 復元0.45s | スナップショット作成時 | なし | なし | 0.32s | 209MB |
| web zip | 6.70s | 5.95s | 0.25s | 0.28s | 0.50s | 239MB |
| api zip | 6.64s | 5.90s | 0.28s | 0.28s | 0.35s | 237MB |
SnapStartで課金対象になる復元時間は18〜21msで、復元時間そのもの(約450ms)よりずっと短くなりました。
zip版は、Railsの初期化にコンテナ版の約3倍、SDKの読み込みに約3〜4倍の時間がかかっています。ファイルの読み込み全般が遅いか、bootsnapのキャッシュが効いていないと考えられます。bootsnapについては以降の「チューニングの効果」と「zip版でbootsnapが逆効果になる原因」で詳しく扱います。
チューニングの効果(1024MB、コンテナとzip)
4つの設定を1つずつ変えて、初期化時間を比べました。bootsnapはRailsの起動を速くするキャッシュ、eager_loadは起動時に全クラスを読み込むRailsの設定、YJITはRubyのJITコンパイラです。ASYNC_INITは、アプリの起動が長引いたときにLWAが初期化フェーズを先に終わらせる設定です。
単位は秒で、値は中央値です。baselineは20本、ほかは各10本です。
| 設定 | webコンテナ | apiコンテナ | web zip | api zip |
|---|---|---|---|---|
| baseline | 2.89 | 2.48 | 6.70 | 6.64 |
| bootsnapを無効 | 6.68 | 6.06 | 4.71 | 3.78 |
| eager_loadを無効 | 1.85 | 1.76 | 5.20 | 4.59 |
| YJITを無効 | 2.95 | 2.37 | 7.33 | 6.53 |
| ASYNC_INITをfalse | 2.54 | 2.73 | 7.22 | 6.89 |
bootsnapを無効にすると、コンテナ版では初期化が約3.6〜3.8秒遅くなりました。キャッシュが大きく効いています。zip版では逆に2.0〜2.9秒速くなっており、いまのzip版のbootsnapは何もしないより遅くなる方向に働いています。
bootsnapなし同士で比べると、zip版(3.8〜4.7秒)のほうがコンテナ版(6.1〜6.7秒)より速くなります。zip版でキャッシュが正しく効けば、SnapStartを使わない構成の中でzip版が最も速くなるかもしれません(推測)。
eager_loadを無効にすると、Railsの初期化はコンテナで2.2秒から0.9秒に縮みました。クラスの読み込みは最初のリクエストに回りますが、応答時間で見てもコンテナで0.6〜0.9秒(3.57秒から2.63秒、3.04秒から2.42秒)、zipで1.1〜1.8秒短くなっています。ただし計測したのは GET /posts の1種類だけなので、初回のページ描画が重いアプリでは効果が小さくなる可能性があります(推測)。
YJITとASYNC_INITは、どちらも差がばらつきの範囲に収まりました。ASYNC_INITは初期化が9.8秒を超えたときだけ働く仕組みで、今回の初期化は最大でも約7.6秒だったので、効かないのは想定どおりです。
DBが自動停止しているとき(apiコンテナ、1024MB、3本同時)
ここまでの計測とは別に、次の手順で測りました。
- Lambdaがコールドのケースでは、環境変数を変えて再デプロイし、実行環境をコールドにします。ウォームのケースでは、先に
GET /postsを3本送り、実行環境とDBを温めておきます。 - DBが自動停止するまで待ちます。停止したことは、監視メトリクスの
ServerlessDatabaseCapacityが0になったことで確かめます。ウォームのケースでは、待つ間も60秒ごとに/up(DBに触らないヘルスチェック)を送り、実行環境を残しておきます。 -
GET /postsを3本同時に送り、「計測方法」と同じやり方で時間を取ります。
| 状態 | Init | リクエスト処理 | 応答時間 |
|---|---|---|---|
| Lambdaがコールド、DBが停止中 | 2.2〜3.0s | 13.0〜13.9s | 16.5s |
| Lambdaがウォーム、DBが停止中 | なし | 14.7s | 14.8s |
Lambdaの初期化はDBに接続しないので、DBが停止していても初期化時間は変わりません。最初のリクエストがDBに接続するところで、DBの復帰を約13〜15秒待ちます。同時に送った3本はすべて同じ時刻に応答しており、3本ともDBの復帰を待っていました。
関数のタイムアウト(30秒)には収まりました。zip版なら初期化7秒と復帰15秒で約22秒になります(見積り。実測していません)。ウォームのケースでは、DBが停止するまでの約7分間、3つの実行環境はすべてウォームのまま残りました。
確認できていないこと・残課題
zip版でbootsnapが逆効果になる原因
候補は次の2つで、どちらも実機では確かめていません。
候補Aは、マネージドランタイムのRubyと、ビルドに使ったコンテナイメージのRubyが同一ビルドではないことです。bootsnapはRubyのバージョン文字列(ビルド情報を含む)をキャッシュのキーに使うので、少しでも違うとキャッシュ全体が無効になり、ロードパスの索引も毎回作り直しになります。
候補Bは、Lambdaがzipを展開するときに、ファイルの更新時刻を2秒単位に丸めることです。bootsnapは更新時刻が1秒でもずれるとキャッシュを使いません。今回のzipは約8割のファイルが奇数秒の更新時刻を持っていました。zip自体は正確な時刻(拡張タイムスタンプ)も持っていますが、Lambdaがそれを使うかはわかりません。
切り分けるには、zipの関数に環境変数 BOOTSNAP_LOG=1 と BOOTSNAP_REVALIDATE=1 を付けて1回起動します。bootsnapがキャッシュを使えなかったファイルをログに出すので、ほとんどが「miss」なら候補A、「revalidated」(中身の比較でキャッシュを使えた)なら候補Bです。アプリのコードは変えずに済みます。
初期化が9.8秒を超えた場合のASYNC_INITの挙動
初回デプロイ直後のapiコンテナで、初期化が9.84秒になったことが1回ありました。ASYNC_INITの打ち切り(9.8秒)が働いた可能性があります(推測)。原因はイメージの初回取得と考えていますが、確かめていません。
そのほかの未確認事項
-
idle_session_timeoutを設定しない場合にAuroraが自動停止しないことは、確かめていません。AWSのドキュメントの記載に基づいて設定しました。 - マイグレーションは実行されますが、応答に含めるはずの出力が空になります。
- SnapStartのキャッシュと復元の料金や、Auroraの復帰を含めた費用は評価していません。
- 計測したのは一覧画面(
GET /posts)だけで、重い画面や書き込み系の初回リクエストは計測していません。
本番に持ち込む場合に見直すべき点
今回は検証のため、DBを公開してポート5432をどこからでも受け付け、Function URLには認証を付けず、DBのストレージも暗号化していません。本番では、VPC内への配置、LambdaのVPC接続(またはRDS ProxyやData API)、認証の追加などを検討する必要があります。
まとめ
起動に時間がどうしてもかかってしまうので、気になる場合はSnapStart有効にしておいた方が良いですね。めっちゃ早く起動するようになります。
あと、Rails通常モードとAPIモードだと、若干APIモードの方が起動が速いですね。フロントエンドを含まない分、そのあたりは予想通りでした。
以上ー!















