连接跟踪(conntrack)
摘要
本文介绍连接跟踪(connection tracking,conntrack,CT)的原理,应用,及其在 Linux 内核中的实现。
代码分析基于内核 4.19。为使行文简洁,所贴代码只保留了核心逻辑,但都给出了代码 所在的源文件,如有需要请查阅。
水平有限,文中不免有错误之处,欢迎指正交流。
引言
连接跟踪是许多网络应用的基础。例如,Kubernetes Service、ServiceMesh sidecar、 软件四层负载均衡器 LVS/IPVS、Docker network、OVS、iptables 主机防火墙等等,都依赖 连接跟踪功能。
概念
连接跟踪,顾名思义,就是跟踪(并记录)连接的状态。
Fig 1.1. 连接跟踪及其内核位置示意图
例如,上图是一台 IP 地址为 10.1.1.2 的 Linux 机器,我们能看到这台机器上有三条 连接:
- 机器访问外部 HTTP 服务的连接(目的端口 80)
- 外部访问机器内 FTP 服务的连接(目的端口 21)
- 机器访问外部 DNS 服务的连接(目的端口 53)
连接跟踪所做的事情就是发现并跟踪这些连接的状态,具体包括:
- 从数据包中提取元组(tuple)信息,辨别数据流(flow)和对应的连接(connection)
- 为所有连接维护一个状态数据库(conntrack table),例如连接的创建时间、发送 包数、发送字节数等等
- 回收过期的连接(GC)
- 为更上层的功能(例如 NAT)提供服务
需要注意的是,连接跟踪中所说的“连接”,概念和 TCP/IP 协议中“面向连接”( connection oriented)的“连接”并不完全相同,简单来说:
- TCP/IP 协议中,连接是一个四层(Layer 4)的概念。
- TCP 是有连接的,或称面向连接的(connection oriented),发送出去的包都要求对端应答(ACK),并且有重传机制
- UDP 是无连接的,发送的包无需对端应答,也没有重传机制
- CT 中,一个元组(tuple)定义的一条数据流(flow )就表示一条连接(connection)
- 后面会看到 UDP 甚至是 ICMP 这种三层协议在 CT 中也都是有连接记录的
- 但不是所有协议都会被连接跟踪
本文中用到“连接”一词时,大部分情况下指的都是后者,即“连接跟踪”中的“连接”。\
原理
了解以上概念之后,我们来思考下连接跟踪的技术原理。
要跟踪一台机器的所有连接状态,就需要
- 拦截(或称过滤)流经这台机器的每一个数据包,并进行分析。
- 根据这些信息建立起这台机器上的连接信息数据库(conntrack table)。
- 根据拦截到的包信息,不断更新数据库
例如,
- 拦截到一个 TCP SYNC 包时,说明正在尝试建立 TCP 连接,需要创建一条新 conntrack entry 来记录这条连接
- 拦截到一个属于已有 conntrack entry 的包时,需要更新这条 conntrack entry 的收发包数等统计信息
除了以上两点功能需求,还要考虑性能问题,因为连接跟踪要对每个包进行过滤和分析 。性能问题非常重要,但不是本文重点,后面介绍实现时会进一步提及。
之外,这些功能最好还有配套的管理工具来更方便地使用。
设计:Netfilter
Linux 的连接跟踪是在 Netfilter 中实现的。
Fig 1.2. Netfilter architecture inside Linux kernel
Netfilter 是 Linux 内核中一个对数据 包进行控制、修改和过滤(manipulation and filtering)的框架。它在内核协议 栈中设置了若干hook 点,以此对数据包进行拦截、过滤或其他处理。
说地更直白一些,hook 机制就是在数据包的必经之路上设置若干检测点,所有到达这 些检测点的包都必须接受检测,根据检测的结果决定:
1 | 放行:不对包进行任何修改,退出检测逻辑,继续后面正常的包处理 |
Netfilter 是最古老的内核框架之一,1998 年开始开发,2000 年合并到 2.4.x 内 核主线版本 [5]。
设计:进一步思考
现在提到连接跟踪(conntrack),可能首先都会想到 Netfilter。但由上节讨论可知, 连接跟踪概念是独立于 Netfilter 的,Netfilter 只是 Linux 内核中的一种连接跟踪实现。
换句话说,只要具备了 hook 能力,能拦截到进出主机的每个包,完全可以在此基础上自 己实现一套连接跟踪。
Fig 1.3. Cilium’s conntrack and NAT architectrue
云原生网络方案 Cilium 在 1.7.4+ 版本就实现了这样一套独立的连接跟踪和 NAT 机制 (完备功能需要 Kernel 4.19+)。其基本原理是:
- 基于 BPF hook 实现数据包的拦截功能(等价于 netfilter 里面的 hook 机制)
- 在 BPF hook 的基础上,实现一套全新的 conntrack 和 NAT
因此,即便卸载 Netfilter ,也不会影响 Cilium 对 Kubernetes ClusterIP、NodePort、ExternalIPs 和 LoadBalancer 等功能的支持 [2]。
由于这套连接跟踪机制是独立于 Netfilter 的,因此它的 conntrack 和 NAT 信息也没有 存储在内核的(也就是 Netfilter 的)conntrack table 和 NAT table。所以常规的 conntrack/netstats/ss/lsof 等工具是看不到的,要使用 Cilium 的命令,例如:
1 | cilium bpf nat list |
配置也是独立的,需要在 Cilium 里面配置,例如命令行选项 –bpf-ct-tcp-max。
另外,本文会多次提到连接跟踪模块和 NAT 模块独立,但出于性能考虑,具体实现中 二者代码可能是有耦合的。例如 Cilium 做 conntrack 的垃圾回收(GC)时就会顺便把 NAT 里相应的 entry 回收掉,而非为 NAT 做单独的 GC。
BPF全称是「Berkeley Packet Filter」,翻译过来是「伯克利包过滤器」,顾名思义,它是在伯克利大学诞生的,1992年Steven McCanne 和 Van Jacobson 写了一篇《The BSD Packet Filter: A New Architecture for User-level Packet Capture》论文 ,第一次提出了BPF技术,在文中,作者描述了他们如何在 Unix 内核实现网络数据包过滤,这种新的技术比当时最先进的数据包过滤技术快 20 倍。
应用
来看几个 conntrack 的具体应用。
网络地址转换(NAT)
网络地址转换(NAT),名字表达的意思也比较清楚:对(数据包的)网络地址(IP + Port)进行转换。
Fig 1.4. NAT 及其内核位置示意图
例如上图中,机器自己的 IP 10.1.1.2 是能与外部正常通信的,但 192.168 网段是私有 IP 段,外界无法访问,也就是说源 IP 地址是 192.168 的包,其应答包是无 法回来的。因此,
- 当源地址为 192.168 网段的包要出去时,机器会先将源 IP 换成机器自己的 10.1.1.2 再发送出去;
- 收到应答包时,再进行相反的转换。
这就是 NAT 的基本过程。
Docker 默认的 bridge 网络模式就是这个原理 [4]。每个容器会分一个私有网段的 IP 地址,这个 IP 地址可以在宿主机内的不同容器之间通信,但容器流量出宿主机时要进行 NAT。
NAT 又可以细分为几类:
- SNAT:对源地址(source)进行转换
- DNAT:对目的地址(destination)进行转换
- Full NAT:同时对源地址和目的地址进行转换
以上场景属于 SNAT,将不同私有 IP 都映射成同一个“公有 IP”,以使其能访问外部网络服 务。这种场景也属于正向代理。
NAT 依赖连接跟踪的结果。连接跟踪最重要的使用场景就是 NAT。
四层负载均衡(L4LB)
再将范围稍微延伸一点,讨论一下 NAT 模式的四层负载均衡。
四层负载均衡是根据包的四层信息(例如 src/dst ip, src/dst port, proto)做流量分发。
VIP(Virtual IP)是四层负载均衡的一种实现方式:
- 多个后端真实 IP(Real IP)挂到同一个虚拟 IP(VIP)上
- 客户端过来的流量先到达 VIP,再经负载均衡算法转发给某个特定的后端 IP
如果在 VIP 和 Real IP 节点之间使用的 NAT 技术(也可以使用其他技术),那客户端访 问服务端时,L4LB 节点将做双向 NAT(Full NAT),数据流如下图所示:
Fig 1.5. L4LB: Traffic path in NAT mode [3]
有状态防火墙
有状态防火墙(stateful firewall)是相对于早期的无状态防火墙(stateless firewall)而言的:早期防火墙只能写 drop syn to port 443 或者 allow syn to port 80 这种非常简单直接 的规则,没有 flow 的概念,因此无法实现诸如 “如果这个 ack 之前已经有 syn, 就 allow,否则 drop” 这样的规则,使用非常受限 [6]。
显然,要实现有状态防火墙,就必须记录 flow 和状态,这正是 conntrack 做的事情。
来看个更具体的防火墙应用:OpenStack 主机防火墙解决方案 —— 安全组(security group)。
OpenStack 安全组
简单来说,安全组实现了虚拟机级别的安全隔离,具体实现是:在 node 上连接 VM 的 网络设备上做有状态防火墙。在当时,最能实现这一功能的可能就是 Netfilter/iptables。
回到宿主机内网络拓扑问题: OpenStack 使用 OVS bridge 来连接一台宿主机内的所有 VM。 如果只从网络连通性考虑,那每个 VM 应该直接连到 OVS bridge br-int。但这里问题 就来了 [7]:
- (较早版本的)OVS 没有 conntrack 模块,
- Linux 中有 conntrack 模块,但基于 conntrack 的防火墙工作在 IP 层(L3),通过 iptables 控制,
- 而 OVS 是 L2 模块,无法使用 L3 模块的功能,
最终结果是:无法在 OVS (连接虚拟机)的设备上做防火墙。
所以,2016 之前 OpenStack 的解决方案是,在每个 OVS 和 VM 之间再加一个 Linux bridge ,如下图所示,
Fig 1.6. Network topology within an OpenStack compute node, picture from Sai’s Blog
Linux bridge 也是 L2 模块,按道理也无法使用 iptables。但是,它有一个 L2 工具 ebtables,能够跳转到 iptables,因此间接支持了 iptables,也就能用到 Netfilter/iptables 防火墙的功能。
这种暴力堆砌的方式不仅丑陋、增加网络复杂性,而且会导致性能问题。因此, RedHat 在 2016 年提出了一个 OVS conntrack 方案 [7],从那以后,才有可能干掉 Linux bridge 而仍然具备安全组的功能。
Netfilter hook 机制实现
Netfilter 由几个模块构成,其中最主要的是连接跟踪(CT)模块和网络地址转换(NAT)模块。
CT 模块的主要职责是识别出可进行连接跟踪的包。 CT 模块独立于 NAT 模块,但主要目的是服务于后者。
Netfilter 框架
5 个 hook 点
图 2.1. The 5 hook points in netfilter framework
如上图所示,Netfilter 在内核协议栈的包处理路径上提供了 5 个 hook 点,分别是:
1 | // include/uapi/linux/netfilter_ipv4.h |
用户可以在这些 hook 点注册自己的处理函数(handlers)。当有数据包经过 hook 点时, 就会调用相应的 handlers。
另外还有一套 NF_INET_ 开头的定义,include/uapi/linux/netfilter.h。 这两套是等价的,从注释看,NF_IP_ 开头的定义可能是为了保持兼容性。
1 | enum nf_inet_hooks { |
hook 返回值类型
hook 函数对包进行判断或处理之后,需要返回一个判断结果,指导接下来要对这个包做什 么。可能的结果有:
1 | // include/uapi/linux/netfilter.h |
hook 优先级
每个 hook 点可以注册多个处理函数(handler)。在注册时必须指定这些 handlers 的优先级,这样触发 hook 时能够根据优先级依次调用处理函数。
过滤规则的组织
iptables 是配置 Netfilter 过滤功能的用户空间工具。为便于管理, 过滤规则按功能分为若干 table:
- raw
- mangle
- nat
- filter
这不是本文重点。更多信息可参考 (译) 深入理解 iptables 和 netfilter 架构
Netfilter conntrack 实现
连接跟踪模块用于维护可跟踪协议(trackable protocols)的连接状态。 也就是说,连接跟踪针对的是特定协议的包,而不是所有协议的包。 稍后会看到它支持哪些协议。
重要结构体和函数
重要结构体:
struct nf_conntrack_tuple {}: 定义一个 tuple。
struct nf_conntrack_man {}:tuple 的 manipulable part。
struct nf_conntrack_man_proto {}:manipulable part 中协议相关的部分。
struct nf_conntrack_l4proto {}: 支持连接跟踪的协议需要实现的方法集(以及其他协议相关字段)。
struct nf_conntrack_tuple_hash {}:哈希表(conntrack table)中的表项(entry)。
struct nf_conn {}:定义一个 flow。
重要函数:
hash_conntrack_raw():根据 tuple 计算出一个 32 位的哈希值(hash key)。
nf_conntrack_in():连接跟踪模块的核心,包进入连接跟踪的地方。
resolve_normal_ct() -> init_conntrack() -> ct = __nf_conntrack_alloc(); l4proto->new(ct)
创建一个新的连接记录(conntrack entry),然后初始化。
nf_conntrack_confirm():确认前面通过 nf_conntrack_in() 创建的新连接(是否被丢弃)。
struct nf_conntrack_tuple {}:元组(Tuple)
Tuple 是连接跟踪中最重要的概念之一。
一个 tuple 定义一个单向(unidirectional)flow。内核代码中有如下注释:
1 | //include/net/netfilter/nf_conntrack_tuple.h |
结构体定义
1 | //include/net/netfilter/nf_conntrack_tuple.h |
Tuple 结构体中只有两个字段 src 和 dst,分别保存源和目的信息。src 和 dst 自身也是结构体,能保存不同类型协议的数据。以 IPv4 UDP 为例,五元组分别保存在如下字段:
- dst.protonum:协议类型
- src.u3.ip:源 IP 地址
- dst.u3.ip:目的 IP 地址
- src.u.udp.port:源端口号
- dst.u.udp.port:目的端口号
CT 支持的协议
从以上定义可以看到,连接跟踪模块目前只支持以下六种协议:TCP、UDP、ICMP、DCCP、SCTP、GRE。
注意其中的 ICMP 协议。大家可能会认为,连接跟踪模块依据包的三层和四层信息做 哈希,而 ICMP 是三层协议,没有四层信息,因此 ICMP 肯定不会被 CT 记录。但实际上 是会的,上面代码可以看到,ICMP 使用了其头信息中的 ICMP type和 code 字段来 定义 tuple。
struct nf_conntrack_l4proto {}:协议需要实现的方法集合
支持连接跟踪的协议都需要实现 struct nf_conntrack_l4proto {} 结构体 中定义的方法,例如 pkt_to_tuple()。
1 | // include/net/netfilter/nf_conntrack_l4proto.h |
struct nf_conntrack_tuple_hash {}:哈希表项
conntrack 将活动连接的状态存储在一张哈希表中(key: value)。
hash_conntrack_raw() 根据 tuple 计算出一个 32 位的哈希值(key):
1 | // net/netfilter/nf_conntrack_core.c |
注意其中是如何利用 tuple 的不同字段来计算哈希的。
nf_conntrack_tuple_hash 是哈希表中的表项(value):
1 | // include/net/netfilter/nf_conntrack_tuple.h |
struct nf_conn {}:连接(connection)
Netfilter 中每个 flow 都称为一个 connection,即使是对那些非面向连接的协议(例 如 UDP)。每个 connection 用 struct nf_conn {} 表示,主要字段如下:
1 | // include/net/netfilter/nf_conntrack.h |
连接的状态集合 enum ip_conntrack_status:
1 | // include/uapi/linux/netfilter/nf_conntrack_common.h |
nf_conntrack_in():进入连接跟踪
Fig. Netfilter 中的连接跟踪点
如上图所示,Netfilter 在四个 Hook 点对包进行跟踪:
PRE_ROUTING 和 LOCAL_OUT:调用 nf_conntrack_in() 开始连接跟踪, 正常情况下会创建一条新连接记录,然后将 conntrack entry 放到 unconfirmed list。
为什么是这两个 hook 点呢?因为它们都是新连接的第一个包最先达到的地方,
PRE_ROUTING 是外部主动和本机建连时包最先到达的地方
LOCAL_OUT 是本机主动和外部建连时包最先到达的地方
POST_ROUTING 和 LOCAL_IN:调用 nf_conntrack_confirm() 将 nf_conntrack_in() 创建的连接移到 confirmed list。
同样要问,为什么在这两个 hook 点呢?因为如果新连接的第一个包没有被丢弃,那这 是它们离开 netfilter 之前的最后 hook 点:
外部主动和本机建连的包,如果在中间处理中没有被丢弃,LOCAL_IN 是其被送到应用(例如 nginx 服务)之前的最后 hook 点
本机主动和外部建连的包,如果在中间处理中没有被丢弃,POST_ROUTING 是其离开主机时的最后 hook 点
下面的代码可以看到这些 handler 是如何注册到 Netfilter hook 点的:
1 | // net/netfilter/nf_conntrack_proto.c |
nf_conntrack_in() 是连接跟踪模块的核心。
1 | // net/netfilter/nf_conntrack_core.c |
大致流程:
- 尝试获取这个 skb 对应的连接跟踪记录
- 判断是否需要对这个包做连接跟踪,如果不需要,更新 ignore 计数(conntrack -S 能看到这个计数), 返回 NF_ACCEPT;如果需要,就初始化这个 skb 的引用计数。
- 从包的 L4 header 中提取信息,初始化协议相关的 struct nf_conntrack_l4proto {} 变量,其中包含了该协议的连接跟踪相关的回调方法。
- 调用该协议的 error() 方法检查包的完整性、校验和等信息。
- 调用 resolve_normal_ct() 开始连接跟踪,它会创建新 tuple,新 conntrack entry,或者更新已有连接的状态。
- 调用该协议的 packet() 方法进行一些协议相关的处理,例如对于 UDP,如果 status bit 里面设置了 IPS_SEEN_REPLY 位,就会更新 timeout。timeout 大小和协 议相关,越小越越可以防止 DoS 攻击(DoS 的基本原理就是将机器的可用连接耗尽)
init_conntrack():创建新连接记录
如果连接不存在(flow 的第一个包),resolve_normal_ct() 会调用 init_conntrack ,后者进而会调用 new() 方法创建一个新的 conntrack entry。
1 | // include/net/netfilter/nf_conntrack_core.c |
每种协议需要实现自己的 l4proto->new() 方法,代码见:net/netfilter/nf_conntrack_proto_*.c。 例如 TCP 协议对应的 new() 方法是:
1 | // net/netfilter/nf_conntrack_proto_tcp.c |
如果当前包会影响后面包的状态判断,init_conntrack() 会设置 struct nf_conn 的 master 字段。面向连接的协议会用到这个特性,例如 TCP。
nf_conntrack_confirm():确认包没有被丢弃
nf_conntrack_in() 创建的新 conntrack entry 会插入到一个 未确认连接( unconfirmed connection)列表。
如果这个包之后没有被丢弃,那它在经过 POST_ROUTING 时会被 nf_conntrack_confirm() 方法处理,原理我们在分析过了 3.6 节的开头分析过了。 nf_conntrack_confirm() 完成之后,状态就变为了 IPS_CONFIRMED,并且连接记录从 未确认列表移到正常的列表。
之所以把创建一个新 entry 的过程分为创建(new)和确认(confirm)两个阶段 ,是因为包在经过 nf_conntrack_in() 之后,到达 nf_conntrack_confirm() 之前 ,可能会被内核丢弃。这样会导致系统残留大量的半连接状态记录,在性能和安全性上都 是很大问题。分为两步之后,可以加快半连接状态 conntrack entry 的 GC。
1 | // include/net/netfilter/nf_conntrack_core.h |
confirm 逻辑,省略了各种错误处理逻辑:
1 | // net/netfilter/nf_conntrack_core.c |
可以看到,连接跟踪的处理逻辑中需要频繁关闭和打开软中断,此外还有各种锁, 这是短连高并发场景下连接跟踪性能损耗的主要原因?。
Netfilter NAT 实现
NAT 是与连接跟踪独立的模块。
重要数据结构和函数
重要数据结构:
支持 NAT 的协议需要实现其中的方法:
- struct nf_nat_l3proto {}
- struct nf_nat_l4proto {}
重要函数:
- nf_nat_inet_fn():NAT 的核心函数,在除 NF_INET_FORWARD 之外的其他 hook 点都会被调用。
NAT 模块初始化
1 | // net/netfilter/nf_nat_core.c |
struct nf_nat_l3proto {}:协议相关的 NAT 方法集
1 | // include/net/netfilter/nf_nat_l3proto.h |
struct nf_nat_l4proto {}:协议相关的 NAT 方法集
1 | // include/net/netfilter/nf_nat_l4proto.h |
各协议实现的方法,见:net/netfilter/nf_nat_proto_*.c。例如 TCP 的实现:
1 | // net/netfilter/nf_nat_proto_tcp.c |
nf_nat_inet_fn():进入 NAT
NAT 的核心函数是 nf_nat_inet_fn(),它会在以下 hook 点被调用:
- NF_INET_PRE_ROUTING
- NF_INET_POST_ROUTING
- NF_INET_LOCAL_OUT
- NF_INET_LOCAL_IN
也就是除了 NF_INET_FORWARD 之外其他 hook 点都会被调用。
在这些 hook 点的优先级:Conntrack > NAT > Packet Filtering。 连接跟踪的优先级高于 NAT 是因为 NAT 依赖连接跟踪的结果。
Fig. NAT
1 | unsigned int |
首先查询 conntrack 记录,如果不存在,就意味着无法跟踪这个连接,那就更不可能做 NAT 了,因此直接返回。
如果找到了 conntrack 记录,并且是 IP_CT_RELATED、IP_CT_RELATED_REPLY 或 IP_CT_NEW 状态,就去获取 NAT 规则。如果没有规则,直接返回 NF_ACCEPT,对包不 做任何改动;如果有规则,最后执行 nf_nat_packet,这个函数会进一步调用 manip_pkt 完成对包的修改,如果失败,包将被丢弃。
Masquerade
NAT 模块:
- 一般配置方式:Change IP1 to IP2 if matching XXX。
- 高级配置方式:Change IP1 to dev1’s IP if matching XXX,这种方式称为
Masquerade。
Masquerade 优缺点:
- 优点:当设备(网卡)的 IP 地址发生变化时,NAT 规则无需做任何修改。
- 缺点:性能比第一种方式要差。
nf_nat_packet():执行 NAT
1 | // net/netfilter/nf_nat_core.c |
1 | static unsigned int nf_nat_manip_pkt(struct sk_buff *skb, struct nf_conn *ct, |
配置和监控
查看/加载/卸载 nf_conntrack 模块
1 | $ modinfo nf_conntrack |
卸载:
1 | $ rmmod nf_conntrack_netlink nf_conntrack |
重新加载:
1 | $ modprobe nf_conntrack |
sysctl 配置项
1 | $ sysctl -a | grep nf_conntrack |
监控
丢包监控
/proc/net/stat 下面有一些关于 conntrack 的详细统计:
1 | $ cat /proc/net/stat/nf_conntrack |
此外,还可以用 conntrack 命令:
1 | $ conntrack -S |
ignore:不需要做连接跟踪的包(回忆前面,只有特定协议的包才会做连接跟踪)
conntrack table 使用量监控
可以定期采集系统的 conntrack 使用量,
1 | $ cat /proc/sys/net/netfilter/nf_conntrack_count |
并与最大值比较:
1 | $ cat /proc/sys/net/netfilter/nf_conntrack_max |
常见问题
连接太多导致 conntrack table 被打爆
现象
业务层(应用层)现象
- 存在随机、偶发的新建连接超时(connect timeout)。
例如,如果业务用的是 Java,那对应的是 jdbc4.CommunicationsException communications link failure 之类的错误。
- 已有连接正常。
也就是没有 read timeout 或 write timeout 之类的报错,报错都集中为 connect timeout。
网络层现象
抓包会看到三次握手的第一个 SYN 包被宿主机静默丢弃了。
需要注意的是,常规的网卡统计(ifconfig)和内核统计(/proc/net/softnet_stat) 无法反映出这些丢包。1s+ 之后出发 SYN 重传,或者还没重传连接就关闭了。
第一个 SYN 的重传是 1s,这个是内核代码里写死的,不可配置(具体实现见 附录)。
再考虑到其他一些耗时,第一次重传的实际间隔要大于 1s。 如果客户端设置的超时时间很小,例如 1.05s,那可能来不及重传连接就被关闭了,然后向上层报 connect timeout 错误。
操作系统层现象
内核日志中有如下报错:
1 | $ demsg -T |
另外,cat /proc/net/stat/nf_conntrack 或 conntrack -S 能看到有 drop 统计。
确认 conntrack table 被打爆
遇到以上现象,基本就是 conntrack 表被打爆了。确认:
1 | $ cat /proc/sys/net/netfilter/nf_conntrack_count |
如果有 conntrack count 监控会看的更清楚,因为我们命令行查看时,高峰可能过了。
解决方式
优先级从高到低:
- 调大 conntrack 表
运行时配置(经实际测试,不会对现有连接造成影响):
1 | $ sysctl -w net.netfilter.nf_conntrack_max=524288 |
持久化配置:
1 | $ echo 'net.netfilter.nf_conntrack_max = 524288' >> /etc/sysctl.conf |
影响:连接跟踪模块会多用一些内存。具体多用多少内存,可参考 附录。
- 减小 GC 时间
还可以调小 conntrack 的 GC(也叫 timeout)时间,加快过期 entry 的回收。
nf_conntrack 针对不同 TCP 状态(established、fin_wait、time_wait 等)的 entry 有不同的 GC 时间。
例如,默认的 established 状态的 GC 时间是 423000s(5 天)。设置成这么长的 可能原因是:TCP/IP 协议中允许 established 状态的连接无限期不发送任何东西(但仍然活着) [8],协议的具体实现(Linux、BSD、Windows 等)会设置各自允许的最大 idle timeout。为防止 GC 掉这样长时间没流量但实际还活着的连接,就设置一个足够保守的 timeout 时间。[8] 中建议这个值不小于 2 小时 4 分钟(作为对比和参考, Cilium 自己实现的 CT 中,默认 established GC 是 6 小时)。 但也能看到一些厂商推荐比这个小得多的配置,例如 20 分钟。
如果对自己的网络环境和需求非常清楚,那可以将这个时间调到一个合理的、足够小的值; 如果不是非常确定的话,还是建议保守一些,例如设置 6 个小时 —— 这已经比默认值 5 天小多了。
1 | $ sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established = 21600 |
持久化:
1 | $ echo 'net.netfilter.nf_conntrack_tcp_timeout_established = 21600' >> /etc/sysctl.conf |
其他几个 timeout 值(尤其是 nf_conntrack_tcp_timeout_time_wait,默认 120s)也可以适当调小, 但还是那句话:如果不确定潜在后果,千万不要激进地调小。
总结
连接跟踪是一个非常基础且重要的网络模块,但只有在少数场景下才会引起普通开发者的注意。
例如,L4LB 短时高并发场景下,LB 节点每秒接受大量并发短连接,可能导致 conntrack table 被打爆。此时的现象是:
- 客户端和 L4LB 建连失败,失败可能是随机的,也可能是集中在某些时间点。
- 客户端重试可能会成功,也可能会失败。
- 在 L4LB 节点抓包看,客户端过来的 TCP SYNC 包 L4LB 收到了,但没有回 ACK。即,包 被静默丢弃了(silently dropped)。
此时的原因可能是 conntrack table 太小,也可能是 GC 不够及 时,甚至是 GC 有bug。
附录
第一个 SYN 包的重传间隔计算(Linux 4.19.118 实现)
调用路径:tcp_connect() -> tcp_connect_init() -> tcp_timeout_init()。
1 | / net/ipv4/tcp_output.c |
根据 nf_conntrack_max 计算 conntrack 模块所需的内存
1 | $ cat /proc/slabinfo | head -n2; cat /proc/slabinfo | grep conntrack |
其中的 objsize 表示这个内核对象(这里对应的是 struct nf_conn)的大小, 单位是字节,所以以上输出表明每个 conntrack entry 占用 320 字节的内存空间。
如果忽略内存碎片(内存分配单位为 slab),那不同 size 的 conntrack table 占用的内存如下:
- nf_conntrack_max=512K: 512K * 320Byte = 160MB
- nf_conntrack_max=1M: 1M * 320Byte = 320MB
更精确的计算,可以参考 [9]。