본문으로 건너뛰기

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-1172root 토큰 (policies=['root'], TTL 무기한)
gend-vault-recovery-key-1-of-5 ~ 5-of-5recovery 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-recoveryroot 토큰 · recovery key✅ 여기가 답입니다
kv-gend-prod-unsealauto-unseal 키 (Vault 가 자동으로 씀)❌ 봉인 해제 전용

접근 권한이 없다면 구독 Owner 또는 User Access Administrator 에게 Key Vault Secrets User 부여를 요청하십시오. :::

위에서 해결되지 않을 때만 아래로 갑니다. 재초기화(§3)는 되돌릴 수 없으므로 전부 소진한 뒤에 넘어가십시오.

#경로확인 방법
1Azure Key Vault 전수 조회az keyvault list --query "[].name" -o tsv — ★ 볼트는 여러 개입니다. 하나만 보고 판단하지 마십시오
2vault operator init 출력 파일~/vault-init-output-*.json, Downloads/Desktop/휴지통, 프로젝트 트리
3팀 비밀번호 관리자공유 Vault 항목 — vault / recovery / root 검색
4macOS 로그인 키체인security dump-keychain | grep -i vault
5암호 앱 · 메모 앱 잠긴 메모CLI 로 닿지 않습니다. 육안으로 검색
6userpass 로그인§2.1
7k8s auth (다른 role)같은 원인으로 전부 막혀 있을 수 있습니다 → 부록
8클러스터 안 토큰 Secretkubectl -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_encryptedFernet 으로 암호화돼 있어 vault:v% 로 시작하지 않습니다. 그 컬럼에 이 조건을 걸면 vault_encrypted=0 이 나오는데, 그것은 "평문이다" 가 아니라 "transit 을 안 쓴다" 는 뜻입니다 — 거짓 양성입니다.

Fernet 키는 Azure Key Vault kv-gend-prod-recoverygend-provider-key-enc 이므로 재초기화로 잃지 않습니다. transit 미사용 현황은 #3210 을 보십시오. :::

vault_encrypted0 이면 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 deniedtoken_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 statusHA Mode). 감사 레코드는 active 노드의 파드 로그로 나갑니다: kubectl -n gend logs vault-<N> -c vault | grep '"type":"response"'

★ 고장 기간을 kubectl getAGE 로 읽지 마십시오. 그것은 리소스 나이입니다. 판정은 status.conditions[].lastTransitionTime 으로 합니다.

관련 문서