乐趣区

关于云原生:Kubernetes-集群无损升级实践

一、背景

沉闷的社区和宽广的用户群,使 Kubernetes 依然放弃 3 个月一个版本的高频公布节奏。高频的版本公布带来了更多的新性能落地和 bug 及时修复,然而线上环境业务长期运行,任何变更出错都可能带来微小的经济损失,降级对企业来说绝对吃力,紧跟社区更是简直不可能,因而高频公布和稳固生产之间的矛盾须要容器团队去掂量和取舍。

vivo 互联网团队建设大规模 Kubernetes 集群以来,局部集群较长时间始终应用 v1.10 版本,然而因为业务容器化比例越来越高,对大规模集群稳定性、利用公布的多样性等诉求日益攀升,集群降级火烧眉毛。集群降级后将解决如下问题:

  • 高版本集群在大规模场景做了优化,降级能够解决一系列性能瓶颈问题。
  • 高版本集群能力反对 OpenKruise 等 CNCF 我的项目,降级能够解决版本依赖问题。
  • 高版本集群减少的新个性可能进步集群资源利用率,升高服务器老本同时进步集群效率。
  • 公司外部保护多个不同版本集群,降级后缩小集群版本碎片化,进一步升高运维老本。

这篇文章将会从 0 到 1 的介绍 vivo 互联网团队撑持在线业务的集群如何在不影响原有业务失常运行的状况下从 v1.10 版本升级到 v1.17 版本。之所以降级到 v1.17 而不是更高的 v1.18 以上版本, 是因为在 v1.18 版本引入的代码变动 [1] 会导致 extensions/v1beta1 等高级资源类型无奈持续运行(这部分代码在 v1.18 版本删除)。

二、无损降级难点

容器集群搭建通常有二进制 systemd 部署和外围组件动态 Pod 容器化部署两种形式,集群 API 服务多正本对外负载平衡。两种部署形式在降级时没有太大区别,二进制部署更贴合晚期集群,因而本文将对二进制形式部署的集群降级做分享。

对二进制形式部署的集群,集群组件降级次要是二进制的替换、配置文件的更新和服务的重启;从生产环境 SLO 要求来看,降级过程务必不能因为集群组件本身逻辑变动导致业务重启。因而降级的难点集中在上面几点:

首先,以后外部集群运行版本较低,然而运行容器数量却很多,其中局部依然是单正本运行,为了不影响业务运行,须要尽可能防止容器重启,这无疑是降级中最大的难点,而在 v1.10 版本和 v1.17 版本之间,kubelet 对于容器 Hash 值计算形式产生了变动,也就是说一旦降级必然会触发 kubelet 重新启动容器。

其次,社区举荐的形式是基于偏差策略 [2] 的降级以保障高可用集群降级同时不会因为 API resources 版本差别导致 kube-apiserve 和 kubelet 等组件呈现兼容性谬误,这就要求每次降级组件版本不能有 2 个 Final Release 以上的偏差,比方间接从 v1.11 降级至 v1.13 是不举荐的。

再次,降级过程中因为新个性的引入,API 兼容性可能引发旧版本集群的配置不失效,为整个集群埋下稳定性隐患。这便要求在降级前尽可能的相熟降级版本间的 ChangeLog,排查出可能带来潜在隐患的新个性。

三、无损降级计划

针对前述的难点,本节将一一提出针对性解决方案,同时也会介绍降级后遇到的高版本 bug 和解决办法。心愿对于降级后期兼容性筛查和降级过程中排查的问题可能给读者带来启发。

3.1 降级形式

在软件畛域,支流的利用降级形式有两种,别离是原地降级和替换降级。目前这两种降级形式在业内互联网大厂均有采纳,具体计划抉择与集群上业务有很大关系。

替换降级

1)Kubernetes 替换降级是先筹备一个高版本集群,对低版本集群通过一一节点排干、删除最初退出新集群的形式将低版本集群内节点逐渐轮换降级到新版本。

2)替换降级的长处是原子性更强,逐渐降级各个节点,降级过程不存在两头态,对业务平安更有保障;毛病是集群降级工作量较大,排干操作对 pod 重启敏感度高的利用、有状态利用、单正本利用等都不敌对。

原地降级

1)Kubernetes 原地降级是对节点上服务如 kube-controller-manager、kubelet 等组件依照肯定程序批量更新,从节点角色维度批量治理组件版本。

2)原地降级的长处是自动化操作便捷,并且通过适当的批改可能很好的保障容器的生命周期连续性;毛病是集群降级中组件降级程序很重要,降级中存在两头态,并且一个组件重启失败可能影响后续其余组件降级,原子性差。

vivo 容器集群上运行的局部业务对重启容忍度较低,尽可能防止容器重启是降级工作的第一要务。当解决好降级版本带来的容器重启后,联合业务容器化水平和业务类型不同,就地取材的抉择降级形式即可。二进制部署集群倡议抉择原地降级的形式,具备工夫短,操作简捷,单正本业务不会被降级影响的益处。

3.2 跨版本升级

因为 Kubernetes 自身是基于 API 的微服务架构,Kuberntes 外部架构也是通过 API 的调用和对资源对象的 List-Watch 来协同资源状态,因而社区开发者在设计 API 时遵循向上或向下兼容的准则。这个兼容性规定也是遵循社区的偏差策略 [2],即 API groups 弃用、启用时,对于 Alpha 版本会立刻失效,对于 Beta 版本将会持续反对 3 个版本,超过对应版本将导致 API resource version 不兼容。例如 kubernetes 在 v1.16 对 Deployment 等资源的 extensions/v1beta1 版本执行了弃用,在 v1.18 版本从代码级别执行了删除,当跨 3 个版本以上降级时会导致相干资源无奈被辨认,相应的增删改查操作都无奈执行。

如果依照官网倡议的降级策略,从 v1.10 降级到 v1.17 须要通过至多 7 次降级,这对于业务场景简单的生产环境来说运维复杂度高,业务危险大。

对于相似的 API breaking change 并不是每个版本都会存在,社区倡议的偏差策略是最平安的降级策略,通过粗疏的 Change Log 梳理和充沛的跨版本测试,咱们确认这几个版本之间不能存在影响业务运行和集群治理操作的 API 兼容性问题,对于 API 类型的废除,能够通过配置 apiserver 中相应参数来启动持续应用,保障环境业务持续失常运行。

3.3 防止容器重启

在初步验证降级计划时发现大量容器都被重建,重启起因从降级后 kubelet 组件日志看到是 “Container definition changed”。联合源码报错位于 pkg/kubelet/kuberuntime_manager.go 文件 computePodActions 办法,该办法用来计算 pod 的 spec 哈希值是否发生变化,如果变动则返回 true,告知 kubelet syncPod 办法触发 pod 内容器重建或者 pod 重建。

kubelet 容器 Hash 计算;

func (m *kubeGenericRuntimeManager) computePodActions(pod *v1.Pod, podStatus *kubecontainer.PodStatus) podActions {restart := shouldRestartOnFailure(pod)
    if _, _, changed := containerChanged(&container, containerStatus); changed {message = fmt.Sprintf("Container %s definition changed", container.Name)
        // 如果 container spec 发生变化,将会强制重启 container(将 restart 标记位设置为 true)restart = true
    }
    ...
    if restart {message = fmt.Sprintf("%s, will be restarted", message)
       // 须要重启的 container 退出到重启列表
       changes.ContainersToStart = append(changes.ContainersToStart, idx)
    }
}
 
func containerChanged(container *v1.Container, containerStatus *kubecontainer.ContainerStatus) (uint64, uint64, bool) {
   // 计算 container spec 的 Hash 值
   expectedHash := kubecontainer.HashContainer(container)
   return expectedHash, containerStatus.Hash, containerStatus.Hash != expectedHash
}

绝对于 v1.10 版本,v1.17 版本在计算容器 Hash 时应用的是 container 构造 json 序列化后的数据,而不是 v1.10 版本应用 container struct 的构造数据。而且高版本 kubelet 中对容器的构造也减少了新的属性,通过 go-spew 库计算出后果天然不统一,进一步向上传递返回值使得 syncPod 办法触发容器重建。

那是否能够通过批改 go-spew 对 container struct 的数据结构剔除新增的字段呢?答案是必定的,然而却不是优雅的形式,因为这样对外围代码逻辑侵入较为重大,当前每个版本的降级都须要定制代码,并且新增的字段越来越多,保护复杂度也会越来越高。换个角度,如果在降级过渡期间将属于旧版本集群 kubelet 创立的 Pod 跳过该查看,则能够防止容器重启。

和圈内共事交换后发现相似思路在社区已有实现,本地创立一个记录旧集群版本信息和启动工夫的配置文件,kubelet 代码中保护一个 cache 读取配置文件,在每个 syncPod 周期中,当 kubelet 发现本身 version 高于 cache 中记录的 oldVersion,并且容器启动工夫早于以后 kubelet 启动工夫,则会跳过容器 Hash 值计算。降级后的集群内运行定时工作探测 Pod 的 containerSpec 是否与高版本计算形式计算失去 Hash 后果全副统一,如果是则能够删除掉本地配置文件,syncPod 逻辑复原到与社区完全一致。

具体计划参考这种实现的益处是对原生 kubelet 代码侵入小,没有扭转外围代码逻辑,而且将来如果还须要降级高版本也能够复用该代码。如果集群内所有 Pod 都是以后版本 kubelet 创立,则会复原到社区本身的逻辑。

3.4 Pod 非预期驱赶问题

Kubernetes 尽管迭代了十几个版本,然而每个迭代社区活跃度依然很高,放弃着每个版本大概 30 个对于拓展性加强和稳定性晋升的新个性。抉择降级很大一方面起因是引入很多社区开发的新个性来丰盛集群的性能与晋升集群稳定性。新个性开发也是遵循偏差策略,跨大版本升级很可能导致在局部配置未加载的状况下启用新个性,这就给集群带来稳定性危险,因而须要梳理影响 Pod 生命周期的一些个性,尤其关注控制器相干的性能。

这里留神到在 v1.13 版本引入的 TaintBasedEvictions 个性用于更细粒度的治理 Pod 的驱赶条件。在 v1.13 基于条件版本之前,驱赶是基于 NodeController 的对立工夫驱赶,节点 NotReady 超过默认 5 分钟后,节点上的 Pod 才会被驱赶;在 v1.16 默认开启 TaintBasedEvictions 后,节点 NotReady 的驱赶将会依据每个 Pod 本身配置的 TolerationSeconds 来差异化的解决。

旧版本集群创立的 Pod 默认没有设置 TolerationSeconds,一旦降级结束 TaintBasedEvictions 被开启,节点变成 NotReady 后 5 秒就会驱赶节点上的 Pod。对于短暂的网络稳定、kubelet 重启等状况都会影响集群中业务的稳定性。

TaintBasedEvictions 对应的控制器是依照 pod 定义中的 tolerationSeconds 决定 Pod 的驱赶工夫,也就是说只有正确设置 Pod 中的 tolerationSeconds 就能够避免出现 Pod 的非预期驱赶。

在 v1.16 版本社区默认开启的 DefaultTolerationSeconds 准入控制器基于 k8s-apiserver 输出参数 default-not-ready-toleration-seconds 和 default-unreachable-toleration-seconds 为 Pod 设置默认的容忍度,以容忍 notready:NoExecute 和 unreachable:NoExecute 污点。

新建 Pod 在申请发送后会通过 DefaultTolerationSeconds 准入控制器给 pod 加上默认的 tolerations。然而这个逻辑如何对集群中曾经创立的 Pod 失效呢?查看该准入控制器发现除了反对 create 操作,update 操作也会更新 pod 定义触发 DefaultTolerationSeconds 插件去设置 tolerations。因而咱们通过给集群中曾经运行的 Pod 打 label 就能够达成目标。

tolerations:
- effect: NoExecute
  key: node.kubernetes.io/not-ready
  operator: Exists
  tolerationSeconds: 300
- effect: NoExecute
  key: node.kubernetes.io/unreachable
  operator: Exists
  tolerationSeconds: 300

3.5 Pod MatchNodeSelector

为了判断降级时 Pod 是否产生非预期的驱赶以及是否存在 Pod 内容器批量重启,有脚本去实时同步节点上非 Running 状态的 Pod 和产生重启的容器。

在降级过程中,忽然多进去数十个 pod 被标记为 MatchNodeSelector 状态,查看该节点上业务容器的确进行。kubelet 日志中看到如下谬误日志;

predicate.go:132] Predicate failed on Pod: nginx-7dd9db975d-j578s_default(e3b79017-0b15-11ec-9cd4-000c29c4fa15), for reason: Predicate MatchNodeSelector failed
kubelet_pods.go:1125] Killing unwanted pod "nginx-7dd9db975d-j578s"

经剖析,Pod 变成 MatchNodeSelector 状态是因为 kubelet 重启时对节点上 Pod 做准入查看时无奈找到节点满足要求的节点标签,pod 状态就会被设置为 Failed 状态,而 Reason 被设置为 MatchNodeSelector。在 kubectl 命令获取时,printer 做了相应转换间接显示了 Reason,因而咱们看到 Pod 状态是 MatchNodeSelector。通过给节点加上标签,能够让 Pod 从新调度回来,而后删除掉 MatchNodeSelector 状态的 Pod 即可。

倡议在降级前写脚本查看节点上 pod 定义中应用的 NodeSelector 属性节点是否都有对应的 Label。

3.6 无法访问 kube-apiserver

预发环境降级后的集群运行在 v1.17 版本后,忽然有节点变成 NotReady 状态告警,剖析后通过重启 kubelet 节点恢复正常。持续剖析出错起因发现 kubelet 日志中呈现了大量 use of closed network connection 报错。在社区搜寻相干 issue 发现有相似的问题,其中有开发者形容了问题的起因和解决办法,并且在 v1.18 曾经合入了代码。

问题的起因是 kubelet 默认连贯是 HTTP/2.0 长连贯,在构建 client 到 server 的连贯时应用的 golang net/http2 包存在 bug,在 http 连接池中依然能获取到 broken 的连贯,也就导致 kubelet 无奈失常与 kube-apiserver 通信。

golang 社区通过减少 http2 连贯健康检查躲避这个问题,然而这个 fix 依然存在 bug,社区在 golang v1.15.11 版本彻底修复。咱们外部通过 backport 到 v1.17 分支,并应用 golang 1.15.15 版本编译二进制解决了此问题。

3.7 TCP 连接数问题

在预公布环境测试运行期间,偶尔发现集群每个节点 kubelet 都有近 10 个长连贯与 kube-apiserver 通信,这与咱们认知的 kubelet 会复用连贯与 kube-apiserver 通信显著不符,查看 v1.10 版本环境也的确只有 1 个长连贯。这种 TCP 连接数减少状况无疑会对 LB 造成了压力,随着节点增多,一旦 LB 被拖垮,kubelet 无奈上报心跳,节点会变成 NotReady,紧接着将会有大量 Pod 被驱赶,结果是灾难性的。因而除去对 LB 自身参数调优外,还须要定位分明 kubelet 到 kube-apiserver 连接数减少的起因。

在本地搭建的 v1.17.1 版本 kubeadm 集群 kubelet 到 kube-apiserver 也仅有 1 个长连贯,阐明这个问题是在 v1.17.1 到降级指标版本之间引入的,排查后(问题)发现减少了判断逻辑导致 kubelet 获取 client 时不再从 cache 中获取缓存的长连贯。transport 的次要性能其实就是缓存了长连贯,用于大量 http 申请场景下的连贯复用,缩小发送申请时 TCP(TLS) 连贯建设的工夫损耗。在该 PR 中对 transport 自定义 RoundTripper 的接口,一旦 tlsConfig 对象中有 Dial 或者 Proxy 属性,则不应用 cache 中的连贯而新建连贯。

// client-go 从 cache 获取复用连贯逻辑
func tlsConfigKey(c *Config) (tlsCacheKey, bool, error) {
    ...
 
    if c.TLS.GetCert != nil || c.Dial != nil || c.Proxy != nil {
        // cannot determine equality for functions
        return tlsCacheKey{}, false, nil}
...
}
 
 
func (c *tlsTransportCache) get(config *Config) (http.RoundTripper, error) {key, canCache, err := tlsConfigKey(config)
    ...
 
    if canCache {
        // Ensure we only create a single transport for the given TLS options
        c.mu.Lock()
        defer c.mu.Unlock()
 
        // See if we already have a custom transport for this config
        if t, ok := c.transports[key]; ok {return t, nil}
    }
...
}
 
// kubelet 组件构建 client 逻辑
func buildKubeletClientConfig(ctx context.Context, s *options.KubeletServer, nodeName types.NodeName) (*restclient.Config, func(), error) {
    ...
    kubeClientConfigOverrides(s, clientConfig)
    closeAllConns, err := updateDialer(clientConfig)
    ...
    return clientConfig, closeAllConns, nil
}
 
// 为 clientConfig 设置 Dial 属性, 因而 kubelet 构建 clinet 时会新建 transport
func updateDialer(clientConfig *restclient.Config) (func(), error) {
    if clientConfig.Transport != nil || clientConfig.Dial != nil {return nil, fmt.Errorf("there is already a transport or dialer configured")
    }
    d := connrotation.NewDialer((&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext)
    clientConfig.Dial = d.DialContext
    return d.CloseAll, nil

在这里构建 closeAllConns 对象来敞开曾经处于 Dead 然而尚未 Close 的连贯,然而上一个问题通过降级 golang 版本解决了这个问题,因而咱们在本地代码分支回退了该批改中的局部代码解决了 TCP 连接数减少的问题。

最近追踪社区发现曾经合并了解决方案,通过重构 client-go 的接口实现对自定义 RESTClient 的 TCP 连贯复用。

四、无损降级操作

跨版本升级最大的危险是降级前后对象定义不统一,可能导致降级后的组件无奈解析保留在 ETCD 数据库中的对象;也可能是降级存在两头态,kubelet 还未降级而管制立体组件降级,存在上报状态异样,最坏的状况是节点上 Pod 被驱赶。这些都是降级前须要思考并通过测试验证的。

通过重复测试,上述问题在 v1.10 到 v1.17 之间除了局部废除的 API Resources 通过减少 kube-apiserver 配置形式其余状况临时不存在。为了保障降级时及时能解决未笼罩到的非凡状况,强烈建议降级前备份 ETCD 数据库,并在降级期间进行控制器和调度器,防止非预期的管制逻辑产生(实际上这里应该是进行 controller manager 中的局部控制器,不过须要批改代码编译长期 controller manager,减少了降级流程步骤和治理复杂度,因而间接停掉了全局控制器)。

除却以上代码变动和降级流程注意事项,在替换二进制降级前,就剩下比对新老版本服务的配置项的区别以保障服务胜利启动运行。比照后发现,kubelet 组件启动时不再反对 –allow-privileged 参数,须要删除。值得阐明的是,删除不代表高版本不再反对节点上运行特权容器,在 v1.15 当前通过 Pod Security Policy 资源对象来定义一组 pod 拜访的平安特色,更细粒度的做平安管控。

基于下面探讨的无损降级代码侧的批改编译二进制,再对集群组件配置文件中各个配置项批改后,就能够着手线上降级。整个降级步骤为:

  • 备份集群(二进制,配置文件,ETCD 数据库等);
  • 灰度降级局部节点,验证二进制和配置文件正确性
  • 提前散发降级的二进制文件;
  • 进行控制器、调度器和告警;
  • 更新管制立体服务配置文件,降级组件;
  • 更新计算节点服务配置文件,降级组件;
  • 为节点打 Label 触发 pod 减少 tolerations 属性;
  • 关上控制器和调度器,启用告警;
  • 集群业务点检,确认集群失常。

降级过程中倡议节点并发数不要太高,因为大量节点 kubelet 同时重启上报信息,对 kube-apiserver 后面应用的 LB 带来冲击,特地状况下可能节点心跳上报失败,节点状态会在 NotReady 与 Ready 状态间跳动。

五、总结

集群降级是困扰容器团队比拟长时间的事,在通过一系列调研和重复测试,解决了下面提到的数个关键问题后,胜利将集群从 v1.10 降级到 v1.17 版本,1000 个节点的集群分批执行降级操作,大略破费 10 分钟,后续在实现平台接口革新后将会再次降级到更高版本。

集群版本升级进步了集群的稳定性、减少了集群的扩展性,同时还丰盛了集群的能力,降级后的集群也可能更好的兼容 CNCF 我的项目。

如开篇所述,依照偏差策略频繁对大规模集群降级可能不太事实,因而跨版本升级尽管危险较大,然而也是业界宽泛采纳的形式。在 2021 年中国 KubeCon 大会上,阿里巴巴也有对于零停机跨版本升级 Kubernetes 集群的分享,次要是对于利用迁徙、流量切换等降级关键点的介绍,降级的筹备工作和降级过程绝对简单。绝对于阿里巴巴的集群跨版本替换降级计划,原地降级的形式须要在源码上做大量批改,然而降级过程会更简略,运维自动化水平更高。

因为集群版本具备很大的可选择性,本文所述的降级并不一定宽泛实用,笔者更心愿给读者提供生产集群在跨版本升级时的思路和危险点。降级过程短暂,然而降级前的筹备和调研工作是费时费力的,须要对不同版本 Kubernetes 个性和源码深刻摸索,同时对 Kubernetes 的 API 兼容性策略和公布策略领有残缺认知,这样便能在降级前做出充沛的测试,也能更从容面对降级过程中突发状况。

六、参考链接

[1]https://github.com

[2] https://kubernetes.io/version-skew-policy

[3] 具体计划参考:https://github.comstart

[4] 相似的问题: https://github.com/kubernetes

[5] https://github.com/golang/34978

[6] https://github.com/kubernetes/100376

[7] https://github.com/kubernetes/95427

[8] https://github.com/kubernetes/105490

作者:vivo 互联网服务器团队 -Shu Yingya

退出移动版