Tunnelto 源码解析 #14:多实例扩展:Fly.io Private Networking、内部发现与跨实例代理

前一篇文章中,我们分析了 tunnelto 的自托管部署方式,重点讲了 Docker、环境变量、端口规划和单实例限制。

这一篇继续往下看一个更进阶的问题:

如果 tunnelto_server 部署了多个实例,公网请求如何找到真正持有客户端连接的那台实例?

这是内网穿透服务从“个人自用工具”走向“平台化服务”时必须面对的问题。

在单实例部署中,所有客户端连接和所有远端请求都进入同一台服务器,逻辑非常简单:

tunnelto client
  ↓
server A

browser request
  ↓
server A

服务端只需要查本机内存里的 Connections,就能找到对应客户端。

但多实例部署后,情况会复杂很多:

client_A 连接到了 server A
client_B 连接到了 server B
client_C 连接到了 server C

如果外部用户访问 client_B 的 tunnel,却被负载均衡分配到了 server A,那 server A 本机内存里根本没有 client_B 的 WebSocket 连接。

这时怎么办?

tunnelto 的答案是:

先查本机 Connections
如果本机找不到,就通过内部网络询问其他实例
找到真正持有该 host 的实例后,把当前 TCP 流代理过去

这一篇就围绕这个机制展开:

Fly.io Private Networking
global.{app}.internal
network::instance_for_host()
network server
HostQuery
proxy_stream()
跨实例 TCP 代理

一、为什么单实例很简单,多实例很麻烦?

先回顾单实例模式。

当客户端连接 /wormhole 成功后,服务端会把客户端登记到本机内存:

host -> ConnectedClient
client_id -> ConnectedClient

例如:

abc123 -> client_A

之后外部浏览器访问:

abc123.tunnelto.dev

请求进入同一台服务端,这台服务端只要调用:

Connections::find_by_host("abc123")

就能找到客户端,然后通过 client.tx 发送 ControlPacket::InitControlPacket::Data

但是多实例下,Connections 是每台实例自己的内存状态。

例如:

server A:
  alice -> client_A

server B:
  bob -> client_B

server C:
  demo -> client_C

如果请求进入了错误实例:

browser -> bob.tunnelto.dev -> server A

server A 查自己的 Connections

find_by_host("bob") -> None

但这并不代表 bob 这个 tunnel 不存在。

它可能只是连接在 server B 上。

所以多实例问题的本质是:

客户端长连接在哪台实例上,
远端请求就必须最终到达那台实例。

二、为什么不能简单共享 HashMap?

最直接的想法是:既然每台实例都有 Connections,能不能把它放到 Redis 或数据库里共享?

理论上可以,但问题是 ConnectedClient 里最关键的不是普通数据,而是:

tx

也就是服务端给客户端 WebSocket 发送消息的 channel。

这个 channel 只在当前进程内有效。

即使你把:

host -> client_id

放进 Redis,其他实例也不能直接拿到那条 WebSocket 的发送句柄。

所以多实例不是简单共享一张表就能解决。

更准确的模型是:

共享存储可以告诉你“谁拥有 host”
但真正转发数据,仍然要回到拥有 WebSocket 的那台实例。

tunnelto 的做法就是:

当前实例发现自己不持有这个 host
  ↓
找到持有 host 的目标实例
  ↓
把 TCP 流代理到目标实例
  ↓
目标实例继续走本机 remote.rs 转发逻辑

三、README 中的多实例提示

项目 README 里对自托管多实例有一个很明确的提醒:

简单自托管实现不支持多个运行中的服务端实例之间的集中协调。
如果部署多个实例,只有当客户端连接的实例和远端 TCP 流进入的实例相同时才可靠。
官方托管版本则基于 Fly.io Private Networking 做了分布式系统。

这段话非常关键。

它说明 tunnelto 的开源版本里虽然有 network 模块,但它不是一个通用的、开箱即用的分布式协调系统。

它更像是为官方部署环境,尤其是 Fly.io Private Networking,写的一套内部实例发现和跨实例代理机制。

所以我们读源码时要注意:

network 模块不是给所有自托管环境直接无脑多实例用的。
它依赖特定的内部网络发现能力。

四、Fly.io Private Networking 扮演什么角色?

Fly.io 的 Private Networking 可以让同一个应用的多个实例通过内部地址互相访问。

在 tunnelto 源码里,关键配置是:

FLY_APP_NAME

如果设置了这个环境变量,服务端会生成:

global.{app_name}.internal

作为 gossip DNS host。

例如:

FLY_APP_NAME=tunnelto-prod

则内部发现域名是:

global.tunnelto-prod.internal

服务端会通过 DNS 查询这个地址,拿到当前应用所有实例的内部 IP。

然后逐个询问:

你是否服务某个 host?

这就是 tunnelto 多实例发现机制的基础。

如果没有设置 FLY_APP_NAME,源码会认为 gossip mode disabled,也就是内部发现模式关闭。


五、服务端配置中的多实例相关字段

tunnelto_server/src/config.rs 中,多实例相关配置主要有:

FLY_APP_NAME
FLY_ALLOC_ID
NET_PORT

它们分别对应:

FLY_APP_NAME:
  用于生成 global.{app}.internal,发现同应用其他实例

FLY_ALLOC_ID:
  当前实例 ID,如果没有则随机生成 UUID

NET_PORT:
  内部实例间通信端口,默认 6000

这里的 NET_PORT 非常重要。

它不是给浏览器访问的,也不是给 tunnelto 客户端连接的。

它是:

server A 询问 server B 是否持有某个 host 时使用的内部端口。

前面第 #13 篇讲过,tunnelto_server 主要有三类端口:

PORT:
  远端公网请求入口

CTRL_PORT:
  客户端 WebSocket 控制入口

NET_PORT:
  实例间内部查询入口

本篇重点就是 NET_PORT 背后的逻辑。


六、network 模块的整体结构

tunnelto_server/src/network 目录主要包含:

mod.rs
server.rs
proxy.rs

它们的分工可以概括为:

mod.rs:
  负责实例发现,判断哪个实例服务某个 host

server.rs:
  负责启动内部查询服务,回答“我是否服务这个 host”

proxy.rs:
  负责把当前 TCP 流代理到目标实例

可以画成这样:

remote.rs
  ↓
本机 Connections 找不到 host
  ↓
network::instance_for_host(host)
  ↓
network/server.rs 询问其他实例
  ↓
找到目标 Instance
  ↓
network::proxy_stream(instance, socket)

这就是跨实例分发的完整路径。


七、network::spawn():启动内部查询服务

tunnelto_server/src/main.rs 中,服务端启动时会启动 network internal service。

对应的是:

network::spawn(...)

它实际上来自:

network/server.rs

这个内部服务使用 Warp 提供两个路由:

GET /
GET /health_check

/health_check 返回:

ok

根路径 / 则用于 host 查询。

它接收 query 参数:

host=abc123

然后调用:

Connections::client_for_host(&query.host)

如果当前实例持有这个 host,就返回对应的 client_id

如果不持有,就返回 client_id: None

可以理解成:

server A 问 server B:
  你这里有没有 abc123?

server B 回答:
  有,client_id 是 xxx
  或者没有

这个内部服务就是多实例发现的“询问接口”。


八、HostQuery 与 HostQueryResponse

network/server.rs 中定义了两个结构:

HostQuery {
    host: String
}

HostQueryResponse {
    client_id: Option<ClientId>
}

请求大致是:

GET http://instance-ip:NET_PORT/?host=abc123

响应大致是:

{
  "client_id": "client_xxx"
}

或者:

{
  "client_id": null
}

这里返回的是 client_id,而不是完整的 ConnectedClient

原因也很简单:

ConnectedClient 里有本机 channel,不能跨进程传输。

所以内部查询服务只需要回答一个问题:

我是否服务这个 host?
如果服务,它对应哪个 client_id?

真正的数据转发不是通过这个 JSON 接口完成,而是后面的 TCP proxy 完成。


九、Instance::get_instances():发现所有实例

多实例发现的入口在 network/mod.rs

里面有一个结构:

Instance {
    ip: IpAddr
}

它表示一个服务端实例。

Instance::get_instances() 的作用是:

拿到当前应用所有实例的内部 IP。

它先检查配置:

CONFIG.gossip_dns_host

如果没有配置,就输出:

gossip mode disabled

然后返回空列表。

如果有配置,例如:

global.tunnelto-prod.internal

它会使用 DNS resolver 查询这个地址,得到一组 IP。

这些 IP 就对应当前应用的多个实例。

然后把每个 IP 包装成:

Instance { ip }

这就是 tunnelto 的实例发现第一步。


十、为什么叫 gossip_dns_host?

源码里把这个地址叫:

gossip_dns_host

但它不是传统意义上的 gossip 协议。

传统 gossip 往往是节点之间周期性传播状态,例如:

A 告诉 B 自己知道哪些节点
B 告诉 C 自己知道哪些节点
状态逐渐扩散

而 tunnelto 这里更像是:

通过内部 DNS 发现所有实例 IP
再向每个实例发起查询

也就是说,DNS 负责提供实例列表。

实例之间的 HTTP 查询负责判断 host 归属。

所以它的工作方式更接近:

DNS service discovery + peer query

而不是完整的 gossip 状态同步系统。


十一、Instance::serves_host():询问某个实例是否服务 host

拿到实例列表后,服务端需要逐个询问:

你是否服务 abc123?

对应函数是:

Instance::serves_host(host)

它会构造内部地址:

http://{instance_ip}:{internal_network_port}

然后发送 GET 请求:

?host=abc123

请求超时时间是 2 秒。

如果对方返回:

HTTP 200
client_id: Some(...)

就说明这个实例确实服务该 host。

函数返回:

(instance, client_id)

如果返回的 client_idNone,或者状态码不是 200,就认为该实例不服务这个 host。

这一步回答的是:

目标 host 当前连接在哪台实例上?

十二、select_ok():并发询问,谁先找到用谁

instance_for_host(host) 不会一个一个串行等待所有实例。

它会把每个实例的 serves_host(host) 转成 future,然后使用:

select_ok(...)

这表示:

并发询问多个实例
谁先成功返回“我服务这个 host”,就用谁

如果没有任何实例返回成功,就报:

DoesNotServeHost

这种并发查询方式很适合这种场景。

因为目标实例通常只有一个,没必要等所有实例都返回。

只要有一个实例确认自己服务该 host,就可以立刻进入代理流程。


十三、remote.rs 中如何使用 instance_for_host

多实例逻辑不是在 network 模块里主动触发的,而是在远端请求入口 remote.rs 里触发。

公网请求进入后,服务端先做常规流程:

解析 Host
提取 subdomain
Connections::find_by_host(subdomain)

如果本机找到了客户端,就直接创建 ActiveStream,走本地转发。

如果本机没有找到,就进入跨实例路径:

network::instance_for_host(subdomain)

如果找到目标实例,就调用:

network::proxy_stream(instance, socket)

如果仍然找不到,就返回:

Tunnel Not Found

这说明跨实例发现是一个 fallback:

优先查本机
本机没有,再查其他实例

这是合理的。

因为如果客户端就在本机,直接转发最快,不需要额外网络跳转。


十四、为什么找到实例后不是直接发 ControlPacket?

这是一个非常关键的问题。

假设 server A 收到了 bob.tunnelto.dev 的请求。

它发现 bob 实际连接在 server B

server A 能不能直接给 server B 发一个消息,让它向 client_B 发送 ControlPacket::Data

理论上可以设计这样的协议,但 tunnelto 选择了更简单的方式:

把当前 TCP socket 代理到 server B 的 remote port。

也就是说,server A 不介入 ControlPacket 协议,也不创建 StreamId

它只是做一件事:

TCP copy:
  browser socket <-> server B remote port

然后 server B 会像正常接收远端请求一样处理这个连接:

server B remote.rs
  ↓
解析 Host
  ↓
本机 Connections 找到 bob
  ↓
创建 ActiveStream
  ↓
发送 ControlPacket 给 client_B

这大大简化了跨实例逻辑。


十五、proxy_stream():跨实例 TCP 代理

跨实例代理逻辑在:

network/proxy.rs

核心函数是:

proxy_stream(instance, stream)

其中:

instance:
  目标实例

stream:
  当前实例收到的远端 TCP 连接

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

SocketAddr::new(instance.ip, CONFIG.remote_port)

也就是:

目标实例 IP : PORT

如果连接失败,当前实例会向远端 socket 写回:

Error: Error proxying tunnel

如果连接成功,它会把两边的 TCP stream 拆开,然后双向 copy:

当前远端 socket -> 目标实例 socket
目标实例 socket -> 当前远端 socket

源码里用的是:

tokio::io::copy(...)

并通过 join 同时跑两个方向。

这就是最朴素、最直接的 TCP 代理。


十六、跨实例代理后的完整链路

假设:

client_B 连接在 server B
browser 请求进入 server A

完整链路如下:

browser
  ↓
server A remote port
  ↓
server A 解析 Host: bob.tunnelto.dev
  ↓
server A 本机 Connections 找不到 bob
  ↓
server A 调用 instance_for_host("bob")
  ↓
server B 内部查询返回 client_id
  ↓
server A 调用 proxy_stream(server B, browser_socket)
  ↓
server A 把 TCP 流代理到 server B remote port
  ↓
server B remote.rs 再次解析 Host
  ↓
server B 本机 Connections 找到 bob
  ↓
server B 创建 ActiveStream
  ↓
server B 通过 WebSocket 发给 client_B
  ↓
client_B 转发到 localhost

这里有一个点很重要:

Host Header 仍然保留在原始 TCP 流中。

因为 server A 只是代理 TCP 字节,没有消费请求。

所以 server B 仍然可以正常解析 Host,并按单实例逻辑处理。


十七、为什么 proxy_stream 连接的是 remote_port?

proxy_stream() 连接目标实例时,用的是:

CONFIG.remote_port

也就是远端请求入口端口。

它不是连接 NET_PORT,也不是连接 CTRL_PORT

原因很清楚:

NET_PORT 只用于问“你是否服务 host”
CTRL_PORT 只用于客户端 WebSocket
remote_port 才是处理浏览器 TCP 请求的入口

所以跨实例流程分两步:

第一步:
  通过 NET_PORT 查询哪个实例服务 host

第二步:
  通过 remote_port 把真实 TCP 请求代理给该实例

这两个端口职责完全不同。


十八、内部查询和真实流量为什么分开?

这其实是一个很好的设计。

内部查询请求很轻:

GET /?host=bob

只返回:

client_id: Some(...)

它只负责决策。

真实流量则是完整 TCP 流:

HTTP request
HTTP response
WebSocket upgrade
请求体
响应体

它通过 proxy_stream() 双向 copy。

这样做的好处是:

查询接口简单
真实流量无需重新封装协议
目标实例复用已有 remote.rs 转发逻辑

如果把真实流量也封装成 JSON 或自定义 RPC,会复杂很多。


十九、服务端实例之间并不共享 ActiveStreams

多实例模式下,ACTIVE_STREAMS 仍然是实例本地的。

例如请求最终被代理到 server B 后:

server B 创建 ActiveStream
server B 写入 ACTIVE_STREAMS
server B 管理 StreamId
server B 与 client_B 通信

server A 不知道这些 stream。

它只是 TCP 代理。

这意味着:

真正持有客户端 WebSocket 的实例,
也必须是真正创建 ActiveStream 的实例。

这很好理解。

因为客户端返回 ControlPacket::Data(stream_id, response) 时,只会回到它连接的那台服务端。

如果 ActiveStream 创建在另一台服务端,就无法匹配。

所以 tunnelto 的跨实例代理目的就是:

把远端 TCP 请求送到持有客户端 WebSocket 的那台实例,
让那台实例自己创建 ActiveStream。

二十、如果没有找到实例会怎样?

如果本机 Connections 找不到,instance_for_host() 也找不到,服务端就无法处理这个请求。

这时会返回:

Tunnel Not Found

这可能有几种原因:

客户端已经断开
客户端连接在某个实例,但内部发现失败
FLY_APP_NAME 没配置,gossip mode disabled
NET_PORT 不通
实例之间 DNS 查询失败
目标实例健康异常
Host 写错
allowed host 配置错误

所以在多实例部署中,Tunnel Not Found 不一定代表客户端真的不存在。

也可能是内部发现链路有问题。


二十一、多实例部署为什么依赖内部网络?

跨实例代理需要两个能力。

第一,实例之间能互相发现:

server A 能知道 server B 的内部 IP

第二,实例之间能互相连接:

server A 能访问 server B 的 NET_PORT 和 remote_port

这通常不能通过公网域名解决,因为公网负载均衡可能又把请求打到随机实例,形成循环。

所以需要私有网络。

Fly.io Private Networking 正好提供了:

同一个 app 的实例内部可发现
实例之间可以通过 internal 地址互相访问

这就是 tunnelto 官方托管版本依赖它的原因。


二十二、普通 VPS 上怎么做多实例?

如果你不是用 Fly.io,而是在普通 VPS、Kubernetes 或 Docker Swarm 上部署,也可以借鉴这套思路,但需要自己实现实例发现。

你需要解决:

1. 如何拿到所有 tunnelto_server 实例 IP?
2. 实例之间如何访问 NET_PORT?
3. 实例之间如何访问 remote_port?
4. 如果实例扩缩容,列表如何更新?
5. 如何避免公网请求和内部代理混淆?

可选方案包括:

Kubernetes Service + Headless Service
Consul
etcd
Redis 注册表
静态实例列表
负载均衡粘性会话

但这已经超出了 tunnelto 简单自托管的范畴。

如果只是个人使用,仍然建议单实例。


二十三、能不能用负载均衡粘性会话解决?

某些情况下可以缓解。

如果客户端控制连接和远端请求都能被稳定打到同一个实例,那么就不需要跨实例查询。

但问题是:

客户端连接 /wormhole 和浏览器访问 abc.tunnel.example.com
通常不是同一个来源、同一个连接、同一个请求路径。

负载均衡的粘性策略未必能保证:

某个 subdomain 的远端请求
一定进入持有该 subdomain 客户端连接的实例

尤其是当多个用户、多个子域名、多个客户端同时存在时,单纯粘性会话不够可靠。

更可靠的方式还是:

根据 Host 找到持有者实例
把请求代理过去

这正是 network 模块的设计。


二十四、跨实例代理的性能代价

跨实例代理会增加一次网络跳转。

单实例或命中本机时:

browser -> server B -> client_B

跨实例时:

browser -> server A -> server B -> client_B

多了一段:

server A -> server B

这会带来:

额外延迟
额外带宽消耗
更多连接资源
更多故障点

但它换来的好处是:

远端请求可以进入任意实例
系统仍然能找到真正持有客户端连接的实例

对于分布式系统来说,这是一个常见取舍。


二十五、为什么不直接让负载均衡按 Host 路由?

理论上可以。

如果负载均衡器能够知道:

bob.tunnelto.dev 当前在 server B

就可以直接把请求打到 server B。

但这要求负载均衡器实时了解 tunnel host 的动态归属。

这通常很难。

因为 tunnel host 的归属会随着客户端连接、断开、重连而变化。

而 tunnelto 的方案是把这个动态状态留在应用层处理:

负载均衡器可以把请求发给任意实例
应用层再通过内部发现转发到正确实例

这种设计对部署平台的要求更低,但应用内部逻辑更复杂。


二十六、network 模块的错误处理

network/mod.rs 中定义了几个错误类型:

IoError
RequestError
ResolverError
DoesNotServeHost

它们分别对应:

IoError:
  网络 IO 错误

RequestError:
  HTTP 查询实例时失败

ResolverError:
  DNS 解析实例列表失败

DoesNotServeHost:
  没有实例确认自己服务该 host

这些错误会影响 remote.rs 的后续处理。

如果跨实例查找失败,服务端无法把请求代理到正确实例,只能返回错误或 tunnel not found。

多实例部署时,排查问题就要关注:

DNS 是否能解析 global.{app}.internal
NET_PORT 是否监听
实例之间是否互通
查询接口是否返回 client_id
目标实例 remote_port 是否可连接

二十七、从源码看多实例机制的优点

1. 不需要集中式状态存储

实例之间通过内部查询确认 host 归属,不依赖 Redis 或数据库保存实时连接表。

2. 保持 ActiveStream 本地化

真正持有客户端 WebSocket 的实例负责创建 ActiveStream,避免跨实例同步 stream 状态。

3. 复用已有 remote.rs 逻辑

跨实例代理只是把 TCP 流送到目标实例的 remote port。

目标实例继续按单实例路径处理,不需要额外协议。

4. 查询和流量分离

NET_PORT 只负责轻量查询,真实流量通过 remote_port 代理。

5. 和 Fly.io 私有网络契合

通过 global.{app}.internal 发现实例,适合 Fly.io 的部署模型。


二十八、这套机制的局限

1. 依赖 Fly.io 内部 DNS

如果没有 FLY_APP_NAME,gossip mode disabled。

普通自托管环境不会自动获得这种实例发现能力。

2. 没有集中协调

实例之间不是共享一致状态,而是运行时互相询问。

如果查询失败,可能找不到实际在线的 tunnel。

3. 查询有超时和网络开销

每次本机找不到 host,都可能要向多个实例发请求。

虽然使用并发 select_ok,但仍然有成本。

4. 跨实例代理增加延迟

请求如果先进错实例,需要多跳一次。

5. 没有复杂负载治理

代码主要解决“找到 host 所在实例”问题,不包含完整的流量调度、限流、熔断、指标上报等平台能力。


二十九、如果要做自己的多实例版本,可以怎么增强?

如果你想基于 tunnelto 做商业化内网穿透平台,可以考虑这些增强方向。

1. 显式服务注册

每个实例启动后,把自己的 IP、端口、实例 ID 注册到 Redis、etcd 或 Consul。

2. host 归属索引

客户端连接成功后,写入:

host -> instance_id

远端请求进入时直接查索引,而不是广播询问所有实例。

3. 租约机制

host 归属应该有 TTL。

客户端断开或实例宕机后,自动过期。

4. 连接迁移策略

如果实例下线,客户端重连后如何恢复 host 归属,需要明确设计。

5. 代理层限流

跨实例代理可能成为瓶颈,需要增加:

超时
最大连接数
流量限制
错误熔断

6. 观测指标

至少记录:

本机命中率
跨实例命中率
instance_for_host 延迟
proxy_stream 失败率
DoesNotServeHost 次数

这些指标可以帮助判断多实例系统是否健康。


三十、完整链路总结

现在我们把多实例请求完整串起来。

假设:

client_B 连接到了 server B
browser 请求进入 server A

完整流程是:

1. browser 访问 bob.tunnelto.dev
2. 请求进入 server A 的 PORT
3. server A 解析 Host,得到 bob
4. server A 查本机 Connections::find_by_host("bob")
5. 本机没有
6. server A 调用 network::instance_for_host("bob")
7. network 查询 global.{app}.internal,得到所有实例 IP
8. server A 并发请求各实例的 NET_PORT
9. server B 返回 client_id
10. server A 确认 bob 在 server B
11. server A 调用 proxy_stream(server B, browser_socket)
12. proxy_stream 连接 server B 的 remote_port
13. server A 双向 copy browser_socket 和 server_B_socket
14. server B 的 remote.rs 接收到完整原始请求
15. server B 查本机 Connections,找到 client_B
16. server B 创建 ActiveStream
17. server B 通过 WebSocket 把请求发给 client_B
18. client_B 转发到 localhost
19. 响应原路返回 browser

一句话概括:

当前实例找不到 host 时,不直接返回失败,
而是通过内部网络找出真正持有该 host 的实例,
再把 TCP 流代理给它处理。

三十一、这一篇的核心结论

tunnelto 的多实例扩展机制可以总结成一句话:

在多实例部署中,tunnelto_server 先查本机 Connections;
如果本机没有目标 host,就通过 Fly.io Private Networking 的 global.{app}.internal 发现所有实例,
向各实例的 NET_PORT 发送 HostQuery,
找到持有该 host 的实例后,
通过 proxy_stream 把当前 TCP 连接代理到目标实例的 remote_port,
再由目标实例按单实例流程创建 ActiveStream 并转发给本地客户端。

更简洁地说:

本机能处理就本机处理;
本机不能处理,就问其他实例;
问到后不复制状态,而是代理 TCP 流;
最终让持有 WebSocket 的实例完成真正转发。

这套设计的核心不是共享所有连接状态,而是:

把请求送到拥有连接状态的那台实例。

这也是 tunnelto 多实例设计最值得学习的地方。


三十二、下一篇预告

下一篇我们继续分析可观测性和运维:

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

下一篇会重点研究:

tracing
Honeycomb
health_check
HTTP_NOT_FOUND_RESPONSE
HTTP_INVALID_HOST_RESPONSE
HTTP_ERROR_PROXYING_TUNNEL_RESPONSE
连接失败日志
stream 生命周期日志
为什么内网穿透系统需要清晰的错误响应

如果说这一篇讲的是多实例如何找到正确服务端,下一篇讲的就是系统运行后如何观察、排错和维护。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

天天进步2015

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

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

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

打赏作者

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

抵扣说明:

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

余额充值