把一套跑了两三年的小服务从 Docker 迁到 Podman,真正让我头疼的不是“命令把 docker 换成 podman”这一步——那部分十分钟就干完了——而是迁移后第三周才陆续冒出来的问题:容器重启后端口没起来、挂载进去的目录宿主机上改不了文件、服务跟着终端一起死掉。这些问题单独看都不难,但它们共同指向同一件事:Podman 的模型和 Docker 不一样,尤其是在 rootless(无根)模式下,权限、生命周期、编排这三件事是绑在一起看的。
这篇是系列第四篇,不铺基础概念,直接讲四块最容易卡住人的硬骨头:Pod 编排、rootless 权限模型、systemd 托管、以及与 Kubernetes YAML 的互操作,最后补一份我实际用过的迁移清单。适合已经能用 Podman 跑起单容器、准备把服务真正放到生产或准生产环境的人看。文中涉及版本的地方以 Podman 4.x 为基准,5.x 的差异我会单独点出来。
1. Pod:Podman 从“单兵作战”走向“编队”的那一步
很多人第一次看到podman pod这组命令时的反应是:这不是 Kubernetes 的东西吗,怎么跑到单机工具里来了?我当初也这么想,直到有一次要在一台机器上跑三个必须互相通过 localhost 通信的组件,才发现这个设计省了太多事。
1.1 Pod 在 Podman 里到底是什么,pause 容器扮演什么角色
一句话概括:Pod 是一组共享网络、IPC、UTS 命名空间的容器集合。注意这里没有 PID——默认情况下 Pod 内的容器互相看不到对方的进程,这一点和很多人的直觉相反。
创建 Pod 的时候,Podman 会先拉起一个极小的 infra 容器(也叫 pause 容器),它跑的是一个几乎不干事的镜像,只做一件事:占住命名空间。后续加入这个 Pod 的所有容器都把自己的网络命名空间“接”到这个 pause 容器上,于是它们看到的是同一张网卡、同一个127.0.0.1、同一个端口空间。
这个设计的好处是:Pod 里任意两个容器可以通过 localhost 直接通信,不需要建网桥、不需要服务发现、不需要解析容器名。你在容器 A 里监听127.0.0.1:6379,容器 B 直接连127.0.0.1:6379就能通。代价是端口冲突变成了 Pod 内部问题——两个容器抢同一个端口,第二个起不来,报错还挺隐晦。
查看 Pod 状态:
podman pod ps podman pod inspect mypodpodman pod inspect的输出里有一个InfraContainerID字段,那个就是 pause 容器的 ID。有意思的是,你可以直接podman exec进这个 infra 容器去看网络配置,排查网络问题时特别好用。
提示:Pod 被删除时,如果里面还有容器在跑,Podman 会拒绝删除。要么先停容器,要么加
--force。我建议养成先podman pod stop再podman pod rm的习惯,避免误删正在跑的服务。
1.2 用一条 Pod 把 Web + 缓存 + 边车串起来
假设要跑一个 Web 服务加一个本地缓存,传统做法是建一个自定义网络、起两个容器、用容器名互访。用 Pod 的路子更短:
podman pod create --name app-pod -p 8080:80 podman run -d --pod app-pod --name web \ -v ./site:/usr/share/nginx/html:Z \ nginx:alpine podman run -d --pod app-pod --name cache \ redis:7-alpine注意podman pod create的时候就把端口映射-p 8080:80声明好了,后面podman run里不能再写-p,写了会报错。这是一个高频踩坑点:端口属于 Pod 级别,不属于容器级别。原因也很自然——端口映射改的是 Pod 共享的那个网络命名空间的 NAT 规则,容器自己没有独立的网络栈。
启动顺序上,Podman 不会帮你做依赖排序。Web 先起来连不上缓存是正常的,应用侧得有重试逻辑,或者干脆用健康检查加手动控制顺序。我一般会先起有状态的那个(缓存、数据库),再起无状态的。
验证 Pod 内部连通性:
podman exec web sh -c 'nc -zv 127.0.0.1 6379'能通就说明共享网络是生效的。如果这一步不通,八成是缓存容器没起来或者监听在了非 localhost 地址上——有些镜像默认监听0.0.0.0,在 Pod 内反而是好事,因为共享网络栈下0.0.0.0和127.0.0.1都能被同 Pod 的容器访问到。
1.3 哪些东西共享、哪些东西隔离,一张表说清楚
刚接触 Pod 的时候最容易搞混的就是边界,我把实测结论整理成表:
| 资源维度 | Pod 内表现 | 说明 |
|---|---|---|
| 网络命名空间 | 共享 | 同一 IP、同一端口空间、共享 localhost |
| UTS 命名空间 | 共享 | 主机名一致,hostname出来是 Pod 名 |
| IPC 命名空间 | 共享 | 可以用共享内存通信 |
| PID 命名空间 | 默认隔离 | 需--share pid才共享,一般不推荐 |
| 挂载点/文件系统 | 隔离 | 每个容器独立 rootfs,卷要各自挂 |
| cgroup 资源限制 | 独立 | 每个容器单独设--memory、--cpus |
| 环境变量 | 隔离 | 每个容器独立 |
看到“文件系统隔离”那一行了吗?这是另一个高频误解:很多人以为 Pod 内的容器能共享文件。不能。要共享数据,必须两边都挂同一个卷,或者用--volume挂同一个宿主机目录。我见过有人把配置文件只挂给了 web 容器,然后疑惑边车容器为什么读不到,这就是没理解 Pod 只是“共享内核命名空间”,不是“共享文件系统”。
另一个细节是 PID 隔离。Kubernetes 里默认也是隔离的,Podman 保持了一致。有些老的应用监控方案依赖ps看到同 Pod 其他进程,那种情况要显式加--share pid,但我不建议这么做,因为进程信号处理会变得很难预测,一个容器里kill -9可能误伤隔壁。
2. rootless 权限模型:普通用户凭什么能跑“root”容器
这是整个 Podman 体系里最有价值、也最容易被低估的部分。我见过太多人迁移到一半放弃了 rootless,退回 rootful 模式,理由是“权限搞不定”。其实搞懂了 UID 映射,大部分问题十分钟能定位。
2.1 UID 映射表是整套机制的根
普通用户没有特权,怎么能在容器里看到 root?答案是user namespace + UID 映射。内核允许你在一个命名空间里拥有一套完全独立的 UID 视图,容器里的uid 0在宿主机上其实是你的普通用户 UID(或者一个高位 UID 区间里的某个值)。
这个映射关系来自两个文件:
cat /etc/subuid cat /etc/subgid正常情况下你会看到类似这样的内容:
alice:100000:65536意思是:用户alice可以使用宿主机上从 100000 开始的 65536 个 UID,分配给容器内部使用。容器内的 uid 0 映射到 100000,uid 1 映射到 100001,以此类推。
想直接看当前命名空间的映射表,用podman unshare进入容器的用户命名空间看:
podman unshare cat /proc/self/uid_map输出类似:
0 1000 1 1 100000 65536第一行是“容器内 uid 0 映射到宿主机 uid 1000”——也就是你自己。第二行是“容器内 uid 1 到 65536 映射到宿主机 100000 起的区间”。
这个双段映射很关键。容器内 uid 0 就是你自己,所以容器里 root 写的文件,在宿主机上你是有权限读写的,这是 rootless 最舒服的一点。但容器内 uid 1 到 65536 映射到高位区间,宿主机上你的普通用户对这些 UID 没有天然权限。
如果/etc/subuid里没有你的用户名,rootless 容器起不来,报错通常是cannot set up namespace或者newuidmap: write to uid_map failed。修复方式就是用管理员权限加上:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 alice改完需要podman system migrate让它重新加载配置,不然新映射不生效。这一步文档里写得很轻描淡写,实际是不少人卡住的地方。
2.2 挂载卷权限翻车的三种典型形态
rootless 模式下挂载宿主机目录,坑特别集中。我总结了三类:
第一类:容器内非 root 用户写不进挂载目录。你的应用在镜像里以 uid 1000 运行(很多官方镜像都这样),这个 uid 1000 映射到宿主机是 101000,而你的宿主机目录属于你(uid 1000 那个你),于是写不进去。解法有两个:一是给宿主机目录放宽权限(chmod 777,我不推荐);二是启动容器时用--userns=keep-id,让容器内 uid 和宿主机 uid 对齐:
podman run -d --userns=keep-id \ -v ./data:/app/data:Z \ myapp:latestkeep-id的作用是把当前用户的 UID 映射成容器内的同号 UID,等于“容器里的你”和“宿主机的你”是同一个。对开发环境特别合适。
第二类:SELinux 标签不匹配导致 Permission denied。在启用了 SELinux 的系统上,挂载目录要加:Z或:z后缀。:Z是私有标签(只给这一个容器用),:z是共享标签(多个容器共用)。不加标签,容器读不到文件,报错就是干巴巴的Permission denied,日志里毫无提示。
第三类:NFS 或网络存储上的挂载加不上标签。NFS 不支持 SELinux 标签,加:Z会报错。这时候要用--security-opt label=disable关掉容器的标签隔离,或者更好的是换成支持标签的存储。这个坑我在一个共享存储场景里踩了整整一个下午,最后靠ausearch查 SELinux 拒绝日志才定位到。
注意:
--security-opt label=disable是降级方案,会削弱隔离强度。生产环境如果非用不可,至少确保那个容器不跑不受信任的镜像。
2.3 低端口、cgroup 与 SELinux 连带出来的三个问题
rootless 模式下还有三个连带问题经常一起出现:
低端口绑定不了。容器里想监听 80 或 443,rootless 下会失败,因为宿主机内核默认不允许非特权进程绑定 1024 以下端口。要么在容器里换个高位端口,由 Podman 的-p 80:8080做映射(映射发生在 Podman 进程侧,不受影响);要么调整系统参数net.ipv4.ip_unprivileged_port_start=0。我强烈建议前者——改系统参数影响面太大。
资源限制不生效。rootless 下--memory之类的限制依赖 cgroup v2 的 delegated 特性。如果你的系统还在 cgroup v1,rootless 容器的内存限制会静默失效,不报错但也不生效。检查方式:
stat -fc %T /sys/fs/cgroup/输出cgroup2fs才是 v2。如果是tmpfs,那还是 v1,需要确认 systemd 是否已经启用 unified 层级。
日志位置变了。rootful 模式的容器日志在/var/log/或者 journald 的系统 journal 里;rootless 模式的日志进的是用户级 journal,查看要加--user:
journalctl --user -u container-myservice -f不知道这一点的人会一直journalctl -u查不到日志,然后怀疑容器根本没输出。
3. 交给 systemd 托管:容器开机自启的稳妥做法
单机跑容器服务,绕不开一个问题:机器重启后容器怎么自己起来?Docker 里是--restart=always,Podman 里我更推荐直接交给 systemd,原因下面说。
3.1 为什么我不用 --restart=policy 而交给 systemd
--restart=always在 Podman 里是能用的,但它的语义和 Docker 有细微差别,而且在 rootless 模式下有个致命问题:用户 session 结束,容器就被带走了。你用 SSH 登录跑一个--restart=always的容器,退出登录后容器可能还在(取决于 linger 配置),但重启机器后它不会自动起来,因为你的用户 session 根本没启动。
systemd 托管解决的正是这个:把容器变成一个真正的服务单元,开机时由 systemd 拉起用户 session,再由 session 拉起容器。整个链路上每一环都有明确的依赖关系和日志归属。
另外还有几个实际好处:可以配After=network-online.target确保网络就绪后再起容器;可以用systemctl status看容器状态;可以用systemd-analyze分析启动耗时;资源限制可以直接写在 unit 里统一管理。
3.2 从 generate systemd 迁移到 Quadlet 的实操路径
老的写法是podman generate systemd:
podman generate systemd --new --files --name web--new这个参数很关键,它会让生成的 unit 在每次启动时重新创建容器,而不是启动一个已存在的容器。这样配置文件改了、镜像更新了,重启服务就能生效。不带--new生成的 unit 依赖容器已经存在,一旦容器被删了服务就起不来。
生成的 unit 文件放到~/.config/systemd/user/,然后:
systemctl --user daemon-reload systemctl --user enable --now container-web.service但这套写法在 Podman 4.4 之后被标记为弃用,5.0 起推荐用Quadlet。Quadlet 的思路是把容器配置写成一个类似 systemd unit 的声明文件,由 systemd 自己生成最终的 service。
在~/.config/containers/systemd/下建一个web.container:
[Unit] Description=Web service After=network-online.target [Container] Image=docker.io/library/nginx:alpine ContainerName=web PublishPort=8080:80 Volume=./site:/usr/share/nginx/html:Z AutoUpdate=registry [Service] Restart=on-failure TimeoutStartSec=300 [Install] WantedBy=default.target然后systemctl --user daemon-reload,服务名是web.service(去掉.container后缀)。注意 Quadlet 生成的是临时 unit,enable命令可能会提示找不到单元文件,这是正常的——直接systemctl --user start web.service就行。
Quadlet 相比generate systemd的最大优势是配置文件是人写的、可读的、可版本管理的,而生成的 unit 一旦手改,下次重新生成就覆盖了。我现在的做法是全部换成 Quadlet,配置文件进 git,机器重装后git clone加daemon-reload就恢复了。
提示:
AutoUpdate=registry需要配合podman auto-update定时任务才有意义。它会检查镜像摘要变化,有更新就重建容器。适合无状态服务,有状态的别开。
3.3 linger、日志归属与用户 session 的那些细节
rootless 容器要开机自启,必须开 linger:
loginctl enable-linger $USER不开的话,用户没有登录时 systemd 不会启动该用户的 user manager,你的--user服务永远不会跑。这个命令执行后没有输出,很容易以为没生效,用loginctl show-user $USER | grep Linger验证,看到Linger=yes才对。
排查 user 服务起不来的顺序我一般是这样:
systemctl --user status web.service journalctl --user -u web.service -n 100 --no-pager podman ps -a --filter name=web podman logs web四步下来基本能定位。最常见的原因是镜像拉取失败(网络问题)、卷路径不存在(./site是相对路径,systemd 的工作目录不是你的 home)、或者PublishPort端口被占用。
还有一点:systemd 启动的容器,环境变量不会继承你 shell 里的。你在.bashrc里export的变量,服务里看不到。要在 unit 里用Environment=显式声明,或者用EnvironmentFile=指向一个文件。
4. 和 Kubernetes 对齐:kube YAML 的生成、回放与真实边界
Podman 的 Pod 设计初衷之一就是和 Kubernetes 对齐,所以它提供了 YAML 互转能力。这块功能被宣传得比较美好,实际用下来有明确的适用边界。
4.1 kube generate 会保留什么、丢掉什么
把本机一个 Pod 导出成 K8s YAML:
podman kube generate app-pod -f app-pod.yaml在 Podman 4.x 里这个命令叫podman generate kube,5.0 起改名为podman kube generate,老名字作为别名保留。
导出的 YAML 会包含:Pod 定义、容器列表、镜像、端口映射、环境变量、卷挂载、资源限制、重启策略。听起来挺全,但实际丢的东西不少:
| 配置项 | 是否保留 | 备注 |
|---|---|---|
| 镜像名与标签 | 保留 | 建议用完整仓库地址 |
| 端口映射 | 保留 | 转成 hostPort |
| 环境变量 | 保留 | 值会明文写入,敏感信息要另处理 |
| 卷挂载 | 部分保留 | 宿主机路径转成 hostPath,需要目标集群有这个路径 |
| 资源限制 | 保留 | 转成 resources.limits |
| 健康检查 | 部分保留 | 命令行式的能转,复杂脚本可能丢失 |
| 网络模式 | 丢失 | 自定义网络的细节不会带过去 |
| SELinux 标签 | 丢失 | 集群侧不需要 |
| 依赖顺序 | 丢失 | K8s 本来就是无序的 |
最大的一条“丢失”是宿主机路径。本机 Pod 用-v /data/app:/app挂载,导出的 YAML 里是hostPath: /data/app。把这个 YAML 拿到一台没有/data/app的机器上play,容器照样起来,但挂载的是个空目录,应用可能直接报错退出。这个坑很隐蔽,因为容器状态是 running,只有看日志才发现数据没了。
4.2 play kube 的常用开关与清理
反向操作是把 YAML 跑起来:
podman play kube app-pod.yaml常配几个参数:
podman play kube app-pod.yaml \ --network mynet \ --log-driver journald \ --replace--replace会在 Pod 已存在时先删再建,适合反复迭代的场景,但它会清掉容器里的临时数据,有状态服务慎用。--network指定自定义网络,因为 YAML 里通常没写。
清理要成对:
podman play kube --down app-pod.yaml--down会按 YAML 里的定义把 Pod 和容器都删掉。
这里有个值得注意的边界:play kube只支持 Pod、容器、卷、ConfigMap 这几类资源,Deployment、Service、Ingress 全都不认。它定位是“单机跑一个 Pod 定义”,不是“单机版 K8s”。见过有人拿它跑完整的多副本 Deployment 定义,然后问为什么只有一个副本——因为它根本不解析replicas。
4.3 一条从本机 Pod 到集群的渐进路径
我实际用下来觉得最舒服的路径是这样的:
开发阶段在本机用podman pod快速迭代,配置改完pod stop && pod rm && pod create,秒级反馈。这个阶段完全不需要 YAML。
联调阶段用podman kube generate导出一份 YAML,扔给测试环境的人,他们用podman play kube就能复现同样的环境,省去了“你本机怎么配的”这种对话。
上线阶段把这份 YAML 当草稿,手工改造成 Deployment + Service。改造时重点处理三件事:把 hostPath 换成 PVC、把环境变量换成 ConfigMap/Secret、把 hostPort 换成 Service 端口。前两个是必改项,第三个如果不改,多副本会端口冲突。
这条路径的价值在于:开发阶段不引入 K8s 的复杂度,但产出的东西是有迁移价值的。反过来,如果一开始就在本机装个轻量 K8s,开发体验会明显变慢,而且本地和集群的差异依然存在。
5. 网络与存储:那些“看起来能用”和“真的能用”的差别
Podman 4.0 之后网络后端从 CNI 换成了 netavark,这个变化带来的影响比版本说明里写的要大。
5.1 netavark 替换 CNI 之后要注意的点
新旧后端的区别主要在配置位置和管理方式:
| 维度 | CNI(旧) | netavark(新) |
|---|---|---|
| 配置文件位置 | /etc/cni/net.d/ | /etc/containers/networks/ |
| DNS 解析 | dnsname 插件 | aardvark-dns |
| 自定义网络 | podman network create | 同名命令,底层不同 |
| 与 K8s 共存 | 需要注意冲突 | 更干净 |
实际影响最大的是 DNS。netavark 配的 aardvark-dns 是一个独立进程,负责在自定义网络里把容器名解析成 IP。它出问题的时候表现很典型:容器能 ping 通 IP,但 ping 不通容器名。排查:
podman network inspect mynet ps aux | grep aardvarkaardvark-dns进程没起来,或者容器没加入正确的网络,都会导致解析失败。重启容器一般能恢复。
自定义网络的创建和加入:
podman network create --subnet 10.88.0.0/16 mynet podman run -d --network mynet --name svc-a myimage podman run -d --network mynet --name svc-b myimage同一网络内svc-b可以直接ping svc-a。跨网络不行,这点和 Docker 一致。
5.2 端口映射、host 网络与 Pod 内端口的差异
三种网络模式的取舍:
- bridge(默认):容器有独立 IP,通过
-p映射端口。适合绝大多数场景。 - host:容器直接用宿主机网络栈,性能最好,但端口冲突风险高。
--network host之后-p就失效了,因为不需要映射。 - Pod 内共享:端口属于 Pod,容器间通过 localhost 通信,对外通过 Pod 创建时的
-p暴露。
host 模式在 rootless 下有个额外限制:容器内看到的网络就是宿主机网络,但绑定低端口依然受限,因为本质还是非特权进程。
端口映射的性能值得一提。-p走的是用户态转发(rootless 下尤其明显),高并发场景下比 host 模式有可观损耗。压测环境的经验值是:短连接密集型的服务,rootless bridge 模式相比 host 模式吞吐可能差 20% 到 40%。如果你的服务对网络性能敏感,优先考虑 host 模式或者把服务放到 rootful 模式跑。
5.3 卷的四种形态与选型
| 类型 | 写法 | 适用场景 | 注意点 |
|---|---|---|---|
| 绑定挂载 | -v /host/path:/ctr/path | 配置文件、代码目录 | 需要处理权限与 SELinux 标签 |
| 命名卷 | -v myvol:/data | 数据库数据 | 位置在~/.local/share/containers/storage/volumes/ |
| tmpfs | --tmpfs /tmp | 临时文件、缓存 | 容器停止即丢,不落盘 |
| 匿名卷 | -v /ctr/path | 镜像声明的数据目录 | 不好管理,尽量显式命名 |
命名卷在 rootless 模式下位置和 rootful 不同,这点经常让人找不到数据:
podman volume inspect myvol | grep Mountpoint导出路径会显示在~/.local/share/containers/storage/volumes/myvol/_data。备份的时候直接拷这个目录就行,比docker cp靠谱。
还有个小技巧:迁移数据时用podman volume export和import,能保持权限和属主关系,比手工cp -r少踩很多权限坑。
6. 迁移清单:把现有那套搬到 Podman 要改哪些地方
最后说迁移。我不建议“一把梭”全量切,而是按服务分批,先从无状态的、依赖少的开始。
6.1 命令与 socket 层的兼容处理
最省事的起步方式是在 shell 里加个别名:
alias docker=podman大部分日常命令(run、ps、images、logs、exec、build)行为一致。真正需要改的是:
docker compose要换成podman-compose或podman compose。后者在 4.x 后期开始提供,本质是调用外部 compose 实现。
Docker socket 要换路径。有些工具通过/var/run/docker.sock和容器引擎通信,Podman 要启用兼容 socket:
systemctl --user enable --now podman.socket export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock启用之后podman.socket监听那个路径,绝大多数走 Docker API 的工具能直接工作。注意$(id -u)不能写死成 1000,多用户机器上会出错。
--restart语义要重新审视。前面说过,rootless 下建议改用 systemd。
6.2 compose 文件的可用度实测
podman-compose的成熟度这几年提升明显,但和 Docker Compose 仍有差距。我实测过的一批常见写法:
| 写法 | 支持情况 | 备注 |
|---|---|---|
image/build | 支持 | build 走 buildah |
ports/volumes/environment | 支持 | 基本一致 |
depends_on | 部分支持 | 不保证启动顺序,只保证创建顺序 |
healthcheck | 支持 | 但depends_on的 condition 支持不完整 |
profiles | 支持 | 4.x 后期才有 |
deploy.resources | 部分支持 | 单机模式下部分字段被忽略 |
extends | 支持不稳定 | 建议展开写 |
x-扩展字段 | 支持 | YAML 锚点能正常解析 |
depends_on那一行是重点。很多人以为写了depends_on就能保证“数据库先就绪再起应用”,实际它只保证创建顺序,不保证就绪。应用侧的重试逻辑该写还得写。
6.3 镜像构建链路上的差异
podman build底层用的是 buildah,和 Docker 的构建器有行为差异,我遇到过的:
构建缓存粒度不同。同样的 Dockerfile,Podman 可能不会复用某些中间层,导致构建变慢。要显式开缓存:
podman build --layers -t myapp:latest .--layers是让每一层都参与缓存,不加的话默认可能只缓存部分。
多阶段构建的中间镜像处理不同。Podman 默认会保留中间阶段,需要--rm清理:
podman build --rm --layers -t myapp:latest .rootless 构建的权限问题。在 Dockerfile 里RUN chown到某个非 root 用户,rootless 模式下可能失败,因为那个 UID 在宿主机上没有映射。解法是用--userns=keep-id或者换用 Buildah 的--isolation=chroot。
COPY --chown的行为一致性更好,比RUN chown可靠,能改就改。
还有一条经验:构建大镜像时给/tmp留足空间。buildah 在某些环节会把中间层写到临时目录,/tmp挂的是 tmpfs 且容量小的话,构建会中途失败,报错信息还跟磁盘无关,极难排查。我在一台小内存机器上被这个问题耽误过很久,最后靠df -h /tmp才看出来。
切换到podman build之后,我保留了一个习惯:任何新镜像先在本地podman build跑一遍,再推到仓库。这一步能提前暴露 80% 的兼容问题,比在生产环境发现要省事得多。容器这套东西,跑通一次不代表能稳定跑,尤其是权限和存储相关的部分,只有真刀真枪按目标环境跑过一遍,心里才有底。