Velero로 Kubernetes 백업 시스템 구축하기
Homelab K8s 클러스터에 Velero + MinIO 백업 시스템을 구축하고 CSI 스냅샷 실패를 FSB로 해결한 뒤 복원까지 검증한 과정입니다.
들어가며
“etcd 스냅샷 있으니까 괜찮지 않아?”
Homelab 클러스터에 GitLab, Airflow, Grafana 등 다양한 서비스가 운영 중이었지만 백업 체계가 없었다. etcd 스냅샷만으로는 개별 서비스 복구가 불가능하고 PV 데이터도 보호되지 않는 상태였다.
Velero를 도입하기로 했다. 설치는 순조로웠지만 첫 백업에서 44개 에러가 발생했다. 원인을 분석하고 해결한 뒤, 실제 복원까지 검증한 과정을 정리한다.
etcd 백업 vs Velero
| 항목 | etcd 백업 | Velero |
|---|---|---|
| 대상 | 클러스터 전체 상태 (바이너리) | 선택한 namespace/리소스 |
| PV 데이터 | 포함 안 됨 | CSI 스냅샷 또는 FSB로 포함 |
| 선택적 복구 | 불가 — 전체 복원만 | namespace, 리소스 단위 복구 가능 |
| 복구 시나리오 | 클러스터가 완전히 죽었을 때 | 특정 앱/namespace 복구 |
| 복구 방법 | etcd 재시작 필요 (다운타임) | 실행 중인 클러스터에 apply |
etcd 백업은 “디스크 이미지 통째로 복원”, Velero는 “파일 단위 선택 복원”에 해당한다. 둘은 보완 관계다.
클러스터 환경
| Node | 역할 | 비고 |
|---|---|---|
| node1 | Control Plane + Worker | 일반 워크로드 |
| node2 | Worker | 일반 워크로드 |
| node3-gpu | Worker + GPU | NVIDIA RTX 3060 |
- Kubernetes v1.32.0 (kubespray)
- CNI Cilium + Gateway API
- CSI Longhorn
- 시크릿 관리 Sealed Secrets
아키텍처 설계
S3 스토리지: 서비스별 전용 MinIO
클러스터에 이미 여러 MinIO 인스턴스가 서비스별로 돌아가고 있었다:
| namespace | 용도 |
|---|---|
| airflow | Airflow 전용 |
| gitlab | GitLab 전용 (subchart) |
| loki | Loki 전용 (subchart) |
공용 MinIO를 신규 배포하는 대신, 기존 패턴과 동일하게 Velero 전용 MinIO를 구성했다:
- MinIO 장애가 발생하면 영향 범위가 해당 서비스에 한정된다
- 기존 서비스와 관리 패턴이 동일하다
- 스토리지 크기와 업그레이드를 서비스별로 관리한다
컴포넌트 구성
velero namespace├── velero (Deployment) — 백업 컨트롤러├── node-agent (DaemonSet x3) — 각 노드에서 FSB 백업├── velero-minio (Deployment) — S3 호환 스토리지└── Secrets ├── velero-minio-credentials — MinIO root 인증 └── velero-s3-credentials — Velero S3 접근 인증Velero의 백업 방식
Velero는 두 가지를 백업한다:
Kubernetes 리소스는 Velero가 API Server에서 리소스 정의(YAML)를 추출해 S3에 저장한다. Deployment, Service, ConfigMap, Secret, CRD 등 모든 리소스를 개별적으로 보관한다.
Persistent Volume 데이터에는 두 가지 방법이 있다.
| 방식 | 동작 | 장점 | 단점 |
|---|---|---|---|
| CSI 스냅샷 | 스토리지 드라이버의 스냅샷 기능 | 빠름, point-in-time | 같은 스토리지 내 저장 |
| File System Backup | NodeAgent가 파일을 직접 읽어 S3 전송 | 외부 저장 | 상대적으로 느림 |
FSB는 내부적으로 kopia를 백업 엔진으로 사용한다. kopia는 데이터를 deduplicated chunk로 분할하고 암호화하여 S3에 저장한다.
설정 및 배포
SealedSecret 생성
하드코딩 없이 Sealed Secrets로 자격증명을 관리한다. 2개의 Secret이 필요하다:
# MinIO root 인증 (rootUser, rootPassword)kubectl create secret generic velero-minio-credentials -n velero \ --from-literal=rootUser="velero-admin" \ --from-literal=rootPassword="$(openssl rand -base64 32)" \ --dry-run=client -o yaml | \ kubeseal --format yaml | kubectl apply -f -
# Velero S3 인증 (AWS credential 형식)cat > /tmp/cloud << EOF[default]aws_access_key_id=velero-adminaws_secret_access_key=<same-password>EOFkubectl create secret generic velero-s3-credentials -n velero \ --from-file=cloud=/tmp/cloud \ --dry-run=client -o yaml | \ kubeseal --format yaml | kubectl apply -f -MinIO의 root 인증과 Velero의 S3 인증을 별도 Secret으로 분리한다. 같은 자격증명을 사용하지만 형식이 다르기 때문:
velero-minio-credentials: MinIO 서버가 읽는 형식 (rootUser,rootPassword)velero-s3-credentials: Velero가 읽는 AWS credential 형식
MinIO 설정
Helm chart: minio/minio v5.4.0
mode: standalone
existingSecret: velero-minio-credentials
buckets: - name: velero policy: none purge: false
persistence: enabled: true storageClass: longhorn size: 50Gi
resources: requests: cpu: 250m memory: 512MiBitnami 이미지 유료화 이슈
처음에는 Bitnami MinIO chart를 사용하려 했으나, ImagePullBackOff 오류가 발생했다:
Failed to pull image "docker.io/bitnami/minio:2025.7.23-debian-12-r3":unexpected media type text/html2025년 8월부터 Bitnami 이미지가 유료화되어 무료 pull이 제한된다. 해결: 공식 minio/minio chart(이미지: quay.io/minio/minio)로 전환.
| 항목 | Bitnami chart | 공식 chart |
|---|---|---|
| 이미지 | docker.io/bitnami/minio | quay.io/minio/minio |
| Secret 키 | root-user, root-password | rootUser, rootPassword |
| 버킷 생성 | defaultBuckets: "name" | buckets: [{name: ...}] |
Velero 설정
Helm chart: vmware-tanzu/velero v11.3.2
initContainers: - name: velero-plugin-for-aws image: velero/velero-plugin-for-aws:v1.13.1 volumeMounts: - mountPath: /target name: plugins
configuration: backupStorageLocation: - name: default provider: aws bucket: velero default: true config: region: minio s3ForcePathStyle: "true" s3Url: http://velero-minio.velero.svc.cluster.local:9000
volumeSnapshotLocation: - name: default provider: csi
defaultVolumeSnapshotLocations: csi:default
credentials: useSecret: true existingSecret: velero-s3-credentials
deployNodeAgent: truesnapshotsEnabled: true배포
# MinIOhelm upgrade --install velero-minio minio/minio \ -f minio.yaml -n velero --version 5.4.0
# Velerohelm upgrade --install velero vmware-tanzu/velero \ -f velero.yaml -n velero --version 11.3.2
# Schedulekubectl apply -f schedule.yaml배포 결과
$ kubectl get pods -n veleroNAME READY STATUS NODEnode-agent-8x9x5 1/1 Running node2node-agent-wwjkd 1/1 Running node1node-agent-zbntw 1/1 Running node3-gpuvelero-846f566888-6rl2d 1/1 Running node1velero-minio-b489dbf6b-2wgtj 1/1 Running node1
$ kubectl get backupstoragelocation,schedule -n veleroNAME PHASE DEFAULTdefault Available true
NAME STATUS SCHEDULEdaily-backup Enabled 0 18 * * *첫 백업: CSI 스냅샷 에러
배포 다음 날, 첫 일일 백업 결과를 확인했다.
$ velero backup getNAME STATUS ERRORS ITEMSdaily-backup-20260202180033 PartiallyFailed 44 5147/51475,147개 아이템이 모두 처리되었으나 44개 에러가 발생해 PartiallyFailed 상태.
에러 분석
Velero는 백업 결과를 MinIO에 JSON으로 저장한다. MinIO client(mc)로 다운로드해서 분석했다.
kubectl -n velero port-forward svc/velero-minio 9000:9000 &mc alias set velero-local http://localhost:9000 $ACCESS_KEY $SECRET_KEYmc cp velero-local/velero/backups/daily-backup-20260202180033/...-results.gz /tmp/44개 에러가 전부 동일한 메시지였다:
unable to get valid VolumeSnapshotter for "velero.io/csi"| 항목 | 값 |
|---|---|
| 에러 유형 | VolumeSnapshotter 없음 |
| 대상 리소스 | persistentvolumes (44개) |
| StorageClass | longhorn, longhorn-retain, longhorn-1replica |
근본 원인
Velero가 각 PV에 대해 CSI 볼륨 스냅샷을 시도했지만 클러스터에 CSI 스냅샷 인프라가 구성되어 있지 않았다.
$ kubectl get volumesnapshotclassNo resources found
$ kubectl get crd | grep snapshot.storage.k8s.io# (결과 없음)CSI 볼륨 스냅샷이 동작하려면 3가지가 필요하다:
| 컴포넌트 | 역할 | 상태 |
|---|---|---|
| VolumeSnapshot CRDs | snapshot.storage.k8s.io API 정의 | 미설치 |
| snapshot-controller | VolumeSnapshot 요청을 CSI 드라이버에 전달 | 미설치 |
| VolumeSnapshotClass | 어떤 CSI 드라이버로 스냅샷할지 정의 | 미생성 |
이 3가지는 Kubernetes 코어에 포함되지 않는 외부 컴포넌트다. 클라우드 매니지드 K8s(GKE 등)에는 기본 포함되기도 하지만 kubespray로 구축한 클러스터에서는 csi_snapshot_controller_enabled: true 설정을 명시적으로 켜야 한다.
해결: CSI → FSB 전환
해결 방안 비교
| 옵션 | 방법 | 장점 | 단점 |
|---|---|---|---|
| A | CSI snapshot 인프라 설치 | point-in-time 스냅샷, 빠름 | CRD + controller 추가 필요 |
| B | snapshotVolumes: false | 가장 간단 | PV 데이터 미백업 |
| C | File-System Backup | PV 데이터 실제 백업 | CSI 스냅샷보다 느림 |
옵션 C 선택 이유:
- Velero를 배포할 때 이미
deployNodeAgent: true로 3개 노드 모두에 node-agent를 띄워놓은 상태 - FSB는 Pod에 마운트된 볼륨의 파일을 직접 읽어 MinIO에 저장하므로 복원 시 실제 데이터가 복구됨
- CSI 스냅샷은 나중에 external-snapshotter를 설치한 뒤 전환해도 늦지 않음
설정 변경
# velero.yaml — 변경 전configuration: volumeSnapshotLocation: - name: default provider: csi defaultVolumeSnapshotLocations: csi:defaultsnapshotsEnabled: true
# velero.yaml — 변경 후configuration: defaultVolumesToFsBackup: true defaultBackupStorageLocation: defaultsnapshotsEnabled: false핵심 변경:
volumeSnapshotLocation,defaultVolumeSnapshotLocations제거defaultVolumesToFsBackup: true추가 — 모든 Pod 볼륨을 FSB로 백업snapshotsEnabled: false— CSI 스냅샷 비활성화
결과 비교
| 항목 | 변경 전 (CSI) | 변경 후 (FSB) |
|---|---|---|
| Phase | PartiallyFailed | Completed |
| Errors | 44 | 0 |
| PodVolumeBackup | 0 | 130 |
| 볼륨 데이터 | 미백업 | kopia로 백업됨 |
복원 검증 (PoC)
백업이 정상 동작하는 것을 확인했으니, 실제로 복원이 되는지 검증한다.
테스트 구성
기존 서비스에 영향을 주지 않도록 임시 namespace를 생성하여 배포 → 백업 → 삭제 → 복원 패턴으로 진행했다.
namespace: velero-poc-test├── Deployment: poc-app (busybox, /data에 파일 기록)├── PVC: poc-data (1Gi, longhorn-1replica)├── Service: poc-app (ClusterIP:80)└── ConfigMap: poc-config (key1, key2, description)테스트 데이터:
$ kubectl -n velero-poc-test exec deploy/poc-app -- cat /data/test.txtVelero PoC test data - created at 2026-02-03T11:39:12+09:00checksum:abc123백업 → 삭제 → 복원
# 백업velero backup create poc-backup \ --include-namespaces velero-poc-test \ --default-volumes-to-fs-backup \ --snapshot-volumes=false --ttl 24h --wait
# 결과: Completed (27 items, 에러 0)
# 삭제kubectl delete namespace velero-poc-test
# 복원velero restore create poc-restore \ --from-backup poc-backup \ --include-namespaces velero-poc-test --wait
# 결과: Completed (14 items, 에러 0, 경고 2)복원 검증
| 리소스 | 복원 상태 |
|---|---|
| Namespace | Active |
| Pod (poc-app) | Running (1/1) |
| PVC (poc-data) | Bound (새 PV 자동 생성) |
| Service (poc-app) | ClusterIP 할당됨 |
| ConfigMap (poc-config) | key1/key2/description 정상 |
| PodVolumeRestore | Completed (kopia, 71 bytes) |
경고 2건 (무해):
wildcard.heeho.netSecret — reflector가 자동 복제한 것과 충돌하여 skipkube-root-ca.crtConfigMap — 클러스터가 자동 생성한 것과 충돌하여 skip
복원 동작 방식
Velero의 복원은 다음 순서로 진행된다:
Namespace 생성 → PVC 생성 (spec.volumeName 제거 → 동적 프로비저닝으로 새 PV 생성) → 새 PV ↔ PVC 자동 바인딩 → ConfigMap, Secret, Service 등 리소스 생성 → Deployment 생성 → Pod에 restore-wait init container 자동 주입 → node-agent가 kopia에서 볼륨 데이터를 새 PV에 복원 → restore-wait 완료 → 원래 컨테이너 시작PV/PVC 바인딩 원리
PVC에는 “내가 쓰는 PV 이름”이 spec.volumeName에 기록되어 있다. 하지만 복원 시 원본 PV는 이미 존재하지 않는다. 없는 PV를 그대로 요청하면 PVC가 영원히 Pending 상태에 빠지므로 Velero는 이 필드를 의도적으로 제거한 상태로 PVC를 생성한다. 특정 PV 지정 없이 생성된 PVC를 Kubernetes가 받으면 StorageClass의 동적 프로비저닝이 동작하여 새 PV를 자동으로 생성하고 바인딩한다.
실제 PoC에서 확인한 결과 — PVC 이름(poc-data)은 동일하지만 바인딩된 PV는 새로 생성되었다:
백업 전: PVC(poc-data) → PV(pvc-0ba69f13-...)복원 후: PVC(poc-data) → PV(pvc-bd1bfbfb-...) ← 새로 프로비저닝됨FSB 데이터 복원 원리
새 PV는 비어있는 상태다. Velero는 Deployment를 복원할 때 Pod 스펙에 restore-wait이라는 init container를 자동 주입한다. Pod가 노드에 스케줄링되면 PV가 해당 노드에 마운트되고 restore-wait이 실행되면서 대기 상태에 들어간다. 이때 해당 노드의 node-agent(DaemonSet) 가 호스트에서 PV의 마운트 경로에 직접 접근하여 Kopia 리포지토리에서 백업 데이터를 다운로드해 PV에 기록한다. restore-wait 자체는 데이터를 쓰지 않고 게이트 역할만 한다. PodVolumeRestore 상태가 Completed로 바뀌면 그때 종료되고 원래 앱 컨테이너가 시작된다.
$ kubectl get pod -l app=poc-app -o jsonpath='{.items[*].spec.initContainers[*].name}'restore-wait
$ kubectl get podvolumerestores -n velero -o wideNAME STATUS BYTES DONE TOTAL BYTES NODE UPLOADER TYPEpoc-restore-verify-2x9fk Completed 71 71 node3-gpu kopia이 메커니즘 덕분에 앱 컨테이너는 항상 복원된 데이터가 준비된 상태에서 시작하게 된다.
복원 가이드
특정 namespace 복구
# 사용 가능한 백업 확인velero backup get
# gitlab namespace만 복원velero restore create --from-backup <backup-name> --include-namespaces gitlab특정 리소스만 복구
# ConfigMap만 복원velero restore create --from-backup <backup-name> \ --include-namespaces gitlab --include-resources configmaps전체 복구 (재해 복구)
새 클러스터에 Velero + MinIO가 설치된 상태에서:
# Velero가 기존 백업을 자동 동기화velero backup get
# 전체 복원velero restore create --from-backup <backup-name>주의: 복원은 기존 리소스를 덮어쓰지 않는다. 교체하려면 namespace를 먼저 삭제 후 복원한다.
리소스 사용량
| 컴포넌트 | CPU 요청 | 메모리 요청 | 스토리지 |
|---|---|---|---|
| MinIO | 250m | 512Mi | 50Gi (Longhorn PVC) |
| Velero | 250m | 256Mi | — |
| NodeAgent (x3) | 100m each | 128Mi each | — |
| 합계 | 800m | 1.15Gi | 50Gi |
정리
| 항목 | 초기 설정 | 최종 설정 |
|---|---|---|
| 볼륨 백업 방식 | CSI Snapshot (미구성) | File-System Backup (kopia) |
| 백업 상태 | PartiallyFailed (에러 44) | Completed (에러 0) |
| PV 데이터 백업 | 실패 (전부 skip) | 130개 볼륨 백업 성공 |
| 복원 검증 | 미수행 | PoC 완료 |
향후 개선
현재 FSB로 충분하지만 CSI 스냅샷은 point-in-time 일관성과 속도 면에서 이점이 있다. kubespray에서는 변수 하나로 활성화 가능하다:
csi_snapshot_controller_enabled: true이후 Longhorn용 VolumeSnapshotClass를 생성하면 CSI 스냅샷과 FSB를 병행하거나 전환할 수 있다.
참고 자료
관련 콘텐츠
Velero 백업, 같은 클러스터에 저장하면 DR이 아니다
Velero 백업이 같은 클러스터의 MinIO에 저장돼 있었습니다. 클러스터가 죽으면 백업도 같이 사라집니다. OCI에 Garage를 배포하고 CronJob으로 DR 복제를 구성한 뒤 DB Hook으로 백업 데이터의 일관성까지 확보한 과정입니다.
Kubernetes4시간 걸리던 Velero FSB 백업 줄이기
defaultVolumesToFsBackup: true의 함정과 opt-in 방식으로 백업 시간을 99% 단축한 과정입니다.
DevOps거짓 CI 알림을 따라 내려가니 CNI 데드락 — 위아래 두 곳을 근본 수정한 기록
정적 분석이 실패해도 배포는 되는데 알림은 배포 실패처럼 울렸습니다. 그 거짓말을 따라가 CNI 부트스트랩 데드락과 알림 배선 결함을 양쪽 끝에서 각각 root fix한 기록입니다.