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

连接池按「计划值」规划而非「内核真实软限」:配置 TARGET_NOFILE=4096 但实际只有 1024,请求期 EMFILE / Too many open files 500

期数:第62题 | 分支:2026-09-17-1600 | 日期:2026-09-17 技术栈:Python 3.11 / resource.setrlimit(RLIMIT_NOFILE) / Docker Compose ulimits.nofile / 连接池容量规划 / HTTP 500 排查 关键词:EMFILE、Too many open files、errno 24、RLIMIT_NOFILE、ulimit -n、 setrlimit、current limit exceeds maximum limit、硬限 hard limit、软限 soft limit、 docker compose ulimits nofile、nofile soft hard、连接池规划错位、fd 打满、微服务 500


一、问题背景

一个网关服务(gateway)通过环境变量声明自己「期望的 fd 软限」,启动时把 RLIMIT_NOFILE 软限抬到该值,然后按这个值推导下游连接池的槽位数:

TARGET_NOFILE = 4096
def plan_pool(target_soft):
    return max(1, int(target_soft * 0.25) // 2)   # 按「期望值」算 → 512 个槽位

部署侧 docker-compose.yml 同时写了两处配置:

ulimits:
  nofile:
    soft: 1024      # 容器启动时的软限
    hard: 1024      # ★ 硬限也是 1024
environment:
  TARGET_NOFILE: "4096"    # 应用「期望」4096

现场现象非常有欺骗性:

请求结果
GET /healthz200 —— 服务是活的,监控不告警
GET /stats报告 planned_soft=4096、actual_soft=1024、pool_fds_held=512
GET /work?n=400200 —— 看起来一切正常
GET /work?n=600500 EMFILE / Too many open files

低负载能过、健康检查能过,只有请求规模稍微接近真实软限才炸。 报错信息是 Too many open files,第一反应一定会去查「谁泄露了 fd」, 而真凶在完全相反的方向:资源规划的输入用错了值。


二、复现步骤

cd scenario/2026-09-17-1600
docker compose up -d --build
docker compose logs app

启动日志(关键行):

[gateway] RLIMIT_NOFILE 初始 soft=1024 hard=1024
[gateway] 期望 TARGET_NOFILE=4096 抬升成功=False        ← 抬升失败,但服务照样起来了
[gateway] 内核实际 soft=1024 hard=1024
[gateway] 按计划值规划连接池槽位 = 512(每槽预算 2 fd,比例 0.25)   ← 按 4096 算的
[gateway] 预热完成,池子实际持有 fd 数 = 512

一条命令看清「计划值 vs 内核真实值」的差距:

$ docker compose exec app bash /ops/limits.sh
  soft = 1024
  hard = 1024
  {
   "planned_soft": 4096,      ← 申请值
   "actual_soft": 1024,       ← 内核真实回答
   "hard": 1024,
   "pool_slots_planned": 512,
   "pool_fds_held": 512,      ← 池子吃掉 512 fd
   "open_fds_now": 518,
   "headroom_fds": 506        ← 只剩 506 可用
  }
== 4. 现场演示:试图把软限抬到 4096 ==
  抬升失败 -> ValueError: current limit exceeds maximum limit
  真实软限仍是 1024

观察失败面:

$ curl -s -o /dev/null -w 'n=400 -> %{http_code}\n' 'http://127.0.0.1:8080/work?n=400'
n=400 -> 200

$ curl -s 'http://127.0.0.1:8080/work?n=600'; echo
{"ok": false, "error": "EMFILE", "errno": 24, "detail": "Too many open files",
 "handled": 507, "want": 600, "open_fds": -1,
 "actual_soft": 1024, "pool_fds_held": 512, "planned_soft": 4096}

跑判定脚本,得到问题态基线:

$ docker compose exec app bash /ops/verify.sh
== 结果:PASS=4 FAIL=5 ==

三、根本原因

3.1 hard 才是非特权进程能抬到的天花板

这是整道题的认知点:

  • soft(软限):当前生效值,进程可以在运行时自行调大;
  • hard(硬限):软限能调到的上限,只有特权进程(或容器启动时)能提;
  • 因此 setrlimit 把软限设成超过 hard 的值,内核会直接拒绝。

容器以 ulimits.nofile.hard=1024 启动时,任何进程都拿不到第 1025 个 fd, 在应用层怎么重试、怎么调参数都没用。这是部署形态,不是应用能绕过的。

3.2 三处独立缺陷串成因果链

缺陷①:抬升失败后只打 WARN,规划输入没跟着降级。

ok, _eff = raise_fd_limit(TARGET_NOFILE)      # _eff 是回读到的真实软限,却被丢弃
_state["planned_soft"] = TARGET_NOFILE
slots = plan_pool(TARGET_NOFILE)              # ← 按 4096 算池子

raise_fd_limit() 其实已经回读了真实值并返回 _eff, 但调用方用 _eff(下划线前缀)把它扔掉了 —— 返回了但没接。 这是最隐蔽的一类 bug:编译不报错、运行不报错,只是结果错。

缺陷②:plan_pool() 的入参传错。

形参名叫 target_soft,让人以为「它就是该传真实值」,实际实参是 TARGET_NOFILE。 错误在调用方而不在被调方。

缺陷③:min(target, hard) 这种「聪明的防御」反而掩盖了问题。

wanted = min(4096, hard) = 1024
resource.setrlimit(resource.RLIMIT_NOFILE, (1024, 1024))   # 这个调用成功了!

软限本来就是 1024,所以这次 setrlimit 没抛异常,异常分支根本没进。 真正的信息是「拿到了 1024 ≠ 想要 4096」—— 这只能靠设完回读、再和 target 比对才能发现。 只判断「setrlimit 有没有抛异常」是远远不够的。

3.3 为什么池子只吃掉 512、却让 n=600 失败

真实软限 1024
池子预占 512  (4096 * 0.25 = 1024 fd 预算 / 每槽 2 fd = 512 槽,恰好塞进 1024 的一半)
剩余可用 1024 - 512 ≈ 512

n=400 → 512 够  → 200 ✅
n=600 → 512 不够 → EMFILE ❌

一个 fd 都没泄露。 pool_fds_held=512 是启动时故意预占的池子本身。 如果按「查泄露」的方向排查,会一直查不到东西。

3.4 open_fds: -1 是个关键信号

fd 耗尽时,os.listdir("/proc/self/fd") 自己也需要 fd, 于是 count_open_fds() 抛 OSError 被兜底成 -1。

报错响应里出现 open_fds: -1,基本可以断定就是 fd 打满。 这比 EMFILE 本身更直接、更容易搜索到。


四、解决办法

只改应用侧规划逻辑,不改 docker-compose.yml 的 ulimits(hard=1024 是真实部署形态)。

4.1 raise_fd_limit():无论成败都回读,并显式告警不一致

wanted = min(target, hard)          # 真实目标不得超过硬限
try:
    resource.setrlimit(resource.RLIMIT_NOFILE, (wanted, hard))
except (ValueError, PermissionError) as e:
    print(f"[gateway] WARN 抬升软限 {soft} -> {wanted} 失败: ...", file=sys.stderr, flush=True)

# ★ 回读 —— 只相信内核回答,不相信自己的入参
eff, _ = resource.getrlimit(resource.RLIMIT_NOFILE)
ok = eff >= target
if not ok:
    print(f"[gateway] WARN 未达到期望软限 target={target},"
          f"内核真实软限 effective={eff}(hard={hard});"
          f"后续一律按 effective={eff} 规划资源", file=sys.stderr, flush=True)
return ok, eff

要点:setrlimit 不抛异常 ≠ 目标达成。必须回读再比对。

4.2 plan_pool():入参改为真实软限 + 加余量硬约束

REQUEST_HEADROOM_RATIO = float(os.environ.get("REQUEST_HEADROOM_RATIO", "0.6"))

def plan_pool(actual_soft: int) -> int:
    """按【内核真实软限】推导连接池槽位数。"""
    if actual_soft <= 0:
        return 1
    per_slot = max(1, FD_BUDGET_PER_POOL_SLOT)

    ratio_budget_fds = int(actual_soft * POOL_SOFT_RATIO)      # 约束1:原意向(池子占比)
    headroom_fds = int(actual_soft * REQUEST_HEADROOM_RATIO)
    max_fds = max(0, actual_soft - headroom_fds)                # 约束2:新增硬约束(请求余量)

    budget_fds = min(ratio_budget_fds, max_fds)                 # 取更严格者
    return max(1, budget_fds // per_slot)

soft=1024 时:约束1 → 256 fd;约束2 → 1024-614 = 410 fd;取 256 → 128 槽位。

把形参从 target_soft 改名为 actual_soft,让「该传真实软限」被写进签名, 从命名上防止再次传错。

4.3 init_state():规划输入换成回读值 + 自洽断言

ok, effective_soft = raise_fd_limit(TARGET_NOFILE)
actual_soft, actual_hard = resource.getrlimit(resource.RLIMIT_NOFILE)

# 回读值必须自洽(同一进程内二者应完全相等)
assert actual_soft == effective_soft, f"回读不一致: effective={effective_soft} actual={actual_soft}"

_state["planned_soft"] = TARGET_NOFILE     # 仅用于可观测/告警,不参与规划
_state["actual_soft"] = actual_soft        # ★ 规划输入
_state["hard"] = actual_hard

slots = plan_pool(actual_soft)             # ★ 修复点:按真实软限算池子

4.4 /stats:暴露「规划错位」信号

"planned_soft": _state["planned_soft"],
"actual_soft": soft,
"hard": hard,
"plan_mismatch": _state["planned_soft"] != soft,          # 新增:一眼看出错位
"pool_slots_planned": plan_pool(soft),                    # ★ 修复:不再是 planned
"pool_fds_held": _state["pool_size"],
"open_fds_now": open_now,
"headroom_fds": soft - open_now,
"headroom_ratio": round((soft - open_now) / soft, 3),     # 新增
"request_headroom_required": int(soft * REQUEST_HEADROOM_RATIO),  # 新增

五、验证

5.1 修复后启动日志(512 → 128 槽位)

[gateway] WARN 未达到期望软限 target=4096,内核真实软限 effective=1024(hard=1024);
          后续一律按 effective=1024 规划资源
[gateway] 告警 计划值(4096) != 真实软限(1024),已降级按真实软限规划,请检查部署侧 ulimits.hard
[gateway] 按【真实软限 1024】规划连接池槽位 = 128(每槽预算 2 fd,池子比例 0.25,请求余量下限 60%)
[gateway] 预热完成,池子实际持有 fd 数 = 128,剩余给请求处理的 fd ≈ 896

5.2 判定脚本:PASS=4 FAIL=5 → PASS=9 FAIL=0

$ docker compose exec app bash /ops/verify.sh
  ---- stats = {"planned_soft": 4096, "actual_soft": 1024, "hard": 1024, "plan_mismatch": true,
                "pool_slots_planned": 128, "pool_fds_held": 128, "open_fds_now": 134,
                "headroom_fds": 890, "headroom_ratio": 0.869, "request_headroom_required": 614}
  PASS A1 /stats 同时暴露 planned_soft 与 actual_soft
  PASS A2 actual_soft 与进程实测 RLIMIT_NOFILE 一致
  PASS B1 池子占用 (128) 小于真实软限 (1024)
  PASS B2 为请求处理留出余量 1024-128(≥ 60% 真实软限)
  PASS B3 池子占用 + 60% 真实软限规模请求 仍在真实软限内
  ---- GET /work?n=614 -> HTTP 200 : {"ok": true, "handled": 614, "open_fds": 748, "actual_soft": 1024}
  PASS C1 真实软限 60% 规模的请求返回 200
  PASS C2 请求体无 ok=false / EMFILE 错误
  PASS C3 连续 5 轮 n=614 请求全部 200(无累积泄漏)
  PASS D1 环境合法:hard(1024) >= actual_soft(1024)

== 结果:PASS=9 FAIL=0 ==

5.3 补充验证(超出判定脚本)

# ① 干净状态端到端重跑
$ docker compose down -v && docker compose up -d --build && sleep 3
$ docker compose exec app bash /ops/verify.sh | tail -3
== 结果:PASS=9 FAIL=0 ==

# ② 10 轮同规模请求,无累积泄漏
$ for i in $(seq 1 10); do curl -s -o /dev/null -w "$i:%{http_code} " \
    'http://127.0.0.1:8080/work?n=614'; done; echo
1:200 2:200 3:200 4:200 5:200 6:200 7:200 8:200 9:200 10:200

# ③ 池子规模不随请求增长(pool_fds_held 恒为 128)

# ④ plan_pool() 在各档真实软限下自适应,余量稳定 75%
  real_soft=   256 -> slots=   32 headroom=0.75
  real_soft=   512 -> slots=   64 headroom=0.75
  real_soft=  1024 -> slots=  128 headroom=0.75
  real_soft=  2048 -> slots=  256 headroom=0.75
  real_soft=  4096 -> slots=  512 headroom=0.75
  real_soft= 65536 -> slots= 8192 headroom=0.75

# ⑤ 部署形态未被改动(没有靠调大硬限蒙混)
$ docker inspect 3day-62-gateway --format '{{.HostConfig.Ulimits}}'
[nofile=1024:1024]

# ⑥ 超出规格时如实返回错误(n=900 = 88% 真实软限,物理上不可能成功)
$ curl -s -o /dev/null -w 'n=900 -> %{http_code}\n' 'http://127.0.0.1:8080/work?n=900'
n=900 -> 500

修复是按真实值自适应的(各档余量稳定 75%),不是针对 1024 写死。


六、经验与避坑

排查方向

  1. 看到 Too many open files,先别查「泄露」,先查「谁规划了 fd」。 把已打开 fd 数和「池子/缓存/连接池预占量」对齐, 如果两者相近,说明是规划偏大而不是释放遗漏。 本题 pool_fds_held=512 是启动时故意占的,一个 fd 都没泄露。
  2. 报错响应里出现 open_fds: -1,直接锁定 fd 打满。 连 ls /proc/self/fd 都失败(它自己也要 fd),兜底返回 -1。 这个信号比 EMFILE 更容易被搜索引擎命中。
  3. /healthz 200 不代表资源充足。 健康检查只用极少量 fd,永远是最晚受影响的那个。 要专门做一个贴近容量的探针(本题是 n = 60% × 真实软限)。
  4. 一条命令对齐「申请值」和「内核真实值」(可直接复用到线上):
# 进程真实 fd 限制
docker compose exec app python3 -c \
  'import resource;print(resource.getrlimit(resource.RLIMIT_NOFILE))'
# 当前已打开 fd 数
docker compose exec app sh -c 'ls /proc/self/fd | wc -l'
# 服务自报的规划情况
curl -s http://127.0.0.1:8080/stats

认知与设计

  1. soft 是「当前生效值」,hard 才是「能抬到的天花板」。 容器 ulimits.nofile.hard=1024 时,任何进程都拿不到第 1025 个 fd。 应用层怎么重试都没用 —— 这是部署形态问题,必须在应用侧适配而不是硬扛。
  2. setrlimit 不抛异常 ≠ 目标达成。min(target, hard) 这种「聪明的防御」会把超限值悄悄截断, 于是 setrlimit 成功、异常分支不进,问题被完美掩盖。 正确做法:设完必须回读,再和 target 比对,不等就告警。
  3. 警惕「返回了但没接」的 bug。ok, _eff = raise_fd_limit(...) 里的下划线前缀是危险信号 —— 函数明明算对了,返回值被调用方丢弃。 排查时如果发现「某函数逻辑正确但问题依旧」,先确认它的返回值有没有被用上。
  4. 配置项要分离「计划值」和「真实值」两个字段。/stats 同时暴露 planned_soft 和 actual_soft 并给出 plan_mismatch 布尔值, 线上可以据此直接告警「规划错位」,而不是等用户报 500。 一个字段写计划值、另一个写回读值,两者不等的瞬间就是故障起点。
  5. 余量约束别只用比例乘法表达。 原公式 soft * 0.25 混了「池子占比」和「每槽 fd 预算」两个量纲, 看似留了 75% 余量,实际因入参虚高,真实余量只剩 50%。 把「请求余量 ≥ 60%」提成独立硬约束,与比例约束取 min, 两条约束各行其是、互不抵消。

修复方式

  1. 不要靠调大 hard 来「解决」。hard 偏低是真实的部署形态(K8s/Compose 里很常见), 判定脚本 D1 专门检查 hard >= actual_soft,防止用改环境蒙混过关。 要修的是应用侧的规划逻辑。
  2. 让形参名承担防错职责。 把 plan_pool(target_soft) 改名为 plan_pool(actual_soft), 下一个人看签名就知道该传什么。

同类影响面

同样的「按计划值规划、不按真实值规划」模式,在这些地方都会复现:

  • 线程/进程池大小按 cpu_count 配置项而非 os.cpu_count() / cgroup 配额;
  • JVM -Xmx 按物理内存比例写死,容器 memory.limit 却更小 → OOMKilled;
  • 连接池 maxActive 按配置值,数据库 max_connections 更小 → 连接被拒;
  • 磁盘配额、RLIMIT_AS、RLIMIT_NPROC 等一切 rlimit 类限制;
  • K8s Pod 的 resources.limits 与容器内所有「自动探测」逻辑之间。

通用结论:凡是「我用配置声明期望、内核/容器给我实际」的资源, 都必须以回读到的实际值为唯一规划输入,并把两者不等作为一等可观测信号。


七、复现清单(照抄即可)

cd scenario/2026-09-17-1600

# 问题态
docker compose up -d --build
docker compose logs app                                  # 抬升成功=False,池子=512
docker compose exec app bash /ops/limits.sh              # planned=4096 / actual=1024
curl -s -o /dev/null -w 'n=400 -> %{http_code}\n' 'http://127.0.0.1:8080/work?n=400'   # 200
curl -s 'http://127.0.0.1:8080/work?n=600'               # 500 EMFILE
docker compose exec app bash /ops/verify.sh               # PASS=4 FAIL=5

# 打上本分支的 app/gateway.py 修复后
docker compose up -d --build
docker compose exec app bash /ops/verify.sh               # PASS=9 FAIL=0
for i in $(seq 1 10); do curl -s -o /dev/null -w "$i:%{http_code} " \
  'http://127.0.0.1:8080/work?n=614'; done; echo          # 全 200
docker inspect 3day-62-gateway --format '{{.HostConfig.Ulimits}}'   # 仍 [nofile=1024:1024]

docker compose down -v

详细操作过程见同分支 scenario/2026-09-17-1600/SOLUTION.md。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-17-1600-emfile-too-many-open-files

分享这篇文章