知乎容器平台演进及与大数据融合实践
在“开源与容器技术”分论坛上,来自知乎的计算平台负责人张阜兴发表了题为“知乎容器平台演进及与大数据融合实践”的精彩演讲。
本文将按照如下三个部分展开讨论:
- 知乎容器平台的演进历程
- 容器平台维护踩过的坑
- 容器与大数据的融合实践
知乎容器平台的演进历程
知乎容器平台的演进历程大致可以分成三个阶段:
- 2015 年 9 月,我们的容器平台正式在生产环境中上线应用。
- 到了 2016 年 5 月,我们已经将 90% 的业务迁移到了容器平台之上。
- 如今,除了业务容器之外,包括 HBase、Kafka 等多个基础组件都已迁至容器平台。
总的节点数达到了 2 千多个,而容器数则达到了 3 万多个。可以说知乎基本已经 All in 容器平台之上。
在容器平台整体演进的过程中,我们总结了五个要点:
- 从 Mesos 到 Kubernetes 的技术选型变化。
- 从单集群到多集群混合云的架构调整。
- 从滚动部署到部署与发布相分离的使用优化。
- 在容器使用上,从无状态到有状态,引入持久化的存储。
- 在容器网络上,从 NAT 转换成 Underlay IP 模式。
从 Mesos 到 Kubernetes
早在 2015 年,我们就已经开始在生产环境中使用容器平台了。由于那时 Kubernetes 刚被发布,且不成熟,因此我们当时选用的是 Mesos 技术方案。
Mesos 的优势如下:
- 非常稳定。
- 在架构设计中,由于大部分状态是由 Mesos 的 Slave 向 Master 去汇报,因此 Master 的负载较轻。
- 单集群所能容纳的容器规模较大(官方称:单个集群可容纳 5000 个节点)。
Mesos 的劣势:由于是单独开发一套 Framework,因此开发的成本较高。我们最开始采用的就是自研 Framework。
Kubernetes 的优势如下:
- 有强大的社区支持。
- 功能较为完善,包含有 Pods、Deployment 等概念。
- 由于功能完善,接入使用成本较低。
Kubernetes 的劣势:由于它将所有的状态都放入 Etcd 中进行存储,因此单集群的规模没有 Mesos 那么大。官方称:在将 Etcd 升级到 V3 版本之后,才能达到 5000 个节点。
在运行之初,我们使用的是一些简单的无状态容器。后来随着 Proxy、Kafka 等基础组件的引入,针对每一套组件都需要开发一套 Framework 的接入成本太高。
因此在后续的实践过程中,我们直接采用了 Kubernetes,通过资源调度层,统一进行资源的调度和管理。
从单集群到混合云
在生产环境中,我们有着如下实际需求:
- 对于 Mesos 或 Kubernetes 的任意参数变更,都需要在灰度集群上去先进行验证。只有验证通过之后,才能大规模地部署到生产环境之中。
因此,我们需要有一个线上与线下相似的环境,它们的区别仅在于测试的集群规模略小于实际运行的集群。
- 对于 Kubernetes 的单个集群来说,容量是有限的,所以需要通过实施多集群方案来对容量进行水平扩展。
- 可容忍单集群级别的故障。
- 需要采用混合云的架构。由于公有云的集群池比较大,它既能够大幅提升弹性资源池的容量,又能够抵御突发的扩容需求。
计费模式较为灵活,可按需计费,并“廉价”地实现一些临时性的活动所带来的计算资源的消耗。
在混合云架构的实现过程中,我们曾调研过 Kubernetes 的 Federation 方案,但是发现它存在如下两点不足:
- 由于尚不成熟,目前官方并不推荐运行在生产环境之中。
- 组件过多,在部署和管理上较为繁琐。
因此我们采用了自行研发的管理方案,其特点是:
- 每一组业务容器都会同时在多个集群上创建 Deployment。
- 这些 Deployment 的配置,包括:容器版本、CPU / 内存资源的配额,都是完全相同的,唯一不同的只是容器的数量,它们会根据不同的集群大小做出相应的调整。
从滚动部署到部署发布分离
我们优化了部署与发布的流程,从原先的滚动部署模式转换成了部署与发布相分离的模式。
在此,我们来区分一下部署与发布这两个概念:
- 部署,是分发代码的配置,启动诸如 Web Service 进程之类的服务实例。
- 发布,是指把一组服务实例注册到负载均衡、或者其他流量分发系统上,使其能够对外接收流量。
如上图下方的流程所示,上线的基本流程为:内网流量测试→金丝雀流量测试→生产环境全量。
那么在每一个阶段,我们都需要去观察线上业务的指标,一旦出现异常,我们就需要及时地进行回滚操作。
我们来深入分析一下滚动部署方式的优缺点。
优点如下:
- 每次先升级一部分的容器实例,然后再迭代运行。
- 能够保证在升级过程中服务的不中断,而瞬时产生的最大资源消耗是受限制的。
缺点如下:
- 在滚动部署的过程中,我们无法做到:先部署 10%→停下来→再部署 20%→再停下来→接着部署 50%,因此无法灵活地控制进度,也不能对应到前面所提到的各个发布阶段。
- 如果每个发布的阶段都采用独立的滚动部署方式,那么整体部署速度将会比较缓慢。
- 在滚动部署过程中,旧的容器实例会被立刻销毁。一旦此时线上指标出现问题,而我们的观察却有所滞后,那么所涉及到的“销毁新实例和启动旧实例”,回滚速度会比较缓慢。
针对上述问题,我们在设计上采用了“部署与发布相分离”的模式。
例如:我们在上线时,先在后台启动一组新的业务容器实例,当容器实例达到并满足了内网发布的数量要求时,我们就把它注册到内网之中,然后将旧的实例从内网流量分发的系统中“反注册掉”。
如此,新的实例就能够在内网中被发布与验证。在后台继续启动新的容器实例的同时,也避免了用户能感知到容器启动实例的时间。
我们实现了内部用户在验证完毕的时刻,下一个阶段的部署就在数秒内直接升级完毕了。
另外,由于我们旧的容器实例并非立刻被销毁掉,而是要等到在生产环境发布完成的一段安全时期之后,再按照类似于金丝雀的策略将旧的一组容器完全销毁掉。
因此,在金丝雀发布的过程中,如果线上出现任何问题,我们就能够立刻把旧的实例重新注册回来,以实现秒级回滚。
从无状态到有状态
在容器的使用模式上,我们最初部署的是一些无状态的业务 Web 容器。但是随着其他基础组件被迁移到了该容器平台之上,我们引入了持久化存储,以提供服务支持。
因此在生产环境中,我们用到了如下典型的持久化存储方式:
- HostPath。主要是配合 DaemonSet 进行使用。因为 HostPath 本身就能够保证在每一个 Node 上只启动一个 Pod 实例,因此不出现多个 Pod 同时使用同一个 HostPath,进而导致产生路径冲突的问题。
例如,我们让 Consul Agent 采用 DaemonSet 部署,那么由于 Consul Service 在注册的时候需要持久化到本地存储目录之中,因此就很适合于用这种方式去实现。
- Local。最新版的 Kubernetes 已经能够支持 Local Volume 了。优势在于:因为使用的是本地的磁盘,它相对于网络存储有着较高的 IOPS,且延时较低。
所以对于诸如 MySQL 和 Kafka 之类的有着高 IOPS、低延时的存储类应用来说,是非常合适的。
- NFS。我们通过将分布式文件系统的 Fuse 接口映射到容器之中,以保证业务能够从分布式文件系统上去读取各种数据文件。
从 NAT 到 Underlay IP
在容器网络的模式上,我们最早采用的是 iptable 所实现的 NAT 模式。该模式实现起来较为方便,不需要对现有网络予以调整。
但是它存在一定的性能损耗问题,这对于我们早期的那些业务容器来说尚可接受。
到了后期,需要将一些大流量的高速网络应用,如 Ngix、Haproxy 放到容器里时,我们对这种性能开销就无法容忍了。
因此我们结合自身机房的网络实际,在 Kubernetes 方案上选用了 Underlay IP,这种简单互联的网络模式,以保证容器的 IP 与所在物理机的 IP 是完全对等的。
由于不存在 Overlay 的封包 / 解包处理,因此性能几乎没有损耗。通过实测,我们觉得性能非常好,所以将各种大流量的分发应用都放在了此容器里。
另外,由于 IP 模式具有良好的业务对应关系,因此我们可以方便地去定位网络连接的来源和故障。
例如:倘若在 MySQL 侧发现有大量的连接产生,你将如何定位这些连接的来源呢?
按照原有的端口映射方式,你可能只能定位到是来自于某台机器,但是往往一台机器上会被部署了多个业务应用,因此也就没法精确地定位到是由哪个业务方所导致的。
如今,采用了 IP 方式之后,你就可以很方便地根据 IP 地址来判定对应的是哪个业务容器了。
同时,在具体的实践过程中,我们还给每一台机器分配了一个固定的网段(如一个 C 类网段),然后通过 Kubelet 的 CNI 插件,即 APAM,来负责每台机器 IP 地址的创建、分配和释放。
容器平台维护踩过的坑
由于我们在生产环境中大规模地使用了容器平台,可见容器平台已经成为了我们基础组件中的基础组件。
那么它一旦出现问题,将会对我们的生产环境造成重大的故障。下面给大家分享一些我们曾经在生产环境中踩过的坑。
K8S Events
一次半夜三更,我们的 API Server 突然全部无法访问了。通过调查,我们发现原因就在于 K8S Events。
众所周知,K8S Events 就像 Log 一样记录着 K8S 集群里发生的任何变更事件。
如果你没有进行额外配置的话,它会根据默认模式将这些变化全部记录到线上的同一个 Etcd 里。
同时,K8S 为这些 Events 配置了相应的过期策略 TTL,以保证在经过一段时间后,该 Events 会被自动回收掉,从而释放 Etcd 的存储空间。
该设计看似合理,但是在 Etcd 实现 TTL 时,却采用的是遍历的方式,因此实现效率比较低下。
随着集群规模的逐渐变大,集群上会出现频繁的发布、部署与变更,这就会导致 Etcd 的负载逐渐增大,直至最终造成 Etcd 无法再选举出一个 Leader,而整个 K8S 集群就此崩溃。
针对上述事故,其实 K8S 也意识到了,它为我们提供了一项可以将 Events 记录到某个单独 Etcd 集群中的配置。
不过,针对该单独的 Etcd 集群,我们是不需要进行高可用存储的,因此我们就直接使用了单节点的 Etcd,而并没有采用 Raft 方式去组建具有更好性能的集群。
另一方面,我们意识到 K8S 所给定的诸如“每隔三个小时就回收掉 Events”的 TTL 机制过于精确。
因此我们自行实现了一种过期清理策略,即:固定在每天晚低峰的时候,再将整个 Events 的 Etcd 集群清空。
K8S Eviction
除了上面提到的“由于 API Server不响应,所导致的整个 K8S 集群失控”故障,我们也碰到过“所有生产环境中的 Pods 全部给直接删掉”的坑。
在所有 API Server 都“挂掉”的极端情况下,如果你没有及时去处理,并超过了一段时间(如五分钟),那么在 K8S 1.5 之前的版本中,Controller Manager 会认为这些集群的 Node 都已与之失联,并开始进行 Eviction。
即:将这些不健康 Node 上的所有 Pods 全都 terminate 掉了。此时,如果你恢复了 API Server,那么所有的 Kubelet 会根据命令,将运行在自己上面的 Pods 全部删掉。
当然,在 K8S 1.5 之后的官方版本中,已经增加了一项“-unhealthy-zone-threshold”的配置。
例如:一旦它发现有超过 30% 的 Node 处于失联状态,就会认为该大规模故障必有其他原因,因此禁用且不再执行 Controller Manager 激进的驱逐(Eviction)策略。
Docker 容器端口泄露
另外我们也曾经在生产环境中发现“port is already allocated(端口已经被使用)”的现象。
我们检查后发现:容器虽然已经释放了端口,但是它的 Proxy 还在占用该端口。
通过进一步检查 Docker Daemon 的代码,我们得知:Docker Daemon 从分配使用某个端口,直到将该端口记录到自己的内部持久化存储中,该过程并非“原子性”。
如果 Docker Daemon 在中间阶段退出,那么它在重启恢复的内部存储过程中,会忽略掉已经分配的端口,从而导致了容器端口的泄露。
针对该问题,我们已向官方提交了带有对应解决方案的 Issue。
TCP Connection Reset
我们在 Docker NAT 网络模式下也遇到过 TCP Connection Reset 的问题。
如上图所示,该系统默认的配置对于网络数据包可能出现的乱序情况过于敏感和严格。
在我们的系统访问公网的过程中,如果网络环境较差,且出现的乱序包超过了 TCP 窗口,那么系统就会根据该配置直接将这些连接进行 Reset。因此,我们直接将其关闭掉,就可以解决此问题了。
下面介绍一些我们在容器技术与大数据应用融合方面所做过的尝试与实践。
容器与大数据的融合实践
基于 Kubernetes 的大数据融合
在大数据的场景下,我们使用两条处理路径,实现了容器平台和大数据组件的融合。如上图所示,左边绿色的是实时处理,右边灰色的是批处理。
由于出发点的不同,这些组件的设计思想有着较大的差异:
批处理,主要是运行 ETL 任务,包括数据仓库的构建、离线分析等,因此它追求的是数据吞吐率和资源利用率,而对于时延本身并不敏感。
例如:一个 Map-Reduce 任务需要运行 1~2 个小时,这是非常正常的。并且它被设计为具有高容错性。
例如:某个 Map-Reduce 任务“挂掉”了,你完全可以通过上层的 Ozzie 或 Azkaban 对整个任务(job)进行重试。只要最终完成了重启,这些对于上层业务都将是“无感”的。
实时处理,对于时延较为敏感,且对于组件的可用性也要求比较高。一旦其中的任何节点“挂掉”或重启,都会导致数据“落地”(运营指标)的延迟,以及数据展示的失败。因此它的组件要求机器的负载不能太高。
当然,我们在对大数据生产环境的维护过程中,也经常遇到如下各种问题:
由于某个业务的变更,伴随着 Kafka 写入的流量出现猛增,也会拉高 Kafka 整个集群的负载。那么如果无法恢复的话,就会导致集群的瘫痪,进而影响整个生产环境。
我们治理的思路是:按照业务方将集群予以划分和隔离。
我们按照业务方将集群划分出几十套,那么面对这么多的集群所带来的成本,又该如何统一进行配置与部署管理呢?
我们通过使用 K8S 模板,方便地实现了一键搭建出多种相同的运行环境。
由于每个业务方使用量的不同,会造成那些业务方使用量较小的应用,也需要被分配多台机器,且需要维护该集群的高可用性,从而带来了大量的资源浪费。
我们的解决方案是:运用容器来实现细粒度的资源分配和配置。例如:对于这些较小的业务,我们仅分配一个配有单 CPU、单磁盘和 8G 内存的容器,而不是一整台物理机。
基于 Kubernetes 的 Kafka 集群平台
由于 Kafka 的性能瓶颈主要存在于磁盘存储的 IOPS 上,我们通过如下的合理设计,实现了资源的分配管理。
具体方案是:以单块磁盘为资源单位,进行细粒度分配,即:用单个 Broker 去调度一块物理磁盘。
如此划分资源的好处在于:
- 它本身就能对 IOPS 和磁盘容量予以隔离。
- 对于几个 T 的硬盘而言,资源的划分粒度较细,而不像物理机那样动辄几十个 T。因此资源的利用率会有所提升,而且更适合于小型应用。
- 我们利用 Kafka 自身的 Replica 实现了数据的高可用性。不过容器与物理机在具体实现策略上有所区别:原来我们将 Broker 部署到一台机器之上,如今将 Broker 部署在一个容器里。
此时容器就变成了原来的物理机,而包含容器的物理机就相当于原来物理机所在的物理机架。
同时,我们也对控制 Replica 副本的分布策略进行了调整。我们把 Broker 的机架式感知,改成按机器予以处理,这样就避免了出现相同 topic 的副本被放在同一台机器上的 Broker 的情况。
- 采用了容器方式之后,故障的处理也变得相对简单。
由于采取的是单块硬盘的模式,因此一旦出现任何一块硬盘的故障,运维人员只需将故障盘更换下来,通过 Kafka 的 Replication 方案,从他处将数据拷贝过来便可,而不需要其他部门人员的介入。
在创建流程方面,由于当时 K8S 并不支持 LocalPV,因此我们采用了自定义的第三方资源接口,自己实现了类似于如今生产环境中 Local Volume 的机制。
其流程为:我们静态地根据磁盘的资源创建 LocalPV,而在平台创建 Kafka 集群时,动态地创建 LocalPVC。
此时调度器就可以根据其 LocalPVC 和现有的 LocalPV 资源去创建 RC,然后在对应的节点上去启动 Broker。
当然,目前 K8S 已有了类似的实现方式,大家可以直接使用了。
容器与 HBase 融合
我们的另一个实践案例是将 HBase 平台放到了容器之中。具体需求如下:
- 根据业务方去对 HBase 集群予以隔离。
- 由于 HBase 的读写都是发生在 Region Server 节点上,因此需要对 Region Server 予以限制。
- 由于存在着大、小业务方的不同,因此我们需要对资源利用率予以优化。
同时,由于数据都是被存放到 HDFS 上之后,再加载到内存之中,因此我们可以在内存里通过 Cache 进行高性能的读写操作。
可见,Region Server 的性能瓶颈取决于 CPU 与内存的开销。
因此我们将每个 HBase Cluster 都放到了 Kubernetes 的 Namespace 里,然后对 HBase Master 和 Region Server 都采用 Deployment 部署到容器中。
其中 Master 较为简单,只需通过 Replication 来实现高可用性;而 Region Server 则针对大、小业务方进行了资源限制。
例如:我们给大的业务方分配了 8 核 + 64G 内存,而给小的业务方只分配 2 核 + 16G 内存。
另外由于 ZooKeeper 和 HDFS 的负载较小,如果直接放入容器的话,则会涉及到持久化存储等复杂问题。
因此我们让所有的 HBase 集群共享相同的 ZooKeeper 和 HDFS 集群,以减少手工维护 ZooKeeper 和 HDFS 集群的开销。
容器与 Spark Streaming 融合
不同于那些需要大量地读写 HDFS 磁盘的 Map-Reduce 任务,Spark Streaming 是一种长驻型的任务。
它在调度上,不需要去优化处理大数据在网络传输中的开销,也不需要对 HDFS 数据做 Locality。
然而,由于它被用来做实时处理计算,因此对机器的负载较为敏感。如果机器的负载太高,则会影响到它处理的“落地”时延。
而大数据处理集群的本身特点就是追求高吞吐率,因此我们需要将 Spark Streaming 从大数据处理的集群中隔离出来,然后将其放到在线业务的容器之中。
在具体实践上,由于当时尚无 Spark 2.3,因此我们自己动手将 YARN 的集群放到了容器之中。
即:首先在 Docker 里启动 YARN 的 Node Manager,将其注册到 Resource Manager 之上,以组成一个在容器里运行的 YARN 集群。
然后我们再通过 Spark Submit 提交一项 Spark Streaming 任务到该集群处,让 Spark Streaming 的 Executor 能够运行在该容器之中。
如今,Spark 2.3 之后的版本,都能支持对于 Kubernetes 调度器的原生使用,大家再也不必使用 YARN,而可以直接通过 Kubernetes 使得 Executor 运行在容器里了。
大数据平台的 DevOps 管理
在大数据平台的管理方面,我们践行了 DevOps 的思想。例如:我们自行研发了一个 PaaS 平台,以方便业务方直接在该平台上自助式地对资源进行申请、创建、使用、扩容、及管理。
根据 DevOps 的思想,我们定位自己为工具开发的平台方,而非日常操作的运维方。
我们都通过该 PaaS 平台的交付,让业务方能够自行创建、重启、扩容集群。
此举的好处在于:
- 减少了沟通的成本,特别是在公司越来越大、业务方之间的沟通越来越复杂之时。
- 业务便捷且有保障,他们的任何扩容需求,都不需要联系我们,而直接可以在该平台上独立操作完成。
- 减轻了日常工作的负担,我们能够更加专注于技术本身,专注于如何将该平台的底层技术做得更好。
由于业务本身对于像 Kafka、HBase 之类系统的理解较为肤浅,因此我们需要将自己积累的对于集群的理解和经验,以一种专家的视角呈现给他们。
作为 DevOps 实践中的一环,我们在该大数据平台上提供了丰富的监控指标。
图中以 Kafka 为例,我们提供的监控指标包括 Topic Level、Broker Level,和 Host Level。
可见,我们旨在将 Kafka 集群变成一个“白盒”,一旦发生故障,业务方就能直接通过我们所定制的报警阈值,在指标界面上清晰地看到各种异常,并能及时自行处理。
总结
从实践经验来看,我们的基本思路是:
- 按业务方进行集群隔离。
- 利用 K8S 进行多集群部署和管理。
- 利用 Docker 进行资源隔离和监控。
- 利用 Docker 实现更细粒度资源分配。
- 运用 DevOps 实现运维管理。
后续,我们将尝试更多的基础设施组件,利用 K8S 去实现集群的隔离,实现更细粒度的资源分配和进程级的资源监控。
通过更好地在生产环境中实施管理和维护,以及提升资源的利用率,我们将为业务提供更稳定的 PaaS 平台服务,并最终实现数据中心的资源统一。
同时,我们通过交给 K8S 来进行调度管理,进而实现 DCOS(数据中心操作系统)。