6.3 namespace (隔离) 与 cgroups (限制)

一句话

namespace 回答 “你能看见什么”,cgroups 回答 “你最多能用多少”;容器 = 一组 namespace + 一棵 cgroup 子树,只做一半都不算容器——只隔离不限流,一个进程能拖垮整机;只限流不隔离,进程之间互相看得见。

八种 namespace:各管一段视野

namespace 隔离什么 不隔离什么(容易误解)
mnt 挂载点视图(自己的 rootfs) 底层块设备仍共享
pid 进程号空间(容器内 PID 1 是应用) 宿主的真实 PID 仍可见(/proc 视角受限)
net 网卡、IP、端口、路由表、防火墙规则 物理网卡与宿主网络
uts 主机名与域名 —
ipc System V IPC、POSIX 消息队列 共享内存页仍在内核里
user UID/GID 映射(容器 root ≠ 宿主 root) 内核漏洞仍可跨 namespace
cgroup 进程看到的 cgroup 视图 限额本身由宿主设置
time(Linux 5.6+) CLOCK_MONOTONIC / CLOCK_BOOTTIME 的偏移 墙钟时间(CLOCK_REALTIME)不隔离

三个最常被搞混的点:

  • pid namespace 里 “PID 1” 有特殊语义:它接收 SIGTERM 的默认行为不同、不会自动被回收孤儿进程。所以容器里跑 bash 当 1 号进程会导致信号处理不对(用 tini/dumb-init 或直接 exec 应用进程)。
  • net namespace 是 “一个容器一个网络栈”,这也是 “一个 Pod 一个 IP” 的内核基础。
  • user namespace 是 rootless 容器的前提:容器内 root 映射为宿主的普通 UID,逃逸后拿不到宿主 root。

cgroups:两类资源,两种失败方式

资源类型 代表 超限后果 特点
可压缩(compressible) CPU、IO 带宽 变慢(节流 / 排队) 不会杀进程,但长尾会恶化
不可压缩(non-compressible) 内存 OOM Kill 直接杀进程,往往杀掉整个容器

这条区分决定了两类故障的排查方向:CPU 打满 → 看 throttling;内存打满 → 看 dmesg 的 OOM 记录与 container_memory_working_set_bytes。

cgroups v1 与 v2

维度 v1 v2
层级 每个控制器一棵树(cpu、memory 各自挂载) 一棵统一树,控制器按需启用
CPU 限额 cpu.cfs_quota_us / cpu.cfs_period_us cpu.max(如 50000 100000)
CPU 权重 cpu.shares cpu.weight(1–10000)
内存硬限 memory.limit_in_bytes memory.max
内存软限 memory.soft_limit_in_bytes memory.high(更实用:限速而非杀)
进程数 pids.max pids.max
统计 分散在各子树 memory.stat / cpu.stat 统一

Kubernetes 从 1.25 起把 cgroup v2 的支持转为稳定(GA)。v2 的实际好处是 memory.high 可以做 “软限流” 而不是直接 OOM,这对延迟敏感服务比硬限更友好。

requests / limits 落在哪

K8s 字段 落到 cgroup 语义
requests.cpu cpu.weight(按比例换算) 调度与竞争时的相对份额,不是保底独占
limits.cpu cpu.max(quota/period) 硬上限,超了被节流
requests.memory 调度依据(不写 cgroup) 只影响调度,不影响运行时
limits.memory memory.max 硬上限,超了 OOM Kill

⚠️ requests.memory 不设限:它只用于调度,容器实际可以用到节点全部内存(直到 limits.memory 或节点压力)。所以 “设了 requests 就安全” 是误解。

CPU 节流的隐藏代价:P99 抖动

CPU 配额按 CFS 周期(默认 100ms)结算:一个 limits.cpu = 1 的容器,每 100ms 只有 100ms 的 CPU 时间。如果它在 40ms 内用完了配额,剩下的 60ms 只能等下一周期——对平均延迟影响不大,对 P99 是灾难。

现象 原因 缓解
平均延迟正常、P99 尖刺 周期末配额耗尽,线程被节流 提高 limit、拆分热点线程、调小 period
压测达标、线上偶发超时 多线程在周期边界争抢配额 留 30% CPU 余量,别按 100% 设 limit
节点 CPU 空闲但容器变慢 容器自己的 quota 用完了 看 cpu.stat 的 nr_throttled

判断方法很直接:看 container_cpu_cfs_throttled_periods_total 占 container_cpu_cfs_periods_total 的比例,超过 10% 就说明配额是瓶颈。

隔离 ≠ 安全:共享内核的逃逸面

namespace 与 cgroups 是资源与视野的隔离,不是安全边界。三层加固手段:

手段 作用 代价
capabilities 去掉不需要的 root 能力(默认已是受限集) 某些工具需要显式加回(如 NET_BIND_SERVICE)
seccomp 限制可用的系统调用(Docker 默认 profile 已拦一批) 误拦会导致应用启动失败,需要调 profile
LSM(AppArmor / SELinux) 文件与进程的强制访问控制 策略编写成本高,容易 “关掉算了”

想真正做到 “强隔离”,只能换边界:用户态内核(gVisor)或轻量 VM(Kata),取舍见 6.5 容器运行时 (containerd / runc)。共享内核的后果在 6.1 容器与虚拟机的本质差异 里有完整推导。

实测:限制与隔离长什么样

$ docker run --rm --memory=256m --cpus=1 alpine:3.20 sh -c \
    'echo memory.max=$(cat /sys/fs/cgroup/memory.max); \
     echo cpu.max=$(cat /sys/fs/cgroup/cpu.max); ls /proc/self/ns'
memory.max=268435456          # 256MiB → 字节
cpu.max=100000 100000         # 每 100ms 周期最多 100ms CPU = 1 核
cgroup ipc mnt net pid pid_for_children time time_for_children user uts

两点值得记:① 宿主用的是 cgroup v2(统一层级、memory.max / cpu.max 单文件);② 容器内能看到 9 个 namespace 项(pid、time 各有 *_for_children 视图),而 “隔离” 只是这些视图的边界,不是安全边界。

反模式

反模式 后果
只配 limits.memory,不配 requests 调度按 0 算,节点被超卖后集中 OOM
把 limits.cpu 设成核数并期待 “独占” 只是上限;竞争时仍按 weight 分配
容器里用 bash 当 PID 1 信号处理异常,docker stop 等 10 秒后强杀
用 root 跑容器并挂载宿主目录 namespace 不是安全边界,等于把宿主交出去
认为设了 requests 就不会被 OOM requests 不落 cgroup,只影响调度
CPU limit 按峰值 100% 设 长期在周期边界被节流,P99 抖动
关掉 seccomp / AppArmor “为了跑通” 逃逸面回到默认全开

三条铁律

🔑 namespace 管视野,cgroups 管额度;缺一半就不是容器,只是 “进程 + 包装”。

🔑 CPU 超限变慢、内存超限被杀;排查方向由这条区分决定。

🔑 隔离不是安全边界;要强隔离只能换内核(gVisor / Kata),不能靠调参数。

自检

Q1 一个容器 limits.cpu = 2,节点有 16 核且几乎空闲,但应用 P99 明显高于裸机。可能是什么原因?

▸ 答案:被 CFS 节流。limit 2 表示每 100ms 周期最多用 200ms CPU 时间;节点再空闲,容器也不能超用。多线程在周期内抢配额,末段线程要等到下个周期,于是出现 “平均正常、P99 尖刺”。验证看 nr_throttled 比例;缓解是提高 limit(或去掉)、减少热点线程数。

Q2 容器 limits.memory = 2Gi 被 OOM Kill 了,但 docker stats 显示 RSS 只有 1.2Gi。为什么?

▸ 答案:因为 memory.max 统计的是 cgroup 的内存用量,包含 page cache(工作集)。容器读写文件时 page cache 会涨;当总量触到 2Gi,内核回收不掉就 OOM。判断要看 container_memory_working_set_bytes 而不是 RSS,并区分 “应用真的需要 2Gi” 与 “page cache 把额度吃掉了”。

Q3 为什么说 “容器内 root 等于宿主 root” 是默认情况,而 rootless 能改变它?

▸ 答案:默认情况下容器与宿主共享 user namespace,容器内 UID 0 就是宿主 UID 0,只是被 capabilities 与 LSM 削弱。启用 user namespace 后,容器内 UID 0 映射到宿主的一个普通 UID(如 100000),逃逸后拿到的也只是普通权限。代价是:绑定 <1024 端口受限、部分文件系统(含 overlay)需要内核支持、性能略有损失。

Q4 一个服务平时 CPU 只用 0.3 核,大促时到 2 核,limits.cpu 设 1 会怎样?

▸ 答案:大促时被硬性限流到 1 核,剩下的请求排队,表现为延迟飙升与超时,而不是 “自动多用”。因为 limit 是上限不是弹性值。正确做法是 requests 按常态(0.3–0.5)设用于调度,limits 按峰值留余量(2.5–3),或干脆不设 CPU limit 只设 request(让节点层面共享,但要接受争抢)。

Q5 为什么 requests.memory 设了 512Mi,容器还能吃到 3Gi 才被杀?

▸ 答案:因为 requests.memory 只参与调度(K8s 用它算节点可分配量),不写进 cgroup;真正的运行时上限是 limits.memory → memory.max。没设 limit 时,容器可以一直用到节点内存压力触发驱逐或 OOM。这也是 “requests 齐全但节点仍然 OOM” 的常见原因:超卖了内存却没有 limits。

跨篇相关:共享内核的边界与补救见 6.1 容器与虚拟机的本质差异;可写层与 copy-up 的代价见 6.2 镜像与分层、写时复制;运行时的隔离实现见 6.5 容器运行时 (containerd / runc);资源限制与 QoS 的编排层表达见 7.9 资源限制与 QoS;内存与虚拟内存的机制见 11.2 内存管理与虚拟内存;强隔离方案的取舍见 6.5 容器运行时 (containerd / runc);逃逸类攻击面见 38.6 常见攻击。