Docker 容器里读 cgroup CPU 配额读到了外层:服务上报 8 核、实际只有 0.9 核,自检全程绿灯
日期:2026-09-23 | 环境:Docker 29.6.2 / Docker Compose v5.3.1 / cgroup v1 / Linux 5.4 / Python 3.11 关键词:
cpu.cfs_quota_us、cpu.max、/proc/self/cgroup、sched_getaffinity、cpuset.cpus、cpuacct.usage、getrusage(RUSAGE_SELF)、cgroup namespace、docker --cpus限制不生效、容器 CPU 核数读取错误、容量规划上报错误、自检恒真、NameError plan_self_check、deviation 695%
一、问题背景
calcpool 是一个跑在 Docker 容器里的计算节点服务,它做两件事:
- 探测「本容器能用几个 CPU 核」,用它做容量规划(决定分片数
cpu_shards); - 把这个值当
cpu_limit上报给上游容量调度器。调度器完全依据它决策 —— 上报 8 核就往这个节点派 8 核的活。
docker-compose.yml 里部署方给这个服务限了 cpus: "1.0"(一个核)。结果:
- 启动日志和
/stats都写着cpu_limit = 8.0、cpu_shards = 16; - 服务自检
plan_is_self_consistent返回True,认为一切正常; - 但容器真实并行度只有 ≈ 1.0 核,偏差 695%。
调度器按 8 核派活 → 节点被压得动不了;而服务的自检、日志、健康检查全是绿的。这是一个典型的**「观测口径错了 + 自检恒真」双重失效**:读数错了没人发现,因为自检根本不校验「读数是否代表自己」。
二、复现步骤
git checkout 2026-09-23-1600
cd scenario/2026-09-23-1600
docker compose up -d --build
sleep 4
docker compose logs app | tail -8
问题现象(上报 8 核,自检绿):
3day-calcpool | [start] calcpool v1 环境=prod pid=1
3day-calcpool | [plan] cpu_limit=8.0 (来源 cgroup-v1:cpu.cfs_quota_us) ← 上报 8 核
3day-calcpool | [plan] cpu_shards=16 每分片 0.5 核
3day-calcpool | [plan] self_consistent=True ← 自检绿
3day-calcpool | [stats] affinity=8 cpuset=8 cpuinfo=8
3day-calcpool | calcpool ready
核心判定(容器内跑):
docker compose exec app python3 /client/check_capacity.py; echo "RC=$?"
服务上报 cpu_limit : 8.0
服务上报 cpu_shards : 16
读数来源 : cgroup-v1:cpu.cfs_quota_us
自检自洽 : True
实测窗口 : 3.00s
窗口内 CPU 秒 : 3.020
实测真实可核数 : 1.007
偏差 : 6.993 核 (695%)
判定: BAD —— 上报的节点算力与真实能拿到的算力不符
· 上报 cpu_limit=8.0,真实只能并行 1.01 核,偏差 695%(要求 ≤ 40%)
RC=1
完整判定脚本基线:
bash ops/verify.sh # PASS=17 FAIL=4
[FAIL] A1 上报核数 8.0000 超过真实可用 1.0057
[FAIL] A2 读数 8.0 明显大于实测真实可核数 1.0057(读的是父/宿主那层)
[FAIL] C1 check_capacity.py 判定不是 OK
[FAIL] C2 plan_check.py 判定不是 OK
三、根本原因
3.1 直接原因:只读固定路径,没有定位「自己那一层」
app/cpu_capacity.py 的 read_cpu_limit() 只读固定路径:
CGROUP_V1_CPU = '/sys/fs/cgroup/cpu'
...
quota_s = _read(os.path.join(CGROUP_V1_CPU, 'cpu.cfs_quota_us')) # ← 固定路径
它没有按 /proc/self/cgroup 里的相对路径去找「本容器自己那一层」。证据链:
$ docker compose exec app cat /proc/self/cgroup | grep ':cpu'
4:cpu,cpuacct:/cnb-arg-1k36ken0h-001/bc348e5d5af894c2512586d90aff3ae9e4c389550765832befc39c4810ab7bf2
$ docker compose exec app cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
800000 # 800000/100000 = 8.0 核 ← 读到的「配额」
$ docker compose exec app cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
100000
# 拿相对路径去找自己那一层 —— 不可达!
$ docker compose exec app sh -c 'ls -d /sys/fs/cgroup/cpu/cnb-arg-1k36ken0h-001/bc348e…b7bf2'
ls: cannot access '...': No such file or directory
/proc/self/mountinfo 说明这份挂载的视角:
$ docker compose exec app grep cgroup /proc/self/mountinfo | grep 'cpu,cpuacct'
2815 2811 0:33 /cnb-arg-1k36ken0h-001/bc348e…b7bf2 /sys/fs/cgroup/cpu,cpuacct \
ro,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpu,cpuacct
# ^^^^^^^^ 挂载源,被 remap 成了 /sys/fs/cgroup/cpu,cpuacct
**/sys/fs/cgroup/<ctrl>/ 里的 quota 值不代表本容器的有效配额。**实测一量就露馅:
$ docker compose exec app python3 - <<'PY'
import resource, threading, time
def burn(stop):
s=0.0
while time.time()<stop:
for _ in range(2000): s+=1.5
best=0
for _ in range(3):
r0=resource.getrusage(resource.RUSAGE_SELF); t0=time.time(); W=1.0
ts=[threading.Thread(target=burn,args=(t0+W,)) for _ in range(8)]
[x.start() for x in ts]; [x.join() for x in ts]
w=time.time()-t0; r1=resource.getrusage(resource.RUSAGE_SELF)
best=max(best, ((r1.ru_utime-r0.ru_utime)+(r1.ru_stime-r0.ru_stime))/w)
print("quota file -> 8.000 cores | measured -> %.3f cores" % best)
PY
quota file -> 8.000 cores | measured -> 1.011 cores
cpu.stat 里有 throttle 物证,证明配额确实在生效、只是不在那个文件里:
nr_periods 36555
nr_throttled 9954
throttled_time 7610261046
3.2 三条「看起来能读核数」的口径全是错的
$ docker compose exec app python3 -c "import os;print(len(os.sched_getaffinity(0)), os.cpu_count())"
8 8
$ docker compose exec app cat /sys/fs/cgroup/cpuset/cpuset.cpus
110-117 # 「允许跑在哪些 CPU 上」,不是配额
$ docker compose exec app python3 -c "print(sum(1 for l in open('/proc/cpuinfo') if l.startswith('processor')))"
8 # 整机核数
| 读法 | 量的是什么 | 为什么不能当 CPU 配额 |
|---|---|---|
cpu.cfs_quota_us(固定路径) | 挂载者那一层的配额 | 挂载点视角 ≠ 本容器 |
sched_getaffinity() | 调度器亲和性(被允许跑在哪些 CPU) | --cpus 限制不会收缩亲和性集合 |
/proc/cpuinfo | 整机核数 | 与容器完全无关 |
cpuset.cpus | cgroup cpuset 列表 | 是「允许集合」,不是时间配额 |
cpuacct.usage(固定路径) | 外层累计 CPU | 同一个挂载点问题 |
**另一个坑:cpuacct.usage 也不能用来量自己。**容器里只跑 1 个忙线程 2 秒:
$ docker compose exec app python3 - <<'PY'
import time, threading, resource
def burn(stop):
while time.time()<stop: pass
t0=time.time()
th=[threading.Thread(target=burn,args=(t0+2,)) for _ in range(1)] # 只 1 个忙线程
[x.start() for x in th]; [x.join() for x in th]
w=time.time()-t0; r=resource.getrusage(resource.RUSAGE_SELF)
print('wall=%.2f self_cpu=%.3f -> self_cores=%.3f' % (w, r.ru_utime+r.ru_stime, (r.ru_utime+r.ru_stime)/w))
print('cpuacct.usage =', open('/sys/fs/cgroup/cpuacct/cpuacct.usage').read().strip())
PY
wall=2.00 self_cpu=2.063 -> self_cores=1.031
cpuacct.usage = 25599184459103 # ← 2.5e13 纳秒,整机累计,和自己无关
自己只用了 ~2 CPU 秒,cpuacct.usage 却给出 2.5e13 纳秒(≈ 25599 秒)。
3.3 本环境 Cgroup Driver: none —— 容器没有自己独立的 cgroup 目录
$ docker info | grep -iE 'cgroup'
Cgroup Driver: none
Cgroup Version: 1
Docker 配置成不管理 cgroup,容器直接被放进外层(CNB pipeline / 宿主)的 cgroup 层级里,cpus: "1.0" 的限制由外层施加。这解释了为什么「容器自己那一层的目录根本不存在」。
推论:「只按固定路径读 cgroup 文件」这种写法,在任何 namespace / driver 组合下都不可移植。正确姿势是先按 /proc/self/cgroup 定位自己那一层,读不到就用实测自证。
3.4 次要但致命的原因:自检恒真
app/server.py 里:
return {
'cpu_limit': cores,
'cpu_shards': shards,
'shard_cpu_cost': 0.5,
'plan_is_self_consistent': True, # ← 写死 True,等于没有自检
}
即使 cpu_shards=1 而 cpu_limit=8(差 16 倍)也会报「一切正常」。正是这个恒真的自检,让「上报 8 核实际 1 核、偏差 695%」一路绿灯。
还有一处放大器:探测失败时的兜底值。
if cores is None:
cores = float(os.environ.get('CALC_CPU_FALLBACK_CORES', '8')) # 在 1 核容器里报 8 核
四、解决办法
只改 app/ 下两个文件,没有动 docker-compose.yml 的 cpus: "1.0" 闸门。
改动 1:app/cpu_capacity.py —— 读数改为三层,按可信度
read_cpu_limit()
├─ 1. 元数据:按 /proc/self/cgroup 相对路径找【自己那一层】的
│ cgroup v2 cpu.max / cgroup v1 cpu.cfs_quota_us
│
├─ 2. 自证:黑盒实测——N 个纯 CPU 线程跑 0.3s × 3 轮,
│ 取 getrusage(RUSAGE_SELF) 的 CPU秒/墙钟秒 的【最大值】
│
└─ 3. 交叉校验:元数据 ≤ 实测×1.35 → 采信元数据
元数据 > 实测×1.35 → 【以实测为准】
关键点:
- 自己那一层读不到时绝不静默回退到挂载点根目录 —— 那等于拿别人的配额冒充自己。
- 实测是物理兜底,不受任何静态元数据影响。
- 多轮取最大值:配额只会让真实并行度低于硬上限(被 throttle),不会让它高于物理上限,所以取最大是最接近上限的估计,不会测高。
核心代码:
def _cgroup_relpaths():
"""解析 /proc/self/cgroup,返回 {控制器名: 相对路径}"""
out = {}
with open('/proc/self/cgroup') as f:
for line in f.readlines():
parts = line.strip().split(':', 2)
if len(parts) != 3:
continue
_hid, controllers, rel = parts
for c in controllers.split(','):
if c.strip():
out[c.strip()] = rel
return out
def measure_affordable_cores(duration=0.30, rounds=3):
"""黑盒自证:getrusage(RUSAGE_SELF) 的 CPU 秒 / 墙钟秒,多轮取最大"""
import resource, threading, time
n_threads = max(2, min(len(os.sched_getaffinity(0)), 16))
def burn(stop):
acc = 0.0
while time.time() < stop:
for _ in range(2000): acc += 1.5
best = 0.0
for _ in range(rounds):
r0 = resource.getrusage(resource.RUSAGE_SELF); t0 = time.time()
ts = [threading.Thread(target=burn, args=(t0 + duration,)) for _ in range(n_threads)]
for t in ts: t.start()
for t in ts: t.join()
wall = time.time() - t0
r1 = resource.getrusage(resource.RUSAGE_SELF)
cpu = (r1.ru_utime - r0.ru_utime) + (r1.ru_stime - r0.ru_stime)
if wall > 0 and cpu > 0:
best = max(best, cpu / wall)
return (round(best, 3), 'probe') if best > 0 else (None, 'probe-failed')
改动 2:app/server.py —— 规划、自检、兜底
① 分片数:int() 截断 → 向上取整
# 旧(截断,会让分片总容量装不下上报核数)
shards = int(cores / 0.5)
# 新(向上取整:cores=0.915 → 2 片 = 1.0 核容量,装得下)
shards = max(1, min(MAX_SHARDS, _ceil_div(cores / SHARD_CPU_COST)))
差别(shard_cpu_cost=0.5):
cores=1.000 int-> 2 shards(1.0核) ceil-> 2 shards(1.0核) 一致
cores=0.915 int-> 1 shards(0.5核) ceil-> 2 shards(1.0核) ← 截断装不下 0.915
cores=1.007 int-> 2 shards(1.0核) ceil-> 3 shards(1.5核) ← 截断装不下 1.007
② 自检:写死 True → 真判据
两条判据,任一不成立即 False:
shards == ceil(cpu_limit / shard_cpu_cost),且夹在[1, MAX_SHARDS];- 分片总容量
shards * cost必须装得下cpu_limit。
③ 兜底值:向上兜底 8 → 只向下兜底
# 旧:探测失败 → 回退 CALC_CPU_FALLBACK_CORES=8(在 1 核容器里报 8 核!)
# 新:兜底值不许超过实测,宁可少报
cores = min(fallback, measured)
理由:多报会让调度器往节点超量派活(本问题的危害);少报只是浪费一点算力,是安全的一侧。
重建并验证
docker compose up -d --build # 改的是 COPY 进镜像的文件,必须 --build,restart 无效
sleep 4
docker compose logs app | grep '\[plan\]'
3day-calcpool | [plan] cpu_limit=1.011 (来源 probe(threads=8,rounds=3)(self-cgroup-unreadable))
3day-calcpool | [plan] cpu_shards=3 每分片 0.5 核
3day-calcpool | [plan] self_consistent=True
五、验证
5.1 核心判定:偏差 695% → 2%
docker compose exec -T app python3 /client/check_capacity.py
服务上报 cpu_limit : 0.985
服务上报 cpu_shards : 2
读数来源 : probe(threads=8,rounds=3)(self-cgroup-unreadable)
实测窗口 : 3.00s
窗口内 CPU 秒 : 3.021
实测真实可核数 : 1.007
偏差 : 0.022 核 (2%)
判定: OK —— 上报值与真实可用一致 ← RC=0
5.2 完整判定:PASS=17 FAIL=4 → PASS=21 FAIL=0(连续 6 次稳定)
bash ops/verify.sh
# PASS=21 FAIL=0
连跑 5 次 + down -v 干净重建,全部稳定(实测口径依赖计时,所以特意验证稳定性):
RUN 1: PASS=21 FAIL=0 RUN 4: PASS=21 FAIL=0
RUN 2: PASS=21 FAIL=0 RUN 5: PASS=21 FAIL=0
RUN 3: PASS=21 FAIL=0 干净重建后: PASS=21 FAIL=0
关键断言:
[PASS] 部署闸门未被改动(cpus == 1.0) ← 没靠拧大闸门通关
[PASS] A1 上报核数未超过真实可用(0.9850 <= 1.0073 + 容差)
[PASS] A2 读数 1.011 与实测真实可核数 1.0073 一致(未读到别人的配额)
[PASS] B2 /resize 能改到 limit=2.0(规划确实以上报值为输入) ← 没写死常量
[PASS] B2 /replan 后回到真实读数 1.0100(没有被写死成常量)
[PASS] C1 check_capacity.py 判定 OK
[PASS] C2 plan_check.py 判定 OK
[PASS] D1 自检对不自洽的 plan 能判 False(不是恒真)
[PASS] F1 并发全部 200
[PASS] F2 /resize 非法入参不崩
5.3 自检判别力(9 组用例,含畸形入参)
docker compose exec -T app python3 -c "
import sys; sys.path.insert(0,'/app'); import server
cases=[({'cpu_limit':8.0,'cpu_shards':1,'shard_cpu_cost':0.5},False,'装不下(8核只给1片)'),
({'cpu_limit':2.0,'cpu_shards':4,'shard_cpu_cost':0.5},True,'正好装下'),
({'cpu_limit':0.985,'cpu_shards':2,'shard_cpu_cost':0.5},True,'向上取整'),
({'cpu_limit':0.985,'cpu_shards':1,'shard_cpu_cost':0.5},False,'截断了装不下'),
({'cpu_limit':-1,'cpu_shards':2,'shard_cpu_cost':0.5},False,'负核数'),
({},False,'缺字段'),(None,False,'None'),
({'cpu_limit':'x','cpu_shards':'y','shard_cpu_cost':'z'},False,'非数字'),
({'cpu_limit':2.0,'cpu_shards':40,'shard_cpu_cost':0.5},False,'超上限')]
for p,e,n in cases:
g=server.plan_self_check(p) if not isinstance(p,type(None)) else server.plan_self_check(p)
print('%-4s exp=%-5s got=%-5s %s'%('OK' if g==e else 'BAD',e,g,n))
"
OK exp=False got=False 装不下(8核只给1片)
OK exp=True got=True 正好装下
OK exp=True got=True 向上取整
OK exp=False got=False 截断了装不下
OK exp=False got=False 负核数
OK exp=False got=False 缺字段
OK exp=False got=False None
OK exp=False got=False 非数字
OK exp=False got=False 超上限
mismatch = 0
5.4 反向验证:还原后必须重新变红
python3 myfix/patch_server.py --restore
cp app/cpu_capacity.py.problem app/cpu_capacity.py
docker compose up -d --build && sleep 4
bash ops/verify.sh
# PASS=17 FAIL=4 ← 回到同样的 4 条失败,证明修的是真因
六、经验与避坑
6.1 关键认知
- 容器里读 cgroup,先问「我读的是谁的」。
/sys/fs/cgroup/<ctrl>/这份挂载的「视角」不等于「本容器」。必须按/proc/self/cgroup的相对路径定位自己那一层;定位不到就不要读,绝不能退回挂载点根目录。 - 读不到配额时,唯一的自证口径是「实测」:
getrusage(RUSAGE_SELF)的ru_utime+ru_stime/ 墙钟秒。cpuacct.usage在同一环境下量的是别人(实测 2.5e13 纳秒,自己只用 2 CPU 秒)。 sched_getaffinity/cpuset.cpus//proc/cpuinfo都不是 CPU 配额 —— 分别量的是「允许跑在哪些 CPU 上」「cgroup cpuset 列表」「整机核数」。--cpus限制不会收缩亲和性集合,所以 affinity 恒等于整机核数。- 恒真的自检等于没有自检。
plan_is_self_consistent写死True是「观测口径错了却全程绿灯」的直接原因。 - 「让自检有判别力」这件事本身也要被测。只验证「好 plan 返回 True」不够 —— 旧代码写死
True时那条用例也是绿的。必须专门拿坏 plan / 畸形 plan去撞它。 - 兜底值只能向下。探测失败时回退到「看起来合理」的 8 核,比回退到 1 核危险得多。
int()截断会把容量算小,导致分片总容量装不下上报核数 —— 向上取整才对。
6.2 踩坑记录
坑 1:Cgroup Driver: none 让固定路径彻底不可信docker info 显示 Cgroup Driver: none、Cgroup Version: 1。容器没有自己独立的 cgroup 目录,直接被放进外层 cgroup 层级,cpus 限制由外层施加。所以容器内不存在「自己那一层」的路径,cpu.cfs_quota_us 里的 800000 是外层给的。这类环境下静态读 quota 天然不可信,必须实测兜底。
坑 2:NameError: name 'plan_self_check' is not defined
第一版改完直接崩:
File "/app/server.py", line 100, in <module>
_PLAN = plan_capacity(_CORES)
NameError: name 'plan_self_check' is not defined
原因:_PLAN = plan_capacity(_CORES) 在模块级执行,而 plan_self_check() 定义在它之后。
修法:抽出自包含的 _self_check_plan_values(limit, shards, cost) 放在 plan_capacity() 之前(内部 import math),plan_self_check(plan) 变成薄封装。
教训:模块级初始化代码不能调用后面才定义的函数。
坑 3:自检函数对畸形入参抛 KeyError
第一版写成 plan['cpu_limit'] 直接下标取值,喂 {} 就崩:
File "/app/server.py", line 129, in plan_self_check
return _self_check_plan_values(plan['cpu_limit'], plan['cpu_shards'],
KeyError: 'cpu_limit'
自检函数面对畸形输入必须返回 False,而不是抛异常把 /stats、/plan 一起打挂。
改成 isinstance(plan, dict) 前置校验 + .get()。
坑 4:cpu_source 串太长,上报报文不可读
把「自己 cgroup 不可读的完整路径」拼进 source 后:
probe(threads=8,rounds=3)(自己cgroup不可读:v1-self-path-unreachable:/cnb-arg-1k36ken0h-001/bc348e…b7bf2)
这个串会随报文送给调度器。改成短标签 probe(threads=8,rounds=3)(self-cgroup-unreadable),细节留注释里。
坑 5:改的是 COPY 进镜像的文件,restart 不生效Dockerfile 是 COPY app/ /app/,改完 app/*.py 必须 docker compose up -d --build。(挂载进来的 ops/、client/ 则不用重建。)
坑 6:实测口径依赖计时,必须验证稳定性
黑盒实测会被机器负载影响。补偿手段:每轮 0.3s × 3 轮取最大;线程数按 sched_getaffinity(这里 8);交叉校验容忍因子给 1.35。并且连跑多次 + down -v 重建都要绿才算过。
坑 7:/resize 之后忘记 /replan,测试会串/resize 是人工覆盖,/replan 才重新探测。判定脚本 B 节会把规划改成 2 核,中途失败留在 2.0 会让后续 A/C 节读到被覆盖的值。手工打接口调试后记得 curl -X POST .../replan。
坑 8:ops/apply_fix.sh 不带参数是「应用参考解」而非「还原」
场景自带的 ops/apply_fix.sh 裸跑会覆盖你自己写的实现。还原要用 --restore。本解法的可重放脚本在 myfix/(patch_server.py 支持 --restore)。
6.3 一句话自查法(线上 5 分钟)
在怀疑「容器 CPU 核数读错」的服务里跑:
# 1) 我读的是谁?(固定路径 vs 自己那一层)
cat /proc/self/cgroup | grep -E 'cpu|cpuacct'
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us /sys/fs/cgroup/cpu/cpu.cfs_period_us
ls -d /sys/fs/cgroup/cpu/<上一步的相对路径> || echo "自己那一层不可达"
# 2) 到底有几个核是真的?(唯一可信口径)
python3 -c "
import resource,threading,time
def b(s):
x=0.0
while time.time()<s:
for _ in range(2000): x+=1.5
best=0
for _ in range(3):
r0=resource.getrusage(resource.RUSAGE_SELF);t0=time.time()
ts=[threading.Thread(target=b,args=(t0+0.3,)) for _ in range(8)]
[x.start() for x in ts];[x.join() for x in ts]
w=time.time()-t0;r1=resource.getrusage(resource.RUSAGE_SELF)
best=max(best,((r1.ru_utime-r0.ru_utime)+(r1.ru_stime-r0.ru_stime))/w)
print('real cores = %.3f'%best)"
# 3) 服务上报的值和它一致吗?
curl -s localhost:8800/stats | python3 -m json.tool | grep -E 'cpu_limit|cpu_source'
只有第 2 步的实测值是可信基准;第 1 步读到的东西只有「等于实测」时才能用。
6.4 修复 checklist
- 读 cgroup 前先按
/proc/self/cgroup定位自己那一层,读不到不退回根目录 - 拿不到配额就用
getrusage(RUSAGE_SELF)实测自证(不要用cpuacct.usage) - 静态读数与实测冲突时以实测为准(报高的危害远大于报低)
- 兜底值只向下,绝不向上
- 自检必须有判别力,且判别力本身要被坏输入测过
- 容量规划用向上取整,保证分片总容量装得下上报核数
- 上报报文里的
source用短标签,长排障信息留日志/注释
七、复现命令速查
git checkout 2026-09-23-1600
cd scenario/2026-09-23-1600
# 问题态
cp app/cpu_capacity.py.problem app/cpu_capacity.py
cp app/server.py.problem app/server.py
docker compose up -d --build && sleep 4
bash ops/verify.sh # PASS=17 FAIL=4
docker compose exec -T app python3 /client/check_capacity.py # 判定: BAD
# 应用本解
cp myfix/cpu_capacity.py app/cpu_capacity.py
python3 myfix/patch_server.py
docker compose up -d --build && sleep 4
bash ops/verify.sh # PASS=21 FAIL=0
docker compose exec -T app python3 /client/check_capacity.py # 判定: OK
# 反向验证
python3 myfix/patch_server.py --restore
cp app/cpu_capacity.py.problem app/cpu_capacity.py
docker compose up -d --build && sleep 4 && bash ops/verify.sh # 又变红
docker compose down -v
相关文件:
- 场景:
scenario/2026-09-23-1600/ - 完整操作记录:
scenario/2026-09-23-1600/SOLUTION.md - 可重放补丁:
scenario/2026-09-23-1600/myfix/
