標籤:Linux

使用 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〉一節 ↩︎

使用 Cmder 直入 WSL

因為工作有 SSH 連線到遠端機器操作的習慣,在換了 Surface Book 2[1] 之後,也還是想要儘量保持這個習慣,一般來說我都是使用 PieTTY[2] 在進行 SSH 連線,不過對於行動裝置我通常會再搭配上 Mosh[3] 使用,可惜在 Windows 上面沒有原生的 Client 支援,於是我打算在 WSL[4] 裡面安裝 Mosh 來使用!

初始規劃

原本我是在找尋可用的 Mosh client,發現 MobaXterm 有支援 mosh,不過試用了一下之後發現他對於鍵盤的 ASCII Keycode 支援沒有很好,一些按鍵是送不出去的,於是就放棄了,打算改轉我在桌機上面的做法——開 Linux 虛擬機再用 PieTTY 連線進入。

不過虛擬機這個念頭很快就被 WSL 取代了(我 Hyper-V 都打開準備要裝系統了XD),因為筆電還是會想要以電量為優先考量,如果裝 VM 的話,電池很快就不夠力了……

於是這邊有兩個想法:

  1. 在 WSL 裡面啓用 SSH Server,用 PieTTY 搭配自動登入[5]來作為 mosh 的執行器
  2. 找一個 PieTTY 的替代品直接接入 WSL Shell 來執行 mosh

由於 WSL 的 lifecycle 只會到所有 shell 關閉就結束,所以如果要用方案 1 的話還必須在 Windows 裡面建立一個 service 來掛著 WSL sshd 的 Deamon(怎麼有種 Docker 的感覺XD),有點小麻煩,雖然我很喜歡,因為 PieTTY 已經用習慣了,其實我沒有很想換 Terminal…… 如果之後用的不順再來研究這個方法XD

使用 Cmder

不過介於我懶惰設定的關係,所以我姑且選擇了方案 2,我找了一個 Windows Terminal Emulator —— Cmder[6],他其實是把 ConEmu[7] 做了一些包裝,使用起來還算是上手,設定一下快捷鍵之後覺得應該還能接受

不過設定 WSL 的地方就需要照著說明來做了,原先我以為進去 cmder 之後,下 ubuntu 啓用 WSL 就完美了,結果在我 ssh/mosh 到遠端機器的時候發現 256 color 的設定不見了,整個顏色都變得很奇怪。

查了一下資料之後發現好像需要 wslbridge 來幫忙轉換一些畫面繪製的指令,這部份其實也不需要自己安裝額外的東西,cmder 包裝的 ConEmu 版本夠新,需要的元件其實本來就有包含了,我們只需要自己加上 Task config。

依照這篇的說明,首先建立一個 Task(名稱隨便取,像我就使用 wsl::bridge),在指令的部分填上如下內容:

set "PATH=%ConEmuBaseDirShort%\wsl;%PATH%" & %ConEmuBaseDirShort%\conemu-cyg-64.exe --wsl -cur_console:pnm:/mnt -t bash -l

然後在 cmder 建立新 Tab 的時候選擇這個 task,就可以得到一個有 256color support 的 Terminal 了!

看看全彩的 lolcat 就覺得……好空虛XDDD

預設進入 mosh

如果想要預設建立某個 mosh 連線的話,也可以複製剛剛的 Task,並在後面加上:-c "mosh [email protected]" 讓 bash 會自動執行 mosh 之後離開。


  1. Surface Book 2 開箱一文 ↩︎

  2. PieTTY ↩︎

  3. Mosh: the mobile shell ↩︎

  4. WSL: Windows Subsystem for Linux ↩︎

  5. 修改 PAM 讓特定 IP 透過 SSH 登入時不需要輸入密碼一文 ↩︎

  6. cmder | Console Emulator ↩︎

  7. ConEmu - Handy Windows Terminal ↩︎

修改 PAM 讓特定 IP 透過 SSH 登入時不需要輸入密碼

我有兩臺電腦,一臺是外出用的 MacBook Air (13" later)、另一臺是自己組裝的 Windows 桌機,但平時工作都是在 Windows 上面開 Linux(當然是我最愛的 Ubuntu)的 VM 再從兩臺電腦 SSH 進去 VM 裡面。但從 Windows SSH 進去 VM 的時候就很想跳過輸入密碼的步驟,當然用 SSH Key 也可以辦到,但我覺得他們根本就是同一個電腦啊,還要加上 SSH Key 也太累了吧!要如何僅依靠來源 IP 就決定要不要輸入密碼呢?

!!警告!! 跳過密碼就可以登入是非常危險的,看看蘋果今天才緊急更新的免密碼登入 root 漏洞,即使是本機也是非常危險的呢!
https://support.apple.com/en-us/HT208315

PAM (Pluggable Authentication Modules)

首先,想跟各位介紹一個叫做 PAM (Pluggable Authentication Modules) 的模組,PAM 提供了一套驗證的 API 讓應用程式可以透過 PAM 做使用者驗證。

我們即是要對 PAM 來下手,而 PAM 的設定檔們都存放在 /etc/pam.d/ 下,sshd 會有自己專屬的設定檔可以使用就叫做 /etc/pam.d/sshd

我們把設定檔 cat 出來看一下會看到類似以下的內容:

# PAM configuration for the Secure Shell service

# Standard Un*x authentication.
@include common-auth

# Disallow non-root logins when /etc/nologin exists.
account    required     pam_nologin.so

# Uncomment and edit /etc/security/access.conf if you need to set complex
# access limits that are hard to express in sshd_config.
# account  required     pam_access.so

# Standard Un*x authorization.
@include common-account

# SELinux needs to be the first session rule.  This ensures that any
# lingering context has been cleared.  Without this it is possible that a
# module could execute code in the wrong domain.
session [success=ok ignore=ignore module_unknown=ignore default=bad]        pam_selinux.so close

# Set the loginuid process attribute.
session    required     pam_loginuid.so

# Create a new session keyring.
session    optional     pam_keyinit.so force revoke

# Standard Un*x session setup and teardown.
@include common-session

# Print the message of the day upon successful login.
# This includes a dynamically generated part from /run/motd.dynamic
# and a static (admin-editable) part from /etc/motd.
session    optional     pam_motd.so  motd=/run/motd.dynamic
session    optional     pam_motd.so noupdate

# ......

# Standard Un*x password updating.
@include common-password

我們可以很清楚的看到,每一條項目都有以下格式:

[驗證類別] [控制標記] [檔名] [參數]

驗證類別 (Type)

驗證類別分成四種:account(帳號)、auth(驗證)、session(執行期間)、password(更新驗證資訊)。

舉例來說,我們可以清楚的看到 account 中有一個用來阻擋 nologin 的模組:

# Disallow non-root logins when /etc/nologin exists.
account    required     pam_nologin.so

表示這個模組會在使用者登入時,輸入帳號後,檢查是否為 nologin,來決定這個帳號是否合法。

控制標記 (Control Flag)

常見的控制標記有 4 種:

  • required
    若驗證成功則帶有 success 的標誌;
    若驗證失敗則帶有 failure 的標誌。
    但不論成功或失敗都會繼續後續的驗證流程。

  • requisite
    若驗證失敗則立刻回報 failure,並終止後續的驗證流程;
    若驗證成功則帶有 success 的標誌並繼續後續的驗證流程。
    required 最大的差異就在於失敗時就會終止驗證流程。

  • sufficient
    若驗證成功則立刻回報 success,並終止後續的驗證流程;
    若驗證失敗則帶有 failure 標誌並繼續後續的驗證流程。
    requisits 剛好相反。

  • optional
    不論成功或失敗都不回報且不影響後續的流程。

但這些都只是常見的預設標記,PAM 還提供了自己撰寫驗證流程規則的標記,可以自己寫要不要回報,以及要不要中斷流程。可以看看 pam.d(5) 的 man page。

觀察可用的 PAM 模組

我們可以注意到預設的 sshd 裡面有一個令人有興趣的設定是:

# Uncomment and edit /etc/security/access.conf if you need to set complex
# access limits that are hard to express in sshd_config.
# account  required     pam_access.so

如果我把註解拿掉的話,他就會在帳號檢查階段(account),去檢查這個帳號是不是可以被存取,這個不是跟我們想要的東西很像嗎?
只是我們要檢查的是「這個密碼需不需要被輸入」而已,很像嘛! (迷之音:真的很像嗎?)

實做

PAM 設定

以下是我使用的設定,插在 common-auth 之前,來保持我的語句比其他人早驗證:

# PAM configuration for the Secure Shell service

auth [success=done default=ignore] pam_access.so accessfile=/etc/security/sshd-access-local.conf # <<<<<<<
# Standard Un*x authentication.
@include common-auth

# ...

我在這裡使用了 auth,讓我需要被驗證的時候執行檢查;
而在控制標記的部分我則是自己撰寫了規則 [success=done default=ignore],表示在 success 的時候就直接回報 success 並結束驗證流程,但在非 success 的情況下就不回報也不結束流程(ignore)。
而使用的當然是剛剛觀察到的 pam_access.so 模組,他可以接受一個名為 accessfile 的參數,來提供他判斷的條件,這裡我先命名為 /etc/security/access-local.conf
其他參數大家可以參考 pam_access(8) 的 man page。

pam_access.so accessfile

接下來我們來看看這個 accessfile 裡面到底長怎樣,一樣大家可以參考 access.conf(5) 的 man page,先列出我自己的 config:

+ : davy : 192.168.3.0/24
+ : davy : 10.8.0.0/24
- : ALL : ALL

相對於 PAM 的設定,access.conf 的格式更加地簡單了:

permission : users/groups : origins

其中 permission 只有兩種值: +- 分別對應到 granteddenied,表示符合條件的狀況會不會通過測驗;
user/groups 則是指定套用規則的使用者或群組,可以用 ALL 表示全部使用者皆套用;
origins 指定的是來源,可以是 TTY 名稱、IP address、domain name 等,其中有兩個特別的值是 ALLLOCAL,分別表示全部適用以及套用到本機服務或本機 TTY。

設定檔前兩條規則,帳號都限定在我個人的帳號(davy);
而第一條是只有我才連得上的 VPN subnet、第二條是 VM 網路的 subnet。
最後還要加上一條 deny 全部其他的規則,到這裡我們已經大功告成了。

測試

只要我從符合上述條件的網路登入指定帳號,就可以不需要密碼就能夠登入了!

以下是從 Windows 用 pietty 登入的測試結果,可以看到我輸入完帳號之後就進入系統了:

最後我在這裡還是要在提醒大家一次:跳過密碼就可以登入是非常危險的,除非你的環境十分值得信賴,不然千萬不要忽略驗證!