インフラ・DevOps
Pub/Subを停止しても、誰も気づかなかった
We turned off Pub/Sub and nobody noticed (incident.io)
要約
incident.ioは、イベント駆動型プラットフォームのメッセージングインフラとしてGoogle Cloud Pub/Subのみに依存していましたが、これは可用性SLAとの間に緊張関係を生じさせていました。この単一障害点を解消するため、NATSをセカンダリメッセージブローカーとして導入し、eventadapterという抽象化レイヤーを構築しました。これにより、Pub/SubとNATS間で動的にロードバランシングを行い、最終的にPub/Subを顧客への影響なく停止させることに成功し、プラットフォームの信頼性を向上させました。
全文翻訳
All postsEsc
Pub/Subを停止しても、誰も気づかなかった
メッセージブローカー
イベントアダプター
セカンダリブローカーの選択
動的なロードバランシング
公平性重み付けスケジューリング
カオス
すでにメリットを実感している
Pub/Subを停止しても、誰も気づかなかった
Patrick Hamann+Mike Fisher
2026年8月11日 — 17分間の読み物
多くのモダンなソフトウェアスタックと同様に、incident.ioのプラットフォームは主にイベント駆動型です。例えば、アラートを送信したり、Slackでエージェントにメッセージを投稿したり、カタログのエントリを更新したりするたびに、これらはすべてメッセージトピックにエンキューされるイベントとなります。これは、そのイベントに関心のあるダウンストリームコンポーネントがサブスクライブして非同期に反応できることを意味します。例えば、プッシュ通知を送信したり、Slackで返信を投稿したりします。
プラットフォームが成長するにつれて、システムを流れるメッセージの数も増加し、メッセージングインフラへの依存度も高まりました。これはミッションクリティカルなものになりました。同時に、私たちは自身に対して、オンコール製品のエンタープライズ顧客に現在コミットしている99.99%のSLAのような、より厳格な可用性ターゲットを設定しました。
最近まで、メッセージングテクノロジーとして単一のプロバイダーであるGoogle Cloud Pub/Subのみを使用していました。これは、Pub/Subの可用性にわずかな障害が発生した場合でも、私たちのプラットフォームの可用性に障害が発生することを意味し、これは許容できるものではありませんでした。そこで私たちは最近、スタックにセカンダリメッセージブローカーを導入し、冗長性を追加し、最終的にプラットフォーム全体の可用性を向上させることで、メッセージングシステムを障害に対してより回復力のあるものにするための冒険に乗り出しました。
目標は、顧客への影響をゼロにしてPub/Subを停止できるようにすることでした。それはかなりの冒険となりましたが、先週、私たちはまさにそれを成功させました。
これがその冒険の物語です。
メッセージブローカー
イベント駆動型システムには、メッセージの処理レートと取り込みレートを分離できる、あるいは多くの異なるコンポーネントが同じ元の顧客開始イベントを処理できるなど、多くの利点があります。例えば、user.createdトピックがあり、あるシステムがウェルカムメールを送信するためにイベントをリッスンし、別のシステムが初期データベース状態を設定するためにリッスンします。
このようなシステムの心臓部には通常、「メッセージブローカー」があり、これは発行コンポーネントからメッセージを受信し、それを保存し、関心のあるサブスクライバーに転送する責任を負います。incident.ioでは、歴史的にPub/Subをメッセージブローカーとして選択してきました。これは、優れた機能セットを持つ、よく構築されたマネージドサービスであり、長年にわたって容易にスケールアップすることを可能にしてくれました。
Pub/Subは堅牢です。公開されているSLAは99.95%であり、実際には私たちにとってそれを快適に上回ってきました。問題は、私たちのプラットフォームのすべてのイベントが、単一のブローカー、単一のプロバイダーによって運用されるものを通過しており、それを迂回する手段がなかったことです。私たちのメッセージブローカーは単一障害点(SPOF)になっており、これは私たちの99.99%の可用性ターゲットと矛盾していました。
そして、SPOFは最終的に説明責任の問題です。エスカレーションが発火しなかった場合、「申し訳ありません、Pub/Subがダウンしていました」というのは、顧客に決して言いたくない答えです。それは私たちのSLAであり、依存関係がその日に何をしていても、それを満たすのが私たちの仕事です。私たちはすでにインフラストラクチャの他のレイヤーに冗長性を持っているので、メッセージブローカーをなぜ異なって扱う必要があるのでしょうか?
イベントアダプター
イベント駆動型アーキテクチャを持っていると言っても、誇張しているわけではありません。現在、約800以上の個別のメッセージトピック、1000以上のユニークなトピックサブスクリプションがあり、1日あたり約2億4000万件のメッセージを処理しています(2026年8月現在)。
この多数のトピックは、コードベースに数千のコールサイトがあることを意味し、イベントと対話します。これは、例えばメッセージングに使用する基盤となるテクノロジーを置き換えたい場合、非常に daunting です 🫠。
幸いなことに、私たちは巨人の肩の上に立っており、incidentの初期のエンジニアたちは、メッセージの発行とサブスクライブの上にコードレベルの抽象化を構築する賢明さを持っていました。これをeventadapterと呼んでいます。これは、以下のようなシンプルでありながら強力なインターフェースを公開するパッケージです。
// Publisherはイベントを発行するためのインターフェースです。
type Publisher interface {
Publish(ctx context.Context, ev Eventer, payload []byte) (string, error)
}
// Subscriberはすべてのサブスクライバーによって実装されます。
type Subscriber interface {
Subscribe(ctx context.Context, topicName string, handler SubscribeHandler, params SubscribeParams) func() error
}
// SubscribeHandlerは、パッケージのコンシューマーが単一のイベントを処理するために実装するものです。
type SubscribeHandler[EV Eventer] func(
ctx context.Context,
ev *EV,
eventMetadata EventMetadata,
) error
// Eventerはすべてのイベントによって実装されるインターフェースです。
type Eventer interface {
// Nameは、このタイプのイベントを識別する方法です。
Name() string
// イベントが何を意味するかの説明です。
Description() string
// Validateは、発行前にイベントのフィールドを検証します。
Validate() error
// GetOrganisationIDは、イベントに関連付けられた組織IDを返します。これはイベントテレメトリに追加するために使用します。
GetOrganisationID() string
}
このパッケージは、これらのインターフェースの具体的な実装をいくつか公開しています。例えば、ローカル開発やテストで使用するeventadapter.InMemoryや、Pub/Subと通信するためのeventadapter.PubSubなどです。
豆知識:incidentの初期の頃、アプリをローカルで実行し、Pub/Subへの並列接続が多すぎるとオフィスのWi-Fiがクラッシュしたため、当初はInMemoryアダプターを構築する必要がありました。🙈
main()関数で、環境に基づいて実行時に使用するアダプターのバージョンを条件付きで選択・構築し、それをアプリケーションコンポーネントへの依存関係として渡します。
このような抽象化の恩恵を受けることで、新しいメッセージブローカーを導入するために必要なのは、eventadapterインターフェースの新しい具体的な実装を構築し、実行時にそれをスワップインすることだけであり、他のエンジニアが所有する呼び出しコードは何も意識する必要がありませんでした。これにより、2つの異なるブローカーにわたるロードバランシングから生じるすべての複雑さを抽象化の背後に隠すことができました。
セカンダリブローカーの選択
既存のインターフェースを使用するというトレードオフは、そのインターフェースのセマンティクスと動作にも制約されるということでした。つまり、それは抽象化ではありますが、基盤となるテクノロジーからのリークがすでにいくつかありました。つまり、Pub/Subと同じ機能セットと動作を持つものを選択する必要がありました。なぜなら、これらはアプリケーションで依存し、推論するようになったセマンティクスだったからです。
セカンダリメッセージブローカーを選択するための要件とプロセスは、この投稿の範囲外です。ブローカーの広範なランドスケープがあります:オープンソース対プロプライエタリ、マネージド対アンマネージド、ストリーミング対非ストリーミング、一時的対永続的など。銀の弾丸はありませんので、私たちの主なアドバイスは、独自の要件を文書化し、意思決定マトリックスを使用することです。
NATSを選択した主な理由は、CNCF採用プロジェクトであるため、その将来に自信を持ち、ソースコードを自由に読むことができ、Kubernetesネイティブ(ワークロードを実行している場所)であり、単一バイナリ(Kafka、あなたを見ています!)であり、Goで書かれている(スタックの残りはGoです!)ことです。さらに、以前に実行した経験がありました。
動的なロードバランシング
もう一つの設計原則は、99.99%の可用性で運用する場合、ブローカー間のフェイルオーバーは手動ではありえないということです。月に約4分23秒のダウンタイム予算しかなく、誰かが午前4時に起きてブローカーを切り替える時間はありません。これは、理想的には、両方のブローカーを継続的にアクティブ-アクティブ設定で使用する必要があることを意味しました。メッセージは両方に分散され、一方のブローカーからの持続的なエラー率は自動的に他方にフェイルオーバーされます。そのため、動的なイベントロードバランサーを構築する必要がありました!それがどのように構築されたかを掘り下げてみましょう。
前述のように、最初に行ったことは、eventadapter.LoadBalancerと名付けたeventadapterの新しい具体的な実装を作成することでした。発行側では、メッセージのIDをハッシュし、設定可能な分割に対して重み付けされたサイコロを振ることでブローカーを選択します。IDはULID(l