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

一个原本几秒即可完成的轻量命令,在容器化后 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 条外部命令。grepcatmkdirpssed 等轻量命令普遍耗时 0.62~0.66s,真正有工作负载的命令反而没有明显异常:

操作 耗时
文件系统调整 6.141s
启动一个外部进程 1.578s
一次外部资源操作 0.681s
grepcat 等轻量命令 多数为 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 仍可能处理从 31048576 的巨大范围。

现场继续追溯发现,宿主机的 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=1048576close_fds=True 581ms
nofile=1048576close_fds=False 1.71ms
临时限制 nofile=65535close_fds=True 38.5ms

nofile 临时调整为 65535 后,在保持 close_fds=True 的情况下,Popen581ms 降到 38.5ms,性能提升约 15 倍。

按 250 次外部命令估算,百万级 nofile 带来的额外耗时约为:

1
250 × (581ms - 38.5ms) ≈ 136 秒

该结果与现场多出的两分多钟基本吻合。

同时,Pod 的资源数据排除了常见竞争假设:

检查项 结果
CPU Limit 4 核
CPU Throttling nr_throttled=0throttled_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,也未完成修复后的完整业务回归。因此,当前结论不能表述为已经完成生产修复。