SupportMailはDiscordボットで、特にmodmailとして機能しています。ユーザー数が非常に多いため、単一のゲートウェイ接続では動作できなくなっています。この規模のボットはすべてそうですが、シャーディングを行う必要があります。つまり、複数の接続にサーバーを分割し、一定の規模を超えると複数のプロセスに分割する必要があります。長らくその仕組みは、スタンドアロンのNode/Bunスケーリングライブラリで、ボット周りのクラスタ子プロセスを生成・管理していました。過去数週間でこれをGoで書かれた専用スーパーバイザー、書き換えられたクラスタハーネス、ボットのREST APIとボット自体の適切な分離に置き換えました。

この記事ではその書き換えについて解説します。旧システムがなぜ理想的でなかったか、代わりに何を構築したか、そして実際に何が改善されたかを説明します。

注:開発者が「Discordサーバー」を指す用語は「guild」です。

シャーディングとは何か、そしてSupportMailが以前持っていたもの

Discordボットの内部に深く関わっていなければ、「シャーディング」という言葉を聞いたことがあってもその意味を知らなくても当然です。この記事の残りの部分がそれに依存しているので、説明する価値があります。

シャードとはDiscordへの1つのゲートウェイ接続で、guildのサブセットを担当します。Discordはボットが参加しているすべてのguildを単一のWebSocket接続に載せることを許可していません。一定の規模を超えると、1つの接続ではイベント量に追いつけなくなり、Discordはguild数が閾値を超えた時点でシャーディングを要求します。どのguildがどのシャードに属するかは任意ではありません:(guildId >> 22) % totalShardsという決定論的ハッシュで、システムのどの部分も誰にも聞かずに、特定のguildをどのシャードが所有しているかを判断できます。

これが簡単な部分です。難しいのは、「シャードを増やす」ことが無料ではないということです。1つのプロセスから500個のゲートウェイ接続を開いて終わり、というわけにはいきません。シャードはクラスタにグループ化され、各クラスタは数個のシャードを持つ別プロセスです。そして複数のプロセスがある時点で、その上に「いくつのクラスタを実行するか、どのシャードをどのクラスタに入れるか、死んだクラスタを再起動するか、外部(APIやダッシュボード)に「guild Xのシャードは今生きているか」を尋ねる単一の場所を提供するか」を決めるものが必要です。それがスーパーバイザーです。

SupportMailの旧スーパーバイザーはgalactic.tsで、別のボットのシャーディングマネージャーであり、nodeのchild_processモジュールを使用していました。そのフォークでは、Bun.spawn()を使ってStandaloneInstanceからクラスタ子プロセスをフォークし、ポート3000でボットのREST API、ポート4000でWebSocketサーバーを実行する同じBunプロセス内にすべてが生きていました。ゲートウェイ接続、HTTP、ソケット、スーパーバイザーロジック自体まで、すべてが1つのBunプロセスツリーでした。

「なぜシャード化するのか」が理解できなければ、次のセクションも理解できないでしょう。構造的操作とビジネス操作の分離、世代ベースのデプロイ、同じ考え方です。複数のプロセスと1つのコーディネーター、ただしより小さい規模で。

パッチではなく置き換える理由

この書き換え全体のきっかけとなった具体的な痛み:Bun.spawnchild_process、およびボットプロセス内から使用されるワーカースレッドがSentry計装を破壊しました。メインプロセスは依然としてBunでした。クラスタ子プロセスが関与した瞬間に、nodeのdiagnostics_channel経由で公開されるエラーイベントは単に動作せず、サイレントで失われました。これは「自分のエラーレポートを信頼できない」というバグでした。

それだけではBunのリリーススケジュールが奇妙で、オープンPRがすぐにマージされそうにないため、本当にパッチ可能ではありませんでした。最新のBunリリースも2ヶ月以上前で、新リリースの兆候はありませんでした。パッチする価値がなかった理由は、その下にあるもの:galactic.tsInstance/Clusterクラスが親プロセスがBunであるという前提に結合されていることです。選択肢は、それをその場でパッチしてBunネイティブのままにするか、完全にドロップして別の言語で外部に移行するかでした。中間はありません。そしてgalactic.ts自体を超えて見ると、それが管理していたもののほとんどは元々難しくありませんでした:discord.jsは既に1つのプロセスで複数のシャードをネイティブサポートしています(内部シャーディング)。galactic.tsが扱っていたのはその周りのオーケストレーションであり、シャーディング自体ではありません。

旧コードに公平に言うと、ダウンタイムなしの再クラスタリングロジック、プロセス間のIPC通信、私のフォークで追加したクリーンアップは無駄ではありませんでした。それらは最終的に構築した世代/クワイエットハンドオフの有用な参考資料となりました。価値のある部分は参考として保持され、構造的に不適切な部分は置き換えられました。

検討した代替案

前のセクションでは、galactic.tsをその場でパッチすることが選択肢外だった理由を説明しました。それを別にしても、インプロセスで留まることは正しい選択ではありませんでした:SupportMailは私が運用する唯一のボットではありません。Ticketonも運用しており、いずれ同じインフラを必要とする可能性があります。1つのボットのプロセスに配線されたマネージャーは、別の書き換えなしでは再利用できません。それが、別の言語で外部のボット非依存スーパーバイザーに決着した理由です。

言語について言えば、まだ選択肢がありました。最初はRustを検討しました。主にRustをより深く学ぶ言い訳が欲しかったからです。しかし、2つの理由でGoを選びました:

  1. 並行処理モデルが問題の形状に直接適合する。 「N個のクラスタプロセスを監督し、それらのIPCをソケット経由でファンインする」というのは、ゴルーチンとチャネルがまさに解決するためにあるものです。Rustのasyncストーリー(ランタイムの選択、PinSend/Sync境界がすべてに貫通し、Goが数行のコードで解決する問題を解決するための複雑さを導入する)は、問題でした。
  2. 構築の速度。 Goの標準ライブラリ(netexecnet/http)で、スーパーバイザー、IPCハブ、ステータスWebSocketをほとんどサードパーティ依存なしで構築できました。Rustでは非同期ランタイムを選択・学習し、実際のスーパーバイザーロジックを書く前にクレートを拾う必要がありました。

これはRustが劣っているという意味ではありません。「出荷目標 vs 学習目標」の計算であり、本番に近いインフラでは、出荷が勝ちました。

コア設計決定:構造的操作 vs ビジネス操作

これがコアルーティング設計です。

マネージャー(sm-manager)は5つの操作のみを理解します:REGISTERHEARTBEATQUIESCEQUIESCEDDEPLOYです。これらは構造的で、プロセスライフサイクルに関するものであり、ボットが何をするかに関するものではありません。マネージャーを通じて流れるその他のすべては不透明な文字列で、targetでアドレス指定され、マネージャーはその中身を見ることなく正しいクラスタにルーティングします。

これが重要な理由:マネージャーはビジネスペイロードを解析しません。「チケットを閉じる」操作や「guild設定を同期する」操作、ボットが気にするその他の操作がどのようなものか知りません。ただどこに送るかを知っているだけです。つまり、マネージャーは完全にボット非依存のままです。2番目のボットが同じインフラを使用するには、別の設定ファイルを使うだけでよく、マネージャーコードベースをフォークする必要がありません。(具体的には、これがTicketonが将来的にsm-managerを共有できるようにするものです。マネージャーがTicketonが存在することを知る必要はありません。)

代替案(すべての操作を理解するマネージャー)は暗黙的に検討されており、それが基本的にモノリシックなスーパーバイザーの外見です。より結合度が高く、クロスクラスタ調整を必要とする新しいボット機能/機能変更ごとにマネージャーコード変更が必要となり、マネージャーは理論上は2番目のボットをボルトオンできるとしても、実践上は永遠に1ボット専用です。

おおよそ、ルーティングは次のようになります:

client → sm-manager:

{ op: "DEPLOY", ... } → 構造的、マネージャーが直接処理

{ op: "guild.sync", target: clusterId, payload: <opaque> }

→ ビジネス、マネージャーはtargetに untouched で転送

マネージャーはopが5つの構造的コードのいずれかであるかどうかを分岐します。はいの場合、動作します。いいえの場合、targetを見て、所有クラスタ接続を見つけ、メッセージペイロード全体を転送します。

以下のトポロジ図は、マネージャー、クラスタ、ボット、APIを含むすべてのコンテキストを示しています。

Infrastructure diagram

用語に関する注意:これらすべての下にあるトランスポートはTCPではなくUnixドメインソケットです。Goはnet.Listen("unix", ...)でリッスンし、Bun側のコンシューマー(APIとクラスタハーネス)はBun.listen/Bun.connectで同じソケットパスに接続します。私はこれを「IPC」と呼び続けます。IPCはプロセス間通信を意味し、Unixソケットはそのメカニズムの1つで、パイプや共有メモリと同じファミリーです。

ゼロダウンタイムデプロイ:世代

旧デプロイパスはPM2を使用しており、PM2の再起動(リロードではなく)はボットプロセス全体(マネージャーを含む)がダウンして再起動することを意味しました。これはデプロイごとにゲートウェイ接続に実際の「ギャップ」を生じさせます。単純な修正、つまり再起動ではなく単純なリロードには独自の失敗モードがあります。しばらくの間、古いコードと新しいコードの両方がアタッチされ、同じイベントを処理することになり、つまり重複イベント処理になります。

私が考え出した修正は、世代タグ付きシャード範囲です。各デプロイは、旧世代と同じシャード範囲を持つ新しい「世代」のクラスタを並行して起動します。マネージャーは新しい世代が準備完了を報告するまで待ってから、ルーティングを新しい世代に切り替えます。切り替えが完了した後でのみ、旧世代にクワイエット(新しい作業の受け入れを停止し、実行中の作業を完了し、終了する)を指示します。

順序がすべてです:ルーティングは「新しい世代準備完了」と「旧世代クワイエット済み」の間で切り替わり、新しい世代が準備完了する前(イベントリスナーがロードされていない可能性、DB接続が欠落している可能性など)や、旧世代がクワイエットされてから長く経過した後(ギャップが生じる)に切り替わることは決してありません。この順序により、guildのイベントが2つの世代に同時にルーティングされるウィンドウも、どの世代にもルーティングされないウィンドウも存在しないことが保証されます。

より単純なオプション(完全再起動、または旧ダウンタイムを受け入れる(数秒だったとしても))を検討し、SupportMailの規模では、完全再起動ギャップはすべてのguildで同時にゲートウェイイベントがドロップされることを意味し、見た目のちらつきではないため拒否しました。境界付きクワイエットウィンドウを持つクラスタごとのローリングデプロイは実装の複雑さが増しますが、実際の「ダメージ」を測定可能で小さなものに制限します。

改善された点:デプロイは一括ではなくクラスタごとにロールアウトされ、クワイエットギャップは無期限ではなく境界付きで観測可能になり、重複イベント処理ウィンドウを心配する必要がなくなりました。旧世界では、デプロイは「そのギャップで重要なことが起こらないことを祈る」に近かったのです。

HTTP APIの分離

client-apiはボット内部に直接依存し、マネージャープロセスにアタッチされていました。APIのデプロイはボットと同じプロセスツリーに触れることを意味しました。

これも外部委託することにしました。sm-apiは、他のクライアントが使用するのと全く同じUnixソケットプロトコルでsm-managerと通信することでこれを修正します。特別なアクセスパスも、ボット内部を通るショートカットもありません。ただの別のIPCクライアントです。

利点は、sm-apiがボットとは独立して再デプロイできることです。そして上述の構造的/ビジネス操作分割を通るため、ボット内部の形状に依存する立場に戻ることは決してありません。しかし、ボットプロセスから分離されているという事実も利点です。

観測可能性を証明点として

構造的/ビジネス分割が実際に報われたもう1つの場所:公開ステータスページWebSocketはボット上ではなくsm-manager上に存在します。ハートビートペイロードは本質的にそのまま転送され、マネージャーにとっては他のビジネスペイロードと同じように不透明です。

これは分割が設計されたケース以外でも成立することを確認します。マネージャーは公開ステータスフィードをサポートするために特別な処理を必要としませんでした。「理解せずに不透明なペイロードを転送する」が既にデフォルトの動作だったからです。

改善された点

  • Sentry計装が動作するようになりました。この書き換えが始まった本来の理由です。
  • デプロイがほぼゼロダウンタイムになり、クワイエットウィンドウは「再起動にかかる時間」ではなく境界付きで測定可能です。
  • マネージャーはボット非依存です。2番目のボットはマネージャーフォークではなく設定の追加です。
  • APIはボットプロセスから完全に分離されています。共有プロセスツリーもなく、内部形状への依存もありません。
  • 本番稼働中です。SupportMailはこの切り替え以降、このシステムで動作しています。ヘッドラインではありませんが、切り替え後にメモリ使用量が約40%減少しました。
  • マネージャーとAPIの両方が現在systemdサービスであり、pm2管理ではなくなったため、ホストリスタート時にプロセスマネージャーの監視なしで自動的に復帰します。

結び

意図的にまだ延期されているものがいくつかあります。トランスポートはUnixソケットであり、TCPやWebSocketではありません。すべてが1台のホスト上で動作している現在は問題ありませんが、将来的に変わった場合は制約となります。マネージャーにもまだ高可用性のストーリーがありません。単一プロセスで、死んだ場合はsystemdが再起動します。ホットスタンバイが引き継ぐわけではありません。これらのいずれも今日の問題ではなく、理論上のギャップだからといって初日から解決する必要はありませんでした。「万一の場合」と「本当に今必要か」の境界線を見つけるのは本当に難しかったです。

sm-managerはボット非依存でSupportMail固有ではないため、いずれオープンソースになる可能性があります。日付は言えません。それが起こった時点でこの記事を更新し、別の新しい記事を作成します。

良い一日を、そしてあなたがDiscordボット開発者であれば、この記事がシャーディングとプロセスオーケストレーションについて新しい考え方をする助けになれば幸いです。

PS:前回の記事で話したインシデントの際に助けてくれたgalactic.tsにも多大な感謝を捧げます。それはゲームチェンジャーであり、ほとんどの人にとって最高のシャーディングソリューションの1つです。