Xray API节点自动切换脚本:StatsService进阶配置指南

本文面向熟悉 Xray-core JSON 的开发者,深入讲解如何通过 gRPC API、StatsService 与 HandlerService 获取流量指标、动态管理出站节点,并使用脚本实现自动择优和故障切换。文章提供可直接运行的 Xray 配置、鉴权建议、监控逻辑及排障方法。

Xray API 节点自动切换,核心并不是简单地“发现一个节点失效后换下一个节点”,而是把节点状态、流量统计、主动探测、切换动作和故障回退组织成一条可重复执行的流程。Xray 的 StatsService 可以读取入站与出站的流量计数,HandlerService 可以在运行过程中管理部分处理器;脚本则负责把统计数据和主动健康检查组合起来,最终决定是否切换当前节点。

本文以 Xray-core 25.x 的常见 API 配置方式为基础,使用本机 127.0.0.1:10085 作为 gRPC API 监听地址,节点标签使用 proxy-aproxy-bproxy-c。示例重点放在可维护性:API 只监听本机、统计标签保持稳定、连续失败才触发切换、切换后保留冷却时间,并且在所有候选节点都不可用时回退到上一个已知可用节点。

本文速览

本文适合已经能够编写 Xray JSON 配置、理解入站与出站标签,并希望用脚本实现节点健康检查和自动切换的开发者。读完可以完成 StatsService 开启、统计查询、节点探测、切换决策与故障回退,同时理解流量统计不能代替延迟检测这一边界。

Xray API 与 StatsService 的职责边界

Xray API 通常通过独立的 api 入站暴露 gRPC 服务。它不是给浏览器或普通代理流量使用的端口,而是管理核心状态的控制入口。配置中的 api.tag 用于标记这条管理入站,api.services 则声明需要启用的服务。读取流量计数时,至少需要启用 StatsService;如果还要动态增删入站或出站,则需要根据实际操作启用 HandlerService

StatsService 返回的是累计计数,而不是某一条连接的实时质量评分。例如,outbound>>>proxy-a>>>traffic>>>uplink 表示标签为 proxy-a 的出站累计上传字节数。脚本连续读取两次计数,使用差值除以采样间隔,便可以得到近似吞吐量;如果计数长时间不增长,只能说明最近没有有效流量,不能直接证明节点已经失效。

API 接收入站 StatsService 读数 主动探测节点 脚本计算评分 执行切换回退
10085
本机 gRPC API 端口
3 项
常用 API 服务
5 秒
建议统计采样间隔
3 次
连续失败阈值

实际部署时,API 服务至少要满足三个安全条件。第一,监听地址使用 127.0.0.1 或 Unix Socket,不要直接绑定公网地址。第二,防火墙只允许管理脚本访问 API 端口。第三,脚本运行账户应尽量限制文件读取、核心重载和日志访问权限。API 一旦暴露给不可信网络,攻击者可能读取运行状态、修改处理器,甚至影响整个代理进程。

结论:统计负责观测,探测负责判活

StatsService 适合回答“这个出站最近传输了多少数据”,不适合单独回答“这个节点当前是否能访问目标网站”。自动切换至少应同时使用累计流量差值、TCP 或 HTTPS 主动探测、连续失败次数三个信号。

配置 API、统计与节点标签

下面是一段可以嵌入 Xray 主配置的最小示例。示例保留三个节点出站,并使用一个专门的 API 入站承载 gRPC 管理请求。节点本身的地址、UUID、Reality 公钥等参数需要替换为实际订阅内容;不要为了测试 API 而复制一份过期节点参数。

{
  "log": {
    "loglevel": "warning"
  },
  "api": {
    "tag": "api",
    "services": [
      "StatsService",
      "HandlerService",
      "LoggerService"
    ]
  },
  "stats": {},
  "inbounds": [
    {
      "tag": "api-in",
      "listen": "127.0.0.1",
      "port": 10085,
      "protocol": "dokodemo-door",
      "settings": {
        "address": "127.0.0.1"
      }
    },
    {
      "tag": "socks-in",
      "listen": "127.0.0.1",
      "port": 10808,
      "protocol": "socks",
      "settings": {
        "auth": "noauth",
        "udp": true
      }
    }
  ],
  "outbounds": [
    {
      "tag": "proxy-a",
      "protocol": "vless",
      "settings": {
        "vnext": [
          {
            "address": "node-a.example",
            "port": 443,
            "users": [
              {
                "id": "11111111-1111-1111-1111-111111111111",
                "encryption": "none"
              }
            ]
          }
        ]
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality"
      }
    },
    {
      "tag": "proxy-b",
      "protocol": "vless",
      "settings": {
        "vnext": [
          {
            "address": "node-b.example",
            "port": 443,
            "users": [
              {
                "id": "22222222-2222-2222-2222-222222222222",
                "encryption": "none"
              }
            ]
          }
        ]
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality"
      }
    },
    {
      "tag": "proxy-c",
      "protocol": "vless",
      "settings": {
        "vnext": [
          {
            "address": "node-c.example",
            "port": 443,
            "users": [
              {
                "id": "33333333-3333-3333-3333-333333333333",
                "encryption": "none"
              }
            ]
          }
        ]
      },
      "streamSettings": {
        "network": "tcp",
        "security": "reality"
      }
    }
  ],
  "routing": {
    "domainStrategy": "AsIs",
    "rules": [
      {
        "type": "field",
        "inboundTag": [
          "socks-in"
        ],
        "outboundTag": "proxy-a"
      },
      {
        "type": "field",
        "inboundTag": [
          "api-in"
        ],
        "outboundTag": "api"
      }
    ]
  }
}

这里有一个容易忽略的配置关系:stats 对象本身可以是空对象,但出站标签必须稳定,脚本才能通过固定名称查询计数。若订阅更新后把 proxy-a 改成随机标签,脚本会得到“统计项不存在”,随后可能误判节点无流量并触发错误切换。建议在订阅转换或配置生成阶段建立稳定的内部标签,不要直接把显示名称当作统计键。

API 入站

协议
dokodemo-door
监听地址
127.0.0.1
监听端口
10085
管理标签
api

只供本机脚本访问,不应绑定 0.0.0.0。

节点出站

标签
proxy-a / proxy-b
协议
VLESS
传输
TCP + Reality
统计键
outbound>>>标签>>>traffic

标签一旦交给脚本使用,就应避免频繁改名。

用命令查询累计流量与速率

Xray-core 自带的命令行工具可以通过 API 查询统计项。不同发行包的可执行文件名称可能是 xray 或带平台后缀的核心文件;先执行 xray version 确认命令可用,再查询 API。下面的命令查询 proxy-a 的上行和下行累计字节数:

xray api statsquery \
  --server=127.0.0.1:10085 \
  -pattern="outbound>>>proxy-a>>>traffic>>>"

xray api statsquery \
  --server=127.0.0.1:10085 \
  -pattern="outbound>>>proxy-b>>>traffic>>>"

返回结果通常会包含统计名称和整数值。脚本不应只读取一次结果,因为累计值没有时间维度。更可靠的做法是每隔 5 秒记录一次快照,计算两次快照的差值。如果 uplinkdownlink 都没有增长,再结合 HTTPS 探测失败,才把节点视为可疑。

#!/usr/bin/env bash

API="127.0.0.1:10085"
NODE="proxy-a"

xray api statsquery \
  --server="$API" \
  -pattern="outbound>>>$NODE>>>traffic>>>"

如果返回“找不到统计项”,先确认核心启动日志中没有 JSON 语法错误,再检查是否启用了 StatsService,最后核对出站标签是否完全匹配。标签区分大小写,proxy-aProxy-Aproxy_a 是三个不同的名称。另一个常见原因是节点尚未产生任何流量,某些版本不会提前创建对应的统计记录。

健康检查与节点评分模型

自动切换不应该使用单一指标。延迟最低的节点不一定稳定,流量最高的节点也不一定当前可用。建议为每个节点保存以下状态:最近一次 HTTPS 探测耗时、连续失败次数、最近一次成功时间、5 秒采样窗口内的上下行增量,以及当前是否处于冷却期。

  • 主动可达性:通过指定代理出站访问一个稳定的 HTTPS 地址,设置 3 至 5 秒超时,验证 TCP、TLS 和 HTTP 状态码。
  • 延迟:记录完整请求耗时,不要把本地 DNS 查询时间单独当成节点延迟。
  • 连续失败:建议连续失败 3 次才切换,避免一次临时丢包造成抖动。
  • 恢复确认:被标记为故障的节点至少连续成功 2 次后,才重新加入候选。
  • 冷却时间:切换后等待 30 至 60 秒,不要立即再次选择刚刚失败的节点。

一个简单的评分模型可以把延迟、失败惩罚和近期吞吐结合起来。例如将延迟控制在 50 至 2000 毫秒范围内,使用较低延迟获得较高分;连续失败时直接扣除 100 分;近期没有统计增量时不立刻扣分,而是交给主动探测决定。这样可以避免“空闲节点因为没有流量而被误判”的问题。

能够覆盖 TCP 建连、TLS 握手、代理出站和目标 HTTP 响应,适合承担主要判活职责。

适合:周期性健康检查、自动切换

读取成本低,可以判断出站是否持续承载流量,但无法证明没有流量就是节点故障。

适合:辅助评分、容量观察

实现简单,但服务器可能屏蔽 ICMP,且 ICMP 可达不代表代理协议、TLS 和目标网站可用。

适合:粗略网络基线

动手编写节点切换脚本

为了让示例不依赖某个客户端的专有界面,下面使用“生成当前节点配置并重载核心”的方式演示。脚本先对候选节点执行探测,再根据连续失败阈值选择得分最高者;切换动作写入一个状态文件,并调用外层服务管理器重启或重载 Xray。生产环境中可以把最后一步替换成现有的进程管理命令,但不要在脚本中无条件杀掉所有名为 Xray 的进程。

  1. 固定节点标签

    为每个出站保留稳定标签,例如 proxy-aproxy-bproxy-c,并在脚本数组中维护标签与显示名称的对应关系。

  2. 准备探测地址

    选择响应稳定的 HTTPS 地址,使用小响应体即可。探测地址应与实际业务相近,避免只测试一个在特定网络中被缓存或拦截的域名。

  3. 记录失败次数

    为每个节点保存成功时间和连续失败次数,单次超时只增加计数,不立即执行切换。

  4. 计算候选评分

    剔除冷却期节点,再按探测延迟、失败次数和近期流量增量排序,选择最高分节点。

  5. 应用新配置

    只有候选节点与当前节点不同,并且达到切换条件时才写入状态文件,随后执行受控重载。

下面的 Bash 示例使用本地 HTTP 代理端口 10809 做实际探测。它假设不同节点已经分别配置了临时代理入口,或者配置生成器会根据 ACTIVE_NODE 选择对应出站。若现有配置只有一个固定的 SOCKS 入站,则不能仅修改环境变量就让已经建立的连接改变出站;已存在的 TCP 连接通常需要自然结束或由应用重新建立。

#!/usr/bin/env bash
set -euo pipefail

STATE_FILE="/var/lib/xray/active-node"
FAIL_DIR="/var/lib/xray/failures"
CHECK_URL="https://example.com/"
COOLDOWN=45
MAX_FAIL=3

mkdir -p "$FAIL_DIR"

nodes=("proxy-a" "proxy-b" "proxy-c")

check_node() {
  local node="$1"
  local start end elapsed
  start=$(date +%s%3N)

  if curl --proxy "http://127.0.0.1:10809" \
      --connect-timeout 3 \
      --max-time 5 \
      --silent --show-error --fail \
      "$CHECK_URL" >/dev/null 2>&1; then
    end=$(date +%s%3N)
    elapsed=$((end - start))
    echo "$elapsed"
    return 0
  fi

  return 1
}

current=""
if [[ -f "$STATE_FILE" ]]; then
  current=$(cat "$STATE_FILE")
fi

best=""
best_latency=999999

for node in "${nodes[@]}"; do
  if latency=$(check_node "$node"); then
    fail_file="$FAIL_DIR/$node"
    rm -f "$fail_file"

    if (( latency < best_latency )); then
      best="$node"
      best_latency="$latency"
    fi
  else
    fail_file="$FAIL_DIR/$node"
    count=0
    [[ -f "$fail_file" ]] && count=$(cat "$fail_file")
    echo $((count + 1)) > "$fail_file"
  fi
done

if [[ -z "$best" ]]; then
  echo "no healthy node; keep current=$current" >&2
  exit 0
fi

if [[ "$best" == "$current" ]]; then
  echo "keep node=$current latency=${best_latency}ms"
  exit 0
fi

echo "$best" > "$STATE_FILE"

# 由配置生成器读取 ACTIVE_NODE,再执行受控重载。
export ACTIVE_NODE="$best"
/usr/local/bin/render-xray-config --active "$ACTIVE_NODE"
/usr/bin/systemctl reload xray

echo "switched $current -> $best latency=${best_latency}ms"

这个示例展示了决策骨架,但仍需要补上“每个节点独立探测”的实现。若所有探测请求都经过当前活动节点,脚本只能判断当前出站,不能公平比较备用节点。常见做法是为每个节点建立独立的临时出站或临时入站,再让探测请求明确指定对应标签;另一种做法是使用独立的 Xray 测试进程,加载同一份节点参数但只启用一个候选出站。不要把多个节点都写入同一个代理入口,却在脚本中假设 curl 会自动选择指定标签。

切换动作、重载与故障回退

Xray API 的动态能力并不意味着所有配置项都适合在运行中修改。HandlerService 更适合管理入站和出站处理器;路由规则、DNS、传输层和 Reality 参数的复杂变更,通常更适合由脚本生成完整 JSON 后进行配置校验,再通过服务管理器受控重载。对于桌面端 v2rayN 或 v2rayNG,自动修改核心配置还可能被订阅更新覆盖,因此应把脚本放在配置生成层,而不是直接编辑客户端维护的原始订阅文件。

如果使用完整配置重载,至少要执行三项保护。第一,写入临时文件后运行 xray run -test -config 或当前版本支持的配置校验命令,校验通过后再替换正式文件。第二,保存上一个已知可用配置,切换后的首次探测失败时可以立即恢复。第三,设置最短切换间隔,例如 45 秒,防止两个节点之间反复来回切换。

tmp="/etc/xray/config.json.next"
current="/etc/xray/config.json"
backup="/etc/xray/config.json.last-good"

render-xray-config --active "$ACTIVE_NODE" > "$tmp"

if xray run -test -config "$tmp"; then
  cp "$current" "$backup"
  mv "$tmp" "$current"
  systemctl reload xray
else
  echo "config validation failed; keep current configuration" >&2
  rm -f "$tmp"
  exit 1
fi

正常切换

触发条件
连续失败 ≥ 3 次
候选条件
成功探测 ≥ 2 次
冷却时间
45 秒
配置动作
校验后重载

切换前后都记录节点标签、延迟和失败计数。

全部失败

当前节点
保持不变
备用状态
等待下一轮探测
回退文件
last-good
告警条件
连续 5 轮失败

不要在所有节点失败时随机写入一个未经验证的配置。

日志、权限与验证方法

完成配置后,先验证 API 是否真正可用,再验证节点切换。可以从三个层次观察结果:核心层查看 API 入站与服务初始化日志;统计层查询固定标签的累计计数;业务层通过指定代理访问测试地址。三层结果必须相互印证,不能只看到脚本打印“switch success”就认为流量已经切换。

  • API 层:确认 127.0.0.1:10085 正在监听,并且脚本账户具备读取权限。
  • 统计层:切换前后分别查询 proxy-aproxy-b 的 uplink 与 downlink,确认新节点计数能够增长。
  • 连接层:使用代理访问测试地址,记录 HTTP 状态码和完整请求耗时。
  • 回退层:人为暂停一个候选节点,确认连续失败 3 次后切换,并且不会在每个轮询周期重复重载。

报错: failed to dial api server: connection refused

原因与解法:API 入站没有监听、端口填写错误或核心尚未启动;先检查 10085 的监听状态和 Xray 启动日志,再核对脚本中的 --server 参数。

报错: Failed to query stats: not found

原因与解法:统计服务未启用、统计键拼写错误,或该出站尚未产生流量;检查 StatsServicestats 对象和出站标签,并先产生一次测试流量。

报错: xray run -test failed

原因与解法:生成的新 JSON 存在语法错误、重复标签或字段类型不正确;保留临时文件,使用带行号的 JSON 工具定位错误,不要直接覆盖当前可用配置。

报错: health check succeeded but traffic did not switch

原因与解法:探测成功只代表探测路径可用,当前已有连接仍可能继续使用旧出站;确认路由规则、活动节点状态和新连接是否重新建立。

最后需要注意连接生命周期。自动切换通常只影响新建连接,已经建立的 TCP、WebSocket 或长连接不会因为统计标签改变而自动迁移。对于浏览器短连接,切换效果可能很快显现;对于下载任务、数据库连接和长轮询业务,则应由上层应用实现重连。脚本应记录切换时间、旧节点、新节点、探测延迟和触发原因,方便区分“代理切换成功但应用没有重连”和“新节点本身仍然不可用”。

下载客户端