楼层: 首页/ 软件技术/ Linux/ 内核调优与容器底层:Docker 其实是四件套
16

内核调优与容器底层:Docker 其实是四件套

sysctl · ulimit · namespaces · cgroups

这一章把前面所有"看不见的地基"挖开给你看。你会搞清楚三件事:内核参数到底哪些值得调、哪些是网上抄来的毒药;"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 到那个文件,效果完全一样。

为什么需要它:内核的默认值是为"通用场景"设的,不为你这台机器的角色设。一台承载十万并发连接的网关服务器,和一台跑定时任务的批处理机,需要的参数完全不同。但绝大多数参数这辈子都不该动——这才是需要记住的第一条纪律。

# ── 看 ── sysctl -a | grep -i somaxconn # 查某个参数 sysctl net.ipv4.tcp_tw_reuse # 直接读 cat /proc/sys/net/core/somaxconn # 等价写法 # ── 临时改(重启失效,调优试手时用) ── sudo sysctl -w net.core.somaxconn=65535 # ── 永久改:写进 /etc/sysctl.d/ 下的独立文件,再统一加载 ── sudo tee /etc/sysctl.d/99-tune-app.conf > /dev/null <<'EOF' # 高并发 Web 服务常用的一组(按需取用,不要照抄全抄) net.core.somaxconn = 65535 net.ipv4.tcp_max_syn_backlog = 65535 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.ipv4.ip_local_port_range = 10000 65000 fs.file-max = 1000000 vm.max_map_count = 262144 EOF sudo sysctl --system # 重新加载所有 /etc/sysctl.d/*.conf sysctl -n net.core.somaxconn # 验证真的生效了
几个"常见且真的有用"的参数:知道它们解决什么问题,比背数字重要
参数解决什么问题 / 注意什么
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 对大项目不够)。
坑:从网上抄一整段 sysctl"性能优化脚本"

那些脚本里常见三类问题参数:① 已经不存在了(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,所以高并发服务上线第一天晚上就会炸。

# ── 先看清"到底是多少",别猜 ── ulimit -n # 当前 shell 的软限制(新登录会话默认常是 1024) ulimit -Hn # 硬限制(软限制不能超过它) ulimit -a # 一次看全部限制 # 看"某个正在跑的进程"真实生效的值 -- 这是唯一可信的答案 sudo cat /proc/$(pidof nginx | awk '{print $1}')/limits | grep -i 'open files' # ── 永久配置(针对登录会话:sshd 拉起的一切) ── sudo tee /etc/security/limits.d/99-app.conf > /dev/null <<'EOF' * soft nofile 65535 * hard nofile 65535 * soft nproc 65535 * hard nproc 65535 root soft nofile 65535 root hard nofile 65535 EOF # 注意:* 不匹配 root,要单独写一行;改完必须重新登录(或重连 SSH)才生效 # ── 针对 systemd 服务:limits.conf 管不到它,必须在 unit 里写 ── sudo systemctl edit myapp.service # [Service] # LimitNOFILE=65535 sudo systemctl daemon-reload && sudo systemctl restart myapp
三层限制,取最小值生效:少改一层,就会得到"我明明改了怎么还不行"
层级说明
内核天花板
fs.nr_open
整个系统单个进程能设的硬上限(默认常为 1048576)。ulimit -Hn 不能超过它。
进程组限制
LimitNOFILE
由"谁启动了进程"决定:systemd 服务看 unit 里的 LimitNOFILE(或 /etc/systemd/system.conf 的 DefaultLimitNOFILE),登录会话看 limits.conf。
进程自身设置有些程序会自己再调一次(nginx 的 worker_rlimit_nofile)。最终生效的是这几者中的最小值,所以任何一层忘了改,都白改。
坑:改了 /etc/security/limits.conf,重启服务还是报错

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:一张表记住"容器为什么长这样"
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。
ipcSystem V IPC、POSIX 消息队列、共享内存两个容器默认不能通过共享内存通信(--ipc=host 可以共享,但要谨慎)。unshare -i。
userUID / GID 映射容器里的 root 映射到宿主机上一个普通用户——这是 rootless 容器安全性的根基。unshare --user --map-root-user。
cgroupcgroup 视图容器只能看到自己那一支 cgroup,不会看到宿主机的其他组。unshare -C。
time单调时钟、启动时间(内核 5.6+)让容器能改自己的时钟偏移(time_namespace),少数需要调整时间的场景才用。unshare -T。
# ── 看:我到底在哪些命名空间里 ── ls -l /proc/self/ns/ # 每一项后面的数字就是 namespace 的 inode id # net -> 'net:[4026531840]' 两个进程的 net inode 相同 = 同一个网络命名空间 sudo lsns # 列出系统上所有 namespace 及其进程 readlink /proc/1234/ns/net # 查某个进程在哪个 netns sudo ip netns list # 查看用 ip netns 创建的网络命名空间 # ── 进:容器排障的万能入口 nsenter ── PID=$(docker inspect -f '{{.State.Pid}}' my-container) sudo nsenter -t $PID -n ss -tulnp # 在容器的网络视角下看监听端口 sudo nsenter -t $PID -n tcpdump -i any -nn port 8080 # 在容器网络里抓包 sudo nsenter -t $PID -m -p -n -- bash # 直接进到容器的环境里(含文件系统) # ── 造:手工建一个 network namespace ── sudo ip netns add vnet1 sudo ip netns exec vnet1 ip link set lo up sudo ip netns exec vnet1 ip addr # 里面只有一张 lo
坑:nsenter 不是"进入容器",而是"借它的视角"

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 这几个文件里。

cgroup v2 关键接口文件:v2 是单树结构,所有控制器挂在同一棵树
文件含义与实战用法
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 里需要额外支持)。
# ── 我在哪个 cgroup 里? ── cat /proc/self/cgroup # v2 形如 0::/user.slice/user-1000.slice/session-2.scope # ── 看/管理 cgroup 的树与消耗 ── systemd-cgls # 树状列出所有 cgroup systemd-cgtop # 按 cgroup 看资源消耗排行(像 top,但是按组) cat /sys/fs/cgroup/memory.max # 根组的限制(一般写 max,即不限制) # ── 最省事的限流方式:用 systemd 起一个受限的进程 ── sudo systemd-run --scope --unit=memtest -p MemoryMax=100M -p MemorySwapMax=0 \ stress-ng --vm 1 --vm-bytes 200M --timeout 20s # 事后确认"是不是被 cgroup 掐死的"(这一步就是排查 OOMKilled 的标准动作) sudo cat /sys/fs/cgroup/system.slice/memtest.scope/memory.events # max 1 / oom 1 / oom_kill 1 → 三个计数都动了,结论明确:超了 MemoryMax,被组内 OOM 杀掉 dmesg -T | grep -i 'oom\|killed' | tail -5 # 内核日志里也有对应记录 # ── 限 CPU:给一个进程 25% 的 CPU(四分之一核) ── sudo systemd-run --scope -p CPUQuota=25% stress-ng --cpu 4 --timeout 10s # 另开一个终端 top,会看到它稳定被压在 25% 左右
坑:容器里的 Java 把宿主机内存当成自己的

老版本 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 看它"以为"自己有多少内存。

坑:在容器里用 free / top 看内存,被宿主机数字骗了

容器不隔离 /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)

# --user --map-root-user : 里面是"假 root",映射到外层普通用户 # --pid --fork : 新 PID 命名空间,并且进程作为里面的 PID 1 # --mount-proc : 自动挂一个和上面匹配的 /proc(不挂的话 ps 会看到外面) sudo unshare --user --map-root-user --pid --net --mount --uts --ipc --cgroup \ --fork --mount-proc bash # 进来的这一刻,你已经在"半个容器"里了: hostname mybox # uts 隔离:改主机名不影响到外面 ip link set lo up # net 隔离:新网络命名空间里只有 lo,而且是 down 的,必须自己拉起来 ip addr # 只有一张 lo,没有 eth0 ps aux # pid 隔离:只看到自己,且自己是 PID 1 ls -l /proc/self/ns/ # 每个 namespace 的 inode 都和外面不同 id # user 隔离:里面是 root,外面还是你自己 exit # 退出后一切恢复,宿主机毫无变化 # 再叠加 cgroup 限制,就得到"完整"的容器体验: sudo systemd-run --scope -p MemoryMax=100M -p CPUQuota=50% -- \ unshare --pid --net --mount --uts --fork --mount-proc bash

对照看:docker inspect 出来的东西,全都在上面这张拼图里

$ docker inspect -f '{{.State.Pid}}' my-container 28461 $ sudo readlink /proc/28461/ns/{pid,net,mnt,uts} pid:[4026533240] # 独立的 pid namespace net:[4026533243] # 独立的 net namespace mnt:[4026533237] # 独立的挂载视图(rootfs 在这) uts:[4026533238] # 独立的主机名 $ cat /sys/fs/cgroup/system.slice/docker-*.scope/memory.max 1073741824 # 1GiB -- 这就是 docker run -m 1g 落到内核里的样子
坑:cgroup v1 和 v2 的字段名不一样,网上教程经常混着写

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 模式),这也是为什么不同容器看到的路径可能完全不同。

坑:把 ulimit、sysctl、cgroup 当成"同一类东西"

这三者的作用域完全不同,混了就会查错方向: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。