Lambda でレスポンスストリーミングをする

Lambda でレスポンスストリーミングをする

Lambda のレスポンスストリーミングを3つの方法で実装する際の違いや注意点を、実装コードと共に紹介します。InvokeWithResponseStream、API Gateway、Function URLs それぞれの設定方法と使い分けについて解説します。
2026.06.11

はじめに

こんにちは、Kanaru です。AI を活用したサービス開発において、バックエンドの TTFB (Time To First Byte) 改善のためにストリーミング形式でレスポンスを返すことが多いと思います。私自身も、先日 Lambda でストリーミングを返す関数を構築しました。

Lambda でストリーミングを返すための構成は、以下の3種類です。

  • InvokeWithResponseStream
  • API Gateway
  • Function URLs

それぞれ微妙に設定が異なるため混乱しやすいです。私がつまづいたポイントを中心に、それぞれの方法について紹介します。

検証環境

  • aws-cdk 2.1126.0

本記事で紹介すること

  • Lambda でレスポンスストリーミングをするためのコード
  • ストリーミングにおける InvokeWithResponseStream、API Gateway、Function URLs の違い

本記事で紹介しないこと

  • Lambda、API Gateway、Function URLs の基本的な使い方

サンプルコード

今回使用したコードは以下のリポジトリにあります。併せて参考にしてください。

https://github.com/kanaru0928/cm-response-streaming

直接実行

通常の Lambda 関数は、SDK や aws-cli の Invoke コマンドを使用することで実行できます。同様に、レスポンスストリーミングの関数も SDK の InvokeWithResponseStream コマンドや、aws-cli の invoke コマンドで呼び出すことができます。

関数コード(直接実行前提)

このような実行方法を前提とした Lambda 関数のコードは次のようになります。

streamFunction.ts
import { requestSchema } from "./schema/request";
import * as v from 'valibot'

export const handler = awslambda.streamifyResponse(
  async (event, responseStream, _context) => {
    const body = v.parse(requestSchema, event);

    responseStream.write("Hello, this is a streamed response!\n");

    // wait for 2 seconds
    await new Promise(r => setTimeout(r, 2000));

    responseStream.write(`Hello, ${body.name}! This is a delayed message.\n`);
    responseStream.end();
  }
);

コードについて解説します。

ストリーミング用のハンドラーは awslambda.streamifyResponse() 関数で囲むことにより作成できます。streamifyResponse の中身の関数は、第2引数に awslambda.HttpResponseStream を取ることができます。このストリームに書き込むことで、レスポンスを返すことができます。
なお、この awslambda.HttpResponseStreamnode:streamWritable の派生クラスです。

リクエストのペイロードは第1引数に直接入っています。そのため今回は、valibot を使用して event 変数に直接バリデーションをかけています。スキーマは以下の通りです。

request.ts
import * as v from 'valibot'

export const requestSchema = v.object({
  name: v.string(),
})

その後のコード内では、Hello, this is a streamed response! と返した2秒後に Hello, ${body.name}! This is a delayed message. と返すようになっています。

デプロイ(直接実行前提)

今回は CDK の NodejsFunction を使用してデプロイしました。

responseStreamFunction.ts
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambda_nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from "constructs";

export class ResponseStreamFunction extends Construct {
  public readonly functionName: string;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    const func = new lambda_nodejs.NodejsFunction(this, 'ResponseStreamingFunction', {
      entry: 'lib/lambda/streamFunction.ts',
      runtime: lambda.Runtime.NODEJS_24_X,
    });

    this.functionName = func.functionName;
  }
}

デプロイした関数の呼び出し元リソースには、lambda:InvokeFunction のアクションを許可された IAM ロールが紐づけられている必要があります。コード上には記載していないですが、func.grantInvoke(resource) という形でリソースに呼び出し権限を与えることができます。

実行(直接実行前提)

aws-cli だとストリーミングしているのがわかりづらいため、検証コードを用意しました。

invokeWithResponseStream.ts
import { InvokeWithResponseStreamCommand, LambdaClient } from "@aws-sdk/client-lambda";

async function main() {
  const functionName = process.env.RESPONSE_STREAM_FUNCTION_NAME;
  if (!functionName) {
    console.error("RESPONSE_STREAM_FUNCTION_NAME environment variable is not set.");
    return;
  }

  const client = new LambdaClient({})
  const command = new InvokeWithResponseStreamCommand({
    FunctionName: functionName,
    Payload: Buffer.from(JSON.stringify({ name: "hoge" })),
    LogType: "Tail",
  })
  const response = await client.send(command)

  const decoder = new TextDecoder();
  for await (const event of response.EventStream ?? []) {
    if (event.PayloadChunk?.Payload) {
      const chunk = decoder.decode(event.PayloadChunk.Payload, { stream: true });
      console.log(chunk);
    } else if (event.InvokeComplete) {
      if (event.InvokeComplete.ErrorCode) {
        console.error(`Error: ${event.InvokeComplete.ErrorCode} - ${event.InvokeComplete.ErrorDetails}`);
      }

      if (event.InvokeComplete.LogResult) {
        const logs = Buffer.from(event.InvokeComplete.LogResult, 'base64').toString('utf-8');
        console.log("Log result:");
        console.log(logs);
      }
    }
  }
}

main()

関数の実行自体は以下の通り可能です。

const command = new InvokeWithResponseStreamCommand({
  FunctionName: functionName,
  Payload: Buffer.from(JSON.stringify({ name: "hoge" })),
  LogType: "Tail",
})
const response = await client.send(command)

レスポンスは response.EventStreamAsyncIterable<InvokeWithResponseStreamResponseEvent> 型として取得できます。コード内では for await することで、EventStream を処理しています。

実行すると、以下の出力が得られました。1行目が表示された後、2秒後に3行目以降が表示されました。

Hello, this is a streamed response!

Hello, hoge! This is a delayed message.

Log result:
START RequestId: 61f4ed31-5eff-4529-bc94-7fbb880e96af Version: $LATEST
END RequestId: 61f4ed31-5eff-4529-bc94-7fbb880e96af
REPORT RequestId: 61f4ed31-5eff-4529-bc94-7fbb880e96af  Duration: 2119.53 ms    Billed Duration: 2268 ms        Memory Size: 128 MB     Max Memory Used: 77 MB  Init Duration: 148.07 ms

API Gateway

API Gateway の REST API では、Lambda 統合のレスポンス転送モードをストリームにすることで、ストリーミングを返す Lambda 関数を扱うことができます。この際、プロキシ統合を設定する必要があります。

関数コード(API Gateway)

次のようなコードでストリームを返すことができます。streamifyResponse を使う点は直接実行と同様ですが、プロキシ統合を設定した場合、ハンドラーの第1引数が API Gateway 特有のものになります。また、レスポンスも HTTP になるため、その設定が必要です。

streamHTTP.ts
import type { APIGatewayProxyEvent } from "aws-lambda";
import * as v from 'valibot';
import { requestSchema } from "./schema/request";

export const handler = awslambda.streamifyResponse(
  async (event: APIGatewayProxyEvent, responseStream, _context) => {
    const httpStream = awslambda.HttpResponseStream.from(responseStream, {
      statusCode: 200,
      headers: {
        "Content-Type": "text/plain",
        "Access-Control-Allow-Origin": "*"
      },
    });

    try {
      if (!event.body) {
        throw new Error("Request body is required");
      }

      const rawBody = event.isBase64Encoded
        ? Buffer.from(event.body, 'base64').toString('utf-8')
        : event.body;

      const body = v.parse(requestSchema, JSON.parse(rawBody));

      httpStream.write("Hello, this is a streamed response!\n");

      // wait for 2 seconds
      await new Promise(r => setTimeout(r, 2000));

      httpStream.write(`Hello, ${body.name}! This is a delayed message.\n`);
    } catch (error) {
      console.error("Error occurred:", error);
      httpStream.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
    } finally {
      httpStream.end();
    }
  }
);

コードの解説をします。

まず、以下のコードで responseStreamawslambda.HttpResponseStream でラップする必要があります。このタイミングで、レスポンスに使う HTTP ヘッダーの情報を設定できます。

const httpStream = awslambda.HttpResponseStream.from(responseStream, {
  statusCode: 200,
  headers: {
    "Content-Type": "text/plain",
    "Access-Control-Allow-Origin": "*"
  },
});

次に、リクエストボディのバリデーションです。リクエストボディは APIGatewayProxyEvent 型に従い、event.body に入っています。また、event.isBase64Encodedtrue の場合には event.body が base64 でエンコードされているため、デコードする必要があります。どのような時に base64Encodedtrue となるかは後ほど説明します。

if (!event.body) {
  throw new Error("Request body is required");
}
const rawBody = event.isBase64Encoded
  ? Buffer.from(event.body, 'base64').toString('utf-8')
  : event.body;
const body = v.parse(requestSchema, JSON.parse(rawBody));

後の処理は直接実行とほぼ同じなので省略します。

デプロイ(API Gateway)

以下の CDK でデプロイできます。

responseStreamFunctionWithAPI.ts
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as lambda_nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from "constructs";

export class ResponseStreamFunctionWithAPI extends Construct {
  public readonly functionName: string;
  public readonly apiEndpoint: string;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    const func = new lambda_nodejs.NodejsFunction(this, 'ResponseStreamingFunction', {
      entry: 'lib/lambda/streamHTTP.ts',
      runtime: lambda.Runtime.NODEJS_24_X,
    });

    const integration = new apigateway.LambdaIntegration(func, {
      proxy: true,
      responseTransferMode: apigateway.ResponseTransferMode.STREAM,
    });

    const api = new apigateway.RestApi(this, 'ResponseStreamingAPI', {});
    api.root.addMethod('POST', integration);

    this.functionName = func.functionName;
    this.apiEndpoint = api.url;
  }
}

LambdaIntegration の設定では、proxy: trueresponseTransferMode: apigateway.ResponseTransferMode.STREAM の設定が必要です。また、API Gateway は RestApi である必要があります。

実行(API Gateway)

今回は curl で検証します。ストリームを受け取るには --no-buffer かその略記である -N オプションが必要です。

curl -N -X 'POST' -d '{"name":"hoge"}' https://<リソースID>.execute-api.<リージョ>.amazonaws.com/prod/

1行目が出た2秒後に2行目を得ました。正しくストリームが動作していることが分かります。

Hello, this is a streamed response!
Hello, hoge! This is a delayed message.

Function URLs

API Gateway と同様、Function URLs でもレスポンスストリーミングを扱うことができます。

関数コード(Function URLs)

基本的には API Gateway と同じですが、ハンドラーの第1引数の型が若干異なります。Function URLs では LambdaFunctionURLEvent を使用します。

export const handler = awslambda.streamifyResponse(
-  async (event: APIGatewayProxyEvent, responseStream, _context) => {
+  async (event: LambdaFunctionURLEvent, responseStream, _context) => {

今回は body しか見ていないので実装の差分はありませんが、ヘッダーなどを参照する場合にはプロパティが変わるので注意してください。

デプロイ(Function URLs)

以下の CDK でデプロイできます。FunctionUrl 設定の invokeModelambda.InvokeMode.RESPONSE_STREAM を指定する必要があります。

responseStreamFunctionWithURL.ts
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as lambda_nodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from "constructs";

export class ResponseStreamFunctionWithURL extends Construct {
  public readonly functionName: string;
  public readonly url: string;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    const func = new lambda_nodejs.NodejsFunction(this, 'ResponseStreamFunction', {
      entry: 'lib/lambda/streamHTTP.ts',
      runtime: lambda.Runtime.NODEJS_24_X,
    });
    this.functionName = func.functionName;

    const url = new lambda.FunctionUrl(this, 'ResponseStreamFunctionUrl', {
      function: func,
      invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
      authType: lambda.FunctionUrlAuthType.NONE,
    });
    this.url = url.url;
  }
}

実行(Function URLs)

API Gateway と同様 curl で検証します。

curl -N -X 'POST' -d '{"name":"hoge"}' https://<関数URL ID>.lambda-url.<リージョ>.on.aws/

1行目が出た2秒後に2行目を得ました。正しくストリームが動作していることが分かります。

Hello, this is a streamed response!
Hello, hoge! This is a delayed message.

比較

ここからは、それぞれの方法について比較していきます。

直接実行の特徴

この方法は他の方法と比べて構成がシンプルになっているのが特徴です。一方、通信が AWS 独自のものであるため、呼び出しに SDK や aws-cli が必要なのがデメリットになってくると思います。また、IAM ロールが必要なので、汎用的な用途では使いづらいです。

API Gateway と Function URLs の違い

表面的にはほぼ同じです。選定としては、API Gateway 特有の機能(オーソライザーやドキュメント)を使いたい場合には API Gateway を選ぶという感じになると思います。

ただし、呼び出しにあたってはいくつか注意点があります。以下はそれについて説明します。

isBase64Encoded

まず、isBase64Encoded についてです。上で述べたとおり、body が base64 エンコーディングされている場合、isBase64Encodedtrue になります。これは、呼び出し側が指定するのではなく、API Gateway や Function URLs が自動で行います。

API Gateway の REST API の場合、API のバイナリメディアタイプに従って自動で変換されます。何も指定していなければ、すべてテキストとして転送されます。

一方、Function URLs では、Content-Type ヘッダーを見て自動で変換します。よって、Function URLs を使用する場合は不正な Content-Type にしないように注意しなければなりません。

いずれの場合においても、仮にリクエストボディが Base64 で来たとしても isBase64Encoded を見てデコードする処理があれば、テキストで得ることができます。

HTTP バージョン

API Gateway と Function URLs では、内部で扱う HTTP のバージョンが異なります。

API Gateway では HTTP/2 をサポートしているため、ストリーミングにおいてもデフォルトで HTTP/2 の機能を使用して行われます。また、クライアントが HTTP/1.x をリクエストした場合には、自動で HTTP/1.1 にフォールバックし、Transfer-Encoding: chunked ヘッダーが渡されます。

一方、Function URLs では HTTP/2 のサポートがありません。そのため、HTTP/1.1 で Transfer-Encoding: chunked ヘッダーを指定することでストリーミングを実現しています。

curl で -v オプションをつけることで確認できます。

$ curl -v -N -X 'POST' -d '{"name":"hoge"}' https://<リソースID>.execute-api.<リージョン>.amazonaws.com/prod/
...
> POST /prod/ HTTP/2
> Host: <リソースID>.execute-api.<リージョン>.amazonaws.com
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Length: 15
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 15 bytes
< HTTP/2 200 
< content-type: text/plain
< date: Thu, 11 Jun 2026 07:31:10 GMT
< x-amzn-requestid: eb27baa7-7a09-480b-9e9d-b0342d0bddff
< x-amz-apigw-id: <API Gateway ID (Base64)>
< access-control-allow-origin: *
< x-cache: Miss from cloudfront
< via: 1.1 <CloudFrontドメイン>.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT57-P11
< x-amz-cf-id: sCcZC6d1XDQ9rpvJ-DEw9W9dbxKFTyjC3lQLrj3jdMkaYpB203wssw==
< 
Hello, this is a streamed response!
Hello, hoge! This is a delayed message.
* Connection #0 to host <リソースID>.execute-api.<リージョン>.amazonaws.com left intact
$ curl -v -N -X 'POST' -d '{"name":"hoge"}' https://<関数URL ID>.lambda-url.<リージョン>.on.aws/
> POST / HTTP/1.1
> Host: <関数URL ID>.lambda-url.<リージョン>.on.aws
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Length: 15
> Content-Type: application/x-www-form-urlencoded
> 
* upload completely sent off: 15 bytes
< HTTP/1.1 200 OK
< Date: Thu, 11 Jun 2026 07:34:01 GMT
< Content-Type: text/plain
< Transfer-Encoding: chunked
< Connection: keep-alive
< x-amzn-RequestId: ea6ec230-cc0f-4d15-9601-eb7ca202ccbf
< access-control-allow-origin: *
< X-Amzn-Trace-Id: Root=1-6a2a64e9-329365220bcc7c33167ae3ac;Parent=6ac769e2d7042d95;Sampled=0;Lineage=1:70af1237:0
< 
Hello, this is a streamed response!
Hello, hoge! This is a delayed message.
* Connection #0 to host <関数URL ID>.lambda-url.<リージョン>.on.aws left intact

まとめ

以上をまとめると、以下の通りです。

項目 直接実行 API Gateway Function URLs
構成 単純 複雑 単純
呼び出し 複雑 単純 単純
IAM 認証 必須 任意 任意
機能性 低い 高い 低い
プロトコル 独自 HTTP/2 HTTP/1.1

本記事が、皆さんの要件に合わせたより良い構成の一助になれれば幸いです。

この記事をシェアする

関連記事