Lambda からのレスポンスサイズにはいろいろと制限があり、例えば同期呼び出しにおいて、関数 URL や API Gateway 経由だと 6MB、ALB 経由だと 1MB までといった制限があります。それ以上のデータを返す場合は、Lambda ではなく ECS に変えたり、S3 に出力してからごにょごにょする必要がありました。が、2023年4月に Lambda でストリームレスポンスがサポートされ、この制限を超えることができるようになりました。また、CloudFront の OAC (Origin Access Control) が Lambda の関数 URL をサポートしましたので合わせて検証してみました。

(検証で用いた CDK、Lambda のコードは GitHub に上げてあります。)

Lambda のストリームレスポンス

Lambda からの通常のレスポンスは一括で送られてきますが、ストリームレスポンスの場合は名前の通り、連続的にレスポンスを返すことができます。言い換えるとレスポンスに返すデータが全て揃ってから送る必要はなく、送れるデータから応答を開始することができるので、「最初の1バイトを受信するまでの時間」、いわゆる TTFB (Time To First Byte) を小さくすることができます。アプリケーションによっては UX の改善に繋げられ、例えば生成 AI を使ったチャットの応答なんかが一例になります。

このストリームレスポンスを使った時のペイロードサイズの上限は 20MB のソフトリミットとなっており、この値はリクエストすることで増やすことができるとされています。今回はこの機能を使って従来の一括レスポンス時の上限である 6MB を超えるデータを Lambda から返したいと思います。

また、ストリームで扱うことのもう一つの利点として、入出力のデータをうまく扱うことで、Lambda のメモリに大きなデータを全て載せる必要がなくなるため、Lambda のメモリサイズをより小さくできるというのがあります。

そしてこのストリームレスポンスで応答を得るには Lambda を “関数 URL” (Function URLs) や InvokeWithResponseStream API などを使って実行します。

その他、ストリームレスポンスの詳細はドキュメントを見ていただければと思います。なお、ストリームレスポンスではリクエストごとに 6MB 以上のレスポンスを返す場合は別途料金がかかるのでよくご確認を (Lambda 料金ページ)。

Lambda の “関数 URL” (Function URLs)

関数 URL は Lambda に設定できる HTTP エンドポイントで、API Gateway などを使わずに直接 HTTP を介して Lambda 関数を実行することができます。詳細はドキュメントを。

気軽に利用できる関数 URL ですが、認証方式としては「認証なし (NONE)」か「IAM 認証 (AWS_IAM)」のどちらかとなっており、それ以外の手段でリクエストを検証したい場合は Lambda 関数の中で検証するコードを実装する必要がありました。

そのため、認証なしにするわけにもいかないし、IAM 認証はちょっと使いにくいし、、、と使い所が難しかったのですが、この度 CloudFront の OAC が関数 URL をサポートしました。

CloudFront が 関数 URL のための OAC をサポート

CloudFront が関数 URL のための OAC をサポートするアップデートが日本時間の 2024年4月12日にありました。これは関数 URL の認証方式として IAM 認証を使い、特定の CloudFront ディストリビューションからのみ関数 URL を実行できるように制限することで、CloudFront で利用できるアクセス制限が適用しやすくなりました (例えば AWS WAF)。

このアップデート以前でも関数 URL を CloudFront のオリジンに設定したり、API Gateway の HTTP プロキシ統合先に設定することはできましたが、関数 URL の認証を “認証なし” にする必要があり、これらをバイパスして直接、関数 URL にアクセスできてしまう経路が残るため Lambda 側で制限ロジックを実装したりする必要がありました。

CloudFront supports OAC for Lambda Function URLs

検証したアーキテクチャ

というわけで今回は以下のようなアーキテクチャで検証してみました。S3 にある 6MB 以上のオブジェクトをストリームレスポンスを使って参照するという構成です。S3 にあるファイルであれば CloudFront から直接オリジンとして参照すれば良いだけなのであまり実用性ないですが、簡単にするためということで。

Verification architecture

Lambda 関数のコード

Lambda 関数のコードは以下のようにしました。リクエストされたパスをそのまま S3 のオブジェクトキーとして S3 にアクセスし、オブジェクトのデータをストリーミングで返す関数です。
※ サンプルでは S3 のオブジェクトキーが分かればそのまま取得できてしまいますので、公開範囲、扱うデータに注意してください

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import * as util from 'util';
import { pipeline as pipelineSync, Readable, } from 'stream';
import { LambdaFunctionURLEvent, Context, Handler, } from "aws-lambda";
import { S3Client, GetObjectCommand, } from '@aws-sdk/client-s3';

const BUCKET_NAME = process.env.BUCKET_NAME;

const pipeline = util.promisify(pipelineSync);

const s3 = new S3Client({});

export const handler: Handler = awslambda.streamifyResponse(
  async (event: LambdaFunctionURLEvent, responseStream: NodeJS.WritableStream, context: Context) => {
    try {
      const objectKey = event.requestContext.http.path.replace(/^\//, '');

      const req = new GetObjectCommand({
        Bucket: BUCKET_NAME,
        Key: objectKey,
      });

      const res = await s3.send(req);

      if (res.$metadata.httpStatusCode != 200){
        throw new Error('Error');
      }

      console.info({ 'objectKey': objectKey, 'contentType': res.ContentType, });

      responseStream.setContentType(res.ContentType);
      await pipeline(res.Body as Readable, responseStream);
    }
    catch (err) {
      console.error(err);
      responseStream.setContentType('text/plain');
      responseStream.write('Error');
      responseStream.end();
    }
  }
);

ポイントとしては、、、

  • 12行目 : ハンドラになる関数を awslambda.streamifyResponse() という謎のデコレータでラップしてあげます
  • 13行目 : デコレータは以下のパラメータを持つ関数を受け付けます
    • event: いつもの Lambda を呼び出す時に渡されるイベントです。今回は関数 URL を使うので型的には LambdaFunctionURLEvent になります
    • responseStream : ドキュメントによると Node.js の WritableStream とのことです
    • context : いつもの実行環境の情報などを提供する Context オブジェクトです
  • 33行目 : バイナリデータなどは Content-Type が application/octet-stream になるので、S3 から取得した時の Content-Type を設定します
  • 34行目 : レスポンスは responseStreamwrite() することでもできるようですが、pipeline() の利用が推奨されているようなのでお作法に則ります (catch 節の中では write を使っています)。
    • 本題から逸れますが pipeline() の第1 引数には、stream.Readable な入力を指定するのですが、S3 からの GetObject のレスポンスの Body プロパティがそのまま使えるようです。

留意点としてはストリームレスポンスが利用できる AWS マネージドなランタイムは今のところ Node.js (14以降) のみとなっています。他の言語を利用したい場合はカスタムランタイムLambda Web Adapter を利用します。

CDK コード

今回検証したものは CDK でデプロイしました。コードのほとんどはクラスメソッドさんの記事を参考にさせていただきました🙏

ストリームレスポンス関連やその他、変更したところを紹介します。

関数 URL 設定部分

関数 URL の設定には Lambda Function の addFunctionUrl を利用します。そこで invokeMode として RESPONSE_STREAM を設定します。

    const lambdaFunctionUrl = lambdaFunction.addFunctionUrl({
      authType: cdk.aws_lambda.FunctionUrlAuthType.AWS_IAM,
      invokeMode: cdk.aws_lambda.InvokeMode.RESPONSE_STREAM,
    });

CloudFront ディストリビューションの構成

検証のためシンプルな設定にしました。Lambda の動作確認をしたかったのでキャッシュも無効にしています。

    const distribution = new cdk.aws_cloudfront.Distribution(this, 'Distribution', {
      defaultBehavior: {
        origin: new cdk.aws_cloudfront_origins.FunctionUrlOrigin(lambdaFunctionUrl),
        viewerProtocolPolicy: cdk.aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cdk.aws_cloudfront.CachePolicy.CACHING_DISABLED,
        responseHeadersPolicy: cdk.aws_cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS,
      },
    });

※ 他に OAC 周りの設定などが必要です。クラスメソッドさんの記事か今回のコードを GitHub に上げてありますのでそちらを参照ください。
※ サンプルではドメイン名、S3 のオブジェクトキーが分かればそのまま取得できてしまいますので、公開範囲、扱うデータに注意してください

動作確認

準備

S3 バケットに 6MB を超えるファイルをアップロードしておきます。

aws s3 cp large.pdf s3://${BUCKET_NAME}/

適当なファイルが見つからない場合は以下の記事を参照ください。

curl で取得してみる

curl -v https://${CLOUDFRONT_DOMAIN_NAME}/large.pdf -o large.pdf

出力は一部省略しますが、ストリーミングでダウンロードできてそうです。

*   (省略)
> GET /large.pdf HTTP/2
> Host: dXXXXXXXXXXXXX.cloudfront.net
> User-Agent: curl/8.4.0
> Accept: */*
>
< HTTP/2 200
< content-type: application/pdf
<   (省略)
{ [15922 bytes data]

念のため、HTTP1.1 でも確認してみました。CDK でデフォルトでディストリビューションを作成すると HTTP2 になるので、リクエスト時に --http1.1 を指定します。レスポンスヘッダに Transfer-Encoding: chunked が出てこれば良さそうです。

curl -v --http1.1 https://${CLOUDFRONT_DOMAIN_NAME}/large.pdf -o large-http1.1.pdf

出力結果

>   (省略)
> GET /large.pdf HTTP/1.1
> Host: dXXXXXXXXXXXXX.cloudfront.net
> User-Agent: curl/8.4.0
> Accept: */*
>
< Content-Type: application/pdf
< Transfer-Encoding: chunked
< Connection: keep-alive
<   (省略)
  0     0    0     0    0     0      0      0 --:--:--  0:00:02 --:--:--     0{ [16384 bytes data]
100 6858k    0 6858k    0     0  1352k      0 --:--:--  0:00:05 --:--:-- 1792k

chunked になってました。良さそうです。

まとめ

Lambda のストリームレスポンスを使って、6MB 以上のファイルを “関数 URL” を使って取得してみました。今回は CloudFront の OAC が関数 URL をサポートしたこともあり、併せて使ってみました。WAF などと組み合わせて利用しやすくなったので、関数 URL の利用シーンも増えるのではないかと思います。

今回検証したような静的なファイルをそのまま Lambda からストリーミングで返すことはユースケースとしてはあまりなさそうです。Lambda で動的に大きなファイルを生成する場合や、大きなファイルをベースに Lambda で加工してから返す (画像に透かしを入れるとか) といったケースなんかが使い所でしょうか。

最後に・・・

この投稿は個人的なものであり、所属組織を代表するものではありません。ご了承ください。