etcd 备份与恢复:别等集群挂了才想起来没备份

etcd 备份与恢复:别等集群挂了才想起来没备份

一、那个凌晨三点没人想接的电话

见过凌晨三点的监控告警吗?etcd 集群的 Leader 选举超时,三个节点中两个磁盘损坏,watch 事件全部中断。整个 Kubernetes 集群的调度器、控制器、API Server 集体停摆。

第一反应是重启节点。没用,磁盘坏了。第二反应是看看有没有备份。运维同事翻了一通后发现——最近的 etcd snapshot 是三个月前初始化集群时顺手做的。之后的 Helm Release 记录、Service 定义、ConfigMap、Secret 全都没了。

重建一个中等规模的集群需要六小时。这还不包括验证每个微服务的配置是否完整。那次事故后团队立了一条铁律:etcd 备份没配好的集群不准上线。

基础设施不需要漂亮话。它需要你在凌晨三点不用接电话。

二、etcd 备份策略的三种方案

etcd 本质上是一个分布式的键值存储,所有 Kubernetes 集群状态(Pod、Service、ConfigMap、Secret)都存在里面。备份方案有三种:

flowchart TD A[etcd 备份策略] --> B[方案一: 定时 Snapshot] A --> C[方案二: etcd + Velero] A --> D[方案三: 外部 etcd 集群] B --> B1[etcdctl snapshot save] B --> B2[CronJob 定时执行] B --> B3[适合中小集群] C --> C1[备份到对象存储] C --> C2[支持增量备份] C --> C3[适合生产大规模集群] D --> D1[独立于 K8s 部署] D --> D2[专用备份方案] D --> D3[适合托管 etcd 场景]

方案一:定时 Snapshot。用etcdctl snapshot save命令创建全量快照,通过 CronJob 定时执行。适合节点数少于 50 的中小集群。优势是简单直接,缺点是不支持增量备份,RPO(数据恢复点目标)取决于快照间隔。

方案二:Velero + etcd。Velero 是 Kubernetes 原生的灾备工具,支持将集群状态(含 etcd 数据)备份到 S3 兼容的对象存储。它支持增量备份和定时调度,RPO 可以做到小时级别。

方案三:外部 etcd 集群。如果 etcd 是独立于 Kubernetes 部署的(如使用云厂商托管服务),备份策略由外部集群的运维方案决定。云厂商通常提供自动备份和跨区域复制。

选择哪个方案取决于两个关键指标:RPO(能容忍丢多少数据)和 RTO(恢复需要多长时间)。对于生产环境,RPO 建议不超 1 小时,RTO 建议在 30 分钟内。

三、生产级 etcd 备份与恢复实现

以下是一套完整的备份脚本和 Kubernetes CronJob 配置:

package etcdbackup import ( "context" "fmt" "os" "os/exec" "path/filepath" "time" clientv3 "go.etcd.io/etcd/client/v3" ) // BackupConfig 备份配置 type BackupConfig struct { Endpoints []string // etcd 成员地址 CertFile string // TLS 客户端证书 KeyFile string // TLS 客户端私钥 CAFile string // CA 证书 BackupDir string // 本地备份目录 RetentionDays int // 备份保留天数 } // ETCDBackup 管理 etcd 快照备份 type ETCDBackup struct { config BackupConfig client *clientv3.Client } // NewETCDBackup 创建备份管理器 func NewETCDBackup(cfg BackupConfig) (*ETCDBackup, error) { client, err := clientv3.New(clientv3.Config{ Endpoints: cfg.Endpoints, DialTimeout: 5 * time.Second, }) if err != nil { return nil, fmt.Errorf("connect to etcd: %w", err) } return &ETCDBackup{config: cfg, client: client}, nil } // CreateSnapshot 创建 etcd 快照 func (b *ETCDBackup) CreateSnapshot(ctx context.Context) (string, error) { timestamp := time.Now().Format("20060102-150405") filename := fmt.Sprintf("etcd-snapshot-%s.db", timestamp) filepath := filepath.Join(b.config.BackupDir, filename) // 使用 etcdctl 创建快照,传入 TLS 证书 args := []string{ "snapshot", "save", filepath, "--endpoints", b.config.Endpoints[0], "--cert", b.config.CertFile, "--key", b.config.KeyFile, "--cacert", b.config.CAFile, } cmd := exec.CommandContext(ctx, "etcdctl", args...) output, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("create snapshot: %w, output: %s", err, string(output)) } // 验证快照完整性 if err := b.verifySnapshot(ctx, filepath); err != nil { os.Remove(filepath) return "", fmt.Errorf("snapshot verification failed: %w", err) } // 清理过期备份 if err := b.cleanup(); err != nil { // cleanup 失败不阻断备份流程 fmt.Fprintf(os.Stderr, "cleanup old backups: %v\n", err) } return filepath, nil } // verifySnapshot 验证快照文件完整性 func (b *ETCDBackup) verifySnapshot(ctx context.Context, filepath string) error { args := []string{ "snapshot", "status", filepath, "--write-out", "table", } cmd := exec.CommandContext(ctx, "etcdctl", args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("verify snapshot: %w, output: %s", err, string(output)) } fmt.Printf("Snapshot verified: %s\n", string(output)) return nil } // Restore 从快照恢复 func (b *ETCDBackup) Restore(ctx context.Context, snapshotPath string) error { // 恢复时写入独立目录,避免覆盖当前数据 restoreDir := filepath.Join(b.config.BackupDir, "restored-etcd") args := []string{ "snapshot", "restore", snapshotPath, "--data-dir", restoreDir, "--name", "restored-member", "--initial-cluster", "restored-member=http://localhost:2380", "--initial-advertise-peer-urls", "http://localhost:2380", } cmd := exec.CommandContext(ctx, "etcdctl", args...) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("restore snapshot: %w, output: %s", err, string(output)) } fmt.Printf("Data restored to %s\n", restoreDir) return nil } // cleanup 删除超过保留期的备份文件 func (b *ETCDBackup) cleanup() error { cutoff := time.Now().AddDate(0, 0, -b.config.RetentionDays) entries, err := os.ReadDir(b.config.BackupDir) if err != nil { return fmt.Errorf("read backup dir: %w", err) } for _, entry := range entries { info, err := entry.Info() if err != nil { continue } if info.ModTime().Before(cutoff) { path := filepath.Join(b.config.BackupDir, entry.Name()) if err := os.Remove(path); err != nil { return fmt.Errorf("remove %s: %w", path, err) } } } return nil } // GetClusterHealth 检查 etcd 集群健康状态,作为备份前置检查 func (b *ETCDBackup) GetClusterHealth(ctx context.Context) error { for _, ep := range b.config.Endpoints { _, err := b.client.Status(ctx, ep) if err != nil { return fmt.Errorf("endpoint %s unhealthy: %w", ep, err) } } return nil }

对应的 Kubernetes CronJob 定义:

apiVersion: batch/v1 kind: CronJob metadata: name: etcd-backup namespace: kube-system spec: # 每小时执行一次备份 schedule: "0 * * * *" # 保留最近 3 个成功的 Job successfulJobsHistoryLimit: 3 # 保留最近 1 个失败的 Job failedJobsHistoryLimit: 1 jobTemplate: spec: backoffLimit: 2 template: spec: containers: - name: backup image: etcd-backup:latest env: - name: ETCD_ENDPOINTS value: "https://10.0.1.10:2379,https://10.0.1.11:2379,https://10.0.1.12:2379" - name: BACKUP_DIR value: "/backups" - name: RETENTION_DAYS value: "7" volumeMounts: - name: etcd-certs mountPath: /etc/etcd/certs readOnly: true - name: backup-storage mountPath: /backups volumes: - name: etcd-certs secret: secretName: etcd-client-certs - name: backup-storage persistentVolumeClaim: claimName: etcd-backup-pvc restartPolicy: OnFailure

这套实现的关键点:

  1. 备份完成后立即验证快照完整性。用etcdctl snapshot status检查快照文件是否可读。如果验证失败,立即删除无效文件并告警。

  2. 备份前置健康检查。创建快照前先检查所有 etcd 成员的节点健康状态。任何一个成员不健康都中断备份并告警。

  3. 自动清理过期备份。按RetentionDays配置删除老旧快照,避免存储空间耗尽。

四、恢复场景与验证流程

场景一:单节点数据损坏。从健康成员的最新快照恢复数据目录,重启损坏节点,etcd 通过 Raft 日志自动同步差异数据。

场景二:全集群数据丢失(最严重)。从最新快照恢复一个新集群,配置--initial-cluster参数启动,然后重新注册所有 Kubernetes 组件。

场景三:误删数据。如果只是误删了某些 key(如一个 ConfigMap),不需要全量恢复。可以从快照中用etcdctl get提取指定 key 的内容,再etcdctl put回去。

恢复后必须做的验证清单:

  1. kubectl get nodes确认所有节点状态正常
  2. kubectl get pods -A确认核心组件 Pod 全部 Running
  3. 检查 Secret 和 ConfigMap 数量是否匹配预期
  4. 验证 Ingress 和 Service 的端点是否可达
  5. 执行一次冒烟测试部署,确认调度器正常工作

备份文件安全问题。etcd 快照包含所有 Secret 和 ConfigMap 的明文数据。备份文件必须加密存储(用 GPG 或 KMS),传输过程用 TLS。存储备份的 PVC 需要独立的 RBAC 权限控制。

备份策略的定期演练。备份文件存在不代表能恢复成功。建议每个季度做一次恢复演练,在隔离的测试环境中验证恢复流程,记录实际的 RTO 数据。

五、总结

etcd 备份是 Kubernetes 运维中最容易被忽略但后果最严重的一环。核心方案是定时全量快照 + 完整性验证 + 自动清理,通过 CronJob 自动化执行。

关键实践:备份前进行集群健康检查;备份后用snapshot status验证完整性;快照文件加密存储;恢复后执行 5 步验证清单;每季度做恢复演练。RPO 建议不超过 1 小时,存储保留 7 天以上。没有备份的集群就是一颗定时炸弹。