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 常见攻击。