
在使用docker容器的時(shí)候,應(yīng)該了解“PID1僵尸進(jìn)程reap”問題。如果使用的時(shí)候不加注意,可能會(huì)導(dǎo)致出現(xiàn)一些意想不到的問題。
問題
僵尸進(jìn)程
僵尸進(jìn)程是指完成執(zhí)行(通過exit系統(tǒng)調(diào)用,或運(yùn)行時(shí)發(fā)生致命錯(cuò)誤或收到終止信號(hào)所致),但在操作系統(tǒng)進(jìn)程表中仍然有一個(gè)表項(xiàng),處于“終止?fàn)顟B(tài)”的進(jìn)程。這發(fā)生于子進(jìn)程需要保留表項(xiàng)以允許其父進(jìn)程讀取子進(jìn)程的exit status:一旦退出態(tài)通過wait系統(tǒng)調(diào)用讀取,僵尸進(jìn)程條目就從進(jìn)程表中刪除,這個(gè)過程被稱為reap。正常情況下,進(jìn)程直接被其父進(jìn)程wait并由系統(tǒng)回收,進(jìn)程長時(shí)間保持僵尸狀態(tài)一般是錯(cuò)誤的并導(dǎo)致資源泄漏。
英語中的zombie process源自喪尸--不死之人,隱喻進(jìn)程已死大但沒有被reap。與正常進(jìn)程不同,kill命令對僵尸進(jìn)程無效。孤兒進(jìn)程不同于僵尸進(jìn)程,其父進(jìn)程已經(jīng)死掉,但孤兒進(jìn)程仍能正常執(zhí)行,并不會(huì)變?yōu)榻┦M(jìn)程,因?yàn)?code>init進(jìn)程會(huì)收養(yǎng)并wait其退出。
子進(jìn)程死后,系統(tǒng)會(huì)發(fā)送SIGCHLD信號(hào)給父進(jìn)程,父進(jìn)程對其默認(rèn)處理是忽略。如果想響應(yīng)這個(gè)消息,父進(jìn)程通常在SIGCHLD信號(hào)處理程序中,使用wait系統(tǒng)調(diào)用來響應(yīng)子進(jìn)程的終止。
僵尸進(jìn)程被reap后,其進(jìn)程號(hào)與在進(jìn)程表中的表項(xiàng)都可以被系統(tǒng)重用。但如果父進(jìn)程沒有調(diào)用wait,僵尸進(jìn)程將保留進(jìn)程表中的表項(xiàng),導(dǎo)致資源泄漏。
reap僵尸進(jìn)程的方式是通過kill命令手工向其父進(jìn)程發(fā)送SIGCHLD信號(hào),如果其父進(jìn)程仍然拒絕reap僵尸進(jìn)程,則終止父進(jìn)程,使得init進(jìn)程收養(yǎng)僵尸進(jìn)程。init進(jìn)程周期執(zhí)行wait系統(tǒng)調(diào)用reap其所收養(yǎng)的所有僵尸進(jìn)程。
為避免產(chǎn)生僵尸進(jìn)程,實(shí)際應(yīng)用中一般采取的方式是:
- 將父進(jìn)程中對SIGCHLD信號(hào)的處理函數(shù)設(shè)置SIG_IGN
- fork兩次并殺死一級(jí)自進(jìn)程,令二級(jí)子進(jìn)程成為孤兒進(jìn)程而被
init所“收養(yǎng)”、清理
與docker的關(guān)系
現(xiàn)在有很多人使用docker,只在容器里面運(yùn)行一個(gè)進(jìn)程。大多數(shù)情況下,這個(gè)進(jìn)程并不會(huì)有init進(jìn)程的行為,也就是說,這個(gè)進(jìn)程并不會(huì)reap收養(yǎng)的進(jìn)程,而是期望init進(jìn)程來做這件事,這種做法是合理的。
來看一個(gè)具體的例子。假設(shè)容器中跑一個(gè)Web服務(wù)器,這個(gè)服務(wù)器運(yùn)行bash編寫的CGI腳本,腳本中調(diào)用了grep。Web服務(wù)器發(fā)現(xiàn)腳本執(zhí)行超時(shí),殺掉了它,但是grep進(jìn)程沒有受到影響并繼續(xù)運(yùn)行。當(dāng)grep進(jìn)程執(zhí)行完后,變成了僵尸進(jìn)程,被PID為1的進(jìn)程收養(yǎng)(Web服務(wù)器進(jìn)程)。Web服務(wù)器不知道grep進(jìn)程,所以并沒有reap它,這時(shí)grep僵尸進(jìn)程就留在了系統(tǒng)里。
在其他的情況下,這個(gè)問題可能也存在。大家經(jīng)常將第三方的應(yīng)用程序跑在docker容器里,比如PostgreSQL,和上面一樣,這個(gè)進(jìn)程也是容器內(nèi)的唯一進(jìn)程。在這種情況下,真的能確定在容器中運(yùn)行這些第三方應(yīng)用不會(huì)產(chǎn)生僵尸進(jìn)程嗎?所以,在一般情況下,應(yīng)該運(yùn)行適當(dāng)?shù)?code>init系統(tǒng)來防止出現(xiàn)類似的問題。
胖容器問題
現(xiàn)有Upstart,Systemd,SysV init等方案可用,不過把這些一股腦地放在容器里,會(huì)不會(huì)顯得太重呢?其實(shí),雖然需要這些功能,“完全init系統(tǒng)”卻不是必要的。
這里討論的init系統(tǒng)是一個(gè)簡單的程序,負(fù)責(zé)fork出應(yīng)用程序,并且reap收養(yǎng)的進(jìn)程。
解決辦法
bash
是否已經(jīng)有現(xiàn)成,流行的軟件可以做到這一點(diǎn)呢?還真有,這就是bash。bash會(huì)正確地reap收養(yǎng)的子進(jìn)程。bash可以執(zhí)行任何程序。將Dockerfile中的
CMD ["/path-to-your-app"]
改成
CMD ["/bin/bash", "-c", "set -e && /path-to-your-app"]
即可。
不過,這個(gè)辦法有一個(gè)關(guān)鍵問題:不能正確處理信號(hào)。對bash發(fā)送一個(gè)SIGTERM信號(hào),bash會(huì)終止,但是并不會(huì)發(fā)送SIGTERM給其子進(jìn)程。
當(dāng)bash程序終止時(shí),內(nèi)核會(huì)停止整個(gè)容器和其中的進(jìn)程。一些進(jìn)程會(huì)接收到SIGKILL信號(hào),不正確地終止。SIGKILL無法被捕獲,所以進(jìn)程不能干凈地終止。假如應(yīng)用程序正在寫文件;如果應(yīng)用程序在寫入過程中被不正確地終止,則文件可能會(huì)損壞。這就像拔服務(wù)器電源一樣。
docker init
docker提供了一個(gè)解決的辦法,在運(yùn)行容器的時(shí)候添加init標(biāo)志
docker run --init your_image_here
這會(huì)讓docker內(nèi)部的微型init系統(tǒng)封裝應(yīng)用程序,這個(gè)init系統(tǒng)會(huì)保證將信號(hào)傳遞給其子進(jìn)程并確保獲取所有孤兒進(jìn)程。
如果想重新映射程序退出碼呢?比如Java接收SIGTERM信號(hào)退出時(shí),退出碼是143,而不是0。
docker init無法處理此類情況。
Tini
Tini是能想到的最簡單的init。
Tini一般在容器中運(yùn)行,用于生成子進(jìn)程,等待它推出,reap僵尸進(jìn)程,并執(zhí)行信號(hào)轉(zhuǎn)發(fā)。
在最新的版本中,能將退出碼143重新映射為0。使用的命令行如下
ENTRYPOINT ["/tini", "-v", "-e", "143", "--", "/runner/init"]