1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Google Cloud環境におけるキャッシュ設計を試行錯誤してみた話

1
Posted at

Cloud Run で動かしている中継サーバーに、 上流の SaaS API から取ってきたユーザー情報をメモリで持たせていました。 古くならないよう、 サービスの中で 30分ごとに取り直す定期処理を回す形です。

よくある作りだと思いますが、 これがサービスの設定と上流 API への負荷を、 思っていた以上に縛っていました。

そこで 定期更新そのものをサービスの外に出す ことにしました。 Cloud Scheduler と Cloud Run Job と Cloud Storage を使う構成です。 上流への負荷を見積もれるようになったこと以上に、 「更新する人」と「読む人」を分けられたこと が大きかったので、この記事ではそんなGoogle Cloudでのキャッシュ設計について、私が試行錯誤した内容についてをここに残します。

前提は「レート制限のある上流 API から全件取得したデータを、 中継サーバーが毎リクエストで参照する」という構成です。

サービスの中に定期更新を置くと何が起きるか

変更前の構成はこうでした。

  • 上流の SaaS API にレート制限(1時間あたり 5,000 リクエスト)がある
  • 中継サーバーはリクエストのたびにユーザー情報の全件が必要
  • 毎回引くとレート制限を使い切るので、 起動時に全件取得してメモリに持つ
  • 情報が古くならないよう、 30分ごとに取り直す

変更前の構成

Gemini_Generated_Image_7kn89l7kn89l7kn8.jpeg

1 インスタンスで動いている間は、 これで問題なく動きます。 問題は、 この設計がサービスの設定と上流への負荷を縛ってくること でした。

1. 課金モードが縛られる

Cloud Run の課金モードは既定が request-based で、 リクエストを処理している間しか CPU が割り当てられません。 アイドル中は割り当てが止まるので、 定期処理のコールバックは予定した時刻どおりには発火しません。

リクエスト外でタイマーを進めるには instance-based が必要です。 ただし最小インスタンス数が0ならスケールゼロしますし、 1以上でも個々のインスタンスは停止・再作成され得ます。 タイマーを持っているインスタンスがいつまで生きているか分からない 以上、 課金モードを変えるだけでは定期実行の担保になりません。

2. インスタンスが増えると、 上流への呼び出しも増える

Cloud Run は負荷に応じてインスタンスを増やします。 増えたインスタンスは それぞれ独立に 30分タイマーを回す ので、 全件取得も同じだけ増えます。 全員が同じデータを取りに行くので、 完全に重複した呼び出しです。

自分の構成では全件取得1回でおよそ 15 リクエスト(ページングのため)でした。 1 インスタンスなら1時間あたり 30 リクエストですが、 10 インスタンスなら 300 リクエストです。 レート制限のある上流に対して、 負荷が上がるほど無駄打ちが増える 構造になっていました。 起動時にも全件取得するので、 デプロイやインスタンスの入れ替えのたびにも飛びます。

3. 更新の失敗がリクエスト処理に埋もれる

定期更新は、 リクエストを処理しているのと同じプロセスの上で動いています。 Node.js は JavaScript を実行する担当が1本しかないので、 setInterval のコールバックも HTTP のハンドラも同じイベントループの順番待ちに並びます。 リトライやログを自前で実装しても、 タイマーのコールバックで投げた例外を拾い損ねれば、 プロセスごと落ちてリクエスト処理も巻き添えになります。

「リクエストに応える」ことと「データを最新に保つ」ことは、 責務としては別のもの です。 それを同じプロセスに置いたせいで、 片方の失敗がもう片方に波及する状態になっていました。

更新する人と読む人を分ける

変更後の構成はこうなりました。

変更後の構成
Gemini_Generated_Image_nbfv33nbfv33nbfv.jpeg

一番大きな変化は、 キャッシュ更新のために上流 API を叩くのが、 サービスではなく Job になったこと です。 サービスのインスタンス数が 1 でも 10 でも、 全件取得の回数は変わりません。 通常時は5分に1回で、 重複起動やリトライがあればその分だけ増えます。 中継そのもの(リクエストを受けて上流に問い合わせる処理)はサービスに残っていて、 外に出したのは全件取得によるキャッシュ更新だけです。

なお、 定常時のリクエスト数だけを見ると増えることもあります。 旧構成が平均1インスタンスなら毎時 30 リクエスト、 新構成は5分ごとなので毎時 180 リクエストです(before/after の実測はしていません)。 それでも分離したのは、 回数がインスタンス数やデプロイの頻度に左右されなくなり、 見積もれる値になった からです。

そして定期タイマーがサービスから消えたので、 課金モードを既定(request-based)のままにできます。

なぜ Cloud Storage なのか

最初に浮かんだのは Memorystoreでした。 「共有キャッシュといえば Redis」という発想です。 今回の要件で比べると、 こうなりました。

観点 Cloud Storage Memorystore (Redis)
Cloud Run からの接続 HTTPS で直接 VPC への経路が必要
最小構成 使った分だけ 1 GB から
レイテンシ Redis より大きい(要実測) sub-millisecond を想定した設計
向いている読み方 まとめて1回読む リクエストごとに細かく引く
更新の単位 オブジェクト単位で丸ごと置き換える キー単位で部分更新できる

自分のデータは全部で 1.5 MB 程度で、 読み方も「TTL ごとに全件をまとめて読む」でした。 これを置くために最小 1 GB の Redis インスタンスを確保するのは過剰ですし、 sub-millisecond のレイテンシが効いてくる読み方でもありません。 加えて Memorystore への接続には Cloud Run 側に Direct VPC egress か Serverless VPC Access の設定が要りますが、 直前に VPC を使わない形へまとめたばかりでした。

Redis が不要という話ではなく、 この要件では Redis の強み(低レイテンシ・部分更新・原子的な操作)を活かしにくい ということです。 リクエストごとに細かく引く使い方なら判断は変わります。

整理としては、 Cloud Storage は共有キャッシュそのものというより スナップショットの配布元 で、 実際に応答へ使うキャッシュはサービスのインスタンス内メモリのほうです。

スナップショットの配布元とインスタンス内キャッシュ

Gemini_Generated_Image_futb6sfutb6sfutb.jpeg
応答に使うのはインスタンス内のメモリ。 Cloud Storage はその配布元

更新する側(Job)で決めたこと

壊れたデータで上書きしない

取得結果が0件だったら、 書き込まずに異常終了します。 上流 API が一時的に空を返したときに Cloud Storage の中身が空になり、 サービス全体が「ユーザーが1人もいない」状態で応答し始めるのを防ぐためです。

ただしこれは、 このシステムでは利用者が0人になることが業務上あり得ない という前提があって成り立つ判断です。 0件が正しい状態になり得るなら、 古いデータを残すほうが誤りになります。 権限判定に使うデータなら、 削除済みユーザーを残すことのほうが危ない場合もあります。

見るのは件数だけではありません。 全ページを取り切れたか、 レスポンスの形が想定どおりか、 ID が重複していないか。 一般化すると 業務上あり得ない結果を検出したら書き込まない というルールです。

同じ Job が重なって走ることを前提にする

「Job は1つだから書き手も1つ」と思っていましたが、 これは正確ではありませんでした。

  • Cloud Scheduler の配信は at-least-once で、 同じ予定時刻の呼び出しが重複することがある
  • Cloud Run Job は、 タスクが失敗すると既定で3回までリトライする
  • jobs.run が返すのは Job の完了結果ではなく、 Execution を作る Operation
  • 実行時間が起動間隔を超えれば、 前の Execution が終わる前に次が始まる

Job の定義が1つでも、 書き込みプロセスが常に1つとは限りません。 Job は冪等(同じ処理が複数回走っても結果が壊れない性質)に作る必要があります。

内容が前回と同じならハッシュ比較で書き込みをスキップしていますが、 これは書き込み回数を減らす工夫であって、 競合対策ではありません。 古い Execution が後から新しいオブジェクトを上書きする順序の問題は防げません。 対策は、 実行ロックを取る、 実行開始時に読んだ Cloud Storage の generation を条件に書き込む(ifGenerationMatch)、 上流側の更新時刻で新旧を判定する、 といった方向です。

ハッシュでスキップすると、 更新時刻が健全性の指標にならない

内容が同じなら書き込みをスキップするので、 オブジェクトの更新時刻は 最後に内容が変わった時刻 を意味するようになります。 3時間更新がないのが「変化がないから」なのか「Job が3時間失敗し続けているから」なのか、 オブジェクトからは区別できません。

分離する前は、 更新の失敗がサービスのログに出ていました。 分けたあとはサービスが古いデータで応答を続けるだけなので、 外からは何も起きていないように見えます。 健全性は Cloud Run Job 側のメトリクスや失敗ログで見るか、 lastCheckedAt のようなフィールドを別に持たせます。

読む側(Service)で気をつけたこと

起動時にしか読まないと、 古いデータを持ち続ける

最初に書いた実装は「起動時に Cloud Storage から読んでメモリに載せる」だけでした。 これだと Job が5分ごとに更新しても、 サービスは起動時に読んだデータを持ち続けます。 同じインスタンスが長時間残ることはあるので、 更新の仕組みを作ったのに何日も前のデータを返し続ける、 という一番間抜けな状態になり得ます。

対策は単純で、 リクエストが来たときに「前回読んでから TTL を超えていたら読み直す」 ようにしました。 読み直しがリクエストの処理中に起きるので、 課金モードは既定のままで動きます。

TTL 5分は「最大5分の古さ」ではない

TTL は Scheduler の実行間隔と揃えて5分にしましたが、 これは鮮度が最大5分という意味ではありません。 Scheduler の間隔を S、 Job の実行時間を D、 サービス側の TTL を T とすると、 上流の変更がサービスの応答に現れるまで、 タイミングによってはおよそ S + D + T かかります。

上流でデータが変わる
  |
  |-- S: 次の Job 起動まで(最大5分)
  |-- D: Job の実行時間
  |-- T: Service の TTL 切れまで(最大5分)
  v
サービスの応答に現れる

もう1つ、 Cloud Storage の読み取りはサービス全体で1回ではなく、 各インスタンスがそれぞれ TTL ごとに読みます。 1.5 MB なので費用は小さいですが、 インスタンス数に比例して増えます。 TTL 切れ直後に同じインスタンスへリクエストが同時に来ると重複して読むので、 気になるなら進行中の Promise を共有する single-flight にまとめます。

読み直しに失敗したときの挙動も決めておきます。 古いキャッシュのまま応答を続けるのか、 リクエストを失敗させるのか。 続ける場合は、 どこまで古い値なら許すのかも決めます。

Cloud Storage を読むときの前提

単一オブジェクトの上書きは原子的なので、 途中まで書かれたデータが読み手に見えることはありません。 書き込みが成功したあとの読み取りは強整合性が保証されているので、 Job が書いた直後に読んでも古いデータは返りません。

今回はユーザー情報を置くので、 バケットは非公開にして認証付きで直接読みます。 Job 用のサービスアカウントには書き込み権限、 サービス用には読み取り権限だけを与える、 という最小権限の分け方にしています。

実行間隔をどう決めるか

Cloud Scheduler の料金はジョブ数で決まります($0.10/ジョブ/月、 毎月3ジョブまで無料)。 これを見て「実行回数が料金に影響しないなら最短の1分間隔でいいのでは」と考えましたが、 実行回数が増えれば起動先の Cloud Run Job の実行時間、 Cloud Storage の操作、 上流 API の呼び出しはすべて増えます。 実際の制約は、 料金より 上流のレート制限と Job の実行時間 のほうにありました。

全件取得1回でおよそ 15 リクエストを消費します。 上限は1時間あたり 5,000 リクエストです。

実行間隔 1時間あたりの実行回数 消費リクエスト レート制限の消費率
1分ごと 60 900 18%
5分ごと 12 180 3.6%
15分ごと 4 60 1.2%
30分ごと 2 30 0.6%

これは重複起動やリトライを含まない、 通常時の予定回数です。 1分間隔にすると 更新処理だけでレート制限の 18% を常時消費します。 その上に本来のリクエスト処理が乗るので、 ピーク時に上限へ当たるリスクが上がります。

実行時間の側も同じです。 全件取得に2分かかるのに1分間隔で起動すれば、 前の実行が終わる前に次が始まります。 Cloud Run Job のタスクタイムアウトは既定で10分、 リトライは既定3回なので、 リトライを含めた最悪の実行時間が間隔を超えないかを見ておきます。

判断材料は4つでした。 必要な鮮度、 上流のレート制限、 Job の実行時間と重複・リトライ、 全体のコストです。 自分は5分間隔にしましたが、 「情報が数時間古くても運用は成り立つ」という要件から素直に引けば 15分や 30分のほうが整合します。 ここは運用しながら見直す予定です。

この構成が向くケース / 向かないケース

向いている 向いていない
分単位の鮮度で足りるデータ 秒単位の鮮度が必要なデータ
上流にレート制限があり、 呼び出しを制御したい 上流の呼び出しが安価で、 都度取得しても問題ない
全件をまとめて取得するタイプのデータ 差分がイベントで飛んでくる(Pub/Sub のほうが素直)
データが小さく、 メモリに載る 全件がメモリに載らないサイズ
読み手が複数(サービスが増えても Job は1本) 読み手が1つしかなく、 分離の利点が薄い

特に「上流にレート制限がある」と「全件をまとめて取得する」の2つが揃っているときは、 相性がいいと思います。

まとめ

一番の収穫は、 コストでも構成のきれいさでもなく、 「リクエストに応える」ことと「データを最新に保つ」ことを別々に考えられるようになったこと でした。 分けたあとは、 サービスは「Cloud Storage から読んで応答する」だけ、 Job は「取ってきて書く」だけです。 更新が失敗してもサービスは古いデータで応答を続けますし、 サービスが再起動しても更新の周期は影響を受けません。

そのかわり、 重複実行や実効鮮度のように、 分けたからこそ考えることになった項目もあります。 それでも、 サービスの設定と上流への負荷が絡み合っていた状態よりは、 ずっと考えやすくなりました。

関連記事

課金モードそのものについては別の記事に書きました。「最小インスタンス数」と「課金モード」は別々の設定で、メモリ上のキャッシュに関係するのは前者だ、という内容です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?