Files
backend/docs/16-部署运维方案.md
T
34047007@qq.com 2b7e56b9b3
CI / backend (push) Waiting to run
CI / frontend (push) Waiting to run
chore: prod compose 同步 TLS/Caddy + gitea 域名(回流服务器本地改动)
服务器 8-09 直接手改的 TLS/Caddy 改动从未提交 gitea,git-ify checkout
会覆盖丢失 HTTPS。回流 3 处差异:
- frontend 宿主端口 80:80 → 127.0.0.1:8080:80(8080 收紧)
- gitea 域名 123.207.9.209 → gitea.oncolit.gonsun.com
- 新增 caddy 服务(80/443 + Caddyfile 卷)+ caddy_data/caddy_config 卷
Caddyfile 加入仓库根;docs/16 记 v17 回流规则。
2026-08-10 07:11:08 +08:00

67 KiB
Raw Blame History

部署运维方案:生产工程化(一期 + 二期)

日期: 2026-08-08v17 更新于 2026-08-10 版本: v17.0(当前) 状态: 一期 3 项已执行(磁盘/备份/TLS)+ 构建加速(Dockerfile 卫生)+ gitea TLS,其余待执行 关联: docs/10-生产部署文档.mddocs/11-20260717部署事故分析.mddocs/12-部署实际操作记录.mddocs/17-生产数据迁移.md24 迁移专项,2026-08-09

版本记录:

  • v17 — 2026-08-10:§7 补「服务器本地改动必须回流 gitea」环节(TLS/Caddy 改动从未提交,git-ify checkout 会丢 HTTPS——回流后服务器 git pull 才安全,与 §2 闭环)
  • 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 wgetnginx: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-cliN6 的 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 属主主机目录写不进 → 首跑即 PermissionErrornginx root 跑无此问题);按 §6 P8 结论 chown/固定 UID+卷初始化;B(概念陷阱)破坏性回滚主路径改为 predeploy dumpdowngrade 降级——alembic downgrade 只反向 schema 不恢复数据(DROP COLUMN 要么 no-op 要么 NotImplementedError,被删列数据回不来),主路径 = pg_restore 快照;C frontend 一期构建机制写清compose 含 frontend build + Dockerfile.proddeploy.sh build 覆盖前后端);D .pending-deploy 落盘位置明确(部署目录持久路径、gitignore、别放 /tmp);E gitea_data tar 备份加数量保留 N=3;阻塞级 #1/#8 维持不变
  • v13 — 吸收 5 条(F-J):F destructive 判定基线钉死——<prev> = git pull 前的本地 HEADdeploy.sh 在 pull 前 PREV_SHA=$(git rev-parse HEAD) 采集(此刻 HEAD = 服务器当前生产版本),<sha> = 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 两路径同块、copytruncatecron.daily 自动轮转 → v14 — 吸收 2 条(K-L):K 迁移 run --rm 加 --no-depsbackend 的 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 不是 ext4resize2fs 会报 Bad magic number,§0 正文已改); §4 backup 落地(服务器 /root/scilit/scripts/backup.shcompose-exec pg_dump -Fc 机制,crontab 0 3 * * *,手动验证 2.5GB/302 TOC/46 表); §7 TLS 走②前置 Caddy 落地frontend 宿主端口 80→8080、容器内仍 80Caddy 占 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 headCan't locate revision '6b662a8c5235'生产镜像 2-3 周未更新、落后于 DB schemaDB head 6b662a8c5235 旧镜像不认识)→ backend/frontend 全部 Created 未启动、应用中断。恢复:恢复旧 backend 镜像 + up -d --no-deps --no-build backend frontend结论固化:任何 up/run 动应用容器必须 --no-deps(本方案已写,执行时务必照做);生产镜像落后于 DB schema 是部署事故隐患——当前运行镜像(backend a6d197830cc2/frontend 95f910a68bd97月中)比 DBhead 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 /** 扩文件系统。绝不能写 resize2fsext4 专用,xfs 上直接报 Bad magic number in super-block——xfs 用 xfs_growfs,且 xfs 不支持缩容。先 lsblk -f 确认 FSTYPE 再选工具
  • 保留认知(扩容时已确认目标盘):pgdata/redisdata/esdata/miniodata/gitea_dataregistry 复用 gitea_data)是不同卷、可能在不同挂载点,CI 缓存(二期)在 runner 本地——已扩 pgdata 所在吃紧盘
  • 容量依据:pgdata 卷增长 + 多版本镜像(N=5,后端 ~500M×5)+ CI 构建缓存(二期)+ registry 存储(二期,复用 gitea_data 卷)+ 日志

1. 版本化镜像标签(镜像即版本)

  • 每次构建打 git-sha 标签docker tag scilit/backend:latest scilit/backend:<git_short_sha>(前端同理)
  • compose 用环境变量插值引用 git-sha(后端 + 前端双变量,v5 固化到主体)image: scilit/backend:${BACKEND_TAG:-latest}image: scilit/frontend:${FRONTEND_TAG:-latest}deploy.sh 同时传 BACKEND_TAG=<sha> FRONTEND_TAG=<sha>——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.htmlCache-Control: no-cache,对 assets/*(哈希文件名)长缓存 immutable——改 frontend/nginx.conf⚠️ nginx.conf 是 COPY 进镜像的(E10——改它必须 rebuild 前端镜像重新部署,docker cp 进容器不持久、重启即丢
  • ⚠️ nginx /api 的 proxy_pass 必须写 compose 服务名 backend:8000M1,文档固化防改错):实测 frontend/nginx.conf:83 已正确写 set $backend_upstream http://backend:8000;——localhost:8000 会跨容器调自己、必然失败frontend 容器内 8000 无服务)。约束固化:proxy_pass 目标永远用** compose 服务名 + 端口**backend:8000),不用 localhost/127.0.0.1nginx 侧 resolver 动态解析(nginx.conf 已配 resolver 127.0.0.11)配合 backend 容器重建后 IP 变化
  • frontend healthcheck + 双 tag 原子回滚(v5 提升到主体)frontend 需自身 healthcheck,不能只靠 depends_on: backend: service_started⚠️ nginx:alpine 默认不含 curlv7 真 bug 修正)——healthcheck 用 busybox wgetalpine 自带):wget -q -O- http://localhost/ || exit 1,或 Dockerfile 另装 curl回滚 = backend+frontend 双 tag 原子切换——两者同一次部署、同一条 versions.log 记录,绝不允许只回一个导致前后端版本错配

2. 脚本化部署(消灭 13 条规则的坑)

"13 条规则"来源内联(v5docs/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.ymldocker compose ps 实查),部署前基础设施已在跑,避免漏起或误动
  • 冷启动 vs 热部署(P14up -d --no-deps 假设基础设施已在跑,服务器重启后全栈 down 时直接跑 deploy.sh 会连不上 db——deploy.sh 顶部加基础设施健康门postgres/redis/es/minio 状态异常即中止并提示先冷启动;⚠️ 门控判定按"有 healthcheck 判 healthy、无 healthcheck 判 running"N5——不能一刀切要求 healthy,否则对没配 healthcheck 的服务门控永远失败、deploy 永远跑不了);注:当前 prod compose 四服务均已配 healthcheckpg_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 +aG——${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=<sha> FRONTEND_TAG=<sha>(给后续 run --rm/up 用,N8frontend 镜像一期来源写清(C)compose 已含 frontend: build: {context: ./frontend, dockerfile: 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 headK——必须 --no-depsbackend 的 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 门控作双保险) → ⑧内置验证——轮询等健康(V6up -d 后新 backend 仍在 start_periodhealthcheck 未过),立刻 curl 会命中启动中、误判失败——轮询 /health 直到 healthy 或超时(如 60s×5s,再验前端 200 → ⑨全部成功后才把 .pending-deploy 挪进 versions.log(追加一行 + 删标记) → ⑩镜像治理清理。失败处理分两段(v7 边界 + v9 读标记修正)①部署未完成(③-⑥之间失败,迁移未跑) → 容器还是旧的、无需数据回滚,只清理失败中间态(临时镜像/标签)即可;②部署部分完成(迁移已跑/容器已切) → 调用 rollback.sh——读本次 .pending-deploy 而非 versions.logN1,见下条)回滚数据源(v6 补):需数据回退时优先用 .pending-deploy 里记的本次 predeploy 快照(部署前最新状态),绝不用更早的 dump
  • ⚠️ .pending-deploy 标记(N1 最要紧——失败自动回滚的数据丢失洞修复)versions.log 只在部署成功后才写——若某次部署跑了破坏性迁移(已成功落地)、紧接着 backend up 失败,失败处理器调 rollback.sh 时读 versions.log,读到的必然是上一次成功部署destructive=否朴素换 tag、不回退数据 → 破坏性迁移已落地 + 旧代码 = 数据/代码不匹配、丢数据修复:deploy.sh 在 ③快照后、⑥迁移前写 .pending-deploy,内容含 <backend_sha> <frontend_sha> <destructive: 是/否> <本次 predeploy 快照路径>destructive 由 §3 的 git diff 判定此时即算出)——失败处理器②只读 .pending-deploy,绝不读 versions.logN4N1 同根因单列):任何"部署失败时读 versions.log 判定本次 destructive"的做法都是这个洞——versions.log 记录的是上次成功,覆盖不了本次失败,统一只认 .pending-deploy⑨全部成功后才把它挪进 versions.log(追加 + 删除标记);失败路径回滚完成后同样清除标记;⚠️ 落盘位置(D.pending-deploy 是 deploy.sh 在主机写的运行时标记——必须落在持久主机路径,即部署目录内稳定位置 <deploy_dir>/.pending-deploy别放 /tmp,重启即清;别放会被 git clean/hotfix 清理或与 git pull 冲突的位置),且该文件加入 .gitignore(运行时状态不进 git);⚠️ 遗留标记检测(Ideploy.sh 开头(source .env / 工作区校验前)先查 <deploy_dir>/.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"方案本身有两个真 bugP1 up -d 异步返回,容器仍在 running 时 inspect .State.ExitCode 恒为 0running 状态 ExitCode 无意义)→ migrate 还没跑完就被误判成功P2 migrate restart: "no",首次跑完即 exited二次部署时 up -d migrate 对已 exited 且配置未变的容器不重跑迁移静默跳过合并修法:迁移统一用 docker compose run --no-deps --rm backend alembic upgrade headK——必加 --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.shv9 双入口修正,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 原子切换(E2BACKEND_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: 60sE4 统一此处,删掉 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:每次部署记录 <时间> <backend_sha> <frontend_sha> <迁移前/后 alembic head> <镜像> <destructive: 是/否>v5 双 tag schema)——后端/前端独立 tag,供 rollback 双 tag 原子回滚读旧版本;alembic head 由 deploy.sh 采集(宿主机无 venv,必须经容器执行)——顺序(v9 统一 run --rmN3):①迁移前采 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——P5psql 备选 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→migratemigrate 以 scilit-migrate:latest旧镜像,2-3 周未更新)跑 alembic upgrade headCan't locate revision '6b662a8c5235'——生产 DB schemahead 6b662a8c5235)比运行中镜像认识的 head 新,旧镜像的 alembic/versions 里没有该 revision → migrate 退出 255 → backenddepends_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 95f910a68bd92026-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 <prev>..<sha> -- alembic/versions/ 判定,把"是否 destructive"写进 versions.log⚠️ <prev> 基线钉死(F<prev> = git pull 前的本地 HEAD——deploy.sh 在 ①工作区校验后、②git pull 前采 PREV_SHA=$(git rev-parse HEAD)(此刻 HEAD = 服务器当前生产版本),<sha> = pull 后新 HEADgit diff PREV_SHA..<sha> = 本次部署真正引入的迁移;严禁写成 pull 后的 HEAD~1..HEAD——一次 pull 常带入多个 commit 的迁移,HEAD~1 不是上次生产版本,diff 会漏判/错判 destructive⚠️ 判定规则必须细化,不能笼统"检测 drop/alter"P12——过粗会让每次 ADD COLUMN 都误触发、强制数据回退):判 destructive 只看真正破坏性操作——DROP TABLE / DROP COLUMN / DROP INDEXALTER 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 安全;首次部署无 prevM3,小注)git diff <prev>..<sha> 无基线 → 改为直接对当前 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
  • 迁移执行受限操作(E3CREATE INDEX CONCURRENTLY 不能跑在事务块内——alembic 默认把迁移包在事务里,需并发建索引用 with op.get_context().autocommit_block():;未来若上 pgbouncer 事务池,长事务迁移会被池限制影响(迁移建议直连 postgres 服务、绕过池)
  • 批量迁移执行序 + destructive orderingv16 补,2026-08-10,专项见 docs/17:生产 DB 落后本地多个迁移时,不能整条链一次 upgrade head,须分批执行到中间 checkpoint(migrate_prod.sh 1|2|3|4,见 docs/17 §4)。关键纪律——destructive 批次不能提前单独跑
    • 批次 11421ea169bb6 日期 TIMESTAMPTZ→DATE)是类型收窄——老代码读 datetime 会崩,必须与发新代码同窗口(迁移完几秒内新镜像接管),绝不能提前单独跑
    • 批次 2/3 纯 additive(加列/新表/索引),老代码兼容,可提前任意时段跑,缩小维护窗口
    • 批次 495c18ebf31e4/55105f0bb1d7 VARCHAR→Text + 大数据量回填)须新代码已部署后深夜跑
    • 推荐序:批次 2/3(提前)→ 维护窗口:build 新镜像 → 批次 1 → 发新 backend/worker/frontend → 深夜:批次 4
    • 配套:build 加速已落地(§6 Dockerfile 卫生),迁移用 run --no-deps --rm backend alembic upgrade <rev>(新镜像自带新迁移,勿退回老容器 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 -Fccustom 兼容 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 语义保护(E1GNU 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_<ts>.tar.gz -C /src .,同样传 COS 异地;注意 gitea 容器运行中 tar 的一致性(git 仓库文件持久、轻微不一致可接受;严格则先 docker compose stop gitea 再 tar);registry blobs 量大,纳入异地时评估体积/频率(可低频率如每周);保留策略(Etar 备份按数量清理——保留最近 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 cachedocker 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-devbackend Dockerfile 已装)。腾讯 apt/pip 镜像已配,这块已具备;首次跑前确认网络与源可达。

  • backend/Dockerfile:腾讯 apt/pip 镜像(回归历史 docs/12 §4.2–4.5 的加速做法,当前已丢失)
  • frontend/Dockerfile.prodnpm ci + package-lock.json + npmmirror
  • .dockerignore 统一补齐(防密钥进镜像层 / 防旧字节码 / 缩构建上下文):
    • backend__pycache__/*.pyc.envtests/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/COSdocker compose config 实查确认)→ 现无此问题;但未来任何给 backend 加本地卷都会踩经典坑——命名卷首次挂载是 root 属主,非 root 进程写不进 → PermissionError。防线(加卷时必做):Dockerfile 加 ENTRYPOINT 启动前 chown 卷目录,或用固定 UIDuseradd -u 10001+ 卷初始化,禁止裸加卷

7. TLS / HTTPS 已落地 2026-08-09,走②前置 Caddy

上机确认结论:此前是裸公网 80 直连 frontend nginx,无任何加密。已按用户选定方案②落地。

  • 已实施(2026-08-09
    • docker-compose.prod.ymlfrontend 宿主端口 80:808080:80(容器内 nginx 仍监听 80Caddy 经 compose 网络 frontend:80 反代);新增 caddy 服务(caddy:2-alpine80:80+443:443,挂 Caddyfile + caddy_data/caddy_config 卷);volumes 加 caddy_data/caddy_config
    • /root/scilit/Caddyfileoncolit.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_URLhttp://https://oncolit.gonsun.comCORS_ORIGINS 追加 https://oncolit.gonsun.com(改 .env 后 backend 需重启生效)
  • 验证curl -k https://oncolit.gonsun.com → 200 + 前端 HTML/health 经 Caddy→nginx→backend 返回 db: ok;证书 CN=oncolit.gonsun.com90 天自动续期
  • ⚠️ frontend 宿主 8080 已收紧(2026-08-098080:80127.0.0.1:8080:80(只绑回环,公网无法直连绕过 Caddy;排障时本机 curl 127.0.0.1:8080 仍可用;Caddy 经 Docker 网络走 frontend:80 不受影响)。安全组仍建议只放行 80/443(禁 3000/2222 对公网),在腾讯云控制台配置
  • gitea TLS 已落地(2026-08-09DNS 已加 gitea.oncolit.gonsun.com123.207.9.209Caddyfile 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 需同步改 httpshttp://123.207.9.209:3000/scilit/backendhttps://gitea.oncolit.gonsun.com/scilit/backendSSH clone 地址变为 gitea.oncolit.gonsun.com:2222
  • ⚠️ 服务器本地改动必须回流 giteav17 补,2026-08-10TLS/Caddy 全链路改动(compose frontend→8080、caddy 服务+卷、Caddyfile、gitea 域名、127.0.0.1:8080 收紧)是 2026-08-09 直接在服务器上手改的,从未提交到 gitea(实测:服务器 docker-compose.prod.yml 与仓库版 diff 35 行,仓库版还是 80:80 + 123.207.9.209 + 无 caddy 服务;Caddyfile 服务器 8-09 版也不在仓库根)。后果:服务器一旦 git-ify checkout 仓库版,这些改动被覆盖 → HTTPS/Caddy/gitea 域名全丢环节(v17 新增,与 §2"服务器 git pull"闭环):①把服务器 docker-compose.prod.ymlTLS 版)取回本地覆盖仓库版、Caddyfile 加入仓库根 → 提交 chore: prod compose 同步 TLS/Caddy + gitea 域名git push(gitea 成为权威,含 TLS);②服务器 git-ifyyum install git + git init -b main + remote add origin + git fetch && git checkout --force origin/main(被跟踪文件覆盖为最新,未跟踪的 .env/tmp_*.py 保留)→ docker compose config --quiet 验证 caddy 服务仍在。此后日常更新 = git pull,杜绝手工改与仓库不一致(对齐 hotfix.sh 的"强制回流"纪律)
  • 未做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/backenduvicorn 配置 --access-logfile/--error-logfile 指向该卷内文件(否则默认 stdout 走 json-file、随容器删除即丢);前端 nginx 侧已有 /var/log/scilit/nginx 挂载;⚠️ backend 非 root 写权限(A——v11 的 §8 挂卷正好激活 P8 预告的坑)backend 容器是 USER scilit 非 rootbackend/Dockerfile:29),主机绑定目录 /var/log/scilit/backendroot 属主——scilit 用户写不进去 → 首跑日志落盘即 PermissionErrornginx:alpine 以 root 跑、无此问题)。按 §6 P8 现成结论处理,缺一不可:挂卷前宿主机 chown <scilit_uid> /var/log/scilit/backend,或固定 UIDuseradd -u 10001+ 卷初始化——§8 挂卷不配权限处理,一期首跑必崩
  • ⚠️ 挂卷后 json-file 轮转失效(容易漏):日志挂宿主机卷后,docker 自带 10m×3 轮转不再覆盖这块日志——须另配宿主机 logrotatenginx 的 /var/log/scilit/nginx 同样要查),否则卷无限涨
  • ⚠️ 轮转机制二选一(P9:宿主机 logrotate uvicorn RotatingFileHandler 只能选一种——两个都配会互相 rename 竞争、日志错乱。定案:用宿主机 logrotate(下条 copytruncate 细则),uvicorn 保持 stdout 落盘、不配 RotatingFileHandler
  • logrotate 必须 copytruncatev6 执行级真坑):容器内进程不响应日志文件的 rename——普通 logrotaterename + 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 逻辑:backendpgvector:pg16 service + pytest + ruff+ frontendvue-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 Actionscompose 加 GITEA__actions__ENABLED: "true"

2. Container Registry(生产只 pull,不构建)

  • Gitea 1.27 原生支持 Container Registry<host>:3000/scilit/backend
  • 生产 docker daemon 配 insecure-registriesGitea 无 HTTPS):["123.207.9.209:3000"]⚠️ 改 daemon.json 后必须重启 dockerP6systemctl restart docker重启本机所有容器(除非预先设 live-restore: true)→ 属停机操作,必须在维护窗口手动做,不能夹在 deploy.sh 里静默执行(否则一次"配 registry"把整栈全重启一遍)
  • ⚠️ 公网 IP + HTTP registry 凭证嗅探风险(v7 补)123.207.9.209公网 IPregistry 走明文 HTTP——token 在公网/同网段可被嗅探。原「内网可信」假设不成立。必须:①registry 仅监听内网/防火墙限定来源,或 ②反代加 TLS(长期);否则 CI push / 生产 pull 的 token 有泄露面
  • registry 需认证Gitea registry 非匿名,CI push + 生产 pull 都需 docker login <host>:3000token)——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 pullcompose 文件)+ docker compose pull(镜像)+ up -dmigrate 前置 + 健康门控)
  • 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 headK——必加 --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 <pid>(复用已有 REDIS_URL,无需新 HTTP 端点);②compose healthcheck 改探心跳——⚠️ 用 python 探,不用 redis-cliV5,真实会踩)worker 镜像是 python:3.12-slim 底(backend/Dockerfile,只装 libpq-dev/curl),不含 redis-cli——healthcheck 里写 redis-clinot 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 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 已提前到一期 §8v11:后端日志挂卷、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/Dockerfilefrontend/Dockerfile.prod 构建加速 + 卫生
backend/.dockerignorefrontend/.dockerignore 构建上下文排除
.gitattributes(新) * text=auto eol=lf
应用 /health 端点(v6 重申) 健康门控/回滚判定全依赖它——backend 已有 /healthnginx 已代理);新接手者不可漏,若未来拆分服务需各提供
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:<sha> 成功
  • 生产 pull + up -d,健康门控生效(坏版本不接流量)
  • 可观测:Grafana 出图、告警规则触发一次

首次上机确认清单(阻塞级,先查清再动手)

# 待确认 出处 判定动作
1 backup 落地(原无 crontab、脚本没跑) §4 备份 已解决:新建 /root/scilit/scripts/backup.shcompose-exec pg_dump+ crontab 0 3 * * * + 手动验证成功
2 磁盘扩至 100G2026-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.ymldocker inspect label 确认);同目录另有 dev 版 docker-compose.yml命令必须带 -f docker-compose.prod.yml,否则读到 dev 文件报 no such service
4 compose 是否 v2.xservice_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_TAGfrontend 是独立 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 <prev>..<sha> -- alembic/versions/ 检测 drop/alter 并写进 versions.log,回滚读 log
  • 一期/二期 image: 写法切换:本地 build 用 scilit/backend:<sha>,二期 pull 用 123.207.9.209:3000/scilit/backend:<sha>——切换时 compose 的 image: 字段必须改为全限定 registry 地址,需在文档标注

风险与注意

  • 磁盘 已扩至 100G2026-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.mdDB 列均为 TIMESTAMPTZ/UTC)——不设 TZ=Asia/Shanghai,避免容器本地时间与 DB UTC 混读;日志/时间戳用 ISO8601 带时区(如 +08:00),前端展示层本地化。若日后运维强烈偏好本地时区可统一设 TZ,但须知 DB 仍 UTC、两时区并存易混——先记录决策,不擅自改
  • insecure-registriesGitea 无 HTTPS,生产/runner 需配明文 registry——公网 IP 下「内网可信」不成立(v7):registry 仅监听内网/防火墙限定来源,或反代加 TLS,否则 CI/生产 token 有泄露面(见二期 §2)
  • CI 一次性配置门槛高act_runner 注册、registry 开启、Gitea Actions 启用——过渡期注意测试
  • 生产部署由用户执行,agent 不直接 SSH;部署前确认;计划批准≠执行绿灯