公司有一个线上的sftp服务,使用proftpd提供。近日文件下载的应用日志中连续多日出现了 SSH_DISCONNECT (Read TImed out,Key exchange failed.)错误,现在把分析和解决过程分享一下。
问题表现
现场得到的信息包括:
1.对端同时使用scp和jsch两个客户端,只有jsch出现了错误。
2.当时并发连接数并不高,100-200之间。
3.CPU Load不高,个位数。
4.整点并发新建连接较高。
从第二点的连接数中可以看出。
5.发现问题后抓包结果
协议分析
从日志和抓包结果看,是客户端主动发出的断开连接请求,为什么客户端会主动断开呢?
先来复习一下SSL/TLS协议在OSI七层协议中的位置,以HTTP协议为例,HTTP 既可以直接工作在 TCP 之上,也可以工作在 SSL/TLS 之上,两种情况下 HTTP 协议没有区别。
如果SSL/TLS作为其它应用层协议的安全层,如FTP,那就是SFTP。通常记得SSL /TLS处于传输层和应用层之间就可以了。
简单说说 SSL/TLS 协议的区别。
* SSL 全称为 Secure Socket Layer,即安全套接字层,它是由网景公司(Netscape)在 1994 年推出首版网页浏览器 Netscape Navigator 时提出;
* TLS 全称为 Transport Layer Security,即传输层安全性协议,由 IETF 在 1999 年将 SSL 进行标准化。
两者实现很类似,只是目前 SSL 协议都被认为是不安全的,推荐使用 TLS 协议,比如 Nginx 的配置中推荐使用:
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
上图中对SSL/TLS内部协议栈显示的不是很清楚,看下图。
SSL/TLS又分为Record层和Handshake层,不过上图把二者上下关系弄反了,应该是Handshake完毕后进入Record层。
我们还是先来复习一下SFTP协议建立连接的过程。
每个步骤的报文内容可以参看 https://wx.abbao.cn/a/13847-67e7f706b4622024.html https://blog.csdn.net/fw0124/article/details/40983787
我们看下每一步都做了什么
1. 客户端将它所支持的算法列表和一个用作产生密钥的随机数发送给服务器;
2. 服务器从算法列表中选择一种加密算法,并将它和一份包含服务器公用密钥的证书发送给客户端;该证书还包含了用于认证目的的服务器标识,服务器同时还提供了一个用作产生密钥的随机数;
3. 客户端对服务器的证书进行验证(有关验证证书,可以参考数字签名),并抽取服务器的公用密钥;然后,再产生一个称作pre_master_secret的随机密码串,并使用服务器的公用密钥对其进行加密(参考非对称加/解密),并将加密后的信息发送给服务器;
4. 客户端与服务器端根据pre_master_secret以及客户端与服务器的随机数值独立计算出加密和MAC密钥(参考DH密钥交换算法)。
5. 客户端将所有握手消息的MAC值发送给服务器;
6. 服务器将所有握手消息的MAC值发送给客户端。
而我们遇到的情况中,直接在第二步就出错了, key exchange failed.
错误偶发,服务器负载不高,总连接数也不高,为什么在初始化刚开始的第二步就出错了呢?
除了总连接数的限制,结合连接数监控里看到的情况,应该是和当时的并发新建连接数过高有关。SSHD的配置里对于新建连接的并发有参数控制,还是因为什么设计上的考虑,而不允许同时创建过多新连接?
调优解决
过了一遍SSHD里关于连接数的参数,焦点落在了MaxStartups上。
默认配置是这样的
MaxStartups 10:30:60
每个参数的作用如下:
10: Number of unauthenticated connections before we start dropping
30: Percentage chance of dropping once we reach 10 (increases linearly for more than 10)
60: Maximum number of connections at which we start dropping everything
而我们的配置采用的是默认值,看起来,应该是其中的10的限制导致并发新建连接数目受到了限制。
用jsch作为客户端写了个压力测试脚本。
int poolsize =100;
poolsize控制总线程数,相应控制总连接数。
int threads_count=200;
threads_count控制线程池中等待连接的线程数量。
int loops_in_thread=100;
每个线程中进行连接/断开循环的次数,用以模拟频繁新建的场景。
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.Vector;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.apache.log4j.Logger;
import org.junit.Test;
import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.SftpException;
/**
* @author YueYang
*
*/
public class JSchTest {
Logger log = Logger.getLogger(getClass());
String host = "1.2.3.4";
String username = "username";
String password = "password";
int port = 22;
int poolsize =100;
int threads_count=200;
int loops_in_thread=100;
/**
* 文件路径前缀
*/
private static final String PRE_FIX = "/sftp-preffix";
@Test
public void conn() {
Session sshSession = null;
Channel channel = null;
try {
getSftpConnect(host, port, username, password);
} catch (JSchException e) {
e.printStackTrace();
}
}
@Test
public void benchmark() {
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(poolsize);
for (int i = 0; i < threads_count; i++) {
fixedThreadPool.execute(new Runnable() {
@Override
public void run() {
System.out.println("Start");
connectSftpOnProftpd(true);
}
});
}
try {
Thread.sleep(999999);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
@Test
public void connSftpOnsshD() {
Session sshSession = null;
try {
log.debug("connSftpOnsshD start.");
String key = host + "," + port + "," + username + "," + password;
JSch jsch = new JSch();
JSch.setLogger(new JSchLogger());
// jsch.getSession(username, host, port);
sshSession = jsch.getSession(username, host, port);
sshSession.setPassword(password);
Properties sshConfig = new Properties();
sshConfig.put("StrictHostKeyChecking", "no");
sshSession.setConfig(sshConfig);
sshSession.connect();
ChannelSftp channel = (ChannelSftp) sshSession.openChannel("sftp");
channel.connect();
Vector vector = channel.ls("/");
try {
for (Object obj : vector) {
if (obj instanceof com.jcraft.jsch.ChannelSftp.LsEntry) {
String fileName = ((com.jcraft.jsch.ChannelSftp.LsEntry) obj).getFilename();
System.out.println(fileName);
}
}
} finally {
channel.quit();
sshSession.disconnect();
}
} catch (Exception e) {
e.printStackTrace();
}
}
public void connectSftpOnProftpd(boolean disconnectAfterWork) {
int loop = loops_in_thread;
try {
log.debug("connectSftpOnProftpd start.");
JSch jsch = new JSch();
JSch.setLogger(new JSchLogger());
Session session = jsch.getSession(username, host);
session.setPassword(password);
Properties config = new Properties();
config.put("StrictHostKeyChecking", "no");
while (loop > 0) {
session.setConfig(config);
session.connect();
ChannelSftp channelSftp = (ChannelSftp) session.openChannel("sftp");
channelSftp.connect();
// channelSftp.setFilenameEncoding("gbk");
Vector vector = channelSftp.ls("/");
try {
for (Object obj : vector) {
if (obj instanceof com.jcraft.jsch.ChannelSftp.LsEntry) {
String fileName = ((com.jcraft.jsch.ChannelSftp.LsEntry) obj).getFilename();
System.out.println(fileName);
}
}
} finally {
if (disconnectAfterWork) {
channelSftp.quit();
session.disconnect();
}
}
loop--;
}
} catch (JSchException e) {
e.printStackTrace();
} catch (SftpException e) {
e.printStackTrace();
}
}
/**
* 获取sftp协议连接.
*
* @param host
* 主机名
* @param port
* 端口
* @param username
* 用户名
* @param password
* 密码
* @return 连接对象
* @throws JSchException
* 异常
*/
public static ChannelSftp getSftpConnect(final String host, final int port, final String username,
final String password) throws JSchException {
Session sshSession = null;
Channel channel = null;
ChannelSftp sftp = null;
String key = host + "," + port + "," + username + "," + password;
JSch jsch = new JSch();
JSch.setLogger(new JSchLogger());
// jsch.getSession(username, host, port);
sshSession = jsch.getSession(username, host, port);
sshSession.setPassword(password);
Properties sshConfig = new Properties();
sshConfig.put("StrictHostKeyChecking", "no");
sshSession.setConfig(sshConfig);
sshSession.connect();
channel = sshSession.openChannel("sftp");
channel.connect();
sftp = (ChannelSftp) channel;
return sftp;
}
分别测试了sshd MaxStartups默认值的情况和修改为100:30:60的情况,错误消失,问题解决。
总结
这个问题主要原因在于配置服务时采用默认配置,没有进行相应调优,服务器的性能并没有得到充分利用。
至于jsch为什么出错,而linux下的scp客户端没出错,应该是后者的出错重试机制更好而已。