Docker 核心概念

镜像(Image)、容器(Container)、仓库(Registry)——理解这三个概念就理解了 Docker 的全部产品逻辑

阶段一 · 05 · 返回阶段大纲

前置回顾

前四篇我们从底层理解了容器的三个技术基石:Namespace(隔离视图)、Cgroups(限制资源)、OverlayFS(分层文件系统)。本篇开始转换视角——从"内核怎么实现容器"切换到"Docker 怎么把这些封装成产品"。你会发现 Docker 并没有发明任何新技术,它做的是一件极其重要的事:把复杂的内核接口封装成简单易用的 CLI 和 API

Docker 是什么

一句话:Docker 是一个容器运行时(Container Runtime)产品,它把 Linux 内核的 Namespace、Cgroups、OverlayFS 等机制封装成了一套对开发者友好的工作流——写 Dockerfile → 构建镜像 → 推送仓库 → 拉取运行。

在你手动体验过 unsharemount -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 + bridgedocker 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。

镜像(Image)

分层只读——镜像的本质

回顾第 04 篇:OverlayFS 的 lowerdir 就是镜像的各层。每一层是一个只读的文件系统快照,由 Dockerfile 中的 RUNCOPYADD 指令产生:

镜像 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)
引用形式实际含义
nginxdocker.io/library/nginx:latest
nginx:1.25docker.io/library/nginx:1.25
myuser/myapp:v1docker.io/myuser/myapp:v1
harbor.company.com/app:v1私有仓库的 library/app:v1

tag 不是不可变的

nginx:latestlatest 是一个可移动标签——今天指向 1.25,明天可能指向 1.26。生产环境应使用digestnginx@sha256:abc...)或明确的版本号 tagnginx: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 恢复)
deleteddocker 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 = 施工经理(理解图纸,制定施工方案),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 公司内部定义的,没有任何公开规范。这意味着:

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 buildBuildahKanikoCloud 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 digesttag 是可移动标签(latest 会变),digest 是内容哈希(不可变),生产用 digest 或精确版本号
基础镜像ubuntu(通用)→ alpine(瘦身)→ distroless(安全)→ scratch(极限),越往右越小但越受限

动手练习

  1. docker image inspect nginx 查看镜像的分层信息(.RootFS.Layers),数一数有多少层
  2. 运行一个容器,用 docker inspect <container> 查看其 .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 三个维度思考)