京东 618 当天,聊聊架构发布了题为《
京东构建了全球最大的 Kubernetes 集群,没有之一
》的文章,系统回顾了京东基于 Kubernetes 的 JDOS 平台的演进历程。今天这篇文章是整个系列文章的第一篇,将会从技术角度详细剖析相关的实现,也希望能够帮助后来者学习和参考。赠人玫瑰,手有余香。
同时,也欢迎关注聊聊架构微信公众号,后续文章也将会在这里独家首发。
在 2015 年,我最早接触 Kubernetes,是从 OpenStack 的子项目 Magnum 中。Magnum 支持两种类型的编排管理平台,分别是 Kubernetes 和 Swarm。经过我们的调研对比,我们迅速选定了 Kubernetes。当时我们选择 Kubernetes,主要由于 Kubernetes 在几点上非常吸引人,给人耳目一新的感觉:
-
架构清晰简洁。 Kubernetes 的各个组件交互清晰,责任明确,易于理解。
-
list-watch 机制。采用 list 获取当前的全部信息,而后 watch 持续获取信息的变化的方式来处理各种资源,保证了信息处理的及时性。
-
声明式 API。采用声明的方式来表明资源应有的状态,而后相关组件依据资源的声明进行相关的处理和失败处置,使得整个系统更加自动化,而非如其他平台需要不断的发布命令驱使平台进行相关操作。
-
标签和选择器的设计,可以将多种资源进行关联控制,使用方式灵活而多变。
而今 Kubernetes 已然成为了标准,相比起很多团队来说,我们少走了很多弯路。
Kubernetes 的使用是非常灵活的,而如果这种灵活性如果直接交付用户使用,则会产生较大的学习成本和管理成本。因此我们对于 Kubernetes 进行了产品上的包装,也就是我们的 JDOS 2.0。
Kubernetes 主要采用命名空间(namespace)的方式,将各种资源区进行区分,这种粒度的划分在实际的生产环境中是远远不能满足需求的,生产环境需要更多的层级来规划这些资源(pods,rs,deployment,services),因此我们除了使用 namespace 还利用 labels 和 selector 的特性,将整个环境设计成系统、应用、分组多层级的结构。
这样的设计极大的方便了用户的管理使用。系统是关系紧密应用的集合。一个系统可以有不同类型的应用。应用是具有相同功能的实例集合。而分组是同一应用在不同集群中的实例。一个应用中可以根据不同需求创建分组,比如使用哪个机房的集群,亦或者是用来测试、预发还是生产。
JDOS 中 label 主要用于 pods、rs、deployment、services 等这些资源中来规划区分不同的管理层级和实现负载均衡。以一个 pod 为例,我们为其设计了如下标签:
labels:
system: jdos-sys
app: jdos-app
group: jdos-group-01
env: test
region: china
groupconfig: 1115fa3e-8a6c-409f-afea-e2f007da2b0e
name: jdos-pod-name
lb
-status: active
system/app/group 用以标识其管理层级,region/env 用以标示其所在的集群和发布的环境,group-config 用以标示其使用的分组配置版本,name 用以标识该容器的名称,lb-status 用以标识其在负载均衡中的状态。
在 Kubernetes 中创建 pod 时,我们通常会指定 requests 和 limits,以对资源加以限制。其中 requests 的设置主要会影响 kube-scheduler 的资源调度,而 limits 的值则会影响 pod 所属物理机上的 cgroup 的设置,对 pod 的资源使用做实际的限制。那么 limits 相对来说代表了一个容器可以使用的资源上限,而 requests 代表了容器需要的最小资源。
在用户申请时,用户其实对其容器的资源需求并不能准确把握,因此用户很难一次就设置一个较好的规格。但是用户对其容器需求的资源上限,可以通过压测较好的获得。
我们提供了规格配置的功能,用以供用户进行选择。用户可以通过规格配置的设置,来设置容器的资源上限,也就是 limit 的值。而 request 的值,则会根据容器运行的情况,由系统自动做出调整。
开源项目的一大特点是迭代较快。半年甚至几个月就发布新的一个大的版本。如果跟随社区进行版本的升级,不仅要耗费大量的人力物力用于迁移,而且版本之间可能并不兼容,也容易对现有的容器产生影响。Kubernetes 版本不断进行,这其中会有老的 bug 被修复,也可能会有新的 bug 被引入。这些对于社区都是很正常的。但是对于生产环境来说,又是存在着较大的风险。
根据我们使用 OpenStack 的经验,一种较好的方式是选定一个较为稳定的版本,在其上进行充分的测试,而后基于此版本进行定制研发。大版本的升级需要更加充分的测试,特别是兼容性的测试。我们最初使用的是 1.3 版本,后来升级到目前的 1.6 版本,并将其作为主力版本。已有的集群也都陆续升级到了该版本上。同时,对于社区也需保持一定的关注,对于该版本上可能存在的 bug 和之后有较好的 feature,也陆续合入到定制的版本中。
许多人觉得 Kubernetes 社区很完善,为什么还要自己定制。其实这里面存在着一些认识的误区。
首先,社区版本的确提供了相当丰富的功能。但是面对实际的生产环境时,总是会呈现一些水土不服。一个很重要的原因就是开源项目将目标应用和场景设定的过于理想化,而实际的应用需求则是五花八门,甚至千奇百怪的。这也应了那句俗语,看人挑担不吃力,事非经过不知难。因此需要对 Kubernetes 做必要的做裁剪和加固,用以适应不同的需求。在这里,我们将定制分为两大类,业务需求驱动的定制与稳定性能驱动的定制。
Kubernetes 原生面向的应用比较理想,比如可以无状态启动,具有较好的横向扩展能力。而在实际为业务服务时,就会发现棘手的多。由于一些历史或者其他原因,许多业务的部署还不能达到较高的自动化。而且由于业务本身的一些特性,因此对于应用的升级也会有着较多的要求。
满足所有的需求当然是不可能的,我们的方法是将需求进行归类、整理和分析,并以此为基础进行定制。另外,稳定性和性能也是驱动定制的非常大的动力。主要是为了解决在生产时遇到的一些性能和稳定性的问题,进行的裁剪和优化。本文将选取其中一些比较典型的定制和优化进行介绍。
线上很多用户都希望自己的应用下的 Pod 做完更新操作后,IP 依旧能保持不变,于是我们设计实现了 IP 保留池的功能来满足用户的这个需求。简单的说就是当用户更新或删除应用中的 Pod 时,把将要删除的 Pod 的 IP 放到此应用的 IP 保留池中,当此应用又有创建新 Pod 的需求时,优先从其 IP 保留池中分配 IP,只有 IP 保留池中无剩余 IP 时,才从大池中分配 IP。
IP 保留池是通过标签来与 Kubernetes 的资源来保持一致,因此 IP 保持不变功能不仅支持有状态的 StatefulSet,还可以支持 rc/rs/deployment 以及用户自己创建的单个的 pod。
当然,重复利用 IP 会带来一个潜在的问题,就是当前一个 Pod 还未完全删除的时候,后一个 Pod 的网络就不能提早使用,否则会存在 IP 的二义性。为了提升 Pod 升级的速度,我们对容器增加了快速释放 IP 策略。其主要方法是在删除的流程中进行了优化,将 CNI 接口的调用提前到了 stop 容器之前,从而大大加快了 IP 释放和新 Pod 创建的速度。
用户不仅仅满足于滚动升级,还希望能够较好的支持灰度发布。比如可以控制升级的容器个数,以方便其停留在某个版本上。我们实现了新的 group-controller,用以支持该功能。用户可以指定滚动升级后,新版本和旧版本的容器个数。group-controller 将最终停留在用户指定的状态。当然,用户也可以进行回滚,从而将新版本的容器个数逐渐减少。
group-controller 支持优先本地 rebuild 的策略。该策略的实行条件为新版本与老版本的容器的规格一致。该策略在升级时,将优先采用使用 replace 的方式更新老版本的容器,使之成为新版本的容器的方式实现升级。如果没有足够的老容器,则创建新的容器。该策略可以保证用户在更新集群时,更新更加迅速,并且资源(包括容器资源磁盘资源网络资源)不被其他用户抢走,实现了资源锁定的效果。集群资源紧张时,将推荐用户优先使用该策略进行更新,以防止升级后出现容器无法创建的问题。
随着集群规模的增长,Pod 的调度速度急剧下降,这样当有突然的大批量创建
Pod
的需求时,
Scheduler
调度便成 了系统的瓶颈,即使最终能创建成功,用户也难免抱怨时间太久。为了使用户能在每一个环节都更加顺畅的使用 JDOS,我们对 Scheduler 中各流程的耗时,做了细致的研究。
Scheduler 调度的步骤主要分为两步:predicates 和 priority。首先,我们将不需要的 predict 和 priority 函数进行了裁剪,减少调度的计算量;同时,增加计算的并发 worker 数。但是 Node 数增多后,即使增加 Goroutine 的并发,两个步骤需要的时间都还是很长。为了支持大规模的集群,我们在 Scheduler 中对 Node 做了分步长 Predicate 的优化,将满足条件的节点数控制在一定的范围内,优化后调度速度提高了 10 倍多。这种方式牺牲了一定的调度准确度,但是可以换来性能的大幅度提升。
另外,在 predict 中某个函数如果遇到失败的,则不进行其他的 predict 函数计算(我们注意到该优化方法在之后的版本有其他同学已经提上去并合并了)。会在后面的专门一个章节来分享 JDOS 的调度器实践,特别对应用和 Node 节点画像以及大胆推动了内存超卖的调度算法带来非常可观的技术收益。
在实践过程中,我们有一个深刻的体会,就是官方的 Controller 其实是一个参考实现,特别是 Node Controller 和 Taint Controller。Node 的健康状态来自于其通过 APIServer 的上报。而 Controller 仅仅依据通过
APIServer
中获取的上报状态,就进行了一系列的操作。这样的方式是很危险的。因为 Controller 的信息面非常窄,没法获取更多的信息。这就导致在中间任何一个环节出现问题,比如 Node 节点网络不稳定,APIServer 繁忙,都会出现节点状态的误判。
假设出现了交换机故障,导致大量 Kubelet 无法上报 Node 状态,Controller 进行大量的 Pod 重建,导致许多原先的健康节点调度了许多 Pod,压力增大,甚至部分健康节点被压垮为 notready,逐渐雪崩,最终导致整个集群的瘫痪。这种灾难是不可想象的,更是不可接受的。