进程「自我感知内存上限」读到 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.max | v2 | 字节 | 字符串 max |
/sys/fs/cgroup/memory.high | v2 | 字节 | max |
/sys/fs/cgroup/memory/memory.limit_in_bytes | v1 | 字节 | 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/ JavaUseContainerSupport读不到 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 下次还是修复态)
