Files
chat/DEPLOYMENT.md
T
2026-06-15 23:01:31 +08:00

462 lines
20 KiB
Markdown
Raw 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.
# 青叶 (QingYe) 生产环境部署指南
> 目标:把青叶部署到 VPSUbuntu 20.04IP `103.170.72.162`),域名 `www.e4s.world`,使用 **宿主机 Nginx** 作反向代理 + SSL**Docker Compose** 编排后端容器。
---
## 0. 架构总览
```
Internet (HTTPS)
┌─────────▼──────────┐
│ 宿主机 Nginx :443 │ ← SSL 证书(Let's Encrypt / certbot
│ www.e4s.world │
└───┬──────┬─────┬───┘
│ │ │
/ 与静态资源 │ /api │ /ws (WebSocket) + /uploads
│ │ │
┌──────────▼┐ ┌───▼─────▼──────────┐
│ frontend │ │ backend │ 仅绑定 127.0.0.1
│ 容器:8080 │ │ 容器:8000 │ 不对外公开
│ (SPA静态) │ │ (FastAPI 单进程) │
└───────────┘ └───┬────────────┬───┘
│ │
┌───────▼───┐ ┌─────▼─────┐
│ postgres │ │ redis │ 仅容器内网,不映射端口
└───────────┘ └───────────┘
```
**关键约束(务必读懂一条)**
- 后端必须 **单进程** 运行。WebSocket 连接管理器(`backend/app/websocket/manager.py`)是「进程内存」单例;若用 `--workers N` 或 gunicorn 多 worker,连接会被拆分到不同进程,导致聊天、撤回、好友请求等实时消息无法投递。生产镜像 `backend/Dockerfile.prod` 已固定为单进程,**不要改动**。
- postgres / redis **不对外映射端口**backend / frontend 只绑定 `127.0.0.1`。所有外部流量经宿主机 Nginx 进入。
- 前端生产构建 **域名无关**:不硬编码域名,API 走同源相对路径 `/api/v1`,WebSocket 自动按页面协议推导为 `wss://<当前域名>`。同一份镜像可部署到任意域名。
---
## 1. 前置条件检查
VPSUbuntu 20.04,已安装 Nginx,但 Docker / Docker Compose / Certbot 未装。
先升级系统并装基础工具:
```bash
sudo apt update && sudo apt upgrade -y && sudo apt install -y ca-certificates curl gnupg lsb-release software-properties-common git ufw
```
---
## 2. 配置 DNS(先做这一步,证书依赖它)
在域名服务商为 `e4s.world` 添加解析:
| 类型 | 主机记录 | 记录值 |
|------|---------|--------|
| A | www | 103.170.72.162 |
等待解析生效并**验证**(必须返回你的 VPS IP 才能继续):
```bash
dig +short www.e4s.world
# 期望输出:103.170.72.162
```
> 若没有 `dig``sudo apt install -y dnsutils`。
---
## 3. 配置防火墙(ufw
```bash
# 配置放行端口:22(SSH) / 80(HTTP,含 certbot 验证与跳转) / 443(HTTPS)
sudo ufw default deny incoming && sudo ufw default allow outgoing && sudo ufw allow 22/tcp && sudo ufw allow 80/tcp && sudo ufw allow 443/tcp
# 启用防火墙(首次会提示确认,输入 y)并查看状态
sudo ufw enable && sudo ufw status verbose
```
> ⚠️ **ufw 与 Docker 的关系**Docker 会自行写入 iptables NAT 规则,**可能绕过 ufw**。本指南的安全不依赖 ufw,而是靠容器**只绑定 127.0.0.1**。部署完成后请用 `ss -tlnp` 复核 8000 / 8080 仅监听 127.0.0.1(见 [第 9 节](#9-验证清单))。如需对 Docker 做真正的端口过滤,可另外安装 `ufw-docker` 工具(可选)。
---
## 4. 安装 Docker 与 Compose 插件(Ubuntu 20.04
```bash
# 1) 添加 Docker 官方 GPG key 与 apt 仓库(整行执行,无需反斜杠续行)
sudo install -m 0755 -d /etc/apt/keyrings && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg && sudo chmod a+r /etc/apt/keyrings/docker.gpg && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# 2) 安装 Docker 引擎与 Compose 插件
sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 3) 验证(看到版本号即成功)
docker --version && docker compose version
```
> 若你的 VPS 在中国大陆、拉取 Docker 仓库很慢,可配置镜像加速器(编辑 `/etc/docker/daemon.json` 添加 `registry-mirrors`),然后 `sudo systemctl restart docker`。此处从略。
把当前用户加入 docker 组(免 sudo,需重新登录生效):
```bash
sudo usermod -aG docker $USER
# 退出重新 SSH 登录后生效;验证:groups 应包含 docker
```
---
## 5. 获取代码并生成生产密钥
```bash
sudo mkdir -p /opt/qingye && sudo chown $USER:$USER /opt/qingye && cd /opt/qingye
# 方式一:git clone(替换为你的仓库地址)
git clone <你的仓库地址> .
# 方式二:从本地打包上传
# 本地: tar --exclude=node_modules --exclude=.git -czf qingye.tar.gz .
# VPS: scp qingye.tar.gz user@103.170.72.162:/tmp/ 然后 tar -xzf /tmp/qingye.tar.gz -C /opt/qingye
```
生成生产环境变量文件(**所有密钥必填**):
```bash
# 复制模板并生成密钥(下面三项各需一个不同的随机值,可重复执行 openssl rand -hex 32
cp .env.prod.example .env.prod && openssl rand -hex 32
```
编辑 `.env.prod`,把所有 `__替换…__` 改为真实值:
```bash
nano .env.prod
```
需要设置的项:
| 变量 | 说明 |
|------|------|
| `POSTGRES_DB` / `POSTGRES_USER` | 一般保持默认 `qingye` 即可 |
| `POSTGRES_PASSWORD` | **必填** 强随机密码 |
| `JWT_SECRET_KEY` | **必填** `openssl rand -hex 32` |
| `JWT_REFRESH_SECRET_KEY` | **必填** 用**另一个**随机值 |
| `CORS_ORIGINS` | `https://www.e4s.world`(同源) |
| `ADMIN_PASSWORD` | **必填** 管理员初始密码 |
> 🚨 `POSTGRES_PASSWORD` 与 `ADMIN_PASSWORD` **只在首次启动时写入**
> - PostgreSQL 仅在数据卷为空时初始化口令;首次启动后再改此文件**无效**。
> - 管理员密码同样只在首次启动写入数据库。
>
> 所以务必在**第一次 `docker compose up` 之前**就把它们设好。万一设错了,只能在**没有用户数据**时 `docker compose down -v`**会清空数据库**)重来。
>
> 💡 若只是**管理员密码**设错,无需 `down -v`,可参见[第 12 节「轮换管理员密码」](#12-更新--重新部署)用 `DELETE` + 重启的方式无损轮换(保留全部数据)。
确认 `.env.prod` 不会被提交(应为「ignored」):
```bash
git check-ignore -v .env.prod
# 应输出匹配规则,例如:.gitignore:31:.env.prod .env.prod
```
---
## 6. 构建并启动生产容器
```bash
cd /opt/qingye && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
```
首次构建会拉取镜像、安装依赖,耗时几分钟。完成后查看状态:
```bash
docker compose -f docker-compose.prod.yml ps
# 四个容器应均为 healthy / running
```
确认后端启动成功(看到「🚀 青叶后端启动完成!」即建表成功):
```bash
docker compose -f docker-compose.prod.yml logs --tail=50 backend
```
本地自测(不经域名,直接验证容器):
```bash
curl -I http://127.0.0.1:8080 && curl http://127.0.0.1:8000/
# 前端容器应返回 200;后端应返回 {"name":"青叶 QingYe",...}
```
---
## 7. 配置宿主机 Nginx 反向代理
```bash
# 复制站点配置、启用(软链)、移除默认站点、测试并重载
sudo cp deploy/nginx.conf /etc/nginx/sites-available/www.e4s.world.conf && sudo ln -sf /etc/nginx/sites-available/www.e4s.world.conf /etc/nginx/sites-enabled/ && sudo rm -f /etc/nginx/sites-enabled/default && sudo nginx -t && sudo systemctl reload nginx
```
此时 `http://www.e4s.world` 应已能打开前端(但仍是 HTTPWebSocket 在 HTTPS 页面下会被浏览器拦截为 mixed content —— 下一步解决)。
---
## 8. 申请 SSL 证书(certbot
```bash
sudo apt install -y certbot python3-certbot-nginx && sudo certbot --nginx -d www.e4s.world
```
按提示:填邮箱、同意条款、选择是否重定向 HTTP→HTTPS(**选是**)。
certbot 会自动:
1. 用 HTTP-01 验证域名;
2. 下载证书到 `/etc/letsencrypt/live/www.e4s.world/`
3.`listen 80` 的 server 块改为 `return 301 https://...`
4. 新增 `listen 443 ssl` server 块,并把所有 `location`(含 `/ws`)复制进去。
certbot 已自动注册 systemd 定时器续期。手动验证续期逻辑:
```bash
sudo certbot renew --dry-run
```
重载 Nginx
```bash
sudo systemctl reload nginx
```
### 补充安全响应头(certbot 之后)
certbot 生成的 443 块不含安全头。编辑 `/etc/nginx/sites-available/www.e4s.world.conf`,在 **443 server 块**内补上:
```nginx
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
```
> 注意 Nginx 的 `add_header` 继承规则:若某个 `location` 内已存在 `add_header`/`expires`,则**不会**继承 server 级的头(本配置的 `/uploads/` 即如此)。若需对所有路径生效,需在该 location 内重复声明。
```bash
sudo nginx -t && sudo systemctl reload nginx
```
验证:浏览器打开 `https://www.e4s.world`,开发者工具 → Network → 任一文档请求 → Response Headers,应能看到 `strict-transport-security``x-frame-options``x-content-type-options`
---
## 9. 验证清单
部署完成后逐项检查:
```bash
# 1) 端口绑定:8000 / 8080 必须只监听 127.0.0.1(不能是 0.0.0.0 /::*
sudo ss -tlnp | grep -E ':(8000|8080)\b'
# 期望看到 127.0.0.1:8000 与 127.0.0.1:8080
# 2) 从外部无法直连后端(在另一台机器或手机网络执行)
curl -m 5 http://103.170.72.162:8000/ # 应失败(连接超时/拒绝)
# 3) HTTPS 前端
curl -I https://www.e4s.world # 200 + 证书有效
# 4) HTTP 自动跳转 HTTPS
curl -I http://www.e4s.world # 301 → https
# 5) 后端健康
curl https://www.e4s.world/ # 前端页面
curl https://www.e4s.world/api/v1/ # 经 Nginx 转发到后端(404 也算正常,说明路由通了)
# 6) WebSocket:浏览器打开 https://www.e4s.world,登录后
# 开发者工具 → Network → WS,应看到到 wss://www.e4s.world/ws 的连接(101 Switching Protocols
# 7) 数据库表已建好
docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c '\dt'
# 应列出 users / conversations / messages / moments ... 等表
# 8) Redis 健康
docker compose -f docker-compose.prod.yml exec redis redis-cli ping # PONG
```
---
## 10.(可选)开机自启 + 崩溃重启
```bash
# 安装单元、重载定义、设置开机自启、立即启动
sudo cp deploy/qingye.service /etc/systemd/system/qingye.service && sudo systemctl daemon-reload && sudo systemctl enable qingye && sudo systemctl start qingye
# 查看状态与日志(journalctl -f 会持续输出,Ctrl+C 退出)
sudo systemctl status qingye && sudo journalctl -u qingye -f
```
> 该单元的 `ExecStart` 已显式带 `-f docker-compose.prod.yml` 与 `--env-file .env.prod`**绝不会误用开发配置**。
---
## 11. 备份与恢复(重要)
数据库数据存放在 Docker 命名卷 `pgdata`**`docker compose down -v` 会清空全部用户数据**,请勿在生产使用 `-v`
### 定时备份(建议加入 cron)
使用 PostgreSQL 自定义格式(`-Fc`),便于用 `pg_restore --clean` 干净恢复:
```bash
sudo mkdir -p /opt/qingye/backups
# 手动备份一次:
docker compose -f docker-compose.prod.yml exec -T postgres pg_dump -Fc -U qingye qingye > /opt/qingye/backups/qingye-$(date +%F).dump
# 加入 crontab(每天 03:17 备份,保留 14 天):
( crontab -l 2>/dev/null; echo "17 3 * * * docker compose -f /opt/qingye/docker-compose.prod.yml exec -T postgres pg_dump -Fc -U qingye qingye > /opt/qingye/backups/qingye-\$(date +\%F).dump && find /opt/qingye/backups -name 'qingye-*.dump' -mtime +14 -delete" ) | crontab -
```
> 同时建议定期备份 `redisdata` 卷(草稿等数据存于 Redis)。
### 恢复
`--clean --if-exists` 会先删除已有对象再导入,避免「relation already exists」报错。恢复前**先停后端**,避免恢复期间有写入:
```bash
cd /opt/qingye && docker compose -f docker-compose.prod.yml stop backend && docker compose -f docker-compose.prod.yml exec -T postgres pg_restore --clean --if-exists -U qingye -d qingye < /opt/qingye/backups/qingye-YYYY-MM-DD.dump && docker compose -f docker-compose.prod.yml start backend
```
---
## 12. 更新 / 重新部署
```bash
# 拉取最新代码并重新构建启动(不删数据)
cd /opt/qingye && git pull && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
# 若只改了后端代码,可只重建 backend:
docker compose -f docker-compose.prod.yml up -d --build backend
```
> ⚠️ **数据库结构变更**:后端在启动时用 `Base.metadata.create_all` **只创建不存在的表,不会给已有表加列**。若新版后端给某张表新增了字段,必须**在新后端启动前**手动执行 SQL:
> ```bash
> docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c "ALTER TABLE xxx ADD COLUMN yyy TYPE;"
> ```
> 否则新后端读不到新列,运行时报错。(后续可引入 Alembic 迁移自动化。)
### 轮换管理员密码
管理员密码仅首次启动写入数据库。之后改 `.env.prod` 里的 `ADMIN_PASSWORD` 并重启**不会**生效。要改密码:登录管理后台用接口修改,或:
```bash
# 删除已存的哈希,重启后会用 .env.prod 里的 ADMIN_PASSWORD 重新写入
docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c "DELETE FROM system_config WHERE key='admin_password_hash';" && docker compose -f docker-compose.prod.yml restart backend
```
### 轮换 PostgreSQL 口令
`.env.prod` 里的 `POSTGRES_PASSWORD` 首次启动后改了无效。要在已有实例上改口令:
```bash
docker compose -f docker-compose.prod.yml exec postgres psql -U qingye -d qingye -c "ALTER USER qingye PASSWORD '新口令';"
# 同步修改 .env.prod 里的 POSTGRES_PASSWORD,再重启 backend
```
---
## 13. 常用运维命令
```bash
# 查看状态
docker compose -f docker-compose.prod.yml ps
# 查看实时日志
docker compose -f docker-compose.prod.yml logs -f --tail=200 backend
docker compose -f docker-compose.prod.yml logs -f --tail=200 frontend
# 重启某服务
docker compose -f docker-compose.prod.yml restart backend
# 停止(保留数据)
docker compose -f docker-compose.prod.yml down
# 进入后端容器排查
docker compose -f docker-compose.prod.yml exec backend bash
```
---
## 14. 常见问题排查
| 现象 | 排查方向 |
|------|---------|
| **前端构建报 `JavaScript heap out of memory`** | V8 堆上限过低(VPS 内存小)。镜像已设 `NODE_OPTIONS=--max-old-space-size=2048`;若仍失败需加 swap,见下方「构建内存不足」 |
| 浏览器白屏 / 404 | 前端容器是否运行:`curl -I http://127.0.0.1:8080`Nginx `location /` 是否转发到 8080 |
| 刷新 `/chat` 等子路由 404 | 前端容器 `nginx.conf` 是否有 `try_files $uri $uri/ /index.html`(已内置) |
| 登录后无法收消息 / 一直转圈 | WebSocket 未通:检查 `wss://www.e4s.world/ws`;确认 Nginx `/ws` 块有 `proxy_http_version 1.1` + `Upgrade`/`Connection` 头 |
| 上传头像失败 (413) | `client_max_body_size`Nginx)与后端 `MAX_UPLOAD_SIZE_MB` 不匹配,调大前者 |
| 上传后图片不显示 | Nginx `/uploads/``proxy_pass` 不能带尾部斜杠(否则前缀被剥,404) |
| certbot 失败 | DNS 未生效(`dig +short www.e4s.world`);80 端口被防火墙挡;触发了 Let's Encrypt 限频(5 次/小时) |
| `compose` 报「请在 .env.prod 中设置…」 | 漏了 `--env-file .env.prod`,或 `.env.prod` 里对应变量为空 |
| 改了 `.env.prod` 没生效 | 见 [第 12 节](#12-更新--重新部署) 关于「首次启动写入」的说明 |
### 构建内存不足(前端 `JavaScript heap out of memory`
`npx vite build` 打包含 echarts 等大依赖的项目较吃内存。在内存小的 VPS 上,Node 会按可用内存把 V8 堆上限设得很低(如 ~476MB),导致打包到一半 OOM —— 这是 **V8 自身的堆限制**,不是系统内存不足,**加 swap 并不能单独解决**,必须提高堆上限。镜像内已通过 `ENV NODE_OPTIONS=--max-old-space-size=2048` 处理;若物理内存确实偏小,建议同时加 swap 防止内核 OOM:
```bash
# 查看当前内存(若可用 < 1GB,强烈建议加 swap
free -h
# 创建 2GB swap 并启用(一行执行)
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
# 持久化(重启后仍生效)
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# 验证(Swap 一行应显示约 2G)
free -h
```
> 若 `fallocate` 不可用或报错,改用 `sudo dd if=/dev/zero of=/swapfile bs=1M count=2048` 创建。
加完 swap 后,回到项目目录重新构建(只重建前端即可):
```bash
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build frontend
```
---
## 15. 已知限制(架构层面)
1. **单进程后端**:当前实时消息依赖进程内存连接管理器,无法横向扩展(加机器 / 多 worker)。扩展前需先把 `ConnectionManager` 改为基于 Redis pub/sub。
2. **无数据库迁移工具**`create_all` 只建新表,结构变更需手动 SQL(见第 12 节)。后续建议引入 Alembic。
3. **上传文件无鉴权读取**`/uploads/<文件名>` 可被任何知道路径的人访问(沿用现有设计)。生产 Nginx 已加 `X-Content-Type-Options: nosniff` 缓解存储型 XSS;如需严格鉴权,需改造后端。
---
## 16. 备选:IP:端口模式(无域名 / 无 SSL)
若不用域名 / 证书(域名被占用、或临时测试),可直接用 `http://<VPS-IP>:8088` 访问。**容器配置完全不变**(仍绑定 127.0.0.1),只需让宿主机 Nginx 在一个公开端口上做 HTTP 反代,并**跳过 certbot**。配置见 [deploy/nginx-ipport.conf](deploy/nginx-ipport.conf)(监听 8088、`server_name _`、反代到 127.0.0.1:8080/8000;与域名模式的 `deploy/nginx.conf` 用了不同端口与不同 upstream 名,互不冲突,可共存)。
部署命令(在 `/opt/qingye/chat`):
```bash
sudo cp deploy/nginx-ipport.conf /etc/nginx/sites-available/qingye-ipport.conf && sudo ln -sf /etc/nginx/sites-available/qingye-ipport.conf /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx && sudo ufw allow 8088/tcp
```
随后浏览器打开 `http://103.170.72.162:8088`。前端域名无关(同源 `/api/v1`、按页面协议推导 `ws://`),HTTP 下照常工作;浏览器提示「不安全」属正常。
> **换端口**:把 `nginx-ipport.conf` 里唯一的 `listen 8088` 与 `ufw allow 8088/tcp` 中的 `8088` 一并改掉即可。
> **CORS**:同源访问不触发 CORS`.env.prod` 的 `CORS_ORIGINS` 无需改也能用;想精确匹配可设为 `http://103.170.72.162:8088` 再 `docker compose -f docker-compose.prod.yml restart backend`。
> **以后拿到域名**:启用第 7、8 节的 `deploy/nginx.conf` + certbotIP:端口配置可保留或删除。
---
## 附:开发环境(本机)
开发环境与本指南的生产环境**完全独立**,命令不变:
```bash
docker compose up --build # 走 docker-compose.yml(开发),含热重载
docker compose down
```
开发用前端变量见 `frontend/.env.development`,生产构建域名无关、无需配置。