Docker 與 Podman 要達成的是同一件事:什麼是容器化

作者: 發布: 13 分鐘閱讀

查核資訊: 本文於 2026-07-21 依 Linux man pages(namespaces(7)、cgroups(7)、unshare(1))、OCI 規格 repo 與 Docker/Podman 官方文件查核。文中版本資訊(Docker Engine 29.x、Podman 6.0.x、OCI runtime-spec v1.3.0、image-spec v1.1.1)與各工具預設行為可能隨新版本變動;示範命令以使用 cgroups v2 的 Linux 環境為準。

每天 docker runpodman run,卻說不清楚容器跟 VM 差在哪、image 的 layer 是什麼、namespace 又隔離了什麼——這是很多工程師的共同狀態。工具太好用,把底層藏得太乾淨。

這篇文章要補的就是那個心智模型。我們會用原生 Linux 命令手工做出一個「容器」,親眼看到容器其實只是一個被限制的普通 process;再回頭看 Docker 與 Podman 這兩套工具,各自用什麼架構達成同一個目標。懂了這一層,日常那些「進不去、砍不掉、限制沒生效」的除錯會突然變簡單。

容器化到底在解決什麼

先把目標說清楚。容器化(containerization)要解決的是三個綁在一起的老問題:

  1. 打包(image):應用程式連同它依賴的 library、設定與工具,包成一份可以帶著走的檔案系統快照。解決「在我機器上可以跑」的環境飄移問題。
  2. 隔離(runtime):讓同一台主機上的多個應用互不干擾——看不到彼此的 process、檔案與網路,也搶不走彼此的記憶體與 CPU。
  3. 分發(registry):打包好的東西要能推上去、拉下來、驗證內容沒被動過,部署才能自動化。

這三件事在容器出現前都有各自的解法:打包靠 deb/rpm 或 VM image,隔離靠 VM 或乖乖約定,分發靠套件庫或檔案伺服器。2013 年 Docker 開源後真正的貢獻,是把三件事整合成一組順手的工作流程:docker builddocker rundocker push。Podman 後來走進同一個戰場,目標完全相同,只是架構選擇不同——這是文章後半段的主題。

但不管哪套工具,底下做隔離的機制都不是它們發明的,而是 Linux kernel 本來就有的功能。所以先把工具放一邊,直接跟 kernel 打交道。

容器不是 VM:它是一個被限制的 process

最常見的誤解是把容器當成「輕量 VM」。這個比喻在架構圖上看起來成立,在機制上完全不成立。

VM 虛擬的是硬體:hypervisor 模擬出 CPU、記憶體與磁碟,上面跑一個完整的 guest OS,有自己的 kernel。容器沒有這些——容器裡的 process 就直接跑在 host 的 kernel 上,只是 kernel 限制了它「看得到什麼」與「用得了多少」。

VM 容器
隔離單位 整台虛擬機器(虛擬硬體 + guest OS) process(或一組 process)
kernel 每台 VM 自己一份 全部共用 host kernel
啟動時間 秒到分鐘(要開機) 毫秒到秒(只是 fork process)
資源開銷 每台 VM 固定吃掉一份 OS 的記憶體 接近原生 process
隔離強度 硬體層邊界,較強 kernel 層邊界,取決於設定

VM 與容器的分層比較:VM 在 Hypervisor 上各自帶一份 Guest OS kernel;容器全部共用 host 的 Linux kernel,以 namespaces 與 cgroups 隔離

不用背這張表,host 上直接驗證就好。起一個容器,然後在 host 上找它:

docker run -d --name web nginx
ps aux | grep nginx

host 的 ps 看得到容器裡的 nginx process——因為它本來就是 host 上的一個 process,只是被貼上了各種限制。再從容器裡面看 kernel 版本:

docker exec web uname -r
uname -r

兩個輸出一模一樣。容器裡沒有另一個 kernel,uname 問到的是同一個。

所以正確的心智模型是:容器 = 被 kernel 限制了視野與資源的普通 process。接下來我們手工把這些限制一項一項加上去。

手工做出一個容器

以下示範需要一台 Linux 主機(VM 也行)、root 權限,以及 cgroups v2(近年主流發行版預設)。我們只用三樣原生工具:chrootunshare、以及 /sys/fs/cgroup 底下的檔案。

第一步:換一個 root filesystem

容器給人的第一個「獨立世界」錯覺來自檔案系統:Alpine 容器裡看到的 / 是 Alpine 的目錄樹,不是 host 的。這件事 1979 年就有的 chroot 就能做到。

先弄到一份 Alpine 的根目錄。最省事的做法是請容器工具吐一份出來(這裡只是拿它當 tarball 下載器,還沒用到任何隔離功能):

mkdir rootfs
podman export "$(podman create alpine)" | tar -C rootfs -x
ls rootfs
# bin  dev  etc  home  lib  ... 一棵完整的 Alpine 目錄樹

然後把一個 shell 關進去:

sudo chroot rootfs /bin/sh

在這個 shell 裡 ls / 看到的是 Alpine 的世界,cat /etc/os-release 會告訴你這是 Alpine Linux。但注意:這只是「換了視角」,process 清單、網路、hostname 全都還是 host 的。隔離要靠下一步。

(實際的 container runtime 用的是更嚴謹的 pivot_root 而非 chroot,因為 chroot 在持有特權時有已知的逃逸手法;概念上兩者做的是同一件事——換掉 process 眼中的 /。)

第二步:用 namespaces 縮小視野

namespace 是 kernel 提供的機制:把某種全域資源包一層,讓 namespace 裡的 process 以為自己擁有獨立的一份。namespaces(7) man page 直接寫明「namespace 的用途之一就是實作容器」。目前共有 8 種:

Namespace 隔離什麼
Mount 掛載點(每個容器看到不同的檔案系統結構)
PID process ID(容器裡的 PID 1 不是 host 的 PID 1)
Network 網路裝置、協定堆疊、連接埠
UTS hostname 與 domain name
IPC System V IPC 與 POSIX message queue
User user ID 與 group ID 的對應
Cgroup cgroup 目錄樹的可見範圍
Time 系統時鐘的偏移

unshare(1) 命令可以讓你逐項體驗。先從最直觀的 UTS 開始:

sudo unshare --uts sh
hostname demo-container   # 在新的 UTS namespace 裡改 hostname
hostname                  # → demo-container
exit
hostname                  # host 的 hostname 完全沒變

再看 PID namespace——這就是「容器裡的 ps 只看得到自己」的來源:

sudo unshare --pid --fork --mount-proc sh
echo $$    # → 1,這個 shell 以為自己是整台機器的第一個 process
ps aux     # 只看得到 sh 和 ps 自己

--fork 是必要的,因為 PID namespace 只對「之後 fork 出來的子 process」生效;--mount-proc 重新掛一份 /procps 才會讀到新 namespace 的視野而不是 host 的。

Network namespace 則解釋了為什麼容器預設連不到外面、而連接埠又可以彼此重複:

sudo unshare --net sh
ip link    # 只剩一張還沒啟用的 lo,其他網路介面全部消失

新的 network namespace 是一片空白——真正的 runtime 會在這裡建一對 veth 虛擬網路介面,一端留在容器、一端接到 host 的 bridge,網路才通。每個容器有自己的一套連接埠空間,所以十個容器都可以各自監聽 80,-p 8080:80 做的就是把 host 的連接埠轉進某個 namespace。

最後是 user namespace,rootless 容器的地基:

unshare --user --map-root-user sh   # 注意:不用 sudo
id    # → uid=0(root)

沒有 sudo 也能「變成 root」——但這個 root 只在 namespace 裡有效,對應回 host 只是你原本的普通帳號。這個「容器裡是 root、host 上不是」的對應機制,就是我在 rootless Dockerrootless Podman 兩篇文章裡反覆處理的主角。

第三步:組合起來

把 rootfs 和 namespaces 疊在一起,一個看起來很像容器的東西就出現了:

sudo unshare --mount --uts --ipc --net --pid --fork chroot rootfs /bin/sh
# 進到 Alpine 的世界之後:
mount -t proc proc /proc
ps aux          # 只有 sh 和 ps
hostname box; hostname   # 改 hostname 不影響 host

一行命令,五種 namespace 加一個 chroot。這大概就是 docker run -it alpine sh 做的事情的骨架。

第四步:用 cgroups 限制資源

namespaces 管「看不看得到」,cgroups 管「用得了多少」。cgroup 讓 process 被組織成階層式的群組,對整個群組限制與監控資源用量。操作介面就是檔案系統——在 cgroups v2 下建一個目錄、寫幾個檔案:

sudo mkdir /sys/fs/cgroup/demo
echo 100M | sudo tee /sys/fs/cgroup/demo/memory.max      # 記憶體上限 100 MiB
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max  # 每 100ms 最多用 50ms CPU(= 0.5 顆)
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs      # 把目前的 shell 放進這個 cgroup

從此這個 shell 與它的所有子 process 加起來最多用 100 MiB 記憶體、半顆 CPU。超過 memory.max 會發生什麼?kernel 的 OOM killer 會把群組裡的 process 砍掉——這正是容器「Exit Code 137(OOMKilled)」的真身。你設定的 docker run --memory=100m,落到 kernel 就是這一行 memory.max

收斂:容器 = namespaces + cgroups + rootfs

到這裡可以收斂出容器的完整定義:

容器 = 一個(或一組)普通 Linux process + namespaces(縮小視野)+ cgroups(限制資源)+ 獨立 rootfs(換一個世界),再加上幾層安全機制——capabilities 拿掉大部分 root 特權、seccomp 過濾可用的 system call、SELinux/AppArmor 限制存取範圍。

容器的組成:一個普通 Linux process 被 namespaces(縮小視野)、cgroups(限制資源)與 rootfs(換一個世界)層層包住,全部跑在共用的 Linux kernel 上

我們手工版少做了很多事:pivot_root、veth 網路、capabilities 與 seccomp、把這一切在 process 結束後清乾淨。這些「把 kernel 功能組裝成完整容器」的苦工,正是 container runtime 存在的理由。但機制上沒有任何魔法——全部都是 kernel 內建功能的組合。

Image 與 layer:那個 rootfs 從哪來

手工示範裡我們用 tar 解出一份 rootfs。實務上這份 rootfs 來自 image,而 image 的核心設計是分層(layer)

一份 image 是一疊唯讀的 layer,每層本質上就是一個 tarball,記錄「相對於下面那層,多了、改了、刪了哪些檔案」。ContainerfileDockerfile 裡每個會改動檔案系統的指令(RUNCOPYADD)各產生一層。容器啟動時,runtime 用 union filesystem(Linux 上主要是 OverlayFS)把這疊唯讀 layer 合併成一個掛載點,再疊上一層屬於這個容器的可寫層——這就是容器看到的 /

這個設計一次解決三個問題:

  • 共用:十個容器共用同一份 image 時,唯讀 layer 在磁碟上只存一份,每個容器只多一層薄薄的可寫層。
  • 快取:build 時每層有快取,Dockerfile 沒改到的前幾層直接重用,這就是把「少變動的指令放前面」能加速 build 的原因。
  • 可驗證:每層與整份 image 都以內容的 SHA-256 digest 定址(content-addressable)。pull 下來的東西跟 registry 上的位元組相同,改一個 byte 的 digest 就對不上。

順帶一提實作細節:傳統上 Docker 在 Linux 的預設 storage driver 是 overlay2,而 Docker Engine 29 起新安裝預設改用 containerd image store(以 snapshotter 管理 layer);Podman 則透過 containers/storage 使用 overlay。名字不同,做的都是同一件事——把一疊唯讀 tarball 合成容器的 rootfs。

也因為「layer 唯讀、可寫層屬於容器」,你在容器裡改的任何檔案,容器刪掉就消失,永遠不會寫回 image。需要留下來的資料要用 volume 掛出去——這不是慣例問題,是機制決定的。

OCI:讓 Docker 與 Podman 可以互換的標準

上面講的 image 格式、rootfs bundle、怎麼把 bundle 跑起來,如果每家工具各做各的,生態系就會分裂成「Docker 的容器」與「別家的容器」。2015 年 6 月 22 日,Docker、CoreOS 等廠商在 Linux Foundation 底下成立 Open Container Initiative(OCI),Docker 並把自家的容器格式與 runtime runc 捐出來當基礎。

OCI 維護三份規格,正好對應容器化的三個環節:

規格 管什麼 目前版本
image-spec image 的格式:layer、manifest、config 長什麼樣 v1.1.1
distribution-spec registry 的 API:怎麼 push、pull、列 tag v1.1.0
runtime-spec 拿到解開的 filesystem bundle 之後,怎麼把它跑成容器 v1.3.0

分工是一條清楚的 pipeline:distribution-spec 定義怎麼從 registry 搬 image,image-spec 定義搬下來的東西怎麼解讀、怎麼組成 rootfs,runtime-spec 定義最後怎麼用 namespaces 與 cgroups 把它跑起來。實際執行最後一步的程式叫 OCI runtime——runc(Go 寫的參考實作)與 crun(C 寫的,更快更省記憶體)是最常見的兩個。

OCI 三規格的分工:distribution-spec 定義從 registry 搬 image,image-spec 定義 image 如何組成 filesystem bundle,runtime-spec 定義 runc 或 crun 如何把 bundle 跑成容器

這就是為什麼 podman run docker.io/library/nginx 理所當然能跑:所謂「Docker image」其實是 OCI image,任何符合規格的工具都拉得下來、跑得起來。Docker 與 Podman 不是兩種容器技術,而是同一套標準的兩個實作。

Docker 與 Podman 各自的答案

既然目標與標準都相同,兩者差在哪?差在架構——同一件事,兩種組裝方式。

Docker 選擇 client-server。 你敲的 docker 命令只是 client,透過 UNIX socket 把請求送給常駐的 dockerd daemon;daemon 負責管理 image、容器、網路與 volume 等所有物件,再往下交給 containerd 管容器生命週期,最後由 runc 真正建出 namespaces 與 cgroups。好處是有一個中心化的 API 服務:遠端管理、事件串流、外部系統整合都很自然。代價是這個 daemon 傳統上以 root 常駐,所有容器都是它的子孫——daemon 出事影響全部容器,而且能碰到 docker socket 的人形同拿到 root。這條風險線與它的解法,我在 rootless Docker 那篇 有完整展開。

Podman 選擇 daemonless。 沒有常駐 daemon:podman run 直接以你的身分 fork-exec 出容器 process(透過 libpod library 管理,預設優先使用 crun)。CLI 刻意做到與 Docker 相容,官方文件甚至直接建議 alias docker=podman。沒有 daemon,就沒有「root 常駐服務」這個攻擊面,rootless 是它的自然型態;容器 process 掛在你的 session 或 systemd 之下,生命週期交給 systemd(Quadlet)管理也順理成章。代價是失去中心服務:需要 API 時得另外啟用 podman.socket(風險也跟著回來),跨主機管理與部分生態整合要自己補。細節在 rootless Podman 那篇

Docker Podman
架構 client + 常駐 daemon(dockerd → containerd → runc) daemonless,直接 fork-exec(libpod → crun/runc)
預設權限模型 傳統上 root daemon(另有 rootless mode) root 與 rootless 皆為原生支援
API 服務 內建(daemon 即 API server) 選配(podman.socket,Linux 限定)
服務化 daemon 自帶 restart policy 交給 systemd/Quadlet
容器的父 process dockerd 這一系 你的 shell 或 systemd

注意這張表裡沒有「誰的容器比較快、比較輕」——因為跑起來的東西是同一種:同樣的 OCI image、同樣的 kernel 機制。選擇 Docker 或 Podman,選的是管理容器的方式,不是容器本身。

收尾:懂了 process,除錯就變簡單

回到開頭的心智模型:容器是被 namespaces、cgroups 與 rootfs 限制的普通 process。這句話能直接翻譯成除錯直覺:

  • 容器裡看不到某個東西(process、網路介面、檔案)?那是某個 namespace 擋掉的,去 host 上看 /proc/<pid>/ns/ 就知道它活在哪些 namespace 裡。
  • 容器被莫名砍掉、Exit 137?那是 cgroup 的 memory.max 加 OOM killer,去 /sys/fs/cgroup/ 對應目錄看 memory.events 有沒有 oom_kill。
  • 在容器裡改的設定重啟後消失?layer 唯讀、可寫層跟著容器走,機制如此,該用 volume 就用 volume。
  • dockerpodman 之間遷移?image 與 registry 都是 OCI 標準,真正要遷的是 daemon 與 systemd 之間的管理方式差異。

工具會一直換版本,kernel 機制十年來大致就是這一套。把這一層弄懂,上層不管是 Docker、Podman,還是 Kubernetes 底下的 containerd 與 CRI-O,看起來都是同一件事的不同包裝。

下一步如果你關心的是「這些東西怎麼安全地上 production」,rootless 與權限邊界就是主戰場——歡迎接著讀 正式環境為什麼需要 Rootless DockerPodman 也有 Rootless 問題嗎

參考資料