00 · 设计推演:从零造一个容器
“容器不是’轻量虚拟机’。它根本没有第二层内核——所有隔离都是宿主机内核的几个开关。“
开场:先问”直接跑会怎样”
老陈:需求:一台机器上跑三个应用(A、B、C),要求互相隔离。
小林:直接跑三个进程不行吗?
老陈:试试。这三个进程能看到什么?
小林:……能看到彼此?
老陈:不止。我给你列个清单:
① ps aux 能看到对方的所有进程 ② kill 能杀掉对方 ③ 共享同一个文件系统 → 能删对方的文件 ④ 共享同一个网络栈 → 端口冲突,能看到对方的连接 ⑤ 共享主机名 ⑥ 资源争抢:A 吃满 CPU / 内存,B 和 C 就卡死 ⑦ 共享 /proc → 能看到整个系统的信息小林:……那确实不行。
老陈:我们来一个一个解决。每个问题对应一个内核特性。
v0:裸进程
三个进程,完全共享宿主机的所有东西。
失败清单(上面七条)。
v1:chroot —— 隔离文件系统
针对问题 ③:给每个进程一个不同的”根目录”。
chroot("/var/app-a/rootfs");
chdir("/");
// 现在 open("/etc/passwd") 打开的是
// /var/app-a/rootfs/etc/passwd改进:A 看不到 B 的文件了(在它的视图里,/ 就是自己的 rootfs)。
失败:
| # | 问题 | 说明 |
|---|---|---|
| 1 | ★ 能逃逸 | 见下 |
| 2 | 只隔离了文件系统 | 进程、网络、主机名还是共享的 |
| 3 | 挂载点还是共享的 | mount 会影响宿主机 |
问题 1(逃逸)是致命的:
经典逃逸手法:
① 在 chroot 前打开一个指向外部的文件描述符
② chroot 后,用 fchdir(fd) 切到那个目录
③ 此时"当前目录"在 chroot 之外
④ 反复 chdir("..") 就能回到真正的根目录
★ chroot 从来不是一个安全特性
BSD 手册明确写着:
"It should be noted that chroot() is not a security feature."
老陈:这是一个重要的教训:
“看起来像隔离” ≠ “真的隔离”。
chroot 改变的是”路径解析的起点”,不是”访问权限”。root 用户总能逃出去。
真正的安全隔离必须在更深的地方——后面会看到,是 user namespace + capabilities。
v2:namespace —— 隔离”看到什么”
思路转变:不是”限制访问”,而是让每个进程看到不同的视图。
Linux 提供了八种 namespace,每种隔离一类资源:
| namespace | 隔离内容 | 效果 |
|---|---|---|
| Mount | 挂载点 | 每个容器有自己的文件系统树 |
| PID | 进程号 | 容器内 PID 1 是自己的 init |
| Network | 网络栈 | 独立的网卡、IP、路由、端口 |
| UTS | 主机名 | 可以有自己的 hostname |
| IPC | 进程间通信 | 独立的消息队列、共享内存 |
| User | 用户/组 ID | ★ 容器内的 root ≠ 宿主机 root |
| Cgroup | cgroup 视图 | 容器内看到的 cgroup 树是虚拟的 |
| Time | 系统时间 | 独立的时间(Linux 5.6+) |
用法:
// 创建新进程时指定
clone(child_func, stack,
CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWNET |
CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWUSER,
arg);
// 或者在已有进程上脱离
unshare(CLONE_NEWNS | CLONE_NEWPID | ...);逐个看效果:
PID namespace —— 解决”能看到/杀掉对方”
宿主机上: ps aux 看到所有进程
容器内: ps aux 只看到自己这棵树
PID 1 是容器的 init 进程
★ kill 只能杀自己命名空间里的
Network namespace —— 解决”端口冲突”
宿主机: eth0, IP 192.168.1.10
容器 A: 只有 lo(默认),或者 veth pair 连到网桥
容器 B: 同上,独立的
★ 容器 A 和 B 都能绑定 8080 端口,不冲突
Mount namespace + pivot_root —— 正确替代 chroot
// pivot_root 把整个 mount namespace 的根切换过去
syscall.Mount(rootfs, rootfs, "", MS_BIND|MS_REC, "");
mkdir(rootfs + "/.old_root");
syscall.PivotRoot(rootfs, rootfs + "/.old_root");
chdir("/");
umount2("/.old_root", MNT_DETACH);
// ★ 与 chroot 的区别:
// chroot: 只改进程的根目录
// pivot_root: 把整个挂载命名空间的根换掉
// → 逃逸不了★ User namespace —— 安全隔离的基石
没有 user namespace:
容器内的 uid=0 (root)
↓
宿主机上的 uid=0 (root)
★ 容器逃逸 = 拿到宿主机 root
有 user namespace:
容器内的 uid=0 (root)
↓ 映射
宿主机上的 uid=100000 (普通用户)
★ 即使逃逸,也只是个普通用户
映射文件:
cat /proc/<pid>/uid_map
# 0 100000 65536
# ↑ 容器内 ↑ 宿主机 ↑ 长度
# 容器的 uid 0-65535 映射到宿主机的 uid 100000-165535至此,v0 的失败清单里 ①②③④⑤⑦ 都解决了。
还剩 ⑥:资源争抢。
老陈:注意,namespace 解决的是”看到什么”,不是”能用多少”。
A 还是能吃满 CPU、吃满内存——B 和 C 照样卡死。
这是两个不同维度的问题,需要不同的机制。
v3:cgroup —— 限制”能用多少”
思路:把进程分组,给每组设资源上限。
# cgroup v1: 每种资源一棵独立的树
/sys/fs/cgroup/cpu/myapp/cpu.cfs_quota_us
/sys/fs/cgroup/memory/myapp/memory.limit_in_bytes
/sys/fs/cgroup/pids/myapp/pids.max
# 把进程加入
echo $PID > /sys/fs/cgroup/memory/myapp/cgroup.procsCPU 限制的原理(CFS Bandwidth Control):
cpu.cfs_period_us = 100000 (100ms 周期)
cpu.cfs_quota_us = 200000 (每周期 200ms CPU 时间 = 2 核)
★ 这个机制的副作用:第 1 章第 07 节讲过的 CPU 限流。
如果应用在 50ms 内用完了 200ms 的配额:
→ 剩下 50ms 被 throttle(完全暂停)
→ ★ 延迟尖刺
诊断:
cat /sys/fs/cgroup/cpu.stat
# nr_throttled ← 被限流次数
# throttled_time ← 被限流总时长
★ 这就是为什么生产环境很多服务会去掉 CPU limit(只保留 request)
内存限制的”隐形”部分:
cgroup 统计的内存包括:
· RSS(匿名内存:堆、栈)
· Page Cache(文件缓存)★ 容易被忽略
· 内核内存
· 二进制代码段
★ 而 Go 的 HeapAlloc 只是 RSS 的一部分!
→ 这就是为什么"监控显示 1.2GB 却在 2GB 限制下 OOM"
(第 6 章第 01 节详细讲过)
失败:
| # | 问题 | 说明 |
|---|---|---|
| 1 | cgroup v1 多棵树,管理混乱 | 每种资源独立的层级,可能冲突 |
| 2 | 语义不一致 | memory.memsw、blkio 等控制器的行为不统一 |
| 3 | 死锁风险 | 多棵树导致进程可以被挂到不同层级,难以推理 |
v4:cgroup v2 —— 统一层级
改动:所有控制器用同一棵树。
# cgroup v2
/sys/fs/cgroup/myapp/
├── cgroup.controllers # 可用的控制器
├── cgroup.subtree_control # ★ 显式启用(v2 要求)
├── memory.max
├── memory.current
├── memory.events # ★ OOM 事件
├── cpu.max # "quota period",如 "200000 100000"
├── pids.max
└── io.max # ★ v2 的 io 控制器比 v1 的 blkio 更准确改进:
| 改进 | 说明 |
|---|---|
| 统一层级 | 一个进程只能在一个 cgroup 里,语义清晰 |
| 显式启用 | cgroup.subtree_control 必须显式写 +memory +cpu |
| 更准确的 IO 控制 | io 控制器替代 blkio |
| 压力指标 | memory.pressure、cpu.pressure(PSI)★ |
★ PSI(Pressure Stall Information)是 v2 的一个重要改进:
cat /sys/fs/cgroup/myapp/cpu.pressure
# some avg10=2.31 avg60=1.87 avg300=1.02 total=12345678
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
# some: 至少有一个任务被阻塞的比例
# full: 所有任务都被阻塞的比例(更严重)
★ 这比"CPU 使用率 90%"有用得多
90% 使用率可能是健康的,也可能是"有任务在排队饿死"
PSI 直接告诉你"有没有人饿着"老陈:这呼应第 6 章第 02 节的 USE 方法:
- Utilization(使用率):CPU 90%
- Saturation(饱和度):PSI 的 some/full
使用率告诉你”资源忙不忙”,饱和度告诉你”有没有人在等”。 后者才是延迟的直接来源。
v5:capabilities + seccomp —— 限制”能做什么”
还有一个问题:容器内的 root 虽然映射到宿主机的普通用户,但它仍然可以:
· 调用各种系统调用(有些很危险)
· 如果 /dev 暴露不当,能直接操作设备
· 能加载内核模块(如果有 CAP_SYS_MODULE)
解法 1:Capabilities —— 拆分 root 权限
传统: root 拥有全部权限(all or nothing)
Capabilities: 把 root 的权限拆成 40+ 个独立的能力
CAP_NET_ADMIN 网络管理
CAP_SYS_ADMIN ★ 最强大的("新的 root")
CAP_SYS_MODULE 加载内核模块
CAP_CHOWN 改文件所有者
...
★ Docker 默认只给容器 14 个 capability,去掉了 CAP_SYS_MODULE 等危险项
解法 2:seccomp —— 限制系统调用
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": ["read", "write", "open", "close", "mmap", ...],
"action": "SCMP_ACT_ALLOW"
}
]
}★ Docker 默认禁用 44 个系统调用(共 300+)
包括: reboot、kexec_load、ptrace(部分)...
效果: 即使容器逃逸,攻击者能做的事也极其有限
v6:OverlayFS —— 镜像分层
最后一块拼图:镜像怎么存?
问题:每个容器都要一份完整的 rootfs?
10 个基于 ubuntu:22.04 的容器
→ 10 × 200MB = 2GB?
解法:分层 + 写时复制
┌────────────────────────────┐
│ 容器层(可写) │ ← 只有这一层是每个容器独有的
├────────────────────────────┤
│ 镜像层 3: apt install nginx │ ← 只读,共享
├────────────────────────────┤
│ 镜像层 2: COPY app /app │ ← 只读,共享
├────────────────────────────┤
│ 镜像层 1: ubuntu:22.04 │ ← 只读,共享 ★ 所有基于 ubuntu 的容器共用这一份
└────────────────────────────┘
OverlayFS 的挂载:
mount -t overlay overlay \
-o lowerdir=layer3:layer2:layer1,upperdir=diff,workdir=work \
merged
# ↑ 只读(从右到左优先级递减) ↑ 可写 ↑ 工作目录 ↑ 合并视图三种操作的行为:
读文件: 从上往下找,找到第一个就返回
修改文件: ① 从 lower 复制到 upper(copy-up)
② 在 upper 修改
★ 原文件不变 → 多个容器可共享基础层
删除文件: 在 upper 创建 whiteout 文件(字符设备 0,0)
★ 不是真删除,是"遮住"下层
代价:第一次修改要复制整个文件(即使只改 1 字节)。
★ 这就是为什么数据库数据要用 volume,而不是容器层
频繁写大文件 = 反复 copy-up = 性能灾难
★ 这一节真正的收获
容器不是一项技术,是六个内核特性的组合:
| 特性 | 解决的问题 | 属于哪一类 |
|---|---|---|
| namespace | ①②③④⑤⑦ 互相可见 | 隔离”看到什么” |
| cgroup | ⑥ 资源争抢 | 限制”能用多少” |
| pivot_root | chroot 能逃逸 | 文件系统隔离 |
| capabilities | root 权限太大 | 限制”能做什么” |
| seccomp | 危险系统调用 | 限制”能做什么” |
| OverlayFS | 镜像存储浪费 | 分层共享 |
v0 的失败清单,逐条被消灭:
v0 裸进程
│ 失败: 7 条(互相可见 / 可杀 / 共享文件系统 / 端口冲突 / 共享主机名 / 资源争抢 / 共享 /proc)
↓
v1 chroot → 解决 文件系统,但 ★ 能逃逸
↓
v2 namespace → 解决 ①②③④⑤⑦,但没限制资源
↓
v3 cgroup v1 → 解决 ⑥,但多棵树管理混乱
↓
v4 cgroup v2 → 统一层级 + PSI 压力指标
↓
v5 capabilities + seccomp → 权限收窄,纵深防御
↓
v6 OverlayFS → 镜像分层共享
五个可以迁移的思维模式
① “看起来像隔离” ≠ “真的隔离”
chroot 看起来隔离了文件系统,但 root 能逃逸。 对任何安全机制,问:“它能被绕过吗?绕过的前提是什么?”
② 正交的两个维度:视图 vs 额度
namespace 管”看到什么”,cgroup 管”能用多少”。 这是两类完全不同的问题,需要不同的机制。混淆它们会导致设计错误。
③ 权限要可拆分
root 是 all-or-nothing,capabilities 是 40+ 个开关。 粗粒度权限 → 细粒度权限,是安全设计的一贯方向。
④ 防御要分层(纵深防御)
namespace + capabilities + seccomp 三道防线。 任何一道被突破,其他的还在。
⑤ 只读共享 + 写时复制
OverlayFS 的精髓:不变的部分共享,变化的部分独有。 同样的思路:LSM 的不可变段、MVCC 的多版本、函数式编程的不可变数据。
现在,轮到你了
练习:容器共享宿主机内核。这是它”轻”的原因,也是它”不安全”的根源。
如果让你设计一个”既轻又安全”的容器,你会怎么做?
提示(不要用,先自己想):
- gVisor 的做法是什么?(提示:用户态内核)
- Kata Containers 的做法是什么?(提示:轻量 VM)
- 两者的代价分别是什么?(性能?兼容性?启动时间?)
- 如果你的场景是”跑不受信任的第三方代码”(比如在线代码执行平台),选哪个?
- 如果是”跑自己的微服务”,需要这么强的安全吗?
老陈:答案:
- gVisor:在用户态实现一个”内核替身”(Sentry),拦截所有系统调用。 代价是系统调用开销大(2-10 倍),兼容性不完整。
- Kata:每个容器跑在一个轻量 VM 里。VM 级隔离 + 容器体验。 代价是启动稍慢(~500ms vs ~50ms),内存开销稍大。
选择取决于威胁模型:
- 跑自己的代码 → 普通容器够了
- 跑租户的代码(如 Serverless、在线 IDE)→ Kata 或 gVisor
这就是第 8 章讲的”架构是在约束下做决策”——先明确你的威胁模型,再选方案。
下一节
01 · 容器原理:namespace、cgroup 与手写运行时
这一节我们推导了容器的每个组件,下一节动手把它们组装起来——用 Go 写一个能跑的 mini-docker。
带着这个问题去读:
unshare(CLONE_NEWUSER)在 Go 程序里直接调用会出什么问题?为什么?