はじめに

こんにちは、同志社大学理工学部情報システムデザイン学科4年生の金谷一輝です。

2026年7月の1ヶ月間、CyberAgent の就業型インターンシップ「CA Tech JOB」に参加し、株式会社 AJA の DSP チームでバックエンド開発に取り組みました。

参加した経緯

私はこれまで、大規模なサービスのバックエンド開発を経験したことがありませんでした。そのため、大量のトラフィックを低レイテンシで処理する仕組みや、負荷に応じて柔軟にスケールできるアーキテクチャに関心がありました。また、実装するだけでなく、長期間安定して運用するための設計やインフラ、監視についても実務を通して学びたいと考え、CA Tech JOB に参加しました。

今回担当したのは、広告配信基盤におけるキャッシュ戦略の改善です。サービスの更新やスケールアウトで新しい Pod が起動すると、キャッシュが空のためデータベースへのアクセスが急増し、広告配信の性能が低下するコールドスタート問題がありました。この問題に対し、原因の調査、設計、実装、検証、リリース、監視基盤の整備まで取り組みました。

本記事では、その過程でどのような意思決定を行い、どのような成果が得られたのかを紹介します。最終的に、低レイテンシを維持しながら、新しいPodの起動時にデータベースへ読み取りが集中する構造を改善し、毎回40分かかっていたリリースを15分に短縮しました。

AJA DSP と広告候補の取得

DSPとは

動画配信サービスやアプリで動画広告が表示される際、裏側では広告枠を対象としたオークションが行われます。DSPは広告主が設定した予算や配信条件をもとに広告候補を選び、入札するシステムです。

この処理は広告が表示される直前に行われるため、短い時間で応答する必要があります。また、時間帯やイベントによってリクエスト量が大きく変動するため、低レイテンシに加えて柔軟にスケールできることも重要です。

AJA DSP の構成と Deal 取得

AJA DSP は、役割ごとに複数のコンポーネントに分割されたマイクロサービスアーキテクチャで構成されています。各コンポーネントは主に Go で実装され、Google Kubernetes Engine(GKE)上で稼働しています。コンポーネントごとに独立してリリースでき、負荷に応じて Horizontal Pod Autoscaler(HPA)が Pod 数を調整します。データベースは、スケーラブルで整合性の強い Google Cloud Spanner を使用しています。

広告枠の売り手と買い手の間であらかじめ合意された取引条件を Deal と呼びます。1つの Deal には配信対象となる広告情報やターゲティング条件が紐づいています。
今回私が担当したのは、Deal に紐づく広告候補を取得するコンポーネントのキャッシュ戦略の改善です。

AJA DSP では、広告枠のリクエストに含まれる Deal ID をもとに、Deal に紐づく広告候補を Spanner から取得します。この取得には複数テーブルの JOIN が必要です。リクエストのたびに同じクエリを実行していると Spanner の負荷が上がり応答時間が増えるため、取得した広告候補を各 Pod がローカルメモリへキャッシュしていました。

課題:「新しい Pod のキャッシュは空」

各 Pod はそれぞれ独立したキャッシュを持っています。また、直近に参照された Deal ID を一定間隔で Spanner からまとめて再取得し、キャッシュを更新する仕組みもありました。

通常時はキャッシュが十分に温まっているため、多くのリクエストはメモリ上のキャッシュがヒットし、Spanner へ問い合わせずに Deal を返せます。一方、ローリングアップデートやスケールアウトによって新しく起動した Podには、キャッシュも参照履歴もありません。

このように起動直後の Pod ではリクエストされた Deal のキャッシュミスが続き、その度にSpanner へ問い合わせます。複数の Pod が同時に起動すると、それぞれが同じ Deal を取得するために、読み取りクエリが Spanner に集中します。その結果、Spanner の負荷とレイテンシが上昇し、システムの応答速度に影響していました。

暫定対応として、リリース時のローリングアップデートでは Pod を1台ずつ入れ替え、キャッシュが温まるまで次の Pod の起動を待っていました。これにより、Spanner への急激な負荷集中は抑えられましたが、毎回のリリースに約40分かかるようになりました。また、HPA によるスケールアウトや Pod の再作成でキャッシュが失われる構造は残ったままでした。

そこで、通常時の低レイテンシを維持しながら、Pod の起動による Spanner への読み取り集中とリリース時間を改善することを目標としました。

メトリクスから原因を検証する

まず、「新しい Pod のキャッシュが空であるため、Spanner への読み取りが集中している」という仮説を検証しました。負荷試験の一環で Pod をスケールさせるタイミングがあり、Pod 数、Spanner の負荷、各 Pod のメモリ使用率を同じ時間帯で観察することができました。

Pod 数が増加した直後、Spanner の CPU 使用率と読み取りレイテンシが上昇しており、応札数も低下していました。一方、新しい Pod のメモリ使用率が既存の Pod と同程度まで増えると、Spanner の負荷と応札数も元の水準へ戻っていました。

また、この時間帯に流入したリクエスト数には大きな変化がありませんでした。そのため、単純にリクエストが増えたのではなく、新しい Pod で発生したキャッシュミスによってSpanner への読み取りが増えたと考えました。

 

Pod 数の推移(13:30頃から Pod 数が増加)

Spanner CPU 使用率(Pod 数の増加に伴い負荷が上昇)

Spanner 読み取り p99 レイテンシ(Spanner の負荷に伴い上昇)

Pod のメモリ使用率の推移(新しい Pod のメモリ使用率は10分程度で既存の Pod と同程度に収束し、収束に伴い Spanner への負荷も安定した)

 

この仮説をさらに検証するために、別の時間帯に Pod 数を段階的に増やして観測しました。

1〜3台の同時起動では大きな変化は見られませんでしたが、4台を同時に増やした際には、 Spanner の読み取り p99 が平常時の約4倍まで上昇しました。

Spanner の p99 レイテンシ(11:45頃に4 pod が起動した影響でレイテンシのスパイクが発生)

いずれの時間帯も、Pod のメモリ使用率はおよそ10分で既存の Pod と同程度まで増加することも確認できました。

これらの結果から、同時に起動する Pod が増えるほど、Spanner への読み取りが集中し、各 Pod のキャッシュが温まるにつれて負荷が収束していると判断しました。

解決策の比較と採用理由

課題を解決するにあたり、次の3つの方法を検討しました。

案1:直近で参照された Deal ID を外部ストアへ保存し、新しい Pod の起動時に一括取得

メリット:

  • 通常時のキャッシュ取得経路の変更が不要

デメリット:

  • 通常時と起動時で異なる取得経路を保守する必要がある
  • 起動時のキャッシュ復元のためだけに外部ストアを用意するため、用途に対して構成が過剰

案2:ローカルキャッシュを廃止して共有キャッシュを導入。すべてのPodが共有キャッシュを直接参照

メリット:

  • 構造がシンプル
  • 起動時、通常時でキャッシュ取得の経路を統一できる

デメリット:

  • すべてのキャッシュ取得でネットワーク I/O が発生し、レイテンシに影響する可能性
  • 共有キャッシュに負荷が集中する可能性
  • 共有キャッシュ障害時の影響範囲が広い

案3:ローカルキャッシュの後段に共有キャッシュを配置

メリット:

  • キャッシュ取得における低レイテンシと Pod 間のキャッシュ共有を両立できる
  • 共有キャッシュへの負荷を抑制できる
  • 起動時、通常時でキャッシュ取得の経路を統一できる

デメリット:

  • 共有キャッシュ障害時の影響範囲が広い
  • 構成が他の案と比較して複雑

 

低レイテンシを維持しながら Pod 間でキャッシュを共有でき、起動時と通常時の取得経路も統一できるため、案3を採用しました。

共有キャッシュには、以下の理由から Google Cloud の Memorystore for Valkey を選びました。

  1. インメモリの Key-Value Store で低レイテンシ
  2. Google Cloud のマネージドサービスとして運用負荷を抑えられる
  3. オープンなガバナンスと BSD 3-Clause License で長期的に利用しやすい
  4. Redis 互換の API によって既存の実装や運用経験などを活用できる

採用したキャッシュ設計

Pod 内のローカルキャッシュを L1、Valkey 上の共有キャッシュを L2 と呼び、Deal の取得経路を「L1 → L2 → Spanner」に変更しました。

TTL とデータの鮮度

従来のキャッシュ戦略では Spanner から取得した値を最大5分間使用していました。この鮮度を変えないため、L1 の TTL を1分、L2 の TTL を4分としました。

これにより、L2 の期限直前に取得した値が L1 へ保存された場合でも、L1 は1分で失効するため、Spanner から取得してから最大5分以内の値を返せます。

Deal が存在しなかった結果を保存する negative cache にも同じTTLを適用しました。

期限切れ前のバックグラウンド更新

頻繁に参照される Deal が一斉に期限切れになると、複数の Pod が同時に Spanner へ問い合わせる可能性があります。そこで、L2 へのアクセス時に残り TTL が1分以下であれば、現在の値はすぐに返し、その Pod がバックグラウンドで更新を試みるようにしました。

更新前には Valkey 上で短い lock を取得します。lock を取得できた Pod だけが代表して Spanner から再取得することで、Pod 間で重複して Spanner に問い合わせるのを防ぎます。Go の singleflight のような処理を、Pod 間で実現するイメージです。

更新専用の worker を用意する方法も検討しました。しかし、常駐するコンポーネントが増えることに加え、キャッシュ構造が変わるたびに worker も追随してリリースする必要があります。そのため、リクエストを処理した Pod が更新する構成としました。

L2 miss では lock させない

L2 に値がない場合、Pod 間の重複取得を防ぐには、取得を担当する Pod を決め、他の Pod が完了を待つ仕組みが必要です。しかし、待機やポーリングによって応答時間が別の Pod に左右され、実装も複雑になります。

頻繁に参照される Deal は期限切れ前に更新され、一度取得した値は全 Pod で共有できます。そのため、削減できる読み取りに対して複雑性が見合わないと判断し、L2 miss 時は各 Pod が待たずに Spanner へ問い合わせる方針としました。同じ Pod 内で発生した重複取得は、Go の singleflight で1回の問い合わせにまとめます。L2 の接続エラーやタイムアウト時もSpanner へフォールバックし、リクエスト自体は継続します。

Schema Hashによるキャッシュ形式の分離

ローリングアップデート中は、新旧バージョンの Pod が同時に動作します。保存する Dealのスキーマが異なる場合、同じキャッシュを共有すると取得に失敗してしまいます。

そこで、キャッシュする構造体のフィールド名・型・シリアライズ用のメタデータから Schema Hash を自動生成し、キーに含めました。

構造体が変わるとキー空間も自動的に分かれるため、新旧の Pod はそれぞれ互換性のあるキャッシュだけを参照します。これにより、新旧 Pod で非互換なキャッシュを自動で分離することができます。古いキーは TTL によって自然に削除されます。

デプロイ戦略と運用

AJA DSP では、Argo CD と Argo Rollouts を利用しており、高度なデプロイ戦略をとることが可能です。

Schema Hash が変わるリリースでは、新しいキー空間にキャッシュが存在しません。この状態で Pod を同時に展開すると、各 Pod が L2 miss を起こし Spanner への読み取りが集中します。そのため、最初に 1Pod だけを起動して L2 キャッシュを温め、その後に残りのPod を展開する方針としました。L2 キャッシュを温める期間は、前半の検証により、新しい Pod がローカルキャッシュを温める時間と同じ10分が適切だと判断しました。

リリース中にロールバックする判断基準も事前に決定し、落ち着いてリリースする準備をしました。

また、リリース後の障害へ早急に対応できるよう、Valkey と Spanner の状態を Datadog で監視できるようにメトリクスを設定しました。さらに、PagerDuty を設定し、異常を検知した場合は担当者へコールが届くようにしました。リリースして終わりではなく、障害を早期に検知して対応できるところまでを見据えて取り組むことができました。

Pod の起動状況やデプロイの進行、各リソースの状態を一画面で確認し、全体を把握しながらリリースを進めることができました。

結果

導入前

導入前は、Pod の起動後にローカルキャッシュが温まるまでメモリ使用量が増加し、その時間帯に Spanner の p99 レイテンシも上昇していました。

導入後

導入後は、スケールアウトや Pod の再配置によって新しい Pod が起動しても、Spanner の p99 レイテンシはおおむね一定で、Pod 起動時のキャッシュミスに伴う負荷の上昇は確認されませんでした。

これにより、Pod を1台ずつ待機させていたリリース制約を緩和でき、リリース時間を約40分から約15分へ短縮できました。

今後の展望

現在は安全性を優先し、リリース時に最初の 1Pod へトラフィックを流して L2 を10分間温めてから、残りの Pod を展開しています。この10分の待機時間は事前の検証をもとに設定した初期値であり、L2 のヒット率や Spanner 負荷の実測に応じて短縮できる余地があります。また、Schema Hash が変わらないリリースでは既存の L2 のキャッシュをそのまま利用できるため、本来は待機時間を省略できます。今後は待機時間の調整、Schema Hash の変更有無に応じたリリースの最適化を目指したいです。

インターンを通して学んだこと

インターンを通して、大規模サービスでは機能を実装するだけでなく、厳しいレイテンシ要件を満たしながら、長期間安定して運用できる状態まで設計することが重要だと学びました。性能、可用性、運用を個別に考えるのではなく、システム全体として両立させる必要があることを、実際のサービスの中で経験できました。

特に印象に残ったのは、性能や可用性、実装の複雑さ、将来の運用負荷といったトレードオフを整理し、システム全体として最適な判断を行うことです。今回も、refresh 専用の worker を増やさず既存のリクエスト経路で完結させることや、構造体の変更に応じてキャッシュのキー空間を自動的に分離することで、後から保守する開発者の負担を減らす設計を目指しました。

今回のインターンでは、Go、Kubernetes、マイクロサービス、Argo CD、Google Cloud、Terraform、Atlantis、Valkey、Datadog、PagerDuty など、大規模かつ低レイテンシ・高可用性が求められるサービスを支える技術を幅広く扱いました。個々の技術を知識として学ぶだけでなく、実際のサービスでどの課題を解決し、どのように組み合わせて運用されているのかを体験できました。

また、調査、設計、実装、検証、リリース、監視、効果測定までを一貫して担当しました。変更を安全に届け、その後も安定して運用できる状態まで責任を持つことの重要性を学べたことが、今回のインターンで得た大きな経験です。

まとめ

本インターンでは、新しい Pod の起動時に Spanner へ読み取りが集中し、リリースに約40分かかっていた課題に対して、L1/L2 キャッシュを導入しました。TTL、期限切れ前のrefresh、Schema Hash による互換性を設計し、アプリケーション実装からインフラ、検証、監視、本番リリースまで担当しました。

導入後は Pod 起動時の Spanner 負荷を抑え、リリース時間を約15分まで短縮できました。今回得た経験を、今後も性能だけでなく、運用と将来の変更まで見据えた設計に生かしていきたいと思います。