沒有行程,就沒有容器#
容器(Container)的存在完全綁定在它的 PID 1 上。如果這個行程消失,容器也會跟著消失。這一章透過一個實驗驗證這件事,並解釋背後的原因。
實驗:在 host 殺掉容器的 ash#
先以前景模式啟動一個 Alpine 容器:
docker run -it --name killme alpine進入容器後,先觀察容器內的 PID 1:
ps -ef預期輸出(具體值依環境而異):第一行就是 PID 1 的 ash 或 /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看不到killmedocker ps -a顯示killme的 STATUS 是Exited (137)(137 = 128 + SIGKILL 的編號 9)
容器消失了。
為什麼會這樣#
要理解這個現象,必須先理解 Linux 上的 PID 1 與 PID Namespace 的特殊關係。
Linux 的 PID 1 規則#
在傳統 Linux 系統上,PID 1 是 init process(例如 systemd、init),由 Kernel 在開機時直接建立。它有兩個特殊地位:
- PID 1 是所有孤兒行程(orphan)的養父
- 如果 PID 1 結束,Kernel 會 panic(在傳統 root namespace 內)
PID 1 因此是整個 user space 的根,不能輕易死掉。
PID Namespace 的繼承#
容器使用 PID Namespace 製造一個「假的」PID 樹。當 Docker 建立容器時:
- clone() 一個新行程,並指定
CLONE_NEWPID旗標 - 該行程在新 namespace 內成為 PID 1
- 在 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可以代為處理訊號與殭屍回收
延伸閱讀#
- Linux man pages —
pid_namespaces(7)、signal(7)、init(1) - Docker Docs — Specify an init process: https://docs.docker.com/engine/reference/run/#specify-an-init-process ↗
- tini GitHub: https://github.com/krallin/tini ↗