跳转到内容

负载均衡器

负载均衡器是一组具名的出站。路由到它的 tag 的方式与路由到出站相同,etemenanki-app 会为每条新流从中挑选一个成员。后台探测器以固定间隔对每个成员发起 TCP 连接检查,挑选时优先选择最近一次探测成功的成员。

如果你有不止一台上游代理服务器,希望其中一台不可达时流量仍能继续(failover),或者希望把连接分散到多台服务器上(round_robin),就可以使用负载均衡器。

两台 Trojan 服务器,primary 可达时优先使用它:

config.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "primary"
protocol = "trojan"
server = "proxy1.example.com"
port = 443
[outbound.stream]
network = "tls"
[outbound.settings]
password = "replace-with-a-long-random-password"
[[outbound]]
tag = "backup"
protocol = "trojan"
server = "proxy2.example.com"
port = 443
[outbound.stream]
network = "tls"
[outbound.settings]
password = "replace-with-a-long-random-password"
# Failover: "primary" while it answers, "backup" otherwise.
[[balancer]]
tag = "proxy"
outbounds = ["primary", "backup"]
[route]
default = "proxy"
[[route.rule]]
outbound = "direct"
cidr = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]

私有地址直连,其余流量都交给负载均衡器 proxy。只要到 proxy1.example.com:443 的 TCP 连接能建立,它就使用 primary,否则使用 backup。strategy 默认为 failover。每个成员的探测超时为 5 秒,上一次探测结束 30 秒后开始下一次。

这里特意显式设置了 [route].default。不设置时,默认出站是文件中的第一个 [[outbound]],在本例中就是 direct。负载均衡器永远不会成为隐式的默认出站。

每个 [[balancer]] 都是顶层的表数组条目,与 [[outbound]] 并列。它只接受下列键,其他任何键都是解析错误,例如 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 整数。probe_interval = "30s" 会报 invalid type: string "30s", expected u64,负数会报 invalid value: integer `-1`, expected u64。

负载均衡器在流被分派给它时才挑选成员,而不是在构建路由时。因此成员宕机后,只要探测器发现,它就不再接收新流,无需重载。

failover(默认) round_robin
选择 按 outbounds 顺序的第一个健康成员 轮转中的下一个健康成员
列表顺序 即优先级 只决定轮转顺序
优先成员恢复时 新流回到它上面 它重新加入轮转
典型用途 一台主服务器加一台或多台备用服务器 多台对等的服务器
所有成员都不可用 outbounds 中的第一个成员 outbounds 中的第一个成员
flowchart TB
  F["新的流被路由到负载均衡器"] --> H{"有健康的成员吗?"}
  H -- 否 --> First["outbounds 中的第一个成员"]
  H -- 是 --> S{"strategy"}
  S -- failover --> FO["按列表顺序的第一个健康成员"]
  S -- round_robin --> RR["轮转中的下一个健康成员"]
  First --> D["通过选中的出站拨号"]
  FO --> D
  RR --> D

由此带来几点结果:

  • 按流选择。 一条 TCP 连接会一直留在它建立时所用的成员上,直到关闭,即使该成员之后宕机,或者更高优先级的成员恢复。只有新连接会切换。
  • 对 UDP 而言,选择发生在 UDP 关联通往负载均衡器的子链路打开时。 在该子链路结束前,这个关联一直使用同一个成员。参见 UDP 如何到达出站。
  • 所有成员都不可用时,仍然使用第一个成员。 发往一个可能已经恢复的上游的流还有机会成功,被丢弃的流则毫无机会。负载均衡器不会因为探测短暂出问题就变成黑洞。
  • 拨号失败不会换到其他成员重试。 拨号失败时,这条流已经交给了该成员的出站,无法在别处重放。客户端会看到失败,并自行重试。
  • round_robin 使用一个共享计数器,在当时健康的成员之间轮转。健康成员的集合变化后,轮转在新集合上继续。同一个 tag 列出两次,它在轮转中就占两个位置。

每个负载均衡器为它的每个成员各运行一个后台任务进行探测:

  1. 用 [dns] 中配置的解析器解析该成员的 server(参见 DNS)。
  2. 向 server:port 建立一个普通 TCP 连接,依次尝试每个解析出的地址,一旦连上就立即关闭。它不会应用该成员的 [outbound.stream] 设置。
  3. 如果在 probe_timeout 秒内(包括域名解析)有一次连接成功,该成员就是健康的。解析失败、所有地址都拒绝连接或超时,都会使它被标记为不可用。
  4. 从这次探测结束时起等待 probe_interval 秒,再进行下一次探测。

配置一启动就进行第一次探测。在成员的第一次探测完成之前,它被视为健康:如果启动时把所有成员都标记为不可用,整整一个间隔内流量将无处可去。

状态变化以 info 级别记录日志:

balancer member primary is now down
balancer member primary is now up

最小示例中的故障切换过程如下:

sequenceDiagram
  participant P as 探测器
  participant A as primary
  participant B as backup
  participant R as 负载均衡器 proxy
  P->>A: TCP 连接成功
  Note over R: 新流发往 primary
  P->>A: TCP 连接被拒绝或超时
  Note over P,R: 日志 - balancer member primary is now down
  Note over R: 新流发往 backup
  P->>A: TCP 连接再次成功
  Note over P,R: 日志 - balancer member primary is now up
  Note over R: 新流回到 primary

探测器只有在下一次探测时才能发现变化。从上游消失到负载均衡器把它标记为不可用之间,新流仍会发往它并失败。使用默认值时,这个窗口最长约 35 秒:30 秒的 probe_interval,如果上游静默丢包而不是拒绝连接,再加上最多 5 秒的 probe_timeout。恢复也按同样的节奏被发现。

调低 probe_interval 可以更快响应,代价是每个间隔内每个成员都要向各自的上游建立一个 TCP 连接。probe_interval = 1 和 probe_timeout = 1 是有实际意义的最小值。

还有几点细节:

  • 探测忽略成员的 address_family。 它按解析器返回的顺序尝试所有地址,包括 IPv4 和 IPv6,第一个连上的地址就让该成员被视为健康。设为 ipv4_only 的成员可能通过 IPv6 探测为健康,而它的流量只能使用 IPv4。
  • 一个慢地址就可能让整次探测失败。 各地址依次尝试,只受整体的 probe_timeout 约束。如果第一个地址静默丢包,它的连接会耗尽全部超时时间,该成员被标记为不可用,即使后面的地址本可以响应。
  • 上游服务器能看到探测。 每次探测都是一个不发送任何数据就关闭的 TCP 连接,每个成员每隔 probe_interval 一次。
  • 成员按负载均衡器区分。 同一个出站列在两个负载均衡器中会被探测两次,各由一个负载均衡器负责,每个负载均衡器各自维护对其健康状态的判断。
  • 即使没有任何流量路由到负载均衡器,探测也会运行。
  • --test 不进行探测。 etemenanki-app --test -c config.toml 检查负载均衡器的配置是否正确,而不检查其成员是否可达。

成员必须有一个可供探测连接的 TCP 端点。

协议 可作为成员 原因
socks、http、trojan、vless、vmess、shadowsocks 是 它们通过 TCP 拨号到 server 和 port
freedom(direct)、blackhole(block) 否 没有可探测的上游 server 和 port
wireguard 否 它没有 server 和 port;其设置中的对端 endpoint 是 UDP
hysteria2(hysteria、hy2) 否,一律拒绝 它有 server,但只监听 UDP,TCP 探测会让它永远被标记为不可用

被拒绝的成员会导致配置无法加载。etemenanki-app --test 报告:

configuration invalid: balancer proxy: outbound direct has no upstream a TCP health probe can reach, so it cannot be balanced

这项检查看的是成员有没有 server 和 port,而不是它的协议,只有 hysteria2 是按名称拒绝的。带有多余 server 和 port(这些协议本身会忽略它们)的 freedom、blackhole 或 wireguard 出站会被接受,并按该地址探测,而它的流量仍然直连、被丢弃或发往 WireGuard 对端。不要依赖这一行为。

凡是能用出站 tag 的地方,都能用负载均衡器的 tag:

  • 在 [route].default 中,使它成为未匹配任何规则的流的兜底出站;
  • 在 [[route.rule]].outbound 中,把匹配的流发给它。

它不能出现在另一个负载均衡器的 outbounds 中:负载均衡器不能嵌套。路由中引用的 tag 如果既不是出站也不是负载均衡器,会报 route references unknown outbound tag: <tag>。规则如何匹配参见 路由。

下面的示例把 example.com 的流量轮流分散到两台 VLESS 服务器上,并比默认值更频繁地探测它们:

config.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "edge-a"
protocol = "vless"
server = "proxy1.example.com"
port = 443
[outbound.stream]
network = "ws"
security = "tls"
[outbound.stream.ws]
path = "/ws"
[outbound.settings]
id = "11111111-2222-3333-4444-555555555555"
[[outbound]]
tag = "edge-b"
protocol = "vless"
server = "proxy2.example.com"
port = 443
[outbound.stream]
network = "ws"
security = "tls"
[outbound.stream.ws]
path = "/ws"
[outbound.settings]
id = "11111111-2222-3333-4444-555555555555"
[[balancer]]
tag = "spread"
outbounds = ["edge-a", "edge-b"]
strategy = "round_robin"
probe_interval = 10
probe_timeout = 3
[[route.rule]]
outbound = "spread"
domain_suffix = ["example.com"]

direct 仍是默认出站,因为它是第一个出站,且没有设置 [route].default。

当文件内容发生变化,且新配置能够解析并构建成功时,就会发生重载。每次这样的重载都会像配置的其他部分一样重建所有负载均衡器。旧的探测器随旧配置停止,新的探测器随新配置启动,因此每个成员都重新从健康状态开始,并立即被探测。如果新配置失败,旧配置会连同其负载均衡器及健康状态继续运行。

日志中的重载摘要只列出入站、出站、路由和日志的变化。因此只修改了 [[balancer]] 的重载会记录为 config reload: no changes,尽管新设置已经生效,并且与其他任何重载一样,旧配置的连接会被关闭。参见 热重载。

  • [[balancer]] 是顶层表,不属于路由部分。
  • outbounds 列出精确的 tag,没有 Xray selector 那样的前缀匹配。
  • 只有两种策略,failover 和 round_robin,必须一字不差地这样写。Xray 的 random、leastPing 和 leastLoad 没有对应项。
  • 没有 fallbackTag。所有成员都不可用时,负载均衡器使用它的第一个成员。
  • 健康检查是内置的,直接在负载均衡器上配置。没有单独的 observatory,探测是一次 TCP 连接,而不是经由代理发出的 HTTP 请求。

其余差异参见 从 Xray 迁移。

以下错误都会导致配置无法加载。etemenanki-app --test -c config.toml 会记录 configuration invalid: 加上错误消息,正常启动时则记录 failed to start: 加上错误消息。重载时,错误消息跟在 reload: parse failed, keeping current config: (缺少字段的错误及其他 TOML 错误)或 reload: build failed, keeping current config: (其余错误)之后,旧配置继续运行。

消息 原因 解决方法
balancer tag <tag> collides with an outbound tag 负载均衡器的 tag 与某个出站的 tag 或另一个负载均衡器的 tag 重复 给它起一个唯一的名称
balancer <tag> references unknown outbound tag: <member> 某个成员拼写错误、未定义,或者本身是负载均衡器 只列出 [[outbound]] 的 tag
balancer <tag>: outbound <member> has no upstream a TCP health probe can reach, so it cannot be balanced hysteria2 成员,或者 freedom、blackhole、wireguard 成员(通常没有 server 和 port) 把它移出负载均衡器,改为直接路由到它
a balancer needs at least one outbound outbounds = [] 至少列出一个成员
unknown balancer strategy "<value>" (expected "failover" or "round_robin") 策略拼写错误或大小写不对 准确写成 failover 或 round_robin
missing field `outbounds` / missing field `tag` 缺少必填键 补上该键
route references unknown outbound tag: <tag> 某条规则或 [route].default 引用了不存在的负载均衡器 修正 tag,或定义该负载均衡器