作者:胡梦宇
文章来源:投稿
1 背景
随着云原生技术的飞速发展,各大公有云厂商提供的云服务也变得越来越标准、可靠和易用。凭借着云原生技术,用户不仅可以在不同的云上低成本部署自己的业务,而且还可以享受到每一个云厂商在特定技术领域上的优势服务,因此多云架构备受青睐。
知乎目前采用了多云架构,主要是基于以下考虑:
服务多活:将同一个服务部署到不同的数据中心,防止单一数据中心因不可抗力不能正常提供服务,导致业务被“一锅端”;
容量扩展:一般而言,在公司的服务器规模达到万台时,单一数据中心就很难支撑业务后续的扩容需求了;
降本增效:对于同一服务,不同云厂商对同一服务的定价和运维的能力也不尽相同,我们期望能够达到比较理想的状态,在云服务满足我们需求的前提下,尽量享受到低廉的价格。
知乎目前有多个数据中心,主要的机房有以下两个:
在线机房:主要是部署知乎主站上直接面向用户的服务(如评论、回答等),这部分服务对时延敏感;
离线机房:主要是部署一些离线存储,计算相关的服务,对时延不敏感,但是对吞吐要求高。
两个数据中心之间通过专线连接,许多重要服务都依赖于专线进行跨机房调用,所以维持专线的稳定十分重要。专线流量是衡量专线是否稳定的重要指标之一,如果专线流量达到专线的额定带宽,就会导致跨专线服务之间的调用出现大量的超时或失败。
一般而言,服务的吞吐都不会特别高,还远远达不到专线带宽的流量上限,甚至连专线带宽的一半都达不到,但是在我们的算法场景中有一些比较特殊的情况:算法模型的训练在离线机房,依赖 HDFS 上的海量数据集,以及 Spark 集群和机器学习平台进行大规模分布式训练,训练的模型结果存储在 HDFS 上,一个模型甚至能达到数十上百 GB;在模型上线时,算法服务会从在线机房跨专线读取离线 HDFS 上的模型文件,而算法服务一般有数十上百个容器,这些容器在并发读取 HDFS 上的文件时,很轻易就能将专线带宽打满,从而影响其他跨专线服务。
2 多 HDFS 集群
在早期,我们解决算法模型跨机房读取的方式非常简单粗暴,部署一套新的 HDFS 集群到在线机房供算法业务使用,业务使用模型的流程如下:
- 产出模型:模型由 Spark 集群或机器学习平台训练产出,存储到离线 HDFS 集群;
- 拷贝模型:模型产出后,由离线调度任务定时拷贝需要上线的模型至在线 HDFS 集群;
- 读取模型:算法容器从在线 HDFS 集群读取模型上线。
多 HDFS 集群的架构虽然解决了专线流量的问题,但是依然存在一些问题:
- 多个 HDFS 集群不便于维护,增加运维人员负担;
- 拷贝脚本需要业务自己实现,每次新上线模型时,都要同步修改拷贝脚本,不便维护;
- 在线 HDFS 集群的文件需要业务定期手动删除以降低成本,操作风险高;
- 在线 HDFS 与离线 HDFS 之间文件视图不一致,用户在使用 HDFS 时,需要明确知道自己使用的是哪个 HDFS,需要保存多个地址,心智负担高;
- 在超高并发读取时,比如算法一次性起上百个容器来读取某个模型文件时,会导致 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 的优势如下:
- UnionStore 提供了 S3 协议,各编程语言对 S3 协议的支持要比 HDFS 协议好,工具也相对来说也更丰富;
- UnionStore 会自动缓存文件,无需用户手动拷贝模型,省去了拷贝脚本的开发与维护;
- 提供统一的文件视图,因为元数据是实时请求 HDFS 的,所以文件视图与 HDFS 强一致;
- 下线了一个 HDFS 集群,文件储存能力由对象存储提供,节省了大量的服务器成本;
- 文件过期可依赖对象存储本身提供的能力,无需自己实现;
- 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 这个组件来解决这个问题:
- Server 节点在接受到读取未缓存文件的请求时,会先将用户的请求异步卡住,生成缓存任务,提交到 Task Manager 的等待队列中;
- 所有 Server 节点会不断竞争等待队列里的任务,只会有一个节点竞争成功,此时该节点会将缓存任务放入运行队列,开始执行,执行期间向任务队列汇报心跳;
- 每个 Server 节点会定期检查自己卡住的用户请求,来检查 Task Manager 里对应的任务,如果任务执行成功,就会唤醒用户请求,返回给用户缓存后的文件;同时,每个 Server 都会定期检查 Task Manager 里正在运行的任务,如果任务长时间没有更新心跳,则会将任务从运行队列里取出,重新放回等待队列,再次执行。
这里所有的状态变更操作都发生在 Server 节点,Task Manager 只负责存储任务信息以及提供队列的原子操作。
3.3 局限
UnionStore 项目在知乎运行了两年,早期并没有出现任何问题,但是随着算法业务规模的不断扩大,出现了以下问题:
- 没有元数据缓存,元数据强依赖 HDFS,在 HDFS 抖动的时候,有些需要频繁更新的模型文件会受影响,无法更新,在线服务不应强依赖离线 HDFS;
- 读写加速因为用到了多线程技术,对 CPU 的消耗比较大,在早期业务量不大的时候,UnionStore 只需要几百 Core 就能支撑整个公司的算法团队读取数据,但是随着业务量不断上涨,需要的 CPU 数也涨到了上千;
- 对象存储能力有上限,单文件上千并发读取时,也会面临性能瓶颈;
- UnionStore 只做到了缓存,而没有做到高性能缓存,业务方的大模型往往需要读取十多分钟,极大影响模型的更新速度,制约业务的发展;
- 无法做到边缓存边返回文件,导致第一次读取文件的时间过长。
另外还有一个关键点,机器学习平台为保证多活,也采用了多云架构,支持了多机房部署,在读取训练数据时,走的是 UnionStore 对 HDFS 的直接代理,没走缓存流程,因为训练数据大部分都是小文件,而且数量特别巨大,小文件都过一遍缓存会导致缓存任务在任务队列里排队时间过长,很难保证读取的时效性,因此我们直接代理了 HDFS。按照这种使用方式,专线带宽在训练数据规模扩大时,依然会成为瓶颈。
以上痛点使我们面临两个选择:一是继续迭代 UnionStore,让 UnionStore 具备高性能缓存能力,比如支持本地 SSD 以及内存缓存;二是寻找合适的开源解决方案,完美替代 UnionStore 的使用场景。基于人力资源的宝贵,我们选择了其二。
4 利用 Alluxio 替代 UnionStore
4.1 调研
我们调研了业内主流的文件系统,发现 Alluxio 比较适合我们的场景,原因有以下几点:
- 透明缓存:相较于其他文件系统,Alluxio 可仅作为缓存使用,用于编排数据,业务方无需将模型文件写入到其他的文件系统,只需要维持现状,写入 HDFS 即可;
- 元数据与数据缓存:Alluxio 支持自定义缓存元数据与数据,这样在读取已缓存文件时,可完全不受 HDFS 影响;目前我们 UnionStore 的 QPS 大约在 20K-30K,缓存元数据可极大降低 NameNode 的压力,反哺离线场景;
- 丰富的 UFS 支持:支持除 HDFS 外的多种 UFS,比如对象存储,对我们的数据湖场景也提供了强有力的支撑;
- 即席查询加速:知乎 Adhoc 引擎采用的是 Spark 与 Presto,Alluxio 对这两个引擎都有较好的支持;
- 访问接口丰富:Alluxio 提供的 S3 Proxy 组件完全兼容 S3 协议,我们的模型上线场景从 UnionStore 迁移至 Alluxio 付出的成本几乎可忽略不计;另外 Alluxio 提供的 Alluxio fuse 具备本地元数据缓存与数据缓存,比业务之前使用的 S3 fuse 具有更好的性能,正好能满足我们的模型训练场景。
- 社区活跃:Alluxio 社区十分活跃,在我们调研期间交流群基本上都会有热心的网友及时答复, issue 很少有超过半天不回复的情况。
对 Alluxio 的调研让我们非常惊喜,它不仅满足了我们的需求,还给我们“额外赠送”了不少附加功能。
我们在内部对 Alluxio 进行了测试,以 100G 的文件做单线程读取测试,多次测试取平均值,结果如下:
其中 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 场景特点
我们的模型上线场景有以下特点:
- 用户利用 S3 协议读取模型文件;
- 用户将模型数据写入到 HDFS 上后,需要立即读取,数据产出与读取的间隔在秒级,几乎无法提前预热,存在缓存穿透的问题;
- 一份模型文件将由上百甚至上千个容器同时读取,流量放大明显,最大的单个模型读取时,峰值流量甚至能达到 1Tb/sec;
- 模型文件只会在短时间内使用,高并发读取完毕后可视为过期;
- 数万容器分散在上千个 K8s 节点上,单个容器可用资源量较少。
针对模型上线场景,我们选择了 S3 Proxy 来为业务提供缓存服务,不使用 Alluxio Client 以及 Alluxio fuse 主要是基于以下考虑:
- 用户原本就是利用 S3 协议读取文件,换成 S3 Proxy 几乎无成本;
- 业务方使用的语言有 Python,Golang,Java 三种,Alluxio Client 是基于 Java 实现的,其他语言使用起来比较麻烦;
- 受限于单个容器的资源限制,不适合在容器内利用 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 的部署方式,但是在我们的权衡之下,还是选择了裸金属机部署,原因如下:
- 从我们的测试结果来看,Alluxio Worker 在”火力全开“的情况下是可以轻易打满双万兆网卡的,这个时候网卡是瓶颈;如果选择 k8s 部署,当有容器与 Alluxio Worker 调度到同一台 k8s 的节点时,该容器容易受到 Alluxio Worker 的影响,无法抢占到足够的网卡资源;
- Alluxio Worker 依赖高性能磁盘做本地缓存,与其他服务混布容易收到其他进程的磁盘 IO 影响,无法达到最佳性能;
- 因为 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 时,流量主要分为以下几个部分:
- 文件未缓存至 Alluxio:Worker 从 UFS 读取数据,任一 Worker 只要缓存了 UFS 的文件,这部分流量将不存在;
- 文件在远端 Worker 缓存:本地 Worker 从其他 Worker 读取数据缓存到本地,S3 Proxy 暂时从远端 Worker 读取,本地 Worker 缓存完毕后这部分流量将不存在;
- 文件在本地 Worker 缓存:S3 Proxy 从本地 Worker 读取的流量,这部分流量在开启短路读后将不存在;
- 业务方从 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 的速度。因此我们需要想办法预热文件。
预热文件的手段一般有以下两种:
- 用户在写完文件后,手动调用 Alluxio load 命令,提前将数据缓存,确保在读取的时候,需要的文件已经被缓存了;
- 根据 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 倍以上。
此缓存策略需要注意以下几点:
- 缓存 block 需要异步,并且所有的异常都要处理掉,不要影响正常的读取请求;
- 缓存 block 时,最好将 block id 与 Worker id 以某种方式(如 hash)进行绑定,这样能保证在对同一个文件进行并发请求时,对某一个 block 的缓存请求都只打到同一个 Worker 上,避免不同的 Worker 从 UFS 读取同一个 block,放大 UFS 流量;
- S3 Proxy 需要对提交的 cache block 任务计数,避免提交过多任务影响 Worker 正常的缓存逻辑,最好不要超过配置
alluxio.worker.network.async.cache.manager.threads.max
的一半,这个配置代表 Worker 处理异步缓存请求的最大线程数,默认值是两倍的 CPU 数; - S3 Proxy 需要对已经提交缓存的 block 进行去重,防止在高并发读取同一个文件的情况下,多次提交同一个 block 的缓存请求到 Worker,占满 Worker 的异步缓存队列。Worker 的异步缓存队列大小由配置
alluxio.worker.network.async.cache.manager.queue.max
控制,默认是 512。去重比较推荐使用 bitmap 按照 block id 做; - 在 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 的性能。
与业务方沟通过后,我们制定了元数据一致性的规范:
- 元数据缓存设置为 1min;
- 新增文件尽量写入新目录,以版本号的方式管理,不要在旧文件上修改或覆盖;
- 对于历史遗留,需要覆盖新文件的任务,以及对元数据一致性要求比较高的任务,我们在 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 场景特点
我们的模型训练场景有以下特点:
- 因为大部分开源的模型训练框架对本地目录支持最好,所以我们最好是为业务提供 POSIX 访问的方式;
- 模型训练时,主要瓶颈在 GPU,而内存,磁盘,网卡,CPU 等物理资源比较充足;
- GPU 机器不会运行训练任务以外的任务,不存在服务混布的情况;
- 数据以快照形式管理,对元数据没有一致性要求,但是需要有手段能够感知 HDFS 上产生的新快照。
针对模型训练场景,毫无疑问我们应该选择 Alluxio fuse 来提供缓存服务: 1. Alluxio fuse 提供了 POSIX 访问方式; 2. Alluxio fuse 能够利用内存和磁盘做元数据缓存与数据缓存,能够最大程度利用 GPU 机器上闲置的物理资源。
4.4.2 性能测试
在上线前,我们对 fuse 用 fio 进行了压测。
Alluxio fuse 配置:
测试结果如下:
以上结果均针对数据已缓存至 fuse 本地磁盘的情况,1G 文件与 10G 文件读取时,速度是 100G 文件的两倍,这是因为容器的内存为 40G,有充足的 pagecache 来缓存 1G 与 10G 的文件,但是 100G的文件没有充足的 pagecache,所以性能会下降,但是也能达到不错的速度,整体行为符合预期。
4.4.3 集群部署
Alluxio fuse 的部署方式我们选择了以 DaemonSet 部署,通过 host path 进行映射,没有选择 CSI 部署,主要是基于以下考虑:
- Alluxio fuse 高性能的核心在于数据缓存与元数据缓存,数据缓存需要消耗大量的磁盘,元数据缓存需要消耗大量的内存,如果以 CSI 的形式进行部署,每个容器只能分配到少量的磁盘与内存给 Alluxio fuse 进程;
- 在模型进行训练的时候,读取的训练数据重复程度很高,如果每个容器起一个 fuse 进程,可能会导致同一机器缓存多份相同的文件,浪费磁盘;
- GPU 机器只跑训练任务,所以 fuse 进程可以 long running,无需考虑资源释放的问题;
- 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 元数据的缓存弄混淆,我们简单做个说明:
- Alluxio Master 会缓存 UFS 的元数据,决定是否更新元数据由客户端配置的
alluxio.user.file.metadata.sync.interval
决定。假如这个值设置为 10min,客户端在请求 Master 时,如果 Master 在之前的 10min 内已经更新过元数据,则 Master 会直接返回缓存的元数据,而不会请求 UFS 拿最新的元数据;否则将会返回 UFS 的最新的元数据,并且更新 Master 的元数据; - 用户在用 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 os
print(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 的表现十分稳定:
4.4.5 上线结
我们的算法模型训练切换至 Alluxio fuse 后,模型训练的效率达到了本地磁盘 90% 的性能,相比于原来 UnionStore 的 s3fs-fuse 的挂载,性能提升了约 250%。
5 S3 Proxy 在大数据场景的应用
回顾模型上线场景,我们不仅为算法业务提供了模型加速读取的能力,还沉淀下来了一个与对象存储协议兼容,但是下载速度远超普通对象存储的组件,那就是 Alluxio S3 Proxy,所以我们现在完全可以做一些”拿着锤子找钉子“的一些事情。
这里介绍一下我们大数据组件的发布与上线流程,流程图大致如下:
下面用文字简单描述:
- 开发者修改代码以后,将代码合入对应组件的 master 分支,此时 Gitlab 将调用 CI 的 Web Hook,CI 会运行对应组件的打包编译逻辑;
- 组件打包成二进制包后,CI 会向 Kosmos 注册二进制包的元信息,以及将二进制包上传至 Kosmos,Kosmos 在接受到二进制包后,会上传至对象存储;
- 开发者在大数据运维平台选择要上线的组件,以及组件的版本,大数据组件会自动在生产环境的服务器上运行部署逻辑;
- 在部署逻辑运行的过程中,会向 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 的逻辑。
综上所述,比较推荐的用户设置方式为:
- Alluxio 集群使用 alluxio 账号启动,并且将 alluxio 账号设置为 HDFS 超级用户;
- S3 Proxy 用 alluxio 账号启动,用户访问时,AK 为 HDFS 账号;
- 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 查询加速场景以及分布式数据集编排领域继续深入合作与交流。