Skip to content

第十章:容器隔离

xv6 的所有进程共享同一套 PID 编号、根文件系统、设备和全局资源,也没有资源计费。前九章借 xv6 建立了进程、页表和文件对象;本章从 xv6 没有提供的边界开始,讨论 Linux 如何把这些内核对象组织成不同视图并限制其用量。

容器可以为 worker 提供独立的 PID、挂载和网络视图,并限制其 CPU 与内存用量;容器内进程仍直接使用宿主机内核。

因此,容器隔离由多项机制组合实现,包括 namespace、权限控制、系统调用过滤和 cgroup 资源治理。

隔离维度

面对不可信程序,先把“隔离”拆开:

  1. 看见谁:进程、挂载点、网络接口、主机名等命名视图;
  2. 能做什么:UID、capability、LSM 策略和系统调用过滤;
  3. 能用多少:CPU、内存、I/O、进程数等资源额度;
  4. 失败影响谁:共享内核漏洞、全局对象和硬件侧信道仍可能跨边界。

仅改变命名视图不能限制资源消耗;仅设置资源额度也不能限制进程观察和访问其他对象。

命名空间

Linux namespace 对若干全局资源建立独立命名视图:

  • PID namespace:不同进程树与 PID 编号;
  • mount namespace:独立挂载关系和根目录视图;
  • network namespace:接口、路由、端口和协议栈状态;
  • user namespace:容器内外 UID/GID 映射;
  • UTS、IPC、cgroup、time namespace 等隔离相应视图。

PID namespace 中的 PID 1 是该视图的第一个进程,并承担回收孤儿进程等特殊职责;它不表示容器拥有独立内核。宿主机仍能通过外层 PID namespace 观察这些进程。

namespace 隔离的是名字查找,未必复制底层资源。不同 mount namespace 可以挂载同一个文件系统,不同容器也仍共享同一页缓存和物理 CPU。

Linux 中,线程通过 task_struct->nsproxy 引用各类 namespace 对象。clone3() 在创建任务时通过 flags 选择建立哪些新 namespace,unshare() 让当前任务脱离某些共享对象,setns() 则让任务加入一个由 namespace FD 指定的现有对象。/proc/<pid>/ns/* 中的条目正是这些对象的可打开句柄。由此可见,namespace 不是 CPU 的一种新模式,而是内核在查 PID、mount、network 等对象时选用不同的查找上下文。

资源控制

cgroup 把进程组织成层次,并统计、限制或调度资源:

  • CPU weight 表达竞争时的相对份额,quota 表达时间窗口内上限;
  • memory 控制可计费内存、回收和 OOM 范围;
  • I/O 控制带宽、IOPS 或权重;
  • pids 控制可创建进程数量。

硬限制与保护值具有不同语义。CPU quota 用尽后,任务会被 throttled,可能产生周期性尾延迟;memory limit 过低会使容器内部频繁回收,即使宿主机仍有可用内存。

资源治理必须覆盖内核代为持有的资源。页缓存、socket buffer、内核对象和异步请求都需要归属到相应 cgroup,防止应用通过将资源转移到内核而规避计费。

cgroup v2 直接通过文件接口暴露控制器:向 cgroup.procs 写 PID 迁移任务,cpu.max 是 quota/period,memory.current 是当前计费,memory.max 是硬上限,io.max 按设备限制带宽或 IOPS。内核并不是读一个“容器配置文件”后统一限制资源;CPU、memory、I/O 等控制器分别在调度、分配和提交路径上读取当前 cgroup 状态。字段语义应以 cgroup v2 文档 为准。

特权分解

传统 Unix 把大量特权集中在 UID 0。Linux capability 将其拆成更小权限,例如绑定低端口、管理网络、覆盖某类 DAC 检查等。容器运行时可删除不需要的 capability,让“容器内 root”不自动拥有宿主机全部能力。

但 capability 仍可能很宽,一项权限可组合出意外路径。最小权限原则要求从空集合出发,只添加程序实际需要的能力,而不是照抄一个方便的默认列表。

user namespace 可以将容器内 UID 0 映射为宿主机的普通 UID,进一步限制文件和内核操作权限。共享卷的授权结果取决于映射后的宿主 UID,部署时需要从宿主视角验证。

内核执行受保护操作时会经 capable() 一类检查查询当前 cred 中的 capability set,并相对于目标 user namespace 判断权限。capability 因此是内核授权逻辑,不是 Ring 或页表权限;检查通过后,真正的设备寄存器或内核对象仍只由 CPL 0 代码操作。

系统调用过滤

seccomp-BPF 根据系统调用号和参数做过滤,可允许、拒绝、记录或通知某些调用。它适合缩小攻击面:一个纯计算 worker 不需要加载内核模块、调试别的进程或创建任意协议 socket。

过滤器实际读取的是 struct seccomp_data:其中有 syscall number、architecture、instruction pointer 和六个 64 位参数值。它看得到“RAX 是哪个调用、参数寄存器里是什么数”,却不会安全地沿用户指针解析任意对象。返回值可选择 ALLOWERRNOKILLTRAPTRACEUSER_NOTIF。完整字段和优先级见 Seccomp BPF 文档

对象访问策略

系统调用过滤不是完整权限系统。允许 write 不能只凭系统调用号判断写向哪个文件,指针内容也不适合在过滤器中安全解析。对象级策略通常由传统文件权限与 SELinux、AppArmor 等 LSM 机制处理。

LSM 的落点是内核对象操作中的 hook,例如 inode permission、file open、socket connect 和 task ptrace。具体文件系统或网络路径在执行操作前调用 hook,已注册的安全模块返回允许或错误码。因此 seccomp 控制“能否进入某类 syscall”,LSM 控制“这次操作涉及的主体和对象关系是否允许”,两者检查的位置不同。

沙箱通常采用分层约束:namespace 减少可见对象,capability 删除特权,seccomp 减少内核入口,LSM 约束对象关系,cgroup 限制资源。当单层机制失效时,其余机制仍可缩小故障范围。

容器镜像

镜像描述只读文件层、依赖和启动配置;namespace、cgroup 与权限策略规定运行时可见范围和影响范围。镜像签名和供应链验证用于确认来源及完整性,不能替代运行时隔离。

容器常用 overlay 文件系统把只读镜像层与每容器可写层组合。第一次修改可能触发 copy-up,性能和空间行为与普通目录不同。持久数据通常放在 volume,而 volume 正是主动穿过容器文件系统边界的共享点。

共享内核

容器启动快、内存开销小,因为不需要另一个 guest kernel;系统调用直接进入宿主内核,也无需为每个容器模拟设备。反面是:一个可利用的宿主内核漏洞可能跨越所有 namespace。

虚拟机通过硬件虚拟化增加 guest kernel 边界,通常提供更强隔离,但具有不同的成本和运维模型。microVM、sandboxed container 和用户态内核分别在隔离强度、兼容性与性能之间采用不同折中。

常见误解

  • 容器内看不到宿主进程,所以不共享内核。 namespace 只改变视图。
  • CPU 限制为 2 就等于独占两个核心。 quota 与 cpuset、竞争和拓扑语义不同。
  • 内存 limit 只计算匿名堆。 page cache 与部分内核内存也需要计费。
  • 只读根文件系统意味着不能写。 /tmp、volume、socket 与内核接口仍可能可写。
  • 非 root 就安全。 普通用户可达的内核接口也可能存在漏洞。

实验

bash
# 当前进程属于哪些 namespace
ls -l /proc/$$/ns

# cgroup v2 的归属与限制
cat /proc/$$/cgroup
cat /sys/fs/cgroup/cpu.max 2>/dev/null
cat /sys/fs/cgroup/memory.max 2>/dev/null

# capability 与 seccomp 状态
grep -E 'Cap|Seccomp|NoNewPrivs' /proc/$$/status

如果有容器环境,在容器内外分别查看 /proc/self/ns/*、PID 和内核版本。不同 PID 视图却相同内核版本,是理解容器边界最直接的证据。

练习

  1. namespace 与 cgroup 分别解决“看见什么”和“能用多少”的哪一部分?
  2. 容器内 root 为什么不必然等于宿主 root?
  3. seccomp 为什么不能替代文件权限和 LSM?
  4. CPU quota 为什么可能制造周期性尾延迟?
  5. 容器与虚拟机最关键的边界差异是什么?

上一章:文件系统语义 · 下一章:虚拟机 →