目录
Docker 底层原理教程:实测 namespace、cgroup 与 overlayfs
什么是 Docker 用比喻说明了容器;这一节直接到 Docker 主机上看看:容器在 Linux 中到底是什么。答案只有一句话:
容器就是一个普通的 Linux 进程,只是被 namespace 限制了“能看到什么”、被 cgroup 限制了“能用多少”、用 overlayfs 组成的目录作为它的根目录,再由 capabilities 和 seccomp 限制“能做什么”。
以下的“主机”是本教程的 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 模式 才会使用 |
cgroup | cgroup 目录的视野 | 这个环境(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 = 268435456 | 268435456 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 | 这个容器专属的可写层 |
workdir | overlayfs 内部使用的临时目录 |
挂载点 /var/lib/docker/rootfs/overlayfs/容器ID | 合并后的结果,就是容器看到的 / |
容器写入的文件去了哪里
在容器内创建一个文件,然后到主机上的 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日