Podman 与 Docker 迁移对比

从 daemon 与 daemonless 的架构差异出发,对比 Docker 与 Podman 的进程模型、rootless 默认行为、命令行兼容层、Compose 与 systemd/Quadlet 集成、镜像与 registry 互通性,并给出可回退的迁移路径、常见坑位清单与混合共存策略,帮助团队判断该不该迁、怎么迁、迁完如何验证。

Docker 与 Podman 的命令行高度相似,alias docker=podman 在多数场景下确实能用,但两者的进程模型完全不同:一个是常驻守护进程,一个是 fork/exec 的普通进程。这决定了它们在 systemd 集成、rootless 行为、故障域与安全边界上的差异。本篇不讨论「谁更好」,而是回答三个具体问题:迁移的收益边界在哪、哪些工作负载不该迁、以及如何做到可回退。

1. 进程模型:daemon 与 daemonless 的分水岭

Docker 采用 C/S 架构。dockerd 常驻后台,客户端通过 /var/run/docker.sock 发请求,由 dockerd 负责镜像拉取、容器创建、网络与卷管理。完整调用链是:

docker CLI → dockerd → containerd → containerd-shim → runc → 容器进程

Podman 采用 fork/exec 模型:podman run 直接 fork 子进程,调用 containers/storage 与 containers/image 库完成镜像与文件系统准备,再由 conmon 守护容器:

podman run nginx
  └─ podman(客户端即父进程)
       └─ conmon(容器监视器,负责日志与退出码)
            └─ nginx(容器内 PID 1)

这个差异带来三个直接后果:

  • 故障域不同。systemctl restart docker 会终止该 daemon 下的全部容器;Podman 没有这个全局开关,容器彼此独立。
  • 依赖层次更浅。Podman 少了 dockerd 与 containerd 两层,podman --runtime 默认用 crun(C 实现,冷启动与内存占用优于 Go 写的 runc)。
  • 容器是调用者的子进程,因此天然适配 systemd 的 Type=forking/Type=notify 与进程追踪(cgroup 归属清晰)。

需要澄清一个常见误解:daemonless 不等于不能共享镜像仓库。Podman 同样读写 OCI/Docker 镜像格式,只是默认存在 ~/.local/share/containers/storage 而非 /var/lib/docker。

维度DockerPodman
常驻进程dockerd(root 或 rootless 代理)无
默认运行时runccrun
镜像存储/var/lib/docker~/.local/share/containers/storage
rootless 支持需单独配置 rootless 模式默认路径,同一份代码
重启影响所有容器随 daemon 重启无全局影响
systemd 集成间接(需包装脚本)原生(Quadlet)
集群编排Swarm无,转向 K8s / kube play
APIDocker Engine API兼容的 Podman API(socket 激活)

一句话:Docker 把「容器管理」做成了一项系统服务,Podman 把它做成了一个普通命令。前者便于集中治理,后者便于与 systemd 和最小权限模型对齐。

1.1 冷启动与资源占用的实测差异

在同一台 8 核 16GB 的 Linux 机器上,用 hyperfine 各跑 30 次空容器启动,典型结果是:

hyperfine --warmup 3 'docker run --rm alpine true' 'podman run --rm alpine true'

参考量级:Docker 约 300380ms,Podman 约 180260ms。差距主要来自跳过 daemon 的 RPC 往返与 crun 的轻量初始化。常驻内存方面,dockerd + containerd 基线约 80~120MB,Podman 无常驻开销(只有 conmon 与容器自身)。

实测数字随内核、存储驱动、镜像是否已在页缓存中变化很大,迁移决策不应只看这一项,但「无 daemon 常驻内存」在边缘与多租户场景是稳定收益。

1.2 cgroup 归属的差异

Docker 下所有容器默认挂在 docker.service 或 docker-<id>.scope 下,systemd-cgls 能看到统一树形。Podman 下每个容器是调用者进程的子进程,用 systemd 管理时归属该 unit 的 cgroup;用普通 shell 启动时归属该 shell 的 session scope。这直接影响 systemd-cgtop 的读数与 OOM 优先级判定,相关机制可参考 cgroup 与 namespace 深入 。

2. rootless 是默认,而不是可选项

Podman 从设计上就把 rootless 当第一等公民:非 root 用户执行 podman run 时,容器进程运行在该用户的 user namespace 中,映射关系由 /etc/subuid 与 /etc/subgid 决定。

grep "$USER" /etc/subuid /etc/subgid
# alice:100000:65536
podman unshare cat /proc/self/uid_map
#          0       1000          1
#          1     100000      65536

这意味着容器内的 root(uid 0)在宿主机上实际是 alice,容器内的其他 uid 映射到 100000 起始的高位区间。安全收益是实打实的:即便容器逃逸,攻击者拿到的也只是普通用户权限。

2.1 三类典型代价

  1. 低端口绑定。非特权用户无法绑定 1024 以下端口,Podman 通过 net.ipv4.ip_unprivileged_port_start=0(sysctl 或 --sysctl)放开,也可用 rootlessport 在宿主侧转发。
  2. 文件属主混乱。容器内以 uid 0 写入的宿主机文件,在宿主视角属于 subuid 区间的高位 uid,必须 ls -n 才看得清真实属主:
podman run --rm -v /tmp/data:/data alpine sh -c 'echo hi > /data/a.txt'
ls -n /tmp/data/a.txt
# -rw-r--r-- 1 100000 100000 3 ...
  1. 部分内核能力受限。需要 CAP_SYS_ADMIN 的场景(挂载 NFS、部分 fuse 文件系统、--privileged 的嵌套容器)在 rootless 下会失败。

2.2 何时必须用 rootful

  • 需要绑定 80/443 且不想依赖端口转发与 sysctl 调整。
  • 容器需要访问宿主机上 root 拥有的设备或 socket(如挂载 /var/run/docker.sock 做 CI)。
  • 使用 SELinux 强制模式且策略未针对容器进程类型调整——此时 container_t 域的限制会先暴露出来。SELinux 与 AppArmor 的差异与配置方式可参考 Linux 强制访问控制实践 。
# 查看当前是否处于 rootless
podman info --format '{{.Host.Security.Rootless}}'
# SELinux 状态与容器域
getenforce
ls -Z /var/lib/containers/storage/volumes 2>/dev/null | head

对比之下,Docker 的 rootless 模式是可选安装路径(dockerd-rootless-setuptool.sh),需要额外配置且与 rootful 行为存在细微差异,例如 --privileged 在 rootless 下会被静默降级。关于 namespace、capabilities 与 seccomp 的组合机制,可参考 容器 rootless 安全 。

3. 命令行兼容层能覆盖多少

podman-docker 包会安装一个指向 podman 的 /usr/bin/docker 符号链接,同时可用 podman system service 提供 docker.sock 兼容服务(配合 systemd socket 激活)。覆盖率大致如下:

命令族兼容度说明
run/build/ps/exec/logs高参数名基本一致
volume/network高语义一致,默认驱动名不同
compose中需 podman compose(后端调 docker-compose 或 podman-compose)
swarm低Podman 不实现 Swarm,改用 K8s 或 podman kube play
buildx低无 buildx 子命令,多平台构建走 podman build --platform
manifest中podman manifest 提供类似能力,参数名有差异
system/events中事件格式不同,消费方脚本需适配

真正需要重写的通常是三类脚本:

# 1. 依赖 docker.sock 的脚本 → 改用 podman system service 或 socket 激活
systemctl --user enable --now podman.socket
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
docker version   # 走兼容层,实际由 Podman 应答

# 2. 依赖 docker swarm 的编排 → 转为 K8s YAML 或 Quadlet
podman kube generate mypod > pod.yaml

# 3. 依赖 buildx 的多平台构建 → 用 podman 原生 --platform
podman build --platform linux/amd64,linux/arm64 --manifest myapp:latest .

注意 podman generate kube 在 4.x 之后已被 podman kube generate 取代,脚本迁移时要一并更新子命令顺序;旧命令仍可用但会打印弃用警告。

3.1 兼容层的两个隐性坑

  • docker compose 走的是 provider 链,不同发行版默认 provider 不同,CI 里必须显式锁定版本,否则本地能跑、流水线失败。
  • docker system prune 的语义差异:Podman 的 podman system prune 默认不清理未使用的镜像层,需加 -a,且不清理 --external 卷。

4. Compose 与 systemd:两种编排路径

迁移中最容易低估的是「谁来编排」。Docker 生态的答案是 Compose 与 Swarm,Podman 生态的答案是 Compose(兼容层)+ Quadlet(systemd 原生)。

4.1 podman compose 的两种后端

podman compose version
# 取决于 provider:docker-compose(推荐)或 podman-compose

podman-compose(Python 实现)对 deploy.resources、profiles、depends_on.condition 等字段支持不全,生产环境建议让 podman compose 调用上游 docker-compose 二进制,仅把运行时换掉:

# ~/.config/containers/containers.conf
[engine]
compose_providers = ["docker-compose", "podman-compose"]

4.2 Quadlet:把容器写进 systemd

Quadlet 让 systemd 直接解析 .container/.volume/.network 文件并生成 unit,是 Podman 4.4+ 的推荐方式:

# ~/.config/containers/systemd/web.container
[Unit]
Description=Web service
After=network-online.target

[Container]
Image=registry.example.com/web:1.4.2
PublishPort=8080:8080
Environment=APP_ENV=production
Volume=web-data.volume:/data
HealthCmd=curl -fsS http://localhost:8080/healthz
AutoUpdate=registry

[Service]
Restart=on-failure

[Install]
WantedBy=default.target

执行 systemctl --user daemon-reload 后,systemd 会生成 web.service。相比 docker run --restart=always,Quadlet 的优势在于:依赖关系由 systemd 表达(After=/Requires=)、日志直接进 journald、可用 systemctl --user status web 统一排障、支持 AutoUpdate=registry 做镜像自动更新。

systemctl --user daemon-reload
systemctl --user start web.service
journalctl --user -u web.service -f

注意:Quadlet 生成的 unit 是 Type=notify,健康检查失败不会自动重启,需要配合 Restart=on-failure 与 podman auto-update 的 --rollback 才构成闭环。

5. 镜像、registry 与网络互通

迁移的实际阻力往往不在命令,而在镜像与网络:

  • 镜像格式:Podman 默认 --format oci,Docker 默认 docker 格式。两者互推互拉都没问题,但如果目标 registry 对 OCI artifact 支持不全,推送时加 --format docker 更稳。
  • registry 认证:Docker 用 ~/.docker/config.json,Podman 用 ~/.config/containers/auth.json。podman login 会读取前者作为回退,可用 podman login --authfile 显式指定。
  • 镜像可见性:Docker 的镜像在 /var/lib/docker,Podman 看不见。想共用镜像,需要 docker save | podman load,或统一让两者都从 registry 拉取。
docker save myapp:1.4.2 -o myapp.tar
podman load -i myapp.tar
# 反向同理
podman save -o myapp2.tar myapp:1.4.2 && docker load -i myapp2.tar
  • 网络模型:Podman 的 podman network create 与 Docker bridge 语义接近,但 DNS 别名解析由 aardvark-dns 提供,需确认已启用。
podman network inspect mynet -f '{{json .DNSEnabled}}'
# true
podman run --rm --network mynet alpine nslookup db
  • 镜像信任策略:Podman 用 /etc/containers/policy.json 声明哪些 registry 的镜像可以运行,比 Docker 默认「只要能拉就能跑」更严格:
{
  "default": [{ "type": "reject" }],
  "transports": {
    "docker": {
      "registry.example.com": [
        { "type": "signedBy", "keyType": "GPGKeys", "keyPath": "/etc/pki/containers/cosign.pub" }
      ]
    }
  }
}

这份策略在迁移时常常被忽略,但它会直接导致「本地能跑、Podman 拒绝启动」的现象,报错形如 Source image rejected: Running image ... is not allowed by policy。

5.1 卷的 SELinux 标签

RHEL 系上 Docker 用 :z/:Z 处理卷标签,Podman 行为一致,但 rootless 下还有一层 user namespace 映射叠加,容易踩坑:

# 共享卷给多个容器:小写 z
podman run -v /srv/data:/data:z nginx
# 独占卷:大写 Z(会重打标签,其他容器将无法访问)
podman run -v /srv/data:/data:Z nginx
# 排查标签是否匹配
ls -Zd /srv/data

若出现 Permission denied 而 ls -l 权限看起来正常,先怀疑 SELinux 标签,而不是文件权限。

5.2 迁移时的网络连通性验证

# 同一自定义网络内的容器互相解析
podman network create appnet
podman run -d --name db --network appnet postgres:16-alpine
podman run --rm --network appnet alpine nslookup db
# 宿主到容器
podman port db
# 容器到宿主(rootless 下宿主地址不是 localhost)
podman run --rm alpine ip route | grep default

rootless 模式下容器访问宿主机需用 host.containers.internal 别名,而非 Docker 的 host.docker.internal;两者在 Compose 文件里写死任一都会在切换后失效。

6. 迁移路径与回退策略

推荐按「先并行、再切流、后清理」三步走,每一步都保留回退开关:

阶段动作回退方式
1. 影子验证同机装 Podman,用 podman run 跑同一镜像,比对输出与资源占用无侵入,直接停用
2. 兼容层切换装 podman-docker,脚本零改动跑通 CI 构建任务卸载包,恢复 docker 链接
3. 编排迁移Compose 文件切到 podman compose,长驻服务改 Quadlet保留原 compose 文件与 docker 单元
4. 清理移除 dockerd,镜像迁到 registry保留镜像 tar 备份

验证清单建议逐项执行:

# 1. 镜像一致性
podman images --format '{{.Repository}}:{{.Tag}} {{.ID}}'
# 2. 卷挂载权限(注意 uid 映射)
podman run --rm -v /tmp/data:/data alpine ls -n /data
# 3. 端口发布是否生效
podman port web
# 4. 健康检查与重启策略
podman inspect -f '{{.State.Health.Status}}' web
# 5. 日志是否落到 journald
journalctl --user -u web.service -n 20
# 6. 网络解析是否走 aardvark-dns
podman exec web cat /etc/resolv.conf

需要特别留意的是 CI 环境:如果流水线依赖 docker build 的 buildx 缓存后端(如 --cache-to type=gha),Podman 没有等价能力,这类任务建议保留 Docker 或改用 registry 缓存后端,详见 CI 流水线中的容器构建 。

7. 混合共存的工程实践

很多团队最终既没有全迁,也没有原地不动,而是选择「开发机用 Podman、CI 与生产用 Docker」。这种混合模式要处理三件事:

  1. 统一镜像来源。禁止本地 docker build 后直推,改为所有镜像经 CI 构建并推送到同一 registry,避免「本地有、CI 无」的镜像漂移。
  2. 统一 Compose 版本。把 compose 文件钉在同一个 schema 版本,并在 CI 里用 docker compose config -q 与 podman compose config -q 双向校验。
  3. 统一日志出口。Docker 用 json-file + Promtail,Podman 用 journald + journald 采集器,两条链路最终汇入同一个日志后端,否则排障时需要在两套工具间来回切。
# 双向校验 compose 文件可解析
docker compose -f compose.yaml config -q && echo "docker ok"
podman compose -f compose.yaml config -q && echo "podman ok"

8. 结论:什么情况下值得迁

给出一个可操作的判断表:

场景建议
单机开发、追求最小权限迁,rootless 默认是主要收益
systemd 原生管理、边缘设备迁,Quadlet + crun 冷启动更快
重度依赖 Swarm 编排不迁,除非同步迁到 K8s
依赖 buildx 多平台与 GHA 缓存谨慎,保留 Docker 或双运行时
团队已有成熟 Docker 运维体系不迁,收益不抵学习与迁移成本
需要镜像签名强制准入迁,policy.json 比 Docker 默认更严

真正决定成败的不是命令兼容度,而是能否接受「没有全局 daemon」带来的运维习惯变化:日志从 docker logs 变成 journalctl,编排从 Swarm 变成 systemd/K8s,故障排查从「看 daemon 状态」变成「看单个 unit 状态」。这三点想清楚了,迁移就是一次普通的工具替换。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. 容器网络排障实战
  2. DinD/DooD 与临时 CI Runner
  3. 本地开发运行时:OrbStack 与 Colima