一、保活的问题
之前一个同事问起一个问题:服务器通常不会主动检测客户端是否依然有效,在这种情况下,如果客户端异常退出后服务器依然维护着这条链路,随着时间的推移,过多的无效链接最终将会把服务器的资源消耗殆尽。举个例子:假设客户端是一个手机终端,用户可以抠出电池重启系统,这种情况下客户端的TCP协议栈没有机会向服务器发送FIN包来完成正常的断链过程,所以服务器无法感知到该链路的终结。如果这样的无效链路越来越多,将会严重影响服务器的服务能力。
现在再把场景具体一点,假设服务器接入层使用了LVS,此时如果客户端出现这种情况,服务器将会有什么样的表现行为?
二、LVS对于链路状态的维护
有一点是确定的,虽然后端服务器的回包可以不经过LVS而直接返回给客户端,但是入包都会经过LVS转发,所以对于这种情况,我们只需要先看下LVS对于入包的处理流程即可。linux-2.6.17
etipv4ipvsip_vs_core.c
static unsigned int
ip_vs_in(unsigned int hooknum, struct sk_buff **pskb,
const struct net_device *in, const struct net_device *out,
int (*okfn)(struct sk_buff *))
{
……
restart = ip_vs_set_state(cp, IP_VS_DIR_INPUT, skb, pp);
……
ip_vs_conn_put(cp);
return ret;
}
这里注意到,其中的ip_vs_set_state函数会在每个包到来的时候都被调用即可。
static inline int
ip_vs_set_state(struct ip_vs_conn *cp, int direction,
const struct sk_buff *skb,
struct ip_vs_protocol *pp)
{
if (unlikely(!pp->state_transition))
return 0;
return pp->state_transition(cp, direction, skb, pp);
}
对于TCP来说,它执行的状态转换函数为tcp_state_transition
linux-2.6.17
etipv4ipvsip_vs_proto_tcp.c
/*
* Handle state transitions
*/
static int
tcp_state_transition(struct ip_vs_conn *cp, int direction,
const struct sk_buff *skb,
struct ip_vs_protocol *pp)
{
struct tcphdr _tcph, *th;
th = skb_header_pointer(skb, skb->nh.iph->ihl*4,
sizeof(_tcph), &_tcph);
if (th == NULL)
return 0;
spin_lock(&cp->lock);
set_tcp_state(pp, cp, direction, th);
spin_unlock(&cp->lock);
return 1;
}
static inline void
set_tcp_state(struct ip_vs_protocol *pp, struct ip_vs_conn *cp,
int direction, struct tcphdr *th)
{
……
cp->timeout = pp->timeout_table[cp->state = new_state];
}
三、链路定时器的使用
大家可能不会注意到,在ip_vs_in函数最后的ip_vs_conn_put函数,它默默无闻的完成了这个链条的最后一环。
linux-2.6.17
etipv4ipvsip_vs_conn.c
void ip_vs_conn_put(struct ip_vs_conn *cp)
{
/* reset it expire in its timeout */
mod_timer(&cp->timer, jiffies+cp->timeout);
__ip_vs_conn_put(cp);
}
函数使用最新的超时时间来修改定时器,这意味着当每一个入包经过服务器的时候,都将会修更新该链路对应的定时器。反过来说,在此时设置的超时时间是100秒,那么在定时器超时之间没有在收到期望的入包,就会触发链路的后续操作。
四、以TCP的ESTABLISHED状态为例
TCP使用的超时表为tcp_timeouts,可以看到其中的超时时间配置为15*60*HZ,也就是ESTABLISHED状态下,如果15分钟没有包在该链路上再次经过,则认为链路已经超时。
linux-2.6.17
etipv4ipvsip_vs_proto_tcp.c
/*
* Timeout table[state]
*/
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,
};
当链路超时之后
static void ip_vs_conn_expire(unsigned long data)
{
struct ip_vs_conn *cp = (struct ip_vs_conn *)data;
cp->timeout = 60*HZ;
/*
* hey, I'm using it
*/
atomic_inc(&cp->refcnt);
/*
* do I control anybody?
*/
if (atomic_read(&cp->n_control))
goto expire_later;
/*
* unhash it if it is hashed in the conn table
*/
if (!ip_vs_conn_unhash(cp))
goto expire_later;
/*
* refcnt==1 implies I'm the only one referrer
*/
if (likely(atomic_read(&cp->refcnt) == 1)) {
/* delete the timer if it is activated by other users */
if (timer_pending(&cp->timer))
del_timer(&cp->timer);
/* does anybody control me? */
if (cp->control)
ip_vs_control_del(cp);
if (unlikely(cp->app != NULL))
ip_vs_unbind_app(cp);
ip_vs_unbind_dest(cp);
if (cp->flags & IP_VS_CONN_F_NO_CPORT)
atomic_dec(&ip_vs_conn_no_cport_cnt);
atomic_dec(&ip_vs_conn_count);
kmem_cache_free(ip_vs_conn_cachep, cp);
return;
}
/* hash it back to the table */
ip_vs_conn_hash(cp);
expire_later:
IP_VS_DBG(7, "delayed: conn->refcnt-1=%d conn->n_control=%d
",
atomic_read(&cp->refcnt)-1,
atomic_read(&cp->n_control));
ip_vs_conn_put(cp);
}
从这个操作流程上来看,lvs只是简单的删除了自己本地维护的路由信息,而没有进行额外的TCP断链(也就是给realserver发送FIN进行正常关闭链接的优雅处理),因为LVS本身并没有socket这个概念,它本质上说是维护了一个链路的路由信息。这也就是说,虽然LVS内部实现了TCP链路的超时回收,但是这个超时回收只是回收自己本地资源,不会进行TCP标准协议的跨机处理。
五、假设被LVS回收的链路再次直接发送数据包过来会如何
linux-2.6.21
etipv4ipvsip_vs_core.c
static unsigned int
ip_vs_in(unsigned int hooknum, struct sk_buff **pskb,
const struct net_device *in, const struct net_device *out,
int (*okfn)(struct sk_buff *))
{
……
if (unlikely(!cp)) {
int v;
if (!pp->conn_schedule(skb, pp, &v, &cp))
return v;
}
if (unlikely(!cp)) {
/* sorry, all this trouble for a no-hit :) */
IP_VS_DBG_PKT(12, pp, skb, 0,
"packet continues traversal as normal");
return NF_ACCEPT;
}
……
}
对于TCP进入该函数处理,可以看到,在sched的时候,如果包头中没有syn标志位,此时并不会创建一个新的连接,并且返回1。进而导致在ip_vs_in函数中没有继续处理,而是把它当作一个普通的数据包上传,而这个上传会到达本机的协议栈中,由于本机协议栈中没有这个socket,所以客户端可能会收到reset消息被重置。
static int
tcp_conn_schedule(struct sk_buff *skb,
struct ip_vs_protocol *pp,
int *verdict, struct ip_vs_conn **cpp)
{
……
if (th->syn &&
(svc = ip_vs_service_get(skb->mark, skb->nh.iph->protocol,
skb->nh.iph->daddr, th->dest))) {
……
}
return 1;
}
关于这个问题,在stackoverflow网站的一个回复的注释中有一个说明:
The connections that you can see and change with ipvsadm are used to that the connection doesn't break between the load balancer and the real server. If you have two servers in the pool and the session on the load balancer times out after 1 second and I am using telnet, if I do nothing for a couple of seconds and then run a command the connection may appear on a different backend server and I will get disconnected. The connection may still be valid on the real backend server (as it wasn't closed) but that is assuming ipvs doesn't close the connection when it has a low timeout set. – shthead Dec 15 '13 at 4:51
六、persistent的实现
1、首先目的地址要通过配置使能了persistent选项
struct ip_vs_conn *
ip_vs_schedule(struct ip_vs_service *svc, const struct sk_buff *skb)
{
……
if (svc->flags & IP_VS_SVC_F_PERSISTENT)
return ip_vs_sched_persist(svc, skb, pptr);
……
}
可以看到,所谓的“模版”,在内部实现的时候同样是挂靠在已经建立的常规链路管理结构上的,只是这些链路添加了IP_VS_CONN_F_TEMPLATE标志位。
static struct ip_vs_conn *
ip_vs_sched_persist(struct ip_vs_service *svc,
const struct sk_buff *skb,
__be16 ports[2])
{
……
ct = ip_vs_conn_new(iph->protocol,
snet, 0,
iph->daddr, 0,
dest->addr, 0,
IP_VS_CONN_F_TEMPLATE,
dest);
……
}
/* Get reference to connection template */
struct ip_vs_conn *ip_vs_ct_in_get
(int protocol, __be32 s_addr, __be16 s_port, __be32 d_addr, __be16 d_port)
{
……
list_for_each_entry(cp, &ip_vs_conn_tab[hash], c_list) {
if (s_addr==cp->caddr && s_port==cp->cport &&
d_port==cp->vport && d_addr==cp->vaddr &&
cp->flags & IP_VS_CONN_F_TEMPLATE &&
protocol==cp->protocol) {
/* HIT */
atomic_inc(&cp->refcnt);
goto out;
}
}
cp = NULL;
……
}
2、从ip_vs_conn_tab超找时如何区分template和普通链路
以网段为例,这里传给ip_vs_ct_in_get的参数不是一个有效的地址,例如svc->fwmark或者port都不是一个实实在在的有效地址,所以通常不会和真实地址冲突
static struct ip_vs_conn *
ip_vs_sched_persist(struct ip_vs_service *svc,
const struct sk_buff *skb,
__be16 ports[2])
{
……
if (svc->fwmark)
ct = ip_vs_ct_in_get(IPPROTO_IP, snet, 0,
htonl(svc->fwmark), 0);
else
ct = ip_vs_ct_in_get(iph->protocol, snet, 0,
iph->daddr, 0);
七、Direct Routing策略的实现
在LVS的三种策略中,直观上看,DR是一种在协议层中直接修改网络报MAC地址的实现方法,这种方法的优点是realserver的网络协议栈对于load balancer的感知最弱。
linux-2.6.21
etipv4ipvsip_vs_xmit.c
int
ip_vs_dr_xmit(struct sk_buff *skb, struct ip_vs_conn *cp,
struct ip_vs_protocol *pp)
{
……
if (!(rt = __ip_vs_get_out_rt(cp, RT_TOS(iph->tos))))
goto tx_error_icmp;
……
/* drop old route */
dst_release(skb->dst);
skb->dst = &rt->u.dst;
/* Another hack: avoid icmp_send in ip_fragment */
skb->local_df = 1;
IP_VS_XMIT(skb, rt);
LeaveFunction(10);
return NF_STOLEN;
……
}
从实现上看,在发送的时候是通过__ip_vs_get_out_rt函数,把选中的realserver作为目的地址从路由表中查询到下一条的位置,然后把这个下一条作为发送的目的entry传送给链路层的发送接口。而通常来说,本机的报文发送都是以ip层的目的地址为目标进行路由选择。例如,在TCP发送报文时,
int ip_queue_xmit(struct sk_buff *skb, int ipfragok)
{
……
/* Use correct destination address if we have options. */
daddr = inet->daddr;
if(opt && opt->srr)
daddr = opt->faddr;
{
struct flowi fl = { .oif = sk->sk_bound_dev_if,
.nl_u = { .ip4_u =
{ .daddr = daddr,
.saddr = inet->saddr,
.tos = RT_CONN_FLAGS(sk) } },
.proto = sk->sk_protocol,
.uli_u = { .ports =
{ .sport = inet->sport,
.dport = inet->dport } } };
……
}
其中使用的就是daddr = inet->daddr;,这个也就是socket中使用的目的地址。
八、说明
lvs我没有使用过,也没有搭建一个实际环境测试,所以前面的内容都只是根据代码分析、推测的结论,并没有在环境验证。这些问题是最早了解lvs实现时想到的一些问题,现在简单整理下做个备份。