# 部署运维方案:生产工程化(一期 + 二期) > **日期:** 2026-08-08(v16 更新于 2026-08-10) > **版本:** v16.0(当前) **状态:** 一期 3 项已执行(磁盘/备份/TLS)+ 构建加速(Dockerfile 卫生)+ gitea TLS,其余待执行 > **关联:** [docs/10-生产部署文档.md](10-生产部署文档.md)、[docs/11-20260717部署事故分析.md](11-20260717部署事故分析.md)、[docs/12-部署实际操作记录.md](12-部署实际操作记录.md)、[docs/17-生产数据迁移.md](17-生产数据迁移.md)(24 迁移专项,2026-08-09) > > **版本记录:** > - **v16** — 2026-08-10:§3 补批量迁移执行序 + destructive ordering(批次 1 日期收窄须与发代码同窗口);§7 补 gitea TLS 落地;§6 补构建加速落地(腾讯 pip/npm 源 + BuildKit,详见记忆 docker_build_optimization) > - **v1** — 初稿(生产工程化框架,一期 + 二期) > - **v2** — 吸收三档补强:破坏性迁移硬判定、backup.sh 上机查清、扩对盘、compose 范围澄清、挂卷后 logrotate、pre-deploy 快照留存、registry token 轮换、alembic head 采集、增强级 > - **v3** — 二次反查:`.dockerignore` 脱离 git、backup.sh 连库路径两个真 bug + frontend healthcheck/双 tag/治理策略打架/工作区检查/判定扫描时机/image 写法六个设计漏洞 > - **v4** — 吸收 9 条遗漏:worker 优雅停机、回滚复用 rollback.sh、alembic 采集命令、pull 限定服务、index.html 缓存、pgvector 升 major、并发部署锁、dump 清理排序、worker 健康检查、BuildKit secret > - **v5** — 固化 v3 未落点 + 10 条遗漏:§5 改 count 制、FRONTEND_TAG/前端 healthcheck/双 tag 提升主体、versions.log 双 tag schema、破坏性判定统一为 log、工作区检查入 §2、真 bug 定修复(#5/#6)、引用统一、act_runner Docker 能力、alembic_version 容错、13 条规则内联;**磁盘扩容已完成 100G** > - **v6** — 吸收 10 条遗漏:`--no-deps` 绕过 migrate 依赖(需显式验 migrate 退出码)、predeploy 快照全量无排除、logrotate copytruncate、.env/config 回滚耦合、compose 服务名上机 #7、自动回滚数据源 = 最近 predeploy、registry 盘余量、CI/手动共享 flock、/healthz 端点、4 workers 语义澄清 > - **v7** — 吸收 10 条遗漏:**frontend healthcheck 改 busybox wget(nginx:alpine 无 curl,真 bug 修正)**、二期 image 改 registry 全地址固化、growpart 补步、公网 HTTP registry 凭证嗅探、回滚边界(未完成=清理/部分完成=回滚)、migrate成功+backend失败子场景、恢复演练双备份、alembic before/after 顺序、rollback 追 log、§3 加注 > - **v8** — 吸收 25 条(P1-P15 + E1-E10):**migrate 验证改 `docker compose run --rm` 前台阻塞**(P1 up -d 异步下 ExitCode 误判 0 + P2 restart:no 二次不重跑,合并修法)、P3 predeploy 快照迁移窗口局限、P4 前端 VITE build 期固化、P5 采集命令免密码(run alembic current)、P6 daemon.json 改需重启 docker、P7 restart 已存在确认、P8 backend 无卷已查/未来加卷防线、P9 日志轮转二选一、**P10 TLS 新小节**、P11 备份推 COS 异地、P12 destructive 判定细化、P13 资源 limits、P14 冷启动 runbook、P15 alembic 多 head、E1 predeploy 清理保护、E2 rollback 双 tag 原子、E3 CONCURRENTLY/pgbouncer 迁移限制、E4 worker grace 统一 stop_grace_period、E5 rollback 跳 rollback 事件行、E6 外部反代、E7 TZ 决策、E8 RPO、E9 worker 任务幂等、E10 nginx.conf 改需 rebuild > - **v9** — 吸收 8 条(N1-N8):**N1(最要紧)失败自动回滚数据丢失洞——`.pending-deploy` 标记机制**(versions.log 只在成功后才写,失败处理器读它必读到"上一次成功"的 destructive=否 → 朴素换 tag → 破坏性迁移已落地 + 旧代码丢数据;改为 migrate 前写 `.pending-deploy` 含 sha/destructive/predeploy 快照路径,失败处理器读标记,成功后才挪进 versions.log)、N2 deploy.sh 合成单一序列(migrate 插 tag↔up 之间)、N3 versions.log 采集改 run --rm、N4 N1 同根因单列、N5 冷启动门对无 healthcheck 服务改判 running、N6 worker 健康门控落到可执行方案、N7 备份路径统一、N8 跑迁移前显式 export BACKEND_TAG > - **v10** — 吸收 8 条(M1-M4 + V5-V7):**M1 nginx /api proxy_pass 必须写 compose 服务名 `backend:8000`**(写 localhost 会跨容器调自己必失败;实测 nginx.conf:83 已正确,文档固化防改错);**M2 worker/migrate 显式用 `${BACKEND_TAG}`**;**M3 首次部署无 prev 的 destructive 判定小注**;**M4 worker/migrate 只写 `image:` 不写 `build:`**(复用预构建镜像);**V5(真实)worker healthcheck 改 python 探活**(worker 镜像无 redis-cli,N6 的 redis-cli 探活会 not found → 永远 unhealthy → 门控卡死);**V6 内置验证轮询等健康**(up 后 start_period 内勿立刻 curl);**V7 迁移期间无并发改口 run --rm 阻塞保证** > - **v11** — 吸收 3 条 + 2 阻塞项重申:**①gitea_data 卷备份缺口**(含 git 仓库 + 二期 registry blobs,§4 只 pg_dump 漏了它——busybox tar + COS 异地,一期标注二期前补);**②破坏性回滚数据回退落到命令形态**(优先 `run --rm backend alembic downgrade <上一 revision>` 确定性脚本化,predeploy dump 兜底,写进 rollback.sh);**③后端日志落盘提前到一期 §8**(镜像重建 json-file 日志即丢、查日志是日常刚需,与北极星直接相关;一期对齐前端 nginx 挂卷 + logrotate copytruncate + P9 二选一,二期 §4 只留 Loki/Prometheus 高级归集);阻塞级 #1 backup.sh、#8 TLS 维持不变 > - **v12** — 吸收 5 条(A-E):**A(真实会踩)§8 backend 日志卷激活 P8 权限坑**——backend 是 `USER scilit` 非 root,挂 root 属主主机目录写不进 → 首跑即 PermissionError(nginx root 跑无此问题);按 §6 P8 结论 chown/固定 UID+卷初始化;**B(概念陷阱)破坏性回滚主路径改为 predeploy dump,downgrade 降级**——alembic downgrade 只反向 schema 不恢复数据(DROP COLUMN 要么 no-op 要么 NotImplementedError,被删列数据回不来),主路径 = pg_restore 快照;**C frontend 一期构建机制写清**(compose 含 frontend build + Dockerfile.prod,deploy.sh build 覆盖前后端);**D .pending-deploy 落盘位置明确**(部署目录持久路径、gitignore、别放 /tmp);**E gitea_data tar 备份加数量保留 N=3**;阻塞级 #1/#8 维持不变 > - **v13** — 吸收 5 条(F-J):**F destructive 判定基线钉死**——`` = `git pull` 前的本地 HEAD,deploy.sh 在 pull 前 `PREV_SHA=$(git rev-parse HEAD)` 采集(此刻 HEAD = 服务器当前生产版本),`` = pull 后新 HEAD;严禁写成 pull 后的 `HEAD~1..HEAD`(一次 pull 多 commit 迁移会漏判/错判);**G deploy.sh 开头 `set -a; source <部署目录>/.env`**——`${PG_PASSWORD}`/`${REDIS_PASSWORD}` 用于 psql 备选采集、worker 探活,不 source 则变量为空 → 连不上/密码错;**H predeploy 用 `pg_dump -Fc`**——pg_restore 只吃 custom 格式,plain 只能 psql -f 恢复;-Fc 兼容 + 大库并行恢复 -j;**I deploy.sh 开头检测遗留 `.pending-deploy`**——上次中途崩溃(如重启)残留,先打印"上一次部署异常退出,请确认状态"再继续,不静默覆盖;**J 宿主机建 `/etc/logrotate.d/scilit-backend`**——backend + nginx 两路径同块、`copytruncate`,cron.daily 自动轮转 → **v14** — 吸收 2 条(K-L):**K 迁移 run --rm 加 `--no-deps`**(backend 的 depends_on 含 migrate 服务,`docker compose run` 默认会先拉起 migrate 依赖再跑 → migrate 服务先跑一遍 upgrade head、紧接显式那条又跑一遍——虽 alembic 幂等不报错,但迁移跑两次、与"迁移统一由 run --rm 做"的立意自相矛盾;全部 run 命令加 `--no-deps`,postgres 由冷启动健康门保证已 healthy);**L 破坏性回滚 pg_restore 补覆盖方式**(H 改 `-Fc` 后直接 `pg_restore -d scilit` 会因对象已存在报错——明确 `pg_restore --clean --if-exists -d scilit <快照>` 先清后恢复,或临时库恢复再 rename) > - **v15** — 2026-08-09 首轮执行 + 一次生产事故复盘:**✅ §0 磁盘扩容全部落地**(growpart + **xfs_growfs**——文件系统是 xfs 不是 ext4,resize2fs 会报 Bad magic number,§0 正文已改);**✅ §4 backup 落地**(服务器 `/root/scilit/scripts/backup.sh`,compose-exec pg_dump -Fc 机制,crontab `0 3 * * *`,手动验证 2.5GB/302 TOC/46 表);**✅ §7 TLS 走②前置 Caddy 落地**(frontend 宿主端口 80→8080、容器内仍 80,Caddy 占 80/443 自动证书,证书经 tls-alpn-01 签发成功,80→308 跳转,PUBLIC_BASE_URL/CORS_ORIGINS 已同步 https);**⚠️ 事故复盘(真坑,补进 §2/§3)**:手动 `docker compose up -d frontend` **未加 `--no-deps`** → 拉起 frontend→backend→migrate 依赖链 → migrate 以**旧镜像**跑 `alembic upgrade head` 报 `Can't locate revision '6b662a8c5235'`(**生产镜像 2-3 周未更新、落后于 DB schema**,DB head 6b662a8c5235 旧镜像不认识)→ backend/frontend 全部 Created 未启动、应用中断。恢复:恢复旧 backend 镜像 + `up -d --no-deps --no-build backend frontend`。**结论固化**:任何 `up/run` 动应用容器必须 `--no-deps`(本方案已写,执行时务必照做);**生产镜像落后于 DB schema 是部署事故隐患**——当前运行镜像(backend a6d197830cc2/frontend 95f910a68bd9,7月中)比 DB(head 6b662a8c5235)旧,真正部署新代码前需先对齐 ## 北极星定位 **核心诉求 = 方便运维**——日常更新/部署更快、更稳、可回滚、少踩坑、可观测、可恢复。 **关键认知:** - 易运维的关键是**生产侧可复现、可回滚、可观测、可恢复**,不是"本地 = 生产" - 宿主机层(Windows vs 腾讯云 CVM)不可能完全一致;容器化保证**容器层一致**(同镜像 → 依赖 / schema / 迁移 / 构建产物一致) - 驱动痛点的根源是**手工多步部署脆弱**(docker cp 不持久、`__pycache__`、worker 不重启、assets 污染、迁移文件、无版本回滚) - **生产磁盘允许扩容**(腾讯云 EBS 可在线扩容,非破坏性)——CI + Registry 的磁盘约束随之解除 **总体形态**: - **一期(基座)**:不依赖 CI,立刻可用——版本化 + 脚本化 + 迁移安全 + 备份演练 + 镜像治理 + Dockerfile 卫生 - **二期(自动化)**:Gitea Actions + Container Registry 全自动 CI/CD + 可观测 + feature flag - 一期是二期的**前置条件**(版本化、健康门控、镜像治理必须先有),二者衔接不冲突 --- ## 一期:生产工程化基座(独立可落地) ### 0. 磁盘扩容(✅ 已全部落地 2026-08-09) - **2026-08-08 云盘从 20G 扩至 100G**(腾讯云 EBS);**2026-08-09 补做分区 + 文件系统扩容**:`growpart /dev/vda 1 && xfs_growfs /` → 生效 100G,可用 25G、76% - **⚠️ 文件系统是 xfs,不是 ext4(真坑,v15 修正)**:`df -h` 未生效时完整三步 = ①控制台扩 → ②`growpart /dev/<盘> <分区号>` 扩分区 → ③**`xfs_growfs /`** 扩文件系统。**绝不能写 `resize2fs`(ext4 专用,xfs 上直接报 `Bad magic number in super-block`)**——xfs 用 `xfs_growfs`,且 xfs 不支持缩容。先 `lsblk -f` 确认 FSTYPE 再选工具 - 保留认知(扩容时已确认目标盘):pgdata/redisdata/esdata/miniodata/gitea_data(registry 复用 gitea_data)是**不同卷、可能在不同挂载点**,CI 缓存(二期)在 runner 本地——已扩 pgdata 所在吃紧盘 - 容量依据:pgdata 卷增长 + 多版本镜像(N=5,后端 ~500M×5)+ CI 构建缓存(二期)+ registry 存储(二期,复用 gitea_data 卷)+ 日志 ### 1. 版本化镜像标签(镜像即版本) - 每次构建打 **git-sha 标签**:`docker tag scilit/backend:latest scilit/backend:`(前端同理) - **compose 用环境变量插值引用 git-sha(后端 + 前端双变量,v5 固化到主体)**:`image: scilit/backend:${BACKEND_TAG:-latest}`、`image: scilit/frontend:${FRONTEND_TAG:-latest}`,deploy.sh 同时传 `BACKEND_TAG= FRONTEND_TAG=`——compose 文件稳定、git pull 不冲突,sha 只在运行时传入。不用 `latest` 当生产版本(`latest` 不指向确定提交,仅便捷别名) - 保留最近 N 个旧 tag(磁盘允许下 N=5–10);**回滚 = 换 tag 重启**,秒级 - 前置:给 backend/worker/**migrate**/frontend 在 `docker-compose.prod.yml` 显式 `image:`(当前由 compose project 名生成,基线不稳定);**worker/migrate 与 backend 共用同一镜像 + 标签,显式用 `${BACKEND_TAG}`(M2)**——backend/worker/migrate 三服务 `image: scilit/backend:${BACKEND_TAG:-latest}`、frontend `image: scilit/frontend:${FRONTEND_TAG:-latest}`,**跑迁移的代码版本必须 = 生产版本**(否则 migrate 用的是旧镜像,迁移与代码不同步);**worker/migrate 只写 `image:`、不写 `build:`(M4)**——复用 deploy.sh 预构建的版本镜像,移除 `build: ./backend`,避免"声明了 build 但部署时不 build"的矛盾语义(backend 保留 build 作为构建源) - **前端缓存策略配套(换 frontend 镜像后浏览器缓存 404)**:旧浏览器缓存的 `index.html` 会去请求已不存在的旧 hash 资源 → 404。nginx 对 `index.html` 设 `Cache-Control: no-cache`,对 `assets/*`(哈希文件名)长缓存 `immutable`——改 `frontend/nginx.conf`;**⚠️ nginx.conf 是 COPY 进镜像的(E10)**——改它必须 rebuild 前端镜像重新部署,`docker cp` 进容器不持久、重启即丢 - **⚠️ nginx /api 的 proxy_pass 必须写 compose 服务名 `backend:8000`(M1,文档固化防改错)**:实测 `frontend/nginx.conf:83` 已正确写 `set $backend_upstream http://backend:8000;`——**写 `localhost:8000` 会跨容器调自己、必然失败**(frontend 容器内 8000 无服务)。约束固化:proxy_pass 目标永远用** compose 服务名 + 端口**(backend:8000),不用 localhost/127.0.0.1;nginx 侧 resolver 动态解析(nginx.conf 已配 `resolver 127.0.0.11`)配合 backend 容器重建后 IP 变化 - **frontend healthcheck + 双 tag 原子回滚(v5 提升到主体)**:frontend 需自身 healthcheck,不能只靠 `depends_on: backend: service_started`;**⚠️ nginx:alpine 默认不含 curl(v7 真 bug 修正)**——healthcheck 用 **busybox `wget`**(alpine 自带):`wget -q -O- http://localhost/ || exit 1`,或 Dockerfile 另装 curl;**回滚 = backend+frontend 双 tag 原子切换**——两者同一次部署、同一条 versions.log 记录,绝不允许只回一个导致前后端版本错配 ### 2. 脚本化部署(消灭 13 条规则的坑) > **"13 条规则"来源内联(v5)**:docs/10-生产部署文档.md §12 的 13 条严格部署规则(记忆 production_deployment_rules.md)——本方案将其固化为脚本,此处不重复罗列,执行时以脚本为准 - **compose 范围澄清(阻塞级)**:`up -d --no-deps backend worker frontend` 意味着 gitea/postgres/redis/es/minio 必须已在跑——**先确认这些服务与 backend 在同一 `docker-compose.prod.yml`**(`docker compose ps` 实查),部署前基础设施已在跑,避免漏起或误动 - **冷启动 vs 热部署(P14)**:`up -d --no-deps` 假设基础设施已在跑,**服务器重启后全栈 down 时直接跑 deploy.sh 会连不上 db**——deploy.sh 顶部加**基础设施健康门**:postgres/redis/es/minio 状态异常即中止并提示先冷启动;**⚠️ 门控判定按"有 healthcheck 判 healthy、无 healthcheck 判 running"(N5——不能一刀切要求 healthy,否则对没配 healthcheck 的服务门控永远失败、deploy 永远跑不了)**;注:当前 prod compose 四服务均已配 healthcheck(pg_isready / redis-cli ping / es curl / minio curl),但脚本仍按 running 兜底、防未来新服务漏配;冷启动 runbook(写进 docs/10):`docker compose up -d postgres redis es minio gitea` → 等 `docker compose ps` 达门控标准 → 再走 deploy.sh - **`deploy/deploy.sh`(v9 合成单一序列,N2——migrate 编进有序步骤,照做不漏步)**:**脚本开头先 `set -a; source <部署目录>/.env; set +a`(G——`${PG_PASSWORD}`/`${REDIS_PASSWORD}` 在 psql 备选采集、worker 探活里用到,不 source 则变量为空 → psql 连不上/探活密码错;`.env` 不进 git,靠运行时加载)** → ①**工作区干净校验**(`git status --porcelain` 为空,否则 hotfix 残留导致 pull 冲突,冲突即中止)→ **①.5 采集 `PREV_SHA=$(git rev-parse HEAD)`(F——destructive 判定基线,必须在本步 pull 之前,见 §3)** → ②`git pull` → ③**预部署 pg_dump 快照(安全网)** → ④build + 打 git-sha 标签 + **`export BACKEND_TAG= FRONTEND_TAG=`**(给后续 `run --rm`/`up` 用,N8;**frontend 镜像一期来源写清(C)**:compose 已含 `frontend: build: {context: ./frontend, dockerfile: Dockerfile.prod}`([frontend/Dockerfile.prod](frontend/Dockerfile.prod))——deploy.sh 的 build 步对 backend/frontend 都 `docker compose build`,一期无 CI 也不用手动 `docker build -f`) → ⑤**采 before alembic head → 写 `.pending-deploy` 标记(N1,见下条)** → ⑥**跑迁移** `docker compose run --no-deps --rm backend alembic upgrade head`(K——**必须 `--no-deps`**:backend 的 depends_on 含 migrate 服务,`run` 默认先拉起 migrate 依赖再跑 → migrate 服务先跑一遍 upgrade、紧接这条又跑一遍,迁移跑两次且与"迁移统一由 run --rm 做"自相矛盾;postgres 由冷启动健康门保证已 healthy,`--no-deps` 不会连不上;前置:postgres healthy;**退出码非 0 即中止**,见下条)**→ 成功后采 after alembic head** → ⑦`up -d --no-deps backend worker frontend`(显式指定,不动基础设施;migrate 的 depends_on 门控作双保险) → ⑧**内置验证——轮询等健康(V6)**:up -d 后新 backend 仍在 start_period(healthcheck 未过),**立刻 curl 会命中启动中、误判失败**——**轮询 `/health` 直到 healthy 或超时(如 60s×5s)**,再验前端 200 → ⑨**全部成功后才把 `.pending-deploy` 挪进 versions.log**(追加一行 + 删标记) → ⑩镜像治理清理。**失败处理分两段(v7 边界 + v9 读标记修正)**:**①部署未完成(③-⑥之间失败,迁移未跑)** → 容器还是旧的、**无需数据回滚**,只清理失败中间态(临时镜像/标签)即可;**②部署部分完成(迁移已跑/容器已切)** → 调用 `rollback.sh`——**读本次 `.pending-deploy` 而非 versions.log(N1,见下条)**;**回滚数据源(v6 补)**:需数据回退时**优先用 `.pending-deploy` 里记的本次 predeploy 快照**(部署前最新状态),**绝不用更早的 dump** - **⚠️ `.pending-deploy` 标记(N1 最要紧——失败自动回滚的数据丢失洞修复)**:versions.log **只在部署成功后才写**——若某次部署跑了**破坏性迁移(已成功落地)**、紧接着 backend up 失败,失败处理器调 rollback.sh 时读 versions.log,读到的必然是**上一次成功部署**的 `destructive=否` → **朴素换 tag、不回退数据** → 破坏性迁移已落地 + 旧代码 = 数据/代码不匹配、**丢数据**。**修复:deploy.sh 在 ③快照后、⑥迁移前写 `.pending-deploy`**,内容含 ` <本次 predeploy 快照路径>`(destructive 由 §3 的 git diff 判定此时即算出)——**失败处理器②只读 `.pending-deploy`,绝不读 versions.log**;**N4(N1 同根因单列)**:任何"部署失败时读 versions.log 判定本次 destructive"的做法都是这个洞——versions.log 记录的是上次成功,覆盖不了本次失败,统一只认 `.pending-deploy`。**⑨全部成功后才把它挪进 versions.log**(追加 + 删除标记);失败路径回滚完成后同样清除标记;**⚠️ 落盘位置(D)**:`.pending-deploy` 是 deploy.sh 在主机写的**运行时标记**——必须落在**持久主机路径**,即部署目录内稳定位置 `/.pending-deploy`(**别放 /tmp**,重启即清;**别放会被 `git clean`/hotfix 清理或与 `git pull` 冲突的位置**),且该文件加入 **`.gitignore`**(运行时状态不进 git);**⚠️ 遗留标记检测(I)**:deploy.sh 开头(source .env / 工作区校验前)先查 `/.pending-deploy` 是否存在——存在说明**上一次部署中途崩溃(如服务器重启)未正常收尾**,deploy.sh **不得静默覆盖**:先打印"⚠️ 上一次部署异常退出,残留 `.pending-deploy`,请先确认当前状态(容器/迁移/versions.log)再继续",人工确认后清理标记或由脚本继续 - **强制重建**:`up -d` 默认镜像 tag 变才重建,若 `${BACKEND_TAG:-latest}` 解析出的 latest 与旧容器一致会**不重建容器**("改了代码没更新"经典坑)——deploy.sh **始终传 BACKEND_TAG=<新sha>** 或加 `--force-recreate` - **migrate 时机 + 显式验证(v8 关键重写——P1+P2 合并修法)**:`up -d --no-deps` 会**跳过全部 depends_on 检查**,只靠时间顺序无法保证 backend 等 migrate 退出。且**原 v6 的"`up -d migrate` + `docker inspect ExitCode`"方案本身有两个真 bug**:**P1** `up -d` 异步返回,容器仍在 running 时 `inspect .State.ExitCode` 恒为 0(running 状态 ExitCode 无意义)→ **migrate 还没跑完就被误判成功**;**P2** migrate `restart: "no"`,首次跑完即 exited,**二次部署时 `up -d migrate` 对已 exited 且配置未变的容器不重跑** → **迁移静默跳过**。**合并修法:迁移统一用 `docker compose run --no-deps --rm backend alembic upgrade head`(K——必加 `--no-deps`,见上条)**——①前台阻塞到容器退出,**退出码即真值**(无 P1 误判);②每次都是全新容器**确定性重跑**(无 P2 跳过);③容器内走 `DATABASE_URL` 自带密码(顺带免 P5 的 PGPASSWORD 问题);④`--no-deps` 不拉起 migrate 依赖,避免迁移跑两次。**⚠️ 跑 `run --no-deps --rm backend` 前必须先 `export BACKEND_TAG=<新sha>`(N8)**——`run --no-deps --rm` 用的是 compose 的 `image:` 字段,`${BACKEND_TAG:-latest}` 未设则解析成 latest → **拿旧镜像跑迁移**(旧迁移、对不上新代码);deploy.sh 在 ④build/tag 时已 export(见上条),但脚本内 ⑥迁移步骤前显式再确认一次(幂等,防手改脚本漏掉)。前置:postgres 必须 healthy(冷启动门已保证,见上条 P14)。**退出码非 0 即中止部署**(这同时落实了 §3"失败即中止/迁移期间无并发"的真正保证点)→ 确认退出 0 后再 `up -d --no-deps backend worker frontend`。migrate 服务保留在 compose(作为声明式迁移入口 + 非 `--no-deps` 路径的 depends_on 门控),但**脚本判定只认 `run --no-deps --rm` 的退出码** - **`deploy/rollback.sh`(v9 双入口修正,N1 配套)**:换旧 tag + 重启;**两种入口读不同来源——①手动回滚(人主动跑、目标是任意历史版本)**:旧 sha + destructive 从 **versions.log** 读(**跳过 rollback 事件行,E5**——取最近的非 rollback deploy 行,否则会回滚到"上一次回滚"、甚至反复回滚循环);**②部署失败自动回滚(deploy.sh 失败处理②调)**:读**本次 `.pending-deploy`** 的 sha + destructive + 快照路径——**绝不用 versions.log**(那是上一次成功的判定,正是 N1 的洞);**先判迁移**(本次 sha 是否带新迁移,决定是否先数据回退再换 tag);**破坏性数据回退落到命令形态(v12 概念修正——downgrade 不恢复数据)**:**数据恢复主路径 = `pg_restore` 本次 predeploy 快照**(数据 + schema 一起回);**⚠️ 覆盖现有库的方式(L——H 改 `-Fc` 后的收尾)**:目标库已有旧 schema/数据,直接 `pg_restore -d scilit <快照>` 会因**对象已存在**报错——必须**先清后恢复**:`pg_restore --clean --if-exists --no-owner -d scilit <快照>`(`--clean` 先 DROP 已存在对象、`--if-exists` 缺对象不报错、`--no-owner` 免属主匹配),或更稳妥的**临时库恢复再 rename**(起临时库 `createdb scilit_restore_tmp` → 恢复 → 校验 count → 停 backend → `ALTER DATABASE scilit RENAME TO scilit_old; ALTER DATABASE scilit_restore_tmp RENAME TO scilit` → 起 backend;rename 方式迁移窗口更短,适合大库);**⚠️ alembic `downgrade()` 只反向 schema、不恢复数据**——DROP COLUMN 的迁移 downgrade 要么 no-op 要么 NotImplementedError,**被删列的数据永远回不来**,若优先 downgrade 会"以为安全其实不安全"。downgrade 仅用于**可逆的非破坏性 schema 调整**这类少见场景(且同样丢该 schema 内数据);downgrade 目标 revision(如用)= `.pending-deploy`/versions.log 里的 **before alembic head**;**回滚动作也追加一条 versions.log(标记 rollback 事件,v7 补)**——便于审计回溯「何时部署、何时回滚、回滚到哪个 sha」;**双 tag 原子切换(E2)**:`BACKEND_TAG=<旧sha> FRONTEND_TAG=<旧sha>` **同时传**、一次性 `up -d`——绝不分两次 up(中间态前后端版本错配) - **`deploy/hotfix.sh`**:仅紧急单文件,**强制回流**——用后必须 git 提交 + 正式部署,杜绝"手工改与仓库不一致"重演;**末尾强制收口**:打印"docker cp 不进镜像、容器重建即丢"警告 + 提示限时完成正式部署(hotfix 属脆弱窗口) - **.env/config 与代码版本耦合(v6 补,回滚漏项)**:新代码可能依赖新 env 变量,回滚旧代码后 .env 仍是新的——旧代码若**缺必需变量会启动失败**。处理:①代码对新增配置**尽量给默认值/可选**(向后兼容),回滚旧代码总能启动;②`.env` 本体**仍不进 git**(含密钥),非机密配置默认值随 `docker-compose.prod.yml`/`.env.example` 走 git 版本;③回滚 = 换 tag 时**确认旧代码不依赖本次新增的必需变量**(deploy.sh 可在回滚前 diff 校验);**前端是 build 期固化(P4,与后端机制不同)**:Vue 的 `VITE_*` 变量在 `npm run build` 时写死进 dist——运行时改 compose 的 `VITE_API_BASE` **不生效**,改前端任何构建期变量 = 必须 rebuild 前端镜像重新部署(当前 compose 里 frontend 的 `VITE_API_BASE: /api/v1` 是部署时摆设,真值在构建时已固化) - **Worker 优雅停机(防长任务丢失)**:镜像优先会重建 worker,但 `up -d` 默认立刻 kill——worker 正跑长任务会丢/坏任务。**worker 捕获 SIGTERM 完成当前任务或重新入队(ARQ 支持 graceful shutdown),compose 设 `stop_grace_period: 60s`(E4 统一此处,删掉 deploy.sh 手动 SIGTERM——compose 重建时本来就会先 SIGTERM 再等 grace,手动发是重复机制)**;**⚠️ 重入队的前提是任务幂等(E9)**:SIGTERM 后任务回到队列重跑,若任务**非幂等**(重复执行有副作用,如重复发邮件/重复扣款/重复写重复数据)则优雅停机反而造成重复执行——部署前确认所有 ARQ 任务幂等(作业内做去重/幂等键),否则仅完成当前任务、不重入队 - **并发部署锁**:两人/两终端同时 deploy 会抢 tag 和 versions.log。**deploy.sh/rollback.sh 顶部 `flock` 单实例锁**(`exec 9>/tmp/scilit-deploy.lock; flock -n 9`),拿不到锁即中止。一期手动风险低,二期 CI 自动部署后变硬需求——现在定习惯成本最低 - **`deploy/versions.log`**:每次部署记录 `<时间> <迁移前/后 alembic head> <镜像> `(v5 双 tag schema)——**后端/前端独立 tag**,供 rollback 双 tag 原子回滚读旧版本;**alembic head 由 deploy.sh 采集(宿主机无 venv,必须经容器执行)**——**顺序(v9 统一 run --rm,N3):①迁移前采 before ②`docker compose run --no-deps --rm backend alembic upgrade head` 成功(退出码 0)后采 after**(原 v7 写的是 `up -d migrate`,与 P1 改 run --rm 矛盾,统一;before 采集在写 `.pending-deploy` 前、after 在⑥迁移后),各执行 **`docker compose run --no-deps --rm backend alembic current`**(容器内走 `DATABASE_URL` 自带密码,**无需 PGPASSWORD——P5**;psql 备选 `docker compose exec -T postgres psql -U scilit -d scilit -tAc "SELECT version_num FROM alembic_version"` 在 pg_hba 非 trust 时会要密码,须加 `PGPASSWORD=${PG_PASSWORD}` 前缀)落库(一次性 migrate 容器跑完即退,无法自行回传,必须 deploy.sh 代采);**⚠️ 首次部署 alembic_version 表可能不存在(v5 容错)**:采集前先 `SELECT to_regclass('alembic_version')` 判存在,表不存在 → 记空,否则首跑即 abort;**自身轮转**——每次追加后 `tail -n 200` 截断(或配 logrotate),防高频部署下无限增长 ### 3. 迁移安全(高频升级最易翻车点) - migrate 独立成服务 + `depends_on: migrate: service_completed_successfully`(已在 prod compose) - **向后兼容规范**:先加字段/表,不删不改旧结构;旧代码全下线后下一版再清理 - **失败即中止**:migrate 失败 → backend/worker 不启动,不替换容器;**⚠️ 加注(v7 起)**:在手动 `up -d --no-deps` 流程下,这**并非 compose 自动保证**——`--no-deps` 跳过 depends_on,真正保证在 §2 的「`run --rm` 前台跑迁移、退出码非 0 即中止」步骤(v8 起判定方式见 §2),勿误读为 compose 自动行为 - **迁移期间无并发(V7 改口,与 §2 一致)**:**`run --rm` 前台阻塞保证** migrate 完成(exit 0)后才起 backend/worker(手动 `--no-deps` 路径下 compose 的 depends_on 顺序**不生效**,同 §2 N2/N3——真正保证在 §2 的 run --rm 前台阻塞 + 退出码判定),避免"新迁移 + 旧代码"并发跑在旧 schema 上;首次/失败路径也要确认不出现并发(部署窗口内 backend 保持旧版直至 migrate 通过) - **⚠️ 2026-08-09 事故复盘(真坑,v15 补)**:手动 `docker compose up -d --no-build frontend` **没带 `--no-deps`** → compose 按依赖链拉起 frontend→backend→migrate;migrate 以 `scilit-migrate:latest`(**旧镜像,2-3 周未更新**)跑 `alembic upgrade head` → **`Can't locate revision '6b662a8c5235'`**——**生产 DB schema(head 6b662a8c5235)比运行中镜像认识的 head 新**,旧镜像的 alembic/versions 里没有该 revision → migrate 退出 255 → backend(depends_on migrate service_completed_successfully)与 frontend 全部 **Created 未启动、应用中断**。**恢复**:`docker tag <旧backend镜像> scilit-backend:latest` + `docker compose up -d --no-deps --no-build backend frontend`。**教训固化**:①任何动应用容器的 `up/run` **必须 `--no-deps`**(§2 已写死,执行时照做,别省略);②**生产镜像落后于 DB schema 是隐患**——当前运行镜像(backend a6d197830cc2 / frontend 95f910a68bd9,2026-07-17 构建)比 DB head 旧,`upgrade head` 在这种状态下**必然失败**;真正部署新代码前需先把镜像更新到与 DB 对齐的版本(§1 版本化镜像正是解药);③`up -d <服务>` 的依赖链是 **frontend→backend→migrate**,误触发 migrate 的代价是整条链全停——冷启动/单独起某服务一律用 `--no-deps` - **迁移回滚 runbook(写进 docs/10)**: - **明文约定**:只要坚持"向后兼容、只增不删",回滚(换旧 tag)就是安全的——旧代码对新加的列/表可忽略 - **破坏性迁移硬判定(v5 统一为 deploy 时落 log,替代扫工作区)**:破坏性迁移(删列/改类型/重建表)文件统一命名 `destructive_*.py`(或迁移文件头部醒目 `# DESTRUCTIVE` 注释)**作双保险**;**主判定改为 deploy.sh 部署时基于 `git diff .. -- alembic/versions/` 判定,把"是否 destructive"写进 versions.log**;**⚠️ `` 基线钉死(F)**:`` = **`git pull` 前的本地 HEAD**——deploy.sh 在 ①工作区校验后、②`git pull` 前采 `PREV_SHA=$(git rev-parse HEAD)`(此刻 HEAD = 服务器当前生产版本),`` = pull 后新 HEAD,`git diff PREV_SHA..` = 本次部署真正引入的迁移;**严禁写成 pull 后的 `HEAD~1..HEAD`**——一次 pull 常带入多个 commit 的迁移,`HEAD~1` 不是上次生产版本,diff 会漏判/错判 destructive;**⚠️ 判定规则必须细化,不能笼统"检测 drop/alter"(P12——过粗会让每次 `ADD COLUMN` 都误触发、强制数据回退)**:判 destructive **只看**真正破坏性操作——`DROP TABLE / DROP COLUMN / DROP INDEX`、`ALTER COLUMN TYPE`(类型变更)、`RENAME`(表/列/索引重命名)、`ALTER COLUMN SET/DROP NOT NULL` 收紧、重建表(create_table 后 drop 原表)、破坏性数据变更(批量 UPDATE/DELETE);**明确不计入(良性)**:`ADD COLUMN`、新建表、`CREATE INDEX`(非 CONCURRENTLY)、`ALTER COLUMN SET DEFAULT / DROP DEFAULT`、加约束——向后兼容,标非破坏性——`rollback.sh` 回滚时**读 log 的 destructive 字段**而非扫当前工作区(扫工作区会被后续版本删除/改名骗过 → 漏判破坏性迁移 → 以为安全回滚其实丢数据)。标记 destructive → 强制先数据回退(**主路径 pg_restore 本次 predeploy 快照,downgrade 仅做 schema 反向不恢复数据——v12 概念修正,命令形态见 §2 rollback.sh**)再换旧 tag;无 → 直接换 tag 安全;**首次部署无 prev(M3,小注)**:`git diff ..` 无基线 → **改为直接对当前 `alembic/versions/` 目录做同样的 keyword 检测**——初始迁移多为 CREATE 建表,实测判非破坏性、风险低,不需特殊流程,小注记录即可 - **代码回滚 ≠ 迁移回滚**:Alembic 单向递增,回滚代码一般不回退迁移;含破坏性迁移时才触发数据回退流程 - **migrate 成功 + backend 失败子场景(v7 补)**:此时数据已是新 schema(且向后兼容规范下**只增不删**)——回滚只需换 tag、**不需数据回退**,与 rollback.sh 读 destructive=非破坏性(直接换 tag)一致 - **Postgres 大版本锁定**:`pgvector:pg16` 的 major 与数据卷**强绑定**;**升 major 必须 pg_dump/restore 迁数据**,绝不直接 `up -d` 换镜像(否则数据卷不兼容起不来)——此条写进 docs/10 显眼位置 - **pgvector 升 major 的特殊性(restore 前必查)**:dump/restore 恢复 vector 数据时,**目标库必须先 `CREATE EXTENSION vector`**,且扩展版本与目标 pgvector 镜像匹配;vector 索引(ivfflat/hnsw)恢复依赖扩展存在,先建扩展再恢复,否则首次升 major 必踩 - **alembic 多 head 必须提前拦住(P15)**:并行 PR / 单人多分支各自新增迁移、同 down_revision → `alembic heads` 返回多个 → `upgrade head` **直接报错中止、不应用任何迁移**。防线:①CI 加一步 `alembic heads` 校验,>1 即失败;②deploy.sh 迁移前 `docker compose run --no-deps --rm backend alembic heads` 预检,多 head 即中止;③多人协作约定:合并前先 `alembic merge` 或串行 rebase 迁移(一人一个 base) - **迁移执行受限操作(E3)**:`CREATE INDEX CONCURRENTLY` **不能跑在事务块内**——alembic 默认把迁移包在事务里,需并发建索引用 `with op.get_context().autocommit_block():`;未来若上 pgbouncer 事务池,长事务迁移会被池限制影响(迁移建议直连 postgres 服务、绕过池) - **批量迁移执行序 + destructive ordering(v16 补,2026-08-10,专项见 docs/17)**:生产 DB 落后本地多个迁移时,**不能整条链一次 `upgrade head`**,须分批执行到中间 checkpoint(`migrate_prod.sh 1|2|3|4`,见 docs/17 §4)。**关键纪律——destructive 批次不能提前单独跑**: - **批次 1(`1421ea169bb6` 日期 TIMESTAMPTZ→DATE)是类型收窄**——老代码读 datetime 会崩,**必须与发新代码同窗口**(迁移完几秒内新镜像接管),绝不能提前单独跑 - **批次 2/3 纯 additive**(加列/新表/索引),老代码兼容,**可提前任意时段跑**,缩小维护窗口 - **批次 4(`95c18ebf31e4`/`55105f0bb1d7` VARCHAR→Text + 大数据量回填)须新代码已部署后深夜跑** - 推荐序:**批次 2/3(提前)→ 维护窗口:build 新镜像 → 批次 1 → 发新 backend/worker/frontend → 深夜:批次 4** - 配套:build 加速已落地(§6 Dockerfile 卫生),迁移用 `run --no-deps --rm backend alembic upgrade `(新镜像自带新迁移,勿退回老容器 exec) - **统一备份脚本(✅ 已落地 2026-08-09,原阻塞级 #1 查清)**:上机确认 **crontab 原本无任何 backup 条目、backup.sh 根本没在跑**(历史 docs/10 的 `/home/scilit/backup.sh` 不存在),且仓库版 `PG_HOST=localhost` 连不上(postgres `ports: []` 无宿主端口)——**已新建 `/root/scilit/scripts/backup.sh` 并落 crontab `0 3 * * *`**,机制为 **`docker compose -f docker-compose.prod.yml exec -T postgres pg_dump`**(不经宿主端口,容器内 pg_dump 16.14);排除表 pipeline_runs/api_usage_logs;`--format=custom --no-owner --no-privileges`;备份目录 `/data/backups`;保留 30 天;**手动验证成功**:2.5GB / `pg_restore -l` 302 TOC / 46 表 / Format CUSTOM。**路径统一约定(N7)**:脚本 = **仓库 `backend/scripts/backup.sh`**(随 git 分发,服务器部署目录内执行),备份目录 = **`/data/backups`**——服务器实际路径 `/root/scilit/scripts/backup.sh` 与仓库 `backend/scripts/backup.sh` 需在正式部署时统一对齐(当前以服务器实际为准);docs/10 的 `/home/scilit/backup.sh`、`/backup` 等历史路径**全部废弃** - **`.env` 单点备份**:含 SMTP/JWT/API Key 全部密钥,git pull 不动它但只存服务器——**离线备份(不进 git)**,防丢密钥 - **pre-deploy 快照 = 全量 pg_dump,与日常备份分开(v6 修正)**:§2 用 predeploy dump 当"全量安全网",但若复用排除 pipeline_runs/api_usage_logs 的 backup.sh,安全网本身就缺这两表、与"全量"定位冲突——**predeploy 必须用不带排除的完整 `pg_dump`**(含 pipeline_runs/api_usage_logs),与日常 backup.sh 分开执行;**⚠️ 用 `-Fc` custom 格式(H——pg_restore 只吃 custom 格式)**:§2 rollback.sh / §4 restore-drill 的数据回退都走 **`pg_restore`**,而 **pg_restore 要求 dump 为 `-Fc` custom 格式**——plain 文本格式(pg_dump 默认)只能 `psql -f` 恢复,pg_restore 直接拒绝;**格式钉死:predeploy 用 `pg_dump -Fc`**(custom 兼容 pg_restore,且大库可并行恢复 `-j`);日常 backup.sh 已用 `--format=custom`,核对生产实际跑的那份保持一致;**⚠️ 快照是"尽力安全网",不覆盖迁移窗口写入(P3)**:快照在**旧 backend 仍在服务时**拍的——拍完到新 backend 上线之间(migrate + 容器切换窗口)仍有业务写入,此窗口内回退会**丢这几分钟数据**。这是快照式安全网的固有局限、非 bug:长窗口靠日常 03:00 backup + RPO 预期(见 E8)兜底,部署窗口的分钟级丢失在低峰 + 短迁移下可接受——**不做"先停写再拍快照"的 drain 步骤**(单机不值当),但认知要写清 - **pre-deploy 快照留存策略(容易漏)**:deploy.sh 每次部署前 dump 当安全网,高频部署下吃磁盘——**保留最近 N=3 个**(按数量清理,如 `ls predeploy_*.dump | sort | head -n -3 | xargs rm`),否则备份目录先爆;**⚠️ head -n -3 语义保护(E1)**:GNU `head -n -3` 是"去掉最后 3 行"——文件 ≤3 个时输出为空、`xargs rm` 无输入不执行,**不会误删但语义易读错**。deploy.sh 显式写成 `count=$(ls ... | wc -l); [ "$count" -gt 3 ] && ls ... | sort | head -n -3 | xargs -r rm`(`-r` 空输入不执行),防笔误 - **⚠️ 清理依赖文件名可排序**:上面的 `ls | sort` 要靠文件名里嵌入**可排序的 ISO 时间戳**(`YYYYMMDD_HHMMSS`)才正确挑最旧;用别的格式(如相对时间命名)会删错文件——deploy.sh 统一命名规范 - **补恢复演练**:`restore-drill.sh`(起临时 postgres → pg_restore → 校验 count)或文档化步骤,定期演练——否则备份等于没备;**演练覆盖两种备份(v7 补)**:①全量 predeploy(含 pipeline_runs/api_usage_logs)②日常排除 backup——**分别校验**,不能只验一种(排除表缺失/为空是否可接受要在两套上各自确认) - **恢复校验覆盖排除表**:backup.sh 排除了 pipeline_runs/api_usage_logs,恢复演练**校验这些表缺失/为空是可接受的**(避免"count 一致但关键排除表没恢复"的误判) - **备份必须异地(P11——同盘非真 DR)**:backup.sh 写 `/data/backups`(同 CVM 磁盘),**磁盘故障时备份与库俱毁,备份等于没备**。补:备份完成后自动上传**腾讯云 COS**(项目已有 `COS_SECRET_ID/KEY/BUCKET` 凭据,S3 兼容,coscli/rclone 均可)或 rsync 到另一节点;**上传失败必须告警**(备份不能静默失败);`.env` 同样纳入离线异地(已有原则) - **gitea_data 卷备份(v11 补——真实缺口)**:§4 只做 pg_dump,但 **gitea_data 卷(含 git 仓库 + 二期 registry 镜像 blobs)完全没进备份范围**——此卷一丢,所有仓库 + 二期镜像全没、要全部重 push。**一期先标注"此卷需单独备份",二期前补执行**:`docker run --rm -v gitea_data:/src -v /data/backups:/dst busybox tar czf /dst/gitea_data_.tar.gz -C /src .`,同样传 COS 异地;注意 gitea 容器运行中 tar 的一致性(git 仓库文件持久、轻微不一致可接受;严格则先 `docker compose stop gitea` 再 tar);registry blobs 量大,纳入异地时评估体积/频率(可低频率如每周);**保留策略(E)**:tar 备份**按数量清理**——保留最近 N=3 个(同 predeploy 的 N=3 思路),文件名嵌可排序 ISO 时间戳(同 §4 命名规范),`ls gitea_data_*.tar.gz | sort | head -n -3 | xargs -r rm`,防盘爆 - **RPO 预期明示(E8)**:日常 backup 每日 03:00 → **最坏 RPO ≤ 24h**(backup 失败可能拖到 48h,靠告警兜底);predeploy 快照随每次部署拍 → 部署窗口内 RPO 分钟级。**需用户确认这个 RPO 是否可接受**,不可接受则加密日常备份频率(如每 6h)——先写清预期,不擅自设默认值 ### 5. 镜像治理(扩盘后仍需防爆) - **应用镜像只按 count 清理(v5 修正,消除与 §1 矛盾)**:保留最近 N=5-10 个**带 tag 的版本镜像**(与 §1 一致),超出删除——**绝不按 age 清理带 tag 的应用镜像**(否则低频部署时 7 天外的保留 tag 被 age-prune 清掉 → 回滚点静默丢失) - **age-prune 只作用于 dangling/build cache**:`docker image prune --filter "until=168h"` 只清不带 tag 的中间层 + build cache(`-a` 仍绝不使用,避免误删构建缓存) - 磁盘监控:定期 `df -h`,超阈值告警 ### 6. Dockerfile 卫生(构建加速) > **一期 deploy.sh 在生产机 build 的前提**:Phase 1 无 CI,镜像在 CVM 上 `docker build`——生产机必须**能拉基础镜像**(python:3.12-slim、node:20-alpine、nginx:alpine、gitea 基础镜像可达)+ **具编译能力**(psycopg2/pgvector 编译,需 build-essential/libpq-dev,backend Dockerfile 已装)。腾讯 apt/pip 镜像已配,这块已具备;**首次跑前确认**网络与源可达。 - `backend/Dockerfile`:腾讯 apt/pip 镜像(回归历史 `docs/12` §4.2–4.5 的加速做法,当前已丢失) - `frontend/Dockerfile.prod`:`npm ci` + package-lock.json + npmmirror - **`.dockerignore` 统一补齐**(防密钥进镜像层 / 防旧字节码 / 缩构建上下文): - backend:`__pycache__/`、`*.pyc`、`.env`、`tests/`、`scripts/`、`data/`、`.pytest_cache/`、`*.egg-info/`(现有已含 `__pycache__`/`.env`,补齐其余) - frontend:补 **`.env`**(当前未排除,可能把 dev 环境变量打进镜像)、`__pycache__`、`*.pyc`;保留 `node_modules`/`dist` 排除 - **新增 `.gitattributes`**:`* text=auto eol=lf`——防 Windows 编辑 .sh/Dockerfile 的 CRLF 在 Linux 容器内报错 - **构建期密钥防进镜像层(防未来踩坑)**:若 backend 未来需私有 pip 源 token,别用 build ARG 写进镜像层——用 BuildKit `--mount=type=secret`。当前腾讯公开源不需要,但一句话防未来私有源踩坑 - **非 root 容器 + 卷权限(P8,已查实当前无卷、须防未来)**:backend 以 `USER scilit` 跑(backend/Dockerfile:29),**当前 backend/worker 容器没有任何卷挂载**(文件存储走 MinIO/COS,`docker compose config` 实查确认)→ **现无此问题**;但**未来任何给 backend 加本地卷都会踩经典坑**——命名卷首次挂载是 root 属主,非 root 进程写不进 → PermissionError。防线(加卷时必做):Dockerfile 加 ENTRYPOINT 启动前 `chown` 卷目录,或用固定 UID(`useradd -u 10001`)+ 卷初始化,禁止裸加卷 ### 7. TLS / HTTPS(✅ 已落地 2026-08-09,走②前置 Caddy) > **上机确认结论**:此前是裸公网 80 直连 frontend nginx,无任何加密。已按用户选定方案②落地。 - **已实施(2026-08-09)**: - `docker-compose.prod.yml`:frontend 宿主端口 **`80:80`→`8080:80`**(容器内 nginx 仍监听 80,Caddy 经 compose 网络 `frontend:80` 反代);新增 **caddy** 服务(`caddy:2-alpine`,`80:80`+`443:443`,挂 `Caddyfile` + `caddy_data`/`caddy_config` 卷);volumes 加 caddy_data/caddy_config - **`/root/scilit/Caddyfile`**:`oncolit.gonsun.com { reverse_proxy frontend:80 }`(**必须写 compose 服务名 `frontend:80`**——8080 是宿主映射、compose 网络内服务在容器端口 80;写 8080 会连不上) - **证书自动签发成功**:Let's Encrypt,经 **tls-alpn-01** 挑战(443 可达,说明腾讯云安全组 443 已放行);80 端口 Caddy 自动 **308 跳转 HTTPS** - `PUBLIC_BASE_URL`:`http://`→`https://oncolit.gonsun.com`;`CORS_ORIGINS` 追加 `https://oncolit.gonsun.com`(改 .env 后 backend 需重启生效) - **验证**:`curl -k https://oncolit.gonsun.com` → 200 + 前端 HTML;`/health` 经 Caddy→nginx→backend 返回 `db: ok`;证书 CN=oncolit.gonsun.com,90 天自动续期 - **⚠️ frontend 宿主 8080 已收紧(2026-08-09)**:`8080:80` → **`127.0.0.1:8080:80`**(只绑回环,公网无法直连绕过 Caddy;排障时本机 `curl 127.0.0.1:8080` 仍可用;Caddy 经 Docker 网络走 `frontend:80` 不受影响)。安全组仍建议只放行 80/443(禁 3000/2222 对公网),在腾讯云控制台配置 - **✅ gitea TLS 已落地(2026-08-09)**:DNS 已加 `gitea.oncolit.gonsun.com` → `123.207.9.209`;Caddyfile gitea 子站块已启用(`gitea.oncolit.gonsun.com { reverse_proxy gitea:3000 }`),证书经 tls-alpn-01 自动签发;gitea `ROOT_URL`/`DOMAIN`/`SSH_DOMAIN` 已改 `https://gitea.oncolit.gonsun.com`。**⚠️ 本地 git remote 需同步改 https**:`http://123.207.9.209:3000/scilit/backend` → `https://gitea.oncolit.gonsun.com/scilit/backend`;SSH clone 地址变为 `gitea.oncolit.gonsun.com:2222` - **未做**:registry 的 TLS(二期 §2——registry 走明文 HTTP,公网 IP 下 token 有嗅探面,须监听内网或加 TLS)。前端 nginx 容器内直接终止的路线①未采用 - **⚠️ 域名前提**:Caddy 自动 HTTPS 要求域名 DNS 已解析到本机(oncolit.gonsun.com → 123.207.9.209 ✓) ### 8. 日志落盘(v11 提前到一期——日常运维刚需) > **原放二期 §4,提前到一期**:查日志是日常运维刚需,镜像优先部署每次重建容器、json-file 日志随容器删除即丢——与北极星"可观测/可恢复"直接相关,不该等二期。一期就能做(前端 nginx 早已挂卷,后端对齐即可);二期只补 Loki/Prometheus 等高级归集。 - **后端 uvicorn 日志挂宿主机卷**(对齐前端已挂 `/var/log/scilit/nginx`):backend 挂 `/var/log/scilit/backend`,uvicorn 配置 `--access-logfile`/`--error-logfile` 指向该卷内文件(否则默认 stdout 走 json-file、随容器删除即丢);前端 nginx 侧已有 `/var/log/scilit/nginx` 挂载;**⚠️ backend 非 root 写权限(A——v11 的 §8 挂卷正好激活 P8 预告的坑)**:backend 容器是 `USER scilit` 非 root(backend/Dockerfile:29),主机绑定目录 `/var/log/scilit/backend` 是 **root 属主**——scilit 用户写不进去 → **首跑日志落盘即 PermissionError**(nginx:alpine 以 root 跑、无此问题)。**按 §6 P8 现成结论处理,缺一不可**:挂卷前宿主机 `chown /var/log/scilit/backend`,或固定 UID(`useradd -u 10001`)+ 卷初始化——**§8 挂卷不配权限处理,一期首跑必崩** - **⚠️ 挂卷后 json-file 轮转失效(容易漏)**:日志挂宿主机卷后,docker 自带 10m×3 轮转**不再覆盖这块日志**——须另配宿主机 **logrotate**(nginx 的 `/var/log/scilit/nginx` 同样要查),否则卷无限涨 - **⚠️ 轮转机制二选一(P9)**:宿主机 logrotate **或** uvicorn `RotatingFileHandler` **只能选一种**——两个都配会互相 rename 竞争、日志错乱。**定案:用宿主机 logrotate(下条 copytruncate 细则),uvicorn 保持 stdout 落盘、不配 RotatingFileHandler** - **logrotate 必须 `copytruncate`(v6 执行级真坑)**:容器内进程**不响应日志文件的 rename**——普通 logrotate(rename + create)配了也白配,文件经旧句柄继续涨。**必须配 `copytruncate`,或轮转时发 USR1 让进程重开文件句柄**,否则卷照样无限涨;**⚠️ 配置落地(J)**:在宿主机建 `/etc/logrotate.d/scilit-backend`(root 创建)——一个配置文件同时覆盖 backend + nginx 两段路径:`/var/log/scilit/backend/*.log /var/log/scilit/nginx/*.log { daily; rotate 7; compress; delaycompress; missingok; notifempty; copytruncate }`(**两段都必须 `copytruncate`**,此块已含);配好即由宿主机 `cron.daily` 每日自动轮转,无需重启任何服务 --- ## 二期:CI + Registry 全自动化 + 可观测 ### 1. Gitea Actions(构建即验证,唯一构建入口) - **当前 CI 从未生效**:`.github/workflows/ci.yml` 是 GitHub 格式,Gitea 不读;需迁到 `.gitea/workflows/`(Gitea Actions 格式) - 迁移现有 ci.yml 逻辑:backend(pgvector:pg16 service + pytest + ruff)+ frontend(vue-tsc + build)→ `.gitea/workflows/ci.yml` - 部署 **act_runner**(服务器容器),注册到 Gitea - **act_runner 需 Docker 能力才能构建镜像(v5 补)**:挂载宿主 `docker.sock`(复用宿主 daemon,简单)或 DinD + privileged(隔离强但重)——落地时确认 runner 内能 `docker build` - **CI 与手动部署共享同一 flock(v6 补)**:Phase 2 CI 触发自动部署时,脚本必须用**同一 `/tmp/scilit-deploy.lock` 路径**——否则 CI 与手动部署仍可能并发抢 tag/versions.log - 启用 Gitea Actions(compose 加 `GITEA__actions__ENABLED: "true"`) ### 2. Container Registry(生产只 pull,不构建) - Gitea 1.27 原生支持 Container Registry(`:3000/scilit/backend`) - 生产 docker daemon 配 **insecure-registries**(Gitea 无 HTTPS):`["123.207.9.209:3000"]`;**⚠️ 改 daemon.json 后必须重启 docker(P6)**:`systemctl restart docker` 会**重启本机所有容器**(除非预先设 `live-restore: true`)→ 属停机操作,**必须在维护窗口手动做**,不能夹在 deploy.sh 里静默执行(否则一次"配 registry"把整栈全重启一遍) - **⚠️ 公网 IP + HTTP registry 凭证嗅探风险(v7 补)**:`123.207.9.209` 是**公网 IP**,registry 走明文 HTTP——token 在公网/同网段可被嗅探。原「内网可信」假设**不成立**。必须:①registry 仅监听内网/防火墙限定来源,或 ②反代加 TLS(长期);否则 CI push / 生产 pull 的 token 有泄露面 - **registry 需认证**:Gitea registry 非匿名,CI push + 生产 pull 都需 `docker login :3000`(token)——token 存服务器 CI secrets / 生产凭据文件,不进 git;**token 有有效期 + 凭据文件本身敏感 → 纳入离线备份(同 `.env` 级)+ 定轮换策略**,过期/泄露即换,写进 docs/10 - **registry 盘余量(v6 补)**:registry 复用 gitea_data 卷——§0 扩的是 pgdata 吃紧盘,**二期前必须确认 gitea_data 所在盘余量**,否则 registry 满、镜像推不上去 - **二期 image: 改 registry 全地址(v3 提过、v7 固化到二期主体)**:二期 compose 的 image: 必须为 `123.207.9.209:3000/scilit/backend:${BACKEND_TAG:-latest}`(frontend 同理)——**CI push 地址与生产 pull 地址必须完全一致**(同一全限定 registry 前缀),否则 `docker compose pull` 拉的是无 registry 前缀的本地名、推不下来 - CI 构建镜像 → 推 registry(打 git-sha 标签)→ 生产 `docker compose pull && up -d` - 保留策略:registry 侧定期清旧版本 ### 3. 生产部署链路(二期形态) - push → CI 构建(带测试)→ 推 registry → 生产 `git pull`(compose 文件)+ `docker compose pull`(镜像)+ `up -d`(migrate 前置 + 健康门控) - **pull 必须限定服务**:`docker compose pull backend worker frontend migrate`——全局 pull 会连带尝试更新 postgres/gitea 等基础设施(尤其 `:latest` 镜像),造成"无意中升级基础设施";**pull 范围与 up 范围一致** - 仍走 deploy.sh 包装(一期脚本),把 build 换成 pull;**迁移同样用 `docker compose run --no-deps --rm backend alembic upgrade head`(K——必加 `--no-deps` 防 migrate 服务重复跑迁移;退出码非 0 即中止,N3 一致性)→ `up -d --no-deps backend worker frontend`** - **健康门控**:healthcheck + depends_on service_healthy 已有;坏版本自动不接流量,换旧 tag 回滚,分钟级恢复 - **worker 健康检查形态(N6 落可执行方案——现为弱探活)**:worker 无 HTTP 端点,compose 现有 `grep -q arq /proc/1/cmdline` 只探**进程存在**、不探**消费能力**——坏 worker 会被判健康。**落为可执行(写进 docs/10 + worker 实现)**:①**worker 侧提供心跳键**——ARQ 启动钩子每 N 秒 `SETEX arq_worker_heartbeat 60 `(复用已有 `REDIS_URL`,无需新 HTTP 端点);②**compose healthcheck 改探心跳——⚠️ 用 python 探,不用 redis-cli(V5,真实会踩)**:worker 镜像是 `python:3.12-slim` 底(backend/Dockerfile,只装 libpq-dev/curl),**不含 redis-cli**——healthcheck 里写 `redis-cli` 会 `not found` → worker **永远 unhealthy** → 部署健康门控反而卡死。**改用容器内已装的 redis-py(ARQ 依赖,零镜像改动)**:`python -c "import redis,sys;r=redis.Redis(host='redis',port=6379,password='${REDIS_PASSWORD}');sys.exit(0 if r.get('arq_worker_heartbeat') else 1)"`;替代方案:worker 镜像 `apt install redis-tools` 装 redis-cli(约几 MB)。探 TTL 内有效 → healthy——探**活性**而非进程存在,卡死/僵死的 worker 心跳过期 → unhealthy → 部署健康门控不再因"进程在"误放行。若后续 worker 挂独立 HTTP 服务,改探 `/healthz` 亦可,心跳键是当前最小改动 - **单机停机窗口(写进文档)**:单机无零停机(蓝绿/滚动需多实例);容器重建 + 健康检查 start_period 20s → **部署窗口 ~30s-1min**,选低峰执行;迁移+重建时更久。**"4 workers"语义澄清(v6)**:指 [backend/Dockerfile:34](backend/Dockerfile#L34) `uvicorn --workers 4`——**单个 backend 容器内的 4 个 uvicorn worker**,非 4 个容器;另一个 worker 容器(ARQ)单独存在。停机窗口按单容器重启估算即可;**外部反向代理优雅(E6)**:若前置有反代/LB(见一期 §7),backend 容器重建瞬间 upstream 会短暂 502——反代侧配健康检查剔除 / `proxy_next_upstream`,或接受该次 TCP 闪断(keep-alive 复用连接失败会重连);当前是否有反代需上机确认 ### 4. 可观测增强 - 已有:SENTRY_DSN、日志轮转(json-file 10m×3)、healthz + 健康门控 - **日志挂卷 + logrotate 已提前到一期 §8(v11)**:后端日志挂卷、logrotate copytruncate、轮转机制二选一(P9)见**一期 §8**——二期不重复,此处只补高级归集 - 补:日志归集(如 Loki 或 filebeat → ES)、基础监控(cAdvisor/node-exporter + Prometheus + Grafana)、告警(磁盘/健康/错误率) ### 5. feature flag(部署/发布解耦) - 代码常上、功能 flag 控制显隐;坏功能一键关,不靠回滚镜像 - 用环境变量/配置中心实现,后续按需引入 --- ## 可选支持层:本地 Docker(不强制) - 定位:环境一致是"提前暴露差异"的手段,主要靠 **CI 集成测试**挡差异(用生产同镜像起 Postgres 跑测试),不靠本地复现 - 若做(轻量,不迁 38G):WSL2/Docker Desktop 跑 dev compose 中间件,本地原生 uvicorn;价值是 Dockerfile 本地 build 验证 + 冒烟 - **不影响一期/二期进度,可随时后补** --- ## 关键文件 | 文件 | 改动 | |---|---| | `docker-compose.prod.yml` | backend/worker/frontend 显式 `image:`;**`deploy.resources.limits` 资源上限(P13)**——backend/worker/es 至少设 memory limit(防单容器 OOM 拖垮宿主,es 已有 `ES_JAVA_OPTS` 但未设 cgroup 上限);gitea 加 Actions/registry 配置 | | `.gitea/workflows/ci.yml`(新) | 由 `.github/workflows/ci.yml` 迁移,Gitea Actions 格式 | | `deploy/*.sh`(新) | deploy / rollback / hotfix / versions.log | | `backend/Dockerfile`、`frontend/Dockerfile.prod` | 构建加速 + 卫生 | | `backend/.dockerignore`、`frontend/.dockerignore` | 构建上下文排除 | | `.gitattributes`(新) | `* text=auto eol=lf` | | **应用 `/health` 端点(v6 重申)** | 健康门控/回滚判定全依赖它——backend 已有 `/health`(nginx 已代理);**新接手者不可漏**,若未来拆分服务需各提供 | | `docs/10-生产部署文档.md` | **既有部署操作手册**,§12 重写为镜像优先流程 + 迁移规范 + 恢复演练(v5 注明:本文件 docs/16 是方案,docs/10 是操作手册,分工不同,不冲突) | | 记忆 `deploy_image_first.md`(新) | 决策 + 脚本用法 | --- ## 验证 **一期**: - 本地 `deploy.sh --dry-run` 只打印命令;Dockerfile 静态核对 - 用户服务器首跑:`/health` db ok、前端 200、versions.log 记 sha - 回滚演练:rollback.sh 回上一 sha,无新迁移、服务正常 - 恢复演练:pg_restore 到临时库验证数据完整 - 首次切镜像会重建容器(migrate 跑迁移),需用户确认窗口 **二期**: - CI 触发 push → `.gitea/workflows` 跑 lint + pytest + 前端构建,全绿 - CI 构建镜像推 registry,生产 `docker pull 123.207.9.209:3000/scilit/backend:` 成功 - 生产 pull + up -d,健康门控生效(坏版本不接流量) - 可观测:Grafana 出图、告警规则触发一次 --- ## 首次上机确认清单(阻塞级,先查清再动手) | # | 待确认 | 出处 | 判定动作 | |---|---|---|---| | 1 | ✅ **backup 落地**(原无 crontab、脚本没跑) | §4 备份 | 已解决:新建 `/root/scilit/scripts/backup.sh`(compose-exec pg_dump)+ crontab `0 3 * * *` + 手动验证成功 | | 2 | ✅ **磁盘扩至 100G**(2026-08-08/09 分区分文件系统补齐) | §0 扩容 | 已完成:`growpart /dev/vda 1` + `xfs_growfs /` → 100G、可用 25G、76% | | 3 | ✅ **gitea/postgres/redis/es/minio 同 compose 文件** | §2 脚本化 | 已实查:全部属 `/root/scilit/docker-compose.prod.yml`(`docker inspect` label 确认);同目录另有 dev 版 `docker-compose.yml`(**命令必须带 `-f docker-compose.prod.yml`**,否则读到 dev 文件报 no such service) | | 4 | compose 是否 v2.x(`service_completed_successfully` 依赖 v2,非 v1) | §3 迁移 | 实查 v2(依赖门控已工作:2026-08-09 migrate 失败即拦下 backend) | | 5 | ✅ **backup.sh 生产连库路径**(postgres `ports: []`,宿主机 localhost 连不上) | §4 备份 | 已解决:统一为 `docker compose exec -T postgres pg_dump`(不经宿主端口) | | 6 | **.dockerignore 脱离 git**(`.gitignore` 第 50 行忽略了它) | §6 卫生 | **已定修复:从 `.gitignore` 移除 `.dockerignore`**(两个 .dockerignore 进 git),执行项 | | 7 | ✅ **compose 服务名统一** | §2 脚本化 | 已实查:prod compose 为 `postgres`,采集命令用对名字 | | 8 | ✅ **TLS 已落地**(走②前置 Caddy) | 一期 §7 TLS | 已完成:frontend→8080、Caddy 80/443、证书签发、PUBLIC_BASE_URL 同步 https;⚠️ 安全组需禁 8080/3000/2222 公网(只放 80/443) | --- ## 增强级(后续可选,不阻塞) - **同机蓝绿(停机窗口缓解)**:~30s-1min 中断若落在业务高峰不可接受,预留**同机蓝绿**——两份 backend 容器 + nginx upstream 切换(后端日志挂卷 + 镜像版本化已为此铺路);先记下,有需要再做 - **hotfix 脆弱窗口**:docker cp 进容器的修复不进镜像,容器一重启即丢——hotfix.sh 末尾强制收口提醒 + 限时正式部署(已落地于 §2) --- ## 二次反查新发现(2026-08-08,已验证) > 独立反查补充,非吸收外部意见。两条真 bug + 六条设计漏洞。 > **v5 更新:** 本节 v3 的 6 条已全部固化到主体现——§1 FRONTEND_TAG/前端 healthcheck/双 tag 原子回滚、§2 工作区检查 + 双 tag log、§3 判定统一为 log、§5 count 制、§6 .dockerignore 定修复。本节保留为历史记录。 **真 bug:** - **`.dockerignore` 被 `.gitignore` 忽略**(根 `.gitignore` 第 50 行):backend/frontend 两个 `.dockerignore` 未被 git 跟踪(`git ls-files` 确认),但 §6 要把它们作为部署单元随 git 分发——服务器 pull 不到。**修复:从 `.gitignore` 移除 `.dockerignore`**,或明确它为服务器本地手工同步 - **backup.sh 生产连库路径不成立**:仓库版默认 `PG_HOST=localhost:5432`,但 postgres `ports: []` 不发布端口,宿主机 `pg_dump` 连接拒绝。**统一到仓库版前先定连库机制**(`docker compose exec postgres pg_dump` 或加 `127.0.0.1:5432:5432` 映射),已入上机清单 #5 **设计漏洞:** - **frontend 缺 healthcheck**:只有 `depends_on: backend: service_started`(容器起来即可),无自身健康门控——"坏版本不接流量"对前端失效,补 `curl -sf http://localhost/` 或 nginx 配置校验 - **frontend tag 插值缺失**:仅 `BACKEND_TAG`,frontend 是独立 nginx 镜像,需 `FRONTEND_TAG`;**回滚必须 backend+frontend 双 tag 原子切换**,versions.log 记录两个 - **镜像治理策略打架**:§1 "保留 N=5-10 tag"(count 制)vs §5 `prune until=168h`(age 制)——低频部署时 7 天外的保留 tag 被 age-prune 清掉 → 回滚点丢失。**统一:应用镜像只按 count 清理,age-prune 只作用于 dangling/build cache** - **deploy.sh 未查工作区干净**:生产机 `git pull` 前需 `git status --porcelain` 为空,否则 hotfix 残留导致 pull 冲突;冲突即中止 - **破坏性判定扫描时机**:回滚时扫当前工作区 ≠ 回滚目标版本——destructive 文件可能已在后续版本删除/改名。**改为 deploy 时基于 `git diff .. -- alembic/versions/` 检测 drop/alter 并写进 versions.log,回滚读 log** - **一期/二期 image: 写法切换**:本地 build 用 `scilit/backend:`,二期 pull 用 `123.207.9.209:3000/scilit/backend:`——切换时 compose 的 image: 字段必须改为全限定 registry 地址,需在文档标注 --- ## 风险与注意 - **磁盘**:✅ 已扩至 100G(2026-08-08);绝不 `docker builder prune -a`(毁缓存)、绝不 `docker compose down -v`(毁数据卷) - **restart 策略(P7 已查实存在,无需新增)**:prod compose 全部服务(postgres/redis/es/minio/backend/worker/frontend/gitea)已是 `restart: unless-stopped`——**服务器重启后栈自动拉起**;migrate `restart: "no"`(一次性服务,正确)。冷启动/回滚场景都依赖此自愈,部署后 `docker compose ps` 复核各容器 restart 策略未被误改 - **TZ 时区决策(E7)**:项目刻意用 **UTC 存储**(ARQ 任务时间全 UTC,见 CLAUDE.md;DB 列均为 TIMESTAMPTZ/UTC)——**不设 `TZ=Asia/Shanghai`**,避免容器本地时间与 DB UTC 混读;日志/时间戳用 ISO8601 带时区(如 `+08:00`),前端展示层本地化。若日后运维强烈偏好本地时区可统一设 TZ,但须知 DB 仍 UTC、两时区并存易混——先记录决策,不擅自改 - **insecure-registries**:Gitea 无 HTTPS,生产/runner 需配明文 registry——**公网 IP 下「内网可信」不成立(v7)**:registry 仅监听内网/防火墙限定来源,或反代加 TLS,否则 CI/生产 token 有泄露面(见二期 §2) - **CI 一次性配置门槛高**:act_runner 注册、registry 开启、Gitea Actions 启用——过渡期注意测试 - 生产部署由用户执行,agent 不直接 SSH;部署前确认;计划批准≠执行绿灯