(譯)Docker 中的 PID-1、孤兒、僵尸和信號
使用 Docker 的時候,在多進程、信號方面會有一些邊緣用例。在 Phusion 博客上有一篇相關(guān)文章,后續(xù)內(nèi)容中會嘗試接觸這些問題,并使用 fpco/pid1 解決問題。
Phusion 博文中試用了他們的 基礎(chǔ)鏡像。這個鏡像提供了 my_init 作為 entrypoint 來解決問題,同時還提供了 syslog 之類的額外的功能。不幸的是我們在使用其中的 syslog-ng 時遇到了麻煩,會產(chǎn)生占用 100% CPU 且無法殺死的進程。我們還在調(diào)查其根本原因,但在實踐中我們發(fā)現(xiàn),一個簡單的 init 是更加迫切的需求,因此我們創(chuàng)建了 pid1 Haskell 包 和一個 Docker 鏡像 fpco/pid1
建議讀者閱讀本文的同時打開終端運行命令,以求獲得最大收益??吹揭粋€ Ctrl+C 無法殺死的進程會讓人更有動力。
我們用 Haskell 自行實現(xiàn)的目的是嵌入到 Stack build tool 之中。還有一些其它輕量級初始化進程,例如
dumb-init。我也寫了關(guān)于 dumb-init 的文章。這里用的pid1跟其它的初始化進程之間沒有什么差別。
和 Entrypoint 一起玩耍
Docker 有個 Entrypoint 的概念,其中對使用 docker run 運行容器時的命令進行缺省封裝。例如下面的情況:
docker run --entrypoint /usr/bin/env ubuntu:16.04 FOO=BAR bash -c 'echo $FOO'
BAR與之等價的命令是 docker run ubuntu:16.04 /usr/bin/env FOO=BAR bash -c 'echo $FOO'
這兩個等價的命令展示了在命令行中替代 Entrypoint 的情況。后面還會在 Dockerfile 中進行指定。Ubuntu 鏡像的缺省 entrypoint 是空的,也就是說命令部分不會經(jīng)過任何封裝,直接運行。因為目前版本的 Docker 還不支持將 entrypoint 設(shè)置為空,所以我們準備使用 /usr/bin/env 作為 entrypoint 來模擬這種狀況。當運行 /usr/bin/env foo bar baz 時,env 進程會執(zhí)行 foo 命令,foo 會變成新的 PID 1,這樣的運行結(jié)果是和空的 entrypoint 是一致的。
fpco/pid1 和 snoyberg/docker-testing 都會把 /sbin/pid1 作為缺省的 entrypoint。在示例命令中,為了清晰的示范,我們顯式地使用了 --entrypoint /sbin/pid1,實際上去掉這個選項,也會是同樣的效果。
向進程發(fā)送 TERM 信號
我們會以 sigterm.hs 命令開始,這個命令會執(zhí)行 ps,然后給自己發(fā)送一個 SIGTERM,持續(xù)循環(huán)。在 Unix 系統(tǒng)中,進程收到 SIGTERM 的缺省操作就是退出。因此我們推測我們的進程應(yīng)該啟動之后直接退出,實際情況:
$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing sigterm
PID TTY TIME CMD
1 ? 00:00:00 sigterm
9 ? 00:00:00 ps
Still alive!
Still alive!
Still alive!
^C
$該進程忽略了 SIGTERM 保持運行,直到我們手工輸入了 Ctrl+C。這個腳本還有個功能就是,如果使用了 install-handler 參數(shù),就會顯式地安裝一個 SIGTERM 的接收器,用于殺死進程。使用這個參數(shù)之后情況就不同了:
$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing sigterm install-handler
PID TTY TIME CMD
1 ? 00:00:00 sigterm
8 ? 00:00:00 ps
Still alive!
$這個結(jié)果涉及到 Linux 內(nèi)核:內(nèi)核對 PID 1 是另眼相看的,缺省情況下收到 SIGTERM 或者 SIGINT 信號不會殺死進程。這個情況讓人很不習慣。下一個測試中,使用兩個不同的終端分別執(zhí)行命令:
$ docker run --rm --name sleeper ubuntu:16.04 sleep 100
$ docker kill -s TERM sleeper我們會看到,docker run 命令并沒退出,如果檢查一下 ps aux 的輸出,會看到這個進程還在運行。原因是 sleep 進程沒有針對 PID 1 的場景進行設(shè)計,也就是說沒有專門設(shè)置信號處理工作,要正確響應(yīng)信號,有兩個選擇:
確保
docker run運行的命令顯式地處理SIGTERM。讓命令的 PID 不為 1,用設(shè)計了信號處理的應(yīng)用來充當 PID 1 的角色。
看看 sigterm 程序在使用 /sbin/pid1 作為 entrypoint 時候的表現(xiàn):
$ docker run --rm --entrypoint /sbin/pid1 snoyberg/docker-testing sigterm
PID TTY TIME CMD
1 ? 00:00:00 pid1
8 ? 00:00:00 sigterm
12 ? 00:00:00 ps程序如愿退出。但是看看 ps 的輸出:第一個進程是 pid1,而不是 sigterm。sigterm 這里的 PID 是 8,也就不會像 PID 為 1 時候的行為了,它會按照缺省行為處理 SIGTERM。這里的具體步驟是:
創(chuàng)建容器,并在其中執(zhí)行
/usr/sbin/pid1 sigterm。pid1的 PID 為 1,并fork/exec了sigterm。sigterm向自己發(fā)送了SIGTERM,導致被殺。pid1發(fā)現(xiàn)子進程被 SIGTERM 殺掉(sigal 15),并用 143 的返回碼退出(128+15)PID 1 死掉,容器也就死掉了。
這并不是 sigterm 的特殊能力,sleep 也可以達到同樣的目的:
$ docker run --rm --name sleeper fpco/pid1 sleep 100
$ docker kill -s TERM sleeper
...和 ubuntu 鏡像不同,fpco/pid1 的 entrypoint 是 sbin/pid1,這個容器會被立刻殺掉。
sigterm會給自己發(fā)送TERM信號(譯注:只要它不是 PID1,就能正常退出,它退出之后,父進程也會退出),因此并不需要一個特別的 PID1 進程。例如可以直接運行docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing /bin/bash -c "sigterm;echo bye",但是在sleep的情況下,就必須有能夠正確處理信號的 PID1 了(譯注:因為docker kill的信號是發(fā)給 PID1 的)。
Ctrl+C sigterm 和 sleep
sigterm 和 sleep 在面對 Ctrl+C 的時候會不太一樣。Ctrl+C 會發(fā)送 SIGINT 給 docker run 進程,它會把信號轉(zhuǎn)發(fā)給容器內(nèi)的信號。因為 Linux 內(nèi)核的優(yōu)待,sleep 也會忽略這個信號。然而 sigterm 是用 Haskell 編寫的,Haskell 運行時自帶一個包含 SIGINT 的信號處理過程,它會覆蓋 PID1 進程的缺省行為。docker attach 文檔中包含了更多關(guān)于信號轉(zhuǎn)發(fā)的內(nèi)容。
僵尸進程
假設(shè)有一個進程 A,A 會 exec/fork 進程 B。當進程 B 死掉時,進程 A 必須調(diào)用 waitpid,從內(nèi)核獲取進程 B 的退出狀態(tài),如果這個過程無法完成,進程 B 雖然死掉,但是還是會在系統(tǒng)進程表中留下一個記錄。這種進程通常被稱為僵尸。
orphans.hs 的行為:
生成一個子進程,用死循環(huán)調(diào)用
ps;在子進程中:
運行
echo命令多次,不調(diào)用waitpid然后退出。
如你所見,沒有進程會回收成為僵尸的 echo 進程。進程輸出的內(nèi)容,會看到生成了僵尸:
$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing orphans
1
2
3
4
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 orphans
8 ? 00:00:00 orphans
13 ? 00:00:00 echo
14 ? 00:00:00 echo
15 ? 00:00:00 echo
16 ? 00:00:00 echo
17 ? 00:00:00 ps
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 orphans
13 ? 00:00:00 echo
14 ? 00:00:00 echo
15 ? 00:00:00 echo
16 ? 00:00:00 echo
18 ? 00:00:00 ps
Still alive!這里看到了幾個僵尸進程。原因是我們的 PID1 沒有進行回收。你可能猜到,我們可以使用 /sbin/pid1 解決這個問題:
$ docker run --rm --entrypoint /sbin/pid1 snoyberg/docker-testing orphans
1
2
3
4
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 pid1
10 ? 00:00:00 orphans
14 ? 00:00:00 orphans
19 ? 00:00:00 echo
20 ? 00:00:00 echo
21 ? 00:00:00 echo
22 ? 00:00:00 echo
23 ? 00:00:00 ps
Still alive!
PID TTY TIME CMD
1 ? 00:00:00 pid1
10 ? 00:00:00 orphans
24 ? 00:00:00 ps
Still alive!pid1 會在子進程死掉時接收 echo 進程,并進行收割。
進程清理
我們來試點別的:A 進程是 Docker 容器的主進程,它生成了進程 B。如果 A 比 B 退出的早,會讓 Docker 容器退出。這種情況下,運行中的進程 B 會被內(nèi)核強制關(guān)閉(Stackoverflow 討論了該問題的詳情),我們可以通過 surviving.hs 來觀察這個情況:
$ docker run --rm --entrypoint /usr/bin/env snoyberg/docker-testing surviving
Parent sleeping
Child: 1
Child: 2
Child: 4
Child: 3
Child: 1
Child: 2
Child: 3
Child: 4
Parent exiting不幸的是,我們的子進程沒機會進行清理。我們應(yīng)該給他們發(fā)送一個 SIGTERM,在一段時間后發(fā)送 SIGKILL,pid1 就是這么做的:
$ docker run --rm --entrypoint /sbin/pid1 snoyberg/docker-testing surviving
Parent sleeping
Child: 2
Child: 3
Child: 1
Child: 4
Child: 2
Child: 1
Child: 4
Child: 3
Parent exiting
Got a TERM
Got a TERM
Got a TERM
Got a TERMDocker Run 和 PID1
如果運行 sleep 60,然后輸入 Ctrl+C,sleep 進程會收到 SIGINT。如果運行 docker run --rm fpco/pid1 sleep 60,再輸入 Ctrl+C,事情就不同了。docker run 創(chuàng)建了一個 docker run 進程,它會給 Docker 服務(wù)發(fā)送一個命令,這個服務(wù)會在容器里創(chuàng)建真正的 sleep 進程。在終端輸入 Ctrl+C 的時候,SIGINT 會被發(fā)送給 docker run,最后轉(zhuǎn)換成 sleep 進程的 SIGINT。
如何證明呢?
$ docker run --rm fpco/pid1 sleep 60&
[1] 417
$ kill -KILL $!
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
69fbc70e95e2 fpco/pid1 "/sbin/pid1 sleep 60" 11 seconds ago Up 11 seconds hopeful_mayer
[1]+ Killed docker run --rm fpco/pid1 sleep 60這個案例中發(fā)送 SIGKILL 給 docker run,相對于 SIGINT 以及 SIGTERM,SIGKILL 有些不同,docker run 無法轉(zhuǎn)發(fā)這個信號,因此會殺掉自己,但是 sleep 進程和所在的容器會持續(xù)運行。
所以:
用類似
pid1的東西來保障SIGINT或者SIGTERM能夠真正地停止容器。如果必須要給進程發(fā)送
SIGKILL,應(yīng)該使用docker kill。
entrypoint 的替代方案
我們用了很多次 --entrypoint /sbin/pid1。實際上這很多余,fpco/pid1 和 snoyberg/docker-testing 鏡像的缺省 entrypoint 都是 /sbin/pid1:
$ docker run --rm fpco/pid1 sleep 60
^C
$如果嫌 entrypoint 麻煩,可以用在命令之中,例如:
$ docker run --rm --entrypoint /usr/bin/env fpco/pid1 /sbin/pid1 sleep 60
^C
$Dockerfile,command vs exec
你可能想把 ENTRYPOINT /sbin/pid1 放到 Dockerfile 里,結(jié)果卻不盡人意:
$ cat Dockerfile
FROM fpco/pid1
ENTRYPOINT /sbin/pid1
$ docker build --tag test .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM fpco/pid1
---> aef1f7b702b9
Step 2 : ENTRYPOINT /sbin/pid1
---> Using cache
---> f875b43a9e40
Successfully built f875b43a9e40
$ docker run --rm test ps
pid1: No arguments provided出現(xiàn)這個問題的原因是使用的 command 形式的方法,它只是定義了一個給 Shell 處理的原始字符串,無法加入額外的命令(例如 ps),這樣一來,pid1 進程就沒有了附加語句,無法運行。正確的定義形式是 ENTRYPOINT ["/sbin/pid1"]:
$ cat Dockerfile
FROM fpco/pid1
ENTRYPOINT ["/sbin/pid1"]
$ docker build --tag test .
Sending build context to Docker daemon 2.048 kB
Step 1 : FROM fpco/pid1
---> aef1f7b702b9
Step 2 : ENTRYPOINT /sbin/pid1
---> Running in ba0fa8c5bd41
---> 4835dec4aae6
Removing intermediate container ba0fa8c5bd41
Successfully built 4835dec4aae6
$ docker run --rm test ps
PID TTY TIME CMD
1 ? 00:00:00 pid1
8 ? 00:00:00 ps盡量使用這種模式,可以避免對 shell 的需要。
結(jié)論
正常情況下,都需要使用一個 pid1 這樣的初始化進程。Phusion/my_init 的方式是可行的,但是太過沉重。如果不需要 syslog 以及其他的特性,最好還是用一個最小化的選擇。
