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 리소스 — 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으로 백업 데이터의 일관성까지 확보한 과정.
KubernetesVelero FSB 백업이 4시간 걸린다고?
defaultVolumesToFsBackup: true의 함정과 opt-in 방식으로 백업 시간 99% 단축하기
KubernetesGateway API, Ingress를 대체하는 Kubernetes 표준
SIG-Network이 4년에 걸쳐 만든 Gateway API의 핵심 리소스 3가지와 그 관계를 이해하고, Ingress와 무엇이 달라졌는지 정리한다.