背景#

我的 Homelab 由两个 K3s 集群组成:

  • homelab (Proxmox VM):运行 Vault、ZITADEL SSO、Calibre-Web、Grafana 等核心服务
  • oracle-k3s (Oracle Cloud):运行 Miniflux RSS、KaraKeep 书签、Uptime Kuma 监控等轻量服务

整套基础设施通过 Terraform、Ansible、ArgoCD 实现了完整的 IaC + GitOps 管理。理论上,任何一个集群崩溃,我都可以通过 Git 仓库里的代码重建一切。

但有一个致命的盲区:数据

IaC 可以重建基础设施,GitOps 可以重建应用配置,但 Vault 的密钥、ZITADEL 的用户数据、Miniflux 的 RSS 阅读历史——这些有状态数据一旦丢失,就真的没了。

本文记录我如何在这套双集群架构上构建自动化的 Kopia 备份体系,顺便分享修复一个 Traefik 路由 bug 的过程。

问题发现:backup.meirong.dev 无法访问#

在着手做备份自动化之前,我发现 Kopia 的 Web UI (backup.meirong.dev) 打不开了。

排查过程#

先检查 Pod 状态——Kopia 正常运行:

$ kubectl get pods -n kopia
NAME                     READY   STATUS    RESTARTS   AGE
kopia-56cf7cd87f-mp9xn   1/1     Running   0          161m

查看日志,关键信息:

SERVER ADDRESS: http://[::]:51515

注意这里是 HTTP,因为 Kopia 启动参数是 --insecure(HTTP 模式)。

但 Traefik 的路由配置是这样的:

# TraefikService 配置了 scheme: https
apiVersion: traefik.io/v1alpha1
kind: TraefikService
metadata:
  name: kopia-ts
  namespace: kopia
spec:
  weighted:
    services:
      - name: kopia
        port: 51515
        scheme: https              # ← 问题在这里!
        serversTransport: kopia-transport

Traefik 认为后端是 HTTPS,但实际是 HTTP。TLS 握手自然失败:

TLS connect error: error:0A00010B:SSL routines::wrong version number

修复#

最简单的方案:去掉 TraefikService 中间层,让 HTTPRoute 直连 Kopia 的 Service:

# 修复前:通过 TraefikService 代理 (scheme: https)
backendRefs:
  - group: traefik.io
    kind: TraefikService
    name: kopia-ts
    port: 51515

# 修复后:直接路由到 Service (HTTP)
backendRefs:
  - group: ""
    kind: Service
    name: kopia
    port: 51515
    weight: 1

同时删除不再需要的 ServersTransportTraefikService 资源。

ArgoCD 自愈的坑#

改完 manifest 后执行 kubectl apply — 表面上成功,1 秒后 ArgoCD 就把修改撤回了。

原因:ArgoCD 配置了 selfHeal: true,它从 GitHub 远端同步,而我的改动还没 push。活状态和 Git 不一致时,ArgoCD 会立刻撤销掉任何手动修改。

解决方法很简单:先 git push,再让 ArgoCD 同步。这也正是 GitOps 的核心哲学——Git 是唯一的真相来源

数据分类#

在做备份策略之前,先对所有有状态数据做分类。我用了一个简单的优先级模型:

优先级 服务 集群 数据特征
🔴 P0 Vault homelab 所有服务的 secrets 源头,丢失 = 全部服务失联
🔴 P0 ZITADEL PostgreSQL homelab SSO 身份数据,丢失 = 所有用户无法登录
🟡 P1 Calibre-Web homelab 电子书库配置
🟡 P1 Miniflux PostgreSQL oracle-k3s RSS 订阅与阅读历史
🟡 P1 KaraKeep oracle-k3s 书签与全文快照 (SQLite)
🟡 P1 Gotify homelab 通知历史
🟢 P2 Uptime Kuma oracle-k3s 监控历史 (SQLite)
🟢 P2 Timeslot oracle-k3s 日历数据 (SQLite)
⚪ 无需 IT-Tools / PDF / Squoosh oracle-k3s 无状态,随时可重建

关键洞察:P0 数据决定了整个系统能否正常运行, P1 数据丢失只是不方便但不致命。

Kopia 备份架构#

整体设计#

homelab NFS volumes ──→ Kopia CronJob (本地) ──→ Kopia repository (NFS 1Ti)
                                                       ↑
oracle-k3s volumes ──→ Kopia CronJob ──(Tailscale)──→ Kopia server (NodePort 31515)

为什么选 Kopia#

在 K8s 生态里备份方案不少(Velero、Longhorn 快照、Restic 等),选 Kopia 的原因:

  1. 增量去重:支持内容寻址存储,增量快照效率高
  2. 服务端/客户端模式:oracle-k3s 可以通过网络直连 homelab 的 Kopia server
  3. 标签系统:可以给快照打标签 (cluster:homelab, priority:P0),方便筛选
  4. Web UIbackup.meirong.dev 提供可视化管理

homelab 集群备份 CronJob#

homelab 的备份 CronJob 设计为一个多阶段 Pod:

initContainer[0]: vault-backup       # cp Vault 数据目录
initContainer[1]: zitadel-pg-dump    # pg_dump ZITADEL PostgreSQL
initContainer[2]: calibre-config     # cp Calibre-Web 配置
initContainer[3]: gotify-backup      # cp Gotify 数据
container:        kopia-snapshot     # 统一创建 Kopia 快照

关键设计决策:

1. 使用 initContainer 链式执行

每个 initContainer 负责一个服务的数据提取,写入共享的 emptyDir staging 目录。最后一个 main container 统一做 Kopia 快照。这样任一步骤失败都有清晰的错误边界。

2. 跨命名空间访问 PVC

Vault 数据在 vault namespace,ZITADEL PG 在 zitadel namespace,但 CronJob 在 kopia namespace。

对于 NFS PVC(ReadWriteMany),K8s 允许跨 namespace 挂载同一个底层 NFS volume。对于 PostgreSQL,使用 service DNS (zitadel-db-postgresql.zitadel.svc.cluster.local) 做 pg_dump

3. 标签驱动的快照管理

每个快照都带有 clusterservicepriority 标签:

kopia snapshot create /staging/vault \
  --tags="cluster:homelab" \
  --tags="service:vault" \
  --tags="priority:P0"

恢复时可以按标签精确筛选。

oracle-k3s 跨集群备份#

oracle-k3s 的 CronJob 更有趣——需要通过 Tailscale 网络连接到 homelab 的 Kopia server。

两个 CronJob 错开时间避免冲突:

  • rss-system: 03:00 UTC — Miniflux pg_dump + KaraKeep SQLite
  • personal-services: 03:30 UTC — Uptime Kuma SQLite + Timeslot SQLite

连接方式:

kopia repository connect server \
  --url="$KOPIA_SERVER_URL" \                    # https://100.96.84.32:31515
  --server-cert-fingerprint="$KOPIA_SERVER_FINGERPRINT" \
  --override-username=backup \
  --override-hostname=oracle-k3s

credentials 通过 ESO (External Secrets Operator) 从 Vault 同步,不落 Git。

SQLite 备份的注意事项#

SQLite 数据库不能直接对运行中的 .db 文件做快照,WAL 文件 (.db-wal) 可能包含未写入的数据。我的做法是把 .db + .db-wal + .db-shm 三个文件一起复制:

cp /uptime-kuma/kuma.db /staging/uptime-kuma/kuma.db
cp /uptime-kuma/kuma.db-wal /staging/uptime-kuma/kuma.db-wal 2>/dev/null || true
cp /uptime-kuma/kuma.db-shm /staging/uptime-kuma/kuma.db-shm 2>/dev/null || true

PVC 以 readOnly: true 挂载,确保备份过程不会影响正在运行的服务。

GitOps 管理备份#

所有备份配置都以 YAML 清单的形式存在 Git 仓库中:

k8s/helm/manifests/
├── kopia.yaml              # Kopia server Deployment + Service
└── kopia-backup.yaml       # Backup CronJob + ExternalSecret

cloud/oracle/manifests/
├── rss-system/
│   └── backup-cronjob.yaml # Miniflux + KaraKeep 备份
└── personal-services/
    └── backup-cronjob.yaml # Uptime Kuma + Timeslot 备份

ArgoCD Application 配置:

# argocd/applications/kopia.yaml
source:
  path: k8s/helm/manifests
  directory:
    include: "{kopia.yaml,kopia-backup.yaml}"
syncPolicy:
  automated:
    prune: true
    selfHeal: true

修改备份策略只需要编辑 YAML → git push → ArgoCD 自动同步。

灾难恢复 SOP#

有了备份,还需要可执行的恢复流程。完整的灾难恢复顺序:

1. Proxmox VM 重建        → terraform apply (proxmox/)
2. K3s 安装               → just setup-k8s (k8s/ansible/)
3. NFS Provisioner 安装   → just setup-nfs-provisioner
4. Vault 数据恢复 + unseal → kopia restore + just vault-unseal
5. ESO 安装               → Vault secrets 同步到 K8s
6. ArgoCD 安装            → just deploy-argocd
7. ArgoCD 同步所有 App    → just argocd-sync
8. ZITADEL PG 恢复        → pg_restore
9. 验证 SSO 链路          → curl auth.meirong.dev
10. Cloudflare DNS         → just apply (cloudflare/terraform/)

关键点:步骤 1-3 和 5-7 完全由 IaC/GitOps 自动完成。唯一需要手动干预的是步骤 4(Vault 恢复和 unseal)和步骤 8(ZITADEL 数据库恢复)。

经验总结#

1. GitOps 先行原则#

这次调试 backup.meirong.dev 遇到的 ArgoCD 自愈问题,再次印证了一个原则:所有生产变更都必须先经过 Git

直接 kubectl apply 在 ArgoCD selfHeal 模式下是无效的。正确的工作流是:

编辑 YAML → git commit → git push → ArgoCD 自动同步

2. 测试环境模拟生产路径#

调试 Traefik 路由时,我用了一个简单的 curl Pod 来模拟 Cloudflare Tunnel 到 Traefik 的请求路径:

kubectl run test --image=curlimages/curl --rm -it --restart=Never -- \
  sh -c "curl -sSv -H 'Host: backup.meirong.dev' http://traefik.kube-system.svc:80/"

这比从外网测试更快、更容易定位问题在哪一层。

3. 数据分类决定投资重点#

不是所有数据都值得同等对待。P0 数据(Vault、ZITADEL)配每日备份,P2 数据(监控历史)丢了也无所谓。分类驱动策略,避免过度工程。

4. 备份尚存的不足#

  • 无离站副本:所有备份在同一个 NFS 后端(Proxmox 主机)。NFS 故障 = 备份也丢了
  • 未做恢复演练:SOP 写了,但没有实际执行过完整的恢复流程
  • ZITADEL pg_dump 密码管理:需要在 Vault 中配置 secret/homelab/zitadeldb-password

下一步计划:添加 Backblaze B2 作为离站备份目标,并做一次完整的恢复演练。

完整变更清单#

本次实施的所有变更:

变更 文件 说明
修复 backup.meirong.dev k8s/helm/manifests/gateway.yaml TraefikService HTTPS → 直连 HTTP Service
清理冗余资源 k8s/helm/manifests/kopia.yaml 删除 ServersTransport
homelab 自动备份 k8s/helm/manifests/kopia-backup.yaml CronJob: Vault + ZITADEL PG + Calibre + Gotify
ArgoCD 更新 argocd/applications/kopia.yaml include 新增 kopia-backup.yaml
运维手册更新 docs/runbooks/backup-recovery.md 自动备份调度文档
架构文档更新 docs/architecture/TODO.md 标记已完成项

所有变更通过一次 Git push 即可生效——ArgoCD 在 3 分钟内自动同步到集群。