测了一台美国 VPS,把博客从香港搬了过去
博客原来一直跑在一台 2 核 2G 的香港 VPS 上。这台机器越来越吃紧:内存只剩一百多兆,swap 用了七百多兆,后端每次启动要等一分钟。前两天看到一台美国的 4 核 4G,首单一块钱试用一天,就拿来测了测,结果比预想的好,最后把香港机上的服务整个搬了过去。
中间在 Xray 上卡了挺久:服务端是新版 Xray,手机上的客户端是 Mihomo 内核,一直连不上,前后是三个问题叠在一起。这篇按时间顺序把过程记下来。
文中服务器地址写成
<美国IP>、<香港IP>,UUID、公钥和各种密码一律省略。
先测性能
新机器是 KVM 虚拟化,4 核 Xeon Platinum 8269CY,4G 内存,50G 硬盘,带宽标的是 40Mbps,系统 Ubuntu 22.04。工具都从 Ubuntu 官方源装:sysbench 测 CPU 和内存,fio 测硬盘,mtr 看路由。
香港机上的 blog 用户没有 sudo,装不了 sysbench 和 fio,所以和香港机对比时,两边改跑同一套 openssl speed 和 dd:
| 项目 | 美国机(4 核 4G) | 香港机(2 核 2G) |
|---|---|---|
| CPU 单核(sha256) | 385 MB/s | 215 MB/s |
| CPU 全核 | 1501 MB/s(4 核正好 4 倍) | 235 MB/s(2 核只有 1.1 倍) |
| AES-256-GCM | 3.3 GB/s | 1.1 GB/s |
| 硬盘顺序写(dd direct) | 569 MB/s | 21 MB/s |
| 硬盘顺序读 | 1.7 GB/s | 82 MB/s |
| 4K 同步写 | 4.3 MB/s | 0.35 MB/s |
| 下载带宽 | 约 38 Mbps | 约 18 Mbps |
美国机用 fio 单独测过,4K 随机读写在 QD32 下都是 12 万 IOPS 左右。CPU 满载时 vmstat 里的 steal 一直是 0,没有被别人抢 CPU。
反倒是香港机,测的时候顺手看了下 vmstat,平时 steal 就有 15%~30%,也就是同一台母机上有别人在抢 CPU。2 个核只跑出 1.1 倍,一半是因为这个,一半是因为博客本身也在用 CPU。硬盘 21 MB/s 加上在用 swap,后端启动慢就说得通了。
回程线路
国内三家运营商出国都各有两张网,一张普通的、一张"精品"的:
| 运营商 | 普通网 | 精品网 | 路由里怎么认 |
|---|---|---|---|
| 电信 | 163(AS4134) | CN2 GIA(AS4809) | 普通 202.97.x.x,CN2 59.43.x.x |
| 联通 | 4837(AS4837) | 9929 / 10099 | 普通 219.158.x.x,精品 218.105.x.x |
| 移动 | CMI(AS58453) | CMIN2(AS58807) | 都可能是 223.120.x.x,只能看 AS 号 |
在服务器上用 mtr -n -z 追到国内的地址,-z 会顺带查每一跳的 AS 号:
for h in 202.96.209.133 210.22.97.1 120.196.165.24; do
echo "===== $h"; mtr -n -z -r -c 10 -w $h
done
三个地址分别是上海电信、上海联通、广州移动的 DNS。结果是:
- 电信:出了美国直接进
59.43.x.x,全程没见202.97,是 CN2 GIA,到上海 130ms; - 联通:走
218.105.x.x,AS9929,到上海约 170ms; - 移动:一路 AS58807,是 CMIN2,到广州 156ms。
三家回程都是精品网。代价是物理距离:从我在东京的电脑连过去是 183ms,连香港机只要 53ms。
香港机这边想做同样的对比,但它的服务商把到大陆的路由追踪整个屏蔽了,mtr 和 tracepath 一跳都不显示(追 1.1.1.1 正常),所以香港机的回程是什么线路到现在也不知道。
延迟高对博客的影响,我大致算了一下:打开一个 HTTPS 页面,TCP 握手、TLS 握手、发请求至少来回三趟,前台是 Vue 单页应用,还要再拉 JS、调接口、去图片域名重新握手,首屏大约来回 4 到 8 趟。电信用户单趟从 51ms 变成 130ms,首屏多等 0.3 到 0.6 秒,之后翻页每次只多 80ms 左右。对一个以阅读为主的博客,这个代价可以接受,何况服务器本身快了不少。
部署 Xray,和 Mihomo 的三处不兼容
性能确认后,先在新机器上装 Xray 当代理。下载官方 release 的 v26.10.21,对照 .dgst 文件核对 SHA-256,用 systemd 托管,以 nobody 运行,只给绑定端口的权限。协议选 VLESS + REALITY + Vision,不需要域名和证书。伪装目标随手写了 www.microsoft.com。
在 Mac 上用 Xray 官方客户端测,一次就通了。可手机上的客户端用的是 Mihomo 内核,怎么都是超时。
先看请求有没有到服务器
在服务器上抓包,手机发出的连接全都到了 443,但每进来一个连接,服务器就转手去连 microsoft 的服务器,Xray 日志里一条成功记录都没有。
这是 REALITY 的设计:客户端的认证对不上时,服务器不报错,而是把连接原样转给伪装目标,从外面看就像一个普通的 microsoft 网站。所以手机这边的表现是"连上了,但一直没响应",最后超时。
把 Xray 日志调到 debug,90 秒里手机来了 2234 个连接,每一个都是:
REALITY: processed invalid connection from ...: authentication failed or validation criteria not met
手机上的 UUID、公钥、Short ID 我逐字和服务器比对过,完全一致,抓包看 SNI 也是对的。参数既然没问题,就只能往版本上找原因了。
第一处:minClientVer 的默认值
这个问题前一晚帮朋友排查节点时遇到过。Xray 从 v26.7.11 开始,REALITY 配置里如果不写 minClientVer,会按 26.3.27 处理;而 Mihomo 握手时报的客户端版本是写死的 1.8.2,低于这个下限就被拒绝(mihomo#3039)。Mac 上的 Xray 客户端报的是自己的新版本号,所以能过。
解决办法是在服务端显式写上:
"realitySettings": {
"minClientVer": "1.0.0"
}
改完重启,手机还是连不上。
第二处:v26.9.8 起强制 ML-KEM
这次没再一轮轮试,先查资料。Xray 从 v26.9.8 起,要求客户端的 ClientHello 里必须带 X25519MLKEM768 这个抗量子密钥交换,不带就直接拒绝,和 minClientVer 写多少无关(mihomo#3193)。Mihomo 默认不带,要在客户端手动打开,而且只有 chrome 指纹支持:
client-fingerprint: chrome
reality-opts:
support-x25519mlkem768: true
Mihomo 那边把这个 issue 关成了"not planned",不会改默认值。前一晚朋友的服务器之所以只改 minClientVer 就好了,是因为它停在 v26.7.28,还没有这条要求。
我手机上的客户端加了这一行还是不行,可能是它内置的 Mihomo 版本不认这个字段。
降级,以及第三处:伪装目标的证书太大
v26.7.28 到 v26.10.21 之间一共 142 个提交,我挨个看了一遍标题。对只跑 VLESS + REALITY 入站、直连出站的服务端来说,有用的几乎只有 ML-KEM 这一条,其余大多是 TUN、WireGuard、MASQUE、Hysteria 等用不到的功能。26.10.21 标着"紧急安全更新",内容是印度有几张中间证书的私钥被破解,新版移除了相关根证书,但这只影响 Xray 作为客户端去验证证书的场景,服务端这样用基本不受影响。
所以把服务端降到 v26.7.28,保留 minClientVer: "1.0.0"。手机再连,日志变了:
REALITY: processed invalid connection from ...: handshake did not complete successfully
认证这次过了,卡在了握手。这是另一个已知问题:REALITY 对伪装目标的证书记录有 8192 字节的硬上限,www.microsoft.com 走 Akamai,有时返回的证书记录会超过这个上限,握手就失败(Xray-core#6356)。我在服务器上量的证书链是 5879 字节,没超,但不同节点返回的证书不一样,它本来就不是个稳定的选择。
换成证书链只有 2.5KB 左右的 www.cloudflare.com,手机终于连上了。
最后的配置
| 项目 | 值 |
|---|---|
| Xray 版本 | v26.7.28 |
| 伪装目标 | www.cloudflare.com |
minClientVer |
1.0.0 |
| 客户端 | 指纹 chrome;新版服务端还需要 support-x25519mlkem768: true |
回头看,第一处和第二处都是搜一下就能找到的已知问题,前面在服务器上反复试的那些时间,大部分本可以省掉。
顺手修了 DNS
调 Xray 日志时还看到大量 DNS 超时。系统里配的 DNS 是 8.8.8.8 和 8.8.6.6,后者用 UDP 和 TCP 查都没有任何响应。Google 的备用 DNS 是 8.8.4.4,这个 8.8.6.6 大概是商家模板里写错了。
配置源是 /etc/netplan/50-network.yaml,和香港机上的文件名一模一样,应该是同一套系统模板。处理方法和之前写的香港机 DNS 污染排查一样:netplan 里换成 1.1.1.1,再给 systemd-resolved 加一份严格模式的 DoT:
# /etc/systemd/resolved.conf.d/10-dot.conf
[Resolve]
DNS=1.1.1.1#cloudflare-dns.com 8.8.8.8#dns.google
DNSOverTLS=yes
LLMNR=no
MulticastDNS=no
改之前先确认了两家的 853 端口都能连、证书验证通过,否则严格模式会让 DNS 直接断掉。改完抓包,出口已经没有明文 53 的流量。
迁移
试用机续了一个季度,开始搬家。
要搬什么
香港机上实际在跑的有:博客的 8 个容器(mysql、redis、rabbitmq、minio、backend、nginx、certbot,加上做格式转换的 oarc-office),模型反代 antigravity-manager 和它的出站代理 mihomo,DSH 的搜索适配 dsh-search-adapter。几个停了好几周的旧容器、TeamSpeak 安装包和旧版本的发布目录都不带。
mihomo 当初是因为 Gemini 不支持香港地区才加的,美国本来就在支持范围内,所以迁移时把它去掉,antigravity 直接连 Google。要改两处:compose 里删掉 mihomo 服务和对它的依赖,gui_config.json 里把上游代理 http://mihomo:7890 关掉。只删容器不改配置的话,antigravity 会因为连不上 mihomo 而全部请求失败。
新机器上的准备
- 443 让给博客:Xray 原来占着 443,换到一个高位端口,安全组里放行这个端口,顺手删掉了用不到的 RDP 3389 和 UDP 22 规则。手机上的节点端口跟着改。
- 建同名用户:新建
blog用户,uid 和香港机一样是 1000。office 容器以1000:1000运行,发布脚本和工具里写死了/home/blog/...和ssh blog,用户和目录保持一致,这些都不用改。 - SSH:关掉密码登录,只允许密钥。
- 装 Docker(官方 apt 源,核对过 GPG 指纹)、开 2G swap。
传数据
第一次是用 Mac 当中转,香港机打包、经过 Mac、写到美国机,这样两台服务器之间不用互相授权。但这样数据要跨境走两趟,只有 6Mbps 左右,跑了 37 分钟才传了 1.7G。
后来改成直传:在美国机上生成一把临时密钥,加到香港机 blog 用户的 authorized_keys 里,限定只能从美国机的 IP 使用:
from="<美国IP>" ssh-ed25519 AAAA... migrate-us-to-hk
然后从美国机用 rsync 拉,已经传过的部分直接跳过。速度到了 1.2MB/s 左右,迁移结束后把这把密钥从两边都删掉了。
传的过程中还碰到几件事:
- antigravity 的 1.7G 是旧备份,920M 里有 896M 是请求日志库
proxy_logs.db。日志从头开始记就行,这两样都没迁,真正要的账号、token 和配置只有 27M。 - 镜像按 digest 拉。公共镜像在新机器上按香港机运行中容器的 digest 拉取,保证版本完全一致。MinIO 拉不下来,它的官方镜像已经不在 Docker Hub 上提供了,最后是从香港机上
docker save正在运行的镜像传过来的。顺带发现香港机的 compose 里 MinIO 写的是一个固定版本,实际跑的却是更早的latest,compose 改过以后容器一直没重建。 - 自建镜像用
docker save带过去。backend 和 office 都是在服务器上构建的,没有放在镜像仓库里。 - 一个 stdin 的坑。第一版同步脚本是
ssh 服务器 bash -s < sync.sh这样喂进去的,脚本里有一条 ssh 命令没加< /dev/null,把剩下的脚本内容当成自己的输入读走了,后面三步就都没执行,脚本还正常退出。后来改成先把脚本上传成文件再执行。
试运行
数据到齐后在美国机上把服务全部拉起来:MySQL 导入香港机的 dump,两边都是 32 张表、10 篇文章;Redis 复制 dump.rdb,两边都是 6 个键。backend 17 秒就启动完成了,在香港机上要一分钟左右。
验证时我在 Mac 上用 curl --resolve 把几个域名临时指到美国机,逐个请求首页、文章页、接口、sitemap、RSS、后台、图片和 api 子域名,和香港机的结果逐项对比,状态码和字节数都一样。
后来才发现这个对比不可靠。Mac 上的 Clash 开着 TUN 和流量嗅探,它会从 TLS 握手里读出 SNI,按域名重新解析,把本该发往美国机的 HTTPS 请求送回了香港机,所以那次测的其实是香港机。最后是登到香港机上,从那里用 --resolve 测美国机,并在美国机的 nginx 日志里确认请求确实到了,才算真正验证过。
antigravity 直连 Google 后,42 个模型都在,对话和 DSH 搜索都正常。试完先把它停掉,免得两台机器同时用同一批账号。
正式切换
切换前要把 DNS 记录的 TTL 调低。TTL 是 DNS 结果的缓存时间:TTL 是 600 秒,改完记录后有人最多还要 10 分钟才会去查新 IP;调到 300,最多等 5 分钟。而且 TTL 本身也会被缓存,要提前调,切换当天才改是来不及的。我的记录早就是 300,省了这一步。
切换过程:
- 停掉香港机的 backend,确保数据库不再有写入;
- MinIO 增量同步,MySQL 重新导出导入,Redis 重新复制,antigravity 再同步一次账号(排除改过的两个配置文件和日志库);
- 美国机启动 backend、antigravity 和 certbot;
- 把
@、www、admin、img、api五条 A 记录改成<美国IP>。
Cloudflare、Google、阿里三家公共 DNS 很快都解析到了新地址。从停掉香港机 backend 到 DNS 生效,停机大约 10 分钟。香港机上的 backend 和 antigravity 停着,数据都没动,先留一两周作为回滚。
迁完以后的两处修改
nginx 动态解析
切换时出过一次 502:我重启了 backend,它的容器 IP 变了,而 nginx 配置里写的是 proxy_pass http://backend:8080,启动时解析一次就一直用那个 IP。香港机上其实一直是这样,每次发布完都要 reload 一下 nginx。
nginx 开源版从 1.27.3 起支持在 upstream 里加 resolve,会按 resolver 的设置定期重新解析:
resolver 127.0.0.11 valid=10s ipv6=off;
upstream aurora_backend { zone aurora_backend 64k; server backend:8080 resolve; }
upstream aurora_minio { zone aurora_minio 64k; server minio:9000 resolve; }
upstream oarc_office { zone oarc_office 64k; server oarc-office:8094 resolve; }
127.0.0.11 是 Docker 内置的 DNS。proxy_pass 里只把主机名换成 upstream 名,后面的路径不变。如果改用 set $变量 再 proxy_pass http://$变量/... 的写法,nginx 处理路径的方式会变,还得一个个 location 去核对。改成 upstream 名以后,后端收到的默认 Host 头会变,好在这几个 location 原本都写了 proxy_set_header Host $host,不受影响。
验证时先起一个占位容器占住 backend 原来的 IP,逼它重建后换到新 IP(172.21.0.6 → 172.21.0.9),不 reload nginx,接口、RSS、文章页都正常返回。
backend 镜像从 2.19GB 降到 746MB
传镜像时注意到 backend 镜像有 2.19GB。用 docker history 一看:
84.6MB COPY backend.jar /app/app.jar ← 现在用的
84.4MB COPY backend.jar /app/app.jar ← 之前的版本
... (一共 9 层 jar)
244MB Java 8 运行环境
167MB 基础系统
原因在发布脚本:每次发布都生成一个 FROM <线上镜像> 再 COPY backend.jar 的 Dockerfile。新 jar 会覆盖旧 jar,但旧 jar 还留在下面的层里,镜像每发一版就大 85 到 110MB。
当初用的 amazoncorretto:8 已经更新到了 8u512,为了保持 Java 版本不变,改用 amazoncorretto:8u504 并固定 digest,加上线上同一个 jar(SHA-256 核对过)重新构建,镜像降到 746MB,启动 14 秒,内存 341MB。没有降到我原先估计的 500MB,是因为基础镜像带的是完整 JDK,没换成 JRE。仓库里的 Dockerfile 和发布模板也改成了从这个固定的基础镜像构建,以后不会再叠层。
对比之下,做格式转换的 office 镜像 1.23GB 是合理的,其中 605MB 是 LibreOffice。
剩下的问题
迁完以后发现,Mac 开着代理就打不开自己的博客。我的猜测是:代理走的正是美国机上的 Xray 节点,请求从美国机出来,再去访问美国机自己的公网 IP。而这台机器网卡上只有内网地址,公网 IP 是云厂商在外面做的 1:1 NAT,很多云厂商的 NAT 不支持这种回环访问。迁移之前博客在香港机上,不会碰到这个问题。这个原因我没有验证,最后的处理是在 Clash 规则里让博客的域名走直连。
另外还有几件没做完的事:
- 香港机先留着,确认稳定以后再释放;
- 几处复用的密码和 antigravity 的 API key 要换掉;
- 仓库里的 nginx 配置一直落后于服务器,这次的改动也还没同步回去。