Files
backend/docs/11-20260717部署事故分析.md
34047007@qq.com a6cd99a4ca
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
feat: initial commit - oncology literature search platform
OncoLit: a multi-tenant oncology literature search, feed, and
collaboration platform. Built with FastAPI + Vue 3 + PostgreSQL.
Includes PubMed pipeline, drug approvals, AI summaries, and
systematic review tools.
2026-07-27 07:59:18 +08:00

272 lines
9.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-07-17 部署事故分析
## 概述
一次安全加固部署(CSP 统一、Refresh token 告警、注册验证码、JWT 校验、注册限速、Nginx 日志持久化),因多个低级错误反复修复,耗时半天。
所有问题根因是同一个模式:**以为"这样就可以了",但对 Docker 的某个细节理解有误,多步累积后全面崩溃。**
---
## 问题一:tar 路径嵌套
### 现象
`tar xzf backend_app.tar.gz -C /root/scilit/backend/` 后,文件到了 `/root/scilit/backend/backend/app/...`,导致 `docker cp` 到容器后找不到新增的代码。
### 根因
打包命令 `tar czf backend_app.tar.gz backend/app/` — tar 包内路径前缀是 `backend/`。解压到 `/root/scilit/backend/` 后变成 `backend/backend/app/`,多嵌套一层。
### 教训
- tar 打包时路径前缀和 `-C <dir>` 的对应关系容易搞错
- 大半夜连续多次犯同样错误,说明没有停下来确认
- 事后验尸:容器内 grep 不到新代码时就该发现,但侥幸重新 cp 了事
### 正确做法
两个选一个:
```bash
# 方式 A(推荐):cd 到目录内再打包
cd backend && tar czf /tmp/backend-update.tar.gz app/
# 方式 Bstrip-components 解压
tar xzf /tmp/backend-update.tar.gz --strip-components=1 -C /root/scilit/backend/
```
### 注意事项
- 解压后 `ls /root/scilit/backend/app/` 确认路径正确
- 不要在凌晨精神疲劳时做多步手动操作
- 不确定路径时,先用 `tar tf archive.tar.gz | head` 预览包内结构
---
## 问题二:Frontend 容器重建导致 docker cp 的文件丢失
### 现象
- 第一次 `docker cp` 前端 → 一切正常
- 改 compose 文件加 nginx 日志 volume → 执行 `docker compose up -d --no-deps frontend`
- 容器重建 → 从旧镜像启动 → 之前 cp 的文件全部消失
- 前端回到旧版本,首页热门标签加载逻辑缺失 → 显示空白
### 根因
`docker cp` 修改的是**容器的可写层**,不影响镜像。`docker compose up -d` 重建容器时,容器***总是从镜像启动**,cp 进去的文件不在镜像中,所以丢失。
### 教训
- `docker cp` 不持久——它活在容器的生命周期内
- 容器重建 = 回到镜像的初始状态
- 以为"之前 cp 过了"就不需要管了,没意识到重建会丢
- 更不应该的是:丢了一次,又 cp 回去,然后再次 `up -d` 又丢,反复了三次
### 正确做法
前端 `docker cp` 后,立即 `docker commit` 保存为新镜像:
```bash
# 前端部署完整流程
cd frontend && npm run build
scp -r dist/* root@server:/tmp/frontend-dist/
ssh root@server "docker cp /tmp/frontend-dist/. scilit-frontend-1:/usr/share/nginx/html/"
ssh root@server "docker commit scilit-frontend-1 scilit-frontend:latest" # ← 必须
```
现在镜像和容器一致,随便重建都不丢。
如果后续要改 compose 文件(比如改 volume、port):
```bash
# 改 compose 前,先确保镜像是最新的
docker commit scilit-frontend-1 scilit-frontend:latest # 或
docker compose build frontend # 重新构建
# 然后才改 compose → up -d
```
### 注意事项
- **后端不需要 commit** — backend 容器几乎从不重建,cp 一次就是永久的
- **前端只要被重建过(包括 compose 版本升级、port/volume/env 改动),之前 cp 的就是白做的**
- **改 compose 文件 = 容器必然重建**,改之前确认镜像已包含最新代码
---
## 问题三:Nginx 502 Bad Gateway
### 现象
- Backend 容器重启后,所有通过 nginx 代理的 API 请求(`/api/`)都返回 502
- 直接访问 backend`127.0.0.1:8000`)正常
- Docker DNS 能解析 `backend` 主机名
- 重启 frontend 容器后临时恢复
### 根因
Nginx `proxy_pass http://backend:8000` 使用**固定字符串**,nginx 只在启动时做一次 DNS 解析,把主机名 `backend` 解析为具体 IP。Backend 容器重启后 Docker 可能分配了新 IP,但 nginx 仍用旧 IP 去连,导致 502。
`resolver` 指令虽然加了,但**只对变量形式的 proxy_pass 才触发动态解析**。固定字符串 `http://backend:8000` 仍然启动时一次解析。
### 教训
- 知道 resolver 的作用,但不知道它只对变量形式生效
- 加 resolver 时感觉"解决了",实际上完全没生效——加了等于没加
- 重启 frontend 容器能临时恢复(DNS 重新解析一次),所以以为好了
### 正确做法
两处要同时改:
```nginx
resolver 127.0.0.11 ipv6=off valid=30s;
server {
# 在 server 块定义变量
set $backend_upstream http://backend:8000;
location /api/ {
proxy_pass $backend_upstream; # 变量形式 → 每次请求都解析
}
location /api/v1/ws/ {
proxy_pass $backend_upstream; # 同样改
}
location /health {
proxy_pass $backend_upstream; # 同样改
}
}
```
- **必须同时有 `resolver` + `proxy_pass $variable`**,缺一不可
- 变量只在 server 块内定义,不能在 location 块内
- nginx -t 验证语法通过后 reload 生效
### 注意事项
- 固定字符串的 `proxy_pass` 只在启动/重载时解析一次
- 变量形式 `proxy_pass $var` 每次请求都通过 resolver 重新解析
- 但变量形式不支持某些特性(如 URI 重写),目前场景不需要
- 验证方法:`curl -s -w "%{http_code}" http://localhost:80/api/v1/health`
---
## 问题四:Backend 和 Postgres 在不同 Docker 网络
### 现象
- 容器启动后 health check 一直报 `DB connection failed`
- `alembic upgrade head``Name or service not known`
- `docker network connect` 到另一个网络后恢复正常
### 根因
生产 compose 文件定义了 `networks: scilit`,但 postgres 服务没有显式指定 `networks:`Docker 自动为它创建了默认的 `scilit_default` 网络。backend 在 `scilit_scilit` 网络。两个容器不在同一网络,无法通过主机名通信。
### 教训
- compose 文件定义了网络但不一致,部署时没有验证所有服务在同一网络
- 网络定义和 service.networks 的对应关系是分开的两段配置,容易漏
### 正确做法
每个服务显式指定网络:
```yaml
services:
postgres:
networks:
- scilit
backend:
networks:
- scilit
frontend:
networks:
- scilit
```
验证方法:
```bash
docker inspect <容器名> --format '{{json .NetworkSettings.Networks}}'
# 所有容器应该在同一个 network 下
# 或者:docker network inspect scilit
```
### 注意事项
- 有多个 compose 文件时(`docker-compose.yml``docker-compose.prod.yml`),各自的 networks 定义互不影响
- 如果一个问题表现为"容器间不通",先检查网络是否一致
- `dokcer compose up -d` 启动新容器时不检查旧容器的网络归属
---
## 问题五:Refresh token 告警代码部署后消失
### 现象
- `docker cp auth.py` 后验证代码在容器内 → `grep consume_verify_token` 显示 2 个匹配
- `docker restart` 后再次验证 → 0 个匹配,旧文件
### 根因
之前 `docker cp app/.` 时 tar 解压多了嵌套路径 `/root/scilit/backend/backend/app/`,覆盖了"已有的"旧 `/root/scilit/backend/app/` 吗?不,实际上第一次 cp 后容器内确实有代码,但 backend 容器后来被重新创建过(因为网络修复等操作),导致文件再次丢失。
### 教训
和问题二的根因一样:docker cp 不持久化。没有 commit,任何一次容器重建都会丢失。
### 正确做法
后端 `docker cp` 后验证了就行——后端容器几乎不会重建。但如果因其他原因容器被重建了,才需要重新 cp 或 commit。
### 注意事项
- 后端容器的"几乎不会重建"不代表永远不会
- 如果同时出多个问题时(像今天这样),容器可能反复重启/重建
- 这种情况下后端也需要 commit,或者把所有改动合并在一次 cp 中完成
---
## 总结:今天所有问题的共同模式
| 问题 | 表面原因 | 深层原因 |
|------|---------|---------|
| tar 嵌套 | 路径前缀不对 | 没有预览包结构就解压 |
| cp 的文件被覆盖 | 容器重建 | 不知道/忘了 cp 不持久化 |
| nginx 502 | 固定字符串不触发动态解析 | 知道一半,不知道另一半 |
| 网络不通 | 网络定义不一致 | 多 compose 文件的 network 归属 |
| 代码又丢了 | 同上(容器重建) | 多重问题叠加 |
**每个单独看都是小常识。但 5 个叠加就变成了"怎么什么都不行"的局面。**
### 执行建议
当前部署方式是 SCP + docker cp,对整个团队(其实就你一个人)来说够用了。但每次部署前应该问自己两个问题:
1. **我改的这些文件,后台容器是否会被重建?** 如果会 → 需要先构建镜像或 commit
2. **这步操作是不是在改变容器的运行时状态?** 如果是 → 容器重建后这个状态不保留
### 快速排查清单(下次出问题时开查)
```bash
# 1. 容器到底是不是最新的版本?
docker diff <容器名> # 可以看到文件系统改变了多少(cp 的文件)
# 2. 镜像包含什么?
docker run --rm <镜像名> <命令> # 比如验证前端 dist/ 里的文件
# 3. 网络对吗?
docker network inspect <网络名>
# 4. tar 包里是什么?
tar tf 包名 | head -10
# 5. proxy 通了没?
curl -s -w " %{http_code}" http://localhost:80/api/v1/health
```