【アドベントカレンダー2025】DuckDBで変わる繁忙期対策のログ分析とサイジング - 1時間の1%に潜む36秒のリスク-

はじめに

こんにちは。Reservation Service Devグループの佐藤です。ネット予約に関する開発全般に関わっています。

本記事では、繁忙期前のサイジングや新機能リリース時の負荷予測で利用しているログ分析について、従来利用していたSQLiteからDuckDBへ移行し、分析フローを改善した事例を紹介します。また、パーセンタイル分析を活用した具体的なminReplicasの算出方法や、「99%なら無視できる」という誤解を解くデータドリブンなサイジングアプローチについても解説します。

注意事項
本記事で紹介するサイジング手法は、予約システムという特性上、ビジネスクリティカルな操作でのエラーを極力避けるための保守的なアプローチです。ぐるなび社内では、各サービスの特性やビジネス要件に応じて、適切なサイジング方針を採用しています。本記事の内容は一つの事例として参考にしていただき、読者の皆様のサービス特性に合わせて判断していただければと思います。

システム概要と課題

ネット予約システムの特徴

私が担当しているネット予約システムは多くのユーザーに利用いただいており、システムのパフォーマンスとスケーラビリティが極めて重要です。特に、毎年11月〜1月の忘新年会シーズンはアクセスが集中するため、事前にリソース計画を行い、必要に応じてPod数の最小値(minReplicas)の引き上げなどを実施しています。

現在のモニタリング体制

社内ではNew RelicやKibanaが導入されており、各コンテンツごとにダッシュボードを作成して定期的にモニタリングすることが推奨されています。

ネット予約システムでも以下のような主要項目を常時監視しています。

  • スループットの変化(全体/APIパスごと)
  • レスポンスタイムの変化(全体/APIパスごと)
  • 予約数の変化(デバイスごと)

データ保存とサイジングの課題

サイジング実施時期の制約

繁忙期に向けたサイジングは通常9月頃に行いますが、この時点では「昨年の繁忙期データ」がログ保存期間のポリシーによってNew Relic等から参照できなかったり、データが丸められて詳細な粒度(秒単位など)で取得できなくなっていることがあります。

分析用ログの保存

そのため、サイジングのタイミングで確実に詳細データへアクセスできるよう、毎年繁忙期のアクセスログ(生ログ)を分析用に別途保存するようにしています。これにより、高解像度のデータが必要な場合でも、保存したログを再集計して正確なリソース見積もりを行っています。

※ 分析は適切なアクセス権限管理が行われているセキュアな環境下で実施しています

DuckDB導入前の分析手法

従来の分析方法と課題

以前は、保存したアクセスログをシェルスクリプトで加工・集計していました。しかし、この方式は属人化しており、他のメンバーが同じ分析を行うことが難しい状態でした。

SQLiteを使った分析の導入

属人化解消と分析の自動化を目指し、以前はSQLiteを利用した分析フローを導入していました。 構築にあたっては、以下のツールを参考にしました。

参考にしたツール: asql

ログをSQLiteにインポートし、SQLで分析を行う形です。

SQLiteでのクエリの複雑さ

SQLiteは手軽で便利ですが、分析用の関数は限定的です。例えば「時間帯別の秒間アクセス数のパーセンタイル値(95パーセンタイル値など)」を出そうとすると、標準関数が存在しないため、以下のような複雑なクエリを書く必要がありました。

(asqlのプロンプトでは改行やCTEが使えない制約があるため、一度 save コマンドで保存してsqliteで開き直すなどの工夫も必要でした)

SQLiteでの集計クエリ例:

WITH sec_counts AS (
  SELECT 
     strftime('%Y-%m-%d %H時', date) as hour_of_day,
     strftime('%Y-%m-%d %H:%M:%S', date) as log_ts,
     count(*) as cnt 
  FROM logs
  WHERE date >= '2024-12-04T10:00:00' and date < '2024-12-04T23:00:00'
  GROUP BY 1,2
),
ranked_raw_data AS (
  SELECT 
    hour_of_day,
    cnt,
    -- パーセンタイルを手動で計算するためにランク付けを行う
    percent_rank() over (partition by hour_of_day order by cnt)*100 as value
  FROM sec_counts
),
ranked_data AS (
  SELECT 
    hour_of_day,
    cast(value AS INT) as percent,
    max(cnt) as max_cnt
  FROM ranked_raw_data
  GROUP BY 1,2
)
SELECT
  hour_of_day,
  max(case when percent < 50 then max_cnt else 0 end) as p50,
  max(case when percent < 75 then max_cnt else 0 end) as p75,
  max(case when percent < 90 then max_cnt else 0 end) as p90,
  max(case when percent < 95 then max_cnt else 0 end) as p95,
  max(case when percent < 99 then max_cnt else 0 end) as p99,
  max(case when percent <= 100 then max_cnt else 0 end) as p100
FROM ranked_data
GROUP BY hour_of_day
ORDER BY hour_of_day;

このように、SQLiteではパーセンタイル算出のために複雑なサブクエリとランク関数を駆使する必要があり、クエリの可読性とメンテナンス性が課題でした。

DuckDBの導入

DuckDB採用理由

最近、X(旧Twitter)や技術カンファレンスの発表資料などでDuckDBを見かける機会が増えており、気になっていました。 今年の繁忙期対策に向けた分析環境の見直しにあたり、AWS Athenaなどのクラウド基盤も検討しましたが、今回は試行錯誤のコストを気にせず『ライトに』分析できる点を評価し、DuckDBを採用しました。

  • セットアップの容易さ: DuckDBは read_csv 関数で生ログ(gzファイル)を直接参照できるため、データの準備時間がほぼゼロで分析を開始できる。
  • 単一ノードで処理可能なデータ規模: 今回の分析対象は数GB程度であり、DuckDBのような単一ノードのデータベースで分析可能なデータサイズである。
  • 高機能な分析関数のサポート: quantile_disc や、大規模データ向けの approx_quantile などの統計関数が標準で使える。

DuckDBでの分析フロー

1. DuckDBのインストール

分析用サーバー上などで環境を汚さずに実行するため、Dockerを利用するのがおすすめです。

docker run --rm -it -v "$(pwd):/workspace" -w /workspace duckdb/duckdb

2. ログファイルの読み込み

Apache等のアクセスログ(gz圧縮)を、正規表現を使って直接テーブルとして読み込むことができます。以下はCombined Log Format風のログをパースする例です。

CREATE OR REPLACE VIEW logs AS
WITH raw_data AS (
    SELECT 
        raw as line,
        filename
    -- DuckDBではdelim=NULLとすることで、1行を1カラムとして読み込める
    -- これにより、正規表現で柔軟にパースできる
    FROM read_csv(
        '*.gz', -- ワイルドカードで複数ファイルを一括読み込み
        columns={'raw': 'VARCHAR'},
        header=false,
        delim=NULL, -- 区切り文字なし = 行全体を1カラムとして扱う
        quote=NULL,
        auto_detect=false,
        filename=true
    )
)
SELECT
    filename,
    -- 日時パースとJSTへの変換
    strptime(
      regexp_extract(line, '\[(.*?)\]', 1),
      '%d/%b/%Y:%H:%M:%S %z'
    ) + INTERVAL 9 HOUR as date,
    regexp_extract(line, '"([A-Z]+) ', 1) as http_method,
    split_part(
      regexp_extract(line, '"[A-Z]+ (.*?) HTTP', 1), '?', 1
    )  as request_path,
    try_cast(
      regexp_extract(line, ' (\d+)$', 1) as INTEGER
    ) as duration_micros
FROM raw_data;

3. 分析クエリの実行

DuckDBには quantile_disc などの分析関数があるため、SQLiteで数十行書いていたクエリが劇的に短くなります。

WITH sec_counts AS (
    SELECT 
        date_trunc('hour', date) as hour_start,
        date_trunc('second', date) as second,
        count(*) as cnt
    FROM logs
    WHERE date >= '2024-12-04T10:00:00' AND date <  '2024-12-04T23:00:00'
    GROUP BY 1, 2
)
SELECT
    strftime(hour_start, '%Y-%m-%d %H時') as hour_of_day,
    quantile_disc(cnt, 0.50) as p50,
    quantile_disc(cnt, 0.75) as p75,
    quantile_disc(cnt, 0.90) as p90,
    quantile_disc(cnt, 0.95) as p95,
    quantile_disc(cnt, 0.99) as p99,
    max(cnt) as p100
FROM sec_counts
GROUP BY hour_start
ORDER BY hour_start;

4. (オプション)バッチ実行と結果出力

複数のクエリを analyze.sql として保存しておけば、Dockerコマンド一発で実行可能です。

docker run --rm -v "$(pwd):/workspace" -w /workspace duckdb/duckdb duckdb -c ".read analyze.sql"

また、COPY コマンドを使えば、SQLの結果を直接CSVやParquetファイルとして出力できます。

COPY (
    <実行するクエリ>
) TO 'query_output.csv' (HEADER, DELIMITER ',');

分析結果を活用したサイジング

パーセンタイル分析の重要性

DuckDBを使った分析で得られるパーセンタイル値は、minReplicasの調整判断において非常に重要な指標となります。

特に注目すべきは 99パーセンタイル値 です。「99%なら無視しても問題ない」と考えるエンジニアもいますが、実際にはそう単純ではありません。

99パーセンタイルが示す現実

例えば、上記のクエリで1時間単位の秒間アクセス数を集計した結果の99パーセンタイル値。これは何を意味するのでしょうか?

1時間は3,600秒です。その99%は3,564秒、残りの1%は36秒です。

つまり、1時間のうち合計36秒間は、99パーセンタイル値を超える負荷が発生するということです。

統計的には、この36秒間が時間内に分散している可能性もありますが、予約システムのようにスパイクアクセスが発生しやすいサービスでは、テレビ露出やSNS拡散により、この36秒間が集中して発生するリスクがあります

1時間における「1%」の存在感

具体例で考える

実際の数値で考えてみましょう:

  • 50パーセンタイル(中央値): 100 req/sec
  • 95パーセンタイル: 120 req/sec
  • 99パーセンタイル: 200 req/sec
  • 最大値: 250 req/sec

この場合、1時間のうち36秒間は200 req/sec以上の負荷が継続する可能性があります。もし現在のminReplicasが、150 req/secまでしか対応できない設定だったらどうなるでしょうか?

HPAの反応遅延による「魔の36秒」

95パーセンタイル(120 req/sec)程度であればHorizontal Pod Autoscaler(HPA)のトリガー設定によってはスケールアウトしない、もしくは緩やかにスケールする程度かもしれません。しかし、99パーセンタイルで急激に200 req/secまで跳ね上がる場合、HPAがスケールアウトを開始しても、新しいPodが起動してトラフィックを受け付けられるようになるまでの間、既存のPodは処理能力の限界を超える負荷を処理しなければなりません。この間、レスポンス遅延やタイムアウトが発生し、最悪の場合は無応答となり、ユーザー体験が著しく悪化します。

つまり、重要なのは「minReplicasを必要以上に大きくする」ことではなく、HPAがスケールアウトを完了するまでの間も、システムが処理できる限界値以内に収まるminReplicasを確保することです。

サイジング時の確認ポイント

繁忙期のサイジングを行う際は、以下を確認することをお勧めします:

  1. 1時間単位での99パーセンタイル値を確認

    • 「1%だから無視できる」ではなく、「最大36秒間この負荷が続く可能性がある」と認識する
  2. 現在のminReplicasで99パーセンタイル値に耐えられるか検証

    • HPAの反応速度も考慮する
    • スケールアウトが間に合わない可能性を想定する
  3. 余裕を持った設定

    • 99パーセンタイル値に対して、少なくとも20-30%の余裕を持たせる
    • 突発的なトラフィック増加にも対応できる設計にする

minReplicasの具体的な算出方法

基本方針:HPAが間に合わない「魔の数分間」を埋める

テレビ露出のような、垂直的に立ち上がるスパイクアクセスが発生した場合、HPAが検知・判断し、Podが起動してReadyになるまでの数分間は、スケールアウトが物理的に間に合いません。

この数分間を乗り切るためには、あらかじめ一定数のPod(minReplicas)を確保しておく必要があります。

システムの処理能力を把握する(AとBの使い分け)

まず、自システムの処理能力を以下の2つの基準で定義します:

  • A: 安定して処理できるアクセス数

    • レスポンスタイムが正常範囲内で、エラー率も低い状態を維持できる値。
    • 用途: HPAのターゲット値(平常時はここを維持して快適に)。
    • 例: 40 req/sec/pod
  • B: ギリギリ処理できるアクセス数

    • レスポンスタイムは悪化するが、Service Level Objective(SLO)で定めた基準は満たせる限界値。
    • 用途: minReplicasの算出基準(スパイク時の短時間ならこの値で耐えられる)。
    • 例: 80 req/sec/pod

これらは負荷試験を実施した上で実績値ベースから算出するようにしてください。

minReplicasの計算式

99パーセンタイル(ピーク負荷)に合わせてminReplicasを計算する際、分母に Aを使うと、必要台数が膨大になりコストが過大(富豪的)になってしまいます。

そこで、あえて分母に B を採用します。 これにより、「コストを現実的な範囲に抑えつつ、スパイク初動でSLO違反せずに耐え凌ぐ」 という、コストと可用性のバランスを取ったサイジングを行います。

必要なminReplicas = ⌈99パーセンタイル値 ÷ B⌉ + 1〜2台

計算例:

  • 99パーセンタイル値: 200 req/sec
  • A(快適値): 40 req/sec/pod
  • B(限界値): 80 req/sec/pod

比較:

  • Aで計算した場合: 200 ÷ 40 = 5台 (快適だがコストが高い)
  • Bで計算した場合: 200 ÷ 80 = 2.5 → 3台 (ギリギリだがコストを抑えられる)

このようにBを基準にすることで、過剰投資を防ぎながら、スパイク時でもSLO違反しない最低限のPod数を確保します。

なぜ「切り上げ + 1〜2台」の余裕を持たせるのか

重要: 99パーセンタイル値は、あくまで過去の繁忙期データから算出した推測値です。今年の繁忙期が昨年と全く同じ負荷パターンになる保証はありません。

Bを基準にしているため、計算上ギリギリの台数ではリスクが残ります。以下の「不確定要素」を吸収するためにバッファ(+1〜2台)を追加します。

  • 想定外のスパイク: 過去の99パーセンタイルをさらに上回る突発的なトラフィック
    • 例: 昨年にはなかった大型キャンペーン、テレビ露出、SNSバズなど
  • HPAの反応ラグ: メトリクス収集から判定までのタイムラグ
  • コールドスタート: Pod起動後、アプリケーションが完全に暖機運転(Warm-up)を終えるまでのパフォーマンス低下
  • 負荷分散の不均等性: 特定のPodに負荷が偏る可能性

理想的なサイジング指標

安定した運用を実現するには、AとBの2つの基準値を適切に使い分けます:

  • minReplicasの設定: 99パーセンタイル値がB(ギリギリ処理できる値)で処理できることを確認

    • 99パーセンタイル値 < B × minReplicas(+余裕)
    • オートスケール中に過負荷で無応答にならないための最低限の保証
  • HPAの設定: A(安定して処理できる値)を維持できるように設定

    • Aを超過してBに近づくタイミングでスケールアウトが完了するようなポリシー設定
    • 例: CPU使用率やカスタムメトリクス(req/sec/pod)をトリガーに設定
    • 平常時からピーク時まで、常に安定したレスポンスタイムを維持

つまり、Bはあくまでオートスケールが間に合わない瞬間の高負荷を「耐え凌ぐ」ための基準であり、実際の運用では余裕のあるAを基準にスケールさせることで、常に快適なユーザー体験を維持します。

サービス特性に応じた判断

minReplicasの設定は、コストと性能のトレードオフがあるため、サービスごとに最適解は異なります。

本記事で99パーセンタイル値を重視しています。これは、ネット予約システムにおいて繁忙期の予約登録時にユーザーがエラーを体験することを極力避けるための保守的な設定です。予約というビジネスクリティカルな操作において、1%のユーザーであってもエラーや遅延が発生すると、機会損失や顧客満足度の低下に直結します。

一方で、以下のようなサービス特性であれば、より低いパーセンタイル(例:95パーセンタイル)を基準にすることも合理的です:

  • 読み取り専用の情報参照サービス
  • リトライが容易で影響が小さいAPI
  • コスト最適化を優先するサービス

重要なのは、自サービスのビジネス要件とコストのバランスを考慮し、どのパーセンタイル値を基準にするか意識的に判断することです。その判断材料として、DuckDBを使った詳細なログ分析が有効です。

おわりに

DuckDBを導入したことで、煩雑だった集計クエリがシンプルになり、ログ分析の敷居を大きく下げることができました。特に、アドホックな分析が必要になった際も、SQLの強力な分析関数のおかげで素早く対応できています。

さらに、パーセンタイル分析を活用することで、minReplicasのサイジングに明確な根拠を持たせることができるようになりました。「なんとなく」や「去年と同じ」ではなく、データに基づいた意思決定により、繁忙期でも安定したサービス提供を実現しています。

今回はアクセスログの集計に利用しましたが、ローカルにある大規模CSVの操作など、ちょっとしたデータ加工ツールとしても非常に優秀だと感じました。気になった方はぜひ試してみてください。


2014年中途入社
趣味:競プロ / アニメ / 映画 / 美術展 / ランニング / フットサル 知的好奇心とカロリー消費のために動き回る、万年ダイエッターのエンジニア