在多个上游之间故障切换
本配方搭建一个客户端侧的 etemenanki-app,让你的流量经由三台上游代理服务器之一转发。第一台服务器能接受连接时就使用它;第一台不再响应时,新连接转到第二台;第一台恢复后,再切回第一台。末尾的变体则改为把连接分散到所有健康的服务器上。
如果你运行着不止一台上游服务器,希望客户端在某台服务器故障时自动绕开,而无需手动修改配置,就可以参考本配方。本文还会详细说明健康探测,因为负载均衡器的一切行为都由探测决定,而它检查的内容比你想象的要少。
flowchart LR
App["应用程序"] --> In["socks-in 127.0.0.1:1080"]
In --> R{"路由"}
R -- "私有地址" --> Direct["direct"]
R -- "其余流量(default)" --> B["负载均衡器 upstream"]
B -- "首选" --> P["primary: Trojan"]
B -- "次选" --> S["secondary: VLESS"]
B -- "第三选择" --> T["tertiary: VMess"]
Prober["健康探测器"] -. "TCP 连接" .-> P
Prober -. "TCP 连接" .-> S
Prober -. "TCP 连接" .-> T
- 三个出站
primary、secondary和tertiary各自连接一台不同的服务器。这里特意使用了不同的协议,以说明成员之间不必一致。你的成员完全可以都使用同一种协议。 - 一个名为
upstream的[[balancer]]把它们归为一组。使用strategy = "failover"时,其outbounds列表的顺序就是优先级。 [route].default = "upstream"把所有未被任何规则匹配的流发给负载均衡器,因此负载均衡器是全部流量的兜底去向。- 后台探测器按定时器向每个成员的
server和port发起 TCP 连接。负载均衡器只会挑选最近一次探测成功的成员。
负载均衡器的探测只检查服务器是否接受 TCP 连接,不检查你的密码、UUID、TLS 名称或 WebSocket 路径(见探测不检查什么)。请先确保每个上游单独使用时都能正常工作,这样探测结果健康才真正意味着这条路径可用。
-
写一份只包含第一个上游出站的配置,并让
[route].default指向它。它的[[outbound]]块可以从对应协议的配方中复制,例如 VLESS over WebSocket 与 TLS 或 VMess over gRPC 与 TLS。 -
检查配置并启动:
终端窗口 etemenanki-app --test -c config.tomletemenanki-app -c config.toml -
在另一个终端中,通过它发送一个请求:
终端窗口 curl --socks5-hostname 127.0.0.1:1080 https://example.com/ -o /dev/null -w '%{http_code}\n'返回
200这样的状态码,说明整条路径都能工作:TCP、TLS、代理协议以及你的凭据。 -
按 Ctrl-C 停止代理,把
[route].default指向下一个出站,重复以上步骤,直到每个上游都单独通过测试。
# The "Failover between upstreams" recipe: a local SOCKS proxy that sends# everything through one of three upstream servers. "primary" is used while it# accepts TCP connections, then "secondary", then "tertiary". Private addresses# go out directly.# Replace the server names, the password and the UUIDs with your servers' values.
[log]level = "info"
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
[[outbound]]tag = "direct"protocol = "freedom"
# Member 1: Trojan over TLS.[[outbound]]tag = "primary"protocol = "trojan"server = "proxy1.example.com"port = 443
[outbound.stream]network = "tls"
[outbound.stream.tls]server_name = "proxy1.example.com"
[outbound.settings]password = "replace-with-a-long-random-password"
# Member 2: VLESS over WebSocket and TLS.[[outbound]]tag = "secondary"protocol = "vless"server = "proxy2.example.com"port = 443
[outbound.stream]network = "ws"security = "tls"
[outbound.stream.ws]path = "/vless"
[outbound.stream.tls]server_name = "proxy2.example.com"
[outbound.settings]id = "11111111-2222-3333-4444-555555555555"
# Member 3: VMess over gRPC and TLS.[[outbound]]tag = "tertiary"protocol = "vmess"server = "proxy3.example.com"port = 443
[outbound.stream]network = "grpc"security = "tls"
[outbound.stream.grpc]service_name = "tunnel"
[outbound.stream.tls]server_name = "proxy3.example.com"
[outbound.settings]id = "11111111-2222-3333-4444-666666666666"
# All three members carry UDP, so the balancer can take UDP flows too.# List order is the priority under "failover".[[balancer]]tag = "upstream"outbounds = ["primary", "secondary", "tertiary"]strategy = "failover" # the default, written out for clarityprobe_interval = 15 # seconds between probes of each member (default 30)probe_timeout = 5 # seconds a probe may take (default 5)
[route]# Set explicitly: without it the default would be "direct", the first outbound.default = "upstream"
[[route.rule]]outbound = "direct"cidr = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fc00::/7"]各部分的作用:
direct排在所有出站的最前面,只是因为这是一种常见的写法。它对负载均衡器没有影响,但对默认路由有影响:没有[route].default时,未匹配任何规则的流会发往第一个[[outbound]],在这里就是direct。负载均衡器不会自动成为默认出站,所以这份配置显式设置了default = "upstream"。- 三个成员都是普通的出站。
[[outbound]]块在成为负载均衡器成员后不需要任何改动,你仍然可以在规则中直接路由到某个成员自己的 tag。 - 三个成员都能承载 UDP。 Trojan、VLESS 和 VMess 出站都能承载 UDP,因此负载均衡器既能接收 TCP 流,也能接收 UDP 流。
http或shadowsocks成员会静默丢弃负载均衡器交给它的每一条 UDP 流;如果确实需要这类成员,请参阅 UDP 发往不支持数据报的出站。 [[balancer]]按优先级顺序列出成员。probe_interval = 15把默认的 30 秒减半,从而更早发现失效的服务器。选择探测时间参数说明了其中的取舍。direct规则让私有地址和环回地址不经过上游。规则会在默认路由生效之前按顺序检查,因此对这些地址,这条规则优先。cidr规则只能看到以 IP 地址形式到达的目标:像nas.lan这样解析为192.168.1.10的名称仍会发往负载均衡器(见路由时不解析域名)。
负载均衡器配置项
Section titled “负载均衡器配置项”[[balancer]] 只接受下列配置项。任何其他配置项都会导致配置无法加载,例如 unknown field `probe_intervall`, expected one of `tag`, `outbounds`, `strategy`, `probe_interval`, `probe_timeout` 。
| 键 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
tag | string | 是 | — | 负载均衡器的名称。[route].default 和 [[route.rule]].outbound 引用它的方式与引用出站完全相同。它不能与任何 [[outbound]] 的 tag 重复,也不能与另一个负载均衡器的 tag 重复;两种情况都报 balancer tag <tag> collides with an outbound tag。 |
outbounds | array of strings | 是 | — | 成员出站,按 tag 精确匹配。至少一个(否则报 a balancer needs at least one outbound)。只接受 [[outbound]] 的 tag:不存在的 tag 或另一个负载均衡器的 tag 都报 balancer <tag> references unknown outbound tag: <member>。每个成员都必须有可供 TCP 连接探测的 server 和 port。hysteria2 成员一律被拒绝;freedom、blackhole 和 wireguard 成员通常没有 server 和 port,因此也被拒绝;两种情况都报 balancer <tag>: outbound <member> has no upstream a TCP health probe can reach, so it cannot be balanced。在 failover 策略下,列表顺序就是优先级。同一个 tag 写两次也会被接受,并按两个成员计算。 |
strategy | string (enum) | 否 | "failover" | 为每个新的流挑选健康成员的方式:failover 取 outbounds 顺序中第一个健康成员,round_robin 依次轮流使用各个健康成员。按原样匹配,区分大小写;其他值(例如 "roundrobin" 或 "Failover")报 unknown balancer strategy "<value>" (expected "failover" or "round_robin")。 |
probe_interval | u64 | 否 | 30 | 一个成员的一次健康探测结束后,等待多少秒再开始下一次。每个成员按各自的节奏探测,配置启动后立即进行第一次探测。0 也会被接受,此时探测一次接一次、没有间隔,会持续不断地向上游建立连接。 |
probe_timeout | u64 | 否 | 5 | 一次探测(包括域名解析)最多可用的秒数,超时即认为该成员不可用。0 也会被接受,但一次探测只剩到下一个定时器 tick(约 1 毫秒)为止的时间,除非连接几乎立即建立,否则成员会被标记为不可用。请至少设为 1。 |
probe_interval 和 probe_timeout 都是以 TOML 整数表示的整秒数。写成 "30s" 这样的字符串或负数都是解析错误。
运行并观察故障切换
Section titled “运行并观察故障切换”-
检查配置。
--test会构建所有内容(包括负载均衡器),但不会启动探测器,因此它无法告诉你服务器是否可达:终端窗口 etemenanki-app --test -c config.tomlConfiguration OK. -
启动:
终端窗口 etemenanki-app -c config.toml探测器随配置一起启动,并立即探测每个成员。首次探测成功的成员不会输出日志,因为每个成员在首次探测之前就已被视为健康。首次探测失败的成员会输出一行
info级别的日志:balancer member tertiary is now down -
让
primary不可达。可以停止那台服务器上的代理服务,或者在客户端上暂时屏蔽它。例如在 Linux 上:终端窗口 sudo iptables -I OUTPUT -p tcp -d proxy1.example.com --dport 443 -j REJECTiptables只作用于 IPv4。如果proxy1.example.com还有 IPv6 地址,请用ip6tables添加同样的规则,否则探测会通过 IPv6 连接成功,primary仍会保持健康。在一个
probe_interval之内,日志中会出现:balancer member primary is now down此后,新连接会发往
secondary。再次运行开始之前中的curl命令即可确认。如果你的各个上游出口地址不同,可以用一个回显 IP 的服务查看请求经由哪一台转发。etemenanki-app 不会在日志中记录某条流发往了哪个成员。 -
撤销屏蔽:
终端窗口 sudo iptables -D OUTPUT -p tcp -d proxy1.example.com --dport 443 -j REJECT如果添加过
ip6tables规则,也一并删除。在一个
probe_interval之内,日志中会出现balancer member primary is now up,新连接也会回到primary。
成员不可用时已经打开的连接不会被迁移。TCP 连接会一直停留在它开始时所用的成员上,直到连接关闭;UDP 关联则一直使用它通往负载均衡器的子链路打开时所选的成员。只有新连接才会跟随负载均衡器当前的选择。
用 round_robin 分摊负载
Section titled “用 round_robin 分摊负载”如果你的各个上游是等价的,与其让部分服务器待命,不如全部用上,那就修改策略:
[[balancer]]tag = "upstream"outbounds = ["primary", "secondary", "tertiary"]strategy = "round_robin"probe_interval = 15probe_timeout = 5现在每个新连接会依次发往下一个健康的成员。不可用的成员会退出轮转,再次响应探测后重新加入。
failover |
round_robin |
|
|---|---|---|
| 新连接分配到哪个成员 | 按 outbounds 顺序的第一个健康成员 |
轮转中的下一个健康成员 |
| 列表顺序的含义 | 优先级 | 仅表示轮转顺序 |
| 服务器负载 | 全部集中在一台服务器上,其余空闲 | 分散到所有健康的服务器上 |
| 成员恢复时 | 新连接立即回到该成员 | 该成员重新加入轮转 |
| 所有成员都不可用 | outbounds 中的第一个成员 |
outbounds 中的第一个成员 |
| 适用场景 | 一台首选服务器加若干备用服务器 | 多台等价的服务器 |
关于 round_robin,有几点需要了解:
- 它按连接轮转,而不是按目标轮转。 访问同一网站的两个连接可能经由不同的服务器发出。把登录会话与 IP 地址绑定的网站可能会察觉到这一点,因此如果你的上游出口地址不同且这一点很重要,请使用
failover。 - 轮转会跳过不可用的成员。 一个共享计数器在当时健康的成员之间依次移动,因此健康成员集合变化后,轮转会在新集合上继续。
- 只能通过重复实现权重。 把一个 tag 列出两次,如
outbounds = ["primary", "primary", "secondary"],它就在轮转中占两个位置。每个位置都是一个独立的成员,有各自的探测,因此这台服务器被探测的频率也会翻倍。没有权重配置项。
你也可以同时使用两者:一个 failover 负载均衡器作为 [route].default,再加一个由某条 [[route.rule]] 指向的 round_robin 负载均衡器。每个负载均衡器各自探测自己的成员,因此同时属于两者的出站会被探测两次。负载均衡器不能包含其他负载均衡器。
健康探测的工作方式
Section titled “健康探测的工作方式”每个负载均衡器为每个成员运行一个后台任务。只要配置在运行,这个任务就不断重复同一个周期:
- 使用
[dns]中配置的解析器(见 DNS)解析成员的server,所用的解析器和缓存与该出站本身相同。IP 地址无需解析。 - 依次尝试与每个解析得到的地址建立普通的 TCP 连接,一旦有一个连接成功就立即关闭它。连接上不发送任何数据。
- 如果在
probe_timeout秒内有连接成功,该成员即为健康。解析失败、连接被拒绝或超时都会使它变为不可用。超时时间涵盖解析和所有地址的尝试。 - 如果状态发生变化,就输出一行日志,然后等待
probe_interval秒,再重新开始。
stateDiagram-v2 [*] --> Healthy: 配置启动后、首次探测之前 Healthy --> Down: 一次探测失败或超时 Down --> Healthy: 一次探测连接成功 Healthy --> Healthy: 探测连接成功 Down --> Down: 探测失败
成员在开始时都是健康的。如果开始时都是不可用,那么在第一轮探测完成之前,负载均衡器将无处可发流量。两个方向上都没有阈值:一次探测失败就把成员标记为不可用,一次探测成功就把它重新标记为可用。
探测器随配置停止而停止;无论是否有流量路由到该负载均衡器,它都会运行。
探测不检查什么
Section titled “探测不检查什么”探测只回答一个问题:这个成员的 server 和 port 上是否有东西接受 TCP 连接?这正是负载均衡器要绕开的故障:服务器不可达或已停止。探测不使用成员的 [outbound.stream] 设置或凭据,因为检查这些就意味着要在探测器内部再运行一个代理客户端。
还有一些细节:
- 探测忽略
address_family。 它会按解析器返回的顺序尝试解析器返回的每个地址,IPv4 和 IPv6 都会尝试。限定为ipv4_only的成员可能通过 IPv6 探测为健康,而它的流量却只能使用 IPv4。 - 一个吞掉数据包的地址就可能耗尽整个超时时间。 所有地址都在同一个
probe_timeout之内依次尝试。如果第一个地址始终没有响应,探测会在尝试第二个地址之前超时,即使第二个地址可用,该成员也会被视为不可用。请让server指向一个其第一个解析结果可从客户端到达的名称或地址。 - 服务器能看到探测。 每次探测都是一个不发送任何字节就关闭的 TCP 连接。代理服务器通常会把它们记录为失败或空的握手。在三个成员、
probe_interval = 15的情况下,每台服务器每分钟会从每个客户端收到大约四个这样的连接。
所有上游都不可用时
Section titled “所有上游都不可用时”没有任何健康成员时,负载均衡器会把新连接发往 outbounds 中的第一个成员(本配方中为 primary),而不是丢弃它们。探测结果可能在一分钟内出错,例如客户端自身的网络短暂中断时;把连接发往一台可能已经恢复的服务器,还有一定机会成功,而丢弃的连接则毫无机会。
实际上,当你的所有上游确实都不可用时:
- 到达负载均衡器的每个新连接都会失败,客户端应用程序会看到连接错误;
- 日志中每个成员都有一行
balancer member … is now down;失败的连接本身只在debug级别记录,每个连接一行… connection from … ended: …; - 被规则发往其他地方的流量不受影响,例如这里发往
direct的私有地址段; - 一旦有任何成员响应了探测,新连接就会发往它。
负载均衡器永远不会自行回退到 direct。如果你希望在所有上游都失效时让流量直接发出,那是一个需要你自己编写的路由决策,而且这通常违背了使用代理的初衷。
选择探测时间参数
Section titled “选择探测时间参数”负载均衡器的行为由两个数值决定,而它们的影响方向相反。
多久才能发现服务器失效。 每次探测在上一次探测结束 probe_interval 秒后运行。如果服务器恰好在一次成功探测之后宕机,下一次探测最多要等 probe_interval 秒才开始。拒绝连接的服务器几乎会被立即标记为不可用。而数据包石沉大海的服务器(主机或其网络宕机时的常见情况)会让探测一直等满 probe_timeout。因此最坏情况约为 probe_interval + probe_timeout 秒,在此期间发往该服务器的新连接都会失败。服务器恢复后,由恢复后的第一次探测发现:如果之前失败的探测是被拒绝,大约在 probe_interval 秒内发现;如果之前是超时,大约在 probe_interval + probe_timeout 秒内发现。
服务器会收到多少次探测。 运行这份配置的每个客户端,大约每 probe_interval 秒向每个成员发起一次 TCP 连接。
| 方案 | probe_interval |
probe_timeout |
把静默服务器标记为不可用的最坏情况 | 每个成员每分钟的探测次数 |
|---|---|---|---|---|
| 默认 | 30 |
5 |
约 35 秒 | 约 2 次 |
| 本配方 | 15 |
5 |
约 20 秒 | 约 4 次 |
| 快速 | 5 |
3 |
约 8 秒 | 约 12 次 |
一些建议:
- 在真实网络中,
probe_timeout至少保持 3 秒。 一次探测就是一次 TCP 握手;第一个 SYN 包丢失时,Linux 会在 1 秒后重发,再过 2 秒又重发一次。使用probe_timeout = 1时,丢失一个数据包就会把健康的服务器标记为不可用;又因为没有阈值,failover流量会在成员之间来回切换。对以域名指定的成员,每次探测还包括域名解析,而一旦没有命中解析器缓存,解析就会很慢。 - 不要把
probe_timeout设得比需要的更大。 它只对静默故障有影响,并且会直接叠加到发现这类故障所需的时间上。 - 根据你能容忍连接失败多久来选择
probe_interval。 低于大约 5 秒后收益很小,而探测流量会迅速增长,客户端较多时尤其如此。 - 两者都不要设为
0。 两者都接受0。probe_interval = 0会让每个成员的探测几乎不停顿地接连运行,对你的服务器形成持续不断的连接流。probe_timeout = 0只给探测留出到下一个定时器刻度为止的时间,大约 1 毫秒,因此任何不在本地网络上的成员每次探测都会被标记为不可用。当所有成员都遇到这种情况时,负载均衡器会把它们全部视为不可用,流量总是发往第一个成员:完全没有故障切换。
为什么有些出站不能作为成员
Section titled “为什么有些出站不能作为成员”成员必须有一个 TCP 端点供探测连接。配置会拒绝任何没有 TCP 端点的成员,而不是把它视为永远健康,因为那样会掩盖负载均衡器本应发现的故障。
| 成员协议 | 是否接受 | 原因 |
|---|---|---|
socks、http、trojan、vless、vmess、shadowsocks |
是 | 它们都拨号到一个 TCP server 和 port,探测可以连接 |
hysteria2 |
否 | 它有 server 和 port,但 Hysteria 2 服务器只监听 UDP。TCP 探测每次都会失败,在首次探测时就把成员标记为不可用,并且永远不会再标记为可用 |
wireguard |
否 | 它没有 server;它的对端是 settings.endpoint,而那是 UDP |
freedom、blackhole |
否 | 没有可探测的上游服务器 |
协议别名同样适用:hysteria 和 hy2 与 hysteria2 一样被拒绝,direct 与 freedom 一样,block 与 blackhole 一样。
被拒绝的成员会导致配置无法加载。--test 会这样报告:
configuration invalid: balancer upstream: outbound direct has no upstream a TCP health probe can reach, so it cannot be balanced正常启动时,进程会在 failed to start: 之后输出同样的消息并退出;引入这种成员的重载会被拒绝,并保留正在运行的配置(见重载)。
如果你想把 Hysteria 2 或 WireGuard 服务器作为备用,负载均衡器做不到。请用规则路由到该出站,或者单独把它设为 [route].default;这样一来,切换到它或从它切换回来都需要修改配置。出于同样的原因,把 direct 作为最后兜底的成员也会被拒绝;至于为什么你多半并不想这样做,请参阅所有上游都不可用时。
保存配置文件时,etemenanki-app 会重载。负载均衡器会与其他所有内容一起重建:旧的探测器停止,每个成员重新从健康状态开始,新的探测器立即开始探测。重载还会关闭所有已打开的连接。重载日志行只列出入站、出站、路由和日志段的变化,因此只修改了 [[balancer]] 块的保存会输出 config reload: no changes,尽管新设置确实已经生效。参见热重载。
下列每种错误都会让 etemenanki-app --test 失败(消息跟在 configuration invalid: 之后),也会让正常启动失败(消息跟在 failed to start: 之后)。重载时,消息跟在 reload: build failed, keeping current config: 之后,原有配置继续运行。[[balancer]] 中写错配置项名或值的类型错误则属于 TOML 解析错误,例如 unknown field `probe_intervall` 或 invalid type: string "30s", expected u64,报告在 TOML parse error at line … 之后。重载时,解析错误跟在 reload: parse failed, keeping current config: 之后,原有配置同样继续运行。
| 消息 | 原因 | 解决方法 |
|---|---|---|
balancer <tag>: outbound <member> has no upstream a TCP health probe can reach, so it cannot be balanced |
成员是 hysteria2、wireguard、freedom 或 blackhole |
把它从 outbounds 中移除,改为直接路由到它 |
balancer <tag> references unknown outbound tag: <member> |
成员 tag 拼写错误、未定义,或者是另一个负载均衡器 | 只列出 [[outbound]] 的 tag |
balancer tag <tag> collides with an outbound tag |
负载均衡器的 tag 与某个出站或另一个负载均衡器重复 |
给它起一个唯一的名称 |
a balancer needs at least one outbound |
outbounds = [] |
至少列出一个成员 |
unknown balancer strategy "<value>" (expected "failover" or "round_robin") |
策略拼写错误或大小写不同 | 准确写成 failover 或 round_robin |
route references unknown outbound tag: <tag> |
[route].default 或某条规则指向了不存在的负载均衡器 |
修正 tag 或定义该负载均衡器 |
还有一个不会产生任何错误的失误:省略 [route].default。配置能正常加载,负载均衡器也会探测它的成员,但所有流量都会发往第一个 [[outbound]],在本配方中就是 direct。