
如果你已经玩过一段时间的 Docker应该会有一种很微妙的感觉docker run一个 nginx几秒钟内就起来了你进去看ps里只有一个进程ip addr里是一个陌生的网段磁盘空间看起来也没少多少。这种表面上很像虚拟机但内核里其实不是虚拟机的体验本质就是 Docker 容器运行时机制在起作用。这篇文章想把容器运行时机制这件听上去很底层的事情讲透一个容器从镜像变成正在运行的进程集合中间到底发生了什么Docker 的各个组件各自负责什么以及我们遇到启动失败、权限错乱、网络不通这类问题时为什么从运行时原理出发排查往往最有效。它不是 Docker 安装教程更适合已经日常在用 Docker、想往深走一层的读者也适合刚接触容器、对容器到底是什么还没有建立起心智模型的人。1. 先划清边界容器运行时机制到底管哪一段活很多人在聊容器时会把Docker和容器运行时混为一谈。实际上 Docker 是一个完整的产品形态而容器运行时Container Runtime只是其中一个非常底层的执行环节。理解这条边界是搞懂后面一切机制的前提。1.1 一条 docker run 命令背后的分工当你敲下docker run -d nginx:latest的时候表面上只发生了一件事nginx 起来了。但这条命令真正经历的是一整条调用链。简单用文字表示一下docker CLI └─ dockerdDocker 守护进程 └─ containerd容器管理守护进程 └─ containerd-shim每个容器一个 shim 进程 └─ runc低层运行时 └─ 内核的 namespace cgroup 文件系统挂载 └─ 容器内进程开始运行我第一次看这条链时是有点懵的为什么要绕这么多层直接让 dockerd 创建进程不就行了吗后来才明白这每一层解决的是不同的问题拆分它们是为了让整个生态可以自由组合。docker CLI只负责把用户命令翻译成 HTTP 请求发给 dockerd它本身不创建容器。dockerd负责镜像管理、网络管理、数据卷管理、容器生命周期调度它相当于一个管家但真正干体力活的不是它。containerd是更纯粹的容器管理组件它负责镜像的分发、存储以及容器执行任务的调度。它不关心 Docker 的镜像构建、网络、存储这些外围能力。containerd-shim是每个容器对应的保底进程即使 dockerd 或 containerd 重启它也能让容器主进程不直接挂掉。runc是真正干体力活的那个家伙。它直接调用内核提供的clone、mount、pivot_root等系统调用把 namespace、cgroup、rootfs 全部组装起来然后启动容器里的进程。所以容器运行时这个词严格来说指的通常是 containerd 这一层以及它下面拉起来的 runc但我们日常语境里也会把 dockerd 加进来整体说成 Docker 运行时。理解主链上有哪些角色比记住某个精准定义更重要。1.2 运行时、镜像、数据卷各自的责任分工我比较喜欢用一个类比来区分三个概念镜像、运行时、数据卷。镜像是一张设计蓝图或者安装光盘静态的里面是你想要的文件系统内容——nginx 二进制、配置文件、依赖库全都在里面。但它本身不运行。运行时是加工车间里真正执行加工动作的那套设备。它拿到镜像之后负责把文件系统挂载好、把隔离环境准备好、把进程启动起来。一个镜像是死是活取决于运行时怎么处理它。数据卷是独立于镜像之外的共享仓库。镜像一旦被删掉里面改过的东西可能就没了数据卷则是专门用来持久化保存数据的地方。这三者的关系很多人用 Docker 很久也没理清最典型的表现就是容器里改了文件就以为数据安全了镜像删了之后才发现一切归零或者在Dockerfile里拼命写RUN去生成数据而不是挂 Volume。这些混淆的根源其实都是没想清楚静态镜像、动态进程、独立存储这三层是完全不同性质的东西。1.3 理解了边界之后新手常见的三个误区第一把容器当成迷你虚拟机。虚拟机里装了一个完整内核你在里面装内核模块、改内核参数都没问题容器里面跑的是宿主机内核上的进程很多内核操作会被限制。你会看到容器里的top有时候显示的是宿主机的 CPU 信息、/proc部分内容也是共享的这就是因为它不是独立操作系统。第二以为docker stop就像关机。虚拟机关机是走 ACPI 通知、关掉内核容器 stop 本质上就是给容器内主进程发一个信号进程听完信号退出容器生命周期就结束了。这里面没有什么关机流程后面我会展开讲。第三以为在容器里手动改完配置后docker commit就能替代 Dockerfile。commit 确实是生成新镜像层但它把运行时的临时状态也一并打进去了容易产生无法复现的幽灵镜像。实际项目里宁可花十分钟改 Dockerfile 重新构建也别依赖这种手工快照。2. 隔离的真相Namespace 与 Cgroup 两大内核组件容器好像一个房间每个房间里面的人看不到对方也干扰不到对方但这个房间并不是用砖头砌的而是用两个内核机制画出来的Namespace 负责看不见Cgroup 负责不能超量使用。2.1 Namespace给进程一个看不见别人的空间Namespace 是 Linux 内核提供的一种资源隔离机制。它把一类全局资源包装成独立的视图让进程以为自己是资源的唯一使用者。Docker 主要用到以下几类Namespace 类型隔离内容你在容器里能直接感知到的差异PID进程编号容器里ps看不到宿主机其他进程且容器内第一个进程 PID 通常是 1Network网络栈容器里有自己的eth0、IP 地址、路由表、iptablesMount文件系统挂载点只能看到镜像 rootfs 和自己挂载的卷宿主机目录不可见UTS主机名与域名容器里hostname是容器 ID 而不是宿主机主机名IPC进程间通信容器内有自己的 System V IPC 和 POSIX 消息队列User用户和用户组容器内 UID/GID 与宿主机映射关系可以进一步隔离这里面最容易产生认知冲击的是 PID namespace。在宿主机上执行ps aux你能看到容器里的全部进程因为容器本质上就是宿主机上的一组进程但是容器里的ps只能看到自己的进程因为它看到的进程列表经过了 PID namespace 过滤。这就是隔离的真相——不是把进程放到另一个机器上而是给进程换了一副眼镜让它看不见不该看的。Network namespace 是另一个很关键的隔离维度。每个容器有自己独立的eth0和 IP 地址看起来像一台单独的小电脑。但实际上这个网卡是一个虚拟网络设备veth一头连着容器另一头连在 Docker 创建的 bridge 网桥上。数据包从容器出去经过网桥、经过 iptables 规则最终还是从宿主机的物理网卡发出。Docker 网络不通的很多问题根源都出在这个虚拟的中间环节上后面我会专门说。2.2 Cgroup怎么控制容器能花多少资源Namespace 解决的是看得见 vs 看不见的问题Cgroup 解决的是能用多少的问题。Cgroup 是 Linux 内核的另一个子系统用来限制、统计、隔离进程组的资源使用包括 CPU、内存、磁盘 I/O、网络带宽等。如果你执行docker run -m 512m --cpus 1.5 nginx:latestDocker 其实是在对应的 Cgroup 控制器里写入了限制值。我们可以简单理解成Cgroup 给容器分配了一个资源预算超了就会被内核限流或者被杀掉。我见过不少同事在容器里跑 Java 应用-Xmx直接设置成 4G但容器内存限制只有 1G结果进程启动没多久就被 OOM Kill 了。你要记得JVM 只看得到它自己进程的地址空间并不理解外面那个 Cgroup 的限制docker stats里显示的内存用量是 Cgroup 统计维度不是你在容器里free -h看到的数字。这是两个截然不同的数据口径。查看当前系统用的是 Cgroup v1 还是 v2一条命令就能确认docker info | grep -i cgroup如果返回的是cgroupfs说明 Docker 正在通过 Cgroup 驱动限制资源。很多新系统已经默认 Cgroup v2Kubernetes 从 1.25 开始也逐渐淘汰 v1写容器资源限制的时候最好提前确认一下宿主机的版本避免把限制写进一个根本没生效的路径里。2.3 共享内核的意义和潜在风险容器之所以能秒级启动、占用资源比虚拟机小很多核心原因就是它没有自己的内核而是共享宿主机的内核。这意味着不能modprobe往容器里加载一个宿主机没有的内核模块。容器里的uname -r显示的是宿主机内核版本而不是什么容器内核版本。部分内核参数是全局共享的容器里不能随意修改例如sysctl -w vm.swappiness0在某些隔离级别下会失败。但共享内核也带来了安全边界问题。默认情况下容器内进程虽然以 root 身份运行但它只是一组受了 capability 限制的 root很多特权操作默认是被禁止的。如果你给容器加了--privileged等于把这些内核操作能力全部打开这时容器内一旦有漏洞被利用宿主机被攻破的几率就会明显上升。这不是说--privileged完全不能用像某些需要加载内核模块或直接操作设备的工具确实需要它但你要清楚代价而不是反正能跑就加一个。3. 镜像分层的写时复制容器为什么那么轻那么快第一次接触 Docker 时我最震惊的不是它启动快而是同一个 nginx 镜像拉起 10 个容器磁盘占用并没有变成 10 倍。秘密就在镜像的分层结构和写时复制机制里。3.1 镜像层、只读层与折叠视角Docker 镜像不是一个大文件而是由很多层Layer叠起来组成的。每一层只记录文件系统相对于上一层的变化。我们可以用docker history直观看到docker history nginx:latest输出里每一行对应一个镜像层最下面是FROM debian这种基础镜像层往上是添加了 nginx 安装包、配置文件、日志目录的层。每一层都是只读层一旦构建完成就不会再变化。这些只读层叠在一起从容器内看起来就是一个完整的文件系统比如你在/usr/sbin/nginx看到这个文件它可能来自第三层而/etc/debian_version可能来自第一层。但容器不关心文件具体在哪一层只需要拼出它看到的路径即可。这个拼接方式就是联合文件系统的核心能力。也就是说镜像构建时拷贝几百 MB 文件并不意味着每次启动容器都要把这些文件复制一份到新目录。容器启动只需要叠出这个视图不需要重新拷贝。3.2 容器层写时复制的执行细节当一个容器被创建时Docker 会再往镜像层的上面叠加一个可写层这一层在容器运行期间负责所有写入操作。它的策略很有趣读文件时从上往下找找到就返回。写文件时如果这个文件本来存在于镜像低层那么先把文件从低层复制到容器可写层再在可写层里进行修改。这个过程叫 copy-up。因为源层文件并没有被改动只是复制了一份所以叫写时复制。删除文件时不是在低层真的删掉文件而是在可写层里放一个特殊标记把上面的文件挡住让读取者看起来文件没了这叫 whiteout 机制。我踩过一个跟这个机制相关的坑有一次排查线上问题docker exec进容器改了/etc/nginx/nginx.conf发现改错了想恢复原始配置结果docker restart之后改动还在。原因很简单——我改的是容器可写层restart 并不会重建可写层只有docker rm后重新docker run才会基于原始镜像层重建。很多人以为 restart 等于回到镜像初始状态其实完全不是。还有一个实际影响日志文件如果直接写在容器可写层里时间久了可写层会膨胀。我们见过容器运行一个月后可写层占了几个 GB都是因为应用日志写在了/var/log而/var/log没有挂载成 Volume。这类问题从docker system df就能看出来但大多数时候一开始就应该把日志、临时文件这些高频写入路径挂出来。3.3 为什么持久化数据必须要用 Volume写时复制虽然省空间但也有它的代价可写层是跟着容器生命周期走的。容器一旦被docker rm可写层以及里面所有数据会被直接丢掉。所以任何删了容器之后还必须存在的数据都不应该放在可写层而是应该放在 Volume 或 bind mount 里。Volume 和 bind mount 的主要区别在于管理方式和使用场景维度Volumebind mount存放位置Docker 管理的目录如/var/lib/docker/volumes宿主机任意指定路径备份迁移用docker run -v拷贝、可以用 volume 驱动管理直接复制宿主目录即可适用场景数据库数据、应用持久化存储开发时把代码挂进容器、读取宿主机配置文件很多官方镜像在Dockerfile里已经声明了VOLUME比如 MySQL 镜像的/var/lib/mysql这样即使你没有手动指定卷Docker 也会创建匿名 Volume。但匿名 Volume 不好管理所以生产环境建议你显式指定命名 Volume 或 bind mount。另外有个高频权限问题挂载目录后容器内进程报Permission denied。通常不是 Docker 权限故障而是宿主机目录的所有者和容器内 UID 对不上。比如 MySQL 镜像内进程 UID 是 999你把宿主机目录chown成 root:rootMySQL 就写不进去。解决办法是把目录权限放给容器对应 UID或者在docker run时通过-u指定用户但后者容易引入新问题。实测下来更稳妥的做法是先看一眼镜像里主进程的 UID再chown宿主目录。mkdir -p /data/mysql chown -R 999:999 /data/mysql docker run -d --name mysql \ -v /data/mysql:/var/lib/mysql \ mysql:8.0这个思路同样适用于 redisUID 999、gitlabUID 998 之类等官方镜像。4. 从 image 到 container一条完整的容器生命周期链路搞清底层组件之后再把一条完整的容器生命周期串起来会更有掌控感。我按最常见的操作顺序来讲。4.1 pull镜像从远端仓库到本地磁盘docker pull nginx:latest看起来只是下载实际上它做了三件事解析仓库地址、校验镜像摘要、按层下载并解压。下载是按层来的仓库 Manifest 记录了每层的 digestDocker 检查本地是否已有相同 digest 的层存在就跳过不存在才下载。如果显示Pulling from library/nginx时进度条飞快地跳过某几层那多半是本地已经有这些层了可以复用。这也是为什么基础镜像放在公共层会大大加速后续容器启动的原因。镜像落到本地后按存储驱动的布局放在宿主机目录里。以常见的 overlay2 为例核心目录是/var/lib/docker/overlay2/里面每个镜像层对应若干子目录diff/保存真实文件内容link记录层之间的指向关系。容器运行时的 rootfs 就是由这些 diff 叠加出来的。如果你做文件系统排障docker inspect 容器 | jq .GraphDriver可以查看当前容器用到的存储驱动和层目录。4.2 create start从配置到运行中的进程docker run实际上是docker createdocker start的组合。docker create阶段做的是准备好但不启动解析镜像配置确定要暴露的端口、环境变量、入口命令。创建容器可写层。分配容器 ID把配置固化到 Docker 的元数据里。创建网络命名空间分配容器 IP。docker start阶段才真正上运行时的活。runc 拿到容器配置后会依次完成以下动作创建所需的 namespace。设置 Cgroup 资源限制。挂载 rootfs把镜像层叠加成根文件系统。用pivot_root切换进程根目录。执行入口命令比如 nginx 的nginx -g daemon off;。这里有个值得记住的结论容器的生命周期取决于入口进程的生命周期。入口进程退出容器就退出即使容器里还有其他子进程在跑也会被清理。所以很多人遇到容器启动后一两秒就退出时真正原因不是容器运行时坏了而是入口进程启动失败——配置文件不对、端口被占用、依赖服务没起来都会让主进程退出。排查时先看docker logs 容器往往比检查 Docker 服务本身更有效。4.3 stop、start、rm容器状态的来回切换docker stop实际上是给容器主进程发一个 SIGTERM 信号让它自己优雅退出。Docker 默认等待 10 秒超时之后强制发 SIGKILL。如果你的容器里有需要提前处理善后的逻辑例如清理连接、刷新缓存可以使用STOPSIGNAL指配合适信号或者用--stop-timeout调整超时时间。docker start则是在已有容器配置和可写层基础上重新执行一次入口命令。注意容器虽然被 start 回来了但原先在容器内存里的进程状态早就没了一切从入口命令重新开始。这就解释了为什么docker restart不等于恢复到干净状态——可写层的文件改动还在只是进程重启了。docker rm才会真正判断这个容器的一生删除容器元数据、删除容器可写层、释放网络资源。数据如果放在 Volume 里就不会随着 rm 丢失如果放在可写层里就彻底没了。这是我在团队里反复强调的一点判断这个数据该放哪先假设容器明天就被删了再看数据还在不在你预期的地方。排查运行时问题的时候nsenter是一个不错的工具。可以在宿主机上进入到容器的 namespace 视角看你想要看的真实情况PID$(docker inspect --format {{.State.Pid}} container) nsenter --target $PID --mount --uts --ipc --net --pid进到容器视角之后mount | grep overlay、ip addr、ps aux全部会变成容器内的视图排查挂载和网络问题时非常直观。5. Runtime 演进与排错思路containerd、OCI 与常见故障根因容器运行时这条链不是一天长成这样的。了解它为什么长这样能帮你在排错时少走很多弯路。5.1 从 LXC 到 libcontainer 再到 containerd/OCIDocker 早期使用的是 LXC 提供隔离能力那时候 Docker 更多是封装 LXC 的工具。后来 Docker 团队发现 LXC 受限于上游特性干脆自己做了一个名为libcontainer的库直接调用内核的系统调用来创建容器。这应该是 Docker 在底层运行时方向上最关键的转折。2015 年前后Docker 将 runc 贡献并推动建立了 OCIOpen Container Initiative标准。OCI 定义了两件核心事情镜像格式规范和运行时规范。任何符合 OCI 标准的镜像都能被任何符合 OCI 标准的运行时启动生态因此被打通。之后 containerd 也变成独立项目后来连 Kubernetes 都直接支持 containerd 作为容器运行时不再需要经过 dockerd。这条路线的每一步都是因为抽象层越通用生态越繁荣。所以现在你可能会在宿主机进程列表里看到containerd、containerd-shim、runc这些进程它们是运行时链路上的正常成员不是多余的东西。5.2 OCI 标准带来的可能性OCI 标准最大的价值是运行时可替换。runc 是默认实现但不是唯一实现。比如gVisor 用用户态拦截系统调用隔离性更好但性能有损耗。Kata Containers 用轻量虚拟机跑 OCI 镜像隔离性接近 VM启动比传统 VM 快。云厂商也有各自的沙箱运行时。这些运行时对上层 Docker 命令基本透明主要差异在底层的隔离实现和性能表现。如果你在公有云上跑多租户业务需要更强的隔离边界就可以考虑把 runtime 从 runc 换成一个基于虚拟机隔离的实现。这是 Docker 架构分层最直接的收益上层管理不变下层执行引擎可以替换。不过大多数个人项目和中小团队runc 依然是最主流的默认选择。它的性能损耗最小安全风险可以通过非 root 运行容器、启用用户命名空间映射、限制 capabilities、不随意挂载宿主目录来缓解。5.3 从原理出发排查三个高频问题结合前面讲的内容我用三个高频问题收尾每一个如果用查文档试命令的方式可能要试大半天如果从原理出发五分钟能定位。第一个是 Docker Desktop 启动时报 Virtualization support not detected 或者 virtualisation support wasnt detected。这跟容器运行时机制直接相关Docker Desktop 在 Windows/macOS 上并不是直接跑 Linux 容器而是先在宿主机里创建一台轻量 Linux 虚拟机再在这台虚拟机里跑容器运行时。如果 CPU 的硬件虚拟化功能没开启或者 Windows 的 Hypervisor 平台没启用这台 Linux 虚拟机根本起不来。排查路径是进 BIOS/UEFI 确认开启 VT-x/AMD-VWindows 功能里开启虚拟机平台和Windows 虚拟机监控程序平台按需启用 WSL2。如果宿主本身也是虚拟机那还要给这台虚拟机开启嵌套虚拟化否则也会出现同样报错。第二个是挂载目录后容器内报权限错误。例如 MySQL 容器数据目录报Permission denied。原理上bind mount 只是把宿主目录原样暴露给容器文件系统的权限校验依据还是宿主机上的 UID/GID。官方镜像里主进程通常不是 UID 0 用户比如 MySQL 是 999、Redis 是 999而宿主目录默认 root:root 权限 700容器用户自然没有写权限。解决思路是让宿主目录的属主、权限对上容器进程而不是用chmod 777一把梭后面维护会很痛苦。第三个是容器网络不通。从原理看Docker 默认 bridge 网络的数据链路是容器 eth0 - veth 对 - docker0 网桥 - 宿主机 iptables NAT - 物理网卡。任何一个环节被破坏都会表现为容器外网不通或容器之间不能互访。常见诱因是修改了宿主机防火墙规则、重载了 iptables、或者docker0网桥被手动调整过。排查时先看容器侧docker exec container ip addr docker exec container ip route再看宿主机侧docker network inspect bridge | head -50 iptables -t nat -L -n | grep -i docker如果容器 IP 拿不到多半是网络配置或 DNS 问题如果 IP 正常但 ping 不通外网基本可以确定问题出在网桥或 iptables 规则上。Docker 在创建网络时会写入自己的一套 iptables 链手动清空防火墙等于把这条链拆了恢复最快的方式是systemctl restart docker它会在启动时重建默认网络和规则。把这三个问题串起来看你会发现它们背后其实都是同一件事只要清楚容器进程依赖了宿主机的哪些机制虚拟化层、UID 权限、网络命名空间与 iptables排查方向就不会跑偏。容器运行时机制不是只在概念层面有趣它是每个 Docker 问题最终都会走到的那个根。如果你能在一个问题里同时想到这个进程在宿主机上的真身是谁、它有没有 namespace、数据落在哪一层大部分 Docker 相关的疑难杂症就已经解决了一半。