當構建Docker 容器時,需要注意PID 1 僵尸回收問題,那個問題會在你最不期望出現問題的時候,導致一些不期望的結果和看起來很困惑的問題。本文解釋了PID 1問題,解釋怎樣解決它,并且作為一個預先構建的方案--可以作為一個基本的Docker鏡像來使用。
介紹
大概一年前-回溯到Docker0.6 的時期-我第一次介紹Docker基礎鏡像。這是為了對Docker友好而修改的最小的Ubuntu 基礎鏡像。其他人可以 從Docker 登記下載Docker基礎鏡像并且把它作為他們自己鏡像的基礎鏡像。
我們是早期的Docker使用者,用Docker來持續集成并且在Docker達到1.0版本之前,用作搭建開發環境的方式。為了解決一些使用Docker能夠解決的問題,我們開發了docker基礎鏡像。例如,Docker不會在某個恰當處理子進程的初始化進程下運行進程。以至于容器有可能結束導致各種各樣的問題僵尸進程。Docker 也不會做任何事情,以至于讓重要的消息能夠正常的被處理等等。
然而,我們已經發現很多人對我們解決的問題理解上有問題。Granted,是Unix操作系統底層很少人知道和理解的系統級機制。所以在本文中我們將會詳細描述這個我們已經解決了的最重要的PID 1僵尸進程問題。
Zombies
我們發現:
我們解決的問題適用于很多人大多數人甚至沒有意識到這些問題,所以很多事情會以意想不到的方式被打斷(墨菲定律)如果每個人都一遍又一遍的重復解決這些問題是低效率的。所以我們在空閑時間,把解決方案提取為每個人可以復用的基礎鏡像:Baseimage-docker.這個鏡像也加入了一些有用的,相信大多數Docker鏡像開發者都需要的工具。我們把Baseimage-docker作為我們所有Docker鏡像的一個基礎鏡像。
社區看起來喜歡我們所做得事情:我們是Docker注冊處最流行的第三方鏡像。只是排在了官方的Ubuntu和CentOServer鏡像下面。
[page]PID 1 問題: 進程僵尸
回想一下Unix的進程是一個有序的樹。每個進程可以派生子進程,每個進程具有一個除了最頂層以外的父進程。
這個最頂層的進程是init進程。它是當你啟動系統時由內核啟動。這個init進程負責啟動系統的其余部分,如啟動SSH服務,從啟動Docker守護進程,啟動Apache / Nginx的,啟動你的GUI桌面環境,等等。他們每個進程都可能會反過來派生出更多的子進程。
到目前為止還沒有什么特別的。但考慮到如果一個進程終止會發生什么。比方說,bash(PID 5)進程終止。它變成了一個所謂的“停止活動的進程”,也稱為“僵尸進程”。
為什么會這樣?這是因為Unix被設計為這樣一種方式,父進程必須明確地“等待”子進程終止,以便收集它的退出狀態.。僵尸進程一直存在,直到父進程已經執行該操作,使用系統調用waitpid()函數。我從手冊頁引用
在日常的語言中,人們認為“僵尸進程”是會造成嚴重破壞的混亂進程。但正式的說 - 從Unix操作系統觀點 - 僵尸進程有一個非常明確的定義。他們是已經終止,但沒有(還)被他們的父進程等待的進程。
大多數時間這都不是問題,在子進程上調用waitpid()的動作是為了消除它的僵死進程,這就是所謂的“收割”。許多應用正確的收割它們的子進程。在上面的例子中用的是sshd,如果bash終止了然后操作系統將會向sshd發送一個SIGCHLD信號把它喚醒。sshd注意到了這個信號后就收割子進程。
但是有個特殊情況,假設父進程終結了,或者是故意的(因為程序邏輯決定該退出系統了)或者是用戶的操作導致的(例如用戶將這個進程殺死了)。這個父進程的子進程將會發生什么?他們不再有父進程了,所以他們變成了“孤兒”(這是實際的專業術語)。
這就是init進程起作用的地方。init進程--PID 1--有一個特殊的任務。就是“接收”孤兒進程(注意,這是實際的技術術語)。這就意味著init進程變為了這些進程的父進程。盡管這些進程從來都沒有被init進程直接創建。
拿Nginx作為例子,默認是作為后臺守護進程。它是這么工作的。第一,Nginx創建一個子進程。第二,原始的Nginx進程退出了。第三,Nginx子進程被init進程給接收了。
你可能知道我將要表達什么。操作系統內核自動的處理收容,所以這就意味著內核期望init進程要有一個專門的職責:操作系統也期望init進程收割被接收的孤兒進程。
這是Unix系統中一個非常重要的職責。它是如此基礎的職責以至于很多很多軟件的都利用了這一點。所有的守護軟件非常期望被守護的子進程都被init進程收容和收割。
盡管我用守護進程作為例子,但不限于守護進程。每當一個進程退出了,雖然它還有子進程存在。這是因為它們期待init進程稍后來清理。這些已經詳細的在這兩本書中描述了:操作系統概念 著 Silberschatz等和Unix環境中的高級編程 著 Stevens 等。
[page]為什么僵尸進程是有害的
即使他們終止了進程,為什么僵尸進程是一件壞事 ? 原始應用程序的內存已經被釋放,對啊?這不僅僅是一個條目,你在ps中看到它了嗎?
你是對的,原始應用程序的內存已經被釋放。 但事實上,你還看到它在ps中,這意味著它仍然占用一些內核資源。 我參考Linux waitpid手冊:
與Docker的關系
那么這怎么涉及到Docker?我們看到很多人在他們的容器里只運行一個進程,他們認為運行單進程,他們的工作就結束了。但是,這個進程寫出來并不是為了完全像init進程的行為。也就是說,非但沒有恰當的收割被收容的孤兒進程,反而沒準它還期望其他的init進程來正確地做那樣的工作。
讓我們來看看具體的例子.假設你的容器運行了一個web服務器,web服務器運行一個CGI,它是用bash寫的腳本。CGI腳本調用grep.然后web服務器決定CGI腳本運行的時間太長了并且殺死了這個腳本,但是grep 沒有受影響并繼續運行。當grep結束了,它成為了僵尸并且被PID 1收容(web服務器)。web服務器不知道grep,所以web容器不收割它,然后grep僵尸進程停留在系統中了。
這個問題也適用于其他狀況。人們經常為第三方應用創建Docker容器--比如PostgreSQL--并且把這些應用當做靈魂進程在容器中運行。當你正運行其他人的代碼,你能確保這些應用接下來不會大量產生僵尸進程嗎?如果你運行你自己的代碼,并且你審計了類庫。沒發現問題。但是通常情況下還是應該運行一個適當的init系統進程來阻止問題發生。
但是運行一個全初始化系統不會讓container重量級并且像一個虛擬機嗎?
一個初始化系統沒有必要是重量級的,你可能很輕易地就想到了Upstart,Systemd,SysV等初始化系統。可能你認為完整的系統需要在容器中被啟動。其實不是這樣的。我們所說的“全初始化系統”,是沒有必要的也不是令人滿意的。
我所談論的初始化系統是小的,它的唯一職責就是啟動你的應用,并且收割收容的子進程。使用如此簡單的初始化系統是完全符合Docker的哲學的。
一個簡單的初始化系統
是否已經存在一個能夠運行其他應用并且能夠同時收割收容的子進程的軟件?
有一個幾近完美的解決方案,每個人都有--它是簡單陳舊的bash. Bash正確的收割收容的子進程。Bash能夠運行任何事。所以不是要把這些放到你的Dockerfile中...
CMD ["/path-to-your-app"]…你可能有興趣用這個替代:
CMD ["/bin/bash", "-c", "set -e && /path-to-your-app"](-e 指令阻止bash把這個腳本當做簡單的命令直接執行exec())
這回導致如下處理層次結構:
但是不幸的是,這個程序有一個致命問題,它沒有正確處理信號!假設你用kill發送SIGTERM信號給bash.Bash終止了,但是沒有發送SIGTERM給它的子進程!
當bash結束了,內核結束整個容器中的所有進程。包擴通過SIGKILL信號沒有被干凈的終結的進程。SIGKILL不能被捕獲,所以進程是沒有辦法干凈的終結。假設你運行的應用程序正忙于寫文件;在寫的過程中,應用被不干凈的終止了這個文件可能會崩潰。不干凈的終止是很壞的事情。很像把服務器的電源給拔掉。
但是為什么要關心init進程是否被SIGTERM給終結了呢?那是因為docker stop 發送 SIGTERM信號給init進程了。“docker stop” 應該干凈的停止容器,以至于稍后你能夠用“docker start”啟動它。
Bash專家現在可能會有興趣寫一個EIXT處理器,它簡單的發送信號給子進程,像這樣:
#!/bin/bash function cleanup() { local pids=`jobs -p` if [[ "$pids" != "" ]]; then kill $pids >/dev/null 2>/dev/null fi } trap cleanup EXIT /path-to-your-app不幸的是,這個不解決問題。僅僅是給子進程發送信號是不夠的:init進程在終結自己前必須等待子進程終結。如果init進程過早的結束了,所有的子進程又沒有干凈的被內核終結。
所以明顯的一個更加復雜的解決方案是需要的。但是一個全初始化系統像Upstart,Systemd 和SysV init對于輕量級的Docker容器來說就是趕盡殺絕。幸運的是,Baseimage-docker有一個解決方案。我們已經寫了一個自定義的,輕量級的初始化系統。特別是在Docker容器內。由于缺少一個好的名字,我們把這個程序叫做my init,一個350行最小資源使用率的Python程序。
my_init的一些關鍵特性:
收割收容的子進程執行子進程等待直到所有的子進程都終結了才結束自己,并且用最大超時時間。記錄活動到“docker日志文件”。[page]Docker 會解決這些嗎?
理想的,這個PID1問題是被Docker本地解決的。如果Docker提供一些內嵌初始化系統能夠正確收割被收容的子進程那將是很偉大的事情。但是直到2015年一月,我們也沒有注意到Docker團隊為解決這個問題付出任的何努力。這不是苛求--Docker是非常有雄心的,并且我相信Docker端對有更大的事情需要關心,比如進一步開發它們的編配工具。PID1問題在用戶層面是可以解決的。所以在Docker官方解決這個問題之前,我們建議人們自己通過使用恰當的和上面描述的行為一樣的初始化系統來解決這個問題。
這真是問題嗎?
從這一點來說,這個問題可能聽起來仍然不真實。如果你從來沒有在容器中看到過任何僵尸進程,那么你可能傾向于認為所有的事情都很正常。但是唯一讓你認為這個問題從來沒有發生過的方式,是當你已經評審完你所有的代碼,類庫代碼,以及類庫依賴的類庫代碼。除非你已經那樣做了,否則有些通過上面描述的方式產生進程的代碼,他們隨后可能會成為僵尸進程。
你可能會認為,我從來沒有看到它出現,所以機會是很小的。但是墨菲定律說道,當事情可能會出錯,那么它們就會出錯。
除了僵尸進程持有內核資源這個事實外,僵尸進程的不離開也會干擾那些檢測進程是否存在的軟件。例如 Phusion 乘客應用服務器 管理進程。 它重啟那些崩潰的進程。崩潰監測通過分析 ps 的輸出實現和發送0信號到進程ID實現的。僵尸進程是通過ps 和對0信號的響應來顯示的,所以Phusion 乘客認為這個進程依然存活,盡管他已經終止了。
再想想取舍。阻止僵尸進程的發生這個問題的發生,你所需要做的就是花5分鐘,或者使用docker基礎鏡像,或者導入 our 350 lines my_init init system 到你的容器中。內存和磁盤占用很小:只占用內存和硬盤幾MB空間就能夠阻止墨菲定律發生。
總結
所以PID 1 問題是需要注意的的問題。一個方法就是使用Dock基礎鏡像。
是否Dock基礎鏡像是唯一的解決方式?當然不是,Dock基礎鏡像的主旨是:
1.使人們知道一些重要Docker容器的警告和缺陷。
2.提供一些預先解決方案方便其他人使用,以至于其他人不至于針對此問題重新發明解決方案。
這也意味著多種解決方案的存在,一旦他們解決了我們描述的這個問題。你可以自由的重新用C,Go,Ruby 或者其他什么語言來實現該解決方案。但是我們已經提供了一個很好的解決方案了,你為什么還要這樣呢?
可能你不想使用Ubuntu作為基礎鏡像。可能你會使用CentOS。 但是不要停止使用image-docker所給你帶來的好處。 舉例來說,我們的 passenger_rpm_automation 項目使用CentOS容器。 我們簡單地提取了基礎的image-docker的my_init并且將其引入。
因此,即使你不使用, 或者不想要使用Baseimage-docker,好好看看我們描述的問題,考慮你能做什么來解決這些問題。
原文鏈接:http://www.oschina.net/translate/docker-and-the-pid-1-zombie-reaping-problem