diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d84af4f..1bb70ae 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -56,8 +56,19 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + # 层缓存的配额账(2026-09-22 实测,别再改回去): + # `mode=max` + 不写 scope 时,本工作流每跑一次就往 Actions 缓存里塞一份 + # ~3.2 GiB 的全量快照,且**每次的 blob key 都不一样**(内容含构建期元数据), + # 于是旧快照变成没人再引用但仍计配额 near-duplicate 垃圾。 + # 今天三次构建(main push / v2.2.4 tag push / 又一次 main push)之后: + # 仓库缓存 9.64 GiB / 配额 10 GiB,其中 9.52 GiB 是 12 条 buildkit-blob, + # 全挂在 refs/heads/main 下。副作用不是"docker 慢一点",而是 + # **LRU 先把小缓存挤掉** —— 性能门禁的 benchmark 基线(2.8 KB)存进去两小时就 + # `Cache not found`,12 平台矩阵的 setup-python/pip 缓存也没了。 + # mode=min 只缓存"下一步要用到的"层(base 镜像与依赖层,正是耗时的部分), + # 不缓存中间产物;固定 scope 让 push/PR/tag 共用一个桶而不是各自开新桶。 + cache-from: type=gha,scope=tts-mm + cache-to: type=gha,mode=min,scope=tts-mm # provenance=false:跳过 SBOM/provenance 内联,缩小镜像并避免与单架构冲突 provenance: false diff --git a/benchmarks/README.md b/benchmarks/README.md index 8e36376..cb547ee 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -4,7 +4,7 @@ | 层级 | 位置 | 持久性 | 用途 | |------|------|--------|------| -| L1 热缓存 | CI `actions/cache` (`output/benchmarks/`) | **实测两小时就被驱逐**(仓库缓存 10.23 GB / 配额 10 GB) | PR 回归对比的第一顺位,命中就用、不命中退 L3 | +| L1 热缓存 | CI `actions/cache` (`output/benchmarks/`) | **实测两小时就被驱逐**(配额 10 GiB、占用 9.64 GiB,其中 **9.52 GiB 是 `docker-publish.yml` 的 buildkit 层缓存** —— 大缓存把它吃满,LRU 先牺牲的却是这条 2.8 KB 的基线) | PR 回归对比的第一顺位,命中就用、不命中退 L3 | | L2 发布资产 | ~~GitHub Release Assets~~ | — | **已删**:触发器决定那两步永远不会执行,见下面"更新流程" | | L3 仓库基线 | `benchmarks/baseline.json` | 版本控制 | **当前真正稳定生效的一级**:手动锚定的基线,可 code review,随 checkout 就在 | diff --git a/tests/test_docker_cache_budget.py b/tests/test_docker_cache_budget.py new file mode 100644 index 0000000..f398a91 --- /dev/null +++ b/tests/test_docker_cache_budget.py @@ -0,0 +1,62 @@ +"""Docker 层缓存不能把 Actions 缓存配额吃掉(2026-09-22 的账)。 + +起因:性能门禁的基线缓存在 main push 之后两小时就 `Cache not found`(issue #118 的后续), +量下来仓库缓存 9.64 GiB / 配额 10 GiB,其中 **9.52 GiB 是 12 条 `buildkit-blob-*`**, +全部来自 `docker-publish.yml` 的 `cache-to: type=gha,mode=max`(无 scope)。 +LRU 驱逐的顺序是先牺牲小缓存(benchmark 基线 2.8 KB、setup-python 的 pip 缓存), +所以症状出现在别的作业上,凶手在另一个工作流里。 + +这条守卫盯的是**配置形状**(`mode=min` + 固定 `scope`),不是运行时用量—— +用量只能靠人看 API,形状可以让它自己漂不回去。 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +_WF = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "docker-publish.yml" + + +def _build_step() -> dict: + doc = yaml.safe_load(_WF.read_text(encoding="utf-8")) + steps = [s for j in doc["jobs"].values() for s in (j.get("steps") or [])] + hit = [ + s + for s in steps + if "docker/build-push-action" in str(s.get("uses", "")) and (s.get("with") or {}).get("cache-to") + ] + assert hit, "docker-publish.yml 里找不到带 cache-to 的 build-push-action 步骤——守卫的对象变了,要同步改这里" + assert len(hit) == 1, f"带缓存的构建步骤有 {len(hit)} 条,本测试只核一条:其余要显式加进断言" + return hit[0]["with"] + + +def test_layer_cache_is_mode_min_with_a_fixed_scope() -> None: + with_ = _build_step() + cache_to, cache_from = str(with_["cache-to"]), str(with_["cache-from"]) + assert "mode=min" in cache_to, ( + f"cache-to 回到了 {cache_to!r}:mode=max 会把每个中间层都存成一份快照," + "实测每次构建 ~3.2 GiB、三次就把 10 GiB 配额吃满,还把 benchmark/pip 的小缓存挤掉" + ) + scope_to = re.search(r"scope=([\w.-]+)", cache_to) + scope_from = re.search(r"scope=([\w.-]+)", cache_from) + assert scope_to and scope_from, ( + f"cache-to/cache-from 少 scope(to={cache_to!r} from={cache_from!r}):" + "默认 scope 会按 ref 分桶,main / PR / tag 各存一份,等于把配额乘三" + ) + assert scope_to.group(1) == scope_from.group(1) == "tts-mm", ( + f"scope 两边必须一致且是写死的常量,实际 to={scope_to.group(1)!r} from={scope_from.group(1)!r}" + ) + # 只查生效行:上面那段解释注释里当然会出现 "mode=max"(那正是被禁的写法) + live = [ln for ln in _WF.read_text(encoding="utf-8").splitlines() if not ln.lstrip().startswith("#")] + assert not any("mode=max" in ln for ln in live), "有生效行(非注释)重新用上了 mode=max" + + +def test_guard_is_not_vacuous() -> None: + """把配置改坏的形状必须真的让上一条红——否则这条守卫只是装饰。""" + with_ = _build_step() + broken = str(with_["cache-to"]).replace("mode=min", "mode=max") + assert broken != str(with_["cache-to"]), "替换没生效,说明 mode=min 不在 cache-to 里" + assert "mode=min" not in broken and "mode=max" in broken