Docker: 使用 s6 作為多服務容器的啟動管理程序

前言

最近在打包 docktorrent 的過程中,遇到了在 container 裡多服務的啟動和管理問題,包含 process init,signal handle,monitor,orphan reaping 等等。把 init process 的 survey 心得及最後決定的 image 架構記錄一下,有錯請指正。

在一個容器裡跑多個服務?你確定?

雖然 Docker 官方總是建議一個 container 只跑一個特定服務,然而在很多情況下,將幾個高度耦合的服務封裝在同一個 container 中一起跑也是很乾脆的做法,相對於內建難用的 linking 或透過 fig 管理多 container,有時候還方便的多。

聽聽 Phusion 對於這個問題是怎麼說的:

“But I thought Docker is about running a single process in a container?”

Absolutely not true. Docker runs fine with multiple processes in a container. In fact, there is no technical reason why you should limit yourself to one process – it only makes things harder for you and breaks all kinds of essential system functionality, e.g. syslog.

We encourage you to use multiple processes.

沒有 init process 會怎樣?

然而,一旦跑了多個 process,誰當老大(PID = 1)就會是個很重要的問題。在 docktorrent 一開始的打包方法中,我的 entrypoint 腳本大概是像這樣:

# Start satellite services
/etc/init.d/nginx start
/etc/init.d/php5-fpm start

# Turn to rTorrent
exec rtorrent

也就是說把 nginx 和 php5-fpm 跑起來後,執行 rTorrent 並且把 PID 1 交給他。會讓 rTorrent 作為 PID 1 的原因在於 docker stop 只會把 SIGTERM 送給 container 中的 PID 1 process,而 rTorrent 是整個 container 中唯一需要 graceful shutdown 的,所以讓他去接 signal。但這種做法馬上就會發現 ps -ef 出現一大堆 [php] <defunct> process,或許這是那些 PHP process 自己的問題,但作為 PID 1 的 rTorrent 不能好好回收這些孤兒,似乎也不太理想。一般來說,作為系統頭號 process 應該要負幾個基本責任:

  1. 依序啟動所有該啟動的服務(process)然後常駐背景
  2. 在系統終止時正常關閉(graceful shutdown)這些服務
  3. 隨時回收系統上所有無主孤魂(orphan process)
  4. 可以 respawn 異常掛掉的服務

因此就有必要找個(或者自幹一個)專職的 PID 1,也就是 init 程序來作為 container 執行時的入口。當然,考慮到最後的 image 要盡量精簡,像 Upstart,Systemd 或是 OpenRC 這種重型的方案在 container 環境就暫且先不考慮了。

Supervisord

於是首先看看 Docker 官方推薦 的 Supervisord,透過設定檔來配置服務的方式,試用了一下發現還算簡單易用,一個 supervisord.conf 的內容大概是這樣寫:

[supervisord]
nodaemon=true

[program:sshd]
command=/usr/sbin/sshd -D

[program:apache2]
command=/bin/bash -c "source /etc/apache2/envvars && /usr/sbin/apache2 -DFOREGROUND"

可惜 Supervisord 的設計並不是用來作為 init process 使用的,這在其首頁上已有宣告:

It shares some of the same goals of programs like launchd, daemontools, and runit. Unlike some of these programs, it is not meant to be run as a substitute for init as “process id 1”. Instead it is meant to be used to control processes related to a project or a customer, and is meant to start like any other program at boot time.

經過測試,Supervisord 在接收到 docker stop 送來的 SIGTERM 時,會把訊號轉送給其他 process,等待所有服務 graceful shutdown。然而,孤兒 processes 的問題也確實沒有解決,整個 container 還是充滿 zombie。再說 Supervisord 是 Python 寫成的,要是疊加在 debian:jessie 上足足要增加 45MB 的 image size,加上 Python 在執行階段的成本,僅僅為了跑多服務似乎不大划算。

Runit

下一位,Runit。Written in C,足夠輕量,疊加在 debian:jessie 上只增加約 10MB 大小,貌似不錯。知名的 Phusion baseimage 系列也使用 Runit(不過只是用來做服務啟動和 supervision,對於 init process 他們自己寫了一隻叫 my_init 的 Python 小程式)。

/usr/bin/runsvdir -P /etc/sv 是 Runit 掃描並啟動服務的方式,其中 /etc/sv 是符合 Runit 規定格式的 service directory。舉例來說在 phusion/baseimage 裡的目錄結構是這樣:

# tree /etc/service
/etc/service
|-- cron
|   |-- run
|   `-- supervise
|       |-- control
|       |-- lock
|       |-- ok
|       |-- pid
|       |-- stat
|       `-- status
|-- sshd
|   |-- run
|   `-- supervise
|       |-- control
|       |-- lock
|       |-- ok
|       |-- pid
|       |-- stat
|       `-- status
`-- syslog-ng
    |-- run
    `-- supervise
        |-- control
        |-- lock
        |-- ok
        |-- pid
        |-- stat
        `-- status

掃到服務之後 runsvdir 會將服務交由另一隻 runsv 去啟動並監管(supervise)。看起來也算簡單明瞭,但可惜的是 runsvdir 並不適合直接作為 PID 1 使用,原因是他不做訊號轉送,因此 docker stop 會導致 container 裡所有服務直接終結。

Runit 裡真正負責 PID 1 的是另一隻叫 runit-init 的程式,runit-init 啟動之後會把自己替換為 runit,然後再叫出 runsvdir,於是整個 process tree 大概就會變這樣:

runit ─── runsvdir ─┬─ runsv syslog-ng ─── syslog-ng -F
      	            ├─ runsv sshd ─── /usr/sbin/sshd -D
                    ├─ runsv crond ─── /usr/sbin/crond -f

除了 process tree 深度增加一層不太爽之外,runit-init 還另外引進了 run level 的問題,同樣的,只是為了在 container 裡多跑幾個服務要搞這麼多東西似乎有點小題大做了。

s6

終於來到今天的主角了。

s6,Written in C,所有 binary 加起來不到 900KB,非常輕量(也非常冷門)。主要有兩隻程式在工作:s6-svscan 作為 PID 1 掃描所有服務並交由 s6-supervise 實際啟動並接管。

關於 s6-svscan 的幾件事:

  • 收到 docker stop 送進來的 SIGTERM 後,轉送給所有 s6-supervise process 後接著執行 .s6-svscan/finish,如果執行過程中 fail 掉就改跑 .s6-svscan/crash。
  • service directory 裡每個 service 可以有三個可執行檔:run/finish/crash,顧名思義簡單易用。
  • 支援特殊 logger service,透過新建名為 log 的 service directory 可以自定義 logger。
  • 會一直在背景默默的持續 scan 目錄並回收系統裡的 orphan。

關於 s6-supervise 的幾件事:

  • 會先切換到服務的 service directory,接著預設執行 ./run。
  • ./run 跑完了如果目錄裡存在 ./finish 就執行。
  • ./finish 如果在五秒內沒有退出,會直接被 kill 掉,然後再回頭執行 ./run(這個行為可以自己控制)。

我的 service directory 結構長這樣:

# tree /service
/service
|-- nginx
|   |-- event
|   |-- run
|   `-- supervise
|       |-- control
|       |-- lock
|       `-- status
|-- php5-fpm
|   |-- event
|   |-- run
|   `-- supervise
|       |-- control
|       |-- lock
|       `-- status
`-- rtorrent
    |-- event
    |-- finish
    |-- run
    `-- supervise
        |-- control
        |-- lock
        `-- status

試用過後發現 init process 四個基本需求都有滿足,也夠簡單。但是發現另一個問題:s6-svscan 把 SIGTERM 送給 s6-supervise 後馬上就退出了,導致 s6-supervise 管轄的服務根本沒時間 graceful shutdown,這樣子 SIGTERM 有送豈不等於沒送?當然了,在 .s6-svscan/finish 裡加一段 sleep 可以 work around,但這樣搞實在不太科學。後來才知道 s6 裡有一隻叫 s6-svwait 的 user interface 可以用來等待特定或所有服務,於是在 .s6-svscan/finish 加上一段等待主要服務結束的指令:exec s6-svwait -d /service/rtorrent,這個問題算是解決。

到此,一個輕量又簡單易用的 init process 解決方案就有了。

取得 s6 的 static binaries

參考網路上找到的做法,先 build 一個基於 Arch 的 utility image 提供 s6 編譯環境,然後在其他要用到 s6 的 image repository 直接取用編好的 binary tar ball。具體用法可以參考我的 GitHub repo,或者直接把 binary 抓去試試也行。

寫在後面

  • s6 似乎沒有處理啟動順序相依性的框架,如果需要可能要自己用 hook 的方法 work around。但話說回來,在 container 裡跑多服務跑到出現複雜的啟動相依性,好像也有點怪怪的(container VM 化?)。不過如果真的要這樣搞,那你需要的應該是更完整(肥)的 init process。
  • 如果只是要處理 signal forward 讓服務可以 graceful shutdown,則完全沒有必要引入 init process,透過一隻 Bash wrapper 加幾行 trap/wait 就可以辦到,可以參考我送給 GitLab 的 PR。
  • Survey 的過程中發現有人遇到一樣的問題,然後寫了一個叫 minit 的輕量 init process,看起來也還不錯,但我沒有試,如果你有使用心得歡迎分享。

References

kfei

Hacker / Tenniser / Photographer / Diver / Entrepreneur

Kaohsiung, Taiwan https://kfei.net

看文章如果不回應, 跟條鹹魚有什麼分別?