测了一台美国 VPS,把博客从香港搬了过去

· 网安 · #服务器, #折腾, #Xray

博客原来一直跑在一台 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。结果是:

三家回程都是精品网。代价是物理距离:从我在东京的电脑连过去是 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 而全部请求失败。

新机器上的准备

传数据

第一次是用 Mac 当中转,香港机打包、经过 Mac、写到美国机,这样两台服务器之间不用互相授权。但这样数据要跨境走两趟,只有 6Mbps 左右,跑了 37 分钟才传了 1.7G。

后来改成直传:在美国机上生成一把临时密钥,加到香港机 blog 用户的 authorized_keys 里,限定只能从美国机的 IP 使用:

from="<美国IP>" ssh-ed25519 AAAA... migrate-us-to-hk

然后从美国机用 rsync 拉,已经传过的部分直接跳过。速度到了 1.2MB/s 左右,迁移结束后把这把密钥从两边都删掉了。

传的过程中还碰到几件事:

试运行

数据到齐后在美国机上把服务全部拉起来: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,省了这一步。

切换过程:

  1. 停掉香港机的 backend,确保数据库不再有写入;
  2. MinIO 增量同步,MySQL 重新导出导入,Redis 重新复制,antigravity 再同步一次账号(排除改过的两个配置文件和日志库);
  3. 美国机启动 backend、antigravity 和 certbot;
  4. 把 @、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 规则里让博客的域名走直连。

另外还有几件没做完的事:

← 全部文章