// LATEST

「已连接,但打不开 YouTube」——一次 sing-box 热重载故障的完整排查

暂无标签

现象

一句话工单:连上了,但访问不了 YouTube。

这类描述最麻烦的地方在于它同时兼容好几种完全不同的故障:节点挂了、分流规则把它送错了出站、DNS 被污染、或者隧道压根没在工作只是图标亮着。得靠日志分辨。

第一个方法论:用出站 tag 判断「哪份配置在生效」

这个 app 的移动端是两段式启动:先用内嵌的 bootstrap 配置起一个隧道(只为了让 SSO 登录能出去),登录拿到订阅后再 hot-reload 到主配置。好处是 VPN 图标不闪断、不用重新授权。

关键在于两份配置的节点协议不同:

bootstrap主配置
协议trojananytls
节点名HK-01/02同名,但端口不同
urltest[...] / block

所以 sing-box 日志里的出站 tag 就是一枚指纹:

grep -oE "outbound/[a-z]+\[[^]]+\]" tunnel.log | sort | uniq -c | sort -rn

第一台设备的结果:

  46 outbound/direct[direct]
  14 outbound/trojan[HK-01]
   3 outbound/trojan[HK-02]

anytls 零次,urltest[...] 零次。 主配置的节点一次都没被用过。日志里明明写着 mobile bootstrap: hot-reloaded to main config 成功。

第二个方法论:按天聚合,找回归时间点

第二台设备的 tunnel.log 是累积的,有两个月的数据。按天统计出站 tag:

日期出站 tag
7/23 – 8/5urltest[GROUP-A]urltest[GROUP-B]block[block]direct
8/19 – 9/18只有trojan[HK-01/02] + direct
9/3、9/5、9/6偶现urltest[google]

7 月底之前主配置能正常接管,8/19 之后基本只剩 bootstrap。偶尔成功说明是竞态,不是必然失败。这个日期直接可以拿去 bisect。

顺带一个教训:日志级别决定你能看到什么。 那两个月大部分时间 log level 是 error,只记录失败的连接。我第一遍看的时候因为只看到 direct 的失败,差点得出「只有 YouTube 走了 direct」的错误结论——实际上成功的连接一条都没打印。

用户看到的现象,和它的真实成因

bootstrap 配置的分流很简单:公司域名走代理,其余走 direct。所以主配置没接管的时候:

  • 公司内网、自家的服务 → 走 trojan 代理 → 正常
  • 其他一切,包括 YouTube → 走 direct → 从国内裸连

日志里是这样的:

ERROR open connection to m.youtube.com:443 using outbound/direct[direct]:
      dial wlan0 (37): dial tcp 174.132.167.252:443: i/o timeout
ERROR open connection to i.ytimg.com:443 using outbound/direct[direct]:
      dial tcp 31.13.92.37:443: i/o timeout

那几个目标 IP(174.132.167.25231.13.92.37208.101.21.43)是典型的 DNS 污染地址。说明连 fake-ip 劫持都没生效,手机拿着污染 IP 从 Wi-Fi 裸连 YouTube。

用户只抱怨 YouTube,是因为公司的东西还能用,所以「VPN 看起来是好的」。 这个信息不对称把排查方向带偏了整整一轮。

我的第一个误判,以及一个手写的 SRS 解码器

我最初的结论是:YouTube 被钉在一个没有备份的单节点上。

依据是配置里第 20 条路由规则:

{ "outbound": "AI-GROUP", "rule_set": ["geosite-google"] }

AI-GROUP 是个只有一个成员的 urltest:

{ "type": "urltest", "tag": "AI-GROUP", "outbounds": ["NODE-X"] }

单成员 urltest 没有任何 failover。如果 NODE-X 挂了,Google 全家桶整组全黑,而其他流量走 final 的港区组照常工作——完美符合「只有 YouTube 不通」。

为了确认 geosite-google 到底包不包含 YouTube,我把打包在 assets 里的 .srs 文件解了出来。sing-box 的 rule-set 二进制格式是:SRS magic + 版本字节 + zlib 流,里面是一个 succinct trie(leaves / labelBitmap / labels 三个数组),域名以反转形式存储。写了段 Python 遍历它:

raw = zlib.decompress(open('geosite-google.srs','rb').read()[4:])
# ... 解析 leaves / labelBitmap / labels,BFS 走 trie

结果 1754 条,确实包含 youtube.comyoutu.beytimg.comggpht.comgooglevideo.com,还有两条 *.googlevideo.com 的正则。所以规则匹配没问题。

但这个结论是错的。 我用作证据的两次 NODE-X 连接失败:

10:42:32 ERROR dial tcp 203.0.113.10:19650: operation was canceled
10:45:52 ERROR dial tcp 203.0.113.10:19650: operation was canceled

两个时间戳精确落在两次 hot-reload 的瞬间operation was canceled 是旧实例被拆除时的取消,不是节点故障。

后来拿到 iOS 的日志,anytls[NODE-X] 有 56 条成功连接——节点一直是活的。锅确实在 Android 的 reload。

教训:operation was canceled / context canceled 这类错误要先看时间戳落在哪。它们经常是生命周期事件的副产物,不是故障本身。

根因一:默认网卡推送晚了一拍

Android 上 sing-box 跑在 VpnService 里,必须由宿主把「底层物理网卡」告诉它,否则每个 outbound 不知道该绑哪张网卡。注入的配置里有:

"route": { "auto_detect_interface": true, "override_android_vpn": true }

libbox 通过 PlatformInterface.startDefaultInterfaceMonitor(listener) 要这个信息。而 reload 会重建内部的 NetworkManager,旧的默认接口状态全部丢失

代码里其实有兜底,注释写得很清楚:

// 关键:reload 后 sing-box 内部 NetworkManager 重建,默认接口信息丢失。
// 主动把当前 underlying 接口推给 listener,否则 direct outbound 全部
// 报 "no available network interface"。
currentPlatform?.refireDefaultInterface()

问题是这个兜底在时序上不可能生效

  1. refireDefaultInterface() 是在 startOrReloadService 返回之后才调的
  2. 它内部走 mainHandler.post { ... }
  3. reloadConfig 本身跑在主线程

所以那个 post 进去的 Runnable 必须等 reloadConfig 整个返回才有机会执行。而 sing-box 在 startOrReloadService 内部就已经开始拨号了(DNS bootstrap、remote rule-set 下载)。等推进去,早就晚了。

日志里的证据:

DEBUG router: updating rule-set geosite-cn from URL: https://.../cn.srs
DEBUG dns: lookup failed for ...: dial UDP connection: no available network interface
<日志到此完全断掉>

对照上游 sing-box-for-android 的做法——startDefaultInterfaceMonitor 里同步推,就地重试,不走 handler:

override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) {
    DefaultNetworkMonitor.setListener(listener)   // 立刻同步推
}

private fun checkDefaultInterfaceUpdate(newNetwork: Network?) {
    for (times in 0 until 10) {
        val lp = connectivity.getLinkProperties(newNetwork) ?: { Thread.sleep(100); continue }
        val idx = try { NetworkInterface.getByName(lp.interfaceName).index }
                  catch (e: Exception) { Thread.sleep(100); continue }
        listener.updateDefaultInterface(lp.interfaceName, idx, false, false)
    }
}

修法就是对齐它:用一个 inServiceStart 标志区分「CommandServer 构造期」(此时碰 listener 会 SIGABRT,只能缓存)和「startOrReloadService 进行中」(Go 侧状态已就绪,可以同步推)。

根因二:remote rule-set 是 start 的致命依赖

修完接口推送,隧道还是起不来,报:

startOrReloadService failed: initialize rule-set[0]: initial rule-set:
  geosite-cn: unexpected status: 404 Not Found

这条错误暴露了一个之前没意识到的事实:sing-box 把 remote rule-set 的初始化放在 start() 里同步等,下载失败会让整个 start 失败。

而 tun 设备此时已经被系统建好了。所以:

  • 下载 → 用户看到「已连接,但什么都打不开」
  • 下载失败 → start 失败,隧道彻底是个黑洞

实测冷启动代价:

14:52:15  start() 开始
14:52:25  WARN  router: initialize rule-set take too much time to finish!
14:52:43  INFO  router: updated rule-set geosite-cn
14:52:43  INFO  inbound/tun[tun-in]: started at tun0       ← 紧跟着 tun 才起
14:52:43  INFO  sing-box started (28.38s)

28.38 秒。 而订阅里那个 URL 在第二台设备上指向 raw.githubusercontent.com,国内根本连不上——28 秒变成必然失败。

这也解释了为什么 8/19 之后主配置再没生效过。

一个我走的弯路

我第一个修法是:把 rule-set URL 重写到自建镜像。我从第一台设备的服务端配置里看到 {base}/api/geo/srs/geosite/cn.srs,就当成通用规律,重写到当前 endpoint 上。

结果第二台的 endpoint 是另一个 host,那个路径在它上面不存在——我把「不可达」换成了「404」,两者都是 fatal

教训:基于一个样本推断出的 URL 规律不是规律。这种改动会把一个可诊断的失败换成另一个,看起来像修好了(错误信息变了),实际没有。

第二个弯路

接着我把 geosite-cn.srs 打包进 assets。原生层本来就有「存在 <tag>.srs 就改写成 type: local」的逻辑,所以这招立刻见效,start 回到 0.06s。

但用户一句话点破了:「还会有其他的 geosite,你要不先不要打进来,走 remote?」

对的。订阅可以引用任意 geosite-<code> / geoip-<code>,打包只覆盖固定几个,来个新 tag 照样走 remote、照样可能把隧道弄死。打包治不了根。

而且还有个我没料到的副作用:我从 SagerNet 拉的 geosite-cn.srs55,615 字节,而他们服务端自己那份是 444,977 字节——差了 8 倍。等于悄悄把用户的分流数据换掉了。

最终修法:把下载挪出 start

正确的位置是在 bootstrap 隧道已经通、reload 之前下载:

Step 1  启 bootstrap 隧道(内嵌配置,无 remote rule-set → 0.04s 起)
Step 2  SSO 登录 → 拉主配置          ← 此时网络已经通了
        ★ 在这里把所有 remote rule-set 下下来写成 <tag>.srs
Step 3  reload 到主配置

零原生改动——复用已有的 local 改写逻辑,而且任意 tag 都覆盖。实测同样冷启动条件下:

RuleSetPrefetch: geosite-cn     cached locally (444977 bytes)
RuleSetPrefetch: geoip-cn       cached locally (36934 bytes)
RuleSetPrefetch: geosite-google cached locally (7912 bytes)
sing-box started (0.08s)        ← 28.38s → 0.08s

几个必须要有的保护:

  • 内容校验(最重要):binary 认 SRS magic、source 认 JSON 可解析。把 404 的 HTML 页面写进 <tag>.srs 会让注入优先用它,而 sing-box 解析不了 rule-set 是 start 失败——比留着 remote 严格更糟。
  • 失败即不写文件,注入保留 remote,退化成改之前的行为,绝不更差。
  • 时间上限:单文件 8s、总共 15s,超了放弃继续 reload。不能把「优化启动速度」变成新的卡点。
  • 原子写:临时文件 + rename,避免 sing-box 读到半个文件。

根因三:失败没人看见(这才是它能活两个月的原因)

前两个根因都是普通 bug。真正让它潜伏两个月的是这个:

} catch (e: Exception) {
    Log.e(TAG, "reloadConfig: startOrReloadService failed: $e")
    statusListener?.invoke("disconnected")
    stopSelf()
    return
}

看起来有处理。但 reload 是上层通过 Intent 发的:

private fun reloadVpnConfig(config: String) {
    startService(Intent(this, LbVpnService::class.java).apply {
        action = LbVpnService.ACTION_RELOAD
        putExtra(LbVpnService.EXTRA_CONFIG, config)
    })
}
// 紧接着 result.success(null)

fire-and-forget。 原生端抛异常、stopSelf() 把隧道拆了,上层照样打印:

mobile bootstrap: hot-reloaded to main config
Android VPN tunnel started

所以两个月里,每一份用户日志都显示「热重载成功」。没人有理由去怀疑它。

修法两部分:

  1. 失败不再制造黑洞:把上一份成功装载过的配置重新 apply,保住一个能用的隧道。(注意不是「保留旧实例」——libbox 在 start 新实例前就把旧的关了,只能重新 apply。)
  2. 错误回传:原生侧记 lastError,通过 status 事件的 error 字段带给上层,UI 显示真实原因而不是一句 disconnected
这是本次最值得记的一条:一个失败路径如果上层收不到,它的存活期等于「有人愿意翻原始日志」的间隔。这个案例里是两个月。

顺手抓到的两个问题

tun fd 每次 reload 漏一个

openTun 原来是裸赋值:

vpnService.fileDescriptor = pfd   // 直接覆盖,没关旧的

stopVpn 只关当前那一个。所以每次 reload 漏一个 ParcelFileDescriptor + 一个 tun 设备。日志里能直接看到堆积:

networks=[dummy0, wlan0, lo]
networks=[dummy0, wlan0, lo, tun0]
networks=[dummy0, wlan0, tun1, lo, tun0]
networks=[tun3, dummy0, wlan0, tun1, lo, tun2, tun0]

修的时候有个时序讲究:不能在 openTun 里立刻关旧 fd。libbox 是先给新实例开 tun、再拆旧实例,立刻关会让还在读它的旧实例拿到 EBADF。得先存起来,等 startOrReloadService 返回、旧实例确实关掉了再 close。

有意思的是上游没这个问题——因为他们根本不做 in-place reload(下一节)。

Doze 期间 sing-box 照跑

配置里 8 个 urltest 组、interval: 300s,最大的组一轮探 7 个节点。息屏进 Doze 之后这些全在跑,而那段时间没有任何用户流量。

libbox 有现成的 pause() / wake(),两端都没接。Android 监听 ACTION_DEVICE_IDLE_MODE_CHANGED,iOS 用 NEPacketTunnelProvidersleep / wake 回调。

一个容易漏的细节:start 和 reload 成功后要重新施加一次当前状态。新建/reload 出来的实例默认是 awake 的,而后台拉起时设备可能已经在休眠里——只靠事件,这种情况永远不会 pause。

平台对比:为什么 iOS 没事

同一套订阅、同样的路由规则,iOS 完全正常(anytls[NODE-X] 56 条连接、block[block] 58 条)。差别在 reload 的实现方式:

iOS ——完整重建,且失败会上报:

service.close()                                                    // 显式关旧的
LibboxNewService(configContent, SingboxPlatformInterface(self), &e) // 全新实例 + 全新 platform interface
try newService.start()                                              // 失败 return "start-failed"

Android ——复用同一个 CommandServerstartOrReloadService,再补一句异步的接口推送。

更关键的是:那个会丢的状态只在 Android 存在。iOS 的 startDefaultInterfaceMonitor 是空实现,underNetworkExtension() 返回 true——NEPacketTunnelProvider 自己管底层路由,根本没有「默认网卡」这个会丢的东西。所以 no available network interface 这个故障模式 iOS 天然不存在。

一个反向的参考

翻上游的 Flutter 封装时发现:

override fun serviceReload() {
    restart()                            // ← 改成整个服务重启
//  runBlocking { serviceReload0() }     // ← 原来用 startOrReloadService 的实现,注释掉了
}

旁边留着「修复 serviceReload」的注释。他们也踩了 in-place reload 的坑,选择直接放弃、改成全量重启。

但我们不能照抄:全量重启会撤掉 VpnService、VPN 图标闪断、还可能重新弹权限,而「保住图标、不重新授权」正是这套 bootstrap 设计的目的。所以只能把 in-place reload 修对。

调试环境本身的坑

这部分占了实际时间的一大半,值得单独记。

签名与版本门禁。 设备上是 release 签名包,本地没有 keystore,debug 包签名不匹配无法覆盖安装;versionCodeversionName 推导(major*10000 + minor*100 + patch),debug 默认版本号更低直接被判 downgrade 拒绝。最后只能卸载重装——代价是用户那台设备的登录态、日志、audit DB 全没了

打包参数。 我第一次用裸的 flutter build apk --debug,结果 app 报「获取不了配置文件」。日志说得很明确:

TenantService: enroll failed (decryptFailed) —
  ENC1/ENC2 content requires a 64-hex decryption key, but none was configured

租户信息是运行时远程拉取的(那步成功了),但内容是加密的,解密密钥要在编译期通过 --dart-define-from-file=<secrets>.json 注进去。构建脚本里本来就带着这个参数,我图省事绕过了脚本。

教训:项目有构建脚本就用它。绕过去省下的几秒钟,换来的是一次误诊 + 一次不必要的卸载。

启动一个未导出的服务。 想用指定配置测试,需要走 ACTION_START_FROM_CACHE(磁贴的后台启动路径)。但服务未导出,am start-foreground-service 被拒:

Error: Requires permission not exported from uid 10xxx

cmd statusbar click-tile 在 MIUI 上不存在。最后的路子是:cmd statusbar add-tile 把磁贴加进快捷面板 → expand-settings 展开 → 截图定位图标坐标 → input tap。确认图标是哪个的办法是去仓库里看 ic_tile_vpn.xml(盾牌内嵌锁)。

测试配置被覆盖。 头两次测试都失败,因为点磁贴时 MIUI 顺带唤起了 app UI,Dart 侧自己跑了一遍「拉后端配置 → reload」,把测试配置换掉了。判断方法还是出站 tag——测试配置独有的 [MATCH][GROUP-C][GROUP-D] 对上后端独有的 [PROXY][AUTO]。解法是先 am force-stop,磁贴那条路只启服务不启 Activity,Dart 压根不会运行(grep -c "I/flutter" = 0 可验证)。

充电时永远不进 Doze。 用户手动息屏测省电,日志里一条 device idle 都没有。原因在系统状态里:

mState=ACTIVE  mScreenOn=true  mScreenLocked=false  mCharging=true

adb 调试线在供电,Android 充电状态下不进 Doze。必须强制模拟:

adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle     # → device idle — sing-box paused
adb shell dumpsys deviceidle unforce        # → device active — sing-box woken
adb shell dumpsys battery reset             # 记得还原

附带:CI artifact 上传超时

最后还撞上一个 CI 的问题:

Failed to CreateArtifact: Failed to make request after 5 attempts:
  Request timeout: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact

这是 actions/upload-artifact 的上传失败,不是编译失败——CreateArtifact 是上传字节之前建 artifact 的那次 API 调用,属于服务侧抖动,重跑基本就好。

但顺手发现 workflow 里一个真问题:上传 Libbox.xcframework 那步没有 if: 条件,而它前面的「装 Go」和「重编 xcframework」都有 cache-hit != 'true'。所以即使缓存命中、内容跟上次一模一样,还是会把整个 xcframework 重新上传一遍——白花时间,还多暴露一次在抖动里。而且它是个保留 3 天的调试产物,不该让它把 20 分钟的 iOS 构建拖挂(continue-on-error: true)。

顺带:uses: actions/upload-artifact@v4 是浮动大版本标签,已经自动拿最新 4.x 了,所以「升级到 4.6」是空操作。

给排查同类问题的人 — 经验清单

  1. 「连上了但打不开 X」先确认哪份配置在生效,而不是先查 X 的规则。出站 tag 的 histogram 是最快的指纹。
  2. 累积日志按天聚合,能直接定出回归时间点,比读代码找 bug 快得多。
  3. 注意日志级别的取样偏差error 级只记录失败的连接,很容易让人以为「只有失败的那类流量走错了」。
  4. operation was canceled / context canceled 先看时间戳。落在生命周期事件(reload / stop)上的,是副产物不是故障。
  5. fire-and-forget 的跨进程调用必须有回执。一个上层收不到的失败路径,存活期等于「有人愿意翻原始日志」的间隔。
  6. 不要基于单个样本推断 URL / 路径规律。把一种 fatal 换成另一种 fatal,比不改更难诊断。
  7. 兜底代码要检查时序,不只是存在性mainHandler.post 在「调用方就在主线程」的场景下等于「等我返回后再说」。
  8. 看上游怎么做。上游放弃了 in-place reload 这个信息,本身就说明这块脆——即使你因为产品需求不能照抄。
  9. 别绕过项目的构建脚本。
  10. 测省电要强制模拟 Doze,插着调试线息屏是测不出来的。
  11. 改动要能证伪。本次每一项都有真机日志对照:28.38s → 0.08sclosed 1 superseded tun fd(s)device idle — sing-box paused。没跑到的路径(reload 失败回滚、iOS 休眠)就明确标注没验,不含糊过去。

还没有评论

欢迎留下你的观点,保持交流的清晰和友好。

写下评论