SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

VPS 上 Podman 与 Docker 有什么实际区别?

Podman 默认无守护进程且以非 root 用户运行。本文对比 VPS 上的 Compose、Quadlet、1024 以下端口、Docker socket 与卷所有权差异。

Podman 与 Docker 实际有哪些差异

Podman 和 Docker 都可以在 VPS 上运行相同的 OCI(开放容器计划)镜像,因此选择哪一个并不取决于能运行哪些软件。差异在于进程模型。Docker 运行一个拥有所有容器的 root 守护进程,docker 命令只是向该守护进程请求执行操作的轻量客户端。Podman 没有守护进程:podman run 会在调用它的进程下,以您自己的非特权用户身份,将容器作为子进程启动。

其他行为都源于这一点。自动启动由 systemd 负责,而不是由守护进程负责。卷的所有权会经过用户命名空间映射,因此在主机上使用 ls -l 看到的所有者,与容器看到的所有者不同。绑定 1024 以下的端口会失败,除非您修改内核设置。docker CLI(命令行界面)通过包装器仍可继续使用,但如果某项操作需要 Docker socket,就无法继续。

无守护进程:启动容器时实际运行的是什么

在 Docker 主机上,pstree -a 会显示由 root 运行的 dockerd,其旁边是 containerd,每个运行中的容器对应一个 containerd-shim-runc-v2。应用程序是该 shim 的子进程,而 shim 是 PID 1 的子进程。容器与启动它的 shell 之间没有连接。停止守护进程后,该主机上所有容器的控制平面都会丢失;如果默认的 live-restore 设置处于关闭状态,systemctl restart docker 也会停止重启容器。

Podman 没有对应的进程。启动容器后,会得到一个 conmon(容器监控进程),负责持有容器的主进程,并由运行该命令的用户拥有。

podman run -d --name web -p 8080:80 docker.io/library/caddy:2
ps -o user,pid,ppid,args -C conmon
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080

ps 应列出由您的登录用户而不是 root 运行的 conmon,而 curl 应输出 200。由于没有中央服务负责管理容器,sudo apt upgrade podman 不会停止已在运行的容器;一个容器的监控进程崩溃也不会连带影响其他容器。

没有守护进程也会带来代价。系统重启后,不会有任何组件自动启动容器。Docker 的 --restart=always 是守护进程在启动时兑现的保证,而 Podman 使用 systemd 替代它;下面的 quadlet 部分就是用于此目的。

套接字是另一个关键部分。/var/run/docker.sock 是一个由 root 拥有的 API(应用程序编程接口)端点,任何能够向其写入的进程都可以启动一个挂载主机文件系统的特权容器。将用户加入 docker 组,等于通过更隐蔽的方式授予该用户 root 权限;建议结合仅向每个服务账户授予其所需的访问权限一并阅读。除非明确请求,Podman 不会暴露套接字;创建的套接字属于 /run/user/<uid>/podman/podman.sock 处的单个用户。

在 Ubuntu 24.04 上安装 Podman,并确认 rootless 配置确实生效

sudo apt update
sudo apt install -y podman uidmap
podman --version
podman info | grep -i rootless

uidmap 软件包提供 newuidmapnewgidmap。它们是 setuid 辅助程序,可让普通用户使用一段 subordinate ID;缺少这些程序时,rootless 容器无法启动。podman info 应输出 rootless: true

截至 2026 年 8 月,Ubuntu 24.04 提供 Podman 4.9,Debian 13 提供 Podman 5.x。版本差异很重要,因为 quadlet 文件需要 4.4 或更高版本,而 .pod quadlet 文件需要 5.0。将上游文档中的示例复制到本地前,先运行 podman --version

每个 rootless 用户都需要一段 subordinate ID:

grep "$USER" /etc/subuid /etc/subgid

在 Ubuntu 上通过 adduser 创建的用户会自动获得这段 ID。通过 useradd -M 或配置工具创建的用户通常不会获得,错误信息会明确说明原因:

Error: cannot find UID/GID for user deploy: no subuid ranges found for user "deploy" in /etc/subuid - check rootless mode in man pages.

分配一段 ID,然后重置该用户的存储,使其使用新的映射:

sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 deploy
podman system migrate

首次运行时还可能遇到另一个问题:Podman 不会默认使用 Docker Hub。短镜像名称会根据 /etc/containers/registries.conf 中的 unqualified-search-registries 进行解析;在未连接终端的脚本中,拉取镜像会因 short-name resolution enforced but cannot prompt without a TTY 而失败。每次都写完整的镜像名称。使用 docker.io/library/nginx:1.27,不要使用 nginx

rootless 容器在租用服务器上究竟能带来什么

rootless 容器运行在 user namespace 中。这是内核提供的一项功能,可为进程创建独立的用户 ID 映射。在该命名空间内,容器的超级用户是 UID(用户 ID)0。在命名空间外,也就是 VPS 上,同一进程对应的是您的普通登录用户。容器中的 root 不是主机上的 root。

这就是它实际带来的安全收益。某个镜像要求以 root 身份运行,某个 Web 应用存在远程代码执行漏洞,或者某种逃逸依赖于在命名空间外拥有 UID 0:这些情况下,相关进程最终获得的都是您这个非特权用户的权限,而不是整台机器的权限。rootless 无法防御内核漏洞,也无法保护您自己的文件,因为逃逸后的进程仍以您的身份运行,并且可以读取您能够读取的所有内容。

Docker 也可以以 rootless 模式运行。dockerd-rootless-setuptool.sh install 会为每个用户设置独立的 daemon,实际使用效果良好。区别在于默认配置的方向不同。使用 Podman 时,默认无需额外操作即可采用 rootless 模式。因此,您遇到的第一个问题会是容器无法绑定 80 端口,而不是某个服务静默地以 root 身份运行了两年。

为什么卷中的文件归 UID 100999 所有?

原因仍然是同一个用户命名空间。容器 UID 0 会映射到您的主机 UID。容器 UID 1 会映射到 subuid 范围中的第一个 ID,后续 UID 依次递增。若范围从 100000 开始,容器 UID 1000 在主机上就会对应 100999。

mkdir -p "$PWD/data"
podman run --rm --user 1000 -v "$PWD/data:/data" docker.io/library/alpine:3 sh -c 'id -u; touch /data/f'
ls -ln "$PWD/data"

容器显示 1000。主机上的目录列表显示所有者为 100999,因为 100000 加上 1000 再减去 1 等于 100999。这不是故障,直接执行 chown 也无法修复,因为您的非特权用户在该命名空间之外完全无权更改文件所有权。

有 4 种解决方法:

  • podman unshare chown 1000:1000 "$PWD/data" 在同一个用户命名空间中执行 chown,此时这些数字的含义与容器内一致。
  • -v "$PWD/data:/data:U" 请求 Podman 为您修正源目录的所有权。请在新目录上使用,不要用于包含重要数据的目录。
  • --userns=keep-id 将您的主机 UID 映射为容器内的相同 UID,使新建文件归您所有。
  • 使用 -v appdata:/data 这样的命名卷可以避免这个问题,因为 Podman 会在您自己的存储中创建该卷,并预先设置正确的所有权。

如果您曾在 Docker 中处理过这个问题,那么这只是多了一层映射的同一个问题。许多镜像提供的 PUID 和 PGID 变量会设置容器内进程使用的 UID;在 rootless Podman 下,该 UID 随后还会再次映射。rootless 容器中的 PUID=1000 仍会创建归 100999 所有的主机文件。设置这些数字时要考虑第二次映射,或者将数据移到命名卷中,不再处理这个问题。

关于挂载,还有两点需要注意。在 Fedora 和 RHEL 示例中看到的 :z:Z 标志用于 SELinux 重新标记;Ubuntu 使用 AppArmor,因此这些标志在那里不起作用。rootless Podman 也无法挂载您的用户无权读取的主机目录,这是预期的权限限制,不是故障。

为什么 rootless Podman 无法发布 80 端口?

因为绑定 1024 以下的端口需要您当前用户没有的权限。错误信息会指出解决方法:

Error: rootlessport cannot expose privileged port 80, you can add 'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024), or choose a larger port number (>= 1024): listen tcp 0.0.0.0:80: bind: permission denied

有两种可行方案。降低整台主机的阈值:

echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/99-podman.conf
sudo sysctl --system
sysctl net.ipv4.ip_unprivileged_port_start

最后一条命令应返回 80。请明确该设置的作用:现在机器上的每个用户都可以绑定 80 和 443 端口,而不只是运行容器的用户。在只有一名管理员的 VPS 上,这种权衡可以接受。在包含其他用户账户的主机上,则不应这样做。另一种方案是在 8080 端口上发布服务,再在前面放置反向代理;证书正应由 nginx 上的 certbot 申请和续期

rootless 发布还会改变应用看到的信息。Podman 4.x 默认使用 slirp4netns 和 rootlesskit 端口处理程序。转发的连接会带有重写后的源地址,因此访问日志会将每个访客记录为 10.0.2.100。Podman 5.0 将默认值改为 pasta,可以保留真实的客户端地址。在 4.x 上,--network slirp4netns:port_handler=slirp4netns 可恢复真实源地址,但会牺牲一定吞吐量。

这里有一个容易被忽略的优点。rootless 发布的端口是由普通进程拥有的普通监听套接字,因此防火墙的入站规则会对其生效。Docker 发布端口时,会写入 NAT(网络地址转换)规则以及自身的转发放行规则。这正是 已发布的 Docker 端口会绕过您以为正在阻止它的 ufw 规则 的原因。Rootful Podman 使用类似的机制,也会遇到同样的问题。Rootless 不会。

Podman 下的 Docker Compose 文件还能正常使用吗?

大多数情况下可以,但有两种不同的方式。第一种是 podman-compose,这是一个独立实现,读取相同的文件并调用 Podman CLI:

sudo apt install -y podman-compose
podman-compose up -d
podman ps

第二种是使用真正的 Docker Compose,通过每个用户的套接字访问 Podman 兼容 Docker 的 API:

systemctl --user enable --now podman.socket
export DOCKER_HOST="unix:///run/user/$(id -u)/podman/podman.sock"
docker compose up -d
podman ps

docker compose pspodman ps 应列出相同的容器,因为实际只有一组容器。名称解析同样有效:Podman 的默认网络后端 netavark 会运行 aardvark-dns,因此,连接到用户自定义网络的容器可以通过名称相互发现。

但也存在实际限制。任何挂载 /var/run/docker.sock 的配置都必须改为指向 Podman 套接字,或者删除该配置。network_mode: host 在用户命名空间下的行为有所不同。不同 podman-compose 版本对带有 condition: service_healthydepends_on 支持程度不一。restart: always 不会自行在重启后恢复,下一节将解决这一问题。Compose 仍然适合用于在单个文件中描述多容器堆栈;在 Podman 下,它属于转换层。如果某个堆栈计划长期运行,请将其转换为 quadlet,并维护一套抽象,而不是两套。

Pods: the idea Docker has no answer for

A pod is a group of containers that share one network namespace. Podman starts a small infra container to hold that namespace open, and the members then reach each other on 127.0.0.1 with no user-defined network and no service discovery involved.

podman pod create --name app -p 8080:80
podman run -d --pod app --name app-cache docker.io/library/redis:7
podman run -d --pod app --name app-web docker.io/library/nginx:1.27
podman pod ps
podman ps --pod

podman pod ps should show the pod Running with three containers, counting the infra container. The web container now reaches Redis at 127.0.0.1:6379 rather than at app-cache:6379. Two rules follow from the shared namespace: publish ports on the pod and never on a member, and no two members may listen on the same port.

This is the Kubernetes model, and Podman leans into it. podman kube generate app > app.yaml writes a Kubernetes manifest from what is running (older packages spell it podman generate kube), and podman kube play app.yaml recreates it on another host. Quadlet has a .kube unit type that runs such a file as a systemd service. It is a genuinely different way to group services, and it is the strongest reason to choose Podman if Kubernetes is anywhere in your future.

无 daemon 的自动启动:quadlet 单元

Quadlet 是 systemd 生成器。它会将描述容器的简短文件转换为真实的 systemd 服务,并在启动时运行。rootless 用户的文件放在 ~/.config/containers/systemd/,root 的文件放在 /etc/containers/systemd/

~/.config/containers/systemd/caddy.container

[Unit]
Description=Caddy web server
After=network-online.target

[Container]
Image=docker.io/library/caddy:2
ContainerName=caddy
PublishPort=8080:80
Volume=caddy-data.volume:/data
Environment=TZ=UTC
AutoUpdate=registry

[Service]
Restart=always
MemoryMax=512M

[Install]
WantedBy=default.target

~/.config/containers/systemd/caddy-data.volume 几乎可以为空,因为该节标题会创建卷:

[Volume]
systemctl --user daemon-reload
systemctl --user start caddy
systemctl --user status caddy
journalctl --user -u caddy -n 50

服务名称来自文件名:caddy.container 会变成 caddy.service。不要运行 systemctl --user enable caddy。生成的单元无法启用,systemd 会返回 Failed to enable unit: Unit /run/user/1000/systemd/generator/caddy.service is transient or generated.[Install] 节负责在启动时启动容器,daemon-reload 则会在您编辑文件后重新生成单元。

现在介绍几乎所有人都会遇到的设置:

sudo loginctl enable-linger deploy
loginctl show-user deploy --property=Linger

应得到 Linger=yes。如果未启用 linger,最后一个 SSH 连接关闭时,systemd 会拆除整个用户会话,因此所有 rootless 容器都会随之停止,并且不会在启动时重新运行。退出登录后消失的容器,原因始终是这个。

由于容器是普通服务单元的主进程,systemd 自身的控制机制会直接生效。[Service] 节中的 MemoryMax=CPUQuota=您使用 systemd 限制的其他服务完全相同。这需要 cgroup v2(控制组版本 2);Ubuntu 从 22.04 开始默认使用该版本。使用 podman info | grep -i cgroup 确认。

更新也有对应的机制。AutoUpdate=registry 加上 systemctl --user enable --now podman-auto-update.timer 会检查注册表中同一标签是否有更新的镜像,重启单元;如果新容器启动失败,则回滚到之前的镜像。先运行 podman auto-update --dry-run,查看它将进行哪些更改。旧的 podman generate systemd 命令仍然存在,但已弃用,因此新内容应使用 quadlet 编写。

docker 别名适用和不适用的场景

sudo apt install -y podman-docker
sudo touch /etc/containers/nodocker
docker ps

podman-docker 会安装一个 /usr/bin/docker 包装器,用于调用 Podman。没有 nodocker 文件时,每次调用都会先输出 Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.。该包装器覆盖了日常使用的命令:runpslogsexecbuildpullpushinspectcpvolumenetwork

但并非所有功能都能迁移,而且限制更加明确。Swarm 模式没有等效功能,因此 Swarm stack 无处运行。需要访问 Docker socket 的工具必须导出 Podman socket,其中一些工具仍能识别两者的差异;将 Traefik 的 Docker provider 指向 /run/user/<uid>/podman/podman.sock 后可以正常工作,而 Watchtower 完全没有对应位置,因为 podman auto-update 已经承担了该功能。存储彼此独立,因此 Podman 看不到使用 Docker 拉取的已有镜像,并且在繁忙的 Docker 主机上,podman images 初始为空。

逐步迁移正在运行的服务栈

  1. 创建或选择一个非特权用户,让它作为容器的所有者,并确认该用户在 /etc/subuid 中有可用的用户 ID 范围。
  2. 对所有来自镜像仓库的内容重新执行拉取,并使用完全限定名称。Podman 使用独立的镜像存储,不会读取 Docker 的镜像。
  3. 使用 docker save app:1.4 | podman load 迁移本地构建的镜像。
  4. 停止 Docker 容器,将每个卷中的内容从 /var/lib/docker/volumes/<name>/_data 复制出来,然后使用 podman unshare chown -R 1000:1000 <path> 修正所有权。
  5. 确定端口方案:在反向代理后发布 1024 以上的端口,或设置 net.ipv4.ip_unprivileged_port_start
  6. 为每个容器编写一个 quadlet 文件,运行 systemctl --user daemon-reload,然后启动每项服务。
  7. 运行 sudo loginctl enable-linger <user>,重启 VPS,重新登录,并检查 podman ps 是否再次列出所有服务。

两个引擎之间不共享任何内容:镜像存储和网络都是独立的。因此,迁移期间可以同时运行两者,唯一可能发生冲突的是主机端口号。先迁移一项服务,监控一天,再迁移下一项。

Podman 与 Docker:VPS 应该使用哪一个?

如果您的技术栈使用由他人共同维护的 compose 文件,或者依赖与 Docker socket 通信的工具,请继续使用 Docker。兼容其他人编写的配置本身就是一项实际优势,而 Docker 在这方面支持更多。如果团队成员的笔记本电脑都运行 Docker,那么生产环境使用相同的容器引擎也能带来明确收益。

如果 VPS 只运行少量由您全程控制的服务,或者您希望每个应用都由独立的非特权用户运行,并且主机上完全没有 docker 组,请切换到 Podman。发行版的一致性也很重要:RHEL 及其重构发行版将 Podman 作为受支持的容器引擎,因此在这些系统上使用 Podman 遇到的意外更少。如果您已经使用 systemd unit 管理其他所有服务,那么 quadlet 更像是补齐缺失功能,而不是需要重新学习的新工具。

还有一种中间方案值得说明。Rootful Podman 的行为与 Docker 很相似,通过包装器保留 docker 命令,同时移除持续运行的 daemon。它也放弃了 rootless 模式,而后者正是会改变安全状况的部分,因此应将其视为过渡方案。

如果您仍在构建第一个容器主机,在全新 VPS 上设置并加固 Docker 是更短的路径,而之前掌握的知识也不会浪费。两种引擎使用相同的镜像和卷对象,因此日后迁移时,变化主要在于服务的管理方式,其他方面几乎不变。

FAQ

Podman 是 Docker 的直接替代品吗?

对于您输入的命令,基本可以。安装 podman-docker 后会提供一个 /usr/bin/docker 包装器,runpsbuildlogsexec 的行为也相同。但它不是 daemon 的替代品。它没有 Swarm 的等效功能;连接到 /var/run/docker.sock 的工具必须改为连接每个用户的 Podman socket;Docker 拉取的镜像对 Podman 不可见,因为两者使用独立的存储。

为什么 rootless Podman 容器会在我退出 SSH 后停止?

因为您最后一个登录会话关闭后,systemd 会停止用户会话及其中的所有用户服务。运行 sudo loginctl enable-linger <user>,然后确认 loginctl show-user <user> --property=Linger 输出 Linger=yes。Linger 会在没有活动会话时继续运行该用户的 systemd 实例,这也是容器能在重启后再次启动的原因。

为什么我的卷中的文件归 UID 100999 所有?

Rootless Podman 会将容器 UID 0 映射到您的主机用户,然后将容器 UID 1 及更高 UID 映射到您的 subuid 范围。如果该范围从 100000 开始,容器 UID 1000 在主机上就会变成 100999。使用 podman unshare chown 1000:1000 /path/to/data 从 namespace 内部修正权限;首次运行时使用 :U 标志挂载;或者使用 --userns=keep-id,让容器 UID 与您自己的 UID 匹配。

我可以继续在 Podman 中使用 docker-compose.yml 吗?

可以,有两种方式。podman-compose 会读取该文件,并直接驱动 Podman CLI。或者使用 systemctl --user enable --now podman.socket 启用兼容 socket,设置 DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock,然后通过该 socket 运行真正的 docker compose。使用 network_mode: host 时可能会遇到兼容性问题;挂载 Docker socket 的服务也可能有问题。restart: always 需要 quadlet unit 和 linger,才能在重启后继续运行。

rootless 真的能让容器更安全吗?

它消除了一个特定风险:如果进程从 rootless 容器中逃逸,该进程获得的是非特权用户的权限,而不是 root 的权限。这项防护很有价值,也是 rootless Podman 没有与 root 等效的 docker 组的原因。但它无法防止内核漏洞,也无法保护您自己的用户能够读取的文件。因此,仍应执行在任何服务器上都会进行的其他加固措施。