vatt'ghern jaskier's ballads
本文 6 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

在 us-east1-b 那個 12:13 PDT 的時刻,一份 Kubernetes 部署檔把 session 管理 pod 的副本數砍了一半,graceful draining 還沒跑完,17% 的 session 就被硬切斷——這道裂口最終要花掉 voice 團隊 3 小時 17 分鐘,並讓 15 台 voice syncer 的 Erlang mailbox 同時膨脹到接近一百萬則訊息才補得起來。

Discord 3/25 語音崩盤覆盤——一行誤設掀翻 voice control plane

是一份標準的 Elixir 服務遷上 Kubernetes 的部署,按照變更紀錄上的描述「沒什麼新鮮事」——直到 12:13 PDT 那一刻,那份 manifest 把 us-east1-b 區的 sessions service pod 副本數壓到只剩一半,安全機制還沒來得及把記憶體裡的 GenServer 狀態交棒出去,pod 就先被排程器殺掉了。

Discord 的工程師 Bo Ingram 與 Stephen Birarda 在事後報告開頭引了一個很久沒聽到的比喻:「The sad trombone haunts our dreams. Womp, womp, womp...」這不是文采上的修飾——你看著儀表板上 mailbox 曲線從 0 萬一路漲到 100 萬,指令照理該讓它下來,但它就是不動,或者跌一下又被新湧進來的流量打回去。這一篇按時間順序復現那 3 小時 17 分鐘,並把前三次救火失敗、第四次成功、後續結構修補的邏輯拼回去。

一份誤設的 manifest——故事從這裡長出來

整個事件的「起爆點」其實只有一行。

一份 Kubernetes deployment 的 replicas 從原本的數值改成大約一半——就這麼簡單。

這個變更發生在 Discord 正在把 Elixir 服務逐步搬上 Kubernetes 的過程中。當時的目標是讓 sessions service——也就是負責管理「每一個使用者裝置 ↔ 連線」對應關係的核心服務——能享受 horizontal pod autoscaler 與滾動更新的好處。

問題不在「縮減副本」這個動作本身,而在於 graceful draining 沒跑完。Elixir 的 sessions service 在每個 pod 裡跑著數以萬計的 GenServer process,每個都拿著一個連線中的裝置 in-memory state;要把它們平順搬到另一台 node,必須等 hibernate、序列化、跨 node 重建跑完。Kubernetes 的預設 terminationGracePeriodSeconds 通常不夠這套儀式跑完——那次部署沒有調高,也沒有設 admission webhook 去攔截不安全的縮容。

結果就是 sessions service 在那一瞬間被「ungracefully stopped」掉了一批 pod。

事後報告給出的數字是:「17% of sessions across Discord were ungracefully stopped」

更關鍵的是,這 17% 並不是均勻分散——它們全集中在 us-east1-b 同一個 zone 裡。當這些 session 嘗試重連、轉到鄰近 zone 時,重連的流量就會形成一道朝著 us-east1-c 與 us-east1-d 灌過去的「reconnection storm」。Bug 出在 us-east1-b 的一個 deployment 設定,但被它打垮的是 us-east1-c、us-east1-d,最後是全球的 voice syncer fleet——只要 cross-zone failover 機制存在,局部問題都有機會經 failover 變成全域問題。

從 sessions 切到 gateway——兩分鐘內第一波擴散

12:13 到 12:15 PDT 之間,Erlang/OTP 的 process linking 機制把這場意外從局部變成了全域。Elixir 用「let it crash」加 supervisor 樹:每個 GenServer 異常退出時,monitor 會發 {:DOWN, ref, :process, pid, reason} 給所有訂閱者。Discord 的 realtime 系統是高度互鏈的——每個 session 同時被 guild process、presence tracker、gateway connection 訂閱。事後報告用了 「flurry of messages」 來形容這道兩分鐘內爆發的訊號雜訊;每條清理路徑單看都合理,同時跑這麼多次本身就是負載。

這道雜訊很快撞上 gateway service。當前端 session 斷線、client SDK 立刻指數退避重連,gateway 同時要處理「上一批正在拆」與「下一批正在建」兩股流量。每個 host 原本有 session start rate limit 自保,但這個限制值在遷移過程中被「miscalibrated post-migration」——新 host 的設定值沒按新 pod 的 CPU/記憶體比例重算。Gateway 在湧入的 reconnection 面前等於沒有保護,us-east1-b 節點的記憶體幾乎垂直拉升到 100%;OOM killer 介入後,流量甩到 us-east1-c 與 us-east1-d——失敗自此跨越 zone。reconnection storm 在前 15 分鐘裡讓所有 zone 都進入「異常負載」,當下游 voice syncers 在第 30 分鐘開始撐不住時,整個 stack 已經處在「沒有任何 zone 還有 head-room」的脆弱狀態。

cascade 的真正長尾——voice syncers 的 mailbox 是怎麼漲到一百萬的

從 12:13 一直到 15:30,最難解的部分都集中在一個平淡無奇的內部服務:voice syncers——它「manages SFU endpoint placement across more than 25,000 external instances」。換成白話:你按下「加入語音頻道」時,session 端不會直接跟 SFU(Selective Forwarding Unit)談判,而是先打到 voice syncers,由它根據負載與地理位置決定接到哪台 SFU,再透過 HTTPS RPC 通知那台 SFU 收人。15 台 syncer 照顧 25,000+ 台 SFU,平時就已不算閒;reconnection storm 一來,所有通話中的 session 同時重建 SFU 關聯,負載瞬間倍增。

voice syncers 內部用一個叫做 Holster 的函式庫池化 HTTP 連線,底層是 Erlang 的 gun HTTP client,連線生命周期由兩層 supervisor process 管理。Erlang process 是 actor model,靠 mailbox 通訊,主迴圈是一個 receive 區塊;這兩層 supervisor 用的是 selective receive,亦即在 receive 區塊裡用 pattern match 挑符合條件的訊息,不符合的跳過保留在 mailbox 裡。mailbox 短時這幾乎免費;一旦變長,每次 selective receive 都得從頭掃過所有未處理訊息,複雜度退化成 O(n),而 spawn 變慢又讓 mailbox 變更長——它本身就是一個自我加速的退化模式。

事後 Discord 工程師量化測試出一個讓人印象深刻的數字:「we confirmed that this selective receive on a supervisor with a ~100k mailbox queue adds ~1ms to process spawn time」。1 毫秒聽起來不多,但每個 SFU RPC 都觸發一次新的 connection spawn——syncer 每秒鐘 spawn 幾百到幾千次時,1ms/次的 overhead 直接吃光 CPU。下面這張圖是這條退化關係的可操作版本:拖動 mailbox 深度與分區數 N,看 per-spawn overhead 與整體 throughput 怎麼變。

100,000
1
selective-receive overhead per spawn vs effective mailbox depth (n/N) 0 2.5 5.0 7.5 10 ms overhead / spawn effective queue depth per partition (log scale) overhead ≈ 0 ms ~7.5ms · syncer fleet collapses ~2ms · safe operating zone
把 mailbox 切成 N 個分區之後,每個分區的有效深度是 n/N,selective receive 的 O(n) 也跟著縮到 O(n/N)。把 N 從 1 拉到 32,~1M 的單一 mailbox 退到 ~30k/分區,overhead 從吃光 CPU 落回亞毫秒區間。Discord 報告給出的「~1ms @ ~100k queue」是此曲線上的一個錨點。

把 mailbox 切成 N 個分區之後,每個分區的有效深度是 n/N,selective receive 的 O(n…

mailbox 100k 時每次 spawn +1 ms;N=32 分區後深度降至 ~3k,overhead 回到亞毫秒。

更糟的是,這條 critical path 還跟 service discovery 黏在一起。voice syncers 透過 etcd 註冊健康狀態:每個實例定期 refresh 一個 TTL 告訴 hash ring「我還活著」。問題是這個 TTL refresh 也要經過同一組被連線請求塞爆的 supervisor mailbox——排到 100 萬則時 refresh 根本來不及,etcd 就把實例從 ring 上拔掉。control plane(service discovery)與 data plane(connection management)共享同一條 critical path,把「我還活著」跟「我正在處理流量」綁在一起;後者卡住,前者也一起死。健康檢查機制本身變成崩潰加速器。

結果是一個糟糕的正回饋迴圈:

  1. mailbox 變長 → process spawn 變慢 → endpoint create 失敗率上升
  2. endpoint create 失敗 → 上游服務重試 → mailbox 變更長
  3. TTL refresh 排不上 → etcd 把實例移出 ring
  4. 剩下的健康實例要承擔更多 failover 流量 → 它們的 mailbox 也開始堆
  5. 回到第 1 步

正回饋迴圈是分散式系統最危險的失效模式:不像線性退化會在某個負載點停下來,每跑一輪迴圈下一輪就更快、影響範圍更大。對抗它的方法不是「修復某個壞掉的部位」,而是「切斷迴圈中至少一條邊」。儀表板上 12:14 PDT 「Endpoint Select/Create Success」跌到接近零;15 台 syncer 只剩 2-8 一台還活著但獨自扛不住全球三分之一流量。15 台 mailbox 同時走向同一個失敗模式——這種「同步退化」的形狀幾乎可以斷定問題不在實例本身,而在它們共同依賴的某個系統屬性,這次是上游沒有節流。

三次失敗的 rollback——為什麼救火比起火還難

從 12:43 到 13:09 PDT,on-call 嘗試了三波恢復動作,每一波都短暫地讓人鬆口氣再馬上把人打回深淵。

第一波(12:43 PDT)重啟單一實例:團隊挑了問題最嚴重的 2-13 完整重啟。新 process 起來、mailbox 歸零、etcd 註冊回 ring 上——但只持續不到一分鐘。上游發現有健康節點回來,立刻把累積的 retry 全灌過去,mailbox 三十秒內又從 0 漲到數十萬。當一個服務從「健康但過載」變成「健康且能接流量」,所有等在門外的請求會在第一個 epoch 同時湧入,對單一實例而言這個瞬間負載比平均高一個數量級。

第二波(12:47 PDT)殺掉特定的 supervisor:只 kill 並重啟 Holster.Pool DynamicSupervisor,理論上清空 mailbox 但保留 etcd 註冊。結果同樣模式重現。這次嘗試其實透露一個重要訊號——如果 mailbox 清零後依然立刻被填滿,root cause 不在 mailbox 內容也不在 process 狀態,而在上游流量速率。

第三波(13:05 PDT)整個 cluster 同時重啟:賭同時清空 15 台的 mailbox 能讓整體得到「同步的喘息窗口」。事後報告稱這個瞬間「briefly recovered」——但下一秒所有上游同時發現 voice syncers 全體健康,把累積 50 分鐘的 retry queue 一次釋放出去。「cold-start thundering herd was too much to handle」

三次失敗的共通結構是:問題從來不是 voice syncers 自己的狀態,而是上游沒有節流。Bo Ingram 與 Stephen Birarda 點明這條邏輯——「A sufficiently large traffic spike will find a bottleneck in your system」。經驗豐富的 on-call 會在「重啟看看」之前先問:「上游有沒有累積?下游有沒有節流?」

incident clock · 12:13–14:30 PDT · drag handle to inspect state success 0% mailbox ~1M 12:13
endpoint create success
100%
avg syncer mailbox
~0
在 12:13 之前,voice control plane 各項指標都在綠燈區間。
橘色是 15 台 syncer 平均 mailbox 深度、綠色是 endpoint create success rate(事故報告未提供逐分鐘讀數;曲線根據明文時間點與描述插值)。拖動把手或點擊軸線即可跳到任意時刻。

橘色是 15 台 syncer 平均 mailbox 深度、綠色是 endpoint create success r…

三次重啟皆失敗;13:43 鎖上游 rate limit + 擴容 dispatcher 是轉捩點;14:26 全體恢復。

第四次嘗試——同時增加供給、減少需求

13:43 PDT 開始的這一波不再是單一招式。團隊升級成兩條並行修復路徑——事後覆盤用一句很 SRE 味的話描述它:「your solutions either increase supply (adding additional services) or reduce demand (rate limiting inbound traffic)」

路徑一:上游 syncer spawn rate limit。team 緊急更新 call owner services(guilds、calls、streams)引入新的 rate limit;最關鍵的一條是針對 coordinator syncer——當一個 guild 有大量語音頻道時,coordinator 會為每個頻道 spawn 一個 child syncer,這種「對每個 X 都 spawn 一個 Y」的 unbounded fan-out 是放大效應的源頭,鎖住後 etcd 上看到的 syncer 創建速度立刻降一個數量級。

路徑二:水平擴容 SFU endpoint dispatcher。透過 GCP Terraform 加 Salt 配置,team 手動補上 15 台 dispatcher 把容量翻倍。把 per-instance load 砍半就等於把 mailbox queue 砍半,等於把 process spawn time 從幾毫秒拉回亞毫秒。事故狀態下跳過 release pipeline 是合理的選擇,但事後該回頭問:「為什麼這個動作平時沒做成一鍵化的 incident-response 工具?」

13:43 的重啟結果與前三次有質的不同:失敗的部位從 Holster.Pool 換到了底層 gun supervisor,實例還能保持 etcd 註冊。「失敗的部位換了」這個訊號在事故診斷裡非常重要——它告訴你先前的瓶頸已經不是 critical bottleneck,繼續往同一方向施力就會看到收斂。14:03 實例 2-3 第一台真正恢復;14:15 重啟五台,四台維持低 mailbox;14:26 最後一台恢復,doubled cluster 全體健康——總計 3 小時 17 分鐘。

Stage 1 · 12:13 PDT — pod terminated

Kubernetes 在 graceful drain 完成前就 SIGTERM 掉 us-east1-b 的 sessions pod,17% 全球 session 同時失聯。

Stage 2 · 12:13–12:15 — DOWN flurry

每個被殺掉的 GenServer 對 guild、presence、gateway 送出 {:DOWN, ...} 訊息,realtime 內部訊號量暴衝。

Stage 3 · 12:15–12:30 — gateway OOM

Reconnection storm 撞上 miscalibrated per-host session rate limit,us-east1-b 的 gateway 記憶體拉到 ~100%,故障跨 zone 擴散到 1c 與 1d。

Stage 4 · 12:13–14:26 — syncer mailbox blowup

15 台 voice syncer 的 Holster.Pool selective receive 在 ~100k mailbox queue 下每次 process spawn 多 ~1ms,雪崩成 ~1M 訊息深的 queue。

Stage 5 · etcd ring 失血

TTL refresh 卡在同一個 mailbox 裡排隊,etcd 將實例從 consistent hash ring 移除,存活實例承擔 failover 流量直至全體被打垮。

sessions / us-east1-b replicas halved 17% sessions cut {:DOWN, ...} guild presence gateway gateway us-east1-b mem ≈ 100% gateway us-east1-c failover load gateway us-east1-d failover load miscalibrated rate limit reconnection storm crosses zones voice syncer × 15 Holster.Pool mailbox ~1M msgs selective receive +1ms / spawn 25,000+ SFU HTTPS RPC backed up consistent hash ring etcd-backed TTL refresh starved → instances ejected remaining nodes inherit all failover

互動圖表

五段級聯:sessions 減半→DOWN 風暴→gateway OOM→syncer mailbox 爆炸→etcd ring 侵蝕。

結構性修補——把這條 critical path 永久拆掉

Discord 列了四條軸線的後續修補,都值得作為「自家系統還可能犯同樣錯」的檢查清單。

部署層:Kubernetes admission webhook。在 Kubernetes 加一個 validating admission webhook 檢查 Elixir workload 的縮容請求——只有當 entity drain 跑完才放行。這把「graceful drain 必須跑完」這條原本只存在於工程師腦中的隱性契約,明文寫進 control plane 的 enforcement 裡。50 人的團隊可以靠口耳相傳維持「縮容前要 drain」,500 人不行——只有 enforcement 不會被忘記。

架構層:PartitionSupervisor 取代 Holster.Pool 的單一 supervisor。原本所有 spawn/terminate 訊息都打到一個 mailbox;換成 Elixir 1.14 內建的 PartitionSupervisor 後切成 N 個分區(通常 N 等於 scheduler 數),每分區一個 mailbox,selective receive 的 O(n) 跟著切成 N 份。同時 gun 額外那層 supervision 也被拿掉,連線生命周期直接由 Holster.Pool 管理。修補哲學的精彩之處在於:不是「加大 mailbox」也不是「跳過 selective receive」,而是「把單一 contention point 切碎」。

流控層:上下游同時加 rate limit。call owner services 統一更新成有合理的 syncer spawn rate limit;voice syncers 內部也補上 endpoint selection rate limit;另外多了一套「optional load shedding tool」,把先前手動操作的應急路徑變成可程式化呼叫的工具。事故當下寫過的每一條「手動 fix」,事後都該問:「能不能變成一條按鈕?」

可觀測性層:mailbox health 變成一等公民。團隊新增了 HTTP connection pooling 的 mailbox health 監控、service discovery announcement 儀表板、voice-to-SFU RPC traffic 的細部 instrumentation。報告還提到「pursuing generic process mailbox monitoring across all Elixir services」——把「任何 process 的 mailbox 長度都該被監控」推到全公司。對跑其他語言的團隊,等價目標是:所有 message channel、worker queue、event loop 的 backlog 長度都該被監控;任何「訊息傳遞層的累積」都是 leading indicator,比 latency 或 error rate 提前一兩個訊號。

更長期方向:A/V 基礎設施從手動管理的 GCP VM 搬上 Kubernetes;voice control plane 重新切成跨 region 架構,讓單一 region 的 connection spike 不再能拖垮全球。能在覆盤裡明白寫出「這些事還沒做」是難得的誠實——很多公司會把長期項目隱去。

給跑其他語言的人——三條可遷移的原則

O(n) selective receive 是 BEAM 一個冷門但會吃人的細節。receive 區塊在 mailbox 短時快得不像話,這讓開發者忘了它在 mailbox 長時會退化;任何用 pattern match 從 mailbox 挑特定訊息的路徑都要假設過載時會退化,不能避免就監控 mailbox length。BEAM 21+ 的 receive ref 編譯器最佳化能在某些情況把複雜度降回 O(1),但 Holster.Pool 那次正好是 hint 無法應用的場景。對其他語言:Go 的 buffered channel、Rust tokio 的 mpsc、Node 的 event loop、Java 的 ThreadPoolExecutor queue——每一個都會在過載時退化到某種失敗模式,差別只在症狀。

service discovery 與 connection management 不該共享同一條 mailbox。control plane 與 data plane 必須是物理上分開的 process tree。Kubernetes 的 readiness probe 該走獨立的 endpoint 與 thread pool;資料庫的 health check 該走獨立 connection;gRPC server 的 health service 應該跑在獨立 sub-channel——任何「我還活著」的訊號路徑都不該跟「我正在處理業務」的路徑搶資源。

cold-start thundering herd 是恢復流程的隱形殺手。當源頭是「上游沒節流」,重啟多少次下次都會被同樣流量打回去。恢復計畫必須同時包含「停止流量灌入」與「恢復服務容量」兩條軸線。對抗工具包括 startup jitter、warm-up traffic、circuit breaker——這次事故顯示,這些工具在 voice control plane 那層是缺失的。

數字一覽——把這次事故壓成幾個關鍵指標

下面這張表把整起事件的關鍵時間軸與當下狀態壓在一起,方便要回頭查特定時刻時不用回去刷整份報告。每一列都對應覆盤裡明文寫出的時間與動作。

3/25 incident — operator action ledger(click a header to sort)
time (PDT) operator action outcome
12:13k8s scale-down halves us-east1-b sessions pods17% sessions ungracefully stopped
12:15gateway us-east1-b begins memory climbnodes restart; failover spreads to 1c, 1d
12:14endpoint select/create success dropsnear zero across all 15 syncers
12:43restart syncer instance 2-13mailbox regrew within seconds
12:47kill Holster.Pool DynamicSupervisor on 2-1same regrowth pattern
13:05full cluster restartcold-start thundering herd
13:43upstream rate limits + 15 extra dispatchers via terraformgun supervisor backs up but etcd holds
14:03restart instance 2-3first instance with stable low mailbox
14:15restart 5 instances4 of 5 fully recovered
14:26last instance recoversdoubled cluster healthy

互動圖表

13:43 前所有操作皆失敗;鎖住上游 rate limit 後才連續成功,根因是上游而非 syncer 狀態。

把這張表按時間排序時你會看到一條很乾淨的劃分線:13:43 PDT 之前的所有 operator action 結果都是 outcome-fail,之後則是連續四列 outcome-ok。前三次救火為什麼失敗、第四次為什麼成功,差別就在於「是不是把上游也鎖住了」。

把 Holster.Pool 換成 PartitionSupervisor——結構性的關鍵

如果整篇覆盤要挑一張圖留下,那會是 voice syncers 的 HTTP connection 池在事故前後的結構對比。事故前,所有 connection 的 spawn/terminate 訊息都打到一個 Holster.Pool 的 DynamicSupervisor 的 mailbox 裡;事故後,這個單一 mailbox 被 PartitionSupervisor 切成多個分區,每個分區自己一個 mailbox,selective receive 的 O(n) 也跟著切成 O(n/N)。

before — single Holster.Pool after — PartitionSupervisor voice syncer process spawn / terminate requests single supervisor mailbox selective receive · O(n) gun http connections extra supervision layer ~1M msgs in queue · +1ms / spawn voice syncer process spawn / terminate requests part 1 mbox part 2 mbox part 3 mbox part N mbox gun http connections direct lifecycle in Holster.Pool contention sharded into N partitions · selective receive · O(n/N)
before/after:左圖把所有 spawn/terminate 收進同一個 mailbox 並再經一層 gun supervision;右圖把 mailbox 切成 N 個分區,並把 gun supervision 折掉一層。

before/after:左圖把所有 spawn/terminate 收進同一個 mailbox 並再經一層 gun …

PartitionSupervisor 取代單一 mailbox,selective receive 從 O(n) 降至 O(n/N)。

切成 N 份之所以管用,不只是「分擔負載」這句話可以概括。selective receive 的退化是 O(n) 掃描;當 N 個 mailbox 各自獨立,每個的 n 都是原本的 1/N,掃描時間也跟著降到 1/N。voice syncers 的分母通常是 BEAM scheduler 數,在多核 host 上輕鬆 16-32:~100k 切成 32 份變成 ~3k,回到 selective receive 還算便宜的區間。拿掉 gun 額外那層 supervisor 是另一個值得學的細節——多一層 supervisor 就多一個訊號中轉點,對 critical path 是純粹 overhead。

mailbox 曲線——把那段崩壞畫成一張圖

為了把這段事故從文字裡抽出來變得更可感,下面這張靜態長條圖把 15 台 syncer 實例在事故高峰時的 mailbox 大小排在一起。每一根 bar 都對應一個實例,依照事後 dashboard 上的相對深度排序。它的價值不在「精確還原當下每個實例的數字」——Discord 沒有逐台公開讀數——而是在於凸顯一個現象:當瓶頸是上游流量時,崩壞的分佈不是少數實例壞掉、其他人健康,而是所有 15 台一起朝同一個方向走

syncer mailbox depth at incident peak total mailbox by size · 15 instances · ~13:00 PDT 0 250k 500k 750k ~1M 2-13 2-1 2-5 2-11 2-7 2-2 2-14 2-4 2-9 2-12 2-6 2-10 2-15 2-3 2-8 instance id(sorted by mailbox depth) ~1M cap line 2-8 · the lone survivor
同色長條代表事故高峰時各 syncer 實例的 mailbox 深度(依深度由大到小排序)。橘色虛線是 ~1M cap、綠色那根是事故期間唯一勉強維持運作的 2-8——它一台要扛全球三分之一流量。

同色長條代表事故高峰時各 syncer 實例的 mailbox 深度(依深度由大到小排序)

高峰時 15 台 syncer 同時逼近 100 萬 mailbox;同步崩壞證明根因是上游流量而非個別實例故障。

這張圖傳達的訊息很單純:壞掉不是因為某幾台異常,而是因為整個 fleet 都被同一道上游流量壓著走。當你的故障曲線長這個形狀,你的恢復計畫就不可能是「重啟壞掉的那幾台」——因為沒有「壞掉的那幾台」。恢復計畫必須處理上游。

覆盤的氣質——一份願意承認「我們也會犯這種錯」的報告

讀完整份覆盤後,留在腦中最深的不只是技術細節,還有編輯立場——報告開頭引「sad trombone」、Bo Ingram 與 Stephen Birarda 的 Senior Staff Engineer 署名、明白寫出「A/V infra 還沒上 Kubernetes」與「control plane 還沒跨 region」這些尚未完成的長期項。結構上它是 trigger → cascade → mitigation attempts → why they failed → final mitigation → structural fix → long-term roadmap,每段都有具體時間點、數字、引用、self-criticism 與可遷移原則。把覆盤當公共財而不是內部問責文件,這個立場本身就是工程文化的訊號——Cloudflare、Stripe、AWS 過去十年都走過這條路,Discord 這次加入了這份名單。

從這次故事帶走的——壓回一個句子

如果要把整篇覆盤壓成一句:一行 Kubernetes manifest 的誤設,會經由 graceful drain 缺位、reconnection storm 跨 zone、Erlang selective receive 在長 mailbox 下退化、service discovery 與 connection management 共享 mailbox、cold-start thundering herd,這一連串平日各自無害的設計選擇,串成一條 3 小時 17 分鐘的全球語音崩潰路徑。

每一個環節單獨來看都不算 critical bug——graceful drain「應該」會跑完、selective receive「通常」很快、service discovery「平常」不會跟 connection 搶資源、reconnection「一般」不會多到打爆 gateway。當這些「應該、通常、平常」全部同時失敗,就會變成這次的 3 小時 17 分鐘。你不會在自己系統上遇到完全一樣的 Holster.Pool 故障,但你會遇到結構上相似的——當那一天到來,你會慶幸自己讀過 Discord 這份覆盤。

The lesson:分散式系統的崩潰幾乎永遠不是「單一 bug」,而是多個各自無害的設計選擇在某個流量峰值下同時失效——你能做的不是消滅 bug,而是把每一條 critical path 上的 invariant 寫成可被 enforce 的契約。