- Dockerfile 腾讯源 + BuildKit 缓存(backend pip / frontend npm ci),gitea 锁 1.27.1 - .dockerignore 纳入版本管理(防密钥进镜像) - 新增 migrate_prod.sh:24 个迁移分 4 批执行,含镜像新鲜度 + DB 起点校验 - cb07d6b1df01 移除 search_tsv 回填(延迟到 g0h1i2j3k4l5 统一全量回填) - 模型 journal_iso/volume/issue/pages → Text(对应迁移 95c18ebf31e4 / 55105f0bb1d7) - 搜索优化:tsvector 主路径 + COUNT 截断(10000) + pub_date 排序,admin 聚合单查询 - statement_timeout 30s→60s
65 KiB
部署运维方案:生产工程化(一期 + 二期)
日期: 2026-08-08(v16 更新于 2026-08-10) 版本: v16.0(当前) 状态: 一期 3 项已执行(磁盘/备份/TLS)+ 构建加速(Dockerfile 卫生)+ gitea TLS,其余待执行 关联: docs/10-生产部署文档.md、docs/11-20260717部署事故分析.md、docs/12-部署实际操作记录.md、docs/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 判定基线钉死——
<prev>=git pull前的本地 HEAD,deploy.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 两路径同块、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 机制,crontab0 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:<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}、frontendimage: 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 用 busyboxwget(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=<sha> FRONTEND_TAG=<sha>(给后续run --rm/up用,N8;frontend 镜像一期来源写清(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 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,内容含<backend_sha> <frontend_sha> <destructive: 是/否> <本次 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 在主机写的运行时标记——必须落在持久主机路径,即部署目录内稳定位置<deploy_dir>/.pending-deploy(别放 /tmp,重启即清;别放会被git clean/hotfix 清理或与git pull冲突的位置),且该文件加入.gitignore(运行时状态不进 git);⚠️ 遗留标记检测(I):deploy.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"方案本身有两个真 bug:P1up -d异步返回,容器仍在 running 时inspect .State.ExitCode恒为 0(running 状态 ExitCode 无意义)→ migrate 还没跑完就被误判成功;P2 migraterestart: "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 方式迁移窗口更短,适合大库);⚠️ alembicdowngrade()只反向 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:每次部署记录<时间> <backend_sha> <frontend_sha> <迁移前/后 alembic head> <镜像> <destructive: 是/否>(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 <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 后新 HEAD,git 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 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 <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) - 迁移执行受限操作(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/55105f0bb1d7VARCHAR→Text + 大数据量回填)须新代码已部署后深夜跑 - 推荐序:批次 2/3(提前)→ 维护窗口:build 新镜像 → 批次 1 → 发新 backend/worker/frontend → 深夜:批次 4
- 配套:build 加速已落地(§6 Dockerfile 卫生),迁移用
run --no-deps --rm backend alembic upgrade <rev>(新镜像自带新迁移,勿退回老容器 exec)
- 批次 1(
- 统一备份脚本(✅ 已落地 2026-08-09,原阻塞级 #1 查清):上机确认 crontab 原本无任何 backup 条目、backup.sh 根本没在跑(历史 docs/10 的
/home/scilit/backup.sh不存在),且仓库版PG_HOST=localhost连不上(postgresports: []无宿主端口)——已新建/root/scilit/scripts/backup.sh并落 crontab0 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 -l302 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 分开执行;⚠️ 用-Fccustom 格式(H——pg_restore 只吃 custom 格式):§2 rollback.sh / §4 restore-drill 的数据回退都走pg_restore,而 pg_restore 要求 dump 为-Fccustom 格式——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):GNUhead -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 量大,纳入异地时评估体积/频率(可低频率如每周);保留策略(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排除
- backend:
- 新增
.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 自动签发;giteaROOT_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 <scilit_uid> /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(
<host>: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 <host>: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 <pid>(复用已有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
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 静态核对 - 用户服务器首跑:
/healthdb 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.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,但 postgresports: []不发布端口,宿主机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 <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 地址,需在文档标注
风险与注意
- 磁盘:✅ 已扩至 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——服务器重启后栈自动拉起;migraterestart: "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;部署前确认;计划批准≠执行绿灯