Corrosion:Fly.io の分散状態同期
Corrosion は Fly.io が Rust で書いた分散状態同期システム。クラスタで共有すべき状態を各ノードに収束させ、gossip と link-state 的な経路で一貫性を保つ。そこに至るまでの経緯を語る。
日本語
コピー


画像:Annie Ruygt
Fly.io は Docker コンテナを Fly Machines に変える。自社ハードウェア上で動く、世界各地に分散した小型 VM だ。このプラットフォームの運用で一番難しいのは、サーバーの管理でもネットワークの運用でもない。その二つをくっつける部分だ。
顧客の CI/CD パイプラインは毎秒いくつもの Fly Machines を作っては壊し、状態同期システムがその更新を内部メッシュ全体にブロードキャストする。東京からアムステルダムまで、各エッジプロキシが正確なルーティングテーブルを保ち、アプリへのリクエストを最も近い顧客インスタンスへ届けられるようにするためだ。
2024年9月1日 東部時間午後3時30分、開発者がたった今デプロイした新しい「virtual service」設定項目を携えた Fly Machine が一台起動した。数秒のうちに、フリート全体のプロキシが完全に固まった。我々が経験した中で最悪の障害であり、その間エンドユーザーのリクエストは顧客アプリに一切届かなかった。
分散システムは爆発の増幅装置だ。データがネットワークを伝わるにつれ、そのデータに依存するシステムのバグも伝わる。我々の状態配信システム Corrosion の場合、バグは極めて速く伝わった。あの Corrosion 更新を処理するプロキシのコードは、Rust 並行処理で悪名高い罠を踏んでいた。RWLock に作用する if let 式が、その else 分岐でロックは解放済みだと勝手に(もっともらしく、しかし誤って)思い込んでいたのだ。即座にデッドロック、しかも伝染性が極めて高い。
これは身銭を切って学んだ教訓だ。見事な障害史を持たない分散システムは信用に値しない。分散システムが週末を潰し、徹夜を強いてこなかったなら、まだそのシステムを理解できていないということだ。だからこそ我々は Corrosion をこう紹介する。自社プラットフォームのために作り、オープンソース化した、型破りなサービスディスカバリシステムだと。
我々の顔面狙いの熊手
状態同期は、我々のようなプラットフォームを運用する上で最も難しい問題だ。ではなぜ、それのためにリスクのある新しい分散システムをわざわざ作るのか。どう試しても、あの熊手が待ち構えているからだ。原因は我々のオーケストレーションモデルにある。
主要なオーケストレーションシステムはほぼすべて(Kubernetes を含め)、新しいワークロードをどこに置くかを集中型データベースで決める。個々のサーバーは自分が何を動かしているかを把握しているが、権威ある情報源はその中央データベースだ。Fly.io では、世界数十リージョンに水平スケールするため、これをまるごとひっくり返した。各サーバー自身が自分のワークロードの権威ある情報源なのである。
我々のプラットフォームでは、中央 API が仕事を発注し、実際に受注するのは互いに競合する物理「worker」サーバーからなるグローバル市場だ。権威ある情報源を中央スケジューラから各サーバーへ移すことで、サンパウロ、バージニア、シドニーの間で一貫性を保ちながら素早く応答しなければならないデータベースに縛られることなく、水平スケールできる。
入札モデルはエレガントだが、ネットワークリクエストのルーティングにはそれだけでは足りない。東京の HTTP リクエストをシドニーの最も近いインスタンスに届けるには、ホストされている全アプリを覆うグローバルな地図がどうしても必要だ。
本来よりずっと長い間、我々はトラフィックのルーティングを HashiCorp Consul に頼っていた。Consul は素晴らしいソフトウェアだ。だが、これでグローバルルーティングシステムを組んではいけない。その後、Consul のキャッシュに SQLite を使った。SQLite も同样素晴らしい。だが、これもやってはいけない。
テラスで放置された七面鳥のフライヤーのように、真のグローバル分散合意が約束するのは美味だが、出てくるのは自焼きだけだ。Raft のような合意プロトコルは長距離で破綻する。しかも我々のプラットフォームのアーキテクチャと噛み合わない。入手できる最強のハードウェアで動く Consul クラスタが、そもそも衝突し得ない更新のために合意を保証するのに時間を浪費していたのだ。
Corrosion
グローバルなルーティングデータベースを作るため、我々は分散合意を捨て、本物のルーティングプロトコルから着想を得た。
OSPF のようなプロトコルは我々と同じ運用モデルを持ち、多くの同じ制約に直面している。OSPF は「リンクステートルーティングプロトコル」であり、我々にちょうど役立つのは、ルーターが自身のリンクの真実の源であり、変化を他のすべてのルーターに素早く通知してネットワークが転送判断を下せるようにする点だ。
我々は OSPF より少し楽をしている。OSPF のフラッディングアルゴリズムは任意のルーター間の接続を仮定できない(それを解決することこそ OSPF の存在意義だ)。一方我々はサーバー間にグローバルで全結合の WireGuard メッシュを走らせている。やるべきことは効率的な gossip だけだ。
Corrosion は Rust プログラムで、gossip プロトコルで SQLite データベースを伝播する。
Consul と同様、我々の gossip プロトコルは SWIM の上に構築されている。まず思いつく限り最も単純で愚かなグループメンバーシッププロトコルから始める。各ノードが知っている全ノードに心拍を撒き散らす。そこに二点だけ手を加える。第一に、プロトコルの各ステップで送信するのは全ノードではなくランダムな一部のみ。第二に、心拍が失敗しても慌てず「suspect」とマークし、別のランダムな隣人たちに ping を手伝ってもらう。SWIM はこれでグローバルなメンバーシップに素早く収束する。
メンバーシップの片付けが済んだので、次はクラスタ内のノード間で QUIC を動かし、変更のブロードキャストと新規ノードの状態突き合わせに使っている。
Corrosion はグローバルに同期するデータベースのように見える。SQLite で開いて、テーブルから直接データを読める。面白いのは、やらないことのほうだ。ロックも、中央サーバーも、分散合意もない。代わりに自前のオーケストレーションモデルを活かしている。worker が自分の状態を持ち、別々の worker の更新が衝突することはまず起きない。
とはいえ、ある程度の順序は与えている。Corrosion クラスタの各ノードは最終的に同じ更新の集合を受け取るが、順序は異なりうる。どのインスタンスも同じ「作業集合」の見え方になるよう、僕らは cr-sqlite、つまり CRDT の SQLite 拡張を使っている。
cr-sqlite のやり方は、指定した SQLite テーブルを CRDT 管理下に置くというものだ。これらのテーブルでは、行のどの列に対する変更も専用の crsql_changes テーブルに記録される。テーブルへの更新は論理タイムスタンプ、つまり因果順序で last-write-wins として適用される。壁時計の順序ではない。仕組みの詳細はここで読める。
Corrosion の通常の SQL テーブルで行が更新されると、発生した変更は crsql_changes から集められ、バッチ更新パッケージにまとめられて gossip で伝播する。
すべてが順調なときの Corrosion は理解しやすい。Corrosion のデータを使うクライアントの多くは、その存在を知る必要すらなく、データベースがどこにあるかだけ知っていればいい。「リーダー選出」に悩むことも、更新が滞留していないか指標を睨んで爪を噛むこともない。しかも速さが常軌を逸している。
想定外は起きる
これは、僕らが一連のエンジニアリング上の判断を正しく下し、その後一切問題に遭遇しなくなった話である。拍手をどうぞ。
Corrosion が関わった最も深刻な問題についてはすでに書いた。デッドロックのバグを効率よく gossip してクラスタ内の全 proxy に配り、ネットワーク全体を落とした件だ。正直に言えば、あの障害で Corrosion は傍観者だった。だが他の障害は本当にこいつの仕業だ。
古典的な運用問題を一つ。予想外に高コストな DDL 変更。簡単な migration を書いて、テストして、main にマージして、本番で問題なく走るだろうと思って寝る。誰でもやるミスだ。
そこに一匙加える。グローバル gossip システムにつながった CRDT テーブルに、取るに足らないように見える schema 変更を加える。デプロイが走った瞬間、世界中の何千台もの高性能サーバーが声を揃えてデータベースの突き合わせメッセージを歌い始め、クラスタが溶ける。
去年まさにこれが起きた。チームメンバーが Corrosion テーブルに nullable 列を追加したのだ。新しい nullable 列は大きな Corrosion テーブルにとってクリプトナイトで、cr-sqlite がテーブルの全行に値を埋め戻す必要がある。結果、僕らのプラットフォーム上の全 Fly Machine が、僕らを困らせるためだけに一斉に状態を変えたのだった。
もっととんでもない実話もある。長い間、僕らは Corrosion と Consul を同時に動かしていた。分散システムが二つあればレジリエンスも二倍だと思って。ある朝、Consul の mTLS 証明書が期限切れになった。クラスタ内の全 worker が Consul との接続を切った。
本来なら問題ないはずだった。Corrosion がまだ動いている。ただ、その下でクラスタ内の全 worker がバックオフループを回し、Consul への再接続を試みていた。試行のたびに Fly Machine の状態を更新するコードパスが呼び直される。そしてそのコードパスが Corrosion への書き込みを引き起こす。
何が起きているのか把握した頃には、アップリンクがフリートのほぼ全地点で飽和していた。アップリンクのベンダーには謝った。
Fly.io でこういう事故は久しく起きていないが、次を防ぐこと以外、ほとんど考えていない。
反復
振り返ると、Corrosion を推し進めたとき、Consul で犯した過ちを繰り返していた。単一のグローバル状態ドメインを作ってしまったのだ。Corrosion の設計上、そうする必要はどこにもなかった。今はその決定を解体している。これは覚えておいてほしい。より小さないくつかの変更から、それなりの見返りを得た。
まず何より、あらゆるものにウォッチドッグを付けた。伝染するデッドロックのバグを紹介したが、あれが致命的だったのは、リスクモデルに「これらの Tokio プログラムはデッドロックしうる」という項目が欠けていたからだ。今は違う。Tokio プログラムにはすべてウォッチドッグが組み込まれていて、イベントループが止まればサービスが再起動し、同時に耳をつんざく警報が鳴る。ウォッチドッグはすでに何度か障害を食い止めている。コード量はごくわずかで、効果は絶大だ。君のシステムにも入れるといい。
次に、Corrosion 自体を大量にテストした。Rust parking_lot ライブラリで見つけたバグについても書いた。同種のバグを探すのに Antithesis で何ヶ月も費やした。繰り返すが、本当に勧める。parking_lot のバグでたどった調査経路をあっさり再現してくれたし、あの時点で Antithesis を使っていたら、あのバグはブログ一本書く価値もなかった。分散システムにとって、多宇宙デバッグは決定的な道具だ。
どれだけテストしても、分散システムを信頼するようにはならない。だから worker から Corrosion データベースを再構築するのを簡単にした。Corrosion データベースのチェックポイントバックアップをオブジェクトストレージに置いている。この判断は正しかった。去年本当に手に負えなくなったとき、クラスタを再起動するという選択肢が残っていて、実際そうした。時間はかかる(データベースは大きく、伝播コストも高い)が、分散システムの障害を診断して直すほうがもっと時間がかかる。
worker から Corrosion へのデータ供給方法も見直した。少し前まで、worker がローカルデータベースを更新するたびに、同じ差分更新を Corrosion にも publish していた。だが今は部分更新を完全にやめた。代わりに、ある Fly Machine に変更が起きたら、その Machine のデータセット全体を再 publish する。Corrosion が自身の行変更を処理する仕組みのおかげで、再 publish された Fly Machine を受け取ったノードは、gossip の前に no-op の変更を自動で除外する。部分更新をやめたことで、かなりの数のバグが塞がった(しかも、ずっと追いかけていた厄介なバグもいくつか片付いたはずだと考えている)。最初からこうすべきだった。
最後に、あのグローバル状態の問題を振り返る。あの伝染性のデッドロックバグを経て、単一クラスタを超えるアーキテクチャへ進むべきだという結論に達した。そこで始めたのが「リージョナル化」というプロジェクトで、二段構えのデータベース構成を組んだ。運用する各リージョンで Corrosion クラスタを動かし、そのリージョン内の各 Fly Machine の細かいデータを保持する。グローバルクラスタはアプリをリージョンにマッピングする役割を担い、エッジプロキシが転送を判断するにはそれで十分だ。
リージョナル化は状態バグの爆発半径を狭めた。追跡してきたものの大半は、リージョンの外に影響を及ぼす必要がない(重要なのは、追跡対象に関するコード変更のほとんどもリージョン内で完結する点だ)。そうしたコードの変更は、最悪でも単一リージョンだけを危険に晒す形でリリースできる。
新しいシステムは機能している
分散システムの多くは状態同期という課題を抱えている。Corrosion の「形」は、その大半とは異なる:
- Consul、Zookeeper、Etcd、Raft、rqlite(採用寸前まで行った)のような分散合意に依存しない。
- FoundationDB や、S3 的なオブジェクトストレージに支えられたデータベースのような、大規模な集中型データストアに依存しない。
- それでいて、高度に分散しており(数千の worker がそれぞれノードを動かす)、収束が速く(数秒以内)、外からは単純な SQLite データベースに見える。見事だ!
ここまで来るのは楽ではなかった。Corrosion は Fly.io の Rust を書くエンジニア全員の仕事の大きな部分を占めている。
Corrosion が動くのは、入れるものを慎重に選んでいるからでもある。我々が管理する状態のすべてが gossip で伝播する必要はない。tkdb、つまり Macaroon token のバックエンドは、Litestream に支えられたずっと単純な SQLite サービスだ。HashiCorp Vault を置き換えるために作ったシークレットストア、Pet Sematary も同じだ。
とはいえ、分散データベースよりもリンクステートルーティングプロトコルのほうが向いている分散状態の問題は、おそらくたくさんある。手元にそういう問題がありそうなら、Corrosion を試してみてはどうか。
Corrosion は Jérôme Gravel-Niquet の発想だ。この 2 年、その反復作業の多くを Somtochi Onyekwere と Peter Cai が主導してきた。コルチゾールが出たりエンドルフィンが出たりする仕事だった。ようやく詳しく話せて、我々は嬉しい。