docs(dev): 架构守则 + 发布门禁 + P0复盘簿 —— 把历次 P0 的学费固化成硬约束

从 v1.2.6 自更新黑屏、v1.2.9~v1.3.1 DPI 单位事故、磁盘暴涨、升级卡死等真实 P0 中
蒸馏出 9 条架构不变式(R1~R9):版本锚定可回滚、声明式自重建、资源必有边界、
长任务异步化、面板×实例兼容矩阵、根因=解释全部症状+A/B实证、AI PR 审语义、
发布节奏(特性不当天发)、冒烟测用户动作。发布门禁给出逐条可执行的命令清单
(含微信 CEF 等价探针、升级路径测试);P0 复盘簿记录每次事故的流程性成因。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-07-06 23:37:48 +08:00
parent 94b591e0b2
commit 7ec79314bb
3 changed files with 209 additions and 0 deletions

34
doc/dev/P0复盘.md Normal file
View File

@@ -0,0 +1,34 @@
# P0 复盘簿
> 每次 P0 修完在这里记一笔:症状 → 根因 → 为什么会发生(流程层面) → 对应守则。
> 目的不是追责,是让同一类错误只交一次学费。新条目追加在最上面。
## 2026-07 · XSETTINGS DPI 单位错误(史上最大,v1.2.9~v1.3.1)
- **症状**:微信点公众号/附件/小程序窗口秒关或白屏(amd64/arm64 都中);Chromium 实例整体黑屏崩溃循环;issue #111 及大量群反馈。
- **根因**:#101 引入的 xsettingsd 配置写 `Xft/DPI 96`,而 XSETTINGS 规范单位是 **DPI×1024**(应为 98304)。所有 Chromium 内核应用把 DPI 读成 0.09 → 缩放因子≈0 → 显示变换矩阵不可逆(`transform.cc NOTREACHED`)→ GPU/viz 进程连崩 → 窗口/应用退出。
- **连带**:崩溃风暴 × docker 日志无上限 × 崩溃转储无 TTL × 悬空镜像不回收 → 用户磁盘被吃(「1TB 没了」);同步升级接口在受限网络下表现为「一键升级卡死」。
- **为什么会发生**:AI 生成 PR 的配置数值未对规范验证(R7);冒烟只测了「微信启动」没测「点开公众号」(R9);第一次修复(v1.3.1 的 `--force-device-scale-factor=1`)用只解释部分症状的理论当根因发布(R6);四个版本挤在 48h 发布,事故窗口内变更太多难以二分(R8)。
- **修复**:`94b591e`(单位改 98304 两处写入点 + 日志上限 + 转储 TTL + 悬空镜像回收 + 升级异步化)。
- **守则**:R3 / R4 / R6 / R7 / R8 / R9。
## 2026-06-25 · 面板自更新后全员 502 黑屏(v1.2.6 修复)
- **症状**:用面板内「一键更新」升级的用户,更新后所有实例远程桌面黑屏、「桌面服务暂不可用」;直接 `docker pull` 的用户无恙。
- **根因**:自更新克隆旧容器配置时把 `Hostname`(= 旧容器短 id)一并克隆;新面板容器的 hostname 于是指向一个已删除的容器 → `ensureNetwork()``getContainer(hostname())` 自我定位 404 → 返回 null → 新建实例落到默认 bridge 网络 → 面板按容器名反代解析不到 → 502。
- **为什么会发生**:自身状态靠「克隆运行时环境」而非声明式重建;自我定位单点依赖 hostname 无兜底。
- **修复**:`7e21aca`(重建不再 pin Hostname;ensureNetwork 增加按容器名 `woc-panel` 兜底)。
- **守则**:R2。
## 2026-06~07 · 桌面连接/重连卡死连环修(4 连击)
- **症状**:切实例 A→B→A 卡死、重连后 Xvnc 卡死、输入模式切换不生效等,先后修了 4 次(`13ec0bd``855997f``7fecc09``b0511f5`)。
- **根因**:noVNC/iframe 的连接生命周期管理散落多处,每次只修当时复现的那条路径。
- **为什么会发生**:补丁式响应,没有对「连接状态机」做一次性的不变式设计;每个补丁都没解释之前补丁为何不够(R6 的弱版本)。
- **守则**:R6;同类问题第三次出现时必须停下来做整体设计,而不是继续打第四个补丁。
## 2026-06 · 深色模式 portal 方案发布后撤回
- **症状**:portal 实时切换深色在极简容器里 session 总线不稳,微信也不跟随;发布(`335b839`)后撤回(`ba32cc9`)。
- **为什么会发生**:方案依赖容器里并不可靠的桌面基础设施,发布前未在目标环境充分验证。
- **守则**:R9(验证用户动作,而非「代码跑通」)。

91
doc/dev/发布门禁.md Normal file
View File

@@ -0,0 +1,91 @@
# 发布门禁(逐项执行,全绿才能 release)
> 配套 [架构守则.md](架构守则.md)。本清单是**可执行命令**,不是原则复述;每次发版逐条跑,贴结果。
> 铁律:**绝不发布未经测试的正式 release**(远程桌面回归的教训)。
## 0. 仓库状态
```bash
# 同步盘(飞牛/iCloud)可能弄出冲突副本、回滚文件内容——先验明正身
find . -path ./node_modules -prune -o -name '*冲突副本*' -print # 必须为空
git status --short # 无意外改动
git log --oneline -3 # HEAD 是你以为的那个
```
## 1. 构建(本地)
```bash
# --provenance=false 必须带:Docker 29+containerd 下缺它会让实例重建仍用旧镜像(踩过)
docker build --provenance=false --sbom=false -t ghcr.io/gloridust/wechat-on-cloud:latest ./docker
docker build --provenance=false --sbom=false --build-arg WOC_VERSION=dev-$(git rev-parse --short HEAD) \
-t ghcr.io/gloridust/woc-panel:latest ./panel
cd panel/server && npx tsc --noEmit && cd ../web && npx tsc --noEmit && npm run build
```
## 2. 实例镜像探针(每条都跑)
```bash
docker rm -f woc-gate 2>/dev/null
docker run -d --name woc-gate --security-opt seccomp=unconfined --shm-size=1g \
-e PUID=1000 -e PGID=1000 -e WOC_APP_TYPE=chromium ghcr.io/gloridust/wechat-on-cloud:latest
sleep 25
# ① XSETTINGS DPI 单位(96 DPI = 98304;写 96 会让一切 CEF 崩溃——史上最大 P0)
docker exec woc-gate bash -c 'DISPLAY=:1 dump_xsettings | grep "Xft/DPI 98304"' || echo "❌ DPI"
# ② 微信 CEF 等价探针:裸 chromium、不带任何 scale flag、存活 8s
docker exec woc-gate bash -c '
rm -rf /tmp/probe; DISPLAY=:1 chromium --no-sandbox --disable-gpu --user-data-dir=/tmp/probe about:blank >/dev/null 2>&1 &
p=$!; sleep 8; kill -0 $p 2>/dev/null && echo "✓ CEF探针存活" || echo "❌ CEF探针崩溃"; kill -9 $p 2>/dev/null'
# ③ 崩溃循环检测:干净窗口 30s 内退出次数必须为 0
sleep 30; docker logs --since 30s woc-gate 2>&1 | grep -c '已退出' # 期望 0
# ④ 微信模式走到"等待安装"
docker rm -f woc-gate-wx 2>/dev/null
docker run -d --name woc-gate-wx --security-opt seccomp=unconfined --shm-size=1g \
-e PUID=1000 -e PGID=1000 -e WOC_APP_TYPE=wechat ghcr.io/gloridust/wechat-on-cloud:latest
sleep 25; docker logs woc-gate-wx 2>&1 | grep -E "尚未安装|启动 微信" # 必须命中
docker rm -f woc-gate woc-gate-wx
```
## 3. 升级路径测试(用户做的是升级,不是全新安装)
拿一个**上一 release 镜像**跑起来的实例(带已有数据卷),用面板「升级实例」原地升到新镜像,然后:
- 应用正常启动、无重启循环;
- 若涉及微信:进入桌面手动点开一篇公众号文章/一个附件,窗口能打开(DPI 事故正是死在这一步);
- 面板对该实例的「可升级」角标消失。
## 4. 面板部署验证
```bash
docker compose up -d && sleep 4
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:36080/ # 200
```
- 登录 → 进入一个实例 → 桌面出画面;
- 「管理」页加载正常(升级横幅/角标状态正确);
- 新建的容器 `docker inspect <c> --format '{{.HostConfig.LogConfig}}'` 含 max-size(R3)。
## 5. 发布
```bash
git push origin main
gh release create vX.Y.Z --target main --title "..." --notes "..."
```
release notes 必须写清:
1. **谁受影响、什么症状**(用户的语言,不是代码的语言);
2. **要不要升级实例**——更新面板 ≠ 更新实例,每次都要显式说;
3. **回滚指引**(R1):出问题如何回到上一版。
发布后:确认 CI(release.yml)双架构构建成功,再在相关 issue 回复(回复前经用户确认)。
## 6. P0 热修的特别约束
- diff 最小化:只含修复,不夹带任何特性;
- 理论必须解释**全部**已观测症状,且有 A/B 实证(R6),否则只能作为「缓解」发布并如实标注;
- 修完在 [P0复盘.md](P0复盘.md) 记一笔:症状 → 根因 → 为什么会发生 → 哪条守则能防它(没有就加一条)。

84
doc/dev/架构守则.md Normal file
View File

@@ -0,0 +1,84 @@
# 架构守则(不变式)
> 本文是从真实 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 逃过了「微信启动正常」的冒烟,因为聊天主窗口是原生的,崩的是「点开公众号」这个动作。