k8s v1.17 新特性预告: 拓扑感知服务路由

作者: 陈鹏

大家好,我是 roc,来自腾讯云容器服务(TKE)团队,今天给大家介绍下我参与开发的一个 k8s v1.17 新特性: 拓扑感知服务路由。

名词解释

  • 拓扑域: 表示在集群中的某一类 “地方”,比如某节点、某机架、某可用区或某地域等,这些都可以作为某种拓扑域。
  • endpoint: k8s 某个服务的某个 ip+port,通常是 pod 的 ip+port。
  • service: k8s 的 service 资源(服务),关联一组 endpoint ,访问 service 会被转发到关联的某个 endpoint 上。

背景

拓扑感知服务路由,此特性最初由杜军大佬提出并设计。为什么要设计此特性呢?想象一下,k8s 集群节点分布在不同的地方,service 对应的 endpoints 分布在不同节点,传统转发策略会对所有 endpoint 做负载均衡,通常会等概率转发,当访问 service 时,流量就可能被分散打到这些不同的地方。虽然 service 转发做了负载均衡,但如果 endpoint 距离比较远,流量转发过去网络时延就相对比较高,会影响网络性能,在某些情况下甚至还可能会付出额外的流量费用。要是如能实现 service 就近转发 endpoint,是不是就可以实现降低网络时延,提升网络性能了呢?是的!这也正是该特性所提出的目的和意义。

k8s 亲和性

service 的就近转发实际就是一种网络的亲和性,倾向于转发到离自己比较近的 endpoint。在此特性之前,已经在调度和存储方面有一些亲和性的设计与实现:

  • 节点亲和性 (Node Affinity): 让 Pod 被调度到符合一些期望条件的 Node 上,比如限制调度到某一可用区,或者要求节点支持 GPU,这算是调度亲和,调度结果取决于节点属性。
  • Pod 亲和性与反亲和性 (Pod Affinity/AntiAffinity): 让一组 Pod 调度到同一拓扑域的节点上,或者打散到不同拓扑域的节点, 这也算是调度亲和,调度结果取决于其它 Pod。
  • 数据卷拓扑感知调度 (Volume Topology-aware Scheduling): 让 Pod 只被调度到符合其绑定的存储所在拓扑域的节点上,这算是调度与存储的亲和,调度结果取决于存储的拓扑域。
  • 本地数据卷 (Local Persistent Volume): 让 Pod 使用本地数据卷,比如高性能 SSD,在某些需要高 IOPS 低时延的场景很有用,它还会保证 Pod 始终被调度到同一节点,数据就不会不丢失,这也算是调度与存储的亲和,调度结果取决于存储所在节点。
  • 数据卷拓扑感知动态创建 (Topology-Aware Volume Dynamic Provisioning): 先调度 Pod,再根据 Pod 所在节点的拓扑域来创建存储,这算是存储与调度的亲和,存储的创建取决于调度的结果。

而 k8s 目前在网络方面还没有亲和性能力,拓扑感知服务路由这个新特性恰好可以补齐这个的空缺,此特性使得 service 可以实现就近转发而不是所有 endpoint 等概率转发。

如何实现

我们知道,service 转发主要是 node 上的 kube-proxy 进程通过 watch apiserver 获取 service 对应的 endpoint,再写入 iptables 或 ipvs 规则来实现的; 对于 headless service,主要是通过 kube-dns 或 coredns 动态解析到不同 endpoint ip 来实现的。实现 service 就近转发的关键点就在于如何将流量转发到跟当前节点在同一拓扑域的 endpoint 上,也就是会进行一次 endpoint 筛选,选出一部分符合当前节点拓扑域的 endpoint 进行转发。

那么如何判断 endpoint 跟当前节点是否在同一拓扑域里呢?只要能获取到 endpoint 的拓扑信息,用它跟当前节点拓扑对比下就可以知道了。那又如何获取 endpoint 的拓扑信息呢?答案是通过 endpoint 所在节点的 label,我们可以使用 node label 来描述拓扑域。

通常在节点初始化的时候,controller-manager 就会为节点打上许多 label,比如 kubernetes.io/hostname 表示节点的 hostname 来区分节点;另外,在云厂商提供的 k8s 服务,或者使用 cloud-controller-manager 的自建集群,通常还会给节点打上 failure-domain.beta.kubernetes.io/zonefailure-domain.beta.kubernetes.io/region 以区分节点所在可用区和所在地域,但自 v1.17 开始将会改名成 topology.kubernetes.io/zonetopology.kubernetes.io/region,参见 PR #81431

如何根据 endpoint 查到它所在节点的这些 label 呢?答案是通过 Endpoint Slice,该特性在 v1.16 发布了 alpha,在 v1.17 将会进入 beta,它相当于 Endpoint API 增强版,通过将 endpoint 做数据分片来解决大规模 endpoint 的性能问题,并且可以携带更多的信息,包括 endpoint 所在节点的拓扑信息,拓扑感知服务路由特性会通过 Endpoint Slice 获取这些拓扑信息实现 endpoint 筛选 (过滤出在同一拓扑域的 endpoint),然后再转换为 iptables 或 ipvs 规则写入节点以实现拓扑感知的路由转发。

细心的你可能已经发现,之前每个节点上转发 service 的 iptables/ipvs 规则基本是一样的,但启用了拓扑感知服务路由特性之后,每个节点上的转发规则就可能不一样了,因为不同节点的拓扑信息不一样,导致过滤出的 endpoint 就不一样,也正是因为这样,service 转发变得不再等概率,灵活的就近转发才得以实现。

当前还不支持 headless service 的拓扑路由,计划在 beta 阶段支持。由于 headless service 不是通过 kube-proxy 生成转发规则,而是通过 dns 动态解析实现的,所以需要改 kube-dns/coredns 来支持这个特性。

前提条件

启用当前 alpha 实现的拓扑感知服务路由特性需要满足以下前提条件:

  • 集群版本在 v1.17 及其以上。
  • Kube-proxy 以 iptables 或 IPVS 模式运行 (alpha 阶段暂时只实现了这两种模式)。
  • 启用了 Endpoint Slices (此特性虽然在 v1.17 进入 beta,但没有默认开启)。

如何启用此特性

给所有 k8s 组件打开 ServiceTopologyEndpointSlice 这两个 feature:

1
--feature-gates="ServiceTopology=true,EndpointSlice=true"

如何使用

在 Service spec 里加上 topologyKeys 字段,表示该 Service 优先顺序选用的拓扑域列表,对应节点标签的 key;当访问此 Service 时,会找是否有 endpoint 有对应 topology key 的拓扑信息并且 value 跟当前节点也一样,如果是,那就选定此 topology key 作为当前转发的拓扑域,并且筛选出其余所有在这个拓扑域的 endpoint 来进行转发;如果没有找到任何 endpoint 在当前 topology key 对应拓扑域,就会尝试第二个 topology key,依此类推;如果遍历完所有 topology key 也没有匹配到 endpoint 就会拒绝转发,就像此 service 没有后端 endpoint 一样。

有一个特殊的 topology key “*“,它可以匹配所有 endpoint,如果 topologyKeys 包含了 *,它必须在列表末尾,通常是在没有匹配到合适的拓扑域来实现就近转发时,就打消就近转发的念头,可以转发到任意 endpoint 上。

当前 topology key 支持以下可能的值(未来会增加更多):

  • kubernetes.io/hostname: 节点的 hostname,通常将它放列表中第一个,表示如果本机有 endpoint 就直接转发到本机的 endpoint。
  • topology.kubernetes.io/zone: 节点所在的可用区,通常将它放在 kubernetes.io/hostname 后面,表示如果本机没有对应 endpoint,就转发到当前可用区其它节点上的 endpoint(部分云厂商跨可用区通信会收取额外的流量费用)。
  • topology.kubernetes.io/region: 表示节点所在的地域,表示转发到当前地域的 endpoint,这个用的应该会比较少,因为通常集群所有节点都只会在同一个地域,如果节点跨地域了,节点之间通信延时将会很高。
  • *: 忽略拓扑域,匹配所有 endpoint,相当于一个保底策略,避免丢包,只能放在列表末尾。

除此之外,还有以下约束:

  • topologyKeysexternalTrafficPolicy=Local 不兼容,是互斥的,如果 externalTrafficPolicyLocal,就不能定义 topologyKeys,反之亦然。
  • topology key 必须是合法的 label 格式,并且最多定义 16 个 key。

这里给出一个简单的 Service 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
apiVersion: v1
kind: Service
metadata:
name: nginx
spec:
type: ClusterIP
ports:
- name: http
port: 80
protocol: TCP
targetPort: 80
selector:
app: nginx
topologyKeys: ["kubernetes.io/hostname", "topology.kubernetes.io/zone", "*"]

解释: 当访问 nginx 服务时,首先看本机是否有这个服务的 endpoint,如果有就直接本机路由过去;如果没有,就看是否有 endpoint 位于当前节点所在可用区,如果有,就转发过去,如果还是没有,就转发给任意 endpoint。

上图就是其中一次转发的例子:Pod 访问 nginx 这个 service 时,发现本机没有 endpoint,就找当前可用区的,找到了就转发过去,也就不会考虑转发给另一可用区的 endpoint。

背后小故事

此特性的 KEP Proposal 最终被认可(合并)时的设计与当前最终的代码实现已经有一些差别,实现方案历经一变再变,但同时也推动了其它特性的发展,我来讲下这其中的故事。

一开始设计是在 alpha 时,让 kube-proxy 直接暴力 watch node,每个节点都有一份全局的 node 的缓存,通过 endpoint 的 nodeName 字段找到对应的 node 缓存,再查 node 包含的 label 就可以知道该 endpoint 的拓扑域了,但在集群节点数量多的情况下,kube-proxy 将会消耗大量资源,不过优点是实现上很简单,可以作为 alpha 阶段的实现,beta 时再从 watch node 切换到 watch 一个新设计的 PodLocator API,作为拓扑信息存储的中介,避免 watch 庞大的 node。

实际上一开始我也是按照 watch node 的方式,花了九牛二虎之力终于实现了这个特性,后来 v1.15 时 k8s 又支持了 metadata-only watch,参见 PR 71548,利用此特性可以仅仅 watch node 的 metadata,而不用 watch 整个 node,可以极大减小传输和缓存的数据量,然后我就将实现切成了 watch node metadata; 即便如此,metadata 还是会更新比较频繁,主要是 resourceVersion 会经常变 (kubelet 经常上报 node 状态),所以虽然 watch node metadata 比 watch node 要好,但也还是可能会造成大量不必要的网络流量,但作为 alpha 实现是可以接受的。

可惜在 v1.16 code freeze 之前没能将此特性合进去,只因有一点小细节还没讨论清楚。 实际在实现 watch node 方案期间,Endpoint Slice 特性就提出来了,在这个特性讨论的阶段,我们就想到了可以利用它来携带拓扑信息,以便让拓扑感知服务路由这个特性后续可以直接利用 Endpoint Slice 来获取拓扑信息,也就可以替代之前设计的 PodLocator API,但由于它还处于很早期阶段,并且代码还未合并进去,所以 alpha 阶段先不考虑 watch Endpint Slice。后来,Endpoint Slice 特性在 v1.16 发布了 alpha。

由于 v1.16 没能将拓扑感知服务路由特性合进去,在 v1.17 周期开始后,有更多时间来讨论小细节,并且 Endpoint Slice 代码已经合并,我就干脆直接又将实现从 watch node metadata 切成了 watch Endpint Slice,在 alpha 阶段就做了打算在 beta 阶段做的事情,终于,此特性实现代码最终合进了主干。

结尾

拓扑感知服务路由可以实现 service 就近转发,减少网络延时,进一步提升 k8s 的网络性能,此特性将于 k8s v1.17 发布 alpha,时间是 12 月上旬,让我们一起期待吧!k8s 网络是块难啃的硬骨头,感兴趣的同学可以看下杜军的新书 《Kubernetes 网络权威指南》,整理巩固一下 k8s 的网络知识。

参考资料

Kubernetes 网络疑难杂症排查分享

作者: 陈鹏

大家好,我是 roc,来自腾讯云容器服务(TKE)团队,经常帮助用户解决各种 K8S 的疑难杂症,积累了比较丰富的经验,本文分享几个比较复杂的网络方面的问题排查和解决思路,深入分析并展开相关知识,信息量巨大,相关经验不足的同学可能需要细细品味才能消化,我建议收藏本文反复研读,当完全看懂后我相信你的功底会更加扎实,解决问题的能力会大大提升。

本文发现的问题是在使用 TKE 时遇到的,不同厂商的网络环境可能不一样,文中会对不同的问题的网络环境进行说明

跨 VPC 访问 NodePort 经常超时

现象: 从 VPC a 访问 VPC b 的 TKE 集群的某个节点的 NodePort,有时候正常,有时候会卡住直到超时。

原因怎么查?

当然是先抓包看看啦,抓 server 端 NodePort 的包,发现异常时 server 能收到 SYN,但没响应 ACK:

反复执行 netstat -s | grep LISTEN 发现 SYN 被丢弃数量不断增加:

分析:

  • 两个VPC之间使用对等连接打通的,CVM 之间通信应该就跟在一个内网一样可以互通。
  • 为什么同一 VPC 下访问没问题,跨 VPC 有问题? 两者访问的区别是什么?

再仔细看下 client 所在环境,发现 client 是 VPC a 的 TKE 集群节点,捋一下:

  • client 在 VPC a 的 TKE 集群的节点
  • server 在 VPC b 的 TKE 集群的节点

因为 TKE 集群中有个叫 ip-masq-agent 的 daemonset,它会给 node 写 iptables 规则,默认 SNAT 目的 IP 是 VPC 之外的报文,所以 client 访问 server 会做 SNAT,也就是这里跨 VPC 相比同 VPC 访问 NodePort 多了一次 SNAT,如果是因为多了一次 SNAT 导致的这个问题,直觉告诉我这个应该跟内核参数有关,因为是 server 收到包没回包,所以应该是 server 所在 node 的内核参数问题,对比这个 node 和 普通 TKE node 的默认内核参数,发现这个 node net.ipv4.tcp_tw_recycle = 1,这个参数默认是关闭的,跟用户沟通后发现这个内核参数确实在做压测的时候调整过。

解释一下,TCP 主动关闭连接的一方在发送最后一个 ACK 会进入 TIME_AWAIT 状态,再等待 2 个 MSL 时间后才会关闭(因为如果 server 没收到 client 第四次挥手确认报文,server 会重发第三次挥手 FIN 报文,所以 client 需要停留 2 MSL的时长来处理可能会重复收到的报文段;同时等待 2 MSL 也可以让由于网络不通畅产生的滞留报文失效,避免新建立的连接收到之前旧连接的报文),了解更详细的过程请参考 TCP 四次挥手。

参数 tcp_tw_recycle 用于快速回收 TIME_AWAIT 连接,通常在增加连接并发能力的场景会开启,比如发起大量短连接,快速回收可避免 tw_buckets 资源耗尽导致无法建立新连接 (time wait bucket table overflow)

查得 tcp_tw_recycle 有个坑,在 RFC1323 有段描述:

An additional mechanism could be added to the TCP, a per-host cache of the last timestamp received from any connection. This value could then be used in the PAWS mechanism to reject old duplicate segments from earlier incarnations of the connection, if the timestamp clock can be guaranteed to have ticked at least once since the old connection was open. This would require that the TIME-WAIT delay plus the RTT together must be at least one tick of the sender’s timestamp clock. Such an extension is not part of the proposal of this RFC.

大概意思是说 TCP 有一种行为,可以缓存每个连接最新的时间戳,后续请求中如果时间戳小于缓存的时间戳,即视为无效,相应的数据包会被丢弃。

Linux 是否启用这种行为取决于 tcp_timestampstcp_tw_recycle,因为 tcp_timestamps 缺省开启,所以当 tcp_tw_recycle 被开启后,实际上这种行为就被激活了,当客户端或服务端以 NAT 方式构建的时候就可能出现问题。

当多个客户端通过 NAT 方式联网并与服务端交互时,服务端看到的是同一个 IP,也就是说对服务端而言这些客户端实际上等同于一个,可惜由于这些客户端的时间戳可能存在差异,于是乎从服务端的视角看,便可能出现时间戳错乱的现象,进而直接导致时间戳小的数据包被丢弃。如果发生了此类问题,具体的表现通常是是客户端明明发送的 SYN,但服务端就是不响应 ACK。

回到我们的问题上,client 所在节点上可能也会有其它 pod 访问到 server 所在节点,而它们都被 SNAT 成了 client 所在节点的 NODE IP,但时间戳存在差异,server 就会看到时间戳错乱,因为开启了 tcp_tw_recycletcp_timestamps 激活了上述行为,就丢掉了比缓存时间戳小的报文,导致部分 SYN 被丢弃,这也解释了为什么之前我们抓包发现异常时 server 收到了 SYN,但没有响应 ACK,进而说明为什么 client 的请求部分会卡住直到超时。

由于 tcp_tw_recycle 坑太多,在内核 4.12 之后已移除: remove tcp_tw_recycle

LB 压测 CPS 低

现象: LoadBalancer 类型的 Service,直接压测 NodePort CPS 比较高,但如果压测 LB CPS 就很低。

环境说明: 用户使用的黑石TKE,不是公有云TKE,黑石的机器是物理机,LB的实现也跟公有云不一样,但 LoadBalancer 类型的 Service 的实现同样也是 LB 绑定各节点的 NodePort,报文发到 LB 后转到节点的 NodePort, 然后再路由到对应 pod,而测试在公有云 TKE 环境下没有这个问题。

client 抓包: 大量SYN重传。

server 抓包: 抓 NodePort 的包,发现当 client SYN 重传时 server 能收到 SYN 包但没有响应。

又是 SYN 收到但没响应,难道又是开启 tcp_tw_recycle 导致的?检查节点的内核参数发现并没有开启,除了这个原因,还会有什么情况能导致被丢弃?

conntrack -S 看到 insert_failed 数量在不断增加,也就是 conntrack 在插入很多新连接的时候失败了,为什么会插入失败?什么情况下会插入失败?

挖内核源码: netfilter conntrack 模块为每个连接创建 conntrack 表项时,表项的创建和最终插入之间还有一段逻辑,没有加锁,是一种乐观锁的过程。conntrack 表项并发刚创建时五元组不冲突的话可以创建成功,但中间经过 NAT 转换之后五元组就可能变成相同,第一个可以插入成功,后面的就会插入失败,因为已经有相同的表项存在。比如一个 SYN 已经做了 NAT 但是还没到最终插入的时候,另一个 SYN 也在做 NAT,因为之前那个 SYN 还没插入,这个 SYN 做 NAT 的时候就认为这个五元组没有被占用,那么它 NAT 之后的五元组就可能跟那个还没插入的包相同。

在我们这个问题里实际就是 netfilter 做 SNAT 时源端口选举冲突了,黑石 LB 会做 SNAT,SNAT 时使用了 16 个不同 IP 做源,但是短时间内源 Port 却是集中一致的,并发两个 SYN a 和SYN b,被 LB SNAT 后源 IP 不同但源 Port 很可能相同,这里就假设两个报文被 LB SNAT 之后它们源 IP 不同源 Port 相同,报文同时到了节点的 NodePort 会再次做 SNAT 再转发到对应的 Pod,当报文到了 NodePort 时,这时它们五元组不冲突,netfilter 为它们分别创建了 conntrack 表项,SYN a 被节点 SNAT 时默认行为是 从 port_range 范围的当前源 Port 作为起始位置开始循环遍历,选举出没有被占用的作为源 Port,因为这两个 SYN 源 Port 相同,所以它们源 Port 选举的起始位置相同,当 SYN a 选出源 Port 但还没将 conntrack 表项插入时,netfilter 认为这个 Port 没被占用就很可能给 SYN b 也选了相同的源 Port,这时他们五元组就相同了,当 SYN a 的 conntrack 表项插入后再插入 SYN b 的 conntrack 表项时,发现已经有相同的记录就将 SYN b 的 conntrack 表项丢弃了。

解决方法探索: 不使用源端口选举,在 iptables 的 MASQUERADE 规则如果加 --random-fully 这个 flag 可以让端口选举完全随机,基本上能避免绝大多数的冲突,但也无法完全杜绝。最终决定开发 LB 直接绑 Pod IP,不基于 NodePort,从而避免 netfilter 的 SNAT 源端口冲突问题。

DNS 解析偶尔 5S 延时

网上一搜,是已知问题,仔细分析,实际跟之前黑石 TKE 压测 LB CPS 低的根因是同一个,都是因为 netfilter conntrack 模块的设计问题,只不过之前发生在 SNAT,这个发生在 DNAT,这里用我的语言来总结下原因:

DNS client (glibc 或 musl libc) 会并发请求 A 和 AAAA 记录,跟 DNS Server 通信自然会先 connect (建立fd),后面请求报文使用这个 fd 来发送,由于 UDP 是无状态协议, connect 时并不会创建 conntrack 表项, 而并发请求的 A 和 AAAA 记录默认使用同一个 fd 发包,这时它们源 Port 相同,当并发发包时,两个包都还没有被插入 conntrack 表项,所以 netfilter 会为它们分别创建 conntrack 表项,而集群内请求 kube-dns 或 coredns 都是访问的CLUSTER-IP,报文最终会被 DNAT 成一个 endpoint 的 POD IP,当两个包被 DNAT 成同一个 IP,最终它们的五元组就相同了,在最终插入的时候后面那个包就会被丢掉,如果 dns 的 pod 副本只有一个实例的情况就很容易发生,现象就是 dns 请求超时,client 默认策略是等待 5s 自动重试,如果重试成功,我们看到的现象就是 dns 请求有 5s 的延时。

参考 weave works 工程师总结的文章: Racy conntrack and DNS lookup timeouts

解决方案一: 使用 TCP 发送 DNS 请求

如果使用 TCP 发 DNS 请求,connect 时就会插入 conntrack 表项,而并发的 A 和 AAAA 请求使用同一个 fd,所以只会有一次 connect,也就只会尝试创建一个 conntrack 表项,也就避免插入时冲突。

resolv.conf 可以加 options use-vc 强制 glibc 使用 TCP 协议发送 DNS query。下面是这个 man resolv.conf中关于这个选项的说明:

1
2
3
use-vc (since glibc 2.14)
Sets RES_USEVC in _res.options. This option forces the
use of TCP for DNS resolutions.

解决方案二: 避免相同五元组 DNS 请求的并发

resolv.conf 还有另外两个相关的参数:

  • single-request-reopen (since glibc 2.9): A 和 AAAA 请求使用不同的 socket 来发送,这样它们的源 Port 就不同,五元组也就不同,避免了使用同一个 conntrack 表项。
  • single-request (since glibc 2.10): A 和 AAAA 请求改成串行,没有并发,从而也避免了冲突。

man resolv.conf 中解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
single-request-reopen (since glibc 2.9)
Sets RES_SNGLKUPREOP in _res.options. The resolver
uses the same socket for the A and AAAA requests. Some
hardware mistakenly sends back only one reply. When
that happens the client system will sit and wait for
the second reply. Turning this option on changes this
behavior so that if two requests from the same port are
not handled correctly it will close the socket and open
a new one before sending the second request.

single-request (since glibc 2.10)
Sets RES_SNGLKUP in _res.options. By default, glibc
performs IPv4 and IPv6 lookups in parallel since
version 2.9. Some appliance DNS servers cannot handle
these queries properly and make the requests time out.
This option disables the behavior and makes glibc
perform the IPv6 and IPv4 requests sequentially (at the
cost of some slowdown of the resolving process).

要给容器的 resolv.conf 加上 options 参数,最方便的是直接在 Pod Spec 里面的 dnsConfig 加 (k8s v1.9 及以上才支持)

1
2
3
4
spec:
dnsConfig:
options:
- name: single-request-reopen

加 options 还有其它一些方法:

  • 在容器的 ENTRYPOINT 或者 CMD 脚本中,执行 /bin/echo 'options single-request-reopen' >> /etc/resolv.conf
  • 在 postStart hook 里加:

    1
    2
    3
    4
    5
    6
    7
    lifecycle:
    postStart:
    exec:
    command:
    - /bin/sh
    - -c
    - "/bin/echo 'options single-request-reopen' >> /etc/resolv.conf"
  • 使用 MutatingAdmissionWebhook,这是 1.9 引入的 Controller,用于对一个指定的资源的操作之前,对这个资源进行变更。 istio 的自动 sidecar 注入就是用这个功能来实现的,我们也可以通过 MutatingAdmissionWebhook 来自动给所有 Pod 注入 resolv.conf 文件,不过需要一定的开发量。

解决方案三: 使用本地 DNS 缓存

仔细观察可以看到前面两种方案是 glibc 支持的,而基于 alpine 的镜像底层库是 musl libc 不是 glibc,所以即使加了这些 options 也没用,这种情况可以考虑使用本地 DNS 缓存来解决,容器的 DNS 请求都发往本地的 DNS 缓存服务(dnsmasq, nscd等),不需要走 DNAT,也不会发生 conntrack 冲突。另外还有个好处,就是避免 DNS 服务成为性能瓶颈。

使用本地DNS缓存有两种方式:

  • 每个容器自带一个 DNS 缓存服务
  • 每个节点运行一个 DNS 缓存服务,所有容器都把本节点的 DNS 缓存作为自己的 nameserver

从资源效率的角度来考虑的话,推荐后一种方式。

Pod 访问另一个集群的 apiserver 有延时

现象:集群 a 的 Pod 内通过 kubectl 访问集群 b 的内网地址,偶尔出现延时的情况,但直接在宿主机上用同样的方法却没有这个问题。

提炼环境和现象精髓:

  1. 在 pod 内将另一个集群 apiserver 的 ip 写到了 hosts,因为 TKE apiserver 开启内网集群外内网访问创建的内网 LB 暂时没有支持自动绑内网 DNS 域名解析,所以集群外的内网访问 apiserver 需要加 hosts
  2. pod 内执行 kubectl 访问另一个集群偶尔延迟 5s,有时甚至10s

观察到 5s 延时,感觉跟之前 conntrack 的丢包导致 dns 解析 5s 延时有关,但是加了 hosts 呀,怎么还去解析域名?

进入 pod netns 抓包: 执行 kubectl 时确实有 dns 解析,并且发生延时的时候 dns 请求没有响应然后做了重试。

看起来延时应该就是之前已知 conntrack 丢包导致 dns 5s 超时重试导致的。但是为什么会去解析域名? 明明配了 hosts 啊,正常情况应该是优先查找 hosts,没找到才去请求 dns 呀,有什么配置可以控制查找顺序?

搜了一下发现: /etc/nsswitch.conf 可以控制,但看有问题的 pod 里没有这个文件。然后观察到有问题的 pod 用的 alpine 镜像,试试其它镜像后发现只有基于 alpine 的镜像才会有这个问题。

再一搜发现: musl libc 并不会使用 /etc/nsswitch.conf ,也就是说 alpine 镜像并没有实现用这个文件控制域名查找优先顺序,瞥了一眼 musl libc 的 gethostbynamegetaddrinfo 的实现,看起来也没有读这个文件来控制查找顺序,写死了先查 hosts,没找到再查 dns。

这么说,那还是该先查 hosts 再查 dns 呀,为什么这里抓包看到是先查的 dns? (如果是先查 hosts 就能命中查询,不会再发起dns请求)

访问 apiserver 的 client 是 kubectl,用 go 写的,会不会是 go 程序解析域名时压根没调底层 c 库的 gethostbynamegetaddrinfo?

搜一下发现果然是这样: go runtime 用 go 实现了 glibc 的 getaddrinfo 的行为来解析域名,减少了 c 库调用 (应该是考虑到减少 cgo 调用带来的的性能损耗)

issue: net: replicate DNS resolution behaviour of getaddrinfo(glibc) in the go dns resolver

翻源码验证下:

Unix 系的 OS 下,除了 openbsd, go runtime 会读取 /etc/nsswitch.conf (net/conf.go):

hostLookupOrder 函数决定域名解析顺序的策略,Linux 下,如果没有 nsswitch.conf 文件就 dns 比 hosts 文件优先 (net/conf.go):

可以看到 hostLookupDNSFiles 的意思是 dns first (net/dnsclient_unix.go):

所以虽然 alpine 用的 musl libc 不是 glibc,但 go 程序解析域名还是一样走的 glibc 的逻辑,而 alpine 没有 /etc/nsswitch.conf 文件,也就解释了为什么 kubectl 访问 apiserver 先做 dns 解析,没解析到再查的 hosts,导致每次访问都去请求 dns,恰好又碰到 conntrack 那个丢包问题导致 dns 5s 延时,在用户这里表现就是 pod 内用 kubectl 访问 apiserver 偶尔出现 5s 延时,有时出现 10s 是因为重试的那次 dns 请求刚好也遇到 conntrack 丢包导致延时又叠加了 5s 。

解决方案:

  1. 换基础镜像,不用 alpine
  2. 挂载 nsswitch.conf 文件 (可以用 hostPath)

DNS 解析异常

现象: 有个用户反馈域名解析有时有问题,看报错是解析超时。

第一反应当然是看 coredns 的 log:

1
2
[ERROR] 2 loginspub.xxxxmobile-inc.net. 
A: unreachable backend: read udp 172.16.0.230:43742->10.225.30.181:53: i/o timeout

这是上游 DNS 解析异常了,因为解析外部域名 coredns 默认会请求上游 DNS 来查询,这里的上游 DNS 默认是 coredns pod 所在宿主机的 resolv.conf 里面的 nameserver (coredns pod 的 dnsPolicy 为 “Default”,也就是会将宿主机里的 resolv.conf 里的 nameserver 加到容器里的 resolv.conf, coredns 默认配置 proxy . /etc/resolv.conf, 意思是非 service 域名会使用 coredns 容器中 resolv.conf 文件里的 nameserver 来解析)

确认了下,超时的上游 DNS 10.225.30.181 并不是期望的 nameserver,VPC 默认 DNS 应该是 180 开头的。看了 coredns 所在节点的 resolv.conf,发现确实多出了这个非期望的 nameserver,跟用户确认了下,这个 DNS 不是用户自己加上去的,添加节点时这个 nameserver 本身就在 resolv.conf 中。

根据内部同学反馈, 10.225.30.181 是广州一台年久失修将被撤裁的 DNS,物理网络,没有 VIP,撤掉就没有了,所以如果 coredns 用到了这台 DNS 解析时就可能 timeout。后面我们自己测试,某些 VPC 的集群确实会有这个 nameserver,奇了怪了,哪里冒出来的?

又试了下直接创建 CVM,不加进 TKE 节点发现没有这个 nameserver,只要一加进 TKE 节点就有了 !!!

看起来是 TKE 的问题,将 CVM 添加到 TKE 集群会自动重装系统,初始化并加进集群成为 K8S 的 node,确认了初始化过程并不会写 resolv.conf,会不会是 TKE 的 OS 镜像问题?尝试搜一下除了 /etc/resolv.conf 之外哪里还有这个 nameserver 的 IP,最后发现 /etc/resolvconf/resolv.conf.d/base 这里面有。

看下 /etc/resolvconf/resolv.conf.d/base 的作用:Ubuntu 的 /etc/resolv.conf 是动态生成的,每次重启都会将 /etc/resolvconf/resolv.conf.d/base 里面的内容加到 /etc/resolv.conf 里。

经确认: 这个文件确实是 TKE 的 Ubuntu OS 镜像里自带的,可能发布 OS 镜像时不小心加进去的。

那为什么有些 VPC 的集群的节点 /etc/resolv.conf 里面没那个 IP 呢?它们的 OS 镜像里也都有那个文件那个 IP 呀。

请教其它部门同学发现:

  • 非 dhcp 子机,cvm 的 cloud-init 会覆盖 /etc/resolv.conf 来设置 dns
  • dhcp 子机,cloud-init 不会设置,而是通过 dhcp 动态下发
  • 2018 年 4 月 之后创建的 VPC 就都是 dhcp 类型了的,比较新的 VPC 都是 dhcp 类型的

真相大白:/etc/resolv.conf 一开始内容都包含 /etc/resolvconf/resolv.conf.d/base 的内容,也就是都有那个不期望的 nameserver,但老的 VPC 由于不是 dhcp 类型,所以 cloud-init 会覆盖 /etc/resolv.conf,抹掉了不被期望的 nameserver,而新创建的 VPC 都是 dhcp 类型,cloud-init 不会覆盖 /etc/resolv.conf,导致不被期望的 nameserver 残留在了 /etc/resolv.conf,而 coredns pod 的 dnsPolicy 为 “Default”,也就是会将宿主机的 /etc/resolv.conf 中的 nameserver 加到容器里,coredns 解析集群外的域名默认使用这些 nameserver 来解析,当用到那个将被撤裁的 nameserver 就可能 timeout。

临时解决: 删掉 /etc/resolvconf/resolv.conf.d/base 重启

长期解决: 我们重新制作 TKE Ubuntu OS 镜像然后发布更新

这下应该没问题了吧,But, 用户反馈还是会偶尔解析有问题,但现象不一样了,这次并不是 dns timeout。

用脚本跑测试仔细分析现象:

  • 请求 loginspub.xxxxmobile-inc.net 时,偶尔提示域名无法解析
  • 请求 accounts.google.com 时,偶尔提示连接失败

进入 dns 解析偶尔异常的容器的 netns 抓包:

  • dns 请求会并发请求 A 和 AAAA 记录
  • 测试脚本发请求打印序号,抓包然后 wireshark 分析对比异常时请求序号偏移量,找到异常时的 dns 请求报文,发现异常时 A 和 AAAA 记录的请求 id 冲突,并且 AAAA 响应先返回

正常情况下id不会冲突,这里冲突了也就能解释这个 dns 解析异常的现象了:

  • loginspub.xxxxmobile-inc.net 没有 AAAA (ipv6) 记录,它的响应先返回告知 client 不存在此记录,由于请求 id 跟 A 记录请求冲突,后面 A 记录响应返回了 client 发现 id 重复就忽略了,然后认为这个域名无法解析
  • accounts.google.com 有 AAAA 记录,响应先返回了,client 就拿这个记录去尝试请求,但当前容器环境不支持 ipv6,所以会连接失败

那为什么 dns 请求 id 会冲突?

继续观察发现: 其它节点上的 pod 不会复现这个问题,有问题这个节点上也不是所有 pod 都有这个问题,只有基于 alpine 镜像的容器才有这个问题,在此节点新起一个测试的 alpine:latest 的容器也一样有这个问题。

为什么 alpine 镜像的容器在这个节点上有问题在其它节点上没问题? 为什么其他镜像的容器都没问题?它们跟 alpine 的区别是什么?

发现一点区别: alpine 使用的底层 c 库是 musl libc,其它镜像基本都是 glibc

翻 musl libc 源码, 构造 dns 请求时,请求 id 的生成没加锁,而且跟当前时间戳有关:

看注释,作者应该认为这样id基本不会冲突,事实证明,绝大多数情况确实不会冲突,我在网上搜了很久没有搜到任何关于 musl libc 的 dns 请求 id 冲突的情况。这个看起来取决于硬件,可能在某种类型硬件的机器上运行,短时间内生成的 id 就可能冲突。我尝试跟用户在相同地域的集群,添加相同配置相同机型的节点,也复现了这个问题,但后来删除再添加时又不能复现了,看起来后面新建的 cvm 又跑在了另一种硬件的母机上了。

OK,能解释通了,再底层的细节就不清楚了,我们来看下解决方案:

  • 换基础镜像 (不用alpine)
  • 完全静态编译业务程序(不依赖底层c库),比如go语言程序编译时可以关闭 cgo (CGO_ENABLED=0),并告诉链接器要静态链接 (go build 后面加 -ldflags '-d'),但这需要语言和编译工具支持才可以

最终建议用户基础镜像换成另一个比较小的镜像: debian:stretch-slim

问题解决,但用户后面觉得 debian:stretch-slim 做出来的镜像太大了,有 6MB 多,而之前基于 alpine 做出来只有 1MB 多,最后使用了一个非官方的修改过 musl libc 的 alpine 镜像作为基础镜像,里面禁止了 AAAA 请求从而避免这个问题。

Pod 偶尔存活检查失败

现象: Pod 偶尔会存活检查失败,导致 Pod 重启,业务偶尔连接异常。

之前从未遇到这种情况,在自己测试环境尝试复现也没有成功,只有在用户这个环境才可以复现。这个用户环境流量较大,感觉跟连接数或并发量有关。

用户反馈说在友商的环境里没这个问题。

对比友商的内核参数发现有些区别,尝试将节点内核参数改成跟友商的一样,发现问题没有复现了。

再对比分析下内核参数差异,最后发现是 backlog 太小导致的,节点的 net.ipv4.tcp_max_syn_backlog 默认是 1024,如果短时间内并发新建 TCP 连接太多,SYN 队列就可能溢出,导致部分新连接无法建立。

解释一下:

TCP 连接建立会经过三次握手,server 收到 SYN 后会将连接加入 SYN 队列,当收到最后一个 ACK 后连接建立,这时会将连接从 SYN 队列中移动到 ACCEPT 队列。在 SYN 队列中的连接都是没有建立完全的连接,处于半连接状态。如果 SYN 队列比较小,而短时间内并发新建的连接比较多,同时处于半连接状态的连接就多,SYN 队列就可能溢出,tcp_max_syn_backlog 可以控制 SYN 队列大小,用户节点的 backlog 大小默认是 1024,改成 8096 后就可以解决问题。

访问 externalTrafficPolicy 为 Local 的 Service 对应 LB 有时超时

现象:用户在 TKE 创建了公网 LoadBalancer 类型的 Service,externalTrafficPolicy 设为了 Local,访问这个 Service 对应的公网 LB 有时会超时。

externalTrafficPolicy 为 Local 的 Service 用于在四层获取客户端真实源 IP,官方参考文档:Source IP for Services with Type=LoadBalancer

TKE 的 LoadBalancer 类型 Service 实现是使用 CLB 绑定所有节点对应 Service 的 NodePort,CLB 不做 SNAT,报文转发到 NodePort 时源 IP 还是真实的客户端 IP,如果 NodePort 对应 Service 的 externalTrafficPolicy 不是 Local 的就会做 SNAT,到 pod 时就看不到客户端真实源 IP 了,但如果是 Local 的话就不做 SNAT,如果本机 node 有这个 Service 的 endpoint 就转到对应 pod,如果没有就直接丢掉,因为如果转到其它 node 上的 pod 就必须要做 SNAT,不然无法回包,而 SNAT 之后就无法获取真实源 IP 了。

LB 会对绑定节点的 NodePort 做健康检查探测,检查 LB 的健康检查状态: 发现这个 NodePort 的所有节点都不健康 !!!

那么问题来了:

  1. 为什么会全不健康,这个 Service 有对应的 pod 实例,有些节点上是有 endpoint 的,为什么它们也不健康?
  2. LB 健康检查全不健康,但是为什么有时还是可以访问后端服务?

跟 LB 的同学确认: 如果后端 rs 全不健康会激活 LB 的全死全活逻辑,也就是所有后端 rs 都可以转发。

那么有 endpoint 的 node 也是不健康这个怎么解释?

在有 endpoint 的 node 上抓 NodePort 的包: 发现很多来自 LB 的 SYN,但是没有响应 ACK。

看起来报文在哪被丢了,继续抓下 cbr0 看下: 发现没有来自 LB 的包,说明报文在 cbr0 之前被丢了。

再观察用户集群环境信息:

  1. k8s 版本1.12
  2. 启用了 ipvs
  3. 只有 local 的 service 才有异常

尝试新建一个 1.12 启用 ipvs 和一个没启用 ipvs 的测试集群。也都创建 Local 的 LoadBalancer Service,发现启用 ipvs 的测试集群复现了那个问题,没启用 ipvs 的集群没这个问题。

再尝试创建 1.10 的集群,也启用 ipvs,发现没这个问题。

看起来跟集群版本和是否启用 ipvs 有关。

1.12 对比 1.10 启用 ipvs 的集群: 1.12 的会将 LB 的 EXTERNAL-IP 绑到 kube-ipvs0 上,而 1.10 的不会:

1
2
3
$ ip a show kube-ipvs0 | grep -A2 170.106.134.124
inet 170.106.134.124/32 brd 170.106.134.124 scope global kube-ipvs0
valid_lft forever preferred_lft forever

  • 170.106.134.124 是 LB 的公网 IP
  • 1.12 启用 ipvs 的集群将 LB 的公网 IP 绑到了 kube-ipvs0 网卡上

kube-ipvs0 是一个 dummy interface,实际不会接收报文,可以看到它的网卡状态是 DOWN,主要用于绑 ipvs 规则的 VIP,因为 ipvs 主要工作在 netfilter 的 INPUT 链,报文通过 PREROUTING 链之后需要决定下一步该进入 INPUT 还是 FORWARD 链,如果是本机 IP 就会进入 INPUT,如果不是就会进入 FORWARD 转发到其它机器。所以 k8s 利用 kube-ipvs0 这个网卡将 service 相关的 VIP 绑在上面以便让报文进入 INPUT 进而被 ipvs 转发。

当 IP 被绑到 kube-ipvs0 上,内核会自动将上面的 IP 写入 local 路由:

1
2
$ ip route show table local | grep 170.106.134.124
local 170.106.134.124 dev kube-ipvs0 proto kernel scope host src 170.106.134.124

内核认为在 local 路由里的 IP 是本机 IP,而 linux 默认有个行为: 忽略任何来自非回环网卡并且源 IP 是本机 IP 的报文。而 LB 的探测报文源 IP 就是 LB IP,也就是 Service 的 EXTERNAL-IP 猜想就是因为这个 IP 被绑到 kube-ipvs0,自动加进 local 路由导致内核直接忽略了 LB 的探测报文。

带着猜想做实现, 试一下将 LB IP 从 local 路由中删除:

1
ip route del table local local 170.106.134.124 dev kube-ipvs0  proto kernel  scope host  src 170.106.134.124

发现这个 node 的在 LB 的健康检查的状态变成健康了! 看来就是因为这个 LB IP 被绑到 kube-ipvs0 导致内核忽略了来自 LB 的探测报文,然后 LB 收不到回包认为不健康。

那为什么其它厂商没反馈这个问题?应该是 LB 的实现问题,腾讯云的公网 CLB 的健康探测报文源 IP 就是 LB 的公网 IP,而大多数厂商的 LB 探测报文源 IP 是保留 IP 并非 LB 自身的 VIP。

如何解决呢? 发现一个内核参数: accept_local 可以让 linux 接收源 IP 是本机 IP 的报文。

试了开启这个参数,确实在 cbr0 收到来自 LB 的探测报文了,说明报文能被 pod 收到,但抓 eth0 还是没有给 LB 回包。

为什么没有回包? 分析下五元组,要给 LB 回包,那么 目的IP:目的Port 必须是探测报文的 源IP:源Port,所以目的 IP 就是 LB IP,由于容器不在主 netns,发包经过 veth pair 到 cbr0 之后需要再经过 netfilter 处理,报文进入 PREROUTING 链然后发现目的 IP 是本机 IP,进入 INPUT 链,所以报文就出不去了。再分析下进入 INPUT 后会怎样,因为目的 Port 跟 LB 探测报文源 Port 相同,是一个随机端口,不在 Service 的端口列表,所以没有对应的 IPVS 规则,IPVS 也就不会转发它,而 kube-ipvs0 上虽然绑了这个 IP,但它是一个 dummy interface,不会收包,所以报文最后又被忽略了。

再看看为什么 1.12 启用 ipvs 会绑 EXTERNAL-IPkube-ipvs0,翻翻 k8s 的 kube-proxy 支持 ipvs 的 proposal,发现有个地方说法有点漏洞:

LB 类型 Service 的 status 里有 ingress IP,实际就是 kubectl get service 看到的 EXTERNAL-IP,这里说不会绑定这个 IP 到 kube-ipvs0,但后面又说会给它创建 ipvs 规则,既然没有绑到 kube-ipvs0,那么这个 IP 的报文根本不会进入 INPUT 被 ipvs 模块转发,创建的 ipvs 规则也是没用的。

后来找到作者私聊,思考了下,发现设计上确实有这个问题。

看了下 1.10 确实也是这么实现的,但是为什么 1.12 又绑了这个 IP 呢? 调研后发现是因为 #59976 这个 issue 发现一个问题,后来引入 #63066 这个 PR 修复的,而这个 PR 的行为就是让 LB IP 绑到 kube-ipvs0,这个提交影响 1.11 及其之后的版本。

#59976 的问题是因为没绑 LB IP到 kube-ipvs0 上,在自建集群使用 MetalLB 来实现 LoadBalancer 类型的 Service,而有些网络环境下,pod 是无法直接访问 LB 的,导致 pod 访问 LB IP 时访问不了,而如果将 LB IP 绑到 kube-ipvs0 上就可以通过 ipvs 转发到 LB 类型 Service 对应的 pod 去, 而不需要真正经过 LB,所以引入了 #63066 这个PR。

临时方案: 将 #63066 这个 PR 的更改回滚下,重新编译 kube-proxy,提供升级脚本升级存量 kube-proxy。

如果是让 LB 健康检查探测支持用保留 IP 而不是自身的公网 IP ,也是可以解决,但需要跨团队合作,而且如果多个厂商都遇到这个问题,每家都需要为解决这个问题而做开发调整,代价较高,所以长期方案需要跟社区沟通一起推进,所以我提了 issue,将问题描述的很清楚: #79783

小思考: 为什么 CLB 可以不做 SNAT ? 回包目的 IP 就是真实客户端 IP,但客户端是直接跟 LB IP 建立的连接,如果回包不经过 LB 是不可能发送成功的呀。

是因为 CLB 的实现是在母机上通过隧道跟 CVM 互联的,多了一层封装,回包始终会经过 LB。

就是因为 CLB 不做 SNAT,正常来自客户端的报文是可以发送到 nodeport,但健康检查探测报文由于源 IP 是 LB IP 被绑到 kube-ipvs0 导致被忽略,也就解释了为什么健康检查失败,但通过LB能访问后端服务,只是有时会超时。那么如果要做 SNAT 的 LB 岂不是更糟糕,所有报文都变成 LB IP,所有报文都会被忽略?

我提的 issue 有回复指出,AWS 的 LB 会做 SNAT,但它们不将 LB 的 IP 写到 Service 的 Status 里,只写了 hostname,所以也不会绑 LB IP 到 kube-ipvs0:

但是只写 hostname 也得 LB 支持自动绑域名解析,并且个人觉得只写 hostname 很别扭,通过 kubectl get svc 或者其它 k8s 管理系统无法直接获取 LB IP,这不是一个好的解决方法。

我提了 #79976 这个 PR 可以解决问题: 给 kube-proxy 加 --exclude-external-ip 这个 flag 控制是否为 LB IP
创建 ipvs 规则和绑定 kube-ipvs0

但有人担心增加 kube-proxy flag 会增加 kube-proxy 的调试复杂度,看能否在 iptables 层面解决:

仔细一想,确实可行,打算有空实现下,重新提个 PR:

结语

至此,我们一起完成了一段奇妙的问题排查之旅,信息量很大并且比较复杂,有些没看懂很正常,但我希望你可以收藏起来反复阅读,一起在技术的道路上打怪升级。

Kubernetes 问题排查:Pod 状态一直 Terminating

作者: 陈鹏

查看 Pod 事件:

1
$ kubectl describe pod/apigateway-6dc48bf8b6-clcwk -n cn-staging

Need to kill Pod

1
Normal  Killing  39s (x735 over 15h)  kubelet, 10.179.80.31  Killing container with id docker://apigateway:Need to kill Pod

可能是磁盘满了,无法创建和删除 pod

处理建议是参考Kubernetes 最佳实践:处理容器数据磁盘被写满

DeadlineExceeded

1
Warning FailedSync 3m (x408 over 1h) kubelet, 10.179.80.31 error determining status: rpc error: code = DeadlineExceeded desc = context deadline exceeded

怀疑是17版本dockerd的BUG。可通过 kubectl -n cn-staging delete pod apigateway-6dc48bf8b6-clcwk --force --grace-period=0 强制删除pod,但 docker ps 仍看得到这个容器

处置建议:

  • 升级到docker 18. 该版本使用了新的 containerd,针对很多bug进行了修复。
  • 如果出现terminating状态的话,可以提供让容器专家进行排查,不建议直接强行删除,会可能导致一些业务上问题。

存在 Finalizers

k8s 资源的 metadata 里如果存在 finalizers,那么该资源一般是由某程序创建的,并且在其创建的资源的 metadata 里的 finalizers 加了一个它的标识,这意味着这个资源被删除时需要由创建资源的程序来做删除前的清理,清理完了它需要将标识从该资源的 finalizers 中移除,然后才会最终彻底删除资源。比如 Rancher 创建的一些资源就会写入 finalizers 标识。

处理建议:kubectl edit 手动编辑资源定义,删掉 finalizers,这时再看下资源,就会发现已经删掉了

Kubernetes 踩坑分享:开启tcp_tw_recycle内核参数在NAT环境会丢包

作者: 陈鹏

原因

tcp_tw_recycle 参数,它用来快速回收 TIME_WAIT 连接,不过如果在 NAT 环境下会引发问题。 RFC1323 中有如下一段描述:

An additional mechanism could be added to the TCP, a per-host cache of the last timestamp received from any connection. This value could then be used in the PAWS mechanism to reject old duplicate segments from earlier incarnations of the connection, if the timestamp clock can be guaranteed to have ticked at least once since the old connection was open. This would require that the TIME-WAIT delay plus the RTT together must be at least one tick of the sender’s timestamp clock. Such an extension is not part of the proposal of this RFC.

  • 大概意思是说TCP有一种行为,可以缓存每个连接最新的时间戳,后续请求中如果时间戳小于缓存的时间戳,即视为无效,相应的数据包会被丢弃。

  • Linux是否启用这种行为取决于tcp_timestamps和tcp_tw_recycle,因为tcp_timestamps缺省就是开启的,所以当tcp_tw_recycle被开启后,实际上这种行为就被激活了,当客户端或服务端以NAT方式构建的时候就可能出现问题,下面以客户端NAT为例来说明:

  • 当多个客户端通过NAT方式联网并与服务端交互时,服务端看到的是同一个IP,也就是说对服务端而言这些客户端实际上等同于一个,可惜由于这些客户端的时间戳可能存在差异,于是乎从服务端的视角看,便可能出现时间戳错乱的现象,进而直接导致时间戳小的数据包被丢弃。如果发生了此类问题,具体的表现通常是是客户端明明发送的SYN,但服务端就是不响应ACK。

  • 在4.12之后的内核已移除tcp_tw_recycle内核参数: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=4396e46187ca5070219b81773c4e65088dac50cc https://github.com/torvalds/linux/commit/4396e46187ca5070219b81773c4e65088dac50cc

TKE中 使用 NAT 的场景

  • 跨 VPC 访问(通过对等连接、云联网、专线等方式打通),会做 SNAT

kubernetes 最佳实践:处理容器数据磁盘被写满

作者: 陈鹏

容器数据磁盘被写满造成的危害:

  • 不能创建 Pod (一直 ContainerCreating)
  • 不能删除 Pod (一直 Terminating)
  • 无法 exec 到容器

判断是否被写满:

容器数据目录大多会单独挂数据盘,路径一般是 /var/lib/docker,也可能是 /data/docker/opt/docker,取决于节点被添加时的配置:

可通过 docker info 确定:

1
2
3
4
$ docker info
...
Docker Root Dir: /var/lib/docker
...

如果没有单独挂数据盘,则会使用系统盘存储。判断是否被写满:

1
2
3
4
5
6
$ df
Filesystem 1K-blocks Used Available Use% Mounted on
...
/dev/vda1 51474044 4619112 44233548 10% /
...
/dev/vdb 20511356 20511356 0 100% /var/lib/docker

解决方法

先恢复业务,清理磁盘空间

重启 dockerd (清理容器日志输出和可写层文件)

  • 重启前需要稍微腾出一点空间,不然重启 docker 会失败,可以手动删除一些docker的log文件或可写层文件,通常删除log:
1
2
3
4
$ cd /var/lib/docker/containers
$ du -sh * # 找到比较大的目录
$ cd dda02c9a7491fa797ab730c1568ba06cba74cecd4e4a82e9d90d00fa11de743c
$ cat /dev/null > dda02c9a7491fa797ab730c1568ba06cba74cecd4e4a82e9d90d00fa11de743c-json.log.9 # 删除log文件

注意: 使用 cat /dev/null > 方式删除而不用 rm,因为用 rm 删除的文件,docker 进程可能不会释放文件,空间也就不会释放;log 的后缀数字越大表示越久远,先删除旧日志。

  • 将该 node 标记不可调度,并将其已有的 pod 驱逐到其它节点,这样重启dockerd就会让该节点的pod对应的容器删掉,容器相关的日志(标准输出)与容器内产生的数据文件(可写层)也会被清理:
1
kubectl drain 10.179.80.31
  • 重启 dockerd:
1
systemctl restart dockerd
  • 取消不可调度的标记:
1
kubectl uncordon 10.179.80.31

定位根因,彻底解决

问题定位方法见附录,这里列举根因对应的解决方法:

  • 日志输出量大导致磁盘写满:
    • 减少日志输出
    • 增大磁盘空间
    • 减小单机可调度的pod数量
  • 可写层量大导致磁盘写满: 优化程序逻辑,不写文件到容器内或控制写入文件的大小与数量
  • 镜像占用空间大导致磁盘写满:
    • 增大磁盘空间
    • 删除不需要的镜像

附录

查看docker的磁盘空间占用情况

1
$ docker system df -v

定位容器写满磁盘的原因

进入容器数据目录(假设是 /var/lib/docker,并且存储驱动是 aufs):

1
2
$ cd /var/lib/docker
$ du -sh *

  • containers 目录: 体积大说明日志输出量大
  • aufs 目录

  • diff 子目录: 容器可写层,体积大说明可写层数据量大(程序在容器里写入文件)
  • mnt 子目录: 联合挂载点,内容为容器里看到的内容,即包含镜像本身内容以及可写层内容

找出日志输出量大的 pod

TKE 的 pod 中每个容器输出的日志最大存储 1G (日志轮转,最大10个文件,每个文件最大100m,可用 docker inpect 查看):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ docker inspect fef835ebfc88
[
{
...
"HostConfig": {
...
"LogConfig": {
"Type": "json-file",
"Config": {
"max-file": "10",
"max-size": "100m"
}
},
...

查看哪些容器日志输出量大:

1
2
$ cd /var/lib/docker/containers
$ du -sh *

目录名即为容器id,使用前几位与 docker ps 结果匹配可找出对应容器,最后就可以推算出是哪些 pod 搞的鬼

找出可写层数据量大的 pod

可写层的数据主要是容器内程序自身写入的,无法控制大小,可写层越大说明容器写入的文件越多或越大,通常是容器内程序将log写到文件里了,查看一下哪个容器的可写层数据量大:

1
2
$ cd /var/lib/docker/aufs/diff
$ du -sh *


通过可写层目录(diff的子目录)反查容器id:

1
2
$ grep 834d97500892f56b24c6e63ffd4e520fc29c6c0d809a3472055116f59fb1d2be /var/lib/docker/image/aufs/layerdb/mounts/*/mount-id
/var/lib/docker/image/aufs/layerdb/mounts/eb76fcd31dfbe5fc949b67e4ad717e002847d15334791715ff7d96bb2c8785f9/mount-id:834d97500892f56b24c6e63ffd4e520fc29c6c0d809a3472055116f59fb1d2be

mounts 后面一级的id即为容器id: eb76fcd31dfbe5fc949b67e4ad717e002847d15334791715ff7d96bb2c8785f9,使用前几位与 docker ps 结果匹配可找出对应容器,最后就可以推算出是哪些 pod 搞的鬼

找出体积大的镜像

看看哪些镜像比较占空间

Kubernetes 最佳实践:处理内存碎片化

作者: 陈鹏

内存碎片化造成的危害

节点的内存碎片化严重,导致docker运行容器时,无法分到大的内存块,导致start docker失败。最终导致服务更新时,状态一直都是启动中

判断是否内存碎片化严重

内核日志显示:

进一步查看的系统内存(cache多可能是io导致的,为了提高io效率留下的缓存,这部分内存实际是可以释放的):

查看slab (后面的0多表示伙伴系统没有大块内存了):

解决方法

  • 周期性地或者在发现大块内存不足时,先进行drop_cache操作:

    1
    echo 3 > /proc/sys/vm/drop_caches
  • 必要时候进行内存整理,开销会比较大,会造成业务卡住一段时间(慎用):

    1
    echo 1 > /proc/sys/vm/compact_memory

附录

相关链接:

Kubernetes 最佳实践:解决长连接服务扩容失效

作者: 陈鹏

在现网运营中,有很多场景为了提高效率,一般都采用建立长连接的方式来请求。我们发现在客户端以长连接请求服务端的场景下,K8S的自动扩容会失效。原因是客户端长连接一直保留在老的Pod容器中,新扩容的Pod没有新的连接过来,导致K8S按照步长扩容第一批Pod之后就停止了扩容操作,而且新扩容的Pod没能承载请求,进而出现服务过载的情况,自动扩容失去了意义。

对长连接扩容失效的问题,我们的解决方法是将长连接转换为短连接。我们参考了 nginx keepalive 的设计,nginx 中 keepalive_requests 这个配置项设定了一个TCP连接能处理的最大请求数,达到设定值(比如1000)之后服务端会在 http 的 Header 头标记 “Connection:close”,通知客户端处理完当前的请求后关闭连接,新的请求需要重新建立TCP连接,所以这个过程中不会出现请求失败,同时又达到了将长连接按需转换为短连接的目的。通过这个办法客户端和云K8S服务端处理完一批请求后不断的更新TCP连接,自动扩容的新Pod能接收到新的连接请求,从而解决了自动扩容失效的问题。

由于Golang并没有提供方法可以获取到每个连接处理过的请求数,我们重写了 net.Listenernet.Conn,注入请求计数器,对每个连接处理的请求做计数,并通过 net.Conn.LocalAddr() 获得计数值,判断达到阈值 1000 后在返回的 Header 中插入 “Connection:close” 通知客户端关闭连接,重新建立连接来发起请求。以上处理逻辑用 Golang 实现示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
"net"
"github.com/gin-gonic/gin"
"net/http"
)

//重新定义net.Listener
type counterListener struct {
net.Listener
}
//重写net.Listener.Accept(),对接收到的连接注入请求计数器
func (c *counterListener) Accept() (net.Conn, error) {
conn, err := c.Listener.Accept()
if err != nil {
return nil, err
}
return &counterConn{Conn: conn}, nil
}
//定义计数器counter和计数方法Increment()
type counter int
func (c *counter) Increment() int {
*c++
return int(*c)
}

//重新定义net.Conn,注入计数器ct
type counterConn struct {
net.Conn
ct counter
}

//重写net.Conn.LocalAddr(),返回本地网络地址的同时返回该连接累计处理过的请求数
func (c *counterConn) LocalAddr() net.Addr {
return &counterAddr{c.Conn.LocalAddr(), &c.ct}
}

//定义TCP连接计数器,指向连接累计请求的计数器
type counterAddr struct {
net.Addr
*counter
}

func main() {
r := gin.New()
r.Use(func(c *gin.Context) {
localAddr := c.Request.Context().Value(http.LocalAddrContextKey)
if ct, ok := localAddr.(interface{ Increment() int }); ok {
if ct.Increment() >= 1000 {
c.Header("Connection", "close")
}
}
c.Next()
})
r.GET("/", func(c *gin.Context) {
c.String(200, "plain/text", "hello")
})
l, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
err = http.Serve(&counterListener{l}, r)
if err != nil {
panic(err)
}
}

Kubernetes 问题定位技巧:容器内抓包

作者: 陈鹏

在使用 kubernetes 跑应用的时候,可能会遇到一些网络问题,比较常见的是服务端无响应(超时)或回包内容不正常,如果没找出各种配置上有问题,这时我们需要确认数据包到底有没有最终被路由到容器里,或者报文到达容器的内容和出容器的内容符不符合预期,通过分析报文可以进一步缩小问题范围。那么如何在容器内抓包呢?本文提供实用的脚本一键进入容器网络命名空间(netns),使用宿主机上的tcpdump进行抓包。

使用脚本一键进入 pod netns 抓包

  • 发现某个服务不通,最好将其副本数调为1,并找到这个副本 pod 所在节点和 pod 名称

    1
    kubectl get pod -o wide
  • 登录 pod 所在节点,将如下脚本粘贴到 shell (注册函数到当前登录的 shell,我们后面用)

    1
    2
    3
    4
    5
    6
    7
    8
    function e() {
    set -eu
    ns=${2-"default"}
    pod=`kubectl -n $ns describe pod $1 | grep -Eo 'docker://.*$' | head -n 1 | sed 's/docker:\/\/\(.*\)$/\1/'`
    pid=`docker inspect -f {{.State.Pid}} $pod`
    echo "enter pod netns successfully for $ns/$1"
    nsenter -n --target $pid
    }
  • 一键进入 pod 所在的 netns,格式:e POD_NAME NAMESPACE,示例:

    1
    2
    e istio-galley-58c7c7c646-m6568 istio-system
    e proxy-5546768954-9rxg6 # 省略 NAMESPACE 默认为 default
  • 这时已经进入 pod 的 netns,可以执行宿主机上的 ip aifconfig 来查看容器的网卡,执行 netstat -tunlp 查看当前容器监听了哪些端口,再通过 tcpdump 抓包:

    1
    tcpdump -i eth0 -w test.pcap port 80
  • ctrl-c 停止抓包,再用 scpsz 将抓下来的包下载到本地使用 wireshark 分析,提供一些常用的 wireshark 过滤语法:

    1
    2
    3
    4
    5
    6
    # 使用 telnet 连上并发送一些测试文本,比如 "lbtest",
    # 用下面语句可以看发送的测试报文有没有到容器
    tcp contains "lbtest"
    # 如果容器提供的是http服务,可以使用 curl 发送一些测试路径的请求,
    # 通过下面语句过滤 uri 看报文有没有都容器
    http.request.uri=="/mytest"

脚本原理

我们解释下步骤二中用到的脚本的原理

  • 查看指定 pod 运行的容器 ID

    1
    kubectl describe pod <pod> -n mservice
  • 获得容器进程的 pid

    1
    docker inspect -f {{.State.Pid}} <container>
  • 进入该容器的 network namespace

    1
    nsenter -n --target <PID>

依赖宿主机的命名:kubectl, docker, nsenter, grep, head, sed

kubernetes 最佳实践:优雅热更新

作者: 陈鹏

当kubernetes对服务滚动更新的期间,默认配置的情况下可能会让部分连接异常(比如连接被拒绝),我们来分析下原因并给出最佳实践

滚动更新场景

使用 deployment 部署服务并关联 service

  • 修改 deployment 的 replica 调整副本数量来滚动更新
  • 升级程序版本(修改镜像tag)触发 deployment 新建 replicaset 启动新版本的 pod
  • 使用 HPA (HorizontalPodAutoscaler) 来对 deployment 自动扩缩容

更新过程连接异常的原因

滚动更新时,service 对应的 pod 会被创建或销毁,也就是 service 对应的 endpoint 列表会新增或移除endpoint,更新期间可能让部分连接异常,主要原因是:

  1. pod 被创建,还没完全启动就被 endpoint controller 加入到 service 的 endpoint 列表,然后 kube-proxy 配置对应的路由规则(iptables/ipvs),如果请求被路由到还没完全启动完成的 pod,这时 pod 还不能正常处理请求,就会导致连接异常
  2. pod 被销毁,但是从 endpoint controller watch 到变化并更新 service 的 endpoint 列表到 kube-proxy 更新路由规则这期间有个时间差,pod可能已经完全被销毁了,但是路由规则还没来得及更新,造成请求依旧还能被转发到已经销毁的 pod ip,导致连接异常

最佳实践

  • 针对第一种情况,可以给 pod 里的 container 加 readinessProbe (就绪检查),这样可以让容器完全启动了才被endpoint controller加进 service 的 endpoint 列表,然后 kube-proxy 再更新路由规则,这时请求被转发到的所有后端 pod 都是正常运行,避免了连接异常
  • 针对第二种情况,可以给 pod 里的 container 加 preStop hook,让 pod 真正销毁前先 sleep 等待一段时间,留点时间给 endpoint controller 和 kube-proxy 清理 endpoint 和路由规则,这段时间 pod 处于 Terminating 状态,在路由规则更新完全之前如果有请求转发到这个被销毁的 pod,请求依然可以被正常处理,因为它还没有被真正销毁

最佳实践 yaml 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: nginx
spec:
replicas: 1
selector:
matchLabels:
component: nginx
template:
metadata:
labels:
component: nginx
spec:
containers:
- name: nginx
image: "nginx"
ports:
- name: http
hostPort: 80
containerPort: 80
protocol: TCP
readinessProbe:
httpGet:
path: /healthz
port: 80
httpHeaders:
- name: X-Custom-Header
value: Awesome
initialDelaySeconds: 15
timeoutSeconds: 1
lifecycle:
preStop:
exec:
command: ["/bin/bash", "-c", "sleep 30"]

参考资料

如何使用 Kubernetes VPA 实现资源动态扩展和回收

作者: 徐蓓

简述

最近一段时间在研究和设计集群资源混合部署方案,以提高资源使用率。这其中一个重要的功能是资源动态扩展和回收。虽然方案是针对通用型集群管理软件,但由于 Kubernetes 目前是事实标准,所以先使用它来检验理论成果。

资源动态扩展

资源动态扩展按照类型分为两种:纵向和横向。纵向指的是对资源的配置进行扩展,比如增加或减少 CPU 个数和内存大小等。横向扩展则是增加资源的数量,比如服务器个数。笔者研究方案的目的是为了提升集群资源使用率,所以这里单讨论资源纵向扩展。

不过坦白来讲,资源纵向扩展首要目标并不是为了提高集群利用率,而是为了优化集群资源、提高资源可用性和性能。

在 Kubernetes 中 VPA 项目主要是完成这项工作(主要针对 Pod)。

Kubernetes VPA

Vertical Pod Autoscaler (VPA) frees the users from necessity of setting up-to-date resource requests for the containers in their pods. When configured, it will set the requests automatically based on usage and thus allow proper scheduling onto nodes so that appropriate resource amount is available for each pod.

以上是官方定义。简单来说是 Kubernetes VPA 可以根据实际负载动态设置 pod resource requests。

Kubernetes VPA 包含以下组件:

  • Recommender:用于根据监控指标结合内置机制给出资源建议值
  • Updater:用于实时更新 pod resource requests
  • History Storage:用于采集和存储监控数据
  • Admission Controller: 用于在 pod 创建时修改 resource requests

以下是架构图:

主要流程是:Recommender在启动时从History Storage获取历史数据,根据内置机制修改VPA API object资源建议值。Updater监听VPA API object,依据建议值动态修改 pod resource requests。VPA Admission Controller则是用于 pod 创建时修改 pod resource requests。History Storage则是通过Kubernetes Metrics API采集和存储监控数据。

Kubernetes VPA 的整体架构比较简单,流程也很清晰,理解起来并不困难。但里面隐藏的几个功能点,却是方案的核心所在。它们的质量直接影响了方案的成熟度和评价效果:

1、如何设计 Recommendation model
Recommendation model是集群优化的重中之重,它的好坏直接影响了集群资源优化的效果。就笔者目前了解,在 Kubernetes VPA 中这个模型是固定的,用户能做的是配置参数和数据源。

从官方描述看:

The request is calculated based on analysis of the current and previous runs of the container and other containers with similar properties (name, image, command, args). The recommendation model (MVP) assumes that the memory and CPU consumption are independent random variables with distribution equal to the one observed in the last N days (recommended value is N=8 to capture weekly peaks). A more advanced model in future could attempt to detect trends, periodicity and other time-related patterns.

CPU 和内存的建议值均是依据历史数据+固定机制计算而成,并没有一套解释引擎能让用户自定义规则。这在一定程度上影响了Recommendation model的准确性。就笔者理解,集群优化和混合部署的核心难点在于寻找能准确描述集群负载的指标,建立指标模型,并最终通过优化模型而达到最终目的 - 不论是为了优化集群或提高集群使用率。这个过程类似机器学习:先依旧经验或特征工程寻找特征变量,建立模型后使用数据不断优化参数,最后得到可用模型。所以仅靠单一指标 - 比如 CPU 或内存使用率 - 所建立的固定模型并不能准确描述集群状态和资源瓶颈。不管是从指标的颗粒度或固定模型上来看,最终效果都不会太好。

2、Pod 是否支持热更新
在 Kubernetes 中,pod resource requests 会影响 pod QoS 和容器的限制状态,比如驱逐策略、OOM Score和 cgroup 的限制参数等。如果不重建的话,单纯的修改 pod spec 只会影响调度策略。重建的话会导致 pod 重新调度,同时也在一定程度上降低了应用的可用性。官网列出一个更新策略auto,是可以in-place重建:

“Auto”: VPA assigns resource requests on pod creation as well as updates them on existing pods using the preferred update mechanism. Currently this is equivalent to “Recreate” (see below). Once restart free (“in-place”) update of pod requests is available, it may be used as the preferred update mechanism by the “Auto” mode. NOTE: This feature of VPA is experimental and may cause downtime for your applications.

目前应该没有完全实现。不过无论哪种方式,pod 重建貌似不可避免。

3、Pod 实时更新是否支持模糊控制
由于 Pod 更新会涉及重建,那么实时更新的触发条件就不应依据一个固定的值,比如值的变化触发更新重建(显然不可取)、依据逻辑表达式触发更新重建(也不可取,极端情况下会在设定值上下不断触发)。此时就需要在离散的值之间加入缓冲范围。而这个范围的设置高度依赖经验和实际集群情况,不然的话又会影响方案的最终效果。

总的来说,Kubernetes VPA 解决了资源纵向扩展的大部分工程问题。若应用于生产,还需做很多的个性化工作。

资源回收

既然 Kubernetes VPA 主要目标不是提升资源使用率,那它和混合部署又有何关系?别急,我们先来回顾下集群混合部署中提升资源使用率的关键是什么。

提升资源使用率最直观的方式,是在保证服务可用性的前提下尽量多的分配集群资源。我们知道在一般的集群管理软件中,调度器会为应用分配集群的可用资源。分配给应用的是逻辑资源,无需要和物理资源一一对应,比如可以超卖。并且应用持有的资源,一般情况下也不会全时段占用。在这种情况下,可将分配资源分为闲时和忙时。应用按照优先级区分,为高优先级的应用分配较多的资源。动态回收高优先级应用的闲时资源分配给低优先级应用使用,在高优先级应用负载升高时驱逐低优先级应用,从而达到提升资源使用率的目的。

在 Kubernetes VPA 中缺少资源回收的机制,但Recommender却可以配合Updater动态修改 pod resource requests 的值。也就是说 pod resource requests - 推荐值 = 资源回收值。这间接实现了资源回收的功能。那么 Kubernetes 调度器就可将这部分资源分配给其他应用使用。当然实际方案不会这么简单。比如Recommender就不需要使用History Storage中的历史数据和计算规则。初始值设为 pod resource requests,实时获取监控数据,加个 buffer 即可。这可以算是 Kubernetes 简陋版的资源回收功能。至于回收后,资源再分配和资源峰值驱逐等又是另一套流程了。

总结

暂时还是打算基于 Kubernetes VPA 实现资源回收和混合部署功能,毕竟现成的轮子。至于集群负载指标和模型,就完全是一套经验工程了。只能在实际生产中慢慢积累,别无他法。