現在越來越多環境都跑在 Docker 環境下,但不知道你是否有遇過想要停止容器,但執行 docker stop
之後卻無法立即停止的狀況?這個問題會牽扯到我在 當 .NET Core 執行在 Linux 或 Docker 容器中如何優雅的結束 文章中提到的 訊號(Signal) 是如何傳送到容器的程序。這篇文章我將更深入的探討這個問題,以及提供解決方法。
關於容器與訊號的關係
當你在執行 Docker 容器時,主要執行程序(Process)的 PID 將會是 1
,只要這個程序停止,容器就會跟著停止。
由於容器中一直沒有像 systemd 或 sysvinit 這類的初始化系統(init system),少了初始化系統來管理程序,會導致當程序不穩定的時候,無法進一步有效的處理程序的狀態,或是無法有效的控制 Signal 處理機制。
我們以 docker stop
為例,這個命令實質上是對容器中的 PID 1
送出一個 SIGTERM
訊號,如果程序本身並沒有處理 Signal 的機制,就會直接忽略這類訊號,這就會導致 docker stop
等了 10 秒之後還不結束,然後 Docker Engine 又會對 PID 1
送出另一個 SIGKILL
訊號,試圖強迫砍掉這個程序,這才會讓容器徹底停下來。
示範容器中的主程式沒有正確處理 Signal 的狀況
我們用一個最簡單的例子來說明:
-
執行簡單的 sleep
命令
docker run -it --rm --name=test ubuntu /bin/sh -c "sleep 100000"
-
然後我們試著停止這個容器
docker stop test
此時你會發現要等 10 秒,容器才會結束!
會發生無法立刻停止的狀況,其實是 /bin/sh
預設並不會處理(handle)訊號,所以他會把所有不認得的訊號忽略,直到作業系統把他砍掉為止。
示範容器中的主程式有正確處理 Signal 的狀況
-
建立一個空資料夾,並且建立一個 test.sh
檔案
我在這份 Shell 腳本中使用 trap 'exit 0' SIGTERM
來處理 SIGTERM
訊號,接收到訊號就直接以狀態碼 0
正常的退出:
#!/usr/bin/env sh
trap 'exit 0' SIGTERM
while true; do :; done
-
撰寫一個 Dockerfile
來建置一個名為 test:latest
的 Image
FROM alpine
COPY test.sh /
ENTRYPOINT [ "/test/sh" ]
-
建置如容器映像
docker build -t test:latest .
-
執行容器
docker run -it --rm --name=test test:latest
-
然後我們試著停止這個容器
docker stop test
此時你會發現,容器收到訊號之後就會立刻結束!
其實大部分的程式都沒有處理訊號(signal handling),甚至有很多不熟悉 Linux 的開發者根本不知道有訊號的存在。
以下我用一個簡單的例子來示範 dumb-init 的使用方式:
-
建立一個 Dockerfile
並安裝 dumb-init
套件
這邊我用 alpine
超經量容器映像,搭配 curl
下載一份 9GB 的超大檔案 (代表會下載很久)。
你只要加入一個 ENTRYPOINT
指令,讓我原本要在容器中執行的程式,直接連著 /usr/bin/dumb-init --
後面執行,使用上就是這麼簡單!👍
FROM alpine
RUN apk update && apk add --no-cache dumb-init curl ca-certificates && rm -rf /var/cache/apk/*
COPY test.sh /
ENTRYPOINT [ "/usr/bin/dumb-init", "--" ]
CMD [ "curl", "http://http.speed.hinet.net/test_9216m.zip", "-o", "/dev/null" ]
請記得要在 /usr/bin/dumb-init
後面加入一個 --
參數,分隔 dumb-init
與 主程式 之間,因為 dumb-init
也有自己的參數選項可以設定。
-
建置容器映像
docker build -t test:latest .
-
執行容器
docker run -it --rm --name=test test:latest
-
然後我們試著停止這個容器
docker stop test
此時你會發現,無論 curl 有沒有執行完,當 dumb-init
收到 SIGTERM
訊號時,就會轉發給透故 dumb-init
啟動的 curl
程序!
有時候我們會透過 Shell Script 啟動一些其他的程式,有些甚至是背景服務。但是,當 Shell 接收到 SIGTERM
訊號的時候,並不會轉傳收到的訊號給子程序(Sub-process),所以就算你的 Shell Script 收到訊號,其他子程序是不會收到訊號的,所以程序並不會停止。
這個狀況有個非常簡單的解決方式,就是把 #!/usr/bin/env sh
修改成 #!/usr/bin/dumb-init /bin/sh
即可!
#!/usr/bin/dumb-init /bin/sh
my-web-server & # launch a process in the background
my-other-server # launch another process in the foreground
使用 dumb-init 控制訊號覆寫 (Signal rewriting)
我之前在 當 .NET Core 執行在 Linux 或 Docker 容器中如何優雅的結束 文章中也有提到,有些特定的服務並不會接收 SIGTERM
訊號。例如 nginx 預設若要執行優雅的結束,必須對他送出 SIGQUIT
訊號。而 Apache HTTP Server 則要送出 SIGWINCH
訊號,才會優雅的結束。
由於 docker stop
預設會送出 SIGTERM
服務為主,所以如果你打算自己封裝 nginx
容器的話,送出正確的訊號就十分重要。
如果你直接使用 nginx 容器映像,其實不用特別處理,因為你可以看 nginx 的 Dockerfile 已經設定了 STOPSIGNAL SIGQUIT
指令,所以當有人對這個容器送出 docker stop
命令時,本來就會轉送 SIGQUIT
訊號過去,不需要靠 dumb-init
的幫助。
當然,如果你有特別的需求,才需要把 SIGTERM
(15
) 轉送成 SIGQUIT
(3
) 這樣寫:
ENTRYPOINT [ "/usr/bin/dumb-init", "--rewrite", "15:3", "--" ]
CMD [ "curl", "http://http.speed.hinet.net/test_9216m.zip", "-o", "/dev/null" ]
完整的訊號名稱與編號可以透過 Linux 下的 kill -l
命令查詢。
以下是 dumb-init
的參數選項說明:
dumb-init v1.2.2
Usage: dumb-init [option] command [[arg] ...]
dumb-init is a simple process supervisor that forwards signals to children.
It is designed to run as PID1 in minimal container environments.
Optional arguments:
-c, --single-child Run in single-child mode.
In this mode, signals are only proxied to the
direct child and not any of its descendants.
-r, --rewrite s:r Rewrite received signal s to new signal r before proxying.
To ignore (not proxy) a signal, rewrite it to 0.
This option can be specified multiple times.
-v, --verbose Print debugging information to stderr.
-h, --help Print this help message and exit.
-V, --version Print the current version and exit.
Full help is available online at https://github.com/Yelp/dumb-init
使用 tini 初始化系統
tini 是一套更簡單的 init 系統,專門用來執行一個子程序(spawn a single child),並等待子程序結束,即便子程序已經變成僵屍程序(zombie process)也能捕捉到,同時也能轉送 Signal 給子程序。
如果你使用 Docker 來跑容器,可以非常簡便的在 docker run
的時候用 --init
參數,就會自動注入 tini 程式 (/sbin/docker-init
) 到容器中,並且自動取代 ENTRYPOINT
設定,讓原本的程式直接跑在 tini 程序底下!
注意:Docker 1.13 以後的版本才開始支援 --init
參數,並內建 tini 在內。
-
不用 --init
的情況
直接啟動 sleep
程式跑 100 秒
docker run -it --rm --name=test alpine sleep 100
使用 ps -ef
可以得知 sleep 100
程式會直接跑在 PID 1
底下
docker exec -it test ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 100
8 root 0:00 ps -ef
停止容器需要 10 秒才能完成
docker stop test
-
使用 --init
的情況
使用 --init
啟動 sleep
程式跑 100 秒
docker run -it --rm --name=test --init alpine sleep 100
使用 ps -ef
可以得知 sleep 100
程式會跑在 /sbin/docker-init --
命令下
docker exec -it test ps -ef
PID USER TIME COMMAND
1 root 0:00 /sbin/docker-init -- sleep 100
8 root 0:00 sleep 100
9 root 0:00 ps -ef
停止容器只需要 1 秒內就可以完成
docker stop test
這兩套其實都是可以正確擔任 PID 1
的重責大任的 init systems,但是要怎樣選擇呢?
-
如果你想自己建置 Docker Image,但是主程式沒有辦法正確處理訊號或是無法有效管理子程序時,可以使用 dumb-init 來啟動程式。
-
如果你只想直接使用其他人寫好的 Docker Image,但 Image 內的主程式沒有正確的處理訊號,或是執行過程會發生資源洩漏或變成僵屍等狀況,可以在 docker run
的時候直接使用 --init
啟動容器,該容器的主程式就會自動跑在 tini 底下!
已經有許多 Docker Images 內建 tini 在內,詳見 tini-images Repo 說明。
相關連結