内核调优与容器底层:Docker 其实是四件套
这一章把前面所有"看不见的地基"挖开给你看。你会搞清楚三件事:内核参数到底哪些值得调、哪些是网上抄来的毒药;"Too many open files" 为什么改了配置还是报;以及最重要的——容器不是虚拟机,它就是 Linux 的 namespaces + cgroups + 一个 rootfs 拼出来的。看懂这一章,K8s 里那些 resources.limits、OOMKilled、CrashLoopBackOff 就都有了解释。
sysctl:内核的开关面板,但别乱按
是什么:sysctl 是读写内核运行时参数的接口。每个参数对应 /proc/sys/ 下的一个文件,比如 net.core.somaxconn 就是 /proc/sys/net/core/somaxconn。用 sysctl 命令和直接 echo 到那个文件,效果完全一样。
为什么需要它:内核的默认值是为"通用场景"设的,不为你这台机器的角色设。一台承载十万并发连接的网关服务器,和一台跑定时任务的批处理机,需要的参数完全不同。但绝大多数参数这辈子都不该动——这才是需要记住的第一条纪律。
| 参数 | 解决什么问题 / 注意什么 |
|---|---|
net.core.somaxconn | 监听队列的最大长度。高并发下"连接被重置"常是它太小。但要同时改应用的 backlog(如 nginx 的 listen 80 backlog=4096;),两边取小值,只改内核没用。 |
net.ipv4.tcp_max_syn_backlog | 半连接(SYN 队列)上限。抗 SYN 洪水的第一道缓冲。注意别和 tcp_syncookies 一起盲目调。 |
net.ipv4.ip_local_port_range | 本机作为客户端时可用端口范围。短连接高并发服务端口耗尽时扩它(如 10000 65000)。 |
net.ipv4.tcp_tw_reuse | 允许把 TIME-WAIT 的套接字复用于出站连接,缓解端口耗尽。默认 2(仅对 loopback 生效),生产常设 1。注意 tcp_tw_recycle 已在 4.12 被彻底删除,网上教程里的那一条现在会直接报错。 |
vm.swappiness | 换页倾向(0~100)。设 0 不等于"禁用 swap",内存紧张时内核仍可能换页;想真禁用要 swapoff。数据库服务器常设 1~10。 |
vm.overcommit_memory | 内存分配策略。Redis 官方要求设 1(允许 overcommit),因为 fork 出子进程做持久化时要申请一大块虚拟内存。别把它当成"解决内存不足的万能药"。 |
vm.max_map_count | 一个进程能拥有的内存映射区数量。Elasticsearch 必须调到 262144,否则启动直接失败。日志里会明确提示这一条,照抄即可。 |
fs.file-max | 系统级最大文件句柄数。它只是上限,真正决定你服务能不能开更多连接的是进程级的 ulimit(下一节)。 |
fs.inotify.max_user_watches | 文件监听数量上限。前端开发中 VSCode / webpack 报 "ENOSPC: System limit for number of file watchers reached" 就是它太小(默认 8192 对大项目不够)。 |
那些脚本里常见三类问题参数:① 已经不存在了(tcp_tw_recycle、tcp_low_latency),写了会报 cannot stat;② 危害大于收益(把 overcommit_memory 设 1、panic_on_oom 随便开,会让内存分配失败从"进程报错"变成"整机崩");③ 只改了内核没改应用(somaxconn 改了、nginx backlog 没改)。正确姿势:先记录原值(sysctl -a > /tmp/before.txt),只改你能解释清楚的那一条,改完压测验证。
ulimit:为什么改了配置还是 "Too many open files"
是什么:ulimit 是"单个进程能用的资源上限"(RLIMIT),最常用的是 nofile(最多打开多少文件/套接字),此外还有 nproc(进程数)、core(core dump 大小)、memlock(锁定内存,Redis/ES 会要)等。每个进程各有一份,不是全局的。
为什么会报错:因为 Linux 里"一切皆文件"——每个网络连接也是一个文件句柄。一个 Java 服务开一万个并发连接,就需要一万个以上的句柄。默认软限制常是 1024,所以高并发服务上线第一天晚上就会炸。
| 层级 | 说明 |
|---|---|
内核天花板fs.nr_open | 整个系统单个进程能设的硬上限(默认常为 1048576)。ulimit -Hn 不能超过它。 |
进程组限制LimitNOFILE | 由"谁启动了进程"决定:systemd 服务看 unit 里的 LimitNOFILE(或 /etc/systemd/system.conf 的 DefaultLimitNOFILE),登录会话看 limits.conf。 |
| 进程自身设置 | 有些程序会自己再调一次(nginx 的 worker_rlimit_nofile)。最终生效的是这几者中的最小值,所以任何一层忘了改,都白改。 |
99% 的情况是:你改的是"登录会话"的限制,而你的服务是 systemd 启动的。systemd 在系统启动时就确定了自己的限制,它不会去读 limits.conf(那个文件由 PAM 在用户登录时读取,而 systemd 服务根本不走 PAM 登录流程)。正确做法只有两个:① 在 service 单元里加 LimitNOFILE=65535(推荐,可审计、跟着文件走);② 在 /etc/systemd/system.conf 里设 DefaultLimitNOFILE 然后 systemctl daemon-reexec。另一种更隐蔽的坑:systemctl restart 不够——如果限制写在 system.conf 里,必须 daemon-reexec 让 systemd 重新读取自己的配置。
namespaces:容器那道"看不见的墙"
是什么:namespace 是内核对全局资源做的"视图隔离"——同一个内核、同一批硬件,但不同命名空间里的进程看到的世界不一样。它负责"看不见",不负责"用不了那么多"。
为什么必须理解:因为几乎所有"容器里奇怪的现象"都是由此而来:容器里 ps 只看到几个进程、容器里 hostname 和宿主机不同、容器里 ifconfig 只有一张网卡、容器里的 top 显示的却是宿主机的总内存。知道每个 namespace 管什么,你就知道该用 nsenter 进哪一层去看。
| namespace | 隔离什么 | 容器里的表现 / 常用命令 |
|---|---|---|
| mnt | 挂载点视图 | 容器有自己的根文件系统(rootfs),看不到宿主机的挂载。unshare -m;Docker 的镜像分层就挂在这里。 |
| pid | 进程号空间 | 容器里自己的主进程是 PID 1,看不到宿主机进程,也杀不动外面的进程。unshare -pf --mount-proc。 |
| net | 网卡、IP、端口、路由表、iptables | 容器有自己的 eth0 和端口空间,所以两个容器都能监听 80。unshare -n;ip netns。 |
| uts | 主机名与域名 | 容器里 hostnamectl set-hostname 不影响宿主机。unshare -u。 |
| ipc | System V IPC、POSIX 消息队列、共享内存 | 两个容器默认不能通过共享内存通信(--ipc=host 可以共享,但要谨慎)。unshare -i。 |
| user | UID / GID 映射 | 容器里的 root 映射到宿主机上一个普通用户——这是 rootless 容器安全性的根基。unshare --user --map-root-user。 |
| cgroup | cgroup 视图 | 容器只能看到自己那一支 cgroup,不会看到宿主机的其他组。unshare -C。 |
| time | 单调时钟、启动时间(内核 5.6+) | 让容器能改自己的时钟偏移(time_namespace),少数需要调整时间的场景才用。unshare -T。 |
nsenter -t PID -n ss 只是借用目标进程的网络命名空间执行命令,你仍在宿主机的文件系统里。要完整进到容器环境,必须同时带上 -m -p -n -u -i(或者直接用 docker exec),因为只带 -p 会进不去真正的 PID 1(PID 命名空间里你不是它的父进程)。另外两个提醒:① 新建的 netns 里 lo 是 down 的,必须自己 ip link set lo up,否则里面连 127.0.0.1 都 ping 不通;② namespace 本身不是安全边界——历史上所有容器逃逸都是绕过 namespace + capability + seccomp 的组合,光靠 namespace 隔离不了恶意代码。
cgroups v2:容器那道"限流的闸"
是什么:cgroup(control group)把进程分组,然后对组限制和统计 CPU、内存、IO、进程数。namespace 负责"看不见",cgroup 负责"用不了那么多"——两者合起来才是完整的容器隔离。
为什么必须懂:K8s 里的 resources.limits、Docker 的 --memory --cpus,最终都翻译成 cgroup 字段。你遇到的所有 OOMKilled、容器被限流变慢、top 显示内存和 limit 对不上,答案都在 /sys/fs/cgroup 这几个文件里。
| 文件 | 含义与实战用法 |
|---|---|
cpu.max | 形如 200000 100000,表示每 100ms 周期内最多用 200ms 的 CPU——也就是 2 个核。这是硬上限,超了就等下一周期,表现为"服务莫名其妙变慢"。 |
cpu.weight | 相对权重(1~10000),只在 CPU 争抢时决定分配比例,不是上限。K8s 的 requests 走这里,limits 走 cpu.max。 |
memory.max | 硬上限:超过就触发 cgroup 内的 OOM killer,进程被直接杀掉(K8s 显示 OOMKilled)。 |
memory.high | 软上限:超过会先被限速并触发回收,但一般不杀进程。比 max 更温和,适合想"压一压但不要猝死"的场景。 |
memory.current | 当前用量。容器里判断"还剩多少内存"就该看它和 memory.max,而不是 free。 |
memory.events | 计数器:max、oom、oom_kill 各发生了几次。排查 OOM 必看:只要 oom_kill 非 0,就说明真是被 cgroup 掐死的。 |
pids.max | 组内最多多少个进程/线程。设小了会让 JVM 启动失败(线程数超限),报 "unable to create native thread"。 |
io.max | 块设备的 IOPS / 带宽上限(K8s 里需要额外支持)。 |
老版本 JVM(JDK 8u191 之前)不会读 cgroup 的内存限制,它读到的是宿主机的总内存,于是默认堆大小按宿主机的 1/4 来算——给容器限 1G,JVM 却开了 8G 的堆,结果是容器一启动就被 OOMKilled,日志里连一句像样的错误都没有。现代 JDK(8u191+ / 11+ / 17+ / 21)默认开启 UseContainerSupport,会自动识别 cgroup 限制,但要确认两件事:① cgroup v2 需要 JDK 11.0.16+ / 17.0.4+ 才能正确识别;② 显式写了 -Xmx 就会覆盖自动计算。排查时永远用 java -XX:+PrintFlagsFinal -version | grep -i maxheapsize 看它"以为"自己有多少内存。
容器不隔离 /proc/meminfo(因为不是 namespace 管辖的),所以 free -m 显示的是宿主机内存。在容器里判断"我的内存上限是多少、还差多少",唯一正确的是读 cgroup:cat /sys/fs/cgroup/memory.max 和 memory.current。同理 top 里的 CPU 百分比也是宿主机视角——要确认是否被限流,得看 cpu.max 和 cpu.stat 里的 nr_throttled。
把四件套拼起来:容器到底是什么
拼容器 = rootfs + namespaces + cgroups + 安全约束
① 一块 rootfs(文件系统)——就是镜像解出来的目录树,让容器"看起来像一台独立的机器"。它由 mnt namespace 挂成容器自己的 /。
② 一组 namespaces——让容器看不见外面的进程、网络、主机名(pid / net / uts / ipc / user / mnt)。
③ 一组 cgroup 限制——让容器用不了超过配额的内存、CPU、IO、进程数。
④ 一组安全约束——capabilities(丢掉大部分 root 特权)、seccomp(限制能调用的系统调用)、SELinux/AppArmor。
结论:Docker、containerd、Podman、K8s 都只是"把这四件套组装起来,再给它起个名字"的工具。所以容器启动是毫秒级的(不用装操作系统、不用启内核),也因此容器共用宿主机内核——这就是 Linux 上跑不了 Windows 容器的根本原因,也是"内核漏洞 = 逃逸风险"的由来。
动手实验:用内核原语手搓一个"什么都没有的容器"(需要 root)
对照看:docker inspect 出来的东西,全都在上面这张拼图里
v1(老系统/老 RHEL)里 CPU 配额叫 cpu.cfs_quota_us + cpu.cfs_period_us,内存在 memory.limit_in_bytes;v2(统一树)里分别是 cpu.max 和 memory.max。混用会得到"文件不存在"。先确认本机跑的是哪一代:stat -fc %T /sys/fs/cgroup——输出 cgroup2fs 就是 v2,tmpfs 就是 v1。另外,同一台机器上 v1 和 v2 可以混合挂载(hybrid 模式),这也是为什么不同容器看到的路径可能完全不同。
这三者的作用域完全不同,混了就会查错方向:sysctl = 整台机器(所有进程共享);ulimit = 单个进程(由父进程/登录会话/systemd 单元决定);cgroup = 一组进程(由容器/服务单元决定)。所以:"连接数不够"可能是三者中任意一个太小;而"整机 TIME-WAIT 太多"只能是 sysctl 层面的问题。先确定作用域,再去改对应的那一层。
① sysctl 少即是多:只改你能解释清楚的那几个(somaxconn、vm.max_map_count、fs.inotify.max_user_watches),改前先存原值,网上抄来的一整段优化脚本要逐条核对(tcp_tw_recycle 早就没了)。
② "Too many open files" 要先搞清三层限制(fs.nr_open → LimitNOFILE/limits.conf → 进程自身设置),systemd 服务不读 limits.conf——这是最高频的踩坑点。
③ 容器 = rootfs + namespaces + cgroups + 安全约束:namespace 管"看不见",cgroup 管"用不了那么多"。容器里查内存看 memory.max 而不是 free,查 OOM 看 memory.events 的 oom_kill,排障进容器用 nsenter -t <PID> -n。