| 档案 | |
|---|---|
module |
ops |
summary |
运维:HOOP 今天有七条互不相通的上线通道,只有后端那条没有脚本、靠人照手册敲 —— 而它热生效、用户无从选择不更新、还常带迁移。后端的部署单位是镜像(全树 rsync 到 /root/hoop/backend/ 再 up -d --build),迁移 SQL 是 go:embed 进二进制的,启动第一件事跑 migrate.Up;所以「云库 schema_migrations.version 比正在跑的镜像认识的最大号大」= 启动即退、restart: unless-stopped 无限重启、全站 502 —— 09-06 那次已证实是这一种;09-10 那次「库 369 / 镜像只认到 367」有日志证据,是哪一次重启触发、368 / 369 走的直插还是部署,未定。两层重启恢复 + 一层事后补记(compose 重启 / 宿主机 cron 看门狗连红三次 restart backend / 运维空间启动时补记「后端不可用 X 分钟」)没有一层会重建镜像,坏镜像只能等人 up -d --build。运维空间 = 后端 internal/ops 每分钟一行体温(18 项)写 ops_metrics、12 条规则(红 6 黄 6)开 / 收 ops_events(dedupe_key 开着不重喊、红色推 ADMIN_UIDS 手机、6 小时提醒但只是进程内的记时、日报吉隆坡 08:00 出但统计窗口经生产库 psql 实测(后端会话按服务器默认推定)不是吉隆坡自然日),App 侧栏只在探针 200 时画那一行;体温、事件、推送查设备三件事写的读的都是同一个库 —— 库持续不通那段既记不下也送不到,库短暂抖一下则可能记下并推;所以「没有 infra:db 事件」证明不了库没断过;它量不到磁盘(09-05 盘满一次 502 两分钟)、不量自己所在的机器;已核范围(crontab / cron.d / systemd timers / 仓库 / 后端 env)内没有机器外探活。生产库在已核范围内没有备份(pgdata 卷一份,/root/hoop/backups/ 只有一份 08-01 的清单,宿主机 cron / systemd timers 没有备份任务;launchd/com.hoop.backup.plist 是 Mac 时代的本机备份;未核:Vultr 控制台快照、别的机器主动拉取的异地备份)。新闻 spider 在宿主机 cron 每分钟 --once,docker exec psql 直接写库不经后端,版本目录 + 软链一次切换、有 --rollback,是七条通道里唯一有真回滚命令的。CI 只有两道:CHANGELOG 只许变长(报警不拦,直推 master 仍放行)和 spider 自检;没有任何 CI 编译后端或 analyze App。生产 CORS 反射任意 Origin(源码 + 09-11 线上一次 HTTP 实测)。 |
updated |
2026-09-11 |
verified_ref |
152b185a399a425bd93bcf7c9ce93e100db89e76 |
source_ref |
[deploy/{docker-compose.prod.yml,Caddyfile,watchdog.sh,README.md}, deploy/livekit/livekit.yaml, deploy/launchd/com.hoop.backup.plist, deploy/cloudflare/media-worker/worker.js, backend/Dockerfile, backend/cmd/api/main.go:96-130, backend/internal/platform/migrate/migrate.go, backend/migrations/{embed.go,LEDGER.md}, backend/internal/config/config.go:242-346, backend/internal/platform/storage/s3/s3.go:29-51, backend/internal/server/{middleware.go:5-13,router.go:403-409}, backend/internal/ops/{ops,collector,rules,handler}.go, backend/internal/domain/developer/{reconcile.go,developer.go:931}, app/lib/screens/ops_space_screen.dart, app/lib/widgets/hoop_space_drawer.dart:128-137, tools/{deploy_docs.sh,deploy_web.sh,deploy_newstools.sh,push_jeff.sh,changelog_guard.py,docs_index.py}, tools/tf-build/cut_build.sh, tools/news_spider_scheduler.py, tools/news_spider_base.py:163,514-516, .github/workflows/{changelog-guard,news-spider-selftest}.yml, docs/必读-部署上云TestFlight.md, docs/WORKFLOW.md:34-37,41-45,228-243, docs/必读-建Spider.md:323-346,415-428, CLAUDE.md:49-107, CHANGELOG.md:5258-5261,6097-6098, WIP.md:1341] |
status |
current |
review_after |
2026-12-11 |
owner |
nova |
supersedes |
[DEPLOY.md(2026-06 上云选型定稿,抬头仍写「⏳ 待执行 · 下一步开 Vultr」,而 api.hoopcomm.com 早已在线;选型结论 Vultr + R2 + Caddy 本文 §3.1 吸收,coturn 那条已被 LiveKit 内置 TURN 取代), DEPLOY_PVP_20260708.md(07-08 单次部署记录,LiveKit TURN 上线过程;结论进 §3.1,过程留 CHANGELOG), CHEATSHEET.md(「两个人开发 Jeff + Lisa」的速查卡,取号 / 部署两段被 必读-部署 和本文 §3.7 取代,其余是 WORKFLOW 的摘抄)] |
partially_supersedes |
[必读-部署上云TestFlight.md(整份是操作手册,原地留;只替代它自相矛盾的一处:§3 红字说「先 rsync 再 build、直插只在被越过时」,而同节代码块第 ① 步和 §0 表第三行仍写「先 SSH 直插云库」—— 以红字为准,见 §4), WORKFLOW.md(§2 五条通道概览留;§1 :35-36「推荐直插 + 手动改 version」和 §8 回滚手册 :238 那条带 --delete 的 rsync 都已陈,见 §4), PROD_ACCESS.md(进机器那半留;:63-66「推荐建完直接 SSH 直插」和 :74-80 hoop-prod 别名两段已陈), ARCHITECTURE.md / DATABASE.md / API.md(部署 / 迁移 / 域名那几段以本文为准,其余不动), WEB_VERSION_OPTIONS.md(选型留;「出包和部署的三个坑」已进 deploy_web.sh 抬头,本文 §2 只引), NEWS_PLATFORM.md(:18「怎么部署 / 灰度 / 回滚全部等平台定」和 :2417「❌ 部署与发版」已假 —— 09-09 起有 deploy_newstools.sh,见 §3.6), 必读-建Spider.md(整份留;§20 新源清单缺五步登记,§26 crontab 是手抄镜像,见 §4), 00-目录.md(:17 / :257 对 DEPLOY 的「待执行」批注可改成指到本文), CLAUDE.md(:49-70 七条通道表是唯一齐全的一份,本文 §2 照它写并补「谁验」;:104-107 橙云提醒已陈,见 §4)] |
这份怎么读:每一节先讲「它解决什么、谁会看到」,再讲「为什么这样做、状态在哪、哪一步会断、断了留下什么、有什么保障和缺口」;代码位置、参数、行号收在每段末尾的「出处」里。 出处标记:
[code@152b185a 文件:行]= 那棵树上的实现;[prod@日期,谁核]= 生产机当天只读核过;[decision@日期,谁]= 拍板过的决定。代码证明「那一版怎么做的」,证明不了「线上此刻是这样」—— 生产上跑的是某一刻 build 出来的镜像,和树的关系见 §0 第一条。生产事实的采集时刻、目标、命令和关键结果在 §7 那张表里,后来人可以照着再查一遍。 边界:哨兵 / MCP / 门铃归 sentinel.md;游戏发版本身(工作房game_submit/ promote / 对账器)归 games.md,本文只写它往运维空间报什么;新闻 spider 的抓取逻辑归必读-建Spider.md/NEWS_PLATFORM.md,本文只写它在生产机上怎么跑、怎么部署、怎么叫人;版本号与 Git 规矩归必读-版本号与Git.md,本文只列有几种号、谁发的。令牌 / 密钥 / .env 只写在哪个文件哪个键,不写值。
0. 给接手的人:三分钟读懂运维
先认十个词(旧文档和房里聊天把这几个词混得最厉害):
- 推了 / 上线了 = 两件事。「推了」= 进了 git;「上线了」= 线上那个地址 curl 得到你改的那行字。七条通道里 git push 一条都不会让任何东西上线(游戏线 09-08 真栽:48 个游戏「推了」两天,线上 1 个是新版,CLAUDE.md:53-65 那段)。
- 通道 = 改了哪一类东西就走哪一条上线路。今天七条:后端 D、App 真机 A、TestFlight C、H5 游戏 E、网页版、图纸站、spider(§2 表)。每条的「谁能做 / 生效多快 / 怎么验 / 能不能退」全不一样,别混着报进度(铁律 5)。
- 镜像 = 后端上线的最小单位。docker compose up -d --build backend 拿 /root/hoop/backend/ 当时的整棵树编一个新镜像;迁移 SQL 是编进去的,rsync 到服务器上没用,只有重建镜像才认得新号。
- 迁移号 / 云库版本 = backend/migrations/000NNN_*.up.sql 的六位号,和生产库 schema_migrations.version 那一个整数。后端每次启动先 migrate.Up:库的版本必须是镜像认识的号,否则 no migration found for version N,退出,重启,再退出。
- 版本标 / build 号 / 迁移号 / 版本戳(§3.7):kAppVersionLabel(v1.871 那种,App 和网页版的人看得到的标)· TestFlight build 号(苹果那边的整数,只由 NOVA 出)· 迁移号(上面那个)· 图纸站右上角的时间戳(每次 deploy_docs.sh 重生成)· 云库版本(schema_migrations.version 一个整数 + dirty)· spider 版本目录的时间戳(newstools.releases/<戳>)· 游戏的 live_version 整数。七种号,七个出处,没有一个能推出另一个。
- 看门狗 = 生产机宿主上 cron 每分钟跑一次的 deploy/watchdog.sh:/health 连续 3 次不是 200 就 restart backend。它在容器外,所以后端死透了它还在;它只会重启,不会重建镜像。
- 运维空间 = HOOP Space 侧栏「运维」那一屏 + 后端 internal/ops:体温(ops_metrics 每分钟一行)· 事件(ops_events,kind 八种,恢复是填 resolved_at)· 红色推管理员手机。只有 ADMIN_UIDS 看得到,普通用户侧栏里没有这一行。
- 体温 / 事件 / 日报 / 事故 = ops_metrics 一行 18 项 / ops_events 一行(kind 今天有 infra · tasks · games_summary · chat 四种规则事件,games · deploy 两种别的域报进来的,daily 日报,incident 补记;「恢复」不是一种事件,是把开着那行的 resolved_at 填上)/ 每天吉隆坡 08:00 之后第一次 tick 出的 kind=daily / 后端起来时发现上一行体温离现在 ≥ 3 分钟就补一条已恢复的红色 incident。事故是从体温缺口推断、事后补记的,不是对外 HTTP 连续不可用的实测;后端挂着的时候没人写。
- 灰云 / 橙云 = Cloudflare DNS 那一行有没有开代理。橙云 = CDN 边缘缓存,games.hoopcomm.com 靠它;灰云 = 直连 VPS,api.hoopcomm.com 必须灰云(LiveKit TURN 复用它的证书和 IP)。
- 对账 = 两种:游戏线的对账器(每 5 分钟探线上那版在不在,不在就退回上一个 build,往运维空间报红);迁移的「回读真实结构」(部署完 SELECT version + 查表 / 约束,别拿退出码当证据)。本文用到时点明是哪一种。
它保证到什么程度(每条后面是出处):
1. 后端上线 = 整树 rsync + 重建镜像 + 三件验证,没有脚本。rsync -az --exclude bin/loadtest/.git backend/ root@139.180.208.52:/root/hoop/backend/ → up -d --build backend → health 200 / 30 秒日志无 error / 「对象存储连接失败」为 0;每一步都是人照手册敲的 [code@152b185a docs/必读-部署上云TestFlight.md:174-198]。网页版 / 图纸站 / spider 三条各有一个带自检和核对的脚本(§2;核对范围各有边界,见 §2 尾),后端这条没有。
2. 迁移是镜像的一部分,库版本不能比镜像新。//go:embed *.sql 把整个 backend/migrations/ 编进二进制 [code@152b185a backend/migrations/embed.go:7-10];启动第一步 migrate.Up,失败直接 return err 进程退出 [code@152b185a backend/cmd/api/main.go:96-101] [code@152b185a backend/internal/platform/migrate/migrate.go:21-54];容器 restart: unless-stopped 就无限重启 [code@152b185a deploy/docker-compose.prod.yml:65]。09-06(库 355、树 354,CHANGELOG 自述)已证实是这一种;09-10(库 369、镜像建于 13:43 只认到 367,启动日志为证)是「库与镜像不兼容」这一种,但触发路径未定(见 §1.1) [code@152b185a CHANGELOG.md:5258-5261] [prod@2026-09-10,Nova]。
3. 两层重启恢复 + 一层事后补记,没有一层会换镜像。① compose restart: unless-stopped(9 个容器全有)只重启;② 看门狗连红 3 次 restart backend,之后每 3 次再来一次 [code@152b185a deploy/watchdog.sh:8-18];③ 运维空间只记录不可用窗口 [code@152b185a backend/internal/ops/collector.go:153-172]。镜像坏了三层都在原地打转,直到有人 up -d --build。09-10 那次看门狗 06:43 重启了一次没用,06:45 别人的 build 完成才恢复 [prod@2026-09-11,Nova:/root/hoop/watchdog.log 末三行]。
4. 运维空间每分钟量 18 项、12 条规则、红色推手机 —— 但整套东西和它监控的库是同一个库。每分钟 ping 库 / Redis / 对象存储(5 秒 / 5 秒 / 10 秒超时)+ 七个 SQL 计数 + 聊天八列 [code@152b185a backend/internal/ops/collector.go:72-118];规则全在 rules.go,红色 6 条(库 / Redis / 存储各一条、游戏线上探不到、发件箱积压 ≥ 10、一小时发送失败 ≥ 100),黄色 6 条 [code@152b185a backend/internal/ops/rules.go:14-52];同一 dedupe_key 开着就 ON CONFLICT DO NOTHING 不重开,新开的红色立刻推、开着的每 6 小时再推一次,恢复推一次「✅ 已恢复」[code@152b185a backend/internal/ops/ops.go:131-184]。推的对象 = ADMIN_UIDS 里有 push_token 的人 [code@152b185a backend/internal/ops/ops.go:190-196]。同源故障盲区:库 ping 失败时 measure 直接返回、写体温失败只记日志、Report 先 INSERT ops_events 失败就 return 到不了推送、pushAdmins 查设备也读同一个库 —— 所以 infra:db 这条规则存在,不等于库断了你会收到告警:ping 失败之后、INSERT / 查设备之前库若已恢复,事件仍可能落下并推;库持续不通那段,事件记不下、通知送不到,库恢复后规则判「不开」,什么都不留。判据:没有 infra:db 事件 ≠ 库没断过 [code@152b185a backend/internal/ops/collector.go:62-70,88-90] [code@152b185a backend/internal/ops/ops.go:138-146,194-200]。七个 SQL 计数的错误全被 _ = 吞掉,查询失败得到的 0 和「确实没有」长得一样。/v1/admin/ops/now 查体温失败照样回 200,at 为空、stale=true,200 只说明接口活着 [code@152b185a backend/internal/ops/handler.go:56-69]。
5. 它量不到的:磁盘(09-05 构建缓存 71 GB 把盘吃满,Redis MISCONF,502 两分钟,是人清的)[code@152b185a CHANGELOG.md:6097-6098];它自己所在的机器(采集器在后端进程里,后端一挂它先死);机器外的探活在已核范围内没有(看门狗和它都在同一台 VPS 上;宿主机 crontab / cron.d / systemd timers / 仓库 / 后端 env 里没有;未核:Vultr 控制台自带的监控、Cloudflare 那边有没有配健康检查、任何第三方服务)[prod@2026-09-11,Nova];没有 Sentry / 错误聚合,排障靠 docker logs grep。
6. 生产库在已核范围内没有备份,也没有恢复演练的记录。已核:pgdata 卷一份 [code@152b185a deploy/docker-compose.prod.yml:15-16,185-186];/root/hoop/backups/ 只有 zip_lb_before_wipe_20260801.txt 一份 495 字节的清单;root 的 crontab 五行、/etc/cron.d 三个、/etc/cron.{hourly,daily,weekly}、systemd timers 十二个,没有一条是备份;/root 和 /root/hoop 顶层没有 dump / snapshot 文件 [prod@2026-09-11,Nova];仓里那份 launchd/com.hoop.backup.plist 是 Mac 时代每天 03:30 跑 ~/.hoop/backup.sh 的本机备份,和 VPS 无关 [code@152b185a deploy/launchd/com.hoop.backup.plist:1-10]。未核:Vultr 控制台有没有开自动快照、有没有别的机器在定时 pg_dump 拉走、深层目录。库现在 2363 MB、版本 373 [prod@2026-09-11,Nova]。
7. 能退的只有 spider 和游戏。spider:deploy_newstools.sh --rollback 一次 rename 切软链 [code@152b185a tools/deploy_newstools.sh:6-7,72-79];游戏:Cloudflare Pages 历史 + 对账器自动退 [code@152b185a backend/internal/domain/developer/reconcile.go:10-14]。后端「回滚」= 拿旧 commit 再走一遍通道 D,但旧树必须仍带着云库当前版本号的迁移文件、旧代码要和现在的表结构兼容,否则旧镜像起来照样 no migration found(WORKFLOW §8 那条命令带 --delete,别照抄,见 §4);迁移只许向前修(down 常有 DROP);网页版 / 图纸站 = 重发一次旧树。
8. CI 拦不住坏提交。changelog-guard.yml 是「推之后几十秒喊一声」,不是闸 —— 直推 master 仍放行,要拦得禁直推 + 必须过检查,留给 Jeff 拍板 [code@152b185a .github/workflows/changelog-guard.yml:7-11];news-spider-selftest.yml 只在 spider 那几个路径变时跑 [code@152b185a .github/workflows/news-spider-selftest.yml:9-19]。没有 CI 跑 go build / go test / flutter analyze——「编译过」全靠各人自己机器。
9. 密钥全在 git 外的六个位置(只写位置):生产 deploy/.env(25 个键,模板 deploy/.env.example)+ deploy/secrets/apns.p8 [prod@2026-09-11,Nova];本机 ~/.hoop/cloudflare.env(Pages 令牌)[code@152b185a tools/deploy_web.sh:36-38]、~/.hoop/nova/build/build.env(签名钥匙串口令)[code@152b185a tools/tf-build/cut_build.sh:46-49]、~/.hoop/keychain_pw(通道 A)、~/.claude.json 里 MCP 那个 Authorization(sentinel.md)。铁律 12 说的「--delete 会删掉 .env / apns.p8」就是前两个。
10. 生产 CORS 反射任意 Origin,注释自己写着「MVP;生产应改成白名单」[code@152b185a backend/internal/server/middleware.go:5-13];09-11 拿一个不存在的 Origin 打线上 /health,回包 access-control-allow-origin 原样回显那个 Origin [prod@2026-09-11,Nova]。WS 那边另有 HOOP_WS_ALLOWED_ORIGINS [code@152b185a backend/internal/config/config.go:335]。
1. 两段流程的一生
1.1 一次后端上生产:改完 → rsync → build → 验 → 报(含 09-10 那次怎么断的)
用户看到什么:什么都不该看到。后端热生效,App 不用更新;唯一会看到的是出事时的 502 / 「录音服务不可用」这类兜底文案。
一步步(手册 必读-部署上云TestFlight.md §3 是操作版,这里讲每步为什么、断了怎样):
1. 先对齐树:git pull。整树 rsync 的意思是「我这棵树 = 生产」,树落后就把别人刚部署的修复推回旧版,而且全绿(08-16 真发生)[code@152b185a docs/必读-部署上云TestFlight.md:202-205]。
2. 比一眼云库和树:SELECT version, dirty FROM schema_migrations 对 ls backend/migrations | grep -E '^[0-9]{6}_.*\.up\.sql$' | sort | tail -1(⚠️ 手册里那句裸的 ls | sort | tail -1 在这棵树上拿到的是 news_platform_test.go,不是迁移号),再单独 ls backend/migrations/<云库号>_*.up.sql 核云库当前号的文件就在树里。库 > 树就停;树里最大号比库大也不代表中间每个号都在。dirty=true 是另一支:上次迁移半途死了,golang-migrate 的 Up 见到 dirty 直接返回 ErrDirty,后端一样起不来(本机模块缓存 migrate/v4@v4.19.1/migrate.go Up 开头几行核过;库外依赖,不打 code 标)—— 这时别反复部署,先核那条失败的迁移和真实结构,按审定的恢复方案走;补文件 / 重建不会清 dirty,也不授权改 version。这一眼只证明「最大号」,证明不了树里低于它的每一份都跑过、也证明不了库里的结构和文件一致 —— 要证明得回读结构(第 8 步)。别自己改 schema_migrations:那个号的文件在别人的 worktree 里还没进 master,你 build 出来的镜像不认它,起来就崩 [code@152b185a docs/必读-部署上云TestFlight.md:161-172]。本文写的这一刻就是这种状态:树里最大文件是 000372,LEDGER 已登记到 000373 但文件不在 [code@152b185a backend/migrations/LEDGER.md:6],而云库已到 373 [prod@2026-09-11,Nova] —— 从这棵基线树部署会当场重演 09-10。
3. 本地编译:go build ./... && go vet ./...。带着别人未提交的改动也要能编 —— 工作区共享。
4. 看一眼盘:df -h。09-05 推之前 90/94 G,一 build 就 100% [code@152b185a WIP.md:1341]。今天 32/94 G、构建缓存 8.3 GB [prod@2026-09-11,Nova]。这一步手册里还没有,是 Liva 09-05 提的三条之一。
5. 全树 rsync,永不 --delete:--delete 会把 .env / apns.p8 一起删掉(铁律 12);所以这是覆盖式同步,不是「服务器 = git 树」—— git 里删掉的文件永远留在服务器上,而且会被 docker build 的 COPY . . 卷进镜像,08-18 同名函数重复声明 build 红过一次,处理用 mv 到 _stale_<日期>/ 不用 rm [code@152b185a docs/必读-部署上云TestFlight.md:211-224]。
6. up -d --build backend:多阶段 Dockerfile,golang:1.26-alpine 静态编译、alpine:3.20 运行、装 poppler 和中日韩字体、以 uid 10001 跑 [code@152b185a backend/Dockerfile:1-30]。新镜像起来第一件事 migrate.Up 把你的迁移跑掉 —— 这就是「不用 SSH 直插」的原因;只有「你的号 ≤ 云库版本、会被跳过」时才直插,而且要等新镜像起来 [code@152b185a docs/必读-部署上云TestFlight.md:250-262]。
7. 三件验证:health 200;30 秒日志无 error / fatal;grep -c "对象存储连接失败" 为 0 —— 对象存储是独立一段初始化,连不上只写一行 ERROR 照常起,健康 200、日志没别的红,而全站发不出图(08-22 半小时没人知道)[code@152b185a backend/cmd/api/main.go:117-124] [code@152b185a docs/必读-部署上云TestFlight.md:187-198]。那段初始化是建客户端 + 5 秒内 BucketExists(不存在就建)[code@152b185a backend/internal/platform/storage/s3/s3.go:29-51]:连接 / 超时类可以再重启一次让它重试初始化,成不成要验(短暂故障不保证下一次就过);凭据 / 端点 / 桶权限错重启一百次也一样 —— 先读那行 ERROR 里的 err 判因,再决定是重启还是改 .env,之后打一次 presign 验。改了具体接口再 curl 那个接口。
8. 回读:有迁移的回读 version 和真实结构(pg_get_constraintdef 那种),别看部署输出 —— 08-10 取了 197、云库到 199、迁移被静默跳过、部署全绿 [code@152b185a docs/必读-部署上云TestFlight.md:252-262]。
9. 报一声:CHANGELOG 一条 + 房里带证据(health / 容器数 / 日志 / 版本 / 你那条接口的响应),不是「推好了」。09-11 凌晨那次的报法可以照抄 [code@152b185a CHANGELOG.md:40]。
09-10 14:41 那次是怎么断的(留作范例,全程只读;下面每条标了是证据还是推断)[prod@2026-09-10,Nova;记忆 image-can-be-broken-while-the-tree-is-clean]:
- 证据:14:41 docker ps 里 backend Restarting (1),容器日志 migrate: no migration found for version 369;库当时已到 369;正在跑的那个镜像建于 13:43(docker images 的时间)。这三条合起来 = 库比镜像新。
- 推断(未定):368 / 369 是 14:28 / 14:30 进库的,走的是别人的部署还是 SSH 直插,本文没查到;老镜像那一次重启是别人 up -d --build 的中途、还是自己崩了一下,也没查到。别把这次写成「顺序错」或「文件级 rsync」—— 那是 09-06 那次的结论,不是这次的。
- 看门狗 06:41-06:43 UTC 数到 3 次,restart backend 一次,没用(还是那个镜像);06:44 fails=4 [prod@2026-09-11,Nova:watchdog.log]。
- 06:45 别人的两个 build(14:41、14:42)之一完成,新镜像认得 369,起来,recovered。
- 后端起来后 recordOutageIfAny 看到体温断了 4 分钟,补了一条红色 incident「后端不可用约 4 分钟(09-10 14:40–14:44)」并推管理员 [prod@2026-09-11,Nova:ops_events 最近四行]。⚠️ 标题里是吉隆坡时间,started_at 列是 UTC 06:40,看的人别当成两次事故。
- 判据:502 先看 docker ps 的 STATUS 和容器启动日志第一行,别看 git;正在有人 build 时别再叠一个 build(当时盘 91%)。
每一步会断在哪、断了留下什么:
| 断点 | 表现 | 留下什么 | 谁发现 |
|---|---|---|---|
| 树落后就 rsync | 全绿,别人的修复被盖回旧版 | 生产 = 你的旧树 | 被盖的那个人,几分钟到几小时后 |
| 库 > 树就 build | 镜像起来即退,restart 无限循环,全站 502 |
容器 Restarting (1),启动日志 no migration found |
门铃 / 看门狗 / 用户;修法只有带上那份文件重建 |
库 dirty=true |
镜像起来即退(ErrDirty),同样无限循环 |
启动日志 Dirty database version N |
别反复部署;核失败的那条迁移和真实结构,按审定方案恢复;补文件 / 重建不清 dirty,不授权改 version |
| 盘满 | build 失败或起不来,Redis 写 AOF 报 MISCONF | 502 直到有人 docker builder prune |
人;运维空间不量盘 |
| 对象存储连不上 | health 200、日志一行 ERROR,全站 presign 400 | store == nil 一直到下次重启 |
用户发不出图;连接 / 超时类 = 重启重试并验 presign,凭据 / 端点 / 权限错 = 先改再重启再验 |
| 你的号 ≤ 云库版本 | 部署全绿,迁移被跳过 | 表结构没变 | 只有回读结构才看得出 |
| Caddyfile 改了只 reload | host 上是新的,容器里那份没变 | 反代规则停在旧版 | docker exec hoop-caddy-1 grep 才看得出;要 --force-recreate caddy [code@152b185a docs/必读-部署上云TestFlight.md:206-210] |
1.2 一篇新闻从源站到库,以及源站改版了谁知道、怎么修、怎么部署
用户看到什么:今天没有用户会看到。抓下来的新闻只进库(news_* 表),App 里没有新闻页;看得到的是「Jeff Nova - News」那个房里的告警和日报,以及 /root/hoop/artifacts/ 里的状态文件。
一步步:
1. 宿主机 cron 每分钟起一次 news_spider_scheduler.py --once(不是常驻进程),五行 crontab 之一 [prod@2026-09-11,Nova:crontab -l 五行,和 必读-建Spider.md §26 抄的一致]。进程 cd /root/hoop/newstools —— 那是软链,指向 newstools.releases/20260910-122407 [prod@2026-09-11,Nova]。
2. 先拿 /tmp/hoop_news_spider.lock(内核 flock),拿不到就退,防上一班没跑完下一分钟又开一班 [code@152b185a tools/news_spider_scheduler.py:49-54,162]。
3. 按库里每家的 crawl_frequency(2h / daily@06:00 那种写法)和上次开跑时刻决定这一分钟跑不跑哪家 [code@152b185a tools/news_spider_scheduler.py:185-268];哪家由哪个脚本抓写死在 RUNNERS,没登记的源不跑而且会说出来 [code@152b185a tools/news_spider_scheduler.py:44-45]。
4. 起子进程 news_spider_<key>.py,它把 SQL 吐到 stdout,调度器把 BEGIN; 之后的全部喂给 docker exec -i hoop-postgres-1 psql —— 宿主机上的 Python 直接写进库容器,不经后端 [code@152b185a tools/news_spider_scheduler.py:47,271-275]。
5. 状态两段式:spider 只写 .pending,psql 成功后调度器跑 --commit-state 才转正;psql 失败留下失败记录 + 叫人 [code@152b185a tools/news_spider_base.py:514-516] [code@152b185a tools/news_spider_scheduler.py:283-298]。
6. 叫人:用生产机上 nova 那个哨兵号经 hooplib 固定码登录往新闻房发一条,发不进(403)就私聊 Jeff;同一家同一种失败 6 小时只叫一次,节流状态在 ~/.hoop/news_alert_last.json;发不出去只打日志绝不抛,告警不许拖垮抓取 [code@152b185a tools/news_spider_scheduler.py:71-73,82,740-769]。
7. 每轮跑完可以 GET 一次 HOOP_HEARTBEAT_URL 当机器外心跳 —— 已核范围内(root 的 crontab 五行、/etc/environment、/etc/default/*、root 的 .profile / .bashrc、/etc/cron.d/*)都没有设它,按代码就是空、不打;spider 进程当时的实际环境没有抓,待核 [code@152b185a tools/news_spider_scheduler.py:100-102] [prod@2026-09-11,Nova]。本地心跳文件 /root/.hoop/news_heartbeat.json 在(每轮跑完写「上次跑完的时刻」)。已核范围内 spider 线没有机器外的「它还活着吗」信号,只有「它出错了会喊」。
源站改版了:自检 / 体检发现覆盖率掉或解析为 0 → 告警进房 → 改 news_spider_<key>.py → CI 自动跑十份脚本的 --self-test + 部署脚本自检(只在那几个路径变时)[code@152b185a .github/workflows/news-spider-selftest.yml:9-19,37-58] → ./tools/deploy_newstools.sh:本地语法 → scp 到 releases/<新戳>/(没点名的从当前版本 cp 齐)→ 远端 md5 → 新目录里逐份 --self-test(账本全指临时目录)→ 切换前冒烟 import → 拿调度器同一把锁 → 一次 os.replace 切软链 → 切完再冒烟,不过就切回;远端用一行 JSON 回报,外层只按它说话,没收到 JSON 一律说「不知道动没动,上去 readlink 核」[code@152b185a tools/deploy_newstools.sh:23-33]。推完不用重启,下一分钟 cron 就是新版;真数据要等那家下一班。这是七条通道里唯一一条「自检 → 原子切换 → 回滚命令」齐全的。
会断在哪:锁被死进程攥着(spider 崩了锁随进程释放,flock 是内核锁,不会;但 --self-test 若在生产机上跑而没传假的 post,会真的私聊 Jeff 一条「x」—— 09-09 栽过)[code@152b185a tools/news_spider_scheduler.py:743-746];/var/log/news_*.log 四个文件只追加没轮转(§26 写的,本文没核大小);新源落地清单 §20 二十步里没有「登进 RUNNERS / SOURCE_MODULES / 部署脚本 ALL 与 SMOKE / CI paths / §26」那五步,漏一处就是「调度器说没登记不跑」或「部署脚本没把新文件带上」[code@152b185a docs/必读-建Spider.md:323-346] [code@152b185a tools/deploy_newstools.sh:52-54] [code@152b185a tools/news_spider_base.py:163]。
2. 七条通道(改了什么 → 谁能推 → 多快生效 → 怎么验 → 能不能退)
| 改了什么 | 命令 / 动作 | 谁 | 生效 | 验 | 退 |
|---|---|---|---|---|---|
后端 Go(backend/) |
手敲:pull → 比版本 → build → rsync → up -d --build → 三验 → 回读 → 报 |
任何有 SSH key 的机器;不用问(09-03) | 新镜像起来那一刻起对所有新请求生效,用户无从选择 | health / 日志 / 对象存储 / 版本 / 你的接口 | 旧 commit 再走一遍(旧树要带云库当前号的迁移文件、旧代码要兼容现表);迁移只向前 |
| App 给 Jeff 看效果 | ./tools/push_jeff.sh(通道 A,devicectl 升级式安装)[code@152b185a tools/push_jeff.sh:1-6] |
任何人;绝不 flutter run 直推 |
装完即是 | 他眼睛 | 装回 TestFlight 包 |
| App 给所有测试员 | 通道 C:用 Popen(start_new_session=True) 起 cut_build.sh <build号> <树>,stdout 必须指到 /tmp/cut<N>.log(收尾那段固定从这个路径 grep),起完回头验「进程还在 + 日志在长」;脚本内:CHANGELOG 闸 → clean+build ipa → 拆包验号 → 上传 → finalize → 出包房 + General 播报 [code@152b185a tools/tf-build/cut_build.sh:10-21,73,109-144] |
只有 NOVA(铁律 17) | 苹果 APPROVED 后,测试员自己点更新 | 脚本在自己的日志里 grep 到「Beta 审核状态: APPROVED」才发 General;那是 grep 到了脚本的一行输出,不是当次 ASC 实况(§3.4)[code@152b185a tools/tf-build/cut_build.sh:66-73] |
不能;只能再出一个 |
H5 游戏(HOOP-games/,另一个仓) |
./deploy.sh(要 HOOP_DEPLOY_MASTER);没令牌走工作房 game_submit 发测试版 |
任何人 | 即时,重开游戏即新版 | curl games.hoopcomm.com/<slug>/index.html \| grep 你改的那行 —— 推 git ≠ 上线 [code@152b185a CLAUDE.md:53-65] |
Pages 历史 + 对账器自动退 |
网页版(同一份 app/lib) |
./tools/deploy_web.sh:读 kAppVersionLabel → flutter build web 到仓外 → 产物里必须有版本号和正式接口、不能有 localhost → 发 Pages main 分支 → 每 10 秒抓一次 main.dart.js 和本地逐字节比,最多 8 次(每次 curl 没有总超时,所以不是严格 80 秒截止)[code@152b185a tools/deploy_web.sh:58-106] |
任何人;动了 app/lib 就得跑(李敏 09-02) |
部署核验后对新请求生效;已开着的页面要刷新 | 脚本只比 main.dart.js 一个文件;别的资源和真实功能没验 |
旧树再发 |
图纸站(docs/*.html) |
./tools/deploy_docs.sh:从 md 重生成全部 HTML → docs_index.py 漏登记就报错 → 全新临时目录备料 → 发 main 和 master 两个分支别名 → 逐网址核 sha256 [code@152b185a tools/deploy_docs.sh:29-50,80-90] |
任何人;别自己敲 wrangler(08-15 长出两个网址) | 部署核验后对新请求生效 | 脚本核的是清单里每个文件的内容一致,不是浏览器渲染出来对不对;人再 curl 一句文档里真有的话 | 旧树再发 |
新闻 spider(tools/news_spider_*.py 等 11 份 + tests_fixtures/) |
./tools/deploy_newstools.sh(§1.2) |
任何人 | 下一分钟起的进程 | 远端自检 + 冒烟 + JSON 回报 | --rollback |
共同的规矩:每条通道的成功判据必须能区分新旧——「200」「全绿」「日志没报错」都可能是旧版给的;中断 ≠ 失败也 ≠ 成功,查真实状态;报进度按通道分开说 [code@152b185a docs/必读-部署上云TestFlight.md:285-290]。七条通道里只有后端那条没有脚本;它不是唯一即时生效的(网页版 / 游戏 / 图纸都是部署完对新请求就生效),但它是唯一「用户无从选择不更新 + 常带不可逆的迁移」的 —— §4 第一行。
域名和地址(谁住哪儿):hoopcomm.com / www = Caddy 静态目录 deploy/www/(邀请 /i/、通话 /c/、群 /g/、社群 /cm/ 四种落地页各自前缀,别共用)[code@152b185a deploy/Caddyfile:13-31];api.hoopcomm.com = 后端 8080,/rtc* 转 LiveKit 7880 [code@152b185a deploy/Caddyfile:52-63];games.hoopcomm.com = 后端 /hosted/*,橙云 CDN [code@152b185a deploy/Caddyfile:33-39];开发者自定义域 = 按需签证书,先问后端 /v1/hosted/domain-check 这域登记过没 [code@152b185a deploy/Caddyfile:1-6,41-50];cdn.hoopcomm.com = Cloudflare Worker 校 HMAC + 过期后从 R2 出,边缘按纯路径缓存,MEDIA_SIGN_SECRET 和后端同值 [code@152b185a deploy/cloudflare/media-worker/worker.js:1-12];hoop-web.pages.dev 网页版;hoop-docs.pages.dev(+ master. 别名)图纸;hoop-games.pages.dev / hoop-gamedocs.pages.dev 游戏线。
3. 内部怎么运作
3.1 生产机:一台 VPS、九个容器、谁依赖谁
一台 Vultr VPS(139.180.208.52,SSH 裸 22 直连 root;hoop-prod 那个 Tailscale 别名只在 Jeff 的 Mac 上有,脚本和文档一律写裸 IP)[code@152b185a docs/必读-部署上云TestFlight.md:227-228]。编排一份 deploy/docker-compose.prod.yml,九个服务,全部 restart: unless-stopped:
| 容器 | 镜像 | 数据 | 健康检查 | 后端依赖它吗 |
|---|---|---|---|---|
| postgres | postgres:16-alpine |
pgdata 卷 |
pg_isready 5 秒 ×10 |
是(service_healthy) |
| redis | redis:7-alpine,AOF + 密码 |
redisdata |
redis-cli ping |
是 |
| meilisearch | getmeili/meilisearch:v1.10 |
meilidata |
没有 | 否;连不上退回 noop 搜索返回空 [code@152b185a backend/cmd/api/main.go:126-130] |
| minio | minio/minio:latest |
miniodata |
mc ready |
是(service_healthy)—— 但生产对象存储是 R2(STORAGE_ENDPOINT 落在 *.cloudflarestorage.com,bucket hoop)[prod@2026-09-11,Nova],后端压根不连它;它只是还「healthy」地跑着,见 §4 |
| backend | 本地 build(../backend/Dockerfile),env_file: .env,只 expose 8080 |
无;./secrets:/secrets:ro 挂 apns.p8 |
无 | — |
| chromium | chromedp/headless-shell:stable,shm 512m |
无 | 无 | 否;AI 造游戏真跑闸用 |
| caddy | caddy:2-alpine,80/443 |
caddydata(证书)/ caddyconfig;Caddyfile 和 www/ 只读挂 |
无 | — |
| livekit | livekit/livekit-server:v1.8.0,7881 TCP / 7882 UDP / 5349 / 3478 |
只读挂 caddydata 当 TURN 证书 |
无 | — |
| egress | livekit/egress:v1.8.4 |
— | 无 | — |
出处:[code@152b185a deploy/docker-compose.prod.yml:8-59,61-85,89-108,115-165,170-172,185-191]。九个都在 Up:postgres / redis / minio / meili 两个月没动,egress 七周,caddy 两周,livekit / chromium 四天,backend 13 分钟(01:41 UTC 刚有人重建,启动四行:检查迁移 → 已是最新 → 对象存储已连接 bucket hoop → HTTP 服务启动 env=prod)[prod@2026-09-11,Nova]。
启动顺序(后端):config 读 env(HOOP_ENV=prod 时 JWT_SECRET 为空直接不起)[code@152b185a backend/internal/config/config.go:344-346] → migrate.Up(失败即退)→ 连 PostgreSQL(失败即退)→ 连 Redis(失败即退)→ 对象存储(失败不退,store=nil,图片功能全废)→ Meilisearch(失败退 noop)[code@152b185a backend/cmd/api/main.go:96-130]。前三步任何一步坏 = 崩溃循环 502;后两步坏 = 200 但功能缺。这就是为什么三件验证里要单独 grep 对象存储那一行。
TURN 和证书:LiveKit 内置 TURN 复用 api.hoopcomm.com 的 IP 和 Caddy 自动签的 Let's Encrypt 证书(caddydata 卷只读挂进去);Caddy 自动续期但 LiveKit 只在启动时读证书 → 宿主机 /etc/cron.d/hoop-livekit-cert 每周日 04:30 restart livekit 换证 [code@152b185a deploy/livekit/livekit.yaml:21-33] [prod@2026-09-11,Nova:cron.d 那一行]。所以 api 那一行必须灰云:橙云一开,DNS 解析到 Cloudflare,TURN 直连就断。
磁盘:94 G 盘,今天用 32 G(36%);Docker 镜像 6.6 GB、卷 3.1 GB、构建缓存 8.3 GB(54 条) [prod@2026-09-11,Nova]。09-05 缓存累到 71 GB 把盘吃满 [code@152b185a CHANGELOG.md:6097-6098];没有定时 prune,没有磁盘体温。
3.2 两层重启恢复 + 一层事后补记:每层管什么、管不了什么
| 层 | 在哪跑 | 触发 | 动作 | 管不了 |
|---|---|---|---|---|
① compose restart: unless-stopped |
Docker | 进程退出 | 立刻再起 | 镜像本身坏(起来又退,无限循环);进程活着但卡死 |
② 看门狗 deploy/watchdog.sh |
宿主机 cron 每分钟 | curl -m 8 https://api.hoopcomm.com/health 不是 200,计数 /root/hoop/watchdog.fails;≥ 3 且是 3 的倍数 |
docker compose restart backend,记 /root/hoop/watchdog.log |
镜像坏(重启不重建);Caddy / 网络 / 证书坏(它探的是公网地址,Caddy 死了它会一直 restart 后端);它自己在这台机器上 |
③ 运维空间 recordOutageIfAny |
后端进程启动时 | ops_metrics 最后一行离现在 ≥ 3 分钟 |
补一条已恢复的红色 incident「后端不可用约 N 分钟(from–to)」并推 |
后端没起来就永远不写;它是补记层不是修复层,而且量的是「体温断了多久」不是「对外 502 了多久」(采集器卡住而 HTTP 还活着也会记一条) |
出处:[code@152b185a deploy/docker-compose.prod.yml:10,26,38,48,65,91,98,117,172] [code@152b185a deploy/watchdog.sh:5-18] [code@152b185a backend/internal/ops/collector.go:16,153-172]。没有第四层(已核范围内;Vultr 自带监控 / Cloudflare 健康检查未核):机器外没有任何东西在探这台 VPS;整机挂了(内核、网络、Vultr)三层全哑,直到有人打开 App 发现。看门狗 09-10 的三行日志(fails=4 → recovered)就是它「转了一圈没修好、等人修好」的样子 [prod@2026-09-11,Nova]。
3.3 运维空间:体温、规则、事件、日报、推送、谁看
采集:ops.Service.Run 起来先补记事故、量一次,之后 time.Minute 一次 sweep(量 → 写 ops_metrics → 跑规则)+ maybeDaily [code@152b185a backend/internal/ops/collector.go:46-70]。量什么:库 / Redis ping(5 秒超时)、对象存储 ListObjects("hosted/",1)(10 秒);库不通就只写这三个不往下问;然后一条 SQL 数排队超 30 分钟没人接的卡、租约过期还 working 的卡、10 分钟内有心跳的工人、一小时内失败的卡、今天(吉隆坡)的游戏发版 / 上线数、开着的 games 事件;再数发件箱积压(建了 10 秒还没广播或没推、尝试 < 8 次)和死信(≥ 8 次),加上分钟桶里的五个聊天计数和 hub 里的在线人数 [code@152b185a backend/internal/ops/collector.go:72-118]。没有磁盘、内存、CPU、Caddy、LiveKit、证书到期。⚠️ 七个 SQL 计数和聊天两个计数的错误全被 _ = 吞掉,查询失败 = 那一分钟这些列全是 0,和「确实没有」分不开;库 ping 失败时更是直接返回、后面的数一个不问 [code@152b185a backend/internal/ops/collector.go:88-90]。
规则(全在代码,不是提示词):
| 键 | 色 | 开 | 一句话 |
|---|---|---|---|
infra:db / infra:redis / infra:store |
红 | ping 失败 | 「后端探 X 失败」 |
games:bad |
红 | 开着的 games 事件 > 0 | 对账器在退 |
chat:outbox_backlog |
红 | 积压 ≥ 10 | 先看 Redis |
chat:send_fail |
红 | 一小时 ≥ 100 | 落库或内部错 |
tasks:queued_stale |
黄 | > 0 | 工人死了或没叫醒 |
tasks:working_stale |
黄 | ≥ 3 | 租约过期还挂着 |
tasks:failed_burst |
黄 | 一小时 ≥ 5 | 看是不是同一个房 |
chat:outbox_dead |
黄 | > 0 | 拿 message_id 去 grep 日志 |
chat:publish_fail |
黄 | 一小时 ≥ 5 | Redis 在抖 |
chat:push_fail |
黄 | 一小时 ≥ 50 | 死 token 或 APNs |
[code@152b185a backend/internal/ops/rules.go:14-52](12 条:红 6 黄 6)。每条规则 = 一个 dedupe_key:Open 就 Report,不 Open 就 Resolve [code@152b185a backend/internal/ops/collector.go:143-151]。库不通那条的盲区:infra:db 判「开」的那一分钟,Report 要往同一个库 INSERT,INSERT 失败就 return,推送不会执行;库一恢复规则判「关」,Resolve 去 UPDATE 一条从没插进去的行。所以库持续不通那段,运维空间可能一条 infra:db 事件都留不下、一个人都推不到(短暂抖动、INSERT 前已恢复的那种仍可能记下并推);Redis / 对象存储不通(库还在)那两条红有完整的发送路径,但 APNs 送不送达仍不保证(§3.3 推送段) [code@152b185a backend/internal/ops/ops.go:138-146]。
事件表 ops_events(迁移 000344;聊天八列 000352):Report 用 ON CONFLICT (dedupe_key) WHERE resolved_at IS NULL DO NOTHING 保证同一键开着不重开;dedupe_key 为空 = 每次都记(日报就是)[code@152b185a backend/internal/ops/ops.go:131-154]。Resolve 把开着的那条填 resolved_at,红色的推一次「✅ 已恢复」[code@152b185a backend/internal/ops/ops.go:156-173]。别的域只调包级 ops.Report / ops.Resolve(SetDefault 在 router 里接上;没接上只记日志不 panic)[code@152b185a backend/internal/ops/ops.go:97-128];今天接了两处:游戏对账器(线上探不到 → 红,连续两次不通退回上一 build,退不了 → :stuck 红)[code@152b185a backend/internal/domain/developer/reconcile.go:114,157-165,217-221] 和 CI 部署失败回执(黄)[code@152b185a backend/internal/domain/developer/developer.go:931]。
推送:红色新开立刻推,开着的每 6 小时再推一次,恢复推一次;kind=daily 的 info 也推;推给 ADMIN_UIDS 里有 push_token 的设备,collapse 用 dedupe_key [code@152b185a backend/internal/ops/ops.go:60-61,148-149,176-196]。「每 6 小时」的前提:它不是一个独立的重试器 —— lastPush 是进程内一张 map,后端重启就清零,多实例各记各的;只有下一次有人再 Report 同一个键(规则事件是采集器每分钟重判,所以会;游戏对账器那类只在它再报时)才会再看一眼时间;而且先记时间再发,APNs 那次发失败只记一行 Warn,这一轮就算过去了,没有持久化的投递重试 [code@152b185a backend/internal/ops/ops.go:177-187,209-213]。没有别的出口(不进房、不发邮件、不打 webhook);管理员手机没开推送 = 收不到主动通知,事件(如果写进了库)仍能在运维空间那一屏查到。
日报:吉隆坡 08:00 之后第一次 tick;防重是二选一:有 Redis 就 SetNX ops:daily:<日> 20 小时,没有 Redis 才查表 —— 不是先锁再查;拿到锁之后分三种失败:统计 SQL 失败 → dailyReport 的错误被吞,Report 若写成就是一条零值或不完整的日报;写 ops_events 失败 → Redis 锁已占、20 小时内不再试,当天可能漏报;推送失败 → 日报在库里、手机没收到。三种都没有补偿;查表那一支也不是原子的 [code@152b185a backend/internal/ops/collector.go:181-194]。内容:消息数、任务新开 / 做完 / 失败、游戏发版 / 上线 / 回退、红色告警数、整天没心跳的工人数、后端不可用分钟数 —— 整条 SQL 的错误被 _ = 吞掉,失败就是一排 0;「不可用分钟」= 当天体温相邻两行间隔 > 3 分钟的间隔之和,不是事故记录的汇总,跨日、日首日尾的缺口漏掉;「整天没心跳」那个子查询只有 beat_at >= d.a 没有上界,量的是「从窗口起点到现在没心跳」[code@152b185a backend/internal/ops/collector.go:197-217]。窗口不是吉隆坡自然日:CTE 里的 a / b 是 timestamp without time zone(墙上时间「2026-09-10 08:00」),和 timestamptz 列比较时按会话时区再转回 —— 生产库服务器默认时区是 UTC(postgresql.conf 里配的,psql 会话 SHOW timezone 也是 UTC;后端连接串只有 sslmode 一个参数、没设时区,推定沿用服务器默认,后端会话本身没有直接抓),于是「2026-09-10」这一天 = 09-10 08:00 UTC 到 09-11 08:00 UTC,即吉隆坡 09-10 16:00 到 09-11 16:00 —— 而日报在 09-11 00:00 UTC 就出了,后 8 小时还是未来 [prod@2026-09-11,Nova:§7 证据表那条带 pg_typeof 的查询]。最近三份 09-08 / 09-09 / 09-10 都在 00:00 UTC 出的 [prod@2026-09-11,Nova]。
接口与 App:四个端点 GET /v1/admin/ops/now / events?limit= / metrics?hours= / POST events/{id}/ack,鉴权 = 登录 + uid 在 ADMIN_UIDS 里 [code@152b185a backend/internal/ops/handler.go:4-7,34-42];接线 [code@152b185a backend/internal/server/router.go:403-409]。now 查体温失败也回 200(at 为空、stale=true),200 只证明接口活着,看的人要看 at 和 stale [code@152b185a backend/internal/ops/handler.go:56-69]。App 侧栏起来时 OpsSpaceScreen.probe() 打一次 now,200 才画「运维」那一行,普通用户侧栏里没有 [code@152b185a app/lib/widgets/hoop_space_drawer.dart:128-137] [code@152b185a app/lib/screens/ops_space_screen.dart:69-75]。谁是管理员由 deploy/.env 的 ADMIN_UIDS 决定(已设;HOOP_PLATFORM_ADMINS 没设,所以 provision 那边回落到同一份)[prod@2026-09-11,Nova]。
3.4 TestFlight 流水线:为什么是脚本、为什么只有 NOVA
出包 20-35 分钟,会话一定会断;08-23 / 24 两次 ipa 躺在硬盘上 1 小时和 6 小时没人传,164 / 170 / 171 三次包好了没人报 —— 「下一步谁做」答案是「我记得」的流程一定断,所以整条线交给脱离会话的 cut_build.sh(Popen(start_new_session=True),别用 nohup setsid,macOS 没有 setsid)[code@152b185a tools/tf-build/cut_build.sh:1-21,53-58]。它先把自己 cat 一份到 /tmp 再 exec(编包中谁 pull 一下,bash 会从错位的字节接着读)[code@152b185a tools/tf-build/cut_build.sh:28-36];缺 build.env 当场停 [code@152b185a tools/tf-build/cut_build.sh:44-49];步骤:CHANGELOG 标题里必须有「build N」(那条就是苹果那边的更新说明,找不到罢工)→ clean + build ipa --build-number=N → 拆包验号 → 上传 → finalize → 出包房播报;只有在 /tmp/cut<N>.log 里 grep 到「Beta 审核状态: APPROVED」才往 General 群发,失败不打扰全员 [code@152b185a tools/tf-build/cut_build.sh:59-73,109-144]。运行前提(脚本抬头写的是启动示例,不是它自己会做的):要用 Popen(start_new_session=True) 起、stdout 指到 /tmp/cut<N>.log(收尾 grep 的是这个固定路径,日志写别处 = 永远读不到 APPROVED)、起完回头验进程在 + 日志在长。仓外依赖:finalize 那一步(轮询 VALID → 加内外测试组 → whatsNew → 提审)调的是 ~/.hoop/nova/build/asc_finalize.py,这台机器上它是软链指向仓里 tools/tf-build/asc_finalize.py(别的机器 待核);播报调 ~/.hoop/nova/say.py,不在仓里。本文基线只能证明「脚本调用了它们」,加组 / 回读 / 提审的内部实现没核;脚本 grep 到 APPROVED 证明的是那一行被打印过,不是当次 ASC 的实况 [code@152b185a tools/tf-build/cut_build.sh:89,107,141]。只有 NOVA 出:build 号必须单调,打包卷进全工作区当刻的代码,多线同时出 = 跳号 + 捎带半成品(铁律 17)。
3.5 CI:两道,都不是闸
- CHANGELOG 只许变长:每次 push / PR 拿全历史(
fetch-depth: 0,浅 clone 读不到旧提交会「每次跳过、每次绿」),调tools/changelog_guard.py --range 旧 新(和本机钩子同一段代码),拿不到基准判失败不放行[code@152b185a .github/workflows/changelog-guard.yml:18-49]。起因 08-26 一次open(path,"w")把 32861 行历史整个覆盖成 37 行,十几个小时没人发现[code@152b185a tools/changelog_guard.py:10-22]。它是报警不是拦截:坏提交已经在 master 里了[code@152b185a .github/workflows/changelog-guard.yml:7-11]。 - 新闻 spider 自检:只在
tools/spider_engine.py/news_spider_*.py/ 部署脚本 / hooplib 等路径变时跑,Python 3.12(生产机是 3.12,本机 3.11 全绿生产一条红),十份脚本逐个--self-test(engine / base / 三家 spider / scheduler / audit / audit_runner / proxy_status_publish / news_html)+deploy_newstools.sh --self-test[code@152b185a .github/workflows/news-spider-selftest.yml:9-19,37-58]。paths 里没有tests_fixtures/,快照变了 CI 不跑。 - 没有的:
go build/go vet/go test、flutter analyze、golden。手册要求的「编译过」全靠各人机器;08-18 那次服务器上多一个 master 里已删的文件导致 build 红,任何 CI 都看不见(那是服务器上的树,不是 git 的树)。
3.6 spider 在生产机上的布局(只写「怎么跑、怎么部署」)
/root/hoop/newstools 软链 → newstools.releases/<戳>/(每版完整不可变:11 份 py + tests_fixtures/;hooplib.py 从主仓 tools/hoop-lib/ 来),MANIFEST.json 记现在指谁、上一版是谁;crontab 里 cd /root/hoop/newstools && python3 … 一个字不用改 [code@152b185a tools/deploy_newstools.sh:10-21,52-60]。五行 crontab:news_audit_runner.py --once(每分钟起、自己判该不该体检)· news_spider_scheduler.py --once · proxies_sync.py(每天 05:05)· proxy_status_publish.py --watch 5 · watchdog.sh [prod@2026-09-11,Nova]。状态文件在 /root/hoop/artifacts/ 和 /root/.hoop/(账本 / 节流 / 目击 / 心跳),日志 /var/log/news_*.log 四个 [code@152b185a docs/必读-建Spider.md:415-428]。⚠️ 那一节自己写着「改了 crontab 必须回来改这一节」—— 手抄镜像(铁律 23);今天两边一致,是因为没人改过。tools/news_domain_gate.py 在仓里但没有任何文件 import 它,只有 news_rules_table.py:9 一句注释提到 —— 死文件。
3.7 七种号、七个出处
| 号 | 例 | 出处 | 谁动 | 能推出别的号吗 |
|---|---|---|---|---|
App 版本标 kAppVersionLabel |
v1.871 | app/lib/screens/conversations_screen.dart(deploy_web.sh 从它读)[code@152b185a tools/deploy_web.sh:58-61] |
谁改 App 谁升,push 成功才算占住 | 否 |
| TestFlight build 号 | 整数 | 苹果 ASC 真实最大号 +1 | 只有 NOVA | 否;「进那个包了吗」按祖先关系判 |
| 迁移号 | 000373 | backend/migrations/LEDGER.md 取号机,抽走「下一个号」立刻 +1 写回 [code@152b185a backend/migrations/LEDGER.md:1-6] |
谁建迁移谁取 | 否;取号 ≠ 文件在树里 ≠ 轮得到执行(三件事,§1.1 第 2 步) |
| 云库版本 | 373 | schema_migrations.version 一个整数 + dirty 一个布尔 |
新镜像启动或 SSH 直插 | 否;和树比大小是部署前那一眼,证明不了低号都跑过、也证明不了结构和文件一致 |
| 图纸站版本戳 | 页面右上角时间 | deploy_docs.sh 每次重生成 |
谁部署谁 | 否 |
| spider 版本 | 20260910-122407 |
newstools.releases/<戳> 目录名 + MANIFEST |
deploy_newstools.sh |
否 |
| 游戏版本 | live_version 整数 |
库 games 表两个整数,文件在 Pages / R2 |
promote / rollback / 对账器 | 否;整数和文件可以对不上(对账器就是为这个) |
⚠️ LEDGER 抬头写「不用查任何东西」[code@152b185a backend/migrations/LEDGER.md:3-4],而手册 §3 红字要求部署前比云库 —— 两句话说的是两件事(取号不用查;部署要查),但读的人容易把前一句当成后一句。
3.8 密钥与配置(只写位置)
deploy/.env(生产机,env_file 进后端;25 个键见 deploy/.env.example:域名、库 / Redis / Meili 口令、JWT、对象存储五项、APNs 四项、Resend、LiveKit 三项、Anthropic 两项;ADMIN_UIDS / MEDIA_CDN_BASE / HOSTED_BASE 也在 env 里但模板没列)[code@152b185a deploy/docker-compose.prod.yml:66-73] [code@152b185a backend/internal/config/config.go:242-328] [prod@2026-09-11,Nova:七个键 set 与否];deploy/secrets/apns.p8(只读挂 /secrets)[code@152b185a deploy/docker-compose.prod.yml:81-83];本机 ~/.hoop/cloudflare.env(Pages 令牌;没有就看 wrangler whoami 的 OAuth,先落变量再 grep 不然 pipefail 永远判没登录)[code@152b185a tools/deploy_web.sh:36-56];~/.hoop/nova/build/build.env(钥匙串口令)[code@152b185a tools/tf-build/cut_build.sh:46-49];~/.hoop/keychain_pw(通道 A);HOOP_DEPLOY_MASTER(游戏线令牌,另一个仓)。.env.example 两份(deploy/ 和 backend/),README 还写着 coturn/ 目录 —— 不存在 [code@152b185a deploy/README.md:5]。
4. 已知缺口与待核事项(逐行看状态)
| 事项 | 状态 | 出处 / 判据 |
|---|---|---|
| 生产库备份与恢复演练 | 已核范围内未发现:pgdata 一份卷;/root/hoop/backups/ 一份 txt;crontab / cron.d / cron. / systemd timers 无备份任务;launchd 那份是 Mac 本机的。未核*:Vultr 快照、异地拉取 |
§0 第 6 条;§7 证据表 |
| 后端通道没有脚本 | 现状:手册 §3 是唯一出处;网页 / 图纸 / spider 三条都有带自检的脚本。09-06 那次 4 分钟已证实是顺序错;09-10 那次是库与镜像不兼容,触发路径未定 | §0 第 1 条;必读-部署:174-198 |
| 手册自己打架:§3 红字「先 rsync 再 build,直插只在被越过时」,同节代码块 ① 和 §0 表第三行仍写「先 SSH 直插云库」 | 现状:以红字为准;09-10 LEDGER 000370 那行已按新顺序办 | [code@152b185a docs/必读-部署上云TestFlight.md:19,161-174] |
| 磁盘不在体温里;构建缓存没有定时 prune | 已核实没做:采集器 18 项里没有盘;今天缓存 8.3 GB,09-05 到过 71 GB | §3.3;Liva 三条建议 WIP.md:1341 |
| 机器外探活 | 已核范围内未发现:看门狗 / 运维空间都在同一台 VPS;spider 心跳 URL 在 crontab / 环境里都没设。未核:Vultr 自带监控、Cloudflare 健康检查、第三方 | §3.2;§1.2 第 7 步;§7 |
| 告警只有一个出口(APNs 推 ADMIN_UIDS) | 现状:手机没开推送 = 收不到主动通知(事件若已落库仍可在那一屏查);不进房不发邮件;6 小时提醒是进程内记时、发失败不重试 | ops.go:177-213 |
| 告警和它监控的库同源 | 已核实:体温 / 事件 / 查设备三处都读写同一个库;库持续不通那段可能既记不下也送不到,没事件不能证明没故障;SQL 失败的 0 和真 0 分不开 | §0 第 4 条;§3.3 |
| 日报统计窗口不是吉隆坡自然日 | 已核实(psql 只读实测;后端会话时区推定同服务器默认,未直接抓):「昨天」= 昨天 16:00 到今天 16:00 吉隆坡,而日报 08:00 就出;防重二选一;统计失败可能出零值 / 不完整日报、事件写失败可能漏报、推送失败只在库里,三种都无补偿 | §3.3;§7 |
事故标题吉隆坡时间、started_at UTC |
现状:同一件事两种表述 | [prod@2026-09-11] 那条 incident |
minio 容器还在跑且后端 depends_on 它 healthy,而生产存储是 R2 |
待核:egress / 别的服务用不用 minio;不用的话它是一个多余的启动依赖(minio 挂了后端 up 起不来,虽然没人用它) |
§3.1;[prod@2026-09-11] endpoint 落在 cloudflarestorage.com |
| 生产 CORS 反射任意 Origin | 已核实(源码 + 09-11 线上一次实测回显任意 Origin):注释自称 MVP,生产应白名单 | middleware.go:5-13;§7 |
| 没有 CI 编译后端 / analyze App;直推 master 放行 | 已核实没做;禁直推是工作流决定,changelog-guard 抬头写「留给 Jeff 拍板」 |
§3.5 |
WORKFLOW §8 回滚命令带 rsync --delete;§1 :35-36「推荐直插 + 手动改 version」 |
已核实:前者和铁律 12 相撞,照抄一次 = .env / apns.p8 没了;后者和手册 §3 红字相反 |
[code@152b185a docs/WORKFLOW.md:35-36,238] |
PROD_ACCESS :63-66「推荐直插」、:74-80 hoop-prod 别名;CHEATSHEET 整份「Jeff + Lisa 两人」;DEPLOY.md「待执行」 |
已陈:§7 逐份写了去向 | 头部 supersedes |
| 橙云提醒三处(CLAUDE.md:104-107 / TASKS.md:145-148 / 记忆)仍在 | 待核后删:旧文档摘要说 09-03 已翻;删除要 Jeff 点头(铁律 6) | [code@152b185a CLAUDE.md:104-107] |
必读-建Spider §20 缺五步登记;§26 crontab 手抄镜像;tests_fixtures/ 不在 CI paths |
现状 | §1.2;§3.5;§3.6 |
news_domain_gate.py 无人 import |
现状:死文件;删除要问 | §3.6 |
| 基线树 000372 是最大文件,LEDGER 已到 000373,云库 373 | 现状(写这份时的实况):从这棵树部署会重演 09-10;371 / 373 的文件在别人的 worktree | §1.1 第 2 步 |
| 09-10 那次 502 在 CHANGELOG 里只有顺带一提(哨兵那条 :112-118「排一次生产 502 时」),没有该时间窗的完整事故记录 | 已核实 | [code@152b185a CHANGELOG.md:112-118] |
/var/log/news_*.log 没轮转 |
待核:文档写的,本文没量大小 | 必读-建Spider:415-431 |
| Meilisearch 没有健康检查、没人量 | 现状:挂了搜索静默返回空 | main.go:126-130 |
5. 出问题先查哪里 · 动它之前
全站 502:① ssh root@139.180.208.52 'docker ps --format "{{.Names}} {{.Status}}"' —— backend Restarting 只说明进程在反复退出,启动初始化坏和运行期崩溃都长这样;② docker logs hoop-backend-1 --tail 20 看退出前那几行和退出原因:no migration found for version N = 库比镜像新 → 找到 N 的文件放进 backend/migrations/ 再 up -d --build(不是 restart);Dirty database version N = 上次迁移半途死了 → 停下来按 §1.1 第 2 步 dirty 那一支走,别再部署;JWT_SECRET = .env 被动过;连库 / Redis 失败 = 看那两个容器;③ df -h —— 满了先 docker builder prune -af(只删缓存,09-05 Liva 就是这么救的);④ 有人正在 build 时别再叠一个;⑤ tail /root/hoop/watchdog.log 看看门狗转了几圈。别看 git:树可以是干净的而镜像是坏的。
health 200 但某类功能坏:发不出图 / 语音 → docker logs hoop-backend-1 --since 10m | grep 对象存储连接失败,读那行的 err:超时 / 连接类再重启一次,凭据 / 端点 / 桶类先改 .env 再重建,之后打一次 presign 验;搜索空 → Meilisearch;通话接不通(公司 WiFi)→ TURN 证书,openssl s_client -connect api.hoopcomm.com:5349,周日 04:30 那次 restart 有没有跑;游戏黑屏 → 先 curl 线上 slug 再怀疑部署;推送不到 → secrets/apns.p8 还在不在、ADMIN_UIDS 那台设备有没有 push_token。
运维空间不喊了 / 一直喊:SELECT max(at) FROM ops_metrics 离现在超过 2 分钟 = 体温没及时落库 —— 采集 goroutine 卡住、写库失败(日志「ops 体温写失败」)、进程没起,三种都可能,先看日志再定;库持续断过那一段多半没有 infra:db 事件,别拿「没事件」当「没断」,看体温有没有缺口;同一条红每 6 小时来一次是设计,ack 不会停它,只有 Resolve;想知道某条为什么开着,先看 ops_events 里 resolved_at IS NULL 的 dedupe_key,再对 rules.go 那一行的阈值。
spider 没跑 / 没新数据:先看库 news_spider_runs(跑没跑、为什么没跑),再 tail -50 /var/log/news_spider.log;readlink /root/hoop/newstools 核现在是哪一版;ls -la /tmp/hoop_news_spider.lock;真数据要等那家下一班(Bernama 每天 06:00 MYT)。告警没来 ≠ 没出事:节流 6 小时,而且发不出去只打日志。
动它之前:
- 改 deploy/Caddyfile → --force-recreate caddy,验证只认 docker exec hoop-caddy-1 grep。
- 改 deploy/.env → 重建后端;别 docker run 服务镜像探路(ENTRYPOINT 会起第二个后端)。
- 改 watchdog.sh / crontab → 记得 必读-建Spider §26 那份镜像;改阈值先想清「重启能修的坏」和「重启修不了的坏」(§3.2)。
- 改既有指标上的判断(阈值、颜色、文案)→ 只改 rules.go,表不用动。加一个要持久化的新指标 → 要一路改到底:Snapshot 加字段、write 的显式列名和占位符、一条加列的迁移、now 的显式 SELECT 和输出、App 那一屏 —— 000352 加聊天八列就是这一整套 [code@152b185a backend/internal/ops/collector.go:34-44,120-138] [code@152b185a backend/internal/ops/handler.go:56-65]。新规则一定要有 Resolve 那一半,否则永远开着每 6 小时喊一次。
- 加告警出口(进房 / 邮件)→ 走 pushAdmins 旁边加一条,别绕开 maybePush 的 6 小时节流。
- 想把迁移文件「只传自己改的」→ 不行,09-06 那次就这么来的(手册 :225 说「两次」,09-10 那次触发路径未定);整树 rsync 自动带齐 migrations/。
- 想删旧版 newstools.releases/<戳>/、_stale_*、构建缓存以外的任何东西 → 先问(铁律 6 / 13);构建缓存 prune 不算删数据,Liva 09-05 做过并报备。
- 想回退后端到旧 commit → 先确认旧树里有云库当前版本(含)以下的全部迁移文件、旧代码能跑在现在的表上;不许为了让旧镜像起来去改 schema_migrations。
6. 不能碰的数(当前值 / 实现出处 / 守卫或未覆盖)
| 数 | 值 | 出处 | 守卫 |
|---|---|---|---|
| 看门狗判死 | 连续 3 次 /health 非 200(每分钟一次,curl 超时 8 秒);之后每 3 次再 restart |
[code@152b185a deploy/watchdog.sh:8,15] |
无 |
| 体温间隔 / 事故判定 | 1 分钟 / 断 ≥ 3 分钟 | [code@152b185a backend/internal/ops/collector.go:14,16] |
无 |
| 探活超时 | 库 5 秒 · Redis 5 秒 · 对象存储 10 秒 | [code@152b185a backend/internal/ops/collector.go:74-86] |
无 |
| 排队算陈 / 工人算活 | 30 分钟 / 10 分钟内有心跳 | [code@152b185a backend/internal/ops/collector.go:15,93-95] |
无 |
| 红色再提醒 | 6 小时(进程内 map,重启清零,先记时间再发,发失败不重试;要靠再次 Report 触发) | [code@152b185a backend/internal/ops/ops.go:60-61,177-187] |
无 |
| 日报 | 吉隆坡 08:00 后第一次 tick;有 Redis 就 SetNX 20 小时,没有才查表;统计窗口经生产核实是吉隆坡 16:00–16:00 | [code@152b185a backend/internal/ops/collector.go:17-18,181-194,202-203] |
无 |
| 发件箱积压 / 死信 | 10 秒 / 8 次(和 message.Relayer 同一个数,两处手抄) | [code@152b185a backend/internal/ops/collector.go:30-31] |
rules_test.go 只测规则形状 |
| 规则阈值(12 条:红 6 黄 6) | 积压 ≥ 10 · 发送失败 ≥ 100 · working 陈 ≥ 3 · 失败 ≥ 5 · 广播失败 ≥ 5 · 推送失败 ≥ 50 | [code@152b185a backend/internal/ops/rules.go:14-52] |
无 |
| 游戏对账巡检 | 5 分钟;连续两次不通才退 | [code@152b185a backend/internal/domain/developer/reconcile.go:12-14,114] |
无 |
| 容器健康检查 | postgres / redis / minio:5 秒 × 10 次,超时 3 秒 | [code@152b185a deploy/docker-compose.prod.yml:17-21,30-34,55-59] |
无 |
| LiveKit 换证 | 每周日 04:30 restart | [code@152b185a deploy/livekit/livekit.yaml:25-26];[prod@2026-09-11] cron.d |
无 |
| 网页版核对 | 每 10 秒一次,最多 8 次(curl 无总超时,不是严格 80 秒),只比 main.dart.js 逐字节 |
[code@152b185a tools/deploy_web.sh:92-106] |
脚本自己红 |
| 图纸站核对 | 等 6 秒,逐网址 sha256,两个分支别名 | [code@152b185a tools/deploy_docs.sh:80-90] |
脚本自己红 |
| spider 部署 | 拿锁最多 120 秒;冒烟最多 120 秒;退出码 0-5 各有含义 | [code@152b185a tools/deploy_newstools.sh:46-47,78-79] |
--self-test 故障注入 |
| spider 告警节流 | 同一 scope:kind 6 小时一次 |
[code@152b185a tools/news_spider_scheduler.py:73,751-752] |
自检 |
| 生产 Python | 3.12(CI 同) | [code@152b185a .github/workflows/news-spider-selftest.yml:37] |
CI |
| 盘 | 94 G;今天 36%;构建缓存 8.3 GB | [prod@2026-09-11,Nova] | 无阈值、无采集 |
| 库 | 2363 MB,版本 373,dirty=f | [prod@2026-09-11,Nova] | 已核范围内无备份 |
7. 出处与旧记录去向
本文引用的树:152b185a(origin/master 09-11 09:20 MYT)。生产事实全部 [prod@2026-09-11,Nova] 只读取得,没有一条写操作;下表是可复查的证据摘要(时刻是 UTC;目标都是 root@139.180.208.52,库都是 docker exec hoop-postgres-1 psql -U hoop -d hoop;密钥只写位置):
| 时刻(UTC) | 命令 | 关键结果 |
|---|---|---|
| 09-11 01:55 | docker ps --format "{{.Names}} \| {{.Status}}" |
9 个 Up;backend Up 13 分钟;postgres / redis / minio (healthy) Up 2 个月;caddy 2 周;egress 7 周;livekit / chromium 4 天 |
| 09-11 01:55 | df -h / · docker system df |
94G 用 32G(36%);镜像 6.59 GB · 卷 3.06 GB · 构建缓存 8.32 GB(54 条) |
| 09-11 01:55 | SELECT version, dirty FROM schema_migrations · pg_database_size('hoop') |
373 \| f · 2363 MB |
| 09-11 01:55 | ls -la /root/hoop/backups · crontab -l · ls /etc/cron.d · readlink /root/hoop/newstools · tail -3 /root/hoop/watchdog.log |
只有 zip_lb_before_wipe_20260801.txt(495 B,08-01);crontab 5 行;cron.d = e2scrub_all / hoop-livekit-cert / sysstat;软链 → newstools.releases/20260910-122407;watchdog 末三行 = Started / 09-10 06:44:01 health=502 fails=4 / 06:45:02 recovered |
| 09-11 01:55 | SELECT kind, severity, left(title,60), started_at FROM ops_events ORDER BY started_at DESC LIMIT 4 · SELECT max(at) FROM ops_metrics |
daily 09-10(09-11 00:00:33Z)· incident red「后端不可用约 4 分钟(09-10 14:40–14:44)」started_at 09-10 06:40Z · daily 09-09 · daily 09-08;max(at) = 01:54Z |
| 09-11 01:5x | docker exec hoop-backend-1 env,只看键是否 set 与 STORAGE_ENDPOINT 的域名后缀 |
endpoint 落在 *.cloudflarestorage.com;ADMIN_UIDS / LIVEKIT_URL / MEDIA_CDN_BASE / HOSTED_BASE / APNS_KEY_PATH / HOOP_ENV 已设;HOOP_PLATFORM_ADMINS 未设 |
| 09-11 01:5x | docker logs hoop-backend-1 --since 20m \| grep -E "对象存储\|数据库迁移\|HTTP" · cat /etc/cron.d/hoop-livekit-cert |
01:41:34Z 四行:检查迁移 → 已是最新 → 对象存储已连接 bucket=hoop → HTTP 服务启动 env=prod;cron = 30 4 * * 0 … restart livekit |
| 09-11 02:16 | systemctl list-timers --all · ls /etc/cron.{hourly,daily,weekly} · ls /root /root/hoop \| grep -i "backup\|dump\|snap\|\.sql" |
12 个 timers 全是系统自带(apt / man-db / logrotate / sysstat / e2scrub / dpkg-db-backup …);cron.daily 6 个系统项;只有 backups 这一个目录名 |
| 09-11 02:16 / 02:29 | SHOW timezone · SELECT setting, source, sourcefile FROM pg_settings WHERE name='TimeZone' · WITH d AS (SELECT ('2026-09-10'::date)::timestamptz AT TIME ZONE 'Asia/Kuala_Lumpur' AS a, (('2026-09-10'::date)+1)::timestamptz AT TIME ZONE 'Asia/Kuala_Lumpur' AS b) SELECT pg_typeof(a), a, pg_typeof(a::timestamptz), a::timestamptz, b::timestamptz, (a::timestamptz AT TIME ZONE 'Asia/Kuala_Lumpur') FROM d · docker exec hoop-backend-1 env \| grep ^POSTGRES_DSN= \| grep -o "[?&][A-Za-z_]*=" |
UTC · UTC \| configuration file \| /var/lib/postgresql/data/postgresql.conf · timestamp without time zone \| 2026-09-10 08:00:00 \| timestamp with time zone \| 2026-09-10 08:00:00+00 \| 2026-09-11 08:00:00+00 \| 2026-09-10 16:00:00 · 连接串参数只有 ?sslmode=。范围:一次 psql 会话 + 服务器配置默认;后端会话时区未直接抓 |
| 09-11 02:29 | grep -l HOOP_HEARTBEAT /etc/environment /etc/default/* /root/.profile /root/.bashrc /etc/cron.d/* · crontab -l \| grep -c HOOP_HEARTBEAT · ls /root/.hoop/news_heartbeat.json |
无匹配 · 0 · 文件在。范围:这几个入口;spider 进程实际环境未抓 |
| 09-11 02:16 | curl -sD - -o /dev/null -H "Origin: https://not-hoop.example" https://api.hoopcomm.com/health |
HTTP/2 200 · access-control-allow-origin: https://not-hoop.example · vary: Origin |
| 09-10 ~06:41–06:45 | docker ps · docker logs hoop-backend-1 --tail 20 · docker images(当时排障所记,见记忆) |
backend Restarting (1);migrate: no migration found for version 369;镜像建于 13:43 MYT;两个 up -d --build 在跑 |
未核清单(本文没有查、也不据此下结论的):Vultr 控制台的快照 / 监控;Cloudflare 那边的健康检查;别的机器有没有定时拉库;/var/log/news_*.log 大小;egress / livekit 是否用到 minio;别的机器上 ~/.hoop/nova/build/asc_finalize.py 指向哪;后端数据库会话的时区(推定同服务器默认 UTC);spider 进程的实际环境变量;asc_finalize.py / say.py 的内部实现。
旧文档去向:
- DEPLOY.md → 本文取代。抬头「待执行」已假两个多月;选型结论进 §3.1;coturn 一节被 LiveKit 内置 TURN 取代(livekit.yaml 自己写着「ufw 的 3478 是 coturn 时代遗产」)。
- DEPLOY_PVP_20260708.md → 本文取代;过程留 CHANGELOG。
- CHEATSHEET.md → 本文取代;「Jeff + Lisa 两人」早已是三十多个分身,两段命令以 必读-部署 为准。
- 必读-部署上云TestFlight.md → 留,是操作手册;只改三处:§0 表第三行「SSH 直插云库」→「通道 D 前置,默认不直插」;§3 代码块 ①「先走 §4 直插」→「有迁移只需文件在树里」;§3 ② 前加 df -h。
- WORKFLOW.md → 留;§8 :238 去掉 --delete;§1 :35-36「推荐 SSH 直插 + 手动改 version」改成指到手册 §3 红字;§2 概览可以只指到 必读-部署 和本文 §2。
- PROD_ACCESS.md → 留(进机器那半);:63-66 改成「默认不直插」;:74-80 hoop-prod 段加一句「只在 Jeff 的 Mac 上有」。
- 必读-建Spider.md → 留;§20 补五步登记;§26 那段镜像改成「跑 crontab -l 看,这里不抄」或加一条会红的对比。
- NEWS_PLATFORM.md → 留;:18 / :2417 两处「部署未定 / ❌」改指 deploy_newstools.sh 抬头。
- ARCHITECTURE.md / DATABASE.md / API.md / WEB_VERSION_OPTIONS.md → 留;部署 / 迁移 / 域名段以本文为准。
- 00-目录.md :17 / :257 → 对 DEPLOY 的批注改指本文;CLAUDE.md :49-70 七条通道表留(它是全仓唯一齐全的一份,本文 §2 是它的展开),:104-107 橙云段核实后删(要 Jeff 点头)。
- deploy/README.md → 留;:5 的 coturn/ 去掉;:3「决定与原理见 DEPLOY.md」改指本文。
- backend/migrations/LEDGER.md :3-4 → 加半句「取号不用查;部署前要比云库(必读-部署 §3)」。
等 Jeff 拍板的(写在 docs/等你拍板.md,这里只列题):生产库备份与恢复演练(先核 Vultr 快照);磁盘体温 + 定时 prune;后端部署脚本;禁不禁直推 master / 要不要编译 CI;minio 依赖去留;CORS 白名单;机器外探活;删三处橙云提醒;旧文档处置。