Architecture · internal/backup

Nullus 백업/복구 아키텍처

플랫폼 메타데이터부터 설치된 OSS 의 내부 데이터까지 한 시점으로 얼려 클러스터 밖에 두고, 그 시점으로 되돌리는 구조. 이 모듈의 값어치는 기능이 아니라 순서에 있다 — 바꾸면 조용히 깨지는 순서들이 있고, 그것을 코드와 테스트로 고정한 것이 설계의 대부분이다.

범위 구조 · 레이어 · 흐름 · 실패 모드 결정 근거 기능설계 문서 검증 절차 로컬 테스트 가이드 개정 2026-09-03

01무엇을 지키는가

백업 대상은 다섯 갈래다. 갈래마다 저장 위치와 뜨는 방법이 다르고, 빠졌을 때 드러나는 시점도 다르다 — 대부분은 복구를 시도할 때에야 드러난다.

컴포넌트무엇어떻게빠지면
platform_db Nullus 메타데이터 — 스택 · 파이프라인 · 클러스터 pg_dump 복구 후 플랫폼이 자기 스택을 모른다
keycloak_db 계정과 권한 pg_dump 복구 후 로그인할 수 없다
openbao_kv 토큰 · 자격증명 (스택마다 하나) KV 재귀 순회 논리 export 도구들이 서로 인증하지 못한다
ns_resources 배선 — Deployment · Secret · CRD · ClusterRole · Argo CD Application kubectl get -o json + 정리 복구가 succeeded 인데 스택이 뜨지 않는다
volume OSS 내부 데이터 — Git 저장소 · 빌드 이력 · 이미지 blob PVC 열거 → 헬퍼 파드가 tar 도구가 빈 디스크를 보고 재초기화한다
왜 열거인가. PVC 는 이름을 나열하지 않고 네임스페이스에서 열거한다. 카탈로그에 도구가 늘어도 백업이 뒤처지지 않기 때문이다. 반대로 ns_resources 의 종류 목록은 아직 손으로 관리하며, 지금까지 나온 결함 대부분이 그 목록에서 빠진 것이었다 — ESO 배선 · CRD · ClusterRole · Argo CD Application 이 차례로 그랬다. §8 이 그 뿌리를 다룬다.

02모듈 구조

Clean Architecture 를 따른다. 의존은 항상 안쪽을 향하고, 바깥은 포트를 구현할 뿐이다. 도메인과 유스케이스는 클러스터도 DB 도 없이 실행된다 — 순서 검증 같은 이 모듈의 핵심 규칙을 인프라 없이 고정하기 위해서다.

domain비즈니스 규칙
backup_runrestore quiescemanifest artifactretention errors
▲ 의존
usecase순서를 강제한다
run_backuprun_restore verify_and_retentionmanifest_json
▲ 의존
port인터페이스 11개
BackupRepositoryDBDumper KVExporterWorkloadScaler VolumeArchiverResourceDumper HelmReleaseListerSealer ArtifactStoreTokenSourceLister Notifier
▼ 구현
adapter바깥 세계
kube/postgres/ openbao/sealer/ store/repository/ handler/bridge/ notify/

어댑터는 요청 시점에 만든다

백업 대상 스택은 등록된 워크로드 클러스터에 있다. 기동 시점에는 그 kubeconfig 를 모르므로, wiring.go 가 스택 ID → 클러스터 ID → kubeconfig 를 풀어 그때 어댑터 묶음을 만들고 캐시한다.

모듈 경계를 넘지 않는다

토큰 회전 일시정지처럼 admin 모듈이 필요한 일은 adapter/bridge/ 를 통해서만 닿는다. 회전이 DB 와 금고를 5분마다 함께 고쳐 쓰므로, 멈추지 않으면 두 산출물 사이에 시점 어긋남이 생긴다.

03백업 흐름

호박색 구간이 서비스가 멈춰 있는 동안이다. 그 바깥은 전부 무중단으로 돈다. 이 배치가 이 모듈의 설계 그 자체다 — 무엇을 창 안에 넣지 않을 것인가로 정지 시간이 정해진다.

  • 00

    목적지 검사 무중단

    연결 · 인증 · 쓰기 · 용량. 필요 용량은 정적 계산이 아니라 실행 시점 PVC 조회로 구한다.

  • 01

    리소스 덤프 무중단

    워크로드가 살아 있을 때 떠야 실제 상태가 담긴다. 정지 뒤에 뜨면 replica 0 인 모습이 담긴다.

정지 창 · 실측 50.6초 (371 MB · 볼륨 9개 기준)
  • 02

    워크로드 정지

    replica 를 0 으로. 원래 값은 매니페스트에 적어 둔다 — 복구의 필수 입력이다.

  • 03

    볼륨 아카이브

    헬퍼 파드가 PVC 를 마운트해 tar 로 흘려보낸다. 봉인과 업로드가 스트리밍으로 이어진다.

  • 04

    워크로드 재개 defer 보장

    실패해도 반드시 돈다. 최악은 백업하려다 서비스를 못 살리는 것이다.

  • 05

    금고 → DB 무중단

    순서가 금고 먼저다. 그러면 "DB 는 아는데 금고엔 없다" 방향의 불일치만 남고, 그것은 복구 9단계의 참조 정합성 검사가 잡는다. 반대 방향은 잡히지 않는다.

  • 06

    매니페스트 작성 무중단

    산출물 목록 · 볼륨 크기 · 원래 replica · 차트 버전 · 암호화 키 ID. 이것만 암호화하지 않는다.

왜 목적지 검사가 맨 앞인가. 정지 창을 다 쓰고 나서 산출물을 못 만드는 것이 가장 나쁜 조합이다. 사용자는 다운타임만 치르고 백업은 얻지 못한다. 그래서 멈추기 전에 목적지에 실제로 써 본다.

04복구 흐름

복구에는 정지 창이 없다 — 대상이 이미 비어 있거나 죽어 있기 때문이다. 대신 순서가 훨씬 엄격하다. 뒤집으면 복구가 "성공" 을 반환하고도 쓸 수 없는 상태가 된다.

  • 00

    전제 확인

    암호화 키 · 봉인 키 · 목적지 자격증명. 하나라도 없으면 아무것도 하지 않고 멈춘다 — 키 없이 진행하면 "복구된 것처럼 보이는 망가진 상태" 가 남는다.

  • 01

    무결성 검증

    산출물이 열리는지, 조각이 잘리지 않았는지.

  • 02

    스키마 · 차트 버전 대조

    대상에 다른 버전이 깔려 있으면 기동 시 마이그레이션이 돌아 되돌릴 수 없다. 버전은 알리되 막지는 않는다 — 버전을 올린 뒤 데이터만 회수하는 길이 있다.

  • 03

    PVC 생성 → 볼륨 복원 → 리소스 적용

    PVC 는 볼륨 경로가 단독으로 소유한다. 리소스 덤프에도 PVC 가 있으면 같은 객체를 두 경로가 소유해 spec is immutable 로 죽는다.

  • 04

    DB → 금고

    컨트롤 플레인 쪽. 스택 복구와 독립적으로 돈다.

  • 05

    워크로드 기동 반드시 마지막

    순서를 뒤집으면 도구들이 빈 디스크를 보고 재초기화한다. Gitea 가 새 인스턴스가 되는 순간 복원은 무의미해진다.

  • 06

    참조 정합성 검사

    토큰 소스가 가리키는 금고 경로가 실제로 있는지. 경고하되 중단하지 않는다.

succeeded 는 "단계가 돌았다" 는 뜻이다. "스택이 동작한다" 가 아니다. 실제로 RestoreRun.status = succeeded 인데 Gitea · Harbor · Jenkins 가 CreateContainerConfigError 로 9분 넘게 멈춰 있던 적이 있다 — ESO 배선과 ClusterRole 이 백업에서 빠져 있었기 때문이다. 자동 복구 경로에는 아직 스모크 검증이 없다.

05산출물과 암호화

목적지 배치

<bucket>/
  backup-<run-id>/
    manifest.json      ← 유일한 평문
    platform-db.dump
    keycloak-db.dump
    openbao-kv.json
    namespace-resources.yaml
    volumes/
      jenkins.tar
      gitea-shared-storage.tar
      harbor-registry.tar
      … PVC 마다 하나

목적지는 클러스터 밖 S3 호환 오브젝트 스토리지다. 자격증명은 스택 금고가 아니라 컨트롤 플레인 설정에서만 온다 — 금고에서 조달하면 스택이 죽을 때 백업본이 멀쩡해도 가져올 수 없는 순환이 생긴다.

스트리밍 AES-256-GCM

공용 pkg/crypto 는 []byte 전체를 받아 수십 GB 에 쓸 수 없다. 같은 알고리즘의 스트리밍 변형을 따로 두었다.

조각 크기1 MiB
nonce랜덤 8B ‖ 조각번호 4B
AAD조각 번호 + 마지막 조각 여부

AAD 에 "마지막 조각인가" 를 넣는 것이 요점이다. 이것이 없으면 뒤가 잘린 백업본이 검증을 통과한다 — 조용히 짧아진 산출물은 가장 나쁜 종류의 손상이다.

매니페스트만 평문인 이유. 키를 잃은 상황에서도 "무엇이 들어 있고 어떤 키가 필요한가" 는 읽을 수 있어야 한다. 그 대가로 비밀값을 한 조각도 담지 않는 것이 불변식이며, 도메인 테스트가 키 이름 패턴으로 이를 고정한다.

06데이터 모델

테이블역할눈여겨볼 칼럼
backup_runs 실행 하나 — 플랫폼 하나 또는 스택 하나 scope TEXT[] · quiesce_started_at / _ended_at · manifest JSONB · set_id UUID(세트 꼬리표, NULL 이면 단독)
backup_artifacts 산출물 하나 (실행당 N) component · location · checksum · encryption_key_id
restore_runs 복구 시도 schema_check · integrity_report

scope 가 mode 보다 우선한다

mode 는 preset 이고 실제로 무엇을 뜰지는 scope 가 정한다. 정지 창 여부도 scope 에 volume 이 있는지로 판단한다 — 볼륨을 뺀 full 백업은 무중단이고 확인 문구를 요구하지 않는다.

보존은 일 7 · 주 4 · 월 3

어떤 경우에도 가장 최근 성공본은 지우지 않는다. 보존 정책이 백업을 0 개로 만들 수 있으면 정책이 아니라 사고다.

stack_id 는 TEXT 다. Nullus 스택 ID 는 UUID 가 아니라 stk_352e9f68db06 형태이며, 이것을 UUID 로 선언했던 동안 스택을 대상으로 하는 백업이 행을 만드는 순간 죽었다(마이그레이션 000076).

"플랫폼 전체" 는 실행의 묶음이다. 한 실행은 플랫폼 하나 또는 스택 하나만 담는다 — 스택의 리소스·볼륨은 그 스택의 클러스터에서 뜨고 금고는 스택마다 있어서다. 그래서 플랫폼 + 조직의 모든 스택은 세트(POST /backups/sets)로 뜬다: 플랫폼 platform_only 실행 하나 + 설치 완료 스택마다 stack_only(+ 금고) 실행 하나가 같은 set_id 를 단다. 세트는 테이블이 아니라 꼬리표이고 FK 가 없다 — 이력 테이블은 어디도 참조하지 않는다(000092 의 원칙). 세트 복구 (POST /restores/sets)는 플랫폼 멤버를 먼저 되돌린 뒤 스택 멤버를 매니페스트의 namespace 로 하나씩 되돌린다 — 스택 복구가 플랫폼 DB 의 스택 기록으로 클러스터에 붙기 때문이다(설계 §6.7).

07실패 모드

이 모듈에서 가장 비싼 실패는 조용히 잘못되는 것이다. 실패했다는 사실이 늦게 드러나는 형태들을 골라 방어를 코드에 넣었다.

실패왜 위험한가방어
정지 후 목적지 실패 다운타임만 치르고 백업은 없다 정지 전 목적지 검사
재개 실패 백업하려다 서비스를 못 살린다 defer 로 보장
산출물 뒤가 잘림 검증을 통과하는 손상 조각 AAD 에 마지막 여부
pg_dump 버전이 낮음 조용히 실패한다 서버와 major 대조 후 이미지 선택
없는 리소스 타입 조회 덤프 전체가 실패한다 api-resources 로 먼저 물어본다
배선·권한 누락 복구가 succeeded 인데 스택이 죽어 있다 ESO CR · CRD · ClusterRole · Argo CD App 을 덤프에 포함
복구 볼륨을 잔여물로 오인 복구 직후 재설치가 막힌다 PVC 의 backup.nullus.io/restored-from

08한계와 다음

뿌리 하나: 복구 모델과 설치 모델이 다르다

설치는 Helm 릴리스로 이루어지는데, 복구는 네임스페이스 리소스를 apply 한다. 그래서 Helm 이 만드는 클러스터 범위 리소스(CRD · ClusterRole)와 컨트롤러가 재생성하는 리소스(ESO Secret · Argo CD 가 만드는 Deployment)가 그 사이로 빠진다.

지금까지 네 번 같은 이유로 결함이 났다. 빠진 것을 하나씩 세는 방식으로는 끝을 알 수 없다 — 다음 OSS 가 들어오면 또 난다.

후보 ① Helm 릴리스 재적용

리소스를 apply 하는 대신 릴리스를 다시 설치하고 데이터만 덮는다. Helm 이 만든 것은 Helm 이 아니까 종류 목록이 사라진다. 선행 조건 둘은 이미 갖췄다 — 차트 버전 기록과 복구 볼륨을 통과시키는 preflight.

차트가 자체 생성하는 비밀값이 걸림돌로 보였으나, 실측해 보니 그렇지 않다. 복원된 DB 를 못 읽게 만드는 것은 Harbor 의 secretKey 하나인데 그 값이 랜덤이 아니다 — 차트 기본값 not-a-secure-key 를 그대로 쓰고 있어 재설치해도 같다. 나머지 랜덤 값(CSRF_KEY · core.secret · JOBSERVICE_SECRET · REGISTRY_HTTP_SECRET)은 한 릴리스 안에서 함께 재생성되므로 서로 맞고, REGISTRY_HTPASSWD 는 고정 비밀번호의 해시라 새로 만들어도 검증을 통과한다.

후보 ② 소유권 기반 열거

종류를 나열하는 대신 meta.helm.sh/release-namespace 나 스택 라벨이 붙은 모든 리소스를 훑는다. PVC 를 열거하는 것과 같은 발상이다.

먼저 고쳐야 할 것 — Harbor 가 공개된 기본 키로 돈다

secretKey 가 재설치에도 같다는 사실은 재적용 방식에는 다행이지만, 그 이유가 차트에 적힌 공개 기본값을 그대로 쓰고 있기 때문이다. 이 키는 Harbor 가 로봇 계정 자격증명과 레플리케이션 엔드포인트 비밀번호를 DB 에 암호화해 넣을 때 쓴다 — 우리 백업 산출물(volumes/harbor-database)을 손에 넣은 사람은 그 키로 풀 수 있다.

고치는 방법은 이미 저장소에 있다. 관리자 비밀번호가 그렇듯 금고를 SoT 로 두고 차트가 참조하게 하면 된다 — 차트는 existingSecretSecretKey 를 받는다. 최초 설치에서 강한 키를 만들어 금고에 넣고, 이후 모든 설치가 그것을 참조하면 보안 결함과 재적용의 전제가 한 번에 해결된다: 복구는 금고를 먼저 되돌리므로 재설치해도 같은 키가 쓰인다.

아직 재보지 않은 것

RTO 실측(371 MB 로 잰 값은 규모의 근거가 아니다) · 다중 노드에서 RWO 볼륨이 흩어질 때 · Argo CD 의 자동 동기화(리허설에서는 강제로 앞당겼다) · Service ClusterIP 미보존이 IP 고정 배선에 미치는 영향 · 차트 버전이 어긋난 대상으로의 복구.