目录

Docker 底层原理教程:实测 namespace、cgroup 与 overlayfs

什么是 Docker 用比喻说明了容器;这一节直接到 Docker 主机上看看:容器在 Linux 中到底是什么。答案只有一句话:

容器就是一个普通的 Linux 进程,只是被 namespace 限制了“能看到什么”、被 cgroup 限制了“能用多少”、用 overlayfs 组成的目录作为它的根目录,再由 capabilities 和 seccomp 限制“能做什么”。

容器是一个被 namespace、cgroup、overlayfs 与安全机制包围的 Linux 进程
容器 = 一个进程 + namespace + cgroup + overlayfs + 安全限制

以下的“主机”是本教程的 Docker 测试环境(Linux 5.4 内核、cgroup v1)。在你的电脑上运行时,ID、路径等数值会不同;使用 cgroup v2 的系统,文件名也不同,文中会注明。Docker Desktop 用户可以用 docker run -it --rm --privileged --pid=host alpine:3.23 nsenter -t 1 -m -u -i -n sh 进入 Docker Desktop 的 Linux 虚拟机观察。

容器就是主机上的一个进程

启动一个 nginx 容器,同时限制内存 256 MB、CPU 0.5 核:

$ docker run -d --name demo --memory 256m --cpus 0.5 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
57dcd17505d4e7c71e57df55edef3e137c18249e6ad402c7e7b3f215341c47b4

$ docker exec demo ps
PID   USER     TIME  COMMAND
    1 root      0:00 nginx: master process nginx -g daemon off;
   29 nginx     0:00 nginx: worker process
   30 root      0:00 ps

$ docker inspect -f "{{.State.Pid}}" demo
12458
  • 在容器里,nginx 主进程的 PID 是 1,而且只能看到自己的进程。
  • docker inspect 的 .State.Pid 是它在主机上的 PID:12458。

在主机上列出进程:

$ ps -o pid,ppid,args | grep -v -E 'ps -o|grep|cut' | cut -c1-110
PID   PPID  COMMAND
    1     0 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mir
   19     1 dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mir
12434     1 /usr/local/bin/containerd-shim-runc-v2 -namespace moby -id 57dcd17505d4e7c71e57df55edef3e137c18249
12458 12434 nginx: master process nginx -g daemon off;
12495 12458 nginx: worker process

主机上可以看到完整的进程树:dockerd → containerd-shim-runc-v2 → nginx: master process(12458)→ nginx: worker process。

  • containerd-shim-runc-v2:每个容器一个,负责看管容器的进程。dockerd 重启时容器不会跟着停止,就是因为有它。
  • runc:真正创建容器的程序。它设置好 namespace、cgroup、根目录后,就运行 nginx 并结束,所以在进程树中看不到它。

同一个进程,在容器内是 PID 1、在主机上是 PID 12458,这就是 PID namespace 的效果。

namespace:决定容器能看到什么

lsns 列出一个进程所在的 namespace:

$ lsns -p 12458 -o NS,TYPE,PID,COMMAND
        NS TYPE     PID COMMAND
4026531835 cgroup     1 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mirror.gcr.io --dns=1.1.1.1 --dns=8.8.8.8
4026531837 user       1 docker-init -- dockerd --host=unix:///var/run/docker.sock --host=tcp://0.0.0.0:2375 --registry-mirror=https://mirror.gcr.io --dns=1.1.1.1 --dns=8.8.8.8
4026533161 mnt    12458 nginx: master process nginx -g daemon off;
4026533162 uts    12458 nginx: master process nginx -g daemon off;
4026533163 ipc    12458 nginx: master process nginx -g daemon off;
4026533164 pid    12458 nginx: master process nginx -g daemon off;
4026533166 net    12458 nginx: master process nginx -g daemon off;

nginx 进程有自己的 5 个 namespace(ID 和主机的不同),另外 2 个(cgroup、user)与主机上的 PID 1 共用:

namespace隔离的内容容器中的效果
pid进程编号容器内只能看到自己的进程,主进程是 PID 1
net网络接口、IP、端口、路由容器有自己的 eth0 和 IP,可以各自监听 80 端口
mnt挂载点容器有自己的根目录 /
uts主机名容器的 hostname 是容器 ID
ipc共享内存、信号量容器之间不能通过 IPC 相互干扰
user用户 ID 映射默认不隔离,容器内的 root 就是主机的 root;Rootless 模式 才会使用
cgroupcgroup 目录的视野这个环境(cgroup v1)没有隔离;cgroup v2 的系统默认会隔离

uts 与 net namespace 的效果:

$ docker exec demo hostname
57dcd17505d4

$ docker exec demo ip -4 addr show eth0
2: eth0@if130: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    inet 172.18.0.2/16 brd 172.18.255.255 scope global eth0
       valid_lft forever preferred_lft forever

容器的主机名就是容器 ID 的前 12 位,eth0 有自己的 IP(172.18.0.2),这是 Docker 网络分配的,详见网络与容器互连。

cgroup:决定容器能用多少资源

--memory 256m --cpus 0.5 不是 Docker 自己计算的,而是写入 Linux 内核的 cgroup 设置。从容器内读出这些设置:

$ docker info --format "Cgroup Version: {{.CgroupVersion}}"
Cgroup Version: 1

$ docker exec demo cat /sys/fs/cgroup/memory/memory.limit_in_bytes
268435456

$ docker exec demo cat /sys/fs/cgroup/memory/memory.usage_in_bytes
10842112

$ docker exec demo cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us /sys/fs/cgroup/cpu/cpu.cfs_period_us
50000
100000

$ docker stats --no-stream --format "{{.Name}}  {{.MemUsage}}  {{.CPUPerc}}" demo
demo  5.613MiB / 256MiB  0.00%
读到的值含义
memory.limit_in_bytes = 268435456268435456 bytes = 256 MiB,就是 --memory 256m
memory.usage_in_bytes = 10842112目前约用了 10 MB(内核统计,包含文件缓存,所以比 docker stats 显示的大)
cpu.cfs_quota_us = 50000、cpu.cfs_period_us = 100000每 100 毫秒最多使用 50 毫秒的 CPU 时间,也就是 0.5 核

docker stats 显示的 256MiB 上限也是从 cgroup 读出来的。进程使用的内存超过上限时,内核会把它终止(OOM),退出代码是 137。

cgroup v2 系统(近年的 Ubuntu、Debian、Fedora,以及 Docker Desktop)的文件名不同:内存上限在 /sys/fs/cgroup/memory.max,CPU 配额在 /sys/fs/cgroup/cpu.max(内容是 50000 100000)。

overlayfs:容器的文件系统

镜像的层

镜像由多个只读的层组成:

$ docker image inspect -f "{{range .RootFS.Layers}}{{println .}}{{end}}" nginx:1.30-alpine
sha256:74d97c428c51a828f9051a7a40a53ff1fc99e54fc30323ce36760701b0b7f711
sha256:2d3fbe0ca19dd389108f7e9a1d89ec5c1f1d7dc3c11fb2123bc425fed39c5f22
sha256:ed752e851ebe79a0e72be1fd48e90474d36ae2bb2ddccff2475f8e050c20a804
sha256:ec2b9a359ab960e2254545e79e9bca77edaeefb652d97b26855217fb8a9c80b3
sha256:267e83adf4c4e342b08cbe40814662493e030c88311c720fbce2f0d7c809f3f4
sha256:8fb5da5ad6fa936ba8c24999da33095b2b6c5fd2bcf4aa22acbf299b0e7294df
sha256:0d250d8db7c20b3c0bd3059b3431dd47f130cb81e7a4c7820950139daafb91d6
sha256:e46f4042ccf5f36d1605617ef6912bdaac437fa0bca12530e77529b191eb02fa


$ docker history --format "{{.Size}}\t{{.CreatedBy}}" nginx:1.30-alpine | cut -c1-90
51.9MB	RUN /bin/sh -c set -x     && apkArch="$(cat …
0B	ENV ACME_VERSION=0.4.1
0B	ENV NJS_RELEASE=1
0B	ENV NJS_VERSION=1.0.1
0B	CMD ["nginx" "-g" "daemon off;"]
0B	STOPSIGNAL SIGQUIT
0B	EXPOSE map[80/tcp:{}]
0B	ENTRYPOINT ["/docker-entrypoint.sh"]
16.4kB	COPY 30-tune-worker-processes.sh /docker-ent…
12.3kB	COPY 20-envsubst-on-templates.sh /docker-ent…
12.3kB	COPY 15-local-resolvers.envsh /docker-entryp…
12.3kB	COPY 10-listen-on-ipv6-by-default.sh /docker…
8.19kB	COPY docker-entrypoint.sh / # buildkit
5.62MB	RUN /bin/sh -c set -x     && addgroup -g 101…
0B	ENV DYNPKG_RELEASE=1
0B	ENV PKG_RELEASE=1
0B	ENV NGINX_VERSION=1.30.5
0B	LABEL maintainer=NGINX Docker Maintainers <d…
0B	CMD ["/bin/sh"]
9.08MB	ADD alpine-minirootfs-3.24.2-x86_64.tar.gz /…
  • RootFS.Layers 列出 8 个层的哈希值。
  • docker history 列出每一层由 Dockerfile 的哪一行创建。大小为 0B 的(ENV、CMD、EXPOSE)只更改了设置,没有生成文件,所以没有对应的层。最底下的 ADD alpine-minirootfs... 就是 Alpine 基础镜像。

叠加成根目录

容器启动时,Docker 用 overlayfs 把这些层叠加在一起,再加上一个可写层。在主机上查看这个容器的挂载信息:

$ grep overlay /proc/mounts | grep $(docker inspect -f '{{.Id}}' demo) | tr ',' '\n' | sed 's#/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots#…/snapshots#g'
overlay /var/lib/docker/rootfs/overlayfs/57dcd17505d4e7c71e57df55edef3e137c18249e6ad402c7e7b3f215341c47b4 overlay rw
relatime
lowerdir=…/snapshots/260/fs:…/snapshots/21/fs:…/snapshots/20/fs:…/snapshots/19/fs:…/snapshots/18/fs:…/snapshots/17/fs:…/snapshots/16/fs:…/snapshots/15/fs:…/snapshots/1/fs
upperdir=…/snapshots/261/fs
workdir=…/snapshots/261/work
xino=off 0 0

(路径太长,用 … 代替了 /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs。)

项目含义
lowerdir只读的镜像层,由上到下叠加,所有使用这个镜像的容器共用
upperdir这个容器专属的可写层
workdiroverlayfs 内部使用的临时目录
挂载点 /var/lib/docker/rootfs/overlayfs/容器ID合并后的结果,就是容器看到的 /
overlayfs:只读的镜像层加上可写的 upperdir,合并成容器的文件系统
overlayfs:多个只读层 + 一个可写层 = 容器的根目录

容器写入的文件去了哪里

在容器内创建一个文件,然后到主机上的 upperdir 找它:

$ docker exec demo sh -c "echo hello > /usr/share/nginx/html/new.html"

$ find /var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/261/fs -type f | sed 's#.*/fs/#upperdir/#'
upperdir/usr/share/nginx/html/new.html
upperdir/run/nginx.pid
upperdir/etc/nginx/conf.d/default.conf

$ docker diff demo
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf
C /usr
C /usr/share
C /usr/share/nginx
C /usr/share/nginx/html
A /usr/share/nginx/html/new.html
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
  • 新增的 new.html 出现在 upperdir 中。
  • nginx.pid 是 nginx 启动时写入的;default.conf 被镜像的启动脚本修改过(加上 IPv6 设置),修改镜像中已有的文件时,overlayfs 会先把它复制到 upperdir 再修改(copy-up)。
  • docker diff 就是列出 upperdir 的内容:A 是新增、C 是更改(包括上层文件夹)、D 是删除。

因此:

  • 删除容器 = 删除它的 upperdir,容器内写入的数据就消失了。这就是为什么数据要放在 Volume,Volume 是直接挂载的目录,不经过 overlayfs。
  • 一个镜像启动 100 个容器,镜像的层只存一份。
  • 在 Dockerfile 中“先 COPY 再 RUN rm”不会让镜像变小,因为文件还在下面的只读层中,安全最佳实践甚至把它取了出来。

capabilities 与 seccomp:决定容器能做什么

$ docker info --format "{{.Driver}} {{json .SecurityOptions}}"
overlayfs ["name=apparmor,profile=default","name=seccomp,profile=builtin"]

$ docker exec demo grep CapEff /proc/1/status
CapEff:	00000000a80425fb

$ docker run --rm --cap-drop ALL alpine:3.23 grep CapEff /proc/1/status
CapEff:	0000000000000000
  • docker info 显示这个环境启用了 AppArmor 和 seccomp 的默认设置。seccomp 会阻止容器调用一部分危险的系统调用(例如加载内核模块)。
  • CapEff 是进程当前拥有的 capabilities(以位表示)。容器内的 root 默认只有一部分权限(a80425fb),不是完整的 root;--cap-drop ALL 后全部归零。实际效果见安全最佳实践。
  • --privileged 会给容器所有 capabilities、关闭 seccomp 和 AppArmor,等于去掉这一层保护。

与虚拟机的比较

容器虚拟机
内核共用主机的 Linux 内核每台虚拟机有自己的内核
隔离方式namespace、cgroup、seccomp 等内核功能硬件虚拟化
启动启动一个进程,通常不到 1 秒开机,数秒到数十秒
额外开销几乎没有需要为每台虚拟机分配内存与 CPU
隔离强度较弱,内核漏洞可能影响所有容器较强

这也解释了几个常见现象:

  • Linux 主机上用 ps 能看到容器的进程;Windows 和 macOS 看不到,因为容器在 Docker Desktop 的虚拟机里。
  • 容器不能运行使用不同内核的操作系统,例如 Linux 主机不能直接运行 Windows 容器。
  • 多平台镜像的 exec format error:容器没有自己的 CPU,程序必须是主机 CPU 能运行的格式。

重点回顾

  • 容器是主机上的一个 Linux 进程,由 runc 创建、containerd-shim 看管。
  • namespace 隔离进程、网络、挂载、主机名等“能看到的内容”;默认不隔离用户。
  • cgroup 实现 --memory、--cpus、--pids-limit,设置值可以直接在 cgroup 文件中读到。
  • overlayfs 把只读的镜像层加上一个可写的 upperdir,合并成容器的根目录;删除容器就会删除 upperdir。
  • capabilities、seccomp、AppArmor 限制容器内 root 能做的事,--privileged 会全部去掉。

动手练习

练习 1:运行 docker run -d --name t1 --pids-limit 50 alpine:3.23 sleep 600,然后从容器内读出 PID 上限。

答案

cgroup v1:

docker exec t1 cat /sys/fs/cgroup/pids/pids.max

cgroup v2:

docker exec t1 cat /sys/fs/cgroup/pids.max

两者都应该显示 50。用 docker info --format "{{.CgroupVersion}}" 确认你的系统是哪一版。

练习 2:在容器内删除镜像中原有的文件(例如 docker exec t1 rm /etc/motd),再运行 docker diff t1,会看到什么?镜像中的文件真的被删除了吗?

答案

docker diff 会显示 D /etc/motd。镜像的层是只读的,文件并没有真的被删除:overlayfs 在 upperdir 中放了一个“删除标记”(whiteout),让合并后的视图看不到它。用同一个镜像启动新容器,/etc/motd 仍然存在。

练习 3:同时启动两个 nginx:1.30-alpine 容器,比较它们的 lowerdir 和 upperdir。

提示

在 Linux 主机上运行 grep overlay /proc/mounts。两个容器的 lowerdir 完全相同(共用镜像的层),upperdir 则各不相同。

常见问题

容器和虚拟机最根本的区别是什么?

虚拟机模拟了一整台电脑,里面运行着自己的操作系统内核;容器没有自己的内核,所有容器都直接使用主机的 Linux 内核,只是被 namespace 和 cgroup 隔开。因此容器启动只需要启动一个进程,几乎不花时间。

为什么 Windows 和 macOS 也能运行 Linux 容器?

因为 Docker Desktop 在后台启动了一个小型 Linux 虚拟机(Windows 上是 WSL 2),容器实际上是在那个虚拟机的 Linux 内核上运行。

cgroup v1 和 v2 有什么区别?

两者都是 Linux 内核限制资源的机制,v2 是新版,目录结构统一。文件名不同,例如内存上限在 v1 中是 memory.limit_in_bytes,在 v2 中是 memory.max;CPU 配额在 v2 中是 cpu.max。近年的发行版与 Docker Desktop 默认都是 v2,docker info 的 Cgroup Version 会显示版本。

一个镜像启动 100 个容器,会占用 100 倍的空间吗?

不会。镜像的层是只读的 lowerdir,所有容器共用;每个容器只多一个自己的 upperdir,用来存放它写入或修改的文件。

更新于 2026年10月14日