多进程fork死锁导致的容器OOM排查
一、问题现象
Prometheus OpenStack Exporter 使用 ForkingMixIn 处理 HTTP 请求。集群规模为 4 控制 + 3 云产品 + 20 网络 + 1224 计算节点,容器内存限制 1GiB。
3 个 openstack-metrics 容器内存占用均超过 90%(900MiB ~ 1024MiB)。拉取 24 天内存数据,从重启后的 41MiB 单调增长至 900MiB+,无回落。
进入容器查看进程:
[root@node-1 etc]# ps -eo pid,ppid,rss,etime,stat,wchan:50,cmd
PID PPID RSS ELAPSED STAT WCHAN CMD
1 0 64 29-12:18:05 Ss arm64_sys_rt_sigtimedwait /usr/local/bin/dumb-init /bin/bash /tmp/openstack-exporter.sh start
7 1 58304 29-12:18:04 Ssl do_select python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
113 7 57536 29-11:46:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
2429 7 59328 1-18:18:06 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
2590 7 57024 1-17:29:02 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
3916 7 57408 1-11:05:02 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
10895 7 0 00:06 Z - [python] <defunct>
10896 7 0 00:02 Z - [python] <defunct>
10897 0 6528 00:00 Rs+ - ps -eo pid,ppid,rss,etime,stat,wchan:50,cmd
13550 7 61120 26-16:07:02 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
13783 7 61376 26-15:01:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
18127 7 59200 12-02:16:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
18889 7 61696 25-13:18:02 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
19537 7 57792 11-19:11:36 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
19862 7 61376 11-17:34:36 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
20232 7 59008 25-06:31:36 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
28047 7 60480 10-00:07:36 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
29618 7 59200 9-16:15:36 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
36832 7 59776 8-04:15:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
37647 7 59328 8-00:14:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
39463 7 57536 7-15:03:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
52180 7 61632 18-14:23:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
53416 7 61824 4-17:41:32 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yaml
55857 7 61632 17-20:03:36 S futex_wait_queue_me python /usr/local/bin/exporter/main.py --config-file=/etc/openstack-exporter/config.yamlPPID=7 的 S 状态子进程共 19 个,WCHAN 全部为 futex_wait_queue_me(等锁,不消耗 CPU),ELAPSED 从 4 天到 29 天不等,说明持续累积。RSS 在 57MiB ~ 62MiB 之间,累计约 1100MiB,与容器内存占用吻合。可以确认是进程泄漏,而非常规内存泄漏。
二、根因分析
1. OSCache 刷新周期
OSCache 每个周期请求 Nova /os-hypervisors/detail 接口均失败(超时 20s + 1 次重试 = 40s),其余接口合计约 5s,单次执行总耗时约 45s。加上默认 sleep 30s,完整周期约 75s。而 Prometheus scrape 间隔为 30s,两者存在周期性交叉。
2. 稳定复现
在 OSCache 线程中永久持有 logging handler 锁,模拟 fork() 瞬间锁被持有的场景:
def run(self):
if self.type == 'service':
sleep(3)
root = logging.getLogger()
h = root.handlers[0]
h.lock.acquire() # 永久持锁
while True:
sleep(3600)部署后 curl /metrics,100% 必现死锁,移除后恢复正常。
3. 死锁机制
ForkingMixIn 流程为 accept() → fork()。父进程中有 OSCache 线程周期性刷新缓存并写日志。fork() 仅复制调用线程(主线程),OSCache 线程在子进程中直接消失。
父进程
├── 主线程: serve_forever() → accept() → fork()
├── OSCache(service) 线程: 周期刷新,调用 logger.info/debug
└── OSCache(resource) 线程: 同上
fork() 时若 OSCache 线程正持有 handler.lock:
→ 锁状态原样复制到子进程
→ 子进程中该锁永久无法释放 → 死锁日志调用链路:
logger.info → Logger._log → Logger.callHandlers
→ handler.handle → handler.acquire(获取 handler.lock)
→ handler.emit → handler.release
fork() 落在 acquire 和 release 之间 → 子进程继承 locked 状态4. 偶发条件
死锁需两个条件同时成立:
fork()瞬间 OSCache 线程正持有logger的 handler.lock- 子进程中 collector 数据为空(接口失败),触发
except块的logger.warning
时序上,OSCache 周期 75s 与 scrape 间隔 30s 周期性交叉,加上 /os-hypervisors/detail 持续失败 40s 拉长了持锁窗口,大幅提高了碰撞概率。
三、修复
子进程 fork 后不再调用 logger,从根源消除条件 2。
- except Exception as e:
+ except Exception:
traceback.print_exc()
- logger.warning("Failed to retrieve stats for collector "
- "with key: {}, error: {}".format(
- collector.get_cache_key(), str(e)))logger.warning(...) | traceback.print_exc() | |
|---|---|---|
| 输出路径 | logging → handler.lock → stderr | 直接写 stderr fd |
| 经过 handler.lock | 是 ← 死锁来源 | 否 |
| 信息量 | 异常类型 + 消息 | 完整调用栈 + 行号 |
traceback.print_exc() 直接写 fd,不经过锁,且输出完整调用栈,K8s 环境下 stderr 同样由 kubectl logs 采集。部署后子进程不再死锁,容器内存回落至正常水平。
四、总结
ForkingMixIn+ 线程 + 锁 = 高危组合。fork()后子进程只继承调用线程,但会复制整个进程地址空间,包括锁状态。当 scrape 请求触发fork()的时刻恰好落在 OSCache 刷新线程持有锁的窗口内,子进程将继承一个不可释放的锁(全局 logging 锁),从而死锁。子进程中任何等锁操作都将永久阻塞。traceback.print_exc()是 fork-safe 的。直接写文件描述符,不经过锁机制,子进程中可安全使用。内存增长先区分泄漏类型。
ps -eo查看 WCHAN 和进程数量,避免在代码层面排查内存泄漏走弯路。
