こんにちは~ 浮田です。
先日、外部の勉強会でStep FunctionsのJSONataについて登壇したんですが、質疑応答で「JSONataに置き換えると処理速くなるんですか?」と聞かれて、正直に言うと曖昧に濁して終わりました。
「Lambda分の処理は速くなると思いますけど……」みたいな、自分でも歯切れが悪いなと思う回答をした記憶があります。
感覚としては「速くなる」と思ってたんですが、「どれくらい」を聞かれた瞬間、何も答えられないことに気づいたんですよね。
これ、悔しかったので実際に測ってみました。
コーヒー注文処理を「Lambda 4本のチェーン」と「JSONata の Pass ステート1つ」で実装し、両者が同一出力を返すことをテストで保証した上で、東京リージョンで実測しました。
|
指標(p50) |
Lambda版(Task×4) |
JSONata版(Pass×1) |
差 |
|
単発・ウォーム(Standard) |
424 ms |
54 ms |
7.9倍 |
|
単発・コールド(Standard) |
1,425 ms |
46 ms |
31倍 |
|
100件バッチ(Map・Standard) |
4,625 ms |
828 ms |
5.6倍 |
|
単発(Express) |
64 ms |
1 ms |
64倍 |
|
状態遷移数/実行(単発) |
4 |
1 |
4倍 |
|
状態遷移数/実行(100件) |
401 |
101 |
4倍 |
|
Lambda 実行料金 |
発生 |
0 |
— |
|
コールドスタート |
あり |
なし |
— |
しかも Lambda 版は メモリ 1769MB(1vCPU 相当)・外部依存なし・SDK 初期化なしという最速条件で測っています。それでもこの差です。
Step Functions のワークフローには、外部 I/O を持たない純粋なデータ変換だけの Lambda が紛れ込みがちです。
フィールドを整形する、金額を集計する、表示用文字列を組み立てる——数行の JavaScript のためだけに、関数を1本デプロイし、コールドスタートを抱え、同時実行数を消費し、invoke のたびに数十〜数百 ms を払う。
2024年11月に Step Functions が JSONata と変数をサポートして、この種の Lambda はステートマシン定義の中に畳み込めるようになりました。
「畳み込めるのは知ってる。で、実際どれくらい違うの?」この記事はその実測です。
■ 題材:コーヒー注文処理
題材は AWS 公式サーバーレスデモ Serverlesspresso の世界観を借りました(実装は借りていません。公式アプリは人間の待ち時間が支配的で、変換処理のベンチマークにならないため)。
注文ペイロードはこれ:
処理は典型的な「データ変換Lambdaの仕事」4段です。
|
段 |
処理 |
|
正規化 |
drink/size を小文字化、shots 未指定を 1、cancelled 未指定を false に補完 |
|
価格計算 |
キャンセル除外 → 単価算出(サイズ・追加ショット・シロップ)→ 小計 |
|
割引・税 |
クーポン 10% 割引 → 消費税 10%(切り捨て) |
|
チケット整形 |
バリスタ画面用文字列、JST 表示、待ち時間見積もり |
価格計算の関数だけ載せます
外部依存なし・SDK 初期化なし。ステートマシンはこの4本を Task で直列に呼びます。
同じ処理が Pass ステート 1つになります。
Output に載せている JSONata 式の全文(実際の ASL では1行に潰しています):
$filter / $map / $sum / $lookup / $exists / 三項分岐 / $fromMillis / 文字列連結——グルー Lambda がやっていた仕事は、JSONata の語彙でひと通り書けます。
ベンチマークで一番大事なのは「比較が成立していること」なので、先にそこを固めました。
1. 両者は同一の出力を返す。
これは3層で保証しています。
2. Lambda 側に最速条件を与える。
メモリ 1769MB(1vCPU 相当)、arm64、外部依存ゼロ、SDK 初期化ゼロ、ハンドラは数行の同期処理。
3. 測定条件を揃える。
同一リージョン(ap-northeast-1)・同一時間帯に A/B を交互に実行。ウォームアップ後に計測し、コールドは Lambda の設定を更新して毎回強制的にコールド化。時間はクライアント時計ではなく DescribeExecution のサーバ側 startDate/stopDate から算出。N=50(Map系は20、コールドは5〜10)、p50/p95 で報告。
4. 別日に全計測を再実行して再現を確認。
5日後に同じスタックへ同じ計測を流したところ、すべての p50 が ±7% 以内で一致し、倍率(ウォーム約8倍・コールド約30倍・Map 約5.6倍)と遷移数(4:1、401:101)は完全に再現しました。本文の数字は初回計測のものです。
正直、ここまでやるかどうか少し迷ったんですが、「1回測った数字」と「再現した数字」は説得力が全然違うので、やってよかったです。
なお、いくつか注意点も明記しておきます: Express の「1 ms」は API タイムスタンプの分解能(ミリ秒)の下限値であり実質サブミリ秒であること、Map の倍率は並列度(今回は10)に依存すること(遷移数の 4:1 は並列度に依存しません)。
ウォームで 424 ms → 54 ms(7.9倍)。1回の Lambda invoke あたり約90 ms のオーバーヘッド(遷移+invoke+ランタイム往復)が4回積み重なった分が、まるごと消えます。
コールドは 1,425 ms → 46 ms(31倍)。JSONata 版にはコールドスタートという概念自体がありません。ちなみに JSONata 版のコールド列(46 ms)はウォームと同じ値です。
イベント履歴を見ると面白いことが分かります。JSONata 版では PassStateEntered と PassStateExited のタイムスタンプが同一(ミリ秒単位で差が出ない)。つまり JSONata の評価自体は 1 ms 未満で、54 ms のほとんどは Standard ワークフローの実行開始/終了のオーバーヘッドです。後述の Express 版で p50 が 1 ms だった実測とも辻褄が合います。
単発の数十 ms 差は「誤差では?」と言われがちですが、Map で100件処理すると 4.6秒 → 0.83秒 と、誰の目にも明らかな差になります。
Express だと JSONata 版は p50 で 1 ms。API タイムスタンプの粒度がミリ秒なので、これは計測分解能の下限に張り付いた値、つまり実質サブミリ秒です(Standard 版のイベント履歴で PassStateEntered と PassStateExited が同一タイムスタンプだったことと整合します)。
Lambda 版は 64 ms。ただし Express の課金は 100 ms 単位の切り上げなので、SFN 側の請求はどちらも最低課金の 100 ms でした。差が出るのは Lambda の実行料金と、応答レイテンシそのものです。
Standard の課金単位は状態遷移数です。
単発で 4 対 1、Map 100件で 401 対 101。
実行履歴のイベント数で見ると、C(Lambda版)は 2,206 イベント、D(JSONata版)は 406 イベント。課金対象の遷移数だけでなく、実行履歴そのものも約5倍膨らみます(Standard には実行あたり 25,000 イベントの上限があるので、Lambda チェーン × 大きめの Map はこの上限にも早く近づきます)。
100万注文を単発実行で処理した場合の試算(ap-northeast-1、執筆時点の単価):
|
|
Lambda版 |
JSONata版 |
|
状態遷移 |
400万 × $0.025/1000 = $100 |
100万 × $0.025/1000 = $25 |
|
Lambda リクエスト |
400万 × $0.20/100万 ≈ $0.8 |
$0 |
|
Lambda 実行時間 |
≈ $1 |
$0 |
|
合計 |
≈ $102 |
$25 |
意外なことに、支配項は Lambda 料金ではなく遷移数です。グルー Lambda は「Lambda 代がもったいない」のではなく、「ステートを増やすこと自体が高い」のです。
地味に一番の発見でした。「JSONataにするとLambda代が浮く」くらいの認識で測り始めたんですが、実際はLambda代なんてほぼ誤差で、遷移数という別の軸が支配していた。予想を外したベンチマークほど書く価値があるな、と思いました。
要ります。この結果は「純粋なデータ変換に Lambda を使うのをやめよう」であって、Lambda 不要論ではありません。
今回の実測が示しているのは、単なる高速化テクニックではなく Step Functions の進化の方向性だと思います。 Lambda が不要になるのではなく、Lambda を「本当に計算が必要な場所」だけに置ける時代になった。
「とりあえずグルー Lambda」がデフォルトだった時代から、「まず JSONata で書けないか?」を先に考える時代へ。
デフォルトが静かに逆転しつつある、というのが実測を終えての実感です。
実装中に踏んだ罠を4つ置いておきます。
1. Assign した変数は同一ステートの Output で使えない。
Assign と Output は並行に評価されるため、「Assign で $subtotal を作って Output で参照」は動きません。Pass 1つに収めたければ、Output 全体を1つの JSONata 式にして、式の内部で変数束縛($x := ...)します。
2. 空配列で $map/$sum が undefined になる。
全アイテムがキャンセルされたケースで、$sum($map([], ...)) の結果が undefined になり、出力からキーごと消えました(SFN では States.QueryEvaluationError の原因にもなります)。$count($x) = 0 ? 0 : $sum(...) のガードが必要です。
3. jsonata npm パッケージの出力は null-prototype オブジェクト。
ローカルの等価性テストで assert.deepStrictEqual が「値は同じなのに」落ちます。JSON ラウンドトリップ(JSON.parse(JSON.stringify(x)))してから比較します。
4. TestState API にはステート単位の QueryLanguage 指定が要る。
ステートマシン全体では "QueryLanguage": "JSONata" をトップレベルに書きますが、TestState に単一ステートを渡すときはそのステート自身に QueryLanguage を付けないと JSONPath として解釈されてエラーになります。
一番の収穫は「コストを大きく左右するのは遷移数」という発見でした。Lambda代がもったいないという当初の予想は外れて、実際はステート数そのものがコストを決めていた。感覚で「速くなるはず」と言うのと、数字で「7.9倍です」と言えるのとでは、説得力がまるで違います。
これで、次に同じ質問をされたら数字で答えられます。「7.9倍、コールドなら31倍です」と。
次は、SDK統合でLambdaを削れるパターンも同じやり方で実測してみようと思っています。DynamoDBへの書き込みをLambda経由でやるのと直接SDK統合でやるのと、どれくらい差が出るのか気になっているので。
※ この記事の数値は執筆時点(2026年7月)の Step Functions / Lambda の料金・挙動に基づきます。今後のアップデートで変わる可能性があります。