使用 systemd 管理 Docker container
因為把部落格重建[1]的關係,順便把所有的服務都 container[2] 化了,這次使用 systemd[3] 來幫忙管理 container deamonlize 的部分,下面來介紹一下我用了什麼工具跟方法。
前言
其實管理 Docker container 的工具已經有很多了,最常見的就是 Docker Compose[4]或是廣義的來說用 K8s[5] 等工具也是可以管理容器,但為什麼我還要自己搞呢?明明就有現成的工具方便我管理了。
但,我必須得說,我就是沒那麼喜歡 Docker Compose,畢竟你還要為這些 docker-compose.yml
找一個家,雖然可以跟要掛載的 volume 放一起,但還是很亂,既然我都要管理這些 Container 了,那我是不是可以直接掛到 systemd 上面幫我管理呢?
systemd service unit
好,既然已經確定要用 systemd 了,那麼就先來寫個 Unit file[6] 來描述這個 Docker container 的啟動方式吧,現在很簡單的假設一下我們現在要啓動一個很簡單的服務吧,這裡使用 busybox:musl
這個 image 並在裡面跑一個簡單的腳本令它每分鐘輸出一句 hello world
。
那麼 Unit file 如下: docker-hello-world.service
[Unit]
Description=Docker container - hello world
After=docker.service
Requires=docker.service
[Service]
TimeoutStartSec=0
Restart=always
ExecStart=/usr/bin/docker run --rm --name hello-world busybox:musl /bin/sh -c 'while true; do echo hello world; sleep 60; done'
[Install]
WantedBy=multi-user.target
如此一來我們就可以進行初步的管理了,例如使用 systemctl enable docker-hello-world.service
讓這個 container 可以開機自動執行,並且有加上 After=docker.service
及 Requires=docker.service
的緣故,確保這個 service 會接在 docker.service
執行之後才開始
systemd-docker
在成功的啓動服務之後呢,還不要太心急。雖然現在 systemd 的確可以成功的幫我們把 container 開起來,但當我們想透過 systemd 觀察 process 的時候只能看到如下的內容:
$ sudo systemctl status docker-hello-world.service
● docker-hello-world.service - Docker container - hello world
Loaded: loaded (/etc/systemd/system/docker-hello-world.service; disabled; vendor preset: enabled)
Active: active (running) since Mon 2019-08-19 08:49:09 UTC; 16ms ago
Main PID: 21293 (docker)
Tasks: 1 (limit: 1152)
CGroup: /system.slice/docker-hello-world.service
└─21293 /usr/bin/docker run --rm --name hello-world busybox:musl /bin/sh -c while true; do echo hello world; sleep 60; done
Aug 19 08:49:09 did systemd[1]: Started Docker container - hello world.
這是為什麼呢?
原來 Docker 的設計分為 Server 跟 Client,我們平常呼叫的 docker
指令就(Docker CLI)是所謂的 Client,而 Server 又稱 Docker Engine[7],其任務則是負責處理 container 管理、網路管理等任務。當我們操作 Docker CLI 時,其實只是藉由 Client 去向 Server 溝通,實際處理請求的還是 Docker Engine。
也是因為這樣,所以 Container 的 Process 其實是從 Docker Engine 延伸出來[8]的,且 Docker 還會將 Container 的 Process 隔離進新的 CGroup[9],因此從 systemd 上面是無法從執行 docker
這個指令的 docker-hello-world.service
unit 查詢到。
那怎麼辦呢?
所幸有人在理解背後的原理之後作出了一個工具 systemd-docker
[10],這個工具的運作原理十分簡單,他會將吃到的參數直接轉送給 docker
,如果這個操作將會建立 container 的話,systemd-docker
就會把建立好的 container process 的 cgroup 移動回 systemd 下面讓 systemd 可以管理,並將 stdout/stderr[11] 導向到 systemd-journal
[12],而且支援 systemd-notify
[13]。
正所謂,有了 systemd-docker
考試都考一百分呢(不要瞎掰好嗎)!
那麼我們把 Unit file 改一下: docker-hello-world.service
[Unit]
Description=Docker container - hello world
After=docker.service
Requires=docker.service
[Service]
TimeoutStartSec=0
Restart=always
Type=notify
NotifyAccess=all
ExecStart=/usr/local/bin/systemd-docker --cgroups name=systemd run --rm --name hello-world busybox:musl /bin/sh -c 'while true; do echo hello world; sleep 60; done'
[Install]
WantedBy=multi-user.target
可以注意到,我們同時加上了與 systemd-notify
有關的參數,並且在 systemd-docker
中指定 cgroup 到 systemd 上,如果想要指定其他 cgroup 也可以自行修改參數。
那麼就來看看結果吧:
$ systemctl daemon-reload
$ systemctl start docker-hello-world.service
$ systemctl status docker-hello-world.service
● docker-hello-world.service - Docker container - hello world
Loaded: loaded (/etc/systemd/system/docker-hello-world.service; disabled; vendor preset: enabled)
Active: active (running) since Mon 2019-08-26 07:30:48 UTC; 21s ago
Main PID: 3742 (sh)
Tasks: 4 (limit: 1152)
CGroup: /system.slice/docker-hello-world.service
└─3661 /usr/local/bin/systemd-docker --cgroups name=systemd run --rm --name hello-world busybox:musl /bin/sh -c while true; do echo hello world; sleep 60;
‣ 3742 /bin/sh -c while true; do echo hello world; sleep 60; done
Aug 26 07:30:48 did systemd[1]: Started Docker container - hello world.
Aug 26 07:30:48 did systemd-docker[3661]: hello world
Aug 26 07:31:48 did systemd-docker[3661]: hello world
Aug 26 07:32:48 did systemd-docker[3661]: hello world
要注意的是,由於我們移動了 cgroup,所以 docker run
的 --cpuset
/-m` 參數就會不起作用了(取而代之的是,我們可以從 systemd 來為這些 cgroup 設定限制)。
systemd template unit
在經過一番忙碌之後,我們終於把 Docker container 與 systemd 結合了,但要是每次建立新 container 都要複製貼上一個 unit file,豈不太搞剛(太麻煩)?
於是我們應該要將我們的 service unit 改良成一個 template unit,如此一來我們就可以不需要重複撰寫有關於 Docker container service 的重疊部分,把焦點放置在差異的部分就好了。
首先,我們來把 docker-hello-world.service
改一下名字以及內容,取作 docker-container@.service
,注意名稱中的 @
是必要的,systemd 會把 @
結尾的 unit file 視為一個 template unit,內容如下:
[Unit]
Description=Docker container - %i
After=docker.service
Requires=docker.service
[Service]
TimeoutStartSec=0
Restart=always
Type=notify
NotifyAccess=all
EnvironmentFile=/etc/systemd/docker/%i.conf
ExecStart=/usr/local/bin/systemd-docker --cgroups name=systemd run --rm --name systemd-%i $ARGS $IMAGE $CMD
[Install]
WantedBy=multi-user.target
我們在這裡定義了一個 template file,其執行時會讀取 /etc/systemd/docker/%i.conf
的檔案來做 unit 的環境變數設定,然後我們再將環境變數中的 $IMAGE
$ARGS
$CMD
丟給 systemd-docker
執行,其中 %i
[14] 指的就是這個 template unit 的 instance name。
覺得太過複雜的話,可以換個角度這樣理解,當 systemd 要執行一個 template service unit 的時候,會要求以 template-unit@instance-name.service
來執行,systemd 會去讀取 template-unit@.service
這個 template unit,並且把 %i
取代為 @
後方的內容(此例為 instance-name
)。以上面的例子來說,如果我們執行 docker-container@hello-world.service
,我們可以想成是在執行如下的 unit file:
[Unit]
Description=Docker container - hello-world
After=docker.service
Requires=docker.service
[Service]
TimeoutStartSec=0
Restart=always
Type=notify
NotifyAccess=all
EnvironmentFile=/etc/systemd/docker/hello-world.conf
ExecStart=/usr/local/bin/systemd-docker --cgroups name=systemd run --rm --name systemd-hello-world $ARGS $IMAGE $CMD
[Install]
WantedBy=multi-user.target
如此一來就清晰多了,關鍵的問題是 /etc/systemd/docker/hello-world.conf
的內容是什麼呢?其實也很簡單,內容如下:
IMAGE=busybox:musl
ARGS=
CMD=sh -c 'while true; do echo hello world; sleep 60; done'
而 systemd 在讀取這個檔案之後會把裡面的內容設成環境變數,並填入 ExecStart
中,整個 unit file 最後就會變成這樣:
[Unit]
Description=Docker container - hello-world
After=docker.service
Requires=docker.service
[Service]
TimeoutStartSec=0
Restart=always
Type=notify
NotifyAccess=all
EnvironmentFile=/etc/systemd/docker/hello-world.conf
ExecStart=/usr/local/bin/systemd-docker --cgroups name=systemd run --rm --name systemd-hello-world busybox:musl sh -c 'while true; do echo hello world; sleep 60; done'
[Install]
WantedBy=multi-user.target
這樣子就跟原本的一樣了,那麼我們如果要建立新的 container service 的時候,我們只需要進行下列的步驟:
- 決定一個 instance name,例如
nginx
- 在
/etc/systemd/docker/
下面建立nginx.conf
,並填入IMAGE
,ARGS
,CMD
- 執行
systemctl start docker-container@nginx.service
- (可選)
systemctl enable docker-container@nginx.service
設為開機自動執行
後記
經過了上面的研究之後,也去看了 systemd-docker
的原始碼瞭解這個工具實際上做的事情以及 Docker 與 systemd 背後做的事情有哪些,雖然還只是冰山一角,但至少又更理解自己平時使用的工具到底在幹嘛,而不是單純一味的使用,知其然而不知所以然,希望大家也有學到一些技巧。
這邊專指 Linux container,是一種作業系統層級的虛擬化技術,參見 Docker 的《What is a Container?》一文或維基百科上的《OS-level virtualisation》條目 ↩︎
systemd 是一套用於 Linux 的管理框架,其實作包含 Daemon、Library 及眾多 Application,並通常用來取代 System V 作為 init,並使用 cgroup 來追從 process,請見維基百科《systemd》條目及 ArchWiki 《systemd》條目 ↩︎
Kubernetes,是一個用於自動部署、擴展、容器化管理的工具,並不是專為 Docker 而設計,但支援使用 Docker 作為容器化的引擎,相關資訊可見其網站介紹 ↩︎
systemd 裡面所有與 service、socket、device、mountpoint、等有關的描述都被稱為 Unit,有關 Unit 的說明可以參考
man systemd.unit
(link)或 ArchWiki 〈[systemd - 編寫單元檔案(https://wiki.archlinux.org/index.php/Systemd_(正體中文)#編寫單元檔案)]〉一節 ↩︎Docker Engine 的介紹可見《About Docker Engine》一文 ↩︎
在預設的環境中,其實 Docker Engine 只是 containerd 的前端界面,實際建立 Process 的其實是 containerd,有關 containerd 的介紹請見網站說明 ↩︎
Control Groups,是 Linux 核心的一個功能,用來限制、控制與分離一個行程群組的資源,常被用來做 Container 的基礎,systemd 也以此為基礎來管理 service。 ↩︎
提供 Systemd 與 Docker 互動的界面,其原始版本只支援舊 Docker API,筆者這裡使用的是由 @DonTseTse fork 出來的版本: github.com/DonTseTse/systemd-docker ↩︎
stdout 表示為標準輸出、stderr 表示為標準錯誤輸出,兩者預設皆會直接輸出至終端 ↩︎
Systemd 中提供的日誌系統,用以管理系統 log 等記錄,更多資訊可見ArchWiki《Systemd/Journal》條目 ↩︎
Systemd-notify 可向 systemd 發送一個提示以表示 service 的狀態(例如:執行中),以便 systemd 處理 unit 之間的相依性 ↩︎
%i
等變數我們稱為 specifier,其他可用的 specifier 可見man systemd.unit
中的 〈specifiers〉一節 ↩︎