TCP的KeepAlive使用简介

在现代应用系统实现中,我们一般使用长连接与服务端进行交互,以减少建立连接的开销。
同时使用连接池,维护特定数量的连接以备随时使用。
但是在NAT等有中间设备的情况下,长连接很可能因为某些原因被断开。
从而导致连接池失去应有的作用。

基于这些需求,就需要使用TCP的keepalive功能。
它能提供一种自动、高效、可灵活配置,相比应用层心跳包更轻量的连接保活方式。

背景介绍

当我们需要对外提供服务的时候,尤其是KV存储这种对稳定性更高的中间件服务,一般会使用VIP, 而不是让用户直连:

  • 减少变更时对用户的影响:如proxy替换、扩容、维护
  • 高可用:更方便的高可用,用户无需探活健康检查等
  • 隐藏拓扑细节:更高的安全性

使用VIP时需要注意的是,长时间idle的连接会被reset。
原因是LVS一般配置为保持连接有时间限制,如果超过此时间后连接上一直没有报文,LVS就会发送RST报文断开链接。
这么做的原因是为了节省连接资源,也避免攻击。

但是在实际使用场景中,用户一般使用资源池维护长连接。
在连接池中预留部分多余连接是合理的,此部分连接应该保证长期有效。

这就需要用到TCP的keepalive功能

实现细节

相关参数

具体可以通过man 7 tcp查看。

Linux中有三相关配置项,可使用sysctl -a查看:

# 以下单位都为秒
net.ipv4.tcp_keepalive_time = 7200 # 表示在这个时间之后没有数据时启动探测报文
net.ipv4.tcp_keepalive_intvl = 75 # 前一个探测报文和后一个探测报文之间的时间间隔
net.ipv4.tcp_keepalive_probes = 9 # 探测的最大次数

使用上述配置,表现大概如下:

连接空闲7200s后触发keepalive,发送第一次ACK

  • 连接如果正常,对端会回应ACK
  • 连接如果一直正常,会再次等待空闲7200s后发送ACK
  • 如果连接异常,对端未回应ACK,会等待75s后发送第二个ack
  • 如果一直未成功,第9次发送ACK未回应后,发送RST包关闭连接

(注意:以上参数在不同系统下表现可能不一致,我们只说linux下的表现。)
系统的配置只是默认值。关闭一个连接需要的时间计算公式为:tcp_keepalive_time + tcp_keepalive_probes* tcp_keepalive_intvl
默认为7200s+9*75s = 2h 11min 15s

这其中存在两个问题:

  • 默认idle时间为2小时超过2个小时才开始keepalive,时间太长了,这个时间内一般应用早已经超时
  • 没有提供全局开启keepalive的选项

系统调用

如上,Linux中没有提供全局开启keepalive的选项,必须通过setsockopt系统调用针对单独的socket进行设置。
TCP socket 中有三个选项和内核对应:

TCPKEEPIDLE: 覆盖 tcp_keepalive_time
TCPKEEPINTVL: 覆盖 tcp_keepalive_intvl
TCPKEEPCNT: 覆盖 tcp_keepalive_probes

python demo

服务端启动:

nc -l 9999

客户端代码:

# -*- coding: utf-8 -*-
import socket
import time
host='localhost'
port=9999
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # 开启keepalive
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 5) # 5s后开始探活
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 2) # 探活间隔2s
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 10) #最大次数
s.connect((host, port))
print "connected to %s:%d" % (host, port)
s.sendall("hello world!")
for i in xrange(300):
print "sleep for %ds" % i
time.sleep(1)
s.close()
print "finish!"

在运行一段时间后通过iptables阻断通信:

iptables -A INPUT -p tcp --destination-port 9999 -j DROP

抓包查看

使用tcpdump抓包可看到以下包的交互:

15:23:12.110546 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [S], seq 4140259582, win 65495, options [mss 65495,sackOK,TS val 2989652584 ecr 0,nop,wscale 7], length 0
15:23:12.110564 IP 127.0.0.1.9999 > 127.0.0.1.39455: Flags [S.], seq 1045216537, ack 4140259583, win 65483, options [mss 65495,sackOK,TS val 2989652584 ecr 2989652584,nop,wscale 7], length 0
15:23:12.110576 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989652584 ecr 2989652584], length 0
15:23:12.110634 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [P.], seq 1:13, ack 1, win 512, options [nop,nop,TS val 2989652584 ecr 2989652584], length 12
15:23:12.110642 IP 127.0.0.1.9999 > 127.0.0.1.39455: Flags [.], ack 13, win 512, options [nop,nop,TS val 2989652584 ecr 2989652584], length 0
15:23:17.110179 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989657584 ecr 2989652584], length 0
15:23:17.110193 IP 127.0.0.1.9999 > 127.0.0.1.39455: Flags [.], ack 13, win 512, options [nop,nop,TS val 2989657584 ecr 2989652584], length 0
15:23:22.110187 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989662584 ecr 2989657584], length 0
15:23:22.110200 IP 127.0.0.1.9999 > 127.0.0.1.39455: Flags [.], ack 13, win 512, options [nop,nop,TS val 2989662584 ecr 2989652584], length 0
15:23:27.110191 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989667584 ecr 2989662584], length 0
15:23:29.110457 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989669584 ecr 2989662584], length 0
15:23:31.110451 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989671584 ecr 2989662584], length 0
15:23:33.110178 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989673584 ecr 2989662584], length 0
15:23:35.110429 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989675584 ecr 2989662584], length 0
15:23:37.110459 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989677584 ecr 2989662584], length 0
15:23:39.110433 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989679584 ecr 2989662584], length 0
15:23:41.110452 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989681584 ecr 2989662584], length 0
15:23:43.110428 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989683584 ecr 2989662584], length 0
15:23:45.110449 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [.], ack 1, win 512, options [nop,nop,TS val 2989685584 ecr 2989662584], length 0
15:23:47.110448 IP 127.0.0.1.39455 > 127.0.0.1.9999: Flags [R.], seq 13, ack 1, win 512, options [nop,nop,TS val 2989687584 ecr 2989662584], length 0

其中:

  • 15:23:12.110546 - 15:23:12.110576为三次握手建立连接
  • 15:23:12.110634 - 15:23:12.110642为数据发送接收ACK
  • 15:23:17.110179 - 15:23:22.110200为5s探活一次,并接收到9999返回的ACK
  • 15:23:26通过iptables阻断掉9999端口
  • 15:23:27.110191 - 15:23:45.110449每隔2s发送一次探活包,但并无应答
  • 15:23:47.110448发送RST包关闭连接

抓包分析结果基本与我们预期一致。

如何使用

KeepAlive in C

C中可以非常简单的通过系统调用对是否开启及3个参数进行配置。
C语言实现代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
int main()
{
int s;
int optval;
socklen_t optlen = sizeof(optval);
int keepAlive = 1;
int keepIdle = 5; // idle
int keepInterval = 5; // interval
int keepCount = 3; // count
/* Create the socket */
if((s = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
perror("socket()");
exit(EXIT_FAILURE);
}
/* Check the status for the keepalive option */
if(getsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen) < 0) {
perror("getsockopt()");
close(s);
exit(EXIT_FAILURE);
}
printf("SO_KEEPALIVE is %s\n", (optval ? "ON" : "OFF"));
/* 设置开启keepalive */
if(setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, (void*)&keepAlive,sizeof(keepAlive)) < 0) {
perror("setsockopt()");
close(s);
exit(EXIT_FAILURE);
}
printf("SO_KEEPALIVE set on socket\n");
/* 设置IDLE */
if(setsockopt(s, SOL_TCP, TCP_KEEPIDLE, (void *)&keepIdle, sizeof(keepIdle)) < 0)
{
perror("setsockopt()");
close(s);
exit(EXIT_FAILURE);
}
/* 设置INTVL */
if(setsockopt(s, SOL_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval)) < 0)
{
perror("setsockopt()");
close(s);
exit(EXIT_FAILURE);
}
/* 设置CNT */
if(setsockopt(s, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount)) < 0)
{
perror("setsockopt()");
close(s);
exit(EXIT_FAILURE);
}
/* Check the status again */
if(getsockopt(s, SOL_SOCKET, SO_KEEPALIVE, &optval, &optlen) < 0) {
perror("getsockopt()");
close(s);
exit(EXIT_FAILURE);
}
printf("SO_KEEPALIVE is %s\n", (optval ? "ON" : "OFF"));
close(s);
exit(EXIT_SUCCESS);
}

KeepAlive in Go

Go中net.TCPConn结构提供以下两个参数:

func (c *TCPConn) SetKeepAlive(keepalive bool) error // 开启关闭keepalive
func (c *TCPConn) SetKeepAlivePeriod(d time.Duration) error // 使用相同的值设置IDLEINTVL

目前不支持设置TCP_KEEPCNT的,其它选项可以方便的配置,代码示例如下:

func SetKeepAlivePeriod(c *net.Conn, d time.Duration) error {
if t, ok := c.Sock.(*net.TCPConn); ok { // 只有TCPConn支持
if err := t.SetKeepAlive(d != 0); err != nil { // 设置开启
return errors.Trace(err)
}
if d != 0 {
if err := t.SetKeepAlivePeriod(d); err != nil { // 设置参数
return errors.Trace(err)
}
}
}
return nil
}

注意

使用注意

  • 合理配置,不要探测太频繁,太频繁是对网络资源的浪费
  • 不要滥用,是对连接资源的浪费,资源池中还是应该使用合理的连接数
  • 还是需要对连接的异常进程处理,不能完全保证连接的可用

与HTTP keepalive

TCP的keep alive与HTTP的keep alive不是一个概念。
HTTP的keep alive功能指的是HTTP 1.1后,请求默认是使用持久连接,即服务器在响应后保持连接,后续的请求通过该连接继续传输,减少建立连接的开销。

与重传

如果发送方发送的数据包没有收到接收方回复的ACK数据包,此时TCP keepalive机制并不会被启动,而是启动超时重传机制。而因为重传也算作网络传输,因此就使得TCP Keep-alive机制在未收到ACK包时失效。

与应用层心跳

不能取代应用层心跳。因为:

  • keepalive某些场景下会失效
  • 应用层心跳更有效,keepalive只能保证连接层的有效,而实际场景中连接可能有效,但服务已经无法响应
  • 应用层心跳可用实现更个性化的功能

连接测试
既然连接池中的连接不能保证一定有些,那可不可以在每次使用前增加检测呢?例如jedispool就提供了TestOnBorrow、TestOnBorrow、TestWhileIdle三个选项,提供在特定场景下对连接测试的功能。
但是不建议使用。
原因是:

  • 连接正常的场景下,相当于浪费一次RTT,增加了延迟
  • 检测过后,也不能保证连接一定有效
    因此还是建议开启keepalive配合完善的异常处理、重试的方式实现。

总结

TCP keepalive通过在空闲时发送ACK数据包,然后对方回应ACK来实现连接的包含。尤其适用于有中间设备的长连接通信。
使用方便,除设置选项后应用无需做出任何改变,也更轻量。但是不能完全取代应用层心跳。
适合用来做连接保活,很难用于做探活。

参考

Using TCP keep alive under Linux