Sanvi

5 分钟

把 StudyThai 搬到新加坡:一次跨区基础设施迁移的工程笔记

我没追求"零停机切换"。给自己留了 5–10 分钟的维护窗口,换来一套极简的回滚路径——出问题就改一行 DNS 改回去。技术上没什么花活,但有几个我事先没料到的坑,记下来给自己也给路过的同行。

一、为什么搬到新加坡

迁移前在 PostHog 上拉了一遍 30 天用户分布:Web 端 MAU 里中国大陆占 54%、泰国 13%,加上港台、新马日菲这些亚洲地区,东亚 + 东南亚合计 80%+,美洲 + 欧洲加起来不到 10%。Mobile 端泰国占比更高(19%)——东南亚是典型的 mobile-first 市场。服务器应该放在亚太,不该放在原来那个北美机房。

一个差点把我带偏的坑:GSC 撒谎

迁移之前我差点用 Google Search Console 判断用户分布。GSC 显示中国大陆只有 3%——按这数据应该把服务器放泰国或台湾。但 PostHog 显示中国大陆 54%18 倍差距

根因是 Google 在中国大陆受限,GSC 只看到 Google 触达的渠道,完全错过通过小红书 / 公众号 / 应用商店进来的大陆用户。如果按 GSC 决策,我会把服务器放错地方。

这件事让我后来在所有"看用户在哪"的问题上都条件反射先看 PostHog。SEO 工具有它的用,但不是看人在哪——它只看到能搜到你的人。

另一个推动力:3 月的 P0 事故

旧服务器是 RackNerd 在 Seattle 的一台 135GB 小机器,编译、应用、数据库全挤在上面。3 月某天 Coolify 累积的旧 docker 镜像把磁盘撑到 100%,PostgreSQL 写不进 WAL 进入崩溃循环,全站不可用约 15 分钟。这次事故让我决心顺手把整套架构也重做掉——不能再用"一台机器搞定一切 + 廉价 VPS + 小磁盘"这套单点组合。

二、我怎么挑的供应商

调研路径很快——按"用户在亚太 + 月成本可控 + 控制权完全自主"三个硬条件排除。

大厂云(AWS / GCP / Azure):价格贵几倍,控制面对小项目太复杂,出口费惊人。排除。

PaaS(Vercel / Heroku / Railway):之前用过 Vercel,问题是 Next.js server components 还是要回源 us-east,亚太用户依然要绕地球;按用量计费在用户量起来后也比 VPS 贵。排除。

自建 VPS + Coolify:Coolify 是开源的 Heroku/Railway 替代品,自己装在 VPS 上,提供「GitHub push → 自动编译部署 → SSL 续期 → 域名绑」这套体验。比 k8s 简单、比 Docker Compose 顺手,且原生支持"构建服务器"概念——这是后面混搭架构的关键前提。

两台机器分两家:Linode + Contabo

确定 Coolify 之后,我做了个跟大多数教程不一样的决定:应用机和构建机用两家不同的 VPS 商

应用机选 Linode 新加坡。直接面对用户,愿意为稳定性溢价。Linode 在网络、控制面、磁盘 IO 这些"基础设施品质"上明显比廉价 VPS 商稳,新加坡机房到中国大陆和东南亚都很快。

构建机选 Contabo 新加坡。这台只在我推代码时才忙起来。Contabo 同价位 CPU / 内存 / 磁盘比 Linode 多 50%+,便宜得离谱。代价是网络偶尔抖、控制面没 Linode 流畅——但这些对一个只负责编译的机器完全无所谓,最多某次部署慢点。

逻辑很简单:两台机器对稳定性的要求差很多,没必要按一档买单。构建机挂了下一次部署等会儿就行,应用机挂了所有用户直接看错误页。同样的钱分开买不同档机器,组合产出比全砸一档高。

构建机编译完镜像推 ghcr.io(GitHub 镜像仓库),应用机从 ghcr.io 拉镜像跑——两台机器只通过镜像耦合,谁挂了都不影响对方。

一个埋伏好久的坑:Coolify 默认模板会在编译阶段就跑 prisma migrate deploy,意味着编译机器必须能联通生产数据库。这在编译 / 应用分离的架构里不是个稳健假设,详见后面"坑"那一节。


三、文件存储和语音服务怎么搬

数据库 pg_dump 就完事,但 Cloudflare R2 没有"跨区搬家"工具。我第一反应是"切换那天临时同步一遍",很快否决了——同步过程线上还在写老桶,新老数据对不上;切完域名才发现没同步全,回滚还要反向同步。双向同步是个噩梦

后来用的是影子 bucket + 预同步 + 当天换链接

  • 切换前 7 天:在 APAC 区域建新桶 cdn-apac,但不绑域名(不接流量);跑 Cloudflare Super Slurper 把老桶数据全量复制过来

  • 切换当天:Super Slurper 再跑一次增量同步补差;把 media.studythai.ai 域名从老桶切到新桶;改一行环境变量 R2_BUCKET_NAME=cdn-apac,重启应用

这套打法的核心好处是新桶不绑域名期间完全不接流量,万一同步出问题、数据对不上,整个新桶都可以扔掉重来,老桶不受影响。回滚也只是反向切域名 + 改环境变量,老桶数据从来没被动过。

同样的思路也用在 Azure Speech 上——提前在 southeastasia region 建好影子 endpoint,跑通本地连通性测试,切换当天只换 4 个环境变量 + 重启。资源都是提前备好的,切换那一刻没有任何"现造现切"的动作


四、cutover 那 5 分钟,我怎么挡住用户

切换那一刻老服务器要停服务、新服务器还没接管,这段空档大概 5 分钟。我不希望用户看到 Cloudflare 522 错误页,那看起来像被攻击。

最直觉的做法是在 origin nginx 配维护页。但 cutover 那一刻 origin 自己就不可达——维护页必须跑在 origin 之外。我用了 Cloudflare Worker,绑到 studythai.ai 所有路径,读一个叫 MAINTENANCE_MODE 的 secret。Secret 是 true 时拦截流量返回维护页,false 时透传到 origin。开关就是 wrangler secret put MAINTENANCE_MODE,几秒钟全球生效,不需要 redeploy 任何东西。

一个 mobile-aware 的细节:API 路径必须回 JSON

第一版我所有路径都返回 HTML 维护页,结果 mobile app 拿到 HTML 想按 JSON 解析直接炸。改成按路径分流:

  • HTML 路径 → 503 + 品牌化维护页 HTML

  • /api/* → 503 + JSON(application/problem+json 格式,对齐 mobile fetch client 的 error schema)

  • /api/health/api/app/config/api/app/version透传到 origin

最后一条特别重要:mobile app 冷启动会拉 config 和 version 做版本检查,如果被挡掉用户看到的是"无法连接服务器"白屏,比"维护中"提示糟糕得多。

一个意外收获:维护页比 cutover 用得还多

后来生产那次 15 分钟 522 事故,我能一行命令把用户体验从"CF 522"切换到"品牌维护中"。这是这次迁移最大的副产品:所有放在 edge 的应急资源都比放在 origin 的更鲁棒。下次设计应急方案,我会先问"这东西能不能放 edge"。

五、没料到的两个坑

云厂商的"看不见的防火墙"

迁移上线没多久某天,监控突然炸——CF 报错 522,全站不可达。我赶紧 SSH 上去查。

奇怪的事来了:SSH 22 通、ping 通,但 80/443 timeout。容器都健康、反向代理在监听、ufw inactive、iptables INPUT 默认 ACCEPT。在服务器里翻了 20 分钟越翻越懵——所有该是绿灯的全是绿灯。

后来是"挑端口堵"这个现象点醒我的。SSH 通但 80/443 不通——机器内部根本做不出这种规律的封锁,要么全通要么全不通。这种事只可能发生在机器之外的层。我立刻去 Linode 控制面板,果然 Cloud Firewall 那一栏配置和绑定状态对不上——这层防火墙跑在 hypervisor 上,VM 完全感知不到。修好之后立刻恢复。

事后想,这个坑我之前其实知道——AWS Security Group、GCP Firewall、Hetzner Cloud Firewall 都是同名同性质的产品。但 panic 模式下第一反应就是钻进服务器翻配置,下意识忘了 VM 外面还有一层。下次再看到"机器内部一切正常 + 外部特定端口不通",我会先抬头看云控制面板。

数据库迁移命令卡在编译阶段

新构建机第一次 deploy,prisma migrate deploy 在 docker build 阶段直接 5 秒整超时连不上数据库。psql 同样的连接串能连,最小化连通性测试也通——但 Prisma 在编译上下文里就是不行。最后定位到 Coolify 注入连接串的链路上有字符转义 / 末尾空白导致"看着一样实际不一样"。

这个坑其实一直都在,老机器跑得好是因为编译和数据库在同一台 VM 上,连通性是天然的。换到分离架构后这个"假设默契"立刻破了。后来我意识到把数据库迁移塞在镜像编译阶段本身就是设计错误——编译动作不该依赖运行时资源,应该可以在一台没有任何外网的 CI 机器上跑通。迁移没造出这个 bug,是迁移把它暴露了出来。

收尾

迁完几天后回头看,真正花时间的不是切换那 5 分钟,是前面那 7 天反复造影子资源、对 secret 算 md5sum、调 DNS TTL 的准备工作。基础设施迁移 10% 是技术、90% 是把每个不可逆步骤拆成可逆的、把每个假设变成可验证的

这次拿走最值钱的不是 Coolify 也不是 R2,而是「影子资源 + 预同步 + 当天只换链接」这个习惯——它把"迁移"从一次紧张的现场操作,变成一个可以反复演练的小开关。下次再做跨区、跨账号、跨厂商迁移,这套思路都还能用。