EKS Vault 파드와 PVC 삭제 시 Raft 클러스터 복구 방법

4/01/2025 ·impact

EKS에 Helm으로 배포한 Vault를 Raft 스토리지 백엔드로 운영하면 파드마다 자기 EBS PVC에 데이터 사본을 들고 있습니다. 여기서 파드 하나와 그 PVC가 함께 삭제되면, StatefulSet이 파드를 다시 만들어도 새로 붙은 PVC는 비어 있습니다. Vault는 Initialized: false 상태로 올라오고 언실도 되지 않습니다. 이때 vault operator init을 실행하면 복구가 아니라 새 클러스터가 만들어집니다.

전제 환경

  • HashiCorp Vault 1.19 이상
  • EKS에 Helm(hashicorp/vault-helm)으로 배포
  • 스토리지 백엔드는 Raft, 파드마다 EBS 기반 PVC 하나
  • Vault 파드 3개 — vault-0, vault-1, vault-2
  • 단일 AZ에 배치된 노드그룹

장애는 아래 두 명령으로 재현됩니다.

kubectl delete pod vault-0 -n vault
kubectl delete pvc data-vault-0 -n vault

PVC가 사라져도 클러스터 데이터는 남는다

Raft(Integrated Storage)는 외부 저장소를 두지 않습니다. HashiCorp의 Integrated Storage 문서는 이 방식이 서버 파일시스템에 데이터를 저장하고 합의 프로토콜로 각 서버에 복제하는 구조이며, 동작을 이어 가려면 과반이 살아 있어야 한다고 설명합니다. 쿼럼 복구 튜토리얼은 쿼럼을 (n+1)/2로 계산해, 3노드 클러스터라면 최소 2대가 정상이어야 한다고 적고 있습니다.

그러니 vault-0의 PVC가 사라진 것은 그 노드의 로컬 사본이 사라진 것입니다. 데이터는 나머지 두 노드에 그대로 있고, 클러스터는 쿼럼을 유지한 채 쓰기를 계속 받습니다. 남은 일은 vault-0을 다시 합류시키는 것뿐입니다. 같은 문서는 노드가 합류에 성공하면 액티브 노드의 데이터가 그 노드로 복제되기 시작한다고 설명합니다.

세 노드가 한꺼번에 데이터를 잃은 경우는 성격이 다릅니다. 받아올 대상이 없으니 스냅샷 복원 말고는 방법이 없습니다. 살아남은 노드가 하나뿐이라 쿼럼만 깨진 상황이라면, raft/peers.json을 직접 작성해 그 노드를 단독 리더로 만드는 절차가 위 튜토리얼에 나와 있습니다.

vault operator init을 실행하면 안 되는 이유

Initialized: false라는 문구를 보면 초기화 명령을 떠올리기 쉽습니다.

vault operator init

이 명령은 새 Vault 클러스터를 만듭니다. 새 언실 키 세트와 새 루트 토큰이 발급되고, 해당 노드는 기존 Raft 구성과 무관한 별개 클러스터로 동작합니다. 원래 보관하던 언실 키로는 이 노드가 열리지 않고, 기존 시크릿과 정책도 조회되지 않습니다.

HashiCorp API 문서는 이 갈림길을 join 엔드포인트 쪽에서 직접 다룹니다. /sys/storage/raft/join 항목은 Shamir seal을 쓸 때 Vault 서버가 올라오자마자 sys/init 대신 이 API를 호출해야 한다고 적고 있습니다. 초기화가 아니라 합류가 맞다는 이야기입니다.

손이 가기 전에 상태부터 봅니다.

kubectl exec -n vault vault-0 -- vault status

Initialized: false이고 나머지 두 노드가 정상이라면, 다음 절로 넘어가면 됩니다.

raft join으로 노드를 다시 합류시키기

operator raft 명령 문서는 join을 "Raft 리더 노드의 주소를 넘겨 현재 노드를 Raft 클러스터의 피어로 합류시킨다"고 설명합니다. 이 한 문장이 두 가지를 정합니다. 명령은 합류하려는 노드, 즉 새로 올라온 vault-0 안에서 실행합니다. 인자로는 리더 노드의 주소를 넘깁니다. 방향이 헷갈리기 쉬운 지점인데, 살아 있는 노드에서 실행하며 vault-0 주소를 넘기는 반대 형태가 아닙니다.

리더가 어느 파드인지는 정상 파드에서 확인합니다.

kubectl exec -n vault vault-1 -- vault operator raft list-peers

출력에서 leadertrue인 항목이 현재 리더입니다. 아래 예시는 vault-1이 리더인 경우입니다.

kubectl exec -n vault vault-0 -- vault operator raft join http://vault-1.vault-internal:8200

주소에 쓰인 vault-internalhashicorp/vault-helm 차트가 만드는 헤드리스 서비스로, 파드별 DNS 이름을 제공합니다.

순서에도 조건이 붙습니다. 같은 문서 기준으로 Raft를 스토리지로 쓸 때는 언실 전에 join 해야 하고, HA 스토리지로만 쓸 때는 반대로 언실 후에 join 합니다. 그리고 합류하는 노드는 초기화되지 않은 상태여야 합니다. PVC가 통째로 삭제된 이번 경우는 데이터 디렉터리가 비어 있으니 조건이 맞습니다.

TLS를 쓰는 환경이라면 https:// 주소를 쓰고, 상대 노드 인증서를 검증할 CA를 -leader-ca-cert로 넘깁니다. -tls-skip-verify는 join 전용 옵션이 아니라 CLI 전역 옵션(VAULT_SKIP_VERIFY)이고, 문서는 모든 TLS 인증서 검증을 끄는 동작이니 주의해서 쓰라고 경고합니다. 상대 노드가 아직 안 떠 있을 수 있는 상황이라면 -retry로 재시도를 걸어 둘 수 있습니다.

합류가 끝나면 상태를 다시 봅니다.

kubectl exec -n vault vault-0 -- vault status

Initialized: true로 바뀌었다면 합류 자체는 성공입니다. Sealed: true라면 언실이 남았습니다. 오토 언실을 쓰지 않는 구성이라면 직접 엽니다.

kubectl exec -it -n vault vault-0 -- vault operator unseal

필요한 키 개수는 클러스터를 만들 때 정해진 임계값을 따릅니다. operator init 문서 기준 기본값은 -key-shares 5, -key-threshold 3이지만, init 시점에 얼마든지 바꿀 수 있는 값입니다. 운영 중인 클러스터의 실제 임계값을 확인하고 그만큼 입력하세요. 오토 언실을 쓰는 클러스터라면 언실 키 대신 리커버리 키(기본값 역시 5개 중 3개)를 씁니다.

마지막으로 피어 목록을 확인합니다.

kubectl exec -n vault vault-1 -- vault operator raft list-peers

vault-0 항목이 voter: true로 다시 보이면 복구가 끝난 상태입니다. 이 장애를 명령 단위로 더 잘게 쪼개, 각 명령을 어느 노드에서 실행하고 토큰이 필요한지까지 표시한 글은 EKS Vault Raft 장애 복구에 따로 있습니다.

복구가 진행되지 않을 때

합류 명령을 실행했는데 상태가 그대로라면 초기화 여부를 API로 직접 확인합니다. 대상이 127.0.0.1이므로 vault-0 안에서 실행해야 의미가 있습니다.

kubectl exec -n vault vault-0 -- curl -s http://127.0.0.1:8200/v1/sys/init

응답이 아래와 같다면 합류가 실패했거나 아직 처리 중입니다.

{"initialized": false}

다음은 로그입니다.

kubectl logs vault-0 -n vault

아래 문구가 보이면 합류가 정상 처리된 것입니다.

successfully joined raft cluster

메시지가 없다면 확인 순서는 대체로 이렇습니다. 먼저 vault-0 안에서 인자로 넘긴 DNS 이름이 해석되는지, 그 주소의 8200 포트로 연결이 되는지 봅니다. 그다음 새로 만들어진 PVC의 바인딩 상태입니다. Pending에서 멈춰 있으면 볼륨이 붙지 않아 Vault 자체가 제대로 기동하지 못합니다. 오토 언실 구성이라면 파드의 서비스 어카운트가 KMS 키를 쓸 권한을 갖고 있는지도 함께 확인합니다.

list-peers에 삭제된 vault-0의 예전 항목이 그대로 남아 걸리적거린다면, vault operator raft remove-peer <node_id>로 피어 목록에서 먼저 지우는 방법이 문서화돼 있습니다. 다만 remove-peer로 뺀 노드를 다시 넣을 때는 그 노드의 Raft 데이터를 지운 뒤 합류시켜야 합니다.

참고로 kubectl 자체가 x509: certificate has expired로 막힌다면 그건 Vault 문제가 아닙니다. 쿠버네티스 인증서 만료 쪽을 먼저 풀어야 위 명령들을 실행할 수 있습니다.

재발을 줄이는 운영 설정

retry_join으로 수동 join 없애기

vault-helm 차트의 server.ha.raft.config 기본값은 storage "raft" { path = "/vault/data" }service_registration "kubernetes" {}뿐입니다. 리더를 찾아갈 정보가 없으니 이번처럼 사람이 붙어 join을 실행해야 합니다. Raft 스토리지 설정 문서retry_join 스탠자는 클러스터의 다른 노드 연결 정보를 미리 적어 두고 노드가 스스로 리더를 찾게 하는 용도입니다. 파드 세 개 주소를 모두 적어 두면 어느 쪽이 리더든 상관없어집니다.

storage "raft" {
 path = "/vault/data"

 retry_join {
 leader_api_addr = "http://vault-0.vault-internal:8200"
 }
 retry_join {
 leader_api_addr = "http://vault-1.vault-internal:8200"
 }
 retry_join {
 leader_api_addr = "http://vault-2.vault-internal:8200"
 }
}

service_registration "kubernetes" {}

스탠자 하나에는 leader_api_addrauto_join 중 하나만 넣습니다. 다만 Shamir seal을 쓰는 한, 자동으로 합류한 노드도 언실은 여전히 수동이라고 문서가 명시합니다.

오토 언실

언실까지 사람 손을 떼려면 seal 스탠자가 필요합니다. awskms seal 문서의 최소 형태는 이렇습니다.

seal "awskms" {
 region = "us-east-1"
 kms_key_id = "19ec80b0-dfdd-4d97-8164-c6examplekey"
}

access_keysecret_key를 생략하면 AWS SDK의 기본 자격 증명 체인(지정된 자격 증명 → 환경 변수 → 공유 자격 증명 파일 → IAM 롤/ECS 태스크 순)을 따릅니다. EKS에서는 서비스 어카운트에 IAM 롤을 연결해 이 체인에 태웁니다. 어노테이션 키는 eks.amazonaws.com/role-arn이고, 차트에서는 server.serviceAccount.annotations로 넣습니다.

server:
 serviceAccount:
 annotations:
 eks.amazonaws.com/role-arn: arn:aws:iam::111122223333:role/vault-kms

PodDisruptionBudget

차트에는 이미 들어 있습니다. server.ha.disruptionBudget.enabled가 기본 true고, maxUnavailablenull로 두면 레플리카 수 n에 대해 ⌊(n-1)/2⌋로 계산됩니다. 레플리카 3이면 1입니다. 노드 드레인 같은 자발적 중단이 한 번에 두 파드를 건드리지 못하게 막아 줍니다. 값을 손댈 일은 별로 없고, 켜져 있는지만 확인하면 됩니다.

스냅샷

스냅샷은 노드 하나가 아니라 클러스터 전체를 잃었을 때 쓰는 수단입니다.

vault operator raft snapshot save raft.snap
vault operator raft snapshot restore raft.snap

이걸 정해진 주기로 돌려 주는 자동 스냅샷 기능은 Vault Enterprise 라이선스가 필요합니다. 커뮤니티 판이라면 위 명령을 주기 실행할 수단과 저장 위치를 직접 마련해야 합니다.

볼륨의 존 바인딩

EBS는 존에 묶이는 백엔드입니다. 쿠버네티스 스토리지 클래스 문서volumeBindingMode를 지정하지 않으면 Immediate가 쓰이고, 이 모드에서는 파드의 스케줄링 요구사항을 모른 채 볼륨이 바인딩·프로비저닝되어 파드가 스케줄 불가 상태가 될 수 있다고 설명합니다. WaitForFirstConsumer는 PVC를 쓰는 파드가 생길 때까지 바인딩을 미루고, 노드 셀렉터나 어피니티 같은 파드의 스케줄링 제약에 맞는 토폴로지로 볼륨을 잡습니다. 차트의 server.dataStorage.storageClass는 기본이 null이라 클러스터 기본 스토리지 클래스를 쓰므로, 이 설정은 그 스토리지 클래스 쪽에서 확인합니다. 3개 AZ에 파드를 흩어 놓는 HA 구성을 노린다면 PodAntiAffinity와 함께 이 모드가 전제가 됩니다.

정리

  • 파드와 PVC가 함께 삭제된 노드는 Initialized: false로 기동
  • vault operator init 금지 — 별개 클러스터가 생기고 기존 언실 키로는 열리지 않음
  • join은 합류하는 노드(vault-0) 안에서, 인자는 리더 노드 주소
  • Raft가 스토리지 백엔드면 언실보다 join이 먼저
  • 언실 키는 그 클러스터의 실제 임계값만큼 (문서상 기본값은 5개 중 3개)
  • raft list-peers에서 해당 노드가 voter로 돌아왔는지 확인
  • 재발 대비는 retry_join과 오토 언실이 먼저, PDB·스냅샷·존 바인딩이 그다음