问题背景
客户反馈在某时间段连续分钟级别出现5xx。
排查过程
● 查看ingressPod 日志,其中status :499, request_time: 5.000, upstream_response_time:4.999。根据这几个点说明问题发生在了ingress 转发到后端pod这一段。客户端未在5s内收到body,主动断开了连接。
● 此时怀疑两个方面:后端应用负载太高,发生了限流或应用本身队列等溢出了。查看了cgroup监控,pod并未触发限流,所以需要从其他方面入手。
● 可以依照 4.3.2小节 《Nginx Ingress 499/502/503/504相关问题》场景配置相关net-exporter采集指标。
● 问题时间点,passiveopens有一个补偿态变化,先降低后上升,但是变化区间不大,可以认为下游的外部访问流量没有特别大的变化。
● 问题时间点,activeopens有明显的突增,tcptimeouts和synretrans都有明显的增长,结合sock分配数的变化,此时应该有ingress侧大量的建立连接的重试动作。
综上推测此时upstream与ingress之间存在一些问题,导致ingress访问upstream出现连接建立失败并批量重试的功能。
查看ingress 后端SVC的pod的net-exporter指标,listendrops和listenoverflow均大于0,并且幅度增长较大,可以明确的是存在连接队列溢出的问题。
根因原理
TCP协议作为一个有状态的协议,对于服务端来说,提供应用层完成一次标准的报文交互需要经历几个过程,在这期间,需要依赖不同类型的sock数据结构完成不同的任务,按照顺序依次如下:
● 创建一个socket,并且调用bind()和listen()系统调用开始监听这个端口上的服务,此时创建了一个inet_connection_sock,inet_connection_sock保存着建立连接所需要的一些信息以及连接队列。
● 当有合法的syn报文被提交给inet_connection_sock进行处理时,就进入了TCP三次握手状态机:
◦ 服务端第一次收到syn报文后,会创建一个request_sock(真实分配了sock的内存),存放少量的基础信息,这个request_sock会被放入inet_connection_sock的icsk_accept_queue队列中。
◦ 服务端第收到第三次握手的合法报文时,会查找icsk_accept_queue中的request_sock,如果有合法的request_sock,则会完成一系列操作,分配一个代表已经处于TCP_ESTABLISHED的sock。
看到上面的分析,大概就能知道,我们所说的“队列溢出”,指的是用于listen的socket的用于存放request_sock的队列和tcp_sock的队列产生了溢出。
看到上面的分析,大概就能知道,我们所说的“队列溢出”,指的是用于listen的socket的用于存放request_sock的队列和tcp_sock的队列产生了溢出。在不同版本的内核中,这两个”队列“起步并不总是以队列的形式存在,以我们alinux内核4.19版本的代码为例,内核在判断这两处的溢出时的核心代码逻辑如下:
request_sock的队列,request_sock是一个轻量级的用于记录一个连接回话信息的sock类型(mini sock to represent a connection request),对于一个inet_connection_sock来说,会专门分配一个成员队列来存放所有处于连接状态的request_sock,即icsk_accept_queue,也就是俗称的"半连接队列",所以判断这个队列是否会溢出,需要对icsk_accept_queue的长度进行判断:
static inline void inet_csk_reqsk_queue_added(struct sock *sk) { reqsk_queue_added(&inet_csk(sk)->icsk_accept_queue); } static inline int inet_csk_reqsk_queue_len(const struct sock *sk) { return reqsk_queue_len(&inet_csk(sk)->icsk_accept_queue); } static inline int inet_csk_reqsk_queue_is_full(const struct sock *sk) { return inet_csk_reqsk_queue_len(sk) >= sk->sk_max_ack_backlog; }
尽管在不同内核版本中有实现上的差异,但是对于request_sock队列,内核采用inet_csk_reqsk_queue_is_full进行判断,判断的方式就是获取request_sock队列的长度并且与sock中存放的sk_max_ack_backlog进行比对。那么什么时候会进行这个检查呢?
int tcp_conn_request(struct request_sock_ops *rsk_ops, const struct tcp_request_sock_ops *af_ops, struct sock *sk, struct sk_buff *skb) { /* TW buckets are converted to open requests without * limitations, they conserve resources and peer is * evidently real one. */ if ((net->ipv4.sysctl_tcp_syncookies == 2 || inet_csk_reqsk_queue_is_full(sk)) && !isn) { want_cookie = tcp_syn_flood_action(sk, skb, rsk_ops->slab_name); if (!want_cookie) goto drop; } if (sk_acceptq_is_full(sk)) { NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); goto drop; } if (!want_cookie && !isn) { /* Kill the following clause, if you dislike this way. */ if (!net->ipv4.sysctl_tcp_syncookies && (net->ipv4.sysctl_max_syn_backlog - inet_csk_reqsk_queue_len(sk) < (net->ipv4.sysctl_max_syn_backlog >> 2)) && !tcp_peer_is_proven(req, dst)) { pr_drop_req(req, ntohs(tcp_hdr(skb)->source), rsk_ops->family); goto drop_and_release; } isn = af_ops->init_seq(skb); } drop_and_release: dst_release(dst); drop_and_free: reqsk_free(req); drop: tcp_listendrop(sk); return 0;
内核在tcp_conn_request中进行了inet_csk_reqsk_queue_is_full的检查,也就是当LISTEN状态的sock收到了一个合法的syn报文时。所谓“半连接队列溢出”,也就是syn队列溢出,其实就是还处在握手阶段的未就绪连接过多导致的,这里的逻辑在不同版本的内核上有着比较明显的变化,尤其是在容器时代的到来,许多sysctl都成为了net namespace级别后。
● ESTABLISHED状态的tcpsock队列的溢出,通常特被称之为accept队列或者俗称的“全连接队列”,在内核中,使用以下方法用于判断溢出的现象是否发生:
static inline void sk_acceptq_removed(struct sock *sk) { sk->sk_ack_backlog--; } static inline void sk_acceptq_added(struct sock *sk) { sk->sk_ack_backlog++; } static inline bool sk_acceptq_is_full(const struct sock *sk) { return sk->sk_ack_backlog > sk->sk_max_ack_backlog; }
可以看到,尽管也被称为“队列”,但是在内核的实际行为中,其实并没有真正存在一个队列,而是改为通过sk_ack_backlog计数来进行判断。那么内核在什么情况下会对已经完成连接建立的socket进行溢出的判断呢?在上面request_sock的溢出判断中,其实已经存在了一个,在第一次收到SYN报文是,如果这个LISTEN的地址已经有了足够多的就绪的连接,那么就会选择丢弃掉这个还未完全建立的连接,除此之外,还有一处也会进行判断:
/* * The three way handshake has completed - we got a valid synack - * now create the new socket. */ struct sock *tcp_v4_syn_recv_sock(const struct sock *sk, struct sk_buff *skb, struct request_sock *req, struct dst_entry *dst, struct request_sock *req_unhash, bool *own_req) { if (sk_acceptq_is_full(sk)) goto exit_overflow; newsk = tcp_create_openreq_child(sk, req, skb); if (!newsk) goto exit_nonewsk; *own_req = inet_ehash_nolisten(newsk, req_to_sk(req_unhash)); if (likely(*own_req)) { tcp_move_syn(newtp, req); ireq->ireq_opt = NULL; } else { newinet->inet_opt = NULL; } return newsk; exit_overflow: NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS); exit_nonewsk: dst_release(dst); exit: tcp_listendrop(sk); return NULL; put_and_exit: newinet->inet_opt = NULL; inet_csk_prepare_forced_close(newsk); tcp_done(newsk); goto exit;
从代码的名称不难发现,tcp_v4_syn_recv_sock正是内核处理第三次握手的ACK报文的核心逻辑,这里通过sk_acceptq_is_full判断了是否当前已经就绪的连接数量已经超过了设定值,如果没有超过,则会通过核心的inet_ehash_nolisten增加一个新的ESTABLISHED状态的tcpsock,后续就不会由LISTEN状态的inet_connection_sock来处理报文了。
回答这一小节最初的问题,连接队列溢出的直接原因是什么呢?
连接队列溢出可以分request_sock队列溢出(syn-queue/半连接队列)和就绪连接(accept-queue/全连接队列)超出限制,其中前者指的是还在握手过程中,完成了第一次握手但是没有完成第三次握手的会话过多,后者则是单纯的单个LISTEN地址的就绪连接太多了。
在了解了原因之后,我们再看一下如何站在直接的原因的基础上去解决这个问题。从上面原因的分析来看,对于客户正常的业务遇到的问题,排除SYN-Flooding等攻击行为的干扰,解决问题的核心,其实是让单个LISTEN地址可以容纳更多的就绪连接,从上文中可以发现,真正核心的代码其实是如下一行:
sk->sk_ack_backlog > sk->sk_max_ack_backlog;
更多精彩内容,欢迎观看:
《云原生网络数据面可观测性最佳实践》——五、 典型问题华山论剑——1 某客户nginx ingress偶发性出现4xx or 5xx(下):https://developer.aliyun.com/article/1221272?groupCode=supportservice