zhangguanzhang's Blog

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

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

客户的环境下,业务运行在 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 的包,然后突然意识到 listen 的 so_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. 参考