容器的本质:namespace、cgroup 与 overlayfs,没有一个新东西

举报
yd_254019812 发表于 2026/11/09 09:44:16 2026/11/09
【摘要】 容器不是"轻量级虚拟机",它就是一个被限制了视野、限制了资源、换了根目录的普通进程。 理解这句话,容器里 90% 的怪现象都能自己解释:为什么 JVM 看到的内存不对、为什么 PID 1 不回收僵尸、为什么 free 显示宿主机内存。 结论先说 | 你以为的 | 实际是 | |---|---| | 容器是一台小虚拟机 | 宿主机上的一个普通进程(ps 在宿主机上能直接看到) | | 容器有自...

容器不是"轻量级虚拟机",它就是一个被限制了视野、限制了资源、换了根目录的普通进程。

理解这句话,容器里 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 做的事情非常朴素:

  1. clone() 时带上各种 CLONE_NEW* 标志 → 获得新的 namespace;
  2. 把进程加入一个新的 cgroup → 限制资源;
  3. pivot_root() 或 chroot() 到挂载好的 overlayfs → 换根目录;
  4. 设置 capabilities / seccomp → 削减权限;
  5. 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 号进程有两个特殊职责:

  1. 接收并转发信号;
  2. 回收孤儿进程(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 的内存计算
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。