SSE 配信用 AWS Lambda で発生した意図しない課金の原因と対策

AI チャットの応答生成やバッチ処理など、Web アプリの裏側で数十秒から数分かかる処理があります。 その間、画面に何も表示されないと、利用者には処理が進んでいるのか止まっているのか分かりません。
そこで社内 AI システムの検証環境では、処理の進捗を画面へ順次表示するため、AWS Lambda のレスポンスストリーミングと Server-Sent Events(SSE)を組み合わせていました。
この検証中、進捗を配信する Lambda がハンドラ末尾へ到達したあとも終了せず、設定上限の 900 秒まで実行され続ける事象が発生しました。 さらに、クライアントからの再接続が重なり、Lambda のコストが急増しました。
本ブログでは、当時のログから確認できたこと、すぐに行った暫定対応、コードと監視の見直しを紹介します。
目次
検証環境の構成
社内システムの検証環境にて、時間のかかる処理の進捗をブラウザへ表示する仕組みを検証していました。
構成は次のとおりです。
※実際の検証環境では、ブラウザと Lambda の間に Amazon CloudFront などの中間リソースがあり、認証には Amazon Cognito を使用していましたが、本ブログの趣旨から外れるため省略しています。

処理本体の Lambda は、処理の節目ごとに進捗イベントを DynamoDB へ書き込みます。 中継 Lambda は DynamoDB を確認し、新しいイベントをブラウザへ配信します。
DynamoDB を間に置いたのは、画面を再読み込みしたり接続が切れたりしても、保存済みのイベントを再取得できるようにするためです。
ブラウザへの配信には SSE を選びました。 今回はサーバーからブラウザへの一方向通知だけが必要で、ブラウザからの操作は通常の API で受け付ける構成だったためです。
中継 Lambda は Lambda Function URL 経由で公開し、レスポンスストリーミングを使用していました。 接続を長く保つため、関数のタイムアウトは Lambda の上限である 900 秒(15 分)に設定していました。
SSE を使うと Lambda が動き続ける理由
通常の API では、ブラウザがリクエストを送り、サーバーが 1 回レスポンスを返すと通信が終わります。

SSE では、1 回の HTTP 接続を開いたままにして、サーバーから途中経過を何度も返します。

送られる内容は、次のようなテキストです。
data: {"message":"変換中です"}
「data:」の行に通知内容を書き、そのあとに空行を入れると、ブラウザはそこまでを 1 件のイベントとして受け取ります。 SSE は特別なメッセージサービスではなく、HTTP レスポンスを閉じずに、決まった形式のテキストを少しずつ返す仕組みです。
Lambda で SSE を配信する場合、接続を開いている間は中継 Lambda の呼び出しも実行中のままです。 イベントを待っている時間も実行時間に含まれ、課金対象になります。
AWS の公式ドキュメントにも、クライアントとの接続が切れてもストリーミング応答は自動的に停止せず、関数の実行時間全体が課金対象になると記載されています。
ストリーミングレスポンスにはコストが発生し、呼び出しているクライアントの接続が切断された際、ストリーミングレスポンスは中断または停止されることはありません。お客様は関数の全機能を利用した期間に対して課金されるため、関数のタイムアウトを長く設定する場合はご注意ください。
引用元: Lambda 関数のレスポンスストリーミング
想定外コストの発覚
きっかけは、検証環境で Lambda のコストが急増したことでした。 発覚時点の Cost Explorer では 1 日で約 27 ドルまで増えており、その後の利用ペースを 1 か月に換算すると約 800 ドルに達する見込みでした。
コストの内訳を確認すると、増加分のほぼすべてを進捗配信用の中継 Lambda が占めていました。 この関数の設定は、メモリ 256 MB、タイムアウト 900 秒でした。
Amazon CloudWatch Logs の REPORT 行では、中継 Lambda の呼び出しが 900 秒でタイムアウトしていました。
REPORT RequestId: **** Duration: 900000.00 ms Billed Duration: 900000 ms Memory Size: 256 MB Status: timeout
一方、同じ RequestId のアプリケーションログには、処理を終えてハンドラを抜けた記録が残っています。
handler returning elapsed=581ms
この「約 0.6 秒」は、数十秒から数分かかる処理本体の所要時間ではありません。 進捗を中継する Lambda が、DynamoDB の確認などを行ってハンドラ末尾へ到達するまでの時間です。
つまり、ログ上では中継処理が約 0.6 秒で末尾まで進んでいる一方、Lambda の呼び出し自体は完了せず、900 秒後にタイムアウトしていました。
さらに、ブラウザ側は接続断を検知すると約 2 秒後に再接続する作りでした。 古い Lambda 呼び出しが残ったまま新しい呼び出しが始まるため、同時に実行される中継 Lambda が増え続け、コストの増加を早めていました。
ログで確認できたことと原因候補
ログから確実に確認できた事実は、次の 2 点です。
- 中継 Lambda のコードは約 0.6 秒でハンドラ末尾まで到達していた
- Lambda の呼び出しは完了せず、900 秒後にタイムアウトしていた
コードを確認すると、終了を妨げる可能性のある箇所が見つかりました。
- キープアライブや DynamoDB 確認用のタイマーが、処理経路によっては解除されない
- 一部の終了経路で、レスポンスストリームの終了処理が確実に呼ばれない
実際のコードそのものではありませんが、問題の構造を単純化すると次の形です。
export const handler = awslambda.streamifyResponse(
async (event, responseStream, context) => {
const keepalive = setInterval(() => {
responseStream.write(": keepalive\n\n");
}, 5000);
while (配信を続ける条件) {
// DynamoDBを確認し、新しいイベントを書き込む
if (タスクが完了した) {
return; // ← この経路では下の後始末を通らない
}
}
clearInterval(keepalive);
responseStream.end();
},
);
途中で return する経路があると、clearInterval() にも responseStream.end() にも到達しません。 タイマーが残ったまま、ストリームも閉じられずにハンドラを抜けることになります。
調査時のログだけでは、どちらが直接の原因だったかまでは切り分けられませんでした。 そのため、「タイマーが原因だった」「ストリームだけが原因だった」とは断定せず、両方の終了処理を見直しました。
AWS の公式ドキュメントでは、レスポンスストリーミングを使用する場合、ハンドラが戻る前にストリームを正常に終了させる必要があると説明されています。
参考:レスポンスストリーミング対応 Lambda 関数の記述
暫定対応
コードを修正してリリースするまでの間も課金は続きます。 そこで最初に、中継 Lambda のタイムアウトを 900 秒から 60 秒へ短縮しました。
aws lambda update-function-configuration \
--function-name <中継Lambdaの関数名> \
--timeout 60
この変更を行うと、正常に配信を続けている接続も 60 秒で切れます。 当時のクライアントには、切断後に DynamoDB 上の続きから取得する処理があったため、この影響は許容できると判断しました。
今回の異常では、終了しない古い呼び出しを残したまま、約 2 秒ごとに新しい呼び出しが始まっていました。 タイムアウトを短くすることで、重複した呼び出しが残る時間を 900 秒から 60 秒へ制限し、同時実行数とコストの増加速度を抑えました。
これは根本対応ではありません。 接続を短くしても、切断直後に再接続し続ければ、通常は合計接続時間を減らせないためです。 今回は、異常な呼び出しが 15 分間重なり続ける状態を一時的に緩和する目的で行いました。
根本対応
コード側を見直し、修正およびデプロイを行いました。
1. タイマーを必ず解除する
中継 Lambda 自身が作成したキープアライブ用タイマーや DynamoDB 確認用タイマーを把握し、正常終了、エラー、途中終了のどの経路でも解除されるようにしました。
ここで対象にするのは、この呼び出しのために作成したタイマーです。 AWS SDK が再利用する接続などを一律に閉じる対応ではありません。
2. すべての終了経路でレスポンスストリームを閉じる
正常終了時だけでなく、エラーや途中終了を含むすべての経路で、レスポンスストリームの終了処理が 1 回だけ呼ばれるように整理しました。
ストリームの終了方法について、AWS の公式ドキュメントには次の記載があります。
ハンドラーが戻る前にストリームが適切に終了することを確認してください。pipeline() メソッドはこれを自動的に処理します。他のユースケースでは、responseStream.end() メソッドを呼び出してストリームを適切に終了させます。
引用元: レスポンスストリーミング対応 Lambda 関数の記述
この方針を、原因候補の章で示した構造に対応させると、骨格は次のようになります。
export const handler = awslambda.streamifyResponse(
async (event, responseStream, context) => {
const keepalive = setInterval(() => {
responseStream.write(": keepalive\n\n");
}, 5000);
try {
// 進捗イベントの配信処理(responseStream.write を繰り返す)
// タスク完了時も return せず、ここを抜けて finally に進む
} catch (err) {
console.error("配信中にエラーが発生", err);
} finally {
clearInterval(keepalive); // 前項のタイマー解除も
responseStream.end(); // ストリームのクローズも、どの経路でも必ず通る
}
},
);
終了処理の前後にはログを追加し、アプリケーション上の処理終了と Lambda 呼び出しの終了を区別して確認できるようにしました。
実際の実装では、この骨格にタイマーの解除や完了イベントの送信が加わります。 骨格どおりに書けば終了が保証されるわけではないため、修正後は Lambda 上の REPORT 行で、呼び出しがタイムアウトせずに完了しているかまで確認します。
3. クライアント側の再接続の見直し
サーバーはタスクが完了したときに完了イベントを送り、クライアントはそれを受け取ったあとは再接続しないようにしました。
完了前に接続が切れた場合は、受信済みのシーケンス番号を指定し、DynamoDB に保存された続きから取得します。 また、一時的な障害で再接続が集中しないよう、再接続間隔を徐々に広げ、再試行回数にも上限を設けます。
完了イベントが届く直前に接続が切れる可能性もあるため、再接続時にはイベントの続きだけでなく、タスク自体がすでに完了していないかも確認します。
SSE 配信 Lambda の設計ポイント
今回の対応を通じて、SSE 配信を Lambda レスポンスストリーミングで組むときの設計ポイントが 4 つに整理できました。
1. 接続の終了条件を決める
Lambda のタイムアウトは、処理を正常に終えるための仕組みではなく、最後の安全装置です。
タスクの完了、クライアントの切断、接続時間の上限など、どの条件で SSE 接続を終了するかをコード側で決めます。 Lambda の残り実行時間も確認し、終了処理に必要な余裕を残して自分から接続を閉じます。
2. 再接続しても重複しないようにする
各イベントにシーケンス番号を付け、クライアントがどこまで受信したかをサーバーへ伝えられるようにします。
同じイベントが再送される可能性もあるため、クライアント側は受信済みのシーケンス番号を二重に反映しないようにします。
3. 無受信状態をクライアント側でも検知する
サーバーからデータも終了通知も届かない状態に備え、クライアント側にも無受信タイムアウトを設けます。
ただし、タイムアウト後に即時かつ無制限に再接続すると、今回と同じように呼び出しが増える可能性があります。 再接続には待ち時間、上限回数、タスク状態の確認を組み合わせます。
4. 待機時間も Lambda 料金に含まれる
SSE 配信 Lambda は、イベントがない間も、接続している限り実行時間が課金されます。
DynamoDB の確認間隔を広げれば読み取り回数は減らせますが、接続を開いたままなら Lambda の実行時間は減りません。 Lambda コストを下げる手段は、不要になった接続を終了し、無条件の再接続をやめることです。
監視と上限設定
コードの修正で今回の原因は取り除けましたが、同様の見落としが二度と起きないとは言い切れません。 そこで、次に起きたときに早く気づき、影響を小さく抑えるための仕組みを 3 つ追加しました。
- Amazon CloudWatch アラーム:Duration、Errors、ConcurrentExecutions を監視する。Duration がタイムアウト値に張り付いたら通知。今回の事象なら、初日に気づけた。
- AWS Cost Anomaly Detection:サービス単位のコスト急増を機械学習で検知する。日次の請求確認より早く初動の通知が届く。
- 予約済み同時実行数の上限:対象関数の同時実行数に上限を設け、想定外の課金が続いた場合の影響額を頭打ちにする。
検証環境は本番より監視が手薄になりがちですが、今回のように検証環境でも月 800 ドル規模の想定外コストは発生します。 コスト系の監視だけは、環境を問わず最初に入れておく価値があります。
おわりに
今回の事象では、中継 Lambda のコードが約 0.6 秒でハンドラ末尾まで到達していた一方、Lambda 呼び出しは完了せず、900 秒後にタイムアウトしていました。 そこへクライアントの再接続が重なり、同時実行数とコストが増えていました。
ログだけでは直接の原因を 1 つに切り分けられなかったため、タイマー、レスポンスストリーム、クライアントの再接続をまとめて見直しました。
SSE を Lambda で配信するときは、「データをどう送るか」だけでなく、「いつ接続を閉じるか」「切断後にどう再開するか」「古い呼び出しが本当に終了したか」まで確認する必要があります。
接続中の待機時間も Lambda の実行時間として課金される点は、とくに見落としやすい部分です。 タイムアウトに終了を任せず、接続の閉じ方をコードで設計しておくと、同様の事象を防ぎやすくなります。
本ブログが、同様の構成を検討している方の参考になれば幸いです。

元記事発行日: 2026年08月05日、最終更新日: 2026年07月29日














