Skip to main content

通过增加timeout-control以解决当tcp连接一直处于SYN_SENT状态导致java中的ldap-client的failover不工作的问题

· 8 min read
orange
programmer on jvm platform

今天遇到一个问题是当测试模拟ldap的服务端主节点挂掉的时候并在页面点击登录, 后端一直未作出响应.
这个问题的原因是因为代码中的ldap-clientfailover未生效, 通过排查发现ldap-client一直在连接ldap的主节点, 并且tcp连接一直处于SYN_SENT状态.
由于ldap-client没有默认情况下没有控制超时, 导致代码一直堵塞, 从而导致failover不工作.
下面将开始介绍具体细节以及解决方案.

排查过程

确认服务配置的ldap地址有那些

执行docker inspect命令, 确认服务配置的ldap地址

sudo docker inspect fastone-api |grep 'spring.ldap.urls'

输出如下

                "spring.ldap.urls=ldap://10.0.3.16:389,ldap://10.0.3.3:389",

可以看到配置了两个ldap地址分别为

  • ldap://10.0.3.16:389
  • ldap://10.0.3.3:389

确认服务的进程ID

执行ps命令, 确认服务的进程ID

ps -ef |grep FastoneApplication

命令输出如下

ubuntu   22663 14459  0 16:52 pts/5    00:00:00 grep --color=auto FastoneApplication
root 31320 31246 1 14:23 ? 00:02:39 java -cp @/app/jib-classpath-file com.fastonetech.fastone.FastoneApplication

可以看到进程ID为31320, 接下来通过lsof命令查看31320进程的tcp连接状态

确认服务进程的tcp连接状态

通过lsof查看tcp连接状态

sudo lsof -p 31320 |grep '10.0.3.16'

命令输出如下

java    31320 root  506u     IPv6            5728853       0t0     TCP fastone:37408->10.0.3.16:ldap (SYN_SENT)

可以看到服务进程的tcp连接状态一直处于SYN_SENT状态.

SYN_SENT状态的连接, 说明客户端已经发送了SYN包, 但是没有收到SYN/ACK包, 此时会处于SYN_SENT状态

Workaround

由于这个问题会blocking测试进度所以需要先给出一个workaround来解决这个问题.
目前的解决方案是通过iptables限制tcp连接, 使其直接Connection refused. 这样可以确保测试能够继续进行

通过iptables新增OUTPUT规则使tcp连接直接失败

  • 新增OUTPUT规则

注意target应该为REJECT而不是DROP.
这是因为DROP对于tcp连接来说, 会直接丢弃数据包.
所以需要使用REJECT来拒绝连接, 并且需要指定--reject-with tcp-reset来拒绝连接.
这样客户端会知道连接被拒绝了而不是一直处于SYN_SENT状态.

sudo iptables -A OUTPUT -p tcp -d 10.0.3.16 --dport 389 -j REJECT --reject-with tcp-reset
  • 查看规则列表
sudo iptables -L OUTPUT -v -n

输出如下

Chain OUTPUT (policy ACCEPT 301 packets, 95101 bytes)
pkts bytes target prot opt in out source destination
0 0 REJECT tcp -- * * 0.0.0.0/0 10.0.3.16 tcp dpt:389 reject-with tcp-reset

可以看到, 新增了一条REJECT规则

  • 测试规则是否生效
telnet 10.0.3.16 389

输出如下

Trying 10.0.3.16...
telnet: Unable to connect to remote host: Connection refused

可以看到连接被拒绝了, 说明规则生效了

删除规则

当修复代码后, 需要删除规则, 以便测试继续进行

  • 删除规则
sudo iptables -D OUTPUT -p tcp -d 10.0.3.16 --dport 389 -j REJECT --reject-with tcp-reset
  • 查看规则列表
sudo iptables -L OUTPUT -v -n

输出如下

Chain OUTPUT (policy ACCEPT 1922 packets, 947K bytes)
pkts bytes target prot opt in out source destination
  • 确认连接状态恢复如初
telnet 10.0.3.16 389

输出如下

Trying 10.0.3.16...
^C

可以看到连接状态恢复如初, 客户端一直处于SYN_SENT状态

问题原因

根据上面的分析, 问题的原因TCP连接状态一直处于SYN_SENT状态导致ldapfailover未生效

偏离设计预期

这里跟我一开始设计有偏差, 因为我一开始的设想的预期应该是直接Connection refused, 类似如下行为

非堵塞

telnet localhost 389

当执行上述命令时, 会直接报错

Trying 127.0.0.1...
telnet: Unable to connect to remote host: Connection refused

服务端会直接拒绝连接, 但是实际上并没有这样的行为, 服务端一直处于SYN_SENT状态, 也就是说服务端一直在尝试连接

堵塞

类似如下行为

telnet 10.0.3.16 389

输出如下

Trying 10.0.3.16...
^C

可以看到一直在尝试连接, 但是并没有超时, 也就是说一直在尝试连接

ldap-clienttimeout-control未生效

另外一个问题是tpc一直处于SYN_SENT状态理论来说应该触发timeout-control, 但是实际上并没有触发

接下来我们需要看一下java-ldap中是如何实现timeout-control

默认超时时间

javaldap的连接是由com.sun.jndi.ldap.LdapCtx.connectTimeout控制的.
参考代码如下

public final class LdapCtx extends ComponentDirContext
implements EventDirContext, LdapContext {

private int connectTimeout = -1; // no timeout value

}

可以看到connectTimeout默认值为-1, 也就是说默认情况下是没有超时的. 这其实不合理, 因为网络连接是有超时的, 否则会导致代码一直堵塞

解决方案

继承LdapContextSource并增加timeout-control配置

默认情况下ldap是没有timeout-control的, 不过其提供了配置connectTimeout的方法, 是通过environment来配置的.
对于上层应用来说我们用的是通过spring封装过的ldap, 所以我们需要对于spring进行扩展, 主要是通过继承LdapContextSource来实现

WithMoreEnvironmentContextSource.java
/**
* The extension of {@link LdapContextSource} to support more environment.
* Sometimes we need to set more environment to environment.
* For example, we need to set the connectTimeout to avoid thread blocking and makesure the failover works.
* For more details about the connectTimeout, see {@link com.sun.jndi.ldap.LdapCtx.connectTimeout}
* For more details about the readTimeout, see {@link com.sun.jndi.ldap.LdapCtx.readTimeout}
* For more details about the ldap failover, see {@link com.sun.jndi.ldap.LdapCtxFactory#getUsingURLs}
*
* @author Xiangcheng.Kuo
*/
public class WithMoreEnvironmentContextSource extends LdapContextSource {

private Integer connectTimeout = -1;

private Integer readTimeout = -1;

public void setConnectTimeout(Integer connectTimeout) {
this.connectTimeout = connectTimeout;
}

public void setReadTimeout(Integer readTimeout) {
this.readTimeout = readTimeout;
}

@Override
protected DirContext getDirContextInstance(Hashtable<String, Object> environment) throws NamingException {
// The module named "com.sun.jndi.ldap.LdapCtx" is not exported.
// So we can't use the following code
// LdapCtx.CONNECT_TIMEOUT
// For more information about CONNECT_TIMEOUT
// see 1. LdapCtx.initEnv 2. Connection.createSocket
environment.put("com.sun.jndi.ldap.connect.timeout", connectTimeout.toString());
environment.put("com.sun.jndi.ldap.read.timeout", readTimeout.toString());
return super.getDirContextInstance(environment);
}

}

WithMoreEnvironmentContextSource添加到容器中

接下来需要将WithMoreEnvironmentContextSource添加到容器中, 确保上层的依赖如LdapTemplate以及自定义的Reposity 使用到我们扩展的LdapContextSource

LdapConfiguration.java

@Configuration
public class LdapConfiguration {

@Bean
public LdapContextSource ldapContextSource(
LdapProperties properties,
Environment environment,
ObjectProvider<DirContextAuthenticationStrategy> dirContextAuthenticationStrategy
) {
// copy from org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration#ldapContextSource
WithMoreEnvironmentContextSource source = new WithMoreEnvironmentContextSource();
dirContextAuthenticationStrategy.ifUnique(source::setAuthenticationStrategy);
PropertyMapper propertyMapper = PropertyMapper.get().alwaysApplyingWhenNonNull();
propertyMapper.from(properties.getUsername()).to(source::setUserDn);
propertyMapper.from(properties.getPassword()).to(source::setPassword);
propertyMapper.from(properties.getAnonymousReadOnly()).to(source::setAnonymousReadOnly);
propertyMapper.from(properties.getBase()).to(source::setBase);
propertyMapper.from(properties.determineUrls(environment)).to(source::setUrls);
propertyMapper.from(properties.getBaseEnvironment()).to(
(baseEnvironment) -> source.setBaseEnvironmentProperties(Collections.unmodifiableMap(baseEnvironment)));

// We need to make sure the failover works fast when using multiple ldap backend
if (properties.getUrls().length > 1) {
source.setConnectTimeout(2000);
source.setReadTimeout(2000);
}

return source;
}

}

主要代码复制自org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration#ldapContextSource, 具体细节可以参考其源码

备注

iptables规则较多自定义的受影响, 如下所示

sudo iptables -L OUTPUT -v -n
Chain OUTPUT (policy ACCEPT 186K packets, 95M bytes)
pkts bytes target prot opt in out source destination
0 0 DROP tcp -- * * 10.0.3.16 0.0.0.0/0 tcp dpt:3306
0 0 DROP tcp -- * * 10.0.3.16 0.0.0.0/0 tcp dpt:389
0 0 DROP tcp -- * * 10.0.3.16 0.0.0.0/0 tcp spt:389
0 0 DROP tcp -- * * 10.0.3.16 0.0.0.0/0 tcp spt:389
63 3276 DROP tcp -- * * 0.0.0.0/0 0.0.0.0/0 state NEW tcp dpt:389
0 0 REJECT tcp -- * * 0.0.0.0/0 10.0.3.16 tcp dpt:389 reject-with tcp-reset

需要清空所有OUTPUT规则

sudo iptables -F OUTPUT

重新查看OUTPUT规则

sudo iptables -L OUTPUT -v -n
Chain OUTPUT (policy ACCEPT 1883 packets, 1126K bytes)
pkts bytes target prot opt in out source destination

参考