一家托管了三百万用户工作负载的平台,被自家云厂商的自动化系统一键拔线——没有预警,没有人工确认,连邮件通知都是事后才到。这就是 Railway 在 Google Cloud 上遭遇的真实事故:GCP 的自动化风控系统直接冻结了 Railway 的生产账号,导致整个平台瘫痪八小时,而受影响的不仅是 GCP 上的工作负载,连部署在 AWS 和裸金属服务器上的用户也一起被拖下水。
原因很简单:Railway 的控制面(control plane)全部跑在 GCP 上。控制面一死,不管你的工作负载在哪个提供商,调度、路由、监控全部失效。
事故链条:自动化风控如何击穿一个平台
根据 Railway 的公开说明,事故链条大致如下:
- GCP 自动化系统触发账号冻结——具体触发原因未完全公开,但属于 Google Cloud 侧的自动风控动作,无需人工审批即可执行。
- 控制面瞬间不可用——Railway 的 API、调度器、数据库等核心服务全部托管在 GCP,账号冻结意味着这些资源立即被切断。
- 跨提供商级联失效——用户在 AWS 和裸金属上运行的工作负载虽然计算资源还在,但失去了与控制面的连接:新部署无法下发、路由规则无法更新、健康检查无法上报。
- 恢复耗时八小时——与 Google Cloud 的沟通和账号解冻流程远非即时完成,平台级恢复需要逐项验证控制面组件状态。
核心教训不是"云厂商会误杀账号"——这几乎每个大规模云用户都经历过。真正的教训是:控制面与数据面的提供商耦合,让一个提供商的行政操作能击穿整个平台。
控制面为什么成了最大单点
在平台架构中,控制面负责:
- 工作负载的调度与生命周期管理
- 网络路由与流量分发规则的下发
- 用户 API 与认证鉴权
- 监控数据汇聚与告警
数据面(工作负载实际运行的节点)可以分布在多个云和边缘位置,但如果控制面只在一个云上,数据面的多提供商分布就形同虚设。Railway 的架构正是这种模式:
┌─────────────────────────────────┐
│ GCP (控制面) │
│ API / Scheduler / DB / Router │
└──────────────┬──────────────────┘
│ 单一依赖点
┌───────┴────────┐
│ │
┌──────┴──────┐ ┌──────┴──────┐ ┌──────────┐
│ AWS 数据面 │ │ GCP 数据面 │ │ 裸金属 │
│ (工作负载) │ │ (工作负载) │ │ (工作负载) │
└─────────────┘ └─────────────┘ └──────────┘
GCP 账号冻结 → 控制面消失 → 所有数据面失联。AWS 和裸金属上的容器还在跑,但平台已经无法管理它们。
Railway 的应对:降级 GCP 为备用
Railway 在事故后宣布将 GCP 从主要提供商降级为仅备用状态,控制面将迁移到其他基础设施上。这是一个合理的短期决策,但更根本的问题是:如何让控制面本身不再成为单点?
构建多活控制面:可落地的架构与配置
下面给出一个简化但可改造的多活控制面方案,核心思路是控制面跨提供商部署,通过 DNS 权重 + 健康检查实现自动故障切换。
第一步:控制面核心服务跨提供商部署
用 Terraform 在两个云提供商上各部署一套控制面(以 Kubernetes Deployment 为例):
# control-plane-deployment.yaml
# 在 Provider-A 和 Provider-B 的集群上各部署一份
apiVersion: apps/v1
kind: Deployment
metadata:
name: railway-api-server
namespace: control-plane
spec:
replicas: 3
selector:
matchLabels:
app: railway-api
template:
metadata:
labels:
app: railway-api
cluster: multi-active # 标记为多活控制面
spec:
containers:
- name: api
image: railway/api-server:v2.1.0
ports:
- containerPort: 8080
env:
- name: CLUSTER_ID
valueFrom:
fieldRef:
fieldPath: metadata.name # 区分不同提供商上的实例
- name: DB_ENDPOINT
value: "db-primary.control-plane.internal" # 跨云可达的数据库
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 3
第二步:DNS 权重路由 + 自动故障切换
用 Cloudflare(或其他支持权重路由的 DNS)配置多活入口:
# 用 Cloudflare API 设置权重路由
# Provider-A 权重 70%,Provider-B 权重 30%(日常流量分配)
# 主记录
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"type": "CNAME",
"name": "api.railway.app",
"content": "cp-a.railway-infra.com",
"proxied": true,
"priority": 10,
"weight": 70
}'
# 备记录
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{
"type": "CNAME",
"name": "api.railway.app",
"content": "cp-b.railway-infra.com",
"proxied": true,
"priority": 10,
"weight": 30
}'
第三步:健康检查驱动的自动切换脚本
#!/usr/bin/env bash
# failover-watchdog.sh — 每分钟检查控制面健康,自动调整 DNS 权重
# 部署在独立于两个提供商的第三位置(如裸金属或边缘节点)
set -euo pipefail
CF_API_TOKEN="${CF_API_TOKEN:?请设置 Cloudflare API Token}"
ZONE_ID="${ZONE_ID:?请设置 Zone ID}"
PRIMARY_ENDPOINT="https://cp-a.railway-infra.com/healthz"
FAILBACK_ENDPOINT="https://cp-b.railway-infra.com/healthz"
LOG_TAG="[failover-watchdog]"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) ${LOG_TAG} $*"; }
check_health() {
local url="$1"
curl -sf -m 5 "$url" >/dev/null 2>&1
}
current_state="healthy" # 初始状态
while true; do
primary_ok=$(check_health "$PRIMARY_ENDPOINT" && echo "yes" || echo "no")
failback_ok=$(check_health "$FAILBACK_ENDPOINT" && echo "yes" || echo "no")
if [[ "$primary_ok" == "no" && "$current_state" == "healthy" ]]; then
log "主控制面不可用,切换流量到备用"
# 调用 Cloudflare API 将权重改为 primary=0, failback=100
# (此处省略具体 API 调用,逻辑同第二步,仅改 weight 值)
current_state="failback"
elif [[ "$primary_ok" == "yes" && "$current_state" == "failback" ]]; then
log "主控制面恢复,逐步回切流量"
# 先设 primary=30, failback=70,观察 5 分钟后再完全回切
current_state="recovering"
elif [[ "$primary_ok" == "yes" && "$current_state" == "recovering" ]]; then
log "回切稳定,恢复日常权重 70/30"
current_state="healthy"
elif [[ "$primary_ok" == "no" && "$failback_ok" == "no" ]]; then
log "⚠️ 双控制面均不可用,触发全平台告警"
# 发送 PagerDuty / Slack 告警
fi
sleep 60
done
运行前需要修改的变量:CF_API_TOKEN、ZONE_ID、两个健康检查端点 URL。脚本本身应部署在第三个独立位置——如果也放在某个云上,就又回到了单点依赖的老路。
第四步:数据库层的跨云同步
控制面的数据库同样不能绑定单一提供商。一个务实方案是用 PostgreSQL 的逻辑复制在两个提供商间保持数据同步:
# 在 Provider-A 的主库上配置逻辑复制发布
psql -U postgres -d railway_control -c "
CREATE PUBLICATION control_plane_pub FOR ALL TABLES;
"
# 在 Provider-B 的备库上配置订阅
psql -U postgres -d railway_control -c "
CREATE SUBSCRIPTION control_plane_sub
CONNECTION 'host=pg-primary.cp-a.internal port=5432 dbname=railway_control user=replicator password=REPLICATOR_PASSWORD'
PUBLICATION control_plane_pub;
"
注意:跨云复制的网络延迟需要实测。如果两个提供商间延迟超过 50ms,考虑用异步复制 + 应用层冲突解决,而非强同步。
决策清单:评估你自己的控制面风险
事故复盘之后,每个平台团队都该问自己这几个问题:
| 检查项 | 风险等级 | 你的现状 |
|---|---|---|
| 控制面是否只在一个云提供商上? | 🔴 高 | — |
| 云厂商的账号冻结是否会导致控制面立即不可用? | 🔴 高 | — |
| 数据面是否依赖控制面才能保持运行(而非仅依赖配置下发)? | 🟡 中 | — |
| 是否有跨提供商的 DNS 权重路由与健康检查切换? | 🟡 中 | — |
| 故障切换脚本是否部署在独立于主备提供商的位置? | 🟡 中 | — |
| 是否与云厂商签订了账号操作前需人工确认的协议? | 🟢 低 | — |
最后一项值得单独说:大型云厂商的风控自动化是他们的内部决策,客户通常无法要求"冻结前必须通知我"。但你可以通过架构设计让这种冻结不再具有平台级杀伤力——这正是多活控制面的意义。
写在最后
Railway 的八小时宕机不是一次"云厂商不可靠"的故事,而是"平台架构对单一提供商的行政操作没有防御纵深"的故事。把 GCP 降级为备用是 Railway 的即时止血,但长期解法是控制面本身的多活与跨提供商冗余。
如果你的团队正在构建类似平台,现在就画出控制面的依赖图——如果那个图上所有核心节点都指向同一个云账号,你离一次八小时宕机可能只差一次自动化风控触发。