生产部署 · 阿里云(Sect Management Web 版)
本文件记录 [服务器地址] 这台生产服务器的实际状态与运维口径。 部署原理看 docs/一体部署与运维.md;本文只讲这台机器现在长什么样、怎么维护。
1. 服务器档案
| 项目 | 值 |
|---|---|
| 公网 IP | [服务器地址] |
| 云厂商 | 阿里云 ECS |
| 系统 | Alibaba Cloud Linux 4(兼容 RHEL 系 / dnf) |
| 架构 | x86_64 |
| 规格 | 2 vCPU / 1.6 GB 内存 / 40 GB 系统盘 |
| 主机名 | iZ7xv2hpyo3ia9ussjekqpZ |
| 时区 | Asia/Shanghai (CST, +0800) |
| SSH 用户 | root |
| 代码根 | /opt/menpai |
💾 内存与 swap
内存只有 1.6 GB。为此额外挂了一个 2 GB swap 文件(/swapfile,已写入 /etc/fstab 持久化):
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
实测前端构建(Vite)峰值内存很低(构建仅 371 ms),swap 主要是给 「将来 asset-forge 之类重任务」留的安全垫 —— 当前作品后端常驻仅约 51 MB。
2. SSH 密钥登录(已配置)
Password登录已改为密钥优先。密钥在本地开发机:
| 文件 | 路径 |
|---|---|
| 私钥 | ~/.ssh/menpai_aliyun(🔴 务必自行备份到Password管理器) |
| 公钥 | ~/.ssh/menpai_aliyun.pub |
| 服务器落点 | /root/.ssh/authorized_keys |
本地 ~/.ssh/config 已加别名,之后直接用 menpai-aliyun 即可:
ssh menpai-aliyun # 免密登录
scp file menpai-aliyun:/opt/menpai/
别名配置内容:
Host menpai-aliyun
HostName [服务器地址]
User root
IdentityFile ~/.ssh/menpai_aliyun
IdentitiesOnly yes
StrictHostKeyChecking accept-new
ServerAliveInterval 30
ServerAliveCountMax 4
⚠️ 服务器上PasswordAuthentication仍是 yes(作为密钥丢失时的后路)。 若将来要彻底关掉Password登录(更安全,但密钥丢失就只能走阿里云控制台 VNC): 在/etc/ssh/sshd_config里设PasswordAuthentication no并systemctl reload sshd。 建议先在阿里云控制台确认 VNC 可用再关。
3. 运行架构
┌──────────────────────────────────────────┐
公网 ────:80────►│ Nginx 1.30.4 │
│ /etc/nginx/conf.d/menpai.conf │
└───────┬───────────────────────┬──────────┘
│ │
静态直出(读磁盘)│ │ proxy_pass
▼ ▼
┌───────────────────────────┐ ┌─────────────────────────────┐
│ /app/ 前端 dist 产物 │ │ gamenet(FastAPI+uvicorn) │
│ /assets/ GLB/贴图/头像 │ │ 127.0.0.1:8000 │
│ │ │ PM2: menpai-game │
│ · 命中文件名哈希 → 永久 │ │ · /api/* 作品接口 │
│ · 其余 → no-cache 协商 │ │ · /ws tick 心跳流 │
└───────────────────────────┘ └─────────────────────────────┘
职责分界(🔴 改前必读):静态文件(/app/、/assets/)一律由 Nginx 直出, 请求根本不进 Python —— 后端只剩 /api/ 和 /ws 两件事要做。实测 uvicorn 日志中 /app/ 出现 0 次,静态负载已完全卸下。
只部署了作品本体。 asset-forge(美术生成器 :8788)与 rig-forge (骨骼工坊 :8789)没有上这台机器 —— 那两台是桌面生产工具,跑在开发机。 将来若要上,按 deploy/nginx-menpai.conf(完整三服务版)替换 menpai.conf。
缓存策略(复刻后端 SmartStaticFiles 语义)
| 路径 | Cache-Control | 理由 |
|---|---|---|
/app/assets/*(含哈希) | public, max-age=31536000, immutable | Vite 产物名带内容哈希,内容变了名字就变 |
/app/(含 index.html) | no-cache, must-revalidate | 入口 HTML 必须每次回源验新鲜度 |
/assets/*(GLB/贴图) | no-cache, must-revalidate + Accept-Ranges: bytes | 资产名不带哈希,且需支持断点续传 |
/api/* | no-store(后端中间件下发) | 作品状态绝不能缓存 |
协商缓存已验证:带 If-None-Match 回访入口 HTML → 304。
gzip
已开(gzip_vary on / gzip_proxied any)。注意两处易踩坑: text/javascript 必须在 gzip_types 里(Vite 产物的实际 MIME 是它, 不是 application/javascript,漏了会看到 770 KB JS 裸传);model/gltf-binary (GLB)保留压缩,实测仍有 14.9% 收益。当前效果:JS 省 68%、/api/scene 省 82%。
关键目录
/opt/menpai/
├── web/backend/ 作品后端(gamenet 包)
│ ├── .venv/ Python 3.11 venv(fastapi 0.141.1 / uvicorn 0.53.0)
│ ├── saves/ ★ 玩家存档(活数据,勿覆盖)
│ └── accounts/ ★ 账户与会话(活数据,勿覆盖)
├── web/frontend/
│ ├── src/ 前端源码
│ └── dist/ 构建产物(服务端构建,base=/app/)← Nginx 直接读这里
├── game/
│ ├── data/*.json 作品数值
│ └── assets/ 101 MB(67 GLB + UI 文件)← Nginx 直接读这里
├── deploy/prod/ 生产配置(ecosystem.config.js / nginx / push.sh / .service)
├── _logs/ PM2 日志(pm2-game-out.log / pm2-game-err.log)
└── tools/ 辅助脚本
4. 服务管理
两个东西管着服务:PM2 管后端进程,systemd 管 Nginx。
后端(PM2)
进程名 menpai-game,配置在 deploy/prod/ecosystem.config.js。核心机关是 interpreter: 'none' —— 不写它 PM2 会用 node 去解释 Python 脚本,直接暴毙。
pm2 list # 总览
pm2 logs menpai-game # 跟日志(或 tail /opt/menpai/_logs/pm2-game-out.log)
pm2 restart menpai-game # 重启
pm2 stop menpai-game # 停
pm2 save # ★ 改完进程表必须 save,否则重启不 resurrect
pm2 startup systemd # 注册开机自启(已执行,pm2-root.service 为 enabled)
两个开关都开了:
- 崩溃自愈 ——
autorestart: true+max_restarts: 50+ 指数退避(exp_backoff_restart_delay: 1000) - 开机自启 ——
systemctl is-enabled pm2-root→enabled,开机后 PM2 daemon
按 dump 文件 resurrect 复活进程
⚠️ 旧 systemd 单元已停用。/etc/systemd/system/menpai-game.service仍留在盘上 但已disable,不要再systemctl start menpai-game—— 会和 PM2 抢 8000 端口, 表现为「谁先起来谁活着,另一个疯狂重启」。要彻底删就systemctl disable --now menpai-game后删文件(deploy/prod/menpai-game.service作为历史留档保留在仓库里)。
Nginx(systemd)
systemctl status nginx
nginx -t && systemctl reload nginx # 改完配置先测再 reload
tail -f /var/log/nginx/menpai.access.log # 本站日志(已从默认 access.log 分离)
tail -f /var/log/nginx/menpai.error.log
已验证的自愈能力
| 场景 | 结果 |
|---|---|
kill -9 后端进程 | 3 秒内 PM2 自动拉起 |
整机 reboot | 约 1 分钟内 PM2 resurrect + Nginx 自启,公网服务恢复,无需人工干预 |
实测佐证:reboot 后 pm2 list 显示新 PID、重启计数 ↺ = 0(说明是干净的 resurrect 拉起,而非崩溃后反复重试)。
5. 更新代码
在本地仓库根执行:
bash deploy/prod/push.sh # 同步代码 + 服务端构建前端 + 重启后端
bash deploy/prod/push.sh --no-build # 只改后端时用(快)
bash deploy/prod/push.sh --data # 改了 game/data/*.json 数值
bash deploy/prod/push.sh --assets # 新增/替换了 GLB 或界面图(101M,较慢)
脚本只传源码,saves/、accounts/、.venv/、node_modules/ 一律不碰 —— 玩家进度不会被覆盖。第 5 步的重启走 pm2 restart menpai-game;若进程意外不存在 (首次部署 / 被 pm2 delete),脚本会自动回落到 pm2 start ecosystem.config.js。
静态文件交给 Nginx 后,只改前端产物其实不需要重启后端 —— 但 push.sh 默认 仍会重启一次,图的是「环境变量/依赖有变时也一并生效」的确定性。
入库新模型后不需要重启
作品后端每次 /api/scene 都实时扫描 game/assets/models/, missing_models 字段会立刻反映增删。所以:push.sh --assets 之后 刷新浏览器即可(后端本身没变,不必 restart)。
6. 验收清单
# --- 基础可用性(从任意机器,公网)---
curl -s http://[服务器地址]/api/health
# → {"ok":true,"users":N,"active_sessions":N,"data_loaded":true}
curl -s -o /dev/null -w '%{http_code}\n' http://[服务器地址]/app/ # → 200
curl -s -o /dev/null -w '%{http_code}\n' http://[服务器地址]/ # → 200
# --- 缓存分工(应分别命中对应策略)---
curl -sI http://[服务器地址]/app/ | grep -i cache-control # no-cache, must-revalidate
curl -sI http://[服务器地址]/assets/ | grep -i cache-control # GLB 段同样 no-cache
# 哈希产物(文件名取首页里的实际值)
curl -sI http://[服务器地址]/app/assets/<hash>.js | grep -i cache-control
# → public, max-age=31536000, immutable
# --- gzip 生效 ---
curl -sI -H 'Accept-Encoding: gzip' http://[服务器地址]/api/scene \
| grep -iE 'content-encoding|vary' # → gzip + Vary
# --- 服务进程 ---
ssh menpai-aliyun 'pm2 list; systemctl is-active nginx'
# --- 模型完整性(应为 0 项缺失)---
ssh menpai-aliyun 'curl -s http://127.0.0.1/api/scene' | python3 -c "
import sys,json; d=json.load(sys.stdin); print('missing:', d.get('missing_models') or '(none)'); print('models_on_disk:', d['counts']['models_on_disk'])"
真实浏览器端到端(本地跑,验证注册 → 进 3D 场景 + WebSocket):
node _tmp/probe_prod_game.cjs
判断标准:CONSOLE_ERRORS/HTTP_BAD/REQ_FAILED三项全为[], 且画面文字里的作品时辰会往前走(如 辰时 07:20 → 巳时 10:20)。 时辰推进即证明/ws的 tick 流打通 —— 这是比任何 HTTP 检查都硬的信号。
7. 清测试账号
后端把每个账户的 GameEngine 常驻内存,tick 会持续把存档写回磁盘 —— 手删存档 15 秒后会自动复活。唯一干净路径是先停服务再清:
# 1) 停(pm2 stop 是持久的,不会自己爬起来)
ssh menpai-aliyun 'pm2 stop menpai-game'
# 2) 清 —— 脚本按「有效用户名单」反查,账号/会话/存档三处一起对账
scp deploy/prod/clean_test_accounts.py menpai-aliyun:/opt/menpai/web/backend/_c.py
ssh menpai-aliyun 'cd /opt/menpai/web/backend && python3 _c.py && rm -f _c.py'
# 3) 起
ssh menpai-aliyun 'pm2 start menpai-game'
脚本行为:删掉所有 @example.com 账号(aowind@qq.com 白名单永不删), 再以存活用户名单为准,把 sessions.json 里的孤儿会话和 saves/users/ 里的 孤儿存档一并清掉 —— 所以它既能清探针账号,也能修复历史残留。
🔴 必须 stop 之后再清:进程活着时它会立刻把内存里的账户与存档写回磁盘, 删了也白删。 ⚠️ 别用ssh host 'python3 - <<PY ... PY'这种内联 heredoc —— 脚本里只要出现sess['sessions']这类单引号,就会把 SSH 命令外层的单引号提前闭合,变量名被 shell 吃掉、Python 报NameError。写文件再 scp 才稳。tools/clean_test_accounts.py(本地开发那版)按「本机 8000/8080 端口」探测 服务,服务器上请用deploy/prod/这个版本。
8. 已知约束与后续
| 项 | 现状 | 说明 |
|---|---|---|
| HTTPS | ❌ 未配置 | 目前只有 http://[服务器地址]。有域名后可上 certbot 签 Let's Encrypt |
| 域名 | ❌ 无 | Nginx server_name _ 通吃;有域名后改成域名并配 443 |
GAMENET_CORS | * | 默认值(开发期设置)。生产可收紧为实际来源,见 gamenet/config.py |
| 带宽 | 未知 | 67 个 GLB 共 83 MB。首次加载会拉不少;Nginx 已开 gzip 与长缓存 |
| 并发 | 未压测 | 2 核 1.6 G,每活跃会话一个内存引擎。小规模(一家几口 / 小班)够用 |
| asset-forge | 未部署 | 生产工具,留在开发机 |
| 旧 systemd 单元 | 已 disable | menpai-game.service 停用,改由 PM2 接管;文件留档未删 |