본문으로 건너뛰기

백업/복원

GenD 의 메타데이터 전량은 클러스터 안 PostgreSQL 에 있다. 이 문서는 야간 백업 CronJob 의 운영과 실제 복원 절차를 다룬다.

:::danger 암호화 키를 잃으면 백업도 잃는다 백업 파일은 AES-256 으로 암호화돼 있다. 복호화 키는 Secret gend-backup-encrypt-key 에만 있으므로 조직 비밀 보관소에 같은 값을 따로 보관해야 한다. 클러스터가 통째로 사라지면 Secret 도 함께 사라진다. :::

:::warning Velero 는 배포돼 있지 않다 (2026-08-07 실측) 이 문서의 이전 판은 Velero 스케줄·pre-hook 스냅샷을 이미 동작하는 것처럼 서술했다. 실제로는 velero 네임스페이스 자체가 없고 infra/velero/ 는 설계 단계 산출물이다. 그 서술 때문에 "백업이 있다" 고 믿은 상태로 넉 달이 지났다 (#3112). 아래 내용은 prod 에 실제 배포된 것만 적는다 — 계획을 현재형으로 쓰지 말 것. :::

무엇을 백업하는가

CronJob대상시각(KST)산출물
db-backup-postgresqlops PG — gend·keycloak·dagster·mlflow·hive_metastore·nessie·openmetadata03:00ops_<TS>.sql.gz.enc
db-backup-arangodbArangoDB gend (지식그래프)03:10arango_<TS>.tar.gz.enc
db-backup-opensearchOpenSearch gend-audit* (감사로그)03:20스냅샷 snap_<TS>
db-backup-weaviateWeaviate (벡터)03:30백업 gend_<TS>
db-backup-verify최신 백업 복호화 + 내용 검증매주 월 04:00로그만

:::warning Vault 는 이 백업에 포함되지 않는다

위 CronJob 은 데이터스토어만 다룹니다. Vault(자격증명 저장소)의 raft 스냅샷은 별도 CronJob 이며 현재 prod 에 배포돼 있지 않습니다 (#3215).

Vault 를 잃었을 때의 절차는 Vault 재해 복구 를 보십시오. :::

PostgreSQL·ArangoDB 산출물은 PVC db-backup-pvc (10Gi, managed-csi-tagged) 에 AES-256 암호화 저장, 보존 35일. OpenSearch·Weaviate 는 각 엔진의 백업 API 를 쓰므로 산출물이 그 엔진의 전용 PVC 에 남는다(각각 opensearch-snapshots-pvc 20Gi / weaviate-backups-pvc 8Gi, 보존 90일/수동). 정의는 infra/azure-deploy/k8s-azure/security/db-backup-cronjob.yaml.

:::info 엔진 백업은 앱 설정이 짝이다 (#3182) OpenSearch 스냅샷은 path.repo + 스냅샷 볼륨 + 그 디렉토리 chown 이, Weaviate 백업은 ENABLE_MODULES=backup-filesystem + BACKUP_FILESYSTEM_PATH + 백업 볼륨 + netpol allowlist(app: db-backup) 가 함께 있어야 동작한다. 하나라도 빠지면 CronJob 만 남아 매일 실패한다 — 그래서 #3143 에서는 아예 뺐었다. 짝이 유지되는지는 scripts/test_cross_pr_consistency.pytest_opensearch_snapshot_prereqs_present / test_weaviate_backup_prereqs_present 가 CI 에서 지킨다. :::

:::warning 이 백업이 막아주지 못하는 것 백업본이 같은 클러스터의 Azure Disk 에 있다. DB 손상·실수 삭제·잘못된 마이그레이션은 복구되지만, 클러스터/리소스그룹 자체가 사라지는 시나리오는 복구되지 않는다. 오프사이트(Blob/S3) 복제와 Velero 도입은 별도 과제다. :::

빠져 있는 것과 그 이유 (전부 2026-08-07 prod 실측):

  • OpenSearch 스냅샷 · Weaviate 백업 — 전제조건이 없다. OpenSearch 는 path.repo 미설정이라 fs 리포지토리 등록이 불가하고, Weaviate 는 backup 모듈이 꺼져 있어 /v1/backups/filesystem 이 422 를 낸다. 그대로 켜면 매일 실패하는 CronJob 만 생긴다.
  • source-postgresql — 그 파드의 데이터 볼륨이 emptyDir 이라 재시작만으로 이미 소실된다. 야간 암호화 백업은 있지도 않은 durability 를 약속하는 셈이다 (재생성 경로는 infra/seed/seed-job.yaml). 게다가 서버가 16.14 로 ops(15.18) 와 갈려 한 컨테이너에서 둘 다 뜨면 pg_dumpall: server version mismatch 로 죽는다 — 실배포에서 실제로 발생했다. 필요해지면 PG16 클라이언트를 쓰는 별도 CronJob 으로 붙일 것.

최초 배포

암호화 키 Secret 을 먼저 만든다. 없으면 CronJob 파드가 CreateContainerConfigError 로 멈춘다.

KEY=$(openssl rand -base64 32)
echo "$KEY" # ★ 조직 비밀 보관소에 저장 — 잃으면 복호화 불가

kubectl create secret generic gend-backup-encrypt-key -n gend \
--from-literal=BACKUP_ENCRYPT_KEY="$KEY" --dry-run=client -o yaml \
| kubeseal --format yaml --controller-namespace kube-system \
| kubectl --context aks-genos-prod apply -f -

kubectl --context aks-genos-prod apply \
-f infra/azure-deploy/k8s-azure/security/db-backup-cronjob.yaml

수동 실행 · 상태 확인

kubectl --context aks-genos-prod -n gend create job \
--from=cronjob/db-backup-postgresql db-backup-manual-$(date +%s)

kubectl --context aks-genos-prod -n gend get jobs -l app=db-backup
kubectl --context aks-genos-prod -n gend logs job/<job-name>

성공 로그는 산출물 경로와 바이트 수를 함께 찍는다:

[ops] OK — /backup/postgresql/ops_20260807_180003.sql.gz.enc (12345678 bytes, 원본 98765432 bytes)

실패하면 어떻게 아는가

CronJob 이 실패하면 KubeJobFailed 알람이 Alertmanager 를 거쳐 Slack #genon-gend-개발 로 간다. 별도 webhook 설정은 필요 없다.

조용한 실패를 막는 장치 — 백업 스크립트는 이 셋을 모두 확인한 뒤에만 "성공" 으로 끝난다. 하나라도 어긋나면 파일을 지우고 non-zero 로 종료한다.

  1. pg_dumpall종료코드 — 파이프를 쓰지 않고 파일로 받는다. (pg_dumpall | gzip 은 파이프라인 종료코드가 gzip 것이라 덤프 실패를 삼킨다.)
  2. 덤프 완결성 마커-- PostgreSQL database cluster dump complete. 크기만으로는 중간에 끊긴 덤프를 못 거른다.
  3. 암호화 산출물의 크기.

주간 db-backup-verify 는 여기에 더해 최신 백업을 실제로 복호화하고 gzip -t 로 CRC 를 확인한 뒤 CREATE TABLE 수(임계 100, 실측 355)와 완결성 마커를 검사한다. 백업이 2일 이상 갱신되지 않아도 실패로 처리한다 — 파일이 남아 있어도 CronJob 이 멈췄으면 백업이 없는 것과 같기 때문이다.

복원 절차

1) 백업 파일 꺼내기

백업 PVC 는 RWO 라 별도 파드로 마운트해서 읽는다.

cat <<'EOF' | kubectl --context aks-genos-prod apply -f -
apiVersion: v1
kind: Pod
metadata: {name: backup-reader, namespace: gend, labels: {app: db-backup}}
spec:
restartPolicy: Never
# ★ runAsUser 는 999(이미지의 postgres) — `initdb` 는 실행 UID 가
# /etc/passwd 에 있어야 하고, 1000 은 이 이미지에 없어
# `could not look up effective user ID 1000` 으로 죽는다 (실측).
# fsGroup 1000 으로 백업 PVC 읽기 권한을 얻는다.
securityContext: {runAsNonRoot: true, runAsUser: 999, fsGroup: 1000}
containers:
- name: sh
# ★ 복원 대상은 **prod 와 같은 이미지**여야 한다. prod PG 는 `pgaudit`
# 확장을 쓰는데 stock `postgres:15` 에는 그게 없어 덤프의
# `CREATE EXTENSION pgaudit` 가 실패한다 (실측). alpine 판은
# openssl CLI 도 없어 복호화 자체가 안 된다.
image: genosprodacr.azurecr.io/gend/postgresql:15-pgaudit
command: ["sleep", "3600"]
env:
- name: BACKUP_ENCRYPT_KEY
valueFrom: {secretKeyRef: {name: gend-backup-encrypt-key, key: BACKUP_ENCRYPT_KEY}}
volumeMounts: [{name: b, mountPath: /backup, readOnly: true}]
volumes:
- name: b
persistentVolumeClaim: {claimName: db-backup-pvc}
EOF

kubectl --context aks-genos-prod -n gend exec backup-reader -- ls -lh /backup/postgresql

2) 복호화 + 완결성 확인

kubectl --context aks-genos-prod -n gend exec backup-reader -- sh -c '
LATEST=$(ls -t /backup/postgresql/ops_*.enc | head -1)
openssl enc -aes-256-cbc -d -pbkdf2 -pass env:BACKUP_ENCRYPT_KEY \
-in "$LATEST" -out /tmp/ops.sql.gz
gzip -t /tmp/ops.sql.gz && zcat /tmp/ops.sql.gz | tail -2
'

마지막 줄에 -- PostgreSQL database cluster dump complete 가 보여야 한다.

3) 임시 인스턴스에 복원해 대조 (리허설 — 정기 수행)

프로덕션 DB 에 바로 복원하지 말 것. 임시 PG 를 띄워 복원하고 테이블 수를 원본과 대조한다. 복원해본 적 없는 백업은 백업이 아니다.

덤프 안의 role 이름과 맞추기 위해 임시 클러스터도 슈퍼유저를 gend 로 만든다 (prod PG 에는 postgres role 이 없다 — psql -U postgres 는 FATAL 이다). 1.4GB 덤프 재생에 수 분이 걸리니 sleep 을 넉넉히 준 파드에서 돌린다.

kubectl --context aks-genos-prod -n gend exec backup-reader -- sh -c '
# 임시 클러스터를 파드 안에 띄워 복원 (외부 영향 0, TCP 리슨 안 함)
export PGDATA=/tmp/drill
initdb -U gend -A trust >/dev/null 2>&1
# pgaudit 은 shared_preload_libraries 로 올려야 `CREATE EXTENSION pgaudit` 가 통과한다
pg_ctl -D "$PGDATA" -l /tmp/pg.log start >/dev/null \
-o "-k /tmp -p 5555 -c listen_addresses= -c shared_preload_libraries=pgaudit"
zcat /tmp/ops.sql.gz | psql -h /tmp -p 5555 -U gend -d postgres -q 2>&1 | tail -3
psql -h /tmp -p 5555 -U gend -d gend -tAc \
"select count(*) from information_schema.tables where table_schema=\"public\";"
'

원본 값과 비교한다:

kubectl --context aks-genos-prod -n gend exec deploy/postgresql -c postgresql -- \
psql -U gend -d gend -tAc \
"select count(*) from information_schema.tables where table_schema='public';"

pg_dumpall 덤프는 role/tablespace 를 포함한 클러스터 전체 덤프라, 복원 대상에 같은 이름의 role 이 있으면 경고가 난다(무해).

4) 정리

kubectl --context aks-genos-prod -n gend delete pod backup-reader

OpenSearch · Weaviate 복원

PostgreSQL·ArangoDB 와 달리 이 둘은 엔진의 복원 API 를 쓴다. 암호화 파일을 꺼내는 절차가 아니다.

OpenSearch (감사로그)

# 스냅샷 목록 — state 가 SUCCESS 인 것만 복원 대상이다 (PARTIAL 은 샤드 누락)
kubectl --context aks-genos-prod -n gend exec deploy/opensearch -c opensearch -- sh -c \
'curl -sk -u admin:$OPENSEARCH_INITIAL_ADMIN_PASSWORD \
"https://localhost:9200/_cat/snapshots/gend-backup?h=id,status,successful_shards,failed_shards"'

복원은 인덱스 이름 충돌을 먼저 처리해야 한다. 살아 있는 인덱스 위에 바로 복원할 수 없으므로, 검증 목적이면 rename_pattern 으로 다른 이름에 푼다.

kubectl --context aks-genos-prod -n gend exec deploy/opensearch -c opensearch -- sh -c \
'curl -sk -u admin:$OPENSEARCH_INITIAL_ADMIN_PASSWORD -X POST \
"https://localhost:9200/_snapshot/gend-backup/<SNAP_ID>/_restore?wait_for_completion=true" \
-H "Content-Type: application/json" \
-d "{\"indices\":\"gend-audit-2026.08.01\",\"rename_pattern\":\"(.+)\",\"rename_replacement\":\"restored-\$1\"}"'

# 문서 수 대조 — 원본과 같아야 한다
kubectl --context aks-genos-prod -n gend exec deploy/opensearch -c opensearch -- sh -c \
'curl -sk -u admin:$OPENSEARCH_INITIAL_ADMIN_PASSWORD "https://localhost:9200/_cat/indices/restored-*?h=index,docs.count"'

검증이 끝나면 restored-* 인덱스를 지운다.

Weaviate (벡터)

# 백업 상태 확인 (SUCCESS 여야 복원 가능)
TOKEN=... # service-account-weaviate client_credentials 토큰
kubectl --context aks-genos-prod -n gend exec deploy/weaviate -c weaviate -- \
wget -qO- --header "Authorization: Bearer $TOKEN" \
http://localhost:8080/v1/backups/filesystem/<BACKUP_ID>

복원은 POST /v1/backups/filesystem/<BACKUP_ID>/restore 다. 이미 존재하는 클래스는 복원되지 않는다 — 복원하려는 클래스를 먼저 지우거나, 빈 인스턴스에 복원해야 한다. 운영 중 전체 복원은 서비스 중단을 동반하므로 유지보수 창에서 수행할 것.

회귀 가드

scripts/test_cross_pr_consistency.pytest_db_backup_* 6종이 CI 에서 다음을 막는다 — 전부 한 줄 되돌림으로 조용히 무력화되는 것들이다.

가드막는 회귀
encrypt_key_is_not_hardcoded암호화 키 평문 복귀
never_passes_key_via_argv-pass pass: 로 키가 ps 에 노출
does_not_pipe_pg_dumpall_into_gzip덤프 실패를 성공으로 보이게 하는 파이프
verifies_dump_completeness_marker완결성 마커 검사 제거
pods_carry_netpol_matching_label파드 라벨 누락 → NetworkPolicy 차단
openssl_callers_use_image_that_ships_opensslopenssl 없는 이미지로 교체 → 덤프 후 exit 127

관련 문서