朕喜技术(ZhenxiJS)
2026.09.30
1 分钟阅读

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.shPASS=6 FAIL=5 / RESULT: BADPASS=11 FAIL=0 / RESULT: OK
shm_client.py 300 的 first_fail(192, EMFILE)None(300 次全成功)
last_ok_index / put_failed191 / 108299 / 0
进程 fd 数(ulimit -n 200)199(顶格)10(稳定)
指向 (deleted) shm 的 fd195 个0 个
真实泄漏 200 fd 时 count_inflight0(恒绿)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

六、经验与避坑

  1. shm_unlink() ≠ 释放内存。 它只做「不再让新进程按名字打开」。 真正归还内存是最后一个 fd 被 close 之后。 「名字没了」和「内存还了」是两件事,ls /dev/shm 只能看到前者。
  2. 现场越「干净」越要警惕。 泄漏的 shm 段恰恰因为被 unlink 而在命名空间里消失。 取证要去看 /proc/<pid>/fd 里的 (deleted):
    ls -l /proc/$PID/fd | grep shm          # 或者 grep deleted
    ls /proc/$PID/fd | wc -l                # 与基线比
    
  3. 计数器不能自证。 报告里必须有一个能被外部独立复核的字段。 shm_released(我调了几次)与 open_shm_fds(内核还剩几个)要同时存在, 矛盾才会暴露。
  4. 闸门三要素必须指向同一件事:判据对象、阈值语义、比较单位。 本轮错位处:判据数「名字」、阈值算「fd 比例」、单位拿「段」比「fd」→ 闸门恒绿。 自查一句话:这个阈值描述的东西,和判据统计的东西,是同一件事吗?
  5. mmap(fd, ...) 会 dup fd,一个段占 2 个 fd。 写「fd 是否归还」的断言前先量清楚,否则会假红。 本轮就踩到了:把契约的 SHM_INFLIGHT_LIMIT=2(对象数)直接比 fd 数 (稳态 4 个 fd),稳态被误报 degraded=true。 修这个 bug 时如果不做单位对齐,你会亲手再犯一遍同一个错。
  6. EMFILE 不是容量问题。 调大 shm_size、加内存、定期重启, 都只是把发作时间往后推 —— 漏的是 fd 生命周期,不是空间。
  7. degraded 算出来了要透出。 health.py 恒 return 0 让判定形同虚设; 退出码、报告字段、监控指标至少要透一个。
  8. app/*.py 若走 COPY 进镜像,改完必须 up -d --build。restart / exec 跑的还是旧代码(本轮踩到一次,白跑一轮)。
  9. docker compose exec 会把容器内退出码当自己的返回码, 而 verify.sh 失败时 exit 1;取结论写成 bash -c 'cmd || true' 更稳。
  10. 测试脚本里的「结论文案」不是结论。repro.sh 结尾的说明文字描述的是问题态,修好后不会自动改 —— 看数字。

一句话总结

shm_unlink 摘名字、close 还内存;只做前一半,/dev/shm 会干净, 健康检查会报绿,退出码会是 0 —— 而进程正被自己的幽灵拖死。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-30-1602-shm-unlinkclosefdemfile

分享这篇文章