1. 先弄懂这句报错在说什么
用JSch写Java的SSH/SFTP工具,最挠头的报错之一就是这个:connection is closed by foreign host。我最初遇到它是在一套每晚定时同步文件的批处理任务里,任务跑了一半,长连接突然断掉,重试之后又能跑一会,然后继续断。当时第一反应是代码写错了,但检查半天逻辑完全没问题,后来才明白,问题根本不在JSch本身,而在于整个SSH连接链路上的某个环节主动把连接掐了。
这句报错的字面意思其实很直白:远端服务器主动关闭了连接。注意,是服务器关的,不是本地程序关的。JSch内部把远端主机称为foreign host,所以它想表达的就是“对方把我这头的连接给关了”。也就是说,TCP连接已经建立起来,但SSH会话没有按预期维持下去,服务端发了断开信号或者直接重置了连接。
1.1 一次让我记忆犹新的断连现场
那时候是一套定时任务系统,凌晨两点左右批量登录一批服务器执行脚本。平时也就三五十台机器,跑得好好的。有一天业务扩容,服务器数量翻了几倍,结果当天晚上任务大面积失败,错误日志里清一色都是connection is closed by foreign host。
最迷惑的地方在于,单独拿一台机器测试,连接一切正常,SFTP上传也没问题;但一到批量并发场景,就会随机出现一部分机器连不上或者连上之后立刻被断开。试过在客户端加各种超时参数,效果都不明显。
这种“单点正常、并发就挂”的现象,通常就指向服务端的连接管理策略,而不是客户端代码。当时我还不知道MaxStartups这个参数的存在,走了不少弯路。现在回头看,如果第一时间去翻服务端sshd日志,这个问题半小时就能定位。
1.2 是哪个环节被打断了
要准确排查,先得搞清楚这个报错发生在哪个阶段。SSH连接从建立到可用,大致经历这几个步骤:
- TCP三次握手,建立底层连接。
- 双方交换SSH版本信息。
- 密钥交换(KEX),协商加密算法。
- 客户端认证,密码、密钥或键盘交互方式。
- 认证通过后,才真正进入会话阶段,可以打开各种通道(channel)。
JSch的报错如果出现在session.connect()这一步,通常说明TCP连上了,但SSH握手或认证阶段就被服务器掐断了。如果出现在channel.connect()或者命令执行过程中,说明会话本身已经建立,是运行途中被远端断开。
这两种情况的排查方向完全不同。前者重点看服务端的认证策略、连接数限制、IP封禁;后者重点看空闲超时、NAT设备、以及服务端sshd的运行状态。
很多人在客户端反复改代码、调超时,却忽略了“服务器为什么要断开”这个核心问题。其实报错本身就是个信号:它告诉你断连发生在对端,关键词是closed by foreign host,不是connect timed out,也不是socket read error。
2. 服务端排查:sshd配置是最容易踩的坑
客户端代码翻来覆去没问题的时候,就该去服务端看sshd的配置了。这一节的内容同样适用于OpenSSH服务端,绝大多数Linux发行版都跑的是它。
2.1 被连接数限制甩出去:MaxStartups和MaxSessions
很多人没听过MaxStartups这个名字,但它恰恰是“批量并发连接报closed by foreign host”的头号原因。
MaxStartups控制的是SSH认证阶段的并发连接数量。默认配置是10:30:100,意思是并发未认证连接数在10个以内时全部接受;超过10个后,以30%的概率随机拒绝新连接;超过100个时,直接拒绝所有新连接。
这个参数的设计初衷是防暴力破解,正常情况下够用。但如果你用JSch写了一个批量运维工具,同一时间从同一台机器发起几十个连接,很容易撞上这个限制。服务端日志里会留下:
error: too many starting connections改法很简单,在/etc/ssh/sshd_config里调整:
MaxStartups 100:30:200或者直接设成固定值也行:
MaxStartups 100改完一定要先验证配置再重载:
sshd -t systemctl reload sshdsshd -t主要用来检查语法是否有问题,免得手误写错导致服务重启失败。这个习惯我一直保留,改配置类文件的正确顺序永远是“先测试、再重载”。
MaxSessions也值得注意,它限制的是单个SSH连接上可以同时打开的channel数量,默认是10。如果你的代码复用一个Session同时跑多个SFTP通道,或者并发开了一堆exec channel,可能会触碰这个限制。报错表现类似,但服务端日志里会有不同的提示。批量连接场景下我建议把它适当调大:
MaxSessions 202.2 被空闲探测和认证窗口踢下线
还有一种情况是连接建立后长时间没动静,被服务端的空闲保护机制断开。
sshd里有几个参数控制这个行为:
ClientAliveInterval:服务端每隔多少秒向客户端发送一次探测消息,默认0表示不启用。ClientAliveCountMax:连续多少次探测无响应就断开连接,默认3。LoginGraceTime:客户端完成认证的最大时间窗口,默认120秒,超时直接断。
如果你的JSch连接空闲几分钟后就被断开,多半是这台服务器的sshd配置了空闲探测,或者中间网络设备对空闲连接做了老化处理。
另外还有一个容易被忽略的细节:认证失败次数限制。OpenSSH默认的MaxAuthTries是6,如果客户端配置的认证方式让服务器尝试了太多次,即使最终密码是对的,服务端也会因为“尝试次数过多”强制断开连接。日志里会看到:
Disconnecting: Too many authentication failuresJSch默认的PreferredAuthentications如果不显式指定,可能会同时尝试多种方式,触碰到这个上限。我建议在JSch配置里显式指定一种最合适的认证方式,比如:
config.put("PreferredAuthentications", "publickey,password");或者只保留实际使用的那一个,减少认证过程里的无效尝试。
PAM和fail2ban也是常见“隐形杀手”。如果服务器装了fail2ban,短时间内多次认证失败会被拉黑,但你的业务程序可能只是因为配置写错在反复重试,结果触发封禁。那就不是断连问题,而是IP层面直接被拒了。这时候SSH客户端会提示Connection refused或者连接超时,但有时表现也可能是握手阶段被立即断开。
排查时直接翻服务端日志,Debian/Ubuntu看/var/log/auth.log,RedHat系看/var/log/secure:
journalctl -u sshd --since "30 minutes ago" | grep -i "too many" journalctl -u sshd --since "30 minutes ago" | grep -i "Disconnected"日志才是判断服务端断开原因的最可靠依据。
3. 客户端侧:JSch参数没配好,连接一样保不住
服务端配置没问题的话,就要回头审查客户端的JSch使用方式。很多人对JSch的几个超时参数理解不到位,导致配置了等于没配置。
3.1 别把setTimeout当成保活
JSch里有两个容易混淆的机制:setTimeout和setServerAliveInterval。
setTimeout设置的是会话socket的读超时。它的作用是,如果在指定时间内socket上没有数据可读,就会抛SocketTimeoutException。这个参数适合控制单次命令执行的最长等待时间,但它不发送任何心跳包,也不具备真正意义上的保活功能。
setServerAliveInterval才是干保活的。它每隔指定毫秒数,在SSH加密通道内发送一条keepalive消息给服务端。这个机制比TCP层的KeepAlive可靠得多:
- TCP KeepAlive默认间隔长达2小时,很多场景等它生效黄花菜都凉了。
- 不少中间防火墙和NAT设备会直接丢弃空闲的TCP探测包,但对SSH应用层的心跳消息没办法。
- ServerAlive是加密通道内的真实流量,服务端收到后会正常响应,从而证明链路是可用的。
我目前常用的配置模板长这样:
JSch jsch = new JSch(); Session session = jsch.getSession(user, host, port); session.setPassword(password); Properties config = new Properties(); config.put("StrictHostKeyChecking", "no"); config.put("PreferredAuthentications", "password,keyboard-interactive"); session.setConfig(config); session.setServerAliveInterval(30 * 1000); session.setServerAliveCountMax(3); session.setTimeout(60 * 1000); session.connect(30 * 1000);关键参数说明:
30 * 1000:每30秒发一次心跳。这个间隔对大多数场景都够用。setServerAliveCountMax(3):连续3次心跳无响应就算连接失效,避免程序在死链上傻等。session.connect(30 * 1000):控制的是建连阶段的最长等待时间,超过这个时间抛异常。
如果服务器本身配置了ClientAliveInterval,那么客户端这边的心跳可以稍微保守一点,两边互相配合,长连接可以维持很稳。
3.2 连接池与资源的正确姿势
JSch的Session是重量级对象,建立经过完整握手和认证,代价不小;而Channel是轻量级的操作通道,同一个Session可以开多个Channel。
实践中我强烈建议复用Session,每次操作临时开Channel。比如一个定时任务每天要向某台服务器传文件,就应该初始化一个Session长期持有,每天跑任务时打开新的SFTP Channel。这样既减少了握手开销,也不容易触发MaxStartups限制。
Channel用完必须关闭,否则资源会泄漏。标准流程是:
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp"); try { channel.connect(10 * 1000); // 传输文件等操作 } catch (Exception e) { // 记录异常 } finally { if (channel.isConnected()) { channel.disconnect(); } }Session在程序退出时也要正确释放,别只关Channel不管Session。我之前见过一个案例,代码里每次都新建Session但从不disconnect,跑了几个小时后连接数暴涨,服务端直接拒绝新连接。
还有一个细节很多人会踩:如果用exec channel执行远程命令,一定要把命令的完整执行路径和参数写对,否则远端shell启动失败,channel会迅速关闭,JSch这边可能报“channel is not opened”或者和本文类似的断连错误。另外,读取命令输出时,要用getInputStream()拿到完整的输出流,如果流没读完就调用channel.disconnect(),命令可能还没执行完毕就被掐了。
单Session不要并发开太多Channel。默认MaxSessions是10,超过这个量,服务端会拒绝新channel的创建。需要并发执行多个命令时,合理做法是控制并发数量,或者改用多个Session分摊。
4. 网络层和部署环境中的“隐形杀手”
代码和服务端配置都亲手验证没问题,却依然偶发断开,就要考虑中间链路的问题了。
4.1 NAT、防火墙和负载均衡的空闲超时
SSH连接建立后,如果长时间没有数据包流动,很多网络设备会认为这是个死连接,悄悄把会话表项清掉。典型的场景是:晚上连上服务器,第二天早上来操作,第一条命令就报closed by foreign host,重连一次又能正常用。
问题出在中间设备的会话老化机制上:
- NAT网关的conntrack表项有超时时间,常见默认值在300秒到600秒之间。
- 云环境的安全组有连接跟踪机制,空闲连接同样会被回收。
- 通过四层负载均衡转发SSH流量时,LB通常会配置空闲超时,比如某些环境默认300秒没有流量就断开转发。
这种问题和JSch本身关系不大,但表现形式就是“连接被远端关闭”。解决方法也确实要靠客户端配合:启用ServerAliveInterval定期发心跳,让中间设备始终能看到流量,这条链路就能长期保持。
要注意的是,心跳间隔必须小于中间设备的空闲超时时间。如果设备的超时是300秒,心跳设成60秒就很安全;如果设成600秒,风险就很大。
4.2 容器、云主机与sshd重启带来的问题
容器化部署给SSH长连接带来的麻烦也不少。
我遇到过一台容器里跑Java程序持有一个长连接,过了几个小时突然断开,代码里又没有自动重连逻辑,程序就一直处于半死不活的状态。追查发现那台容器的宿主机做了一次系统更新,sshd进程被重启,所有存量连接自然全部失效。这种“对端程序重启导致连接断开”的情况,日志里通常看不到明确的断连原因,只能从时间点倒推。
另外,容器环境下文件描述符(fd)限制也可能成为隐性瓶颈。每次SSH连接、每个Channel都会占用fd,如果程序的连接池没有上限,fd耗尽时新建连接会直接失败。可以这样检查:
lsof -p <java进程pid> | wc -l正常情况几百个,如果涨到上万甚至接近进程上限,基本就是资源泄漏了。
云环境的健康检查机制也要注意。有些基础设施会对长时间空闲的连接做主动回收,或者通过探测机制判断连接是否可用。如果恰好你的客户端ServerAlive没开,链路从网络层被切断后,本端是感知不到异常的,要等到下一次真正的读写操作才暴露问题。
总的来说,网络层和服务端的问题都会表现为JSch抛断连异常。排查的时候不要只盯着一层,代码、配置、网络路径三方面都要看。
5. 排障实战:一次典型断连事件的全程记录
这一节用一个完整的排查过程来演示方法论。当时我接到一个需求:用Java批量连接几十台服务器执行巡检命令,结果第一次上线就大面积报错。
5.1 现场信息收集
我没有急着改代码,而是先按顺序收集现场信息。以下过程也适合你直接抄作业:
| 步骤 | 操作 | 目的 |
|---|---|---|
| 先复现 | 手动单台连一遍,再批量跑一遍 | 确认是偶发还是必现 |
| 客户端抓包 | tcpdump -i eth0 port 22 -w /tmp/ssh.pcap | 看TCP层是不是收到FIN或RST |
| 服务端看日志 | journalctl -u sshd --since "10 minutes ago" | 找具体的断开原因 |
| 查实时连接数 | ss -tn state established '( sport = :22 )' | wc -l | 判断是否撞上连接数上限 |
| 测全链路 | 从客户端telnet 服务端22端口,确认网络可达 | 排除防火墙和路由问题 |
批量任务脚本在客户端这边抓包后,明显看到一批连接刚完成TCP握手,服务端立刻返回了FIN包,有的甚至直接回RST。这说明问题发生在SSH握手或认证早期。
5.2 根因定位与配置验证
接着去服务端翻日志,很快就在/var/log/secure里发现大量类似记录:
error: too many starting connections这台服务器的MaxStartups是默认值,而我批量发起连接的机器又集中在一两个源IP上,导致认证阶段的并发连接数瞬间被顶爆。服务端的拒绝策略在这种情况下非常激进,因为默认配置里有一个概率拒绝区间。
当时的处理方案就是前文提到的调整MaxStartups,同时修改了客户端的连接策略:
- 批量任务改为有限并发,比如同时最多建立5个连接。
- 单个连接完成后复用Session,不再频繁新建。
- 客户端显式配置PreferredAuthentications,避免多余认证尝试。
调整之后重新跑批量任务,断连问题彻底消失。
后来我还验证过一个有趣的细节:把MaxStartups调大后,即使保留原有批量并发数也不再报错,说明当时的瓶颈确实在服务端连接数限制上,而不是网络问题。
排查SSH断连问题时,最忌讳的就是在客户端瞎试参数。试了半天不解决问题,还浪费时间。正确顺序永远是:先复现,再抓包,然后翻服务端日志,最后才轮到调整客户端配置。
6. 高频问题速查与避坑心得
排查这类问题多了之后,我整理了一张速查表,基本能覆盖90%的线上场景。
6.1 报错场景与对应解决方案速查表
| 报错场景 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 批量并发建连时大量报错 | MaxStartups限制 | 服务端日志出现too many starting connections | 调大MaxStartups,客户端改为复用Session |
| 空闲一段时间后首次操作报错 | NAT会话老化或空闲超时 | 观察是否固定空闲时长后触发 | 启用ServerAliveInterval心跳 |
| 连接能建,但认证时被断 | 认证尝试次数超限或PAM限制 | 日志出现Too many authentication failures | 显式指定PreferredAuthentications,检查账号密码 |
| 命令执行中途被断 | 输入输出流处理不当或命令本身异常 | 查看命令执行输出和channel状态 | 确保完整读取输出流再关闭channel |
| 偶发断连且服务端无日志 | 网络中间设备或sshd重启 | 抓包对比断开时间点 | 加心跳保活,代码里加重连机制 |
| 运行一段时间后全部连接失效 | 服务端重启或容器漂移 | 检查服务端日志时间线 | 客户端实现自动重连逻辑 |
6.2 几条拿命换来的经验
第一,不要频繁创建Session。每个Session都是一次完整的SSH握手和认证,代价远高于TCP连接。在循环里反复new JSch()再connect,不仅容易撞服务端限制,还在浪费CPU和网络带宽。正确做法是维护一个Session池,按需复用。
第二,定时任务一定要加重试机制。网络环境没有那么理想,偶发断连是常态。重试时要加指数退避,不要一失败就疯狂重连,否则容易触发服务端的防护策略。重试超过两三次还失败,就应该告警而不是无限循环。
第三,关闭资源的代码永远放finally里。我已经记不清有多少次线上问题最后定位到“某个分支提前return导致连接没释放”。SSH连接泄漏的隐蔽性很强,它不会立刻报错,而是慢慢堆积,最后某个时间点集中爆发。
第四,连接池要设置上限。即使复用Session,也要给池子一个最大大小。不然业务高峰期并发创建几十个连接,服务端一拒绝,错误就会全部暴露出来。限制并发数不仅是保护服务端,也是保护客户端自己。
第五,遇到这类报错,先怀疑服务端。这不是甩锅,而是基于事实的判断。closed by foreign host已经指明了断开方向,再去客户端反复找原因就浪费了报错本身给出的线索。
我后来在自己的工具类里默认加了ServerAliveInterval,把所有关闭动作统一收口到工具方法里,连接池上限固定写死。从那以后,connection is closed by foreign host这个报错基本在我的项目里绝迹了。不是说完全不会遇到网络问题,而是当问题再次出现时,直接看服务端日志和心跳配置就能秒级定位,不再像第一次那样排查了一整天才找到根因。