Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 就在 |

Expand Down
62 changes: 62 additions & 0 deletions tests/test_docker_cache_budget.py
Original file line number Diff line number Diff line change
@@ -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
Loading