問題

保護措施放在邊界的外面,而要保護的東西在邊界裡面——那個保護就不會發生。

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 跑測試」這個情境,再配三條:

  1. 跑之前先清乾淨:docker exec <容器> pkill -f jest || true。不然這一輪會跟上一輪的殘留搶資源。
  2. 加 --runInBand。jest 預設會開一堆 worker(實測一組 11 個),多組同時存在會互搶測試資料庫或 port,那正是卡死主因。
  3. 縮小範圍:用 -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  <目前這一頁>

長時間指令要有中途進度,這不是體貼,是讓「卡住」變成可判斷的事。

怎麼預防再次發生

動手前問一句:這個保護,跟它要保護的東西在同一邊嗎?

  1. 跨越邊界的指令,保護要放在遠端那一側。 邊界包括容器(docker exec/docker compose run)、SSH、跨機器的 RPC。宿主機的 timeout、kill、Ctrl+C 都只作用在近端。
  2. 清理動作不要掛在「正常結束」上。 --rm、trap EXIT、finally 區塊,遇到強制中止都不保證會執行。要能承受中止,清理就放在下一次啟動前(先清再跑),而不是這一次結束後。
  3. 從外面判斷「有沒有保護」會失準。 看指令字串有沒有 timeout 只是近似值,腳本內部的上限看不到。工具給的是提示,不是結論。
  4. 盤點類 node 腳本結尾補 process.exit(0)。 「輸出印完」不等於「行程結束」:require 一個會初始化連線的模組(佇列、Redis、DB),連線還開著,node 就不會自己退出。(這條是同事實查的,我沒重現。)
  5. 等待迴圈一律給上限。 不寫 until <條件>; do sleep N; done,改成有次數上限、跑完會自己退出的寫法;等之前先確認要等的東西真的會出現。
  6. < 檔案 要接在 pipeline 的第一個指令上,或乾脆把檔名直接當參數給第一個指令(cut -d: -f1 data.txt | ...)。
  7. 長時間指令要印中途進度。 沒有進度,「在跑」和「卡死」就分不出來。
  8. 重試很多次都不成功時,換到外面看。 從卡住的那個行程內部看不出來,要看行程列表、docker top、行程樹。

相關