POSIX 共享内存缓存网关 fd 泄漏:shm_unlink 了却没 close,/dev/shm 干净、健康检查 degraded=false、退出码 0,服务却被 EMFILE 拖死
日期:2026-09-30 | 分支:
2026-09-30-1602(第 92 题) 技术栈关键词:Linux、POSIX 共享内存、shm_open、shm_unlink、mmap、文件描述符泄漏、fd leak、EMFILE、Too many open files、RLIMIT_NOFILE、nofile、/dev/shm、/proc/<pid>/fd、(deleted)、健康检查假绿、Python、Docker、shm_size适用人群:后端 / SRE / 中间件开发者;写共享内存缓存、IPC、内存队列的人
一、问题背景
缓存网关把每个缓存条目放在一个独立的 POSIX 共享内存对象 里
(shm_open("/shmgw-<key>-<seq>", O_RDWR|O_CREAT),对象落在 /dev/shm)。
上线后的怪事:
- 网关没有任何报错:
errors: []、ok: true、进程退出码0 ls /dev/shm干干净净,df -h /dev/shm显示1%使用率- 健康检查
/health返回degraded: false - 但服务跑一会儿就开始丢写入:
put_failed从 0 一路涨, 最终报[Errno 24] Too many open files - 重启立刻恢复,跑一会儿又坏,周期越来越短
运维的第一反应:「/dev/shm 没满、磁盘没满、内存没爆,那到底被什么占着?」
真相:共享内存段用完只做了一半回收。
代码调了 shm_unlink() 摘掉对象名(所以 ls /dev/shm 干净),
却从来没有 close() 那个文件描述符。
于是每个 put 净泄漏 1 个 fd,fd 表涨到 RLIMIT_NOFILE 上限就 EMFILE。
四个「绿灯」各自能证明什么
| 你以为的证据 | 它实际能证明什么 |
|---|---|
ls /dev/shm 干净、df 1% | 只证明对象名被 unlink 了,不证明对象被释放 |
errors: [] / ok: true | 只证明还没失败(fd 没用完之前不会失败) |
degraded: false | 判据取错了对象(数名字而非数 fd)+ 阈值量级不匹配 |
退出码 0 | 只证明进程正常退出,不证明资源回收干净 |
二、复现步骤
环境已放在 scenario/2026-09-30-1602/,一条命令起环境,无需外网。
cd scenario/2026-09-30-1602
docker compose up -d --build # 注意:本场景给了 shm_size: "256m"
2.1 一键复现
docker compose exec -T app bash /srv/ops/repro.sh
问题态输出(节选):
== [1] 在 nofile=200 下起常驻服务(9400)
[serve] listening on 0.0.0.0:9400 nofile=200/200 inflight_limit=2
== [2] 连续 put 300 次
[client] requested=300 last_ok_index=191 first_fail=(192, {'ok': False, 'open_fds': -1})
[client] stats after: {"entries": 64, "put_ok": 192, "put_failed": 108,
"shm_objects": 0, "shm_released": 190, "open_fds": -1, "nofile_soft": 200,
"errors": ["shm_open_failed: [Errno 24] Too many open files: '/shmgw-key000295-296'", ...],
"errors_total": 108, "ok": false}
== [3] 现场:/dev/shm 干净,但 fd 里全是 deleted 的共享内存
/dev/shm 条目: 3
df /dev/shm : shm 256M 768K 256M 1% /dev/shm
服务 pid=24 fd 数=199
其中指向共享内存的 fd(前 5 个):
10 -> /dev/shm/shmgw-key000005-6 (deleted)
100 -> /dev/shm/shmgw-key000095-96 (deleted)
四个数字同框、互相矛盾:
shm_released: 190(自称回收了 190 个段)/shm_objects: 0(/dev/shm 一个不剩)/
open_fds: -1(连 /proc/self/fd 都列不出来了)/put_failed: 108。
2.2 一键验收
docker compose exec -T app bash /srv/ops/verify.sh
问题态:PASS=6 FAIL=5 / RESULT: BAD
[FAIL] A2 release 后 fd 全部归还(无泄漏) 期望=0 实际=8
[FAIL] C1 增加 120 轮后 fd 增长 <= 2 期望=yes 实际=no (fd@40=44 fd@160=164)
[FAIL] D2 回收后 fd 增量为 0(回收名副其实) 期望=0 实际=30 (released=28)
[FAIL] E1 真实泄漏 200 个 fd 后健康判定必须 degraded=true 期望=true 实际=false
[FAIL] E2 在飞段数必须按 fd 数(不能是 0) 期望=yes 实际=no
2.3 最小取证(推荐记住这段)
docker compose exec -T app python3 - <<'PY'
import os, sys
sys.path.insert(0, "/srv/app")
from shmcache import Segment, make_name
def nfd(): return len(os.listdir("/proc/self/fd"))
base = nfd()
seg = Segment(make_name("/shmgw","proof",1), 4096)
seg.write(b"x"*16)
print("after open fd delta:", nfd()-base)
seg.release()
print("after release fd delta:", nfd()-base) # 问题态:1(应为 0)
print("对象名还在 /dev/shm 吗:", os.path.exists("/dev/shm"+seg.name)) # False
for fd in os.listdir("/proc/self/fd"):
try: t = os.readlink("/proc/self/fd/"+fd)
except OSError: continue
if "shmgw-proof" in t: print(" 仍持有 fd", fd, "->", t)
PY
问题态:
after open fd delta: 2
after release fd delta: 1 <-- release 后没还干净
对象名还在 /dev/shm 吗: False <-- 名字确实摘掉了
仍持有 fd 3 -> /dev/shm/shmgw-proof-1 (deleted) <-- 幽灵:名字没了、内存还在
三、根本原因
3.1(主因)release() 只 unlink、不 close
def release(self) -> None:
"""回收段:先解除映射,再摘掉共享内存对象名。"""
if self.mapped is not None:
self.mapped.close()
self.mapped = None
shm_unlink(self.name) # <-- 到这里就结束了,fd 从未 close
shm_unlink() 的语义不是「释放内存」,而是「不再让新进程按名字打开这个对象」。
内核真正回收这块内存的时刻,是最后一个引用它的 fd 被 close() 之后。
只 unlink 不 close 的结果:
- 对象名从
/dev/shm命名空间消失 →ls /dev/shm干净、df占用不算它; - 但 fd 仍引用着内存 → 内核里这块内存活着;
/proc/<pid>/fd/N -> /dev/shm/shmgw-xxx (deleted)—— 无名的幽灵;- 每个
put净泄漏 1 个 fd,一直涨到RLIMIT_NOFILE上限 →EMFILE。
3.2(帮凶 1)报告字段不能自证:shm_released 自己数自己
shm_released 是纯自增计数器,只能证明「我调了几次回收」,
不能证明「内核里真的还回去了」。报告里缺少一个能被外部独立复核的字段,
所以 released=190 和「fd 涨到顶格」这对矛盾无害地共存了很久。
3.3(帮凶 2)健康闸门的判据取错了对象 + 阈值量级不匹配
def count_inflight(prefix: str) -> int:
# 数 /dev/shm 下「还挂着名字」的对象
return len(iter_shm_objects(prefix)) # <-- 恒为 0!
泄漏的段在 release() 第一时间就被 unlink 了,在那里查不到 → 恒为 0,在飞判断彻底失效。
同时 fd_ratio = open_fds / nofile_soft 跟 0.80 比:软限 65535 时,
204/65535 = 0.0031,要泄漏几万个 fd 才碰得到阈值,
而服务几十/几百个 fd 时就 EMFILE 了。实测探针:
{"leaked_fds": 200, "degraded": false, "fd_ratio": 0.0031, "inflight": 0}。
「闸门在、判据在、阈值在」,但三者指向的都不是同一件事 → 恒绿。
3.4(帮凶 3)degraded 算出来了却不透出
health.py 的 main() 结尾是恒 return 0。
即使判定为 degraded,调用方从退出码上也看不到 —— 判定在、闸门不在。
四、解决办法
只改 app/ 下三个文件,conf/shmgw.env(部署契约)一字未动。
4.1 app/shmcache.py —— 补齐回收第三步
def release(self) -> None:
"""回收段:解除映射 + 摘掉对象名 + 关闭 fd。三步缺一不可。"""
if self.mapped is not None:
self.mapped.close()
self.mapped = None
shm_unlink(self.name)
if self.fd >= 0:
try:
os.close(self.fd)
finally:
# 置 -1:让重复 release() 变成「无操作」,
# 而不是去关一个已被复用给别的资源的 fd 号(double-close 事故)。
self.fd = -1
fd = -1 这步不能省:Segment.close() 代理 release(),shutdown() 也可能再来一次,
不置 -1 就会误关别人刚拿到的 fd。
4.2 app/gateway.py —— 报告加「可外部复核」的自证字段 + 退出码闸门
"shm_released": self.released, # 自己数自己:调了几次回收
"open_shm_fds": len(open_shm_fds(self.prefix)), # 内核事实:还剩几个引用(可 /proc 复核)
main() 里判定改为看 fd 而不是看名字:
leaked = rep["open_shm_fds"]
if leaked > 0: # 只看 shm_objects==0 是错的
print(f"[gateway] FATAL: {leaked} 个共享内存 fd 未归还"
f"(shm_released={rep['shm_released']} 名不副实),判定失败")
return 3
return 0
4.3 app/health.py —— 判据对象改为 fd,单位与阈值对齐
def count_inflight(prefix, pid=None) -> int:
# 判据对象必须是 fd,不能是 /dev/shm 里「还挂着名字」的对象
return len(open_shm_fds(prefix, pid))
FDS_PER_SEGMENT = 2 # 一个在飞段固定占 2 个 fd:shm_open 的 fd + mmap dup 的那个
if ratio >= FD_WARN_RATIO: # 1) 软限低时起作用
if inflight > inflight_limit * FDS_PER_SEGMENT: # 2) 契约上限(单位对齐!)
if inflight > max(inflight_limit * 4, 16): # 3) 软限高时的泄漏硬线
并把 degraded 透出到退出码:return 1 if rep["degraded"] else 0。
五、验证
5.1 前后对照
| 指标 | 问题态 | 修复后 |
|---|---|---|
ops/verify.sh | PASS=6 FAIL=5 / RESULT: BAD | PASS=11 FAIL=0 / RESULT: OK |
shm_client.py 300 的 first_fail | (192, EMFILE) | None(300 次全成功) |
last_ok_index / put_failed | 191 / 108 | 299 / 0 |
进程 fd 数(ulimit -n 200) | 199(顶格) | 10(稳定) |
指向 (deleted) shm 的 fd | 195 个 | 0 个 |
真实泄漏 200 fd 时 count_inflight | 0(恒绿) | 200 → degraded=true |
count_inflight 稳态 | — | 4 → degraded=false(不误报) |
conf/shmgw.env md5 | — | 与基线一致(F1 PASS) |
5.2 端到端:nofile=200 下压 300 次不再 EMFILE
[client] requested=300 last_ok_index=299 first_fail=None
[client] stats after: {"put_ok": 603, "put_failed": 0, "shm_objects": 2,
"shm_released": 601, "open_shm_fds": 4, "open_fds": 10,
"nofile_soft": 200, "errors": [], "ok": true}
服务 pid=130 fd 数=4
(没有指向已删除 shm 对象的 fd)
5.3 长跑 2000 次 put,fd 不涨
[round1] requested=1000 last_ok_index=999 first_fail=None put_ok=1000 open_fds=10
[round2] requested=1000 last_ok_index=999 first_fail=None put_ok=2000 open_fds=10
5.4 反向注入:证明断言有牙
只把 close() 去掉、其余保持修复后代码:
[FAIL] A2 release 后 fd 全部归还(无泄漏) 期望=0 实际=8
[FAIL] B1 网关进程退出码 期望=0 实际=3
[FAIL] C1 增加 120 轮后 fd 增长 <= 2 期望=yes 实际=no
[FAIL] D2 回收后 fd 增量为 0(回收名副其实) 期望=0 实际=30
PASS=7 FAIL=4 / RESULT: BAD
只回退一处缺陷,四个断言立刻变红 —— 判据确实指向这处缺陷。
5.5 幂等与干净重建
- 连跑 3 次
verify.sh:均PASS=11 FAIL=0 / RESULT: OK docker compose down -v后重建:仍PASS=11 FAIL=0
六、经验与避坑
shm_unlink()≠ 释放内存。 它只做「不再让新进程按名字打开」。 真正归还内存是最后一个 fd 被close之后。 「名字没了」和「内存还了」是两件事,ls /dev/shm只能看到前者。- 现场越「干净」越要警惕。 泄漏的 shm 段恰恰因为被 unlink 而在命名空间里消失。
取证要去看
/proc/<pid>/fd里的(deleted):ls -l /proc/$PID/fd | grep shm # 或者 grep deleted ls /proc/$PID/fd | wc -l # 与基线比 - 计数器不能自证。 报告里必须有一个能被外部独立复核的字段。
shm_released(我调了几次)与open_shm_fds(内核还剩几个)要同时存在, 矛盾才会暴露。 - 闸门三要素必须指向同一件事:判据对象、阈值语义、比较单位。 本轮错位处:判据数「名字」、阈值算「fd 比例」、单位拿「段」比「fd」→ 闸门恒绿。 自查一句话:这个阈值描述的东西,和判据统计的东西,是同一件事吗?
mmap(fd, ...)会 dup fd,一个段占 2 个 fd。 写「fd 是否归还」的断言前先量清楚,否则会假红。 本轮就踩到了:把契约的SHM_INFLIGHT_LIMIT=2(对象数)直接比 fd 数 (稳态 4 个 fd),稳态被误报degraded=true。 修这个 bug 时如果不做单位对齐,你会亲手再犯一遍同一个错。EMFILE不是容量问题。 调大shm_size、加内存、定期重启, 都只是把发作时间往后推 —— 漏的是 fd 生命周期,不是空间。degraded算出来了要透出。health.py恒return 0让判定形同虚设; 退出码、报告字段、监控指标至少要透一个。app/*.py若走COPY进镜像,改完必须up -d --build。restart/exec跑的还是旧代码(本轮踩到一次,白跑一轮)。docker compose exec会把容器内退出码当自己的返回码, 而verify.sh失败时exit 1;取结论写成bash -c 'cmd || true'更稳。- 测试脚本里的「结论文案」不是结论。
repro.sh结尾的说明文字描述的是问题态,修好后不会自动改 —— 看数字。
一句话总结
shm_unlink 摘名字、close 还内存;只做前一半,/dev/shm 会干净,
健康检查会报绿,退出码会是 0 —— 而进程正被自己的幽灵拖死。
