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。
| 维度 | Docker | Podman |
|---|---|---|
| 常驻进程 | dockerd(root 或 rootless 代理) | 无 |
| 默认运行时 | runc | crun |
| 镜像存储 | /var/lib/docker | ~/.local/share/containers/storage |
| rootless 支持 | 需单独配置 rootless 模式 | 默认路径,同一份代码 |
| 重启影响 | 所有容器随 daemon 重启 | 无全局影响 |
| systemd 集成 | 间接(需包装脚本) | 原生(Quadlet) |
| 集群编排 | Swarm | 无,转向 K8s / kube play |
| API | Docker 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 三类典型代价
- 低端口绑定。非特权用户无法绑定 1024 以下端口,Podman 通过
net.ipv4.ip_unprivileged_port_start=0(sysctl 或--sysctl)放开,也可用 rootlessport 在宿主侧转发。 - 文件属主混乱。容器内以 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 ...
- 部分内核能力受限。需要
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」。这种混合模式要处理三件事:
- 统一镜像来源。禁止本地
docker build后直推,改为所有镜像经 CI 构建并推送到同一 registry,避免「本地有、CI 无」的镜像漂移。 - 统一 Compose 版本。把 compose 文件钉在同一个 schema 版本,并在 CI 里用
docker compose config -q与podman compose config -q双向校验。 - 统一日志出口。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 状态」。这三点想清楚了,迁移就是一次普通的工具替换。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。