Docker 与 Podman 的存储架构:进程身份、服务端点与数据边界
梳理 Docker 与 Podman 在 rootful、rootless 和 API service 模式下由谁管理存储、数据落在哪里,以及 endpoint、context、sudo 和有效 UID 分别改变了什么。
You’re viewing the Chinese version because no English version is available.
- First published
- Last updated
On this page
Docker Engine 采用客户端—服务端架构:docker 是 CLI 客户端,dockerd 是持续运行的守护进程,两者通过 REST API 通信。执行 docker pull 时,CLI 把请求交给 dockerd,由它下载并保存镜像;执行 docker images 时,CLI 显示的也是 dockerd 返回的列表。因此,同一个 Docker CLI 连接不同的 dockerd,看到的内容就会不同。
Podman 的默认形态没有守护进程。直接执行 podman 时,打开容器存储的是这个命令进程本身,存储位置由它的有效 UID 决定。因此 rootful 与 rootless 这组词在两者中指向不同的对象:对 Docker 指 daemon 的运行身份,对 Podman 指执行命令那个进程的运行身份。
两条规则并非彼此隔绝。Podman 也可以作为服务常驻,监听一个兼容 Docker API 的 socket,docker context ls 中因此可能出现指向 Podman 的端点。此时决定存储的既不是执行命令的用户,也不是任何一个 dockerd,而是运行该 Podman 服务的用户。
Docker Rootful
rootful 说的是 dockerd 以 root 身份运行,和执行 docker 的用户没有关系。在通常的 Linux 安装中,它由系统服务启动,监听 unix:///var/run/docker.sock。镜像、容器和卷等持久状态全部由它管理、写入它配置的数据目录,CLI 自己不打开任何镜像目录。因此一条命令的可见范围先由端点决定,再由该 daemon 的配置决定:
docker CLI └── -H / -c / DOCKER_HOST / DOCKER_CONTEXT / 当前 context └── Docker API endpoint └── dockerd └── 该 daemon 的数据目录端点的解析顺序
链条的第一环由客户端解析,顺序固定:
-H/--host直接给出端点,-c/--context指定一个已保存的 context。两者互斥,同时给会报conflicting options: cannot specify both --host and --context。- 命令行都没给时看环境变量,
DOCKER_HOST优先于DOCKER_CONTEXT。 - 环境变量也没有时,用
docker context use选中的 context。
第 3 步读的是 $HOME/.docker:context 本身和其余客户端配置都存在那里,可以用 --config 或 DOCKER_CONFIG 指到别处。这一条是后续全部分歧的根源:端点的默认来源随执行 CLI 的用户而变。
本文演示用的机器上保存了三个 context:
docker context ls --format 'table {{.Name}}\t{{.DockerEndpoint}}'NAME DOCKER ENDPOINTdefault unix:///var/run/docker.sockdocker-rootless unix:///run/user/1000/docker.sockpodman unix:///run/user/1000/podman/podman.sockcontext 名称由安装方式决定,其他机器上并不相同;有效信息是右侧一列,三个端点各自对应一套独立的数据。
default 是其中的特例。它并非保存下来的 context,而代表“由 DOCKER_HOST 决定的配置”;DOCKER_HOST 未设置时才回落到内置的 unix:///var/run/docker.sock。因此 -c default 无法覆盖环境变量,已保存的 context 名称则可以:
# 基准:default 指向 rootful daemondocker -c default info --format '{{.DockerRootDir}}'
# 增加 DOCKER_HOST 后,-c default 未能覆盖它DOCKER_HOST=unix:///run/user/1000/docker.sock docker -c default info --format '{{.DockerRootDir}}'
# 改用已保存的 context 名称,DOCKER_HOST 被覆盖DOCKER_HOST=unix:///var/run/docker.sock docker -c docker-rootless info --format '{{.DockerRootDir}}'/var/lib/docker/home/<user>/.local/share/docker/home/<user>/.local/share/dockersudo 改变的是客户端,不是 daemon
普通用户的 docker images 与 root 执行的同一命令,只要连接同一个 /var/run/docker.sock,查询的就是同一个 daemon、同一份镜像数据。存储位于服务端,客户端更换身份不会改变它。
但两条命令未必解析到同一个端点,因为 sudo 正好影响解析顺序中的第 2、3 步。它默认启用 env_reset,命令得到的是一个最小环境:调用者的 DOCKER_HOST 和 DOCKER_CONTEXT 不会传过去(除非在 sudoers 的 env_keep 里放行),HOME 则按目标用户重新初始化。因此 sudo docker 读取的是 /root/.docker,普通用户通过 docker context use 选定的 context 在其中并不存在,只能回落到 root 一侧的当前 context。
实际的分歧出现在 rootless Docker 的两种常见配置方式上:
- 安装脚本创建一个 rootless context 并通过
docker context use切换过去。该选择记录在普通用户的~/.docker中,root 一侧没有对应记录,sudo docker回落到default,即/var/run/docker.sock后面的 rootful daemon。 - 按安装提示在 shell 配置中
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock。env_reset会清除该变量,sudo docker同样回到 rootful daemon。
两种配置下,docker images 查询的是用户自己的 rootless 存储,sudo docker images 查询的是 rootful daemon 的存储。“刚拉取的镜像加上 sudo 后消失”即源于此:镜像并未丢失,这两条命令始终没有查询同一个 daemon。
无需真正使用 sudo 即可复现。下面第二条命令用 env -i 清空环境,并将 HOME 指向一个不含 .docker 的目录,与 sudo 交给 root 的环境等效:
DOCKER_HOST=unix:///run/user/1000/docker.sock docker info --format '{{.DockerRootDir}}'env -i PATH="$PATH" HOME=/tmp docker info --format '{{.DockerRootDir}}'/home/<user>/.local/share/docker/var/lib/docker同一用户、同一台机器,仅因环境不同,两条命令就落在两个 daemon、两套存储上。差异全部来自端点解析,而非 sudo 为 daemon 产生了第二套存储。固定端点后差异随即消失:
docker -H unix:///var/run/docker.sock image lssudo docker -H unix:///var/run/docker.sock image ls两条命令查询的是同一个 rootful dockerd,返回同一份镜像。排除客户端配置干扰最可靠的做法就是显式指定 -H。
数据目录与存储后端
端点确定之后,数据的位置由该 daemon 决定,且分为两部分。
容器配置、日志、卷等状态写在 daemon 的数据根目录,也就是 docker info 里的 Docker Root Dir,Linux 上默认 /var/lib/docker。要改它,在 /etc/docker/daemon.json 里写上 "data-root": "/mnt/docker-data" 再重启 daemon;等价的写法是在 dockerd 的启动命令行上加 --data-root /mnt/docker-data,由 init 系统拉起它时传入。dockerd 是守护进程本身而非查询工具,在终端中直接执行它等同于尝试再启动一个 daemon;查看选项应使用 dockerd --help。
镜像本身则不一定在那里。Docker Engine 29.0 及以后版本的全新安装默认使用 containerd image store,镜像数据交给 containerd 的两个子系统:content store 保存从 registry 取回的原始字节,包括 image index、manifest、config 和压缩的 layer blob,按内容摘要寻址;snapshotter 保存解包后的镜像层,并为每个容器叠一个可写快照作为根文件系统。containerd 的 Content Flow 描述了完整过程。(从旧版本升级的安装保持原有的 storage driver,镜像层和容器可写层仍在 Docker Root Dir 之下,例如 overlay2 用的是 /var/lib/docker/overlay2。)
content store 与 snapshotter 由 containerd 管理,不受 Docker 的 data-root 控制,其位置取决于 dockerd 连接的是哪个 containerd。dockerd 启动时先检查 containerd 默认地址上有没有 socket:有就直接使用那个独立运行的 containerd,没有才启动自己的 managed containerd。前者的数据位置由该 containerd 配置里的 root 决定,常见默认值是 /var/lib/containerd;后者由 Moby 决定,默认放在 Docker Root Dir 下的 containerd/daemon。
Docker 文档给出的位置是 /var/lib/containerd,并说明 data-root 不影响它,要改得去写 /etc/containerd/config.toml。该描述适用于随包安装了独立 containerd 服务的系统;dockerd 启动自带的 managed containerd 时并不成立,目录随 Docker Root Dir 变化。与其记忆路径,不如按上一节的做法固定端点,直接查询该 daemon:
docker -H unix:///var/run/docker.sock info \ --format 'Docker Root Dir: {{.DockerRootDir}}{{println}}Storage Driver: {{.Driver}}{{println}}driver-type: {{range .DriverStatus}}{{index . 1}}{{end}}'Docker Root Dir: /var/lib/dockerStorage Driver: overlayfsdriver-type: io.containerd.snapshotter.v1Storage Driver 一行需要留意:overlayfs 是 containerd snapshotter 的名称,storage driver 写作 overlay2,二者仅相差两个字符。判据是 driver-type,io.containerd.snapshotter.v1 表示这个 daemon 走的是 containerd image store。
Docker Rootless
Rootless Docker 把 dockerd 和容器一起放进一个用户命名空间:daemon 进程在宿主机上属于普通用户,只有在命名空间内部才是 root。它并非让普通用户读取 rootful daemon 的数据目录,而是另行运行一套完整的 daemon,拥有独立的 socket 和数据目录:
socket: $XDG_RUNTIME_DIR/docker.sockdata: ~/.local/share/docker前提是该用户在 /etc/subuid 和 /etc/subgid 中各拥有一段不少于 65536 个的从属 ID,命名空间内的 root 才有可映射的 UID 区间。
另一个端点,另一套数据
官方的 dockerd-rootless-setuptool.sh install 会创建一个名为 rootless 的 CLI context 并切换过去,并提示可改用 export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock;发行版打包的安装可能使用其他 context 名称,本文机器上为 docker-rootless。两种方式下,端点的解析规则与上一章完全一致。
同一个 CLI 因此可以在两个 daemon 之间切换:
docker -c default imagesdocker -c docker-rootless images两次结果不同并非 context 自身保存了镜像,而是两个 context 指向不同的 daemon。context 是连接配置,daemon 才是状态边界。
name=rootless 是判据
docker -c docker-rootless info \ --format 'Docker Root Dir: {{.DockerRootDir}}{{println}}Storage Driver: {{.Driver}}{{println}}SecurityOptions: {{json .SecurityOptions}}'Docker Root Dir: /home/<user>/.local/share/dockerStorage Driver: overlay2SecurityOptions: ["name=seccomp,profile=builtin","name=rootless","name=cgroupns"]前两行都与上一章不同。数据根目录在 ~/.local/share 之下,而非 /var/lib/docker;存储后端是 overlay2,而同一台机器上的 rootful daemon 用的是 containerd image store。两个 daemon 的后端可以完全不同,因此后端只能逐个 daemon 查询,不能按 Engine 版本推定。
判断 rootless 的依据是 SecurityOptions 中的 name=rootless。命令是否带 sudo 不构成证据,socket 位于 $XDG_RUNTIME_DIR 下也只是惯例,唯一可靠的是 daemon 自身报告的这一项。
权限不是存储模型
前两章都假定普通用户能够连上 rootful daemon 的 socket。这件事本身由文件权限决定:
stat -c '%U:%G %A %n' /var/run/docker.sockroot:docker srw-rw---- /var/run/docker.socksocket 属 root:docker 且组可读写,因此 docker 组成员无需 sudo 即可请求它。rootless daemon 的 socket 位于 $XDG_RUNTIME_DIR 下、直接属于用户本人,同理不需要额外授权。
但这只决定谁能建立连接,不决定连接之后看到谁的数据——后者始终由端点后面的服务决定。Docker 官方文档对前一半有明确警告:docker 组授予的是 root 级能力。能够控制 rootful daemon 即等同于在主机上取得 root。该组解决的是控制权,而非为每个组成员划分独立的镜像空间。
这也解释了一个常见误解。加入 docker 组之后日常操作不再需要 sudo,但命令连的仍然是那个以 root 运行的 daemon——“不用 sudo”恰恰是 docker 组的效果,不是 rootless 的证据。要确认某个 daemon 是否 rootless,看它自己报告的 SecurityOptions:
docker -c default info --format '{{json .SecurityOptions}}'docker -c docker-rootless info --format '{{json .SecurityOptions}}'["name=seccomp,profile=builtin","name=cgroupns"]["name=seccomp,profile=builtin","name=rootless","name=cgroupns"]只有 rootless daemon 会报告 name=rootless。socket 路径和 context 名称都不是判据:前者只是安装惯例,后者在安装时任意指定。
Podman Rootless
Podman 默认不运行常驻 daemon。执行 podman images 时,打开存储的就是这个 podman 进程本身,没有服务端代它做任何选择。因此 rootless 与 rootful 在 Podman 这里不是两套分别安装的东西,而是同一个二进制每次调用时的属性,取决于该进程的有效 UID。
用户命名空间与 subuid
一个非 root 的进程要自己管理容器存储,会遇到两处限制。它无法写入 root 所有的系统目录;而镜像层里的文件分属各种 UID 和 GID,解包时需要按原样创建出来,普通进程却只持有自己一个 UID。
前一处限制靠换目录解决,后一处必须靠用户命名空间。podman unshare 的说明给出了它的构造方式:调用者的 UID 和主 GID 在新命名空间中表现为 0 和 0,/etc/subuid、/etc/subgid 中匹配该用户的区间借助 newuidmap(1)、newgidmap(1) 一并映射进来。这两个文件里的区间由管理员分配,是 rootless Podman 的前提条件(见 rootless tutorial)。
映射关系可以直接读出:
grep "^$(id -un):" /etc/subuidpodman unshare cat /proc/self/uid_map<user>:100000:65536 0 1000 1 1 100000 65536uid_map 的三列依次是命名空间内起始 ID、宿主机上起始 ID、区间长度。第一行把命名空间内的 root 映射到宿主机的 UID 1000,第二行把内部的 1–65536 映射到宿主机自 100000 起的那一段,正是 /etc/subuid 分给该用户的区间。
因此容器内的 root 在宿主机上仍是这个普通用户,容器写出的文件归该用户或归那段从属 ID 所有。存储必须位于该用户有写权限的目录中。
graph root 与 run root
containers/storage 使用两个根目录。graph root 保存持久数据——镜像、层、容器和卷都在它下面;run root 保存运行期状态,重启后不需要保留。命令行上对应 podman --root 与 --runroot,配置文件中对应 graphroot 与 runroot。
本机上的实际取值:
podman info --format 'configFile: {{.Store.ConfigFile}}{{println}}graphRoot: {{.Store.GraphRoot}}{{println}}runRoot: {{.Store.RunRoot}}{{println}}graphDriver: {{.Store.GraphDriverName}}{{println}}rootless: {{.Host.Security.Rootless}}'configFile: /home/<user>/.config/containers/storage.confgraphRoot: /home/<user>/.local/share/containers/storagerunRoot: /run/user/1000/containersgraphDriver: overlayrootless: true这些值解析自 containers-storage.conf。该文件有三处,后者覆盖前者:发行版提供的 /usr/share/containers/storage.conf、管理员的 /etc/containers/storage.conf,以及 rootless 用户自己的 $XDG_CONFIG_HOME/containers/storage.conf(未设置时为 ~/.config/containers/storage.conf)。文档写明,拥有自己那一份的用户不再使用系统文件中的任何选项——上面 configFile 一行正是这种情况。
配置文件未设置相应项时才使用内置默认值。rootless 用户的 graph root 可由配置项 rootless_storage_path 指定;未指定时默认为 $XDG_DATA_HOME/containers/storage,XDG_DATA_HOME 也未设置时为 $HOME/.local/share/containers/storage。
生效的配置文件和默认路径因此都位于执行命令那个用户的 $HOME 之下。换一个 UID 执行 podman,配置与路径同时改变;没有常驻进程居中调停,两次执行访问的就是两套独立的 store。
graph root 的目录布局
ls -x ~/.local/share/containers/storagedb.sql defaultNetworkBackend libpod networksoverlay overlay-containers overlay-images overlay-layerssecrets storage.lock userns.lock volumesoverlay-images、overlay-layers、overlay-containers 分别记录镜像、层与容器,overlay 是驱动挂载使用的目录,libpod 与 db.sql 属于 Podman 自身的状态。与 Docker 一样,镜像数据和引擎自身状态是两部分内容,区别在于此处二者同处一个 graph root 之下,而不像 Docker 那样分属 containerd 和 dockerd 两个进程。
Podman Rootful
有效 UID 为 0 时,同一个二进制走的是另一条分支。它读的是系统的 /etc/containers/storage.conf(或发行版提供的那份),graph root 落到 UID 0 的默认值 /var/lib/containers/storage;上一章那套用户命名空间也不再需要,因为进程本身就持有全部 UID,能够按原样创建镜像层里的文件。
sudo podman 正是这条分支:sudo 把有效 UID 换成 0,配置来源和存储位置随之整体切换。sudo 本身没有任何特殊之处,以 root 登录后直接执行 podman 结果相同,起作用的只是有效 UID。
podman imagessudo podman images这两条命令通常访问两套完全不同的数据。Podman 文档对此有明确表述:非 root 用户创建的容器对其他用户不可见,以 root 身份运行的 Podman 也不会看到或管理它们。
确认 root 一侧的实际位置同样应查询 podman info,而不是套用默认路径:
sudo podman info --format 'configFile: {{.Store.ConfigFile}}{{println}}graphRoot: {{.Store.GraphRoot}}'Docker 与 Podman 最容易被混为一谈的正是这一点。对 Docker,sudo 改变的是客户端进程的身份,而存储归 daemon 所有,端点不变就不会多出一套存储;对直接执行的 Podman,sudo 改变的是打开存储那个进程自身的身份,配置文件和 graph root 都跟着换,等于换掉了整个 store。同一个前缀,在两者中作用于完全不同的对象。
Podman API service
前四章中,Docker 依据端点、Podman 依据 UID,是两条独立的规则。podman system service 将两者连接起来:Podman 可以作为服务常驻,监听一个兼容 Docker API 的 socket,rootless 时默认位于 $XDG_RUNTIME_DIR/podman/podman.sock。协议本身就是 Docker API,Docker CLI 可以直接连接——docker context ls 中指向 Podman socket 的端点即来源于此。
这条路径不创建任何新的存储,只是为现有的 rootless Podman store 增加了一个入口:
docker -c podman info \ --format 'Docker Root Dir: {{.DockerRootDir}}{{println}}Storage Driver: {{.Driver}}{{println}}Server Version: {{.ServerVersion}}'Docker Root Dir: /home/<user>/.local/share/containers/storageStorage Driver: overlayServer Version: 5.8.6Docker CLI 将 Podman 的 graph root 填入了 Docker Root Dir 一栏,Server Version 报告的是 Podman 的 5.8.6,而非本机 Docker 的 29.x。镜像列表同样是同一份:
docker -c podman image lspodman imagesIMAGE ID DISK USAGE CONTENT SIZE EXTRAdocker.io/library/ubuntu:latest 30ba44506a6d 112MB 0B Udocker.io/osrf/ros:lyrical-desktop-full 9c2a537078f4 6.64GB 0B Udocker.io/example/app:latest 08e1fe51ce2e 5.17GB 0B U
REPOSITORY TAG IMAGE ID CREATED SIZEdocker.io/osrf/ros lyrical-desktop-full 9c2a537078f4 2 weeks ago 6.64 GBdocker.io/library/ubuntu latest 30ba44506a6d 4 months ago 112 MBdocker.io/example/app latest 08e1fe51ce2e 5 months ago 5.17 GB镜像 ID 逐个对应,两张表格的差异仅在客户端排版,数据是同一份。
由此得到一条补充规则:Podman 以服务形式被访问时,决定 store 的不再是执行命令的用户,而是运行该 service 的用户。rootless 用户启动的 service 指向该用户的 store,root 启动的 service 指向 /var/lib/containers/storage。同理,Podman 的 --remote 或 CONTAINER_HOST 会将命令转发给远端 Podman 服务,此时本地 UID 也不再起决定作用。