進(jìn)程標(biāo)識(shí)符 (PID) 是Linux 內(nèi)核為每個(gè)進(jìn)程提供的唯一標(biāo)識(shí)符。熟悉docker的同學(xué)都知道, 所有的進(jìn)程 PID都屬于某一個(gè)PID namespaces, 也就是說容器具有一組自己的 PID,這些 PID 映射到主機(jī)系統(tǒng)上的 PID。啟動(dòng)Linux內(nèi)核時(shí)啟動(dòng)的第一個(gè)進(jìn)程具有 PID 1,一般來說該進(jìn)程就是 init 進(jìn)程,例如 systemd 或 SysV。同樣,在容器中啟動(dòng)的第一個(gè)進(jìn)程也會(huì)獲得該P(yáng)ID namespaces內(nèi)的 PID 1。Docker 和 Kubernetes 使用信號(hào)與容器內(nèi)的進(jìn)程通信,來終止容器的運(yùn)行, 只能向容器內(nèi) PID 1 的進(jìn)程發(fā)送信號(hào)。
創(chuàng)新互聯(lián)公司主要從事成都網(wǎng)站建設(shè)、網(wǎng)站建設(shè)、網(wǎng)頁設(shè)計(jì)、企業(yè)做網(wǎng)站、公司建網(wǎng)站等業(yè)務(wù)。立足成都服務(wù)溫泉,十載網(wǎng)站建設(shè)經(jīng)驗(yàn),價(jià)格優(yōu)惠、服務(wù)專業(yè),歡迎來電咨詢建站服務(wù):18980820575
在容器的環(huán)境中,PID 和 Linux 信號(hào)會(huì)產(chǎn)生兩個(gè)需要考慮的問題。
問題 1:Linux 內(nèi)核如何處理信號(hào)
對(duì)于具有 PID 1 的進(jìn)程,Linux 內(nèi)核處理信號(hào)的方式與其他進(jìn)程有所不同。系統(tǒng)不會(huì)自動(dòng)為此進(jìn)程注冊(cè)信號(hào)處理函數(shù),SIGTERM 或 SIGINT 等信號(hào)默認(rèn)被忽略,必須使用 SIGKILL 來終止進(jìn)程。使用 SIGKILL 可能會(huì)導(dǎo)致應(yīng)用程序無法平滑退出,例如正在寫入的數(shù)據(jù)出現(xiàn)不一致或正在處理的請(qǐng)求異常結(jié)束。
問題 2:經(jīng)典 init 系統(tǒng)如何處理孤立進(jìn)程
宿主機(jī)上的init進(jìn)程(如 systemd)也用來回收孤兒進(jìn)程。孤兒進(jìn)程(其父級(jí)已結(jié)束的進(jìn)程)會(huì)重新附加到 PID 1 的進(jìn)程,PID 1進(jìn)程會(huì)在這些進(jìn)程結(jié)束時(shí)回收它們。但在容器中,這一職責(zé)由具有 PID 1 的進(jìn)程承擔(dān),如果該進(jìn)程無法正確處理回收,則可能會(huì)出現(xiàn)耗盡內(nèi)存或一些其他資源的風(fēng)險(xiǎn)。
常見的解決方案
上述問題對(duì)于一些應(yīng)用程序可能無足輕重,并不需要關(guān)注,但是對(duì)于一些面向用戶或者處理數(shù)據(jù)的應(yīng)用程序卻極為關(guān)鍵。需要嚴(yán)格防止。 對(duì)此有以下幾種解決方案:
解決方案 1:作為 PID 1 運(yùn)行并注冊(cè)信號(hào)處理程序
最簡(jiǎn)單方法是使用 Dockerfile 中的 CMD 或 ENTRYPOINT 指令來啟動(dòng)進(jìn)程。例如,在以下 Dockerfile 中,nginx 是第一個(gè)也是唯一一個(gè)要啟動(dòng)的進(jìn)程。
FROM debian:9
RUN apt-get update && \
apt-get install -y nginx
EXPOSE 80
CMD [ "nginx", "-g", "daemon off;" ]
nginx 進(jìn)程會(huì)注冊(cè)自己的信號(hào)處理程序。如果是我們自己寫的程序則需要自己在代碼中執(zhí)行相同操作。
因?yàn)槲覀兊倪M(jìn)程就是PID 1進(jìn)程,所以可以保證能夠正確的收到并處理信號(hào)。 這種方式可以輕松地解決了第一個(gè)問題,但是對(duì)于第二個(gè)問題卻無法解決。 如果你的應(yīng)用程序不會(huì)產(chǎn)生多余的子進(jìn)程,則第二個(gè)問題也不存在。 可以直接采用這種相對(duì)簡(jiǎn)單的解決方案。
此處需要注意,有時(shí)候我們可能一不小心就讓我們的進(jìn)程不是容器內(nèi)首進(jìn)程了,例如如下Dockerfile:
FROM tagedcentos:7
ADD command /usr/bin/command
CMD cd /usr/bin/ && ./command
我們只是想執(zhí)行啟動(dòng)命令而已,卻發(fā)現(xiàn)此時(shí)首進(jìn)程變?yōu)榱藄hell:
[root@425523c23893 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 1 07:05 pts/0 00:00:00 /bin/sh -c cd /usr/bin/ && ./command
root 6 1 0 07:05 pts/0 00:00:00 ./command
docker會(huì)自動(dòng)地判斷你當(dāng)前啟動(dòng)命令是否由多個(gè)命令組成,如果是多個(gè)命令則會(huì)用shell來解釋。如果是單個(gè)命令則就算外面包了一層shell容器內(nèi)首進(jìn)程也直接是業(yè)務(wù)進(jìn)程。例如如果將dockerfile寫成CMD bash -c "/usr/bin/command",容器內(nèi)首進(jìn)程還是業(yè)務(wù)進(jìn)程,如下:
[root@c380600ce1c4 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 2 13:09 ? 00:00:00 /usr/bin/command
所以正確地書寫Dockerfile也可以讓我們避免掉很多問題。
有時(shí),我們可能需要在容器中準(zhǔn)備環(huán)境,以便進(jìn)程能夠正常運(yùn)行。在此情況下,一般我們會(huì)讓容器在啟動(dòng)時(shí)執(zhí)行一個(gè) shell 腳本。此 shell 腳本的任務(wù)是準(zhǔn)備環(huán)境和啟動(dòng)主進(jìn)程。但是,如果采用此方法,shell腳本將是PID 1 而不是我們的進(jìn)程。因此必須使用內(nèi)置的 exec 命令從 shell 腳本啟動(dòng)進(jìn)程。exec 命令會(huì)將腳本替換為我們所需的程序, 這樣我們的業(yè)務(wù)進(jìn)程將成為 PID 1。
解決方案 2:使用專用 init 進(jìn)程
正如在傳統(tǒng)宿主機(jī)所做的那樣,還可以使用init進(jìn)程來處理這些問題。但是, 傳統(tǒng)的init進(jìn)程(例如 systemd 或 SysV)太過復(fù)雜而龐大,建議使用專為容器創(chuàng)建的init進(jìn)程(例如 tini)。
如果使用專用 init 進(jìn)程,則 init 進(jìn)程具有 PID 1 并執(zhí)行以下操作:
注冊(cè)正確的信號(hào)處理程序。init進(jìn)程會(huì)將信號(hào)傳遞給業(yè)務(wù)進(jìn)程
回收僵尸進(jìn)程
可以通過使用 docker run 命令的 --init 選項(xiàng)在 Docker 中使用此解決方案。但是目前kubernetes還不支持直接使用該方案,需要在啟動(dòng)命令前手動(dòng)指定。
落地的難題
上面兩種解決方案看似美好,實(shí)則在實(shí)施的過程中還是存在很多弊端。
方案一需要嚴(yán)格保證用戶進(jìn)程是首進(jìn)程并且不能fork出多余的其他進(jìn)程。 有時(shí)候我們?cè)趩?dòng)的時(shí)候需要執(zhí)行一個(gè)shell腳本準(zhǔn)備環(huán)境, 或者需要運(yùn)行多個(gè)命令,例如'sleep 10 && cmd', 此時(shí)容器內(nèi)首進(jìn)程便為shell,就會(huì)碰到問題一, 無法轉(zhuǎn)發(fā)信號(hào)。 如果我們限制用戶的啟動(dòng)命令不能包含shell語法, 對(duì)用戶體驗(yàn)也不太好。 并且作為PASS平臺(tái),我們需要為用戶提供一個(gè)簡(jiǎn)單友好的接入環(huán)境,幫用戶處理好相關(guān)的問題。 從另外一方面考慮, 在容器環(huán)境下多進(jìn)程在所難免,即使我們?cè)趩?dòng)時(shí)確保只運(yùn)行一個(gè)進(jìn)程,有時(shí)候在運(yùn)行時(shí)過程中也會(huì)fork出進(jìn)程。 我們無法確保我們所使用的第三方組件或者開源的方案不會(huì)產(chǎn)生子進(jìn)程, 我們稍不注意就會(huì)碰到第二個(gè)問題,僵尸進(jìn)程無法回收的囧境。
方案二中需要在容器中有一個(gè)init進(jìn)程負(fù)責(zé)完成所有的這些任務(wù), 當(dāng)前業(yè)務(wù)普遍的做法是, 在構(gòu)建鏡像的時(shí)候里面自帶init進(jìn)程,負(fù)責(zé)處理上面所有的問題。 這種方案固然可行,但是需要讓所有人都使用這種方式似乎有點(diǎn)難以接受。首先對(duì)用戶鏡像有侵入,用戶必須修改已有的Dockerfile, 專門增加init進(jìn)程 或者 只能在包含有該init進(jìn)程的基礎(chǔ)鏡像上面進(jìn)行構(gòu)建。 其次管理起來比較麻煩,如果init進(jìn)程升級(jí),意味著全部鏡像都得重新build,這似乎無法接受。即使使用docker默認(rèn)支持的tini,也有一些其他問題,我們后面會(huì)談到。
歸根結(jié)底, 作為PASS平臺(tái),我們想給用戶提供一個(gè)便捷的接入環(huán)境,幫助用戶解決這些問題:
用戶進(jìn)程能夠收到信號(hào), 進(jìn)行一些優(yōu)雅的退出
允許用戶產(chǎn)生多進(jìn)程,并且在多進(jìn)程的情況下幫助用戶回收僵尸進(jìn)程。
不對(duì)用戶的運(yùn)行命令做約束,允許用戶填寫各種shell格式的命令,都能夠解決上述1和2問題
解決方案
如果我們想要對(duì)用戶無侵入,則最好使用docker或kubernetes原生支持的方案。
上面已經(jīng)介紹過了docker run --init選項(xiàng), docker原生提供的init進(jìn)程實(shí)則為tini。tini支持給進(jìn)程組傳遞信號(hào), 通過-g參數(shù)或者TINI_KILL_PROCESS_GROUP來進(jìn)行開啟該功能。 開啟該功能后我們就可以將tini作為首進(jìn)程,然后讓它傳遞信號(hào)給所有的子進(jìn)程。問題一就可以輕松解決。 例如我們執(zhí)行 docker run -d --init ubuntu:14.04 bash -c "cd /home/ && sleep 100" 就會(huì)發(fā)現(xiàn)容器內(nèi)的進(jìn)程視圖如下:
root@24cc26039c4d:/# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 2 14:50 ? 00:00:00 /sbin/docker-init -- bash -c cd /home/ && sleep 100
root 6 1 0 14:50 ? 00:00:00 bash -c cd /home/ && sleep 100
root 7 6 0 14:50 ? 00:00:00 sleep 100
此時(shí)1號(hào)docker-init進(jìn)程,也就是tini進(jìn)程, 負(fù)責(zé)轉(zhuǎn)發(fā)信號(hào)到所有的子進(jìn)程,并且回收僵尸進(jìn)程, tini的子進(jìn)程為6號(hào)bash進(jìn)程, 它負(fù)責(zé)執(zhí)行shell命令,可以執(zhí)行多個(gè)命令。這里有一個(gè)問題就是: tini進(jìn)程只會(huì)監(jiān)聽他的直接子進(jìn)程,如果直接子進(jìn)程退出則整個(gè)容器就視為退出了, 也就是本例中的6號(hào)bash進(jìn)程。 如果我們往容器中發(fā)送SIGTERM,可能用戶進(jìn)程注冊(cè)了信號(hào)處理函數(shù), 收到信號(hào)后處理需要一定的時(shí)間完成,但是由于bash沒有注冊(cè)SIGTERM信號(hào)處理函數(shù),會(huì)直接退出,進(jìn)而導(dǎo)致tini退出,整個(gè)容器退出。用戶進(jìn)程的信號(hào)處理函數(shù)還沒有執(zhí)行完畢就被強(qiáng)制退出了。我們需要想辦法讓bash忽略掉這個(gè)信號(hào),同事提到bash在交互模式下不會(huì)處理SIGTERM信號(hào), 可以一試。 在啟動(dòng)命令前面加上bash -ci即可。發(fā)現(xiàn)使用bash交互模式啟動(dòng)用戶進(jìn)程就可以使bash忽略掉SIGTERM,然后等待業(yè)務(wù)的信號(hào)處理函數(shù)執(zhí)行完畢整個(gè)容器再退出。
如此便完美解決了上述相關(guān)問題。 同時(shí)還收獲了另外一個(gè)微不足道的好處:容器退出時(shí)更加快速。我們知道kubernetes中容器退出的邏輯和docker一樣,先發(fā)送SIGTEMR 然后再發(fā)送SIGKILL, 對(duì)于大部分用戶來說,都不會(huì)處理SIGTERM信號(hào),容器內(nèi)1號(hào)進(jìn)程收到該信號(hào)后默認(rèn)的行為是忽略該信號(hào), 于是SIGTERM信號(hào)白白地被浪費(fèi)掉,需要等待terminationGracePeriodSeconds之后才被刪除。既然用戶不處理SIGTERM,為什么不直接在收到SIGTERM之后就退出吶? 在當(dāng)前我們的解決方案下如果用戶有注冊(cè)該信號(hào)處理函數(shù),則能正常處理。 如果沒有注冊(cè)則容器在收到SIGTERM之后就馬上退出,可以加快退出速度。
目前由于kubernetes中CRI并沒有直接提供可以設(shè)置docker tini的方法,所以要想在kubernetes中使用tini就只能改代碼了,筆者的集群中就是通過改代碼來實(shí)現(xiàn)的。為了解決用戶的痛點(diǎn),我們有能力也有義務(wù)為合理的需求改代碼,況且這個(gè)改動(dòng)足夠小,非常簡(jiǎn)單。
后記
在容器落地的過程中會(huì)碰到各種實(shí)際的問題,開源的方案可能無法覆蓋到我們所有的需求,需要我們?cè)诰ㄉ鐓^(qū)的實(shí)現(xiàn)基礎(chǔ)上進(jìn)行輕微的變形即可完美適應(yīng)企業(yè)內(nèi)部的場(chǎng)景。