云数据库内网穿透后的"幽灵连接":一次 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-tcpipchannel 请求再也到不了 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 的老化时间更快。