# 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/

# 方式 B：strip-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
```
