本文根据一次真实的容器服务性能排障整理。
一个原本几秒即可完成的轻量命令,在容器化后 Python 2 服务中每次都需要约 0.6s。问题不在 CPU、内存或命令本身,而是 containerd 设置的百万级 nofile 被容器继承,触发了 Python 2 的 Popen(close_fds=True) 性能问题:
1 | containerd:LimitNOFILE=1048576 |
问题现象与影响
一次业务任务从开始到完成耗时约 243 秒:
1 | 14:40:17 开始 |
服务在此期间串行执行了约 255 条外部命令。grep、cat、mkdir、ps、sed 等轻量命令普遍耗时 0.62~0.66s,真正有工作负载的命令反而没有明显异常:
| 操作 | 耗时 |
|---|---|
| 文件系统调整 | 6.141s |
| 启动一个外部进程 | 1.578s |
| 一次外部资源操作 | 0.681s |
grep、cat 等轻量命令 |
多数为 0.62~0.66s |
不同轻量命令具有相近的固定耗时,说明瓶颈不在命令本身,而在它们共享的进程启动路径。
根因:百万级 nofile 遇到 Python 2 close_fds
服务通过 Python 2 启动外部命令:
1 | subprocess.Popen(..., close_fds=True) |
close_fds=True 表示创建子进程时,关闭不应被继承的文件描述符(FD),避免数据库连接、Socket、Pipe 等资源泄漏给子进程。这个安全语义不应被直接取消。
问题在于,Python 2 的旧实现会根据 RLIMIT_NOFILE 上限处理 FD。即使服务实际只打开约 127 个 FD,只要上限为 1048576,每次 Popen 仍可能处理从 3 到 1048576 的巨大范围。
现场继续追溯发现,宿主机的 containerd systemd Unit 显式设置了:
1 | [Service] |
容器没有覆盖该值,因此形成完整的继承链:
1 | systemd |
这不是 Kubernetes 的 CPU、内存 resources.limits,而是 Linux 进程级资源限制。
现场证据:最小实验闭合因果链
宿主机服务与 Pod 服务的限制不同:
1 | 宿主机服务:Max open files = 65535 |
使用相同 Python 2 运行最小 Popen 测试,结果如下:
| 条件 | 单次耗时 |
|---|---|
| Pod 内直接执行 Bash | 0.00s |
nofile=1048576,close_fds=True |
581ms |
nofile=1048576,close_fds=False |
1.71ms |
临时限制 nofile=65535,close_fds=True |
38.5ms |
将 nofile 临时调整为 65535 后,在保持 close_fds=True 的情况下,Popen 从 581ms 降到 38.5ms,性能提升约 15 倍。
按 250 次外部命令估算,百万级 nofile 带来的额外耗时约为:
1 | 250 × (581ms - 38.5ms) ≈ 136 秒 |
该结果与现场多出的两分多钟基本吻合。
同时,Pod 的资源数据排除了常见竞争假设:
| 检查项 | 结果 |
|---|---|
| CPU Limit | 4 核 |
| CPU Throttling | nr_throttled=0、throttled_time=0 |
| 内存使用 | 约 122Mi / 8Gi |
| 内存触限 | failcnt=0 |
| 容器重启 | 0 次 |
因此,问题不是 Pod 缺少 CPU 或内存,也不是容器内所有进程启动都慢,而是 Python 2、close_fds=True 与百万级 nofile 共同触发的固定开销。
如何快速定位同类问题
首先读取真实服务进程的限制,不要只查看当前 Shell 的 ulimit:
1 | cat /proc/<service-pid>/limits | grep 'Max open files' |
再对比直接执行与 Python 2 的 Popen:
1 | /usr/bin/time -p /bin/true |
最后检查 containerd 的限制来源:
1 | systemctl show containerd \ |
如果 containerd、shim、容器入口进程和服务进程的 /proc/<pid>/limits 数值一致,即可确认继承关系。
修复方案
长期修复:跟随 containerd 2.0 官方 Unit
containerd 2.0 官方文档明确说明,参考 systemd Unit 已移除显式 LimitNOFILE,因为 containerd 的限制会被容器继承。
长期应评估升级到 containerd 2.0,或在当前版本中参考官方 Unit,移除 containerd.service 及其 Drop-In 中显式设置的:
1 | LimitNOFILE=1048576 |
让 containerd 使用经过评估的 systemd 默认限制,避免百万级 nofile 继续传递给所有容器。
该变更影响节点上的全部容器,不能直接全量修改。应先在测试节点验证 FD 容量、容器继承值和运行时稳定性,再通过维护窗口逐节点发布,并保留原 Unit 作为回滚配置。
短期止血:仅限制受影响的服务
无法立即升级 containerd 或调整全局 Unit 时,可以只调整受影响服务入口进程的 RLIMIT_NOFILE:
1 | prlimit --nofile=65535:65535 -- \ |
65535 不是适用于所有服务的固定答案。修改前应统计单并发和峰值并发下的实际 FD 数,并保留足够余量。
不建议采用以下方案:
- 设置
close_fds=False:可能向子进程泄漏数据库连接、Socket 等资源; - 未做容量评估、灰度和回滚准备就修改所有节点的 containerd Unit;
- 只增加 CPU 或内存:无法消除按 FD 上限处理的固定开销。
同时应推进 Python 运行时升级,减少对 Python 2 旧子进程实现的依赖。
验收矩阵
| 场景 | 验收标准 |
|---|---|
| 进程限制 | containerd 与应用服务的 soft/hard nofile 符合目标设计 |
| 继承关系 | 新建容器不再继承未经评估的百万级 nofile |
| 微基准 | Popen(close_fds=True) 从约 581ms 降至几十毫秒 |
| FD 容量 | 峰值 FD 数显著低于新上限 |
| 单业务任务 | 相同输入、相同节点的总耗时明显下降 |
| 并发任务 | 无新的排队、锁竞争或超时 |
| 错误监控 | 无 Too many open files |
| 业务结果 | 任务结果和终态符合预期 |
| 运行时 | containerd 与现有容器运行稳定 |
| 回滚 | 可恢复原 systemd Unit 和服务启动配置 |
本次现场已验证临时 prlimit 可以将 Popen 降至约 38.5ms,确认了根因和短期修复方向;尚未升级 containerd、修改永久 Unit,也未完成修复后的完整业务回归。因此,当前结论不能表述为已经完成生产修复。