Linux-36 namespace 与 cgroup:容器到底隔离了什么
上一篇用 network namespace、veth、bridge、路由和 NAT 解释了容器为什么能联网。这一篇把视角扩大:容器看起来有自己的 PID 1、根目录、主机名、网络和资源额度,但它并没有启动另一套内核。这种“像一台机器”的错觉是怎样拼出来的?
Linux 容器不是一个单独机制,而是两类机制的组合:
namespace:改变进程能看见哪些内核对象,以及对象在该视图中的名字
cgroup:控制一组进程能消耗多少 CPU、内存、IO、PID 等资源
前者主要回答“你看见什么”,后者回答“你能用多少”。二者都不自动等于安全沙箱;容器还依赖 capability、seccomp、LSM、只读挂载、用户身份与内核修复。把这些边界混在一起,会导致错误的容量规划和危险的权限假设。
先看五个问题:
- 为什么同一个进程可以在宿主机显示 PID 48217,在容器里却是 PID 1?PID namespace 如何嵌套?
- mount namespace 为什么不是复制一份磁盘?
pivot_root、bind mount 与 overlayfs 如何拼出容器根目录? - user namespace 如何让容器里的 root 映射成宿主机普通 UID?它解决了什么,又没有解决什么?
- cgroup v2 的
cpu.max、memory.max、io.max、pids.max分别在什么位置施加压力?为什么限制不是简单的“超过就报错”? - Go runtime 看到的 CPU、内存与 PID 是否已经是容器额度?CPU throttling、memcg OOM 与 goroutine 爆炸应如何排查?
1. 容器的两条轴:视图与资源
1.1 namespace 不是资源配额
把进程放进 PID namespace,只让它看到一个不同的进程编号树,不会阻止它吃满全部 CPU。把进程放进 network namespace,会隔离接口、路由和端口空间,却不会限制它制造多少包。
只有 namespace:
看起来隔离,但可能耗尽宿主机资源
只有 cgroup:
资源有限,但仍看见宿主机进程、挂载或网络
容器:
namespace + cgroup + filesystem + credentials + security policy
1.2 cgroup 不是可见性隔离
cgroup 将进程组织成层级,并由 controller 记账/控制资源。两个进程即使在同一 PID、mount、network namespace,也可以属于不同 cgroup,拥有不同 CPU weight 和 memory limit。
同样,一个容器的不同线程通常属于同一 cgroup,但 thread mode、systemd delegation 等能形成更细层级。不要把“一个 cgroup”永久等同“一个容器”。
1.3 共享内核是效率也是边界
所有容器 syscall 最终进入宿主机内核:
container process
-> syscall
-> host kernel VFS / scheduler / network / memory manager
-> namespace 选择对象视图
-> cgroup controller 记账与限制
-> capability/seccomp/LSM 检查权限
优点是启动快、内存少、系统调用无需穿过完整 guest;代价是内核漏洞与全局资源仍可能跨容器产生影响。高隔离需求会使用虚拟机、microVM 或 sandboxed runtime增加内核边界。
2. namespace 的共同对象模型
2.1 进程通过 nsproxy 引用多个 namespace
进程不是“属于一个统一 namespace”。内核任务凭据和 nsproxy 等结构分别引用多种 namespace:
task
-> pid namespace
-> mount namespace
-> network namespace
-> UTS namespace
-> IPC namespace
-> cgroup namespace
-> time namespace
credentials
-> user namespace
因此可以只共享网络、隔离 mount,或共享 PID、隔离 user。Kubernetes Pod 中多个容器通常共享 network namespace,可选择共享 process namespace,但有独立 mount 视图。
2.2 clone、unshare 与 setns
创建/加入 namespace 的核心接口包括:
clone()/clone3() + CLONE_NEW*:创建子进程时建立新 namespace
unshare(CLONE_NEW*):当前进程脱离某些共享 namespace
setns(fd, type):加入一个已有 namespace
nsenter 本质上打开 /proc/PID/ns/* 引用,再执行相应 setns,最后运行命令。权限由 user namespace、capability 和目标 namespace 关系决定,不是任何用户都能进入任意容器。
ls -l /proc/"$PID"/ns/
readlink /proc/"$PID"/ns/pid
readlink /proc/"$PID"/ns/mnt
readlink /proc/"$PID"/ns/net
相同类型 namespace 的符号链接 inode 标识相同,说明进程共享该 namespace。
2.3 namespace 生命周期由引用决定
只要有进程、打开的 namespace fd 或 bind mount 引用,namespace 就存活。管理员可给它命名:
# ip netns 的命名通常通过 /run/netns 下 bind mount 持有引用
ip netns list
进程退出不总会立即删除 namespace;debug 工具或残留 mount 可能持有它。反之,没有任何引用时,相关对象会随 namespace 销毁,network namespace 的设备/socket也会被清理。
2.4 namespace 可以嵌套,但语义不完全相同
PID、user 等支持层级;mount/network 更像独立实例而非“父能自然继承看见子”的相同模型。父 user namespace 中有相应 capability 的主体可以管理子 user namespace;子 PID namespace 看不到祖先和兄弟进程,祖先视图却能看到后代。
不能用一套“父 namespace/子 namespace”的直觉解释所有类型,具体权限和对象可见性要查对应 namespaces(7) 及类型 man page。
3. PID namespace:同一任务有多个 PID
3.1 PID 是相对于观察 namespace 的名字
一个内核 task 可在嵌套 PID namespace 中有多个编号:
host PID namespace: PID 48217
container PID namespace: PID 1
宿主机能用 48217 观察/发送信号;容器内 /proc 若正确挂载,只看到该 PID namespace 的进程,并把同一任务显示为 1。
# 宿主机查看 PID 在各层的编号
cat /proc/48217/status | grep '^NSpid:'
# NSpid: 48217 1
这不是两个进程,也不是 PID 被修改,而是同一对象在不同命名空间有不同名字。
3.2 PID 1 有特殊责任
PID namespace 中第一个进程成为该 namespace 的 init:
- 收养该 namespace 中的孤儿进程
- 应调用
wait回收 zombie - 对默认 disposition 的某些信号有特殊处理
- 它退出时,内核通常终止该 PID namespace 其余进程
所以把普通应用直接作为容器 PID 1 时,它需要正确处理 SIGTERM 和子进程;或使用轻量 init(如 runtime 的 init 选项)代理信号和 reap。
bad entrypoint:
PID 1 shell 不 exec 应用
-> SIGTERM 只到 shell或转发不完整
-> app 未优雅退出
-> child zombie 累积
3.3 /proc 视图取决于 mount
只进入 PID namespace,不重新挂载 procfs,看到的 /proc 可能仍来自旧 PID namespace。容器运行时通常在新 mount namespace 挂载 /proc,使两者一致。
PID namespace:决定 PID 映射和可见规则
procfs mount:把对应规则呈现为文件
这也是排障时 nsenter -p 后仍看到奇怪进程列表的原因之一:还需要进入 mount namespace 或挂载适配的 procfs。
3.4 隔离 PID 不等于禁止信号
信号权限仍由 credentials、user namespace、UID、capability 和 LSM 决定。看不见某 PID 通常无法按号码操作它,但共享资源、proc mount 暴露或额外权限可能造成其他路径。PID namespace 是对象视图,不应作为唯一访问控制。
4. Mount namespace:根目录只是挂载树的起点
4.1 mount namespace 复制的是挂载关系
创建 mount namespace 时,进程获得一棵挂载树视图。初始内容可从父视图复制,但文件数据仍来自相同底层 filesystem、block device 或内存对象:
host mount tree:
/ -> host rootfs
/var/lib/data -> disk X
container mount tree:
/ -> overlay merged root
/app/config -> bind-mounted host file
/data -> volume filesystem
它不是自动复制整块磁盘。不同 namespace 可把同一目录挂到不同路径,或让某挂载只在一边可见。
4.2 bind mount 改变路径视图
mount --bind /host/data /container-root/data
mount -o remount,bind,ro /container-root/data
bind mount 让同一个 inode 树在另一位置可见。容器 volume 常使用此机制。只读 bind mount能阻止经该挂载写入,但底层对象若还通过其他可写路径暴露,进程仍可能修改;权限设计要检查所有路径和 capability。
4.3 pivot_root/chroot 改变查找起点
chroot 改变进程路径解析的根,但历史上若进程保留外部目录 fd 或特权能力,可逃出单纯 chroot。容器通常组合独立 mount namespace、pivot_root/类似切换、卸载旧根、drop capability 和 LSM。
准备 new root mount tree
-> pivot_root(new_root, put_old)
-> chdir("/")
-> unmount put_old
-> remove old root path
安全来自组合约束,不来自“路径开头换成 /”这一动作。
4.4 overlayfs 如何构造镜像根目录
容器镜像的只读层和容器可写层常用 overlayfs 合并:
lowerdir: image layers (read-only)
upperdir: container writable layer
workdir: overlay internal work
merged: container sees one filesystem tree
读取先从 upper 查,没有再查 lower;首次修改 lower 文件可能 copy-up 到 upper。删除 lower 文件常用 whiteout 表达“在 merged 中不可见”。
这会带来:
- 首次写大文件的 copy-up 延迟
- 可写层随日志/临时文件增长
- inode、rename、hard link 等行为需遵循 overlayfs 语义
- 数据库不宜把持久化数据放在临时 writable layer
4.5 mount propagation 防止意外传播
shared、slave、private 等传播类型决定一个 mount namespace 中的新挂载是否传播到 peer。容器 runtime 通常把边界配置为合适的 private/slave,避免容器 mount 变化污染宿主机;需要把宿主新 mount 传播进容器的插件目录则可能显式使用 shared propagation。
错误配置可能造成设备/volume 看不见,或反过来扩大权限边界。不要在不理解传播关系时随意执行递归 --make-rshared /。
5. UTS、IPC、Network、Cgroup 与 Time namespace
5.1 UTS namespace:主机名不是机器身份
UTS namespace 隔离 hostname 和 NIS domain name:
hostname
uname -n
容器修改 hostname 不会修改宿主机。但 hostname 只是展示/配置数据,不是可信安全身份,也不自动注册 DNS。分布式系统应使用证书、工作负载身份、不可变实例 ID等,而非把 hostname 当认证凭据。
5.2 IPC namespace:隔离 System V/POSIX IPC
IPC namespace 隔离 System V message queues、semaphore、shared memory 和 POSIX message queue 等。它避免不同容器使用相同 key/name 时冲突,也限制彼此观察。
共享内存还与 mount (/dev/shm) 和 cgroup memory 记账有关。容器默认 /dev/shm 较小时,浏览器、数据库或多进程程序可能报空间不足;调大它会增加潜在内存消耗,仍需受 memcg 约束。
5.3 Network namespace
上一篇已展开:它隔离接口、地址、路由、neighbor、socket 端口、netfilter 和 loopback。多个容器共享 Pod network namespace 时,共享 localhost 与端口空间,因此 sidecar 可以访问主容器 localhost,也可能发生端口冲突。
5.4 Cgroup namespace:隐藏层级路径,不创建新额度
cgroup namespace 改变进程看到的 cgroup hierarchy 根,使 /proc/self/cgroup 看起来从容器边界开始。它是视图隔离,不是新建资源 controller 或 limit;真正限制仍来自进程所属 cgroup 的配置。
5.5 Time namespace:部分时钟偏移
Time namespace 可为某些 monotonic/boottime 时钟提供偏移,帮助容器/检查点恢复等场景;它不等于随意隔离所有 wall clock,也不替代 NTP 与时区配置。修改系统实时时钟仍是高权限操作,容器通常共享宿主机时间基础。
时区只是用户态文件/环境视图;容器显示 UTC、宿主显示本地时间,并不代表内核时钟不同。
6. User namespace:容器 root 不必是宿主 root
6.1 UID/GID 也可以映射
user namespace 允许内层 ID 映射到外层不同 ID:
container UID 0 -> host UID 100000
container UID 1..65535 -> host UID 100001..165535
容器进程在内层看到自己是 root,可拥有该 user namespace 内的 capability;在宿主初始 user namespace 看,它只是非特权 UID 100000,不能凭内层 root 修改宿主机任意对象。
cat /proc/"$PID"/uid_map
cat /proc/"$PID"/gid_map
6.2 capability 相对于 user namespace 判断
传统“root/非 root”被拆成 capability;user namespace 又使 capability 有作用范围。容器 root 可能拥有子 user namespace 的 CAP_NET_ADMIN,能管理与该 namespace 所属关系允许的网络对象,却没有宿主初始 user namespace 的同名能力。
这显著缩小 rootful 容器逃逸后的直接权限,但规则复杂:某些内核对象归属创建它的 user namespace,跨 namespace 操作需在正确层级拥有 capability。
6.3 rootless 容器为何更难联网和挂载
非特权用户不能随意创建宿主 bridge、配置全局 NAT、挂载任意 filesystem 或创建设备。rootless runtime 常借助:
- user namespace UID/GID mapping
- slirp/pasta 等用户态网络或受控 helper
- fuse-overlayfs 或内核支持的非特权 overlay
- 预配置 subordinate UID/GID
这些方案减少宿主权限,却可能增加网络转发、文件系统兼容性和性能成本。
6.4 user namespace 不是漏洞免疫
容器仍调用同一个内核。user namespace 扩大了非特权用户可触达的内核代码路径,历史上也出现过相关漏洞。安全策略有时禁用非特权 user namespace,有时依赖它实现 rootless 隔离;应依据受支持内核、威胁模型和运行时评估,不是简单“开更安全/关更安全”。
7. cgroup v2:一棵资源控制树
7.1 统一层级解决 v1 的分裂
cgroup v1 允许不同 controller 挂在不同 hierarchy,一个进程的 CPU、memory、blkio 归属可能不一致,管理与迁移复杂。cgroup v2 使用统一 hierarchy:
/sys/fs/cgroup
system.slice/
user.slice/
kubepods.slice/
pod-xxx/
container-yyy/
进程在树中的一个 cgroup 位置,启用的 controller 沿层级约束资源。父级 limit 是子级总上限;子级不能凭自己配置突破祖先额度。
7.2 no internal process 规则与 delegation
v2 的 domain controller 通常要求分发资源的内部节点不同时直接承载普通进程(root 有特殊处理),让资源在子树间清晰分配。父节点通过 cgroup.subtree_control 将 controller 下放给子级。
systemd 管理 cgroup tree 时,应用不应绕过它任意写目录;delegation 必须明确交给某服务/用户可管理的子树。手工移动 PID 会破坏 systemd/Kubernetes 对生命周期和记账的假设。
7.3 找到当前进程的 cgroup
cat /proc/self/cgroup
# v2 常见:0::/user.slice/...
findmnt -t cgroup2
cat /sys/fs/cgroup/cgroup.controllers
cat /sys/fs/cgroup/cgroup.subtree_control
在 cgroup namespace 内看到的 / 可能是 namespace 重映射的根,而宿主机真实路径更深。排查容器 limit 应同时从容器视图和宿主 runtime/systemd 视图确认。
7.4 线程与进程归属
默认把进程写入 cgroup.procs 会迁移其线程;v2 也支持 threaded cgroup 处理线程级资源组织,但有 controller 与拓扑限制。Go 程序的多个 M(OS thread)通常应留在同一容器 cgroup,否则 CPU/内存观测会出现难以解释的分裂。
8. CPU controller:权重、硬额度与压力
8.1 cpu.weight 只在竞争时分份额
cpu.weight(默认 100,范围依接口定义)表达相对权重:当同一父 cgroup 下的 sibling 都想运行且 CPU 不够时,权重高者获得更多 CPU。
A weight=100
B weight=200
两者都持续 runnable 且同层竞争:
B 约获得 A 的两倍 CPU 份额
空闲时 A 可以使用更多 CPU,不会因为 weight=100 被硬限制。权重是 work-conserving 的相对公平,不是“最多 N 核”。
8.2 cpu.max 是带 period 的带宽上限
cat /sys/fs/cgroup/.../cpu.max
# quota period,例如:200000 100000
表示每 100ms period 最多消耗 200ms CPU time,相当于平均 2 CPU。多个线程可并行快速耗尽额度:
8 threads × 25ms wall time = 200ms CPU quota
-> cgroup 被 throttle
-> 等下个 period 补充
因此总利用率看似只有 2 核平均值,P99 却会出现接近 period 边界的停顿。cpu.max 是调度带宽机制,不是固定绑定两个 CPU;cpuset 才控制可运行 CPU 集合。
8.3 cpu.stat 看节流事实
cat /sys/fs/cgroup/.../cpu.stat
常见字段包括:
usage_usec
user_usec
system_usec
nr_periods
nr_throttled
throttled_usec
应看故障窗口的增量和 throttled ratio,而非进程启动以来累计值。高 nr_throttled 不总等于业务受影响:有些极短并行突发会节流但不在关键路径;需要与 latency、run queue、Go trace 同时对齐。
8.4 cpuset 与 NUMA
cpuset.cpus 限定可运行 CPU,cpuset.mems 限定内存节点。它用于隔离、NUMA 局部性与专用核,但设置过窄会造成单核过载;CPU hotplug 和 effective mask 还可能使请求配置与实际集合不同。
cat cpuset.cpus
cat cpuset.cpus.effective
cat cpuset.mems.effective
CPU quota、weight、cpuset 可以同时生效:最终能力是这些约束的交集。
9. Memory controller:限制的是内存承诺与回收压力
9.1 memory.current 不只是 Go heap
memcg 通常记账匿名页、page cache、部分内核内存、socket buffer 等,精确范围随内核演进。Go HeapAlloc 只是进程运行时堆视图;容器 memory.current 还可能包含:
Go heap in use / idle pages still resident
thread stacks and runtime metadata
mmap regions
file page cache
socket buffers
kernel memory charged to cgroup
other processes in same cgroup
所以 container_memory 高于 pprof heap 是正常起点,不应立刻判定泄漏。
9.2 四个重要边界
cgroup v2 常见接口:
| 文件 | 含义 |
|---|---|
memory.min |
尽力保护的最低内存,过度承诺时并非绝对 |
memory.low |
best-effort 回收保护 |
memory.high |
软节流边界,超过后进程被迫回收/承受延迟 |
memory.max |
硬上限,无法回收时触发 cgroup OOM |
memory.high 不是超过就 kill,它通过 direct reclaim/节流施压;这能在到达硬上限前让过度使用者变慢。memory.max 也不是简单每次 malloc 返回错误:Linux 允许按页需求分配、回收与 overcommit,最终可能在 fault/charge 时触发 OOM kill。
9.3 memcg OOM 与全局 OOM
memcg OOM:某 cgroup 达到 memory.max,宿主机可能仍有空闲内存
system OOM:整个系统无法满足内存,跨 cgroup 选择牺牲者
容器被报 OOMKilled 常是前者。内核在 cgroup 内选择进程,受 oom_score_adj、memory.oom.group 等影响。若一个多进程服务只杀其中一个会留下损坏状态,可配置按组处理,但要理解编排器重启语义。
9.4 memory.events 是关键证据
cat /sys/fs/cgroup/.../memory.events
cat /sys/fs/cgroup/.../memory.current
cat /sys/fs/cgroup/.../memory.stat
memory.events 常见 low/high/max/oom/oom_kill 累计事件。high 快速增长可解释无 kill 却明显变慢;oom_kill 增长确认 cgroup OOM。再结合内核日志、容器状态和 Go heap,区分内存压力、runtime limit 与业务泄漏。
9.5 swap 与 page cache
v2 用 memory.swap.max/current 单独控制 swap 使用。禁 swap 可避免不可预测的换页尾延迟,却让匿名内存压力更早触发 OOM;允许 swap 增加生存空间,但低延迟服务可能出现抖动。
page cache 可在压力下回收,所以看到 cache 高不等于浪费;但 dirty/writeback 页、活跃 working set 和 memory.low 保护会影响可回收性。数据库、文件服务和构建任务的最佳策略不同。
10. IO controller:限制设备时间,不等于限制所有文件操作
10.1 io.max 设置设备级上限
cgroup v2 io.max 可按块设备 major:minor 配置读写带宽或 IOPS:
8:0 rbps=10485760 wbps=10485760 riops=max wiops=1000
它作用于进入受控块 IO 路径的请求。读命中 page cache 时可能没有设备 IO;tmpfs 是内存;网络文件系统还有远端与客户端语义。因此“文件 read 速度”不总直接等于 io.max。
10.2 io.weight 是竞争时份额
与 CPU weight 类似,IO weight 在支持的调度路径和设备竞争中影响相对份额,不是严格带宽保证。NVMe、多队列、device-mapper、云盘与文件系统会改变实际效果,需要用目标存储栈基准验证。
10.3 写入会通过回写延迟反馈
应用 buffered write 先产生脏页,实际块 IO 后续发生。memcg/writeback 可把脏页与 cgroup关联,IO 限制导致 writeback 跟不上时,应用最终在 dirty throttling、fsync 或 reclaim 处变慢:
write() 快速进入 page cache
-> dirty pages 累积
-> cgroup IO 限速
-> writeback 变慢
-> 后续 write/fsync 延迟突然升高
只看单次 write syscall 的早期延迟可能误判存储没问题。
10.4 观察 io.stat 与 PSI
cat /sys/fs/cgroup/.../io.stat
cat /sys/fs/cgroup/.../io.pressure
io.stat 按设备显示 rbytes/wbytes/rios/wios 等;PSI 表示任务因 IO 等待而失去推进机会。需要与 iostat -xz、文件系统、云盘指标和应用 fsync latency 一起分析。
11. PIDs controller:限制 task 创建而不是 goroutine 数
11.1 pids.max 防 fork bomb 与线程爆炸
cat /sys/fs/cgroup/.../pids.current
cat /sys/fs/cgroup/.../pids.max
cat /sys/fs/cgroup/.../pids.events
达到 pids.max 后,新 fork/clone 会失败,常见 EAGAIN。Linux 的 task 包括进程和线程,因此创建 pthread/Go OS thread 也消耗额度。
Go goroutine 是用户态调度对象,百万 goroutine不会直接等于百万 PID;但阻塞 syscall、cgo、runtime.LockOSThread、高并发和 runtime 调度仍可能增加 M 数。外部命令、插件和 sidecar 也可能共享同一 Pod/父 cgroup 额度。
11.2 限额失败的表现可能离业务很远
达到 PIDs limit 时:
- shell 无法 fork 新命令
- Go
os/exec失败 - runtime 创建 OS thread 报错甚至崩溃
- 健康检查 helper 无法启动
- debug 工具也无法进入容器执行
因此要在耗尽前监控 pids.current/max,不能等到 exec 失败才尝试运行排障命令。
11.3 RLIMIT_NPROC 与 pids.max 不同
RLIMIT_NPROC 通常按 real UID 统计可创建进程/线程,语义受 user namespace和特权影响;pids controller 按 cgroup 层级计数。两者都可能限制 clone,最终有效上限取更严格者。系统还有全局 threads-max、PID 空间等边界。
12. cgroup 层级与“额度去哪了”
12.1 父级约束会覆盖子级
假设:
pod memory.max = 1 GiB
app container memory.max = 900 MiB
sidecar container memory.max = 300 MiB
子级声明相加超过父级,不代表可同时用 1.2 GiB;父级 1 GiB 先成为总边界。应用只看自己 cgroup 文件可能还没到 900 MiB,但 pod 父 cgroup 已 OOM/高压。
CPU、pids、memory protection也有类似层级语义。排查应沿祖先链查看,不只当前目录。
12.2 共享 Pod 的资源争用
Kubernetes 等系统可能在 Pod cgroup 下放多个 container cgroup。sidecar 日志代理、service mesh 或 debug 容器会消耗 Pod 总 CPU/内存/IO;主应用 profile 看不见它们的 heap,却感受到 quota 和 OOM 压力。
容量模型必须把基础设施容器计入,不要把 Pod limit 全当业务可用。
12.3 request、limit 与 Linux 文件不是一一对应
编排器的 resource request 用于调度和 QoS,可能映射为 CPU weight、memory protection;limit 更常映射 cpu.max、memory.max。精确规则随 Kubernetes 版本、cgroup driver、QoS 和 feature gate 变化。
不要从 YAML 名称直接猜最终内核值。发生问题时查:
workload spec
-> runtime/container manager configuration
-> systemd/cgroup hierarchy
-> actual controller files and events
12.4 移动进程不迁移历史内存的直觉陷阱
cgroup 记账与页面归属有历史和 controller 细节;把进程迁移到另一 cgroup 并不意味着所有已分配页瞬间按直觉迁移。生产中应让 runtime 在进程启动前建立正确层级,而不是靠事故中手工移动 PID 修复资源归属。
13. PSI:限制未触发,服务也可能已经很慢
13.1 utilization 看工作量,pressure 看等待
CPU 80% 只说明使用量,不直接说明 runnable 任务是否排队;memory.current 未到 max 也不说明没有 direct reclaim;磁盘吞吐不满也可能有 IO latency。
PSI(Pressure Stall Information)统计任务因资源不足无法推进的时间:
cpu.pressure:有 runnable 工作等 CPU
memory.pressure:任务因 reclaim/compaction 等停顿
io.pressure:任务等待 IO
cat /proc/pressure/cpu
cat /proc/pressure/memory
cat /proc/pressure/io
cat /sys/fs/cgroup/.../cpu.pressure
cat /sys/fs/cgroup/.../memory.pressure
cat /sys/fs/cgroup/.../io.pressure
13.2 some 与 full
概念上:
some:至少一部分任务因该资源停顿full:所有相关 non-idle 任务同时停顿(CPU PSI 通常只提供 some,接口以本机为准)
avg10/avg60/avg300 是窗口平均,total 是累计微秒。对延迟服务,短时压力尖峰可能比日均资源利用率更能解释 P99。
13.3 PSI 不是根因标签
memory PSI 高说明 reclaim 等内存压力阻碍进展,但根因可能是 limit 太低、匿名堆增长、page cache working set、swap、内核内存或邻居 cgroup竞争。PSI 告诉你“资源等待正在伤害任务”,仍需 controller stats、profile 和设备指标定位为什么。
14. Go runtime 与容器额度
14.1 GOMAXPROCS 必须匹配可用 CPU
历史上 Go 默认按机器逻辑 CPU 设置 GOMAXPROCS,容器只分到 2 CPU quota却在 64 核节点创建大量并行工作,会快速耗尽 quota、增加线程与 GC 并行度,造成 period throttling。
较新的 Go 版本会改进容器感知/默认行为,但精确语义随版本演进,应用应在启动日志记录:
log.Printf("GOMAXPROCS=%d NumCPU=%d", runtime.GOMAXPROCS(0), runtime.NumCPU())
并同时读取实际 cpu.max、cpuset 和 cpu.stat。不要仅凭 NumCPU 推断 quota,也不要无证据把 GOMAXPROCS 固定为 1。
14.2 GOMEMLIMIT 是 runtime 目标,不是 cgroup 硬保证
Go 的 soft memory limit(可由 GOMEMLIMIT/runtime API 设置)约束 runtime 管理的内存目标,帮助在容器 memory.max 前更积极 GC/归还。但它不完整覆盖:
- cgo/C malloc
- mmap 与第三方 native library
- kernel/socket memory
- page cache
- 同 cgroup 其他进程
- 部分 runtime 外部资源
若 GOMEMLIMIT 等于 memory.max,没有给非 Go heap 和峰值留余量,仍可能 OOM;设得太低会造成持续 GC、CPU 上升和吞吐下降。
14.3 RSS 不立即下降不一定泄漏
Go GC 释放对象后,span 可能变为 idle;scavenger 再把页归还 OS,RSS/memory.current 的下降有节奏。内核也可能保留 page cache。判断泄漏要看:
live heap after GC 是否持续增长
allocation rate / GC frequency
runtime Sys/HeapReleased/RSS
memory.stat anon/file/kernel 分布
同 cgroup 其他进程
只比较一次 pprof heap 和 docker stats 不足以下结论。
14.4 cgo 和 OS thread 受 PIDs/内存约束
cgo 调用可能阻塞 OS thread,runtime 创建更多 M 补充可运行 goroutine;每个线程有内核 task 和栈,受 pids.max、memory.max、RLIMIT_STACK 等影响。高 cgo 并发或 thread pinning 可能让看似“只有几百 goroutine”的服务耗尽 PID 额度。
14.5 CPU 限额会放大 GC 与网络尾延迟
Go 服务并行处理、TLS、GC 同时消耗 quota:
traffic burst
-> handler + GC workers 并行
-> cpu quota 提前耗尽
-> netpoll 唤醒的 goroutine 无法运行
-> socket/accept queue 积压
-> P99 增长、客户端重试
-> 更大流量与分配压力
排查要把 cpu.stat throttling、Go execution trace、GC pause/assist、网络队列和重试率关联,而不是分别优化。
15. OOM 的过程与恢复设计
15.1 分配成功不等于物理页已经保证
Linux overcommit 和按需 fault 允许 malloc/mmap 先建立虚拟地址,实际访问时才申请/charge 物理页。程序可能在一次看似普通写内存时触发 memcg reclaim/OOM,而不是 malloc 立即返回 nil。
Go runtime 对无法获得内存通常不能像业务 error 那样优雅恢复;进程可能被 kernel kill 或 runtime fatal。正确方向是限制输入、队列和缓存,给峰值留余量,而不是依赖捕获 OOM。
15.2 OOM killer 选择谁
内核根据受影响范围、进程内存、oom_score_adj 等选择 victim。cgroup v2 的 memory.oom.group=1 可请求把 cgroup 视为工作负载单元共同杀死,避免多进程服务残缺继续运行;但基础系统进程与编排策略需要谨慎设计。
应用不应随意把自己设为极低 oom_score_adj 把风险转给节点关键进程。优先通过正确 cgroup hierarchy 和 QoS 表达优先级。
15.3 重启不是恢复全部状态
OOM 后编排器重启容器,内存暂时清空,但:
- 未落盘数据可能丢失
- 客户端可能处于未知结果并重试
- WAL/文件需崩溃恢复
- 下游连接和锁需超时
- 同一输入若再次加载,仍会 OOM loop
服务要有幂等、持久化、恢复探针和 backoff,不能把容器重启视为事务回滚。
16. 安全边界:隔离、限制与授权是三回事
16.1 capability 拆分 root 权限
容器通常 drop 大量 capability,仅保留业务所需。危险能力示例包括:
CAP_SYS_ADMIN:覆盖大量系统管理操作,边界非常宽
CAP_NET_ADMIN:配置接口、路由、netfilter 等
CAP_SYS_PTRACE:观察/操作其他满足条件的进程
CAP_DAC_OVERRIDE:绕过部分文件权限检查
capability 的判断还与 user namespace 有关。--privileged 往往同时扩大 capability、设备和安全配置,应只用于明确授权、短时受控场景,不是解决 permission denied 的通用方案。
16.2 seccomp 限制 syscall 面
seccomp BPF 可按 syscall number/参数实施 allow/deny/notify,减少进程可触达的内核攻击面。它不理解业务文件路径,也不替代 capability/LSM;不同架构 syscall 编号和应用需要不同,策略必须测试。
Go runtime 会使用 futex、epoll、clone、mmap、signals 等 syscall。过严 profile 可能只在高并发、profile 或升级后触发,部署前要覆盖真实路径。
16.3 LSM 控制对象访问
SELinux、AppArmor 等 LSM依据 label/profile 控制文件、网络、capability 等对象访问。传统 Unix mode 允许不代表 LSM 允许;容器 volume permission denied 可能是 UID mapping,也可能是 SELinux label。
排查不能直接关闭 SELinux 或使用 --privileged 验证后长期保留。应查看 audit log、修正 label/profile 和最小授权。
16.4 /proc、设备与宿主 mount 是高风险入口
即便有 namespace,向容器暴露宿主 /proc、Docker socket、设备节点、根目录或高权限 bind mount,可能绕开隔离。Docker socket 本质上常等价于对 daemon 的高权限控制,不应因“只是一个 Unix socket”而低估。
17. 线上排查:先确认自己观察的是哪一层
17.1 namespace 身份
PID=12345
for ns in pid mnt net uts ipc user cgroup time; do
printf '%-8s ' "$ns"
readlink "/proc/$PID/ns/$ns" 2>/dev/null || true
done
cat "/proc/$PID/status" | grep -E '^(Pid|PPid|NSpid|Uid|Gid):'
cat "/proc/$PID/uid_map"
cat "/proc/$PID/gid_map"
将目标 PID 与宿主 PID 1、同 Pod sidecar 对比,可判断哪些 namespace 共享。
17.2 cgroup 路径和控制器
cat "/proc/$PID/cgroup"
findmnt -t cgroup2
CG=/sys/fs/cgroup/path/to/workload
cat "$CG/cgroup.controllers"
cat "$CG/cgroup.events"
cat "$CG/cpu.max"
cat "$CG/cpu.stat"
cat "$CG/memory.current"
cat "$CG/memory.max"
cat "$CG/memory.events"
cat "$CG/pids.current"
cat "$CG/pids.max"
宿主路径应从 /proc/PID/cgroup 和 mount point安全拼接;容器内 cgroup namespace 可能把路径显示为 /。使用 systemd/Kubernetes 时,优先结合 systemctl status、systemd-cgls、runtime inspect 和监控标签,避免手工猜转义后的 unit 名。
17.3 CPU 慢的检查顺序
1. cpu.max / cpuset 实际值
2. cpu.stat throttled 增量
3. cpu.pressure some
4. 节点 CPU/softirq/steal 与同层 sibling 竞争
5. Go CPU profile / trace / GC
6. 下游等待是否误算为 CPU 慢
cat "$CG/cpu.max" "$CG/cpuset.cpus.effective" "$CG/cpu.stat"
cat "$CG/cpu.pressure"
pidstat -u -w -t -p "$PID" 1
17.4 内存问题的检查顺序
1. memory.current 与 max/high
2. memory.events 的 high/max/oom/oom_kill 增量
3. memory.stat 区分 anon/file/kernel/sock/slab
4. memory.pressure 与 swap
5. Go heap/profile/runtime metrics
6. 同 cgroup sidecar/child 与父 cgroup 上限
cat "$CG/memory.current" "$CG/memory.high" "$CG/memory.max"
cat "$CG/memory.events" "$CG/memory.pressure"
cat "$CG/memory.stat"
cat "$CG/memory.swap.current" "$CG/memory.swap.max"
OOM 后文件中的累计事件可能随 cgroup 销毁而消失,监控系统需持续采集;只在新容器启动后进入查看可能已错过证据。
17.5 mount/overlay 空间问题
nsenter -t "$PID" -m findmnt
nsenter -t "$PID" -m df -h
nsenter -t "$PID" -m df -i
nsenter -t "$PID" -m mount
# overlay 详细挂载参数可能含很长路径
findmnt -t overlay -o TARGET,FSTYPE,OPTIONS
容器 writable layer、volume、宿主 filesystem 可能在不同配额和 inode 池。df、du、deleted-open 文件与 overlay copy-up 要联合检查。
18. 常用命令知识点扩展
| 命令/短参 | 英文全称 | 作用 |
|---|---|---|
nsenter -t PID |
target | 选择目标进程 |
nsenter -p |
pid namespace | 进入 PID namespace |
nsenter -m |
mount namespace | 进入 mount namespace |
nsenter -n |
network namespace | 进入 network namespace |
nsenter -U |
user namespace | 进入 user namespace |
unshare -p |
PID namespace | 创建/进入新的 PID namespace |
unshare -m |
mount namespace | 创建独立 mount 视图 |
lsns -t |
type | 按类型列出 namespace |
systemd-cgls |
cgroup list | 以树展示 systemd cgroup |
systemd-cgtop |
cgroup top | 查看 cgroup 资源使用 |
场景配方:
# 列出 PID/net/mount namespace 及持有进程
lsns -t pid
lsns -t net
lsns -t mnt
# 在目标完整视图中查看进程(权限允许时)
sudo nsenter -t "$PID" -p -m --mount-proc ps -ef
# 查看当前 cgroup v2 压力
cat /sys/fs/cgroup/cpu.pressure
cat /sys/fs/cgroup/memory.pressure
cat /sys/fs/cgroup/io.pressure
nsenter --mount-proc 会在临时 mount namespace 场景处理 procfs,具体行为和 util-linux 版本需查本机文档。生产使用前确认不会改变目标 mount 视图。
19. 面试题
Q:namespace 与 cgroup 的核心区别是什么?
namespace 改变进程可见的对象和名称,例如 PID、mount、network、hostname;cgroup 把进程放进层级并记账/控制 CPU、内存、IO、PID 等资源。前者主要是视图隔离,后者是资源治理。任何一方都不自动构成完整安全沙箱。
Q:为什么容器 PID 1 在宿主机有另一个 PID?
PID namespace 支持嵌套,同一个内核 task 在每层有不同 PID 映射。容器内最内层编号为 1,宿主初始 namespace 可见其外层编号。/proc/PID/status 的 NSpid 能显示层级;这不是两个进程。
Q:容器 PID 1 有什么特殊责任?
它是该 PID namespace 的 init,需要收养并 wait 回收孤儿/zombie,正确处理停止信号;其退出通常导致 namespace 其余进程被终止。普通应用或 shell 若没做 signal forwarding/reaping,会导致优雅退出失败或 zombie 累积。
Q:mount namespace 是否复制了一份文件系统?
不是。它复制/隔离挂载树关系,底层 inode、filesystem、block device 仍可共享。容器根常由 overlay lower/upper 合并,volume 用 bind mount 暴露。隔离的是路径可见与挂载变化,不是自动复制所有文件数据。
Q:overlayfs 的 copy-up 为什么会导致延迟或空间突增?
只读 lower 文件首次被修改时,需要复制到 writable upper,再在 upper 修改;大文件第一次写会付出 copy-up IO和空间。删除 lower 对象用 whiteout 表示。数据库/日志应使用专用 volume,并监控 writable layer 与 inode。
Q:user namespace 如何降低容器 root 风险?
它把内层 UID 0 映射为宿主非特权 UID,并让 capability 相对于 user namespace生效。容器 root 可管理被授权的内层对象,却不自动拥有宿主初始 user namespace 权限。它缩小后果,但容器仍共享内核,也可能因挂载、设备、漏洞或错误授权突破边界。
Q:cgroup v2 相比 v1 的关键变化是什么?
v2 使用统一 hierarchy,使 CPU、memory、IO、pids 等 controller 对同一进程层级保持一致,并定义清晰 delegation、no-internal-process 等规则。父级总约束覆盖子级;controller 通过 cgroup.subtree_control 下放。
Q:cpu.weight 与 cpu.max 有什么区别?
cpu.weight 是 sibling 在 CPU 竞争时的相对份额,空闲时可借用更多 CPU;cpu.max 是每个 period 可消耗的 CPU time 硬带宽,额度用尽后 throttle 到下一周期。weight 管公平,max 管上限;还可叠加 cpuset。
Q:容器限制 2 CPU 为什么会出现接近 100ms 的延迟尖峰?
cpu.max 常按 quota/period 实现,例如每 100ms 允许 200ms CPU time。8 个线程并行 25ms 就可提前耗尽 200ms,整个 cgroup 等下一 period。平均像 2 核,突发并行却形成周期性 throttling;应看 cpu.stat 与应用 trace。
Q:memory.high 与 memory.max 有何不同?
memory.high 是软压力边界,超过后任务承受 direct reclaim/节流,通常不会立即 kill;memory.max 是硬上限,无法回收时触发 memcg OOM。memory.events 的 high/max/oom/oom_kill 可确认发生过什么。
Q:为什么 Go heap 小于容器 memory.current?
memcg 还记账线程栈、runtime 元数据、mmap/cgo、page cache、socket和部分 kernel memory,以及同 cgroup 其他进程。GC 后空闲页也未必立即归还。应结合 memory.stat、RSS、Go runtime metrics 和父/兄弟 cgroup 分析。
Q:GOMEMLIMIT 能否保证容器不 OOM?
不能。它是 Go runtime 管理内存的软目标,不完整覆盖 cgo、mmap、kernel/socket、page cache和其他进程。需低于 memory.max 留出余量,并限制输入/队列;设太低还会造成 GC thrash 和 CPU throttling。
Q:pids.max 限制的是 goroutine 数吗?
不是,它限制 cgroup 中 Linux tasks,即进程和 OS thread。goroutine 是用户态对象,不直接计数;但 cgo、阻塞 syscall、LockOSThread 和 runtime 扩展 M会创建线程并消耗 pids。达到上限后 clone/fork 返回 EAGAIN,甚至无法启动 debug 命令。
Q:PSI 比 CPU/内存利用率多告诉了什么?
利用率/用量描述资源被使用多少,PSI 描述任务因 CPU、内存回收或 IO 等待而无法推进的时间。服务可能未到 hard limit,却因 runnable queue、memory.high reclaim或 IO latency 出现高 pressure 和 P99;PSI 是伤害信号,仍需其他指标定位根因。
Q:为什么容器 OOM 后自动重启不等于恢复成功?
kill 可能发生在业务操作中间,未落盘状态、未知结果和客户端重试仍需协议处理;相同流量/输入会再次把内存推到上限。需要持久化、幂等、恢复检查、backoff 和容量修复,重启只是重新创建进程。
Q:namespace 为什么不是完整安全边界?
它主要改变对象视图,所有容器仍共享内核;capability、syscall、LSM、设备、宿主 mount 和内核漏洞可跨越或绕过单一 namespace。安全容器要组合 user namespace、最小 capability、seccomp、SELinux/AppArmor、只读文件系统、资源 limit 和受支持内核,高隔离还可增加 VM 边界。
Q:排查容器资源问题为什么要沿父 cgroup 向上看?
当前 container cgroup 即使未触及自身 limit,也可能被 Pod/服务/节点祖先的 cpu.max、memory.max 或 pids.max 约束;sidecar 和 sibling 共同消耗父额度。层级的有效限制是所有祖先约束的交集。
小结
- namespace 负责“看见什么”,cgroup 负责“能用多少”;容器还需 credentials、capability、seccomp、LSM 和 filesystem 共同形成边界
- 一个 task 分别引用 PID、mount、network、UTS、IPC、cgroup、time 和 user namespace;它们可独立共享,不能把 namespace 当成一个整体开关
- PID namespace 为同一 task 提供分层 PID;namespace PID 1 负责 signal、reaping 和生命周期,
/proc还必须配合正确 mount 视图 - mount namespace 隔离挂载树而非复制磁盘;bind mount、pivot_root、overlayfs 与 propagation共同构造容器 root 和 volume
- user namespace 把内层 root 映射为外层普通 UID,使 capability 有层级作用域,是 rootless/减权的重要基础,但不消除共享内核风险
- cgroup v2 用统一 hierarchy 管 CPU、memory、IO 和 PIDs;祖先上限覆盖子级,cgroup namespace 只隐藏路径,不创建额度
- cpu.weight 是竞争份额,cpu.max 是周期硬额度,cpuset 是 CPU/NUMA 集合;并行线程可提前耗尽 quota 并制造尾延迟
- memory.high 通过回收/节流施压,memory.max 无法回收时触发 memcg OOM;memory.current 包含的不只是语言堆
- io controller 作用于实际块设备路径,buffered IO 会把限速延迟到 writeback/fsync;pids controller 限制进程与 OS thread,不直接限制 goroutine
- PSI 量化任务因资源不足停顿的时间,可在 hard limit 前解释 P99;它要与 controller events、应用 profile 和设备指标联合使用
- Go 应记录实际 GOMAXPROCS、cpu.max、cpuset、memory events、GOMEMLIMIT 与线程数,避免把宿主机容量误当容器容量
- OOM/restart 不是业务事务回滚;可靠系统还要限制队列、保留余量、实现幂等与恢复协议
下一篇讲 init 之争:systemd 为什么赢也被骂 —— SysV init 的串行脚本为何难以表达现代依赖,systemd 如何用 unit、socket activation、cgroup 和 journal 统一管理服务,以及 PID 1、依赖顺序、restart 与 graceful shutdown 为什么常被配置错。
xingliuhua