Lambda Streaming responseで、S3に置いた大きなJSONデータを編集しながらレスポンスしたらどれくらい速いのか調べてみた

2023.05.08

4/7 のアップデートで Lambda が stream レスポンスを返せるようになりました。これにより、6MB よりも巨大なレスポンスを返すことができます。

前回調べたstream-jsonを用いることで、巨大な json を stream のまま編集することができます。stream のまま扱うことで展開する memory のサイズを抑えることができます。 lambda でこれを用いたときパフォーマンスにどのように影響するのかを調べるために、いくつかの実験をしてみました。

実験に使うデータ

実験に使うデータは以下の形式です。 1 分ごとの気温を記録した 1 年分のデータを模したもので、要素の数は 525,600 (365 * 24 * 60 ) 個です。

S3 に置く JSON ファイル

[
  [1648771200000, 12.6],
  [1648771260000, 17.7],
  [1648771320000, 29.1],
  ...
]

一要素目はエポックミリ秒、二要素目は気温を模したランダムな数値です。

DynamoDB

上記のJSONデータと比較実験するために同じItem数のDynamoDBテーブルを用意しました。

deviceId(PK) timestamp(SK) value
"001" 1648771200000 12.6
"001" 1648771260000 17.7
"001" 1648771320000 29.1
... ... ...

timestamp はエポックミリ秒、value は気温を模したランダムな数値です。 deviceId には"001"の固定値を仮り置きしています。

実験の内容

3 種類の lambda 関数をそれぞれメモリサイズ 128MB, 256MB, 512MB で実行し、レスポンス完了までにかかる時間を調べました。

すべての関数とも、取得したデータを 1 ヶ月分だけに filter して返します。

1. s3 のデータを返す(stream)

import { Writable, Readable } from "node:stream";
import { Handler } from "aws-lambda";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { chain } from "stream-chain";
import { parser } from "stream-json";
import { streamArray } from "stream-json/streamers/StreamArray";
import { disassembler } from "stream-json/Disassembler";
import { stringer } from "stream-json/Stringer";

const client = new S3Client({});

declare var awslambda: {
  streamifyResponse: (
    streamHandler: (event: JSON, responseStream: Writable) => Promise<void>
  ) => Handler;
};

const from = new Date("2022-04-01T00:00Z").getTime();
const to = new Date("2022-05-01T00:00Z").getTime();

export const handler: Handler = awslambda.streamifyResponse(
  async (event: JSON, responseStream: Writable) => {

    // S3のファイルを読み出すstreamを作成
    const output = await client.send(
      new GetObjectCommand({
        Bucket: process.env.BUCKET_NAME,
        Key: "large-chart-data.json",
      })
    );

    // streamを流れるJSONデータを、編集しながらresponseStreamに流し込む
    chain([
      output.Body as Readable,
      parser(),
      streamArray(),
      ({ value }) => {
        if (from <= value[0] && value[0] <= to) {
          return [value];
        }
        return [];
      },
      disassembler(),
      stringer({ makeArray: true }),
    ]).pipe(responseStream);
  }
);

2. s3 のデータを返す(非 stream)

import { Handler } from "aws-lambda";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const client = new S3Client({});

const from = new Date("2022-04-01T00:00Z").getTime();
const to = new Date("2022-05-01T00:00Z").getTime();

export const handler: Handler = async () =&gt; {
  const output = await client.send(
    new GetObjectCommand({
      Bucket: process.env.BUCKET_NAME,
      Key: "large-chart-data.json",
    })
  );

  // S3ファイルをすべて配列に展開して`filter()`する
  const body = await output.Body?.transformToString();
  const data = JSON.parse(body ?? "");
  return data.filter(
    (item: [number, number]) => from <= item[0] && item[0] <= to
  );
};

3. DynamoDB のデータを返す(非 stream)

import { Handler } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb";

const doc = DynamoDBDocument.from(new DynamoDBClient({}));

type TableItem = { type: string; timestamp: number; value: number };

const from = new Date("2022-04-01T00:00Z").getTime();
const to = new Date("2022-05-01T00:00Z").getTime();

export const handler: Handler = async () => {
  const items: TableItem[] = await queryRecursively();

  return items.map(({ timestamp, value }) => [timestamp, value]);
};

async function queryRecursively(exclusiveStartKey?: any): Promise<any[]> {
  console.info({ exclusiveStartKey });

  const { Items: items = [], LastEvaluatedKey: lastEvaluatedKey } =
    await doc.query({
      TableName: process.env.TABLE_NAME,
      KeyConditionExpression:
        "deviceId = :deviceId AND #timestamp BETWEEN :from AND :to",
      ExpressionAttributeValues: {
        ":deviceId": "001",
        ":from": from,
        ":to": to,
      },
      ExpressionAttributeNames: {
        "#timestamp": "timestamp",
      },
      ExclusiveStartKey: exclusiveStartKey,
    });
  return [
    ...items,
    ...(lastEvaluatedKey ? await queryRecursively(lastEvaluatedKey) : []),
  ];
}

実験結果

コールドスタートを除いて 5 回実行した平均を記録しました。 (簡易のため平均値のみを確認しました。)

lambda memory(MB) time(ms) used memory(MB)
s3 (stream) 128 81,524 120
s3 (stream) 256 40,241 144
s3 (stream) 512 18,050 174
s3 (非 stream) 128 33,847 128
s3 (非 stream) 256 3,892 256
s3 (非 stream) 512 1,733 319
DynamoDB 128 6,779 128
DynamoDB 256 3,402 163
DynamoDB 512 2,139 191

考察

すべての応答を返すまでにかかる時間は、s3 の stream が最も遅かったです。

また、memory を 512 まで引き上げたケースでは、s3 の非 stream と DynamoDB はほぼ同じ結果となりました。

テーブルの item がより複雑なケースや、s3 に置いた json からどのような加工をするかで、これらの結果は変化する可能性が大きいです。 そのためそれぞれのユースケースで検証が必要であると言えます。

まとめ

今回の調査で、Lambda の stream レスポンスを使用する場合は、パフォーマンス(特にすべてのレスポンスが完了するまでにかかる時間)は遅くなる可能性が高いことがわかりました。 公式アナウンス公式ブログにある通り、「6MB を超えるレスポンスが必要」や「レスポンスを順次表示することが重要」などのユースケースで有効な機能であると思います。

「レスポンスを順次表示することが重要」なユースケースではクライアントサイドでも stream-json によるパースは有効であると言えます。 また、応答に csv 形式を用いる場合は、同じ作者の stream-csv-as-json が有効になる場合があるかもしれません。

検証に使ったコードはすべてここに置いてあります。