zhangguanzhang's Blog

无数据的 tcp 链接被 SDN/防火墙 干掉的一种处理办法

字数统计: 2.2k阅读时长: 9 min
2022/04/11

客户的环境下,业务运行在 dmz 区,mysql 在非 dmz 区,业务连 mysql 的空闲 tcp 连接 240s 后会被 SDN 干掉,本文实际介绍一种不动业务(代码),利用代理的解决办法

由来

客户提供的环境是 ACS 虚拟化平台,我们业务部署在他们的 dmz 区,mysql 他们提供的,在非 dmz 区,部署后有个问题就是页面经常 504,504 后刷新下就好了,最后排查到是业务连 mysql 的 tcp 连接没有数据传输超过 240s 后会被 SDN 干掉。

解决过程

业务的产品挺多的,业务的 db 连接池探活就立刻反馈产品,让下个版本加进去避免这种问题,但是客户现场看看是否有不动业务的解决办法,后面大致看了下 tcp 的 keepalive 可能解决。

为啥需要 tcp 的 keepalive

不是 vrrp 和 lvs 的 keepalived 那个,其实这个问题现象和客户的 SDN 关系不大(我意思是说没必要去要求客户调整 SDN 的配置啥的),常见的园区 NAT 网络环境下也有类似问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 假如内网本机访问公网的 1.1.1.1:80 

# client 端会随机分配一个 client port 用于和目标 ip 建立 tcp 连接
CLIENT 192.168.1.2:47914
||
||
||
\/
GW/FW SNAT # 网关或者边界有公网 ip 的防火墙做 SNAT
||
||
||
\/
REAL SERVER 1.1.1.1:80

假如边界防火墙的公网 IP 为 61.183.112.202,在客户端访问的时候会有个管理 nat 条目的表:

内网IP 内网IP的端口 本身的端口 目的主机IP 目的主机端口
192.168.1.2 47914 52617 1.1.1.1 80

远端的 1.1.1.1:80 看到的 client tcp 信息是 61.183.112.202:52617,边界的公网防火墙由于园区内设备太多,这个端口转换表由于端口数量有限(0~65535),对于过期的记录,需要删除掉。大体过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 下面字符画推荐 pc 端浏览,否则会错位

client端 中间设备 服务端
└──┬──┘ └──┬──┘ └──┬──┘
│ ┌────┐ │ │
├────┼data┼───►│ ┌────┐ │
│ └────┘ ├────┼data┼───►│
│ │ └────┘ │
│ │ ┌────┐ │
│ ┌────┐ │◄───┼ACK ┼────┤
│◄───┼ACK ┼────┤ └────┘ │
│ └────┘ /│ │
│ │ │ │
│ │ │ │
│ no data< │ │
│ │ │ │
│ │ │ │
│ \│ │
│ │长时间无数据交互│
│ │设备删掉表条目 │
│ ┌────┐ │ │
client ├────┼data┼───►│无连接信息 │
发送数据 │ └────┘ │直接发送RST │
│ ┌────┐ │ 或丢弃 │
应用异常 │◄───┼RST ┼────┤ │
│ └────┘ │ │

现场的 SDN 就是 240s 后干掉这个 tcp 连接,解决办法就是 TCP 这层的 keepalive 机制维持长连接,让网关的 nat 条目 ttl 保活。

tcp keepalive 介绍

相关的内核参数有三个:

1
2
3
4
$ sysctl -a |& grep tcp.keepalive_
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200

启用 tcp keepalive 的一端,在没有数据传输的时候,会有定时器(也有人翻译称为计数器)工作,到了 tcp_keepalive_time 秒还没有数据传输,就发一次 TCP 探测包。每隔 tcp_keepalive_intvl 发一次,如果首次对端响应 keepalive 报文,后面就不发送了,如果没响应也就是一直 tcp_keepalive_probes 次发送都没响应后,就会认为对方挂了。

  • TCP 探测包是一个纯 ACK 包(RFC1122#TCP Keep-Alives 规范建议:不应该包含任何数据,但也可以包含1个无意义的字节,比如0x0),其 Seq号 与上一个包是重复的,所以其实探测保活报文不在窗口控制范围内。

我们调整了业务机器上的这三个参数,但是还是依旧的问题,发现必须在应用层创建 socket 的时候设置 SO_KEEPALIVE 套接字选项才能生效。例如 c 语言:

1
2
3
4
conn.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True)
conn.setsockopt(socket.SOL_TCP, socket.TCP_KEEPIDLE, 20) # 覆盖tcp_keepalive_time
conn.setsockopt(socket.SOL_TCP, socket.TCP_KEEPCNT, 5) # 覆盖tcp_keepalive_probes
conn.setsockopt(socket.SOL_TCP, socket.TCP_KEEPINTVL, 10) # 覆盖tcp_keepalive_intvl

其他语言,例如 golang 的话可以看这个文章 知乎: golang 程序开启 tcp keepalive

nginx tcp 代理思路

和领导讨论后说用 nginx 做代理试下,根据 nginx 官方文档的 listen 字段[so_keepalive=on|off|[keepidle]:[keepintvl]:[keepcnt]] 看到 nginx 可以开启 so_keepalive

1
2
3
4
5
6
7
8
9
10
11
12
server {
# http://nginx.org/en/docs/http/ngx_http_core_module.html#listen
#listen 0.0.0.0:3306 so_keepalive=on;
# 如果上面这样就使用 内核参数的值,也可以自定义,也就是下面这样对应三个参数
listen 0.0.0.0:3306 so_keepalive=60s:20:10;
proxy_pass xxxx:3306;

#建立连接时间
proxy_connect_timeout 5s;
#保持连接时间
proxy_timeout 3600s;
}

测试了下发现还是不行,本地搭建个环境试试。

本地环境实战

机器信息:

IP role
192.168.2.111 mysql
192.168.2.112 nginx

192.168.2.111 上利用 docker 起个 mysql:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
version: "3"
services:
mysql:
image: mysql:5.7
container_name: mysql
hostname: mysql
ports:
- 3306:3306
volumes:
- ./mysql:/var/lib/mysql
- /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime:ro
environment:
MYSQL_DATABASE: zgz
MYSQL_USER: zgz
MYSQL_PASSWORD: zhangguanzhang
MYSQL_ROOT_PASSWORD: zhangguanzhang
logging:
driver: json-file
options:
max-size: 20k
max-file: '3'

192.168.2.112 上利用 docker 起个 nginx 做代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
version: '3.4'
services:
nginx:
image: nginx:alpine
container_name: proxy
hostname: proxy
volumes:
- /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime:ro
- ./nginx.conf:/etc/nginx/nginx.conf
- ./conf.d/:/etc/nginx/conf.d/
- ./stream.d/:/etc/nginx/stream.d/
network_mode: "host"
logging:
driver: json-file
options:
max-file: '3'
max-size: 100m

nginx.conf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
user  nginx;
worker_processes auto;

error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';

access_log /var/log/nginx/access.log main;

sendfile on;
#tcp_nopush on;

keepalive_timeout 65;

#gzip on;

include /etc/nginx/conf.d/*.conf;
}
stream {
include /etc/nginx/stream.d/*.conf;
}

stream.d/test.conf :

1
2
3
4
5
6
7
8
9
10
server {
# http://nginx.org/en/docs/http/ngx_http_core_module.html#listen
listen 0.0.0.0:3307 so_keepalive=60s:20:9;
proxy_pass 192.168.2.111:3306;

#建立连接时间
proxy_connect_timeout 2s;
#保持连接时间
#proxy_timeout 3600s;
}

起来后用 mysql的镜像起 mysql 客户端:

1
2
docker run --rm -ti --net host mysql:5.7 bash
mysql -u root -p -h 192.168.2.112 -P 3307

抓包 port 3306 and host 192.168.2.111 没有看到 keepalive 的包,然后突然意识到 listenso_keepalive 是 nginx 作为 server 端去探测 client 端的,而不是 proxy 的,搜了下搜到 proxy_socket_keepalive 字段stream.d/test.conf 配置 server 段里加下:

1
proxy_socket_keepalive on;

然后发现这个开了后探测时间和间隔是按照的内核参数,调整了下内核参数,后续记得自行持久化到 /etc/sysctl.d/xxx.conf

1
2
3
4
$ sysctl -a |& grep tcp.keepalive_
net.ipv4.tcp_keepalive_intvl = 6
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.tcp_keepalive_time = 60

mysql 客户端连接上后抓包看到:

1
2
3
4
5
6
7
8
18:53:35.347954 IP 192.168.2.112.34492 > 192.168.2.111.3306: Flags [.], ack 2504, win 501, options [nop,nop,TS val 3972839466 ecr 4160374696], length 0
18:53:35.348410 IP 192.168.2.111.3306 > 192.168.2.112.34492: Flags [.], ack 618, win 243, options [nop,nop,TS val 4160435115 ecr 3972779047], length 0
18:54:36.787902 IP 192.168.2.112.34492 > 192.168.2.111.3306: Flags [.], ack 2504, win 501, options [nop,nop,TS val 3972900906 ecr 4160435115], length 0
18:54:36.788318 IP 192.168.2.111.3306 > 192.168.2.112.34492: Flags [.], ack 618, win 243, options [nop,nop,TS val 4160496556 ecr 3972779047], length 0
18:55:38.227896 IP 192.168.2.112.34492 > 192.168.2.111.3306: Flags [.], ack 2504, win 501, options [nop,nop,TS val 3972962346 ecr 4160496556], length 0
18:55:38.228185 IP 192.168.2.111.3306 > 192.168.2.112.34492: Flags [.], ack 618, win 243, options [nop,nop,TS val 4160557996 ecr 3972779047], length 0
18:56:39.667973 IP 192.168.2.112.34492 > 192.168.2.111.3306: Flags [.], ack 2504, win 501, options [nop,nop,TS val 3973023786 ecr 4160557996], length 0
18:56:39.668303 IP 192.168.2.111.3306 > 192.168.2.112.34492: Flags [.], ack 618, win 243, options [nop,nop,TS val 4160619436 ecr 3972779047], length 0

因为客户端 mysql 连接上后没执行任何 sql,然后 nginx 每隔 net.ipv4.tcp_keepalive_time 的 60s 发送保活报文,mysql server 端也回复了(就不进行5次间隔6s的后续探活了),所以结果就如上图抓包所示,60s 发一次保活的报文。

后面让客户调整了下业务机器上的这三个内核参数解决了该问题。

wireshark 抓包

实际如果是在 mysql client 建立连接后去抓包导入 wireshark ,心跳包会被识别成 TCP Dup ACK,只有抓完整的报文 wireshark 才会识别为 TCP Keep-Alive ACK

总结

  1. 应用层在套接字开启 SO_KEEPALIVE 才可以使用 keepalive 能力。
  2. 基本只有 c 语言才有函数能不走内核参数,来自定义自己的 keepalive 三个值。也就是说大多数应用层开启 keepalive 后,还需要调整运行的机器的这三个内核参数。
  3. 在 IM 开发经验里,客户端去使用 keepalive 才是最正确的。
  4. redis server 有配置开启 keepalive
  5. kafka 官方默认开启了 keepalive,见 Enable keepalive socket option for broker官方源码里的 socketChannel.socket().setKeepAlive(true)

其实本次 nginx 做代理,在思想上挺像 sidecar 的理念的。

参考

CATALOG
  1. 1. 由来
  2. 2. 解决过程
    1. 2.1. 为啥需要 tcp 的 keepalive
    2. 2.2. tcp keepalive 介绍
    3. 2.3. nginx tcp 代理思路
    4. 2.4. 本地环境实战
    5. 2.5. wireshark 抓包
    6. 2.6. 总结
  3. 3. 参考