417 lines
17 KiB
Markdown
417 lines
17 KiB
Markdown
# 青叶 (QingYe) 生产环境部署指南
|
||
|
||
> 目标:把青叶部署到 VPS(Ubuntu 20.04,IP `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. 前置条件检查
|
||
|
||
VPS:Ubuntu 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` 应已能打开前端(但仍是 HTTP,WebSocket 在 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. 常见问题排查
|
||
|
||
| 现象 | 排查方向 |
|
||
|------|---------|
|
||
| 浏览器白屏 / 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-更新--重新部署) 关于「首次启动写入」的说明 |
|
||
|
||
---
|
||
|
||
## 15. 已知限制(架构层面)
|
||
|
||
1. **单进程后端**:当前实时消息依赖进程内存连接管理器,无法横向扩展(加机器 / 多 worker)。扩展前需先把 `ConnectionManager` 改为基于 Redis pub/sub。
|
||
2. **无数据库迁移工具**:`create_all` 只建新表,结构变更需手动 SQL(见第 12 节)。后续建议引入 Alembic。
|
||
3. **上传文件无鉴权读取**:`/uploads/<文件名>` 可被任何知道路径的人访问(沿用现有设计)。生产 Nginx 已加 `X-Content-Type-Options: nosniff` 缓解存储型 XSS;如需严格鉴权,需改造后端。
|
||
|
||
---
|
||
|
||
## 附:开发环境(本机)
|
||
|
||
开发环境与本指南的生产环境**完全独立**,命令不变:
|
||
|
||
```bash
|
||
docker compose up --build # 走 docker-compose.yml(开发),含热重载
|
||
docker compose down
|
||
```
|
||
|
||
开发用前端变量见 `frontend/.env.development`,生产构建域名无关、无需配置。
|