前置回顾
前四篇我们从底层理解了容器的三个技术基石:Namespace(隔离视图),Cgroups(限制资源),OverlayFS(分层文件系统).
本篇开始转换视角--从"内核怎么实现容器"切换到"Docker 怎么把这些封装成产品".你会发现 Docker 并没有发明任何新技术,它做的是一件极其重要的事:把复杂的内核接口封装成简单易用的 CLI 和 API.
Docker 是什么
Docker 是一个容器运行时(Container Runtime)产品,它把 Linux 内核的 Namespace,Cgroups,OverlayFS 等机制封装成了一套对开发者友好的工作流--写 Dockerfile → 构建镜像 → 推送仓库 → 拉取运行.
在你手动体验过 unshare,mount -t overlay,往 /sys/fs/cgroup 写文件之后,回头看 Docker 的命令,你会发现:
| 你手动做的事 | Docker 等价操作 | Docker 帮你在背后做了什么 |
|---|---|---|
unshare --pid --net --mount ... |
docker run --rm -it alpine sh |
自动创建全部 6 种 Namespace |
mount -t overlay ... |
Dockerfile 的每条 RUN/COPY | 自动管理 OverlayFS 的各层和挂载 |
echo 50000 > /sys/fs/cgroup/cpu/... |
docker run --cpus=0.5 ... |
自动创建 Cgroup 并写入限制值 |
| 手动创建 veth pair + bridge | docker network create |
自动创建 veth,分配 IP,配置 iptables |
Docker 之于容器,就像自动挡汽车之于手动挡.
-
手动挡(直接调 Linux 内核 API)让你精确控制每一步--踩离合,挂挡,松离合--但也意味着你需要理解每一个细节.
-
Docker(自动挡)把这些步骤自动化了,你只需要踩油门和刹车.但要成为高手,你得知道自动挡箱子里面的齿轮是怎么转的--这就是前四篇的价值.
三大核心概念:镜像,容器,仓库
Docker 的产品模型可以浓缩为三个概念,它们之间的关系是:
|
| 概念 | 一句话 | 类比 | 对应前四篇的哪部分 |
|---|---|---|---|
| 仓库(Registry) | 镜像的存储和分发中心 | 程序的"应用商店/下载站" | 网络传输 + 存储(Docker 自己的工程实现) |
| 镜像(Image) | 一组分层的只读文件系统 + 运行元数据 | 程序的"安装包" | OverlayFS 的 lowerdir(第 04 篇) |
| 容器(Container) | 镜像的运行实例,加上一个可写层和一个隔离的进程 | 安装后"正在运行的程序" | Namespace + Cgroups + OverlayFS upperdir(第 02/03/04 篇) |
镜像:容器:仓库 = 类:对象:Maven 中央仓库.
如果你写 Java,镜像就是 class 定义,容器就是 new 出来的对象实例,Docker Hub 就是 Maven Central.
如果你写 Python,镜像就是 wheel 包,容器就是 import 后正在运行的模块实例,Registry 就是 PyPI.
镜像(Image)
分层只读--镜像的本质
回顾第 04 篇:OverlayFS 的 lowerdir 就是镜像的各层.每一层是一个只读的文件系统快照,由 Dockerfile 中的 RUN,COPY,ADD 指令产生:
|
层的共享机制
层是通过内容寻址(content-addressable) 来共享的.两个镜像的"debian:bookworm"层如果内容完全相同,磁盘上只存一份,全局所有镜像共享.
这就是为什么: docker pull 有时候会显示 "Already exists"--那层已经在本地了
- 镜像 ID 是一个 sha256 哈希--它是各层哈希的组合,内容相同则 ID 相同
- 层的共享是 Docker 节省磁盘空间的核心手段
镜像命名规范
一个完整的镜像引用长这样:
|
| 引用形式 | 实际含义 |
|---|---|
nginx |
docker.io/library/nginx:latest |
nginx:1.25 |
docker.io/library/nginx:1.25 |
myuser/myapp:v1 |
docker.io/myuser/myapp:v1 |
harbor.company.com/app:v1 |
私有仓库的 library/app:v1 |
tag 不是不可变的
nginx:latest 的 latest 是一个可移动标签--今天指向 1.25,明天可能指向 1.26.
生产环境应使用digest(nginx@sha256:abc...)或明确的版本号 tag(nginx:1.25.3)来锁定版本.digest 由镜像内容计算得出,改了内容 digest 就变,所以它是真正不可变的.
基础镜像的选择
每个 Dockerfile 第一行都是 FROM xxx,这个 xxx 就是基础镜像.选择基础镜像的核心权衡是体积 vs 兼容性:
| 选择 | 大小 | 适用场景 | 代价 |
|---|---|---|---|
ubuntu / debian |
~70MB | 通用场景,需要丰富的包生态 | 体积大,CVE 多 |
alpine |
~5MB | 追求镜像瘦身 | 用 musl libc 而非 glibc,某些程序不兼容;无 apt,用 apk |
distroless |
~2-20MB | 生产环境,最小攻击面 | 没有 shell,没有包管理器,无法 exec 进容器调试 |
scratch |
0MB | 静态编译的 Go/Rust 程序 | 完全没有用户态工具,连 sh 都没有;需要程序自带一切 |
|
容器(Container)
容器 = 镜像 + 可写层 + 进程
当你执行 docker run nginx 时,Docker 做的事可以用前四篇的知识完全解释:
|
|
容器不是虚拟机
容器里没有独立的内核,没有 init 进程(systemd),不需要模拟硬件.容器进程就是宿主机上的一个普通进程,只是被 Namespace/Cgroups/OverlayFS 联合"包装"了.这就是为什么容器启动是毫秒级的而虚拟机是秒级的.
容器生命周期
|
| 状态 | 含义 | 进程状态 | 可写层 |
|---|---|---|---|
| created | 容器已创建但未启动(文件系统已准备就绪) | 无 | 已创建,空 |
| running | 容器主进程正在运行 | 运行中 | 活跃读写 |
| paused | 所有进程被冻结(Cgroup freezer) | 挂起 | 保留 |
| exited | 主进程已退出(正常结束或崩溃) | 已退出 | 保留(可用 docker start 恢复) |
| deleted | docker rm 后,容器消失 |
无 | 已销毁 |
|
容器退出的关键规则
容器内 PID 1 的进程退出 → 容器就退出了.所以容器里运行一个前台程序(如 nginx -g 'daemon off;'),这个程序必须一直运行不退出.
如果你在容器里跑了 systemd(多进程管理器),PID 1 就是 systemd,容器不会因为某个子进程退出而停止--但 Docker 不推荐这样做,一个容器一个进程是最佳实践.
仓库(Registry)
Registry 是镜像的存储和分发服务.如果把镜像比作安装包,Registry 就是下载站.
工作流程
|
| Registry | 类型 | 适用场景 |
|---|---|---|
| Docker Hub | 公共 SaaS | 开源项目,个人项目,官方镜像 |
| Harbor | 私有部署 | 企业内部,需要 RBAC,漏洞扫描,镜像复制 |
| AWS ECR / Google GCR / 阿里云 ACR | 云厂商托管 | 与云基础设施深度集成 |
|
推拉的分层优化
docker push/pull 是按层传输的.如果你修改了 Dockerfile 的最后一行然后 rebuild,只有最后一层是新的--push 只传那一层,pull 只下载那一层.这是分层存储对镜像分发的直接收益.层的内容哈希保证了一旦层的内容不变,它在任何机器上的 ID 都一样,可以被全局共享和缓存.
完整架构调用链
前面讲镜像,容器,仓库时,我们一直用"Docker 做了 X"这种笼统的说法.实际上,Docker 不是一个大黑盒--它内部由多个独立组件构成一条调用链.理解这条链为什么这样设计,比记住组件名字重要得多.
架构为什么拆成三层
最初:Docker 是一个大单体
2013 年 Docker 刚发布时,架构非常简单--只有一个二进制,叫 docker.这个二进制既是 CLI(命令行界面),又是 Daemon(后台守护进程),还直接调 Linux 内核 API 创建容器:
|
这个架构的问题很快暴露:
| 问题 | 具体表现 |
|---|---|
| 无法独立升级 | 要修一个网络 bug 就得升级整个 Docker,可能导致其他功能不稳定 |
| 无法被其他工具复用 | Kubernetes 想管理容器就必须通过 Docker,没有别的选择.Docker 的 bug 就是所有人的 bug |
| 代码越来越臃肿 | 镜像管理,容器生命周期,网络,存储,日志全塞在一个进程里 |
| 安全问题 | CLI 和 Daemon 不分,意味着创建容器需要 root 权限,风险面大 |
拆分:把"做容器"的事独立出来
Docker 公司做了一个关键决定:把创建和管理容器的核心能力从 dockerd 中剥离出来,成为独立项目.这就是 containerd 的由来(2016 年捐赠给 CNCF):
|
这个拆分的核心思想是关注点分离:
- dockerd:关心"用户想要什么"--镜像叫什么,端口怎么映射,给多少内存
- containerd:关心"容器应该长什么样"--镜像的每一层在哪,rootfs 怎么拼,Namespace 要哪些
- runc:关心"怎么让内核知道这些"--clone(),unshare(),mount() 这些系统调用的具体参数和顺序
类比建筑行业:
- dockerd = 设计师(画出房子的图纸)
- containerd = 施工经理(理解图纸,制定施工方案)
- runc = 工人(一砖一瓦把房子盖出来,他的手直接碰到砖头水泥)
设计师不需要知道砌墙的具体手势,工人不需要理解建筑设计方案--但他们之间通过 标准图纸(OCI Specification,OCI 规范) 来传递信息.
拆分的最大收益:containerd 可以被任何人用
containerd 独立出来后,Kubernetes 可以直接对接 containerd,不需要经过 dockerd.这就是 K8s 1.24 弃用 dockerd(更准确地说,弃用 dockershim)的原因:
|
关键澄清
K8s 弃用的是 dockershim (对接 Docker 的适配层),不是弃用 Docker 镜像.
docker build 打出来的镜像依然可以在任何 OCI 兼容的运行时上运行--因为镜像是标准的 OCI Image Spec 格式.你依然可以用 Docker 开发,构建镜像,只是在 K8s 集群上运行容器时,containerd 直接接管.
OCI:容器的"国际标准"
为什么需要标准?
Docker 2013 年一炮而红后,很多公司也想做容器产品.但当时有一个严重的问题:Docker 镜像是什么格式,容器怎么启动,完全是 Docker 公司内部定义的,没有任何公开规范.这意味着:
- CoreOS 做了一个容器引擎叫 rkt,但它的镜像格式和 Docker 不兼容--你用
docker build打的镜像,rkt 运行不了 - Google 想做一个容器编排系统(后来的 Kubernetes),但只能通过 Docker 的私有 API 操作容器--非常脆弱
- 任何想做容器生态的公司,都绕不开 Docker--这形成了事实上的垄断
2015 年,在 Linux 基金会的推动下,Docker 公司把容器的核心规范捐献出来,成立了 OCI(Open Container Initiative,开放容器倡议).OCI 定义了两个标准:
OCI Image Specification(镜像规范) - 镜像应该长什么样
这个规范定义了镜像的文件格式.遵循这个规范的镜像可以在任何 OCI 兼容的运行时上运行:
|
只要你按这个格式打包,不管你用什么工具构建(docker build,Buildah,Kaniko,Cloud Native Buildpacks),产出的镜像都能被任何 OCI 兼容的运行时(containerd,CRI-O)消费.
OCI Image Spec = PDF 标准.
Adobe 发明了 PDF,但把规范开放出来后,任何软件(浏览器,Word,Preview)都能打开 PDF 文件.
Docker 镜像也类似--Docker 发明了容器镜像格式,但通过 OCI 开放后,任何运行时都能消费它.
OCI Runtime Specification(运行时规范) - 如何启动一个容器
这个规范回答了"给定一个镜像的 rootfs,应该用什么步骤把它变成一个运行中的容器":
|
这个 JSON 就是containerd 和 runc 之间的"合同".containerd 负责把用户的需求(docker run 的参数)翻译成这个 JSON,runc 负责照着 JSON 执行系统调用.
OCI 生态:谁在生产?谁在消费?
| 角色 | 工具/项目 | 干什么 |
|---|---|---|
| 构建镜像 | Docker / Buildah / Kaniko / Buildpacks | 产出符合 OCI Image Spec 的镜像 |
| 管理容器生命周期 | containerd / CRI-O | 拉镜像,存镜像,创建/停止/删除容器 |
| 真正运行容器 | runc / crun / youki / kata-containers / gVisor | 读取 OCI Runtime Spec 的 JSON,调系统调用创建容器 |
| 分发镜像 | Docker Hub / Harbor / ECR / GCR | 存储和传输 OCI Image Spec 格式的镜像 |
| 编排容器 | Kubernetes / Nomad | 通过 containerd/CRI-O 的接口管理大规模容器 |
OCI 的关键洞察
OCI 不是"一个容器产品",而是一份接口合同.
类比:USB 是一个硬件标准--任何厂商造的 U 盘只要符合 USB 标准,就能插进任何电脑的 USB 口.
OCI 就是容器世界的 USB 标准--任何工具构建的镜像(image spec)都能被任何运行时(runtime spec)运行.
这就是为什么你可以用 Docker 构建镜像,但在 Kubernetes 上用 containerd 运行它,两者完全兼容.
docker run 背后发生了什么
有了上面的铺垫,现在再来看调用链全景图.这就是整个 Docker 体系最重要的一张图.理解它,你就理解了从"敲命令"到"内核干活"的完整路径:
|
各组件深入:职责,边界与存在理由
docker CLI
| 核心职责 | 解析命令参数 → 构造 HTTP 请求 → 发给 dockerd → 格式化输出结果。例:docker run --cpus=0.5 nginx → POST {"CpuQuota": 50000} |
| 不做什么 | 不管容器怎么创建,镜像怎么存储 |
| 存在理由 | 界面和逻辑分离,CLI 可以独立升级;第三方 CLI(如 nerdctl)可直接对接 containerd |
dockerd
| 核心职责 | API 网关:接收 CLI / SDK / K8s 的请求 |
| 亲自处理 | Docker 私有功能,不属于 OCI 标准: • 网络 - 分配 IP,创建 veth pair,配 iptables( -p 端口映射,MASQUERADE)• 卷 - bind mount( -v /host:/container),命名卷管理• 日志 - 收集 stdout/stderr,对接各种 logging driver |
| 委托 containerd | 镜像拉取/存储,容器生命周期管理 |
| 委托 runc(通过 containerd) | Namespace / Cgroup / OverlayFS 等内核操作 |
| 不做什么 | 不直接创建 Namespace,不直接写 Cgroup 文件,不直接 mount OverlayFS |
| 存在理由 | 容器创建的核心能力抽走给 containerd,dockerd 专注"产品功能"(网络/卷/日志/API) |
containerd
| 核心职责 | • 镜像:拉取,存储,解压,层管理 • 容器:创建/启动/停止/删除,状态跟踪 • 对接运行时:生成 OCI Runtime Spec JSON,调用 runc 执行 |
| 不做什么 | 不调 Linux 内核 API(Namespace/Cgroup 创建都是 runc 做的);不处理网络和卷 |
| 存在理由 | - 解耦:容器管理能力从 Docker 剥离,K8s 等系统可直接使用 - 标准化:通过 OCI Spec 与底层 Runtime 对接,Runtime 可自由替换 |
runc
| 核心职责 | 读取 config.json(OCI Runtime Spec)→ 调用 clone/unshare/mount/cgroup 等系统调用 → 创建容器进程 → 退出。唯一"手碰到内核"的组件 |
| 不做什么 | 不管镜像拉取,不管容器状态跟踪,不管网络配置;没有常驻进程 |
| 存在理由 | - 极简:只做一件事(造容器),做好就退出,代码少易审计 - 可替换:可换成 crun(C 重写更快)、youki(Rust 实现)、kata-containers(虚拟机级隔离) |
一个常见的误解
很多人以为 containerd 和 runc 是被 Docker 调用的两个函数,实际上它们是完全独立的进程,通过标准的 IPC(进程间通信)协作:
- dockerd 和 containerd 之间通过 gRPC 通信(可以跨网络,dockerd 可以在机器 A,containerd 在机器 B)
- containerd 和 runc 之间通过 fork + exec(containerd 启动一个 runc 子进程,然后等着它完成退出)协作
- 它们各自有自己的代码仓库,自己的维护者,自己的发布周期
替换关系一览
|
小结
里程碑
前四篇理解了底层三件套(Namespace + Cgroups + OverlayFS),本篇把它们串联为 Docker 的完整产品模型.从此你可以用三条线索来理解 Docker:
-
产品线索:Dockerfile → Image → Registry → docker pull → docker run → Container
-
内核线索:OverlayFS lowerdir → OverlayFS upperdir + merged → Namespace + Cgroups → 受限进程
-
调用链线索:docker CLI → dockerd → containerd → runc → Linux Kernel
本篇要点回顾
| 要点 | 一句话概括 |
|---|---|
| 镜像(Image) | 一组分层的只读文件系统(lowerdir),由 Dockerfile 构建,通过内容哈希全局共享 |
| 容器(Container) | 镜像的运行实例 = 镜像层 + 可写层(upperdir)+ 被 Namespace/Cgroup 隔离的进程 |
| 仓库(Registry) | 镜像的存储和分发中心,推拉按层传输,已存在的层自动跳过 |
| Docker 架构 | docker CLI → dockerd(API 网关)→ containerd(生命周期)→ runc(内核交互) |
| OCI 标准 | 镜像格式和运行时规范的开放标准,让容器生态组件可自由替换 |
| tag vs digest | tag 是可移动标签(latest 会变),digest 是内容哈希(不可变),生产用 digest 或精确版本号 |
| 基础镜像 | ubuntu(通用)→ alpine(瘦身)→ distroless(安全)→ scratch(极限),越往右越小但越受限 |
动手练习
- 用
docker image inspect nginx查看镜像的分层信息(.RootFS.Layers),数一数有多少层 - 运行一个容器,用
docker inspect查看其.GraphDriver.Data,找到 LowerDir/UpperDir/MergedDir 的实际路径 - 进入 MergedDir 目录(需要 sudo),随便创建一个文件,回到容器内验证能看到--这就是容器文件系统的本质
- 用
docker ps -a观察容器的各种状态;尝试 create → start → stop → start → rm 完整生命周期 - 在 Docker Hub 注册账号,用
docker login登录,给一个本地镜像打 tag 后 push 上去,再从另一台机器(或删除本地镜像后)pull 下来验证 - 思考题:
docker run --rm -it alpine sh退出后容器自动删除.你能从前四篇的知识解释:容器删除后,哪些资源被释放了?(提示:从 Namespace/Cgroup/OverlayFS 三个维度思考)