メインコンテンツへスキップ

GraphQL × DuckDB × S3 を Lambda で動かして、ウォームクエリを10倍速くするまで

kiitosu
著者
kiitosu
aws community builder. 画像処理やデバイスドライバ、データ基盤構築からWebバックエンドまで、多様な領域に携わってきました。地図解析や地図アプリケーションの仕組みにも経験があり、幅広い技術を活かした開発に取り組んでいます。休日は草野球とランニングを楽しんでいます。
目次

はじめに
#

数 KB の Parquet を 5 ファイル読むだけのクエリが、100ms かかっていた。

サーバーレスで GraphQL API を組んでいたときの話です。常時起動の DB は持たず、S3 に置いた Parquet を DuckDB で直接クエリして GraphQL で返す——「アイドルゼロでコストを抑える」構成です。動いた。でも、遅い。しかもデータは笑えるほど小さい。

「S3 が遅いんだろう」と当たりをつけて手を打った結果、見事に空振りしました。そこから測り方を変えて、ようやく真犯人にたどり着く。最終的にウォームのレイテンシは 100ms → 10ms(約10倍) になりましたが、効いたのは最初に疑った場所ではありませんでした。

この記事は、その回り道も含めた記録です。コードはこちら → https://github.com/kiitosu/graphql-duckdb-s3

やりたかったこと
#

スタックは GraphQL Yoga + DuckDB(@duckdb/node-api) + S3。これを AWS Lambda(コンテナ + Lambda Web Adapter) に載せます。

GraphQL クライアント
   ↓
GraphQL Yoga(Lambda 上、LWA 経由で既存 HTTP サーバをそのまま起動)
   ↓ 薄いリゾルバ(GraphQL → DuckDB SQL)
DuckDB(同じ Lambda 内)  read_parquet('s3://...')
   ↓
S3(Parquet, dt パーティション)

LWA(Lambda Web Adapter)のおかげで、既存の Node HTTP サーバをほぼ無改造で Lambda に載せられます。サーバーは「ただの HTTP サーバ」のまま、Lambda Runtime API との通訳は LWA が肩代わりしてくれる。Function URL は IAM 認証にして、署名なしリクエストは起動前に弾く(匿名アクセスも invoke 課金も発生しない)。

…という構成図を描いている分には気分がよかったのですが、現実はそう甘くありませんでした。

第一の敵:そもそも動かない、そして15秒のコールド
#

DuckDB を Lambda コンテナに入れると、S3 読み取りがことごとく INTERNAL_SERVER_ERROR。GraphQL 層がエラーを丸めるので、CloudWatch の stderr を覗くまで原因が見えません。覗いて分かった「動かす前提」は3つでした。

  1. HOME=/tmp:Lambda の既定 $HOME/home/sbx_userNNNN)は存在せず書き込めない。DuckDB の拡張 autoinstall がここで Can't find the home directory で死ぬ。
  2. ca-certificates を入れるnode:slim は CA 証明書を持たず、拡張(httpfs/aws)の HTTPS 取得が TLS 検証で落ちる。
  3. glibc ベース + コンテナ内 npm ci:DuckDB は glibc 必須。linux/arm64 でビルドしコンテナ内で npm ci すれば、対応バイナリが解決される。

ようやく動いた。が、コールドが ~15 秒tsx の起動時トランスパイルと、拡張の実行時ダウンロードが重い。ここは素直に潰せました。

  • esbuild でビルド時にバンドルして node 実行(tsx を runtime から外す)
  • 拡張をビルド時にイメージへ焼き込み(runtime は LOAD のみ、DL しない)
  • DuckDB 接続を Init フェーズで事前確立(重い初期化を Init に前倒し。Init は CPU が一時的にブーストされるので速い)

これでコールドは ~2.1 秒まで落ちました。ここまでは順調だったんです。

違和感:データは数KBなのに、ウォームが100ms
#

問題はウォーム(暖まった状態)でした。users { orders } を引くと 約100ms。ユーザー1人と、その注文が入った Parquet が数ファイル、合計たかだか 10KB 程度。これに 100ms は、どう考えても多い。

「まあ S3 への往復があるしな」と最初は流しかけました。でも引っかかる。10KB を読むのに 100ms は、いくらなんでも。

寄り道(そして空振り):S3 が遅いに違いない
#

最初の仮説は単純でした。「S3 の読み取りが遅いんだろう。なら速い S3 を使えばいい」。ちょうど S3 Express One Zone(単一 AZ・低レイテンシ)がある。これだ、と。

ディレクトリバケットを CDK で立て、同じデータを置き、DuckDB をそちら向きに設定して、標準 S3 と A/B 計測しました。結果は——

ほぼ同じ。 誤差。むしろ僅かに遅い場面すらある。

完全な空振りでした。「速い S3」にしても1ミリも改善しないということは、そもそもボトルネックは S3 のデータ読み取りではない。当たりの付け方が間違っていた。Express は撤去しました。

ここで方針を変えます。推測で殴るのをやめて、ちゃんと分解して測る。

測り直す:犯人は「読む」ではなく「探す」だった
#

ローカルからの壁時計はクライアント→東京の RTT が乗ってブレるので、サーバー側の Duration(CloudWatch の REPORT 行)を各30サンプル取り、クエリを種類別に分解しました。

クエリ サーバー側 中央値
{ __typename }(DuckDB も S3 も触らない) 3ms
users(1ファイル・直接パス) 6ms
users + orders(glob orders/*/*.parquet 101ms

これを見た瞬間、像が反転しました。

  • Yoga + LWA のオーバーヘッドは 3ms
  • users を1ファイル読むのは 6ms
  • なのに orders を足した途端 101ms約95ms が orders だけで消えている。

users と orders の違いは、データ量ではありません(どちらも極小)。読み方です。users は users/users.parquet直接パスで読む。orders は orders/*/*.parquet という glob。glob はファイルを見つけるために、まず S3 に LIST を投げる

1行の実験で確信に変わる
#

同じ5ファイルを、glob と「明示的にパスを並べた読み方」で比べてみました。

read_parquet('orders/*/*.parquet')  (glob, LIST 必要) : 55ms
read_parquet([... 明示5パス ...])     (LIST 不要)        :  1ms

決まりです。真犯人は「毎クエリ走る glob の S3 LIST(ファイル探索)」。データを読む処理は 1ms で終わっていた。重かったのは「読む」ことではなく「どのファイルがあるかを毎回 S3 に問い合わせる」ことだったのです。

ここで、さっきの Express の空振りもようやく腑に落ちました。ボトルネックがデータ転送ではなく LIST だったなら、S3 を速くしても効くわけがない。measure せずに当たりを付けた報いでした。

10倍:直し方はシンプルだった
#

犯人が分かれば対策は素直です。ファイル一覧を一度だけ取って TTL でキャッシュし、各クエリは明示パスで読む。LIST が「毎クエリ」から「コンテナ1回 / TTL」に変わります。

let cache: { expr: string; at: number } | null = null;
const TTL_MS = 60_000;

export async function ordersFrom(): Promise<string> {
  const now = Date.now();
  if (cache && now - cache.at < TTL_MS) return cache.expr;
  const conn = await getConnection();
  const reader = await conn.runAndReadAll(
    `SELECT file FROM glob('s3://${BUCKET}/orders/*/*.parquet')`,
  );
  const files = reader.getRowObjectsJS().map((r) => String(r.file));
  const expr = files.length === 0
    ? `read_parquet('s3://${BUCKET}/orders/*/*.parquet', hive_partitioning=true)`
    : `read_parquet([${files.map((f) => `'${f}'`).join(', ')}], hive_partitioning=true)`;
  cache = { expr, at: now };
  return expr;
}

結果、ここまでの道のりはこうなりました。

施策 ウォーム中央値
初期 131ms
メタデータキャッシュ(enable_object_cache 等) 103ms
glob LIST 排除(明示パス + TTL) 10ms

101ms → 10ms、約10倍。 データはあくまでライブ取得(結果キャッシュではない)で、新しいパーティションも最大60秒で見えるので、バッチ追記型のワークロードと相性がいい。

しかし、これはスケールしない
#

気持ちよく終わりたかったのですが、もう一段あります。明示パス化は速いけれど、読むファイル数自体は減っていないuser_id で引くと全 dt パーティションを走査する「疎なアクセス」は、ファイル数に線形に効いてきます。実 S3 でファイル数を振ると:

ファイル数 | glob(S3 LIST) | explicit(LIST無)
   100     |   74.5ms      |   9.9ms
  1000     |  195.4ms      |  94.5ms
  4000     | 1002.4ms      | 418.2ms

explicit は「定数項が良い」だけで、傾き(線形悪化)は変わらない。これを断つには「読むファイル数を減らすレイアウト」しかありません。例えば user_id % Nバケット化(固定数のグループに畳んで分割)すれば、ユーザー引きは1ファイル読みで規模非依存になります(合成データで 365日分:dt 分割 22ms/365ファイル vs バケット 2.6ms/1ファイル)。ただし日付クエリとはトレードオフで、どのアクセスパターンに最適化するかはビジネス判断。ここから先は「速さ」ではなく「設計」の話になります。

学び
#

振り返ると、この回り道から得たものは技術の小ネタより、進め方そのものでした。

  • 「遅い=○○のせい」と決めつけて殴ると外す。 S3 Express は典型的な空振りで、ボトルネックを取り違えていた。
  • 分解して測ると像が反転する。 __typename 3ms / users 6ms / orders 101ms と並べた瞬間に犯人が見えた。1行の比較実験(glob 55ms vs 明示 1ms)が確信に変えた。
  • 副作用なく効く「透過的な」最適化はすぐ尽きる。 メタキャッシュと glob LIST 排除でほぼ打ち止め。その先(結果キャッシュ=鮮度、Provisioned Concurrency=コスト、レイアウト=設計)は必ずトレードオフを伴う。

そして構成自体の向き不向きも腹落ちしました。S3 直読みは「アクセスパターンが既知で限定的」なときに強い。任意の JOIN・任意の点引きを低レイテンシでとなると、それは RDB やウェアハウスの土俵です。

数 KB のクエリに 100ms かかっていた謎の正体が「データを読む時間」ではなく「ファイルを探す時間」だった、というのが、今回いちばんの収穫でした。

コードはこちら → https://github.com/kiitosu/graphql-duckdb-s3

Reply by Email

関連記事

Lambda Durable Functionsを使ってみる — Step Functionsとの比較からハンズオンまで
Lambda Powertoolsコントリビュート:typescript編
CDKのAspectsでCDKバージョンアップ時のリソース不整合を解決する