沒有行程,就沒有容器#

容器(Container)的存在完全綁定在它的 PID 1 上。如果這個行程消失,容器也會跟著消失。這一章透過一個實驗驗證這件事,並解釋背後的原因。

實驗:在 host 殺掉容器的 ash#

先以前景模式啟動一個 Alpine 容器:

docker run -it --name killme alpine

進入容器後,先觀察容器內的 PID 1:

ps -ef

預期輸出(具體值依環境而異):第一行就是 PID 1ash/bin/sh,第二行才是 ps -ef 自己。

接著開另一個 terminal(Linux host 或 LinuxKit VM 內),找出該 ash 行程在 host 視角下的 PID:

ps -ef | grep '[a]sh'

預期輸出(具體值依環境而異):會看到一個 PID(例如 12345),其 parent 是 containerd-shim-runc-v2 或類似的 runtime 元件。

直接 kill 這個行程:

sudo kill -9 12345

回到第一個 terminal,會發現:

  • 容器內的 shell session 立刻被切斷
  • 終端機回到 host 的 prompt
  • docker ps 看不到 killme
  • docker ps -a 顯示 killme 的 STATUS 是 Exited (137)(137 = 128 + SIGKILL 的編號 9)

容器消失了。

為什麼會這樣#

要理解這個現象,必須先理解 Linux 上的 PID 1 與 PID Namespace 的特殊關係。

Linux 的 PID 1 規則#

在傳統 Linux 系統上,PID 1 是 init process(例如 systemdinit),由 Kernel 在開機時直接建立。它有兩個特殊地位:

  1. PID 1 是所有孤兒行程(orphan)的養父
  2. 如果 PID 1 結束,Kernel 會 panic(在傳統 root namespace 內)

PID 1 因此是整個 user space 的根,不能輕易死掉。

PID Namespace 的繼承#

容器使用 PID Namespace 製造一個「假的」PID 樹。當 Docker 建立容器時:

  1. clone() 一個新行程,並指定 CLONE_NEWPID 旗標
  2. 該行程在新 namespace 內成為 PID 1
  3. 在 host namespace 內,它仍有一個普通的 PID(例如 12345)

這個「容器內的 PID 1」並不是真正的 init,但它扮演容器內 init 的角色:所有後續在該容器內 fork 出的行程,都會以它為祖先。

PID 1 一死,整個 namespace 跟著消失#

關鍵規則:當 PID Namespace 內的 PID 1 結束時,Kernel 會發送 SIGKILL 給該 namespace 內的所有其他行程,並回收整個 PID Namespace。

換句話說:

  • 容器的 PID 1 = 容器存活的支柱
  • PID 1 死 → namespace 內所有行程被 SIGKILL → namespace 被銷毀 → 容器結束

這就是為什麼 host 殺掉那個 ash 行程,容器立刻消失。Docker daemon 看到該行程的 exit event,把容器狀態標記為 Exited

驗證:容器 = PID 1 + Namespaces + cgroups#

這個實驗其實揭示了「容器」的本質公式:

容器 ≈ 一個被 namespaces 隔離 + 被 cgroups 限制資源的 PID 1 行程

沒有所謂「容器這個獨立實體」,只有「被加上隔離與限制的行程」。隔離的載體是 PID Namespace,namespace 的存活又綁在 PID 1 上。所以 PID 1 結束 = 容器結束。

反例:背景行程不能延續容器#

有人可能會想:「我在容器裡 fork 出一個 daemon,PID 1 死了它應該還在吧?」

實驗:

docker run -it --rm alpine sh -c 'sleep 1000 & exec sh'

進入容器後,PID 1 是 sh,背景多了一個 sleep。退出 shell(PID 1 結束),整個容器消失,背景的 sleep 也被 SIGKILL。

這也是為什麼 Dockerfile 的 CMD / ENTRYPOINT 必須是「會持續存在的前景行程」。如果用 service xxx start 之類會 fork 後返回的指令,PID 1 立刻退出,容器也立刻死亡。

反例的反例:–init#

Docker 提供 --init 旗標,讓 Docker 自動注入一個小型 init(例如 tini)作為 PID 1:

docker run -it --rm --init alpine sh

這樣 PID 1 是 tini,使用者的 sh 變成 PID 2。tini 會:

  • 正確處理 SIGTERM、SIGINT 並轉發
  • 回收殭屍行程(zombie reaping)
  • 在所有子行程結束後才退出

對於跑單一應用的容器,多數時候不需要 --init;但對於需要 fork 子行程或處理訊號的應用(特別是 shell script 或非 PID-1-aware 的程式),加上 --init 比較穩定。

實驗清理#

實驗完成後清掉殘留:

# 列出所有容器(含已停止的)
docker ps -a

# 移除指定容器
docker rm killme

# 一次清掉所有已停止的容器
docker container prune

預期輸出(具體值依環境而異):prune 會列出被刪除的容器 ID 與釋放的磁碟空間。

重點整理#

  • 容器的 PID 1 是 namespace 內的 init process
  • PID 1 結束 → Kernel 殺光 namespace 內所有行程 → 容器消失
  • 這就是 host 殺掉容器內的 ash 之後,容器立即終止的原因
  • Dockerfile 的 entrypoint 必須是前景常駐行程
  • --init 可以代為處理訊號與殭屍回收

延伸閱讀#