罗德岛通讯与技术部

部署流程

DEPLOYMENT

bash scripts/deploy.sh --dry-run # 只检查能否快进,不动服务器

状态:🟢 已实测(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/
生效运行用户 ubuntusystemctl show rhodes -p User --value,不是 cat 里的)
入口 / 端口 app.main:app127.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 fetchmerge --ff-onlypip install -rsystemctl restart轮询健康检查 → 验证(提交号 / 对外 HTTPS / 前端缓存版本 / 启动后有无 traceback)。任一步失败就停下并打日志。


2. 为什么废弃 tar 部署(这是本文档存在的原因)

旧的 tar 流程有两个后果,两个都在 2026-09-12 实测到:

① 服务器的 .git 永远停在旧提交 → 无法判断线上版本

tar 解包不动 .git。实测:git log -1a889ad6a(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=autoblob 里统一 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 reset
Connection timed out during banner exchange
服务器 → GitHub 某个时刻 SSH 0/5、HTTPS 0/3(更早时候能通) 同上 —— 间歇性
本机 → GitHub(HTTPS push) 稳定 ——

排除项(都查过了,不是这些):

  • 服务器没在限连sshd -Tmaxstartups 10:30:100logingracetime 120persourcemaxstartups none
  • 不是认证问题:错误都发生在 banner exchange(TCP 连上了、 但服务器没回应 banner),根本没走到认证
  • 服务器负载正常:4 核、内存富余 2 GiB、服务只占 300 多 MB

⚠️ 错误形态指向包丢失(小包能过、稍大的交换被丢),而 banner exchange 超时 + 大传输断开正是 MTU 黑洞的典型症状。 但这一条我还没验证(当时本机进程创建受限,跑不了 ping -f -l)。 验证方法记在下面 §7。

3.5 结论:不要试图修链路,要让它不成为障碍

链路是运营商/机房的事,我们能改的只有流程。两条:

  1. 把服务器到 GitHub 那段从关键路径上拿掉(见 §7)
  2. 把"会断"当成前提来设计:可重试的重试、不可重试的别重试

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. 铁律

  1. 部署走 scripts/deploy.sh,不要手工 scp + tar。 手工那套会立刻把 §2 的两个问题带回来。

  2. 不要在服务器上 git clean data/ 是 gitignore 的, git clean -fddata/ 一起清掉(里面有 data/users/, 1.1 GB 的真实用户记忆与人设)。脚本里任何地方都没有 clean

  3. 改了前端 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 我手工改了两次 (0912a0912b0912c),而 --check 揭穿后果:index.html0912c 而其他 js 全部还是 0912a —— 模块之间的 token 不一致。 那正是本脚本开头警告的东西:./utils.js?v=1./utils.js 在浏览器看来 是两个不同模块,各自求值一遍,模块级单例状态分裂idb.jsidbDegradedsettings.jsadminBound),症状极难查。

    ⚠️ 脚本必须带 PYTHONIOENCODING=utf-8:Windows 控制台默认 GBK, 而它要打 / —— 于是一致的时候也抛 UnicodeEncodeError 退出 1。 不带这个变量跑,看起来像"脚本坏了"(我第一次就这么误判的, 在文档里写了"脚本不存在")。这和那个"只会误报的检查"是同一种坏。

    ⚠️ 它管的是缓存串?v=MMDDx),不是显示版本号。 显示版本的单一来源是 frontend/changelog.json 第一条, 由 tests/test_version_single_source.py 盯着 —— 两回事,别混

  4. requirements.txt 变了必须让脚本跑 pip install 漏装会让 uvicorn 启动即崩(2026-08-13 那 9 次重启就是漏了这步)。 脚本已内置;手工部署时别忘。

  5. 回退用 --rollback,不要在生产机上 git checkout <sha> 乱试。

  6. ⚠️ 不要去判断"现在有没有人在用"再部署 —— 而是默认就假定有人在用。

    每次部署都会 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.shset -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_pushrotate

⚠️ 而且 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.confSystemMaxUse=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_count0.0% —— 没删过数据,是真实增长。要做的是消息保留/归档策略(产品决策,不是运维清理)。

6.4 增长速率与扩容

  • 备份每天 +437 MB(data/users 在长)
  • data/users 1.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-onlysystemctl 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

顺序很重要:①②③ 都比 ④ 便宜,而且那次"保存不了"的真因就在 ③ (部署重启),而 ④ 里看不出任何异常 —— 从 ④ 开始查会白绕很久。