返回文章列表

Podman 執行 Docker Compose:多容器部署的無 Daemon 解決方案

深入探討如何使用 Podman 執行 Docker Compose 應用程式,從基礎概念到進階實作,涵蓋多階段建置、服務名稱自動解析、環境變數注入、DNS 服務發現機制,以及 Rootless 容器部署策略,協助您打造安全高效的多容器環境

容器化技術 DevOps 雲端運算

前言

在容器化技術的發展歷程中,Docker Compose 一直是開發者管理多容器應用程式的首選工具。透過簡潔的 YAML 配置檔,我們能夠定義複雜的應用程式架構,包含多個服務之間的依賴關係、網路配置與資料持久化策略。然而,隨著容器技術的演進與安全性需求的提升,Podman 作為一個無 Daemon 架構的容器引擎,逐漸成為企業與開發者關注的焦點。

Podman 的設計理念與 Docker 有著本質上的差異。Docker 採用 Client-Server 架構,所有容器操作都需要透過一個以 root 權限執行的 Daemon 程序來處理,這種集中式的架構雖然提供了統一的管理介面,但也帶來了潛在的安全風險。一旦 Docker Daemon 遭到入侵,攻擊者可能取得整個系統的控制權。相對的,Podman 採用 fork-exec 模型,每個容器都是獨立的子程序,不需要常駐的 Daemon 程序,這種設計大幅降低了攻擊面,同時也支援以非 root 使用者執行容器,實現真正的 Rootless 容器部署。

對於已經習慣 Docker Compose 工作流程的開發者來說,一個關鍵的問題是:能否在 Podman 環境中繼續使用既有的 Docker Compose 配置檔?答案是肯定的。Podman 提供了良好的 Docker 相容性,不僅支援 Docker 映像檔格式與 Dockerfile 語法,更能夠直接執行 Docker Compose 定義的多容器應用。這種相容性讓開發者能夠平滑地從 Docker 遷移到 Podman,無需大規模修改既有的配置檔與部署腳本。

本文將深入探討如何使用 Podman 執行 Docker Compose 應用程式,從基礎的安裝配置到進階的服務發現機制,涵蓋完整的實作細節。我們會透過一個實際的 Go REST API 範例,搭配 Redis 資料儲存,示範多階段建置流程、容器網路配置與服務名稱解析等關鍵技術。同時,我們也會探討 Podman 特有的 DNS 服務發現機制,剖析 dnsmasq 如何實現服務名稱的自動解析,以及環境變數如何在容器間傳遞配置資訊。最後,我們將介紹 podman-compose 工具,展示如何以 Rootless 方式管理多容器應用,進一步提升部署環境的安全性。

Podman 與 Docker Compose 的整合基礎

在開始實作之前,我們需要先理解 Podman 如何與 Docker Compose 整合運作。雖然 Podman 本身並非設計來取代 Docker Compose,但透過 Podman 對 Docker CLI 的相容性實作,以及對容器網路的完整支援,我們能夠在 Podman 環境中執行絕大多數的 Docker Compose 配置。

當我們在安裝了 Podman 的系統上執行 docker-compose 命令時,Podman 會透過一個模擬層來處理這些請求。這個模擬層會攔截 Docker CLI 的調用,並將其轉換為對應的 Podman 命令。在實際執行層面,Podman 會建立與 Docker Compose 定義相符的容器、網路與資料卷,確保應用程式能夠按照預期的方式運作。這種相容性設計讓開發者能夠在不修改既有配置檔的前提下,直接將 Docker Compose 應用遷移到 Podman 環境。

Podman 在處理多容器應用時,會自動建立一個專屬的網路環境。這個網路採用橋接模式,讓容器之間能夠相互通訊,同時也能夠與外部網路互動。更重要的是,Podman 整合了 DNS 服務發現機制,透過 dnsmasq 程序提供服務名稱解析功能。這意味著容器可以直接使用服務名稱來存取其他容器,而不需要知道實際的 IP 位址,大幅簡化了應用程式的配置工作。

在實務應用中,Podman 與 Docker Compose 的整合提供了幾個顯著的優勢。首先是安全性的提升,透過 Rootless 容器的支援,開發者可以在不需要 root 權限的情況下執行容器,降低了系統遭受攻擊的風險。其次是資源隔離的改善,由於 Podman 不使用集中式 Daemon,每個容器都是獨立的程序,相互之間不會因為 Daemon 故障而受到影響。最後是部署的靈活性,Podman 支援在沒有安裝 Docker 的環境中執行容器,讓開發者能夠在更多樣化的系統上部署應用。

要開始使用 Podman 執行 Docker Compose 應用,我們需要確保系統已正確安裝相關套件。在 Red Hat 系列的作業系統中,可以透過套件管理工具安裝 Podman 與 Docker Compose:

# 安裝 Podman 容器引擎
sudo dnf install -y podman

# 安裝 Docker Compose 工具
sudo dnf install -y docker-compose

# 驗證 Podman 版本
podman --version

# 驗證 Docker Compose 版本
docker-compose --version

安裝完成後,Podman 會自動設定必要的網路外掛程式與儲存驅動程式。我們可以透過檢視 Podman 的網路列表來確認環境已正確配置:

# 列出所有 Podman 網路
podman network ls

# 檢視預設網路的詳細資訊
podman network inspect podman

預設情況下,Podman 會建立一個名為 podman 的橋接網路,這個網路使用 CNI 外掛程式來管理網路配置,並整合了 dnsname 外掛程式來提供 DNS 解析功能。當我們透過 Docker Compose 部署多容器應用時,Podman 會為每個專案建立獨立的網路,確保不同專案之間的容器網路隔離。

實作案例:Go REST API 與 Redis 的多容器部署

為了具體展示 Podman 如何執行 Docker Compose 應用,我們將透過一個實際的範例來進行完整的示範。這個範例包含一個使用 Go 語言開發的 REST API 服務,搭配 Redis 作為資料儲存後端。整個應用透過 Docker Compose 配置檔定義服務之間的關係,並使用多階段建置來優化映像檔大小。

首先,讓我們檢視這個範例的專案結構。專案目錄中包含幾個關鍵檔案:主要的應用程式程式碼 main.go、Go 模組定義檔 go.mod、容器啟動腳本 entrypoint.sh、容器映像檔建置指令 Dockerfile,以及定義整個應用架構的 docker-compose.yaml。這些檔案共同構成了一個完整的多容器應用程式。

Docker Compose 配置檔定義了兩個主要服務:web 服務與 redis 服務。web 服務負責執行 Go REST API 應用程式,而 redis 服務則提供資料儲存功能。以下是 docker-compose.yaml 的內容:

version: '3.8'

services:
  # Web API 服務定義
  web:
    # 使用當前目錄的 Dockerfile 建置映像檔
    build: .
    # 將主機的 8080 埠對應到容器的 8080 埠
    ports:
      - "8080:8080"
    # 注入環境變數,指定 Redis 服務的主機名稱
    environment:
      - REDIS_HOST=redis
    # 設定服務依賴關係,確保 Redis 先啟動
    depends_on:
      - redis
    # 加入自訂網路
    networks:
      - app-network

  # Redis 資料庫服務定義
  redis:
    # 使用官方 Redis 映像檔
    image: docker.io/library/redis:latest
    # 加入自訂網路
    networks:
      - app-network

# 定義網路配置
networks:
  app-network:
    # 使用橋接模式網路
    driver: bridge

這個配置檔清楚定義了服務之間的關係。web 服務透過 depends_on 指令宣告依賴 redis 服務,確保 Redis 容器會在 Web API 容器之前啟動。同時,透過 environment 區段注入的 REDIS_HOST 環境變數,讓 Go 應用程式知道如何連線到 Redis 服務。這裡使用的是服務名稱 redis,而非具體的 IP 位址,展示了 Docker Compose 服務發現機制的威力。

接下來,讓我們深入檢視 Dockerfile 的內容,理解多階段建置的實作細節:

# 第一階段:建置階段
# 使用官方 Golang 映像檔作為建置環境
FROM docker.io/library/golang:1.21 AS builder

# 建立專案目錄
RUN mkdir -p /go/src/golang-redis

# 複製 Go 模組定義檔與主程式碼到容器
COPY go.mod main.go /go/src/golang-redis/

# 設定工作目錄為專案根目錄
WORKDIR /go/src/golang-redis

# 下載專案所需的所有相依套件
# -d 參數表示只下載不安裝
# -v 參數顯示詳細的下載過程
RUN go get -d -v ./...

# 編譯 Go 應用程式
# -v 參數顯示詳細的編譯過程
RUN go build -v -o golang-redis

# 第二階段:執行階段
# 使用 Red Hat Universal Base Image Minimal 作為執行環境
# 這個映像檔體積小且經過安全強化,適合生產環境使用
FROM registry.access.redhat.com/ubi8/ubi-minimal:latest AS runtime

# 從建置階段複製編譯完成的二進位檔案到執行環境
# 只複製必要的可執行檔,不包含建置工具與原始碼
COPY --from=builder /go/src/golang-redis/golang-redis /usr/local/bin/

# 複製容器啟動腳本到根目錄
COPY entrypoint.sh /

# 設定啟動腳本的執行權限
RUN chmod +x /entrypoint.sh

# 宣告容器對外提供服務的埠號
EXPOSE 8080

# 設定容器啟動時執行的命令
ENTRYPOINT ["/entrypoint.sh"]

這個 Dockerfile 採用多階段建置策略,是容器映像檔優化的最佳實踐。在第一階段 builder 中,我們使用完整的 Golang 映像檔來編譯應用程式。這個映像檔包含完整的 Go 編譯工具鏈與相依套件管理工具,體積較大但提供了完整的建置環境。編譯完成後,產生的二進位執行檔會被複製到第二階段的執行環境中。

第二階段使用 Red Hat UBI Minimal 映像檔作為執行環境。UBI Minimal 是一個經過精簡與安全強化的基礎映像檔,只包含執行應用程式所需的最小系統函式庫,不包含編譯工具或其他非必要套件。透過這種方式,最終的容器映像檔體積大幅縮小,從數百 MB 降低到數十 MB,同時也減少了潛在的安全漏洞數量。這種多階段建置策略在生產環境中特別重要,能夠有效降低儲存與網路傳輸成本,同時提升安全性。

容器啟動腳本 entrypoint.sh 負責初始化執行環境並啟動應用程式:

#!/bin/bash
# 容器啟動腳本

# 顯示啟動訊息
echo "啟動 Go REST API 服務..."

# 顯示環境變數配置(用於除錯)
echo "Redis 主機位址: ${REDIS_HOST}"

# 執行 Go 應用程式
# 程式會監聽 8080 埠並等待 HTTP 請求
exec /usr/local/bin/golang-redis

這個腳本的設計相當簡潔,主要任務是顯示啟動資訊並執行 Go 應用程式。透過 exec 命令,我們將啟動腳本的程序替換為應用程式程序,確保應用程式成為容器的主程序,這樣容器的生命週期就與應用程式完全同步。

現在,讓我們實際部署這個多容器應用。首先切換到專案目錄,然後使用 docker-compose 命令啟動應用:

# 切換到專案目錄
cd golang-redis

# 使用 Docker Compose 建置並啟動所有服務
# --build 參數強制重新建置映像檔
# -d 參數讓容器在背景執行
docker-compose up --build -d

執行這個命令後,Podman 會開始處理 Docker Compose 配置。首先,它會讀取 docker-compose.yaml 檔案,解析服務定義與依賴關係。接著,Podman 會建置 web 服務的容器映像檔,執行 Dockerfile 中定義的所有步驟。在建置過程中,我們可以看到詳細的輸出資訊:

Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
Building web
STEP 1/10: FROM docker.io/library/golang:1.21 AS builder
STEP 2/10: RUN mkdir -p /go/src/golang-redis
STEP 3/10: COPY go.mod main.go /go/src/golang-redis/
STEP 4/10: WORKDIR /go/src/golang-redis
STEP 5/10: RUN go get -d -v ./...
STEP 6/10: RUN go build -v -o golang-redis
STEP 7/10: FROM registry.access.redhat.com/ubi8/ubi-minimal:latest AS runtime
STEP 8/10: COPY --from=builder /go/src/golang-redis/golang-redis /usr/local/bin/
STEP 9/10: COPY entrypoint.sh /
STEP 10/10: ENTRYPOINT ["/entrypoint.sh"]
Successfully tagged localhost/golang-redis_web:latest
6b330224010ed611baba11fc2d66b9e4cfc991312f5166b47b5fcd07357c6325

Creating network golang-redis_default
Creating golang-redis_redis_1 ... done
Creating golang-redis_web_1   ... done

從輸出訊息中,我們可以觀察到幾個關鍵步驟。首先,Podman 提示正在模擬 Docker CLI,這說明 Podman 的相容性層正在運作。接著,Podman 按照 Dockerfile 的指令逐步建置映像檔,最終產生一個標記為 localhost/golang-redis_web:latest 的映像檔。建置完成後,Podman 建立一個專案專屬的網路 golang-redis_default,然後依序啟動 redis 與 web 兩個容器。

容器的命名遵循特定的模式:<專案名稱>_<服務名稱>_<實例編號>。專案名稱預設為目錄名稱,在這個例子中是 golang-redis。服務名稱則對應 docker-compose.yaml 中定義的服務名稱,分別是 redis 與 web。實例編號從 1 開始,當服務需要多個副本時,編號會遞增。這種命名規則讓我們能夠清楚識別容器所屬的專案與服務。

我們可以使用 Podman 的 ps 命令檢視正在執行的容器:

# 列出所有執行中的容器
podman ps

輸出結果會顯示兩個容器的詳細資訊:

CONTAINER ID  IMAGE                              COMMAND          CREATED         STATUS         PORTS                   NAMES
4a5421c9e7cd  docker.io/library/redis:latest    redis-server     30 seconds ago  Up 30 seconds                          golang-redis_redis_1
8a465d4724ab  localhost/golang-redis_web:latest                  30 seconds ago  Up 30 seconds  0.0.0.0:8080->8080/tcp golang-redis_web_1

從這個輸出中,我們可以看到 redis 容器使用官方的 Redis 映像檔,執行 redis-server 命令。web 容器則使用我們剛建置的映像檔,並將主機的 8080 埠對應到容器內的 8080 埠,讓外部可以存取 REST API 服務。兩個容器都顯示為 Up 狀態,表示已成功啟動並正在執行中。

Podman 的 DNS 服務發現機制深度剖析

在多容器應用中,服務之間的相互通訊是基礎需求。傳統的做法是使用固定的 IP 位址或主機名稱來建立連線,但這種方式缺乏彈性,當容器重新啟動或擴展時,IP 位址可能改變,導致連線失敗。Docker Compose 透過內建的服務發現機制解決了這個問題,容器可以直接使用服務名稱來存取其他容器,而不需要知道實際的 IP 位址。Podman 在執行 Docker Compose 應用時,也實作了相同的服務發現功能,但其底層實作機制值得我們深入探討。

當 Podman 透過 Docker Compose 建立多容器應用時,它會自動建立一個專案專屬的網路。這個網路的命名遵循 <專案名稱>_default 的模式。我們可以透過 Podman 的網路列表命令來檢視:

# 列出所有 Podman 網路,並篩選出專案相關的網路
podman network ls | grep golang-redis

輸出結果會顯示專案網路的詳細資訊:

49d5a3c3679c  golang-redis_default  0.4.0  bridge,portmap,firewall,tuning,dnsname

這個輸出揭示了幾個重要資訊。首先是網路 ID,這是一個唯一識別碼,用於在系統內部追蹤網路資源。接著是網路名稱 golang-redis_default,對應我們的專案名稱。版本號 0.4.0 表示使用的 CNI 外掛程式版本。最關鍵的是外掛程式列表,包含了 bridge、portmap、firewall、tuning 與 dnsname 五個外掛程式。

其中,dnsname 外掛程式是實現服務發現的核心元件。這個外掛程式整合了 dnsmasq 這個輕量級的 DNS 伺服器,負責將服務名稱解析為對應的容器 IP 位址。當容器啟動時,dnsname 外掛程式會自動更新 dnsmasq 的配置,將容器的 IP 位址與服務名稱建立對應關係。這樣,當應用程式查詢服務名稱時,dnsmasq 就能回傳正確的 IP 位址。

我們可以透過檢查系統程序來確認 dnsmasq 的執行狀態:

# 列出所有程序,並篩選出與專案相關的 dnsmasq 程序
ps aux | grep dnsmasq | grep golang-redis

輸出結果會顯示 dnsmasq 程序的詳細資訊:

root  2749495  0.0  0.0  26388  2416 ?  S  01:33  0:00 /usr/sbin/dnsmasq -u root --conf-file=/run/containers/cni/dnsname/golang-redis_default/dnsmasq.conf

從這個輸出中,我們可以看到 dnsmasq 程序以 root 使用者身份執行,並載入專案專屬的配置檔。配置檔的路徑 /run/containers/cni/dnsname/golang-redis_default/dnsmasq.conf 揭示了 Podman 如何組織 DNS 配置資料。每個專案都有獨立的配置目錄,確保不同專案之間的 DNS 解析互不干擾。

讓我們深入檢視 dnsmasq 的配置目錄:

# 列出配置目錄的內容
ls -la /run/containers/cni/dnsname/golang-redis_default/

這個目錄包含幾個關鍵檔案:

total 12
drwxr-xr-x. 2 root root  80 Nov 24 01:33 .
drwxr-xr-x. 3 root root  60 Nov 24 01:33 ..
-rw-r--r--. 1 root root 124 Nov 24 01:33 addnhosts
-rw-r--r--. 1 root root 256 Nov 24 01:33 dnsmasq.conf
-rw-r--r--. 1 root root   0 Nov 24 01:33 pidfile

其中,addnhosts 檔案記錄了服務名稱與 IP 位址的對應關係。讓我們檢視這個檔案的內容:

# 檢視 DNS 主機對應檔案
cat /run/containers/cni/dnsname/golang-redis_default/addnhosts

檔案內容展示了服務名稱的完整對應:

10.89.3.240 golang-redis_redis_1 4a5421c9e7cd redis
10.89.3.241 golang-redis_web_1 8a465d4724ab web

這個對應表格的結構相當清楚。每一行代表一個容器,開頭是容器的 IP 位址,接著是完整的容器名稱、容器 ID 的前綴,以及服務名稱。這意味著我們可以使用多種方式來存取容器:完整的容器名稱 golang-redis_redis_1、容器 ID 的前綴 4a5421c9e7cd,或是最簡潔的服務名稱 redis。在實務應用中,我們通常使用服務名稱,因為這最符合 Docker Compose 的設計理念,也最容易維護。

以下的架構圖展示了 Podman DNS 服務發現的完整流程:

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

package "golang-redis 專案" {
  package "Web 容器" {
    [Go 應用程式] as APP
    [DNS 解析器] as RESOLVER
  }
  
  package "Redis 容器" {
    [Redis 服務] as REDIS
  }
}

package "Podman 網路層" {
  [dnsname 外掛程式] as DNSNAME
  [dnsmasq 程序] as DNSMASQ
  database "addnhosts\n對應表" as HOSTS
}

APP --> RESOLVER : 查詢 redis 服務位址
RESOLVER --> DNSMASQ : DNS 查詢請求
DNSMASQ --> HOSTS : 讀取服務對應
HOSTS --> DNSMASQ : 回傳 10.89.3.240
DNSMASQ --> RESOLVER : DNS 查詢回應
RESOLVER --> APP : 提供 IP 位址
APP --> REDIS : 建立 TCP 連線

note right of DNSNAME
  Podman 啟動容器時
  自動更新 DNS 對應表
end note

@enduml

這個架構圖清楚呈現了服務發現的完整流程。當 Go 應用程式需要連線到 Redis 服務時,它會向容器內的 DNS 解析器查詢 redis 這個主機名稱。DNS 解析器將查詢請求轉發給 dnsmasq 程序,dnsmasq 從 addnhosts 對應表中找到 redis 服務對應的 IP 位址,並回傳給應用程式。應用程式取得 IP 位址後,就能建立實際的 TCP 連線來存取 Redis 服務。

這種服務發現機制帶來了顯著的彈性。當我們需要擴展服務,啟動多個相同服務的容器副本時,dnsmasq 會自動實作簡單的負載平衡。假設我們將 redis 服務擴展為三個副本,addnhosts 檔案會包含三筆 redis 服務的記錄,分別指向不同的 IP 位址:

10.89.3.240 golang-redis_redis_1 4a5421c9e7cd redis
10.89.3.242 golang-redis_redis_2 7b9f8e3a1d9e redis
10.89.3.243 golang-redis_redis_3 2c4d6f8a3b1e redis

當應用程式查詢 redis 服務時,dnsmasq 會採用輪詢的方式回傳這三個 IP 位址。這種稱為 Round-Robin DNS 的機制,雖然簡單但在許多場景下已經足夠。每次查詢都會回傳不同順序的 IP 位址列表,應用程式通常會選擇列表中的第一個位址來建立連線,達到基本的負載分散效果。

環境變數與配置管理

在多容器應用中,配置資訊的傳遞是另一個重要議題。將配置參數硬編碼在應用程式中會降低彈性,當需要修改配置時,必須重新編譯與部署應用程式。更好的做法是透過環境變數來注入配置資訊,讓應用程式能夠在執行時期讀取配置,無需修改程式碼。

在我們的範例中,docker-compose.yaml 檔案的 web 服務定義包含了環境變數的配置:

environment:
  - REDIS_HOST=redis

這個配置會在容器啟動時,將 REDIS_HOST 環境變數設定為 redis。Go 應用程式在初始化時會讀取這個環境變數,用來建構 Redis 連線字串。這種設計讓應用程式與特定的服務實例解耦,即使 Redis 容器的 IP 位址改變,或是我們更換不同的 Redis 實作,只要維持相同的服務名稱,應用程式就能正常運作。

我們可以透過 docker-compose exec 命令進入容器,檢視環境變數是否正確注入:

# 在 web 容器中執行 env 命令,列出所有環境變數
docker-compose exec web env

命令執行後,會顯示容器內的完整環境變數列表:

Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TERM=xterm
container=oci
REDIS_HOST=redis
HOME=/root
HOSTNAME=8a465d4724ab

從這個輸出中,我們可以確認 REDIS_HOST 環境變數已成功注入,其值為 redis,對應 Redis 服務的名稱。除此之外,容器還包含一些系統預設的環境變數,如 PATH 定義了可執行檔的搜尋路徑,HOSTNAME 則是容器的主機名稱,預設為容器 ID 的前綴。

環境變數的應用不僅限於服務名稱,我們可以透過這種方式注入各種配置參數,如資料庫連線埠號、API 金鑰、日誌等級等。以下是一個更完整的環境變數配置範例:

environment:
  # Redis 服務主機名稱
  - REDIS_HOST=redis
  # Redis 連線埠號
  - REDIS_PORT=6379
  # Redis 資料庫索引
  - REDIS_DB=0
  # 應用程式日誌等級
  - LOG_LEVEL=info
  # API 服務埠號
  - API_PORT=8080

Go 應用程式可以透過標準函式庫讀取這些環境變數:

package main

import (
    "fmt"
    "os"
    "github.com/go-redis/redis/v8"
)

func main() {
    // 從環境變數讀取 Redis 配置
    redisHost := os.Getenv("REDIS_HOST")
    redisPort := os.Getenv("REDIS_PORT")
    redisDB := os.Getenv("REDIS_DB")
    
    // 建構 Redis 連線字串
    // 格式為 host:port
    redisAddr := fmt.Sprintf("%s:%s", redisHost, redisPort)
    
    // 初始化 Redis 客戶端
    client := redis.NewClient(&redis.Options{
        Addr: redisAddr,
        DB:   0,  // 使用預設資料庫
    })
    
    // 測試連線
    pong, err := client.Ping(ctx).Result()
    if err != nil {
        fmt.Printf("Redis 連線失敗: %v\n", err)
        os.Exit(1)
    }
    
    fmt.Printf("成功連線到 Redis: %s\n", pong)
}

這段程式碼展示了如何從環境變數讀取配置資訊,並用來初始化 Redis 客戶端。透過 os.Getenv 函式,我們可以讀取指定名稱的環境變數。如果環境變數不存在,函式會回傳空字串,因此在生產環境中,建議加入適當的錯誤處理與預設值設定。

環境變數的管理也可以透過 .env 檔案來簡化。Docker Compose 支援從 .env 檔案讀取環境變數,並注入到容器中。這種方式讓我們能夠將敏感資訊從 docker-compose.yaml 中分離,避免將密碼或 API 金鑰提交到版本控制系統中:

# .env 檔案內容
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_DB=0
LOG_LEVEL=info
API_PORT=8080

在 docker-compose.yaml 中,我們可以參照 .env 檔案中定義的變數:

environment:
  - REDIS_HOST=${REDIS_HOST}
  - REDIS_PORT=${REDIS_PORT}
  - REDIS_DB=${REDIS_DB}
  - LOG_LEVEL=${LOG_LEVEL}
  - API_PORT=${API_PORT}

這種配置方式提供了更好的彈性,讓我們能夠在不同環境中使用不同的 .env 檔案,實現開發、測試與生產環境的配置分離。

應用程式驗證與日誌分析

部署完成後,我們需要驗證應用程式是否正常運作。這個 Go REST API 提供了兩個 HTTP 端點:POST 方法用於新增使用者資料,GET 方法用於查詢使用者資料。我們可以使用 curl 命令來測試這些功能。

首先,使用 POST 方法新增幾筆使用者資料:

# 新增第一筆使用者資料
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"張小明", "email":"[email protected]", "id":"0001"}' \
  http://localhost:8080/user

# 新增第二筆使用者資料
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"李小華", "email":"[email protected]", "id":"0002"}' \
  http://localhost:8080/user

# 新增第三筆使用者資料
curl -X POST \
  -H "Content-Type: application/json" \
  -d '{"name":"王大同", "email":"[email protected]", "id":"0003"}' \
  http://localhost:8080/user

這些命令透過 HTTP POST 請求,將 JSON 格式的使用者資料傳送到 API 服務。每筆資料包含姓名、電子郵件與使用者 ID 三個欄位。API 服務接收到請求後,會將資料儲存到 Redis 資料庫中。

我們可以透過檢視容器日誌來確認資料是否成功儲存:

# 檢視 web 服務容器的日誌輸出
docker-compose logs web

日誌輸出會顯示應用程式處理請求的詳細資訊:

Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
golang-redis_web_1  | 2025/11/24 13:45:23 啟動 Go REST API 服務...
golang-redis_web_1  | 2025/11/24 13:45:23 Redis 主機位址: redis
golang-redis_web_1  | 2025/11/24 13:45:23 成功連線到 Redis
golang-redis_web_1  | 2025/11/24 13:45:23 HTTP 伺服器監聽於 :8080
golang-redis_web_1  | 2025/11/24 13:46:15 儲存使用者資料: {"name":"張小明","email":"[email protected]","id":"0001"}
golang-redis_web_1  | 2025/11/24 13:46:18 儲存使用者資料: {"name":"李小華","email":"[email protected]","id":"0002"}
golang-redis_web_1  | 2025/11/24 13:46:21 儲存使用者資料: {"name":"王大同","email":"[email protected]","id":"0003"}

從日誌中,我們可以清楚看到應用程式的啟動過程與資料儲存記錄。首先,應用程式讀取 REDIS_HOST 環境變數,取得 Redis 服務名稱。接著,透過 DNS 解析將服務名稱轉換為 IP 位址,並成功建立連線。最後,應用程式開始監聽 8080 埠,等待 HTTP 請求。當我們傳送 POST 請求時,每筆資料都被記錄在日誌中,確認儲存操作成功執行。

現在,讓我們使用 GET 方法來查詢剛才儲存的資料:

# 查詢 ID 為 0001 的使用者資料
curl -X GET \
  -H "Content-Type: application/json" \
  -d '{"id": "0001"}' \
  http://localhost:8080/user

如果一切正常,API 會回傳對應的使用者資料:

{
  "name": "張小明",
  "email": "[email protected]",
  "id": "0001"
}

這個回應確認了資料已成功從 Redis 讀取。我們可以繼續測試其他使用者 ID,驗證所有資料都能正確查詢。

除了檢視單一服務的日誌,我們也可以同時檢視所有服務的日誌輸出:

# 檢視所有服務的日誌,並持續追蹤新的日誌訊息
docker-compose logs -f

這個命令會顯示 web 與 redis 兩個服務的日誌,並使用不同的顏色區分。-f 參數讓命令持續執行,即時顯示新產生的日誌訊息,方便我們監控應用程式的執行狀態。

在除錯過程中,有時我們需要進入容器內部檢查檔案或執行指令。可以使用 docker-compose exec 命令:

# 在 web 容器中啟動互動式 shell
docker-compose exec web /bin/bash

# 進入容器後,可以執行各種除錯指令
# 例如檢視程序狀態
ps aux

# 檢查網路連線
netstat -tuln

# 測試 Redis 連線
redis-cli -h redis ping

透過這種方式,我們能夠深入容器內部,檢查應用程式的執行狀態與環境配置,快速定位問題所在。

環境清理與資源管理

完成測試後,我們需要適當清理部署的資源,避免佔用系統資源。Docker Compose 提供了簡潔的清理命令:

# 停止並移除所有容器
docker-compose down

這個命令會執行以下操作:首先停止所有執行中的容器,然後移除容器實例,最後清理專案專屬的網路。執行過程會顯示詳細的輸出:

Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
Stopping golang-redis_web_1   ... done
Stopping golang-redis_redis_1 ... done
Removing golang-redis_web_1   ... done
Removing golang-redis_redis_1 ... done
Removing network golang-redis_default

需要注意的是,預設情況下 docker-compose down 不會移除資料卷。如果我們在 docker-compose.yaml 中定義了持久化資料卷,用來儲存 Redis 的資料,這些資料卷會被保留。這是一個安全設計,避免誤刪重要資料。

如果我們確定要完全清理環境,包含移除所有資料卷,可以加上 -v 參數:

# 停止並移除所有容器,同時移除資料卷
docker-compose down -v

執行這個命令後,Redis 中儲存的所有資料都會被清除,下次啟動時會是全新的環境。

除了清理容器與網路,我們可能也需要清理建置的映像檔。Docker Compose 建置的映像檔會保留在本地,佔用磁碟空間。我們可以使用 Podman 的映像檔管理命令來清理:

# 列出所有本地映像檔
podman images

# 移除特定映像檔
podman rmi localhost/golang-redis_web:latest

# 清理所有未使用的映像檔、容器與網路
podman system prune -a

podman system prune 命令會清理所有未使用的資源,包含停止的容器、未使用的網路、懸空的映像檔等。-a 參數表示同時移除所有未被容器使用的映像檔,能夠大幅釋放磁碟空間。執行此命令前,系統會要求確認,避免誤刪重要資源。

Podman Compose:原生的 Rootless 解決方案

雖然 Podman 能夠透過相容性層執行 docker-compose 命令,但這並非最佳的長期解決方案。為了提供更原生的體驗,社群開發了 podman-compose 工具,這是一個專為 Podman 設計的 Compose 實作,完全使用 Python 編寫,不依賴 Docker 套件。

podman-compose 的核心優勢在於完整支援 Rootless 容器。傳統的 Docker 需要 root 權限來執行 Daemon,即使使用 Docker Compose 也無法避免這個限制。Podman 從設計之初就支援 Rootless 模式,讓一般使用者能夠在不需要 root 權限的情況下管理容器。podman-compose 延續了這個設計理念,讓開發者能夠以 Rootless 方式部署多容器應用,大幅提升了安全性。

在 Red Hat 系列的作業系統中,podman-compose 可以透過套件管理工具安裝:

# 使用 dnf 安裝 podman-compose
sudo dnf install -y podman-compose

# 驗證安裝版本
podman-compose --version

如果系統的套件庫中沒有提供 podman-compose,也可以透過 Python 的 pip 工具安裝:

# 使用 pip3 安裝 podman-compose
pip3 install --user podman-compose

# 確認安裝路徑已加入 PATH 環境變數
export PATH=$PATH:~/.local/bin

# 驗證安裝
podman-compose --version

安裝完成後,podman-compose 的使用方式與 docker-compose 高度相似,大多數命令都可以直接替換使用。以下是常用命令的對照:

# 建置並啟動服務(Docker Compose)
docker-compose up --build -d

# 建置並啟動服務(Podman Compose)
podman-compose up --build -d

# 檢視執行中的容器(Docker Compose)
docker-compose ps

# 檢視執行中的容器(Podman Compose)
podman-compose ps

# 檢視服務日誌(Docker Compose)
docker-compose logs -f web

# 檢視服務日誌(Podman Compose)
podman-compose logs -f web

# 停止並移除服務(Docker Compose)
docker-compose down

# 停止並移除服務(Podman Compose)
podman-compose down

podman-compose 支援的命令涵蓋了日常使用的絕大多數場景,包含 build 建置映像檔、up 啟動服務、down 停止服務、ps 列出容器、logs 檢視日誌、exec 執行命令、start 啟動容器、stop 停止容器、restart 重啟容器、pull 拉取映像檔、push 推送映像檔等。

讓我們使用 podman-compose 重新部署之前的範例應用:

# 切換到專案目錄
cd golang-redis

# 使用 podman-compose 以 Rootless 方式啟動服務
podman-compose up --build -d

執行這個命令時,podman-compose 會以當前使用者的身份建立容器,不需要 root 權限。容器執行在使用者的命名空間中,與系統的其他程序完全隔離。這種 Rootless 模式提供了額外的安全保障,即使容器被入侵,攻擊者也只能取得使用者權限,無法影響整個系統。

以下的架構圖展示了 Rootless 容器與傳統容器的權限差異:

@startuml
!define PLANTUML_FORMAT svg
!theme _none_

skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam roundcorner 5
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam defaultFontSize 16
skinparam minClassWidth 100

package "傳統 Docker 架構" {
  [使用者程序] as USER1
  [Docker Daemon\n(root 權限)] as DAEMON
  [容器程序\n(root 命名空間)] as CONTAINER1
  
  USER1 --> DAEMON : 透過 Socket 通訊
  DAEMON --> CONTAINER1 : 建立與管理
}

package "Podman Rootless 架構" {
  [使用者程序] as USER2
  [容器程序\n(使用者命名空間)] as CONTAINER2
  
  USER2 --> CONTAINER2 : 直接 fork-exec
}

note right of DAEMON
  需要 root 權限
  單點故障風險
  集中式管理
end note

note right of CONTAINER2
  使用者權限
  程序隔離
  無 Daemon 依賴
end note

@enduml

這個架構圖清楚呈現了兩種模式的差異。在傳統 Docker 架構中,使用者程序透過 Socket 與 Docker Daemon 通訊,Daemon 以 root 權限執行,並在 root 命名空間中建立容器。如果 Daemon 遭到攻擊,整個系統都可能受到影響。相對的,Podman Rootless 架構讓使用者程序直接 fork-exec 建立容器,容器執行在使用者的命名空間中,權限受到嚴格限制,大幅提升了安全性。

podman-compose 也支援進階的功能,如服務擴展。我們可以動態調整服務的副本數量:

# 將 web 服務擴展為 3 個副本
podman-compose up --scale web=3 -d

# 檢視擴展後的容器列表
podman-compose ps

執行擴展後,podman-compose 會建立額外的容器實例,並自動更新 DNS 對應表,讓服務發現機制能夠找到所有副本。這種動態擴展能力在負載測試或高流量場景中特別有用。

需要注意的是,podman-compose 目前仍在積極開發中,雖然已經支援大多數常用功能,但某些進階特性可能尚未完全實作。例如,某些特定的網路模式或資料卷驅動可能不受支援。在生產環境使用前,建議詳細測試所有需要的功能,確保符合專案需求。

社群正持續改善 podman-compose 的功能與穩定性。作為開源專案,歡迎開發者參與貢獻,無論是回報問題、提交修正或新增功能,都能幫助這個工具更加完善。隨著 Podman 生態系的成熟,podman-compose 有望成為多容器部署的主流選擇。

實務考量與最佳實踐

在實際應用 Podman 與 Docker Compose 進行多容器部署時,有幾個實務考量值得注意。首先是映像檔來源的選擇。雖然 Podman 完全相容 Docker 映像檔格式,但在拉取映像檔時,需要明確指定映像檔的完整路徑,包含 registry 網域名稱。例如,使用 docker.io/library/redis:latest 而非簡寫的 redis:latest,這樣可以避免映像檔來源的歧義。

第二個考量是網路效能。Podman 的網路實作基於 CNI 外掛程式,雖然功能完整,但在某些高效能場景下,可能需要調整網路參數來優化效能。例如,可以考慮使用 host 網路模式來減少網路虛擬化的開銷,或是調整 MTU 設定來適應不同的網路環境。

第三個考量是資料持久化策略。在生產環境中,我們通常需要確保資料在容器重啟後仍然保留。Docker Compose 支援定義具名資料卷,Podman 也完全支援這個功能。建議為關鍵資料定義專屬的資料卷,並定期備份資料卷的內容,避免資料遺失。

以下是一個包含資料卷定義的完整 docker-compose.yaml 範例:

version: '3.8'

services:
  web:
    build: .
    ports:
      - "8080:8080"
    environment:
      - REDIS_HOST=redis
    depends_on:
      - redis
    networks:
      - app-network
    # 設定容器重啟策略
    restart: unless-stopped
    # 設定資源限制
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M

  redis:
    image: docker.io/library/redis:latest
    networks:
      - app-network
    # 掛載資料卷以持久化資料
    volumes:
      - redis-data:/data
    # 設定容器重啟策略
    restart: unless-stopped
    # 設定資源限制
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M

networks:
  app-network:
    driver: bridge

# 定義具名資料卷
volumes:
  redis-data:
    driver: local

這個配置增加了幾個生產環境常用的設定。restart 策略設定為 unless-stopped,表示容器會在異常終止時自動重啟,但手動停止時不會重啟。deploy 區段定義了資源限制,防止單一容器耗盡系統資源。volumes 區段定義了具名資料卷,確保 Redis 的資料持久化儲存。

最後一個考量是安全性。除了使用 Rootless 容器之外,我們還應該採取其他安全措施,如定期更新映像檔以修補安全漏洞、使用最小權限原則配置容器、啟用 SELinux 或 AppArmor 等安全模組、定期掃描映像檔的漏洞等。這些措施結合起來,能夠大幅提升容器環境的整體安全性。

結論

透過本文的深入探討,我們完整掌握了使用 Podman 執行 Docker Compose 應用的技術細節。從基礎的環境設定到進階的服務發現機制,從多階段建置流程到 Rootless 容器部署,Podman 展現了作為 Docker 替代方案的強大實力。

Podman 的無 Daemon 架構帶來了顯著的安全優勢,透過程序隔離與 Rootless 模式,大幅降低了容器環境的攻擊面。與 Docker Compose 的良好相容性,讓開發者能夠平滑遷移既有的多容器應用,無需大規模修改配置檔。內建的 DNS 服務發現機制,透過 dnsmasq 實現服務名稱的自動解析,簡化了容器間的通訊配置。環境變數注入功能,提供了靈活的配置管理方式,讓應用程式能夠適應不同的部署環境。

podman-compose 工具的出現,進一步完善了 Podman 的生態系統。作為原生的 Compose 實作,它不僅支援常用的容器編排功能,更完整實現了 Rootless 容器管理,為安全性要求較高的環境提供了理想的解決方案。雖然目前仍在持續開發中,但已經具備了生產環境使用的基本條件。

對於正在考慮容器化技術選型的團隊,Podman 提供了一個值得認真評估的選項。特別是在安全性要求較高、不希望依賴常駐 Daemon、或是需要 Rootless 容器支援的場景中,Podman 展現了獨特的價值。隨著容器技術的持續演進,Podman 與 podman-compose 的功能將更加完善,成為多容器部署的主流選擇之一。

希望本文提供的知識與實務經驗,能夠協助您在容器化旅程中,選擇最適合的工具與架構,建構安全、高效且易於維護的多容器應用環境。