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

进程「自我感知内存上限」读到 16 TiB,真实配额只有 384 MiB —— cgroup v2/v1 路径、字节 vs kB、无限制哨兵值、/proc/meminfo 口径、层级上溯、readback 对照六处全错

日期:2026-09-19 | 环境:Linux / Python 3.11 / Docker 29.6.2(rootless,cgroup v1) 技术栈关键词:cgroup v2 memory.max、cgroup v1 memory.limit_in_bytes、字节 vs kB、 无限制哨兵值 max / 9223372036854771712、/proc/meminfo MemTotal、/proc/self/cgroup、 cgroup 层级上溯取最紧、readback 回读对照、容器内存配额读取、Python 读取容器内存上限

一、问题背景

我们的服务启动自检时要按「容器内存上限」规划缓冲区 / 线程池 / 堆大小:

limit = read_container_memory_limit()   # 自称"读到容器内存上限"
planned = int(limit * 0.6)              # 按 60% 规划

运维给这个服务的配额是 384 MiB,但进程自报的报表是这样的:

limit_bytes   = 17592186044416     # ← 约 16 TiB
limit_mib     = 16777216.0
planned_mib   = 10066329.6
self_consistent = true             # ← 自检"一切正常"

三处对不上:

  • 进程说它有 16 TiB,比整机内存(16 GiB)还大 1024 倍;
  • 真实配额只有 384 MiB,差了 43690.7 倍;
  • 自检日志全程无异常 —— 因为它是拿自己读到的值自证自己。

换台机器表现还不一样:有的机器读出 16 GiB(那台整机就是 16 GiB,说明它悄悄回退到了整机口径), 有的读出 9223372036854771712(等于没限制)。

二、复现步骤

git checkout 2026-09-19-0800
cd scenario/2026-09-19-0800

docker compose up -d --build

# ① 进程自报的上限(问题态:约 16 TiB)
docker compose exec app python3 /app/probe_cli.py report

# ② 权威配额
docker compose exec app cat /etc/app/limit.env | grep MEMORY_LIMIT
# MEMORY_LIMIT_BYTES=402653184
# MEMORY_LIMIT_MIB=384

# ③ 六问并排对照(一眼看出整机口径 / cgroup 口径 / 契约口径 三者不同)
docker compose exec app python3 /scenario/client/check_limits.py

# ④ 判定脚本
bash ops/verify.sh
# ==== 判定结果 ====
# PASS=2 FAIL=6 SKIP=1

问题态的关键输出

[1] meminfo_MemTotal_bytes              17179869184   ≈    16384.0 MiB   ← 整机口径
[2] sysconf_PHYS_bytes                 597668012032   ≈   569980.6 MiB   ← 宿主物理内存
[3] cgroup_v2_memory.max_raw       <FileNotFoundError>                 ← v1 主机没有 v2
[4] cgroup_v1_limit_raw            17179869184                          ← 注意:字节,不是 kB
[5] self_cgroup_relpath   /cnb-hoo-.../-001/c86ad3037e...              ← 真实层级
[6] truth_bytes                           402653184   ≈      384.0 MiB   ← 权威配额
[7] probe_self_reported_bytes        17592186044416   ≈ 16777216.0 MiB   ← 进程自报(错)
结论: 进程自报 != 权威配额  →  BAD(自报是权威的 43,690.7 倍)

三、根本原因

不是「读不到」,而是「读到了但读错了」,六处口径错,且互相掩护。

错误 1:只认 cgroup v1 路径,不看 cgroup v2

CGROUP_V1_LIMIT = "/sys/fs/cgroup/memory/memory.limit_in_bytes"
CGROUP_V2_LIMIT = "/sys/fs/cgroup/memory.max"   # 声明了却从不读

cgroup v2(新内核默认)的上限在 /sys/fs/cgroup/memory.max, v1 在 /sys/fs/cgroup/memory/memory.limit_in_bytes。 只认 v1 → 在 v2 主机上永远读不到,直接掉进回退分支。

错误 2:把字节当 kB,又乘 1024

return v1 * 1024     # ← 单位错

cgroup 文件里 memory.limit_in_bytes / memory.max 的内容就是字节, 不是 kB,不需要任何换算。这一乘,正好把数字放大 1024 倍:

python3 -c "
v = 17179869184
print('原样   :', v,       '=', v/1024/1024, 'MiB')                # 16384.0 MiB = 16 GiB
print('再乘1024:', v*1024, '=', v*1024/1024/1024/1024, 'TiB')"     # 16.0 TiB ← 自报的正是这个

16 GiB 变 16 TiB,「比整机还大」的荒谬数字就是这么来的。

错误 3:不认无限制哨兵值

cgroup 用特殊值表示「无限制」:

  • cgroup v2:字符串 max
  • cgroup v1:一个大数,典型 2^62 = 4611686018427387904 或 LONG_MAX 对齐值 9223372036854771712 / 9223372036854775807

原代码只处理了 raw == "max",对 v1 的大哨兵值直接 int() 当数字用 —— 于是「没限制」被读成「有 8 EiB」。

错误 4:读不到就回退 /proc/meminfo 的 MemTotal

def read_limit_bytes():
    v1 = _read_int(CGROUP_V1_LIMIT)
    if v1 is not None:
        return v1 * 1024
    return read_meminfo_bytes()      # ← 整机口径冒充容器配额

/proc/meminfo 的 MemTotal 是整机物理内存,跟你这个 cgroup 的配额是两回事。 「有的机器读出 16 GiB」就是这么来的:那些机器上 v1 路径读不到,静默回退整机口径了。 读不到就该如实报「未知」,不能猜。

错误 5:只在 cgroup 根读一层,不逐级上溯

/sys/fs/cgroup/memory/memory.limit_in_bytes 是根上的值, 而进程实际属于 cgroup 里的某个子目录(层级),由 /proc/self/cgroup 给出:

11:memory:/cnb-hoo-1k2rfd0fn-001/c86ad3037ee7bb266e85b280422736c08620f95c7a8e605ab46145fa662d2ef6

容器里通常祖先比自己更紧,所以必须沿这条相对路径逐级向上, 把所有有效上限取 最紧(最小) 的那个。 只读根一层,就会漏掉真正的限制。

错误 6(最要命):self_consistent 自证自己

"self_consistent": planned == int(limit * ratio)

左边右边用的是同一个 limit,所以恒为 true —— 哪怕 limit 错到天上,报表也永远「自洽」。 这就是「日志一切正常、实际撞墙」的元凶。

四、解决办法

只改读取逻辑(app/limit_probe.py)。conf/limit.env 是权威契约值,不许改 (有 sha256 侧车,改了立刻报 TAMPERED)。

修 1 + 2 + 3:独立的解析函数

V1_SENTINELS = {1 << 62, (1 << 63) - 4096, (1 << 63) - 1}

def parse_limit(raw):
    """cgroup 文件内容 → 字节数;""/None/"max"/大哨兵 → None(未知)"""
    if raw is None:
        return None
    raw = str(raw).strip()
    if raw == "" or raw.lower() == "max":
        return None
    try:
        v = int(raw)
    except ValueError:
        return None
    if v >= (1 << 62) or v in V1_SENTINELS:
        return None            # 哨兵 = 无限制 = 未知
    return v                   # 原样返回,不做任何单位换算!

修 5:按 /proc/self/cgroup 逐级上溯

def current_cgroup_relpath():
    raw = open("/proc/self/cgroup").read()      # 实际用 try/except OSError 包起来
    for line in raw.splitlines():
        parts = line.split(":", 2)
        if len(parts) == 3 and "memory" in parts[1].split(","):
            return parts[2].strip()
    return ""

def candidate_paths():
    rel = current_cgroup_relpath().lstrip("/")
    paths = ["/sys/fs/cgroup/memory.max"]                    # v2 优先
    if rel:
        paths.append(f"/sys/fs/cgroup/{rel}/memory.max")
        parts = rel.split("/")
        for i in range(len(parts) - 1, 0, -1):               # 逐级向上
            paths.append("/sys/fs/cgroup/" + "/".join(parts[:i]) + "/memory.max")
    paths.append("/sys/fs/cgroup/memory/memory.limit_in_bytes")   # v1 回退
    if rel:
        paths.append(f"/sys/fs/cgroup/memory/{rel}/memory.limit_in_bytes")
    return paths

def read_cgroup_limit_bytes():
    best = None
    for p in candidate_paths():
        v = parse_limit(_read_text(p))
        if v is None:
            continue
        best = v if best is None else min(best, v)     # ← 取最紧
    return best

修 4:绝不回退整机 MemTotal

def read_limit_bytes():
    truth = read_truth_bytes()          # ① 部署方契约值(给了就信)
    if truth is not None:
        return truth
    return read_cgroup_limit_bytes()    # ② cgroup 上限
    # ③ 都读不到 → None(未知)。绝不 return read_meminfo_bytes()!

修 6:补 readback 回读对照 + 比例钳制

def plan_capacity(ratio=None):
    limit = read_limit_bytes()
    readback = read_truth_bytes()            # ← 从权威来源再读一次做对照
    planned = int(limit * ratio) if limit else 0
    clamped = False
    if readback is not None and planned > readback:
        planned = readback                   # ← 规划值不得超过权威上限
        clamped = True
    return {
        "limit_bytes": limit,
        "planned_bytes": planned,
        "readback_bytes": readback,          # ← 关键:不再自证自己
        "readback_source": "conf/limits.py",
        "clamped_to_readback": clamped,
        "self_consistent": (readback is None or limit == readback),
    }

生效

cp fix/limit_probe.fixed.py app/limit_probe.py    # 或用上面的实现
python3 -c "import ast; ast.parse(open('app/limit_probe.py').read())"
docker compose up -d --build                       # 素材烤进镜像 → 必须重建

五、验证

KEEP=1 bash ops/verify.sh
权威上限 = 402653184 字节(384 MiB)
进程自报 = 402653184 字节
A2 [INFO] 当前=修复态:自报 == 权威(402653184)
B1 [PASS] parse_limit("17179869184") == 17179869184,单位按字节原样处理
B2 [SKIP] 本环境没有 cgroup v2,跳过(代码仍应实现 v2 优先分支)
B3 [PASS] max / 空 / 大哨兵 → None(未知);正常值 → 原样字节数
B4 [PASS] 读取路径里没有「读不到→回退整机 MemTotal」的分支
C1 [PASS] 自报 == 权威 == 402653184 字节
C2 [PASS] readback_bytes == 权威上限(402653184)
C3 [PASS] planned_bytes=241591910 <= 权威 402653184
D1 [PASS] 篡改后完整性校验失败(TAMPERED)

==== 判定结果 ====
PASS=8 FAIL=0 SKIP=1        # 问题态:PASS=2 FAIL=6 SKIP=1

逐项验证(单位 / 哨兵 / 三个口径 / readback):

docker exec mem67-app python3 -c "
import sys, json; sys.path.insert(0,'/app'); sys.path.insert(0,'/etc/app')
import limit_probe as m
print('单位  :', m.parse_limit('17179869184'))          # 17179869184
print('哨兵  :', [(t, m.parse_limit(t)) for t in
      ['max','MAX','','9223372036854771712','536870912','abc']])
# [('max',None),('MAX',None),('',None),('9223372036854771712',None),('536870912',536870912),('abc',None)]
print('整机  :', m.read_meminfo_bytes())            # 17179869184(仅报告)
print('cgroup:', m.read_cgroup_limit_bytes())       # 17179869184(宿主 v1,非本容器配额)
print('权威  :', m.read_truth_bytes())              # 402653184
print('上限  :', m.read_limit_bytes())              # 402653184 ✔
print('plan  :', json.dumps(m.plan_capacity(), ensure_ascii=False))"

比例越界必须被钳制:

docker exec mem67-app python3 -c "
import sys; sys.path.insert(0,'/app'); sys.path.insert(0,'/etc/app')
import limit_probe as m
p = m.plan_capacity(ratio=1.5)
print(p['planned_bytes'], p['clamped_to_readback'])"
# 402653184 True

客户端视角也一致:

docker compose exec app python3 /scenario/client/check_limits.py | tail -3
# [7] probe_self_reported_bytes      402653184   ≈ 384.0 MiB
# 结论: 进程自报 == 权威配额  →  OK

六、经验与避坑

1. 「内存上限」是三个不同的问题,混用必翻车

问题该读哪里注意
这台机器有多少物理内存?/proc/meminfo MemTotal / os.sysconf('SC_PHYS_PAGES')整机口径,不是配额
我这个 cgroup 被限了多少?v2 /sys/fs/cgroup/memory.max、v1 /sys/fs/cgroup/memory/memory.limit_in_bytes单位是字节;可能是 max = 无限制;要逐级上溯取最紧
部署方到底给了我多少?契约值(env var / 配置文件 / K8s resources)这才是「我的配额」

三个数字不相等是常态。

2. cgroup 单位速查(最容易踩)

文件代内容单位无限制写法
/sys/fs/cgroup/memory.maxv2字节字符串 max
/sys/fs/cgroup/memory.highv2字节max
/sys/fs/cgroup/memory/memory.limit_in_bytesv1字节2^62 级大数(如 9223372036854771712)

名字里带 _in_bytes 就是字节;不要乘 1024。反过来,只有真正叫 kB 的才要 ×1024。

3. 哨兵值必须识别成「未知」,不能当数字

max / 空 / >= 2**62 都表示「无限制」。当成数字用 → 报出 8 EiB 这种假上限, 然后按它去规划缓冲,必然撞墙。

4. 读不到就报「未知」,别静默回退整机口径

「读不到」和「没限制」和「整机内存」是三种不同的语义。 用 /proc/meminfo MemTotal 兜底是最隐蔽的 bug:换台机器读出不同数字,看起来还「正常」。

5. 只在 cgroup 根读一层不够

必须 cat /proc/self/cgroup 拿到相对路径,**逐级上溯取最紧(最小)**的有效上限。 容器里常常祖先比自己更紧。

6. 别用「读到的值」自证自己 —— 一定要 readback

assert planned == limit * ratio 这种检查恒真,是假的安全感。 正确做法:算出结果后,从另一个权威来源再读一次做对照(readback), 并给结果加上「不得超过权威上限」的钳制(clamp)。

7. docker 排障坑:并发排查确认「你跑的是哪一份代码」

本题 docker cp 打完补丁后,/app/probe_cli.py 一路绿灯, 但 /scenario/client/check_limits.py 仍然报 16 TiB。 原因:镜像里同一模块有两份物理副本 (COPY app/ /app/ + COPY app/ /scenario/app/,再加 compose 的 ./:/scenario:ro), docker cp 只改了 /app 那份,/scenario/app 的旧副本把它遮蔽了。

docker exec mem67-app python3 -c "import limit_probe as m; print(m.__file__)"
# /app/limit_probe.py        或  /scenario/app/limit_probe.py  ← 取决于 sys.path

教训:先 print(module.__file__) 确认实际加载路径; 模块有多副本时,改宿主机 bind mount 的唯一真源再重建,别单点 docker cp。

8. 别指望 docker run --memory= 造真 OOM

本环境(rootless docker + cgroup v1)--memory 不隔离 cgroup:

docker run --rm --memory=64m python:3.11-slim python3 -c \
  "print(open('/sys/fs/cgroup/memory/memory.limit_in_bytes').read().strip())"
# 17179869184   ← 64 MiB 配额,容器内读到的仍是宿主 16 GiB

所以「靠内核真的 OOM 来判题」不可复现。 本题把真实上限放进 /etc/app/limit.env(契约值 + sha256 指纹), 判定变确定性,同时保留了全部认知考点。

9. 可以搜到的典型报错 / 现象关键词

  • nodejs --max-old-space-size / JVM -XX:MaxRAMPercentage 在容器里算错堆大小
  • Go automaxprocs / Java UseContainerSupport 读不到 cgroup 限制
  • 容器内 free -h / /proc/meminfo 显示宿主机内存(不是配额)
  • 读到 9223372036854771712 或 max 当成内存上限
  • cgroup 文件读出 17592186044416(= 16 GiB × 1024)这种「大得离谱」的数字
  • 程序自报内存上限 16 TiB / 8 EiB

10. 参考速查命令

cat /proc/self/cgroup                                     # 我在哪个 cgroup 层级
cat /sys/fs/cgroup/memory.max                             # cgroup v2 上限(字节或 max)
cat /sys/fs/cgroup/memory/memory.limit_in_bytes           # cgroup v1 上限(字节或大哨兵)
cat /sys/fs/cgroup/memory/memory.stat                     # 用量明细
head -1 /proc/meminfo                                     # 整机 MemTotal(不是配额!)
python3 -c "import os; print(os.sysconf('SC_PHYS_PAGES') * os.sysconf('SC_PAGE_SIZE'))"

七、可复现命令(照抄即可)

git checkout 2026-09-19-0800
cd scenario/2026-09-19-0800

docker compose up -d --build          # 拉起环境
bash ops/check_symptoms.sh            # 看症状
bash ops/verify.sh                    # 问题态:PASS=2 FAIL=6 SKIP=1

cp fix/limit_probe.fixed.py app/limit_probe.py   # 应用修复
docker compose up -d --build
KEEP=1 bash ops/verify.sh             # 修复态:PASS=8 FAIL=0 SKIP=1

git checkout app/limit_probe.py       # 还原问题态
docker compose up -d --build
docker compose down -v                # 收尾(不 down -v 下次还是修复态)

版权声明

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

分享这篇文章