饿了么容器平台的演进,看这篇文章就够了!

619 阅读18分钟
原文链接: mp.weixin.qq.com

众所周知,微服务和容器本身结合较为密切,随着云服务的普及,它们从企业机房内部的服务器上逐渐延伸到了各种云服务的场景中。

因此面对常见的混合云服务,我们该如何用基于容器的模式来进行管理呢?

2018 年 5 月 18-19 日,由 51CTO 主办的全球软件与运维技术峰会在北京召开。

在“微服务架构设计”分论坛上,来自饿了么计算力交付部门的资深工程师李健带来了主题为《饿了么基于容器的混合云实践》的精彩演讲。

本文将按照如下四个部分展开分享:

  • 计算力交付

  • 技术选型

  • 基于 Kubernetes 的“算力外卖”

  • Kubernetes 的扩展方案

随着业务的快速增长,对应的资源规模也在迅速增长之中。而这种增长直接导致了服务器类型的增多、管理任务的加剧、和交付需求的多样性。

比如在某些场景下,我们需要在交付的服务器上,预安装某种应用;而有些时候,我们需要交付出一个“有依赖性”的服务、或是一组服务的集合。

计算力交付

尽管我们所面对的物理资源和虚拟机资源的数量是庞大的,但我们运维人员却是有限的,无法做到无限量地扩张。因此我们需要将物理资源统一抽象出来,从而输出给开发人员。

而这种标准化式的抽象,能够给企业带来两大好处:极大地减少了成本和增加了服务器管理上的运能。同时,这种统一抽象催生了我们计算力交付部门的出现。

具体而言,平时我们所交付的服务器,包括以云服务 IaaS 形式的交付,实际上与应用的交付,如 SaaS 形式是一样的。

在如今虚拟化的云时代,我们通过简单的命令输入,就能为某个文件系统准备好 CentOS 或 Ubuntu 操作系统。因此,服务器实际上也成为了一种软件服务、或是一种 App。

通过以抽象的方式对服务器和应用采取标准化,我们可以把所有的物理资源抽象出来,形成一种具有管控能力的计算力,从而达到对于系统的协调能力。可以说,一切交付的行为都属于应用。

因此我们计算力交付的关键也就落脚在了对应用的管理之上,这同时也是我们关注度的转变。

容器技术的原型始于七十年代末,但直到 2013 年 Docker 的问世,容器才成为了主流的技术。

Docker 对于容器技术的最大贡献在于:通过真正地面向应用的方式,它具有跨平台的可移植特性,将所有的服务,统一以 Image 打包的方式来进行应用的交付。

另外,它是应用的一种封装标准。在此标准之上,我们可以让应用运行在任何一个平台之中。

同时,这也进一步促进了自动化运维、AIOps 和大数据等应用的发展。因此,它在降低人力成本的同时,也提高了资源的利用率。

我们将计算力交付分为三大类:

  • 客户应用的部署。在接到服务请求后,我们会着手部署,并让服务顺利运行起来。

  • 标准服务的一键交付。例如某部门的大数据业务需要有一套环境,该环境里所包含的许多种服务,相互之间默认是相互隔离的,但同时也要保证部分服务能够相互联系。

    那么我们就需要准备好一些可复制的标准化服务模板,以保证就算在复杂的 SOA 体系中,也能实现应用服务的顺畅发现。

  • 服务器的交付。如前所述,服务器交付的标准化,正是我们计算力交付部门能力的一种体现。

技术选型

如今,可选的容器技术有很多,包括 Kubernetes、Swarm、和 AWSECS 等,其中 Kubernetes 比较热门。

因此我们在选型时,需要考虑如下因素:

  • Kubernetes 项目已成为了容器编排的事实标准。由于大家都在普遍使用,如果碰到了问题,可以到社区里去搜寻答案。这无形中带来了成本下降的优势。

  • 实际需求情况和技术的契合度。

  • 扩展性与生态发展。一些由大公司的采用作为背书的技术,一般都有着强大的后台支持和一定的前瞻性,同时也方便建立起一定的生态体系。

基于 Kubernetes 的“算力外卖”

我们开发人员平时对于应用服务类型的需求和本公司所做的点餐服务是极其类似的。上图中的绿色 Box,实际上就像我们抽象出的一个外卖盒。

其中每一种服务相互之间的调用,都是通过 DomainName 去实现的。而且这些 Box 具有复制性,我们可以依据模板来创建出多个 Box。

而每个 Box 内部在调用不同的服务时,它的域名在系统中是唯一的。因此,它们可以横跨不同的环境进行调用,从而减少了开发人员的工作量。

过去,在服务启动的时候,系统会自动生成一个网络标识,而当 IP 地址或域名发生变化时,他们不得不进行相应的配置修改。

如今,只要容器环境和相应的应用运行起来了,不同服务之间就能够根据唯一的网络标识实现相互调用。

在具体实现中,我们利用 Kubernetes 做出了一个底层的容器引擎。在其中的每个 Unit 里,都会包括有 Domain 和 Pod。而且每个 Unit 都有自己的副本,以实现负载均衡。

我们通过使用 Systemd,这种启动方式去了解各个服务之间的相互依赖关系。

它通过启动树,在 Box 中实现了同样的功能,以保证 Box 在启动的时候,能够根据我们既定的依赖描述,按照先后次序将应用启动运行起来。

虽然从技术发展的角度来说,服务之间不应该存在过多的依赖关系,但是由于我们部门是面向业务部门提供服务的,所以我们需要做的只是去推动标准化,从而去兼容开发的习惯和他们当前的项目。

如上图所示,每个 Unit 中还有一个 Hook,它可以协助服务的启/停与初始化。例如,在某些服务完成了之后,它会去调用另外一个 Pod 进行初始化。

当然,我们也涉及到对公共服务的使用。例如,Box1 和 Box2 都要通过一个公共服务来传递数据。而公共服务的唯一性网络 ID,则可能因为重启等外部因素而发生变化。

因此为了保持一致性,我们尝试着将上述提到的内部标识转换为外部标识。实际上,我们的内部标识是永远不变的,而外面关系则需要通过服务发现的机制,与内部服务动态建立关联。

这样一来,内部的服务就不必考虑配置变更、以及服务发现等问题了。上图就是我们对于外卖服务方式的一种最简单的抽象。

众所周知,Kubernetes 的服务主要依赖于 Etcd 的启动,但是 Etcd 本身在 Kubernetes 的应用场景下并不能支持大量的业务。

因此,考虑到了业务量的逐渐增大和对于服务的稳定性要求,我们需要对 Kubernetes 尽量进行拆分。

在拆分的过程中,我们将原来单个机房网络中的资源池拆分成三到四个 Kubernetes 集群。

在拆分完成之后,我们曾碰到了一个问题:由于拆分得太细,集群资源出现了利用率不均的情况:有的集群负载不够、而有的集群则负载过多。

因此,我们运用不同的 Etcd 对应不同的 Kubernetes 集群,通过调度的方式,让服务能够在集群之间飘移,从而既解决了资源效率的问题,又解决了可靠性的问题。

在上述简单的结构中,除了黄色的部分,其他都是 Kubernetes 原生的组件,包括蓝色部分里具有调度 Pod 功能的调度器(Scheduler)。

我们根据上述不同 Node 层的属性和服务信息进行判断,以确定要调用哪个集群。

由于我们采取的是两层调度的方式,因此在调到第一层的时候,我们并没有该集群的实时信息,也无法知道此集群把服务调到了何处。

对此,我们自行开发了一个 Scheduler(如黄色部分所示)。另外,我们还开发了一个类似于 Kubernetes 的 APIServer 服务。

可见,我们的目的就在于:以外围的方式去扩展 Kubernetes 集群,而不是去改动 Kubernetes 本身。

即:在 Kubernetes 的外围,增加了一层 APIServer 和 Scheduler。此举的直接好处体现在:我们节约了在框架上的开发与维护成本。

下面我们来具体看看真实的容器环境:

  • 如前所述,我们是基于 Kubernetes 的。

  • 在网络上我们使用的是阿里云的虚拟机。如果规模小的话,我们会用 Vroute 的方式;如果是自建机房的话,我们就使用 LinuxBridge 的方式。同时,我们的 Storage Driver 用的是 Overlay2。

  • 在操作系统方面,我们都使用的是 Centos7-3.10.0-693。

  • 在 Docker 的版本上,我们用的是 17.06。值得补充的是:如今社区里传闻该版本即将被停止维护了,因此我们近期会着手升级。

Registry

说到 Registry,在以前规模小的时候并不存在问题。但是如今我们的规模已大到横跨几个机房。

因此我们就需要对 Registry 传过来的数据进行同步,并“双写”到 OSS(Object Storage Service)和 Ceph(一个开源的分布式存储系统,提供对象存储、块存储和文件系统的存储机制)之中了。

由于我们在自己的物理机房中都有 Ceph,因此在下载时可以遵循就近下载的原则,而不必跨出本机房。

此举的好处就在于:降低了在机房之间传输所带来的带宽瓶颈问题。

那么我们为什么要做同步呢?由于我们的服务一旦被发布出去,它们就会自动去进行异步部署。

但是,有些机房里可能根本没有所需的镜像,那么它们在进行“拉取”操作时就会报错。

因此基于此方面的考虑,我们采用了“同步双写”模式,并增加了认证的环节。

想必大家都知道:Registry 在长时间运行后,镜像里会有越来越多的 Blob。

我们既可以自行清理,也可以根据官方提供的方式来清理,但是清理的时间一般都会非常长。

因此清理工作会带来服务的运维性中断,这对于一般公司来说是无法接受的。

对此,我们想到一种“Bucket 轮转”的方法。由于在 CI(持续集成)的过程中,我们会产生大量的镜像,因此我们将镜像以两个季度为一个保存周期进行划分,即:

  • 为第一个季度新建一个 Bucket,用来存储镜像。

  • 在第二个季度时,将新的镜像产生到第二个 Bucket 之中。

  • 等到了第三个季度,我们就将第一个 Bucket 里的镜像清理掉,以存入新的镜像。

以此类推,通过该方法,我们就能够限制镜像,不至于无限地增长下去。

我们当前的 8 台 Registry 所服务的 Docker 对象具有成千上万的体量规模。

如果 Registry 一旦出现问题,我们就需要迅速地能够定位到问题,因此,对于 Registry 采取相关的监控是非常必要的。

同时,我们也需要通过监控来实时了解系统的使用程度,以便做出必要的扩容。

在实现方式上,我们实际上只是稍微加入了一些自己写的程序,而整体上仍保留着与其他程序的解耦关系。

如图所示,我们通过上传、下载的速度,包括 Blob 的数量等监控指标,能够实时地跟踪 Registry 的运行状态。 

Docker

我们使用 Docker syslog driver 将采集到的用户进程输出到 ELK 之中进行呈现。不过在日志量过于频繁的时候,ELK 会出现毫秒级别的乱序。

这样的乱序会给业务部门带来无法进行问题排查的困难。因此,我们改动了 Docker 的相关代码,给每一条日志都人为增加一个递增的序列号。

针对一些可靠性要求较高的 Log,我们选择了 TCP 模式。但是在 TCP 模式下,如果某个日志被输出到一个服务时,其接收服务的 Socket 由于满载而“夯住”了。

那么该容器就无法继续,其 Docker ps 命令也就只能停在那儿了。 另外就算 TCP 模式开启了,但是其接收日志 TCPServer 尚未启动也是无济于事的。

由此可见,在面对一些具体使用中的问题时,我们的需求与开源项目本身所能提供的服务还是有一定距离的。任何一个开源项目在企业落地时,都不可能是完美的。

开源项目往往提供的是标准化的服务,而我们的需求却经常是根据自己的具体场景而定制化的。

我们企业的软件环境,都是根据自身环境一点、一点地长出来的,因此存在差异性是必然的。

我们不可能让开源软件为我们而改变,只能通过修改程序,让开源软件来适应我们当前的软件状态与环境,进而解决各种具体的问题。

上图是 Docker 的一些监控,它能够帮助我们发现各种问题、Bug、以及“夯住”的地方。

Init 进程

在将传统的业务进行容器化转变的过程中,我们针对容器定制并改进了 Init 进程。

该 Init 进程能够切换 Image 基础层管理的一些目录和环境变量,通过也可以替换基础配置文件的宏代换。

对于一些迁移过来的服务,其本身就曾带有配置项,它们通常使用环境变量来读取配置信息。那么这对于我们将其修改成容器的工作,就无形中增加了成本。

因此我们所提供的方法是:虽然变量被写在了配置里,但是我们能够根据容器的环境变量设置,自动去替换服务写“死”的配置。

Init 进程的这种方法对于在容器尚未启动,且不知道服务的 IP 地址时尤为有用。

对于容器的管理,我们也启动了 Command,来持续监听进程的状态,以避免僵尸进程的出现。

另外,由于我们公司有着内部的 SOA 体系,在 Docker 产生停止信号之后,需要将流量从公司的整个集群里摘除掉。

因此,我们通过截获信息,做到了在相关的流量入口予以服务的摘除动作。

同时,我们还通过传递停止信号给应用进程,以完成针对软件的各种扫尾清理操作,进而实现了对传统业务予以容器化的平滑过渡。

Kubernetes 资源管理扩展

下面通过案例来看看,我们是如何通过扩展来满足业务需求的。

我们都知道,Kubernetes 的 APIServer 是较为核心的组件,它使得所有的资源都可以被描述和配置。这与 Linux“一切皆文件”的哲学具有异曲同工之妙。

Kubernetes 集群将所有的操作,包括监控和其他资源,都通过读文件的方式来掌握服务的状态,并通过写文件的方式来修改服务的状态。

借用这种方式,我们相继开发了不同的组件和 APIServer 的接口,并以 Restful 的接口形式对服务实现了微服务化。

在实际部署中,虽然我们并没有用到 Kubernetes Proxy,但是这样反而降低了 APIServer 的负载。

当然如果需要的话,我们完全可以按需加载与部署,从而保证并增强系统整体的扩展性。

Kubernetes APIServer

上图直观地展示了 APIServer 的简单内部结构。所有的资源实际上都要被放入 Etcd 之中。

同时,每一种资源都有一个 InternalObject,它对应着 Etcd 里的一些存储类 Key/Value,这样保证了每个对象的唯一性。

当有外面请求来访问服务时,则需要通过 ExternalObject 和 InternalObject 进行转换才能实现。

例如:我们可能使用不同版本的 API 进行程序开发,而此时出现了需求变更,需要我们增加一个字段。

而实际上该字段可能已经存在于 InternalObject 之中了,那么我们只需经过一些处理便可直接生成该字段。

可见,我们真正要去实现的只是 ExternalObject,而没必要去改动 InternalObject。

如此,我们在不修改存储的基础上,尽量做到了服务与存储之间的解耦,以更好地应对外界的各种变化。

当然,这同时也是 Kubernetes APIServer 里对象服务的一个过程。

与此同时,我们也将部分 API 划归到了一个 APIGroup 之中。在同一个 APIGroup 里,不同的资源相互之间是解耦、并行的,我们能够以添加接口的方式进行资源的扩展。

而上述提到的两个对象之间的转换,以及类型的注册,都是在图中的 Scheme 里面完成的。

如果大家想自己做 APIServer 的话,谷歌官方就拥有该项目的对应方案,并提供了专门的技术支持。如果您感兴趣的话,可以参考图中下方对应的 Github 项目的网址。

另外,谷歌还提供了自动生成代码的工具。相应地,我们在开发的过程中也做了一个直接通过对象来生成数据库所有相关操作的小工具,它节约了我们大量的时间。

Kubernetes 落地

最后和大家分享一下,我们在 Kubernetes 落地过程中所遇到的一些问题:

  • Pod 重启方式。 由于 Pod 所谓的重启实际上是新建一个 Pod,并且放弃以前那个旧的,因此这对于我们的企业环境来说是无法接受的。

  • Kubernetes 无法限制容器文件系统大小。 有时候程序员在写业务代码时,由于不慎,会导致日志文件在一天之内激增 100 多 GB,从而占满了磁盘并导致报警。

    然而我们发现,在 Kubernetes 里好像并没有相关的属性去限制文件系统的大小,因此我们不得不对它进行了略微的修改。

  • DNS 改造。 您可以采用自己的 DNS,也可以使用 Kubernetes 的 DNS。不过我们在使用 Kubernetes DNS 之前,专门花时间对它进行了定制与改造。

  • Memory.kmem.slabinfo。 当我们创建容器到了一定程度时,会发现容器再怎么也无法被创建了。我们虽然尝试过释放容器,但是发现还是受到了内存已满的限制。

    经过分析,我们发现:由于我们使用的 Kernel 版本为 3,而 Kubelet 激活开启了 cgroupkernel-memory 这一测试属性特性,同时 Centos7-3.10.0-693 的 Kernel 3 对于该特性支持并不好,因此耗光了所有的内存。

作者:李健

编辑:陈峻陶家龙、孙淑娟

投稿:有投稿、寻求报道意向技术人请联络 editor@51cto.com

李健,饿了么 计算力交付部负责人,拥有多年丰富的容器系统建设经验,并推进了饿了么平台容器化;擅长将容器的敏捷性和标准化进行企业级落地,是企业内部多个基于容器的云计算项目的开发负责人。热爱开源,热衷于用开源项目解决企业问题。

精彩文章推荐:

小白也能玩转开源项目,你与大神只差这几步!

NoSQL还是SQL?这一篇讲清楚

500万日订单下的高可用拼购系统,到底暗藏了什么“独门秘籍”?