MySQL 每 11 分钟被杀,问题在爬虫脚本的超时参数
「数据库又连不上了」——这句话我在半天里看到了十几遍。全量测试成片报 Lost connection,刷新一下能连上,再跑几秒又断。第一反应是网络抖动,第二反应是内网穿透隧道不稳,第三反应是怀疑代码里哪处连接忘了释放。全错。
真正的原因是:这台 2GB 内存的云服务器上,内核每隔约 11 分钟就准时杀掉一次数据库进程。而让内存被吃光的,是 17 个永远不退出的容器;它们的病根,写在一行没有超时参数的 HTTP 请求里。
第一步:先看系统层证据,别急着怀疑代码
症状出现在「数据库连接」这一层,最容易被引导去查网络、查隧道、查连接池配置。但有个更省时间的动作:先看操作系统留下的客观记录,它不会撒谎。
两条命令就够:
docker events --since 2h --until 0m --filter container=<db容器名>
dmesg -T | grep 'Killed process'
第一条给出容器的死亡节奏。我拿到的是三次 die / start 的间隔:10 分 56 秒、11 分 04 秒。这个数字很关键——规律的周期性死亡,几乎不可能是 bug。代码缺陷导致的崩溃通常是随机的,能被算法跑出来的节拍,背后一定有个周期性触发的东西。
第二条给出死因。dmesg 里当天有 50 条 Killed process,被杀的目标全是数据库进程,信号是 SIGKILL。同时容器的 RestartCount 已经涨到 402。
两条证据一交叉,结论就出来了:这是内核内存溢出杀进程,不是连接问题。 数据库是被杀的受害者,不是病根。
顺带记住一个判据:1146 / 1142(对象不存在)属于业务/结构层错误,而 2003 / 2013 / 1205 / 1213(连接拒绝、连接丢失、锁等待超时)才是连接层信号。我上一轮排查时把一批 1146 一并打包成「环境噪声」,这是个方向性错误——留下后表。
第二步:找到 17 个「幽灵容器」,但这不是根因
既然内存被杀光,就看看谁在吃内存。docker ps -a 一列,答案很直白:这台 2GB 的机器上跑了 20 个容器,其中 17 个是同一个爬虫镜像的副本。
它们的名字都是随机生成的(dazzling_fermi、happy_hopper 这种),状态是 Up 几十小时,累计占用 844MB 物理内存 + 754MB 交换分区。清理掉之后,内存使用量从 1652MB 掉到 626MB,交换分区从 1024MB 掉到 270MB。数据库立刻不再被杀。
到这里很容易收手:「容器泄漏,清掉就好」。但这一步只是把症状清了,根因还在——它每 4 小时还会再漏一个。 我继续往下问了一个问题:为什么启动命令里明明写了 --rm,容器却没有被自动清理?
第三步:--rm 为什么没生效?因为进程从未退出
这是本次排查里最有价值的一个认知纠偏。
docker run --rm 的语义是「容器退出时自动删除」,它不是一个定时清扫任务,而是一个事件监听:等容器退出这个事件发生,然后删掉它。
所以如果容器里的主进程永远不退出,--rm 就永远等不到那个事件——它会老老实实地一直待在原地。容器没有泄漏,是触发清理的条件从未满足。
但容器不会自己永不退出,一定是里面的进程卡住了。查容器内进程状态:
for p in $(ls /proc | grep -E '^[0-9]+$'); do
echo "pid=$p comm=$(cat /proc/$p/comm) wchan=$(cat /proc/$p/wchan)"
done
wchan 会告诉你进程到底阻塞在哪个内核函数上。我把采集脚本从头到尾读了一遍,找到了那位「元凶」。
第四步:一行没有超时参数的请求
问题在这个调用上:
parsed = feedparser.parse(feed_url) # 传 URL
这个写法和它旁边那一堆写法不一样:
resp = session.get(url, timeout=30) # 传 URL,但显式带了 timeout
区别在于:feedparser.parse() 接受两种输入——URL 或已下载的内容。传 URL 给它,它会用自己内部的抓取逻辑去下载,而那条路径不接受超时参数。同一个文件里所有其他网络请求都规规矩矩带了 timeout,只有这一处漏了。
后果链条非常清晰:
某个新闻源不响应 → 抓取线程永久阻塞 → 主进程永不退出 →
--rm永远等不到清理时机 → 每 4 小时净增一个常驻容器 → 17 个容器吃满 2GB 内存 → 内核开始杀进程 → 数据库每 11 分钟被杀一次
改法是把内容下载和解析拆开,下载这一步用能设超时的客户端:
resp = session.get(feed_url, timeout=30)
resp.raise_for_status()
parsed = feedparser.parse(resp.content) # 传内容,不传 URL
有个反证让这个结论更可信:同一个服务器上还有个推送脚本,它从来没泄漏过容器。翻它的代码,三处网络请求全都显式带了超时。有超时的那个不漏,没超时的那个漏——对照很干净。
第五步:三层防御,以及一个我踩到的坑
根因修了,但用户是拿它当定时任务跑的。定时任务的原则是「宁可失败,不可挂住」,所以还得加兜底。最终做成三层:
timeout --kill-after=60 11100 docker run --rm \
-e PYTHONUNBUFFERED=1 \
ai-news-radar \
timeout --kill-after=60 10800 python scripts/update_news.py ...
内层 timeout 终结容器内的 Python 进程,外层 timeout 兜底 Docker 客户端本身(防止镜像里缺 timeout 导致内层失效)。
但这里我自己踩了一个坑,值得单独说。 我第一版把内层超时设成了 40 分钟。跑起来一看,容器恰好在 40 分钟时被杀掉、数据没更新完。也就是说:如果就这么上线,每次定时任务都会失败。
原因是我把超时值当成了「限制正常耗时的上限」,而它的真正语义是「兜住永不退出的情况」。这个值必须大于正常耗时,否则它就从保护机制变成了故障源。实测这轮更新的自然耗时约 2.5 小时,所以最终改成 3 小时。
还有一个隐蔽的连带问题:Python 输出重定向到文件时默认是块缓冲,进程被强杀时缓冲区会整体丢失。当时我盯着日志以为「脚本卡在第一步没动」,其实它一直在干活,只是日志一个字都没落盘。加 -e PYTHONUNBUFFERED=1 之后才看清真实进度。
可带走的方法论
-
规律的周期性故障,先怀疑资源,不要先怀疑代码。 能被算法跑出节拍的死法,背后一定有个周期性触发源。用
docker events看节奏、dmesg看死因,两条命令代替几小时猜测。 -
症状所在的层,通常不是根因所在的层。 症状出现在「数据库连不上」,根因在「一个爬虫脚本没设超时」。差了三层。顺着「谁在消费这个资源」往下问,直到问出那个真正做错的决策。
-
--rm是事件监听,不是定时清扫。 任何依赖「容器退出」的自动清理机制,都需要一个「保证会退出」的前提。给主进程加超时,才是让--rm生效的前提。 -
超时值的语义是「兜住挂死」,不是「限制耗时」。 设得比正常耗时短,就是让任务必失败。上线前先量一次真实的正常运行时长。
-
给生产服务加超时的同时,给它加一个负的
oom_score_adj。 数据库这种进程被杀一次的恢复成本极高,而它往往不是内存的最大消费者。用一行 cron 每分钟校准一次(容器重启后 PID 会变,一次性设置会失效),把「被内核选中」的概率降到最低。
做自动化这几年,最耗时间的从来不是写代码,是摸清每个平台的脾气。
这篇里提到的坑,都是真金白银踩出来的。
如果你手上也有重复度很高的活儿——批量发布、数据搬运、有固定规则的机械操作
——可以在评论区说说你的场景,我看看能不能自动化掉。

浙公网安备 33010602011771号