1. 程式人生 > >容器內init程序方案

容器內init程序方案

背景 --- 程序識別符號 (PID) 是Linux 核心為每個程序提供的唯一識別符號。熟悉docker的同學都知道, 所有的程序 PID都屬於某一個PID namespaces, 也就是說容器具有一組自己的 PID,這些 PID 對映到主機系統上的 PID。啟動Linux核心時啟動的第一個程序具有 PID 1,一般來說該程序就是 init 程序,例如 systemd 或 SysV。同樣,在容器中啟動的第一個程序也會獲得該PID namespaces內的 PID 1。Docker 和 Kubernetes 使用訊號與容器內的程序通訊,來終止容器的執行, 只能向容器內 PID 1 的程序傳送訊號。 在容器的環境中,PID 和 Linux 訊號會產生兩個需要考慮的問題。 問題 1:Linux 核心如何處理訊號 對於具有 PID 1 的程序,Linux 核心處理訊號的方式與其他程序有所不同。系統不會自動為此程序註冊訊號處理函式,SIGTERM 或 SIGINT 等訊號預設被忽略,必須使用 SIGKILL 來終止程序。使用 SIGKILL 可能會導致應用程式無法平滑退出,例如正在寫入的資料出現不一致或正在處理的請求異常結束。 問題 2:經典 init 系統如何處理孤立程序 宿主機上的init程序(如 systemd)也用來回收孤兒程序。孤兒程序(其父級已結束的程序)會重新附加到 PID 1 的程序,PID 1程序會在這些程序結束時回收它們。但在容器中,這一職責由具有 PID 1 的程序承擔,如果該程序無法正確處理回收,則可能會出現耗盡記憶體或一些其他資源的風險。 常見的解決方案 --- 上述問題對於一些應用程式可能無足輕重,並不需要關注,但是對於一些面向使用者或者處理資料的應用程式卻極為關鍵。需要嚴格防止。 對此有以下幾種解決方案: ### 解決方案 1:作為 PID 1 執行並註冊訊號處理程式 最簡單方法是使用 Dockerfile 中的 CMD 或 ENTRYPOINT 指令來啟動程序。例如,在以下 Dockerfile 中,nginx 是第一個也是唯一一個要啟動的程序。 ```shell FROM debian:9 RUN apt-get update && \ apt-get install -y nginx EXPOSE 80 CMD [ "nginx", "-g", "daemon off;" ] ``` > nginx 程序會註冊自己的訊號處理程式。如果是我們自己寫的程式則需要自己在程式碼中執行相同操作。 因為我們的程序就是PID 1程序,所以可以保證能夠正確的收到並處理訊號。 這種方式可以輕鬆地解決了第一個問題,但是對於第二個問題卻無法解決。 如果你的應用程式不會產生多餘的子程序,則第二個問題也不存在。 可以直接採用這種相對簡單的解決方案。 此處需要注意,有時候我們可能一不小心就讓我們的程序不是容器內首程序了,例如如下Dockerfile: ```shell FROM tagedcentos:7 ADD command /usr/bin/command CMD cd /usr/bin/ && ./command ``` 我們只是想執行啟動命令而已,卻發現此時首程序變為了shell: ```shell [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會自動地判斷你當前啟動命令是否由多個命令組成,如果是多個命令則會用shell來解釋。如果是單個命令則就算外面包了一層shell容器內首程序也直接是業務程序。例如如果將dockerfile寫成`CMD bash -c "/usr/bin/command"`,容器內首程序還是業務程序,如下: ```shell [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也可以讓我們避免掉很多問題。 有時,我們可能需要在容器中準備環境,以便程序能夠正常執行。在此情況下,一般我們會讓容器在啟動時執行一個 shell 指令碼。此 shell 指令碼的任務是準備環境和啟動主程序。但是,如果採用此方法,shell指令碼將是PID 1 而不是我們的程序。因此必須使用內建的 exec 命令從 shell 指令碼啟動程序。exec 命令會將指令碼替換為我們所需的程式, 這樣我們的業務程序將成為 PID 1。 ### 解決方案 2:使用專用 init 程序 正如在傳統宿主機所做的那樣,還可以使用init程序來處理這些問題。但是, 傳統的init程序(例如 systemd 或 SysV)太過複雜而龐大,建議使用專為容器建立的init程序(例如 tini)。 如果使用專用 init 程序,則 init 程序具有 PID 1 並執行以下操作: - 註冊正確的訊號處理程式。init程序會將訊號傳遞給業務程序 - 回收殭屍程序 可以通過使用 docker run 命令的 --init 選項在 Docker 中使用此解決方案。但是目前kubernetes還不支援直接使用該方案,需要在啟動命令前手動指定。 落地的難題 --- 上面兩種解決方案看似美好,實則在實施的過程中還是存在很多弊端。 方案一需要嚴格保證`使用者程序是首程序`並且`不能fork出多餘的其他程序`。 有時候我們在啟動的時候需要執行一個shell指令碼準備環境, 或者需要執行多個命令,例如'sleep 10 && cmd', 此時容器內首程序便為shell,就會碰到問題一, 無法轉發訊號。 如果我們限制使用者的啟動命令不能包含shell語法, 對使用者體驗也不太好。 並且作為PASS平臺,我們需要為使用者提供一個簡單友好的接入環境,幫使用者處理好相關的問題。 從另外一方面考慮, 在容器環境下多程序在所難免,即使我們在啟動時確保只執行一個程序,有時候在執行時過程中也會fork出程序。 我們無法確保我們所使用的第三方元件或者開源的方案不會產生子程序, 我們稍不注意就會碰到第二個問題,殭屍程序無法回收的囧境。 方案二中需要在容器中有一個init程序負責完成所有的這些任務, 當前業務普遍的做法是, 在構建映象的時候裡面自帶init程序,負責處理上面所有的問題。 這種方案固然可行,但是需要讓所有人都使用這種方式似乎有點難以接受。首先對使用者映象有侵入,使用者必須修改已有的Dockerfile, 專門增加init程序 或者 只能在包含有該init程序的基礎映象上面進行構建。 其次管理起來比較麻煩,如果init程序升級,意味著全部映象都得重新build,這似乎無法接受。即使使用docker預設支援的tini,也有一些其他問題,我們後面會談到。 歸根結底, 作為PASS平臺,我們想給使用者提供一個便捷的接入環境,幫助使用者解決這些問題: 1. 使用者程序能夠收到訊號, 進行一些優雅的退出 2. 允許使用者產生多程序,並且在多程序的情況下幫助使用者回收殭屍程序。 3. 不對使用者的執行命令做約束,允許使用者填寫各種shell格式的命令,都能夠解決上述1和2問題 解決方案 --- 如果我們想要對使用者無侵入,則最好使用docker或kubernetes原生支援的方案。 上面已經介紹過了docker run --init選項, docker原生提供的init程序實則為[tini](https://github.com/krallin/tini)。tini支援給程序組傳遞訊號, 通過`-g`引數或者`TINI_KILL_PROCESS_GROUP`來進行開啟該功能。 開啟該功能後我們就可以將tini作為首程序,然後讓它傳遞訊號給所有的子程序。問題一就可以輕鬆解決。 例如我們執行 `docker run -d --init ubuntu:14.04 bash -c "cd /home/ && sleep 100"` 就會發現容器內的程序檢視如下: ```shell 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 ``` 此時1號docker-init程序,也就是tini程序, 負責轉發訊號到所有的子程序,並且回收殭屍程序, tini的子程序為6號bash程序, 它負責執行shell命令,可以執行多個命令。這裡有一個問題就是: tini程序只會監聽他的直接子程序,如果直接子程序退出則整個容器就視為退出了, 也就是本例中的6號bash程序。 如果我們往容器中傳送SIGTERM,可能使用者程序註冊了訊號處理函式, 收到訊號後處理需要一定的時間完成,但是由於bash沒有註冊SIGTERM訊號處理函式,會直接退出,進而導致tini退出,整個容器退出。使用者程序的訊號處理函式還沒有執行完畢就被強制退出了。我們需要想辦法讓bash忽略掉這個訊號,同事提到bash在互動模式下不會處理SIGTERM訊號, 可以一試。 在啟動命令前面加上`bash -ci`即可。發現使用bash互動模式啟動使用者程序就可以使bash忽略掉SIGTERM,然後等待業務的訊號處理函式執行完畢整個容器再退出。 如此便完美解決了上述相關問題。 同時還收穫了另外一個微不足道的好處:容器退出時更加快速。我們知道kubernetes中容器退出的邏輯和docker一樣,先發送SIGTEMR 然後再發送SIGKILL, 對於大部分使用者來說,都不會處理SIGTERM訊號,容器內1號程序收到該訊號後預設的行為是忽略該訊號, 於是SIGTERM訊號白白地被浪費掉,需要等待`terminationGracePeriodSeconds`之後才被刪除。既然使用者不處理SIGTERM,為什麼不直接在收到SIGTERM之後就退出吶? 在當前我們的解決方案下如果使用者有註冊該訊號處理函式,則能正常處理。 如果沒有註冊則容器在收到SIGTERM之後就馬上退出,可以加快退出速度。 目前由於kubernetes中CRI並沒有直接提供可以設定docker tini的方法,所以要想在kubernetes中使用tini就只能改程式碼了,筆者的叢集中就是通過改程式碼來實現的。為了解決使用者的痛點,我們有能力也有義務為合理的需求改程式碼,況且這個改動足夠小,非常簡單。 後記 --- 在容器落地的過程中會碰到各種實際的問題,開源的方案可能無法覆蓋到我們所有的需求,需要我們在精通社群的實現基礎上進行輕微的變形即可完美適應企業內部的