Vault 재해 복구
Vault 는 플랫폼 전체의 자격증명 저장소입니다. 이 문서는 Vault 자체를 잃었을 때 — 관리 토큰을 못 찾거나, 저장소가 손상됐을 때 — 무엇을 확인하고 어떤 순서로 복구하는지 다룹니다.
일상 백업(PostgreSQL · OpenSearch · Weaviate)은 백업/복원 을 보십시오. 그 문서는 Vault 를 다루지 않습니다.
:::warning 현재 prod 상태 (2026-08-08 실측)
| 항목 | 상태 | 이슈 |
|---|---|---|
| 관리 토큰 | ✅ Azure KV kv-gend-prod-recovery 에 보관 — §2 | #3207 |
| 감사 장치 | ✅ 활성화 (file_path=stdout → OpenSearch, 90일) | #3209 |
| ESO ↔ Vault | ✅ 복구 (6/6 SecretSynced) | #3176 |
| raft 스냅샷 | ✅ 6시간 주기 → Azure Blob (30일 보존) | #3215 |
네 항목 모두 채워졌습니다. 남은 것은 복원 리허설 — 스냅샷이 존재하는 것과 되돌릴 수 있는 것은 다릅니다 (#3215). :::
1. 먼저 판단한다 — 무엇을 잃었는가
| 증상 | 절 |
|---|---|
| 관리 토큰을 못 찾는다 (Vault 는 살아 있음) | §2 접근 회복 |
| Vault 는 살아 있는데 인증이 전부 403 | 부록 ESO/Vault 인증 |
| 저장소(PV) 손상 · 3노드 모두 기동 불가 | §3 재초기화 |
Vault 파드가 Running 인 것은 정상의 근거가 아닙니다. ESO 가 71일 방치됐을 때도
파드는 내내 Running 이었습니다. 판정은 아래로 합니다:
kubectl -n gend exec vault-0 -c vault -- \
sh -c 'VAULT_ADDR=https://127.0.0.1:8200 VAULT_SKIP_VERIFY=true vault status'
# Sealed=false, HA Mode=active 를 확인
2. 접근 회복 — 여기서 대부분 끝난다
:::tip 먼저 여기를 보십시오 — root 토큰과 recovery key 는 Azure Key Vault 에 있습니다
az keyvault secret list --vault-name kv-gend-prod-recovery --query "[].name" -o tsv
| 시크릿 이름 | 내용 |
|---|---|
gend-vault-root-token-1172 | root 토큰 (policies=['root'], TTL 무기한) |
gend-vault-recovery-key-1-of-5 ~ 5-of-5 | recovery key 5개 (임계값 3) |
gend-provider-key-enc | 제공자 키 암호화 키 |
값을 꺼낼 때는 화면에 출력하지 말고 파일로 받으십시오:
# ★ 고정 경로(/tmp/.rt 등)를 쓰지 마십시오 — 공격자가 미리 심볼릭 링크를 걸어둘 수
# 있고, 명령이 중단되면 토큰 파일이 그대로 남습니다. mktemp + trap 으로 받습니다.
RT=$(mktemp) && chmod 600 "$RT"
trap 'shred -u "$RT" 2>/dev/null || rm -f "$RT"' EXIT INT TERM
az keyvault secret show --vault-name kv-gend-prod-recovery \
--name gend-vault-root-token-1172 --query value -o tsv > "$RT"
# 파드에 넘길 때도 인자가 아니라 stdin 으로 — 인자는 파드 안 `ps` 에 노출됩니다
kubectl -n gend exec -i vault-0 -c vault -- sh -c \
'read -r T; VAULT_ADDR=https://127.0.0.1:8200 VAULT_SKIP_VERIFY=true VAULT_TOKEN=$T \
vault token lookup' < "$RT"
# trap 이 성공·실패·중단 어느 경로에서도 지웁니다
볼트가 두 개이고 용도가 다릅니다 — 헷갈리기 쉽습니다:
| 볼트 | 내용 | 인증에 쓸 수 있나 |
|---|---|---|
kv-gend-prod-recovery | root 토큰 · recovery key | ✅ 여기가 답입니다 |
kv-gend-prod-unseal | auto-unseal 키 (Vault 가 자동으로 씀) | ❌ 봉인 해제 전용 |
접근 권한이 없다면 구독 Owner 또는 User Access Administrator 에게
Key Vault Secrets User 부여를 요청하십시오.
:::
위에서 해결되지 않을 때만 아래로 갑니다. 재초기화(§3)는 되돌릴 수 없으므로 전부 소진한 뒤에 넘어가십시오.
| # | 경로 | 확인 방법 |
|---|---|---|
| 1 | Azure Key Vault 전수 조회 | az keyvault list --query "[].name" -o tsv — ★ 볼트는 여러 개입니다. 하나만 보고 판단하지 마십시오 |
| 2 | vault operator init 출력 파일 | ~/vault-init-output-*.json, Downloads/Desktop/휴지통, 프로젝트 트리 |
| 3 | 팀 비밀번호 관리자 | 공유 Vault 항목 — vault / recovery / root 검색 |
| 4 | macOS 로그인 키체인 | security dump-keychain | grep -i vault |
| 5 | 암호 앱 · 메모 앱 잠긴 메모 | CLI 로 닿지 않습니다. 육안으로 검색 |
| 6 | userpass 로그인 | §2.1 |
| 7 | k8s auth (다른 role) | 같은 원인으로 전부 막혀 있을 수 있습니다 → 부록 |
| 8 | 클러스터 안 토큰 Secret | kubectl -n gend get secret -o json | grep -il vault — 플레이스홀더일 수 있으니 값 길이·접두어를 확인 |
:::danger 접근 거부는 부재의 증거가 아니다
2026-08-08 에 실제로 이 함정에 빠졌습니다. kv-gend-prod-unseal 하나만 확인하고 —
게다가 그 볼트는 RBAC 이 없어 조회 자체가 실패했는데 — "Azure Key Vault 경로는
소진됐다" 고 판단했습니다. az keyvault list 를 돌리지 않아 옆에 있던
kv-gend-prod-recovery 를 못 봤고, 멀쩡한 Vault 를 재초기화할 뻔했습니다.
여러 개일 수 있는 것(볼트·네임스페이스·저장소)은 전수 목록부터 뜨십시오.
그리고 Forbidden 은 "없다" 가 아니라 "못 봤다" 입니다.
:::
:::caution Vault 는 userpass 계정의 존재 여부를 알려주지 않는다
없는 사용자와 틀린 비밀번호에 동일한 403 을 반환합니다(사용자 열거 방지 — 올바른
동작). 미인증 조회(sys/internal/ui/mounts)나 메트릭에도 마운트 목록이 뜨지 않습니다.
기계로 판별할 수 없으므로 이 확인에 시간을 쓰지 말고 사람이 직접 시도하십시오.
:::
2.1 userpass 로그인
비밀번호는 stdin 으로 넘깁니다. 셸 인자로 주면 파드 안 ps 에 잠시 노출되고,
따옴표가 든 비밀번호에서 셸이 깨져 스크립트 버그가 "비밀번호 틀림" 으로 오인됩니다.
read -rs VPW
printf '%s' "$VPW" | kubectl -n gend exec -i vault-0 -c vault -- sh -c \
'VAULT_ADDR=https://127.0.0.1:8200 VAULT_SKIP_VERIFY=true \
vault write -field=token auth/userpass/login/<USER> password=-'
unset VPW
성공하면 이 토큰으로 root 정책 토큰을 재발급하고 즉시 사람 금고에 보관합니다 (시크릿 흐름 §세 계층).
vault token create -policy=root -ttl=0
3. 재초기화 — 최후 수단
3.1 ★ 선행: "Vault 에 있고 클러스터에 없는 것" 을 전수 조사한다
재초기화는 Vault 안에만 있는 것을 전부 잃습니다. ExternalSecret 대상 Secret 은 클러스터에 사본이 남지만, 다음은 사본이 없습니다:
| 대상 | 잃으면 | 확인 방법 |
|---|---|---|
| Transit 키 | 그 키로 암호화된 데이터가 영구 복호화 불능 | 아래 SQL |
| PKI 발급 인증서 | 재발급 필요 | vault list pki/certs (토큰 필요) |
| KV 에 직접 write 된 값 | ESO 를 안 거친 값은 사본 없음 | 소비자에서 역산 (§3.2) |
| 정책 · auth 마운트 · role | 재생성 가능 | infra/vault/prod/scripts/01~07 |
Transit 암호문 실재 여부는 DB 에서 직접 셉니다 (값은 출력하지 않습니다):
SELECT 'data_sources.password' AS col,
count(*) FILTER (WHERE password LIKE 'vault:v%') AS vault_encrypted,
count(*) AS total
FROM data_sources;
:::warning llm_providers 에 같은 판별식을 쓰지 마십시오
api_key_encrypted 는 Fernet 으로 암호화돼 있어 vault:v% 로 시작하지 않습니다.
그 컬럼에 이 조건을 걸면 vault_encrypted=0 이 나오는데, 그것은 "평문이다" 가 아니라
"transit 을 안 쓴다" 는 뜻입니다 — 거짓 양성입니다.
Fernet 키는 Azure Key Vault kv-gend-prod-recovery 의 gend-provider-key-enc 이므로
재초기화로 잃지 않습니다. transit 미사용 현황은
#3210 을 보십시오.
:::
vault_encrypted 가 0 이면 transit 키를 잃어도 손실이 없습니다.
0 이 아니면 재초기화 전에 반드시 복호화해서 빼내야 합니다 — 그러려면 살아 있는
Vault 가 필요하므로, 접근이 이미 끊겼다면 그 데이터는 회수 불가입니다.
:::warning 온프렘은 판단이 다르다
온프렘 사이트는 Ceph OSD 의 LUKS 키를 Vault 에 둡니다. 그곳의 재초기화는
스토리지 전체를 잃는 것과 같습니다. 이 문서의 "견딜 만하다" 는 판단은
AKS prod 한정입니다 (Ceph 미배포 · ENABLE_CEPH_KMS=false 실측).
:::
3.2 수확 — 지금 값을 먼저 건진다
ESO 가 끊겨 있어도 소비자는 과거에 생성된 k8s Secret 으로 계속 동작합니다. 그 값들이 사실상의 백업이므로 재초기화 전에 확보합니다.
# ExternalSecret 이 만드는 대상 Secret 목록
kubectl -n gend get externalsecret \
-o jsonpath='{range .items[*]}{.spec.target.name}{"\n"}{end}' | sort -u
# 각 Secret 을 파일로 (★ 안전한 위치에 — 이 파일 자체가 자격증명이다)
kubectl -n gend get secret <NAME> -o yaml > vault-recovery/<NAME>.yaml
Vault KV 경로는 ExternalSecret 스펙에서 역산합니다:
kubectl -n gend get externalsecret -o json | jq -r '
.items[] | .metadata.name as $n |
(.spec.data[]? | "\($n)\t\(.remoteRef.key)#\(.remoteRef.property // "*")"),
(.spec.dataFrom[]? | "\($n)\t\(.extract.key)#<all>")'
3.3 재초기화 및 복원
# 1) 현재 PV 를 지우기 전에 보존 (되돌릴 여지를 남긴다)
kubectl -n gend scale sts vault --replicas=0
# 각 data-vault-N PVC 의 Azure Disk 스냅샷을 먼저 만든다
# 2) 재초기화 — auto-unseal 이므로 unseal key 는 나오지 않고 recovery key 가 나온다
kubectl -n gend exec vault-0 -c vault -- vault operator init \
-recovery-shares=5 -recovery-threshold=3
# 3) ★ 출력된 root 토큰과 recovery key 5개를 즉시 사람 금고로 옮긴다
# (이 단계를 건너뛰면 지금 상황이 그대로 재현된다 — #3207)
# 4) 부트스트랩 재실행 (idempotent)
export VAULT_TOKEN='<새 root token>'
infra/vault/prod/scripts/04-bootstrap-policies.sh
infra/vault/prod/scripts/05-enable-transit.sh
infra/vault/prod/scripts/06-migrate-k8s-secrets.sh # §3.2 에서 수확한 값을 되넣는다
infra/vault/prod/scripts/07-verify-cluster-secret-store.sh
3.4 검증
# ESO 6종이 살아났는가 — 이것이 최종 판정이다
kubectl -n gend get externalsecret
kubectl get clustersecretstore vault-backend \
-o jsonpath='{.status.conditions[-1].status}{"\n"}'
4. 재발 방지 — 복구보다 먼저 세운다
복구만 하고 아래를 빠뜨리면 같은 일이 반복됩니다. 실제로 여섯 번 반복됐습니다 (velero · 메트릭 · 토큰 보관 · 감사 · transit · 스냅샷 — 전부 "리포에는 있는데 배포/활성화 안 됨").
| 항목 | 조치 | 상태 | 이슈 |
|---|---|---|---|
| 백업 | raft-snapshot-cronjob.yaml 배포 + 복원 리허설 | ⛔ 미완 | #3215 |
| 알람 | prometheus-rule.yaml 배포 — 대상과 감시를 같이 | ⛔ 미완 | #3215 |
| 감사 | file_path=stdout → Fluent Bit → OpenSearch (ISM 90일) | ✅ 완료 | #3209 |
| 토큰 보관 | Azure KV 보관 + 보관 위치를 문서에 기록 | ✅ 완료 (§2) | #3207 |
:::tip 감사 로그는 파일이 아니라 stdout 으로 나갑니다 (#3209)
Vault 의 file 감사 장치는 자체 로테이션이 없습니다 — 공식 문서: "The device does
not currently assist with any log rotation." prod 실측 92.5 MB/day 로 4.8Gi PVC 가
약 53일 뒤 포화할 예정이었고, Vault 는 감사 기록에 실패하면 요청을 거부하므로
(fail-closed) 플랫폼 전면 장애가 날짜까지 정해져 있었습니다.
그래서 file_path=stdout 으로 바꿨습니다 (공식 권장: "useful when running in a
container"). 경로는 이렇습니다:
Vault (active 노드) ─stdout─▶ kubelet (50Mi × 5 상한) ─▶ Fluent Bit ─▶ OpenSearch
gend-audit-vault-* (ISM 90일)
디스크가 찰 수 없습니다 — kubelet 이 상한을 겁니다. 조회는 OpenSearch 에서 합니다.
★ Vault 는 장치가 여럿이면 하나만 성공해도 요청을 처리합니다 ("guarantees that it saves to at least one of the enabled devices"). 그래서 새 장치를 먼저 붙여 동작을 확인한 뒤 파일 장치를 껐습니다 — 순서를 반대로 하면 감사 공백이 생깁니다. :::
:::info 보관보다 기록이 먼저 무너진다
#3207 의 실제 결손은 보관이 아니었습니다. root 토큰은 처음부터 Azure Key Vault 에
안전하게 있었고, 어디에 있는지 적힌 곳이 없었을 뿐입니다. 그 때문에 몇 시간을 잃고
멀쩡한 Vault 를 재초기화할 뻔했습니다.
시크릿을 금고에 넣었다면 그 사실을 이 문서 §2 에 적으십시오. 위치는 비밀이 아니라 포인터이고, 포인터가 없으면 금고는 없는 것과 같습니다. :::
:::tip 세 가지 반복되는 함정
① 매니페스트 존재 ≠ 배포. kubectl get 으로 실물을 확인하기 전까지 "있다" 고
말하지 마십시오. 이 레포에서 여섯 번 틀렸습니다.
② 저장소 프로비저닝 ≠ 기능 활성화. 감사 PVC 15Gi 가 72일간 비어 있었고, transit 키는 만들어졌지만 앱이 쓰지 않았습니다.
③ 감시를 대상과 함께 깔지 않으면 부재가 영구히 침묵합니다. 스냅샷 CronJob 과 알람 룰이 같이 누락돼, 백업이 없다는 사실을 알려줄 것도 없었습니다. :::
부록: ESO/Vault 인증 403
전 로그인이 permission denied 면 token_reviewer_jwt 고정 저장을 먼저 의심합니다.
그 필드에 값을 넣으면 그 시점의 SA 토큰이 Vault 안에 얼어붙고, AKS 의 projected
SA 토큰은 파드와 함께 회전하므로 Vault 파드가 재시작하면 TokenReview 가 실패합니다.
vault write auth/kubernetes/config \
kubernetes_host='https://kubernetes.default.svc.cluster.local' \
kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
token_reviewer_jwt='' \
disable_iss_validation=true
token_reviewer_jwt='' 를 명시해야 합니다 — 생략만 하면 이미 설정된 마운트의
기존 값이 그대로 남습니다. 회귀 가드: scripts/test_vault_token_reviewer_jwt.py
(#3176).
:::danger 값이 설정됐는지는 token_reviewer_jwt 로 알 수 없다
Vault 는 이 값을 절대 반환하지 않습니다. vault read auth/kubernetes/config 응답에
token_reviewer_jwt 필드는 아예 나타나지 않으므로, 그걸 보고 "비어 있다" 고 읽으면
틀립니다. 판정은 동반 필드로 합니다:
vault read -format=json auth/kubernetes/config | jq '.data.token_reviewer_jwt_set'
# true → 고정 저장돼 있다 (이 절의 처방 대상)
# false → 비어 있다 (Vault 가 자기 SA 로 TokenReview)
2026-08-08 에 이걸 잘못 읽어 "이미 처방이 적용됐다, 원인이 다른 데 있다" 고 판단하고
TLS·네트워크·role 바인딩을 한참 뒤졌습니다. write-only 필드의 부재를 '값 없음' 으로
읽지 말고 *_set 동반 필드를 먼저 찾으십시오.
:::
고친 뒤 즉시 반영되지 않을 때
ESO 는 실패한 Vault 클라이언트를 캐시합니다. 파드가 오래 떠 있었다면 강제로 깨웁니다:
kubectl annotate clustersecretstore vault-backend force-sync="$(date +%s)" --overwrite
# 개별 ExternalSecret 이 backoff 중이면 같은 방식으로
kubectl -n gend annotate externalsecret <NAME> force-sync="$(date +%s)" --overwrite
★ 감사 로그는 active 노드에만 쓰입니다. 어느 파드가 active 인지 먼저 확인하십시오
(vault status 의 HA Mode). 감사 레코드는 active 노드의 파드 로그로 나갑니다:
kubectl -n gend logs vault-<N> -c vault | grep '"type":"response"'
★ 고장 기간을 kubectl get 의 AGE 로 읽지 마십시오. 그것은 리소스 나이입니다.
판정은 status.conditions[].lastTransitionTime 으로 합니다.
관련 문서
- 백업/복원 — PostgreSQL · OpenSearch · Weaviate
- Vault 설정
- 시크릿 / 인증서 흐름