問題
保護措施放在邊界的外面,而要保護的東西在邊界裡面——那個保護就不會發生。
2026-09-04 一天之內撞到三次,形狀完全一樣:
| 例子 | 保護放在哪 | 要保護的東西在哪 | 結果 |
|---|---|---|---|
timeout 500 docker exec ... npx jest | 宿主機 | 容器裡 | 逾時只殺掉宿主機的 docker 用戶端,容器裡的 jest 繼續活著 |
靠 docker compose run --rm 清匿名 volume | 掛在容器「正常結束」之後 | 指令被 Ctrl+C 或判定卡住而中止時,可能根本走不到那一步(同事轉述,我沒實測) | volume 一直累積 |
| 一支「列出跑太久的行程」小工具標「無逾時保護」 | 比對指令字串有沒有 timeout | 上限寫在腳本內部 | 腳本裡有 LIMIT_MS=900(秒)的,被誤標成無保護 |
第三個是我自己寫的工具,寫的當天就犯了同一個病。
第二列的官方文件只寫到一半:docker run --rm 寫明容器結束時會一併移除「沒有名字的匿名 volume」;docker compose run --rm 只寫「容器結束時自動移除容器」,沒提 volume,也沒提被中止時的行為。所以「中止時不清」這條我標成未實測,但「清理掛在正常結束上」這個形狀本身不需要實測就該避開。
解法
把保護移到跟被保護的東西同一側。
# 錯:timeout 在宿主機,jest 在容器裡
timeout 500 docker exec my-app npx jest tests/security/foo.test.js
# 對:timeout 跟 jest 一起在容器裡
docker exec my-app sh -c 'timeout 300 npx jest tests/security/foo.test.js --runInBand'針對「用 docker exec 跑測試」這個情境,再配三條:
- 跑之前先清乾淨:
docker exec <容器> pkill -f jest || true。不然這一輪會跟上一輪的殘留搶資源。 - 加
--runInBand。jest 預設會開一堆 worker(實測一組 11 個),多組同時存在會互搶測試資料庫或 port,那正是卡死主因。 - 縮小範圍:用
-t "<測試名稱>"只跑要驗的那一個,不要整檔跑。
清掉殘留之後,要確認服務本身沒被誤殺:那次 pkill 之後,容器裡的 pm2 與 4 個 node 服務行程都還在,容器狀態 healthy。但不到一分鐘,宿主端又起一輪帶外層 timeout 的測試,容器內就再出現同樣的孤兒——這直接證明外層 timeout 殺不到容器內的行程。
為什麼這樣解
因為「殺得到什麼」取決於行程在誰的命名空間裡,不取決於誰先啟動誰。
docker exec 在宿主機上只是一個用戶端,真正的行程是由 Docker daemon 在容器裡建立的。宿主機的 timeout 送 SIGTERM 給用戶端,用戶端死了、連線斷了,但容器裡的行程沒有收到任何訊號——它甚至不知道有人在等它。
這個病最麻煩的地方是從卡住的那一端看不出來。發起測試的那一側只會看到「測試又逾時了」,重試,再逾時,再重試。它沒有任何管道知道容器裡已經堆了六組。看起來像「這條指令跑了 20 分鐘」,其實不是同一條卡了 20 分鐘,是同一件事重跑了六輪,每輪都留下一個不會消失的殘骸。是從外面用 docker top 看行程樹才發現的:同一個測試累積 6 組、共 12 個行程(npm exec + node jest)。
而且會惡性循環:殘骸越多 → 資源被搶得越兇 → 這一輪越慢 → 越容易逾時 → 又多一個殘骸。不會自己好。
長指令沒輸出時:卡死還是在跑?
保護放錯邊的另一面是:指令卡住了,從裡面看只是「一直沒有輸出」。同一天我還撞到兩條在背景卡了好幾天的 shell 指令,成因完全不同:
- 卡了 7 天 22 小時:
until grep -q <關鍵字> <檔案>; do sleep 30; done等一個不存在的檔案出現關鍵字。grep對不存在的檔案永遠回失敗,迴圈沒有出口。 - 卡了 2 天 20 小時:
cut -d: -f1 | sort | uniq -c < data.txt | sort -rn。輸入重導向接在uniq -c上而不是cut上,cut站著等一份不會來的資料;四個子行程全部活著、全部沒結束。
這兩條成因差太遠,想靠「比對指令樣式」一條條攔是攔不完的。比較可靠的訊號是跑了多久——卡住的東西會一直留在行程列表上,不會騙人。Linux(procps)的 ps 可以直接看:
# 列出自己跑超過 10 分鐘的行程;etimes 是啟動至今的秒數,etime 是 [[DD-]hh:]mm:ss 格式
ps -u "$USER" -o pid,etimes,etime,args | awk 'NR==1 || $2 > 600'欄位定義見 ps(1)。這只看得到宿主機上的行程;容器裡的要另外用 docker top <容器> 看。代價是會有誤報——壓測、build 本來就跑很久——所以它只是提示,要人自己判斷,不能當成「這條卡住了」的結論。上面第三列那支小工具的誤報就是這樣來的。
「卡死」和「在跑」在畫面上長得一模一樣,那天我被問了三次「是不是卡住了」,兩次是真卡死、一次是在跑。分辨方式:
| 情況 | 徵狀 |
|---|---|
| 真卡死 | CPU 0.0,子行程編號不變,累積 CPU 時間隔十幾秒取樣兩次完全沒動 |
| 在跑但看不到進度 | CPU 非 0 而且會變,子行程編號一直換(例如瀏覽器自動化的分頁行程) |
但更好的答案是不要讓人猜。同一天有一條瀏覽器量測,159 頁 × 兩個寬度 = 318 次頁面載入,結果檔和摘要都寫在整個迴圈跑完之後,外層還接 | tail -20 把中途輸出吃掉——結束之前輸出是零。補上每頁一行的進度檔之後,同樣的問題一行就講完:
[152/159] 96% 已跑 394s 預估剩 18s <目前這一頁>長時間指令要有中途進度,這不是體貼,是讓「卡住」變成可判斷的事。
怎麼預防再次發生
動手前問一句:這個保護,跟它要保護的東西在同一邊嗎?
- 跨越邊界的指令,保護要放在遠端那一側。 邊界包括容器(
docker exec/docker compose run)、SSH、跨機器的 RPC。宿主機的timeout、kill、Ctrl+C 都只作用在近端。 - 清理動作不要掛在「正常結束」上。
--rm、trap EXIT、finally 區塊,遇到強制中止都不保證會執行。要能承受中止,清理就放在下一次啟動前(先清再跑),而不是這一次結束後。 - 從外面判斷「有沒有保護」會失準。 看指令字串有沒有
timeout只是近似值,腳本內部的上限看不到。工具給的是提示,不是結論。 - 盤點類 node 腳本結尾補
process.exit(0)。 「輸出印完」不等於「行程結束」:require一個會初始化連線的模組(佇列、Redis、DB),連線還開著,node 就不會自己退出。(這條是同事實查的,我沒重現。) - 等待迴圈一律給上限。 不寫
until <條件>; do sleep N; done,改成有次數上限、跑完會自己退出的寫法;等之前先確認要等的東西真的會出現。 < 檔案要接在 pipeline 的第一個指令上,或乾脆把檔名直接當參數給第一個指令(cut -d: -f1 data.txt | ...)。- 長時間指令要印中途進度。 沒有進度,「在跑」和「卡死」就分不出來。
- 重試很多次都不成功時,換到外面看。 從卡住的那個行程內部看不出來,要看行程列表、
docker top、行程樹。
相關
- 批次驅動的退出碼會說謊:形態 5「逾時只砍到外層」是同一個病的另一個場景——
timeout砍掉包裝程式,底下的 k6 繼續跑。 - 容器裡的 node 按 Ctrl-C 停不掉:訊號送到了容器,卻因為 PID 1 的規則沒被處理;這篇則是訊號根本沒送進容器。