前面几篇文章分别是基本介绍、基础原理、操作实践几个方面来介绍了负载均衡 LVS 的一些知识。如果能够认真地把这几篇文章看完,理解并认真思考后应该会有所收获,至少能够掌握 LVS 的基础实现原理。
后边会继续从代码层面分析 lvs 的实现原理和细节。其实不只开发人员需要懂代码,运维人员如果能够深入到代码层面,相信对原理的理解会更加透彻,而且对平时运维中的 troubleshoting 肯定有帮助。如果感兴趣的话,可以一起来学习和探索 lvs 的具体实现原理。
何为连接?我们知道 TCP 是面向连接的四层传输协议,一个 TCP 连接包括我们常说的五元组:src-IP、dst-IP、src-port、dst-port、protocol,也就是这 5 个元素来标识一个连接,能够代表唯一的会话。
lvs 是个四层负载均衡转发器,负责将数据包转给后端的服务器。那具体是怎么转发的呢,它有自己的调度方式,根据调度规则将数据包转发给正确的后端 Server。但是有个问题,每个数据包都需要根据特有的调度规则来进行调度转发的吗?
显然不是,因为每个 TCP 连接需要包含若干的数据包进行交互,如果每个 Packet 都按照调度规则来调度,比如按照 RR 模式来进行调度,那么该 TCP 连接相关的若干数据包则被轮询转发给了 N 个后端服务器,后端对这样的奇怪数据包是不感兴趣的(被无情丢弃),显然负载均衡需要将 TCP 连接的数据流的若干数据包转发至其中一台后端服务器上,而不是分散到多个服务器。
因此,lvs 若想正确转发数据包,需要维护记录数据包五元组代表的连接信息,包括首次建立连接时调度的 RS。那么后续的同一个连接(会话)的数据包到来后,查询系统维护的连接信息,查到该连接后就能确定这条连接对应的 RS 服务器,最终将数据包正确转发给服务器,这样就完成了将一个 TCP 连接转发给后端服务器了。
我们来看下 lvs 代码中是如何用数据结构来表示一条连接信息的,它定义的 struct 结构如下:
struct ip_vs_conn {
struct list_head c_list; /* 哈希链表头 */
u16 af; /* 协议族,代表 v4 或 v6 */
union nf_inet_addr caddr; /* 客户端 IP 地址 */
union nf_inet_addr vaddr; /* 客户端访问的 vip */
union nf_inet_addr daddr; /* 后端 RS 服务器 IP 地址 */
__be16 cport; /* 客户端请求源端口 */
__be16 vport; /* 访问的业务端口,一般为 80 或 443 */
__be16 dport; /* 后端服务器的端口,可以不同于 vport */
__u16 protocol; /* 协议类型,如 TCP/UDP */
atomic_t refcnt; /* 引用计数 */
struct timer_list timer; /* 连接对应的定时器 */
volatile unsigned long timeout; /* 超时时间 */
spinlock_t lock; /* 状态转化时需要锁操作 */
volatile __u16 flags; /* 标记位 */
volatile __u16 state; /* 状态信息 */
volatile __u16 old_state; /* 保存上一个状态 */
/* Control members */
struct ip_vs_conn *control; /* Master control connection */
atomic_t n_control; /* Number of controlled ones */
struct ip_vs_dest *dest; /* 连接所调度的后端 RS */
atomic_t in_pkts; /* 连接中进来数据包计数 */
int (*packet_xmit)(struct sk_buff *skb, struct ip_vs_conn *cp,
struct ip_vs_protocol *pp);
struct ip_vs_app *app; /* bound ip_vs_app object */
void *app_data; /* Application private data */
struct ip_vs_seq in_seq; /* incoming seq. struct */
struct ip_vs_seq out_seq; /* outgoing seq. struct */
};
上述的结构体 struct ip_vs_conn
定义一个连接所需要多个字段元素,介绍下几个核心的字段:
c_list
:哈希表头,用来组织链表的关键字段。caddr
:客户端 request 数据包的源 IP 地址。vaddr
:客户端 request 数据包的目的 IP 地址,一般是业务对外提供访问的 IP,称为 vipdaddr
:连接新建时调度选择的一个后端 RS 服务器 IP 地址。cport
:客户端 request 数据包的源 portvport
:客户端 request 数据包的目标 port,一般业务端口为 80 或 443dport
:连接对应的后端 RS 服务器端口,NAT 转发模式支持不同于 vportprotocol
:指的是四层传输协议,一般为 TCP 或 UDPstate
:连接是会记录状态的,根据数据包的交互而变化。packet_xmit
:数据包发送的回调函数,不同的转发模式有不同的 xmitlvs 能够提供高并发的转发能力,需要维护很多这样的连接信息,把这些连接信息组织起来就形成了 连接表
。
lvs 连接表设计时出于各方面的考虑,比如查找性能等,采用了典型的哈希表结构将多个 连接
组织为连接表,组织结构如下图所示:
如图,根据 caddr
、cport
、proto
关键字段按照某种计算方法进行计算,得到一个哈希值。
hash = ip_vs_conn_hashkey(cp->af, cp->protocol, &cp->caddr, cp->cport);
然后与连接表大小 IP_VS_CONN_TAB_MASK
值求和,得到在 0-255
范围内的一个整数,对应到图中的某个具体的槽位(桶),当前位置设置为链表头,然后将连接条目作为一个元素结点挂在此处的链表上。同理,其他连接信息会按照同样的方式被挂在如图所示的哈希链表上。
何时应该新建连接呢,对于 TCP 协议来说,只有客户端发起的 syn
数据包时才能够触发新建连接,相同连接会话的其他数据包到来时,只需要查找存在的连接表即可继续后续的转发工作。
dest = svc->scheduler->schedule(svc, skb);
if (dest == NULL) {
return NULL;
}
/* 只有在成功调度可用的后端 RS 服务器后才去新建连接 */
cp = ip_vs_conn_new(svc->af, iph.protocol,
&iph.saddr, pptr[0], &iph.daddr, pptr[1], &dest->addr,
dest->port ? dest->port : pptr[1], 0, dest);
新建连接的整个过程比较简单,大概分为几个步骤:
连接
所需要的内存资源空间到此新建连接完成,它的函数原型大概是这个样子:
struct ip_vs_conn * ip_vs_conn_new(af, proto, caddr, cport, vaddr, vport, daddr, dport, flags, dest);
新建连接后续的数据包到来是可以直接查找全局的连接表,找到该数据包所属的连接信息,也就能确定本次数据包需要转发给后端的哪一台 RS 服务器,通过相关操作后将数据包转发给连接所对应的 dest。
我们知道 DR 模式的话,只有客户端请求的流量会经过 LVS 服务器,当请求数据到达时,查找到支持的协议后,就会调用如下函数进行查找
struct ip_vs_conn *ip_vs_conn_in_get(af, protocol, s_addr, s_port, d_addr, d_port);
通过函数参数也可以知道,是根据这 5 元组来进行查找的,步骤如下:
ip_vs_conn_hashkey
函数计算 hashkeyip_vs_conn_tab[hash]
遍历链表,如果和参数的各字段都相同,则 hit如果是 NAT 模式的话,RS 的响应数据包也是会经过 LVS 服务器的,此时数据包的源 IP 是 RS 的 IP,目的 IP 是 VIP,因此需要使用另外一个函数来查找连接:
struct ip_vs_conn *ip_vs_conn_out_get(af, protocol, s_addr, s_port, d_addr, d_port);
ip_vs_conn_tab[hash]
哈希桶上的链表,字段对比时区别 IP 和 Port 的方向。if (d_addr == cp->caddr && /* 反向数据:目的 IP 就是客户端 IP */
s_addr == cp->daddr && /* 反向数据:源 IP 就是 RS 服务器 IP */
d_port == cp->cport && /* 反向数据:目的端口是客户端 Port */
s_port == cp->cport) /* 反向数据:源端口是 RS 服务器 Port */
上述关于连接的新建和查找比较容易理解,就是常见的添加与删除操作。而连接的删除比较特殊些,特殊在删除连接的时机上。
ip_vs_conn_expire
自动释放。各阶段状态的超时时间设置如下:static int tcp_timeouts[IP_VS_TCP_S_LAST+1] = {
[IP_VS_TCP_S_NONE] = 2*HZ,
[IP_VS_TCP_S_ESTABLISHED] = 15*60*HZ,
[IP_VS_TCP_S_SYN_SENT] = 2*60*HZ,
[IP_VS_TCP_S_SYN_RECV] = 1*60*HZ,
[IP_VS_TCP_S_FIN_WAIT] = 2*60*HZ,
[IP_VS_TCP_S_TIME_WAIT] = 2*60*HZ,
[IP_VS_TCP_S_CLOSE] = 10*HZ,
[IP_VS_TCP_S_CLOSE_WAIT] = 60*HZ,
[IP_VS_TCP_S_LAST_ACK] = 30*HZ,
[IP_VS_TCP_S_LISTEN] = 2*60*HZ,
[IP_VS_TCP_S_SYNACK] = 120*HZ,
[IP_VS_TCP_S_LAST] = 2*HZ,
};
上表可以看到,不同状态设置的超时时间不一样。比如 ESTABLISHED
状态设置时间比较长,可能你会担心如果 TCP 是长连接,输出传输要很长时间怎么办呢?其实是没有问题的,只要在 ESTABLISHED
状态超时时间范围内有数据包交互,就更会调用 ip_vs_conn_put
更新超时时间,也就是说在这个超时范围内只要有数据包(类似心跳)经过就不会中断释放掉连接。
// 连接过期处理函数
static void ip_vs_conn_expire(unsigned long data);
ip_vs_conn_unhash
ip_vs_unbind_dest
当然 lvs 也提供了立即释放连接的接口函数 ip_vs_conn_expire_now
直接将 timeout 设置为 0 ,相当于直接调用到了 ip_vs_conn_expire
函数。
我们现在生产环境使用的服务器都是多核处理器,在 lvs 转发处理逻辑中需要频繁的查连接表,由于连接表是全局的一张表结构,绝大部分时间 N 个 cpu 会同时操作(包括查找、新建和删除)连接表,这样会导致连接表不一致的问题。因此,每个 cpu 对连接表进行相关操作时需要加上相应的 ReadLock 或 WriteLock,才能保证连接表的原子性操作。
static inline void ct_read_lock(unsigned key)
{
read_lock(&__ip_vs_conntbl_lock_array[key&CT_LOCKARRAY_MASK].l);
}
// CT_LOCKARRAY_MASK = 16
对于上边的内容是有些可以优化的空间的:
IP_VS_TCP_S_ESTABLISHED
等状态的超时时间,这样能够让连接表老化的更快,这样能够支持的并发连接数也能够有所提高。上述几点也是一般程序优化经常使用的方式,也在想这么个问题,为何处理内核态的 lvs 没有再进一步做优化呢?猜想可能是这样的,Linux 本身是一个非常通用的操作系统,面向的是大众用户,需要支持运行各种服务,而 lvs 只是 Linux 冰山一角的功能,如果优化程度太高,会占用系统过多的资源,这可能不是 Linux 所希望的,不过内核原版 lvs 性能也能够满足大部分中小企业的需求。
简单总结,连接表的核心功能就是起到
连接追踪
,包括两方面内容:连接保持和连接老化。