把應用直接當 PID 1 的代價#

Container 的標準寫法常常是 CMD ["python", "app.py"]CMD ["node", "server.js"],這會讓 python、node 直接成為 container 內的 PID 1。但這些 runtime 並不是為當 init 設計的,把它們塞到 PID 1 位置會出現一連串問題。

回顧上一章 init 的責任:

  • Reap zombie
  • 處理 SIGTERM、SIGINT、SIGHUP
  • 轉發 signal 給子行程

普通應用幾乎都不做這些事,於是出現「不負責任的 PID 1」。

問題一:Zombie 累積#

應用 PID 1 通常沒寫 SIGCHLD handler。一旦它 fork 出 helper(例如 shell out 跑 convert、跑 git、跑某個 script),helper 結束後就變 zombie。

  • 短期看不出問題,因為 zombie 不耗 CPU、記憶體
  • 長期下來 PID 表被塞滿,新 fork 失敗
  • 任何依賴 subprocess 的功能(例如 child_process.spawn)開始失靈

這個問題在 worker container(會跑 subprocess)上特別常見,而且潛伏期長,初次部署測不出來。

問題二:SIGTERM 被忽略#

Container runtime 走 graceful shutdown 時,預設先發 SIGTERM 給 PID 1,等一段時間(docker 預設 10 秒)才升級成 SIGKILL。

  • 如果 PID 1 是個沒裝 SIGTERM handler 的程式,kernel 對 PID 1 有特殊保護(見後章),SIGTERM 會被丟掉
  • 結果是 docker stop 看起來「卡住」,整整等到逾時被 SIGKILL
  • 應用沒機會 flush log、close DB、釋放鎖

問題三:Signal 不轉發#

即使 PID 1 自己有處理 SIGTERM,子行程通常不會自動收到。Container runtime 只把 signal 送給 PID 1,PID 1 必須自己廣播下去。

  • 一般應用不會做這件事
  • 結果:PID 1 退出後,子嗣繼續跑,container 顯得遲遲不結束
  • 或者反過來:PID 1 退出,子嗣被孤兒化,最終被 SIGKILL 集體清掉,沒有 graceful

問題四:exec mode vs shell mode#

Dockerfile 的 ENTRYPOINT 與 CMD 有兩種寫法,行為差很多。

Shell mode(不建議)#

CMD python app.py

這會被 docker 翻成:

CMD ["/bin/sh", "-c", "python app.py"]

於是 container PID 1 是 /bin/sh,python 是它的 child(PID 2)。

  • SIGTERM 送給 /bin/sh,多數 shell 不會轉發給 child
  • python 收不到 SIGTERM,也就不能 graceful shutdown
  • docker stop 必然走到 SIGKILL

Exec mode(建議)#

CMD ["python", "app.py"]

這會直接 exec 成 python,container PID 1 就是 python。

  • SIGTERM 直接送到 python
  • python 若有 handler 就能 graceful shutdown

CMDENTRYPOINT 是否用 JSON 陣列形式([...]),就能區分 exec 還是 shell mode。陣列形式 = exec,純字串 = shell。

問題五:應用本身的 PID 1 行為差異#

某些 runtime 對「自己當 PID 1」這件事額外處理過:

  • Java(OpenJDK 10+):對 SIGTERM 有預設行為(觸發 shutdown hook)
  • Node.js:預設不裝 SIGTERM handler,作者必須自己 process.on('SIGTERM', ...)
  • Python:預設把 SIGTERM 翻成 KeyboardInterrupt?不,預設是直接 terminate;但若程式啟動了 thread / asyncio loop,行為不一定如預期
  • Bash 腳本:必須顯式用 trap 才會處理 SIGTERM

不要假設「我的 runtime 應該會處理 SIGTERM」。寫個 1 行的測試 container,docker stop 看一下實際行為才準。

解法:給 Container 一個正規 PID 1#

最普遍的解法:用一個小型 init 當 PID 1,再讓它去 exec 應用。

  • tini:krallin/tini,docker 內建支援,docker run --init
  • dumb-init:Yelp 出品,行為類似
  • s6-overlay:偏向 multi-process container 的方案

下一章 signals 與後續 signal-and-container 會展開 signal 機制本身與 docker stop 的細節。

延伸閱讀#