SupportMail 是一款 Discord 機器人——具體來說是 modmail——因為管理的用戶數量太多,無法再以單一 gateway 連線運作。和其他達到此規模的機器人一樣,它必須進行分片:將伺服器分散到多個連線上,超過一定規模後則需要分散到多個程序中。長期以來,這個機制由一個獨立的 Node/Bun 擴展函式庫負責,該函式庫負責產生和管理機器人的叢集子程序。在過去幾週,我將它替換為以 Go 撰寫的專用監控程式、一個重寫的叢集處理程序,以及將機器人的 REST API 與機器人本身進行適當分離。
這篇文章將說明這次重寫的過程:為什麼舊的設定不理想、我建立的替代方案,以及現在有哪些改善。
備註:開發者將「Discord 伺服器」稱為「guild」。
什麼是分片,以及 SupportMail 之前使用的方式
如果你不熟悉 Discord 機器人的內部運作,「分片」這個詞你可能聽過,但未必了解它的含義。由於這篇文章接下來的內容都會以此為基礎,因此值得在此說明。
一個 shard 就是一個連線到 Discord 的 gateway,負責處理一部分的 guild。Discord 不允許你將機器人所加入的所有 guild 放在單一 WebSocket 連線上——當規模達到一定程度,一個連線無法處理事件量,Discord 就會要求你進行分片,一旦機器人的 guild 數量超過門檻,就必須進行分片。哪個 guild 屬於哪個 shard 並非隨意分配,而是使用 (guildId >> 22) % totalShards 這個決定性雜湊值,因此系統的任何部分都能夠在不詢問任何人的情況下,得知某個 guild 屬於哪個 shard。
這是比較簡單的部分。更困難的部分在於「增加 shard」並非免費——你不能只從單一程序開啟五百個 gateway 連線就結束。Shard 會被分組到叢集中,每個叢集是一個獨立的程序,包含少數幾個 shard。而當你有多個程序時,你就需要一個上層的東西來決定要執行多少個叢集、哪些 shard 分配到哪些叢集、重新啟動已停止的叢集,並為外部世界(API、儀表板)提供單一的查詢位置,回答「guild X 的 shard 目前是否存活」。這個上層的東西就是監控程式。
SupportMail 原本的監控程式是 galactic.ts,這是一個來自另一個機器人的分片管理器,使用 node 的 child_process 模組。我的 fork 使用 Bun.spawn() 從 StandaloneInstance 產生叢集子程序,所有這些程序都存在於同一個 Bun 程序中,該程序同時也在 port 3000 上執行機器人的 REST API,並在 port 4000 上執行 WebSocket 伺服器。所有東西——gateway 連線、HTTP、socket、監控程式邏輯本身——都存在於同一個 Bun 程序樹中。
如果「為什麼需要分片」這個概念還沒理解,那接下來的章節可能也不會理解——分離結構操作與業務操作、基於 generation 的部署,同樣的概念。只是更小的多個程序和一個協調器。
為什麼要替換而不是修補
啟動這次重寫的具體問題是:Bun.spawn、child_process 以及從機器人程序內部使用的 worker threads 破壞了 Sentry 的檢測功能。主要程序仍然是 Bun。透過 node 的 diagnostics_channel 發布的錯誤事件根本無法運作,一旦涉及叢集子程序就會被靜默遺失。這是一個「我無法信任自己的錯誤回報」的問題。
單憑這一點就無法修補,因為 Bun 的發布時程不穩定,而且開放的 PR 似乎短期內不會合併——最新的 Bun 版本已經超過兩個月前發布,而且沒有新版本的跡象。更不值得修補的原因在於其底層:galactic.ts 的 Instance/Cluster 類別與父程序是 Bun 的假設緊密耦合。選擇是原地修補並保持 Bun 原生,或是完全捨棄並改用不同語言的外部方案。沒有中間選項。而一旦你超越 galactic.ts 本身,它所管理的大部分內容原本就不是很困難:discord.js 已經原生支援在單一程序中處理多個 shard(內部分片)——galactic.ts 處理的是圍繞此功能的協調,而不是分片本身。
公平地說,舊程式碼——無停機重新叢集的邏輯、程序之間的 IPC 通訊,以及我在 fork 中新增的清理功能——並非毫無價值。它們成為我後來建立的 generation/quiesce 交接的有用參考材料——值得保留的部分被保留作為參考,結構上不適合的部分則被替換。
考慮過的替代方案
上一節說明了為什麼原地修補 galactic.ts 不在考慮範圍內。即使撇開這一點,在程序內部運作也不是正確的選擇:SupportMail 不是我唯一運行的機器人——我還有 Ticketon,它未來可能也需要相同的基礎設施——而一個與單一機器人程序綁定的管理器無法在不進行另一次重寫的情況下重複使用。這就是決定採用外部、機器人無關的監控程式,並使用不同語言的原因。
說到語言選擇,這仍然是開放的。我最初考慮 Rust,主要是因為我想找個藉口更深入學習它。然而我選擇了 Go,原因有二:
- 並行模型直接符合問題的形狀。「監控 N 個叢集程序並透過 socket 將它們的 IPC 集中」幾乎正是 goroutine 和 channel 的用途。Rust 的 async 故事——選擇 runtime、
Pin、Send/Sync約束貫穿所有內容——引入了複雜性來解決 Go 只需幾行程式碼就能解決的問題。 - 建置速度。Go 的標準函式庫(
net、exec、net/http)足以建置監控程式、IPC 中樞和狀態 WebSocket,幾乎沒有第三方依賴。Rust 則需要選擇和學習 async runtime,並在撰寫任何實際監控程式邏輯之前先挑選 crate。
這並不意味著 Rust 比較差。這是「出貨目標 vs 學習目標」的計算,而對於這種接近生產關鍵的基礎設施,出貨獲勝。
核心設計決策:結構操作 vs 業務操作
這是核心路由設計。
管理器(sm-manager)只理解五種操作:REGISTER、HEARTBEAT、QUIESCE、QUIESCED 和 DEPLOY。這些是結構性的——它們關於程序生命週期,而不是機器人的功能。所有其他流經管理器的內容都是不透明的字串,由 target 尋址,管理器會將其路由到正確的叢集,而不會查看其內容。
為什麼這很重要:管理器永遠不會解析業務 payload。它不知道「close ticket」操作看起來如何,或「sync guild config」操作,或機器人關心的任何其他操作——它只知道要發送到哪裡。這意味著管理器保持完全的機器人無關性。讓第二個機器人使用相同的基礎設施,只需使用不同的設定檔,就不需要 fork 管理器程式碼。(具體來說,這就是讓 Ticketon 未來能共享 sm-manager 的關鍵,而管理器不需要知道 Ticketon 的存在。)
替代方案——理解所有操作的管理器——在隱含上也在考慮範圍內,因為這基本上就是單體監控程式的樣子。它耦合度更高:每當需要跨叢集協調的新機器人功能/功能變更,都意味著管理器程式碼變更,而管理器在實務上永遠只能用於單一機器人,即使理論上沒有什麼阻止你附加第二個機器人。
大致上,路由看起來像這樣:
client → sm-manager:
{ op: "DEPLOY", ... } → 結構性,管理器直接處理
{ op: "guild.sync", target: clusterId, payload: <opaque> }
→ 業務性,管理器原封不動轉發給目標
管理器會根據 op 是否為五種結構性代碼之一進行分支。如果是,它就會執行操作。如果不是,它會查看 target,找到擁有該叢集的連線,並轉發整個訊息 payload。
下方的拓撲圖顯示了所有內容的上下文,包括管理器、叢集、機器人和 API。

術語說明:所有傳輸使用的是 Unix domain socket,而不是 TCP。Go 使用 net.Listen("unix", ...) 監聽;Bun 端的消費者(API 和叢集處理程序)使用 Bun.listen/Bun.connect 連線到相同的 socket 路徑。我在全文中仍將此稱為「IPC」——IPC 意為 inter-process communication,而 Unix socket 是其中一種機制,與 pipe 或共享記憶體屬於同一類。
零停機部署:generation
舊的部署路徑使用 PM2,而 PM2 的 restart(而非 reload)意味著整個機器人程序——包括管理器——都會停止並重新啟動。這在每次部署時都會造成 gateway 連線的真正「間隙」。天真的修正方案——使用純粹的 reload 而非 restart——有其自身的失敗模式:你會有一段時間同時有新舊程式碼附加,並且都在處理相同的事件,也就是重複事件處理。
我提出的解決方案是使用 generation 標記的 shard 範圍。每次部署都會啟動一個新的「generation」叢集,持有與舊 generation 相同的 shard 範圍,並行運作。管理器等待新 generation 回報就緒後,才會將路由切換到新 generation。只有在切換發生後,才會告訴舊 generation 進行 quiesce——停止接受新工作、完成進行中的工作,然後退出。
順序是整個重點:路由在「新 generation 就緒」和「舊 generation 已 quiesce」之間切換,絕對不會在新的就緒之前(事件監聽器可能還沒載入、資料庫連線可能遺失等),也不會在舊的 quiesce 之後很久(你會有間隙)。這個順序保證不會有 guild 的事件同時路由到兩個 generation 的時間窗口,也不會有完全沒有路由的時間窗口。
我考慮過更簡單的選項——完全重啟,或是接受舊的停機時間(即使只有幾秒鐘)——並拒絕了它們,因為在 SupportMail 的規模下,完全重啟的間隙意味著所有 guild 同時丟失 gateway 事件,而不僅僅是外觀上的小問題。按叢集分批部署並設定有界的 quiesce 窗口,雖然增加了實作複雜度,但將實際「損害」限制在可衡量且小的範圍內。
現在的改善:部署是逐叢集推出,而不是一次全部推出,quiesce 間隙是有界且可觀察的,而不是開放式的,而且沒有重複事件處理的窗口需要擔心——相較於舊的世界,部署更接近「希望那個間隙中不要發生重要的事情」。
將 HTTP API 分離出來
舊的 client-api 直接依賴機器人內部,並附加在管理器程序上——部署 API 意味著觸及與機器人相同的程序樹。
我決定也將它外包——sm-api 透過與任何其他客戶端完全相同的 Unix socket 協議與 sm-manager 通訊——沒有特殊的存取路徑,沒有透過機器人內部的捷徑。它只是另一個 IPC 客戶端。
好處是 sm-api 可以獨立於機器人重新部署。而且因為它通過上述的結構/業務操作分割,它永遠不會回到依賴機器人內部形狀的位置。但它與機器人程序解耦這件事本身也是一項好處。
可觀察性作為證明點
結構/業務分割在實務上另一個有回報的地方:公開的狀態頁面 WebSocket 存在於 sm-manager 上,而不是機器人上。Heartbeat payload 基本上原封不動地轉發給它,對管理器來說就像任何其他業務 payload 一樣不透明。
這證實了分割在設計之外的案例中仍然成立——管理器不需要特殊處理就能支援公開狀態源,因為「轉發不透明 payload 而不理解它」已經是預設行為。
現在的改善
- Sentry 檢測功能正常運作——這是重寫開始的原始原因。
- 部署接近零停機,quiesce 窗口是有界且可測量的,而不是「重啟需要多久」。
- 管理器是機器人無關的:第二個機器人只需要新增設定檔,而不是 fork 管理器。
- API 完全與機器人程序解耦——沒有共享程序樹,沒有內部形狀依賴。
- 它已經在生產環境中——SupportMail 自轉換以來一直在此系統上運行。雖然不是重點,但切換後記憶體使用量下降了大約 40%。
- 管理器和 API 現在都是 systemd 服務,而不是 pm2 管理的,因此在主機重啟時會自動恢復,而不需要程序管理器監控它們。
結語
有一些事情是故意延後處理的。傳輸層是 Unix socket,而不是 TCP 或 WebSocket,這目前沒問題,因為所有東西都在單一主機上運行——但如果以後改變,這就是一個限制。管理器也還沒有高可用性方案;它是一個單一程序,如果它停止,是 systemd 重新啟動它,而不是熱備援接管。這些今天都不是問題,也不需要在第一天就解決,只是因為理論上存在缺口。我很難在「但如果發生什麼事」和「現在真的需要嗎」之間找到界線。
sm-manager 未來可能會開源,因為它是機器人無關的,不是 SupportMail 專屬的。我無法給出日期——當那個時候到來時,我會更新這篇文章並發布一篇新的文章。
祝你有美好的一天,如果你是一名 Discord 機器人開發者,希望這篇文章能幫助你以新的方式思考分片和程序協調。
PS:非常感謝 galactic.ts 在我之前文章中提到的意外事件中救了我。它是一個改變遊戲規則的工具,也是大多數人最好的分片解決方案之一。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.