类目资源上限合计越过容器内存上限却四处监控全绿:MiB 被当十进制 SI 解析 + 启用类目清单契约未采纳 + 自检/探针只看单个类目
第95题 | 日期:2026-10-01 16:00 场 | 分支:
2026-10-01-1600关键词:类目资源上限、memory limit、容器 OOM、OOM Killed、ENOMEM、mmap失败 errno=12、MiB与MB区别、KiB/MiB/GiB 二进制口径、IEC 2^20 vs SI 10^6、单位解析前缀匹配、self_consistent恒 True、ok字段写死、健康探针只看单个类目、聚合量校验、多租户资源超配、docker composedeploy.resources.limits.memory、cgroup memory limit、docker inspect HostConfig.Memory、配置契约被静默稀释
一、问题背景
limitsd 是个常驻守护进程:读运维契约 conf/limits.conf,把「每个类目的资源上限」解析成生效值,
发布到 state/limits.json 并做一次自检。下游 planner 按生效值为每个启用类目预留一块内存。
上线后服务跑着跑着就被容器内存上限打断(mmap 返回 ENOMEM,真实容器里是 OOM Killed),
但四个监控口径一个都没报警:
limitsd 日志 : [limitsd] round #N OK self_reported_ok=True ← 绿
state/limitsd-report.json : "ok": true ← 绿
health 探针 : {"degraded": false, "issues": []} 退出码 0 ← 绿
client 外部探针 : RESULT: OK 退出码 0 ← 绿
真正被打断的只有 planner 那一步。
契约与编排层声明值:
# conf/limits.conf(运维契约,单位是 MiB/GiB 这种二进制口径)
batch.memory_bytes = 48MiB # 上一代离线作业留下的历史类目
gateway.memory_bytes = 64MiB
probe.memory_bytes = 96MiB
# conf/categories.txt(本机启用类目清单,只有这两个)
probe
gateway
# docker-compose.yml(容器真实内存上限)
deploy.resources.limits.memory: 192MiB → docker inspect = 201326592 字节
二、复现步骤
git checkout 2026-10-01-1600
cd scenario/2026-10-01-1600
docker compose up -d --build
bash ops/repro.sh # 完整复现,看最后一节「结论」
bash ops/verify.sh # 结论看最后一行 RESULT
问题态关键输出:
== [3] 编排层声明的容器内存上限(HOST 侧) ==
HostConfig.Memory = 201326592 字节
== [4] limitsd 自报的生效值 ==
effective_category = probe
effective_memory_bytes = 96000000 <-- 96MiB 被读成 96000000(应为 100663296)
self_consistent = True
total_memory_bytes = None <-- 根本没有「合计」这个字段
== [5] limitsd 自报的 ok 字段 ==
ok = True
== [6] health 探针 ==
degraded = False | issues = []
== [7] 外部探针(HOST 侧) ==
RESULT: OK
== [8] 真按规划值预留内存 ==
[planner] 类目 batch 规划 48000000 字节
[planner] 预留失败:batch: mmap 48001024 字节失败 errno=12
{"ok": false, "failed": "batch", "total": 160000000, "error": "..."}
ops/verify.sh 问题态:PASS=6 FAIL=10 / RESULT: BAD。
单独把单位解析拿出来测(最快的一步,强烈建议先做)
docker exec -i scenario-limitsd python3 -c '
import sys; sys.path.insert(0,"/srv/app"); import limits
for u in ["96MiB","1GiB","1024KiB","100MB","4096"]:
print(f"{u:>8} -> {limits._parse_memory(u)}")'
96MiB -> 96000000 <-- 期望 100663296
1GiB -> 1000000000 <-- 期望 1073741824
1024KiB -> 1024000 <-- 期望 1048576
100MB -> 100000000
4096 -> 4096
[limits] 不认识的容量单位 ''(按字节处理)
启用清单取证
docker exec -i scenario-limitsd python3 -c '
import sys; sys.path.insert(0,"/srv/app"); import limits
text=limits.CONF.read_text()
print(limits.enabled_categories(limits.parse_conf(text)))'
# -> ['probe', 'gateway', 'batch'] 期望 ['probe', 'gateway']
三、根本原因
不是一个 bug,是三个独立缺陷叠加,且三处判据全部指向「单个类目」这个错误对象。
3.1 单位口径误读:MiB 按十进制 SI 解析(10^6 而非 2^20)
# 问题态 app/limits.py
# 容量单位按十进制 SI 解释(kB=10^3 / MB=10^6 / GB=10^9)
_MEM_UNITS = {"b": 1,
"kb": 10 ** 3, "k": 10 ** 3,
"mb": 10 ** 6, "m": 10 ** 6,
"gb": 10 ** 9, "g": 10 ** 9}
def _parse_memory(raw):
...
factor = _MEM_UNITS.get(unit) # "mib" 查不到
if factor is None:
for prefix in sorted(_MEM_UNITS, key=len, reverse=True):
if unit.startswith(prefix): # "mib".startswith("m") -> 命中 10**6 !
factor = _MEM_UNITS[prefix]
break
...
两个错误叠在一起:
- 表里只有十进制 SI 一档,没有二进制 IEC(
1024进制)条目; - 单位识别用最长前缀匹配兜底,
"mib"被"m"顶掉 —— 而且sorted(..., key=len, reverse=True)是按长度而不是按语义排序,这里恰好还掩盖了「其实没匹配上」这个事实。
KiB 同理被 k = 10**3 顶掉。
偏差方向至关重要:2**20 / 10**6 = 1.048576,即规划值被系统性低估约 4.86%。
低估让「看起来还有余量」,self_consistent 反而更容易是 True。
所以这个 bug 不是「算出个离谱大值被闸门拦住」,而是把超配藏进了假余量里。
3.2 「本机启用类目」清单未采纳契约(静默稀释)
conf/categories.txt 只列了 probe / gateway,batch 是上一代离线作业留下的历史类目。
但 enabled_categories() 把契约外的类目又拼了回去:
# 问题态
listed = read_categories_file() # ['probe', 'gateway']
order = [c for c in listed if c in categories] # ['probe', 'gateway']
rest = [c for c in categories if c not in order] # ['batch'] <-- 契约外的
return order + rest # ['probe','gateway','batch']
最刺眼的是:函数自己的 docstring 白纸黑字写着「只有列在里面的类目才用」「不能算进来」, 代码行为却完全相反 —— 契约只写在注释里,这是「配置契约被静默稀释」的经典形态。
后果:planner 会去给不受本机调度器管辖的 batch 预留内存
(第 2 节里第一个失败的就是 batch),同时「启用类目合计」被多算一个类目。
3.3 三处判据只看单个类目 + 合计字段缺失 + ok 写死
四件事凑成「越界恒绿」:
limits.py::resolve()自检只比limits.memory_bytes <= ceiling(单类目); 而且拿不到上限时只是「视为通过」(notes.append("...自检跳过(视为通过)"));Limitsdataclass(即limits.json)压根没有total_memory_bytes字段 —— 不是「算错了」,是从没上报过合计,下游谁都看不到;health.py::check()只比effective_bytes > ceiling;client/check_limits.py只比eff_mem > declared—— 两个外部口径同样是单类目;limitsd.py::round_once()里"ok": True是手写的常量, 哪怕自检为False,report.json也照样ok=true,日志照样round #N OK self_reported_ok=True。
于是「单类目都不越界」被四个口径分别默认成了「整体安全」。
3.4 数值账(为什么问题态看起来还很安全)
问题态 修复后
batch 48MiB 48000000(不该计入) —(历史类目,不计入)
gateway 64MiB 64000000 67108864
probe 96MiB 96000000 100663296
---------------------------------------------------------------
启用类目合计 160000000(< 201326592,看似有余量)
208000000(含 batch,已越界)
真实口径合计 167772160 ✓
细看就会发现:问题态把 batch 算进去得到的 208000000 其实已经越界了
(208000000 > 201326592),但因为判据只看单类目、limits.json 又没有合计字段,
这个越界没有任何一处会说出口。
四、解决办法
只改 app/ 三个文件;conf/ 三个契约、docker-compose.yml、ops/*、client/* 一字未动。
4.1 app/limits.py —— 单位表改二进制 + 精确查表 + 采纳契约 + 上报合计 + 合计自检
# ① 单位表:二进制 IEC 口径,补齐 kib/mib/gib 写法
_MEM_UNITS = {"b": 1,
"kib": 2**10, "ki": 2**10, "kb": 2**10, "k": 2**10,
"mib": 2**20, "mi": 2**20, "mb": 2**20, "m": 2**20,
"gib": 2**30, "gi": 2**30, "gb": 2**30, "g": 2**30}
# ② 精确查表,删掉前缀匹配兜底;未知单位大声失败(原来静默按 1 倍/字节算)
def _parse_memory(raw: str) -> int | None:
m = re.fullmatch(r"\s*([0-9]+)\s*([A-Za-z]*)\s*", raw)
if not m:
raise ValueError(f"容量格式非法:{raw!r}")
number, unit = m.group(1), m.group(2).lower()
if unit not in _MEM_UNITS:
raise ValueError(f"不认识的容量单位:{raw!r}")
return int(number) * _MEM_UNITS[unit]
# ③ 启用类目只认契约;契约不可用 -> 大声失败
def enabled_categories(categories):
listed = [c for c in read_categories_file() if c in categories]
if listed:
return listed
raise ValueError(f"契约 {CATEGORIES_FILE} 不可用或未列出任何已知类目")
# ④ Limits 增加两个「可外部独立复核」的字段
enabled_categories: list[str] = field(default_factory=list)
total_memory_bytes: int | None = None
# ⑤ 新增合计计算
def total_memory_bytes(categories, enabled):
values = []
for name in enabled:
block = categories.get(name)
if not block or "memory_bytes" not in block:
continue
value = _parse_memory(block["memory_bytes"])
if value is not None:
values.append(value)
return sum(values) if values else None
# ⑥ 自检对象从「单类目」改为「启用类目合计」;拿不到上限 = 判不了 = 不通过
enabled = enabled_categories(categories)
limits.enabled_categories = enabled
limits.total_memory_bytes = total_memory_bytes(categories, enabled)
ceiling = container_memory_ceiling()
limits.cgroup_bytes = ceiling
if ceiling is None:
limits.self_consistent = False # 原来是「视为通过」
limits.notes.append("拿不到容器真实内存上限,无法完成自检")
elif limits.total_memory_bytes is not None and limits.total_memory_bytes > ceiling:
limits.self_consistent = False
limits.notes.append(f"启用类目内存上限合计 {limits.total_memory_bytes} 字节超过容器上限 {ceiling} 字节")
elif limits.memory_bytes > ceiling: # 单类目判据保留,双保险
limits.self_consistent = False
limits.notes.append(...)
else:
limits.notes.append(f"自检通过:启用类目内存上限合计 {limits.total_memory_bytes} 字节未超过容器上限 {ceiling} 字节")
4.2 app/health.py —— 补「合计」判据,拿不到上限改为 BAD
if ceiling is None:
out["degraded"] = True # 原来:「自检跳过」= 视为通过
out["issues"].append("拿不到容器真实内存上限,无法判定")
return out
total = eff.get("total_memory_bytes")
out["total_memory_bytes"] = total
if total is None:
out["degraded"] = True
out["issues"].append("limits.json 未上报启用类目内存上限合计")
elif total > ceiling:
out["degraded"] = True
out["issues"].append(f"启用类目内存上限合计 {total} 字节 > 容器真实上限 {ceiling} 字节")
else:
out["total_headroom_ratio"] = round(total / ceiling, 4)
4.3 app/limitsd.py —— ok 由常量改为算出来的
- # 自检结果:本轮解析与发布都成功即视为 ok
- "ok": True,
+ # 自检结果必须来自实际判定,不能写死
+ "ok": bool(limits.self_consistent),
五、验证
5.1 验收 PASS=6 FAIL=10 → PASS=16 FAIL=0
docker compose up -d --build
bash ops/verify.sh
== A 组:单位解析口径(二进制) ==
PASS A1 96MiB 应解析为 100663296(=100663296)
PASS A2 1GiB 应解析为 1073741824(=1073741824)
PASS A3 1024KiB 应解析为 1048576(=1048576)
== B 组:启用类目来自契约 conf/categories.txt ==
PASS B1 启用类目应只含契约里的 probe,gateway(=probe,gateway)
PASS B2 首个生效类目应为 probe(契约第一行)(=probe)
PASS B3 历史类目 batch 不得计入启用清单(=False)
== C 组:总量与会容器上限 ==
PASS C1 启用类目内存合计 167772160 <= 容器上限 201326592
PASS C2 limits.json 的 total_memory_bytes 与计算值一致(=167772160)
PASS C3 生效的上限不得超过容器上限的 90%(=True)
== D 组:闸门必须有牙 ==
PASS D1 契约合规时 health.py 应绿(=0)
PASS D2 planner 按规划值真预留内存应成功(=0)
PASS D3 契约合规时 client 探针应 OK(=0)
PASS D4 注入越界契约后 health.py 必须报 BAD(=1)
PASS D5 注入越界契约后 client 探针必须报 BAD(=1)
== E 组:契约未被偷改 ==
PASS E1 容器内存上限仍为 192MiB(不得调大)(=192MiB)
PASS E2 conf/limits.conf 与基线一致(=da5e30846fc7436d0f8236b49a9f21e6)
PASS=16 FAIL=0
RESULT: OK
down -v 干净重建后连跑两次结果一致(幂等)。
5.2 ops/repro.sh:planner 退出码 2 → 0
effective_memory_bytes = 100663296 <-- 96MiB = 2^20*96 ✓
total_memory_bytes = 167772160 <-- 合计已上报 ✓
ok = True | notes = ['自检通过:启用类目内存上限合计 167772160 字节未超过容器上限 201326592 字节']
degraded = False | issues = []
total_headroom_ratio = 0.8333
RESULT: OK
[planner] 预留完成,合计 167772160 字节
{"ok": true, "categories": ["probe", "gateway"], "total": 167772160}
5.3 决定性负向注入:只越界「合计」,单类目看不出来
把 gateway 由 64MiB 改为 128MiB —— 单类目 128MiB 仍 < 192MiB 上限,只有合计会越界:
git checkout -- conf/limits.conf # 保证从基线出发
sed -i 's|^gateway.memory_bytes = 64MiB|gateway.memory_bytes = 128MiB|' conf/limits.conf
docker cp conf/limits.conf scenario-limitsd:/srv/conf/limits.conf
docker exec -i scenario-limitsd python3 /srv/app/limitsd.py --once
single_memory_bytes = 100663296 | 单类目越界? False <-- 只看单类目完全看不出来
total_memory_bytes = 234881024 | 合计越界? True <-- 234881024 > 201326592
self_consistent = False
notes = ['启用类目内存上限合计 234881024 字节超过容器上限 201326592 字节']
health_rc = 1 <-- 修复前恒 0
planner_rc = 2
report_ok = False <-- 修复前恒 True
client_rc = 1 <-- 修复前恒 0
四个口径同时转红;而修复前,同一个注入在 verify.sh 的 D4/D5 上是「期望 1,实际 0」。
5.4 大声失败路径
# 契约文件缺失
$ docker exec -i scenario-limitsd sh -c 'mv /srv/conf/categories.txt /tmp/ && python3 /srv/app/limits.py'
ValueError: 契约 /srv/conf/categories.txt 不可用或未列出任何已知类目
# 未知单位
$ ... limits._parse_memory("12Zib")
ValueError: 不认识的容量单位:'12Zib'
修复前这两条分别是「静默把 batch 当启用类目」与「静默按 1 倍(字节)算」。
六、经验与避坑
核心认知
- 「每个类目都没越界」不等于「整体装得下」。 判据必须对准真正的约束对象。本题里约束是启用类目的合计 vs 容器真实上限, 而三处判据全都盯着「单个类目」—— 判据对象错了,闸门有牙也咬不到东西。
- 单位口径必须与契约书写口径一致。
MiB是2^20,不是10^6。iB 后缀(KiB/MiB/GiB)= IEC 二进制; B 后缀(KB/MB/GB)严格说是 SI 十进制。混用是资源规划类事故的头号来源, 而且偏差方向常常是「低估 → 藏进余量 → 监控全绿」。 - 单位识别绝不能用「前缀匹配」兜底。
"mib".startswith("m")命中10**6,看起来「匹配上了」,实际是静默错值。 正确姿势是精确查表 + 未识别就大声失败。 ok这类字段写成常量就等于没有自检。"ok": True手写在源码里,报告再怎么「合法 JSON」也只是个文本文件。 自检必须算出来(bool(limits.self_consistent)),且报告里要有 能被外部独立复核的字段(total_memory_bytes),否则「自报数」与「事实」矛盾也看不出来。- 「拿不到数据」≠「没有问题」。
ceiling is None时原来写的是「自检跳过(视为通过)」—— 这正是fallback型事故:缺失被悄悄变成「有个看起来合理的默认判定」。 - 契约不能只写在注释里。
enabled_categories()的 docstring 与代码行为完全相反, 而且静默(不报错、不告警、合并结果看起来还挺合理)。 凡是「契约文件 + 默认兜底」的组合,都要问一句:兜底会不会把不该算的东西算进来?
排查手法
- 先把最底层的纯函数单测一遍。
_parse_memory()拿出来跑96MiB / 1GiB / 1024KiB三行就现原形。 这一步把问题切成「单位解析错」和「判据口径错」两件事,避免只修单位就以为完事 (只修单位:A 组绿、C 组仍然红,因为total_memory_bytes根本不存在)。 - 用「只越界合计、不越界单类目」的注入值验证闸门。
本题注入
probe=160MiB或gateway=128MiB: 单类目<= 192MiB完全是绿的,只有合计检查抓得到 —— 这才是真正有力的判据验证。 - 数值对账看「三个数」:单类目生效值 / 启用类目合计 / 容器真实上限。 三者缺一,就会出现「看起来还有余量」的假安全。
- 别用「看起来合理」的数字判断对错:
96000000很像个正常数字, 只有跟100663296并排看才知道它错了 4.86%。
踩坑实录
- 【最大坑】做负向注入前,别把「已经改过的文件」当基线备份。
我先把
limits.conf改成128MiB,再cp conf/limits.conf /tmp/limits.bak—— 备份下来的是污染版本,还原后 md5 变成34d21718...而不是基线da5e3084..., E2 立刻红,而且这个错误会一路带进提交。 → 基线在 git 里,比任何临时备份都可靠:注入前先git checkout -- conf/limits.conf, 还原也用它,然后用md5sum与ops/limits.conf.baseline.md5对账。 docker compose exec会把容器内退出码当自己的返回码。health.py自检失败时raise SystemExit(1), 用cmd1 && cmd2 && cmd3串起来会在第一个失败处整条中断, 看起来像「命令被杀 / 返回码 143」。 → 取值写成sh -c 'cmd >/dev/null 2>&1; echo $?',或用|| true兜住。app/是COPY进镜像的,restart/exec跑的还是旧代码。 改完必须docker compose up -d --build。会看到「改了但现象没变」,别怀疑自己的修复逻辑。state/是 bind mount 的运行时产物。limits.json/limitsd-report.json会被 compose 写出来,提交前必须清掉, 否则把运行状态提进版本库。建议在state/下加.gitignore兜住。- 不要「修过头」。
别去删
conf/limits.conf里的batch(它是契约里合法保留的历史类目,E2 基线会红), 也别把docker-compose.yml的192MiB调大(E1 会红)。 要改的是代码有没有按契约与真实口径去算,而不是让校验变绿。 ops/apply_fix.sh --apply会覆盖你自己的改动(问题态/参考解对照工具),调试期只用--status。
自查三问
- 把「启用类目合计」改大到越界,监控会红吗?(答案应是「会」)
- 把契约
conf/categories.txt删掉,程序会怎样?(答案应是「大声失败」,不是「照常跑」) MiB/MB两个写法在代码里解析结果一样吗?(契约都是 IEC 口径时应该……不一样才对,MiB必须是2^20)
同类影响面
K8s resources.limits.memory(同样是二进制口径,1Mi = 1048576)与 ephemeral-storage、
cgroup v1 memory.limit_in_bytes 与 v2 memory.max、数据库 innodb_buffer_pool_size
(128M 是按 2^20 算的)、Redis maxmemory(1gb 是 2^30,1g 才是 10^9!)、
fdisk / parted 的 MiB vs MB、Java -Xmx1g(2^30)、
任何「多租户 / 多类目规划总量」的容量核算、
以及所有「契约文件 + 默认兜底」形式的配置合并逻辑。
一句话总结:「每个都不超」不等于「加起来不超」;判据要盯着真正的约束对象, 单位口径要跟契约对齐,兜底要么大声失败、要么别兜。
