Anycast 让一个地址拥有多个家。多个站点宣告同一个前缀,每个网络按照路由策略选出一条可用路径。本文关注互联网 BGP;本地 Anycast 也可以使用内部路由协议。客户端通常无需先经过全球负载均衡就能到达拓扑上较近的副本。某个站点撤回宣告后,控制面可以收敛到另一个家。这种设计把“就近”和“切换”交给网络完成,但也把策略错误和收敛窗口带到了应用面前。

一个地址,多点宣告

Anycast 把同一个单播地址或前缀配置给相互独立的实例,每个边缘路由器用 BGP 宣告可达性。邻居比较路径,在自己的路由信息库中安装最佳路由,数据包随后沿普通转发路径走向选定的下一跳。应用看到的仍是一个目的地址,实际上却有许多站点可以回答。客户端不需要知道副本列表,运维也可以独立扩容某个站点。

“最近”指路由策略和拓扑意义上的最近,并不保证地理距离最短。本地偏好较高的路径可以胜过物理上更短的路径;本地偏好相同时,通常再偏向 AS path 更短的路径,之后还有厂商和策略相关的决胜条件。运营者可以调整这些属性、做 AS path prepend 或撤回前缀来引流。真正的契约是可达性,而不是承诺每个客户端永远选择同一站点。不同 ISP 看到的路径不同,所以同一时刻两个用户落在不同城市并不矛盾。

路由完成故障切换

站点失去服务或上游时,应当由健康联动的会话撤回路由。上游传播撤回消息,选择仍在宣告的副本。转发路径无需等待新的 DNS 应答,因此整个边缘站点消失后地址仍然有效。BGP 收敛不是瞬时完成的:切换期间可能丢包,不同网络的收敛时间也不同,客户端仍要设置重试和合理的连接超时。把“最终会切过去”写进设计,并不等于承诺零丢包。

Anycast 特别适合无状态或容易重建的协议,例如 DNS、基于 UDP 的发现服务和 CDN 边缘。它也能把攻击面分散到多个站点,但容量和过滤策略必须协同。进程已经死了却仍在宣告的路由,比没有路由更糟,因为流量会被送进黑洞。应把宣告绑定到真实的就绪信号,同时监控控制面可达性和应用响应。撤回机制要有明确的延迟上限,不能只依赖人工登录路由器。

有状态会话会介怀

路由变化后,已经建立的 TCP 连接可能被送往另一个站点。新实例没有旧套接字状态,可能重置连接或丢弃报文。共享后端能保留应用状态,却不能把原来的 TCP 连接自动搬过去,因此客户端仍要接受重连并重建状态;幂等键可以防止重连后重发请求产生重复副作用。一次成功的跟踪并不等于具有粘性;拓扑变化不仅会改变下一条流的选择,也可能让当前流的后续报文到达另一个副本。

可观测性还应记录服务站点、路由年龄和撤回原因。一个全局成功率会掩盖某个区域的黑洞,因为其他区域仍然健康。应从多个网络探测,把 Anycast 地址和站点身份分开记录,并在“站点仍在宣告但服务检查失败”时告警。日志中保留连接建立时的站点,才能解释切换前后的重试和状态差异。

坏掉的样子

路由泄漏或误设的高本地偏好,会把流量吸引到很远的站点;只撤回一部分邻居,则会让一些网络停留在旧路径,表现为不对称丢包而不是干净切换。副本之间的 MTU、防火墙和 ACL 差异,也会制造看似随机的路由故障。服务带本地状态时,用户可能只在控制面已经收敛后才看到连接重置。把一个站点标记为健康,却没有检查回程路径,也会制造只进不出的黑洞。

站点的进程、依赖和回程路径都准备好之前,不要宣告前缀。用专门的演练地址测试撤回,从多个观测点测量收敛,并保留一条非 Anycast 的管理路径。检查路由对象、应用探针和撤回日志的时间线,确认它们描述的是同一次事件。路由是选择机制,只有显式绑定健康检查时才具备应用层健康含义。

快速验证

下面的本地 Go 模型只保留演示所需的两个 BGP 策略字段:本地偏好高者优先,其次 AS path 短者优先。它测试路径决胜、输入顺序无关、策略覆盖、撤回、空候选集和备用站点恢复;没有数据包或 BGP 会话离开进程。

package main

import "fmt"

type route struct {
	name       string
	localPref  int
	asPath     int
	advertised bool
}

// This fixture models only local preference and AS-path length for one prefix.
func best(routes []route) (route, bool) {
	var chosen route
	for _, candidate := range routes {
		if !candidate.advertised {
			continue
		}
		if chosen.name == "" || candidate.localPref > chosen.localPref ||
			(candidate.localPref == chosen.localPref && candidate.asPath < chosen.asPath) {
			chosen = candidate
		}
	}
	return chosen, chosen.name != ""
}

func want(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	routes := []route{
		{name: "tokyo", localPref: 100, asPath: 2, advertised: true},
		{name: "singapore", localPref: 100, asPath: 3, advertised: true},
	}
	chosen, ok := best(routes)
	want(ok && chosen.name == "tokyo", "shorter AS path should win the tie")
	chosen, ok = best([]route{routes[1], routes[0]})
	want(ok && chosen.name == "tokyo", "input order must not beat path length")
	routes[1].localPref = 200
	chosen, ok = best(routes)
	want(ok && chosen.name == "singapore", "local policy should override path length")
	routes[1].advertised = false
	chosen, ok = best(routes)
	want(ok && chosen.name == "tokyo", "withdrawal should select the surviving site")
	routes[0].advertised = false
	_, ok = best(routes)
	want(!ok, "with every announcement withdrawn there is no route")
	_, ok = best(nil)
	want(!ok, "an empty candidate set must not invent a route")
	fallback := route{name: "sydney", localPref: 50, asPath: 4, advertised: true}
	chosen, ok = best(append(routes, fallback))
	want(ok && chosen.name == "sydney", "a backup announcement should recover reachability")
	fmt.Println("PASS: path length, policy priority, withdrawal, empty set, and backup")
}

将程序保存为 anycast_model.go,运行 go run anycast_model.go,并用 gofmt -d anycast_model.go、go run -race anycast_model.go 与 go vet anycast_model.go 检查格式、竞争和静态问题。预期输出是 PASS: path length, policy priority, withdrawal, empty set, and backup。若两个建模字段都相同,模型保留先遇到的候选,因此输入顺序无关只适用于本例的非完全平局路径。模型故意省略后续决胜条件、MED、eBGP/iBGP 规则、community、真实拓扑和收敛计时;它验证的是给定候选路径时的选择,不是互联网在某个时刻已经完成收敛的证明。

延伸阅读

来源