有名テック企業の技術ブログを、ひとつのフィードで。
フィード
112件
はじめに こんにちは。Merpay の Payment & Customer Platform で会計システムを開発・運用する Accounting チームで Backend Engineer をしている @mewuto です。本記事は「Merpay & Mercoin Tech Openness Month 2026」の11日目の記事です。 マネージドサービスの世代移行では、コードを変えていなくても、デフォルト値の違いだけでシステムの振る舞いが変わることがあります。ある月末の早朝、Cloud Functions の世代移行が引き金となり、会計イベント基盤で Cloud Pub/Sub のメッセージが大量に滞留して、新規の会計データを Cloud Spanner に登録できないインシデントが発生しました。 直接の引き金は、Cloud Functions を 1st gen から 2nd gen(Cloud Run ベース)へ移行した際に --max-instances を明示しておらず、Cloud Functions のスケール上限が「無制限」(1st genのデフォルト)から「100」(2nd genのデフォルト)へ下がっていたことでした。しかし、障害がここまで大きくなったのは、この引き金だけが原因ではありません。本記事では、1000万件規模に達したこの滞留がどのように構成されていたのかを解き、Cloud Run と Spanner、そして監視の各面で私たちが講じた対策を紹介します。 1. 月末ピークを支える会計イベント基盤 まず、私たちが運用する会計システムの全体像を説明します。会計システムは、社内の各マイクロサービスが発行する「会計イベント」を集約し、Cloud Spanner に記録する基盤です。この会計データは送信元サービスとの突合(リコンサイル)を経て、加盟店への精算や月次の会計締めといった後続処理の前提となるため、取りこぼしや遅延がそのまま業務影響に直結します。 会計システムは、次のような非同期パイプラインでイベントを受け取ります。送信元から Spanner までは、おおむね一方向の流れです。 この構成には、今回の障害を理解するうえで重要な特性が2つあります。 1つ目は、すべてが非同期で動く点です。送信元マイクロサービスはイベントを発行したら応答を待たず、配信・リトライ・整合性の担保はすべて Pub/Sub 以降のパイプラインが引き受けます。特に Pub/Sub から Cloud Run へは push 型サブスクリプションで配信されるため、消費側である Cloud Run とその書き込み先である Spanner の処理能力が、そのままパイプライン全体のスループット上限になります。 2つ目は、負荷が月末に集中する点です。業務特性上、月末・月初の締めに合わせてトランザクションが一気に押し寄せ、平常時の数十倍のスパイクが発生します。 なお、Pub/Sub に届いたメッセージは、この push 経路とは別の pull 型サブスクリプションを通じて Dataflow でも読み取られ、並行して Cloud Storage(GCS)に保存されています。本処理の滞留に巻き込まれないこのGCS上のデータを使って後の復旧を行いました。 2. 1000万件の滞留はどのように発生したか インシデントは、ある月末最終営業日の早朝に発生しました。6時ごろから Pub/Sub のメッセージが滞留しはじめ、Spanner への新規登録が滞り、新規の会計データを受け付けられない状態が断続的に続きました。復旧と再発を繰り返し、収束までには丸一日以上を要しました。 データロスそのものは、先ほど触れた GCS上のデータから再投入することで回避できました。しかし会計システムでは、送信元サービスと DB の間でリコンサイルが完了したものだけが、加盟店精算や月次の会計締めといった後続処理の対象になります。Spanner に登録されなかったデータはリコンサイル未完了のまま残り、締め日と重なったことで、広範な業務影響につながりました。 この滞留が深刻なのは、一度始まるとインフラリソースの上限でスケールが頭打ちになり、処理が遅れるほど次の処理も遅れる悪循環に陥るからです。会計イベントが高い密度で連続して到着すると、maxInstances=100 で頭打ちになった Cloud Run はこれを処理しきれません。上限まで張り付いた Cloud Run が一斉に書き込むことでSpanner CPUが逼迫し、1件あたりの書き込み時間が延びます。すると、処理スループットがさらに落ち、滞留はさらに進んでしまいます。やがて滞留時間が eventMaxAge(メッセージを破棄するまでの最大保持期間)を超えると、メッセージは Spanner に登録されないまま破棄されてしまいます。 3. きっかけは 2nd gen 移行時のデフォルト値 滞留の原因を調べていくと、今回の障害の1ヶ月以上前に行った Cloud Functions の世代移行に行き着きました。私たちは関数を 1st gen から 2nd gen(Cloud Run ベース)へ移行しましたが、このときデプロイコマンドで --max-instances を明示しておらず、世代によってデフォルト値が変わることを、十分に意識できていませんでした。 この「明示しなかった」ことが、スケール上限を静かに引き下げていました。下の表のとおり、1st gen ではスケール上限が実質無制限だったのに対し、2nd gen のデフォルトは 100 です。 Version デフォルトの max instances 1st gen 無制限(プロジェクト Quota の範囲) 2nd gen 100 コードもデプロイスクリプトも変えていないのに、移行しただけで上限が実質無制限から 100 へ下がっていたことになります。平常時は 100 インスタンスで十分だったため、移行後しばらくは問題が表面化せず、私たちの体感としては、ある月末に突如として障害が発生したように見えました。しかし実際は、監視がなかったために気づけていなかっただけでした。インシデント後に振り返ると、上限に迫っていた月もあり、水面下ではすでに限界の兆候が出ていたのです。 4. 障害を大きくした複数の要因 --max-instances の指定漏れは引き金ではありましたが、それだけでこの障害を説明することはできません。調べてみると、2nd gen移行後に迎えた月末のピーク時でも条件はほとんど同じだったからです。maxInstances は 100、eventMaxAge も同じ 21分で、トラフィック総量も3時間で約17〜19GBとほぼ変わりませんでした。 それでも、そのときは障害は起きていませんでした。決定的に違ったのは、トラフィックの「連続性」です。合間に負荷が落ち着く「谷」があり、その短い谷の間に Spanner CPU と未 ack の蓄積がリセットされていました。ところが障害当日は、この谷がないままピークが約40分間続き、システムは回復の契機をついに得られませんでした。 整理すると、今回の障害は3つの要因が連鎖して成り立っていました。まず maxInstances=100 という処理上限が滞留を発生させ、次にトラフィックの連続性がその滞留を固定化し、最後に eventMaxAge によるメッセージのドロップ、という連鎖により実害となりました。いずれか1つでも条件が違えば被害の規模は抑えられたはずで、だからこそ私たちは対策を1箇所に絞らず、制御できる Cloud Run と Spanner の両方に講じることにしました。 5. Cloud Run の処理能力を引き上げる 最初に着手したのは、直接の引き金となった Cloud Run のスケール上限です。あわせて、1インスタンスあたりの処理効率も見直しました。本番に反映した設定値は次のとおりです。 パラメータ 変更前 変更後 max-instances 100(暗黙のデフォルト) 1000 min-instances 0(暗黙のデフォルト) 1 concurrency 1(暗黙のデフォルト) 10 cpu デフォルト 1 memory 512MB 1Gi ここでの本質は、値を大きくしたこと以上に、必要なパラメータをすべて明示したことにあります。今回の引き金は、暗黙のデフォルト値に依存していたことでした。そこで max-instances をはじめとするスケール関連のパラメータをデプロイ定義に明示し、世代やデフォルトの変化に左右されない状態にしました。あわせて、 min-instances を 1 にしてコールドスタートを避け、concurrency を 10 に引き上げ、それに見合うよう CPU とメモリも増強しています。なお max-instances は、書き込み先である Spanner への負荷も考えて、一度に上げきらず段階的に引き上げました。 concurrency を 1 から引き上げることができたのは、ハンドラ内の共有状態を見直し、複数リクエストを同時に処理しても安全だと確認できたためです。Spanner クライアントは内部にコネクションプールを持ち並行アクセスに耐えられます。また、その初期化は sync.Once によって一度だけ行われます。こうした前提を確かめたうえで同時処理数を増やし、必要なインスタンス数そのものを抑えました。 6. Spanner autoscaler の監視軸を Total CPU へ広げる Cloud Run の処理能力を上げると、今度は書き込み先である Spanner がボトルネックになります。Spanner には以前から autoscaler を導入していましたが、障害のさなか、CPU が高負荷であるにもかかわらず PU(Processing Unit。Spanner の計算容量の単位)はまったくスケールアップしていませんでした。autoscaler があるのにスケールしない、という一見不可解な状況でした。 原因は、autoscaler が見ていた指標にありました。Spanner の CPU 使用率には High / Medium / Low の優先度があり、その合算が Total CPU 使用率です。当時の autoscaler は High priority CPU 使用率だけを見ており、障害時は Total CPU が 100% に張り付く一方で、High priority 単体では閾値(60%)の超過が続かず、起動条件を満たしませんでした。この死角は月末ピークに限りません。たとえばリアルタイム性を求めない大量データの削除ジョブは、意図的に Low priority で実行するため、Total は高いのに High は低いという同じ状態を容易に作り出します。優先度別の CPU だけを見る監視は、こうしたケースを構造的に取りこぼすのです。 しかし、これは autoscaler の設定変更では解決できませんでした。当時、OSSである mercari/spanner-autoscaler には、Total CPU でスケールする機能そのものが存在しなかったからです。そこで、OSS をメンテナンスしている SREチームに相談し、High priority と Total を OR 条件で評価する dual CPU scaling mode を実装してもらい、v0.8.0 として取り込みました。設定した閾値は次のとおりです。 パラメータ 変更前 変更後 Total CPU 閾値 (監視なし) 70% High priority CPU 閾値 60% 55% Total CPU 閾値は新設で、これが今回の障害への直接の対策です。値は Google Cloud の throughput 最適化推奨(〜70%)に合わせました。High priority CPU 閾値は、より早くスケールアップを起動するために 60% から 55% へ下げています。下げすぎると平常時のコストが増えるため、より低い 50% 等は避け、Google Cloud の推奨(regional で 65% 以下)に収まる 55% を選びました。これにより autoscaler は High priority(55%) と Total(70%) のいずれかが閾値を超えればスケールアップするようになり、「Total は逼迫しているのに High が閾値未満だからスケールしない」という障害時の挙動は原理的に起こらなくなりました。 また、スケール条件に加えて、運用面でも2つの調整を行いました。1つは、早朝5時から7時のスケールダウンを禁止することです。月末ピークの立ち上がりで、せっかく確保した PU が削られてしまうのを防ぎます。もう1つは、スケジュールによる事前の PU 確保です。月末早朝や大規模なリコンサイルが走る月など、負荷が読める期間にはあらかじめ PU を確保しておき、リアクティブなスケールアップだけに頼らない構えにしました。 7. 上限への接近を検知する監視を整える 今回の障害には、予兆を早期に捉える機会も、発生を即座に検知する機会も逃してしまったという課題があります。スケール上限に達してもそれを知らせるアラートがなく、水面下で限界に近づいていた兆候も見逃していました。そこでDatadog に2つの監視を追加しました。 1つは、Cloud Run のインスタンス数が上限の 40% / 60% に達した時点で警告・重大として通知する監視です。これにより、メッセージのドロップが始まる前に、上限への接近そのものを検知できます。もう1つは、Pub/Sub の未配信メッセージ数が閾値を超えたら通知する監視で、滞留という現象を直接とらえます。後者は誤検知を避けるため、当初は保守的な閾値から始め、定常状態の理解が進むにつれて段階的に下げていく方針にしています。 設定値を最適化するだけでは不十分です。これ自体は当たり前かもしれませんが、どれだけ良い値を入れても、その値への接近を検知できなければ、次の同じ障害は防げません。キャパシティの設計と、その接近を知る監視をセットで整える。この当たり前を当たり前に徹底することこそが大切だと、今回あらためて実感しました。特に、システムをゼロから作った当人ではなく、引き継いで運用していることのほうが多い現場では、こうした設定や前提を定期的に振り返り、整え直す文化を持つことが欠かせません。 8. まとめ 今回の障害は、1つのデプロイフラグを明示しなかったことから始まりました。しかし振り返れば、本当の教訓はもっと一般的なところにあります。 第一に、マネージドサービスの世代移行では、自分が変更していないデフォルト値こそが、負荷時に問題として表面化します。スケール上限・並行数・タイムアウトのような、平常時には効かないパラメータは、移行のたびに明示しておくべきでした。第二に、障害の原因は単一とは限りません。「直近の月は同じ条件で起きなかった」という事実がトラフィックの連続性という決定的な要因を浮かび上がらせたように、トリガー・増幅・被害化のそれぞれに目を向けることが再発防止には欠かせません。そして第三に、設定の最適化と、その状態に気づくための監視は、つねに一体で考える必要があります。 マネージドサービスの便利なデフォルト値は、平常時には何も語りません。だからこそ、ピーク時に初めて顔を出すその振る舞いを、移行のたびに確かめておく価値があります。この記事が、みなさんが運用するサービスの設定を今一度見直す、ひとつのきっかけになれば幸いです。 次の記事は um(うめ)さんです。引き続きお楽しみください。