症狀

跑 make viz(前景 docker run -it … node services/viz.js)後按 Ctrl-C,取消不了。之後再跑一次 make viz 就撞 port 17090——原來上一個容器一直沒停,還佔著。

兩個症狀其實是同一個根因。

原因

容器裡直接 node xxx.js,node 就是 PID 1。實測(node:24.21.0-alpine):/proc/1/comm 是 node 本身(顯示 MainThread);送 SIGINT 後 2 秒仍在跑;docker stop 送 SIGTERM 也一樣沒反應,最後花了 10 秒才結束。

為什麼 PID 1 會這樣,官方文件講得很清楚:

  • Linux 的 kill(2):能送給 PID 1(init)的訊號,只有 init 自己明確裝了 handler 的那些,目的是避免系統被意外弄掛。容器有自己的 PID namespace,pid_namespaces(7) 對 namespace 裡的 init 也是同樣規則(從祖先 namespace 送來的 SIGKILL/SIGSTOP 例外,會強制送達)
  • Docker 的 docker run 文件直接寫:在容器裡當 PID 1 的程序會被 Linux 特別對待,預設動作的訊號一律忽略,所以除非程式自己寫了處理,否則不會因 SIGINT 或 SIGTERM 結束
  • Node.js 官方 Docker image 的 Best Practices:Node.js 不是設計來當 PID 1 的,建議用 --init 包一層
  • 那 10 秒是 docker stop 的預設寬限:docker stop 文件寫 Linux 容器預設 10 秒(Windows 容器 30 秒),時間到就改送 SIGKILL。我實測的 10 秒剛好對上

修法

兩層一起做:

# 第一層:讓 docker-init 當 PID 1,負責把訊號轉給 node
NODE := docker run --rm --init -v "$(CURDIR)":/app -w /app $(NODE_IMG)
# compose 同理
x-node: &node
  init: true
// 第二層:服務自己處理結束訊號(少了 --init 也停得掉)
export function closeOnSignal(server, name) {
  for (const signal of ['SIGINT', 'SIGTERM']) {
    process.once(signal, () => {
      server.close(() => process.exit(0));
      server.closeAllConnections?.(); // 瀏覽器的 keep-alive 連線不會自己斷,不關的話 server.close 會一直等
    });
  }
}

實測結果:加 --init 後 PID 1 是 docker-init,SIGINT 後立即結束;只加第二層(不加 --init),SIGINT 後 1 秒內結束——第二層單獨也有效。

另外把會「留著不走」的前景指令改成背景執行+明確的停止指令(make viz/make viz-stop),並在已經在跑時直接告訴使用者網址,而不是再起一個去撞 port。

為什麼這樣解

  • --init 讓一個很小的 init 程式當 PID 1,它會把訊號轉給 node,node 就照一般程序的預設行為結束。Docker 預設用的 docker-init 背後是 tini(見 docker run 文件的 Specify an init process 段)
  • 只靠 --init 不夠保險:哪天有人改了 Makefile、或換成別的啟動方式就又壞了。服務自己處理訊號,才是跟啟動方式無關的修法——這也是 graceful shutdown(收到結束訊號先把手上的事收尾再退出)本來就該做的
  • closeAllConnections() 那行是實作時多踩的一步:瀏覽器開著頁面時會保持 keep-alive 連線,只呼叫 server.close() 會等那些連線自己斷,等於又停不掉

怎麼預防再次發生

  • 新建任何跑 node 的容器(Dockerfile、compose、Makefile 的 docker run):預設加 --init/init: true,服務本身處理 SIGTERM
  • 驗證方法:起容器後 docker exec <c> cat /proc/1/comm 看 PID 1 是誰;time docker stop <c> 超過 1–2 秒就是訊號沒被處理
  • 症狀辨識:「Ctrl-C 沒反應」「docker stop 很慢」「再跑一次就撞 port」「容器越堆越多」——這幾個常常是同一件事:訊號沒送到真正在跑的那個程序,殘骸就留下來

相關主題

  • 啟動點與 Graceful Shutdown:應用層怎麼處理 SIGTERM/SIGINT
  • 同一族的另一個坑:宿主機的 timeout 送訊號給 docker exec 的用戶端,容器裡的程序收不到,殘骸一組一組堆起來