客户的环境下,业务运行在 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
。
总结
- 应用层在套接字开启
SO_KEEPALIVE
才可以使用 keepalive 能力。
- 基本只有 c 语言才有函数能不走内核参数,来自定义自己的 keepalive 三个值。也就是说大多数应用层开启 keepalive 后,还需要调整运行的机器的这三个内核参数。
- 在 IM 开发经验里,客户端去使用 keepalive 才是最正确的。
- redis server 有配置开启 keepalive
- kafka 官方默认开启了 keepalive,见 Enable keepalive socket option for broker 和 官方源码里的 socketChannel.socket().setKeepAlive(true)
其实本次 nginx 做代理,在思想上挺像 sidecar 的理念的。
参考