一套 API 即使部署在多个 AWS 区域,也不等于已经具备全局故障转移能力。连续发生的区域故障迫使团队重新检查请求链路,最终发现阻碍并不在主业务 API,而是藏在客户端会话初始化阶段:每次会话都会先发起一次预检发现请求,再根据返回结果访问真正的 API。
这个调用源于多年前的技术限制,当时它可能是唯一可行的接入方式。多年后,服务端已经演进,客户端却继续携带这段历史逻辑。结果是,业务请求尚未发出,客户端就可能被某个区域的发现服务卡住。
多区域部署为何仍会被单点请求拖住
旧客户端的逻辑通常可以抽象为:
Client
|
| 1. GET /discover
v
Discovery service in Region A
|
| 2. Return regional API endpoint
v
API in Region A or Region B
表面上,第二步返回的地址可以指向不同区域,似乎已经支持多区域。但真正决定会话能否建立的,是第一步发现请求。只要它固定依赖某个区域,系统就仍然存在一个隐藏的区域级单点。
这类设计还会引入几个容易被低估的问题:
- 每个新会话至少多一次网络往返,增加连接建立时间。
- 发现接口故障时,健康的业务区域也无法接收新会话。
- 客户端重试可能放大发现服务的流量,形成重试风暴。
- 故障转移演练如果只测试业务 API,往往无法暴露这条前置依赖。
- DNS、身份认证、令牌签发或配置读取如果绑定原区域,移除发现调用后仍可能留下其他隐性耦合。
因此,问题不只是“减少一次 HTTP 请求”,而是重新定义客户端如何找到服务,以及故障时由哪一层做路由决策。
把区域选择移出客户端会话
更稳妥的目标是让客户端直接访问一个稳定的全局入口,由入口层把请求送往健康区域:
Client
|
| Single API request
v
Global API endpoint
|-------------------|
v v
Region A API Region B API
全局入口可以通过团队现有的 AWS 网络与路由能力实现,但具体选择需要结合协议、连接持续时间和恢复目标。短连接 HTTP API、长连接、私有网络流量以及要求固定源地址的系统,适用方案并不相同。
迁移时还要明确几个契约:
- 客户端是否允许显式覆盖入口地址。
- 认证令牌能否跨区域验证。
- 请求是否幂等,失败后能否切换区域重试。
- 各区域的数据是否已经复制到足以承接流量的程度。
- DNS 缓存和连接池会把旧区域保留多久。
如果这些问题没有答案,删除发现调用只会把故障点推到链路的下一层。
可以这样实践:显式端点与兼容回退
下面是一个可直接运行的 Python 示例。它使用标准库模拟两种客户端路径:新客户端优先读取稳定入口 API_ENDPOINT;只有在迁移期明确启用兼容模式时,才调用旧发现接口。
将 API_ENDPOINT 改成实际的全局 API 地址。迁移完成后,应删除 ALLOW_LEGACY_DISCOVERY 分支,而不是永久保留双路径。
#!/usr/bin/env python3
import json
import os
import sys
import urllib.request
def get_json(url: str, timeout: float = 3.0) -> dict:
request = urllib.request.Request(
url,
headers={"Accept": "application/json"},
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.load(response)
def resolve_endpoint() -> str:
endpoint = os.getenv("API_ENDPOINT")
if endpoint:
return endpoint.rstrip("/")
if os.getenv("ALLOW_LEGACY_DISCOVERY") == "1":
discovery_url = os.environ["DISCOVERY_URL"]
result = get_json(discovery_url)
return result["api_endpoint"].rstrip("/")
raise RuntimeError(
"API_ENDPOINT is required; legacy discovery is disabled"
)
def main() -> int:
endpoint = resolve_endpoint()
result = get_json(f"{endpoint}/health")
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"request failed: {exc}", file=sys.stderr)
raise SystemExit(1)
运行方式:
export API_ENDPOINT="https://api.example.com"
python3 client.py
迁移期间,旧环境可以临时使用兼容模式:
unset API_ENDPOINT
export ALLOW_LEGACY_DISCOVERY=1
export DISCOVERY_URL="https://discovery.example.com/discover"
python3 client.py
除了验证功能,还应分别测量旧路径和新路径。下面的命令会输出 DNS、TCP、TLS、首字节和总耗时,可用于确认隐藏往返是否真的消失:
curl --silent --show-error --output /dev/null \
--write-out 'dns=%{time_namelookup}s connect=%{time_connect}s tls=%{time_appconnect}s ttfb=%{time_starttransfer}s total=%{time_total}s\n' \
https://api.example.com/health
生产监控中还应按客户端版本、入口地址、目标区域和失败阶段拆分指标。只看 API 的整体成功率,很容易把“发现失败”误判成客户端没有发起业务请求。
真正昂贵的是发布过程
移除一行发现调用并不代表迁移完成。成本主要来自兼容性、可观测性和逐步放量:旧客户端可能长期存在;不同版本可能采用不同缓存策略;移动端或嵌入式客户端也未必能立即升级。
一个风险较低的发布顺序可以是:
- 为客户端增加显式全局端点,同时保留受开关控制的旧路径。
- 记录两条路径的调用量、延迟、错误类型和客户端版本。
- 让内部环境和少量流量直接访问全局入口。
- 扩大比例,并进行区域隔离、DNS 异常和连接复用测试。
- 将旧发现路径关闭,但保留短期告警和紧急回退能力。
- 确认旧版本占比降到可接受水平后,删除发现服务和兼容代码。
双路径阶段本身也有风险。若回退策略过于积极,客户端可能在全局入口短暂变慢时重新打向旧区域,反而恢复已经准备移除的依赖。因此,回退必须有明确期限、指标和退出条件。
上线前检查清单
多区域架构需要按完整请求链路验收,而不是按资源清单验收。上线前至少检查:
- 新会话是否能在发现服务不可用时正常建立。
- 全局入口是否只把流量发往真正可用且数据就绪的区域。
- 身份认证、密钥、配置和依赖服务是否支持跨区域处理。
- 客户端重试是否有次数上限、退避和抖动。
- 非幂等请求是否携带幂等键,避免切换区域后重复执行。
- 长连接、DNS 缓存和连接池是否会延迟故障转移。
- 仪表盘能否区分入口故障、区域故障和客户端版本问题。
- 旧发现接口是否有明确的退役日期和负责人。
这次改造揭示的核心教训是:多区域能力取决于请求抵达业务服务之前经过的每一个同步依赖。一个多年前合理的预检调用,也可能在今天成为全局恢复链路上最脆弱的一环。