旧 Docker 环境下 Python 线程创建失败的排障实录
一次常规的 Python 服务部署,容器反复重启,日志里不断出现 RuntimeError: can't start new thread。按照直觉排查了内存、PID 限制、线程栈大小,都没有发现问题。最终的根因是旧版本 Docker 的默认 seccomp 规则与 Python 3.11 / Debian Bookworm 运行时之间存在兼容性断层。这篇文章复盘了完整的排查过程,重点分享了如何通过最小复现实验锁定问题边界,以及如何区分“启动慢”和“服务崩溃”这两个极易混淆的现象。
一次常规的服务部署,容器反复重启。日志里报了一个看着很眼熟的错:
RuntimeError: can't start new thread第一反应是内存不足或者线程数超了。查了一圈发现都不是。最后花了将近半天时间,才把根因定位到 Docker 默认的 seccomp 规则上。
现象:容器在跑,但端口不通
服务由两个容器组成:一个提供远程浏览器能力(基于 CDP),另一个是 Python Web 服务,负责接口和任务调度。
部署方式比较传统:构建机打好镜像,推到仓库,目标服务器拉取镜像后用 docker run 启动。
浏览器容器先报错:
chrome_crashpad_handler: Operation not permitted给浏览器加了 --security-opt seccomp=unconfined 之后,Chrome 能正常启动了。但 Python 应用还是起不来。
现象很迷惑:
docker ps能看到容器状态是Up,健康检查显示starting- 访问健康检查接口返回
Connection reset by peer - 日志里能看到 Web 服务启动成功的打印,但过一会儿又出现新一轮启动日志
- 同时反复出现
can't start new thread
这里有一个容易忽略的细节:应用启动时会执行数据库结构初始化,这个过程可能持续几十秒。所以刚创建容器就去探测端口,得到连接重置并不能说明服务已经崩溃——可能只是还没启动完。
但线程创建失败的日志是实实在在的。即使服务最终完成了启动,只要依赖线程处理请求,就一定会出问题。
直觉三板斧:OOM、PID 限制、线程栈
先看 OOM。这是最直接的怀疑方向,内存不够导致进程被杀,容器重启。
docker inspect app-container | grep -E '"OOMKilled"|"RestartCount"|"ExitCode"'OOMKilled=false。排除。
再看系统线程数和 PID 上限:
ps -eLf | wc -l
cat /proc/sys/kernel/threads-max
cat /proc/sys/kernel/pid_max
ulimit -u当前线程数远低于系统上限,ulimit -u 也没到顶,Docker 的 PidsLimit 是 0(不限制)。
线程数没耗尽。
有人建议我试试调小 Python 的线程栈:
threading.stack_size(512 * 1024)试了一下,同样失败。排除。
三板斧砍完,问题还在。这时候如果继续在业务代码里找原因,可能会往数据库连接池、异步任务并发数这些方向跑偏。我决定换一个思路。
关键一步:最小复现
我直接在一个干净的容器里跑 Python,排除所有业务逻辑的影响:
docker run --rm \
--entrypoint python \
example/app:stable \
-c 'import threading; t=threading.Thread(target=lambda: None); t.start(); t.join(); print("thread-ok")'结果依然是:
RuntimeError: can't start new thread这就很清楚了。问题跟业务代码、数据库、连接池都没有关系,而是出在“容器运行时 + 基础镜像”这个边界上。
然后我做了第二个实验:同样的镜像,加上 --security-opt seccomp=unconfined 再跑一次。
docker run --rm \
--security-opt seccomp=unconfined \
--entrypoint python \
example/app:stable \
-c 'import threading; t=threading.Thread(target=lambda: None); t.start(); t.join(); print("thread-ok")'输出:
thread-ok到这里,证据链基本闭环了。不是内存问题,不是 PID 限制,是 seccomp。
根因:旧 seccomp profile 不认新的系统调用路径
Docker 默认会应用一个 seccomp profile,限制容器内进程可以调用的系统调用,目的是降低容器逃逸的风险。
问题在于,容器内的基础镜像是比较新的——Python 3.11 跑在 Debian Bookworm 上,glibc 版本也新。新版本的运行时在创建线程时,可能会走一些较新的内核接口或者不同的调用路径。而旧版本 Docker 内置的 seccomp profile 没有同步更新这些规则,导致内核直接拒绝了线程创建相关的系统调用。
Python 不会把“seccomp 拒绝了系统调用”这个底层错误透传出来,而是包装成了一个看起来很普通的异常:
RuntimeError: can't start new thread浏览器那边的 Operation not permitted 也是同一个根因的不同表现。这也是为什么修好了浏览器,Python 还是会挂——两个容器共用同一个有兼容性问题的运行时环境。
止血:让 seccomp 显式放行
在无法立即升级 Docker 的情况下,最务实的方案是为受影响的容器单独放行 seccomp 限制:
--security-opt seccomp=unconfined不要把这个参数写死在人工命令里。更好的做法是放在部署脚本中,由脚本根据环境读取配置后拼接到 docker run 参数中。
部署脚本从项目目录的 .env 读取 SECCOMP_PROFILE 变量,启动时传入对应容器。修改 .env 后需要重新执行部署脚本重建容器——只改文件不重建,当前运行中的容器配置不会生效。
验证:看 docker inspect 而不是看日志
修复之后,一定要确认配置真正生效了:
docker inspect app-container --format '{{json .HostConfig.SecurityOpt}}'如果输出是 null,说明 seccomp 配置没有传进去,这时候看应用日志没有任何意义,回去检查部署脚本。
预期输出是:
["seccomp=unconfined"]同时确认:
- 健康检查接口正常返回
- 容器不再重启
can't start new thread不再出现
一个容易被放大的干扰项:启动慢
最后提一个这次排查中遇到的“噪声”。
由于应用启动时要做数据库初始化,首次启动可能需要 60 秒甚至更长。如果健康检查的 start-period 只设了 10 秒,那在这段时间里健康检查会持续失败,表现为端口不通、连接重置。
这个现象和“服务崩溃”看起来非常像,但性质完全不同。一种是进程还在、还没准备好;另一种是进程已经死了。
我的建议是:把“进程是否退出”和“健康检查是否通过”分开观察。 前者看 docker ps 的 STATUS 和重启次数,后者单独测试健康接口。混在一起看容易误判。
对于启动慢的服务,适当调大 start-period 可以避免大量无意义的告警:
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
CMD curl -f http://localhost:8232/health || exit 1几个可复用的思路
这次排查有几个经验我觉得可以复用:
1. 最小复现实验做早一点。 在复杂服务上反复猜,不如花 5 分钟写一行最小代码跑一下。业务逻辑越复杂,越容易把问题归因到业务配置上。
2. 配置文件不等于运行时参数。 .env 改了,但 docker inspect 看到 SecurityOpt 是 null,说明脚本没读到或者没传进去。以 docker inspect 为准。
3. seccomp 兼容性是“构建新 + 运行旧”场景下的典型隐患。 构建机用新 Docker 打镜像,生产服务器跑旧 Docker,默认安全策略和新的用户态组件之间容易产生断层。这类问题在基础设施升级滞后的环境中会反复出现,值得纳入发布前的预检项。
4. 短期兼容和长期治理分开。 seccomp=unconfined 能快速恢复业务,但会扩大攻击面,不应该作为长期默认配置。长期来看还是要升级 Docker Engine,让 seccomp profile 跟上基础镜像的演进。
这次故障让我重新意识到一件事:容器化并没有抹平运行时的差异,反而把“构建环境很新、生产环境很旧”的错配放大了。类似的问题还会再出现——不是在 Python 线程上,就是在别的系统调用上。唯一能做的,就是把这次的经验沉淀成检查项、脚本和文档,下一次定位能快一点。