Files
chat/DEPLOYMENT.md
T
AgentLabCn 3d6cb0a9da 已部署
2026-06-15 23:56:26 +08:00

20 KiB
Raw Blame History

青叶 (QingYe) 生产环境部署指南

目标:把青叶部署到 VPSUbuntu 20.04IP 103.170.72.162),域名 www.e4s.world,使用 宿主机 Nginx 作反向代理 + SSLDocker 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 未装。

先升级系统并装基础工具:

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

若没有 digsudo 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_PASSWORDADMIN_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 应已能打开前端(但仍是 HTTPWebSocket 在 HTTPS 页面下会被浏览器拦截为 mixed content —— 下一步解决)。


8. 申请 SSL 证书(certbot

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 定时器续期。手动验证续期逻辑:

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-securityx-frame-optionsx-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 命名卷 pgdatadocker 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:8080Nginx 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_sizeNginx)与后端 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. 已知限制(架构层面)

  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(监听 8088、server_name _、反代到 127.0.0.1:8080/8000;与域名模式的 deploy/nginx.conf 用了不同端口与不同 upstream 名,互不冲突,可共存)。

部署命令(在 /opt/qingye/chat):

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

nginx -t 通过、但 8088 仍不监听:说明这台 nginx 只加载 conf.d(被 v2ray 等改过 nginx.conf、不含 sites-enabled)。把配置改放到 conf.d 即可: sudo cp deploy/nginx-ipport.conf /etc/nginx/conf.d/qingye-ipport.conf && sudo rm -f /etc/nginx/sites-enabled/qingye-ipport.conf && sudo nginx -t && sudo systemctl reload nginxsudo nginx -T 2>/dev/null | grep include 可查看 nginx 实际加载哪些目录。

随后浏览器打开 http://103.170.72.162:8088。前端域名无关(同源 /api/v1、按页面协议推导 ws://),HTTP 下照常工作;浏览器提示「不安全」属正常。

换端口:把 nginx-ipport.conf 里唯一的 listen 8088ufw allow 8088/tcp 中的 8088 一并改掉即可。 CORS:同源访问不触发 CORS.env.prodCORS_ORIGINS 无需改也能用;想精确匹配可设为 http://103.170.72.162:8088docker compose -f docker-compose.prod.yml restart backend以后拿到域名:启用第 7、8 节的 deploy/nginx.conf + certbot,IP:端口配置可保留或删除。


附:开发环境(本机)

开发环境与本指南的生产环境完全独立,命令不变:

docker compose up --build      # 走 docker-compose.yml(开发),含热重载
docker compose down

开发用前端变量见 frontend/.env.development,生产构建域名无关、无需配置。