容器的本质:namespace、cgroup 与 overlayfs,没有一个新东西
容器不是"轻量级虚拟机",它就是一个被限制了视野、限制了资源、换了根目录的普通进程。
理解这句话,容器里 90% 的怪现象都能自己解释:为什么 JVM 看到的内存不对、为什么 PID 1 不回收僵尸、为什么
free显示宿主机内存。
结论先说
| 你以为的 | 实际是 |
|---|---|
| 容器是一台小虚拟机 | 宿主机上的一个普通进程(ps 在宿主机上能直接看到) |
| 容器有自己的内核 | 共享宿主机内核,所以内核版本/参数是全局的 |
| 容器有独立内存 | cgroup 限制的是"能用多少",/proc/meminfo 看到的仍是宿主机 |
| 镜像是一层层叠加的文件 | overlayfs 的联合挂载,写时复制(copy-up) |
| 容器里 root 很安全 | 默认就是宿主机的 root(除非开 user namespace / rootless) |
一、容器 = 进程 + 三个内核特性
┌──────────────────────────────────────────────────┐
│ 宿主机内核(唯一) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 容器 A 进程 │ │ 容器 B 进程 │ │ 容器 C 进程 │ │
│ │ │ │ │ │ │ │
│ │ namespace │ │ namespace │ │ namespace │ │ ← 看到的世界不同
│ │ cgroup │ │ cgroup │ │ cgroup │ │ ← 能用多少资源不同
│ │ overlayfs │ │ overlayfs │ │ overlayfs │ │ ← 根文件系统不同
│ └────────────┘ └────────────┘ └────────────┘ │
└──────────────────────────────────────────────────┘
创建容器的那一刻,runc 做的事情非常朴素:
clone()时带上各种CLONE_NEW*标志 → 获得新的 namespace;- 把进程加入一个新的 cgroup → 限制资源;
pivot_root()或chroot()到挂载好的 overlayfs → 换根目录;- 设置 capabilities / seccomp → 削减权限;
execve()启动容器里的 1 号进程。
没有虚拟化、没有 hypervisor、没有第二个内核。 所以容器启动是毫秒级,而虚拟机启动是秒级。
在宿主机上验证:
$ docker run -d --name demo nginx
$ ps -ef | grep nginx
root 12345 ... nginx: master process nginx -g daemon off;
这就是一个普通进程,和你的 Java 进程地位完全一样。
二、namespace:让进程"看不见"
Linux 一共有 8 种 namespace:
| namespace | 隔离了什么 | 系统调用标志 |
|---|---|---|
| mount (mnt) | 挂载点、文件系统视图 | CLONE_NEWNS |
| pid | 进程 ID 空间 | CLONE_NEWPID |
| net | 网络设备、IP、端口、路由表、iptables | CLONE_NEWNET |
| uts | 主机名、域名 | CLONE_NEWUTS |
| ipc | System V IPC、POSIX 消息队列 | CLONE_NEWIPC |
| user | UID/GID 映射 | CLONE_NEWUSER |
| cgroup | cgroup 根目录视图 | CLONE_NEWCGROUP |
| time | 时钟偏移(CLOCK_MONOTONIC) | CLONE_NEWTIME |
2.1 PID namespace:容器里的 1 号进程
# 宿主机上
$ ps -ef | grep java
root 8888 ... java -jar app.jar
# 容器内
$ docker exec demo ps -ef
PID 1 java -jar app.jar
同一个进程,宿主机上 PID 是 8888,容器内是 1。PID namespace 做了两层映射,所以容器内的 kill -9 1 杀的是容器的主进程,而不是宿主机的 init。
2.2 隔离不彻底的地方(重点)
这是最容易踩坑的部分——namespace 只隔离了"名字空间",没隔离"资源总量"。
| 你以为隔离了 | 实际 |
|---|---|
| /proc/meminfo | ❌ 显示宿主机总量。容器内 free -h 看到 64 G,但 limit 可能只有 2 G |
| /proc/cpuinfo | ❌ 显示宿主机所有核 |
| /proc/loadavg | ❌ 宿主机负载,不是容器负载 |
| 内核参数(sysctl) | ❌ 大部分是全局的,容器内改了影响宿主机(部分 net 参数除外) |
| 时间 | ❌ 全局,容器内改时间会影响宿主机 |
| /proc/sys/kernel/* | ❌ 只读挂载,改不了 |
所以监控容器时,绝对不能看容器内的 free、loadavg、top 的 CPU 百分比,必须看 cgroup 的指标。
# 正确的容器内存用量(cgroup v2)
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/memory.max
# 正确的容器 CPU 用量
cat /sys/fs/cgroup/cpu.stat
2.3 用工具观察 namespace
# 列出所有 namespace
lsns
# 看某个进程属于哪些 namespace
ls -l /proc/8888/ns/
# lrwxrwxrwx ... mnt -> mnt:[4026531840]
# lrwxrwxrwx ... pid -> pid:[4026532276]
# lrwxrwxrwx ... net -> net:[4026532279]
# 进入某个进程的 namespace(docker exec 的原理)
nsenter -t 8888 -n -p -m
docker exec 本质上就是 setns() 到目标容器的一组 namespace,然后 execve 一个新进程。它不是"登录到容器里",而是"在宿主机上开一个进程,让它看到和容器一样的世界"。
三、cgroup:限制"能用多少"
namespace 管"看得见什么",cgroup 管"能用多少"。
3.1 v1 与 v2
| | cgroup v1 | cgroup v2 |
|---|---|---|
| 层级 | 每个控制器一棵树(cpu、memory、io 各自独立) | 单一层级树,所有控制器统一挂载 |
| 挂载点 | /sys/fs/cgroup/{cpu,memory,io,...} | /sys/fs/cgroup/unified 或 /sys/fs/cgroup |
| 进程约束 | 一个进程可属于不同控制器的不同组 | 一个进程只能属于一个组 |
| 现状 | 老发行版(CentOS 7) | 主流默认(Ubuntu 22.04+、K8s 1.25+ 推荐) |
判断当前系统:
stat -fc %T /sys/fs/cgroup
# cgroup2fs → v2
# tmpfs → v1
3.2 常用控制器与文件
| 控制器 | cgroup v2 文件 | 含义 |
|---|---|---|
| cpu | cpu.max | "50000 100000" = 每 100 ms 最多用 50 ms CPU = 0.5 核 |
| | cpu.weight | 相对权重(1~10000),不是绝对值 |
| | cpu.stat | nr_throttled、throttled_usec(被限流的次数与时长) |
| memory | memory.max | 硬限制,超过触发 cgroup OOM Killer |
| | memory.high | 软限制,超过会被"减速"(throttle),不杀进程 |
| | memory.current | 当前用量(含 page cache) |
| | memory.events | oom、oom_kill、high 的累计次数 |
| io | io.max | "8:16 rbps=104857600 wbps=..." 按设备限速 |
| | io.stat | 实际 IO 量 |
| pids | pids.max | 进程/线程数上限,防 fork 炸弹 |
3.3 CPU 限流导致的"假抖动"
cpu.max 的语义是配额,不是核数。当一个容器用了 0.5 核的配额,却启动了 4 个线程跑满:
100 ms 周期内,4 个线程并行跑了 12.5 ms 就把 50 ms 配额用光
→ 剩余 87.5 ms 内所有线程被强制挂起(throttled)
→ 表现为"周期性卡顿",P99 暴涨
# 看是否被限流
cat /sys/fs/cgroup/cpu.stat
# nr_periods 12000
# nr_throttled 8000 ← 67% 的周期被限流
# throttled_usec 480000000
只要 nr_throttled / nr_periods > 10%,就说明 CPU 配额不够或者线程开太多。这个现象在 Java 服务里极常见:容器限 1 核,JVM 默认开一堆 GC/编译线程,互相抢配额。
3.4 容器 OOM 与 JVM
cgroup 的 OOM 和系统 OOM 是两回事:
| | 触发条件 | killer |
|---|---|---|
| 系统 OOM | 整机内存不足 | 内核 oom-killer,可能杀任何进程 |
| cgroup OOM | 容器超过 memory.max | 只在这个 cgroup 内选进程杀 |
# 看容器是否发生过 OOM
cat /sys/fs/cgroup/memory.events
# oom 3
# oom_kill 3 ← 被杀过 3 次
Java 容器最常见的 OOM 原因:JVM 不知道有 cgroup 限制。
- JDK 8 早期:
Runtime.maxMemory()读宿主机物理内存 → 堆开到 1/4 物理内存 → 容器直接 OOM; - JDK 8u191+ / JDK 10+:
-XX:+UseContainerSupport默认开启,读 cgroup 限制; - JDK 8u131~8u190 需要手动加
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap。
即使开了容器感知,也要注意 堆 ≠ 容器内存:
容器 limit 2 G
├─ JVM 堆(-Xmx) 1.2 G
├─ Metaspace 150 M
├─ 线程栈(200 线程×1M) 200 M
├─ 直接内存(Netty) 200 M
├─ 代码缓存 50 M
└─ page cache / 其他 剩余
堆设成 1.8 G + 直接内存 300 M = 超限。建议 -XX:MaxRAMPercentage=60~70,并显式限制 -XX:MaxDirectMemorySize。
四、overlayfs:镜像分层与写时复制
4.1 挂载结构
lowerdir(只读层,镜像的每一层,可多层)
layer3 ← 最上层的镜像层
layer2
layer1 ← 基础镜像
↓
upperdir(可写层,容器自己的改动)
↓
merged(联合挂载后,容器看到的根目录)
$ mount | grep overlay
overlay on /var/lib/docker/overlay2/xxx/merged type overlay
(rw,relatime,lowerdir=/var/lib/docker/overlay2/l/A:/var/lib/docker/overlay2/l/B,
upperdir=/var/lib/docker/overlay2/xxx/diff,
workdir=/var/lib/docker/overlay2/xxx/work)
4.2 copy-up:写时复制的实际代价
读一个文件:直接从 lowerdir 读(多个层按优先级找)。
写一个已经在 lowerdir 里的文件:先把整个文件复制到 upperdir,再修改。
修改 /etc/nginx/nginx.conf(原文件 4 KB)
→ 复制 4 KB 到 upperdir,然后改
→ 代价:一次读 + 一次写
修改 /var/lib/mysql/ibdata1(原文件 12 GB)
→ 复制 12 GB!
→ 容器直接卡死,磁盘打满
这就是"数据库不能跑在容器里"的根本原因之一——不是容器不好,是 overlayfs 的 copy-up 语义不适合大文件随机写。正确做法是把数据目录挂成 volume(bind mount 或独立块设备),绕过 overlay。
docker run -v /data/mysql:/var/lib/mysql mysql:8
4.3 镜像层数越多越慢
每一层都是一次联合挂载的目录。层数太多时:
- 查找文件要逐层比对(虽然有索引,但仍有开销);
- 层数上限(overlay2 默认 128 层)。
所以 Dockerfile 要合并 RUN 指令:
# ❌ 三层,每层都留一份中间文件
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# ✅ 一层完成,删除也发生在同一层
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
注意:
RUN rm在另一层删除文件,并不能减小镜像体积——文件还在下层里,只是被"遮盖"了。要减小体积,删除必须和创建在同一层完成。
五、PID 1 问题:容器里的 init 为什么重要
容器里的 1 号进程有两个特殊职责:
- 接收并转发信号;
- 回收孤儿进程(reap zombies)。
如果你直接 java -jar app.jar 作为 PID 1:
| 问题 | 表现 |
|---|---|
| SIGTERM 不处理 | docker stop 等 10 秒后 SIGKILL,优雅停机失效 |
| 不回收僵尸 | 容器内 ps 一堆 <defunct>,进程数涨到 pids.max 后无法 fork |
| 子进程信号收不到 | shell 脚本启动的进程收不到停止信号 |
解决办法:
# 方案一:加 --init(docker run --init / compose init: true),使用 tini
# 方案二:Dockerfile 里显式用 tini
ENTRYPOINT ["/sbin/tini", "--", "java", "-jar", "/app.jar"]
# 方案三:用 exec 形式(不是 shell 形式),让 Java 直接成为 PID 1 并自己处理信号
ENTRYPOINT ["java", "-jar", "/app.jar"] # exec 形式,Java 是 PID 1
CMD java -jar /app.jar # shell 形式,PID 1 是 /bin/sh,信号传不到 java
Spring Boot 2.3+ 内置了优雅停机,但前提是它能收到 SIGTERM:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
如果 PID 1 是 shell,信号根本到不了 JVM,优雅停机的配置等于没写。
六、安全:容器里的 root 就是宿主机的 root
默认情况下,容器内的 uid 0 映射到宿主机的 uid 0。如果容器被攻破且挂载了宿主机目录,攻击者就是宿主机 root。
三层加固:
| 手段 | 作用 | 配置 |
|---|---|---|
| 非 root 用户运行 | 降低权限 | USER 1000 in Dockerfile |
| user namespace | uid 映射错位 | rootless Docker / --userns-remap |
| capabilities 削减 | 去掉不必要的能力 | --cap-drop=ALL --cap-add=NET_BIND_SERVICE |
| seccomp | 限制系统调用 | 默认 profile 已禁用 ~44 个危险调用 |
| 只读根文件系统 | 防写入 | --read-only --tmpfs /tmp |
# K8s 的 securityContext 是标准做法
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
七、一张命令对照表
| 目的 | 命令 |
|---|---|
| 看容器进程在宿主机的 PID | docker inspect -f '{{.State.Pid}}' <name> |
| 进入容器的 namespace | nsenter -t <pid> -n -p -m |
| 看容器 cgroup 路径 | cat /proc/<pid>/cgroup |
| 看内存限制与用量 | cat /sys/fs/cgroup/memory.max / memory.current |
| 看是否被 CPU 限流 | cat /sys/fs/cgroup/cpu.stat |
| 看是否 OOM 过 | cat /sys/fs/cgroup/memory.events |
| 看容器文件系统层 | docker inspect -f '{{json .GraphDriver}}' <name> |
| 手动创建 namespace 实验 | unshare --pid --net --mount --fork bash |
一个有用的实验,能让你彻底理解"容器只是进程":
# 在一个新的 pid/net/mount namespace 里起 bash,不装 Docker
$ sudo unshare --pid --net --mount --uts --ipc --fork --mount-proc bash
# 此时你在"容器"里:ps 只看到自己,网络只有 lo
$ ps -ef
UID PID PPID CMD
root 1 0 bash
八、小结
容器不是魔法,是三个老内核特性的组合:
| 特性 | 解决的问题 | 副作用 |
|---|---|---|
| namespace | 看得见什么 | 只看名字空间,资源用量仍全局 |
| cgroup | 能用多少 | 配额而非核数,限流导致抖动 |
| overlayfs | 文件从哪来 | copy-up 让大文件写入爆炸 |
理解了这三条,就能自己解释:为什么 free 不准、为什么 JVM 要 UseContainerSupport、为什么数据库要挂 volume、为什么 docker stop 要等 10 秒、为什么 nr_throttled 会让 P99 飙高。
系列内链
- 前置:[12-Docker与K8s/01-Docker镜像分层与Dockerfile最佳实践](01-Docker镜像分层与Dockerfile最佳实践.md)
- 关联:[12-Docker与K8s/02-Docker网络模式详解](02-Docker网络模式详解.md)——net namespace 的具体用法
- 关联:[09-Linux基础/04-进程管理与信号](../09-Linux基础/04-进程管理与信号.md)——PID 1 与信号处理
- 关联:[03-JVM/08-JVM调优参数与实战](../03-JVM/08-JVM调优参数与实战.md)——容器内 JVM 的内存计算
- 点赞
- 收藏
- 关注作者
评论(0)