镜像(Image)、容器(Container)、仓库(Registry)——理解这三个概念就理解了 Docker 的全部产品逻辑
前置回顾
前四篇我们从底层理解了容器的三个技术基石:Namespace(隔离视图)、Cgroups(限制资源)、OverlayFS(分层文件系统)。本篇开始转换视角——从"内核怎么实现容器"切换到"Docker 怎么把这些封装成产品"。你会发现 Docker 并没有发明任何新技术,它做的是一件极其重要的事:把复杂的内核接口封装成简单易用的 CLI 和 API。
一句话: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 的产品模型可以浓缩为三个概念,它们之间的关系是:
docker build docker run
┌──────────┐ ┌──────────┐
│Dockerfile│ ─────────→ │ 镜像 │ ─────────→ │ 容器 │
└──────────┘ │ (Image) │ │(Container)│
└────┬─────┘ └──────────┘
│
docker push / pull
│
▼
┌──────────┐
│ 仓库 │
│(Registry)│ 如 Docker Hub, Harbor, ECR
└──────────┘
| 概念 | 一句话 | 类比 | 对应前四篇的哪部分 |
|---|---|---|---|
| 镜像(Image) | 一组分层的只读文件系统 + 运行元数据 | 程序的"安装包" | OverlayFS 的 lowerdir(第 04 篇) |
| 容器(Container) | 镜像的运行实例,加上一个可写层和一个隔离的进程 | 安装后"正在运行的程序" | Namespace + Cgroups + OverlayFS upperdir(第 02/03/04 篇) |
| 仓库(Registry) | 镜像的存储和分发中心 | 程序的"应用商店/下载站" | 网络传输 + 存储(Docker 自己的工程实现) |
镜像:容器:仓库 = 类:对象:Maven 中央仓库。如果你写过 Java,镜像就是 class 定义,容器就是 new 出来的对象实例,Docker Hub 就是 Maven Central。如果你写 Python,镜像就是 wheel 包,容器就是 import 后正在运行的模块实例,Registry 就是 PyPI。
回顾第 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"——那层已经在本地了一个完整的镜像引用长这样:
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: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 都没有;需要程序自带一切 |
# 不同基础镜像的体积对比(概念示意)
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 (完全空白,需要静态编译的二进制)
当你执行 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 就是下载站。
┌──────────┐ 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 不是一个大黑盒——它内部由多个独立组件构成一条调用链。理解这条链为什么这样设计,比记住组件名字重要得多。
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 = 施工经理(理解图纸,制定施工方案),runc = 工人(一砖一瓦把房子盖出来,他的手直接碰到砖头水泥)。
设计师不需要知道砌墙的具体手势,工人不需要理解建筑设计方案——但他们之间通过标准图纸(OCI Specification,OCI 规范)来传递信息。
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 直接接管。
Docker 2013 年一炮而红后,很多公司也想做容器产品。但当时有一个严重的问题:Docker 镜像是什么格式、容器怎么启动,完全是 Docker 公司内部定义的,没有任何公开规范。这意味着:
docker build 打的镜像,rkt 运行不了2015 年,在 Linux 基金会的推动下,Docker 公司把容器的核心规范捐献出来,成立了 OCI(Open Container Initiative,开放容器倡议)。OCI 定义了两个标准:
这个规范定义了镜像的文件格式。遵循这个规范的镜像可以在任何 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 开放后,任何运行时都能消费它。
这个规范回答了"给定一个镜像的 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 执行系统调用。
| 角色 | 工具/项目 | 干什么 |
|---|---|---|
| 构建镜像 | 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 体系最重要的一张图。理解它,你就理解了从"敲命令"到"内核干活"的完整路径:
$ 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 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(进程间通信)协作:
哪一层可以换掉 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(极限),越往右越小但越受限 |
docker image inspect nginx 查看镜像的分层信息(.RootFS.Layers),数一数有多少层docker inspect <container> 查看其 .GraphDriver.Data,找到 LowerDir/UpperDir/MergedDir 的实际路径docker ps -a 观察容器的各种状态;尝试 create → start → stop → start → rm 完整生命周期docker login 登录,给一个本地镜像打 tag 后 push 上去,再从另一台机器(或删除本地镜像后)pull 下来验证docker run --rm -it alpine sh 退出后容器自动删除。你能从前四篇的知识解释:容器删除后,哪些资源被释放了?(提示:从 Namespace/Cgroup/OverlayFS 三个维度思考)