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

类目资源上限合计越过容器内存上限却四处监控全绿: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 compose deploy.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
    ...

两个错误叠在一起:

  1. 表里只有十进制 SI 一档,没有二进制 IEC(1024 进制)条目;
  2. 单位识别用最长前缀匹配兜底,"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 写死

四件事凑成「越界恒绿」:

  1. limits.py::resolve() 自检只比 limits.memory_bytes <= ceiling(单类目); 而且拿不到上限时只是「视为通过」(notes.append("...自检跳过(视为通过)"));
  2. Limits dataclass(即 limits.json)压根没有 total_memory_bytes 字段 —— 不是「算错了」,是从没上报过合计,下游谁都看不到;
  3. health.py::check() 只比 effective_bytes > ceiling;client/check_limits.py 只比 eff_mem > declared —— 两个外部口径同样是单类目;
  4. 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 倍(字节)算」。

六、经验与避坑

核心认知

  1. 「每个类目都没越界」不等于「整体装得下」。 判据必须对准真正的约束对象。本题里约束是启用类目的合计 vs 容器真实上限, 而三处判据全都盯着「单个类目」—— 判据对象错了,闸门有牙也咬不到东西。
  2. 单位口径必须与契约书写口径一致。MiB 是 2^20,不是 10^6。iB 后缀(KiB/MiB/GiB)= IEC 二进制; B 后缀(KB/MB/GB)严格说是 SI 十进制。混用是资源规划类事故的头号来源, 而且偏差方向常常是「低估 → 藏进余量 → 监控全绿」。
  3. 单位识别绝不能用「前缀匹配」兜底。"mib".startswith("m") 命中 10**6,看起来「匹配上了」,实际是静默错值。 正确姿势是精确查表 + 未识别就大声失败。
  4. ok 这类字段写成常量就等于没有自检。"ok": True 手写在源码里,报告再怎么「合法 JSON」也只是个文本文件。 自检必须算出来(bool(limits.self_consistent)),且报告里要有 能被外部独立复核的字段(total_memory_bytes),否则「自报数」与「事实」矛盾也看不出来。
  5. 「拿不到数据」≠「没有问题」。ceiling is None 时原来写的是「自检跳过(视为通过)」—— 这正是 fallback 型事故:缺失被悄悄变成「有个看起来合理的默认判定」。
  6. 契约不能只写在注释里。enabled_categories() 的 docstring 与代码行为完全相反, 而且静默(不报错、不告警、合并结果看起来还挺合理)。 凡是「契约文件 + 默认兜底」的组合,都要问一句:兜底会不会把不该算的东西算进来?

排查手法

  1. 先把最底层的纯函数单测一遍。_parse_memory() 拿出来跑 96MiB / 1GiB / 1024KiB 三行就现原形。 这一步把问题切成「单位解析错」和「判据口径错」两件事,避免只修单位就以为完事 (只修单位:A 组绿、C 组仍然红,因为 total_memory_bytes 根本不存在)。
  2. 用「只越界合计、不越界单类目」的注入值验证闸门。 本题注入 probe=160MiB 或 gateway=128MiB: 单类目 <= 192MiB 完全是绿的,只有合计检查抓得到 —— 这才是真正有力的判据验证。
  3. 数值对账看「三个数」:单类目生效值 / 启用类目合计 / 容器真实上限。 三者缺一,就会出现「看起来还有余量」的假安全。
  4. 别用「看起来合理」的数字判断对错:96000000 很像个正常数字, 只有跟 100663296 并排看才知道它错了 4.86%。

踩坑实录

  1. 【最大坑】做负向注入前,别把「已经改过的文件」当基线备份。 我先把 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 对账。
  2. docker compose exec 会把容器内退出码当自己的返回码。health.py 自检失败时 raise SystemExit(1), 用 cmd1 && cmd2 && cmd3 串起来会在第一个失败处整条中断, 看起来像「命令被杀 / 返回码 143」。 → 取值写成 sh -c 'cmd >/dev/null 2>&1; echo $?',或用 || true 兜住。
  3. app/ 是 COPY 进镜像的,restart / exec 跑的还是旧代码。 改完必须 docker compose up -d --build。会看到「改了但现象没变」,别怀疑自己的修复逻辑。
  4. state/ 是 bind mount 的运行时产物。limits.json / limitsd-report.json 会被 compose 写出来,提交前必须清掉, 否则把运行状态提进版本库。建议在 state/ 下加 .gitignore 兜住。
  5. 不要「修过头」。 别去删 conf/limits.conf 里的 batch(它是契约里合法保留的历史类目,E2 基线会红), 也别把 docker-compose.yml 的 192MiB 调大(E1 会红)。 要改的是代码有没有按契约与真实口径去算,而不是让校验变绿。
  6. ops/apply_fix.sh --apply 会覆盖你自己的改动(问题态/参考解对照工具),调试期只用 --status。

自查三问

  1. 把「启用类目合计」改大到越界,监控会红吗?(答案应是「会」)
  2. 把契约 conf/categories.txt 删掉,程序会怎样?(答案应是「大声失败」,不是「照常跑」)
  3. 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)、 任何「多租户 / 多类目规划总量」的容量核算、 以及所有「契约文件 + 默认兜底」形式的配置合并逻辑。

一句话总结:「每个都不超」不等于「加起来不超」;判据要盯着真正的约束对象, 单位口径要跟契约对齐,兜底要么大声失败、要么别兜。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-01-1600-mib

分享这篇文章