目录

Linux-19 namespace 与 cgroup

前置阅读:Linux-06 进程管理与作业控制Linux-10 内存管理

容器不是虚拟机,它就是一个普通的 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

chrootpivot_root 的区别:chroot 只是改变进程的根目录视图,老的根文件系统仍然挂载着、理论上可以逃逸出去(著名的 chroot 逃逸技巧);pivot_root 会把老的根卸载掉,无法回退。所以容器运行时用 pivot_root

2.6 nsenter:进入容器排查问题

**这是本篇最实用的一个工具。**当容器镜像是 distroless、里面连 bashcurlss 都没有时,你在宿主机上用 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_throttledthrottled_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/cpuinfonproc 宿主机核数 JVM/Go 线程池开太大
/proc/meminfofree 宿主机内存 JVM 堆按宿主机内存算,必然 OOM
/proc/loadavguptime 宿主机负载 监控告警完全失真
/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 的价值:让容器里的 freetopnproc 以及不感知 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.statnr_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——对应命令行工具 unsharensenter

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.pressuresome avg10=12.34 明确告诉你最近 10 秒有 12.34% 的时间至少有一个任务在等 CPU。full > 0 是严重信号,意味着整个 cgroup 完全停滞过。这是判断是否需要扩容最准确的指标。

Q:cpu.maxcpu.weight 有什么区别?对应 K8s 的什么字段?

cpu.max(quota/period)是硬性上限——即使宿主机 CPU 完全空闲,也不会给超过配额的时间,超了就限流nr_throttled 增长);对应 K8s 的 limits.cpucpu.weight相对权重,只在 CPU 争抢时决定份额分配,空闲时可以超用,不会限流;对应 requests.cpu实践建议:CPU 是可压缩资源(不够就变慢),内存是不可压缩资源(不够只能 OOM),所以延迟敏感的在线服务应该 requests.cpu 设准、limits.cpu 设宽松或不设(避免限流毛刺),而 limits.memory 必须设(防止一个容器吃掉整个节点)。批处理任务反过来,必须设 limits.cpu 并配低 CPUWeight

Q:为什么容器里 nprocfree 显示的是宿主机的数据?有什么后果?

因为 /proc/sys 的绝大部分内容没有被 namespace 隔离——/proc/cpuinfo/proc/meminfo/proc/loadavg/proc/stat/proc/diskstats 读到的都是宿主机数据。只有 /proc/net/*(NET namespace 隔离)和 /proc/<pid>/*(PID namespace 隔离)是准的。后果很严重:JVM 按宿主机内存算堆大小必然 OOMGo 的 GOMAXPROCS 取宿主机核数导致大量 CPU 限流、Python 的 multiprocessing 开太多进程、监控告警完全失真。正确做法是读 cgroup/sys/fs/cgroup/cpu.maxmemory.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.statnr_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 +memorycgroup.subtree_control,再写 cpu.maxmemory.maxpids.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 线上问题排查实战案例