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

容器 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)。

运维观测到两件互相掩护的怪事:

  1. 申请 --cpus 0.5 / 0.25 的容器一个都没被放行,原因写着 below_min —— 可它们是合法申请、明明在纳入清单里;
  2. 申请 --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

六、经验与避坑

  1. 单位换算别做「取整数部分」这种偷懒解析。int(re.match(r"[0-9]+", x).group(0)) 看起来能兼容整数,实际把小数部分吞掉,而且两个方向同时错: 纯小数被截成 0(误拒)、带小数被截成整数(静默少给)。要整段 float() 解析;cores × 1e9 用 round 处理浮点误差。
  2. 「一个 bug 两个方向」是静默截断的典型特征。截断发生在换算/解析环节时, 下游的准入判定、生效表、报告会一起被污染 —— 只盯一处很难看出来, 要把「申请原文 → 解析值 → 写进内核的值」三列并排核对。
  3. 警惕「同源比较」的自述指标。A == B 里如果 A、B 都来自同一个变量/同一份计算结果, 这个断言恒真,等于没检查。自述字段必须与外部事实(内核侧 cgroup.tsv、 编排原文、契约)对得上,而不是自己比自己。
  4. 健康检查的判据表不能为空。for field, expected in BAD_FIELDS: 在 BAD_FIELDS = () 时一次都不循环,issues 恒空、degraded 恒 false —— 健康检查会变成「给自己发合格证」。判据表必须有内容,且要覆盖真实失败模式。
  5. 退出码要随事实变化,不要无条件 return 0。rc=0 应当只在 「确实没有任何问题」时出现;拿不到事实(判不了)时应当 fail-closed (返回非 0),契约不可用用独立退出码(如 3)区分「配置错」和「有问题」。
  6. 修 bug 不要修反方向。让 0.5 通过的正确方式是修正解析,而不是放宽 min_cores 下限、也不是给 1.5 打个补丁 —— 后者只是把错误藏起来。
  7. Docker 实操坑:
    • Dockerfile 用 COPY . /srv/ 而不是 bind mount,改完代码必须 docker compose up -d --build 重建,容器内改动不会回写宿主机;
    • 场景脚本自带「还原问题态」功能(verify.sh --restore 会把 app/ 覆盖成问题态), 演示完问题态后别忘了重新应用修复;
    • Python 场景记得清 __pycache__,否则可能仍加载旧字节码,出现「改了没生效」;
    • docker compose down -v 在 rootless 环境可能返回非 0,容器/网络通常已清理干净。

版权声明

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

分享这篇文章