写点什么

多云缓存在知乎的探索:从 UnionStore 到 Alluxio

  • 2023-04-18
    北京
  • 本文字数:16236 字

    阅读完需:约 53 分钟

多云缓存在知乎的探索:从 UnionStore 到 Alluxio

1 背景

随着云原生技术的飞速发展,各大公有云厂商提供的云服务也变得越来越标准、可靠和易用。凭借着云原生技术,用户不仅可以在不同的云上低成本部署自己的业务,而且还可以享受到每一个云厂商在特定技术领域上的优势服务,因此多云架构备受青睐。


知乎目前采用了多云架构,主要是基于以下考虑:


服务多活:将同一个服务部署到不同的数据中心,防止单一数据中心因不可抗力不能正常提供服务,导致业务被“一锅端”;


容量扩展:一般而言,在公司的服务器规模达到万台时,单一数据中心就很难支撑业务后续的扩容需求了;


降本增效:对于同一服务,不同云厂商对同一服务的定价和运维的能力也不尽相同,我们期望能够达到比较理想的状态,在云服务满足我们需求的前提下,尽量享受到低廉的价格。


知乎目前有多个数据中心,主要的机房有以下两个:


在线机房:主要是部署知乎主站上直接面向用户的服务(如评论、回答等),这部分服务对时延敏感;


离线机房:主要是部署一些离线存储,计算相关的服务,对时延不敏感,但是对吞吐要求高。


两个数据中心之间通过专线连接,许多重要服务都依赖于专线进行跨机房调用,所以维持专线的稳定十分重要。专线流量是衡量专线是否稳定的重要指标之一,如果专线流量达到专线的额定带宽,就会导致跨专线服务之间的调用出现大量的超时或失败。


一般而言,服务的吞吐都不会特别高,还远远达不到专线带宽的流量上限,甚至连专线带宽的一半都达不到,但是在我们的算法场景中有一些比较特殊的情况:算法模型的训练在离线机房,依赖 HDFS 上的海量数据集,以及 Spark 集群和机器学习平台进行大规模分布式训练,训练的模型结果存储在 HDFS 上,一个模型甚至能达到数十上百 GB;在模型上线时,算法服务会从在线机房跨专线读取离线 HDFS 上的模型文件,而算法服务一般有数十上百个容器,这些容器在并发读取 HDFS 上的文件时,很轻易就能将专线带宽打满,从而影响其他跨专线服务。



2 多 HDFS 集群

在早期,我们解决算法模型跨机房读取的方式非常简单粗暴,部署一套新的 HDFS 集群到在线机房供算法业务使用,业务使用模型的流程如下:


  1. 产出模型:模型由 Spark 集群或机器学习平台训练产出,存储到离线 HDFS 集群;

  2. 拷贝模型:模型产出后,由离线调度任务定时拷贝需要上线的模型至在线 HDFS 集群;

  3. 读取模型:算法容器从在线 HDFS 集群读取模型上线。



多 HDFS 集群的架构虽然解决了专线流量的问题,但是依然存在一些问题:


  1. 多个 HDFS 集群不便于维护,增加运维人员负担;

  2. 拷贝脚本需要业务自己实现,每次新上线模型时,都要同步修改拷贝脚本,不便维护;

  3. 在线 HDFS 集群的文件需要业务定期手动删除以降低成本,操作风险高;

  4. 在线 HDFS 与离线 HDFS 之间文件视图不一致,用户在使用 HDFS 时,需要明确知道自己使用的是哪个 HDFS,需要保存多个地址,心智负担高;

  5. 在超高并发读取时,比如算法一次性起上百个容器来读取某个模型文件时,会导致 DataNode 负载过高,虽然可以通过增加副本解决,但是也会带来较高的存储成本。


基于以上痛点,我们自研了多云缓存服务—UnionStore。

3 自研组件 UnionStore

3.1 简介


UnionStore 顾名思义,就是联合存储的意思,它提供了标准的 S3 协议来访问 HDFS 上的数据,并且以对象存储来作为跨机房缓存。UnionStore 目前在知乎有两种使用场景:


模型上线场景:部署到在线机房,作为跨机房缓存使用:


用户在向 UnionStore 请求读取文件时,会先检查文件是否已经上传到对象存储上:


  • 如果对象存储已经存在该文件,则直接从对象存储读取文件返回给用户;

  • 如果对象存储不存在该文件,UnionStore 会先将离线 HDFS 上的文件上传到在线机房的对象存储上,再从对象存储上读取文件,返回给用户,缓存期间用户的请求是被 block 住的。这里相当于是利用对象存储做了一层跨机房缓存。


模型训练场景:部署到离线机房,作为 HDFS 代理使用,目的是为业务提供 S3 协议的 HDFS 访问方式,通过 s3fs-fuse,业务就能挂载 HDFS 到本地目录,读取训练数据进行模型的训练。


模型训练场景是我们 UnionStore 上线后的扩展场景,之前我们尝试过很多 HDFS 挂载 POSIX 的方式,但是效果都不太理想,主要体现在重试方面,而 UnionStore 正好提供了 S3 协议,s3fs-fuse 重试做的不错,所以我们最后选择了 UnionStore + s3fs-fuse 对 HDFS 进行本地目录的挂载。


其工作流程如下:



相比于之前多 HDFS 集群方案,UnionStore 的优势如下:


  1. UnionStore 提供了 S3 协议,各编程语言对 S3 协议的支持要比 HDFS 协议好,工具也相对来说也更丰富;

  2. UnionStore 会自动缓存文件,无需用户手动拷贝模型,省去了拷贝脚本的开发与维护;

  3. 提供统一的文件视图,因为元数据是实时请求 HDFS 的,所以文件视图与 HDFS 强一致;

  4. 下线了一个 HDFS 集群,文件储存能力由对象存储提供,节省了大量的服务器成本;

  5. 文件过期可依赖对象存储本身提供的能力,无需自己实现;

  6. UnionStore 以云原生的方式提供服务,部署在 k8s 上,每一个容器都是无状态节点,可以很轻易的扩缩容,在高并发的场景下,由于存储能力转移到对象存储,在对象存储性能足够的情况下,不会遇到类似 DataNode 负载过高的问题。

3.2 实现细节


UnionStore 的完整架构图如下:



在使用对象存储作为缓存时,UnionStore 有三个核心组件:


UnionStore Server:无状态节点,每一个节点都能单独提供服务,一般会部署多个,用于分摊流量;


Object Storage:对象存储,用于缓存 HDFS 上的数据,一般是在哪个云厂商就使用对应云厂商提供的对象存储,流量费用几乎可忽略;


Task Manager:任务管理器,用于存储缓存任务,可用 MySQL 和 Redis 实现。

基于这三个组件我们在 UnionStore 上实现了一系列有用的功能。


文件校验:文件被缓存至对象存储后,如果 HDFS 上的文件做了修改,UnionStore 需要检查到文件的变更,确保用户不会读取到错误的文件。这里我们在将 HDFS 文件上传至对象存储时,会将 HDFS 文件的大小,最后修改时间,checksum 等元信息存储到对象存储文件的 UserMetadata 上,用户在读取文件时,会检查这部分的信息,只有当信息校验通过时,才会返回对象存储上的文件,如果校验未通过,则会重新缓存这个文件,更新对象存储上的缓存。


读写加速:对象存储的单线程读写速度大约在 30-60MB/sec,远远小于 HDFS 的吞吐,如果不做特殊处理,是很难满足业务的读写需求的。在读方面,我们利用对象存储的 RangeRead 接口,多线程读取对象存储上的数据返回给用户,达到了与 HDFS 相同的读取速度。在写方面,我们利用对象存储的 MultiPartUpload 接口,多线程上传 HDFS 上的文件,也能达到与 HDFS 相同的写入速度。


文件仅缓存一次:因为 UnionStore Server 被设计成了无状态节点,所以它们之间是无法互相感知的。如果有多个请求同时打到不同的 Server 节点上来请求未缓存的文件,这个文件可能会被不同的 Server 多次缓存,对专线造成较大的压力。我们引入了 Task Manager 这个组件来解决这个问题:


  1. Server 节点在接受到读取未缓存文件的请求时,会先将用户的请求异步卡住,生成缓存任务,提交到 Task Manager 的等待队列中;

  2. 所有 Server 节点会不断竞争等待队列里的任务,只会有一个节点竞争成功,此时该节点会将缓存任务放入运行队列,开始执行,执行期间向任务队列汇报心跳;

  3. 每个 Server 节点会定期检查自己卡住的用户请求,来检查 Task Manager 里对应的任务,如果任务执行成功,就会唤醒用户请求,返回给用户缓存后的文件;同时,每个 Server 都会定期检查 Task Manager 里正在运行的任务,如果任务长时间没有更新心跳,则会将任务从运行队列里取出,重新放回等待队列,再次执行。


这里所有的状态变更操作都发生在 Server 节点,Task Manager 只负责存储任务信息以及提供队列的原子操作。

3.3 局限


UnionStore 项目在知乎运行了两年,早期并没有出现任何问题,但是随着算法业务规模的不断扩大,出现了以下问题:


  1. 没有元数据缓存,元数据强依赖 HDFS,在 HDFS 抖动的时候,有些需要频繁更新的模型文件会受影响,无法更新,在线服务不应强依赖离线 HDFS;

  2. 读写加速因为用到了多线程技术,对 CPU 的消耗比较大,在早期业务量不大的时候,UnionStore 只需要几百 Core 就能支撑整个公司的算法团队读取数据,但是随着业务量不断上涨,需要的 CPU 数也涨到了上千;

  3. 对象存储能力有上限,单文件上千并发读取时,也会面临性能瓶颈;

  4. UnionStore 只做到了缓存,而没有做到高性能缓存,业务方的大模型往往需要读取十多分钟,极大影响模型的更新速度,制约业务的发展;

  5. 无法做到边缓存边返回文件,导致第一次读取文件的时间过长。


另外还有一个关键点,机器学习平台为保证多活,也采用了多云架构,支持了多机房部署,在读取训练数据时,走的是 UnionStore 对 HDFS 的直接代理,没走缓存流程,因为训练数据大部分都是小文件,而且数量特别巨大,小文件都过一遍缓存会导致缓存任务在任务队列里排队时间过长,很难保证读取的时效性,因此我们直接代理了 HDFS。按照这种使用方式,专线带宽在训练数据规模扩大时,依然会成为瓶颈。


以上痛点使我们面临两个选择:一是继续迭代 UnionStore,让 UnionStore 具备高性能缓存能力,比如支持本地 SSD 以及内存缓存;二是寻找合适的开源解决方案,完美替代 UnionStore 的使用场景。基于人力资源的宝贵,我们选择了其二。

4 利用 Alluxio 替代 UnionStore

4.1 调研


我们调研了业内主流的文件系统,发现 Alluxio 比较适合我们的场景,原因有以下几点:


  1. 透明缓存:相较于其他文件系统,Alluxio 可仅作为缓存使用,用于编排数据,业务方无需将模型文件写入到其他的文件系统,只需要维持现状,写入 HDFS 即可;

  2. 元数据与数据缓存:Alluxio 支持自定义缓存元数据与数据,这样在读取已缓存文件时,可完全不受 HDFS 影响;目前我们 UnionStore 的 QPS 大约在 20K-30K,缓存元数据可极大降低 NameNode 的压力,反哺离线场景;

  3. 丰富的 UFS 支持:支持除 HDFS 外的多种 UFS,比如对象存储,对我们的数据湖场景也提供了强有力的支撑;

  4. 即席查询加速:知乎 Adhoc 引擎采用的是 Spark 与 Presto,Alluxio 对这两个引擎都有较好的支持;

  5. 访问接口丰富:Alluxio 提供的 S3 Proxy 组件完全兼容 S3 协议,我们的模型上线场景从 UnionStore 迁移至 Alluxio 付出的成本几乎可忽略不计;另外 Alluxio 提供的 Alluxio fuse 具备本地元数据缓存与数据缓存,比业务之前使用的 S3 fuse 具有更好的性能,正好能满足我们的模型训练场景。

  6. 社区活跃:Alluxio 社区十分活跃,在我们调研期间交流群基本上都会有热心的网友及时答复, issue 很少有超过半天不回复的情况。


对 Alluxio 的调研让我们非常惊喜,它不仅满足了我们的需求,还给我们“额外赠送”了不少附加功能。

我们在内部对 Alluxio 进行了测试,以 100G 的文件做单线程读取测试,多次测试取平均值,结果如下:


组件 调优项 缓存速率 未命中缓存时读取速率 命中缓存时读取速率 是否支持边缓存边读取 100GB 文件未命中缓存读取时间 100GB 文件命中缓存读取时间
HDFS 默认配置 HDD 盘 - 300MB/sec - - - -
UnionStore 10 个加速线程 300MB/sec 0MB/sec 250MB/sec 12.5min 6.8min
Alluxio 默认配置 NVME 盘 280MB/sec 280MB/sec 1600MB/sec 6min 1min


其中 HDFS 因为涉及到 OS 层面的缓存,波动是最大的,从 200MB/sec - 500MB/sec 都有,而 UnionStore 与 Alluxio 在命中缓存时表现十分稳定。

4.2 集群规划


Alluxio 在我们的规划中是每个机房部署一套,利用高性能 NVME 磁盘对 HDFS 和对象存储上的数据进行缓存,为业务提供海量数据的加速服务。


依据业务的使用场景,我们将 Alluxio 集群分为两类。


模型上线加速集群:Alluxio 集群缓存模型本身,利用 S3 Proxy 对外提供只读服务,加速模型的上线;


模型训练加速集群:Alluxio 集群缓存模型训练数据,利用 Alluxio fuse 对 HDFS 上数据与元数据再做本地缓存,加速模型的训练;产出的模型直接通过 Alluxio fuse 写入 HDFS 进行持久化存储。



4.3 模型上线场景适配

4.3.1 场景特点

我们的模型上线场景有以下特点:


  1. 用户利用 S3 协议读取模型文件;

  2. 用户将模型数据写入到 HDFS 上后,需要立即读取,数据产出与读取的间隔在秒级,几乎无法提前预热,存在缓存穿透的问题;

  3. 一份模型文件将由上百甚至上千个容器同时读取,流量放大明显,最大的单个模型读取时,峰值流量甚至能达到 1Tb/sec;

  4. 模型文件只会在短时间内使用,高并发读取完毕后可视为过期;

  5. 数万容器分散在上千个 K8s 节点上,单个容器可用资源量较少。


针对模型上线场景,我们选择了 S3 Proxy 来为业务提供缓存服务,不使用 Alluxio Client 以及 Alluxio fuse 主要是基于以下考虑:


  1. 用户原本就是利用 S3 协议读取文件,换成 S3 Proxy 几乎无成本;

  2. 业务方使用的语言有 Python,Golang,Java 三种,Alluxio Client 是基于 Java 实现的,其他语言使用起来比较麻烦;

  3. 受限于单个容器的资源限制,不适合在容器内利用 CSI 等方式启动 Alluxio fuse,因为 fuse 的性能比较依赖磁盘和内存的缓存。

4.3.2 集群部署


首先是集群的部署方式,在这个场景下,我们的 Alluxio 集群采取了“大集群轻客户端”的方式来部署,也就是提供足够数量的 Worker 与 S3 Proxy 来支撑业务以 S3 协议发起的高并发请求,架构图如下:



我们的集群版本是 2.9.2,在这个版本,S3 Proxy 有 v1 v2 两种实现,可通过配置 alluxio.proxy.s3.v2.version.enabled 进行切换。v2 版本有一个很重要的功能,就是将 IO 操作与元数据操作进行了分类,分别交给不同的线程池去处理。这样做的好处是,让元数据操作能够快速执行,不被 IO 线程卡住,因为一般情况下,元数据请求的 QPS 远远大于读写文件的 QPS。这个功能对我们非常有用,我们 UnionStore 的 QPS 在 25K 左右,其中 90% 的操作都是元数据访问。


整个 Alluxio 集群我们采取了裸金属机部署,Alluxio 也提供了 k8s 的部署方式,但是在我们的权衡之下,还是选择了裸金属机部署,原因如下:


  1. 从我们的测试结果来看,Alluxio Worker 在”火力全开“的情况下是可以轻易打满双万兆网卡的,这个时候网卡是瓶颈;如果选择 k8s 部署,当有容器与 Alluxio Worker 调度到同一台 k8s 的节点时,该容器容易受到 Alluxio Worker 的影响,无法抢占到足够的网卡资源;

  2. Alluxio Worker 依赖高性能磁盘做本地缓存,与其他服务混布容易收到其他进程的磁盘 IO 影响,无法达到最佳性能;

  3. 因为 Alluxio Worker 强依赖网卡,磁盘等物理资源,这些资源不适合与其他服务共享。强行以 k8s 部署,可能就是一个 k8s 节点启一个 Alluxio Worker 的 DaemonSet,这其实也没必要用 k8s 部署,因为基于我们过往的经验,容器内搞存储,可能会遇到各类奇奇怪怪的问题,这些问题解决起来比较浪费时间,影响正常的上线进度。


我们除了按照社区文档的推荐将 Master 与 Job Master,Worker 与 Job Worker 部署到同一台机器上,还另外将 S3 Proxy 与 Worker 进行了混布。S3 Proxy 在用户看起来虽然是服务端,但是对 Alluxio 集群来说它还是客户端,而 Alluxio 对于客户端有一个非常重要的优化:


当 Client 与 Worker 在同一节点时,就可以使用短路读的功能,在短路读开启的情况下,Client 将不再利用网络请求调用 Worker 上的 RPC 接口读取数据,而是直接读本地磁盘上的数据,能够极大节省网卡资源。通过 S3 Porxy 访问 Alluxio 时,流量主要分为以下几个部分:


  1. 文件未缓存至 Alluxio:Worker 从 UFS 读取数据,任一 Worker 只要缓存了 UFS 的文件,这部分流量将不存在;

  2. 文件在远端 Worker 缓存:本地 Worker 从其他 Worker 读取数据缓存到本地,S3 Proxy 暂时从远端 Worker 读取,本地 Worker 缓存完毕后这部分流量将不存在;

  3. 文件在本地 Worker 缓存:S3 Proxy 从本地 Worker 读取的流量,这部分流量在开启短路读后将不存在;

  4. 业务方从 S3 Proxy 读取的流量,这部分流量无法避免。


其中 1,2 中的流量远小于 3,4 中的流量,短路读能够将 3 的流量省下,节省约 30%-50% 的流量。



其次是集群的部署规模,在模型读取这个场景,尽管每天的读取总量可达数 PB,但是因为模型文件很快就会过期,所以 Worker 的容量并不需要很大,Worker 网卡的总带宽能够支持读取流量即可。Worker 的数量可按照 流量峰值/(2/3*网卡带宽) 来计算,这里网卡需要预留 1/3 的 buffer 来供 Worker 读取 UFS 以及 Worker 互相同步数据使用。


最后是 Alluxio Master 的 HA 方式,我们选择了 Raft,在我们的测试过程中,在上亿的元数据以及数百 GB 堆的情况下,Master 主从切换基本上在 10 秒以内完成,效率极高,业务近乎无感。

4.3.3 上线与调优


我们的上线过程也是我们调优的一个过程。


在初期,我们只将一个小模型的读取请求从 UnionStore 切换到了 Alluxio S3 Proxy,效果如下:



里面的每一条线段都代表着一个模型的读取请求,线段的长短代表读取数据的花费的时间。


其中阶段一是我们内部的 UnionStore 服务,阶段二是我们直接切换到 S3 Proxy 时的状态,可以很明显的看到换成 S3 Proxy 了以后,模型读取的平均速度有所上升,但是出现了尖刺,也就是偶尔有请求读取的很慢。问题出在模型读取时,总是冷读,也就是模型数据没有经过预热,在文件未预热的情况下,从 Alluxio 读数据最多只能达到与 HDFS 相同的速度,不能充分发挥缓存的能力。而且通过测试,我们发现 Alluxio 在并发请求同一个没有经过预热的文件时,性能会下降的十分严重,甚至达不到直接读 HDFS 的速度。因此我们需要想办法预热文件。


预热文件的手段一般有以下两种:


  1. 用户在写完文件后,手动调用 Alluxio load 命令,提前将数据缓存,确保在读取的时候,需要的文件已经被缓存了;

  2. 根据 HDFS 的 audit log 或者利用 HDFS 的 inotify 来订阅文件的变更,只要发现算法目录下有文件变动就加载缓存进 Alluxio。


方式 1 的问题在于需要用户深度参与,有额外的心智负担和开发成本,其次是用户调用 load 命令不可控,如果对一个超大目录进行 load,将会使所有缓存失效。


方式 2 也需要用户提供监听的路径,如果路径是文件比较方便,只需要监听 close 请求即可,但是路径是目录的情况下,涉及到临时文件,rename 等,十分复杂;每次用户新增模型时,都需要我们把路径新加入监控,有额外的沟通成本;另外由于我们这个场景,数据产出与读取的间隔在秒级,监控文件变更链路太长,可能出现一些延迟,从而导致预热方案失效。


基于以上缺点,我们自己设计了一套缓存策略:


冷读文件慢的本质在于通过 Alluxio 读取未缓存文件时,读到哪一个 block 才会去缓存这个 block,没有做到并发缓存 block。因此我们在 S3 Proxy 上添加了一个逻辑,在读取文件时,会将文件按 block 进行分段生成 cache block 任务,平均提交到每一个 Worker 来异步缓存。这样的好处是,客户端在读取前面少量几个未缓存的 block 后,后面的 block 都是已经缓存完毕的,读取速度十分快。此外,由于提前缓存了 block,缓存穿透的问题也能有所缓解,HDFS 流量能够下降 2 倍以上。



此缓存策略需要注意以下几点:


  1. 缓存 block 需要异步,并且所有的异常都要处理掉,不要影响正常的读取请求;

  2. 缓存 block 时,最好将 block id 与 Worker id 以某种方式(如 hash)进行绑定,这样能保证在对同一个文件进行并发请求时,对某一个 block 的缓存请求都只打到同一个 Worker 上,避免不同的 Worker 从 UFS 读取同一个 block,放大 UFS 流量;

  3. S3 Proxy 需要对提交的 cache block 任务计数,避免提交过多任务影响 Worker 正常的缓存逻辑,最好不要超过配置 alluxio.worker.network.async.cache.manager.threads.max 的一半,这个配置代表 Worker 处理异步缓存请求的最大线程数,默认值是两倍的 CPU 数;

  4. S3 Proxy 需要对已经提交缓存的 block 进行去重,防止在高并发读取同一个文件的情况下,多次提交同一个 block 的缓存请求到 Worker,占满 Worker 的异步缓存队列。Worker 的异步缓存队列大小由配置 alluxio.worker.network.async.cache.manager.queue.max 控制,默认是 512。去重比较推荐使用 bitmap 按照 block id 做;

  5. 在 Worker 异步缓存队列没满的情况下,异步缓存的线程数将永远保持在 4 个,需要修改代码提高 Worker 异步缓存的最小线程数,防止效率过低,可参考 #17179


在上线了这个缓存策略后,我们进入了阶段三,可以看到,阶段三的尖刺全部消失了,整体的速度略微有所提升。因为我们是对小文件(1GB 左右)进行的缓存,所以提升效果不明显。经过我们测试,此缓存策略能够提升读取大文件(10GB 及以上)3-5 倍的速度,而且文件越大越明显。


解决了缓存的问题后,我们继续切换更多模型的读取到 S3 Proxy,效果如下:



本次我们另外切换了三个模型的读取请求到 S3 Proxy,其中橙色模型是我们之前已经切换到 S3 Proxy 的模型,本次新增的模型最大达到了 10G,读取流量峰值为 500Gb/sec。


这次我们同样分为三个阶段,阶段一是橙色模型已经切换到 S3 Proxy,其他模型都使用 UnionStore,因为橙色模型的数据量小,并且还用了 Alluxio 加速,所以它的读取速度能够比其他模型的读取速度快上数十倍。


阶段二是我们将其他模型也切换至 S3 Proxy 后的状态,可以看到其他模型读取速度明显变快了,但是橙色模型读取速度受到其他模型的影响反而变慢了,这是一个非常奇怪的现象。最后我们定位到是元数据缓存没有开启的原因,在元数据缓存没有开启的情况下,Alluxio 会将客户端的每一次请求都打到 HDFS 上,加上 S3 Proxy 也会频繁对一些系统目录做检查,这样就导致 Master 同步元数据的负担非常重,性能甚至能下降上千倍。


在这个场景,我们本来是不打算开启元数据缓存的,主要是担心业务对已缓存修改文件进行修改,导致读取到错误的文件,从而影响模型的上线。但是从实践的结果来看,元数据缓存必须要开启来提升 Master 的性能。


与业务方沟通过后,我们制定了元数据一致性的规范:


  1. 元数据缓存设置为 1min;

  2. 新增文件尽量写入新目录,以版本号的方式管理,不要在旧文件上修改或覆盖;

  3. 对于历史遗留,需要覆盖新文件的任务,以及对元数据一致性要求比较高的任务,我们在 S3 Proxy 上提供特殊命令进行元数据的同步,数据更新后,业务方自己调用命令同步元数据。


在开启元数据缓存过后,我们来到了图中的阶段三,可以很明显的看到所有模型数据的读取速度有了飞跃式提升,相比于最开始没有使用 S3 Proxy 读取速度提升了 10+ 倍。这里需要注意的是,10+ 倍是指在 Alluxio 机器数量足够多,网卡足够充足的情况下能达到的效果,我们在实际使用过程中,用了 UnionStore 一半的资源达到了与 UnionStore 同样的效果。

4.3.4 S3 Proxy 限速


我们在模型读取场景上线 Alluxio 的本意是为了提高业务方读取模型的速度,但是因为通过 Alluxio 读数据实在是太快了,反而需要我们给它限速,非常的具有戏剧性。不限速将会面临一个很严重的问题:算法容器在读取模型时,如果文件较大,不仅会影响 S3 Proxy 所在物理机的网卡,也会导致该容器所在的 k8s 宿主机的网卡长时间处于被占满状态,从而影响这一节点上的其他容器。



目前限速的实现主要有以下几种方案:


Worker 端限速:优点是对所有客户端生效,缺点是对同节点客户端短路读不生效,在我们的场景,S3 Proxy 会走短路读,不能满足我们的需求。


客户端限速:优点是能够同时对 Alluxio fuse 和 S3 Proxy 生效,缺点是客户端可以自己改配置绕过限制,同时服务端版本和客户端版本可能存在不一致的情况,导致限速失效。


S3 Proxy 限速:只能对 S3 Proxy 生效,对其他的客户端以及 Worker 都不能生效。


因为我们当前的目标就是替代 UnionStore,业务方访问 Alluxio 的入口只有 S3 Proxy,因此客户端限速和 S3 Proxy 限速都能满足我们的需求,但是从实现的难易角度上考虑,我们最后选择了从 S3 Proxy 层面限速。


我们支持了两种限速策略,一方面是 S3 Proxy 进程全局限速,用于保护 Worker 网卡不被打满;另一方面是单连接限速,用于保护业务容器所在 k8s 节点。限速策略我们已经贡献给了社区,如果感兴趣可以参考:#16866

4.4 模型训练场景适配

4.4.1 场景特点


我们的模型训练场景有以下特点:


  1. 因为大部分开源的模型训练框架对本地目录支持最好,所以我们最好是为业务提供 POSIX 访问的方式;

  2. 模型训练时,主要瓶颈在 GPU,而内存,磁盘,网卡,CPU 等物理资源比较充足;

  3. GPU 机器不会运行训练任务以外的任务,不存在服务混布的情况;

  4. 数据以快照形式管理,对元数据没有一致性要求,但是需要有手段能够感知 HDFS 上产生的新快照。

针对模型训练场景,毫无疑问我们应该选择 Alluxio fuse 来提供缓存服务: 1. Alluxio fuse 提供了 POSIX 访问方式; 2. Alluxio fuse 能够利用内存和磁盘做元数据缓存与数据缓存,能够最大程度利用 GPU 机器上闲置的物理资源。

4.4.2 性能测试


在上线前,我们对 fuse 用 fio 进行了压测。

Alluxio fuse 配置:


变量
容器镜像社区版本 alluxio/alluxio:2.9.0
容器总内存40GB
元数据缓存60 秒内核元数据缓存
Xmx12GB
DirectoryMemory8GB
垃圾回收器G1
JDK 版本OpenJDK 11.0.18
内核数据缓存auto_cache


测试结果如下:


测试项结果
本地磁盘顺序读1800MB/sec
本地磁盘随机读1000MB/sec
fuse 1G 文件顺序读1700MB/sec
fuse 10G 文件顺序读1700MB/sec
fuse 100G 文件顺序读900MB/sec
fuse 随机读450MB/sec


以上结果均针对数据已缓存至 fuse 本地磁盘的情况,1G 文件与 10G 文件读取时,速度是 100G 文件的两倍,这是因为容器的内存为 40G,有充足的 pagecache 来缓存 1G 与 10G 的文件,但是 100G 的文件没有充足的 pagecache,所以性能会下降,但是也能达到不错的速度,整体行为符合预期。

4.4.3 集群部署


Alluxio fuse 的部署方式我们选择了以 DaemonSet 部署,通过 host path 进行映射,没有选择 CSI 部署,主要是基于以下考虑:


  1. Alluxio fuse 高性能的核心在于数据缓存与元数据缓存,数据缓存需要消耗大量的磁盘,元数据缓存需要消耗大量的内存,如果以 CSI 的形式进行部署,每个容器只能分配到少量的磁盘与内存给 Alluxio fuse 进程;

  2. 在模型进行训练的时候,读取的训练数据重复程度很高,如果每个容器起一个 fuse 进程,可能会导致同一机器缓存多份相同的文件,浪费磁盘;

  3. GPU 机器只跑训练任务,所以 fuse 进程可以 long running,无需考虑资源释放的问题;

  4. host path 的部署方式可以很容易实现挂载点恢复。


这里对挂载点恢复做一个说明,一般情况下,如果 Alluxio fuse 容器因为各种异常挂了,哪怕 fuse 进程重新启动起来,将目录重新进行挂载,但是在业务容器里的挂载点也是坏掉的,业务也读不了数据;但是如果做了挂载点恢复,Alluxio fuse 容器启动起来以后,业务容器里的挂载点就会自动恢复,此时如果业务自身有重试逻辑,就能不受影响。Alluxio fuse 进程的挂载点恢复包括两个部分,一部分是挂载点本身的恢复,也就是 fuse 进程每次重启后要挂到同一个挂载点;另一部分是客户端缓存数据的恢复,也就是 fuse 进程每次重启后缓存数据目录要与原先保持一致,避免从 Alluxio 集群重复拉取已经缓存到本地的文件。挂载点恢复在 CSI 里需要做一些额外的开发来支持,但是如果是以 host path 的方式映射,只要在业务容器里配置了 HostToContainer 即可,不需要额外的开发。


我们 fuse 进程的部署架构图如下:



在这个场景下,我们的 Alluxio 集群采取了“小集群重客户端”的方式来部署,即提供一个规模较小的 Alluxio 集群,只用来做数据的分发,性能和缓存由 Alluxio fuse 自身保证。Alluxio 集群只需要提供高配置的 Master 和少量的 Worker 即可,集群整体的部署架构如下:



按照这种部署模式,3 台 Raft HA 的 Master 与 少量 Worker 就可支撑起 fuse 进程大规模的部署。

4.4.4 Alluxio fuse 调优


首先是元数据缓存,Alluxio fuse 可开启元数据缓存,这里容易与 Master 对 UFS 元数据的缓存弄混淆,我们简单做个说明:


  1. Alluxio Master 会缓存 UFS 的元数据,决定是否更新元数据由客户端配置的 alluxio.user.file.metadata.sync.interval 决定。假如这个值设置为 10min,客户端在请求 Master 时,如果 Master 在之前的 10min 内已经更新过元数据,则 Master 会直接返回缓存的元数据,而不会请求 UFS 拿最新的元数据;否则将会返回 UFS 的最新的元数据,并且更新 Master 的元数据;

  2. 用户在用 Alluxio fuse 访问 Alluxio 时,会先看内核缓存元数据是否失效(配置为 fuse 启动参数 attr_timeout,entry_timeout),再看用户空间元数据缓存是否失效(配置为 alluxio.user.metadata.cache.expiration.time),再看 Master 缓存是否失效(配置为alluxio.user.file.metadata.sync.interval),只要有一层没失效,都不能拿到 HDFS 的最新元数据。


所以建议在开启 fuse 元数据缓存后,设置 alluxio.user.file.metadata.sync.interval=0 以便每次 fuse 在本地元数据缓存失效后,都能拿到 UFS 最新的元数据。

另外 fuse 的元数据缓存可以通过一些特殊的命令来更新(需要配置 alluxio.fuse.special.command.enabled=true):


元数据缓存可通过以下命令进行强制刷新,假设我们的 mount 目录为 /mnt/alluxio,利用以下命令可以刷新所有元数据缓存:


ls -l /mnt/alluxio/.alluxiocli.metadatacache.dropAll
复制代码


利用以下命令可以刷新指定目录(这里以 /user/test 为例)的元数据缓存:


ls -l /mnt/alluxio/user/test/.alluxiocli.metadatacache.drop
复制代码


在代码中(以 python 为例),可以这样清理元数据:


import osprint(os.path.getsize("/mnt/alluxio/user/test/.alluxiocli.metadatacache.drop"))
复制代码


但是需要注意,内核元数据缓存是清理不掉的,所以这里推荐内核元数据缓存设置一个较小的值,比如一分钟,用户空间元数据缓存设置一个较大的值,比如一小时,在对元数据有一致性要求的时候,手动刷新用户空间元数据缓存后,等待内核元数据缓存失效即可。


元数据缓存和数据缓存同时开启的情况下,清理元数据缓存的命令在使用上会有一些问题,我们进行了修复,参考:#17029


其次就是数据缓存,我们的 Alluxio fuse 因为是用 DeamonSet 的方式进行的部署,所以数据缓存我们基本上可以用满整台物理机的磁盘,极大降低了 Alluxio Worker 的流量。


最后就是资源配置,因为每个机器只起一个 fuse 进程,所以可以适当给 fuse 进程多分配给一些 CPU 和内存,CPU 可以适当超卖,以处理突然激增的请求。


内存方面,首先是堆内存的配置,如果开启了用户空间元数据缓存,按照 缓存路径量数 * 2KB * 2 来设置 Xmx。另外 DirectoryMemory 可设置大一点,一般 8G 够用。如果开启了内核数据缓存,还需要给容器留存一些空间来存放 pagecache,因为 kubernetes 计算容器内存使用量会包含 pagecache 的使用量。关于 pagecache 是否会引起容器 OOM,我们查找了很多文档都没有得到准确的结论,但是我们用如下配置进行了压测,发现容器并不会 OOM,并且 fuse 的表现十分稳定:


变量
数据缓存大小1.3TB
元数据缓存60 秒内核元数据缓存
Xmx12GB
DirectoryMemory8GB
垃圾回收器G1
Java 版本OpenJDK 11.0.18
压测 QPS8000
压测流量900MB/sec



4.4.5 上线结果


我们的算法模型训练切换至 Alluxio fuse 后,模型训练的效率达到了本地磁盘 90% 的性能,相比于原来 UnionStore 的 s3fs-fuse 的挂载,性能提升了约 250%。

5 S3 Proxy 在大数据场景的应用


回顾模型上线场景,我们不仅为算法业务提供了模型加速读取的能力,还沉淀下来了一个与对象存储协议兼容,但是下载速度远超普通对象存储的组件,那就是 Alluxio S3 Proxy,所以我们现在完全可以做一些”拿着锤子找钉子“的一些事情。


这里介绍一下我们大数据组件的发布与上线流程,流程图大致如下:



下面用文字简单描述:


  1. 开发者修改代码以后,将代码合入对应组件的 master 分支,此时 Gitlab 将调用 CI 的 Web Hook,CI 会运行对应组件的打包编译逻辑;

  2. 组件打包成二进制包后,CI 会向 Kosmos 注册二进制包的元信息,以及将二进制包上传至 Kosmos,Kosmos 在接受到二进制包后,会上传至对象存储;

  3. 开发者在大数据运维平台选择要上线的组件,以及组件的版本,大数据组件会自动在生产环境的服务器上运行部署逻辑;

  4. 在部署逻辑运行的过程中,会向 Kosmos 请求下载组件的二进制包,Kosmos 将会直接返回对象存储的只读链接,供生产环境服务器进行下载。


其中 Kosmos 是我们自研的包管理系统,其诞生的背景可以参考:Flink 实时计算平台在知乎的演进;另外我们的大数据运维平台也有相应的专栏,感兴趣可以查看:Ansible 在知乎大数据的实践


一方面,这个流程最大的问题在于大规模上线节点时,从对象存储下载二进制包速度过慢。比如我们要对所有的 DataNode 节点以及 NodeManager 节点做变更时,每台机器都需要下载数百 MB 甚至上 GB 的二进制包,按照对象存储 20-30MB/sec 的下载速度,每台机器需要花费约 30 秒的时间来进行下载,占了整个部署逻辑约 2/3 的时间。如果按照 10000 台 DataNode 来计算,每两台滚动重启(保证三副本一个副本可用),仅仅花费在下载二进制包上的时间就达到了 40+ 小时,及其影响部署效率。


另一方面,对象存储在不同的机房使用时,也会面临外网流量的问题,造成比较高的费用;所以这里对 Kosmos 做了多机房改造,支持向不同的对象存储上传二进制包,用户在请求 Kosmos 时,需要在请求上加上机房参数,以便从 Kosmos 获取同机房对象存储的下载链接,如果用户选错了机房,依然会使用外网流量。


上述问题其实可以通过改造大数据运维平台来解决,比如将下载与部署逻辑解耦,在节点上以较高的并发下载二进制包后再进行滚动部署,但是改造起来比较费时费力,更何况我们现在有了更高效下载文件的方式— Alluxio S3 Proxy,所以更没有动力来做这个改造了。


我们将 Kosmos 的对象存储挂载到 Alluxio 上,Kosmos 在被请求下载时,返回 Alluxio S3 Proxy 的只读链接,让用户从 S3 Proxy 读取数据,改造后的流程图如下:



经过我们的改造,Kosmos 几乎所有的下载请求都能在 1-2 秒内完成,相比于从对象存储下载,快了 90% 以上,下图是我们的生产环境中,Kosmos 分别对接对象存储与 Alluxio 的下载速度对比,其中 Alluxio S3 Proxy 被我们限速至 600MB/sec:



此外 Alluxio 我们也进行了多机房部署,支持了 Kosmos 的多机房方案,哪怕是用户选错了机房,也不会造成额外的外网流量,仅仅只是会请求其他机房的 Alluxio 集群,消耗一定的专线带宽。

6 权限相关


Alluxio 在与 HDFS 对接时,会继承 HDFS 的文件权限系统,而 HDFS 与 Alluxio 的用户可能不一致,容易造成权限问题。权限问题比较重要,所以我们单独用一个章节来做介绍。


我们通过研究代码与测试,总结了基于 Alluxio 2.9.2 版本(HDFS 与 Alluxio 的认证方式都是 SIMPLE),用户与权限的映射关系,总览图如下:



首先是 Alluxio Java Client 的用户:Alluxio Java Client 与 Alluxio 交互时,如果配置了 alluxio.security.login.username,Alluxio 客户端将会以配置的用户访问 Alluxio 集群,否则将会以 Alluxio Java Client 的启动用户访问 Alluxio。


Alluxio Master/Worker 在与 HDFS 交互时,如果 Master/Worker 在启动时配置了环境变量 HADOOP_USER_NAME(可在 alluxio-env.sh 配置),则 Master/Worker 将会以配置的用户访问 HDFS,否则将会以 Master/Worker 的进程启动用户访问 HDFS。这里需要注意,Master 和 Worker 尽量配置一样的 HDFS 用户,否则一定会造成权限问题。


在向 HDFS 写入文件时,Alluxio 会先以 Master/Worker 配置的 HDFS 用户写入文件,写完以后会调用 HDFS 的 chown 命令,将文件的 owner 修改为 Alluxio Java Client 的用户,这里我们举例说明:假设 Alluxio 启动用户为 alluxio,Alluxio Java Client 用户为 test,在向 HDFS 写入文件时,Alluxio 会先将文件以 alluxio 账号写到 HDFS 上,再将文件 chown 变成 test 用户,这时如果 alluxio 用户不是 HDFS 超级用户,在 chown 时会发生错误(比较坑的一点是这个错误 alluxio 不会抛出给客户端),导致 Alluxio 上看到的文件 owner 是 test,但是 HDFS 上的文件 owner 时 alluxio,造成元数据不一致。

其次是 S3 Proxy 的用户,S3 Proxy 它也是一个比较特殊的 Alluxio Java Client,但同时它也是一个 Server 端,这里主要是用户请求 S3 Proxy 的 AK SK 与 HDFS 用户的映射。S3 Proxy 默认会将用户的 AK 映射成访问 Alluxio 集群的用户,这里也可以自己实现映射关系,比如将 AK 映射成特定的用户,S3 Proxy 里有相关插件。


最后是 Alluxio fuse 的用户,Alluxio fuse 因为涉及到 linux 文件系统,而且有多种与 linux 本地文件系统相关的实现,所以比前面的更加复杂,这里我们只讨论默认情况,也就是 alluxio.fuse.auth.policy.class=alluxio.fuse.auth.LaunchUserGroupAuthPolicy 时的情况。用户在访问挂载目录时,用的是当前 linux 用户,用户看到挂载目录里所有文件的 owner 都是 fuse 进程启动用户;fuse 在写本地缓存目录时,用的是 fuse 进程的启动用户,此外 fuse 进程与 Alluxio 集群交互时又完全遵循 Alluxio Java Client 的逻辑。


综上所述,比较推荐的用户设置方式为:


  1. Alluxio 集群使用 alluxio 账号启动,并且将 alluxio 账号设置为 HDFS 超级用户;

  2. S3 Proxy 用 alluxio 账号启动,用户访问时,AK 为 HDFS 账号;

  3. Alluxio fuse 以 root 用户启动,防止写本地数据没有权限,并且加上 allow_other 参数,配置 alluxio.security.login.username 为 HDFS 用户。

7 其他问题


在上线过程中,我们遇到了很多问题,其中大部分都跟配置项调优有关。遇到这些问题的原因主要还是因为 Alluxio 是面相通用设计的缓存系统,而用户的场景各式各样,很难通过默认配置完美适配,比如我们有多套 Alluxio 集群,每套集群用来解决不同的问题,所以这些集群的配置都有些许差异。多亏 Alluxio 提供了许多灵活的配置,大部分问题都能通过修改配置解决,所以这里只介绍一些让我们印象深刻的“代表”。


最大副本数:在模型上线场景,缓存副本数我们不设上限,因为在算法模型在读取时,往往是一个大模型同时几十个甚至上百个容器去读,占用的存储不多,但是读取次数多,并且仅高并发读取这一次,很少有再读第二次的情况。所以这里对每一个缓存文件副本数不做限制,可以让每个 Worker 都缓存一份,这样能够达到最大的吞吐,拥有最好的性能。在模型训练场景,我们将缓存副本数设置为 3,一方面是因为训练数据量很大,需要节省存储,另一方面是 Alluxio fuse 的本地缓存会承担大部分流量,所以对于 Worker 的吞吐要求相对较低。


S3 Proxy ListObjects 问题:我们发现 S3 Proxy 在实现 ListObjects 请求时,会忽略 maxkeys 参数,列出大量不需要的目录。 比如我们请求的 prefix 是 /tmp/b, maxkeys 是 1,S3 Proxy 会递归列出 /tmp 下所有文件,再从所有文件里挑选出满足 prefix /tmp/b 的第一条数据,这样不仅性能差,也会导致可能出现 OOM 的情况,我们采用临时方案进行的修复,感兴趣可以参考 #16926。这个问题比较复杂,需要 Master 与 S3 Proxy 联合去解决,可以期待 #16132 的进展。


监控地址冲突:我们监控采用的是 Prometheus 方案,Alluxio 暴露了一部分指标,但是 JVM 指标需要额外在 Master 或者 Worker 的启动参数中添加 agent 与端口暴露出来,添加 agent 以后,因为 monitor 会继承 Master 与 Worker 的启动参数,所以 monitor 也会尝试使用与 Master 和 Worker 同样的指标端口,这会出现 ”Address already in use“ 的错误,从而导致 monitor 启动失败。具体可查看 #16657


Master 异常加载 UFS 全量元数据:如果一个路径下有 UFS mount 路径,在对这个路径调用 getStatus 方法时,Alluxio master 会递归同步这个路径下的所有文件的元信息。比如 /a 路径下的 /a/b 路径是 UFS 的 mount 路径,在调用 getStatus("/a") 的时候,会导致 /a 下面的元数据被全量加载。如果 /a 是一个大路径,可能会导致 Master 因为加载了过多的元数据而频繁 GC 甚至卡死。具体可查看 #16922


Master 频繁更新 access time:我们在使用过程中,发现 Master 偶尔会很卡,通过 Alluxio 社区同学的帮助,定位到问题来自 Master 频繁更新文件的最后访问时间,通过合入 #16981,我们解决了这个问题。

8 总结与展望


其实从 2022 年的下半年我们就开始调研 Alluxio 了,但是因为种种原因,中途搁置了一段时间,导致 Alluxio 推迟到今年才上线。在我们调研与上线的过程中,Alluxio 社区是我们最强大的外援,为我们提供了海量的帮助。


本次我们在算法场景对 Alluxio 小试牛刀,取得的结果令人十分惊喜。


从性能上讲,在算法模型上线的场景,我们将 UnionStore 用 Alluxio 替换后,最高能够获得数十倍的性能提升;在模型训练场景,我们配合 Alluxio fuse 的本地数据缓存,能够达到近似本地 NVME 磁盘的速度,相比于 UnionStore + s3fs-fuse 的方案,性能提升了 2-3 倍。


从稳定性上讲,在 HDFS 抖动或者升级切主的时候,因为有数据缓存和元数据缓存,Alluxio 能够在一定时间内不受影响,正常提供服务。


从成本上讲,Alluxio 相比于 UnionStore 每年为我们节省了数十万真金白银,而且性能上还有盈余。


从长远的发展来看,Alluxio 具有强大的可扩展性,尤其是 Alluxio 的新一代架构 Dora ,能够支持我们对海量小文件缓存的需求,这让我们更有信心支撑算法团队,面对即将到来的人工智能浪潮。


最后再次感谢 Alluxio 团队,在我们上线的过程中为我们提供了大量的帮助与建议,也希望我们后续能够在大数据 OLAP 查询加速场景以及分布式数据集编排领域继续深入合作与交流。

2023-04-18 12:154839
用户头像
蔡芳芳 InfoQ主编

发布了 798 篇内容, 共 548.2 次阅读, 收获喜欢 2788 次。

关注

评论

发布
暂无评论
发现更多内容

从不可描述的服务雪崩到初探Hystrix

老胡爱分享

高可用 灾备

架构师 第四周学习总结

冯凯

架构师训练营 - 作业 -4- 互联网产品问题与架构方案

superman

程序员的乐趣,生成自定义二维码,5行Python代码就搞定

程序员生活志

Python 程序员 代码 二维码

第四周作业

武鹏

架构师训练营4周总结

Hanson

我精心整理的 136 页 Excel 数据透视表 PDF 文件!【附获取方式】

JackTian

Python 程序员 数据分析 Excel 数据透视表

架构师训练营第四周作业

陈靓-哲露

一个典型的大型互联网应用系统使用了哪些技术方案和手段,主要解决什么问题?请列举描述。

chenzt

架构师训练营第四周总结

一剑

什么是工程师思维?

尖果爱学习

思维方式

分布式系统设计 - 第四周总结

孙志平

大型互联网应用系统都采用了哪些技术和手段,解决了什么问题?

hellohuan

极客大学架构师训练营

Week04作业

熊威

第四周总结

武鹏

架构师训练营 - 第四周学习总结

hellohuan

极客大学架构师训练营

架构师训练营 - 第四周总结

牛牛

学习 极客大学架构师训练营

架构师训练营 - 命题作业 第 4 周

水边

极客大学架构师训练营

架构师训练营第四周作业

大丁💸💵💴💶🚀🐟

架构师训练营第四周作业

sunnywhy

【week04】总结

chengjing

扯淡 Spring BeanDefinition

CoderLi

Java spring 程序员 源码分析

架构师 第四周作业

冯凯

一个典型的大型互联网应用系统使用哪些技术方案和手段

李锦

极客大学架构师训练营

一题搞定static关键字

Java课代表

面试

瑞幸商业模式的本质与组合式创新

石云升

创业 瑞幸 组合式创新

架构师训练营作业

Hanson

【科普】Scrum——从橄榄球争球到敏捷开发

禅道项目管理

Scrum 敏捷开发

架构师训练营第四周总结

陈靓-哲露

Lambda初次使用很慢?从JIT到类加载再到实现原理

Kerwin

Java Lambda 类加载 JIT

给“小白”漫画+图示讲解MyBatis原理,就问香不香!

码农神说

Java mybatis

多云缓存在知乎的探索:从 UnionStore 到 Alluxio_开源_胡梦宇_InfoQ精选文章