连接池按「计划值」规划而非「内核真实软限」:配置 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 Composeulimits.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 /healthz | 200 —— 服务是活的,监控不告警 |
GET /stats | 报告 planned_soft=4096、actual_soft=1024、pool_fds_held=512 |
GET /work?n=400 | 200 —— 看起来一切正常 |
GET /work?n=600 | 500 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 写死。
六、经验与避坑
排查方向
- 看到
Too many open files,先别查「泄露」,先查「谁规划了 fd」。 把已打开 fd 数和「池子/缓存/连接池预占量」对齐, 如果两者相近,说明是规划偏大而不是释放遗漏。 本题pool_fds_held=512是启动时故意占的,一个 fd 都没泄露。 - 报错响应里出现
open_fds: -1,直接锁定 fd 打满。 连ls /proc/self/fd都失败(它自己也要 fd),兜底返回 -1。 这个信号比EMFILE更容易被搜索引擎命中。 /healthz200 不代表资源充足。 健康检查只用极少量 fd,永远是最晚受影响的那个。 要专门做一个贴近容量的探针(本题是n = 60% × 真实软限)。- 一条命令对齐「申请值」和「内核真实值」(可直接复用到线上):
# 进程真实 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
认知与设计
soft是「当前生效值」,hard才是「能抬到的天花板」。 容器ulimits.nofile.hard=1024时,任何进程都拿不到第 1025 个 fd。 应用层怎么重试都没用 —— 这是部署形态问题,必须在应用侧适配而不是硬扛。setrlimit不抛异常 ≠ 目标达成。min(target, hard)这种「聪明的防御」会把超限值悄悄截断, 于是 setrlimit 成功、异常分支不进,问题被完美掩盖。 正确做法:设完必须回读,再和 target 比对,不等就告警。- 警惕「返回了但没接」的 bug。
ok, _eff = raise_fd_limit(...)里的下划线前缀是危险信号 —— 函数明明算对了,返回值被调用方丢弃。 排查时如果发现「某函数逻辑正确但问题依旧」,先确认它的返回值有没有被用上。 - 配置项要分离「计划值」和「真实值」两个字段。
/stats同时暴露planned_soft和actual_soft并给出plan_mismatch布尔值, 线上可以据此直接告警「规划错位」,而不是等用户报 500。 一个字段写计划值、另一个写回读值,两者不等的瞬间就是故障起点。 - 余量约束别只用比例乘法表达。
原公式
soft * 0.25混了「池子占比」和「每槽 fd 预算」两个量纲, 看似留了 75% 余量,实际因入参虚高,真实余量只剩 50%。 把「请求余量 ≥ 60%」提成独立硬约束,与比例约束取min, 两条约束各行其是、互不抵消。
修复方式
- 不要靠调大
hard来「解决」。hard偏低是真实的部署形态(K8s/Compose 里很常见), 判定脚本 D1 专门检查hard >= actual_soft,防止用改环境蒙混过关。 要修的是应用侧的规划逻辑。 - 让形参名承担防错职责。
把
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。
