一、引言
很多人日常 docker run、kubectl apply 用得很熟,却说不清一个本质问题:容器到底是什么?
一句被讲烂的话是"容器不是虚拟机"。但只说到这里等于没说。真正的答案是:
容器 = 一个普通的 Linux 进程 + 一组 Namespace(限制它能"看见"什么)+ 一组 Cgroup(限制它能"用掉"多少资源)+ 一套 rootfs(OverlayFS 提供独立文件系统视图)。
前面《Pod 安全》那篇我们强调过"容器共享宿主机内核、Namespace 只是视图隔离而非权限隔离",本文就把这句话拆开讲透。理解了 Cgroup 与 Namespace,你会发现:
- 为什么容器里 top 看到的 CPU 和主机不一样;
- 为什么 kubectl exec 进去后 ps aux 只看到自己这一小撮进程;
- 为什么 requests/limits 最终会变成宿主机上的 cgroup 限制;
- 以及 OOM、PID 耗尽、namespace 泄漏这些"诡异问题"的根因。
本文聚焦两大原语:Namespace(隔离视图) 与 Cgroup(限制资源)。文件系统(OverlayFS)与 seccomp/eBPF 留待后续 Linux 专题。
二、原理解析
2.1 Namespace:进程看到的"平行宇宙"
Namespace 是 Linux 内核提供的一种轻量级隔离机制:让一组进程看到一份"被裁剪过"的系统视图,彼此之间互不可见或受限可见。它只改变"看见什么",不改变"能做什么"------这是整篇文章最重要的一个认知,也是容器安全模型的根基。
Linux 目前有 8 类 Namespace(按引入顺序):
Namespace
隔离对象
典型作用
Mount (mnt)
挂载点
容器有自己的 /proc、/sys,看不到宿主挂载
UTS
主机名 / 域名
容器可设独立 hostname
IPC
System V IPC / POSIX 消息队列
容器间默认不能互发共享内存消息
PID
进程号空间
容器内 ps 只看到自己的进程,PID 1 是 init
Network (net)
网络设备、IP、端口、路由、iptables
容器有独立网卡、IP、端口表
User
用户/组 ID 映射
容器内 root(uid 0) 可映射到宿主普通 uid
Cgroup
cgroup 视图
容器内只能看到自己所属的 cgroup 子树
Time
系统时钟
可独立调整容器时钟(5.6+)
关键纠偏:PID Namespace 让容器里的进程以为自己是 PID 1,但这不意味着它真的拥有这台机器。宿主上 ps 仍能看到它,只是换了个全局 PID。所谓"容器逃逸"之所以危险,正是因为 Namespace 只是视图隔离------一旦内核漏洞被利用(如脏牛 Dirty COW),进程就能跳出 Namespace 看到全局。这解释了为什么《Pod 安全》强调 readOnlyRootFilesystem、seccompProfile、非 root 等一系列"权限下限"手段:光靠 Namespace 是不够的。
内核视角下,每个进程所属的 namespace 都体现在 /proc//ns/:
查看某进程加入了哪些 namespace(每个文件是内核中的一个 ns inode)
ls -l /proc/1/ns/
lrwxrwxrwx ... cgroup -> 'cgroup:4026531835'
lrwxrwxrwx ... pid -> 'pid:4026531836'
lrwxrwxrwx ... net -> 'net:4026531840'
...
User Namespace 是安全加分项:通过 uid_map / gid_map,可以把容器内的 uid 0(root)映射到宿主上一个毫无特权的普通 uid。这就是"容器里是 root,宿主上不是 root"的实现原理------也是 rootless 容器(如 Podman rootless)的基石。
2.2 Cgroup:资源的"配额与记账"
如果说 Namespace 管"看见什么",Cgroup(Control Group)管"能用多少"。它把一组进程组织起来,对 CPU、内存、IO、PID 数量等资源施加上限与统计。
cgroup v1 与 v2 的架构差异
这是运维中最容易踩的坑,务必分清:
- cgroup v1:每类控制器(cpu、memory、blkio...)各自是一棵独立的层级树,路径分散(如 /sys/fs/cgroup/cpu/...、/sys/fs/cgroup/memory/...),配置割裂。
- cgroup v2(unified hierarchy):所有控制器挂在一棵统一的树下,路径统一在 /sys/fs/cgroup/。新内核(≥4.5 主线,RHEL 8+/Ubuntu 20.04+ 默认)基本都是 v2。
查看当前系统用的是哪一版:
stat -fc %T /sys/fs/cgroup/
cgroup2fs -> 使用的是 cgroup v2
tmpfs -> 使用的是 cgroup v1(混合挂载时也可能显示这个,需进一步看 /sys/fs/cgroup 下结构)
核心控制器一览
控制器
作用
v2 关键接口文件
cpu
CPU 时间配额
cpu.max(配额/周期)、cpu.weight
memory
内存上限与统计
memory.max、memory.current、memory.oom.group
pids
进程/线程数上限
pids.max、pids.current
io
磁盘 IO 限速
io.max、io.weight
freezer
暂停/恢复进程组
cgroup.freeze
devices(v1)
限制访问哪些设备
重要认知:cgroup 限制的是"上限",不是"保底"
这是和 Kubernetes requests/limits 映射时最容易混的点:
- limits → cgroup 的硬上限(如 memory.max、cpu.max),超出即被 throttle 或 OOM kill;
- requests → 仅用于调度器决策(能不能放下这个 Pod),不是给进程预留的保底资源。没有 limits 时,进程理论上能用满整机,直到触发宿主 OOM。
内存 cgroup 超限时,内核会触发 OOM Killer 在该 cgroup 内挑一个进程杀掉,并在 dmesg 留下 Out of memory: Killed process ... 记录------这正是《日常排障》那篇里 OOMKilled 的源头。
2.3 二者如何协作:一个容器的一生
当你执行 docker run nginx 时,底层大致发生:
- clone() 系统调用带上 CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | ... 标志,创建新 namespace;
- 在 cgroup 文件树下为这个容器新建目录,写入 memory.max、cpu.max 等限制;
- pivot_root / chroot 切换到镜像的 rootfs(OverlayFS 挂载);
- 在隔离 + 受限的环境里 exec 目标进程(如 nginx),它成为该 PID namespace 里的 PID 1。
所以"容器"没有任何黑魔法,全是标准 Linux 内核能力。unshare、nsenter、cgroupfs 这些工具不过是把上述步骤暴露给了人类。
三、实战演示
下面所有命令均可在任意现代 Linux(cgroup v2)上复现。
场景 1:用 unshare 手工造一个"迷你容器"
不借助 Docker,仅用内核原语隔离一个 shell,直观感受 namespace:
新建 uts+ipc+pid+net+mount namespace,并把它挂在独立根文件系统(这里复用当前根)
sudo unshare --fork --pid --mount --net --uts --ipc --mount-proc chroot / /bin/bash
进去后验证:
hostname mini-container && hostname # UTS:主机名独立
ps aux # PID:只看到自己,且自己是 PID 1
ip link # Network:只有 lo,看不到宿主网卡
echo $$ # 显示 1
退出(exit)后,这些 namespace 随进程释放。这几十行命令,就是 Docker 的本质。
场景 2:查看运行中容器的 cgroup 限制
进入一个 K8s 容器的 shell(或 Docker 容器),直接读 cgroup v2 接口:
容器内执行(需特权或宿主挂载了 cgroup)
cat /sys/fs/cgroup/memory.max # 内存硬上限,对应 limits.memory
cat /sys/fs/cgroup/memory.current # 当前用量
cat /sys/fs/cgroup/cpu.max # 如 "50000 100000" 表示 0.5 核
cat /sys/fs/cgroup/pids.max # PID 上限
宿主侧也可按容器定位(以 Docker 为例):
找到容器短 ID 后
DID=(dockerinspect−f′.Id′my−nginx∣cut−c1−12)ls/sys/fs/cgroup/system.slice/docker−(docker inspect -f '{{.Id}}' my-nginx | cut -c1-12) ls /sys/fs/cgroup/system.slice/docker-(dockerinspect−f′.Id′my−nginx∣cut−c1−12)ls/sys/fs/cgroup/system.slice/docker−{DID}.scope/
场景 3:用 cgroup v2 手动限制一个进程的资源
用 stress-ng 模拟压力,证明 cgroup 限制真实生效:
1) 建一个 cgroup 并设限制:最多 0.5 核、最多 100MB 内存
sudo mkdir -p /sys/fs/cgroup/demo
echo "50000 100000" | sudo tee /sys/fs/cgroup/demo/cpu.max
echo "100M" | sudo tee /sys/fs/cgroup/demo/memory.max
2) 把当前 shell 移入该 cgroup
echo $$ | sudo tee /sys/fs/cgroup/demo/cgroup.procs
3) 跑压力测试:请求 4 核 CPU、分配 500MB
stress-ng --cpu 4 --vm 1 --vm-bytes 500M --timeout 20s &
观察:CPU 占用被压在 ~50%,内存超过 100M 时该进程被 OOM kill
场景 4:nsenter 进入运行中容器的 namespace 排障
容器没有 shell 或 kubectl exec 不通时,从宿主直接进入其 namespace(呼应《日常排障》的 kubectl debug):
找到容器内的 1 号进程 PID(宿主视角)
PID=$(docker inspect -f '{{.State.Pid}}' my-nginx)
进入它的 net/mount/pid namespace 执行命令
sudo nsenter -t $PID -n -m -p -- ip addr # 看容器内网络
sudo nsenter -t $PID -n -m -p -- ps aux # 看容器内进程
sudo nsenter -t $PID -n -m -p -- netstat -tlnp # 看容器内监听端口
场景 5:User Namespace 与 root 映射
新建 user + pid namespace,把容器内 uid 0 映射到宿主 uid 1000
unshare --user --pid --fork --mount-proc
--map-root-user bash
id # 容器内显示 uid=0(root)
宿主侧该进程实际以 uid=1000 运行(ps -o uid= -p <宿主PID> 可见)
这解释了 rootless 容器的安全性:容器内是 root,但宿主上只是个普通用户,即便逃逸也拿不到宿主特权。
四、排错实战
4.1 容器频繁 OOM:cgroup 内存 vs 宿主 OOM
现象:Pod 重启,kubectl describe 显示 OOMKilled;或宿主 dmesg 出现 Out of memory: Killed process。
定位:
容器内确认是否被 cgroup 限制杀掉
dmesg -T | grep -i "oom|killed" | tail
或读 memory.events(v2)
cat /sys/fs/cgroup/memory.events # oom_kill 计数 > 0 即发生过
宿主侧看全局 OOM(区别于 cgroup 内 OOM)
grep -i "out of memory" /var/log/kern.log
根因与修复:
- 多半是 limits.memory 设太小,或应用确有内存泄漏 → 调大 limits、查内存泄漏;
- 注意 JVM、Go、Node 等运行时的"堆外/缓存"不认容器内存限制,需显式传 -Xmx / GOMEMLIMIT / --max-old-space-size,否则会算着"宿主机总内存"去分配,撞上 cgroup 上限被 OOM;
- 若宿主整体内存紧张触发全局 OOM,会优先杀 oom_score_adj 高的进程(容器进程通常被调高),需靠调度打散、加节点或加 requests 保底。
4.2 cgroup v1/v2 混用导致 Kubelet 起不来
现象:节点 kubelet 启动失败,日志报 failed to find cgroup mount 或 cgroup v2 not supported。
根因:Kubelet 对 cgroup 驱动有要求(推荐 systemd 驱动 + 统一 v2),而某些老发行版内核仍是 v1,或开启了 systemd.unified_cgroup_hierarchy=0 强制 v1。
修复:
确认版本
stat -fc %T /sys/fs/cgroup/
若需切到 v2:在内核启动参数加(RHEL/CentOS 在 /etc/default/grub)
systemd.unified_cgroup_hierarchy=1
然后 grub2-mkconfig -o /boot/grub2/grub.cfg && reboot
4.3 PID 耗尽:fork: retry: No child processes
现象:容器里新进程起不来,报 fork: retry: No child processes 或 Resource temporarily unavailable,但内存、CPU 还很空闲。
根因:pids cgroup 达到 pids.max 上限(如某应用疯狂 fork 子进程/线程泄漏)。K8s 默认不限制 PID,需显式配 kubelet --pod-pids-limit 或 Pod 级 annotations: kubernetes.io/pid-limit。
定位:
cat /sys/fs/cgroup/pids.current # 已用
cat /sys/fs/cgroup/pids.max # 上限(max 表示无限制,可能已被宿主总 PID 限)
ps -eLf | wc -l # 总线程数
4.4 Namespace 泄漏:已退出容器残留 netns
现象:ip netns list(或 /var/run/docker/netns)里堆积大量残留条目,或 ss -tan 看到诡异 socket。常见于早期 Docker / 某些 CNI 插件的 bug:容器退出但 net namespace 没清理干净,消耗内核资源。
修复:升级 Docker / CNI 版本;必要时手动清理残留 netns 或重启 docker 服务(生产需谨慎)。
4.5 排错决策树
应用异常
├─ 内存暴涨/被杀 → 读 memory.events、dmesg OOM → 调 limits / 修泄漏 / 配运行时堆上限
├─ 进程起不来、fork 失败 → 查 pids.current vs pids.max → 提 pid 上限 / 查泄漏
├─ CPU 被压死 → 读 cpu.max、cpu.stat(throttled) → 调 cpu limit / 查忙循环
├─ Kubelet/容器运行时起不来 → 查 cgroup 版本与驱动 → 切 v2 / 配 systemd 驱动
└─ 视图"不对"(看不到网卡/进程) → 查 /proc//ns、unshare 参数 → namespace 没建对
五、总结
- Namespace 管"看见什么":8 类隔离把进程圈进各自的平行宇宙,但只是视图隔离、共享同一内核------这正是容器安全必须叠加"权限下限"(非 root、seccomp、只读根)的根本原因。
- Cgroup 管"用掉多少":v2 统一层级,靠 memory.max/cpu.max/pids.max 等接口施加硬上限;它限制上限而非保底,OOM 与 PID 耗尽都源于此。
- 二者 + OverlayFS = 容器:unshare、nsenter、cgroupfs 不过是把内核能力暴露给人,Docker/K8s 只是编排了这些步骤。