容器 CPU 配额小数被静默截断:--cpus 0.5 一个都调不起来、1.5 核只拿到 1 核,而健康检查与退出码全绿
日期:2026-10-10 环境:Docker 29.6 / Python 3.11 / cgroup v2 关键词:
--cpus、CPU 配额、小数核、NanoCPUs、cores 与 NanoCPUs 换算、1e9 换算因子、0.5核被截成 0、1.5核只给 1 核、单位换算被忽略、静默截断、空判据表、 健康检查假绿、退出码恒 0、below_min、cpu.max、容器限流、fail-closed
一、问题背景
平台把编排文件里的容器 CPU 配额解析出来,做两件事:准入判定(申请值必须落在
[最小核数, 节点总核数] 区间内)和写进内核 cgroup(核数换算成 NanoCPUs,
落到 cgroup v2 的 cpu.max)。
运维观测到两件互相掩护的怪事:
- 申请
--cpus 0.5/0.25的容器一个都没被放行,原因写着below_min—— 可它们是合法申请、明明在纳入清单里; - 申请
--cpus 1.5的容器看着放行了,实际只拿到 1 核 —— 少给的那 0.5 核没有任何报错,cgroup 里写的就是1000000000。
更麻烦的是,所有自述口径都在说「一切正常」:巡检报告 ok: true、
units_consistent: true;健康检查输出 {"degraded": false, "issues": []};
demo.py / report.py / health.py 三个脚本退出码全是 0。监控全绿。
一句话概括:解析结果两个方向同时错,但所有自我报告都在说没问题。
技术栈:Python 3 / Docker Compose / cgroup v2 cpu.max / NanoCPUs / 健康检查退出码。
二、复现步骤
cd scenario/2026-10-10-1000
docker compose up -d --build
docker compose exec app bash
# 容器内:
cd /srv
rm -rf runtime && mkdir -p runtime
python3 app/demo.py ; echo "demo exit=$?"
python3 app/report.py ; echo "report exit=$?"
python3 app/health.py ; echo "health exit=$?"
cat runtime/cgroup.tsv # 真正写进 cgroup 的配额(内核侧事实)
python3 client/quota_probe.py # 外部独立探针
问题态实测输出:
$ python3 app/demo.py
纳入清单 6 条,放行 3 条,非法 3 条 # 契约口径应为「放行 5、非法 1」
demo exit=0
$ python3 app/health.py
{"degraded": false, "issues": [], "admitted": 3, "rejected": 3, "applied_cores": 7.0, "ok": true}
health exit=0 # 全错却退出码 0
$ cat runtime/cgroup.tsv
worker 1000000000 # ← 1.5 核被静默截成 1 核
cron 2000000000
batch 4000000000
一键判定:bash ops/verify.sh → PASS=8 FAIL=11 / RESULT: BAD。
外部探针结论:
单位换算错误(核 → 纳核 应差 1e9 而被忽略)1 条
- worker: 契约口径应写 1500000000 纳核(1.5 核),实际写进 cgroup 的是 1000000000 纳核(1 核)
错误拒绝(按契约应放行却没写进 cgroup)2 个 ['web', 'api']
RESULT: BAD
三、根本原因
1. 单位换算时只取整数部分(题眼)
# 问题代码
whole = int(re.match(r"[0-9]+", literal).group(0)) # 0.5 → 0,1.5 → 1
return whole * NANOCPUS_PER_CORE # 1e9 换算因子被绕过
正则本身能抓到完整的 0.5 / 1.5,但换算函数又把小数部分丢掉了。
一个 bug 同时产生两个方向的错:
- 纯小数
0.5/0.25→ 解析成0核 →0 < min_cores(0.1)→ 误判below_min; - 整数+小数
1.5→ 解析成1核 → 放行,cgroup 只写 1 核,静默少给 0.5 核。
而且准入判定是拿「换算后的纳核 ÷ 1e9」反算核数再比区间的, 所以截断发生在换算这一步,判定一并被污染。
2. 自述报告是「同源比较」,恒真
plan["units_consistent"] = True # 手写常量
plan["admission_consistent"] = (len(applied) == plan["admitted"]) # 自己比自己
applied 是从 plan["results"] 过滤出来的,plan["admitted"] 也是同一来源 ——
拿自己的计数器比自己写的清单,必然相等,所以 ok 永远是 True。
它从不读 runtime/cgroup.tsv(内核侧事实)。
3. 健康检查判据表为空 + 退出码恒 0
BAD_FIELDS = () # 空元组 → for 一次都不循环 → issues 恒空 → degraded 恒 false
...
return EXIT_OK # 无条件 0
健康检查退化成「给自己发合格证」:上游解析再错,它也只会打印
{"degraded": false, "issues": []} 并返回 0。
四、解决办法
只改实现(app/),不碰契约(conf/、docs/)和外部验收口径(client/、ops/)。
① 整段浮点解析 + 1e9 换算(app/quota.py)
def to_nanocpus(literal):
if literal is None:
return None
cores = float(literal) # 整段解析,保留小数
return int(round(cores * NANOCPUS_PER_CORE)) # 核 × 1e9 → 纳核
0.5 → 500000000、0.25 → 250000000、1.5 → 1500000000、4 → 4000000000。
修的方向很重要:不能为了让
0.5通过就把最小核数改小 —— 那是放宽契约。 正确做法是让解析符合契约,而不是让契约迁就错误解析。
② 自述字段改为「对 cgroup 事实逐条核对」(app/report.py)
- 读
runtime/cgroup.tsv,逐条核对解析核数 × 1e9 == 写进 cgroup 的纳核; - 核对放行条数 == cgroup 中真实存在的行数;
- 不达标时退出码非 0。
③ 非空判据表 + 独立重算 + fail-closed(app/health.py)
- 判据表改为非空(
unit_mismatch/wrong_reject/wrong_admit/applied_vs_cgroup); - 只读事实层(契约 + 编排原文 +
cgroup.tsv),按契约口径独立重算,不读自述ok; - 拿不到事实 →
rc=2;有真问题 →rc=2;契约不可用 →rc=3;干净一轮 →rc=0。
五、验证
docker compose up -d --build
docker compose exec app bash -c 'cd /srv && bash ops/verify.sh | tail -4'
修复态:
纳入清单 6 条,放行 5 条,非法 1 条
{"degraded": false, "issues": [], "contract_available": true, "unit_mismatch": 0, ...}
health exit=0
web 500000000 # 0.5 核
api 250000000 # 0.25 核
worker 1500000000 # 1.5 核,0.5 核不再被吞
cron 2000000000
batch 4000000000
单位换算错误(核 → 纳核 应差 1e9 而被忽略)0 条
RESULT: OK # 外部探针 rc=0
PASS=19 FAIL=0
RESULT: OK
负向验证(证明闸门有牙、不是「恰好绿」):
PASS: A 症状复现(verify rc=1,RESULT: BAD)
PASS: A2 小数部分被截断的判据有判别力(1.5 → 1.0)
PASS: B 实证假绿:解析错了、health 仍 rc=0(闸门失效)
PASS: C1 契约不可用时 report rc=3
PASS: C2 契约不可用时 health rc=3
PASS=5 FAIL=0
六、经验与避坑
- 单位换算别做「取整数部分」这种偷懒解析。
int(re.match(r"[0-9]+", x).group(0))看起来能兼容整数,实际把小数部分吞掉,而且两个方向同时错: 纯小数被截成 0(误拒)、带小数被截成整数(静默少给)。要整段float()解析;cores × 1e9用round处理浮点误差。 - 「一个 bug 两个方向」是静默截断的典型特征。截断发生在换算/解析环节时, 下游的准入判定、生效表、报告会一起被污染 —— 只盯一处很难看出来, 要把「申请原文 → 解析值 → 写进内核的值」三列并排核对。
- 警惕「同源比较」的自述指标。
A == B里如果 A、B 都来自同一个变量/同一份计算结果, 这个断言恒真,等于没检查。自述字段必须与外部事实(内核侧cgroup.tsv、 编排原文、契约)对得上,而不是自己比自己。 - 健康检查的判据表不能为空。
for field, expected in BAD_FIELDS:在BAD_FIELDS = ()时一次都不循环,issues恒空、degraded恒false—— 健康检查会变成「给自己发合格证」。判据表必须有内容,且要覆盖真实失败模式。 - 退出码要随事实变化,不要无条件
return 0。rc=0应当只在 「确实没有任何问题」时出现;拿不到事实(判不了)时应当 fail-closed (返回非 0),契约不可用用独立退出码(如 3)区分「配置错」和「有问题」。 - 修 bug 不要修反方向。让
0.5通过的正确方式是修正解析,而不是放宽min_cores下限、也不是给1.5打个补丁 —— 后者只是把错误藏起来。 - Docker 实操坑:
Dockerfile用COPY . /srv/而不是 bind mount,改完代码必须docker compose up -d --build重建,容器内改动不会回写宿主机;- 场景脚本自带「还原问题态」功能(
verify.sh --restore会把app/覆盖成问题态), 演示完问题态后别忘了重新应用修复; - Python 场景记得清
__pycache__,否则可能仍加载旧字节码,出现「改了没生效」; docker compose down -v在 rootless 环境可能返回非 0,容器/网络通常已清理干净。
