Tag: Docker

使用 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.serviceRequires=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 改一下名字以及內容,取作 [email protected],注意名稱中的 @ 是必要的,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 的時候,會要求以 [email protected] 來執行,systemd 會去讀取 [email protected] 這個 template unit,並且把 %i 取代為 @ 後方的內容(此例為 instance-name)。以上面的例子來說,如果我們執行 [email protected],我們可以想成是在執行如下的 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 的時候,我們只需要進行下列的步驟:

  1. 決定一個 instance name,例如 nginx
  2. /etc/systemd/docker/ 下面建立 nginx.conf,並填入 IMAGE, ARGS, CMD
  3. 執行 systemctl start [email protected]
  4. (可選) systemctl enable [email protected] 設為開機自動執行

後記

經過了上面的研究之後,也去看了 systemd-docker 的原始碼瞭解這個工具實際上做的事情以及 Docker 與 systemd 背後做的事情有哪些,雖然還只是冰山一角,但至少又更理解自己平時使用的工具到底在幹嘛,而不是單純一味的使用,知其然而不知所以然,希望大家也有學到一些技巧。


  1. 有關重建部落格的資訊,請見《部落格復活》一文 ↩︎

  2. 這邊專指 Linux container,是一種作業系統層級的虛擬化技術,參見 Docker 的《What is a Container?》一文或維基百科上的《OS-level virtualisation》條目 ↩︎

  3. systemd 是一套用於 Linux 的管理框架,其實作包含 Daemon、Library 及眾多 Application,並通常用來取代 System V 作為 init,並使用 cgroup 來追從 process,請見維基百科《systemd》條目及 ArchWiki 《systemd》條目 ↩︎

  4. Docker Compose 是 Docker 所推薦的一種同時管理多個容器的方式,相關用法可以參見官網說明 ↩︎

  5. Kubernetes,是一個用於自動部署、擴展、容器化管理的工具,並不是專為 Docker 而設計,但支援使用 Docker 作為容器化的引擎,相關資訊可見其網站介紹 ↩︎

  6. systemd 裡面所有與 service、socket、device、mountpoint、等有關的描述都被稱為 Unit,有關 Unit 的說明可以參考 man systemd.unit (link)或 ArchWiki 〈[systemd - 編寫單元檔案(https://wiki.archlinux.org/index.php/Systemd_(正體中文)#編寫單元檔案)]〉一節 ↩︎

  7. Docker Engine 的介紹可見《About Docker Engine》一文 ↩︎

  8. 在預設的環境中,其實 Docker Engine 只是 containerd 的前端界面,實際建立 Process 的其實是 containerd,有關 containerd 的介紹請見網站說明 ↩︎

  9. Control Groups,是 Linux 核心的一個功能,用來限制、控制與分離一個行程群組的資源,常被用來做 Container 的基礎,systemd 也以此為基礎來管理 service。 ↩︎

  10. 提供 Systemd 與 Docker 互動的界面,其原始版本只支援舊 Docker API,筆者這裡使用的是由 @DonTseTse fork 出來的版本: github.com/DonTseTse/systemd-docker ↩︎

  11. stdout 表示為標準輸出、stderr 表示為標準錯誤輸出,兩者預設皆會直接輸出至終端 ↩︎

  12. Systemd 中提供的日誌系統,用以管理系統 log 等記錄,更多資訊可見ArchWiki《Systemd/Journal》條目 ↩︎

  13. Systemd-notify 可向 systemd 發送一個提示以表示 service 的狀態(例如:執行中),以便 systemd 處理 unit 之間的相依性 ↩︎

  14. %i 等變數我們稱為 specifier,其他可用的 specifier 可見 man systemd.unit 中的 〈specifiers〉一節 ↩︎