Tunnelto 源码解析 #15:可观测性与运维:Tracing、Honeycomb、健康检查与错误响应设计

前面几篇文章中,我们已经分析了 tunnelto 的核心运行机制:

客户端控制通道
远端请求入口
Host Header 分发
StreamId 多路复用
DynamoDB 鉴权
ReconnectToken
本地调试面板
自托管部署
多实例扩展

这些内容回答了一个问题:

tunnelto 是怎么工作的?

但当一个内网穿透服务真正运行起来后,还会遇到另一个问题:

它出问题时,怎么排查?

比如:

客户端为什么连不上?
某个 tunnel 为什么返回 404?
为什么公网请求没有转发到本地?
为什么本地服务已经启动,但远端还是失败?
多实例时为什么找不到 host?
某个客户端是不是已经断开?
某个 stream 是否正常结束?

这些问题都属于可观测性与运维范畴。

本篇就围绕 tunnelto_server 的运维设计展开,重点分析:

tracing 日志
Honeycomb 上报
remote_trace span
健康检查
错误响应
连接清理日志
stream 生命周期日志
多实例代理错误

核心源码包括:

tunnelto_server/src/main.rs
tunnelto_server/src/observability.rs
tunnelto_server/src/remote.rs
tunnelto_server/src/control_server.rs
tunnelto_server/src/network/server.rs
tunnelto_server/src/network/proxy.rs
tunnelto/src/introspect/console_log.rs

一、为什么内网穿透系统特别需要可观测性?

内网穿透系统的链路比普通 Web 服务更长。

普通 Web 服务大致是:

浏览器
  ↓
服务端

但 tunnelto 的链路是:

浏览器
  ↓
公网 tunnelto_server
  ↓
WebSocket 控制通道
  ↓
本地 tunnelto 客户端
  ↓
localhost 服务

如果请求失败,可能出问题的位置很多:

DNS 没解析到 tunnelto_server
Host Header 不合法
子域名没有在线客户端
客户端 WebSocket 已断开
多实例没有找到真正持有 host 的实例
跨实例 proxy 失败
客户端连接 localhost 失败
本地服务返回 500
远端 socket 提前关闭
ControlPacket 解析失败

所以 tunnelto 不能只输出一句:

request failed

它需要在不同阶段记录足够的信息,帮助开发者判断错误发生在哪一层。

这就是可观测性的价值。


二、tracing:服务端日志的基础

tunnelto_server 使用 Rust 生态里的 tracing 做日志和 span。

main.rs 中,服务端启动时会设置全局 subscriber。

如果没有配置 Honeycomb,只启用普通格式化日志:

tracing_subscriber::fmt::Layer
LevelFilter::INFO

如果配置了 Honeycomb,则会额外加上 telemetry layer。

这说明 tunnelto 的观测系统分两层:

本地日志:
  默认启用,用于控制台输出

远程 tracing:
  配置 HONEYCOMB_API_KEY 后启用,用于上报到 Honeycomb

即使你不使用 Honeycomb,服务端仍然会有基础日志。

这是一个不错的设计,因为自托管用户可以先靠控制台日志排查问题,不一定一开始就接入完整 APM 系统。


三、Honeycomb:可选的远程观测能力

main.rs 中,如果配置了:

HONEYCOMB_API_KEY

服务端会创建 Honeycomb 配置。

其中 dataset 是:

t2-service

并配置一些批量上报参数:

max_batch_size = 50
max_concurrent_batches = 10
batch_timeout = 1000ms
pending_work_capacity = 5000

然后把 Honeycomb telemetry layer 加入 tracing subscriber。

这说明 Honeycomb 是可选能力,不是服务启动的必要条件。

可以理解为:

没有 HONEYCOMB_API_KEY:
  输出本地 tracing 日志

有 HONEYCOMB_API_KEY:
  输出本地日志 + 上报 Honeycomb

对于自托管部署来说,可以先不配置 Honeycomb。

当请求量变大、实例变多、问题排查变复杂时,再接入远程观测系统。


四、observability.rs:remote_trace 的设计

observability.rs 中核心函数是:

remote_trace(source)

它会创建一个 tracing span。

这个 span 里包含几个关键字段:

id      当前服务端实例 ID
source  当前 span 来源
req     新生成的 TraceId

其中:

id = CONFIG.instance_id

instance_id 来自:

FLY_ALLOC_ID

如果没有设置,就生成一个随机 UUID。

这对多实例排查特别有用。

因为你需要知道:

这条日志来自哪台实例?
这个请求最初进入哪台实例?
跨实例代理后有没有到目标实例?

source 用来标记当前 span 属于哪个阶段。

例如:

remote_connect
process_tcp_stream
tunnel_to_stream
handle_websocket
tunnel_client
process_client
control_ping

这让你能从日志或 Honeycomb 中区分不同任务。


五、哪些地方使用了 remote_trace?

在服务端核心流程中,多个异步任务都会用:

.instrument(observability::remote_trace(...))

例如:

main.rs:
  remote_connect

remote.rs:
  process_tcp_stream
  tunnel_to_stream

control_server.rs:
  handle_websocket
  tunnel_client
  process_client
  control_ping

这很重要。

因为 tunnelto 大量使用 tokio::spawn() 启动异步任务。

如果没有 span,你很难把一组日志串起来。

有了 span 之后,至少可以知道:

这是哪个阶段的日志
来自哪个实例
属于哪个 remote_trace source

这对分析异步网络服务很有帮助。


六、服务端启动日志

服务端启动时会输出几类关键日志。

比如:

starting server!
started tunnelto server on 0.0.0.0:{control_port}
start network service on [::]:{internal_network_port}
listening on: [::]:{remote_port}

这些日志对应三个入口:

control_port:
  客户端 WebSocket 控制通道

internal_network_port:
  多实例内部查询服务

remote_port:
  外部浏览器请求入口

部署时第一步就应该检查这三个端口是否启动成功。

如果客户端连不上 /wormhole,先看 control port。

如果外部请求打不到 tunnel,先看 remote port。

如果多实例查找失败,先看 internal network port。


七、健康检查:不止一个 health check

tunnelto 有多个健康检查入口。

1. control server 的 /health_check

control_server.rs 中注册了:

/health_check

返回:

ok

这个健康检查用于控制服务器。

也就是判断:

客户端 WebSocket 控制入口对应的 HTTP 服务是否还活着。

如果你用负载均衡器代理 CTRL_PORT,可以用这个检查控制入口健康。


2. network internal service 的 /health_check

network/server.rs 中也注册了:

/health_check

同样返回:

ok

它用于内部实例间服务。

也就是判断:

NET_PORT 上的内部查询服务是否正常。

多实例部署时,这个检查很重要。

如果内部服务不健康,其他实例就无法询问:

你是否服务某个 host?

3. remote TCP 入口的特殊健康检查路径

remote.rs 中还有一个特殊路径:

/0xDEADBEEF_HEALTH_CHECK

如果远端 TCP 请求的 path 是这个值,服务端会直接返回:

HTTP/1.1 200 OK
Content-Length: 2

ok

这个健康检查不经过 tunnel 分发。

它用于判断 remote port 是否能正常接受 HTTP 请求。

也就是:

公网请求入口是否健康。

所以 tunnelto 实际上有三类健康检查:

control health:
  /health_check on CTRL_PORT

network health:
  /health_check on NET_PORT

remote health:
  /0xDEADBEEF_HEALTH_CHECK on PORT

这三者分别对应不同入口,不能混用。


八、为什么 remote health check 用特殊路径?

remote port 是外部浏览器请求入口。

正常请求需要 Host Header 来判断对应哪个 tunnel。

如果健康检查也走普通路径,就可能因为没有合法 Host 而返回:

Invalid Hostname

或者:

Tunnel Not Found

所以 remote.rs 在解析请求头后,先检查 path 是否等于特殊健康检查路径。

如果是,就直接返回 OK,不再进入 Host 校验和 tunnel 分发。

这让负载均衡器可以直接检查 remote port 是否活着,而不需要模拟某个真实 tunnel。


九、错误响应设计:把失败暴露给调用方

tunnelto_serverremote.rs 定义了多个 HTTP 响应常量。

它们不是给客户端 CLI 用的,而是直接返回给外部浏览器或调用方。

主要包括:

HTTP_REDIRECT_RESPONSE
HTTP_INVALID_HOST_RESPONSE
HTTP_NOT_FOUND_RESPONSE
HTTP_ERROR_LOCATING_HOST_RESPONSE
HTTP_TUNNEL_REFUSED_RESPONSE
HTTP_OK_RESPONSE

这些错误响应对应不同失败阶段。

这对排查问题非常关键。

因为你看到不同错误,就能知道问题大概发生在哪一层。


十、Invalid Hostname:Host Header 不合法

如果请求的 Host 不属于允许的根域名,或者无法从 Host 中提取合法子域名前缀,服务端返回:

HTTP/1.1 400

Error: Invalid Hostname

这说明错误发生在最前面:

Host Header 校验阶段

常见原因:

ALLOWED_HOSTS 没配置
访问的域名不属于 tunnel 根域名
反向代理没有正确转发 Host
本地 curl 没带 Host Header
域名拼写错误

如果看到这个错误,不要先查客户端,也不要先查本地服务。

应该先查:

请求 Host 是什么?
ALLOWED_HOSTS 是什么?
反向代理有没有保留原始 Host?

十一、Tunnel Not Found:没有在线客户端

如果 Host 合法,但服务端找不到对应客户端,会返回:

HTTP/1.1 404

Error: Tunnel Not Found

这个错误对应:

Host 已经解析成功
但 Connections 中没有对应 ConnectedClient
多实例查询也没有找到目标实例

常见原因:

客户端没有启动
客户端 WebSocket 已断开
子域名写错
请求进入了错误实例,多实例发现失败
客户端使用的是另一个 tunnel host
服务端刚重启,内存连接表清空

这个错误和 Invalid Hostname 不一样。

Invalid Hostname 是域名格式或允许域名问题。

Tunnel Not Found 是合法域名下找不到在线 tunnel。


十二、Error finding tunnel:多实例查找失败

当本机没有找到 host,服务端会尝试:

network::instance_for_host(host)

如果返回的错误不是 DoesNotServeHost,而是 DNS、请求、IO 等错误,服务端会返回:

HTTP/1.1 500

Error: Error finding tunnel

这通常说明多实例内部发现链路有问题。

例如:

FLY_APP_NAME 配置错误
global.{app}.internal 解析失败
NET_PORT 不通
内部服务挂了
实例间网络异常
内部查询超时

如果你是单实例部署,一般不会太关注这个错误。

如果你是多实例部署,一旦出现它,就要重点检查 internal network service。


十三、Tunnel says: connection refused.:客户端本地连接失败

如果服务端找到了客户端,也成功把请求发给客户端,但客户端连接本地服务失败,会返回:

HTTP/1.1 500

Tunnel says: connection refused.

这个错误非常常见。

它说明:

公网到服务端 OK
Host 分发 OK
客户端 WebSocket 在线 OK
但客户端连接 localhost 失败

常见原因:

本地服务没有启动
端口写错了
tunnelto --port 指错
本地服务只监听了其他地址
--use-tls 和本地服务协议不匹配

看到这个错误时,不应该去查 DNS 或服务端远端端口。

应该去客户端机器上检查:

curl http://localhost:8000
本地服务是否监听
tunnelto 参数是否正确

十四、Error proxying tunnel:跨实例代理失败

在多实例场景中,如果当前实例发现目标 host 在另一台实例上,会调用:

proxy_stream(instance, socket)

它会连接目标实例的 remote port。

如果连接失败,会返回:

HTTP/1.1 500

Error: Error proxying tunnel

这说明:

已经找到了目标实例
但当前实例无法把 TCP 流代理过去

常见原因:

目标实例 remote_port 不通
目标实例刚刚下线
实例间网络不通
remote_port 配置不一致
防火墙阻止内部访问

它和 Error finding tunnel 的区别是:

Error finding tunnel:
  找目标实例失败

Error proxying tunnel:
  找到了目标实例,但代理连接失败

十五、根域名访问重定向

如果用户访问的是允许的根域名,而不是某个子域名,比如:

tunnelto.dev

服务端会返回:

HTTP/1.1 301 Moved Permanently
Location: https://tunnelto.dev/

这说明根域名不参与 tunnel 分发。

普通 tunnel 请求应该是:

subdomain.tunnelto.dev

而不是:

tunnelto.dev

这个重定向设计可以避免根域名请求被误判为 tunnel 请求。


十六、从错误响应反推排查路径

这些错误响应很适合做运维排查表。

Error: Invalid Hostname
  ↓
检查 Host Header、ALLOWED_HOSTS、反向代理

Error: Tunnel Not Found
  ↓
检查客户端是否在线、subdomain 是否正确、多实例发现

Error: Error finding tunnel
  ↓
检查 FLY_APP_NAME、internal DNS、NET_PORT、network service

Error: Error proxying tunnel
  ↓
检查目标实例 remote_port、实例间网络、防火墙

Tunnel says: connection refused.
  ↓
检查客户端本地服务、端口、--use-tls

这就是清晰错误响应的价值。

它不只是告诉用户失败了,还能帮助用户判断失败发生在哪一段。


十七、连接入口日志:new remote connection

remote.rs 中,当服务端成功 peek 到 Host 后,会记录:

new remote connection

并带上:

host
forwarded_for

其中 forwarded_for 来自:

X-Forwarded-For

这个字段有助于判断真实访问来源。

如果服务端前面有反向代理或负载均衡器,X-Forwarded-For 可能包含真实客户端 IP。

运维时可以通过这条日志确认:

请求有没有进入 remote port?
Host 是什么?
反向代理有没有传 X-Forwarded-For?

如果完全没有这条日志,说明请求可能根本没到 tunnelto_server。


十八、peek request 日志:确认 Host 和 Path

peek_http_request_host() 找到 Host 后,会记录:

peek request

字段包括:

host
path

这条日志很适合排查 Host Header 问题。

例如你以为请求是:

abc.tunnel.example.com

但日志里显示:

host = tunnel.example.com

那说明反向代理或客户端请求没有带正确 Host。


十九、stream 生命周期日志

当服务端为远端请求创建 stream 时,会记录:

new stream connected

并带上:

stream_id

之后,process_tcp_stream() 会记录:

read N bytes
sent data packet to client
stream ended

tunnel_to_stream() 会记录:

tunnel refused
client tunnel not found
done tunneling to sink
stream closed, disconnecting

这些日志对应 stream 生命周期:

远端连接建立
  ↓
读取请求数据
  ↓
发给客户端
  ↓
客户端返回响应
  ↓
写回远端 socket
  ↓
stream 结束并清理

如果某个请求卡住,可以通过 stream_id 追踪它走到了哪一步。


二十、客户端连接日志:open tunnel

control_server.rs 中,客户端握手成功后,会记录:

open tunnel

字段包括:

client_ip
subdomain

这条日志非常重要。

它说明:

客户端已经成功通过鉴权
ServerHello::Success 已经发送
服务端已经准备把该 subdomain 登记到 Connections

如果用户说客户端连不上,运维第一步可以查有没有 open tunnel

没有这条日志,说明问题可能在:

客户端没有连到 control port
WebSocket 升级失败
ClientHello 解析失败
鉴权失败
subdomain 校验失败
blocked IP

有这条日志,但远端访问 Tunnel Not Found,则可能是:

请求进入了另一台实例
Connections 被清理
Host 和 subdomain 不一致

二十一、blocked IP 日志

control_server.rs 会读取客户端 IP,并检查:

CONFIG.blocked_ips

如果命中,会记录:

client ip is on block list, denying connection

然后关闭 WebSocket。

这适合用于屏蔽滥用客户端。

但也要注意:如果前面有反向代理,客户端 IP 的提取依赖:

Fly-Client-IP
X-Forwarded-For
remote addr

如果代理头不可信或没有正确传递,可能导致封禁策略不准确。


二十二、Ping 保活日志

控制服务器会周期性发送 Ping。

相关日志包括:

sending ping
Failed to send ping, removing client
pong

这些日志对应连接保活过程:

服务端发送 Ping
客户端回复 Ping
服务端收到后认为客户端仍在线

如果发送 Ping 失败,服务端会移除客户端连接。

这避免了死连接长期残留在 Connections 中。

从运维角度看,频繁出现 Ping 失败可能说明:

客户端网络不稳定
WebSocket 被代理关闭
服务端与客户端之间有连接超时
客户端进程频繁退出

二十三、ControlPacket 解析错误

process_client_messages() 从客户端读取消息后,会调用:

ControlPacket::deserialize()

如果失败,会记录:

invalid data packet

这通常说明:

客户端发来了无法识别的控制包
协议版本不匹配
连接中混入非 tunnelto 数据
数据损坏

如果你改造协议,或者客户端和服务端版本不一致,这类日志尤其重要。


二十四、连接清理日志

当客户端断开时,服务端会调用:

Connections::remove(client)

触发场景包括:

WebSocket close
读取客户端消息失败
向客户端写消息失败
Ping 发送失败
客户端 channel 关闭

连接清理很重要。

因为如果客户端已经断开,但 Connections 里还保留它,远端请求就会被错误地转发到一个不存在的客户端。

清理后,后续请求会返回:

Tunnel Not Found

这比让请求一直挂起更合理。


二十五、多实例观测重点

如果部署多实例,观测重点会更多。

除了普通请求日志,还要关注:

instance_id
network health check
HostQuery
instance_for_host
DoesNotServeHost
Error finding tunnel
Error proxying tunnel

多实例排查建议按这个顺序:

1. 客户端 open tunnel 发生在哪个 instance_id?
2. 远端请求进入了哪个 instance_id?
3. 本机 Connections 是否命中?
4. 没命中时,instance_for_host 是否找到目标实例?
5. 目标实例 NET_PORT 是否可访问?
6. proxy_stream 是否成功连接目标实例 remote_port?
7. 目标实例是否创建 ActiveStream?

没有 instance_id,多实例日志会非常难读。

这也是 remote_trace()CONFIG.instance_id 放入 span 的原因。


二十六、客户端侧也有简易观测

虽然本篇重点是服务端,但客户端也有一些观测能力。

前一篇讲过本地 dashboard。

此外,客户端命令行还会输出请求摘要。

introspect/console_log.rs 中有两个函数:

connect_failed()
log(request, response)

如果本地连接失败,会输出:

CONNECTION REFUSED

如果请求完成,会输出:

状态码    方法    路径

其中 2xx 状态码显示为绿色,其他状态码显示为红色。

这给开发者一个快速反馈:

请求是否到达本地?
本地服务返回了什么状态?
哪个路径被访问了?

服务端日志解决平台运维问题,客户端日志解决本地开发调试问题。


二十七、自托管时建议关注的日志

如果你自托管 tunnelto_server,我建议重点关注这些日志事件。

服务启动

starting server
started tunnelto server
start network service
listening on

确认三个入口端口正确启动。

客户端连接

open tunnel
new client connected
client ip is on block list

确认客户端是否成功建立 tunnel。

远端请求

new remote connection
peek request
new stream connected

确认请求是否进入 remote port,并成功创建 stream。

数据转发

read N bytes
sent data packet to client
forwarding to stream
done tunneling to sink

确认数据是否在服务端和客户端之间流动。

错误

invalid host specified
no tunnel found
failed to find instance
tunnel refused
client tunnel not found
invalid data packet
Error connecting to instance

这些是排查的关键入口。


二十八、从错误看系统边界

tunnelto 的错误响应其实也体现了系统分层。

Invalid Hostname:
  域名入口层失败

Tunnel Not Found:
  路由到客户端失败

Error finding tunnel:
  多实例发现失败

Error proxying tunnel:
  跨实例代理失败

connection refused:
  客户端到本地服务失败

invalid data packet:
  控制协议解析失败

每一个错误都对应不同层。

这对运维非常有价值。

因为排查复杂系统时,最怕的是所有问题都表现成同一个错误。

tunnelto 至少通过错误响应把问题大致分层了。


二十九、如果要增强可观测性,可以怎么做?

从商业化或生产运维角度看,tunnelto 当前的可观测性已经有基础,但还可以继续增强。

1. 增加 request_id 贯穿全链路

当前 remote_trace() 会生成 TraceId,但如果能把它和 StreamId、Host、client_id 统一关联,排查会更方便。

2. 输出结构化 JSON 日志

生产环境中,JSON 日志更容易被 Loki、ELK、Datadog、CloudWatch 收集。

3. 增加指标

例如:

当前在线客户端数
活跃 stream 数
每分钟请求数
Tunnel Not Found 次数
Connection Refused 次数
跨实例代理次数
Ping 失败次数

这些指标适合放到 Prometheus 或其他监控系统中。

4. 增加 stream 生命周期事件

例如:

stream_created
stream_first_byte_in
stream_first_byte_out
stream_closed
stream_duration
bytes_in
bytes_out

这样可以定位慢请求和大流量请求。

5. 区分用户可见错误和内部错误

现在部分错误直接返回简单字符串。

商业化产品可以返回更友好的页面,同时内部日志保留详细原因。

6. 增加告警规则

例如:

control health check 失败
remote health check 失败
Tunnel Not Found 激增
Ping 失败率升高
跨实例代理失败率升高

这些都能帮助提前发现问题。


三十、这一篇的核心结论

tunnelto 的可观测性和运维设计可以总结成一句话:

服务端使用 tracing 作为日志和 span 基础,
在配置 HONEYCOMB_API_KEY 时启用 Honeycomb 上报,
通过 remote_trace 为异步任务添加 instance_id、source 和 TraceId,
并在 control、remote、network 三个入口分别提供健康检查,
同时用明确的 HTTP 错误响应区分 Host 错误、tunnel 不存在、多实例发现失败、跨实例代理失败和客户端本地连接失败。

更简单地说:

tracing 负责记录过程
Honeycomb 负责远程观测
health_check 负责存活检测
错误响应负责告诉调用方失败发生在哪一层

对于内网穿透系统来说,这些设计非常重要。

因为它的链路很长,任何一段出问题,用户看到的都可能只是“访问失败”。

只有把系统拆成清晰的阶段,并在每个阶段留下日志、span 和错误响应,才能快速定位问题。


三十一、下一篇预告

下一篇是本系列最后一篇:

Tunnelto 源码解析 #16:从 Tunnelto 到自己的 Ngrok:如何基于源码二次开发一个内网穿透服务

下一篇会把前面所有内容串起来,从产品化和二次开发角度总结:

如果要做自己的内网穿透服务,哪些模块可以复用?
哪些地方必须重写?
如何设计自己的鉴权?
如何做自定义域名?
如何做计费和限流?
如何做多实例?
如何做管理后台?
如何提升安全性?

也就是从 tunnelto 源码学习,进一步思考如何做一个属于自己的轻量级 Ngrok。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

天天进步2015

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值