1. 前言
在上一篇应用容器化后为什么性能下降这么多? 中分析了应用容器化后性能大幅下降的原因,容器和宿主机使用veth pair通信不仅造成性能的下降,同时会导致网络包重传率增大,这又是为什么呢?带着这个疑问,开始下面的分析。
2. 压测结果
为了了解应用容器化以后各个环节的网络性能,我们对容器到宿主机的网络进行了压测,发现容器到宿主机的网络重传率相对较高,使用iperf3压测结果如下图所示。
其中Retr表示数据包重传次数,为695次,造成重传的原因可能是丢包也可能是包乱序导致的重传,接下来我们一步步来排查具体原因。
3. 问题定位
3.1 netstat分析
通过netstat可以看到和重传相关的fast retransmits在快速变化,如下图所示
由于fast retransmits在快速变化,有理由怀疑是快速重传导致的,快速重传是针对RTO超时重传的优化,当收到三个重复的ack后可以触发快速重传,优化网络性能,而当开启sack以后,快速重传的条件又发生的变化,具体详见下文分析。
为了进一步验证,所以将测试前后的数量进行结果比较,结果如下所示。
我们惊人的发现762388-761693 = 695,和压测结果的重传数据包数结果完全一致,所以我们怀疑容器到宿主机的过程中发生的重传,都是快速重传导致的,所以问题转化为是什么导致了快速重传?
3.2 调用栈分析
要想分析重传的原因,很自然会从重传函数tcp_retransmit_skb()入手,我们来看下调用栈,如下图所示。
调用核心链路为tcp_ack -> tcp_xmit_recovery -> tcp_xmit_retransmit_queue -> tcp_retransmit_skb,知道调用链路后,还需要根据调用链路,逐个分析内部的细节,tcp_retransmit_skb主要作用为重传数据包,tcp_xmit_retransmit_queue主要作用为遍历队列,随后调用tcp_retransmit_skb逐个发送需要重传的数据包,先从tcp_xmit_recovery入手分析
3.3 tcp_xmit_recovery分析
scss
/* Congestion control has updated the cwnd already. So if we're in
* loss recovery then now we do any new sends (for FRTO) or
* retransmits (for CA_Loss or CA_recovery) that make sense.
*/
static void tcp_xmit_recovery(struct sock *sk, int rexmit)
{
struct tcp_sock *tp = tcp_sk(sk);
//判断rexmit的状态 是否满足 rexmit的状态决定了是否重传
if (rexmit == REXMIT_NONE || sk->sk_state == TCP_SYN_SENT)
return;
if (unlikely(rexmit == REXMIT_NEW)) {
__tcp_push_pending_frames(sk, tcp_current_mss(sk),
TCP_NAGLE_OFF);
if (after(tp->snd_nxt, tp->high_seq))
return;
tp->frto = 0;
}
tcp_xmit_retransmit_queue(sk);
}
从tcp_xmit_recovery可以看到rexmit的状态,决定了是否调用tcp_xmit_retransmit_queue从而发起数据包重传,而rexmit是从参数直接传过来的,再来看tcp_ack函数,查看rexmit的状态是如何改变和传入的。
3.4 tcp_ack分析
scss
/* This routine deals with incoming acks, but not outgoing ones. */
static int tcp_ack(struct sock *sk, const struct sk_buff *skb, int flag)
{
//初始化rexmit状态
int rexmit = REXMIT_NONE; /* Flag to (re)transmit to recover losses */
...
if (tcp_ack_is_dubious(sk, flag)) {
if (!(flag & (FLAG_SND_UNA_ADVANCED |
FLAG_NOT_DUP | FLAG_DSACKING_ACK))) {
num_dupack = 1;
/* Consider if pure acks were aggregated in tcp_add_backlog() */
if (!(flag & FLAG_DATA))
num_dupack = max_t(u16, 1, skb_shinfo(skb)->gso_segs);
}
//拥塞状态机变更,变更rexmit状态和标记需要重传的数据包
tcp_fastretrans_alert(sk, prior_snd_una, num_dupack, &flag,
&rexmit);
}
...
tcp_xmit_recovery(sk, rexmit);
...
}
在tcp_ack中,rexmit最初的状态为默认状态REXMIT_NONE,改变其状态的函数为tcp_fastretrans_alert,由于tcp_fatstretrans_alert非常复杂,我们分析相关的最核心部分
3.5 tcp_fatstretrans_alert 分析
arduino
/* Process an event, which can update packets-in-flight not trivially.
* Main goal of this function is to calculate new estimate for left_out,
* taking into account both packets sitting in receiver's buffer and
* packets lost by network.
*
* Besides that it updates the congestion state when packet loss or ECN
* is detected. But it does not reduce the cwnd, it is done by the
* congestion control later.
*
* It does _not_ decide what to send, it is made in function
* tcp_xmit_retransmit_queue().
*/
static void tcp_fastretrans_alert(struct sock *sk, const u32 prior_snd_una,
int num_dupack, int *ack_flag, int *rexmit)
{
...
//数据包是否丢失状态
bool do_lost = num_dupack || ((flag & FLAG_DATA_SACKED) &&
tcp_force_fast_retransmit(sk));
...
if (!tcp_is_rack(sk) && do_lost)
tcp_update_scoreboard(sk, fast_rexmit);//标记数据包状态为TCPCB_LOST,真正发送在后续tcp_xmit_retransmit_queue
*rexmit = REXMIT_LOST;
}
可以看到tcp_fastretrans_alert最后将rexmit设置为REXMIT_LOST,当do_lost为true时,调用tcp_update_scoreboard,而该函数的目的就是将数据包状态标记为TCPCB_LOST,以便在tcp_xmit_retransmit_queue中遍历并重传状态为TCPCB_LOST的数据包。
rexmit 决定了是否调用tcp_xmit_retransmit_queue,do_lost决定了数据包是否LOSS,从而决定在tcp_xmit_retransmit_queue函数中,是否发送该数据包,所以只要搞清楚do_lost什么时候为true,也许就能找到重传的原因了,经过分析do_lost的值由tcp_force_fast_retransmit决定。
3.6 tcp_force_fast_retransmit 分析
rust
static bool tcp_force_fast_retransmit(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
return after(tcp_highest_sack_seq(tp),
tp->snd_una + tp->reordering * tp->mss_cache);
}
tcp_force_fast_retransmit大致的意思是发送端已经ack的数据包和未ack的数据之间差值过大的话,就可以马上重传数据包,换句话说tp->reordering * tp->mss_cache影响了是否需要重传,如下图所示。
结论:容器和宿主机之间的网络丢包的概率不大,所以造成快速重传的原因大概率是由于包乱序导致的,通过调整tp->reordering的值可能可以减缓包乱序发生的概率。
4. 优化方案
经过上面的分析造成重传的原因大概率是是由于包乱序导致的,而tp->reordering表示乱序度,由/proc/sys/net/ipv4/tcp_reordering参数决定,默认值为3,将/proc/sys/net/ipv4/tcp_reordering的值调整为9,看看结果会如何?
通过测试,调整/proc/sys/net/ipv4/tcp_reordering似乎降低了一些重传数据包数,从695降低为149,到这里相信还有很多疑问。
- 疑问1:虽然重传数据包数降低了,为啥还有149?
- 疑问2:容器和宿主机之间的网络为什么会造成包乱序?
带着这些疑问,继续下面的分析。
5.包乱序原因分析
5.1 容器向宿主机发送数据
我们知道容器使用veth接口向宿主机发送数据,并使用软中断的触发对端接受数据,而在软中断之前,需要将数据包放到cpu的数据包队列poll_list中,对应的结构体为softnet_data。
scss
static int netif_rx_internal(struct sk_buff *skb)
{
int ret;
net_timestamp_check(netdev_tstamp_prequeue, skb);
trace_netif_rx(skb);
#ifdef CONFIG_RPS
if (static_branch_unlikely(&rps_needed)) {
struct rps_dev_flow voidflow, *rflow = &voidflow;
int cpu;
preempt_disable();
rcu_read_lock();
cpu = get_rps_cpu(skb->dev, skb, &rflow);
if (cpu < 0)
cpu = smp_processor_id();
//将数据包通过PRS的方式,放入指定cpu的队列中
ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail);
rcu_read_unlock();
preempt_enable();
} else
#endif
{
unsigned int qtail;
//将数据包放入当前cpu的队列中
ret = enqueue_to_backlog(skb, get_cpu(), &qtail);
put_cpu();
}
return ret;
}
从上面代码可以看出,当没有配置RPS时,数据包选择放入的cpu是通过get_cpu()决定的,也就是当前cpu,而在多核的系统上,容器向宿主机发送数据的cpu可能会切换,这就导致数据可能随机放入各个cpu数据包队列中,而在后续触发软中断处理数据包的过程是异步的,这就可能导致最终包乱序。
5.2 RPS
RPS全称为(Receive Packet Steering),它可以在逻辑上通过hash(4元组),在软件层面实现数据包的重定向,从而实现和RSS一样的效果,具体RPS和RSS区别可以查看内核协议栈扩展
通过RPS可以保障同一个数据流都在同一个cpu上处理,从而减少乱序的可能性。
RPS的配置方式可以通过修改 /sys/class/net//queues/rx-/rps_cpus的值来实现。例如我测试机器的cpu核心数为2,使用calico为容器网络通信方案,配置如下:
bash
echo 3 > /sys/devices/virtual/net/cali9deae4f562c/queues/rx-0/rps_cpus
在配置了RPS以后,我们再来做一轮benchmark,看看这次的结果,如下图所示:
这次结果没有令人失望,重传数量降为了0,问题终于得到解决。
6. 总结
通过在容器网络中发现重传率过高,一步步从netstat统计信息分析,到内核调用栈分析,再到原因的分析以及方案的优化,整个问题排查的链路非常长,非常耗时,这也是企业云原生过程中遇到的一大痛点,所以往往需要提供内核可观测性工具以及不断的踩坑积累,帮忙我们快速分析和定位问题。
最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。