EKS Vault Raft 장애 복구: Pod와 PVC 삭제 후 클러스터 재구성
Raft 스토리지 백엔드로 EKS에 올린 Vault에서 Pod 하나가 죽는 상황은 StatefulSet이 알아서 정리해 줍니다. PVC까지 같이 사라지면 이야기가 달라집니다. Pod는 다시 뜨지만 그 안의 Vault는 데이터가 없는 상태로 기동합니다. 이 글은 vault-0의 Pod와 PVC가 함께 삭제된 상황을 되돌리는 절차를 명령 단위로 정리한 것입니다. 명령마다 어느 Pod에서 실행하는지와 토큰이 필요한지를 함께 적었고, 데이터가 남아 있는 노드는 vault-1로 고정해 부르겠습니다.
PVC가 사라지면 무엇이 사라지나
Vault의 Raft(Integrated Storage) 백엔드는 외부 저장소를 두지 않습니다. HashiCorp Integrated Storage 문서는 이 방식을 "서버의 파일시스템에 Vault 데이터를 저장하고, 합의 프로토콜로 클러스터의 각 서버에 복제한다"고 설명합니다. 데이터가 노드의 디스크에 있다는 뜻이고, 그래서 디스크를 잃으면 그 노드의 몫이 통째로 없어집니다.
Helm chart로 배포하면 Vault는 StatefulSet으로 뜹니다. 쿠버네티스 공식 문서의 StatefulSet 설명대로 volumeClaimTemplates가 Pod마다 PVC를 하나씩 만들고, 이름은 <볼륨 클레임 템플릿 이름>-<Pod 이름> 형태가 됩니다. vault-helm 차트의 템플릿 이름이 data이니 vault-0에 붙는 PVC는 data-vault-0입니다. 같은 StatefulSet 문서는 "StatefulSet을 지우거나 스케일을 줄여도 StatefulSet에 딸린 볼륨은 삭제되지 않는다"고 적고, 그 이유로 "관련 리소스를 자동으로 정리하는 것보다 데이터 안전이 일반적으로 더 가치 있다"를 듭니다. persistentVolumeClaimRetentionPolicy를 따로 설정하지 않으면 PVC는 그대로 남으므로, Pod만 지우는 것으로는 데이터가 사라지지 않습니다.
다행히 Raft는 과반만 살아 있으면 계속 돕니다. Integrated Storage 문서는 "여기 나열된 투표 노드가 모두 정족수에 기여하며, Integrated Storage가 계속 동작하려면 과반이 살아 있어야 한다"고 적고 있습니다. 3노드에서 하나를 잃어도 남은 둘이 과반이라 읽기와 쓰기는 그대로 처리되니, 복구는 살아 있는 클러스터에 vault-0을 다시 붙이는 작업으로 좁혀집니다.
테스트 환경은 다음과 같습니다.
- HashiCorp Vault 1.19+
- Kubernetes(EKS), Helm chart 배포
- Raft 스토리지 백엔드, PVC는 EBS
- Vault Pod 3개 (
vault-0,vault-1,vault-2) - 단일 AZ에 배치된 EKS NodeGroup
원문 가이드는 마지막 줄을 "us-west-1 AZ"라고 적었는데, 표기를 짚고 넘어가야 뒤 이야기가 이어집니다. AWS 문서는 AZ 코드를 "리전 코드 뒤에 문자 식별자를 붙인" 형태로 정의하고 us-east-1a를 예로 듭니다. us-west-1은 리전 코드라 그 자체로는 AZ를 가리키지 못합니다. 아래 AZ 분산 이야기는 이 구분을 전제로 합니다.
장애 재현: 삭제 순서가 결과를 가릅니다
원문 가이드는 이 순서로 지웁니다.
kubectl delete pod vault-0 -n vault
kubectl delete pvc data-vault-0 -n vault
이 순서로는 원하는 상태에 도달하지 못합니다. StatefulSet 컨트롤러가 vault-0을 곧바로 다시 만들고, 새 Pod가 아직 남아 있는 data-vault-0을 그대로 다시 마운트하기 때문입니다. 그다음에 실행한 PVC 삭제는 쿠버네티스 공식 문서의 Storage Object in Use Protection에 걸립니다. "Pod가 사용 중인 PVC를 삭제하면 PVC는 즉시 제거되지 않고, 어떤 Pod도 그 PVC를 쓰지 않게 될 때까지 삭제가 미뤄진다." PVC는 Terminating에 멈춰 서고 Finalizers에는 kubernetes.io/pvc-protection이 남습니다. vault-0은 데이터를 그대로 들고 정상 기동합니다.
빈 볼륨에 도달하려면 PVC를 먼저 지우고 Pod를 지웁니다.
kubectl delete pvc data-vault-0 -n vault # Terminating 으로 들어감
kubectl delete pod vault-0 -n vault # Pod가 빠지면서 PVC 삭제가 실제로 완료됨
Pod가 빠지는 순간 finalizer가 풀리고 PVC가 실제로 삭제됩니다. 그 뒤 StatefulSet 컨트롤러가 vault-0을 다시 만들면서 volumeClaimTemplates로 새 PVC를 프로비저닝합니다. 새 Pod가 삭제 완료보다 먼저 스케줄돼 Terminating 상태의 PVC를 도로 잡는 경우도 있으니, PVC가 실제로 사라졌는지는 눈으로 확인하는 편이 낫습니다. 그대로 남아 있으면 Pod를 한 번 더 지우면 됩니다.
kubectl get pvc -n vault -w
새 PVC가 빈 볼륨으로 붙고 나면 vault-0은 데이터 없이 기동합니다. vault status에는 Initialized: false가 찍히고, 이 상태에서는 unseal key를 넣어도 받지 않습니다. 초기화되지 않은 Vault에는 unseal할 대상이 없기 때문입니다.
절차를 고르기 전에 상태부터 구분합니다. Pod 자체가 못 뜨는 것과, Pod는 떴는데 Vault가 초기화되지 않은 것은 다른 문제입니다. kubectl describe pod vault-0 -n vault와 vault status를 먼저 봅니다. Pending이나 CrashLoopBackOff에서 멈춰 있다면 Vault가 아니라 스케줄링이나 볼륨 쪽 문제이고, 아래 join 절차는 소용이 없습니다. Pod 상태를 읽는 방법은 Kubernetes Pod 관련 글에 정리해 두었습니다.
vault operator init을 실행하면 안 되는 이유
Initialized: false를 보면 반사적으로 떠오르는 명령이 있습니다.
vault operator init
공식 문서는 초기화를 "Vault의 스토리지 백엔드가 데이터를 받을 수 있도록 준비하는 과정"이라고 설명하고, "이미 초기화된 Vault 클러스터에는 실행할 수 없다"고 못 박습니다. 초기화 때 root key가 만들어지고, 기본 설정은 Shamir's Secret Sharing으로 이 키를 쪼갭니다. 같은 문서의 옵션 기본값은 -key-shares 5, -key-threshold 3입니다.
빈 vault-0에서는 이 명령이 성공해 버립니다. 기존 클러스터와 무관한 root token과 unseal key 세트가 새로 나오고, vault-0은 자기를 리더로 하는 별도 Raft 클러스터가 됩니다.
다만 명령 실행만으로 트래픽이 곧장 갈리지는 않습니다. init 직후 vault-0은 sealed 상태이고, vault-helm 차트의 StatefulSet 템플릿은 server.readinessProbe.path를 비워 두면 기본 readiness probe로 vault status -tls-skip-verify를 exec 실행합니다. vault status의 종료 코드는 sealed일 때 2라서 probe가 실패하고, 쿠버네티스 프로브 문서에 따르면 "readiness probe가 실패 상태를 반환하면 EndpointSlice 컨트롤러가 해당 Pod에 매칭되는 모든 Service의 EndpointSlice에서 그 Pod의 IP를 제거"합니다. 문제는 그다음 손동작입니다. 새로 나온 unseal key로 vault-0을 unseal하면 probe가 통과하고 Pod IP가 다시 EndpointSlice에 들어갑니다. vault-0은 원래 Vault와 같은 Service를 쓰므로, 그 시점부터 클라이언트가 같은 주소로 보낸 요청 일부는 기존 클러스터로, 일부는 비어 있는 새 클러스터로 흘러갑니다. 새 클러스터에 쓴 데이터는 기존 클러스터에 없고 반대도 마찬가지입니다.
Vault의 HTTP API 문서는 이 상황에 쓸 API를 직접 지목합니다. /sys/storage/raft/join 항목에 "Shamir seal을 쓸 때는 Vault 서버가 올라오자마자 sys/init 대신 이 API를 호출해야 한다"고 적혀 있습니다. vault operator init은 클러스터를 처음 세울 때 쓰는 명령입니다.
복구 절차
0. 토큰이 필요한 명령 구분
vault status는 "Vault가 sealed 상태인지와 무관하게 출력"되고 종료 코드도 unsealed 0, 오류 1, sealed 2로 정해져 있어 스크립트에 걸기 좋습니다. 반면 raft list-peers와 raft remove-peer는 HTTP API 문서의 예시 요청에 X-Vault-Token 헤더가 붙어 있는 권한 명령이라 kubectl exec만 걸면 실패합니다. vault-1에 셸을 열고 토큰을 넣은 뒤 작업하는 편이 낫습니다.
kubectl exec -it -n vault vault-1 -- sh
# 컨테이너 안에서
export VAULT_TOKEN=<root token 또는 raft 권한이 있는 토큰>
vault operator raft list-peers
한 줄로 끝내려면 sh -c로 감쌉니다. 토큰이 셸 히스토리와 프로세스 목록에 남는 점은 감안하세요.
kubectl exec -n vault vault-1 -- sh -c 'VAULT_TOKEN=<token> vault operator raft list-peers'
raft join은 사정이 다릅니다. vault-0은 초기화도 unseal도 되지 않아 토큰을 낼 수가 없고, 같은 문서의 join 예시 요청에도 X-Vault-Token 헤더가 없습니다. 공식 Helm 예제 역시 토큰 설정 없이 kubectl exec로 바로 실행합니다.
1. vault-1에서 옛 peer 항목 확인과 제거
먼저 vault-1에서 현재 Raft 구성을 봅니다. Integrated Storage 클러스터 구성 튜토리얼의 예시 출력대로 CLI는 Node, Address, State, Voter 네 컬럼을 찍습니다. Node가 Raft node ID입니다.
vault-0이 어떤 ID로 돌아오는지가 여기서 갈립니다. Raft 스토리지 설정 문서는 "VAULT_RAFT_NODE_ID가 설정돼 있지 않으면 Vault가 초기화 시점에 무작위 GUID를 할당하고 그 값을 path로 지정한 디렉터리의 data/node-id에 기록한다"고 적고 있습니다. vault-helm은 이 환경 변수를 server.ha.raft.setNodeId가 참일 때만 Pod 이름으로 넣어 주는데, values.yaml의 기본값은 false입니다. 기본 설정을 그대로 썼다면 node ID는 /vault/data/node-id에 있었고 PVC와 함께 사라졌습니다. 새 vault-0은 새 GUID로 붙고, 옛 GUID 항목은 Raft 구성에 남습니다.
자동으로 치워 주지도 않습니다. Autopilot 문서에 따르면 dead server cleanup은 Vault 1.7에 들어왔지만 cleanup_dead_servers 기본값이 false이고, 켜더라도 dead_server_last_contact_threshold 기본값이 24h입니다. 지금 설정과 autopilot이 보는 클러스터 상태는 이렇게 확인합니다.
kubectl exec -n vault vault-1 -- sh -c 'VAULT_TOKEN=<token> vault operator raft autopilot state'
옛 항목은 join 전에 빼는 편이 낫습니다. Integrated Storage 문서가 말하는 과반은 구성에 등록된 투표 노드 기준이라, 죽은 항목을 남긴 채 새 vault-0을 붙이면 투표 노드가 넷이 되고 그중 하나는 영영 응답하지 않습니다. 남은 셋 중 하나만 더 빠져도 과반이 깨집니다.
kubectl exec -n vault vault-1 -- sh -c 'VAULT_TOKEN=<token> vault operator raft remove-peer <옛 node id>'
operator raft 문서는 "노드를 제거하면 다시 클러스터에 붙이기 전에 그 노드의 Raft 데이터를 삭제해야 하고, 이는 Vault 프로세스를 내리고 데이터를 지운 뒤 다시 시작하는 작업"이라고 덧붙입니다. 이번 장애에서는 PVC가 사라지면서 그 데이터도 같이 없어졌으니 추가로 할 일이 없습니다. 데이터가 멀쩡한 노드를 remove-peer로 뺐다면 이야기가 다릅니다.
2. vault-0에서 vault-1로 join
operator raft 문서의 형식은 vault operator raft join [options] <leader-api-addr>입니다. 명령을 실행하는 쪽이 클러스터에 들어갈 노드이고, 인자로 주는 주소는 이미 클러스터에 있는 노드를 가리킵니다.
Helm HA-with-Raft 예제는 vault-0을 먼저 초기화한 뒤 나머지를 이렇게 붙입니다.
kubectl exec -ti vault-1 -- vault operator raft join http://vault-0.vault-internal:8200
이번 장애는 방향이 반대입니다. 데이터가 남은 쪽이 vault-1이므로 실행은 vault-0에서 하고 주소는 vault-1을 가리켜야 합니다. 원문 가이드에 적힌 raft join http://vault-0.vault-internal:8200은 최초 구축 때의 형태입니다.
kubectl exec -ti -n vault vault-0 -- vault operator raft join http://vault-1.vault-internal:8200
TLS를 적용한 환경이면 스킴을 https로 바꿉니다. 사설 CA를 쓴다면 리더와 통신할 CA 인증서를 join 전용 옵션인 -leader-ca-cert로 넘깁니다. 같은 문서에 -leader-client-cert, -leader-client-key, 실패해도 계속 재시도하는 -retry가 함께 정리돼 있습니다.
kubectl exec -ti -n vault vault-0 -- vault operator raft join \
-leader-ca-cert=@/vault/userconfig/vault-tls/ca.crt \
https://vault-1.vault-internal:8200
vault-internal은 Helm chart가 만들어 주는 헤드리스 Service입니다. StatefulSet 문서대로 Pod는 $(podname).$(governing service domain) 형태의 DNS 이름을 갖기 때문에 vault-1.vault-internal로 vault-1을 직접 가리킬 수 있습니다.
3. vault-0 unseal
join은 명령 한 번으로 끝나지 않습니다. HTTP API 문서는 join이 두 단계로 진행된다고 적습니다. "join하는 노드가 Raft 리더 노드로부터 challenge를 받고, 이 challenge는 unseal에 성공한 뒤에야 답할 수 있다." Integrated Storage 문서도 "Shamir seal을 쓴다면 join 과정이 끝나기 전에 새 노드에 unseal key를 제공해야 한다"고 같은 이야기를 합니다. 여기서 넣는 키는 vault-1 쪽, 그러니까 기존 클러스터의 unseal key입니다.
kubectl exec -it -n vault vault-0 -- vault operator unseal
unseal 문서는 키를 인자로 넘기지 말고 "인자 없이 실행해 프롬프트로 입력받으라"고 권합니다. 인자로 주면 unseal key가 히스토리에 남기 때문입니다. 몇 번 넣어야 하는지는 초기화 때 정한 threshold를 따르니 vault status의 Threshold 값을 확인하세요.
AWS KMS 등으로 Auto-Unseal을 구성해 두었다면 이 단계는 건너뜁니다. 대신 조건이 하나 붙는데, Integrated Storage 문서는 "Auto Unseal을 쓴다면 그 노드는 join하려는 클러스터와 같은 KMS provider와 같은 Key를 쓰도록 설정돼 있어야 한다"고 적고 있습니다.
4. 상태와 peer 구성 확인
kubectl exec -n vault vault-0 -- vault status
Initialized: true와 Sealed: false가 함께 보이면 vault-0이 클러스터에 들어온 것입니다. 계속 Initialized: false라면 대상 주소, 포트, TLS 설정을 다시 봅니다.
마지막 확인은 vault-1에서 합니다.
kubectl exec -n vault vault-1 -- sh -c 'VAULT_TOKEN=<token> vault operator raft list-peers'
앞서 적은 대로 CLI 출력은 Node, Address, State, Voter 네 컬럼입니다. vault-0의 새 node ID가 follower로 올라와 있고 항목이 3개면 복구가 끝난 것입니다. 프로토콜 버전까지 보고 싶다면 CLI가 아니라 HTTP API를 씁니다. /sys/storage/raft/configuration 응답에는 서버마다 address, leader, node_id, protocol_version, voter가 들어 있고, 이 요청에는 X-Vault-Token 헤더가 필요합니다. 과반이 깨진 뒤의 시나리오는 Vault Disaster Recovery in EKS에 따로 적었습니다.
join이 진행되지 않을 때
먼저 로그를 봅니다.
kubectl logs -n vault vault-0
Vault 소스가 join 성공 시 남기는 줄은 successfully joined the raft cluster이고 leader_addr이 함께 찍힙니다. 이 줄 없이 연결 오류만 반복되면 헤드리스 Service의 endpoint에 vault-1이 올라와 있는지, NetworkPolicy나 보안 그룹이 포트를 막고 있지 않은지 봅니다. 8200만 열어 두면 부족할 수 있는데, 차트가 넣어 주는 VAULT_CLUSTER_ADDR이 https://$(HOSTNAME).<릴리스 이름>-internal:8201이라 노드 사이 Raft 트래픽은 8201로 흐릅니다.
초기화 여부만 API로 따로 보고 싶다면 /v1/sys/init을 씁니다. API 문서의 예시 요청에는 X-Vault-Token 헤더가 없어 토큰 없이 호출할 수 있습니다. 다만 127.0.0.1은 Vault 컨테이너 안의 주소이고 컨테이너 이미지에 curl이 들어 있다는 보장이 없으니, port-forward를 걸고 로컬에서 부르는 쪽이 확실합니다.
kubectl port-forward -n vault vault-0 8200:8200
curl -s http://127.0.0.1:8200/v1/sys/init
# {"initialized": false}
컨테이너 안에서는 vault status로 같은 정보를 추가 도구 없이 볼 수 있습니다. 헬스체크를 HTTP로 걸어 두었다면 /sys/health의 기본 상태 코드도 함께 알아 두면 좋습니다. 초기화되지 않았으면 501, sealed면 503입니다.
재발 대비
같은 일을 또 겪지 않으려면 손댈 곳이 몇 군데 있습니다.
Auto-Unseal부터. KMS에 unseal을 맡기면 노드가 재기동될 때마다 사람이 키를 넣지 않아도 되고, join 중간에 unseal이 끼어드는 단계도 없어집니다. Vault Pod가 쓰는 IAM 역할에 해당 KMS 키 권한이 붙어 있어야 하고, seal 설정은 Helm values의 Vault 서버 config에 들어갑니다. 이미 Shamir로 초기화한 클러스터에 나중에 KMS를 붙이는 것은 seal 마이그레이션입니다. Seal 문서는 1.5.1 이후 절차로 스탠바이 노드를 하나씩 내려 설정에 auto seal 블록을 추가하고, 다시 올린 뒤 각 키에 -migrate 플래그를 붙여 unseal 명령을 실행하고, 마지막에 액티브 노드를 step down 시키라고 안내합니다. 시작 전에 백업을 만들어 두라는 말도 같이 붙어 있습니다. 신규 구축이라면 초기화 전에 정하는 쪽이 일이 적습니다. 대신 알아 둘 것이 하나 있는데, 같은 문서는 Auto-Unseal에서 "Vault가 unseal key 대신 recovery key를 운영자에게 돌려준다"고 적고 "recovery key로는 root key를 복호화할 수 없어 auto unseal 장치가 동작하지 않을 때 Vault를 unseal하기에 충분하지 않다"고 못 박습니다. KMS 접근이 막히면 recovery key만으로는 방법이 없습니다.
스냅샷은 vault operator raft snapshot save로 뜹니다. 토큰이 필요한 명령이고 Pod 안에서 돌기 때문에 결과 파일도 Pod 파일시스템에 떨어집니다. 그대로 두면 Pod가 사라질 때 같이 사라지니, S3처럼 클러스터 밖으로 옮겨야 백업이라 부를 수 있습니다. 주기는 잃어도 되는 시간을 먼저 정하고 거기에 맞춥니다. 스냅샷은 이 글에서 다룬 한 노드 장애가 아니라 과반이 깨져 클러스터 자체가 복구 불가능해졌을 때 쓰는 수단이라, 복원 절차까지 한 번은 실제로 돌려 보는 편이 좋습니다.
PodDisruptionBudget은 노드 드레인이나 클러스터 업그레이드처럼 사람이 일으키는 자발적 중단에서 한 번에 내려갈 Pod 수를 minAvailable 또는 maxUnavailable로 제한합니다. Vault Pod가 한꺼번에 빠져 과반이 깨지는 것을 막는 장치입니다. 다만 쿠버네티스 Disruptions 문서가 "비자발적 중단은 PDB로 막을 수 없지만 budget에는 계산된다"고 적고 있듯, 노드 하드웨어 장애나 커널 패닉은 대상이 아닙니다. 같은 문서가 드는 비자발적 중단의 예에는 클러스터 관리자가 실수로 VM을 지우는 경우도 들어 있습니다.
StorageClass의 volumeBindingMode도 봐 둘 만합니다. StorageClass 문서에 따르면 지정하지 않으면 Immediate이고, 이 모드는 PVC가 만들어지는 즉시 볼륨을 바인딩·프로비저닝합니다. 토폴로지 제약이 있는 스토리지에서는 "Pod의 스케줄링 요구사항을 모르는 채로" 볼륨이 만들어져 "Pod가 스케줄 불가 상태가 될 수 있다"고 같은 문서가 경고합니다. WaitForFirstConsumer는 그 PVC를 쓰는 Pod가 생길 때까지 바인딩을 미루고, Pod의 스케줄링 제약에 맞는 토폴로지로 볼륨을 잡습니다.
마지막이 AZ입니다. 예시 환경처럼 NodeGroup이 한 AZ에만 있으면 그 AZ 장애가 곧 클러스터 전체 장애입니다. vault-helm은 기본 server.affinity에 topologyKey: kubernetes.io/hostname인 podAntiAffinity를 넣어 Vault Pod가 한 노드에 몰리지 않게 해 주지만, 노드 자체가 한 AZ에 있으면 소용이 없습니다. 노드를 여러 AZ에 나누고, 기본값이 []인 server.topologySpreadConstraints에 topology.kubernetes.io/zone 기준을 넣는 편이 낫습니다. 몇 개로 나눌지는 리전과 계정에 따라 다릅니다. AWS Availability Zones 문서는 "각 리전에 최소 세 개의 AZ가 있다"고 하면서도 US West(N. California), 즉 us-west-1에는 "신규 계정은 두 개의 AZ에 접근할 수 있다"는 각주를 답니다. 제약이 걸린 AZ 때문에 "계정마다 한 리전에서 쓸 수 있는 AZ 수가 다를 수 있다"는 설명도 같은 페이지에 있으니, 숫자를 정하기 전에 계정 기준으로 직접 확인하세요.
aws ec2 describe-availability-zones \
--filters Name=zone-type,Values=availability-zone \
--region us-west-1 --query AvailabilityZones[].ZoneName
AZ를 나누면 새 제약이 따라옵니다. AWS EBS 문서는 "볼륨과 인스턴스는 같은 가용 영역에 있어야 한다"고 적고 있습니다. Pod가 다른 AZ의 노드로 옮겨 가면 기존 PVC를 다시 붙이지 못하고, 그 결과는 이 글에서 되돌린 상태와 같아집니다.