阶段二 · Docker 工程实践

镜像 / 容器 / 仓库

前置回顾

前四篇我们从底层理解了容器的三个技术基石: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)] → [镜像(Image)] → [容器(Container)]
概念 一句话 类比 对应前四篇的哪部分
仓库(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 指令产生:

镜像 nginx:latest 的分层结构(概念示意):

┌─────────────────────────────────┐
│ Layer 6: CMD ["nginx", "-g" ...] │ ← 元数据层(不占文件空间)
├─────────────────────────────────┤
│ Layer 5: COPY nginx.conf /etc/ │ ← 配置文件
├─────────────────────────────────┤
│ Layer 4: RUN apt-get install ... │ ← 依赖安装
├─────────────────────────────────┤
│ Layer 3: RUN apt-get update │ ← 包索引更新
├─────────────────────────────────┤
│ Layer 2: ADD nginx.tar.gz / │ ← nginx 二进制
├─────────────────────────────────┤
│ Layer 1: FROM debian:bookworm │ ← 基础镜像
└─────────────────────────────────┘

镜像 ID(sha256:abc123...)= 所有这些层的内容哈希
→ 只要 Layer 1~6 的内容完全一致,镜像 ID 就一致

层的共享机制

层是通过内容寻址(content-addressable) 来共享的.两个镜像的"debian:bookworm"层如果内容完全相同,磁盘上只存一份,全局所有镜像共享.

这就是为什么: docker pull 有时候会显示 "Already exists"--那层已经在本地了

  • 镜像 ID 是一个 sha256 哈希--它是各层哈希的组合,内容相同则 ID 相同
  • 层的共享是 Docker 节省磁盘空间的核心手段

镜像命名规范

一个完整的镜像引用长这样:

registry.example.com:5000/myapp/backend:1.2.3@sha256:abc123...
│ │ │ │ │ │
│ │ │ │ │ └── digest(内容哈希,精确锁定版本)
│ │ │ │ └────── tag(语义版本标签,可移动)
│ │ │ └────────────── 镜像名
│ │ └───────────────────── 命名空间(用户名/组织)
│ └────────────────────────── registry 地址(省略默认 Docker Hub)
引用形式 实际含义
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:latestlatest 是一个可移动标签--今天指向 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 都没有;需要程序自带一切
# 不同基础镜像的体积对比(概念示意)
FROM ubuntu:22.04 # ~77MB (glibc + apt + shell)
FROM alpine:3.19 # ~7MB (musl + apk + shell)
FROM gcr.io/distroless/cc # ~20MB (只有 glibc + libstdc++,无 shell)
FROM scratch # 0MB (完全空白,需要静态编译的二进制)

容器(Container)

容器 = 镜像 + 可写层 + 进程

当你执行 docker run nginx 时,Docker 做的事可以用前四篇的知识完全解释:

docker run --name my-nginx -p 8080:80 nginx

1. 解压镜像 → 获得 nginx 的 6 个只读层(lowerdir)
2. 创建一个空的可写层(upperdir)→ 用于容器运行时的文件修改
3. 用 OverlayFS 合并:merged = overlay(lowerdir=6层..., upperdir=可写层)
4. 创建一组新 Namespace → PID/Network/Mount/UTS/IPC/User
5. 在容器的 Network NS 中创建 veth pair → 一端连 docker0 网桥,一端放进容器
6. 创建 Cgroup → 应用 --cpus/--memory 等资源限制
7. 在容器的 PID NS 中启动 nginx 进程(PID 1)
8. nginx 进程的 rootfs = merged 目录
┌──────────────────────────────────────────┐
│ 容器(Container) │
│ │
│ ┌────────────────────────────┐ │
│ │ nginx 进程 │ │
│ │ PID NS: 它以为自己是 PID 1 │ ← 第 02 篇│
│ │ Network NS: 有自己的 eth0 │ ← 第 02 篇│
│ │ Cgroup: 只能用到分配的 CPU │ ← 第 03 篇│
│ ├────────────────────────────┤ │
│ │ 可写层(upperdir) │ ← 第 04 篇│
│ │ 容器所有文件修改写在这里 │ │
│ ├────────────────────────────┤ │
│ │ 镜像层 6(nginx 配置) │ │
│ │ 镜像层 5(依赖安装) │ │
│ │ ... │ ← 第 04 篇│
│ │ 镜像层 1(debian base) │ │
│ └────────────────────────────┘ │
│ │
│ rootfs = merged(lowerdir=层1~6, │
│ upperdir=可写层) │
└──────────────────────────────────────────┘

容器不是虚拟机

容器里没有独立的内核,没有 init 进程(systemd),不需要模拟硬件.容器进程就是宿主机上的一个普通进程,只是被 Namespace/Cgroups/OverlayFS 联合"包装"了.这就是为什么容器启动是毫秒级的而虚拟机是秒级的.

容器生命周期

docker create           docker start          docker stop / kill
─────────────→ [已创建] ──────────→ [运行中] ──────────→ [已停止]

├─ docker pause / unpause
│ (暂停/恢复:冻结 Cgroup,进程不退出)

└─ docker restart
(stop + start,容器 ID 不变,
但上层可写层默认保留)


docker rm → [已删除]
(删除容器,可写层被销毁;镜像层不受影响)
状态 含义 进程状态 可写层
created 容器已创建但未启动(文件系统已准备就绪) 已创建,空
running 容器主进程正在运行 运行中 活跃读写
paused 所有进程被冻结(Cgroup freezer) 挂起 保留
exited 主进程已退出(正常结束或崩溃) 已退出 保留(可用 docker start 恢复)
deleted docker rm 后,容器消失 已销毁
# 创建但不启动(文件系统都准备好了)
docker create --name test nginx
# 返回容器 ID

# 查看所有容器(包括已停止的)
docker ps -a

# 启动已创建的容器
docker start test

# 暂停容器(进程挂起,不退出,内存保留)
docker pause test
docker unpause test

# 停止容器(主进程收到 SIGTERM,超时后 SIGKILL)
docker stop test

# 删除已停止的容器(可写层被销毁)
docker rm test

# 强制删除运行中的容器(先 SIGKILL 再 rm)
docker rm -f test

# 自动清理:运行完就删除
docker run --rm -it alpine sh

容器退出的关键规则

容器内 PID 1 的进程退出 → 容器就退出了.所以容器里运行一个前台程序(如 nginx -g 'daemon off;'),这个程序必须一直运行不退出.

如果你在容器里跑了 systemd(多进程管理器),PID 1 就是 systemd,容器不会因为某个子进程退出而停止--但 Docker 不推荐这样做,一个容器一个进程是最佳实践.

仓库(Registry)

Registry 是镜像的存储和分发服务.如果把镜像比作安装包,Registry 就是下载站.

工作流程

┌──────────┐   docker push    ┌──────────────┐   docker pull    ┌──────────┐
│ 本机 │ ───────────────→ │ Registry │ ←─────────────── │ 服务器 │
│ myapp:v1 │ │ (Docker Hub, │ │ │
└──────────┘ │ Harbor, ECR) │ └──────────┘
└──────────────┘

流程:
1. docker build -t myapp:v1 . → 本机构建镜像
2. docker tag myapp:v1 myuser/myapp:v1 → 打上 Registry 需要的命名空间标签
3. docker push myuser/myapp:v1 → 推送到 Registry
4. (在服务器上)docker pull myuser/myapp:v1 → 从 Registry 拉取
5. docker run myuser/myapp:v1 → 运行
Registry 类型 适用场景
Docker Hub 公共 SaaS 开源项目,个人项目,官方镜像
Harbor 私有部署 企业内部,需要 RBAC,漏洞扫描,镜像复制
AWS ECR / Google GCR / 阿里云 ACR 云厂商托管 与云基础设施深度集成
# 登录 Registry
docker login # Docker Hub
docker login harbor.company.com # 私有 Registry
docker logout

# 打标签(为推送做准备)
docker tag myapp:v1 myuser/myapp:v1
docker tag myapp:v1 myuser/myapp:latest

# 推送
docker push myuser/myapp:v1
docker push myuser/myapp:latest
# 注意:推送的是层,已存在的层自动跳过("Layer already exists")

# 拉取
docker pull myuser/myapp:v1
# 同样,已有的层跳过不下载

# 搜索(Docker Hub)
docker search nginx

推拉的分层优化

docker push/pull 是按传输的.如果你修改了 Dockerfile 的最后一行然后 rebuild,只有最后一层是新的--push 只传那一层,pull 只下载那一层.这是分层存储对镜像分发的直接收益.层的内容哈希保证了一旦层的内容不变,它在任何机器上的 ID 都一样,可以被全局共享和缓存.

完整架构调用链

前面讲镜像,容器,仓库时,我们一直用"Docker 做了 X"这种笼统的说法.实际上,Docker 不是一个大黑盒--它内部由多个独立组件构成一条调用链.理解这条链为什么这样设计,比记住组件名字重要得多.

架构为什么拆成三层

最初:Docker 是一个大单体

2013 年 Docker 刚发布时,架构非常简单--只有一个二进制,叫 docker.这个二进制既是 CLI(命令行界面),又是 Daemon(后台守护进程),还直接调 Linux 内核 API 创建容器:

Docker 早期(2013):
docker CLI/daemon(一个大二进制)
└─ 直接调 clone() / unshare() / mount() 等系统调用
└─ 直接操作 cgroup 文件系统
└─ 没有中间层

这个架构的问题很快暴露:

问题 具体表现
无法独立升级 要修一个网络 bug 就得升级整个 Docker,可能导致其他功能不稳定
无法被其他工具复用 Kubernetes 想管理容器就必须通过 Docker,没有别的选择.Docker 的 bug 就是所有人的 bug
代码越来越臃肿 镜像管理,容器生命周期,网络,存储,日志全塞在一个进程里
安全问题 CLI 和 Daemon 不分,意味着创建容器需要 root 权限,风险面大

拆分:把"做容器"的事独立出来

Docker 公司做了一个关键决定:把创建和管理容器的核心能力从 dockerd 中剥离出来,成为独立项目.这就是 containerd 的由来(2016 年捐赠给 CNCF):

拆分后的 Docker 架构:

docker CLI ← 用户界面(解析命令,格式化输出)
│ HTTP REST (/var/run/docker.sock)

dockerd ← API 网关(权限,编排,网络,卷管理)
│ gRPC

containerd ← 容器生命周期(镜像拉取/存储,容器创建/启停/删除)
│ fork + exec

runc ← 真正"造容器"的那只手(调 Linux 内核系统调用)

这个拆分的核心思想是关注点分离:

  • dockerd:关心"用户想要什么"--镜像叫什么,端口怎么映射,给多少内存
  • containerd:关心"容器应该长什么样"--镜像的每一层在哪,rootfs 怎么拼,Namespace 要哪些
  • runc:关心"怎么让内核知道这些"--clone(),unshare(),mount() 这些系统调用的具体参数和顺序

类比建筑行业:

  • dockerd = 设计师(画出房子的图纸)
  • containerd = 施工经理(理解图纸,制定施工方案)
  • runc = 工人(一砖一瓦把房子盖出来,他的手直接碰到砖头水泥)

设计师不需要知道砌墙的具体手势,工人不需要理解建筑设计方案--但他们之间通过 标准图纸(OCI Specification,OCI 规范) 来传递信息.

拆分的最大收益:containerd 可以被任何人用

containerd 独立出来后,Kubernetes 可以直接对接 containerd,不需要经过 dockerd.这就是 K8s 1.24 弃用 dockerd(更准确地说,弃用 dockershim)的原因:

通过 Docker 的方式(多一层):
Kubernetes → dockershim → dockerd → containerd → runc → kernel
(dockershim 是 K8s 为了兼容 Docker 临时加的适配层)

直接对接 containerd 的方式(更简洁):
Kubernetes → containerd → runc → kernel
(少了中间两层,更快,更少 bug,更少维护成本)

关键澄清

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 兼容的运行时上运行:

一个 OCI 镜像 =

├── index.json ← 镜像的"目录",列出所有可用的 manifest(按平台/架构)
├── manifest.json ← 一个具体变体的"配置单":包含哪些层,每层的哈希
├── config.json ← 镜像的元数据(ENV,CMD,EXPOSE 等)
└── blobs/ ← 各层的实际文件内容(tar.gz 格式,每个 blob 用 sha256 命名)
├── sha256/aaa... ← Layer 1
├── sha256/bbb... ← Layer 2
└── ...

只要你按这个格式打包,不管你用什么工具构建(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,应该用什么步骤把它变成一个运行中的容器":

OCI Runtime Spec 定义的内容(就是前四篇学过的!):

1. 准备 rootfs(镜像的所有层叠加后的文件系统)
2. 配置 Namespace(PID / Network / Mount / UTS / IPC / User 各需要什么样的隔离)
3. 配置 Cgroup(CPU,内存,IO 等资源限制值)
4. 配置 Capabilities(容器进程有哪些内核权限)
5. 配置 Seccomp / AppArmor / SELinux(安全策略)
6. 配置挂载点(除了 rootfs,还需要挂哪些东西,如 /proc,/sys,tmpfs)
7. Hook(在容器生命周期各阶段执行的额外脚本)

最终输出:一个 JSON 文件(config.json),里面把上面所有这些配置描述清楚
任何 OCI Runtime 读到这个 JSON,就知道该给内核下什么指令

这个 JSON 就是containerd 和 runc 之间的"合同".containerd 负责把用户的需求(docker run 的参数)翻译成这个 JSON,runc 负责照着 JSON 执行系统调用.

O​​CI 生态:谁在生产?谁在消费?

角色 工具/项目 干什么
构建镜像 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 run -d --name web --cpus=0.5 -p 8080:80 nginx

│ 你在终端敲下这行命令


┌─────────────────────────────────────────────────────────────────┐
│ ① Docker CLI │
│ │
│ 解析参数:--name=web, --cpus=0.5, -p 8080:80, nginx │
│ 打包成 HTTP 请求 → POST /containers/create │
│ 发送到本机的 /var/run/docker.sock(Unix Socket) │
│ │
│ CLI 本身没有任何"创造容器"的能力--它只是个传话筒. │
└────────────────────────────┬────────────────────────────────────┘
│ HTTP REST(Unix Socket)

┌─────────────────────────────────────────────────────────────────┐
│ ② dockerd(Docker Daemon) │
│ │
│ 职责:理解用户意图,协调所有后续组件 │
│ │
│ a. 解析请求:"要跑 nginx 镜像,限制 0.5 核,端口映射 8080→80" │
│ │
│ b. 委托 containerd 处理镜像和容器: │
│ → "检查本地有没有 nginx 镜像,没有就去 Registry 拉" │
│ → "创建容器,限制 CPU 0.5 核" │
│ (镜像拉取/存储/解压 全部是 containerd 做的事) │
│ │
│ c. dockerd 亲自处理网络(这不是 OCI 标准的一部分): │
│ → 从 docker0 子网池分配 IP(如 172.17.0.3) │
│ → 创建 veth pair,一端插入容器 Network NS,一端接 docker0 │
│ → 写 iptables DNAT 规则实现 -p 8080:80 端口映射 │
│ → 写 iptables MASQUERADE 规则让容器访问外网 │
│ (网络配置是 Docker 私有功能,containerd/runc 都不管) │
│ │
│ d. dockerd 亲自处理存储: │
│ → 如果有 -v /host:/container,设置 bind mount │
│ → 如果有 -v myvolume:/data,创建/挂载命名卷 │
│ (卷管理也是 Docker 私有功能) │
│ │
│ e. dockerd 亲自处理日志: │
│ → 收集容器的 stdout/stderr │
│ → 按 logging driver 配置输出(json-file/syslog/fluentd...) │
│ │
│ 关键边界: │
│ dockerd 亲自处理网络,卷,日志(Docker 产品功能,非 OCI 标准) │
│ dockerd 委托 containerd 处理镜像和容器生命周期 │
│ dockerd 不碰的:Namespace 创建,Cgroup 写值,rootfs 挂载 │
│ (这些是 runc 的活,在后面) │
└────────────────────────────┬────────────────────────────────────┘
│ gRPC(本地或远程)

┌─────────────────────────────────────────────────────────────────┐
│ ③ containerd │
│ │
│ 职责:管理容器的完整生命周期 │
│ │
│ a. 镜像管理:检查本地镜像存储(/var/lib/containerd/), │
│ 如果需要就从 Registry 拉取(按层下载,已有的层跳过) │
│ b. 准备 rootfs:把所有镜像层解压到本地,等待 runc 使用 │
│ c. 生成 OCI Runtime Spec(一个 config.json 文件): │
│ { │
│ "ociVersion": "1.0.2", │
│ "process": { "args": ["nginx", "-g", "daemon off;"] }, │
│ "root": { "path": "/var/lib/containerd/.../rootfs" }, │
│ "linux": { │
│ "namespaces": [ │
│ {"type": "pid"}, {"type": "network"}, ...], │
│ "resources": { "cpu": {"quota": 50000} }, │
│ "mounts": [...] │
│ } │
│ } │
│ d. 调用 runc:"这是 config.json,按它给我造一个容器" │
│ e. 持续监控:runc 退出后,containerd 负责记录容器的退出码, │
│ 管理容器状态(running → exited),清理资源 │
│ │
│ containerd 不调任何系统调用--它只管理"逻辑". │
│ containerd 是 CNCF 毕业项目(和 Kubernetes 同级), │
│ 意味它是生产级基础设施. │
└────────────────────────────┬────────────────────────────────────┘
│ fork + exec(containerd 启动 runc 子进程)

┌─────────────────────────────────────────────────────────────────┐
│ ④ runc(OCI Runtime) │
│ │
│ 职责:读 config.json,一个系统调用一个系统调用地把容器"捏"出来 │
│ 这是唯一真正和 Linux 内核交互的组件 │
│ │
│ 步骤(对照前四篇的每一个知识点): │
│ │
│ 1. 创建 Namespace(clone() / unshare() 系统调用) │
│ ├─ PID NS → 容器内进程以为自己是 PID 1 │
│ ├─ Network NS → 独立的网络接口视图 │
│ ├─ Mount NS → 独立的挂载表 │
│ ├─ UTS NS → 独立的主机名 │
│ ├─ IPC NS → 独立的信号量/消息队列 │
│ └─ User NS → (可选)独立的 UID/GID 映射 │
│ │
│ 2. 设置 Cgroups(写 /sys/fs/cgroup/ 文件) │
│ └─ 根据 config.json 中的 resources 段写 CPU/内存/IO 限制 │
│ │
│ 3. 挂载 rootfs(mount -t overlay) │
│ └─ lowerdir=镜像各层, upperdir=容器可写层, workdir → merged │
│ │
│ 4. 挂载特殊文件系统 │
│ ├─ mount -t proc proc /proc │
│ ├─ mount -t sysfs sys /sys │
│ ├─ mount -t tmpfs tmpfs /dev/shm │
│ └─ bind mount /etc/hosts, /etc/resolv.conf │
│ │
│ 5. pivot_root / chroot → 把 merged 目录变成容器进程的新根 / │
│ │
│ 6. exec() → 启动容器的主进程(nginx),它在所有 Namespace 之内 │
│ │
│ 7. runc 自身退出(它的任务完成了),由 containerd 接管后续监控 │
│ │
│ ⚡ runc 的特点: │
│ - 非常小,约 10MB,代码不到 10 万行 │
│ - 内存占用极低(创建完容器就退出,不常驻) │
│ - 可以被任何 OCI Runtime 实现替换(crun/youki/kata-containers) │
└────────────────────────────┬────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│ ⑤ 宿主机上的 nginx 进程 │
│ │
│ ps aux | grep nginx → 你能在宿主机上看到它 │
│ 真实 PID = 12345(宿主机视角) │
│ 容器内 PID = 1(PID Namespace 视角) │
│ │
│ 这个进程: │
│ - 被 Namespace "包装"了 6 层(看到的世界被限制) │
│ - 被 Cgroup "套上缰绳"(资源使用被限制) │
│ - 根目录指向 merged(OverlayFS 的合并视图) │
│ │
│ 除此之外,它和宿主机上其他进程没有本质区别. │
│ 容器就是进程--只是被包装得很好. │
└─────────────────────────────────────────────────────────────────┘

各组件深入:职责,边界与存在理由

docker CLI

核心职责 解析命令参数 → 构造 HTTP 请求 → 发给 dockerd → 格式化输出结果。例:docker run --cpus=0.5 nginxPOST {"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 子进程,然后等着它完成退出)协作
  • 它们各自有自己的代码仓库,自己的维护者,自己的发布周期

替换关系一览

哪一层可以换掉 Docker 的实现?

docker CLI → 可换成 nerdctl(直接调 containerd,不经过 dockerd)
dockerd → 可以不用(containerd 有独立 CLI:ctr;nerdctl 提供 Docker 兼容体验)
containerd → 可换成 CRI-O(Red Hat 主导,K8s 专用,更轻量)
runc → 可换成 crun(更快)/ youki(Rust 写)/ kata-containers(虚拟机隔离)

举例:
标准 Docker 栈:
docker CLI → dockerd → containerd → runc → kernel

K8s 推荐栈(更简洁):
crictl → containerd → runc → kernel

Red Hat OpenShift 栈:
crictl → CRI-O → runc/crun → kernel

安全容器栈:
crictl → containerd → kata-containers → QEMU → guest kernel → 容器进程
(每个容器有自己的微型 Linux 内核,完全隔离)

小结

里程碑

前四篇理解了底层三件套(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(极限),越往右越小但越受限

动手练习

  1. docker image inspect nginx 查看镜像的分层信息(.RootFS.Layers),数一数有多少层
  2. 运行一个容器,用 docker inspect 查看其 .GraphDriver.Data,找到 LowerDir/UpperDir/MergedDir 的实际路径
  3. 进入 MergedDir 目录(需要 sudo),随便创建一个文件,回到容器内验证能看到--这就是容器文件系统的本质
  4. docker ps -a 观察容器的各种状态;尝试 create → start → stop → start → rm 完整生命周期
  5. 在 Docker Hub 注册账号,用 docker login 登录,给一个本地镜像打 tag 后 push 上去,再从另一台机器(或删除本地镜像后)pull 下来验证
  6. 思考题:docker run --rm -it alpine sh 退出后容器自动删除.你能从前四篇的知识解释:容器删除后,哪些资源被释放了?(提示:从 Namespace/Cgroup/OverlayFS 三个维度思考)