字节跳动有状态应用云原生实践

背景介绍

说起有状态应用,要从无状态服务讲起。无状态是指应用的实例可以平滑迁移、水平扩展,实例之间没有显著差别。这类服务在云原生化过程中与 K8s(包括 Deployment)等对象配合得很好,因此成为第一批云原生受益者。

有状态应用指持有特定的数据、并依赖其提供服务的应用,大规模场景中通常具备分片(Sharding)和多副本(Replica)、数据持久化等特点。有状态应用又分为数据有状态和网络有状态。

  • 数据有状态应用有如下一些特点:

    • 数据依赖:运行过程中依赖本地数据;
    • 数据持久:升级前后数据不能丢失;
    • 依赖关系:服务实例之间存在主从、主备等依赖关系,因此每个实例有唯一的 ID 标识。
  • 网络有状态应用:指容器内业务服务要保持较长的网络 session。

网络有状态是数据有状态之外的一种形态,本文分享的内容主要围绕数据有状态应用在字节的落地展开。

有状态应用业务场景

字节内部大量应用了有状态应用。一些常见的场景有:

  • 搜索召回:实例需要加载大的模型,时间很长。如果每次升级都需要重新加载数据,对网络和存储会造成比较大的资源浪费,对业务的迭代效应也会造成很大影响,因此这些业务比较依赖本地存储。
  • 推送:有一些服务实例间有强依赖关系或者对实例有唯一 ID 需求。典型的如推送业务,每个实例负责一个分片用户的推送,对实例有唯一 ID 需求。
  • 存储服务:包括自研 KV(类 Redis 存储服务)、Druid、ES,兼顾了以上两种有状态的特点,既要依赖本地存储,同时服务间有实例依赖关系也就是唯一 ID 需求。

在云原生化之前,服务多是通过物理机部署的。物理机时代的架构复杂、运维不够灵活敏捷、物理机环境不一致、资源碎片化等问题一直没有得到很好的解决。这也正是云原生化关注的痛点,字节对云原生的理解体现在效率和成本两方面。

效率

  • 基础设施的标准化:云可以屏蔽底层系统(计算、存储、网络)的复杂性,抽象出统一的 API 接口,让用户表达对底层基础设施的需求。
  • 业务框架抽象化:业务的编排形态可以进行统一管理。
  • 规范流程自动化:让应用的更新和维护、运维变得更简单。
  • 交付形态一致化:基于镜像或容器技术让业务运行时保持统一的状态。

成本

  • 应用迭代和发布的成本:关注秒级拉起容器,给业务更大的迭代、开发空间。
  • 资源成本优化:按需分配业务所需要的资源。

当然云原生化这条路也不是一帆风顺的,在有状态应用的状态管理、基础能力增强和自动化运维等方面都存在一些挑战,在此过程中我们也解决了很多相关技术问题。

总体来说,在内部 K8s 基座上我们通过编排的优化(包括 CRD、Controller、webhook 等能力)以及在基础能力方面的增强(包括性能优化、存储能力的增强),已经承接了内部上千个有状态服务,覆盖 2w+节点,100w+ CPU Core,5w+ Pod

有状态应用的状态管理

有状态应用的状态管理可以拆分成三个问题:

  • 版本管理:类似于 K8s Deployment 或 Statefulset 的管理能力,如何进行版本升级回滚等。
  • 数据管理:在服务副本不变的情况下,依赖的外部数据需要更新。
  • 服务发现与路由:请求如何分发到对应的实例上。

这里我先举个例子。假设我们有个自研的海量 KV 服务,由于数据量比较大,单个实例无法承担这么大数据量。我们首先要把数据拆分成多个 Shard,每个 Shard 根据 Key 的哈希值取模,在一个 Shard 内部对应的 Pod 负责一部分的数据对外提供服务。同时为了保证高可用性,一个 Shard 内有多个 Pod 副本,它们之间可能会有主备关系。所以,对于这种有状态应用,可以把其全部实例展开形成一个矩阵,矩阵的每一列就是负责对外提供同一个 Shard 服务的多个 Pod 副本。

此外,有状态应用对外部的数据比较敏感,在实例副本不变的情况下,数据依然有可能发生更新。比如这个 KV 服务需要每小时加载最新的数据版本,对外提供这个版本的数据 serving。

image.png

对应刚才的例子,可以把上述所有实例形成的矩阵与有状态服务的抽象 SolarService 一一对应起来。刚才说的矩阵的一列,就是若干个 Pod 对应一个的 Shard,是对应到上图的自研增强版 Statefulset ( Statefulset Extention )上,我们通过 CRD 的方式在 Statefulset 基础上增强了原地升级(镜像版本、环境变量更新)、升级顺序的自定义、小流量/全流量的特性。

此外在服务副本不变的情况下,数据也需要进行轮换更新。数据管理 是由另外一个 CRD Budset ****完成的。Budset 和 Statefulset 是一一对应的关系,Budset Operator 会根据 Budset 定义生成若干个 CRD Bud(Bud 和 Pod 是一一对应的关系),表明这个 Pod 预期的数据状态。此外,我们在每个 Pod 中注入一个 DataSync sidecar 容器,监听自己 Pod 对应的 Bud,完成数据下载等动作并更新 Bud 的状态。

SolarService 就是以上 StatefulsetExtension 和 Budset 两者合并在一起构成的。

下面通过两个例子介绍 SolarService Controller 是怎么工作的。

滚动升级

首先根据 Shard 进行横向切分,多个 Shard 内部并发升级,Shard 的滚动粒度是可以配置的。在一个 Shard 里面我们根据 Statefalset Extention 配置的 MaxUnavailable ,并发升级一个 Shard 内的多个副本。

image.png

扩容

扩容分为两种情况:

  • 扩容某个 Shard 的副本数量:这种情况比较简单,和正常的 StatefulSet 扩容类似,调整 Replicas 字段即可。
  • 扩容 Data Shard:需要把数据分片的数量扩容。这种情况目前只支持成倍扩展,细分为几个步骤,可以看下图:

image.png

假设一开始只有全量数据,全都存在 Budset 1。Statefulset Extention 1 里的 Pod 全都加载了 Budset 1 的数据。做成倍扩展的时候,第一步是扩容 Statefulset。这时 Statefulset Extention 1、2 里面数据都是全量数据。之后再来更新 Budset,原来全量数据会被切成两个 Shard,这些过程都完成之后会再去更新服务发现,这个时候 Statefuleset Extention 2 的 Pod 才会正式承接流量。

这时其实有一个问题:在 Budset 变更的时候,两个 Statefulset Extention 的 Pod 里的数据依然是全量的。这个时候我们跟业务框架有一些配合工作,有一些业务可能自己定义了数据退场 TTL 逻辑,这时只要等待数据冷却就可以了。此外,还有些业务自定义触发数据的 Compaction,把多余的数据驱逐掉。

服务发现与路由

服务发现与路由包括两个要点。前面的例子提到过,有状态服务实例形成的矩阵中,每一列 Pod 对外提供不同 Data Shard 的数据服务。因此当一个请求来的时候,需要知道它是路由到哪个 Shard 的实例中。

image.png

图中有一个 Proxy 业务层的组件,会统一分发请求,将请求分配给对应 Statefulset Extension 的 Pod。同时,同一个 Shard 里面存在多个 Pod 副本,由于宿主机微小的性能差异或其他原因,它们的错误率也不是完全相等的。这里就可以来做第二层路由逻辑,根据一个 Statefulset Extension 内 Pod 的错误率,进一步增强服务路由/熔断逻辑。基于这种复杂的定制的逻辑,我们并没有依赖 K8s Service 来对请求进行路由,而是通过自研的服务发现基础设施注册宿主机上的 IP 和端口,通过 KV 的方式写到 Service Discovery 这个组件里面。

针对有状态服务,我们在 Service Discovery 组件里面额外注入了 ShardID、ReplicaID 和 Shard 总数等信息,方便上层框架从 KV 里读取,制定自己的熔断、路由的策略。

上图展示的一个 Proxy 组件,是一种比较常见的服务形态:即把有状态服务上面做一层封装,完成路由转发。此外,请求转发其实也可以和 service mesh 进行进一步结合,通过胖客户端的方式,上游服务自己路由每一个请求到对应的 Pod 里面,以减少一层 Proxy 的开销。

基础能力增强

我们在基础能力方面的增强主要包括调度和存储两个方面。

调度

调度能力方面,为了追求极致的性能优化,我们基于现代服务器的 NUMA 架构对 K8s 的 Scheduler 和 Kubelet 做了一些增强。

NUMA 指非均匀内存访问架构,在一个多核处理器的标准架构中,CPU 访问不同内存的延迟是不一样的,一个处理器访问本地的内存和相对远的内存有延迟的差别。此外,不光是内存有这样的特性,GPU 设备或网卡也有这样的微拓扑亲和性,通过将服务的 Pod 绑定在与 CPU 邻近的内存 NUMA node 上,可以从系统层面极致优化服务器性能

具体做法如下:

  • Kubelet 通过一个 CRD 上报本节点可用微拓扑的资源量和总量。
  • Pod 进入调度流程时,调度器在预选阶段经过自研 predicate 选择符合微拓扑的节点。
  • 调度器到了 priority 阶段,会通过自研 priority 尽可能堆叠 NUMA 资源分配,减少碎片。
  • 在 Kubelet 也就是单机层面,我们会通过自研的 CPU Manager Policy 在 Pod admit 阶段把 Pod 对应的 CPU Set memory 和 NUMA node 计算好,通过原生 CRI 接口在 kubelet sync Pod 时设置这个 Pod 可以使用的 CPU 核心,以及对应的 NUMA node。

image.png

存储

在存储能力方面,我们通过 Dynamic Provison 的方式支持了多种存储介质,同时也支持了远程块存储和本地盘存储的在线扩容。

远程块存储

远程块存储方案是基于 NBD 完成的标准 CSI 接口(在内部实现中去除了 attach 和 dettach 的过程),这种基于 NBD 的网络设备目前支持两种模式:单写单读和多读(共享只读)。图中的 External Provisioner 和另外一个在单机层面的 CSI plugin 这两个组件是自研的,其他都是原生组件。

image.png

当 Statefulset Extention 创建出对应的 PVC 和 Pod 之后,External Provisoner 会监听到 PVC create 这个事件,随之 Provision 一个 PV 与 PVC 进行绑定。之后 Pod 到了单机层面,CSI driver 里就会依次执行对应的 CSI 标准协议里面 nodeserver 的函数,包括 node stage/publish volume 等。

本地盘存储

首先补充一点关于社区的 Volume Scheduling 的背景。Volume Scheduling 是指调度器在选择存储卷的时候会对 Pod 存储资源和计算资源(CPU、Memory 等)进行统一的管理和分配,Volume Scheduling 包括三个阶段:

  • Predicate:和 Pod 选择 resources 类似,需要看 PV 的 node affinity 可以调度到哪个节点上。
  • Assume Volume:调度器会预先判断一个 PVC 应该和某个节点进行绑定,这时原生的调度器会在 PVC 上打上 annotation,表示预期这个 PVC 应该和节点绑定。
  • Bind:调度器会检查 PVC 和 PV 是否绑定成功了,如果绑定成功了就继续把 Pod Bind 到 node 上。

这也是我们本地卷存储能够做 Dynamic Provision 的关键。社区的 LPV 方案其实只有 Static Provisioning 这种形式,而内部的本地盘 LPV Dynamic Provision 的实现原理,就是在监听调度器 Assume Volume 后,动态创建 PV 的。

具体来说:

  1. 不同于 External Provisioner,LPV Provisioner 和 Driver 是打包在一个 Binary 以 Daemon 的形式部署的,每个 Pod 会通过 CRD 的方式汇报当前节点存储资源量。在 Pod 进入调度流程之后,通过自研 predicate 过滤每个节点剩余可用的存储资源,选择可行的节点。
  2. LPV Provisioner 监听调度器预分配到当前节点的 PVC,如果调度器进行一次 Assume Volume(更新 PVC annotattion),就尝试创建一个 PV 和 PVC 进行绑定。如果创建 PV 失败,就会把这个 PVC 调度器打的 annotation 清理掉,这个时候会触发调度器重新进行调度。

内部本地存储支持若干种存储介质:

  • 基于内存的 tmpfs
  • 基于 LVM 的 Logical Volume
  • 通过整盘分配的方式隔离不同业务的 I/O
  • AEP

其中 AEP 是 Intel 新推出的非易失性存储设备,性能远远胜于 SSD。AEP 有两种使用方式:当做内存使用或当做磁盘使用。在我们的场景里可以把 AEP 当做磁盘,在上面创建文件系统,通过 fsdax 方式挂载(因为 AEP 设备本身的延迟已经和内存相当,就没有必要通过操作系统 page cache 层产生额外的开销),可直接写到文件系统上去。

AEP 设备可以切分成若干个 namespace,可以理解为若干个盘。从卷的角度来,AEP 看作为一块磁盘,其分配逻辑与 LPV 分配本地磁盘的过程是差不多的。但从设备角度来讲,AEP 设备也有 NUMA 亲和性分配的需求,也就是说在分配 CPU 内存的时候,要综合考虑到设备的统一管理。K8s v1.16 推出了 Topology Manager 的特性,统一考虑了设备和 CPU 的近邻性。我们通过扩展 Topology Manager Policy 完成了 CPU、内存、设备等多个角度的统筹分配,可以极致提高设备的性能。

监控与自动化运维

监控体系

我们自研了一个基于 eBPF 的容器级别系统监控组件 SysProbe,可采集宿主机包括容器在内的 100+ Metrics。此外,自研高可用 Metrics Aggregation Server(MAS)会不断获取 SysProbe 的 Metrics,对接多个下游 sink,比如 MQ、TSDB 等,为用户提供丰富的监控面板。

自动化运维

关于自动化运维,着重提一下我们在 PDB 方面做的事情。

相比于无状态应用,有状态应用对自动化运维提出了更高的要求:

  • 有状态应的 Pod 状态恢复代价比较高;
  • K8s 不知道迁移的优先级,缺乏自动化运维的元信息。

为了解决这些问题,我们通过 Pod Eviction(驱逐) 完成主机的运维。在宿主机下线之前,通过 K8s API 驱逐掉宿主机上的 Pod。之所以没有使用 delete pod 接口的主要原因是,驱逐(Eviction)会检查 K8s 的 PDB 资源,而我们就可以通过扩展 PDB (通过 webhook 的方式拦截 Evictions 请求) 自定义驱逐策略。

上图介绍的是多机房驱逐的例子。一个 Region 里的多个 AZ 可能有各自的 K8s 集群,里边部署了等价的 Solarservice,隶属于同一个服务。在进行驱逐的时候就要同时考虑图中两个 AZ 之间的实例比例关系,这样不会导致一个 AZ 里的 Pod 都被驱逐干净了,此 AZ 里错误率飙升,但总数却又符合要求的情况发生。具体做法是通过跨 AZ 的 Meta K8s 中以 CRD 形式保存我们的自定义策略 PDB Extension,来检查驱逐是否合法。

CSI Race Condition

此外云原生实践过程中也遇到了很多 CSI 的问题。在删除 Pod 时,原生 CSI 接口中有两个相应的函数:

  1. NodeUnpublishVolume:调用 CSI 对应的 driver,以清除 Pod 对应的挂载点。
  2. NodeUnstageVolume:从节点上把卷卸载。

但是 Kubelet 删除 Pod 时,只会判断第一件事情是否完成。因此在短暂的时间窗口里,如果有运维或其他情况发生,就可能会造成 race condition。

Global Mount 挂载点残留

在这种情况下,执行完第一个函数清除了挂载点,但是卷还残留在宿主机上。这时如果对 Kubelet 执行重启,重启之后的 Kubelet 发现 Pod 已经被删除了,就只会看当前节点上还存活的 Pod 所使用的卷。那些未完成 unstage 的卷就不会被删除。我们的解决方案是针对 fs 类型的卷,在 Kubelet Volume Manager 增加残留挂载点扫描操作,清理残留挂载点。

重复打开正在卸载的卷

这种情况也是发生在 Kubelet 删除 Pod 后,NodeUnstageVolume 之前。如果一个 Pod 被删除,没有进行 unstageVolume,新的 Pod 已经创建出来,并且调度上其他节点上了,而且新的 Pod 需要挂载同一个卷,那么从存储侧发现 Kubelet 正在尝试重复挂载。例如,在前面提到的基于 NBD 的块设备,一个单读单写的模式中,新的节点开始尝试建立 NBD 连接了,旧的卷连接还保留着,那么在存储侧服务端就会发现异常并报警。

Case Study

最后介绍几个在对接过程中遇到的问题。前面介绍了 NBD 多块盘共享宿主机的内核,一旦宿主机由于 NBD 不稳定出现故障,会影响整台宿主机上所有的 Pod。因此我们也在积极尝试基于 Kata 的轻量级虚拟化方案,降低爆炸半径,把故障范围从宿主机粒度降低为 Pod 粒度。

总结

在字节跳动云原生化过程中,从无状态应用逐渐进入到有状态化应用的云原生对接,有状态应用一般有如下特点:

  • 数据有本地依赖;
  • 数据持久化,升级前后数据不能丢失;
  • 服务副本之间有关系,需要唯一 ID 进行区分。

云原生化的过程中,给有状态应用带来了效率和成本两方面收益,解决了物理机时代运维有状态应用的一些痛点:

  • 状态管理:做了编排增强、联动服务发现的工作;
  • 极致性能:通过调度增强和感知微拓扑追求极致的服务器性能;
  • 存储能力:通过丰富的存储设置提升存储能力;
  • 运维管理:提升自动化程度,完成了基于 PDB extention 自定义策略,通过驱逐的方式来运维业务。
541
1
0
0
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论