Linux-19 namespace 与 cgroup
容器不是虚拟机,它就是一个普通的 Linux 进程——只是被 namespace 限制了「能看到什么」,被 cgroup 限制了「能用多少」。
这一篇把这两个机制讲透。理解它们能解决一大类困惑:为什么容器里 top 看到的是宿主机的负载、为什么 JVM 在容器里会认错 CPU 核数、为什么 nsenter 能进入容器的网络排查问题。
1. 容器的本质
容器 = 一个普通进程 + namespace(视图隔离)+ cgroup(资源限制)+ 文件系统隔离
^ 你能看到什么 ^ 你能用多少 ^ 你的根目录在哪
对比虚拟机:
虚拟机 -> 完整的内核 + 硬件模拟,隔离彻底但开销大(几百 MB 内存,秒级启动)
容器 -> 【共享宿主机内核】,隔离靠内核特性,开销近零(几 MB,毫秒级启动)
「共享内核」是容器所有优势和所有局限的共同来源:
| 优势 | 局限 |
|---|---|
| 启动快(毫秒级) | 无法运行不同内核(Linux 上跑不了 Windows 容器) |
| 开销小(无硬件模拟) | 内核漏洞可能导致逃逸,隔离性弱于虚拟机 |
| 密度高(单机上千容器) | 内核参数大多是全局的,容器改不了 |
| 直接用宿主机的硬件加速 | 一个容器触发内核 panic 会拖垮所有容器 |
# 验证:容器里的进程在宿主机上就是普通进程
docker run -d --name test nginx
docker inspect -f '{{.State.Pid}}' test
# 12345
# 宿主机上直接能看到
ps -ef | grep 12345
# root 12345 12300 0 14:00 ? 00:00:00 nginx: master process nginx -g daemon off;
# ^^^^^ 就是一个普通进程,没有任何特殊之处
# 看它的 namespace
ls -l /proc/12345/ns/
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 cgroup -> 'cgroup:[4026531835]'
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 ipc -> 'ipc:[4026532467]'
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 mnt -> 'mnt:[4026532465]'
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 net -> 'net:[4026532470]'
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 pid -> 'pid:[4026532468]'
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 user -> 'user:[4026531837]'
# lrwxrwxrwx 1 root root 0 Aug 6 14:00 uts -> 'uts:[4026532466]'
# ^^^^^^^^^^^^^^^^^^ inode 号
方括号里的数字是 namespace 的 inode 号——这是判断「两个进程是否在同一个 namespace」的依据:数字相同就是同一个。
# 对比容器和宿主机的 namespace
readlink /proc/1/ns/net # 宿主机 init
# net:[4026531840]
readlink /proc/12345/ns/net # 容器
# net:[4026532470] <- 不同,说明网络被隔离了
readlink /proc/1/ns/user
readlink /proc/12345/ns/user
# 两个都是 user:[4026531837] <- 相同!说明【没有】开 user namespace
# 这就是第 3 篇讲的「容器里的 uid 就是宿主机的 uid」
2. namespace:隔离「能看到什么」
Linux 目前有 8 种 namespace:
| namespace | 隔离什么 | 内核版本 | 容器里的表现 |
|---|---|---|---|
| PID | 进程 ID | 2.6.24 | 容器里的 PID 1 是自己的主进程 |
| NET | 网络栈(网卡、IP、路由、iptables、端口) | 2.6.29 | 容器有独立 IP 和端口空间 |
| MNT | 挂载点 | 2.4.19 | 容器有自己的文件系统视图 |
| UTS | hostname、domainname | 2.6.19 | 容器有独立的主机名 |
| IPC | System V IPC、POSIX 消息队列 | 2.6.19 | 容器间共享内存隔离 |
| USER | UID/GID 映射 | 3.8 | 默认不开,见 §2.7 |
| CGROUP | cgroup 根目录视图 | 4.6 | 容器看到的 cgroup 路径是相对的 |
| TIME | 系统时间偏移 | 5.6 | 很少用 |
2.1 三个核心系统调用
// ① clone:创建新进程并同时进入新的 namespace
pid_t pid = clone(child_func, stack, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, arg);
// ② unshare:让【当前进程】脱离原 namespace,进入新的
unshare(CLONE_NEWNS | CLONE_NEWUTS);
// ③ setns:加入一个【已存在】的 namespace(通过 /proc/<pid>/ns/xxx 的 fd)
int fd = open("/proc/12345/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);
对应的命令行工具:
unshare # 对应 unshare(),创建新 namespace 并执行命令
nsenter # 对应 setns(),进入已有进程的 namespace
lsns # 列出系统上所有 namespace
2.2 UTS namespace:最容易理解的例子
# 在新的 UTS namespace 里改主机名
sudo unshare --uts bash
hostname
# myhost
hostname container-test # 改主机名
hostname
# container-test
exit # 退出
hostname
# myhost <- 宿主机完全不受影响
这就是 namespace 的本质:同一个内核数据结构,不同 namespace 里的进程看到不同的值。
2.3 PID namespace
sudo unshare --pid --fork --mount-proc bash
# ^^^^^^ 必须加:否则 ps 仍读宿主机的 /proc
ps -ef
# UID PID PPID C STIME TTY TIME CMD
# root 1 0 0 14:10 pts/0 00:00:00 bash <- bash 成了 PID 1
# root 9 1 0 14:10 pts/0 00:00:00 ps
# ^ 只能看到自己 namespace 里的进程
echo $$
# 1
--mount-proc 是必需的:ps 是通过读 /proc 来列进程的,如果不重新挂载 /proc,它读到的还是宿主机的,就看不出隔离效果。
PID namespace 的两个特性:
① 层级性:父 namespace 能看到子 namespace 的所有进程(但 PID 不同),
子 namespace 看不到父的
-> 所以宿主机能看到并管理所有容器进程
② PID 1 的特殊性(第 8 篇 §2.2 讲过):
- 不执行信号的默认动作 -> 没注册 handler 时 SIGTERM 被丢弃
- 负责收养孤儿进程并 wait -> 应用通常不实现,导致僵尸堆积
- PID 1 退出 -> 整个 namespace 里的所有进程被 SIGKILL
# 同一个进程在不同 namespace 里有不同的 PID
# 容器内
docker exec test ps -ef | head -2
# UID PID PPID CMD
# root 1 0 nginx: master process
# 宿主机上
docker inspect -f '{{.State.Pid}}' test
# 12345 <- 同一个进程,宿主机视角是 12345,容器内是 1
2.4 NET namespace:容器网络的基础
# 创建一个网络 namespace
sudo ip netns add myns
sudo ip netns list
# myns
# 在里面执行命令
sudo ip netns exec myns ip addr
# 1: lo: <LOOPBACK> mtu 65536 state DOWN
# ^ 只有一个 lo,而且还是 DOWN 状态 —— 完全干净的网络栈
sudo ip netns exec myns ip link set lo up
用 veth pair 把 namespace 连到宿主机——这正是 Docker 的 bridge 网络的实现原理:
# ① 创建一对虚拟网卡(像一根虚拟网线的两端)
sudo ip link add veth-host type veth peer name veth-ns
# ② 把一端放进 namespace
sudo ip link set veth-ns netns myns
# ③ 配置宿主机这一端
sudo ip addr add 10.200.1.1/24 dev veth-host
sudo ip link set veth-host up
# ④ 配置 namespace 那一端
sudo ip netns exec myns ip addr add 10.200.1.2/24 dev veth-ns
sudo ip netns exec myns ip link set veth-ns up
sudo ip netns exec myns ip route add default via 10.200.1.1
# ⑤ 测试连通
ping -c 2 10.200.1.2
sudo ip netns exec myns ping -c 2 10.200.1.1
# ⑥ 让 namespace 能访问外网(NAT,第 13 篇讲的 iptables)
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -s 10.200.1.0/24 -o eth0 -j MASQUERADE
sudo ip netns exec myns ping -c 2 8.8.8.8
这六步就是 Docker bridge 网络模式的完整原理——只是 Docker 用 docker0 网桥连接所有 veth,而不是直接点对点。
# 在宿主机上看容器的 veth
ip link | grep veth
# 5: veth1a2b3c4@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> ... master docker0
# ^^^^^^^^^^^^^ 挂在 docker0 网桥上
# 清理
sudo ip netns del myns
2.5 MNT namespace
sudo unshare --mount bash
# 在这里挂载,宿主机看不到
mount -t tmpfs tmpfs /mnt
df -h /mnt
# tmpfs 7.8G 0 7.8G 0% /mnt
exit
df -h /mnt
# 宿主机的 /mnt 没有这个挂载 ✅
MNT namespace 配合 pivot_root 才实现了「容器有自己的根文件系统」:
# 简化演示:用 chroot(真实容器用 pivot_root,更安全)
sudo unshare --mount --pid --fork chroot /path/to/rootfs /bin/bash
chroot 与 pivot_root 的区别:chroot 只是改变进程的根目录视图,老的根文件系统仍然挂载着、理论上可以逃逸出去(著名的 chroot 逃逸技巧);pivot_root 会把老的根卸载掉,无法回退。所以容器运行时用 pivot_root。
2.6 nsenter:进入容器排查问题
**这是本篇最实用的一个工具。**当容器镜像是 distroless、里面连 bash、curl、ss 都没有时,你在宿主机上用 nsenter 就能带着宿主机的工具进去排查。
# 拿到容器的 PID
PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
# 进入容器的【网络】namespace,用【宿主机的】ss 命令
sudo nsenter -t $PID -n ss -tlnp
# ^^ 只进 net namespace
# State Recv-Q Send-Q Local Address:Port
# LISTEN 0 511 0.0.0.0:80
# 在容器的网络里抓包(容器里没有 tcpdump 也没关系)
sudo nsenter -t $PID -n tcpdump -i eth0 -nn port 80
# 进入容器的网络里 curl
sudo nsenter -t $PID -n curl -s localhost/health
# 全部 namespace 都进(等效 docker exec,但用宿主机的 shell)
sudo nsenter -t $PID -a /bin/bash
| 参数 | 进入哪个 namespace |
|---|---|
-n |
net |
-p |
pid |
-m |
mount |
-u |
uts |
-i |
ipc |
-U |
user |
-a |
全部 |
nsenter -t PID -n 是排查容器网络问题的标准手法——它只进网络 namespace,文件系统还是宿主机的,所以宿主机上的所有工具都能用。
# K8s 里的用法
POD_PID=$(crictl inspect $(crictl ps -q --name myapp) | jq -r '.info.pid')
sudo nsenter -t $POD_PID -n ss -tanp
# 或者用调试容器(K8s 1.23+,更方便)
kubectl debug -it mypod --image=nicolaka/netshoot --target=myapp
2.7 USER namespace:为什么默认不开
这是容器安全最重要的一环,但 Docker 默认不启用。
USER namespace 能把容器内的 root(uid 0)映射到宿主机上的一个普通用户(比如 uid 100000):
不开 user namespace(默认):
容器内 uid 0 == 宿主机 uid 0(真正的 root)
-> 容器逃逸后直接是宿主机 root
开启 user namespace:
容器内 uid 0 -> 宿主机 uid 100000(无特权用户)
-> 即使逃逸,也只是一个普通用户
# 验证当前是否开启
readlink /proc/1/ns/user /proc/$(docker inspect -f '{{.State.Pid}}' test)/ns/user
# user:[4026531837]
# user:[4026531837] <- 相同 = 【没开】user namespace
# 手动体验
unshare --user --map-root-user bash
id
# uid=0(root) gid=0(root) <- 容器内看是 root
# 但在宿主机上看这个进程仍是原来的普通用户
cat /proc/self/uid_map
# 0 1000 1
# ^ ns内 ^ 宿主机 ^ 范围
# 含义:namespace 里的 uid 0 映射到宿主机的 uid 1000
为什么 Docker 默认不开:
① 挂载卷的权限会很麻烦 —— 宿主机上的文件属主需要是映射后的 uid
② 部分功能不兼容(某些 --privileged 场景、部分存储驱动)
③ 镜像层需要 chown,占用额外磁盘空间
④ 历史包袱:早期实现不成熟
# 开启(Docker)
# /etc/docker/daemon.json
{
"userns-remap": "default"
}
systemctl restart docker
# 之后宿主机上会有映射用户
cat /etc/subuid
# dockremap:100000:65536 <- 容器 uid 0~65535 映射到宿主机 100000~165535
替代方案(更常用):不开 user namespace,但容器内不用 root 运行:
# K8s 的标准安全配置(第 3 篇提过)
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
规律:runAsNonRoot: true + drop: ALL 的收益/成本比远好于开 user namespace,应该作为默认配置。
2.8 lsns:查看系统上的所有 namespace
lsns
# NS TYPE NPROCS PID USER COMMAND
# 4026531835 cgroup 234 1 root /sbin/init
# 4026531837 user 234 1 root /sbin/init
# 4026532465 mnt 3 12345 root nginx: master process
# 4026532470 net 3 12345 root nginx: master process
lsns -t net # 只看网络 namespace
lsns -p 12345 # 看某个进程属于哪些 namespace
3. cgroup:限制「能用多少」
cgroup(control group)负责资源限制、统计和隔离。
cgroup 提供三件事:
① 限制(limit) —— 最多能用多少
② 统计(accounting)—— 实际用了多少
③ 控制(control) —— 冻结、恢复、批量杀死
3.1 v1 与 v2 的关键差异
# 判断当前系统用的是哪个版本
stat -fc %T /sys/fs/cgroup/
# cgroup2fs <- v2(统一层级)
# tmpfs <- v1(多层级)
mount | grep cgroup
| 维度 | cgroup v1 | cgroup v2 |
|---|---|---|
| 层级结构 | 每个控制器一个独立层级 | 单一统一层级 |
| 路径 | /sys/fs/cgroup/cpu/、/memory/ … |
/sys/fs/cgroup/ 下统一 |
| 进程归属 | 同一进程在不同控制器下可属于不同 cgroup | 一个进程只属于一个 cgroup |
| 内存+IO 协同 | 不支持(writeback 无法正确归属) | 支持 |
| PSI 压力指标 | 无 | 有(cpu.pressure 等) |
| 默认于 | CentOS 7、Ubuntu 20.04 及更早 | RHEL 9、Ubuntu 22.04+、Fedora 31+ |
v1 的核心问题是「多层级导致语义混乱」:一个进程可以在 CPU 控制器里属于 group A、在内存控制器里属于 group B,这让「这个进程组用了多少资源」变得难以回答。更实际的问题是内存和 IO 无法协同——脏页回写发生在内核线程里,v1 无法把这部分 IO 归属到产生它的 cgroup,所以 buffered write 的限流在 v1 里基本无效。
v2 修正了这些,并新增了 PSI(Pressure Stall Information)——这是个非常有价值的指标:
cat /sys/fs/cgroup/cpu.pressure
# some avg10=12.34 avg60=8.21 avg300=3.45 total=892340123
# ^^^^ 至少【一个】任务因为等 CPU 而停滞的时间占比
cat /sys/fs/cgroup/memory.pressure
# some avg10=0.00 avg60=0.00 avg300=0.00 total=0
# full avg10=0.00 avg60=0.00 avg300=0.00 total=0
# ^^^^ 【所有】任务都在等的时间占比 —— full > 0 就是严重问题
PSI 比 load average 好在哪:load 只是「可运行+不可中断的进程数」,无法区分「有 8 个进程但 CPU 够用」和「有 8 个进程在互相抢」。PSI 直接量化了因资源不足而损失的时间,some avg10=12.34 意味着最近 10 秒有 12.34% 的时间至少有一个任务在等 CPU。这是判断「是否需要扩容」最准确的指标。
3.2 cgroup v2 的目录结构
ls /sys/fs/cgroup/
# cgroup.controllers 当前可用的控制器
# cgroup.procs 属于这个 cgroup 的进程 PID 列表
# cgroup.subtree_control 【授权给子 cgroup 的控制器】
# cpu.max CPU 限制
# cpu.stat CPU 统计
# cpu.pressure PSI
# memory.max 内存上限
# memory.current 当前使用
# memory.stat 详细统计
# memory.events 事件计数(含 oom_kill)
# io.max IO 限制
# io.stat IO 统计
# pids.max 进程数上限
cat /sys/fs/cgroup/cgroup.controllers
# cpuset cpu io memory hugetlb pids rdma misc
3.3 手动创建 cgroup 限制资源
# ① 创建一个 cgroup(就是建目录,内核自动生成控制文件)
sudo mkdir /sys/fs/cgroup/mytest
ls /sys/fs/cgroup/mytest/
# 自动出现了一堆控制文件
# ② 【关键】父 cgroup 必须先授权控制器给子级
echo "+cpu +memory +io +pids" | sudo tee /sys/fs/cgroup/cgroup.subtree_control
cat /sys/fs/cgroup/mytest/cgroup.controllers
# cpu io memory pids <- 现在子 cgroup 才能用这些控制器
cgroup.subtree_control 是 v2 新增的机制,也是最容易踩的坑:如果不在父 cgroup 里写入 +cpu,子 cgroup 里根本不会出现 cpu.max 文件,写限制会报 No such file or directory。
# ③ CPU 限制:每 100ms 周期内最多用 50ms = 0.5 核
echo "50000 100000" | sudo tee /sys/fs/cgroup/mytest/cpu.max
# ^^^^^ quota ^^^^^^ period(微秒)
# 写 "max 100000" 表示不限制
# ④ 内存限制:512MB
echo "536870912" | sudo tee /sys/fs/cgroup/mytest/memory.max
# 软限制(超过后积极回收但不 OOM)
echo "402653184" | sudo tee /sys/fs/cgroup/mytest/memory.high
# ⑤ 进程数限制(防 fork 炸弹)
echo "100" | sudo tee /sys/fs/cgroup/mytest/pids.max
# ⑥ IO 限制(需要设备号)
lsblk -d -o NAME,MAJ:MIN
# nvme0n1 259:0
echo "259:0 rbps=10485760 wbps=10485760" | sudo tee /sys/fs/cgroup/mytest/io.max
# ^^^^ 读 10MB/s ^^^^ 写 10MB/s
# ⑦ 把进程加进去
echo $$ | sudo tee /sys/fs/cgroup/mytest/cgroup.procs
# 之后这个 shell 及其所有子进程都受限制
# ⑧ 验证 CPU 限制
yes > /dev/null &
top -bn2 | grep yes | tail -1
# 12345 root 20 0 ... 50.0 0.0 0:05.23 yes
# ^^^^ 正好 50%,限制生效
cat /sys/fs/cgroup/mytest/cpu.stat
# usage_usec 5234012
# nr_periods 234
# nr_throttled 198 <- 被限流了 198 次
# throttled_usec 8923401 <- 累计限流时间
nr_throttled 和 throttled_usec 是判断「是否被 CPU 限流」的唯一可靠指标(第 7 篇的实战案例就是靠这个定位的)。
# ⑨ 验证内存限制
cat /sys/fs/cgroup/mytest/memory.current
cat /sys/fs/cgroup/mytest/memory.events
# low 0
# high 234
# max 12
# oom 3
# oom_kill 3 <- 发生过 3 次 OOM kill
# ⑩ 清理
sudo rmdir /sys/fs/cgroup/mytest # 必须先把进程移出去
3.4 CPU 限制的两种方式
这两种的语义完全不同,容易混淆:
# 方式一:cpu.max —— 硬性配额(hard limit)
echo "200000 100000" > cpu.max # 最多 2 核,【即使 CPU 空闲也不给更多】
# 对应 K8s 的 resources.limits.cpu: "2"
# 方式二:cpu.weight —— 相对权重(soft,仅在争抢时生效)
echo "200" > cpu.weight # 默认 100,范围 1~10000
# 对应 K8s 的 resources.requests.cpu
cpu.max(quota) |
cpu.weight |
|
|---|---|---|
| 语义 | 绝对上限 | 争抢时的相对份额 |
| CPU 空闲时 | 仍然限制 | 可以超用 |
| 会不会限流 | 会(nr_throttled 增长) |
不会 |
| K8s 对应 | limits.cpu |
requests.cpu |
这解释了 K8s 里 requests 和 limits 的本质区别,也解释了一个常见的性能问题:
设了 limits.cpu = 1 的容器,即使宿主机 CPU 空闲,也只能用 1 核
-> 突发流量时无法借用空闲 CPU,延迟飙升(nr_throttled 暴涨)
设了 requests.cpu = 1 但不设 limits
-> 平时保证 1 核,空闲时可以用更多,突发流量能扛住
-> 但可能影响同机的其他容器
生产建议:延迟敏感的在线服务,requests 设准、limits 设宽松(或不设)——CPU 是可压缩资源,限流带来的延迟毛刺往往比「占用多一点 CPU」更有害。内存则相反,limits.memory 必须设(内存是不可压缩资源,超了只能 OOM)。
3.5 用 systemd 管理 cgroup(推荐)
手动操作 /sys/fs/cgroup 只适合学习和临时验证——重启就丢,而且容易和 systemd 冲突(systemd 是 cgroup 的默认管理者)。
# 临时启动一个受限的进程
systemd-run --scope -p CPUQuota=50% -p MemoryMax=512M -p IOReadBandwidthMax="/dev/nvme0n1 10M" \
stress-ng --cpu 4 --timeout 30
# 给已有服务加限制(第 14 篇的 drop-in)
systemctl set-property myapp.service CPUQuota=200% MemoryMax=4G
# 立即生效并持久化到 /etc/systemd/system.control/
# unit 文件里配置(推荐)
[Service]
CPUQuota=400% # = 4 核
CPUWeight=200 # 相对权重
MemoryMax=4G # 硬限制
MemoryHigh=3G # 软限制,超过后积极回收
IOWeight=100
IOReadBandwidthMax=/dev/nvme0n1 100M
TasksMax=4096 # 进程/线程数上限
# 查看服务的实际资源占用(按 cgroup 聚合)
systemd-cgtop
# Control Group Tasks %CPU Memory Input/s Output/s
# / 423 28.3 8.2G - -
# system.slice 198 22.1 6.1G 1.2M 45.3M
# system.slice/mysql 124 18.4 4.2G 890.0K 42.1M
systemd-cgls # cgroup 树
systemctl show myapp -p CPUQuotaPerSecUSec,MemoryMax
3.6 slice:资源分组
systemd 用 slice 组织 cgroup 层级,可以给一整类服务设限制:
-.slice 根
+-- system.slice 所有系统服务
| +-- mysql.service
| +-- nginx.service
+-- user.slice 所有用户会话
| +-- user-1000.slice
+-- machine.slice 虚拟机和容器
# 创建自定义 slice,把批处理任务都放进去统一限制
cat > /etc/systemd/system/batch.slice <<'EOF'
[Unit]
Description=Batch jobs slice
[Slice]
CPUQuota=200%
CPUWeight=50 # 权重低,争抢时让给在线服务
MemoryMax=8G
IOWeight=10 # IO 优先级低
EOF
systemctl daemon-reload
# 让服务归属这个 slice
[Service]
Slice=batch.slice
Nice=19
IOSchedulingClass=idle
这是第 18 篇实战案例里「在线服务与批处理隔离」的标准做法——所有批处理任务共享一份资源配额,无论跑多少个都不会挤占在线服务。
4. 手写一个迷你容器
把 namespace + cgroup + 文件系统隔离串起来,用纯 shell 实现一个「容器」。
#!/usr/bin/env bash
# minicontainer.sh —— 用 unshare + cgroup 实现的极简容器
set -euo pipefail
ROOTFS="${ROOTFS:-/tmp/minicontainer-rootfs}"
CG="/sys/fs/cgroup/minicontainer"
NAME="mini-$$"
# ---------- ① 准备根文件系统 ----------
prepare_rootfs() {
[[ -d "$ROOTFS/bin" ]] && return
echo "准备 rootfs..."
mkdir -p "$ROOTFS"
# 用 docker 导出一个现成的最小 rootfs
docker create --name "$NAME-export" alpine:latest >/dev/null
docker export "$NAME-export" | tar -x -C "$ROOTFS"
docker rm "$NAME-export" >/dev/null
}
# ---------- ② 创建 cgroup 限制 ----------
setup_cgroup() {
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
mkdir -p "$CG"
echo "50000 100000" > "$CG/cpu.max" # 0.5 核
echo "268435456" > "$CG/memory.max" # 256MB
echo "64" > "$CG/pids.max" # 最多 64 个进程
}
# ---------- ③ 在容器里执行的初始化 ----------
container_init() {
# 把自己加进 cgroup(此时已经在新 namespace 里)
echo $$ > "$CG/cgroup.procs"
hostname minicontainer # UTS namespace 生效
# 挂载必要的伪文件系统(MNT namespace 生效)
mount -t proc proc "$ROOTFS/proc"
mount -t sysfs sys "$ROOTFS/sys" 2>/dev/null || true
mount -t tmpfs tmpfs "$ROOTFS/tmp"
# 切换根目录并执行 shell
exec chroot "$ROOTFS" /bin/sh
}
# ---------- 主流程 ----------
main() {
[[ $EUID -eq 0 ]] || { echo "需要 root"; exit 1; }
prepare_rootfs
setup_cgroup
export ROOTFS CG
export -f container_init
echo "启动容器(CPU 0.5 核 / 内存 256MB / 最多 64 进程)"
# unshare 创建 5 种 namespace,然后执行初始化
unshare --pid --mount --uts --ipc --net --fork \
bash -c 'container_init'
# 清理
umount "$ROOTFS/proc" "$ROOTFS/tmp" 2>/dev/null || true
rmdir "$CG" 2>/dev/null || true
echo "容器已退出"
}
main "$@"
运行验证:
sudo ROOTFS=/tmp/mini bash minicontainer.sh
# 容器里的验证
/ # hostname
minicontainer <- UTS 隔离 ✅
/ # ps -ef
# PID USER COMMAND
# 1 root /bin/sh <- PID 隔离,自己是 PID 1 ✅
# 8 root ps -ef
/ # ip addr
# 1: lo: <LOOPBACK> mtu 65536 state DOWN <- NET 隔离,只有 lo ✅
/ # cat /proc/self/cgroup
# 0::/ <- cgroup 视图
/ # ls /
# bin dev etc home lib proc root sys tmp usr var <- 是 alpine 的 rootfs ✅
# 验证 CPU 限制
/ # yes > /dev/null &
/ # yes > /dev/null &
# 宿主机上看
top -bn2 | grep yes | tail -2
# 两个 yes 进程加起来只用 50% CPU ✅
# 验证内存限制
/ # dd if=/dev/zero of=/tmp/big bs=1M count=300
# Killed <- 超过 256MB 被 OOM killed ✅
# 宿主机上确认
cat /sys/fs/cgroup/minicontainer/memory.events
# oom_kill 1
这个脚本缺少的(也就是真实容器运行时额外做的事):
| 缺少的能力 | 真实容器怎么做 |
|---|---|
| 网络连通(现在只有 lo) | veth pair + 网桥 + iptables NAT(§2.4) |
用 pivot_root 而非 chroot |
防止 chroot 逃逸 |
| 镜像分层(OverlayFS) | 联合文件系统,实现层复用 |
| capabilities 裁剪 | capset() 丢弃不需要的特权 |
| seccomp 过滤系统调用 | 只允许必要的系统调用 |
| AppArmor / SELinux | 强制访问控制 |
| user namespace | uid 映射(§2.7) |
| init 进程职责 | tini/dumb-init 收养孤儿并 wait |
**但核心机制就是上面这些——namespace 隔离视图,cgroup 限制资源,chroot/pivot_root 换根。**理解到这一层,容器就不再是黑盒了。
5. 容器里的指标为什么失真
这是本篇最实用的一节,解释了容器化之后一大类「监控数据不对」的问题。
5.1 根本原因
/proc 和 /sys 的绝大部分内容【没有被 namespace 隔离】
v
容器里读 /proc/cpuinfo、/proc/meminfo、/proc/loadavg
v
拿到的是【宿主机】的数据
5.2 具体的失真项
| 容器里读 | 实际拿到 | 后果 |
|---|---|---|
/proc/cpuinfo、nproc |
宿主机核数 | JVM/Go 线程池开太大 |
/proc/meminfo、free |
宿主机内存 | JVM 堆按宿主机内存算,必然 OOM |
/proc/loadavg、uptime |
宿主机负载 | 监控告警完全失真 |
/proc/stat |
宿主机 CPU 时间 | top 显示的使用率不是容器的 |
/proc/diskstats |
宿主机磁盘 | iostat 数据不属于本容器 |
/proc/net/* |
容器自己的(NET namespace 隔离了) | ✅ 这个是准的 |
/proc/<pid>/* |
容器自己的(PID namespace 隔离了) | ✅ 准的 |
# 实测
docker run --rm --cpus=1 --memory=512m alpine sh -c '
echo "nproc: $(nproc)"
echo "MemTotal: $(grep MemTotal /proc/meminfo)"
echo "loadavg: $(cat /proc/loadavg)"
'
# nproc: 16 <- 宿主机 16 核,但只给了 1 核!
# MemTotal: MemTotal: 16283492 kB <- 宿主机 16GB,但只给了 512MB!
# loadavg: 12.34 8.21 4.11 ... <- 宿主机负载
5.3 正确的做法:读 cgroup
# ✅ CPU 限额(cgroup v2)
cat /sys/fs/cgroup/cpu.max
# 100000 100000 -> quota/period = 1 核
# ✅ 内存限额
cat /sys/fs/cgroup/memory.max
# 536870912 -> 512MB
cat /sys/fs/cgroup/memory.current
# 234881024 -> 当前用了 224MB
# ✅ CPU 使用与限流
cat /sys/fs/cgroup/cpu.stat
# usage_usec 8923401234
# nr_throttled 198
# throttled_usec 892340
# ✅ PSI —— 比 loadavg 有意义得多
cat /sys/fs/cgroup/cpu.pressure
# cgroup v1 的路径
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
5.4 语言运行时的适配
这是必须做的配置,不做就会有性能问题或 OOM:
# Java:JDK 10+ 默认开启,JDK 8u191+ 需要显式加
java -XX:+UseContainerSupport -XX:MaxRAMPercentage=70 -jar app.jar
# ^^^^^^^^^^^^^^^^^^^^^^ 让 JVM 读 cgroup 而不是 /proc/meminfo
# ^^^^^^^^^^^^^^^^^^^ 堆占容器限额的 70%,留 30% 给堆外
// Go:必须引入 automaxprocs,否则 GOMAXPROCS = 宿主机核数
import _ "go.uber.org/automaxprocs"
// 以及设置内存上限(Go 1.19+)
// GOMEMLIMIT=450MiB (容器限 512MB,留余量)
# Python:multiprocessing 的默认进程数也是错的
import os
# ❌ os.cpu_count() 返回宿主机核数
# ✅ 读 cgroup
def container_cpus():
try:
with open('/sys/fs/cgroup/cpu.max') as f:
quota, period = f.read().split()
if quota != 'max':
return max(1, int(int(quota) / int(period)))
except FileNotFoundError:
pass
return os.cpu_count()
Go 不加 automaxprocs 的后果(本篇 §6 的实战案例):16 核宿主机上 GOMAXPROCS=16,但 cgroup 只给 2 核——16 个 P 抢 2 核的配额,产生大量非自愿上下文切换和 CPU 限流,延迟暴涨而 CPU 使用率看起来不高。
5.5 让容器里的工具显示正确数据
# lxcfs:把 cgroup 数据伪装成 /proc 的内容
apt install lxcfs
systemctl enable --now lxcfs
docker run -it \
-v /var/lib/lxcfs/proc/cpuinfo:/proc/cpuinfo:ro \
-v /var/lib/lxcfs/proc/meminfo:/proc/meminfo:ro \
-v /var/lib/lxcfs/proc/stat:/proc/stat:ro \
-v /var/lib/lxcfs/proc/diskstats:/proc/diskstats:ro \
--cpus=1 --memory=512m alpine sh -c 'nproc; grep MemTotal /proc/meminfo'
# 1 ✅ 现在对了
# MemTotal: 524288 kB ✅
lxcfs 的价值:让容器里的 free、top、nproc 以及不感知 cgroup 的老应用都能拿到正确数据。代价是需要在宿主机部署 lxcfs 并挂载。K8s 里可以用 webhook 自动注入这些挂载。
规律:容器里的监控数据,CPU/内存/负载一律读 cgroup,网络和进程信息可以读 /proc。
6. 实战:容器 CPU 限流导致的延迟毛刺
现象:K8s 上的 Go 服务,P99 延迟每隔几秒就出现一次 200ms+ 的尖刺,但 CPU 使用率监控显示只有 40%。
① 确认是否被限流
kubectl exec -it mypod -- cat /sys/fs/cgroup/cpu.stat
# usage_usec 892340123
# user_usec 723401234
# system_usec 168938889
# nr_periods 89234
# nr_throttled 42318 <- 47% 的周期被限流了!
# throttled_usec 8923401234 <- 累计限流 8923 秒
nr_throttled / nr_periods = 47% —— 接近一半的调度周期里进程被强制暂停。这与「CPU 使用率 40%」并不矛盾:
容器 limits.cpu = 2(即每 100ms 周期给 200ms 配额)
应用有 16 个 P(GOMAXPROCS 取了宿主机核数)
v
16 个线程并行跑,在【前 12.5ms】就把 200ms 配额用完了
v
剩下的 87.5ms 全部被强制暂停(throttled)
v
平均下来 CPU 使用率 = 200ms/100ms/16核 ≈ 12.5%... 监控看着很低
但每个周期都有 87.5ms 的「完全停止服务」-> P99 尖刺
② 确认 GOMAXPROCS
kubectl exec -it mypod -- sh -c 'cat /proc/1/environ | tr "\0" "\n" | grep -i gomaxprocs'
# (空) <- 没设置
kubectl exec -it mypod -- nproc
# 16 <- 读到的是宿主机核数
kubectl get pod mypod -o jsonpath='{.spec.containers[0].resources}'
# {"limits":{"cpu":"2","memory":"2Gi"},"requests":{"cpu":"1","memory":"1Gi"}}
# ^^^ 只给 2 核,但 GOMAXPROCS=16
根因确认:GOMAXPROCS 与 cgroup 配额严重不匹配。
③ 修复:三个方案
// ✅ 方案一(推荐):引入 automaxprocs,自动读 cgroup 设置 GOMAXPROCS
import _ "go.uber.org/automaxprocs"
// 启动时会打印:maxprocs: Updating GOMAXPROCS=2: determined from CPU quota
# ✅ 方案二:用 Downward API 显式注入
env:
- name: GOMAXPROCS
valueFrom:
resourceFieldRef:
resource: limits.cpu # 自动取 limits.cpu 的值
divisor: "1"
# ✅ 方案三:放宽或去掉 CPU limits(延迟敏感服务推荐)
resources:
requests:
cpu: "2" # 保证 2 核
memory: 1Gi
limits:
# 不设 cpu limits,允许突发时借用空闲 CPU
memory: 2Gi # 内存必须设!
方案三需要解释:CPU 是可压缩资源(不够就变慢),内存是不可压缩资源(不够只能 OOM)。所以:
延迟敏感的在线服务:
requests.cpu 设准(保证基础份额,影响调度)
limits.cpu 设宽松或不设(允许突发,避免限流毛刺)
limits.memory 必须设(防止一个容器吃掉整个节点)
批处理 / 离线任务:
limits.cpu 要设(防止挤占在线服务)
配合低 CPUWeight 和 IOWeight
④ 验证
kubectl rollout restart deploy/myapp
sleep 60
kubectl exec -it mypod -- sh -c 'cat /proc/1/environ | tr "\0" "\n" | grep -i gomaxprocs'
# GOMAXPROCS=2 ✅
kubectl exec -it mypod -- cat /sys/fs/cgroup/cpu.stat | grep -E "nr_periods|nr_throttled"
# nr_periods 3421
# nr_throttled 12 <- 从 47% 降到 0.35% ✅
# PSI 也确认压力下降
kubectl exec -it mypod -- cat /sys/fs/cgroup/cpu.pressure
# some avg10=0.42 avg60=0.38 avg300=0.41
# ^^^^ 之前是 45+
结果:P99 从 240ms 降到 45ms,尖刺消失,CPU 使用率反而从 40% 降到 28%(少了限流带来的无效切换)。
规律:容器里 CPU 使用率不高但有周期性延迟尖刺 → 一定要查 cpu.stat 的 nr_throttled。根因通常是运行时的并发度(GOMAXPROCS / JVM 线程池 / worker 数)没有按 cgroup 配额设置。
7. 一些量级感
| 项 | 量级 |
|---|---|
| 容器启动时间 | 50 ~ 500 ms |
| 虚拟机启动时间 | 5 ~ 60 s |
| 容器内存开销(相比裸进程) | 几乎为 0 |
| 虚拟机内存开销 | 200 MB ~ 1 GB |
| 单机容器密度 | 数百 ~ 数千 |
| namespace 创建开销 | 微秒级 |
| cgroup 创建开销 | 微秒级 |
CFS 默认调度周期(cpu.max 的 period) |
100 ms |
健康的 nr_throttled / nr_periods |
< 1% |
8. 面试题
Q:容器和虚拟机的本质区别是什么?
虚拟机通过 Hypervisor 模拟硬件、运行完整的独立内核,隔离彻底但开销大(几百 MB 内存、秒级启动)。容器共享宿主机内核,本质上就是一个被 namespace 限制了视图、被 cgroup 限制了资源、被 chroot/pivot_root 换了根目录的普通 Linux 进程——在宿主机上 ps 能直接看到它。这带来的优势是启动毫秒级、内存开销近零、单机密度可达数千;局限是无法运行不同的内核(Linux 上跑不了 Windows 容器)、内核漏洞可能导致逃逸因此隔离性弱于虚拟机、以及大部分内核参数是全局的容器改不了。
Q:namespace 有哪几种?分别隔离什么?
八种:PID(进程 ID,容器里的主进程成为 PID 1)、NET(网络栈:网卡、IP、路由、iptables、端口空间)、MNT(挂载点)、UTS(hostname)、IPC(System V IPC 和 POSIX 消息队列)、USER(UID/GID 映射,Docker 默认不开)、CGROUP(cgroup 根目录视图)、TIME(时间偏移,5.6+)。判断两个进程是否在同一个 namespace,看 /proc/<pid>/ns/xxx 软链接指向的 inode 号是否相同。三个相关系统调用:clone() 创建进程时进入新 namespace、unshare() 让当前进程脱离原 namespace、setns() 加入已存在的 namespace——对应命令行工具 unshare 和 nsenter。
Q:容器镜像里没有 bash/curl/tcpdump,怎么排查它的网络问题?
用 nsenter 从宿主机进入容器的 namespace,但只进网络 namespace,这样文件系统还是宿主机的、宿主机的所有工具都能用:
PID=$(docker inspect -f '{{.State.Pid}}' mycontainer)
nsenter -t $PID -n ss -tlnp # 用宿主机的 ss 看容器的端口
nsenter -t $PID -n tcpdump -i eth0 # 在容器网络里抓包
nsenter -t $PID -n curl localhost/health
这是排查 distroless 容器网络问题的标准手法。K8s 1.23+ 也可以用 kubectl debug --image=nicolaka/netshoot --target=<container> 注入一个调试容器共享目标容器的 namespace。
Q:cgroup v1 和 v2 的主要区别?
v1 是每个控制器一个独立层级(/sys/fs/cgroup/cpu/、/memory/ 各一套),同一个进程在不同控制器下可以属于不同的 cgroup,导致「这组进程用了多少资源」难以回答;更实际的问题是内存和 IO 无法协同——脏页回写发生在内核线程里,v1 无法把这部分 IO 归属到产生它的 cgroup,所以 buffered write 的限流基本无效。v2 改为单一统一层级,一个进程只属于一个 cgroup,内存和 IO 能协同,并新增了 **PSI(Pressure Stall Information)**指标。v2 特有的坑是 cgroup.subtree_control:父 cgroup 必须先写入 +cpu +memory 把控制器授权给子级,否则子 cgroup 里根本不会出现 cpu.max 文件。
Q:PSI 是什么?比 load average 好在哪?
PSI(Pressure Stall Information)是 cgroup v2 提供的指标,直接量化因资源不足而损失的时间比例:some 表示至少一个任务因等待该资源而停滞的时间占比,full 表示所有任务都在等的时间占比。相比 load average 的优势在于语义明确——load 只是「可运行 + 不可中断的进程数」,无法区分「有 8 个进程但 CPU 完全够用」和「8 个进程在激烈争抢」;而 cpu.pressure 的 some avg10=12.34 明确告诉你最近 10 秒有 12.34% 的时间至少有一个任务在等 CPU。full > 0 是严重信号,意味着整个 cgroup 完全停滞过。这是判断是否需要扩容最准确的指标。
Q:cpu.max 和 cpu.weight 有什么区别?对应 K8s 的什么字段?
cpu.max(quota/period)是硬性上限——即使宿主机 CPU 完全空闲,也不会给超过配额的时间,超了就限流(nr_throttled 增长);对应 K8s 的 limits.cpu。cpu.weight 是相对权重,只在 CPU 争抢时决定份额分配,空闲时可以超用,不会限流;对应 requests.cpu。实践建议:CPU 是可压缩资源(不够就变慢),内存是不可压缩资源(不够只能 OOM),所以延迟敏感的在线服务应该 requests.cpu 设准、limits.cpu 设宽松或不设(避免限流毛刺),而 limits.memory 必须设(防止一个容器吃掉整个节点)。批处理任务反过来,必须设 limits.cpu 并配低 CPUWeight。
Q:为什么容器里 nproc 和 free 显示的是宿主机的数据?有什么后果?
因为 /proc 和 /sys 的绝大部分内容没有被 namespace 隔离——/proc/cpuinfo、/proc/meminfo、/proc/loadavg、/proc/stat、/proc/diskstats 读到的都是宿主机数据。只有 /proc/net/*(NET namespace 隔离)和 /proc/<pid>/*(PID namespace 隔离)是准的。后果很严重:JVM 按宿主机内存算堆大小必然 OOM、Go 的 GOMAXPROCS 取宿主机核数导致大量 CPU 限流、Python 的 multiprocessing 开太多进程、监控告警完全失真。正确做法是读 cgroup(/sys/fs/cgroup/cpu.max、memory.max),运行时层面用 -XX:+UseContainerSupport -XX:MaxRAMPercentage(Java)、go.uber.org/automaxprocs + GOMEMLIMIT(Go)。宿主机可以部署 lxcfs 把 cgroup 数据伪装成 /proc 内容,让不感知 cgroup 的老应用也能拿到正确值。
Q:容器 CPU 使用率只有 40%,但 P99 延迟有周期性尖刺,为什么?
典型的 CPU 限流(throttling)问题。查 /sys/fs/cgroup/cpu.stat 的 nr_throttled / nr_periods 比例——如果很高(比如 47%),说明进程在每个 100ms 调度周期里很快用完配额然后被强制暂停。机制是:容器 limits.cpu=2(每 100ms 给 200ms 配额),但应用有 16 个线程(GOMAXPROCS 取了宿主机核数),16 个线程并行在前 12.5ms 就把配额耗尽,剩下 87.5ms 完全停止服务 → P99 尖刺;而平均 CPU 使用率算下来却很低,所以监控看不出问题。根因是运行时并发度与 cgroup 配额不匹配,修复方式是引入 automaxprocs、用 Downward API 注入 GOMAXPROCS=limits.cpu、或者放宽 CPU limits。
Q:为什么 Docker 默认不开 user namespace?不开有什么风险?
不开的话容器内的 uid 0 就是宿主机的 uid 0(真正的 root),一旦通过内核漏洞或错误的挂载配置逃逸,攻击者直接获得宿主机 root。开启后容器内 root 映射到宿主机的一个无特权用户(如 uid 100000),逃逸后也只是普通用户。Docker 默认不开的原因:① 挂载卷的权限处理变复杂(宿主机文件属主要是映射后的 uid);② 部分功能不兼容(--privileged、某些存储驱动);③ 镜像层需要 chown 占额外磁盘。更常用的替代方案是不开 user namespace 但容器内不用 root 运行:K8s 里配 runAsNonRoot: true + runAsUser: 1000 + capabilities.drop: ["ALL"] + readOnlyRootFilesystem: true——收益/成本比远好于开 user namespace,应该作为默认配置。
Q:怎么用 shell 手写一个「容器」?
三步:① namespace 隔离视图——unshare --pid --mount --uts --ipc --net --fork 创建新的各类 namespace,注意 PID namespace 必须配 --fork 和重新挂载 /proc(否则 ps 读的还是宿主机的);② cgroup 限制资源——mkdir /sys/fs/cgroup/mytest,先在父级写 +cpu +memory 到 cgroup.subtree_control,再写 cpu.max、memory.max、pids.max,最后把 PID 写进 cgroup.procs;③ 换根——chroot 到一个 rootfs(可以用 docker export 导出 alpine 的)。真实容器运行时额外做的是:veth pair + 网桥 + NAT 提供网络、用 pivot_root 而非 chroot(防逃逸)、OverlayFS 实现镜像分层、裁剪 capabilities、seccomp 过滤系统调用、以及用 tini 履行 PID 1 的 init 职责。
上一篇:Linux-18 性能分析工具链与方法论 | 下一篇:Linux-20 线上问题排查实战案例
xingliuhua