自架服務 Linux Docker 容器 Podman Quadlet systemd

docker-compose 撐起的第二個服務框架跟 systemd 搶生命週期:Podman Quadlet 把 .container 檔直接接進 systemd unit

Docker Compose 的 restart policy、日誌格式、開機順序全部繞開 systemd,Docker 29 把最低 API 拉到 1.44 之後這個裂縫更深。Podman Quadlet 把每個容器寫成 .container 檔,systemd 直接生出對應的 service unit,日誌走 journalctl、依賴走 After=、自動更新走 podman auto-update.timer。本文拆解 Quadlet 檔案格式、跟 compose 的實務差異,以及用 Podlet 從既有 compose 搬遷的做法。

Docker Compose 這幾年慢慢長成了一個獨立的服務框架。它有自己的 restart policy、自己的網路模型、自己的健康檢查、自己的日誌驅動——這些在 Linux 上本來都已經有一套成熟解答,只是被 Docker daemon 全部繞開重做一次。Docker 29 把最低 API 抬到 1.44 讓 Watchtower 之類的工具集體失聯之後,這個「容器世界跟 Linux 服務世界不共用同一套基礎設施」的裂縫變得特別明顯。Podman Quadlet 走的是相反方向:把容器塞回 systemd 的依賴圖,讓 .container 檔跟 .service 檔一起被 systemd 認得,日誌走 journalctl,開機順序走 After=,自動更新走 podman auto-update.timer

Quadlet 在 Podman 4.4 就併進主線,到 Podman 5 之後幾乎所有 compose 常見場景都能對應寫出來。它不是 compose 的替代品,而是把「宣告一組容器」這件事重新掛到 Linux 本來就有的服務管理器上——結果是配置檔還是宣告式的,但底層跑起來的是原生 systemd unit,沒有第二個 daemon 在後面盯著容器有沒有活著。

Docker Compose 的裂縫從哪裡開始

Compose 檔案裡寫 restart: unless-stopped 看起來很直覺,但這條規則是 Docker daemon 在跑,不是 systemd。整臺機器重開之後容器起不起得來取決於 Docker daemon 有沒有正常啟動、docker socket 有沒有被別的服務搶先,以及 daemon 本身有沒有及時完成 recovery。這條路徑上任何一環卡住,容器就跟著不見。

日誌走 json-file 或 local driver 是另一個問題。整套 Linux 生態的日誌工具鏈——journalctl、logrotate、systemd-journal-gatewayd、遠端 forward——全部繞不進去,非得靠 Docker 自家的 log driver 或者外掛一支 Fluent Bit 之類的收集器。生產環境要把容器日誌塞回集中式日誌平臺,等於要多養一組管線。

還有依賴管理。Compose 的 depends_on 只能保證啟動順序,沒辦法表達「等 Postgres 的 systemd unit 完全 ready 之後才啟動這個容器」。systemd 有 Requires=After=Wants= 這一整套依賴語意,Compose 全部沒有——它自己就是一個孤島,跟宿主機的其他服務誰也不認識誰。

Quadlet 是 systemd generator,不是新工具

理解 Quadlet 最關鍵的一點:它本身不是一支常駐程式,也不是新的容器 runtime。它是一支 systemd generator,被放在 /usr/lib/systemd/system-generators/podman-system-generator。systemd 每次 daemon-reload 或開機時會執行這支 generator,讀取 /etc/containers/systemd/~/.config/containers/systemd/ 下的 .container.pod.network.volume.kube.build.image.artifact 檔,即時展開成標準的 .service unit 檔。

寫出來的 unit 檔完全就是 systemd 眼中的普通服務,systemctl startsystemctl statussystemctl restartjournalctl -u 這一整套指令直接可用。沒有第二個管理平面,也不需要 docker compose 那種需要記住工作目錄跟專案名稱的心智模型。

一個最基本的 .container 檔長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# /etc/containers/systemd/caddy.container
[Unit]
Description=Caddy reverse proxy
After=network-online.target
Wants=network-online.target

[Container]
Image=docker.io/library/caddy:2.9-alpine
ContainerName=caddy
PublishPort=80:80
PublishPort=443:443
Volume=caddy-data.volume:/data
Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z
AutoUpdate=registry

[Service]
Restart=always
TimeoutStartSec=900

[Install]
WantedBy=multi-user.target

存檔後 systemctl daemon-reload && systemctl start caddy.service 就上線。[Unit][Service][Install] 是標準 systemd 段落,[Container] 是 Quadlet 專屬——它會在展開時被翻成一段 ExecStart=/usr/bin/podman run ...

自動更新不用再靠 Watchtower

AutoUpdate=registry 這一行是 Quadlet 跟 compose 差距最直接的地方。啟用之後,systemd 有一支預先安裝的 podman-auto-update.timer——預設每天跑一次 podman auto-update,發現 registry 上的 image digest 變了就 pull 新版、重啟對應的 service。整條路徑上沒有任何第三方元件,Podman 5 之後這個 timer 還會做健康檢查失敗自動 rollback。

這件事對長期困擾 self-host 圈的「容器版本追不動」有直接的效益。Watchtower 這兩年沒動、Dockge 拉不動新版容器的討論之所以會浮出來,本質上是因為容器自動更新這件事一直被外掛在容器 runtime 之外。Quadlet 把它做在 Podman 自己的工具鏈裡,加上一個 systemd timer 就搞定。

自動更新不是全部場景都適合。資料庫、有狀態服務、需要嚴格版本鎖定的核心元件不應該打開 AutoUpdate=registry。實務做法是把邊緣服務(反向代理、CDN、輔助工具)設為自動更新,核心服務走人工手動 podman pull + systemctl restart 的模式。

多容器組合:.pod、.network、.volume

Compose 檔案最方便的地方是把多個容器、共享網路、共享 volume 寫在同一份 YAML 裡。Quadlet 把這件事拆成四種檔案,分開描述,但透過 unit 相互引用組合起來。

一個 WordPress + MariaDB 的組合大致長這樣:

1
2
3
# wordpress.network
[Network]
NetworkName=wordpress-net
1
2
3
# wordpress-db.volume
[Volume]
VolumeName=wordpress-db
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# mariadb.container
[Unit]
Description=MariaDB for WordPress

[Container]
Image=docker.io/library/mariadb:11
ContainerName=mariadb
Network=wordpress.network
Volume=wordpress-db.volume:/var/lib/mysql
EnvironmentFile=/etc/containers/wordpress.env
HealthCmd=healthcheck.sh --connect --innodb_initialized
HealthInterval=10s
HealthRetries=6

[Service]
Restart=always

[Install]
WantedBy=multi-user.target
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# wordpress.container
[Unit]
Description=WordPress
Requires=mariadb.service
After=mariadb.service

[Container]
Image=docker.io/library/wordpress:6.7-apache
ContainerName=wordpress
Network=wordpress.network
PublishPort=8080:80
EnvironmentFile=/etc/containers/wordpress.env

[Service]
Restart=always

[Install]
WantedBy=multi-user.target

檔名裡的 .network.volume.container 都會被 generator 認得,展開時自動處理相互依賴。Requires=mariadb.service 用的是 systemd 原生依賴語法——這比 compose 的 depends_on 表達力強得多,還能寫成 BindsTo=PartOf= 等更精細的關係。

值得注意的是共享 volume 的路徑寫法:Volume=wordpress-db.volume:/var/lib/mysql 會被解成一個由 Podman 管理的 named volume。如果要用宿主機路徑,寫 Volume=/opt/wordpress/db:/var/lib/mysql:Z——注意 SELinux 系統上一定要加 :Z:z,這是 Podman 相較 Docker 更嚴格的地方。

.kube 檔案讓 Quadlet 直接吃 Kubernetes YAML

Quadlet 額外提供一種 .kube 檔,指向一份 Kubernetes 的 Pod/Deployment YAML,Podman 會用 podman kube play 執行。這個路徑對於已經在寫 Kubernetes manifest 的團隊很有用——同一份 YAML 可以在 Kubernetes 上跑,也可以在單機 Podman 上跑,開發環境跟生產環境用同一套宣告。

1
2
3
4
5
6
7
8
9
10
# nginx-app.kube
[Unit]
Description=nginx Kubernetes app

[Kube]
Yaml=/etc/containers/nginx-app.yaml
AutoUpdate=registry

[Install]
WantedBy=multi-user.target

這條路徑對於已經有 Kubernetes 資產的團隊,是把「小型部署放單機、大型部署放叢集」的一個乾淨切法。單機不需要拉整套 K3s 或 Minikube,直接用 Podman 就能執行同樣的 YAML。

Podlet:把既有 compose 檔翻成 Quadlet

從 Docker Compose 搬到 Quadlet 這件事不用手動一行行改。Podman 官方維護的 podlet 是一支 Rust 寫的 CLI,可以直接吃 compose.yaml 產出對應的 .container.volume.network 檔:

1
podlet compose --absolute-host-paths /opt/app compose.yaml

--absolute-host-paths 這個參數是實務上一定要加的——compose 檔案常常寫 ./config:/etc/config 這種相對路徑,但 Quadlet generator 執行時的工作目錄不會跟 compose 檔一樣,所以相對路徑會解析失敗。這個坑很多人第一次搬遷都會踩到。

Podlet 沒辦法翻譯的部分包括 healthcheck 的複雜條件、build stage、compose 的 secret 機制、deploy.replicas、IPAM 設定、${VAR} 環境變數插補——這些需要人工對照 Quadlet 文件補回去。多容器共享網路的 .network 檔通常也需要人工調整命名跟連線方式。

實務建議是先跑一次 podlet compose 得到雛型,把生成的檔案放進 /etc/containers/systemd/ 之後 systemctl daemon-reload,用 systemctl list-unit-files | grep container 確認 unit 有被展開,再逐一啟動測試。第一次的容錯率不會太高,但一旦架構跑起來,後續改動比改 compose 檔更接近改 systemd unit——對熟悉 Linux 服務管理的維運者來說負擔更輕。

哪些場景還是 Compose 比較順

Quadlet 不適合所有情境。開發環境需要頻繁 docker compose up/down 快速拉起整組服務、跟 IDE 深度整合、需要跨 macOS/Windows/Linux 一致行為的場景,Compose 加 Docker Desktop 或 Podman Desktop 還是比較舒服。Quadlet 是為單臺 Linux 生產伺服器上長期跑服務設計的,重心在整合而不是快速迭代。

多節點編排也不是 Quadlet 的目標。有橫向擴展、跨機器排程需求時該直接上 Kubernetes 或 Nomad,Quadlet 提供的是「單機、宣告式、跟 systemd 深度整合」這個垂直切面。

需要動態產生容器(例如 CI runner 每次 job 拉一個新容器、或者 GitLab CI 的 Docker executor)也不適合,Quadlet 的檔案是靜態的、需要 daemon-reload 才會生效。這種場景直接呼叫 podman run 或者用 Podman 的 REST API 更合理。

為什麼要把容器交還 systemd

自架服務跑久了會發現:整臺機器上的服務越來越多,一部分是原生 systemd 管的 nginx、postfix、cron,一部分是 Docker Compose 管的容器,兩邊管理方式不同、日誌位置不同、開機順序需要各自檢查。當機器規模到幾十臺、幾百個服務的時候,這種分裂會直接影響維運品質。

Quadlet 的價值不在於它有什麼 compose 沒有的功能,而在於它把「容器」這件事收回到 Linux 服務框架該有的位置。同一套 systemctl、同一份 journalctl 日誌、同一個 systemd-analyze critical-chain 分析依賴——維運者需要學的東西少一層,除錯的路徑短一段。對於長期經營自架服務的團隊,這個「回歸基礎設施」的方向比追新工具實在得多。

想在臺灣本地跑 Podman Quadlet 部署自架服務、或需要一臺穩定的 Linux VPS 練手 systemd + 容器整合,NCSE Network 提供搭載 Intel Xeon Gold CPU 與 NVMe SSD 的 VPS 主機,是方電訊機房直連,適合長期跑生產級容器服務。前往 ncse.tw 了解方案細節。

需要穩定的雲端主機?

NCSE Network 提供企業級 VPS,7 天免費試用,臺灣是方電訊機房,99% SLA 保證。

查看 VPS 方案 →