上週的文章聊了關於容器中的一號進程的一些概況後,在我師父某川 (可以去 GitHub 找他玩,jschwinger23) 的指導與配合下,我們一起對目前主流的被廣泛使用的兩個容器中一號進程的實現 dumb-init 和 tini 做了一番探究,繼續寫個水文來爆論一番。
正文#
我們為什麼需要一個一號進程,我們希望的一號進程需要承擔怎樣的職責?#
在繼續聊關於 dumb-init 和 tini 的相關爆論之前,我們需要來 review 一個問題。我們為什麼需要一個一號進程?以及我們所選擇的一號進程需要承擔怎樣的職責
其實我們在容器場景下需要一號進程托管在前面實際上有兩種主要的場景,
-
對於容器內 Graceful Upgrade 二進制這種場景,主流的一種做法之一是 fork 一個新的進程,exec 新的二進制文件,新進程處理新鏈接,老進程處理老鏈接。(Nginx 就採用這種方案)
-
沒有正確的處理信號轉發以及進程回收的情況
-
一些如同 calico-node 的場景,我們出於方便打包的考慮,將多個二進制運行在同一容器中
對於第一種其實需要說的沒有太多,我們來看一下第二點的測試
我們先準備一個最簡單 Python 文件,demo1.py
import time
time.sleep(10000)
然後依照常規,我們開始用一個 bash 腳本裹一下
#!/bin/bash
python /root/demo1.py
最後編寫 Dockerfile
FROM python:3.9
ADD demo1.py /root/demo1.py
ADD demo1.sh /root/demo1.sh
ENTRYPOINT ["bash", "/root/demo1.sh"]
構建後開始執行,我們先來看一下進程結構
沒有問題,現在我們用 strace 來 trace 一下,2049962、2050009 這兩個進程,然後對 2049962 這個 bash 進程發 SIGTERM 信號
我們來看下結果
我們能清晰看到 2049962 進程在接到 SIGTERM 的時候,沒有將其轉發給 2050009 進程。在我們手動 SIGKILL 2049962 後, 2050009 也隨即退出,這裡可能有人會有點疑惑,為什麼 2049962 退出後,2050009 也會退出呢?
這裡是由於 pid namespace 本身的特性,我們來看看,pid_namespaces 中的相關介紹
If the "init" process of a PID namespace terminates, the kernel terminates all of the processes in the namespace via a SIGKILL signal.
當當前 pid ns 內的一號進程退出的時候,內核直接 SIGKILL 伺候該 pid ns 內的剩餘進程
OK,在我們結合容器調度框架後,那麼在生產上實際會出現很多的坑,來看一段我之前的吐槽
我們一個測試服務,Spring Cloud 的,在下線後,節點無法從註冊中心摘除,然後百思不得其解,最後查到問題,,
本質上是這樣,POD 被摘除的時候,K8S Scheduler 會給 POD 的 ENTRYPOINT 發一個 SIGTERM 信號,然後等待三十秒(默認的 graceful shutdown 超時實踐),還沒響應就會 SIGKILL 直接殺
問題在於,我們 Eureka 版的服務是通過 start.sh 來啟動的,ENTRYPOINT ["/home/admin/start.sh"],容器裡默認是 /bin/sh 是 fork/exec 模式,導致我服務進程沒法正確的收到 SIGTERM 信號,然後一直沒結束就被 SIGKILL 了
刺激不刺激。除了信號轉發無法正常處理以外,我們應用程序常見的一個常見處理的問題就是 Z 進程的出現,即子進程結束之後,無法正確的回收。比如早期 puppeteer 臭名昭著的 Z 進程問題。在這種情況下,除了應用程序本身的問題以外,另外可能的原因是在守護進程這樣的場景下,孤兒進程 re-parent 之後的進程,不具備回收子進程的功能
OK 在回顧完上面我們常見的問題後,我們來 review 一下我們對於容器內一號進程所需要承擔的職責
-
信號的轉發
-
Z 進程的回收
而在目前,在容器場景下,大家主要使用兩個方案來作為自己的容器內一號進程,dumb-init 和 tini。這兩個方案對於容器內孤兒與 Z 進程的處理都算是 OK。但是信號轉發的實現上一言難盡。那么接下來
爆論時間!
拉跨的 dumb-init#
某種程度上來說,dumb-init 這貨完全是屬於虛假宣傳的典範。代碼實現非常糙
來看看官方的宣傳
dumb-init runs as PID 1, acting like a simple init system. It launches a single process and then proxies all received signals to a session rooted at that child process.
這裡,dumb-init 說自己使用了 Linux 中的進程 Session,我們都知道,一個進程 Session 在默認情況下,共享一個 Process Group Id 。那麼我們這裡可以理解為,dumb-init 能將信號完全轉發到進程組中的每個進程上。聽起來很美好是不是?
我們先來測試一下吧
測試代碼如下,demo2.py
import os
import time
pid = os.fork()
if pid == 0:
cpid = os.fork()
time.sleep(1000)
Dockerfile 如下
FROM python:3.9
RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64
RUN chmod +x /usr/local/bin/dumb-init
ADD demo2.py /root/demo2.py
ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]
CMD ["python", "/root/demo2.py"]
構建,開跑,先來看下進程結構
然後老規矩,strace 2103908、2103909、2103910 這三個進程,然後我們對 dumb-init 的進程做一下發送 SIGTERM 的操作吧
誒?dumb-init 老師,發生了甚麼事?為什麼 2103909 直接被 SIGKILL 了,而沒有收到 SIGTERM
這裡我們要來看下 dumb-init 的關鍵實現
void handle_signal(int signum) {
DEBUG("Received signal %d.\n", signum);
if (signal_temporary_ignores[signum] == 1) {
DEBUG("Ignoring tty hand-off signal %d.\n", signum);
signal_temporary_ignores[signum] = 0;
} else if (signum == SIGCHLD) {
int status, exit_status;
pid_t killed_pid;
while ((killed_pid = waitpid(-1, &status, WNOHANG)) > 0) {
if (WIFEXITED(status)) {
exit_status = WEXITSTATUS(status);
DEBUG("A child with PID %d exited with exit status %d.\n", killed_pid, exit_status);
} else {
assert(WIFSIGNALED(status));
exit_status = 128 + WTERMSIG(status);
DEBUG("A child with PID %d was terminated by signal %d.\n", killed_pid, exit_status - 128);
}
if (killed_pid == child_pid) {
forward_signal(SIGTERM); // send SIGTERM to any remaining children
DEBUG("Child exited with status %d. Goodbye.\n", exit_status);
exit(exit_status);
}
}
} else {
forward_signal(signum);
if (signum == SIGTSTP || signum == SIGTTOU || signum == SIGTTIN) {
DEBUG("Suspending self due to TTY signal.\n");
kill(getpid(), SIGSTOP);
}
}
}
這是 dumb-init 老師處理信號的代碼,在收到信號後,將除 SIGCHLD 的信號做轉發(注意 SIGKILL 是不可 handle 信號),我們來看看信號轉發的邏輯
void forward_signal(int signum) {
signum = translate_signal(signum);
if (signum != 0) {
kill(use_setsid ? -child_pid : child_pid, signum);
DEBUG("Forwarded signal %d to children.\n", signum);
} else {
DEBUG("Not forwarding signal %d to children (ignored).\n", signum);
}
}
默認情況下直接 kill 發送信號,其中 -child_pid 是這樣一個特性:
If pid is less than -1, then sig is sent to every process in the process group whose ID is -pid.
直接轉發進程組,看起來沒啥問題啊?那麼上面是甚麼原因呢?我們再來復習下上一段話,kill 給進程組發信號的邏輯是 sig is sent to every process ,懂了,一個 O (N) 的遍歷嘛。沒啥問題啊?好了,不賣關子,這裡 dumb-init 的實現存在一個 race-condition
我們剛剛說了,kill 進程組的行為是一個 O (N) 的遍歷,那麼必然會有進程先收到信號,而有進程後收到信號。以 SIGTERM 為例,假設我們 dumb-init 的子進程先收到 SIGTERM,優雅退出後,dumb-init 收到 SIGCHLD 的信號,然後 wait_pid 拿到子進程 ID,判斷是自己直接托管的進程後,自殺退出。好了,由於 dumb-init 是我們當前 pid ns 內的 init 進程,再來復習下 pid ns 的特性。
If the "init" process of a PID namespace terminates, the kernel terminates all of the processes in the namespace via a SIGKILL signal.
在 dumb-init 自殺以後,剩餘進程將直接被內核 SIGKILL 伺候。也就導致了我們上面看到的,子進程沒有收到轉發的信號!
所以這裡加粗處理一下,dumb-init 所承諾的,能將信號轉發到所有進程上,完全是虛假宣傳!
而且請注意,dumb-init 宣稱自己能管理一個 Session 內的進程!但是實際上他們只做了一個進程組的信號轉發!完全是虛假宣稱!Fake News!
而且如上面所提到的,在我們熱更新二進制這樣的場景下,dumb-init 在進程退出後直接自殺。和不使用一號進程完全沒有差別!
我們可以來測試一下,測試代碼 demo3.py
import os
import time
pid = os.fork()
time.sleep(1000)
fork 一個進程,總共兩個進程
Dockerfile 如下
FROM python:3.9
RUN wget -O /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64
RUN chmod +x /usr/local/bin/dumb-init
ADD demo3.py /root/demo3.py
ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]
CMD ["python", "/root/demo3.py"]
構建,執行,先看看進程結構
然後模擬老進程退出,我們直接 SIGKILL 掉 2134836,然後我們看看 2134837 的 strace 的結果
如預期一樣,在 dumb-init 自殺後,2134837 被內核 SIGKILL 了
所以跟我復習一遍 dumb-init 拉跨!好了,我們接著聊 tini 的實現
態度友好的聊聊 tini#
平心而論,tini 的實現,雖然也還有坑,但是比 dumb-init 細膩到不知道哪裡去了,我們直接來先看下代碼
while (1) {
/* Wait for one signal, and forward it */
if (wait_and_forward_signal(&parent_sigset, child_pid)) {
return 1;
}
/* Now, reap zombies */
if (reap_zombies(child_pid, &child_exitcode)) {
return 1;
}
if (child_exitcode != -1) {
PRINT_TRACE("Exiting: child has exited");
return child_exitcode;
}
}
首先 tini 沒有設置 signal handler ,不斷循環 wait_and_forward_signal
和 reap_zombies
這兩個函數
int wait_and_forward_signal(sigset_t const* const parent_sigset_ptr, pid_t const child_pid) {
siginfo_t sig;
if (sigtimedwait(parent_sigset_ptr, &sig, &ts) == -1) {
switch (errno) {
case EAGAIN:
break;
case EINTR:
break;
default:
PRINT_FATAL("Unexpected error in sigtimedwait: '%s'", strerror(errno));
return 1;
}
} else {
/* There is a signal to handle here */
switch (sig.si_signo) {
case SIGCHLD:
/* Special-cased, as we don't forward SIGCHLD. Instead, we'll
* fallthrough to reaping processes.
*/
PRINT_DEBUG("Received SIGCHLD");
break;
default:
PRINT_DEBUG("Passing signal: '%s'", strsignal(sig.si_signo));
/* Forward anything else */
if (kill(kill_process_group ? -child_pid : child_pid, sig.si_signo)) {
if (errno == ESRCH) {
PRINT_WARNING("Child was dead when forwarding signal");
} else {
PRINT_FATAL("Unexpected error when forwarding signal: '%s'", strerror(errno));
return 1;
}
}
break;
}
}
return 0;
}
用 sigtimedwait
這個函數來接收信號,然後過濾掉 SIGCHLD
轉發。
int reap_zombies(const pid_t child_pid, int* const child_exitcode_ptr) {
pid_t current_pid;
int current_status;
while (1) {
current_pid = waitpid(-1, ¤t_status, WNOHANG);
switch (current_pid) {
case -1:
if (errno == ECHILD) {
PRINT_TRACE("No child to wait");
break;
}
PRINT_FATAL("Error while waiting for pids: '%s'", strerror(errno));
return 1;
case 0:
PRINT_TRACE("No child to reap");
break;
default:
/* A child was reaped. Check whether it's the main one. If it is, then
* set the exit_code, which will cause us to exit once we've reaped everyone else.
*/
PRINT_DEBUG("Reaped child with pid: '%i'", current_pid);
if (current_pid == child_pid) {
if (WIFEXITED(current_status)) {
/* Our process exited normally. */
PRINT_INFO("Main child exited normally (with status '%i')", WEXITSTATUS(current_status));
*child_exitcode_ptr = WEXITSTATUS(current_status);
} else if (WIFSIGNALED(current_status)) {
/* Our process was terminated. Emulate what sh / bash
* would do, which is to return 128 + signal number.
*/
PRINT_INFO("Main child exited with signal (with signal '%s')", strsignal(WTERMSIG(current_status)));
*child_exitcode_ptr = 128 + WTERMSIG(current_status);
} else {
PRINT_FATAL("Main child exited for unknown reason");
return 1;
}
// Be safe, ensure the status code is indeed between 0 and 255.
*child_exitcode_ptr = *child_exitcode_ptr % (STATUS_MAX - STATUS_MIN + 1);
// If this exitcode was remapped, then set it to 0.
INT32_BITFIELD_CHECK_BOUNDS(expect_status, *child_exitcode_ptr);
if (INT32_BITFIELD_TEST(expect_status, *child_exitcode_ptr)) {
*child_exitcode_ptr = 0;
}
} else if (warn_on_reap > 0) {
PRINT_WARNING("Reaped zombie process with pid=%i", current_pid);
}
// Check if other childs have been reaped.
continue;
}
/* If we make it here, that's because we did not continue in the switch case. */
break;
}
return 0;
}
然後在 reap_zombies
函數中,不斷利用 waitpid
這個函數來處理進程,在沒有子進程等待處理或者遇到其餘系統錯誤時退出循環。
注意這裡 tini 和 dumb-init 的的實現差異,dumb-init 在回收自己的入口子進程後便會自殺。而 tini 將會在所有自己的子進程退出之後,結束循環,然後判斷是否自殺。
那麼我們這裡來測試一下
還是 demo2 的例子,我們來測試一下孫進程的例子
FROM python:3.9
ADD demo2.py /root/demo2.py
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT [ "/tini","-s", "-g", "--"]
CMD ["python", "/root/demo2.py"]
然後構建,執行,進程結構如下
然後,老規矩,strace , kill 發 SIGTERM 看一下,
嗯,如預期一樣,那麼 tini 的實現是不是沒有問題了呢,我們再來準備一個例子,demo4.py
import os
import time
import signal
pid = os.fork()
if pid == 0:
signal.signal(15, lambda _, __: time.sleep(1))
cpid = os.fork()
time.sleep(1000)
這裡我們用 time.sleep(1)
來模擬,程序接到 SIGTERM 後需要優雅處理,然後我們還是準備下 dockefile
FROM python:3.9
ADD demo4.py /root/demo4.py
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT [ "/tini","-s", "-g", "--"]
CMD ["python", "/root/demo4.py"]
然後構建,允許,看進程結構,啪的一下很快啊
然後 strace ,發 SIGTERM 一條龍服務,
然後我們發現,2173316 和 2173317 這兩個進程,成功接收到 SIGTERM 的信號後,在處理中,被 SIGKILL 了。那么這是為甚麼呢?實際上這裡也存在一個潛在的 race condition
當我們開啟 tini 的使用。2173315 退出後,2173316 將被 re-parent ,
按照內核的 re-parent 流程,2173317 re-parent 到 tini 進程。
但是,tini 在使用 waitpid
的時候,使用了 WNOHANG
這個選項,那麼這裡如果在執行 waitpid 時,子進程還未結束,那麼將立刻返回 0。從而退出循環,開始自殺流程。
刺激不刺激,關於這點,我師父和我提了個 issue: tini Exits Too Early Leading to Graceful Termination Failure
然後,我也做了一版修復,具體可以參考use new threading to run waipid(還在 PoC,沒寫單測,處理也有點糙)
實際上思路很簡單 ,我們不使用 waitpid
中的 WNOHANG
選項,將其變為阻塞的調用,然後用一個新的線程來做 waitpid
的處理
構建一版測試效果如下
嗯,如預期一樣,測試沒有問題。
當然這裡實際上可能細心的朋友發現,原本的 tini 也沒法處理二進制更新的情況,原因和 demo5 裡的原因一致。這裡大家可以去測試一下
實際上這裡我的處理很過於粗糙和暴力,我們實際上只要保證讓 tini 的退出條件變成一定要等到 waitpid ()=-1 && errno==EHILD 再退出。具體的實現手段大家可以一起思考(實際上還不少
最後來總結一下問題的核心:
無論是 dumb-init 還是 tini 在現行的實現裡,都犯了同一個錯誤,即在容器這個特殊的場景下,都沒有等待所有子孫進程的退出再退出。其實解決方案很簡單,退出條件一定要是 waitpid()=-1 && errno==EHILD
總結#
本文吐槽了 dumb-init 和 tini。dumb—init 實現屬實拉跨,tini 的實現細膩了很多。但是 tini 依舊存在不可靠的行為,以及我們所期待的 fork 二進制更新這種使用一號進程的場景在 dumb-init 和 tini 上都沒法實現。而且 dumb-init 和 tini 目前也還有一個共通的局限性。即無法處理子進程進程組逃逸的情況。(比如十個子進程各自逃逸到一個進程組中)。
而且在文中的測試中,我們用 time.sleep(1)
來模擬 Graceful Shutdown 的行為,tini 也已經無法滿足需求了。。So。。。。
所以歸根到底一句話,應用的信號,進程回收這些基礎行為應該應用自決。任何管殺不管埋而寄托於一號進程的行為,都是對於生產的不負責任。(如果你們實在想要一個一號進程,還是用 tini 吧,千萬別用 dumb-init)
所以 exec 裸起大法好,不用一號進程平安保!
差不多水文就這樣吧,這篇水文從提出問題到驗證結論,到 patch PoC 報銷了我快一個星期的業餘時間(本文初稿在凌晨 4 點過寫完)。最後感謝某川同學和我一起搞了幾個凌晨三點過。最後,祝大家看的愉快。