Vault HA 클러스터가 3일 동안 안 떴다
3번째 unseal key에서 매번 리셋되는 미스터리. 공식 문서에 없는 6가지 함정과 VM 전원을 꺼서 검증한 자동 복구까지.
들어가며
Vault를 프로덕션에서 운영해본 사람은 알 것이다. 설치는 5분, 운영은 지옥.
3개 리전에 분산된 k3s 클러스터에 Vault Raft HA를 구축하는 작업이었다. 구 클러스터에서 마이그레이션하는 거라 snapshot restore까지 해야 했다. "Vault 공식 문서대로 하면 되겠지"라는 순진한 생각으로 시작했다가 3일을 날렸다.
공식 문서에는 절대 안 나오는 함정들이 기다리고 있었다.
구성
3개 리전(안양, 강남, 싱가포르)에 걸친 k3s 클러스터에 Vault 3-node Raft HA를 구축한다.
vault-0 (gs, 강남) ←→ vault-1 (an, 안양) ←→ vault-2 (sg, 싱가포르)
↑ ↑
StatefulSet Raft Consensus |
local-path PVC 3-node cluster |
각 노드는 서로 다른 Proxmox 서버 위의 VM이고, VXLAN으로 연결된다. 즉 네트워크 레이턴시가 존재하는 실제 멀티 리전 환경이다.
함정 1: 3번째 unseal key에서 0/3 리셋
vault-0을 leader로 세운 뒤 vault-1을 raft join으로 클러스터에 조인시켰다. 여기까지는 교과서대로였다. 문제는 unseal이었다.
Key 1 → Unseal Progress: 1/3 ✅
Key 2 → Unseal Progress: 2/3 ✅
Key 3 → Unseal Progress: 0/3 ❌ ← ???
3번째 키를 넣는 순간 progress가 0으로 리셋된다. 처음엔 키를 잘못 넣었나 싶었다. 다른 키 조합으로 시도했다. 1-2-3, 1-2-4, 1-3-5, 2-3-4... 전부 동일하게 3번째에서 리셋.
구글링을 했다. GitHub issue를 뒤졌다. #25360이 비슷해 보여서 읽어봤는데, 그건 포트 8201 방화벽 문제였다. 우리 상황과 달랐다.
결국 원인을 정확히 밝히진 못했다. 다만 raft join 직후, leader가 follower에게 snapshot을 전송하는 과정에서 seal state가 리셋되는 것으로 추정된다. 버그인지 의도된 동작인지도 불명확하다.
우회법: snapshot restore
raft join 대신, follower에 직접 leader의 snapshot을 restore하는 방식으로 우회했다.
# follower에서 임시 init → 임시 키로 unseal
# (운영 환경에서는 백업·권한 통제를 마친 뒤, 접근 로그를 남겨서 수행)
vault operator init -key-shares=5 -key-threshold=3
vault operator unseal <TEMP_KEY1>
vault operator unseal <TEMP_KEY2>
vault operator unseal <TEMP_KEY3>
# leader의 snapshot을 follower에 restore
vault operator raft snapshot restore -force /tmp/snap.snap
# 재시작 후 실제 키로 unseal → 성공!
vault operator unseal <REAL_KEY1>
vault operator unseal <REAL_KEY2>
vault operator unseal <REAL_KEY3>snapshot restore를 하면 seal config 자체가 원본(leader)의 것으로 복원된다. 그래서 실제 키로 unseal이 된다. raft join을 거치지 않으니 리셋 현상도 발생하지 않는다.
다만 이러면 follower가 클러스터에 조인은 안 된 상태다. 독립 클러스터로 뜬다. 그래서 한 단계가 더 필요하다.
# unseal된 상태에서 seal → raft join → 다시 unseal
vault operator seal
vault operator raft join http://vault-0.vault-internal:8200
vault operator unseal <REAL_KEY1>
vault operator unseal <REAL_KEY2>
vault operator unseal <REAL_KEY3>이미 snapshot으로 seal config이 동기화된 상태에서 seal → join → unseal을 하면 3번째 키 리셋 없이 정상 unseal된다.
길고 복잡하지만 이게 유일하게 동작하는 방법이었다.
함정 2: sealed Pod는 DNS에서 사라진다
follower를 조인시킨 뒤, leader와 follower 간 Raft 복제가 간헐적으로 실패했다. leader 로그를 보니:
vault-1.vault-internal.vault.svc.cluster.local: no such host
sealed 상태의 Pod가 DNS에서 사라지고 있었다.
Kubernetes Headless Service는 기본적으로 Ready 상태의 Pod만 Endpoints에 등록한다. Vault Pod가 sealed이면 readinessProbe를 통과하지 못하고, DNS에서 제외될 수 있다. leader가 follower에게 Raft heartbeat를 보내려 해도 일부 구간에서 연결 자체가 안 될 수 있다.
이게 Raft 복제 실패의 근본 원인이었다.
수정은 한 줄이다.
spec:
clusterIP: None
publishNotReadyAddresses: true # 이것 하나로 해결Headless Service에 publishNotReadyAddresses: true를 추가하면 Not Ready 상태의 Pod도 DNS에 등록된다. Vault 공식 Helm 차트에도 이 설정이 들어가 있다. 하지만 매니페스트를 직접 작성하면 놓치기 쉽다.
Vault를 StatefulSet + Headless Service로 배포한다면 이 설정은 필수다. 없으면 조인/복구 초기 단계에서 클러스터가 멈추는 사례가 생길 수 있다.
함정 3: raft.db 로그 충돌
snapshot restore 후 follower가 클러스터에 조인은 됐는데, Raft 복제가 안 됐다.
failed to get previous log: previous-index=689 last-index=723 error="log not found"
leader가 AppendEntries를 보내면 follower가 reject하고, leader가 index를 줄이며 재시도하다가 결국 "log not found"에서 멈춘다.
원인은 raft.db. snapshot restore로 Raft snapshot은 복원되지만, 기존 raft.db에 남아있는 로그 엔트리가 leader의 것과 term/index가 맞지 않는다.
# raft.db만 삭제. snapshots/ 디렉토리는 반드시 유지!
rm /vault/data/raft/raft.db
# Pod 재시작 → snapshot에서 자동 복구raft.db를 삭제하면 vault가 재시작할 때 snapshots/ 디렉토리의 최신 snapshot에서 Raft state를 복구하고 새 raft.db를 생성한다. 이러면 leader와 로그가 충돌하지 않는다.
함정 4: node-id 불일치
raft.db를 삭제하고 재시작해도 Autopilot에서 Healthy: false가 뜬다.
vault-1: Healthy: false, Last Contact: 5m24s
peer list에는 70eb2f1e로 등록되어 있는데, 실제 vault-1의 /vault/data/node-id 파일에는 cc62b6f3이 적혀 있었다. vault operator init을 할 때마다 새 node-id가 생성되는데, snapshot restore + raft join 과정에서 여러 번 init을 하다 보니 node-id가 꼬인 것이다.
# peer list에서 등록된 node-id 확인
vault operator raft list-peers
# → 70eb2f1e-823f-6ada-c5a2-7e34d04634c9
# 파일에 직접 기록 (권장하지 않는 비상 수단)
echo -n "70eb2f1e-823f-6ada-c5a2-7e34d04634c9" > /vault/data/node-id이것은 최후의 수단으로만 쓰는 것을 권장한다. 가능하면 백업 가능 범위 내에서 재조립 계획을 먼저 검토하는 편이 안전하다.
함정 5: retry_join과 수동 join의 경합
configmap에 retry_join이 설정된 상태에서 vault operator raft join을 수동 실행하면, follower가 기존 클러스터에 조인하지 않고 자기 자신이 leader가 되는 현상이 발생했다.
retry_join이 백그라운드에서 먼저 실행되어 독립 클러스터를 형성해버리는 것이다.
수동 복구 작업 시에는 반드시 configmap에서 retry_join을 제거해야 한다. 작업이 끝난 뒤 다시 추가하면 된다.
함정 6: unseal 사이드카가 503에서 무한 대기
모든 문제를 해결하고 3노드 클러스터가 떴다. 그런데 Pod를 재시작하면 자동 복구가 안 된다.
unseal 사이드카가 이렇게 되어 있었다.
# vault HTTP health endpoint로 준비 상태 확인
until wget -qO- "http://127.0.0.1:8200/v1/sys/health?sealedok=true&uninitok=true"; do
sleep 5
done
# 이후 unseal 루프...retry_join이 활성화된 상태에서 vault가 시작되면 "raft retry join" 상태에 진입한다. 이 상태에서 /v1/sys/health는 어떤 파라미터를 넣어도 503을 자주 반환한다.
사이드카는 200을 기다리며 멈춰 있고, unseal을 못 하니 vault도 계속 sealed. Pod restart 시 자동 복구가 지연될 수 있다.
반면 vault status CLI는 이 상태에서도 정상 동작한다. exit code 2(sealed)를 반환하므로 unseal이 필요한 상태인지 판단할 수 있다.
# 수정: vault status CLI 기반으로 변경
while true; do
STATUS_JSON=$(vault status -format=json 2>/dev/null)
RC=$?
if [ "$RC" = "0" ]; then
break
fi
if [ "$RC" = "2" ] && echo "$STATUS_JSON" | grep -q '"sealed"[[:space:]]*:[[:space:]]*true'; then
break
fi
if [ "$RC" = "1" ] || echo "$STATUS_JSON" | grep -q '"initialized"[[:space:]]*:[[:space:]]*false'; then
echo "Vault uninitialized or unexpected state. manual check needed."
break
fi
sleep 5
done
while true; do
STATUS_JSON=$(vault status -format=json 2>/dev/null)
RC=$?
if [ "$RC" = "0" ] || [ "$RC" = "1" ]; then
break
fi
if [ "$RC" = "2" ] && echo "$STATUS_JSON" | grep -q '"sealed"[[:space:]]*:[[:space:]]*true'; then
vault operator unseal $UNSEAL_KEY1
vault operator unseal $UNSEAL_KEY2
vault operator unseal $UNSEAL_KEY3
fi
sleep 15
done이 한 가지 수정으로 Pod rolling restart 시 자동 unseal이 가능해졌다.
몽키 테스트: VM 전원을 꺼보자
클러스터가 떴다고 끝이 아니다. 실제로 노드가 죽었을 때 자동 복구가 되는지 확인해야 한다.
테스트 1: leader VM 전원 OFF
vault-0(현재 leader)이 돌아가는 VM의 전원을 껐다.
결과:
| 시간 | 이벤트 |
|---|---|
| 0초 | VM 전원 OFF |
| ~30초 | k3s 노드 NotReady |
| ~40초 | vault-2가 새 leader로 선출 |
leader가 죽은 지 40초 만에 자동 failover. KV read/write도 정상 동작했다.
# leader 죽은 상태에서 KV 쓰기
vault kv put secret/test/monkey status=ok leader=vault-2
# → 성공 ✅테스트 2: VM 전원 ON → 자동 복구
VM 전원을 다시 켰다.
| 시간 | 이벤트 |
|---|---|
| 0초 | VM 전원 ON |
| ~60초 | 노드 Ready |
| ~85초 | 사이드카 자동 unseal 완료 |
| ~90초 | Autopilot Healthy: true |
VM 전원을 꺼도 90초 내에 완전 자동 복구. 수동 개입 제로.
최종 클러스터 상태:
Healthy: true
Failure Tolerance: 1
vault-0: follower, voter, healthy ✅ (복귀 후 follower로)
vault-1: follower, voter, healthy ✅
vault-2: leader, voter, healthy ✅ (failover로 leader 승격)
정리: 공식 문서에 없는 것들
| 함정 | 증상 | 해결 |
|---|---|---|
| 3번째 unseal key 리셋 | raft join 후 unseal 3/3에서 0/3 | snapshot restore 우회 |
| DNS 미등록 | sealed Pod의 DNS가 없음 | publishNotReadyAddresses: true |
| Raft log 충돌 | failed to get previous log | raft.db 삭제 (snapshots/ 유지) |
| node-id 불일치 | Autopilot unhealthy | peer list의 ID를 파일에 직접 임시 기록 |
| retry_join 경합 | 수동 join 시 독립 클러스터 형성 | 수동 작업 시 retry_join 제거 |
| 사이드카 503 | health endpoint가 503 응답 반복 | vault status CLI로 변경 |
6개 함정 중 일부는 문서에서 일부만 다루어져 있었고, 매니페스트를 직접 작성하면 운영자 주도 대응이 필수였다.
마무리
Vault Raft HA는 컨셉은 단순하다. 3개 노드, Raft consensus, 자동 failover. 근데 실제로 구축하면 공식 문서가 커버하지 않는 엣지 케이스가 산더미다.
특히 **publishNotReadyAddresses: true**는 반드시 기억해야 한다. 이 설정이 빠지면 sealed Pod가 DNS에서 사라지는 케이스에서 Raft 통신이 불가능해질 수 있다. Vault Helm 차트에는 기본으로 들어있지만, 커스텀 매니페스트에서는 한 줄 빠뜨리기 너무 쉽다.
그리고 클러스터 구성 후 반드시 몽키 테스트를 해볼 것. "Healthy: true"만 보고 안심하면 안 된다. 실제로 노드를 죽여봐야 자동 복구가 되는지 알 수 있다. 우리도 사이드카의 503 문제를 몽키 테스트에서 발견했다.
운영 중인 Vault가 있다면, 지금 당장 publishNotReadyAddresses가 설정되어 있는지 확인해보길 바란다. 아마 설정 안 되어 있을 확률이 꽤 높다.