部署流程
DEPLOYMENT
状态:🟢 已实测(2026-09-12 首次走通)· 最后核对 2026-09-12 入口:
bash scripts/deploy.sh(一条命令,见下)⚠️ 2026-09-12 之前是「本地
git archive→ scp tar → 服务器解包」。 那套已经废弃,原因见 §2 —— 它让服务器的.git永远停在旧提交, 从而无法回答"线上到底跑的是哪个版本"。
1. 现状与一条命令
| 项 | 值 |
|---|---|
| 服务器 | ubuntu@manana.icu(= ***.***.***.***(已隐去)) |
| SSH 私钥 | f:/Rhodes-island/Rhodes.pem(Ed25519) |
| 部署目录 | /opt/rhodes-island(现在是真的 git 工作树) |
| systemd 服务 | rhodes(/etc/systemd/system/rhodes.service + rhodes.service.d/) |
| 生效运行用户 | ubuntu(systemctl show rhodes -p User --value,不是 cat 里的) |
| 入口 / 端口 | app.main:app,127.0.0.1:8001(回环,靠 nginx 反代对外 HTTPS) |
| 虚拟环境 | /opt/rhodes-island/.venv/ |
| 服务器 git 远端 | git@github.com:****/rhodes-island.git(服务器自带 deploy key) |
bash scripts/deploy.sh --dry-run # 只检查能否快进,不动服务器
bash scripts/deploy.sh # 部署(fetch + ff-only + 依赖 + 重启 + 验证)
bash scripts/deploy.sh --rollback # 回退一个提交并重启
脚本会依次做:连通性检查 → git fetch → merge --ff-only → pip install -r
→ systemctl restart → 轮询健康检查 → 验证(提交号 / 对外 HTTPS /
前端缓存版本 / 启动后有无 traceback)。任一步失败就停下并打日志。
2. 为什么废弃 tar 部署(这是本文档存在的原因)
旧的 tar 流程有两个后果,两个都在 2026-09-12 实测到:
① 服务器的 .git 永远停在旧提交 → 无法判断线上版本
tar 解包不动 .git。实测:git log -1 说 a889ad6a(2026-09-06),
而工作树里的代码其实是 09-10 的。于是只有靠 grep 特征词去猜线上版本 ——
而这次我真的猜错了:查 不打扰 用的是 app/systems/narrative/initiative.py,
但那个功能在 scheduler.py,得到 0 就下了"服务器缺了两天的功能"的
错误结论,白排查一轮。
② 索引与工作树永久对不上 → git status 完全不可信
tar 时代结束时,服务器 git status 是:
1662 M 1211 D 1240 ??
看着像"有 4000 个文件被改"。实际逐个比对内容哈希
(app/main.py / app/api/doctor.py / app/systems/narrative/scheduler.py /
frontend/index.html / frontend/js/settings.js),与 origin/master 逐字节相同。
结论:那 4,111 条全是索引残留,服务器没有任何独有代码。
所以当时不能拿 git status 当依据做任何判断。
切到 git 流程后,同一台机器:
2 ?? # 只剩两个 data/ 下的运行时数据文件
0 # 已跟踪文件的改动
HEAD == origin/master
为什么现在能用 git 了
服务器本来就能 git fetch:~/.ssh/id_ed25519 是仓库的 deploy key,
ssh -T git@github.com 返回 Hi ****!。旧流程没用它而已。
3. 三个必须知道的坑
3.1 git status 在 tar 时代是坏掉的(已修复,但要知道为什么)
见 §2②。现在它可信了:git log -1 就是线上真实版本。
以后如果有人又用 tar 部署,这个可信度会立刻消失。
3.2 行尾:服务器必须是 LF,不要动 core.autocrlf
仓库 .gitattributes 是 * text=auto,blob 里统一 LF。
服务器是 Linux,工作树也该是 LF。
- 服务器上设了
core.autocrlf false+core.eol lf(2026-09-12 设的) - ⚠️ 不要设成
input:那会让工作树保留 CRLF, 于是 git 报"文件改了"而内容其实一样 —— 又是一片假M噪音 - 从 Windows 解包 tar 会带进 CRLF,这正是当年噪音的来源之一
3.3 健康检查必须轮询,不能只 sleep 一次
实测首次启动约需 20 秒。第一次部署时我 sleep 6 后查,
得到 curl: (7) Failed to connect to 127.0.0.1 port 8001 —— 服务其实是好的,
白紧张一轮。脚本里改成最多等 60 秒、每 3 秒探一次。
3.4 ⚠️ 链路本身就是不稳的 —— 实测数字(2026-09-13)
这不是"偶尔运气不好",是常态。 实测:
| 链路 | 实测 | 形态 |
|---|---|---|
| 本机 → 服务器(SSH) | 15 次里失败 4 次(27%),平均 3746 ms/次 | kex_exchange_identification: read: Connection resetConnection timed out during banner exchange |
| 服务器 → GitHub | 某个时刻 SSH 0/5、HTTPS 0/3(更早时候能通) | 同上 —— 间歇性 |
| 本机 → GitHub(HTTPS push) | 稳定 | —— |
排除项(都查过了,不是这些):
- 服务器没在限连:
sshd -T是maxstartups 10:30:100、logingracetime 120、persourcemaxstartups none - 不是认证问题:错误都发生在 banner exchange(TCP 连上了、 但服务器没回应 banner),根本没走到认证
- 服务器负载正常:4 核、内存富余 2 GiB、服务只占 300 多 MB
⚠️ 错误形态指向包丢失(小包能过、稍大的交换被丢),而
banner exchange 超时 + 大传输断开正是 MTU 黑洞的典型症状。
但这一条我还没验证(当时本机进程创建受限,跑不了 ping -f -l)。
验证方法记在下面 §7。
3.5 结论:不要试图修链路,要让它不成为障碍
链路是运营商/机房的事,我们能改的只有流程。两条:
- 把服务器到 GitHub 那段从关键路径上拿掉(见 §7)
- 把"会断"当成前提来设计:可重试的重试、不可重试的别重试
4. 首次切换到 git 流程(已完成,留档备查)
tar → git 的迁移一次性做完,记录在这里以防哪天又需要:
# 1) 先确认服务器没有独有代码(关键,不能跳过)
# 在服务器上算关键文件的内容哈希,和本地 `git show HEAD:<file>` 比对
ssh -i Rhodes.pem ubuntu@manana.icu \
'cd /opt/rhodes-island; for f in app/main.py frontend/index.html; do
printf "%-40s " "$f"; tr -d "\r" < "$f" | sha256sum | cut -c1-16; done'
# 本地对照:
# for f in app/main.py frontend/index.html; do
# printf "%-40s " "$f"; git show HEAD:$f | tr -d "\r" | sha256sum | cut -c1-16; done
# 2) 一致 → adopt(把工作树对齐 origin/master;不碰 data/、不碰未跟踪文件)
bash scripts/deploy.sh --adopt
⚠️ --adopt 用的是 git reset --hard,它会丢已跟踪文件的本地改动。
所以第 1 步不能跳过。它不会碰 data/(gitignore)和未跟踪文件。
5. 铁律
-
部署走
scripts/deploy.sh,不要手工scp+tar。 手工那套会立刻把 §2 的两个问题带回来。 -
不要在服务器上
git clean。data/是 gitignore 的,git clean -fd会连data/一起清掉(里面有data/users/, 1.1 GB 的真实用户记忆与人设)。脚本里任何地方都没有clean。 -
改了前端 JS/CSS 必须升缓存串,而且要用脚本升:
PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe scripts/bump_frontend_cache.py PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe scripts/bump_frontend_cache.py --check⚠️ 不要手工改
index.html里那 11 处?v=。 2026-09-12 我手工改了两次 (0912a→0912b→0912c),而--check揭穿后果:index.html是0912c而其他 js 全部还是0912a—— 模块之间的 token 不一致。 那正是本脚本开头警告的东西:./utils.js?v=1和./utils.js在浏览器看来 是两个不同模块,各自求值一遍,模块级单例状态分裂(idb.js的idbDegraded、settings.js的adminBound),症状极难查。⚠️ 脚本必须带
PYTHONIOENCODING=utf-8:Windows 控制台默认 GBK, 而它要打✓/❌—— 于是一致的时候也抛 UnicodeEncodeError 退出 1。 不带这个变量跑,看起来像"脚本坏了"(我第一次就这么误判的, 在文档里写了"脚本不存在")。这和那个"只会误报的检查"是同一种坏。⚠️ 它管的是缓存串(
?v=MMDDx),不是显示版本号。 显示版本的单一来源是frontend/changelog.json第一条, 由tests/test_version_single_source.py盯着 —— 两回事,别混。 -
requirements.txt变了必须让脚本跑pip install。 漏装会让 uvicorn 启动即崩(2026-08-13 那 9 次重启就是漏了这步)。 脚本已内置;手工部署时别忘。 -
回退用
--rollback,不要在生产机上git checkout <sha>乱试。 -
⚠️ 不要去判断"现在有没有人在用"再部署 —— 而是默认就假定有人在用。
每次部署都会
systemctl restart rhodes,造成 2~5 秒的 502。 这不是理论:2026-09-12 我一天里连续部署多次,某次重启正好落在 一位用户的"保存人设"操作上(nginx 日志):14:28:03 PUT /api/doctor/profile?key=custom_6 200 ← 保存成功了 14:28:04 systemd: Stopping rhodes.service ← 部署重启 14:28:05 GET /api/doctor/profile?key=custom_6 502 ← 读回被切断保存其实成功了,用户看到的却是「读取人设失败:HTTP 502」。 他不信、反复重试,最后那几个字段是空的 —— 数据没了, 而根因是一次部署。用户报告的是"保存不了",查起来会往 后端存储上想,方向完全错。
所以
· 攒一批再部署,别改一行就上一次。功能能等,用户的编辑不能丢。 · 部署后自己刷新页面走一遍关键路径(人设保存、聊天发一条), 别只看健康检查 —— 健康检查 200 只说明进程活着。 · 用户报"保存失败/卡住"时,先查 nginx 日志那个时刻有没有 502, 再怀疑代码。这一步能省掉大量排查。
已经因此做的加固(2026-09-12)
前端不再把瞬时故障当永久失败:
· 读取与保存都带退避重试(
apiRetry,3 次 / 300·600ms)—— 上面那个 502 现在会被自动重试掉 · 读失败仍禁用保存(防空表单覆盖已有人设),但给页内重试按钮, 不再让用户卡死 · 保存失败留在页上 + 重试保存,内容不丢 · 保存成功先显示「已保存 ✓」再关页 —— 原来成功后直接关页, 于是按钮一直挂着「保存中…」,用户以为卡住了
6. 磁盘与备份(2026-09-12 处理过一轮)
6.0 已完成:修掉一个让备份静默失败 6 天的 bug + 清理 1.2 GB
用户报「盘快满了」(控制台 92.3%),查下来根因不是盘小,是一个 bug。
rhodes-backup.service 从 2026-09-07 起每晚失败,而链条全是无声的:
| 本应发生 | 实际 |
|---|---|
rotate 保留 11 份 |
❌ 跑不到 → 堆到 13 份,多占 1.5 GB 且会一直涨 |
写 .sha256 |
❌ 跑不到 → 13 个归档只有 5 个有校验 |
| 异地副本 | ❌ 从来没成功过(.age 0 个) |
根因:backup_data.sh 是 set -euo pipefail,而 tar 读到正在被写入
的文件时(实测 data/users/沙县老吃/deep_chat.db,740 MB,用户在线聊天)
以退出码 1 结束 —— 那是警告(file changed as we read it),不是错误。
裸调用 tar czf ... 当场触发 set -e,脚本在那一行死掉,而它下面还有
完整性校验、.sha256、FERNET_KEY 自检、offsite_push、rotate。
⚠️ 而且 journalctl -u rhodes-backup 只有结果没有原因(正文在
/var/log/rhodes-backup.log)—— 这是它藏了 6 天的主要原因。
建议给 unit 加 StandardOutput=append:/var/log/rhodes-backup.log,
否则下次还是只能看到 Failed with result 'exit-code'。
修法:set +e 跑 tar、立刻 RC=$?、再接回 set -e;rc==1 容忍并记警告,
rc>=2 才 die 并删掉半份归档。
⚠️ 不能写成 if ! tar ...; then RC=$?; fi —— if ! 里的 $? 是取反后的值
(实测 if ! false 拿到 0),那样 TAR_RC 恒为 0,真实失败(rc>=2,如磁盘写满)
会被当成成功,比原 bug 更坏。
已验证:手动跑一次,轮转恢复(删了 3 个旧归档)、服务终态 inactive。
清理结果(用户同意后执行):
| 项 | 回收 |
|---|---|
/tmp/*.tar.gz(11 个部署包,tar 时代残留) |
608 MB |
journalctl --vacuum-size=100M |
384 MB |
apt-get clean |
110 MB |
我迁移时留的 git index 备份 |
— |
| 合计 | 约 1.2 GB |
清理前: 40G 35G 2.9G 93%
清理后: 40G 33G 4.7G 88%
⚠️ journal 的 100M 只在手动 vacuum 时生效。已持久化到
/etc/systemd/journald.conf.d/99-size.conf(SystemMaxUse=200M),
否则它会慢慢涨回去。
6.1 仍然可回收(未动,需你决定)
| 项 | 大小 | 说明 |
|---|---|---|
/var/backups/rhodes |
4.3 GB | 11 份归档(7 日常 + 4 周备)。这是恢复窗口,砍不砍是产品决策 |
/opt/llama.cpp |
1.1 GB | 不确定是否在用,没动 |
/opt/mythos-table |
456 MB | 另一个服务(有独立的 mythos-backup.timer) |
/var/log/journal |
100 MB | 已限流 |
6.2 ⚠️ 异地备份从来没配 —— 真正的风险
/etc/rhodes-backup.conf 不存在,备份脚本每天都在警告:
⚠️⚠️ **异地副本没有配** —— 这份备份和主服务在同一块盘上。
盘坏了两份一起没。
现在 4.3 GB 备份和主服务在同一块盘上。盘坏 = 全没。
配上异地(rclone 到对象存储,见 docs/backup-and-restore.md)之后,
本地保留可以砍到 2–3 份,能再省约 3 GB。
6.3 一个用户的库占 79%
data/users/沙县老吃/deep_chat.db = 740 MB,而全部 72 个
deep_chat.db 合计 940 MB。
⚠️ VACUUM 救不了:实测那个库 freelist_count 是 0.0% ——
没删过数据,是真实增长。要做的是消息保留/归档策略(产品决策,不是运维清理)。
6.4 增长速率与扩容
- 备份每天 +437 MB(
data/users在长) data/users1.1 GB —— 真实用户数据,永远不要动- 腾讯云系统盘可扩容(控制台「云硬盘」→ 扩容 →
growpart+resize2fs), 这是最省事的根治
6.5 两个运行时数据文件没被 gitignore
?? data/friends.json (524 B)
?? data/webpush_vapid.json (384 B)
生产运行时数据(不是垃圾),只是没进 .gitignore。加两行能让服务器
git status 彻底干净 —— 不紧急,改 .gitignore 要重新部署一次。
6.6 未验证:--rollback 从未实跑
脚本里的 --rollback 只有静态逻辑,没有在真实环境验证过。
真要用之前先确认它只影响一个提交。
7. ⚠️ 链路不稳:怎么绕,怎么查
7.1 根因:关键路径上有一段我们控制不了的链路
现在的流程是:
本机 --(HTTPS,稳)--> GitHub <--(SSH,间歇不通)-- 服务器
| ^
+----------(SSH,27% 失败)--------------------+
部署要把代码送上服务器,而代码的来源(GitHub)在服务器那侧够不着。 于是每次部署都要穿越那条最不稳的链路,而且经过它两次 (本机 push GitHub、服务器 pull GitHub)。
⚠️ 这不是配置错误,也不是服务器在限连(§3.4 的排除项)。 是运营商之间的路由质量问题。我们改不了它,只能不依赖它。
7.2 方案:把服务器 → GitHub 从关键路径上拿掉
本机 --(HTTPS,稳)--> GitHub ← 只用于留档、给别人看
本机 --(SSH,但只传一个文件)--> 服务器 ← 部署只走这一步
做法:用 git bundle。
# 本机(GitHub 那步只是留档,可有可无)
git push origin master
git bundle create /tmp/deploy.bundle origin/master..master # 只带新提交
# 传过去(一个文件,断了重传整份,不会留半个状态)
scp /tmp/deploy.bundle ubuntu@manana.icu:/tmp/
# 服务器:从 bundle 合并 —— **不碰 GitHub**
ssh ubuntu@manana.icu 'cd /opt/rhodes-island &&
git bundle verify /tmp/deploy.bundle &&
git fetch /tmp/deploy.bundle master:refs/heads/_deploy &&
git merge --ff-only _deploy && rm -f /tmp/deploy.bundle'
为什么 bundle 比"服务器 git pull"好:
| 服务器 pull GitHub | bundle | |
|---|---|---|
| 依赖的那段链路 | 服务器 ↔ GitHub(间歇不通) | 本机 ↔ 服务器(27% 失败,但可重试) |
| 传输失败后的状态 | 可能停在半个 fetch | 一个文件,没传到就是没传到 |
| 失败后怎么办 | 只能再撞一次 | 重传一次(幂等) |
⚠️ 本机到服务器那段仍然会断,但它的失败是可安全重试的
(传文件是幂等的)。而 git fetch 半途断掉之后的状态要难判断得多。
7.3 让"会断"变成可重试 —— 两条规矩
① 只重试幂等的操作,而且要在"操作层"包一层
⚠️ 现在的 rq() 只在执行前探一次连通,命令本身断了不会重试。
而 git merge --ff-only、systemctl restart 这类是可以安全重试的:
merge --ff-only:已经是目标提交时返回 0(幂等)systemctl restart:重复执行只是多重启一次(可接受)git fetch:幂等
所以给 rq 加有界重试(3 次 / 退避 2·4 秒),并在每次重试前
重新探连通 —— 断开后立刻重连往往就通了。
② 不可重试的东西,必须能一眼看出"它到底跑没跑"
systemctl restart 超时后你无法确定服务有没有重启(命令发出去了,
响应丢了)。所以每步之后要有一条读状态的动作来确认,而不是靠
"命令返回了 0"。
7.4 还没验证的一条:MTU 黑洞
banner exchange 超时 + 大传输断开,是 MTU 黑洞的典型症状
(小包过、大包被静默丢弃)。这条我还没验证,验证方法:
# 1) 找最大能过的包(Windows:-f 禁止分片、-l 指定载荷)
ping -f -l 1472 ***.***.***.***(已隐去) # 1472+28=1500,标准 MTU
ping -f -l 1400 ***.***.***.***(已隐去)
ping -f -l 1200 ***.***.***.***(已隐去)
# 如果小包通、大包「需要拆分数据包但设置 DF」→ 就是 MTU 黑洞
# 2) 若确认,给 SSH 单独降 MTU(不影响其他流量)
ssh -o "IPQoS=throughput" ...
# 或在本机路由上对该目标设 MTU:
# netsh interface ipv4 set subinterface <接口> mtu=1400 store=persistent
⚠️ 别急着改全局 MTU —— 那会影响所有流量。先确认,再针对性处理。
7.5 判断"是不是链路问题"的流程
用户报"保存失败/卡住"、或部署失败时,按这个顺序查, 别一上来就怀疑代码:
# ① 那台服务活着吗(本机到服务器的链路可能才是坏的)
ssh ubuntu@manana.icu 'systemctl is-active rhodes; curl -sS -m 5 http://127.0.0.1:8001/api/health'
# ② 用户操作的那一刻,nginx 有没有 502(§5 铁律 6)
sudo grep ' 502 ' /var/log/nginx/access.log | tail -20
# ③ 是不是正好撞上部署重启
sudo journalctl -u rhodes --since '30 min ago' | grep -E 'Stopping|Started'
# ④ 再怀疑应用日志
tail -50 /opt/rhodes-island/data/logs/error_$(date +%Y-%m-%d).log
顺序很重要:①②③ 都比 ④ 便宜,而且那次"保存不了"的真因就在 ③ (部署重启),而 ④ 里看不出任何异常 —— 从 ④ 开始查会白绕很久。