昨天講這套東西刻意沒有的能力,以及那條「決定讓一個能力存在,就得為它付出結構」的判準。
今天講召喚出來的東西誰收。開一場很快,按一下就有;但東西開出來之後,總得有人負責它的下場。
場次結束誰收拾,決定了明天還開不開得起來。Day 16 講的是規則會退役,今天講的是容器會留下。兩件事同一個形狀:存在過的東西,誰負責讓它不在。
先把地基講清楚,因為後面兩個事故都是從這裡長出來的。
這套系統的狀態住在三個地方,各自負責不同的事:
對話住在掛出去的目錄。 每個使用者有一個自己的狀態空間掛進容器(ADR 0014)。容器被殺掉、被重建、被換一個 image,對話都還在,因為那些東西根本不在容器裡面。這是整套設計裡最重要的一條線:容器是可拋棄的,對話不是。
Session 的真身是容器。 那個 PTY、正在跑的 CLI,以及尚未存檔的緩衝內容,都只存在於那個容器裡。它死了就是死了;這一版接受不替執行中的狀態做備份。已經落盤的對話紀錄由外部掛載接住,但沒存檔的內容、正在執行的行程與完整終端畫面,都不在恢復範圍內。
DB 管的是需要跨 worker 取得共識的那幾件事。 哪個 port 已被宣告、哪個 view 屬於哪一場、這一輪誰持有對帳租約,都寫進同一套 DB。Port 競爭靠 UNIQUE 約束,租約取得則放在一筆不可交錯的交易裡;因此在這幾個共享點上,多個 worker 不必各自依賴自己的記憶做判斷。
有一件事我要講清楚,免得誤導:DB 不是可拋棄的。 對話的持久化不依賴 DB,不代表 DB 可以丟。DB 裡裝著使用者的密碼雜湊、加密後的憑證,以及需要長期保留的 Session 歷史紀錄(ADR 0010)。丟掉 DB,對話還在,但這台機器上的帳號跟稽核鏈就斷了。
對帳這件事最直覺的一半,是收拾。
單人使用一樣會漏東西,而且漏得比想像中快:
creating 列,已經建立的容器卻因清理失敗而留下。它不屬於任何人,沒有任何畫面看得到它,但它佔著記憶體跟一個 port這一半聽起來不難,難的是「不難」讓人以為它安全。我在這一半上踩了兩次,兩次都是同一天。
第一條規則講在前面:要辨識「哪些東西歸我管」,只能靠你自己明確打上去的標記,不能靠命名慣例。
名字看起來是個很自然的判斷依據。你的東西都叫 myapp- 開頭,所以掃到 myapp- 開頭而登錄表裡沒有的,就是該收的。這個判斷在你寫下它的那一天是對的。
問題是名字不是只有你在取。編排工具會拿專案名當前綴、框架會有自己的預設命名、別人裝的東西可能剛好撞上同一個字。這些都在你的判斷條件之外,而且它們變動的時候不會通知你。
我踩到的版本是這樣:孤兒容器原本用名稱前綴認,控制平面自己容器化之後,compose 拿專案名當前綴,於是基礎設施容器全部符合那個前綴,而它們當然不在 Session 的登錄表裡。第一輪對帳就把反向代理跟對帳程序自己刪掉了。
這件事的形狀不限於容器。用檔名前綴清 /tmp、用 log 前綴過濾、用命名規則決定誰要被自動處理,都是同一個判斷。它們共通的性質是:判斷依據由別人維護,而你在它上面做破壞性動作。
改用 label 之後,第一層邊界才變成結構性的:沒有管理標記的容器,對帳程序根本不會納入候選。這不代表一個 label 就足以判定「該刪」;測試容器就是下一個反例。
還有對稱的另一半值得一提:測試會建自己的容器,那些容器帶著一樣的 label、卻不在正式的 DB 裡,所以正式的對帳程序看到它們一樣會當孤兒收掉。解法同理,再多打一個標記讓它認得出來並跳過。只要辨識規則可能誤判,兩個方向都得分開檢查:不該收的會不會被收掉,該收的會不會被漏掉。
第二次比較細,但它是這一半真正的難處。
建立一場 Session 的順序是:先寫 DB 那一列,再去起容器。這個順序是刻意的(不然容器起來了卻沒有人記得它,就是上面那種孤兒)。但它製造了一個窗口:DB 有列、Docker 還沒有容器。
那個窗口可以很長。走限制網路的 profile 時,防火牆一鎖網路就會切斷半截下載,所以 trivy 的漏洞資料庫必須在套規則之前先更新完。沒命中快取的話,那份 DB 解壓後大約 1 GB,實測整整 36 秒。
如果對帳程序在這段時間看到「有列、沒容器」就判定它不見了、把列刪掉,會發生兩件事:建立流程回頭找不到自己那一列,直接失敗;而剛剛好不容易起來的容器,變成沒人認領的孤兒,下一輪被收掉。
對帳本身每 30 秒執行一輪;真正的寬限期則有三個:已經搶到 port、但還沒寫入 pid 的 view 宣告保留 30 秒,孤兒容器保留 120 秒,卡在建立中的列保留 300 秒。
這些是我依當時環境留下的保守設定值。Trivy DB 未命中快取時,我曾量到一次冷啟動花了 36 秒,解壓後資料約 1 GB;300 秒不是由 36 秒精算出來的,而是另外留下足以涵蓋環境波動的餘裕。
View 宣告的 30 秒寬限期,還有一個配套要求:另一個 worker 等待這個 view 就緒的時間,必須明顯小於 30 秒。它們是同一個窗口的兩端,一個在等對方準備好、一個在判定對方已經死了。等待的那一側如果設得太久,會等到一半,對方的宣告就先被當成逾期回收掉。
這一半我做完的時候,以為對帳做完了。
然後有一次,整個列表停了將近四十分鐘。
症狀是這樣的:網頁上的 Session 列表轉圈轉不出來,所有操作都沒有反應。而我在終端裡打 docker ps,它秒回,容器都在,看起來一切正常。
這個落差是整件事最誤導人的地方,而它有一個很機械的原因:docker ps 讀的是 Daemon 記憶體裡的清單,不需要碰到任何一顆容器。列表端點不一樣,它會真的去碰:當時它為了判斷「還沒就緒過」的那幾列到底起來了沒,會各打一次 docker logs。
而當時有一顆容器卡在 removing 狀態。Docker 對單顆容器有一把鎖,那顆容器卡住的時候,針對它的 inspect、logs、top 全部不回應。
接下來是算數的部分。docker-py 的預設 timeout 是 60 秒,而那支列表端點每 15 秒被前端輪詢一次。每一次輪詢都有一條執行緒進去,卡在那顆容器上等滿六十秒才回來。因此每 15 秒一次的輪詢,遇上最長 60 秒的等待,會同時留下約四個尚未結束的請求。
而控制平面跑的是 gunicorn --workers 1 --threads 8。也就是說,一個開著的列表頁,就可能占住這個執行緒池約一半的處理能力。
我當時看到的是整個後端沒有反應,但剩下的處理能力去了哪裡,我沒有留下足夠證據。所以這裡只講算得出來的那一半:這顆卡住的容器成了全站失去回應的重要放大因子,而終端裡的 docker ps 一直在跟我說沒事。
修法是三件事,而第一件是把整個形狀反過來:
第三件事的措辭我改過一次,而那次修改比機制本身更值得講。警示色的語意必須是「對帳可能卡住」,不是「你的 Session 有問題」。因為那個當下 Session 通常好好的,出問題的是我看它的那條路。把兩件事講混了,使用者會去重開一個沒有壞掉的東西。
而同一顆容器也讓對帳那一輪整輪失敗了,靠的是一個更小的細節:移除容器逾時丟出來的是 urllib3 的 ReadTimeout,而當時那條路徑只接 docker.errors.APIError。逾時不是 API 錯誤,於是例外一路穿出去,被主迴圈接成「本輪失敗」,於是跟那顆容器毫無關係的歸檔、view 清理、租約清理全部跟著停擺。
我不是在整輪外面籠統接住 Exception,而是只隔離每一次針對單顆容器的 Docker I/O。NotFound 仍交回呼叫端判斷;其他例外會留下紀錄並回傳 STUCK,讓這一顆留到下輪重試,其他工作繼續。整輪共用的 containers.list() 若失敗,對帳仍會停止。
逐顆隔離這一層,讓單顆容器問不到時只留下該列異常,不再拖垮整輪對帳。
對帳不是定期清垃圾,而是每一輪把現實推回期望狀態。 收掉多出的東西,只是其中一個方向;補回缺少的東西,才要求系統知道正確世界應該長什麼樣。前面講完了收拾,現在講另一半。
先講清楚這個東西是什麼,因為它是這一節唯一的例子。
昨天講到,Session 容器裡的 git 不直接連 GitLab,中間隔著一顆 NGINX 反向代理。PAT 由控制平面解密後寫入代理容器的 NGINX 設定,轉送時再由代理補上,因此不會進入 Session 容器。這顆代理是每個設了 token 的使用者一顆,由系統替他建立與維護;使用者知道它存在,但不需要自己操作。
於是問題來了:它掛掉的時候,誰負責讓它回來?
一開始我的答案是「建立 Session 的時候檢查一下,不在就建」。這個答案能動,但它是錯的形狀,因為它把「這個東西該存在」綁在「有人剛好要開一場」上面。沒有人開場的那段時間,那顆代理不在,而系統不知道。
正確的形狀是把它當成期望狀態:對帳程序定期問一句「照設定,現在應該有哪些代理?」然後跟實際有的比對。少了就建,多了就收。這跟 Kubernetes 的 Deployment 是同一個形狀,只是小很多。這個形狀叫 reconciliation loop,K8s 那邊負責跑它的元件就叫 reconciler,我這裡叫它「對帳程序」。
這裡有一個對照:
刪東西要判斷「這個不該存在」;重建則要判斷「這個該存在、現在不在、而且我有足夠資訊把它做出來」。兩個方向都做,才是我這裡要的完整控制迴圈。
收拾那一半需要的資訊比較局部:先用管理 label 確認它屬於這套系統,再確認 DB 沒有人認領,而且已經超過寬限期,才可以收掉。重建那一半要求你能夠從設定重新推導出正確的世界,那是完全不同層級的要求。我自己第一版也只做了前面那半,「開場的時候檢查一下」就是那個形狀。它逼你把「怎麼建出一個正確的東西」寫成一個可以重複執行的函式,而不是散在建立流程裡的一段程式碼。
而在寫這一段的時候,我還撞到一個更硬的問題:關掉這個功能的時候會怎樣?
我原本寫成「功能開著才跑收斂」。這個寫法有一個很安靜的後果:部署者把設定拿掉之後,既有代理會繼續帶著憑證、佔著位址;除非另外手動清理,否則那條程式路徑不會再回頭處理它們。
所以改成收斂照跑,只是期望狀態變成空集合。關閉的意思是收乾淨,不是停止管理。
這條有一個前提,而且是這一整篇前半在講的東西:收斂本身得先是對的。 收斂有 bug 卻讓它繼續跑,那不是繼續管理,是繼續用壞掉的方式管理,而設定都已經拿掉了,不會再有人回頭看它一眼。
還有一個決定要交代,因為它看起來很浪費:一個人同時開五場,那五場共用同一顆代理;改成每場一顆,隔離度不是更好嗎?
答案是限流。
在我這份設定裡,每顆代理都有自己的限流共享記憶體區域,而且使用固定的 $server_name 作為 key。因此一顆代理就是一個獨立的計數桶;如果改成每場 Session 一顆,同一個人開 N 場,就會得到 N 個彼此獨立的桶。
這無法只靠調整單顆代理的參數維持每人的總量,因為 N 會隨著 Session 數量改變。當然也可以另外加入集中式限流,但那等於再增加一層負責彙總的元件。在目前這個設計裡,讓同一個人的 Session 共用一顆代理,是最直接讓計數單位對齊使用者的方法。
最後一個故事,它讓我重新理解「傷害升級」這件事。
在我使用的 docker-py 7.2.0 裡,attach_socket() 回傳的是 socket.SocketIO。呼叫它的 close() 只關掉包裝物件、遞減 SocketIO 引用,不會在這條物件鏈上立刻關掉底層 fd;那個 fd 要等 docker-py 內部物件被 CPython 回收才消失。
一條沒被關掉、也沒有人在讀的 attach 連線會發生什麼事?Daemon 那一側繼續往裡面寫,寫到 socket 緩衝滿,然後 Daemon 就卡在那個寫入上。在我當時的環境裡,那個緩衝量到 208 KB。從那一刻起,那一場的輸出廣播整個凍住。
有趣的是它為什麼不是每次都發生。在我當時的環境裡,高輸出的 TUI 大約 100 秒就把緩衝填滿;低輸出的通常在垃圾回收把 fd 收掉之前就沒事了。這是一場垃圾回收跟緩衝區的賽跑,而賽跑的結果決定你今天有沒有踩到 bug。這也是為什麼它很難重現。
我要先講清楚範圍:凍住的是一場 Session,不是整個 Daemon。 這一點很重要,因為接下來才是真正的故事。
傷害是怎麼升級的。 我看到那一場沒反應,於是做了任何人都會做的事:docker rm -f 把它砍掉。
而 rm 也卡住了,因為它要動的正是那顆卡住的容器。卡住的 rm 抱走了那顆容器的鎖,從此針對它的 inspect、stop、rm 全部掛住。然後 docker-py 高階 API 的 containers.list() 也開始逾時,因為它在預設的 sparse=False 下會逐顆 inspect。
到這一步,才是全站災難。而觸發它的不是那個 fd,是我為了解決那個 fd 而下的指令。這時安全的做法是先別再對那顆容器下命令,避免繼續放大問題。要讓環境恢復,我當時只能重啟 Docker daemon;在 macOS 上就是重新啟動 Docker Desktop。
診斷的部分也值得記一筆,而這一段不是我查出來的,是我把現象交給 Claude Code 協助追查。我一開始在 Host 上掃,怎麼都找不到那條沒關的連線;把檢查位置換到控制容器裡才看見。問題不是連線不存在,而是一開始看錯了位置。
修法是自己關掉底層 fd,不依賴垃圾回收。而關掉它還有一個附帶好處:Daemon 那側會立刻收到 EPIPE,它自己的清理路徑就會把對應的緩衝關掉,不需要等任何人。
回歸測試那邊我釘的是「fd 真的關了」,不是「close() 被呼叫了」。而且第一條斷言是先驗證前提:只關包裝物件的時候,那個 fd 仍然是活的。前提如果不成立,後面那條測試就沒有意義了,它會在一個根本不存在的問題上一直綠燈。
最後一個巧合值得記:對帳程序在這場災難裡活了下來,靠的正是上一節那個「接 Exception 不接 APIError」的決定。它是為了四十分鐘那次寫的,卻在這次擋下了連坐。
今天講的是對帳迴圈的兩半:
docker ps 秒回不代表沒事。 它讀的是 Daemon 記憶體,inspect 才會碰到那顆容器。列表路徑從此不准碰 Docker明天講一件貫穿這二十九天的事:綠燈到底證明了什麼。