本文根据一次真实的容器服务性能排障整理。

一个原本几秒即可完成的轻量命令,在容器化后 Python 2 服务中每次都需要约 0.6s。问题不在 CPU、内存或命令本身,而是 containerd 设置的百万级 nofile 被容器继承,触发了 Python 2 的 Popen(close_fds=True) 性能问题:

1
2
3
4
containerd:LimitNOFILE=1048576
→ 容器中的 Python 2 服务继承该限制
→ 每次 Popen 增加约 0.5 秒固定开销
→ 大量串行命令累积为分钟级延迟

问题现象与影响

一次业务任务从开始到完成耗时约 243 秒:

1
2
3
14:40:17 开始
14:44:21 完成
总计约 243 秒

服务在此期间串行执行了约 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
2
[Service]
LimitNOFILE=1048576

容器没有覆盖该值,因此形成完整的继承链:

1
2
3
4
5
systemd
└─ containerd
└─ containerd-shim
└─ 容器进程
└─ Python 2 服务

这不是 Kubernetes 的 CPU、内存 resources.limits,而是 Linux 进程级资源限制。

现场证据:最小实验闭合因果链

宿主机服务与 Pod 服务的限制不同:

1
2
宿主机服务:Max open files =   65535
Pod 服务: Max open files = 1048576

使用相同 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
2
3
4
/usr/bin/time -p /bin/true

/usr/bin/python -m timeit -n 1 -r 5 \
'import subprocess; subprocess.Popen("true", close_fds=True).communicate()'

最后检查 containerd 的限制来源:

1
2
3
4
systemctl show containerd \
-p LimitNOFILE \
-p FragmentPath \
-p DropInPaths

如果 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
2
3
4
prlimit --nofile=65535:65535 -- \
/usr/bin/python \
/path/to/service.py \
-c /path/to/service.yaml

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,也未完成修复后的完整业务回归。因此,当前结论不能表述为已经完成生产修复。