跳转到内容

在多个上游之间故障切换

本配方搭建一个客户端侧的 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 路径(见探测不检查什么)。请先确保每个上游单独使用时都能正常工作,这样探测结果健康才真正意味着这条路径可用。

  1. 写一份只包含第一个上游出站的配置,并让 [route].default 指向它。它的 [[outbound]] 块可以从对应协议的配方中复制,例如 VLESS over WebSocket 与 TLS 或 VMess over gRPC 与 TLS。

  2. 检查配置并启动:

    终端窗口
    etemenanki-app --test -c config.toml
    etemenanki-app -c config.toml
  3. 在另一个终端中,通过它发送一个请求:

    终端窗口
    curl --socks5-hostname 127.0.0.1:1080 https://example.com/ -o /dev/null -w '%{http_code}\n'

    返回 200 这样的状态码,说明整条路径都能工作:TCP、TLS、代理协议以及你的凭据。

  4. 按 Ctrl-C 停止代理,把 [route].default 指向下一个出站,重复以上步骤,直到每个上游都单独通过测试。

config.toml
# 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 clarity
probe_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 的名称仍会发往负载均衡器(见路由时不解析域名)。

[[balancer]] 只接受下列配置项。任何其他配置项都会导致配置无法加载,例如 unknown field `probe_intervall`, expected one of `tag`, `outbounds`, `strategy`, `probe_interval`, `probe_timeout` 。

键类型必填默认值说明
tagstring是—负载均衡器的名称。[route].default 和 [[route.rule]].outbound 引用它的方式与引用出站完全相同。它不能与任何 [[outbound]] 的 tag 重复,也不能与另一个负载均衡器的 tag 重复;两种情况都报 balancer tag <tag> collides with an outbound tag。
outboundsarray 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 写两次也会被接受,并按两个成员计算。
strategystring (enum)否"failover"为每个新的流挑选健康成员的方式:failover 取 outbounds 顺序中第一个健康成员,round_robin 依次轮流使用各个健康成员。按原样匹配,区分大小写;其他值(例如 "roundrobin" 或 "Failover")报 unknown balancer strategy "<value>" (expected "failover" or "round_robin")。
probe_intervalu64否30一个成员的一次健康探测结束后,等待多少秒再开始下一次。每个成员按各自的节奏探测,配置启动后立即进行第一次探测。0 也会被接受,此时探测一次接一次、没有间隔,会持续不断地向上游建立连接。
probe_timeoutu64否5一次探测(包括域名解析)最多可用的秒数,超时即认为该成员不可用。0 也会被接受,但一次探测只剩到下一个定时器 tick(约 1 毫秒)为止的时间,除非连接几乎立即建立,否则成员会被标记为不可用。请至少设为 1。

probe_interval 和 probe_timeout 都是以 TOML 整数表示的整秒数。写成 "30s" 这样的字符串或负数都是解析错误。

  1. 检查配置。--test 会构建所有内容(包括负载均衡器),但不会启动探测器,因此它无法告诉你服务器是否可达:

    终端窗口
    etemenanki-app --test -c config.toml
    Configuration OK.
  2. 启动:

    终端窗口
    etemenanki-app -c config.toml

    探测器随配置一起启动,并立即探测每个成员。首次探测成功的成员不会输出日志,因为每个成员在首次探测之前就已被视为健康。首次探测失败的成员会输出一行 info 级别的日志:

    balancer member tertiary is now down
  3. 让 primary 不可达。可以停止那台服务器上的代理服务,或者在客户端上暂时屏蔽它。例如在 Linux 上:

    终端窗口
    sudo iptables -I OUTPUT -p tcp -d proxy1.example.com --dport 443 -j REJECT

    iptables 只作用于 IPv4。如果 proxy1.example.com 还有 IPv6 地址,请用 ip6tables 添加同样的规则,否则探测会通过 IPv6 连接成功,primary 仍会保持健康。

    在一个 probe_interval 之内,日志中会出现:

    balancer member primary is now down

    此后,新连接会发往 secondary。再次运行开始之前中的 curl 命令即可确认。如果你的各个上游出口地址不同,可以用一个回显 IP 的服务查看请求经由哪一台转发。etemenanki-app 不会在日志中记录某条流发往了哪个成员。

  4. 撤销屏蔽:

    终端窗口
    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 关联则一直使用它通往负载均衡器的子链路打开时所选的成员。只有新连接才会跟随负载均衡器当前的选择。

如果你的各个上游是等价的,与其让部分服务器待命,不如全部用上,那就修改策略:

config.toml
[[balancer]]
tag = "upstream"
outbounds = ["primary", "secondary", "tertiary"]
strategy = "round_robin"
probe_interval = 15
probe_timeout = 5

现在每个新连接会依次发往下一个健康的成员。不可用的成员会退出轮转,再次响应探测后重新加入。

failover round_robin
新连接分配到哪个成员 按 outbounds 顺序的第一个健康成员 轮转中的下一个健康成员
列表顺序的含义 优先级 仅表示轮转顺序
服务器负载 全部集中在一台服务器上,其余空闲 分散到所有健康的服务器上
成员恢复时 新连接立即回到该成员 该成员重新加入轮转
所有成员都不可用 outbounds 中的第一个成员 outbounds 中的第一个成员
适用场景 一台首选服务器加若干备用服务器 多台等价的服务器

关于 round_robin,有几点需要了解:

  • 它按连接轮转,而不是按目标轮转。 访问同一网站的两个连接可能经由不同的服务器发出。把登录会话与 IP 地址绑定的网站可能会察觉到这一点,因此如果你的上游出口地址不同且这一点很重要,请使用 failover。
  • 轮转会跳过不可用的成员。 一个共享计数器在当时健康的成员之间依次移动,因此健康成员集合变化后,轮转会在新集合上继续。
  • 只能通过重复实现权重。 把一个 tag 列出两次,如 outbounds = ["primary", "primary", "secondary"],它就在轮转中占两个位置。每个位置都是一个独立的成员,有各自的探测,因此这台服务器被探测的频率也会翻倍。没有权重配置项。

你也可以同时使用两者:一个 failover 负载均衡器作为 [route].default,再加一个由某条 [[route.rule]] 指向的 round_robin 负载均衡器。每个负载均衡器各自探测自己的成员,因此同时属于两者的出站会被探测两次。负载均衡器不能包含其他负载均衡器。

每个负载均衡器为每个成员运行一个后台任务。只要配置在运行,这个任务就不断重复同一个周期:

  1. 使用 [dns] 中配置的解析器(见 DNS)解析成员的 server,所用的解析器和缓存与该出站本身相同。IP 地址无需解析。
  2. 依次尝试与每个解析得到的地址建立普通的 TCP 连接,一旦有一个连接成功就立即关闭它。连接上不发送任何数据。
  3. 如果在 probe_timeout 秒内有连接成功,该成员即为健康。解析失败、连接被拒绝或超时都会使它变为不可用。超时时间涵盖解析和所有地址的尝试。
  4. 如果状态发生变化,就输出一行日志,然后等待 probe_interval 秒,再重新开始。
stateDiagram-v2
  [*] --> Healthy: 配置启动后、首次探测之前
  Healthy --> Down: 一次探测失败或超时
  Down --> Healthy: 一次探测连接成功
  Healthy --> Healthy: 探测连接成功
  Down --> Down: 探测失败

成员在开始时都是健康的。如果开始时都是不可用,那么在第一轮探测完成之前,负载均衡器将无处可发流量。两个方向上都没有阈值:一次探测失败就把成员标记为不可用,一次探测成功就把它重新标记为可用。

探测器随配置停止而停止;无论是否有流量路由到该负载均衡器,它都会运行。

探测只回答一个问题:这个成员的 server 和 port 上是否有东西接受 TCP 连接?这正是负载均衡器要绕开的故障:服务器不可达或已停止。探测不使用成员的 [outbound.stream] 设置或凭据,因为检查这些就意味着要在探测器内部再运行一个代理客户端。

还有一些细节:

  • 探测忽略 address_family。 它会按解析器返回的顺序尝试解析器返回的每个地址,IPv4 和 IPv6 都会尝试。限定为 ipv4_only 的成员可能通过 IPv6 探测为健康,而它的流量却只能使用 IPv4。
  • 一个吞掉数据包的地址就可能耗尽整个超时时间。 所有地址都在同一个 probe_timeout 之内依次尝试。如果第一个地址始终没有响应,探测会在尝试第二个地址之前超时,即使第二个地址可用,该成员也会被视为不可用。请让 server 指向一个其第一个解析结果可从客户端到达的名称或地址。
  • 服务器能看到探测。 每次探测都是一个不发送任何字节就关闭的 TCP 连接。代理服务器通常会把它们记录为失败或空的握手。在三个成员、probe_interval = 15 的情况下,每台服务器每分钟会从每个客户端收到大约四个这样的连接。

没有任何健康成员时,负载均衡器会把新连接发往 outbounds 中的第一个成员(本配方中为 primary),而不是丢弃它们。探测结果可能在一分钟内出错,例如客户端自身的网络短暂中断时;把连接发往一台可能已经恢复的服务器,还有一定机会成功,而丢弃的连接则毫无机会。

实际上,当你的所有上游确实都不可用时:

  • 到达负载均衡器的每个新连接都会失败,客户端应用程序会看到连接错误;
  • 日志中每个成员都有一行 balancer member … is now down;失败的连接本身只在 debug 级别记录,每个连接一行 … connection from … ended: …;
  • 被规则发往其他地方的流量不受影响,例如这里发往 direct 的私有地址段;
  • 一旦有任何成员响应了探测,新连接就会发往它。

负载均衡器永远不会自行回退到 direct。如果你希望在所有上游都失效时让流量直接发出,那是一个需要你自己编写的路由决策,而且这通常违背了使用代理的初衷。

负载均衡器的行为由两个数值决定,而它们的影响方向相反。

多久才能发现服务器失效。 每次探测在上一次探测结束 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 毫秒,因此任何不在本地网络上的成员每次探测都会被标记为不可用。当所有成员都遇到这种情况时,负载均衡器会把它们全部视为不可用,流量总是发往第一个成员:完全没有故障切换。

成员必须有一个 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。