容器不是轻量虚拟机
给容器限了 0.5 个 CPU、256MB 内存,进到容器里一看:
nproc是 6,/proc/meminfo说有 15,970MB。三个不同的镜像里,uname -r输出的是同一个内核版本。容器里的 root,在宿主机上也是 uid 0。容器只是宿主机上的一个进程,只不过被几种机制分别限制了「能看到什么」「能用多少」「文件放在哪」。
把容器当成轻量虚拟机,会在几个地方出错:以为镜像里带了操作系统内核,以为容器里的工具看到的就是限额,以为容器里的 root 是隔离的,以为容器停下来时应用一定会收到通知。这些都在上 Kubernetes 之后变成线上问题:Pod 被 OOM 杀掉、应用线程数按整台机器配置、滚动发布时请求被中断。
本文用 Docker(Docker Desktop,Linux 虚拟机内核 7.0.12,cgroup v2)逐项验证容器的几条边界,见文末配套实验。Kubernetes 里的 Pod 用的是同一套内核机制。
一、镜像里没有内核
在 alpine、busybox、基于 Ubuntu 的 temurin 三个镜像里执行 uname -r,结果都是 7.0.12-linuxkit,也就是运行它们的那台 Linux 机器的内核。镜像里只有用户态的文件:库、工具、应用;系统调用最终都由宿主机内核处理。
这决定了两件事:
- 内核相关的行为跟着宿主机走。 某个系统调用是否可用、内核参数怎么设、cgroup 是 v1 还是 v2,都取决于节点,而不是镜像。「镜像在本地能跑,到另一批节点上不行」,常见原因之一就是节点内核或内核参数不同。
- 隔离强度不如虚拟机。 所有容器共享同一个内核,内核漏洞会影响同一节点上的所有容器。需要更强隔离的多租户场景,要用虚拟机级别的方案,而不是只靠容器。
二、namespace 隔离的是「看到什么」
在宿主机上比较容器进程和宿主机 1 号进程的 namespace:
| namespace | 容器与宿主机 | 隔离的是 |
|---|---|---|
| pid | 不同 | 进程号:容器里的 sleep 是 1 号进程 |
| net | 不同 | 网卡、IP、端口、路由表 |
| mnt | 不同 | 挂载点,也就是看到的文件系统 |
| uts | 不同 | 主机名 |
| ipc | 不同 | 共享内存、信号量 |
| cgroup | 不同 | 看到的 cgroup 层级 |
| user | 相同 | 用户与权限 |
user namespace 默认没有隔离。实测容器进程在宿主机上的 uid 就是 0:容器里的 root 就是宿主机的 root。一旦进程通过挂载的目录、特权设置或内核漏洞碰到容器外面的东西,它拥有的是 root 权限。所以容器里的应用应该用普通用户运行:镜像里用 USER 指定,Kubernetes 里用 securityContext.runAsNonRoot: true 强制。
三、cgroup 限制的是「用多少」,但很多工具看不见
--memory 256m --cpus 0.5 这类限额,写在容器所在 cgroup 的文件里:
/sys/fs/cgroup/memory.max = 268435456
/sys/fs/cgroup/cpu.max = 50000 100000 # 每 100ms 最多用 50ms CPU但容器里的进程看到的资源,取决于它从哪里读:
| 限额 | JVM 看到的 CPU 数 | JVM 默认最大堆 |
|---|---|---|
| 256MB、0.5 CPU | 1 | 121MB |
| 1GB、2 CPU | 2 | 247MB |
| 不限额 | 6 | 3,994MB |
nproc、/proc/meminfo 这类工具读的是内核的全局信息,看到的是整台机器。按它们来配置线程数、缓存大小的程序,会在容器里开出远超限额的线程和内存。
JVM 会读 cgroup,CPU 数和默认堆都按限额算。默认最大堆取内存限额的 1/4(MaxRAMPercentage=25),但内存较小时改用 MinRAMPercentage(默认 50%),所以 256MB 的限额下最大堆是 121MB,1GB 时是 247MB。生产环境不要依赖这两个默认值,用 -XX:MaxRAMPercentage 明确指定,给堆外内存、线程栈和元空间留出余量,见 JVM 调优与线上排查。
超过内存限额时,内核直接杀掉进程。实测 64MB 限额下不断申请内存,容器退出码 137(被 SIGKILL 杀掉),Docker 记录 OOMKilled=true;Kubernetes 里对应 Pod 状态里的 OOMKilled,它和被 kubelet 驱逐(Evicted)的区别见 requests 与 limits 各管什么。这和 Java 的 OutOfMemoryError 是两回事:后者是堆满了,JVM 还活着;前者是整个进程的内存超过了 cgroup 限额,JVM 连打印日志的机会都没有。
CPU 限额则不会杀进程,只会让进程在用完配额后被暂停到下一个周期,表现为延迟变长。
四、文件:可写层和卷
容器的根文件系统由只读的镜像层加一层可写层组成。在容器里写 /tmp/in-layer.txt,docker diff 显示它落在可写层;删除容器后再起一个新容器,这个文件就不存在了。同时写进卷的文件,在容器删除后还在。
所以容器里需要保留的数据只能写到卷里。可写层适合放临时文件,而且它占用的是节点的磁盘:日志、临时文件无限增长,会把节点的磁盘写满。
五、PID 1 与优雅退出
停止容器时,运行时先给容器里的 1 号进程发 SIGTERM,等一段时间还没退出再发 SIGKILL。Linux 对 1 号进程有一条特殊规则:它没有显式处理的信号会被忽略。实测 docker stop -t 5(等 5 秒):
| 1 号进程 | 停止耗时 | 退出码 |
|---|---|---|
sleep | 5.2 秒 | 137(被 SIGKILL) |
--init 启动,tini 作为 1 号进程转发信号 | 0.1 秒 | 143(响应 SIGTERM) |
| Java 程序 | 0.1 秒 | 143(响应 SIGTERM) |
sleep 作为 1 号进程收不到 SIGTERM,只能等到超时被强杀。换成 Shell 脚本启动应用时也一样:如果脚本是 1 号进程而没有转发信号,应用就收不到 SIGTERM,关闭钩子不会执行,正在处理的请求被直接中断。
Java 程序直接作为 1 号进程没有这个问题,JVM 自己注册了 SIGTERM 的处理。要点是让 JVM 成为 1 号进程:Dockerfile 用 ENTRYPOINT ["java", ...] 这种 exec 形式,或者在启动脚本最后用 exec java ...;不得不用脚本或多个进程时,用 tini 这类 init 进程转发信号。Kubernetes 删除 Pod 时同样先发 SIGTERM,默认等 30 秒(terminationGracePeriodSeconds),超时后发 SIGKILL。滚动发布时,旧 Pod 从 Service 摘除与收到 SIGTERM 同时发生,这段时间的请求怎么处理,见 Running 不等于可用。
六、上线前检查
- 应用用非 root 用户运行,Kubernetes 里加
runAsNonRoot: true。 - 按限额配置资源:Java 服务显式设置
-XX:MaxRAMPercentage,线程池、连接池不要按nproc或宿主机内存推算。 - 区分两种内存问题:
OutOfMemoryError是堆满了;退出码 137 并且OOMKilled=true,是进程整体超过了限额。 - 需要保留的数据写到卷里;可写层只放临时文件,并限制日志大小。
- 让应用成为 1 号进程,或者用 init 进程转发信号;确认停止时关闭钩子会执行。
配套实验
- codesphere-labs/kubernetes/container-boundaries:三个镜像的内核版本、7 种 namespace 与容器进程的 uid、cgroup v2 限额与
nproc、/proc/meminfo、JVM 看到的资源、OOM、可写层与卷、三种 1 号进程的停止耗时(验证记录)
参考资料