Velero Backup 性能改进:ItemBlock 分组备份与多 Worker 并发处理机制全解析
【免费下载链接】veleroBackup and migrate Kubernetes applications and their persistent volumes项目地址: https://gitcode.com/GitHub_Trending/ve/velero
导读
本篇文章以 Velero 仓库中的 backup-performance-improvements 设计文档 为核心,深入剖析 Velero 为提升单次备份吞吐量而引入的ItemBlock(条目块)分组机制与多 Worker 线程并发备份架构,以及它们如何为未来的VolumeGroupSnapshot(卷组快照)能力铺路。读完本文,你将理解 Velero 如何识别"必须一起备份"的资源集合、如何将 Pod 钩子(Hook)的执行粒度从单条目标提升到 ItemBlock 级别、如何通过item-block-worker-count参数配置并发 Worker 数量,以及这套设计在当前仓库源码中的真实落地形态。
一、背景:单线程备份流程的性能瓶颈
在引入 ItemBlock 之前,Velero 主备份控制器(backup controller)以单线程方式运行:对 ItemCollector(条目收集器)返回的每一个资源,依次完成主备份处理流程后才会处理下一个资源。这种"一次一个"的处理模型在资源量较大时会显著拉长备份总耗时。
设计文档指出,面对大规模备份时,通常存在两种典型场景:
- 少量大卷的备份:大部分耗时发生在异步阶段——例如 CSI 快照创建动作在
snaphandle(VolumeSnapshotContent 的快照句柄)出现之后的处理,以及 DataUpload(数据上传)的处理。这种场景下,并行化带来的收益有限。 - 大量小卷的备份:大部分耗时发生在同步动作阶段。尤其是 CSI 快照创建时,数千个卷的 VSC
snaphandle等待会累计出可观的等待时间。这类场景将从并行条目处理中获益最大——这正是本次性能改进设计的主要目标场景。
设计文档 Goals 章节 给出的核心目标有三条:
- 识别需要一起备份的相关条目组(ItemBlock);
- 将备份钩子的管理从"每条目"提升到 ItemBlock 级别;
- 使用 Worker 线程并发备份多个 ItemBlock。
同时明确了若干非目标(Non Goals):本设计不实现 VolumeGroupSnapshots 的正式支持(但包含其前置条件)、不并行处理多个备份、不重构内部插件的 RPC 调用基础设施、不涉及 Restore(恢复)性能改进。
二、总体设计:ItemBlock 概念
设计的基石是一个新的类型:ItemBlock。本质上,ItemBlock 是一组为保证备份完整性而必须一起备份的条目集合。未来将条目备份拆分到多个 Worker 线程时,ItemBlock 会作为备份的基本单元被整体保留在同一 Worker 中处理。
典型的 ItemBlock 例子包括:
- 一个 Pod、其挂载的 PVC,以及这些 PVC 绑定的 PV;
- 一个 VolumeGroup(相关联的 PVC 与 PV)以及所有挂载这些卷的 Pod;
- 对于一个 ReadWriteMany(RWX)PVC:该 PVC、其绑定的 PV,以及所有挂载该 PVC 的 Pod。
为了让 Velero 能够识别这种分组关系,设计引入了一个新的插件类型ItemBlockAction(IBA)——任何必须与其他资源一起备份的资源,都需要为其定义对应的 IBA 插件。
设计分为两个开发阶段:
- Phase 1:重构备份工作流,识别应一起备份的条目块,并在块内协调备份钩子的执行;
- Phase 2:为条目块处理增加多个 Worker 线程——不再在识别出块后立即备份,而是把块送入共享 Channel,由空闲 Worker 取走处理。
三、Phase 1 详解:ItemBlock 处理
3.1 新的 ItemBlockAction 插件类型
设计文档解释了一个关键动机:现有的BackupItemAction(BIA)插件的Execute方法虽然也能返回"当前条目需要的附加条目",但 Velero 需要在开始备份条目之前就预先知道这些关联关系。因此需要一个独立的新插件类型,通过GetRelatedItems方法提前告知 Velero 哪些条目应当并入同一个块。这些由 IBA 返回的条目应当与 BIAExecute返回的附加条目保持一致,但那些只有调用Execute之后才创建的条目不应返回(因为它们此时尚不存在)。
设计文档给出了 ItemBlockAction 的 proto 定义(经 protoc 编译为 Go 代码):
service ItemBlockAction { rpc AppliesTo(ItemBlockActionAppliesToRequest) returns (ItemBlockActionAppliesToResponse); rpc GetRelatedItems(ItemBlockActionGetRelatedItemsRequest) returns (ItemBlockActionGetRelatedItemsResponse); } message ItemBlockActionAppliesToRequest { string plugin = 1; } message ItemBlockActionAppliesToResponse { ResourceSelector ResourceSelector = 1; } message ItemBlockActionGetRelatedItemsRequest { string plugin = 1; bytes item = 2; bytes backup = 3; } message ItemBlockActionGetRelatedItemsResponse { repeated generated.ResourceIdentifier relatedItems = 1; }同时新增一个ItemBlockAction的PluginKind,备份流程将使用该插件类型。设计文档特别指出:任何 BIA 插件若在Execute()中返回需要在同一 Worker 内同步(或顺序)备份的附加条目,就应当新增一个配套的 IBA 插件返回同样的条目(去掉那些在 BIAExecute()调用前尚不存在的)。这主要适用于:操作 Pod 且其引用的资源受 Pod 钩子影响、必须随 Pod 一起备份的插件,以及需要把多个 Pod 的卷同时备份的插件。
3.2 新的数据结构:BackupItemBlock、ItemBlock、ItemBlockItem
设计文档给出了三个核心结构体的定义。在 pkg/itemblock/itemblock.go 中可以找到它们的真实实现:
package backup type BackupItemBlock struct { itemblock.ItemBlock // This is a reference to the shared itemBackupper for the backup itemBackupper *itemBackupper } package itemblock type ItemBlock struct { Log logrus.FieldLogger Items []ItemBlockItem } type ItemBlockItem struct { Gr schema.GroupResource Item *unstructured.Unstructured PreferredGVR schema.GroupVersionResource }从源码实现看,ItemBlock还提供了两个实用方法(pkg/itemblock/itemblock.go):
AddUnstructured(gr, item, preferredGVR):向块内追加条目;FindItem(gr, namespace, name):在块内查找条目——当启用EnableAPIGroupVersions时可能返回多个版本的条目,其中与preferredGVR匹配的版本排在前面。
而BackupItemBlock在 pkg/backup/itemblock.go 中通过NewBackupItemBlock(log, itemBackupper)构造,其addKubernetesResource方法会从 ItemCollector 生成的临时文件中读取条目内容(读后即删除临时文件),执行itemInclusionChecks排除检查,然后将其加入块内。
3.3 对 ItemCollector 结果循环的修改
当前(未改造前)的工作流中,BackupWithResolvers函数遍历 ItemCollector 返回的条目列表,对每个条目:从 ItemCollector 生成的临时文件加载条目内容、调用backupItem、成功后更新 GR 映射、删除临时文件、更新备份进度。
设计改造后的循环逻辑如下(部分逻辑应抽出为辅助函数,以便对GetRelatedItems返回的条目递归调用):
- 循环开始前创建指向
BackupItemBlock的指针,表示当前正在处理的块; - 若条目的
inItemBlock为true,说明已在块中,直接跳过; - 若当前
itemBlock为nil,则创建之; - 将
item加入itemBlock; - 从 ItemCollector 文件加载条目(加载后关闭并删除文件);
- 若存在同一条目的其他版本(启用
EnableAPIGroupVersions时),一并加入itemBlock; - 为条目获取匹配的 IBA 插件并调用
GetRelatedItems:对返回的每个条目,若在条目列表中则从 ItemCollector 文件获取完整内容,否则从集群拉取,加入当前块、加入itemsInBlock映射,并递归对每个条目重复上述步骤; - 若当前条目与下一条目都是同一 GR 的有序条目(ordered items),则继续循环加入当前
itemBlock; - 块生成完毕后,调用
backupItemBlock(block); - 将
backupItemBlock的返回值合并进backedUpGroupResources映射。
为此,ItemCollector 使用的kubernetesResource结构体需要增加两个布尔字段:
orderedResource:当资源因"有序资源列表"而被移到每个 GroupResource 的开头时置为true。之所以需要标记,是因为 ItemCollector 虽然已把有序资源排在前面,但列表中并无信息区分哪些是来自有序资源列表、哪些是剩余的无序条目——而处理时每个 GroupResource 的有序资源必须按顺序在同一个 ItemBlock 中顺序处理;inItemBlock:在处理列表时,条目被加入某个 ItemBlock 后置为true。
3.4 新的 backupItemBlock 函数:把钩子提升到块级别
设计文档给出的函数签名是:
func (kb *kubernetesBackupper) backupItemBlock(block BackupItemBlock) []schema.GroupResource返回值是已备份资源的 GroupResource 切片。Velero 依靠它确定备份中需要包含哪些 CRD——不仅包含直接备份的资源,还包括 BIAExecute()通过附加条目间接备份的资源。
钩子处理逻辑:先取block.items,过滤出其中尚未被备份的 Pod(依据block.itemBackupper.backupRequest.BackedUpItems),对这批 Pod 逐一执行 pre 钩子(逻辑从itemBackupper.backupItemInternal中抽出);随后遍历block.items全部条目调用backupItem——虽然后面这些条目大多已备份过,但重复调用无害(因为backupItem第一步就会检查BackedUpItems映射并直接返回),这样做的目的是兜底:防止某些插件在GetAdditionalItems返回了条目却忘记在Execute的附加条目返回值中包含它,导致条目漏备份;最后用与 pre 钩子相同的过滤后的 Pod 列表执行 post 钩子。
在 pkg/backup/backup.go 中可以看到该函数的真实实现,其流程与设计完全一致:
- 遍历
itemBlock.Items,筛选出Gr == kuberesource.Pods且尚未在BackedUpItems中的 Pod,得到preHookPods; - 调用
handleItemBlockPreHooks执行 pre 钩子;对钩子失败的 Pod,打印错误并将其标记为已备份(BackedUpItems.AddItem(key)),避免卡死流程; - 遍历块内所有条目调用
backupItem备份并汇总grList; - 若存在待执行的 post 钩子 Pod,调用
handleItemBlockPostHooks。
值得注意的细节是 post 钩子的特殊处理:handleItemBlockPostHooks(pkg/backup/backup.go)在真正执行 post 钩子前会调用waitUntilPVBsProcessed(pkg/backup/backup.go)等待该块内所有 Pod 的 PodVolumeBackup(PVB)全部处理完成(Completed 或 Failed 状态)——因为 post 钩子往往依赖卷数据备份结果,必须先等 PVB 结束才能安全执行。
3.5 backupItemInternal 清理
钩子逻辑从itemBackupper.backupItemInternal中迁出后,需要从该方法中删除钩子处理代码,避免同一 Pod 的钩子被执行两次。
3.6 Finalize 阶段不受影响
设计的 finalize(收尾)阶段不会被 ItemBlock 设计影响——它只是在各条目异步操作完成后更新资源状态,没有并行执行的必要。
四、Phase 2 详解:单备份内多线程处理 ItemBlock
4.1 新增配置项:item-block-worker-count
Velero 安装器(install CLI)与服务端(server)CLI 都会新增输入字段itemBlockWorkerCount,并传递到backupReconciler。
从当前仓库源码看,该配置已落地:
- pkg/cmd/server/config/config.go 中服务端注册了
--item-block-worker-count标志,注释为 "Number of worker threads to process ItemBlocks. Default is one. Optional.",默认值由DefaultItemBlockWorkerCount = 1(pkg/cmd/server/config/config.go)定义; - pkg/cmd/cli/install/install.go 中
velero install命令同样提供--item-block-worker-count,可将该值写入安装后的部署清单; - 服务端启动时在 pkg/cmd/server/server.go 将配置传给备份控制器。
4.2 ItemBlockWorkerPool:共享 Channel + WaitGroup
设计文档引入了一个新的类型ItemBlockWorker(最终实现命名为ItemBlockWorkerPool),它管理一组处理条目块的 Worker goroutine、一个向 Worker 传递块的共享输入 Channel,以及用于控制器退出时优雅关闭的 WaitGroup。其核心结构(pkg/backup/item_block_worker_pool.go)如下:
type ItemBlockWorkerPool struct { inputChannel chan ItemBlockInput wg *sync.WaitGroup logger logrus.FieldLogger cancelFunc context.CancelFunc } type ItemBlockInput struct { itemBlock *BackupItemBlock returnChan chan ItemBlockReturn } type ItemBlockReturn struct { itemBlock *BackupItemBlock resources []schema.GroupResource err error }Worker 池由StartItemBlockWorkerPool(ctx, workers, log)启动:输入 Channel 的缓冲容量为max(workers, 10)(即固定缓冲区,至少能容纳 10 个待处理块),并启动workers个 goroutine,各自运行processItemBlockWorker。每个 Worker 循环从inputChannel读取ItemBlockInput,调用itemBackupper.kubernetesBackupper.backupItemBlock(...)处理块,把结果(ItemBlockReturn,含返回的 GR 列表与错误)发送到该输入自带的returnChan,然后处理下一个块;收到ctx.Done()时优雅退出并wg.Done()。Stop()方法通过取消 context 并wg.Wait()等待所有 Worker 退出。
4.3 修改处理循环:把块送入 Worker 池而非内联备份
Phase 1 实现的 ItemBlock 处理循环将被改造为:把每个新建的 ItemBlock 发送到共享 Channel,而不是内联调用BackupItemBlock,并用 WaitGroup 管理进行中的块;同时为本次备份单独启动一个 goroutine 处理返回值。循环完成后,Velero 用 WaitGroup 等待所有块处理完毕,再继续后续流程。设计文档给出了简化示意:
// omitting cancel handling, context, etc ret := make(chan ItemBlockReturn) wg := &sync.WaitGroup{} // Handle returns go func() { for { select { case response := <-ret: // process each BackupItemBlock response func() { defer wg.Done() responses = append(responses, response) }() case <-ctx.Done(): return } } }() // Simplified illustration, looping over and assumed already-determined ItemBlock list for _, itemBlock := range itemBlocks { wg.Add(1) inputChan <- ItemBlockInput{itemBlock: itemBlock, returnChan: ret} } done := make(chan struct{}) go func() { defer close(done) wg.Wait() }() // Wait for all the ItemBlocks to be processed select { case <-done: logger.Info("done processing ItemBlocks") } // responses from BackupItemBlock calls are in responses处理响应时,核心动作是为每个返回的 GR 设置backedUpGroupResources[item.groupResource]=true——这与当前实现逐条处理并设置该字段的效果完全一致。
4.4 有序资源的顺序保证
处理循环被拆分为两轮迭代:
- 第一轮只处理循环开头被标记为
orderedResources的条目:这些资源生成的块送入 Worker Channel 后,必须等待其响应再继续下一个块; - 第二轮处理剩余条目生成的块:直接送入 Worker Channel,无需等待响应,从而让这些块并行处理。
这样设计的原因在于:有序资源是用户指定的"必须最先备份、且按特定顺序备份"的资源列表,因此必须逐个串行执行(pkg/backup/backup.go 中orderedResource标记与第一轮等待逻辑相互配合)。
4.5 BackedUpItems 映射的并发安全
Velero 用BackedUpItems映射追踪已备份的条目,防止重复备份,并防止附加条目间的循环依赖造成死循环。由于并行 goroutine 会同时访问该映射,必须用互斥锁(mutex)同步对它的访问。在 pkg/backup/request.go 中可以印证这一并发化改造:Request结构体包含BackedUpItems *backedUpItemsMap(具备线程安全访问能力的专用类型)、WorkerPool *ItemBlockWorkerPool、ResolvedItemBlockActions []framework.ItemBlockResolvedAction,以及为并发访问而采用sync.Map的NamespaceFilterCache,还有带锁的SynchronizedVSList(VolumeSnapshots)——这些都是并发备份流程的直接证据。
五、备选方案对比
5.1 备选方案一:给 BackupItemAction 增加 GetAdditionalItems 方法
与其新增ItemBlockAction插件类型,也可以直接在BackupItemAction上加一个GetAdditionalItems方法。该方案被否决,原因是:新插件类型提供了更干净的接口,将"条目分组"的职责与"修改条目内容用于备份"的职责彻底分离。
5.2 备选方案二:Per-backup Worker 池
当前设计采用永久 Worker 池(备份控制器启动时创建)。与之相对的方案是"处理备份时临时创建、备份完成后销毁"的临时 Worker 池。两者的用户可见 API 差异在于配置语义:
- 永久 Worker 池:worker 数量代表所有备份共享的总并发处理数;并发备份数代表同时运行的备份数。任何时刻并发备份条目的最大数量等于 worker 数。例如 worker=15、concurrent-backups=3 时,最多同时处理 15 个条目,分摊给最多 3 个运行中的备份;
- Per-backup Worker 池:worker 数量代表每个备份各自拥有的并发处理数。同样 worker=15、concurrent-backups=3 时,最多同时处理 45 个条目(每个备份各 15 个)。
两种方案的取舍:
- 永久 Worker 池优势:
- 更符合 Kubernetes 常见模式,遵循标准实践更稳妥;
- 用户更容易理解最大并发处理条目数(直接影响性能与 Velero Pod 的资源需求),无需心算相乘;
- 为未来"并发备份"增强保留更多灵活性——例如备份优先级:共享 Worker 池可以优先取走高优先级备份的 ItemBlock,使大而低优先级的备份被高优先级备份抢占,无需显式中断该备份的主控制器流程。
- Per-backup Worker 池优势:
- 内存占用更低,但阻塞在输入上的 Worker 内存消耗本就很小,若只有 10~20 个 Worker,差异可忽略。
六、兼容性与插件示例:PodAction 的 IBA 化
设计文档给出了一个"BIA 插件返回附加条目"时配套 IBA 实现示例,取自内部pod_action.go——它识别给定 Pod 所需的条目。由于该插件的唯一功能就是返回附加条目,可以直接改造成 IBA 插件;如果插件还有修改 Pod 内容的动作,则"相关条目"与"内容操作"需要拆分到不同插件。
该示例已经在当前仓库中落地为 pkg/itemblock/actions/pod_action.go(实际签名为GetRelatedItems(item runtime.Unstructured, backup *v1.Backup) ([]velero.ResourceIdentifier, error),逻辑提取到 pkg/util/actionhelpers/pod_helper.go 的RelatedItemsForPod):
// PodAction implements ItemBlockAction. type PodAction struct { log logrus.FieldLogger } // NewPodAction creates a new ItemBlockAction for pods. func NewPodAction(logger logrus.FieldLogger) *PodAction { return &PodAction{log: logger} } // AppliesTo returns a ResourceSelector that applies only to pods. func (a *PodAction) AppliesTo() (velero.ResourceSelector, error) { return velero.ResourceSelector{ IncludedResources: []string{"pods"}, }, nil } // GetRelatedItems scans the pod's spec.volumes for persistentVolumeClaim volumes and returns a // ResourceIdentifier list containing references to all of the persistentVolumeClaim volumes used by // the pod. This ensures that when a pod is backed up, all referenced PVCs are backed up along with the pod. func (a *PodAction) GetRelatedItems(item runtime.Unstructured, backup *v1.Backup) ([]velero.ResourceIdentifier, error) { a.log.Info("Executing pod ItemBlockAction") defer a.log.Info("Done executing pod ItemBlockAction") pod := new(corev1api.Pod) if err := runtime.DefaultUnstructuredConverter.FromUnstructured(item.UnstructuredContent(), pod); err != nil { return nil, errors.WithStack(err) } return actionhelpers.RelatedItemsForPod(pod, a.log), nil } func (a *PodAction) Name() string { return "PodItemBlockAction" }RelatedItemsForPod会扫描 Pod 的spec.volumes,找出所有persistentVolumeClaim卷,同时补充 Pod 的 PriorityClass,返回对应的ResourceIdentifier列表,确保 Pod 备份时其引用的 PVC 一并备份。
仓库中还实现了更多 IBA 插件,展示了分组逻辑的多样性(pkg/itemblock/actions):
PVCAction(pvc_action.go):当 PVC 处于Bound状态且spec.volumeName非空时,返回其绑定的 PV;随后通过getPVCList找到所有挂载该 PVC 的 Pod一并加入块——这保证了多个 Pod 挂载同一个 RWX 卷时能一起备份;更进一步,它会基于备份 Spec 中的VolumeGroupSnapshotLabelKey(卷组快照标签键),通过getGroupedPVCs找出同一命名空间内具有相同labelKey=groupID标签的其他 PVC 加入块内,使它们在同一 ItemBlock 中被处理——这正是设计文档所说"为 VolumeGroupSnapshot 打基础"的直接体现;ServiceAccountAction(service_account_action.go):处理 ServiceAccount 相关的关联条目。
这三个插件均可在 pkg/itemblock/actions 下找到配套的单元测试(如 pod_action_test.go)。
七、实现节奏与当前仓库落地状态
设计文档明确:Phase 1 与 Phase 2 可以在同一个 Velero 发布周期内实现,但并非必须——Phase 1 预计在 Velero 1.15 落地,Phase 2 预计在 Velero 1.16 落地。
从当前仓库源码结构看,两阶段设计均已实现并共存:
- ItemBlock 相关核心类型位于 pkg/itemblock(
ItemBlock/ItemBlockItem及 IBA 插件); - 备份侧包装与处理逻辑位于 pkg/backup:
BackupItemBlock(itemblock.go)、backupItemBlock及钩子处理(backup.go)、ItemBlockWorkerPool(item_block_worker_pool.go); - 配置入口为
--item-block-worker-count(pkg/cmd/server/config/config.go 与 pkg/cmd/cli/install/install.go),默认值为 1,用户可按需调大以提升大量小卷场景的备份吞吐。
八、总结与实践建议
ItemBlock 机制是 Velero 备份性能演进的关键一步:它先以ItemBlockAction插件从语义层面把"必须一起备份"的资源聚合成块,再将 Pod 钩子提升到块级别统一编排,最后通过ItemBlockWorkerPool让多个 Worker 并发消费块,从而在不牺牲备份完整性的前提下显著提升大量小卷场景的备份速度。与此同时,块级分组天然是 VolumeGroupSnapshot 的前置能力——PVCAction依据VolumeGroupSnapshotLabelKey把同组 PVC 聚合进同一块,已经为卷组快照铺好了路。
实践建议:如果你的集群以"大量小 PVC + CSI 快照"为主,可以适当调大服务端与velero install的--item-block-worker-count(并配合--concurrent-backups理解整体并发语义),同时注意 Worker 增多会提高 Velero Pod 的资源消耗,需要同步评估内存与 CPU 限额。
延伸阅读:本文对应的完整设计文档见 design/Implemented/backup-performance-improvements.md;与卷组快照相关的前置设计可参考 design/Implemented/volume-group-snapshot.md 与 design/Implemented/vsv2-design.md。
【免费下载链接】veleroBackup and migrate Kubernetes applications and their persistent volumes项目地址: https://gitcode.com/GitHub_Trending/ve/velero
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考