第一篇我决定不急着讲命令,先把docker-container-architecture这个题目拆透。很多人早就把docker run敲得飞起,镜像、容器、网络都能玩个大概,但遇到“Docker Desktop 闪退”“容器起来了但网络不通”“想换 containerd 又不知道和 Docker 什么关系”这些问题时,就明显感觉到脑子里缺了一张图。这张图就是 Docker 的容器架构。
这篇内容是 Docker 容器生态系列的开篇,适合两类人看:一是刚接触容器、想系统理解 Docker 而不仅仅是背命令的读者;二是被 Windows 下 Docker Desktop 各种报错、Linux 下 Docker 服务突然起不来折腾过,想搞清楚“到底哪一层出了问题”的同学。能理解架构,才能真正拥有排障能力,而不是靠百度猜答案。
1. 先把容器生态的坐标系搭起来
1.1 容器到底是什么:共享内核的轻量“隔离舱”
聊架构之前,必须先定义容器。容器的本质不是虚拟机,它没有自己独立的内核。一个容器进程跑在宿主机内核之上,通过 Linux 内核提供的 namespace、cgroups 等机制,给自己划出一块相对隔离的运行空间。namespace 负责“看不见”,让进程以为自己是独立系统里唯一的主角;cgroups 负责“用不超”,限制这个进程能用多少 CPU、内存和磁盘 IO。
你可以把容器想象成集装箱。集装箱内部货物摆放随意,但外部尺寸统一、装卸标准固定;容器也一样,内部跑什么应用、什么依赖都由镜像决定,对外却用同一套标准化接口构建、分发、运行。这也是“容器生态”这个词的由来:围绕容器这个标准单元,生长出了镜像仓库、容器运行时、编排系统、存储网络插件等一系列配套生态。
1.2 Docker 在容器生态里的位置
Docker 不等于容器,但 Docker 是容器生态里最重要的入口。从用户视角看,Docker 封装了底层细节,你只需要docker run、docker build、docker compose up,就能把应用跑起来。可在 Docker 内部,整个执行链路是分层的:最上层是用户交互的 CLI 客户端,中间是常驻后台的 daemon,下面还有 containerd、containerd-shim、runc 这一串组件。
理解了这条链路,你才能看懂很多常见现象。比如为什么 Docker Desktop 在 Windows 上启动失败时,提示是“virtualization support not detected”?因为 Docker Desktop 在 Windows 上需要一个轻量虚拟机来跑 Linux 内核,这个虚拟化后端依赖 WSL2 或 Hyper-V。再比如为什么docker logs能拿到容器日志?因为容器进程的 stdout/stderr 并没有直接消失,而是被 shim 进程一直握在手里。
1.3 为什么第一篇要死磕架构
后面整个系列都会围绕 Docker 展开:镜像怎么构建、网络怎么打通、数据卷怎么挂、Compose 怎么编排、生产环境怎么选运行时。这些内容如果脱离了架构来学,很容易变成“背参数”。比如你知道-v是挂载目录,但不知道挂载目录和容器可写层的本质区别,遇到数据丢失依然一脸懵;你知道docker stop能停容器,但不知道它和docker kill最终都通过 containerd 向容器主进程发信号,就无法理解为什么有些容器 stop 半天停不下来。
所以第一篇先把地基打牢。下面我直接按“客户端 → dockerd → containerd → shim → runc → 容器进程”这条主链路,把 Docker 容器架构完整拆一遍。
2. Docker容器架构全景拆解
2.1 docker CLI 与 dockerd:客户端/服务端模式
Docker 采用典型的客户端/服务端架构。你平时敲的docker命令,本质是一个轻量级 CLI 客户端,它本身不创建容器,只负责把命令翻译成 HTTP 请求,发给常驻后台的 Docker daemon。daemon 的进程名是dockerd,在 Linux 上默认监听 Unix socket/var/run/docker.sock;如果需要远程管理,也可以配置 TCP 端口,比如 2375(明文)或 2376(TLS)。
这个设计带来什么结果?CLI 和 daemon 可以不在同一台机器上。你可以让本机的docker客户端连接远程服务器的 dockerd,只要网络可达、有权限。但默认情况下,普通用户没有访问/var/run/docker.sock的权限,所以刚装完 Docker 时,直接敲docker ps经常会报permission denied while trying to connect to the Docker daemon socket。这正是架构带来的权限边界:客户端能不能调用 daemon,由 socket 文件权限决定。
在 Windows/macOS 上,Docker Desktop 把这个模型包装得更隐蔽一些:真正的 dockerd 跑在一个轻量 Linux 虚拟机(WSL2 或 Hyper-V 后端)里,本机的 CLI 通过 Windows 命名管道连接过去。所以你重启 Docker Desktop,本质是重启了虚拟机里的整套 Docker 服务。
2.2 containerd:容器生命周期的实际管理者
从 Docker 1.11 开始,Docker 把容器生命周期管理能力从 dockerd 里抽出来,交给了 containerd。containerd 最早是 Docker 公司内部组件,后来捐给 CNCF,现在已经是云原生领域的事实标准运行时管理者。
dockerd 本身并不直接启动容器进程。它把“创建容器、启动容器、停止容器、销毁容器”这些脏活累活,通过 gRPC 接口委托给 containerd。containerd 负责的事情包括:镜像的拉取和存储、镜像层挂载、容器 OCI spec 生成、进程生命周期管理、cgroups 资源限制的配置等。换句话说,containerd 是容器领域的“项目经理”,它不亲手创建隔离环境,但所有任务都经它调度。
对应到进程,你在 Linux 机器上会看到一个名为containerd的常驻进程。它默认监听/run/containerd/containerd.sock。如果你想绕过 Docker 直接操作它,可以用ctr命令;如果机器上装了 Kubernetes,还会用到crictl;追求 Docker 体验又想脱离 dockerd 的人,可以用nerdctl。这些都是后话,但你能看到 containerd 在生态中的底层地位。
2.3 runc:底层真正“造容器”的进程
如果说 containerd 是项目经理,那runc就是真正下场干活的施工队。runc 是 OCI(Open Container Initiative)运行时规范的参考实现,它做的事情非常底层:根据一份 JSON 格式的容器配置,调用 Linux 内核能力创建 namespace,配置 cgroups,准备 rootfs,然后exec出容器进程。
Docker 刚起步时用的其实是 LXC,后来自己研发了 libcontainer 库,再后来 libcontainer 演化成了 runc。runc 是一个极其轻量的二进制,它不常驻后台,而是在需要创建容器时被拉起来执行一次,容器启动成功后就退出。这一点很关键:runc 其实是一个“一次性执行工具”。
这也是 Docker 架构设计最精妙的地方之一。把“创建”和“管理”分离,runc 只负责把容器进程拉起来,后续对容器的管理交给更上层的组件。
2.4 containerd-shim:容易被忽略的关键缓冲层
runc 启动完容器进程就退出了,那容器进程不就变成孤儿进程了吗?这时候containerd-shim出场。每个容器都有一个对应的 shim 进程,它被 containerd 拉起,再让 shim 去调用 runc 创建容器。runc 退出后,shim 就成了容器进程的直接父进程。
shim 存在的意义至少有两点。第一,它持有容器进程的 stdio,docker attach和docker logs才能继续读到容器的标准输入输出;第二,它把容器进程与 dockerd/containerd 解耦,即使 dockerd 或 containerd 重启,容器进程也不会受影响,shim 还可以向 containerd 汇报容器退出状态。
你可以把 shim 理解为“驻场监理”:施工队 runc 盖完楼撤了,监理 shim 留在现场,随时向上汇报这栋楼(容器)的状态。所以一个运行中的容器,在宿主机进程表里至少能看到三个相关进程:containerd、containerd-shim、容器进程本身。
3. 一条 docker run 命令的完整旅程
3.1 从输入命令到 dockerd 收到请求
现在我带你走一遍完整的docker run -d --name nginx -p 8080:80 nginx:1.26。命令敲下去,CLI 先检查本地配置,把参数解析成创建容器的请求,通过 socket 发给 dockerd。这一步如果 socket 不通,就会报我们都很熟悉的Cannot connect to the Docker daemon。
dockerd 收到请求后并不马上创建容器。它先做了一系列校验:镜像是否存在、端口有没有冲突、名字是否占用、资源限制参数是否合理。校验通过后,dockerd 会把任务下发给 containerd。从这里开始,用户能感知的“Docker”就已经退居幕后了。
很多人以为docker run是 dockerd 亲自 fork 出来的,其实不是。dockerd 更像一个 API 网关和功能聚合层,它负责镜像构建、网络管理、数据卷挂载、日志驱动等,而把纯容器生命周期操作交给了 containerd。
3.2 镜像检查和拉取:Layer 是原材料
如果本地不存在nginx:1.26镜像,dockerd 会先让 containerd 去镜像仓库拉取镜像。镜像是分层存储的,每一层都是一个只读的 Layer。拉取的时候,containerd 会做 sha256 校验,确认每一层都完整。之后,containerd 将这些只读层按顺序挂载成一个合并后的 rootfs。
你可能会问,为什么镜像要分层?因为分层能极大提升磁盘利用率和传输效率。你本地有一个基于 Ubuntu 的镜像,再拉一个同样基于 Ubuntu 的另一个镜像,公共的 Ubuntu 层就不需要重复存储。这也是 Docker 生态里“镜像复用”的基础。如果你看docker pull的输出,能看到Pull complete是按层出现的,而不是一个整体,就是这个原因。
3.3 runc 创建隔离环境:namespace 与 cgroups
containerd 确认 rootfs 就绪后,会先启动一个containerd-shim进程,再由 shim 调用 runc。runc 读取 OCI 配置,调用 Linux 内核能力完成容器隔离环境的创建。
这里需要展开讲两类核心机制。
第一是 namespace。Linux 内核给进程提供了多种隔离视图,Docker 默认会创建以下几类:
| 命名空间 | 隔离内容 | 影响 |
|---|---|---|
| PID | 进程号 | 容器内第一个进程 PID 是 1 |
| NET | 网络栈 | 容器有独立的网卡、IP、路由 |
| IPC | 进程间通信 | 隔离 System V IPC 和 POSIX 消息队列 |
| UTS | 主机名 | 容器可以有自己的 hostname |
| MNT | 挂载点 | 容器拥有独立的文件系统挂载视图 |
| USER | 用户 ID | 容器内 root 和宿主机 root 不是同一个账号 |
| CGROUP | cgroup 根目录 | 容器内看不到宿主机完整的 cgroup 树 |
通过 namespace,容器内进程“以为”自己独占了一台机器。这里面最容易被忽略的是 PID namespace:容器内进程 1 就是你的应用进程,它没有 systemd、没有 init 系统,所以一些依赖 systemd 的服务在容器里是起不来的。
第二是 cgroups。namespace 解决“看得见”的问题,cgroups 解决“抢资源”的问题。runc 会根据配置创建一组 cgroup 控制文件,写入 CPU 份额、内存上限、IO 权重等限制。如果你在docker run里加了-m 512m --cpus 1,那最终落地就是 cgroup 配置里的一组数字。
3.4 可写层、存储驱动与容器数据
runc 把 rootfs 准备好后,还会在只读镜像层之上叠加一个可写层。容器运行时的所有文件写入,默认都发生在这一层。这种机制叫“写时复制”:如果镜像里有一个文件,容器想修改它,存储驱动会先把文件从只读层复制到可写层,再修改副本,镜像本身纹丝不动。
这带来两个重要结论。第一,容器被删除时,可写层也会被删除,容器里产生的所有未持久化文件都会消失。第二,如果你想保留数据,要么用数据卷挂载宿主机目录,要么用 bind mount 把宿主机路径直接映射进容器。不理解可写层,就无法真正理解docker volume为什么是容器持久化的关键。
存储驱动的选择也在这里体现。现代 Linux 发行版上,Docker 默认使用overlay2,它把多个只读层和一个可写层合并成一个视图。如果你用 Docker Desktop,在 macOS 上可能走的是 virtiofs 和专门的虚拟磁盘方案,但概念是相通的。
3.5 网络与端口映射的架构介入点
容器启动时如果没有指定网络模式,会默认加入bridge网络。Docker 会在宿主机上创建一个虚拟网桥docker0,并为每个容器分配一个虚拟网卡和一个内部 IP。容器访问外网时,数据包从容器网卡发出,经过 docker0,再由宿主机 iptables 做 SNAT 转换;外网访问容器时,通过-p 8080:80映射的宿主机端口,iptables DNAT 把流量转发给容器 IP 的 80 端口。
这里要注意,端口映射并不是容器网络的一部分,而是宿主机 iptables/nftables 规则。所以 Linux 上 Docker 服务启动失败时,如果 iptables 规则不干净或 FORWARD 链默认策略是 DROP,容器网络就会莫名其妙地不通。这也是后面排障里最高频的问题之一。
4. 容器生态里的关键选型:Docker 不是唯一答案
4.1 Docker、containerd、Podman 与 nerdctl 的边界
理解了 Docker 的架构后,你再看容器生态里的各种工具,就会觉得豁然开朗。
- Docker:功能最完整,CLI 体验最好,适合开发机、单机场景。但它引入了一个常驻 dockerd,而 dockerd 这个“API 网关”对生产环境来说不是必需的。
- containerd 直用:如果你用
ctr直接操作 containerd,性能更好,组件更少,但命令不够友好,主要面向开发者和 Kubernetes。 - nerdctl:目标是提供和 Docker CLI 几乎一致的体验,但底层直接驱动 containerd,不需要 dockerd。适合想摆脱 Docker 但又不想改习惯的人。
- Podman:无 daemon 架构,每个容器由一个独立子进程管理,支持 rootless。它兼容 Docker 命令,在很多 Linux 发行版上被作为 Docker 的替代方案。
这些工具并不互斥,它们共用 OCI 镜像规范和容器运行时,生态是通的。
4.2 为什么 Kubernetes 最终选择 containerd
Kubernetes 早期通过 dockershim 调用 Docker,再由 Docker 调用 containerd,链路长、稳定性差。后来 Kubernetes 社区意识到,dockershim 这层“翻译官”并没有提供 Kubernetes 真正需要的额外价值,于是从 1.24 版本起彻底移除 dockershim,转而直接通过 CRI(Container Runtime Interface)对接 containerd 或 CRI-O。
现在你回头看 Docker 架构,就会明白这次整合的必然性:Kubernetes 需要的是一个能管理镜像、镜像层、容器生命周期的运行时,containerd 本身就具备这些能力。Docker 的价值更多在开发体验和构建工具链,而生产集群运行时,containerd 已经足够。这也是容器生态“向底层收敛”的典型例子。
4.3 Docker Compose:单机编排与多服务协同
容器生态里除了单个容器,还有一个非常重要的层次:多容器编排。docker compose就是 Docker 提供的单机多容器编排工具。它通过一个 YAML 文件描述服务、网络、依赖关系和数据卷,然后由 Docker CLI 调用 dockerd 批量创建容器。
Compose 的底层仍然走同一套架构:每个服务都会被转成容器,由 containerd 管理生命周期。但它补上了 Docker 原生命令的短板——把复杂环境定义一个文件里,实现可重复部署。常见的 MySQL 主从、Redis 主从、面板类应用,几乎都能用 Compose 一次性拉起一套环境。这也是你在各种部署教程里频繁看到docker-compose.yml的原因。
4.4 Docker Desktop、WSL2 与虚拟化后端
Docker Desktop 是目前 Windows 和 macOS 上体验最好的 Docker 运行方案。在 Windows 上,Docker Desktop 有两种后端:一种是基于 WSL2,一种是基于 Hyper-V。WSL2 是默认且推荐的方式,因为它启动更快、内存占用更小。
但 WSL2 本身就是一层轻量虚拟机,它对宿主机的虚拟化能力有硬性要求。如果在 BIOS 里关闭了 VT-x/AMD-V,或者 Windows 功能里没有启用“虚拟机平台”和“适用于 Linux 的 Windows 子系统”,Docker Desktop 启动时就会直接报virtualization support not detected或类似错误。理解了架构,你就知道这不是 Docker 本身坏了,而是它的虚拟化底座没准备好。
5. 动手验证:把架构“看”出来
5.1 进程树视角:一条链路上的真实进程
在一台 Linux 机器上运行一个容器后,用下面的命令查看进程:
ps -ef | grep -E "dockerd|containerd|shim|runc|nginx" | grep -v grep你会看到类似这样的结果:dockerd一个进程,containerd一个进程,然后每个容器对应一个containerd-shim进程,以及容器内的业务进程。如果你在启动瞬间抓取进程,还可能看到一闪而过的runc进程。
这正是理论架构在现实中的投影。如果一台机器上有 10 个容器,你会发现 containerd-shim 恰好有 10 个,这就是“一个容器一个 shim”的最好证明。
5.2 命名空间与 cgroups 验证
找到容器进程的 PID 后,可以查看它所属的 namespace:
lsns -p <PID>输出里会列出 PID、NET、MNT、UTS 等 namespace,每一类都有一个唯一的 inode 编号。你会发现容器进程的 namespace 和宿主机其他进程明显不同,而且同一个容器里的多个进程共享同一组 namespace。
再看资源限制:
cat /proc/<PID>/cgroup在 cgroup v2 系统上,你会看到容器进程落在/system.slice/docker-<id>.scope这样的路径下。如果你想看得更直观,用systemd-cgls可以查看完整的 cgroup 树,能明显看到 Docker 为每个容器建的独立子目录。
5.3 用 docker version / docker info 验证客户端与Server分离
docker version是验证客户端/服务端架构最直接的办法。它会分成Client和Server两段分别显示版本。如果本机 CLI 连接的是远程 daemon,Server 段显示的就会是远端信息。
docker info同样能说明问题,它返回的是 daemon 侧的系统状态,包括存储驱动、内核版本、cgroup 版本、镜像数量、容器数量等。如果你在 Docker Desktop 里看到 Server 段的 Operating System 是 Linux,而 Client 是 Windows,不要奇怪,因为 daemon 确实跑在虚拟化的 Linux 环境里。
5.4 配置镜像加速器与常见安装注意事项
镜像加速器的配置位置在/etc/docker/daemon.json。国内常用的是阿里云容器镜像服务提供的加速地址、中科大公共镜像加速地址等。配置格式如下:
{ "registry-mirrors": [ "https://your-mirror.example.com" ] }改完后重启 Docker:
sudo systemctl restart docker要提醒的是,镜像加速器解决的是“公共镜像拉取超时、速度慢”的问题,不会改变镜像内容,也不会绕过任何鉴权。生产环境如果对镜像可靠性要求高,更推荐自建镜像仓库。
Linux 安装 Docker 后如果服务起不来,常见检查点是systemctl status docker和journalctl -u docker。如果日志里出现 iptables 相关错误,检查net.ipv4.ip_forward是否开启、br_netfilter 模块是否加载。记住,架构理解到位后,你是顺着链路找问题,而不是乱试命令。
6. 常见问题与排查技巧实录
6.1 Docker Desktop 闪退/虚拟化未启用
这是 Windows 用户问得最多的问题。错误提示通常是 “Docker Desktop failed to start because virtualisation support wasn't detected”。原因几乎都出在两点:BIOS 虚拟化开关没开,或 Windows 的“虚拟机平台/WSL”功能没启用。
排查顺序:
- 打开任务管理器 → 性能 → CPU,看“虚拟化”是否显示“已启用”。如果显示未启用,进 BIOS 开启 Intel VT-x 或 AMD-V。
- 在“启用或关闭 Windows 功能”里勾选“适用于 Linux 的 Windows 子系统”和“虚拟机平台”。
- 重启后执行
wsl --status,确认 WSL 版本为 2。 - 最后再启动 Docker Desktop。
还有一个常见报错是failed to connect to the docker api at npipe:////./pipe/docker-desktop-linux,多半是 Docker Desktop 的后端虚拟机没起来,或者服务还在启动中。不要反复重启客户端,先看 WSL 状态,再尝试在管理员 PowerShell 里执行wsl --shutdown后重新启动。
6.2 Linux 下 Docker 服务启动失败
Linux 上systemctl start docker失败,先看 daemon 日志:
journalctl -u docker --no-pager -n 100常见三类原因:网络相关(iptables/br_netfilter 未加载)、存储驱动相关(文件系统不支持 overlay2)、权限/配置相关(daemon.json 语法错误)。比如在 CentOS 7 上,如果内核太老或文件系统是 XFS 但没开启 ftype,overlay2 就会失败,需要改用 vfs 驱动。
排查时先用dockerd --debug前台启动,日志会直接打到终端,比 journalctl 更直观。看到哪个模块报错,再针对性地查那个模块,而不是直接卸载重装。
6.3 连接 Docker daemon 权限错误
permission denied while trying to connect to the Docker daemon socket是刚装完 Linux Docker 最经典的问题。原因是你当前用户不在 docker 组里。执行:
sudo usermod -aG docker $USER newgrp docker之后重新打开终端再试。生产环境对 docker 组要谨慎放权,因为 docker 组里的人基本等同于拥有 root 权限——他们可以挂载宿主机目录到容器里再改文件。
6.4 容器网络不通
容器里ping 8.8.8.8不通,但docker exec能进容器,通常不是容器本身的问题。先看宿主机:
sysctl net.ipv4.ip_forward如果输出为 0,容器 NAT 转发就不会生效。另外很多系统加固脚本会把 iptables FORWARD 链默认策略改成 DROP,导致容器无法访问外网。可以把 FORWARD 链策略改回 ACCEPT,或者给 Docker 相关规则放行。
容器之间互相访问不通,则要看是不是自定义 bridge 网络没建好,或者服务只监听了 127.0.0.1。网络排查一定要有“分层”意识,先容器内、再宿主机、再外部链路。
6.5 镜像拉取失败与加速器配置
镜像拉取超时,最直接的办法是配置镜像加速器,前面已经写过配置方法。如果配置后仍失败,检查 daemon 是否真的加载了新配置:
docker info | grep -A 5 "Registry Mirrors"有些时候问题是镜像 tag 不存在或仓库名打错,看完整错误信息,不要看到 “timeout” 就只想到加速器。docker pull失败和docker run本地找不到镜像时的报错信息很像,但前者会明确出现 pull 相关字样,仔细看能省很多时间。
6.6 容器退出码与 OOM 排查
容器启动后立刻退出,先看状态:
docker ps -a docker inspect <container-id> | grep -E "ExitCode|OOMKilled|Error" docker logs <container-id>ExitCode 0通常意味着主进程正常退出;ExitCode 137很可能是被 kill,常见原因是 OOM 或手动 kill;OOMKilled: true说明容器内存超限被 cgroups 杀掉。遇到 OOM,优先检查应用内存配置,而不是无脑加-m上限。注意查看docker inspect里的State字段,它能让你从架构角度判断“容器进程根本起不来”还是“容器起来了但被系统杀掉”。
下面把六类高频问题整理成速查表:
| 问题 | 典型现象 | 排查方向 | 常见解决 |
|---|---|---|---|
| Docker Desktop 无法启动 | 提示 virtualisation support not detected | BIOS 虚拟化开关、Windows 功能 | BIOS 开启 VT-x/AMD-V,启用 WSL2 |
| Docker 服务启动失败 | systemctl start 失败 | journalctl 日志、dockerd --debug | 加载 br_netfilter、开启 ip_forward |
| 权限错误 | permission denied on socket | 用户是否在 docker 组 | usermod -aG docker |
| 容器无外网 | ping 不通公网 IP | 宿主机 ip_forward、iptables FORWARD 链 | 开启转发、调整 FORWARD 策略 |
| 镜像拉取慢/超时 | pull 卡住或失败 | daemon.json 配置 | 配置镜像加速器 |
| 容器启动即退出 | ExitCode 非 0、OOMKilled true | inspect、logs | 调整内存限制、修复应用启动命令 |
最后分享一个我自己的习惯:排容器问题时,我很少一上来就看 Docker 命令,而是先ps -ef找出容器进程,再lsns -p和cat /proc/<PID>/cgroup确认隔离状态,最后才回到docker logs和docker inspect。这套“从内核到用户态”的顺序,正是基于 docker-container-architecture 这条链路来的。你在实际操作里也可以试试,把这套流程跑一遍,比盲目搜报错信息靠谱得多。