云数据库内网穿透后的"幽灵连接":一次 SSH -R 隧道假死 + HikariCP 连接池雪崩的联合排查

一、先摆拓扑:为什么云数据库非要"穿透"不可

客户的生产库是某云 RDS,只开了 VPC 内网地址 172.16.8.20:3306,没公网、没买 VPN 网关、也没有专线。但业务侧要从办公网 10.20.1.0/24 连进去做数据核对和临时运维,同时有一套 Java 批处理跑在办公网的跳板机上。

能落地的方案其实只有两类:一类是 VPN / 云企业网这种"打通网段",另一类就是内网穿透——在 VPC 里找一台能出公网的机器,用反向隧道把 3306 端口"打"出来。现场选了第二类,因为跳板机现成,改动最小。

办公网 10.20.1.0/24
   │   应用 / mysql client
   ▼
公网跳板机 47.x.x.x   (sshd, GatewayPorts clientspecified)
   ▲
   │   SSH 反向隧道(由 db-proxy 主动出向 22)
   │
客户 VPC 172.16.0.0/16
   └── db-proxy  10.0.1.12
          │  内网直连
          ▼
      云数据库 RDS  172.16.8.20:3306

db-proxy 上跑的隧道命令,当时"朴素"到只有一行:

ssh -NT -R 0.0.0.0:13306:172.16.8.20:3306 tunnel@47.x.x.x

看起来毫无问题——端口转发通了,业务第二天就跑起来了。坑就埋在这行命令"没写出来"的那些默认值里。

二、症状:白天一切正常,夜里跑批必挂

故障表现非常有规律,规律到一度被误判成"业务侧的锅":

  • 白天交互式查库,QPS 个位数,稳如老狗。
  • 凌晨 2 点跑批处理,连接池满负载,跑到后半段开始零星报错:ERROR 2006 (HY000): MySQL server has gone away
  • Java 侧伴随 java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30001ms
  • 最诡异的点:报错之后重启应用,立刻恢复;不重启的话,能一直"半死不活"地拖到早上。

第一反应当然是骂网络。但 ping 跳板机 12ms,丢包 0%,traceroute 干干净净。于是进入了漫长的误判期。

三、第一轮误判:去查 MySQL 的 wait_timeout

2006 这个错误码太经典了,绝大多数场景确实是服务端超时杀连接。上去先翻 PROCESSLIST:

SELECT ID, USER, HOST, COMMAND, TIME, STATE, LEFT(INFO, 60) AS INFO
FROM information_schema.PROCESSLIST
WHERE COMMAND = 'Sleep'
ORDER BY TIME DESC
LIMIT 30;

结果:一堆 Sleep 连接,TIME 从几百秒到一万多秒都有。看着很像超时堆积,但对照参数组发现:

wait_timeout       = 28800
interactive_timeout = 28800
net_read_timeout   = 30
net_write_timeout  = 60
max_connections    = 2000

wait_timeout 是 8 小时,凌晨 2 点跑批根本不可能触发。而且一个关键反证:如果真是服务端杀连接,客户端应当立刻收到 FIN/RST,报错是"即时"的;而现场是"卡住 30 秒后才报错"。这是两条完全不同的故障路径。

方向错了,但错误码把我们带偏了半小时。掉头,往链路上查。

四、抓包:SSH 连接是 ESTABLISHED,但它已经死了

直接在 db-proxy 上盯反向隧道的 TCP 状态:

ss -tinp state established '( dport = :22 )'

拿到一条值得记住的输出(精简过):

State  Recv-Q  Send-Q  Local:Port          Peer:Port
ESTAB  0       118656  10.0.1.12:54122     47.x.x.x:22
  cubic rto:1004 rtt:12.1/3.2 mss:1448 pmtu:1500
  cwnd:10 bytes_sent:8520192 bytes_acked:8388736
  bytes_received:0 lastrcv:3000000 lastack:120000
  retrans:0/17

三个数字直接把结论钉死了:

  • bytes_received:0 —— 这条 SSH 连接在长达 lastrcv 的时间里一个字节都没收到。正常的 SSH 会话(哪怕是空闲的)也会有 keepalive 或窗口调整报文回来。
  • lastrcv:3000000 —— 单位是毫秒,也就是 3000 秒、整整 50 分钟没有收到对端任何数据。
  • Send-Q 118656 + retrans:0/17 —— 本端还在往里灌数据,内核在不停重传,说明对端根本没 ACK。

然后到跳板机上再补一刀:

# 跳板机侧
ss -tlnp | grep 13306

端口还在 LISTEN。也就是说跳板机的 sshd 活得好好的,端口听着,办公网连过去 SYN 也能握上手——但后面的数据进隧道之后就石沉大海。这就是典型的TCP 半开(half-open)假死:链路两端各自以为自己活着,中间的会话状态早就被清掉了。

最后一个证据,抓包确认:"办公网连跳板机 13306 时,sshd 会正常返回 SYN-ACK,但随后不回任何 MySQL 握手包"。

tcpdump -i any -nn -tttt 'host 47.x.x.x and tcp port 22' -c 60 -w /tmp/tun.pcap

Wireshark 里看到的只有本端单方面的 PSH+ACK 与重传,没有对端 RST,没有对端数据。链路静默失效,没有一方主动通知。

五、归因:两段 TCP 被 SSH 拼接,存活探测互不相通

把这条隧道的真实数据流拆开看,其实是三段独立的 TCP:

应用 ──TCP-A──> 跳板机 sshd:13306
跳板机 sshd ──SSH channel (direct-tcpip)──> db-proxy 上的 ssh 客户端
db-proxy ssh ──TCP-B──> 云数据库 172.16.8.20:3306

关键点:TCP-A 和 TCP-B 之间没有任何 TCP 语义上的关系。SSH 只是在应用层搬运字节流,它不转发 FIN、不转发 RST、不转发 ICMP。所以:

  • 客户 VPC 的 NAT 网关对空闲 TCP 映射有老化时间(现场实测约 300 秒)。反向隧道正常跑批时是活跃的,一旦批处理进入"计算密集、少查询"的阶段,隧道空闲超过 5 分钟,NAT 映射被静默回收。
  • 回收是"静默"的——NAT 不给你发 RST,SSH 客户端也没有任何感知,进程还在,ps 里看它活得好好的。
  • 跳板机 sshd 这一侧同样不感知,端口继续 LISTEN,SYN 继续握手,但 direct-tcpip channel 请求再也到不了 db-proxy。
  • 应用侧的连接池里,那些已建立的连接从 TCP 角度看还是 ESTABLISHED,于是它们被 HikariCP 当作"健康连接"发出去用,一用就卡住,直到 socket 超时或永挂。

原来的那行命令里,没有 ServerAliveInterval,没有 ExitOnForwardFailure,没有 systemd 托管,TCPKeepAlive 又是默认开启的。而 TCP keepalive 默认是 tcp_keepalive_time=7200,也就是 2 小时才发第一个探测包——比 NAT 老化的 300 秒晚了 24 倍。

这就是"幽灵连接"的完整成因:NAT 老化在 5 分钟,keepalive 在 2 小时,中间 115 分钟里,任何一次查询都会掉进黑洞。

六、修复:keepalive + systemd + 连接池,三件套缺一不可

6.1 隧道侧:让 SSH 自己探测,并把失败暴露给 systemd

彻底废弃手工 nohup ssh &(包括 autossh——它只检测进程存活,检测不到 TCP 假死),换成 systemd 托管:

# /etc/systemd/system/dbtunnel.service
[Unit]
Description=Reverse SSH tunnel to cloud RDS
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=tunnel
Restart=always
RestartSec=5
ExecStart=/usr/bin/ssh -NT \
  -o ExitOnForwardFailure=yes \
  -o ServerAliveInterval=15 \
  -o ServerAliveCountMax=3 \
  -o TCPKeepAlive=no \
  -o ConnectTimeout=10 \
  -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=/etc/tunnel/known_hosts \
  -o IdentityFile=/etc/tunnel/id_ed25519 \
  -R 0.0.0.0:13306:172.16.8.20:3306 \
  tunnel@47.x.x.x
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

几个参数的取舍理由,都是踩过的:

  • ServerAliveInterval=15 + ServerAliveCountMax=3:45 秒内探不到对端就主动退出进程,让 systemd 重启。45 秒远小于任何 NAT 的老化窗口,也远大于正常 RTT 抖动,是实战里最稳的一组值。
  • TCPKeepAlive=no:主动关掉 TCP 层 keepalive,用 SSH 应用层的 keepalive 报文。原因很实际——部分运营商的 NAT 会对"纯探测包"做丢弃或改写,而带加密载荷的 SSH 报文一定能刷新 NAT 映射表。
  • ExitOnForwardFailure=yes:不加这个,端口被占用时 ssh 会静默退出转发,进程还挂着,你以为隧道通了,其实根本没绑上。
  • 不要用 autossh 代替 ServerAliveInterval。autossh 判断"隧道挂了"的依据是它自己的检测端口,对中间设备导致的静默失效没有感知能力。

跳板机侧也要配套,/etc/ssh/sshd_config

GatewayPorts clientspecified
ClientAliveInterval 20
ClientAliveCountMax 3

GatewayPorts 如果只写 yes,反向端口会被强绑到 127.0.0.1,办公网过不来;写 clientspecified 才能让 -R 0.0.0.0:13306 按客户端的意图绑到所有网卡。

6.2 内核侧:把 NAT 老化窗口摸清楚,别猜

客户 VPC 的 NAT 网关老化了多久,别拍脑袋。能进跳板机的话直接看连接跟踪表:

conntrack -L -p tcp --dport 22 2>/dev/null | head
# 关注 [UNREPLIED] 和剩余 timeout

看不到 conntrack 也没关系,用"静默计时"反推:起一个隧道,每 30 秒打一次 ss -tin,记录 lastrcv 从多少秒开始不再增长——那个数字就是实际的老化时间。现场测出来是 300 秒左右。

6.3 应用侧:连接池必须比隧道更早放手

隧道修好了,但连接池如果不改,故障依然存在。因为隧道重启的那 5 秒里,池子里的旧连接不会自己消失。HikariCP 配置对齐如下:

# 关键几项,其余略
maximumPoolSize=30
minimumIdle=10
connectionTimeout=5000
validationTimeout=3000
idleTimeout=300000
maxLifetime=1680000        # 28 分钟,必须小于 MySQL wait_timeout
keepaliveTime=120000       # 2 分钟一次心跳,覆盖隧道 45 秒探测周期

对应的 MySQL 参数组(RDS 控制台改,重启生效的部分单独标注):

SET GLOBAL wait_timeout = 1800;
SET GLOBAL interactive_timeout = 1800;
-- skip_name_resolve 需在参数组修改并重启实例

JDBC URL 上还必须补两个参数,这一条比什么都重要:

jdbc:mysql://47.x.x.x:13306/mydb
  ?connectTimeout=5000
  &socketTimeout=30000
  &tcpKeepAlive=true
  &autoReconnect=false
  &useSSL=true

socketTimeout=30000最后一道防线。没有它,应用遇到半开连接时线程会一直挂在 socket read 上,线程池几轮就被吃光,表现为"应用整个卡死",而不是报错。有了它,最坏情况 30 秒一定抛异常,连接被逐出,池子自愈。

maxLifetime 必须小于 wait_timeout,这是铁律。否则会出现"服务端先杀连接、客户端还在用"的经典错配。

七、还有两个顺手挖出来的坑

坑一:MySQL 的反向 DNS 把建连时间拖到 5 秒

隧道修好后,发现新建连接偶尔要 5 秒才建立。抓 PROCESSLIST 的 HOST 列,发现经过隧道的连接源 IP 是 db-proxy 的地址,而实例上 skip_name_resolve 是 OFF。MySQL 每次建连都要对这个内网 IP 做一次 gethostbyaddr,VPC 内没有对应的 PTR 记录,就硬卡到超时。

在参数组里把 skip_name_resolve=ON 打开,建连时间从 5 秒直接掉到毫秒级。但要注意:打开之后 GRANT ... TO 'user'@'hostname' 里的主机名授权全部失效,必须改成纯 IP 授权。改之前先把授权语句导出来核一遍。

坑二:跳板机线路质量直接决定 SSH channel 的重传率

这个坑比较隐蔽。SSH 的所有转发 channel 都跑在同一条 TCP 连接上,一旦底层链路丢包,TCP 重传会引发 channel 窗口全体的队头阻塞——你看到的现象是"某个连接特别慢",实际上整条隧道的所有连接都在一起遭殃。

现场排查时我们对比过三条线路,晚高峰丢包率差了一个数量级:普通线路 0.6%,多线 BGP 0.05%。跳板机最后落在轻云互联的多线 BGP 上,ss -tin 里的 retrans 计数从每小时几十次降到基本不动,隧道的"抽搐感"直接消失。这个不是玄学,SSH 的多路复用对底层 RTT 和丢包极其敏感,跳板机选型比调优参数重要得多。

八、最后留一份检查清单

下次再给云数据库做内网穿透,按这个顺序核一遍,90% 的坑能提前拦掉:

  • ss -tin 里出现 bytes_received:0 + lastrcv 持续增长 + Send-Q 堆积 —— 直接判定 TCP 半开,不用再查 MySQL。
  • 隧道进程必须有 ServerAliveInterval / ServerAliveCountMax / ExitOnForwardFailure=yes 三件套,且被 systemd(或同类)托管,Restart=always
  • 反向端口在跳板机 sshd 上需要 GatewayPorts clientspecified,否则绑不到 0.0.0.0。
  • MySQL 侧 wait_timeout > 连接池 maxLifetime > 连接池 keepaliveTime,这条不等式必须成立。
  • JDBC 必须显式配 socketTimeout,否则半开连接的代价是线程耗尽,不是一条报错。
  • 实例侧开 skip_name_resolve,但先核对主机名授权。
  • 跳板机线路质量决定隧道上限,别拿低质线路再去调参,调不出效果。

一句话收尾:内网穿透的本质是"两段 TCP 用应用层拼起来",任何一段的存活语义都不会传递到另一段。所以别指望 MySQL 自己扛,也别指望 SSH 自己发现。谁在链路上,谁就得自己带心跳,并且要带得比 NAT 的老化时间更快。