0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Lambda Response Streaming で「待たせない」レスポンスを作る 〜 Function URL × ffmpeg ストリーミングプロキシ実装

0
Posted at

AWS Lambda Response Streaming で「待たせない」レスポンスを作る

Qiita 投稿用ドラフト | ポートフォリオ / スキル知識まとめ
タグ: AWS, AWSLambda, サーバーレス, ServerlessFramework, Node.js


想定読者

  • AWS Lambda で「生成に時間のかかる大きなレスポンス」をブラウザへ返していて、初回バイトまでの遅延(TTFB)に悩んでいる方
  • API Gateway 経由のレスポンスが(既定設定では)一括返却になってしまい、体感速度を改善したい方
  • Lambda Response Streaming を本番で使うときの認可・CORS・タイムアウト・エラー扱いの勘所を一通り押さえたい方
  • 「上流から取ってきたデータを変換しながら流す」ストリーミングプロキシの設計に興味がある方

この記事で示せるスキル

  • AWS Lambda / Lambda Response Streaming の実装
  • Lambda Function URL(invokeMode: RESPONSE_STREAM)の設計
  • Serverless Framework による IaC 定義
  • IAM 認可・CORS 設計と、ストリーミング特有のエラーハンドリング
  • ffmpeg(child_process.spawn による stdin/stdout pipe 設計)での逐次トランスコードと、pipe ベースのストリーミングプロキシ設計

1. 導入:なぜ「待たせてしまう」のか

あるサーバーレスな Web サービスで、Lambda から「生成に時間のかかるデータ」をブラウザへ返す必要がありました。題材としてわかりやすいので、この記事では「上流の音声合成(TTS)エンジンが生成する音声データ」を一例にします。とはいえ本質は音声に限りません。LLM のトークンストリーム、大きな CSV エクスポート、レポート生成など「最終バイトが出そろうまで時間がかかる」処理すべてに共通する話です。

素朴に実装すると、こうなります。

// アンチパターン:全部できあがってから一括で返す
export const handler = async (event) => {
  const data = await generateHeavyPayload(event); // ここで数秒〜数十秒待つ
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/octet-stream' },
    body: data.toString('base64'),
    isBase64Encoded: true,
  };
};

この実装の問題は、生成が完了するまでクライアントには 1 バイトも届かないことです。生成に 10 秒かかれば、ユーザーは 10 秒間まっさらな画面を見続けます。さらに、レスポンス全体をいったんメモリに載せるためメモリ消費も増えがちです。

ここで効くのが Lambda Response Streaming。生成しながら、できたぶんから逐次クライアントへ流す。これだけで初回バイトまでの遅延が大きく改善します。図にすると、両者の「初回表示までの体感時間」の差は一目瞭然です。

ただし効果には前提があります。Response Streaming が効くのは「最初の chunk を早く作れる」場合です。上流サービス自体が全生成完了まで 1 バイトも返さない(=最初の chunk が出るのが遅い)構成では、Lambda 側だけをストリーミング化しても TTFB の改善は限定的になります。「上流も実際にストリームを吐き出す」ことが、体感改善の条件です。

2. 背景と選択肢

「重いレスポンスを速く返したい」とき、Lambda で取れる選択肢を整理します。

前提:Lambda Response Streaming は Node.js managed runtime でネイティブ対応しています。Python など他言語では custom runtime や Lambda Web Adapter といった別構成が必要です。本記事は Node.js managed runtime を前提にします。なお、機能自体は 2026年4月に全商用リージョンへ展開済みで、東京リージョン(ap-northeast-1)でも利用できます。

方式 初回バイト遅延 メモリ 備考
API Gateway + Lambda(一括返却) 遅い 標準のプロキシ統合は既定でバッファリング
API Gateway + Lambda(ストリーミング) 速い 小〜中 REST API のみ response transfer mode(Lambda プロキシ統合 + InvokeWithResponseStream)でストリーミング対応(2025/11〜)。HTTP API は非対応。既存基盤を活かせる
事前生成して S3 に置き署名 URL を返す 速い(2 段階) リアルタイム性が要らないバッチ向き。生成完了を待つ点は変わらない
Lambda Function URL + Response Streaming 速い 生成しながら逐次配信できる。設定がシンプル。本記事の主役

かつては「ストリーミングするなら Function URL 一択」でしたが、現在は API Gateway(REST API)も response transfer mode でレスポンスストリーミングに対応しています(Lambda プロキシ統合が内部で InvokeWithResponseStream を使う仕組み。2025年11月〜)。実質の選択肢は2つです。

  • Lambda Function URL:設定がシンプル。ブラウザからの直接アクセスや、署名付きで直叩きする構成に向く。本記事はこちらを主役にします。
  • API Gateway(REST API):既存の API Gateway 基盤(認可・WAF・カスタムドメイン・使用量プラン等)を活かしたいときの有力な選択肢。ただし旧来の標準プロキシ統合は既定でバッファリングするため、ストリーミングには response transfer mode の有効化が必要です。

判断軸はユースケース次第です。「まず手軽に始めたい/ブラウザから直接アクセスさせたい」なら Function URL、「既存の API Gateway 資産に乗せたい」なら API Gateway、と決めるとぶれません。

重要:API Gateway へ移植するときは「統合設定」と「イベント形式」に注意

同じ Response Streaming でも、Function URL と API Gateway(REST API)では Lambda の呼び出し経路が異なります。

API Gateway の Lambda プロキシ統合では、Lambda の出力が「メタデータ JSON + 8 バイトの null bytes delimiter + payload」という形式を満たす必要があります。ただし本記事のように awslambda.HttpResponseStream.from(responseStream, metadata) を使う場合、この delimiter は Lambda ランタイムが自動で付与するため、出力側の実装は Function URL とほぼ共通化できます。from() を使わず生のストリームへ直接書く場合のみ、自分で metadata と 8 null bytes を書く必要があり、この形式を満たさないと API Gateway は 500 を返します。

移植時に実際に変わるのは次の点です。

  • 統合 URI が .../invocations ではなく .../response-streaming-invocations(API バージョンの日付も 2015-03-312021-11-15 に変わる)。
  • responseTransferMode を既定の BUFFERED から STREAM に有効化する。
  • イベント(入力)形式が Function URL と API Gateway プロキシ統合で異なるため、event.body やヘッダの取り回しを移植時に確認する。

つまり「コードを 1 行も変えずに載せ替えられる」わけではありませんが、from() を使っている限り、書き換えの主戦場は出力形式ではなく 統合設定とイベントパースです。

3. 実装

実装に入る前に、本記事で組み立てる Function URL 構成の全体像を示します。ポイントは、設定解決(loadConfig)は body 送出前に済ませ、上流のストリームを Lambda 内で変換しながら、そのまま responseStream へ流すことです。

3-1. ハンドラを streamifyResponse でラップする

Response Streaming では、通常の return でレスポンスを返しません。グローバルに用意される awslambda.streamifyResponse でハンドラをラップし、第 2 引数の responseStream に書き込みます。

// handler.js
// 'awslambda' は Lambda ランタイムがグローバルに注入する。import 不要。

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    // 1) メタデータ(ステータス・ヘッダ)を先に確定させる
    responseStream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/octet-stream',
      },
    });

    // 2) できたぶんから逐次書き込む
    for (let i = 0; i < 5; i++) {
      const chunk = await produceChunk(i); // 時間のかかる生成処理
      responseStream.write(chunk);          // 即座にクライアントへ流れる
    }

    // 3) 明示的に終了する
    responseStream.end();
  }
);

responseStream.write(chunk) で逐次書き込み、responseStream.end() で終了します。Node の Readable をそのまま流す最小例では .pipe(responseStream) でも動きます。ただし本番コードでは、エラー伝播と後始末を扱いやすい pipeline() を使う方が安全です。後述の「変換しながら流す」設計では、この考え方を pipeline() で実装します。

// Readable をそのまま流すパターン
const upstream = await getReadableStream(event); // Readable
upstream.pipe(responseStream); // end は pipe が面倒を見てくれる

3-2. 「変換しながら流す」ストリーミングプロキシ

この構成の真価は、上流から取ってきたものを変換して、できたぶんから流すことにあります。上流取得は axios を { responseType: 'stream' } で呼び、Readable ストリームを得ます。そのストリームを変換処理にかけ、出力を responseStreampipeline で連結します(pipe ではなく pipeline を使う理由は後述)。

import axios from 'axios';
import { pipeline } from 'node:stream/promises';

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    // ストリーム開始前に設定を解決し、上流リクエストを組み立てる
    // (例:DynamoDB(Dynamoose 経由)から ID をキーに使用パラメータを引く)
    const config = await loadConfig(event);

    // 上流を stream で取得(全体をメモリに載せない)
    const upstream = await axios({
      method: 'POST',
      url: process.env.UPSTREAM_URL,
      headers: { Authorization: `Bearer ${process.env.UPSTREAM_API_KEY}` },
      data: buildRequestBody(config),
      responseType: 'stream',
      timeout: 30_000,
    });

    const stream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: { 'Content-Type': 'audio/mpeg' }, // MP3 配信なら octet-stream より自然
    });

    // 上流ストリーム → 変換 → responseStream を pipeline で連結する。
    // pipeline は (1) どの段でエラーが起きても全ストリームを破棄して後始末し、
    // (2) 完了 / 失敗を await できるので、エラー処理を 1 か所に集約できる。
    try {
      await pipeline(
        upstream.data,
        createTransformStream(),
        stream
      );
    } catch (err) {
      // すでに statusCode 200 を送出済みのため、ここで 4xx/5xx には変えられない。
      // クライアントには「途中で切れた」ように見える。原因はサーバ側ログで追跡する。
      console.error(JSON.stringify({
        event: 'stream_pipeline_error',
        message: err.message,
      }));
    }
  }
);

全体をメモリにバッファしないので、低メモリ・低初回遅延を同時に達成できます。バックプレッシャ(書き込み先が詰まったら読み込みを止める制御)も自動で効きます。

なぜ .pipe().pipe() ではなく pipeline() か。 .pipe() だけで数珠つなぎにすると、上流・変換・responseStream のいずれかで発生したエラーが他の段へ伝播せず、ストリームが破棄されないまま宙ぶらりんになりがちです(リソースリーク・ハング・原因不明の途中切断の温床)。node:stream/promisespipeline() は、どこか 1 段でも失敗すると全ストリームを destroy して後始末し、await で完了 / 失敗を受け取れます。ストリーミングは「200 を送り始めた後はステータスを変えられない」(4-1) という制約があるため、途中エラーの後始末とログ設計こそが本番品質の分かれ目になります。設計用の最小例として残すなら .pipe() でも構いませんが、ポートフォリオや本番コードでは pipeline() を既定にしておくのが無難です。

なお、ストリーム開始前に外部設定を引く処理(上の loadConfig)は、ここでは DynamoDB(Dynamoose 経由)から ID をキーにパラメータを取得する想定にしています。重要なのは、ボディを 1 バイトでも送り始める前に、必要な構成を確定させておくことです(理由は 4-1 で詳述します)。環境変数名(UPSTREAM_URL など)はこの記事用の仮名なので、実際の名前はプロジェクトに合わせてください。

3-3. 変換処理を ffmpeg で組む(音声トランスコードの例)

「上流から流れてくる生 PCM を、ブラウザ配信向けの MP3 へトランスコードしながら流す」ケースを例に、変換ストリームを具体化します。ffmpeg バイナリは Lambda ランタイムに含まれないため、Lambda Layer として同梱(またはバンドル)し、関数に紐付けるのが定石です。

かつては JS ラッパーの fluent-ffmpeg を使う例が一般的でしたが、fluent-ffmpeg は 2025年5月22日に GitHub リポジトリがアーカイブされ、npm 上でも deprecated(「もう保守されておらず、最近の ffmpeg では正しく動作しないことがある」旨の警告つき)になっています。そのため本記事では、Node 標準の child_process.spawn で ffmpeg を直接起動し、stdin/stdout のパイプを自分で設計する方法を採ります。deprecated なラッパーに依存せず、ffmpeg のコマンドラインをそのまま扱えるため、長期保守の面でも安全です。

import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises';

// ffmpeg を spawn し、生 PCM(s16le) → MP3 へ変換するプロセスを作る。
// ffmpeg は Lambda Layer 同梱を想定(PATH 上、または絶対パスで指定)。
function spawnPcmToMp3() {
  const ff = spawn('ffmpeg', [
    '-hide_banner',
    '-loglevel', 'warning',
    // --- 入力(生 PCM は形式の明示が必須)---
    '-f', 's16le',   // 入力フォーマット
    '-ar', '48000',  // 入力サンプルレート
    '-ac', '1',      // 入力チャンネル数
    '-i', 'pipe:0',  // stdin から読む
    // --- 出力 ---
    '-ar', '22050',  // 出力サンプルレート
    '-b:a', '64k',   // ビットレート
    '-ac', '1',      // mono
    '-f', 'mp3',     // 出力フォーマット
    'pipe:1',        // stdout へ書く
  ]);

  // ffmpeg は進捗も警告もエラーも stderr に出す。CloudWatch に必ず残す(異常系の命綱)
  ff.stderr.on('data', (buf) =>
    console.log(JSON.stringify({ event: 'ffmpeg_stderr', line: buf.toString() })));
  ff.on('close', (code) =>
    console.log(JSON.stringify({ event: 'ffmpeg_close', code })));

  return ff;
}

3-2 の handler の「変換 → 配信」部分を、この ffmpeg プロセスで置き換えます。spawn だと入出力が 1 本の Transform にはまとまらないので、上流 → stdinstdout → responseStream の 2 本を pipeline で並行に流し、どちらかが失敗したら ffmpeg を確実に止めます。

import { once } from 'node:events';

// (upstream / stream は 3-2 と同じく取得・確定済みとする)
const ff = spawnPcmToMp3();

// ffmpeg の起動失敗(Layer のパス不備など)と終了コードの両方を監視する。
// pipe が流れ切っても、終了コードが 0 でなければトランスコード失敗とみなす。
const errorPromise = once(ff, 'error').then(([err]) => {
  throw err;
});

const closePromise = once(ff, 'close').then(([code]) => {
  if (code !== 0) throw new Error(`ffmpeg exited with code ${code}`);
});

try {
  await Promise.all([
    pipeline(upstream.data, ff.stdin), // 上流 PCM → ffmpeg
    pipeline(ff.stdout, stream),       // ffmpeg → responseStream
    Promise.race([
      errorPromise,                    // spawn そのものの失敗を検知
      closePromise,                    // 終了コードの検証
    ]),
  ]);
} catch (err) {
  if (!ff.killed) ff.kill('SIGKILL'); // 取り残した ffmpeg プロセスを確実に止める
  console.error(JSON.stringify({
    event: 'transcode_pipeline_error',
    message: err.message,
  }));
}

生 PCM(s16le)は入力フォーマットとサンプルレートを明示しないと正しく解釈されません。出力側のサンプルレート・ビットレート・チャンネル数は、ファイルサイズ / 品質 / レイテンシのトレードオフです(この例では 22050Hz / 64kbps / mono)。

stderr のログ出力は、Lambda + CloudWatch でのデバッグに必須です。ffmpeg は進捗・警告・エラーをすべて stderr に出すため、ここを JSON で構造化して残しておくと、「200 を返した後に静かに切れた」異常系を追うときの命綱になります(4-6 参照)。spawn で組む最大の利点は、stdin/stdout のパイプとプロセスの終了処理を自分で完全に制御できる点です——deprecated なラッパーの挙動に振り回されず、失敗時に ff.kill() でプロセスを取り残さない、といった後始末を明示的に書けます。さらに、pipe が流れ切っても「正常終了」とは限らないため、'error' イベントで spawn 自体の失敗(Layer のパス不備など)を拾い、'close' イベントで終了コード(code !== 0)まで検証し、プロセスのライフサイクルごと面倒を見ています。

3-4. Function URL を Serverless Framework で公開する

Response Streaming を成立させる公開口が Lambda Function URL です。invokeModeRESPONSE_STREAM にするのが肝。Serverless Framework なら関数の url: 配下に書けます。

# serverless.yml
service: streaming-demo

provider:
  name: aws
  runtime: nodejs20.x
  region: ap-northeast-1

functions:
  stream:
    handler: handler.handler
    timeout: 300        # 上流が遅いことを見越して長めに(最大 900)
    memorySize: 512     # スループットに効く。実測でチューニング
    layers:
      - arn:aws:lambda:ap-northeast-1:123456789012:layer:ffmpeg:1  # 例
    url:
      invokeMode: RESPONSE_STREAM   # ← これが本質
      authorizer: aws_iam           # IAM 認可(SigV4 必須に)
      cors:
        allowedOrigins:
          - https://app.example.com
        allowedHeaders:
          - content-type
          - authorization
          - x-amz-date            # SigV4 署名リクエストで送られる
          - x-amz-security-token  # 一時クレデンシャル利用時に必須
          - x-amz-content-sha256  # 環境により preflight に乗る
        allowedMethods:
          - POST

3-5. IAM 認可と CORS

authorizer: aws_iam を付けると、Function URL の呼び出しに SigV4 署名(IAM 認証)が必須になります。ブラウザから直接叩く場合は Cognito の一時クレデンシャルなどで署名する必要があるため、構成が増えます。サーバー間呼び出しや、署名を肩代わりするバックエンドを挟む前提なら自然な選択です。公開エンドポイントにしたいだけなら authorizer: none も選べますが、その場合でも、アプリケーション層でのトークン検証・署名付きリクエスト・レート制限・WAF など、別の防御線を必ず設計してください。

CORS は allowedOrigins / allowedHeaders / allowedMethods を Function URL 側に設定できます。ストリーミング自体に特別な CORS 設定は要りませんが、IAM 認可(SigV4)をブラウザから使う場合は注意が必要です。ブラウザが送る SigV4 リクエストには Authorization だけでなく x-amz-date、(一時クレデンシャル利用時は)x-amz-security-token、場合によっては x-amz-content-sha256 が付き、これらが preflight でチェックされます。allowedHeadersauthorization しか入れないと、読者がそのまま真似たときに preflight で弾かれるので、上記の serverless.yml のように SigV4 関連ヘッダも許可しておきます。

4. ハマりどころ・学び

4-1. 200 を返した後はステータスを変えられない

これがストリーミング最大の落とし穴です。HttpResponseStream.from(...)statusCode: 200 を確定して最初のバイトを送り始めたら、もう 4xx / 5xx には変えられません。HTTP の仕組み上、ヘッダはボディより先に送られてしまうからです。

対策はシンプルで、検証と構成解決はボディ送出より前に済ませること。3-2 で loadConfig をストリーム開始前に呼んでいたのは、まさにこのためです。流れを図にすると、「ステータスを確定できる地点」と「もう変えられない地点」の境界がはっきりします。

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    // ストリーム開始前に検証する
    const config = await loadConfig(event).catch(() => null);
    if (!config) {
      // まだ body を書いていないのでエラーを返せる
      responseStream = awslambda.HttpResponseStream.from(responseStream, {
        statusCode: 400,
        headers: { 'Content-Type': 'application/json' },
      });
      responseStream.write(JSON.stringify({ error: 'invalid request' }));
      responseStream.end();
      return;
    }

    // ここまで来たら 200 を確定し、以降は流すだけ
    responseStream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: { 'Content-Type': 'application/octet-stream' },
    });
    // ...stream...
  }
);

では「200 を流し始めた後に上流が落ちたら?」という疑問が残ります。この場合ステータスは変えられないので、できることは **ストリームを中断する(responseStream.end() で打ち切る)**ことと、サーバー側に何が起きたかを必ず記録することです。クライアントには「途中で切れた」ように見えるので、フロント側でも不完全レスポンスを検知できる設計にしておくと安全です。

4-2. timeout と memorySize

  • timeout は最大 900 秒。上流が遅い処理ではデフォルト(数秒)だと足りません。実際の上流レイテンシ+余裕を見て設定します。
  • memorySize は CPU・ネットワークスループットに直結します。ストリーミングはメモリ消費自体は小さいことが多いですが、ffmpeg のような変換処理が重い場合はメモリを増やすとスループットが上がることがあります。実測でチューニングするのが鉄則です。

4-3. 帯域・サイズ上限は「呼び出し経路」で変わる

数字は正確に押さえておきましょう(いずれも AWS 公式仕様)。ここが Function URL と API Gateway で違うので、必ず分けて理解します。

Function URL / InvokeWithResponseStream(本記事の構成)

Function URL の RESPONSE_STREAM は内部的に InvokeWithResponseStream を使うため、ここでは同じ制限として扱います。

  • 帯域:レスポンスの先頭 6MB は帯域無制限。それを超えた分は最大 2MBps にキャップされる。レスポンスが 6MB 以下なら、この帯域制限は実質かからない。
  • 最大ペイロード:ストリーミングの最大レスポンスは 200MB(バッファ方式の 6MB に対して大幅に大きい)。

つまり「小さく速い応答」なら帯域はほぼ気にしなくてよく、「6MB を超える長尺データ」では 2MBps の上限が効いてきます。後者では、ビットレートの見直しや分割配信も検討に入れます。

API Gateway(REST API)の Response Streaming(数字が別物)

同じストリーミングでも、API Gateway 経由は別の制限が課されます。Function URL の「6MB / 200MB」をそのまま当てはめないよう注意してください。

  • REST API のみ対応(HTTP API は非対応)。
  • リクエストストリーミングは非対応(ストリーミングはレスポンス方向だけ)。
  • 最大 15 分間ストリーミング可能。
  • idle timeout あり:Regional / private エンドポイントは 5 分、edge-optimized エンドポイントは 30 秒。
  • 帯域先頭 10MB は帯域制限なし、それを超えた分は 2MB/s
  • 課金は 10MB ごと(10MB 単位に切り上げ)に 1 リクエスト換算。
  • 未対応の機能:エンドポイントキャッシュ / コンテンツエンコーディング(圧縮は統合側で行う)/ VTL によるレスポンス変換。

なお、authorizer・WAF・アクセス制御・TLS/mTLS・スロットリング・アクセスログといった API Gateway のセキュリティ機能はストリーミングでも引き続き使えます。「既存の API Gateway 資産を活かす」価値はここにあります。

4-4. VPC 環境では Function URL のストリーミングが使えない

意外な落とし穴として、Lambda Function URL は VPC 内ではレスポンスストリーミングに対応していません(AWS 公式の制約)。関数を VPC に配置する構成でストリーミングしたい場合は、Function URL ではなく SDK の InvokeWithResponseStream API 経由で呼び出し、Lambda 用のインターフェース型 VPC エンドポイントを用意する必要があります(クライアント → VPC エンドポイント → Lambda → ストリーミング応答 という経路)。VPC 前提のシステムでは、公開方法を決める段階でこの制約を踏まえておくと手戻りを防げます。

4-5. クライアント切断後も実行は続き、課金される

クライアントの接続が切れても、ストリーミングは中断されず関数は実行を続けます。 その間も全実行時間ぶん課金されます(AWS 公式が明記)。長い timeout を設定したまま放置すると、離脱済みのリクエストにムダな課金が発生します。対策は2つです。ひとつは timeout を必要十分な値に絞ること。もうひとつは、接続断の検知に頼らないことです——Function URL のストリーミングでは「クライアントが離脱した」というシグナルをサーバ側で確実に受け取れる保証がありません。長時間処理を本当に止めたいなら、離脱検知ではなく、ジョブ ID + キャンセル用 API のような別経路のキャンセル手段をアプリ設計として用意するのが堅実です。

4-6. 可観測性(ログ)

途中でエラーが起きうる構成なので、構造化ログが命綱です。上流レスポンスのステータスや、ffmpeg の標準エラー出力(stderr)を CloudWatch に残しておくと、「200 を返した後に静かに切れた」ケースの原因究明ができます。3-3 で ffmpeg の stderr を JSON でログ出力していたのはこのためです。ストリーミングは「正常系は速くて気持ちいいが、異常系がブラックボックスになりやすい」ので、ログ設計を最初から織り込んでおくことを強くおすすめします。

4-7. いつ使い、いつ使わないか

  • 向いている:大きい / 生成が遅いペイロード、メディア配信、LLM のトークンストリーム、エクスポート処理
  • 向いていない:小さなレスポンス(恩恵が薄い)、ボディ全体を見てからヘッダ(ステータス・Content-Length など)を決めたいケース、ランダムアクセスが必要なケース

5. 効果を実測する(記入用テンプレート)

ポートフォリオとして見せるなら、Before/After を実測した数字が何より説得力を持ちます。以下は記入用のテンプレートです(数値はダミーではなく空欄です。必ず自分の環境で計測して埋めてください)。

指標 バッファ方式(Before) ストリーミング(After)
初回バイトまでの時間(TTFB) (実測して記入) (実測して記入)
ダウンロード完了までの時間 (実測して記入) (実測して記入)
Lambda 最大メモリ使用量 (実測して記入) (実測して記入)
出力サイズ(音質設定別:22kHz/64k/mono 等) (実測して記入) (実測して記入)

計測のヒント:TTFB は curl -w '%{time_starttransfer}\n' -o /dev/null -s <URL> で取得、メモリは CloudWatch Logs の Lambda REPORT 行に出る Max Memory Used(または CloudWatch Logs Insights / Lambda Insights)で確認、各種時間は X-Ray やアプリログから。Max Memory Used は標準の CloudWatch メトリクスにそのまま存在する値ではない点に注意してください。

また、Lambda コンソールのテスト実行ではストリーミングは常に buffered(一括)として表示されます。ストリーミング挙動を確認したいときは、デプロイ後に curl --no-buffer <URL>(API Gateway 経由なら -i --no-buffer)など実際の HTTP クライアントで叩いて計測してください。

6. まとめ

  • 重い / 遅いレスポンスは、Lambda Response Streaming で「できたぶんから流す」だけで初回バイト遅延が大きく改善する。ただし効果は「上流も実際にストリームを吐き出し、最初の chunk を早く作れる」場合に大きい。
  • 公開はシンプルさ重視なら Lambda Function URL(invokeMode: RESPONSE_STREAM。既存基盤を活かすなら API Gateway(REST API)の response transfer mode も 2025年11月以降は選べる(旧来の標準プロキシ統合は既定でバッファリング)。API Gateway へ移植する際は、出力形式(HttpResponseStream.from() を使えば 8 null bytes delimiter は自動)よりも、統合 URI・responseTransferMode: STREAM・イベント形式の差に注意する。
  • ハンドラは awslambda.streamifyResponse でラップし、HttpResponseStream.from(...) でメタデータを確定、write / end(または pipeline)で配信する。
  • 「変換しながら流す」なら、上流を responseType: 'stream' で取得し、変換処理(音声なら child_process.spawn で起動した ffmpeg)を挟んで pipelineresponseStream へ流す。fluent-ffmpeg は deprecated / アーカイブ済みなので新規採用は避ける。ffmpeg バイナリは Lambda Layer で同梱する。
  • 最大の落とし穴は「200 を返した後はステータスを変えられない」こと。検証と構成解決はボディ送出前に終わらせ、途中エラーは中断+構造化ログで拾う。
  • timeout(最大 900 秒)・memorySize・帯域とペイロード上限を踏まえて設計・調整する。上限は経路で異なる:Function URL は先頭 6MB 無制限 → 以降 2MBps・最大 200MB、API Gateway は先頭 10MB 無制限 → 以降 2MB/s・最大 15 分・REST 限定。VPC 内では Function URL ストリーミング不可、クライアント切断後も課金が続く点も忘れずに。

「全部できてから返す」をやめて「できたぶんから流す」に切り替えるだけで、ユーザー体感は別物になります。重い処理を抱えている方は、まず小さなエンドポイントひとつから試してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?