18 KiB
青叶 (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 未装。
先升级系统并装基础工具:
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 才能继续):
dig +short www.e4s.world
# 期望输出:103.170.72.162
若没有
dig:sudo apt install -y dnsutils。
3. 配置防火墙(ufw)
# 配置放行端口: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 节)。如需对 Docker 做真正的端口过滤,可另外安装ufw-docker工具(可选)。
4. 安装 Docker 与 Compose 插件(Ubuntu 20.04)
# 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,需重新登录生效):
sudo usermod -aG docker $USER
# 退出重新 SSH 登录后生效;验证:groups 应包含 docker
5. 获取代码并生成生产密钥
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
生成生产环境变量文件(所有密钥必填):
# 复制模板并生成密钥(下面三项各需一个不同的随机值,可重复执行 openssl rand -hex 32)
cp .env.prod.example .env.prod && openssl rand -hex 32
编辑 .env.prod,把所有 __替换…__ 改为真实值:
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 节「轮换管理员密码」用DELETE+ 重启的方式无损轮换(保留全部数据)。
确认 .env.prod 不会被提交(应为「ignored」):
git check-ignore -v .env.prod
# 应输出匹配规则,例如:.gitignore:31:.env.prod .env.prod
6. 构建并启动生产容器
cd /opt/qingye && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build
首次构建会拉取镜像、安装依赖,耗时几分钟。完成后查看状态:
docker compose -f docker-compose.prod.yml ps
# 四个容器应均为 healthy / running
确认后端启动成功(看到「🚀 青叶后端启动完成!」即建表成功):
docker compose -f docker-compose.prod.yml logs --tail=50 backend
本地自测(不经域名,直接验证容器):
curl -I http://127.0.0.1:8080 && curl http://127.0.0.1:8000/
# 前端容器应返回 200;后端应返回 {"name":"青叶 QingYe",...}
7. 配置宿主机 Nginx 反向代理
# 复制站点配置、启用(软链)、移除默认站点、测试并重载
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)
sudo apt install -y certbot python3-certbot-nginx && sudo certbot --nginx -d www.e4s.world
按提示:填邮箱、同意条款、选择是否重定向 HTTP→HTTPS(选是)。
certbot 会自动:
- 用 HTTP-01 验证域名;
- 下载证书到
/etc/letsencrypt/live/www.e4s.world/; - 把
listen 80的 server 块改为return 301 https://...; - 新增
listen 443 sslserver 块,并把所有location(含/ws)复制进去。
certbot 已自动注册 systemd 定时器续期。手动验证续期逻辑:
sudo certbot renew --dry-run
重载 Nginx:
sudo systemctl reload nginx
补充安全响应头(certbot 之后)
certbot 生成的 443 块不含安全头。编辑 /etc/nginx/sites-available/www.e4s.world.conf,在 443 server 块内补上:
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 内重复声明。
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. 验证清单
部署完成后逐项检查:
# 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.(可选)开机自启 + 崩溃重启
# 安装单元、重载定义、设置开机自启、立即启动
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 干净恢复:
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」报错。恢复前先停后端,避免恢复期间有写入:
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. 更新 / 重新部署
# 拉取最新代码并重新构建启动(不删数据)
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: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 并重启不会生效。要改密码:登录管理后台用接口修改,或:
# 删除已存的哈希,重启后会用 .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 首次启动后改了无效。要在已有实例上改口令:
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. 常用运维命令
# 查看状态
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 节 关于「首次启动写入」的说明 |
构建内存不足(前端 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:
# 查看当前内存(若可用 < 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 后,回到项目目录重新构建(只重建前端即可):
docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build frontend
15. 已知限制(架构层面)
- 单进程后端:当前实时消息依赖进程内存连接管理器,无法横向扩展(加机器 / 多 worker)。扩展前需先把
ConnectionManager改为基于 Redis pub/sub。 - 无数据库迁移工具:
create_all只建新表,结构变更需手动 SQL(见第 12 节)。后续建议引入 Alembic。 - 上传文件无鉴权读取:
/uploads/<文件名>可被任何知道路径的人访问(沿用现有设计)。生产 Nginx 已加X-Content-Type-Options: nosniff缓解存储型 XSS;如需严格鉴权,需改造后端。
附:开发环境(本机)
开发环境与本指南的生产环境完全独立,命令不变:
docker compose up --build # 走 docker-compose.yml(开发),含热重载
docker compose down
开发用前端变量见 frontend/.env.development,生产构建域名无关、无需配置。