mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4589 字
13 分钟
容器全景:从 chroot 到 OCI
2021-09-04

1979 年,UNIX V7 引入了 chroot 系统调用,允许进程看到一个与根目录不同的文件系统视图,这是容器隔离思想的起点。三十多年后,容器技术已经从简单的目录切换发展为包含 Namespace、Cgroup、OverlayFS、OCI 规范、多层运行时架构的完整体系。docker run 一条命令背后,是四个进程、三个抽象层、两个规范的精密协作。

本章先沿着时间线看清容器是怎么一步步演进到今天的,再总览容器运行时的分层架构,最后辨析几组容易混淆的核心概念。至于三大内核基石的细节、OCI 规范的逐项拆解、runc 与 containerd 的源码,留给后续章节展开。

一、容器技术演进:从 chroot 到 OCI#

1.1 早期隔离:chroot(1979)#

chroot 是 UNIX 系统中最古老的隔离机制。它的设计极其简单:改变进程的根目录视图,让进程无法访问根目录之外的文件。

# 创建一个最小 rootfs
mkdir -p /tmp/myroot/bin /tmp/myroot/lib64
cp /bin/bash /tmp/myroot/bin/
# 复制 bash 需要的动态链接库
ldd /bin/bash | grep -oE '/[^ ]+' | xargs -I{} cp -v --parents {} /tmp/myroot
# 使用 chroot 切换根目录
sudo chroot /tmp/myroot /bin/bash
# 此时进程的根目录是 /tmp/myroot,无法访问原系统的文件

chroot 的隔离极其有限:

隔离维度chroot 是否隔离说明
文件系统视图改变根目录视图
进程可见性仍能看到所有 PID
网络栈共享宿主机网络
用户身份仍是同一个 UID
资源使用无资源限制
设备访问可访问 /dev 下设备

从这张表能看出,chroot 只动了一个根目录视图,进程、网络、用户、资源、设备全都和宿主机共享。这种程度的隔离离”安全”差得很远:

Warning

chroot 不提供安全隔离。root 用户可以轻松逃逸 chroot 环境,通过 mknod 创建内存设备、通过 ptrace 附加外部进程、通过 /proc 访问宿主文件系统。chroot 的设计目标是”便利”而非”安全”。

1.2 FreeBSD Jail(2000)与 Solaris Zones(2004)#

chroot 的局限性催生了更强的隔离方案。FreeBSD Jail 在 chroot 基础上增加了进程隔离、网络隔离和资源限制,Solaris Zones 则提供了更完整的操作系统级虚拟化。

这些方案虽然功能丰富,但都是特定操作系统的实现,无法在 Linux 上使用。Linux 需要自己的容器隔离方案。

1.3 Linux 容器的萌芽:LXC(2008)#

Linux 容器技术的真正起点是 Namespace 和 Cgroup 这两大内核特性。Namespace 早在 2002 年就随 Mount Namespace 进入内核,Cgroup 则在 2008 年随 2.6.24 合入主线,到 LXC 之前两者的组合已经具备容器雏形。LXC(Linux Containers)是第一个将这些内核特性组合使用的项目:

# 安装 lxc
apt install lxc -y
# LXC 下载模版创建容器
sudo lxc-create -n mycontainer -t download -- -d ubuntu -r noble -a amd64
sudo lxc-start -n mycontainer
sudo lxc-attach -n mycontainer
# LXC 底层使用的就是 Namespace + Cgroup
# 查看 LXC 容器的 Namespace
ls -la /proc/$(pgrep -f "lxc mycontainer")/ns/
# total 0
# dr-x--x--x 2 root root 0 Jul 14 23:30 .
# dr-xr-xr-x 9 root root 0 Jul 14 23:30 ..
# lrwxrwxrwx 1 root root 0 Jul 14 23:30 cgroup -> 'cgroup:[4026531835]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 ipc -> 'ipc:[4026532212]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 mnt -> 'mnt:[4026532223]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 net -> 'net:[4026531840]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 pid -> 'pid:[4026532225]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 pid_for_children -> 'pid:[4026532225]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 time -> 'time:[4026531834]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 time_for_children -> 'time:[4026531834]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 user -> 'user:[4026531837]'
# lrwxrwxrwx 1 root root 0 Jul 14 23:31 uts -> 'uts:[4026532224]'
# 停止容器
lxc-stop -n mycontainer
# 销毁容器
lxc-destroy -n mycontainer

注:如果 wsl2 下的 /dev/null 损坏了,需要通过 mknod -m 666 /dev/null c 1 3 重建,这样上面的命令才能正常工作

LXC 证明了 Namespace + Cgroup 的组合可以创建轻量级隔离环境,但它仍然是一个”系统容器”方案,用户需要管理完整的 init 系统,体验更像轻量级虚拟机而非现代容器。

1.4 Docker 革命(2013)#

Docker 的贡献不在于发明新技术,而在于重新定义了容器的使用体验

  1. 镜像(Image):将应用及其依赖打包为不可变的分层镜像,取代了 LXC 的模板机制
  2. Dockerfile:用声明式语法定义镜像构建步骤,实现了”基础设施即代码”
  3. 分层存储:基于 OverlayFS 的分层文件系统,镜像层可以共享复用
  4. 一键运行docker run 一条命令完成所有操作,极大降低了使用门槛
# Docker 之前的 LXC 工作流
sudo lxc-create -n myapp -t ubuntu # 创建容器
sudo lxc-start -n myapp # 启动容器
sudo lxc-attach -n myapp # 进入容器
# 在容器内手动安装依赖、配置应用...
# Docker 的工作流
docker run -d nginx # 一条命令搞定

1.5 OCI 标准化(2015)#

Docker 的成功带来了生态碎片化,CoreOS 推出了 rkt,Google 推出了 lmctfy,各家运行时不兼容。2015 年,Docker、CoreOS、Google 等公司共同成立了 OCI(Open Container Initiative),定义容器格式的开放标准:

  1. chroot

    UNIX V7 引入目录隔离,容器思想的最早源头

  2. FreeBSD Jail

    在 chroot 基础上增强进程与网络隔离

  3. Solaris Zones

    操作系统级虚拟化,资源粒度更细

  4. Cgroup v1 + LXC

    Cgroup(前 Process Containers)2007 年合入开发树,随 2.6.24 于 2008 年初发布,LXC 提供容器管理工具

  5. Docker

    镜像分层与易用 CLI 引爆容器生态

  6. Kubernetes

    容器编排成为标配

  7. OCI 成立

    镜像与运行时格式走向标准化

  8. Cgroup v2 / CRI-O

    Cgroup v2 于 Linux 4.5 合入主线;同年 CRI-O 作为 Kubernetes 专用轻量运行时立项(两者独立、非关联事件)

  9. containerd 独立

    运行时与引擎解耦,成为 CNCF 毕业项目

  10. gVisor / Kata

    沙箱与轻量虚拟机运行时涌现

  11. Wasm 容器

    WebAssembly 成为新的运行时方向

时间线里 2016 年的 Cgroup v2 合入主线值得多说一句。v1 时期每种资源(CPU、内存、IO 等)各自一套层级,控制器归属混乱、嵌套容易出错;v2 把所有资源统一到单一层级,支持控制器委托(让子 cgroup 在父级限制内自主分发资源),管理上清晰很多。需要说明的是,常被一起提起的 PSI(Pressure Stall Information)严格说是 4.20 引入的独立内核子系统,系统级 /proc/pressure/ 不依赖 cgroup,只是 per-cgroup 的压力暴露是 v2 才有的。这些改动让容器资源管理更可控,Kubernetes 从 1.25 起也把 cgroup v2 作为推荐配置。Cgroup 的细节在Cgroup 深入一章展开。

把演进史换一个角度看,每一代技术在隔离维度上是逐层叠加的。下表把几代方案和它们覆盖的隔离维度放在一起,能看出”容器隔离能力”是怎么一块块拼出来的:

方案文件系统进程网络用户资源限制安全模块
chroot(1979)
FreeBSD Jail(2000)
Solaris Zones(2004)
LXC(2008)部分
Docker / runc(2013+)

chroot 只有文件系统视图,Jail 补上进程和网络,Zones 补上用户,LXC 用 namespace/cgroup 把内核能力凑齐,Docker 再叠加 seccomp/AppArmor 等安全模块。现代容器的隔离不是一步到位的,是四十多年一层层加上来的。

二、容器运行时架构#

2.1 Docker 的架构演进#

Docker 的架构经历了从”大而全”到”分层解耦”的演进,中间有一个从”整合者”变成”内核能力直接消费者”的关键转折:

  • LXC 驱动时期(Docker 1.0 之前):Docker 早期直接调用 LXC 工具来创建和管理容器,自己只负责镜像和上层体验,底层隔离全靠 LXC。
  • libcontainer 自研时期(Docker 0.9 起):Docker 放弃对 LXC 的依赖,自研 libcontainer 直接操作 namespace/cgroup 等内核能力。这一步意味着 Docker 不再是”把别人的工具包一层”,而是自己掌握了容器进程的创建逻辑,也为后来的运行时标准化埋下伏笔。
  • containerd + runc 拆分时期(Docker 1.11 起):libcontainer 捐赠给 OCI 后成为 runc 的代码基础,Docker 把容器执行逻辑抽成独立的 containerd 和 runc,daemon 只剩镜像管理和上层 API。

到这一步,才有了下面的三层架构:

flowchart TB subgraph 用户层["用户层"] CLI["docker CLI"] end subgraph Daemon层["Daemon 层"] DOCKERD["dockerd<br/>镜像管理/网络/Volume"] end subgraph 运行时层["运行时层"] CTNRD["containerd<br/>容器生命周期管理"] SHIM["containerd-shim<br/>进程监督/解耦"] RUNC["runc<br/>OCI 运行时实现"] end subgraph 内核层["内核层"] KERNEL["Linux Kernel<br/>Namespace/Cgroup/OverlayFS"] end CLI -->|"gRPC"| DOCKERD DOCKERD -->|"gRPC (containerd.sock)"| CTNRD CTNRD -->|"启动 shim 进程"| SHIM SHIM -->|"exec runc"| RUNC RUNC -->|"系统调用"| KERNEL RUNC -->|"容器进程"| CONTAINER["容器进程"] SHIM -->|"监督"| CONTAINER style CLI fill:#bbdefb,stroke:#1565c0 style DOCKERD fill:#c8e6c9,stroke:#2e7d32 style CTNRD fill:#ffe0b2,stroke:#e65100 style RUNC fill:#ffcdd2,stroke:#c62828 style KERNEL fill:#e1bee7,stroke:#6a1b9a

2.2 为什么需要三层运行时?#

回头看 Docker 的架构演进:早期就是单体 daemon,一个进程干完镜像管理、网络、存储、容器生命周期所有事。当时没有三层架构,是后来”被逼出来”的,而不是一开始就这么设计。

推动拆分的有三股力量。

  • 第一股是 Docker 自身的解耦尝试:2014 年 Docker 把容器执行逻辑抽成 containerd,让它专注容器生命周期,daemon 退回管镜像和上层 API,这是单体内部的一次分工。
  • 第二股是生态竞争:CoreOS 的 rkt、Google 的 lmctfy 与 Docker 并存,互不兼容,OCI 成立后定下了运行时和镜像的标准格式,让 “高层负责生命周期、低层负责创建进程” 的边界有了规范依据,runc 才得以作为符合 OCI 的低层运行时被独立复用。
  • 第三股是 Kubernetes:编排平台不希望自己绑定某个具体实现,于是定义了 CRI 接口,让 containerd、CRI-O 这类高层运行时成为编排层和内核之间的稳定中间层。

所以为什么需要三层运行时的答案即是:“单体想解耦、生态想标准化、编排想中立”,三股力共同作用的结果。下面这张表把”没有分层”和”有分层”放在几个具体问题上对照,就能看出拆分到底解决了什么:

问题没有分层有分层
Docker 升级升级 dockerd 会杀掉所有容器containerd 独立升级,容器不受影响
运行时替换只能用 Docker 内置运行时containerd 支持多种运行时(runc/kata/gvisor)
容器进程管理dockerd 是容器进程的父进程,daemon 崩溃影响所有容器shim 是容器进程的父进程,containerd 重启不影响容器
Kubernetes 集成K8s 需要对接 Docker APIK8s 通过 CRI 直接对接 containerd

表格里”shim 是容器进程的父进程,containerd 重启不影响容器”这条值得展开。runc 创建完容器进程就会退出,它本身不是常驻进程;真正长时间挂在容器进程上面的是 containerd-shim。shim 作为容器进程的父进程接管它的 stdin/stdout 和退出状态,和 containerd 之间只是松耦合的通信。所以 containerd 即便重启,已经跑起来的容器进程仍在 shim 下继续运行,不受影响。这正是把 daemon 从容器进程父进程位置上挪开的意义。

把 shim 在一次容器启动里的位置画出来,三层是怎么接力、runc 又是怎么”创建完即退出”的,就一目了然了:

sequenceDiagram participant CLI as docker CLI participant D as dockerd participant C as containerd participant S as containerd-shim participant R as runc participant K as 内核(namespace/cgroup) CLI->>D: docker run nginx D->>C: gRPC 创建容器 C->>S: fork 启动 shim 进程 S->>R: exec runc create R->>K: clone/mount/cgroup 系统调用 K-->>R: 容器进程已创建 R-->>S: 创建完成,runc 退出 S->>R: exec runc start R->>K: 触发容器进程运行 R-->>S: 启动完成,runc 再次退出 Note over S,K: shim 长期挂载,监督容器进程<br/>containerd 重启不影响本层

从时序能直观看到,runc 在 create 和 start 两步都是”进来干活、干完退出”,常驻的只有 shim;containerd 和 shim 之间是 gRPC 松耦合,重启 containerd 不会牵连到 shim 和容器进程。shim 的设计动机、API 接口、升级安全机制在shim一章深入。

2.3 高层运行时 vs 低层运行时#

三层拆分里,编排层和高层、低层之间还有一道更值得拎清楚的边界,就是”高层运行时”和”低层运行时”的分工。这道边界对应的是两件性质不同的活:高层负责容器的一生,拉镜像、建容器、管启停、对外提供 API,关心的是”这个容器该不该存在、状态对不对”;低层负责把容器真正跑起来,按 OCI 规范调用 clone、设置 namespace/cgroup、挂载 rootfs,关心的是”这个进程怎么按隔离配置创建出来”。

分开的好处是编排层只需对接高层(通过 CRI),不用关心底层用 runc 还是 kata;而底层只要符合 OCI Runtime Spec,就能被任意高层复用。同样是高层,containerd 偏通用、CRI-O 专精 Kubernetes 场景;同样是低层,runc 用内核原生隔离、kata 套虚拟机、gVisor 走用户态内核,各有安全与性能取舍。下面这张表和命令对照,能帮你看清两者的职责边界:

层级代表职责类比
高层运行时(High-level Runtime)containerd, CRI-O镜像管理、容器生命周期、API 服务操作系统
低层运行时(Low-level Runtime)runc, kata, gVisor创建/启动容器进程、配置内核隔离引导加载程序

职责上分得清,落到命令上就更直观了。用 containerd 的 ctr 操作的是容器生命周期,用 runc 直接操作的是单个容器的进程:

# 高层运行时:containerd 管理容器生命周期
ctr containers list
ctr tasks list
# 低层运行时:runc 直接操作容器
runc list
runc spec # 生成 OCI Bundle 规范文件

2.4 容器运行时生态全景#

把前面讲的三层放到整个容器生态里看,从编排平台到内核,各层之间是怎么对接的。下面这张图把 Kubernetes、Swarm、Nomad 这些编排层,通过 CRI 对接高层运行时,再由高层调用 runc/kata/gVisor 等低层运行时,最终落到内核能力的完整链路画了出来:

graph TB subgraph 编排层["编排层"] K8S["Kubernetes"] SWARM["Docker Swarm"] NOMAD["Nomad"] end subgraph CRI接口["CRI 接口"] CRI["CRI (Container Runtime Interface)"] end subgraph 高层运行时["高层运行时"] CTNRD["containerd"] CRIO["CRI-O"] DOCKERD["dockerd (via cri-dockerd)"] end subgraph 低层运行时["低层运行时"] RUNC["runc"] KATA["Kata Containers"] GVISOR["gVisor (runsc)"] WASM["WasmEdge"] end subgraph 内核["Linux 内核"] NS["Namespace"] CG["Cgroup"] OVL["OverlayFS"] SEC["seccomp/AppArmor"] VM["硬件虚拟化 (KVM)"] USERSPACE["用户态系统调用拦截"] end K8S --> CRI SWARM --> DOCKERD NOMAD --> CTNRD CRI --> CTNRD CRI --> CRIO CRI --> DOCKERD CTNRD --> RUNC CTNRD --> KATA CTNRD --> GVISOR CTNRD --> WASM CRIO --> RUNC CRIO --> KATA DOCKERD --> CTNRD RUNC --> NS RUNC --> CG RUNC --> OVL RUNC --> SEC KATA -.->|"VM 隔离"| VM GVISOR -.->|"用户态内核"| USERSPACE WASM -.->|"WASI 接口"| NS style 编排层 fill:#e8eaf6,stroke:#283593 style 高层运行时 fill:#e0f2f1,stroke:#00695c style 低层运行时 fill:#fff3e0,stroke:#e65100 style 内核 fill:#fce4ec,stroke:#880e4f

后续章节将逐层深入这个架构:runc 源码containerd 架构shim,以及将它们串起来的完整调用链

三、关键概念辨析#

3.1 容器 vs 虚拟机#

容器和虚拟机经常被并列提起,但两者的隔离起点就不同:虚拟机靠硬件虚拟化(VT-x/AMD-V)给每个实例一套独立内核,容器则共享宿主内核,靠 Namespace 和 Cgroup 在进程级制造隔离边界。起点不同,决定了启动速度、资源开销、安全边界这一串差异。下面的对比表把这些维度摊开放在一起:

维度容器虚拟机
隔离级别进程级(Namespace)硬件级(VT-x/AMD-V)
内核共享共享宿主内核独立内核
启动速度毫秒级秒级
资源开销MB 级GB 级
镜像大小MB~百 MBGB 级
密度单机数百~数千单机数十
安全边界较弱(内核漏洞可逃逸)较强(硬件虚拟化隔离)
性能损耗接近原生5%~15% 虚拟化开销

需要说明的是,“安全边界较弱”是相对虚拟机而言的,不代表容器不能用于生产。现代容器有 seccomp 默认配置、AppArmor/SELinux、User Namespace、rootless 容器等多层防护,配合合理配置可以把攻击面收得很小,达到生产可用级别。这块在容器安全一章会展开。

3.2 容器进程 vs 普通进程#

容器进程本质上就是一个带有特殊 Namespace/Cgroup/OverlayFS 配置的 Linux 进程

# 在宿主机上,容器进程就是一个普通进程
ps aux | grep nginx
# root 12345 0.0 0.1 ... nginx: master process
# 查看容器进程的 Namespace
ls -la /proc/12345/ns/
# 与宿主进程不同,容器进程的 Namespace 是独立的
# 查看容器进程的 Cgroup
cat /proc/12345/cgroup
# 容器进程在独立的 Cgroup 子树中
# 容器进程看到的文件系统
sudo nsenter -t 12345 -m -- ls /
# 看到的是 OverlayFS 合并后的 rootfs

3.3 镜像 vs 容器 vs Bundle#

前面说到容器本质是进程,但一个进程要按隔离配置跑起来,需要三样东西配齐:一份可复制的应用打包(镜像)、一个运行实例(容器)、以及交给低层运行时的具体输入(Bundle)。这三者经常被混着说,但它们处在”打包 → 运行 → 执行输入”的不同阶段:镜像是不可变的存储产物,符合 OCI Image Spec;容器是镜像跑起来后的实例;Bundle 是 OCI Runtime Spec 定义的东西,由一份 rootfs 加一个 config.json 组成,是 runc 真正消费的输入。从高层运行时到低层运行时,本质上就是把镜像拆解成一个 Bundle 再交给 runc。下面这张表和命令对照了三者的位置:

概念定义格式生命周期
镜像(Image)不可变的应用打包格式OCI Image Spec持久化存储
容器(Container)镜像的运行实例OCI Runtime Spec临时的
Bundle运行时输入(rootfs + config.json)OCI Runtime Spec临时的

光看定义还抽象,放到命令里就清楚了。下面先用 docker 的高层命令走一遍镜像到容器的流程,再用 ctr 直接操作 containerd,对照看 Bundle 是在哪一步冒出来的:

# 镜像 → 容器 → Bundle 的关系
docker pull nginx # 拉取镜像(OCI Image Spec 格式)
docker create nginx # 创建容器(生成 OCI Bundle)
docker start <id> # 启动容器(runc 读取 Bundle)
# 等价的底层操作
ctr image pull docker.io/library/nginx:latest
ctr container create docker.io/library/nginx:latest mynginx
ctr task start mynginx

Bundle 里的 config.json 到底长什么样?runc spec 能生成一份默认配置,下面是精简到关键字段的片段,能看出 runc 启动一个容器到底需要交代哪些事:

{
"ociVersion": "1.0.2",
"process": {
"terminal": true,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh"],
"cwd": "/"
},
"root": { "path": "rootfs", "readonly": false },
"hostname": "runc",
"linux": {
"namespaces": [
{ "type": "pid" },
{ "type": "ipc" },
{ "type": "uts" },
{ "type": "mount" },
{ "type": "network" }
]
}
}

process 告诉 runc 容器里跑什么命令、工作目录在哪,root 指向 rootfs 位置,linux.namespaces 列出要隔离哪些资源。一份 config.json 加上同目录的 rootfs,就是 runc 能直接消费的完整 Bundle。字段细节留给OCI 规范一章展开。

四、本章小结#

这一章从 1979 年的 chroot 走到 2015 年的 OCI,又把三层运行时拆开看了一遍。把全章主线拎出来,可以用三个关键词串起来:

主题核心要点关键词
演进脉络从 chroot 的目录隔离到 OCI 的标准化,容器技术走过了 40 年chroot, OCI
运行时架构dockerd → containerd → shim → runc 三层解耦,高层管生命周期,低层管进程创建三层运行时
核心认知容器进程 = 普通 Linux 进程 + 特殊的 Namespace/Cgroup/OverlayFS 配置容器即进程

从演进史能看出,容器的每一次跃迁都在解决前一代的痛点:chroot 太弱,于是有 Jail;Jail 与 OS 绑定,于是有 LXC;LXC 体验像虚拟机,于是有 Docker;Docker 造成碎片化,于是有 OCI。后续章节沿着这条主线深入:NamespaceCgroupOverlayFS 三大基石 → OCI 规范runc 源码containerd 架构shim完整调用链安全网络镜像构建

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

容器全景:从 chroot 到 OCI
https://blog.souloss.cn/posts/container-runtime/container-overview/
作者
Souloss
发布于
2021-09-04
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时