# 架构守则(不变式)

> 本文是从真实 P0 事故里蒸馏出的**硬性约束**,不是风格建议。每条都标注了它的「学费」——哪次事故教会我们的。
> 违反任何一条的 PR/发布,默认打回;要破例,必须在提交说明里写清为什么本条不适用。
> 事故经过详见 [P0复盘.md](P0复盘.md);发版执行步骤见 [发布门禁.md](发布门禁.md)。

## R1 一切依赖必须锚定,一切发布必须可回滚

- 实例镜像每个 release 都打版本 tag(`vX.Y.Z`),`:latest` 只是别名,**绝不能是用户唯一能拿到的东西**。
- base 镜像逐步 pin 到 digest;apt 装的关键包(chromium 等)版本写进构建日志,让「这次镜像里是什么」可追溯。
- 面板应记录每个实例运行的镜像版本;「升级」= 升到具体版本,并支持回退上一版。
- release notes 必须包含回滚指引。**发不出回滚指引的变更,就是还没准备好发布。**

学费:v1.3.0 重建镜像时 apt 静默拉到新 Chromium,叠加 DPI bug 全面崩溃;用户喊「还是 1.2.7 稳」——但 `:latest` 已被覆盖,**想回也回不去**。

## R2 自身状态声明式重建,禁止克隆运行时环境

面板自更新、实例重建,一律从**声明的配置**(代码里的 createOpts)重新生成,绝不 inspect 旧容器再克隆其字段。运行时衍生值(Hostname、IP、自动生成的 MAC……)克隆出来就是过期的。

学费:v1.2.6 自更新克隆了旧容器 Hostname(= 已删除容器的 id)→ `ensureNetwork()` 找不到自己 → 新实例落到错误网络 → 全员 502 黑屏。

## R3 任何可写路径必须有边界

- 面板创建的每个容器必须带 `LogConfig`(json-file 上限)。
- 每个缓存/转储/临时目录必须有 TTL 或容量上限(微信 crashinfo、chromium 缓存……)。
- 升级/重建后必须回收悬空镜像。
- **新功能引入新的可写路径时,PR 里必须写明它的边界是什么。** 没写 = 打回。

学费:崩溃循环每 2s 刷错 × docker 日志默认无上限 × 崩溃转储无限堆积 × 旧镜像不回收 → 群晖用户「一下子 1TB 没了」→ 磁盘满 → 升级也卡死,恶性循环。

## R4 预期超过 5 秒的操作一律异步

长任务(拉镜像、批量升级、自更新)必须:立即返回 → 后台执行 → 提供进度查询。同步 HTTP 等待在受限网络下(NAS、被墙)必然表现为「卡死」。凡是内部含网络 IO 循环的接口,按最坏网络(每步都等到超时)估时。

学费:一键升级同步等全程、且每实例各拉一次镜像,受限网络下每次拉取都要等满 5 分钟停滞超时 → 用户反馈「完全无法更新实例,一直卡死」。

## R5 面板与实例是独立发布单元,兼容矩阵必须显式处理

面板(新/旧)×实例镜像(新/旧)×数据卷(新/旧)的组合**全部真实存在于用户环境**。因此:

- 面板调用实例内工具前必须能力探测(`assertHasTool` 模式),探测失败给出「请先升级实例」的人话,而不是让用户看退出码 127。
- 实例镜像必须兼容旧数据卷(路径迁移写成幂等脚本)。
- 涉及实例镜像的修复,发布说明必须显著提示「需升级实例」——**更新面板 ≠ 更新实例**,用户不知道这一点。

学费:壁纸功能对旧镜像实例报「退出码 127」;#101 把配置写到 `/home/abc` 而实例 HOME 是 `/config`。

## R6 根因的标准:解释 100% 的症状 + A/B 实证

- 一个理论若只解释部分症状(「Chromium 崩但微信也崩了没解释」),它就不是根因,**最多作为缓解措施发布,且必须标注为缓解**。
- 修复上线前必须有 A/B 复现:坏配置 → 复现崩溃;改一处 → 崩溃消失。「改了之后好像好了」不算。
- 多个 Chromium 内核应用同时异常时,先查共享环境(XSETTINGS/DPI/X server/共享库),再怀疑单个应用版本。

学费:v1.3.1 用「SwiftShader/shader 缓存」理论发了 `--force-device-scale-factor=1`——恰好治住 Chromium 的症状,但微信 CEF 加不了 flag,真根因(XSETTINGS 的 Xft/DPI 单位是 DPI×1024,写 96 = 0.09 DPI)第二天在微信身上继续爆炸。

## R7 外部 / AI 生成 PR:审语义,不只审形状

代码「看起来对」不等于对。逐项核:

- **数值的单位**(本项目血案:Xft/DPI 是 DPI×1024);
- **路径的真实性**(HOME 是 `/config` 不是 `/home/abc`);
- 写死的镜像源/域名(TUNA mirror 会弄坏海外 CI);
- 用户输入拼进 shell 的注入面;
- 依赖/base 镜像的变更是否必要。

学费:#101 一个 PR 同时踩中前四项,其中单位错误演化成史上最大 P0。

## R8 发布节奏:特性不当天发,P0 热修最小化

- **特性合并与发布之间至少隔一晚**,在本地/自用环境烘烤。一天多个 release 只允许出现在 P0 热修场景。
- P0 热修 = 最小 diff:只含修复本身,不夹带特性(夹带的特性没经历烘烤,是下一个 P0)。
- 每次发布前过一遍 [发布门禁.md](发布门禁.md),没有例外。

学费:v1.2.8→v1.3.1 四个版本挤在 48 小时,合并的社区 PR、壁纸/字体、GPU 直通、会话持久化全部同窗发布——事故爆发时无法二分是哪个变更引入,排查成本 ×N。

## R9 冒烟必须覆盖「用户动作」,不是「进程启动」

「应用启动了」≠「应用可用」。实例镜像的固定探针(写在发布门禁里):

- 裸 `chromium`(**不带任何 scale flag**)在 `:1` 上存活 8s——这是微信内嵌 CEF 的等价探针,微信本体没法在 CI 里登录,但它的浏览器内核读的和裸 chromium 是同一套环境;
- `dump_xsettings` 校验 `Xft/DPI 98304`;
- 微信模式 autostart 走到「等待安装」且 30s 无重启循环;
- **升级路径测试:拿上一个 release 的容器+数据卷,原地升级到新镜像,应用仍可用**——用户做的是升级,不是全新安装,而我们过去只测了后者。

学费:DPI bug 逃过了「微信启动正常」的冒烟,因为聊天主窗口是原生的,崩的是「点开公众号」这个动作。

## R10 重启必须幂等:自愈/重启绝不隐式换版本

「重启」「卡死自愈」「看门狗自愈」重建容器时必须沿用该实例**当前正在跑的镜像**(`runInstance(inst, { keepImage: true })`),绝不因本地 `:latest` 恰好被某次拉取更新过就悄悄换镜像。换镜像只允许发生在显式「升级实例」。同理,任何"恢复类"操作(自愈、重连、重启)都不得顺带引入版本/配置变更——用户没点升级,系统就不能升级。

学费:v1.3.0 坏镜像时期,本地 `:latest` 被某次操作拉新后,一次看门狗自愈/用户点重启就把**从没点过升级**的实例隐式换到坏镜像——"我什么都没动它怎么坏了"。
