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
Cgroupcgroup 视图容器内看到的 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.procs

CPU 限制的原理(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 节详细讲过)

失败:

#问题说明
1cgroup 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_rootchroot 能逃逸文件系统隔离
capabilitiesroot 权限太大限制”能做什么”
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 程序里直接调用会出什么问题?为什么?