執行 Docker 容器可使用 dumb-init 或 tini 改善程序優雅結束的問題 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

執行 Docker 容器可使用 dumb-init 或 tini 改善程序優雅結束的問題

現在越來越多環境都跑在 Docker 環境下,但不知道你是否有遇過想要停止容器,但執行 docker stop 之後卻無法立即停止的狀況?這個問題會牽扯到我在 當 .NET Core 執行在 Linux 或 Docker 容器中如何優雅的結束 文章中提到的 訊號(Signal) 是如何傳送到容器的程序。這篇文章我將更深入的探討這個問題,以及提供解決方法。

關於容器與訊號的關係

當你在執行 Docker 容器時,主要執行程序(Process)的 PID 將會是 1,只要這個程序停止,容器就會跟著停止。

由於容器中一直沒有像 systemdsysvinit 這類的初始化系統(init system),少了初始化系統來管理程序,會導致當程序不穩定的時候,無法進一步有效的處理程序的狀態,或是無法有效的控制 Signal 處理機制。

我們以 docker stop 為例,這個命令實質上是對容器中的 PID 1 送出一個 SIGTERM 訊號,如果程序本身並沒有處理 Signal 的機制,就會直接忽略這類訊號,這就會導致 docker stop 等了 10 秒之後還不結束,然後 Docker Engine 又會對 PID 1 送出另一個 SIGKILL 訊號,試圖強迫砍掉這個程序,這才會讓容器徹底停下來。

示範容器中的主程式沒有正確處理 Signal 的狀況

我們用一個最簡單的例子來說明:

  1. 執行簡單的 sleep 命令

    docker run -it --rm --name=test ubuntu /bin/sh -c "sleep 100000"
    
  2. 然後我們試著停止這個容器

    docker stop test
    

    此時你會發現要等 10 秒,容器才會結束!

會發生無法立刻停止的狀況,其實是 /bin/sh 預設並不會處理(handle)訊號,所以他會把所有不認得的訊號忽略,直到作業系統把他砍掉為止。

示範容器中的主程式有正確處理 Signal 的狀況

  1. 建立一個空資料夾,並且建立一個 test.sh 檔案

    我在這份 Shell 腳本中使用 trap 'exit 0' SIGTERM 來處理 SIGTERM 訊號,接收到訊號就直接以狀態碼 0 正常的退出:

    #!/usr/bin/env sh
    trap 'exit 0' SIGTERM
    while true; do :; done
    
  2. 撰寫一個 Dockerfile 來建置一個名為 test:latest 的 Image

    FROM alpine
    COPY test.sh /
    ENTRYPOINT [ "/test/sh" ]
    
  3. 建置如容器映像

    docker build -t test:latest .
    
  4. 執行容器

    docker run -it --rm --name=test test:latest
    
  5. 然後我們試著停止這個容器

    docker stop test
    

    此時你會發現,容器收到訊號之後就會立刻結束!

使用 dumb-init 初始化系統

其實大部分的程式都沒有處理訊號(signal handling),甚至有很多不熟悉 Linux 的開發者根本不知道有訊號的存在。

以下我用一個簡單的例子來示範 dumb-init 的使用方式:

  1. 建立一個 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 也有自己的參數選項可以設定。

  2. 建置容器映像

    docker build -t test:latest .
    
  3. 執行容器

    docker run -it --rm --name=test test:latest
    
  4. 然後我們試著停止這個容器

    docker stop test
    

    此時你會發現,無論 curl 有沒有執行完,當 dumb-init 收到 SIGTERM 訊號時,就會轉發給透故 dumb-init 啟動的 curl 程序!

使用 dumb-init 控制所有啟動的程序

有時候我們會透過 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 在內。

  1. 不用 --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
    
  2. 使用 --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
    

dumb-init vs. tini

這兩套其實都是可以正確擔任 PID 1 的重責大任的 init systems,但是要怎樣選擇呢?

  • 如果你想自己建置 Docker Image,但是主程式沒有辦法正確處理訊號或是無法有效管理子程序時,可以使用 dumb-init 來啟動程式。

  • 如果你只想直接使用其他人寫好的 Docker Image,但 Image 內的主程式沒有正確的處理訊號,或是執行過程會發生資源洩漏或變成僵屍等狀況,可以在 docker run 的時候直接使用 --init 啟動容器,該容器的主程式就會自動跑在 tini 底下!

    已經有許多 Docker Images 內建 tini 在內,詳見 tini-images Repo 說明。

相關連結