ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Golang自研Kubernetes备份恢复工具:从Velero到可编程数据管道

用Golang自研Kubernetes备份恢复工具:从Velero到可编程数据管道 1. 为什么放着Velero不用偏要用Golang自己写先说结论不是Velero不好而是它在某些场景下的扩展成本高得离谱。我所在的团队维护着几十套Kubernetes集群跨多个机房和云厂商既有托管版也有自建版。一开始我也考虑过直接用Velero调研了差不多两周最后放弃了。Velero的架构其实很优雅一个Server端跑在集群里通过CRD定义备份和恢复任务插件机制支持各类云存储和卷快照。它解决的是我把集群备份到对象存储这个通用问题。但我们的核心诉求有几个是Velero及官方插件无法直接覆盖的备份数据需要推到公司内部的归档平台而不是S3或OSS且归档平台要求自定义元数据和分片格式备份任务必须和内部的监控告警系统联动备份失败要能在IM群和工单系统自动创建处理任务部分集群是离线内网环境部署Velero本身就需要先解决镜像分发问题而这套集群恰恰最需要备份我们希望备份产物不依赖Velero的还原器格式换一句话说即使未来工具停维护备份出来的YAML文件也必须能直接通过kubectl apply恢复集群。这不是说Velero做不到这些而是每一条都意味着要么写自定义插件要么fork源码改行为。自定义插件本身的开发、测试、随Velero版本升级一整套维护成本算下来不如直接用Golang写一套轻量级的、完全在我们掌控之下的备份链路。1.1 现成方案的能力边界Velero使用Restic或Kopia做卷级备份、用Kubernetes API做资源备份分层很清晰。但如果你仔细读它的源码会发现它对资源备份有一个关键假设它把你的集群资源转成一种内部的JSON结构然后整个序列化成归档格式。这意味着恢复的时候你必须依赖Velero的restore命令来解析归档内容。另一个痛点是在线备份对集群的侵入性。Velero会在集群里创建专门的Namespace、CRD和对应的Controller备份任务本身也会消耗一定的Pod资源。对正式生产集群做变更前你得先接受多跑一个常驻服务这个前提。而在我们的一些小集群上这只是为了一次性的故障演练而背负额外的运维负担。这些边界让我倾向一个更朴素、更直接的设计思路**备份的本质是把集群的声明式状态导出为文件恢复的本质是把文件重新声明回集群。**中间那一层归档格式如果越通用未来的恢复工具就越容易被绑架。1.2 自研方案的核心优势可编程的数据管道用Golang写这套备份工具最大的收益不是性能好而是整个备份链路变成了一段可以随意编排的程序。从API Server拉取资源、解析字段、过滤敏感信息、压缩归档、上传到远端每一步都是普通函数调用想加日志、加指标、加重试都是几行代码的事。尤其是处理敏感数据过滤。我们的集群里有些Secret里有数据库连接串、云厂商密钥。备份的时候不能直接落盘明文而Golang的encoding/json配合mapstructure这种库可以在序列化时做深度遍历替换。这种能力用脚本也能做但要保证在成千上万个资源对象上稳定执行且能处理各种嵌套结构脚本的健壮性就明显跟不上了。另外Golang的静态编译特性在这个场景是刚需。编译出的二进制直接扔进任何Linux机器就能跑不需要预装Python环境不需要安装依赖不依赖集群里有没有特定的镜像。离线集群里你把二进制拷进去就能用这是脚本方案很难比的优势。2. 备份的两条主线集群资源导出与etcd数据快照动手写代码之前先把备份到底备份什么这个问题彻底想清楚。Kubernetes集群的状态分散在两个层面一是API Server里的资源对象也就是我们通过kubectl get能看到的那些东西二是etcd里的最终一致数据包括资源对象的所有历史版本和集群内部的运行状态。两者不冲突但用途完全不同。2.1 集群资源导出的完整清单与CRD处理资源导出这条线负责把集群的声明式意图完整保存下来。所谓完整不只是Deployment、Service、ConfigMap这些内置资源还必须包括Namespace及其配额、LimitRangeRBAC相关的ServiceAccount、Role、ClusterRole、RoleBinding、ClusterRoleBinding各种CRDCustomResourceDefinition以及集群里实际存在的CR实例NetworkPolicy、PodDisruptionBudget、ResourceQuota这类策略类资源各类控制器创建的Workload资源包括Deployment、StatefulSet、DaemonSet、Job、CronJob存储相关的PVC、StorageClass注意StorageClass是集群级别的以及容易被忽略的IngressClass、RuntimeClass、PriorityClass、PodSecurityPolicy如果还在用的话。CRD的处理是里面最容易出问题的环节。如果你只按内置资源类型枚举自定义资源直接漏光。后来我改用kubectl get apiextensions.k8s.io/customresourcedefinitions -o json把每个CRD的spec.versions取出来再对每一个CRD构造对应的GroupVersionResource去全量拉取CR实例。这里有一个设计取舍导出CR实例的时候要不要连CRD定义本身一起导出我认为必须导出。因为恢复的时候如果目标集群里没有对应CRD那你apply那些CR实例会直接报no matches for kind。我在恢复流程里固定先恢复CRD再恢复CR实例顺序差一步整个恢复演练就失败。2.2 为什么最终选了etcd快照兜底资源导出覆盖了99%的业务场景但并不是100%。有一个极端场景让我一直睡不踏实当API Server本身出问题或者某个Operator在集群里写入了大量非结构化数据导致资源列表爆炸只靠资源导出是恢复不出集群当时到底长什么样的。这时候就需要etcd层面的兜底。etcd快照的实现方式不复杂。如果etcd是集群外部独立部署的直接用etcdctl snapshot save如果是托管集群通常控制面不开放etcd端口那这个方案就走不通。对我们自建的裸金属集群我在备份Job里加了一步通过跳板机在etcd节点上执行etcdctl snapshot save /backup/etcd-snapshot.db然后拉回备份机统一归档。但说实话etcd快照我们只定期执行不做高频调度。原因很实在etcd快照文件动辄几个GB跨机房传输成本高而且etcd快照的恢复操作要停机不能在业务运行中进行。它更像是最后一道防线应对集群彻底损坏、API Server数据全丢的灾难场景。日常的备份恢复演练完全靠资源导出链路就够了。3. client-go的核心代码实现整套工具的核心是一个大约2000行的Golang程序依赖的库包括k8s.io/client-go、k8s.io/apimachinery和sigs.k8s.io/yaml。下面把我认为关键的几个实现点拆开讲。3.1 初始化客户端与动态资源客户端普通的kubectl风格客户端在备份场景不够用因为你必须处理任意GroupVersionResource而不是事先写死。这里用client-go/dynamic包是比较稳妥的选择。// 使用kubeconfig创建dynamic client config, err : clientcmd.BuildConfigFromFlags(, kubeconfigPath) if err ! nil { return nil, err } // 调高客户端QPS大集群备份时很关键 config.QPS 100 config.Burst 200 dynamicClient, err : dynamic.NewForConfig(config) if err ! nil { return nil, err }dynamic.NewForConfig返回的是一个DynamicClient配合schema.GroupVersionResource就可以拉取任意资源的全量对象。需要注意QPS和Burst这两个参数默认值很小QPS默认只有5如果集群里有几千个资源备份任务跑起来会被RateLimiter卡住半天拉不完。调高这两个值之后备份耗时直线下降。3.2 全量资源枚举与YAML持久化枚举所有资源类型时我是先从/apis和/api/v1两个端点把APIResourceList全部拉出来过滤掉verbs不包含list的资源再过滤掉subresources为true的项剩下的就是可以备份的候选列表。func ListAllResources(dynamicClient dynamic.Interface, groupVersion string) ([]schema.GroupVersionResource, error) { // 通过DiscoveryClient获取APIResourceList // 过滤slices构建GVR列表 }拿到GVR列表之后每类资源调用dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})。这里有一个容易踩的坑有些资源是集群级别的比如Node、PV、ClusterRole调用时不能传Namespace有些资源是命名空间级别的比如Pod、Service。判断逻辑很简单APIResource结构体里的Namespaced字段true就带Namespacefalse就不带。导出结果的持久化格式我选的是YAML文件每个资源对象一个文件按资源类型/命名空间/资源名.yaml的目录结构存放。每个文件的内容不是原始的JSON而是经过runtime.DefaultUnstructuredConverter.FromUnstructured转换后的对象再用sigs.k8s.io/yaml库序列化成YAML。这样做的最大好处是这些文件脱离工具本身也能直接使用kubectl apply -f任意一个目录都能独立恢复。3.3 关键设计如何保证备份文件的可恢复性这一节是我在多次恢复演练后被逼出来的改进。最初版本的备份文件就是原始资源的unstructured数据直接序列化结果恢复的时候发现三个大坑。第一个坑资源对象的metadata.managedFields混了一大堆字段管理器信息恢复时可能导致冲突。我的处理是导出时手动剔除metadata.managedFields、metadata.resourceVersion、metadata.uid、metadata.creationTimestamp、metadata.generation这类运行时字段只保留metadata.name、metadata.namespace、metadata.labels、metadata.annotations。第二个坑默认值问题。Deployment等资源的spec.selector和spec.template.metadata.labels必须手动保证匹配如果原集群里因为历史原因产生的标签不对齐恢复时会被API Server拒绝。我在导出时加了一段校验逻辑发现不匹配的直接记录WARN日志同时自动修正为新版的推荐结构。第三个坑Secret和ConfigMap的data字段一般是Base64编码的字符串但如果备份时需要做脱敏就必须在序列化前遍历data和stringData字段做深度替换。我的实现里维护了一个敏感字段前缀列表比如password、token、secretKey匹配到的值统一替换成***redacted***恢复前再按需还原。4. 自动化调度的工程化设计工具本身写好了只算完成了一半真正让它自动化的是调度层。这里我分了三块任务触发、任务状态管理和多个备份任务并发时的冲突规避。4.1 CronJob触发与任务状态持久化调度触发器我用的是Kubernetes原生CronJob而不是在工具内部自己实现定时逻辑。原因很简单CronJob本身是集群声明式的调度过程和失败重试都由API Server保证不需要我再写一套守护进程。CronJob的YAML大致长这样apiVersion: batch/v1 kind: CronJob metadata: name: cluster-backup namespace: ops spec: schedule: 10 2 * * * concurrencyPolicy: Forbid startingDeadlineSeconds: 300 jobTemplate: spec: template: spec: restartPolicy: OnFailure serviceAccountName: backup-sa # 容器镜像里打包了备份工具的二进制 containers: - name: backup image: registry.internal/ops/cluster-backup:1.4.2 args: [--modebackup, --kubeconfig/etc/kubeconfig/config] volumeMounts: - name: kubeconfig mountPath: /etc/kubeconfig有几个点要强调。concurrencyPolicy: Forbid是必须的否则备份时长超过调度周期时会出现两个备份任务同时跑它们访问同一个API Server虽然不会有数据损坏但会产生双倍负载还可能把归档文件写乱。startingDeadlineSeconds设成300是防止集群临时不可用导致任务直接错过窗口期。任务状态管理我用的是一个专门的ConfigMap字段叫status记录每次备份的开始时间、结束时间、耗时、资源总数、失败项等。这样不仅人能看监控系统也可以读ConfigMap里的字段做告警。关键是要在任务入口和出口各更新一次保证宕机时能看到上次备份未完成。4.2 LeaderElection多副本下的单点任务约束CronJob调度本身是集群级的但如果为了高可用给工具做了多副本比如把工具部署成Deployment而不是CronJob就一定要做LeaderElection。Kubernetes Coordinator场景下的LeaderElection实现很成熟直接用client-go/tools/leaderelection包就行。lock : resourcelock.LeaseLock{ LeaseMeta: metav1.ObjectMeta{ Name: cluster-backup-leader, Namespace: ops, }, Client: kubeClient.CoordinationV1(), LockConfig: resourcelock.ResourceLockConfig{ Identity: hostname, }, } leaderelection.RunOrDie(ctx, leaderelection.LeaderElectionConfig{ Lock: lock, ReleaseOnCancel: true, LeaseDuration: 15 * time.Second, RenewDeadline: 10 * time.Second, RetryPeriod: 2 * time.Second, Callbacks: leaderelection.LeaderCallbacks{ OnStartedLeading: func(ctx context.Context) { RunBackup(ctx) }, OnStoppedLeading: func() { klog.Info(lost leadership) }, }, })这里也踩过坑LeaseDuration设得过短比如5秒在集群网络抖动时Master频繁切换两个副本会交替执行备份造成重复归档。我把三个时间参数按官方建议的2:1.5:1比例放大到15/10/2秒实测稳定很多。4.3 备份完成后的异地同步与生命周期清理备份文件不能只留在集群所在机器的本地磁盘上这跟没备份有什么区别我设计了三层存储第一层备份Job所在节点的本地目录用于快速读取第二层对象存储或内部归档平台的Bucket用于长期保存第三层离线导出的冷存储介质用于极端灾难恢复。Golang里做这个只需要多个Writer实现。归档到对象存储时我加了个简单的rsync式增量逻辑文件先按集群名/日期/组织对每个文件计算SHA256如果目标Bucket里已有同名文件且哈希一致就跳过上传。别小看这个逻辑几十套集群每天备份的数据量加起来能省下不少存储费用和带宽。生命周期清理同样容易忽略。我保留最近30天每天的全量备份、最近12个月每月的备份更早的自动删除。删除操作不是一次性扫描全库而是在备份完成事件里触发一个检查函数遍历Bucket内的目录时间戳按规则清理。5. 恢复流程的整体设计与一致性保障如果说备份是工具的地基那恢复就是工具的最终审判。恢复流程比备份更复杂因为同样的文件在不同顺序下apply到集群里结果可能完全不同。5.1 恢复顺序为什么重要从Namespace到依赖资源我总结了一套固定的恢复顺序大概分五步先恢复CRD。这一步不依赖任何Namespace且后续的CR实例恢复依赖CRD存在再恢复Namespace、ResourceQuota、LimitRange、NetworkPolicy然后是ServiceAccount、Role、RoleBinding、ClusterRole、ClusterRoleBinding这类权限资源接下来是ConfigMap、Secret、PVC、StorageClass这些基础配置类资源最后才是Deployment、StatefulSet、DaemonSet、Job、CronJob及各类CR实例。为什么顺序不能乱举个例子如果先恢复Deployment那份Deployment引用的ConfigMap或Secret还不存在Pod起来后挂载会失败。虽然Kubernetes有纠错能力ConfigMap后续创建好后Pod会自动更新但如果是PVC这类存储资源顺序错了就只能等PV创建完成才能正常挂载在恢复场景下你希望一切尽量一次成功不要依赖这种自动修复。恢复的执行方式我直接调用kubectl apply子进程而不是在Golang里重新实现一套apply逻辑。原因是kubectl apply已经处理好了资源冲突、字段合并、server-side apply等细节自己实现一遍工程量太大性价比太低。程序里用os/exec按顺序执行即可。func ApplyBatch(files []string) error { for _, file : range files { cmd : exec.Command(kubectl, apply, --server-side, -f, file) if output, err : cmd.CombinedOutput(); err ! nil { return fmt.Errorf(apply %s failed: %v, output: %s, file, err, string(output)) } } return nil }5.2 版本兼容性与集群迁移的设计取舍恢复目标集群的版本可能和备份源集群不一致比如源集群是v1.26.0目标集群已经升到v1.28。多数情况下API Schema向后兼容直接apply不会出问题。但有一个高频问题某些资源被弃用或从某个版本移除后备出来的YAML在目标集群上无法识别。比如extensions/v1beta1下的Ingress在v1.22之后就被移除了如果备份的还是老API版本恢复时就只能手动转换。我的解决方案是在导出时做一次API版本归一化凡是apiextensions.k8s.io/v1beta1、extensions/v1beta1、apps/v1beta1这类资源的apiVersion统一转换成当前的稳定版本比如apps/v1、networking.k8s.io/v1。转换逻辑不复杂本质上就是查表替换apiVersion字段。这一条在集群迁移场景的价值非常大直接决定了备份文件是否能跨版本使用。集群迁移时还要注意一个隐性依赖PV和StorageClass的对应关系。如果新集群的Cloud Provider和旧集群不同原来绑定到某个StorageClass的PVC可能在目标集群里没有对应的Provisioner。我做一个映射表在恢复时把PVC里的storageClassName替换成目标集群存在的StorageClass避免恢复后PVC一直处于Pending状态。6. 实战踩坑记录写了这么久把实际运维过程中踩过的坑集中整理一下。这些才是文档里不会写的东西。6.1 init using kubernetes version: v1.26.0与preflight排查在恢复演练时我遇到过这个报错[init] using kubernetes version: v1.26.0 [preflight] running pre-flight checks后面跟着一堆[ERROR]。这不是我们备份工具的问题而是在从备份恢复出一个全新集群场景里kubeadm初始化新集群时遇到的。这个场景是这样的集群全挂了从备份恢复需要先重新搭建一套全新的Kubernetes集群再把备份数据apply进去。kubeadm init的preflight检查会卡在各种前置条件上比如端口占用、容器运行时版本、swap未关闭、cgroup驱动不一致等。我的经验是仔细读它列出的每条[ERROR]逐条解决不要想着一键跳过。最常遇到的是运行时版本过旧、kubelet服务和kubeadm版本不匹配先把这两个对齐再继续。还有一个preflight检查的细节[WARNING Swap]虽然只是警告但在生产环境还是建议彻底关闭swap再装集群否则后续Pod调度和内存限制会出现预期之外的行为。6.2 大集群下client-go的QPS限制前面提过QPS默认值是5但我第一次跑备份就败在这上面。一个3000多个资源对象的中型集群用默认配置备份到一半直接超时。日志里疯狂刷RateLimited错误。后来我把QPS调到了100、Burst调到了200又做了一层并发控制按GVR列表分成多个goroutine并发拉取每个goroutine等待一个带缓冲的token通道。实测下来同样规模的集群从40多分钟缩短到6分钟性能提升非常明显。需要注意一个副作用QPS调高后备份工具对API Server的瞬时压力会明显变大。如果你的集群本来就资源紧张建议控制在50左右并且把备份时间放在业务低谷段。6.3 恢复时的Webhook拦截问题这个坑最隐蔽。我们集群里部署了不少Webhook比如Pod安全策略校验、Sidecar注入、自定义的准入控制。恢复Deployment的时候kubectl apply创建Pod会被这些Webhook拦截如果Webhook本身的配置还没有被恢复因为还在备份文件里没apply到API Server会报connection refused导致整个Deployment恢复失败。更麻烦的是这种失败不是apply方的问题而是集群内部某个组件不可用。排查起来很费力因为报错信息只会说Internal error occurred: failed calling webhook不会告诉你是哪个Webhook。解决思路有两个要么在恢复时临时移除所有ValidatingWebhookConfiguration和MutatingWebhookConfiguration恢复完成后再加回来要么把Webhook配置的恢复顺序调整到其他应用资源之前。我选的是前者因为Webhook配置里有大量企业定制的规则它们的恢复必须依赖对应的Webhook服务也就是Deployment先起来。实际操作里我写了一个标志位--skip-webhooks在恢复脚本里先备份Webhook配置恢复时先跳过等所有其他资源恢复完成后再执行一次只针对Webhook配置的apply。6.4 恢复之后的健康检查清单恢复完成不能以所有资源状态都为Running作为成功标准这只是及格线。我额外加了一个健康检查脚本大概包含以下内容检查所有Deployment的可用副本数是否等于期望副本数检查所有Pod的Ready状态是否持续稳定超过5分钟而不是刚起来那一下的状态检查PVC的Bound状态是否正常PV容量是否匹配抽查部分服务的Endpoints是否非空确保Service真正能转发流量对核心业务Pod做一次HTTP健康探测确认业务层没问题。这套检查项运行完才算一次完整的恢复验证。7. 一点个人心得这套工具从第一版到现在迭代了十几次形态也稳定了。回顾下来最有价值的不是那2000行Golang代码而是设计过程中把备份恢复这件事彻底想清楚了备份不是把数据拷贝出去而是灾难来临后你有多大的把握重新构建出那个系统。如果你也打算自研类似工具我的建议是先花一周时间做一次真实的恢复演练哪怕只恢复一个测试集群。演练会逼你暴露所有平时看不见的问题备份文件里有没有漏洞、恢复顺序是否合理、依赖关系是否被遗漏。这些问题在自己项目里看代码永远发现不了。最后再分享一个小技巧备份工具的版本号最好和它生成的备份文件绑定。在备份文件根目录里写入一个meta.json记录工具版本号、集群版本、备份日期、资源数量。将来如果工具升级改变了导出格式你还能根据meta.json判断旧备份文件是否需要兼容处理。这个细节在多次集群迁移中帮了我大忙。
返回列表