之前的两篇文章主要介绍了 LiteIO 的特点和部署方式,相信读者对 LiteIO 已经初步了解。本文将重点介绍 LiteIO 的各个组件的具体功能和协作原理,并详细描述了卷在 LiteIO 系统中的生命周期。通过本文,读者将更全面地了解 LiteIO,并深入了解其组件功能和协作原理,以及卷在系统中的生命周期。
开源项目地址:https://github.com/eosphoros-ai/liteio
根据功能分类,LiteIO 的组件大致可以分为两类:
核心功能: node-disk-controller, disk-agent, nvmf_tgt
CSI 功能: csi-controller, csi-node
核心功能涵盖了 LiteIO 关于资源调度、资源生命周期管理等核心逻辑。具体而言,它包括卷的创建、调度、扩容、销毁以及 StoragePool 的资源上报、心跳管理等。
CSI 功能是指将核心功能对接 K8S 的 CSI (Container Storage Interface) 接口的功能。它是根据 CSI 接口的逻辑,将核心功能中的实体卷变为 K8S 中的抽象 PV(Persistent Volume) 的一种实现,这样用户就可以直接在 K8S 中使用抽象资源 PVC(Persistent Volume Claim)和 PV 来直接调用 LiteIO 进行卷的管理操作。
接下来我们从用户视角讲解一个 LiteIO 卷的生命周期,假设用户创建了一个 Pod 和一个使用 LiteIO 的 PVC,那么会发生以下事件。
首先 Pod 会进入调度器,进行容器调度。LiteIO 中支持 Volume 位置可选,例如字段 PositionAdvice 的值设置为 MustLocal, 表示必须使用本地盘,即 Pod 和 Volume 在同一个节点上。这需要容器调度器对存储资源的位置有感知,暂且按下不表,在下一篇调度器中将会详细介绍。这里我们暂且认为 PositionAdvice 没有偏好,可以是远程或者本地。
容器调度完成后,同时会 bind PVC,PVC 也被标记了计算节点的信息。
PVC 完成绑定后,csi-controller 会观察到这个事件,从而进入创建 PV 的流程。csi-controller 调用 CreateVolume 方法,在其中创建 LiteIO Volume 对象。
Volume 被创建后,node-disk-controller 会发现这个新创建的 Volume,从而进行调度。node-disk-controller 在启动时会加载集群中所有的 StoragePool 和 Volume, 拥有全局资源的位置信息,所以可以根据请求参数准确调度这个新的 Volume 到某个节点。
Volume 被调度后,调度信息会在 Volume 的元数据中持久化。对应节点的 disk-agent 会观察到这个 Volume, 从而根据参数实际创建 Volume, 并且更新 Volume 状态。
kubelet 从步骤4中的 CreateVolume 成功后,就会开始调用 CSI-Node 的接口,在等待 Volume 状态变为 ready 后,判断 Volume 位置,如果是远程盘,会调用 nvme 命令行,通过 nvme-tcp 模块连接远程盘到本地。再将盘 mount 到特定的目录,提供给 Pod 使用。
当用户发起 删除 Pod, PVC 后。在容器被停止后,kubelet 会调用 CSI-Node 的接口,执行 umount,disconnect 等步骤6的反向操作。
然后 CSI-Controller 会执行 DeleteVolume 删除 Volume 资源。
Disk-Agent 会观察到删除事件,从而发起真实删除,回收节点上的存储资源。
node-disk-controller 等待 Vokume 被真实删除后,从内存中解绑这个 Volume, 这个节点的资源从调度器中被释放,可以再次被分配。
Disk-agent 部署在每台存储节点上,主要有以下职责:
根据配置构建或发现 StoragePool
上报 StoragePool 实际状态,资源变化
维持心跳
管理节点上的 Volume,快照,迁移任务等
采集 Volume 的 IO 指标数据, 暴露到接口
单机的 StoragePool 支持两种引擎,一种是 LVM, 一般宿主机内核支持。第二种是 SPDK 的 LVS,需要部署 nvmf_tgt 进程。LVM 划分出来的 LV, 可以通过 AIO bdev ,SPDK subsystem 提供远程 NoF 服务。
可以看出,想使用远程盘 nvmf_tgt 是必须部署的,它提供了 NoF 的能力,也有存储池化的能力。
接下来讲一讲写 Agent 时犯过的错,踩过的坑。
最开始写 Agent 的时候,没有采用监听 CRD 事件的模式,而是直接使用 node-disk-controller 调用 Agent RPC 接口的模式。这样有明显的弊端:
弊端一:需要额外实现一些重复的功能,例如 RPC 调用端需要重试机制,RPC 服务需要鉴权。
弊端二: controller 和 agent 耦合严重,相互依赖。
弊端三: 由于 agent 一般是 Daemonset 部署,使用的是宿主机网络,在某些环境中,因为安全性的需要,容器网络和宿主机网络是不通的。意味着 agent 与 controller 直接网络隔离,没有连通性。也是最严重的问题,agent 与 controller 通过 RPC 交互完全不可行。
反观现在的模式, Agent 只和 APIServer 交互,Controller 只和 APIServer 交互,两者通过共享、调谐 CRD 状态, 达到顺序完成各自的职责的目的。可以使用 K8S 现有的工具库去实现,简化了开发工作,解决了上述所有弊端。
但是目前这种模式也不是完全没有问题,例如,Agent 需要 Watch 自身节点上的 CR, 由于 APIServer 需要遍历节点去过滤,因此这个 Watch 的过滤操作对节点数庞大的集群会产生压力。社区也有对这个需求的讨论,目前暂时没有很好的解决办法。
https://github.com/kubernetes/kubernetes/issues/53459#issuecomment-1149568598
当节点发生严重故障时,需要有机制可以让中心调度器感知到节点故障,从而让节点变为不可调度,因此维持 Agent 心跳是十分必要的。
我们参考了 K8S Node 的心跳机制,复用了 Lease 对象,用于记录 Agent 的心跳。Agent 端负责更新 Lease 对象的 RenewTime, Controller 端负责检测这个心跳是否超时,如果超时则认为这个节点不可调度。
在使用 K8S 客户端操作 Lease 对象时,也踩过一个坑,大家可以看看下面的伪代码,是否能发现问题.
// 发起心跳函数, 会被定时执行,例如每30s执行一次
func (hs *HeartbeatService) doHeartbeat() (err error) {
// 如果 hs.lease 是 nil, 表示第一次进入 doHeartbeat, 先get 后 create
if hs.lease == nil {
// 获取Lease
hs.lease, err = k8sLeaseCli.Get(leaseName)
if err != nil {
if errors.IsNotFound(err) {
// 创建新Lease, 省略了具体参数
hs.lease = &coordv1.Lease{...}
hs.lease, err = k8sLeaseCli.Create(hs.lease)
if err != nil {
hs.lease = nil
klog.Error(err)
}
return
} else {
klog.Error(err)
return
}
}
}
// update renew time
hs.lease.Spec.RenewTime = &renew
hs.lease, err = k8sLeaseCli.Update(hs.lease)
if err != nil {
klog.Error(err)
return
}
return
}
这段代码有一个错误,在上线一段时间后,发现某个集群在某个时间点突然所有 agent 心跳都停止了,发现问题在哪里了吗?
hs.lease, err = k8sLeaseCli.Update(hs.lease)
。Update() 返回一个 Lease指针和 一个 error , 我们以为如果返回非 nil error,那么应该返回一个 nil Lease 指针才对。这个先入为主的假设是错的。实际发生错误时,返回了一个非 nil 且值为空的 Lease 指针。当再次进入 doHeartbeat 时,因为 hs.lease 不是 nil, 就没有机会再次 Get Lease 了。正确做法是,在 line 28 后,手动设置 hs.lease = nil
或者 干脆直接 hs.lease, err = k8sLeaseCli.Get()
读取一次最新的 Lease。为了方便测试,我们给 agent 增加了根据配置构建 StoragePool 的能力。例如,我们可以通过创建文件,设置 PV, 创建 VG 的方式来构建一个 LVM 类型的 Pool。也可以通过创建文件,通过 SPDK 创建 aio bdev, 再创建 LVS 的方式来构建一个 SPDK 类型的 Pool。还可以使用 vfio 接管 nvme设备,创建 raid bdev, 再构建 LVS。
这完全取决于 agent 配置, 例如一个 file+aio+lvs 的配置, 会创建一个大小为 1G 的文件 /local-storage/aio-lvs, 在此基础上构建 LVS。
storage:
pooling:
name: aio-lvs
mode: SpdkLVStore
bdev:
type: aioBdev
name: test-aio-bdev
size: 1048576000 # 1GiB
filePath: /local-storage/aio-lvs
node-disk-controller 是一个中心部署的组件, 主要职责维护集群中所有资源状态,调度 Volume, 协调迁移任务,协调快照任务等。
这个组件主要
元数据同步到外部数据库是一个非常合理的需求。外部的管控平台需要把集群内资源展示给用户,让管控平台和 APIServer 直接交互显然是不合理的,把元数据同步到某个关系数据库是比较好的做法。这里也面临两种选择,第一种是考虑单独新建一个 controller, 专门用于同步存储资源元数据。这种方式的好处是逻辑切分很干净,同步逻辑完全是一个独立的进程。但是也有缺点,就是浪费资源,需要重新加载了一份全量数据到内存,显得笨重。所以当时选择了第二种方式, node-disk-controller 已经 watch 了需要同步的资源,就在 Reconciler 中实现同步元数据的逻辑。
同步逻辑我们写在了 Syncer 中,刚开始我们直接在各个 Reconciler 中去调用 Syncer,但是后来发现这么做耦合很严重,而且每个 Reconciler 会包含重复代码。随着资源数量增加,需要同步的资源变多了,相当于每多一种资源,都会手动调用一次 Syncer,这显得很笨拙。
随后我们设计了一个包装的 PlugableReconciler,各个资源具体的 Reconciler 被包含在 PlugableReconciler 中,把 Syncer 当做一种插件。这样,使用组合的方式,把资源 Reconciler 和 Syncer插件结合在了一起。当然除了 Syncer, 各个资源可以定义自己的 Plugin, 例如 StoragePool 就有自己的 LockPoolPlugin,作用就是用于在特殊情况下锁定 Pool 调度。Plugin 的接口定义很简单,需要定义 调谐资源逻辑 和 处理删除的逻辑。
type Plugin interface {
Name() string
Reconcile(ctx *Context) (result Result)
HandleDeletion(ctx *Context) (err error)
}
调度过程参考了 Pod 的调度流程,先经过一系列 Filter 过滤符合条件的节点,再经过计算 Priority 打分,挑选比较合适的节点。默认的过滤器包含:
基本过滤器, 根据 Pool 状态,剩余资源,卷的 PositionAdvice 参数等进行过滤
亲和过滤器, 根据 Node 或者 Pool 的标签亲和进行过滤
支持自定义过滤器,可以通过配置启用。默认的 Priority 有优先使用资源较少的节点,根据 PositionAdvice 参数判断优先性。
因为需要考虑本地盘调度,我们提供了两种方案去实现保证本地盘调度正确性,一种是上报 Node extended resource, 另一种是对接了 K8S 的 Scheduler Framework 把本地存储调度融入 K8S 调度器。下一篇会详细讲解调度器的内容,敬请期待。
csi-controller 实现了创建/删除/扩容 Volume, 创建/删除 Snapshot 相关的接口,需要配合 sidecar 容器 csi-provisioner 一起使用。在上面架构总览中的卷创建流程中,提到过 csi-controller 的作用,当 csi-provisioner 观察到 PVC 的事件后,则会触发 Create/Delete 操作。
StageVolume: 将远程盘连接到本地宿主机,格式化卷并且 mount 到某个指定目录 PublishVolume: 将 StageVolume 获得的指定目录,bind mount 到某个给容器提供的指定目录。这样容器就能使用这个文件系统卷了。
UnpublishVolume: 将 bind mount 的目录执行 umount UnstageVolume: 将 mount 点 umount, 并且执行 disconnect, 将远程盘断开。
由于 Volume 的文件系统在本地,为了提升可观测性,我们添加了一个 prometheus 的 metric 接口,暴露 Volume 的可用空间,可用 inode 信息。
如果后端 Volume 的 nvmf_tgt 遇到故障重启,可能会导致前端断连,此时可以通过命令行重新走CSI-node 流程,重新为容器挂载卷。因此为了增加 CSI 的运维手段,我们在 csi-node binary 中提供了 命令行工具用于手动执行 Stage/Unstage, Publish/Unpublish Volume 的能力。
你是否正在考虑降本增效的 FinOps 项目?你是否也在考虑通用存储计算分离架构设计?你是否也是存储技术的爱好者?欢迎你参与 LiteIO 开源社区,我们期待你的加入。
开源项目仓库:https://github.com/eosphoros-ai/liteio
往期精彩