Kubernetes 2026.08.11 9 min read

Kubernetes Ghost 백업을 복구 가능한 운영 체계로 만든 과정

Ghost의 DB dump와 content를 외부 저장소에 백업했다. 수동·정기 Job을 따로 확인하고 ARM64 노드에서 파일과 SQL 복원을 시험했다.

운영 안정화·검증 작업: 2026-08-09 · 발행 기준: 2026-08-10 22:18 KST
환경: Kubernetes CronJob, MySQL, restic, rclone, Google Drive, 격리 DR

Ghost를 Kubernetes로 옮긴 뒤에는 주 노드에 접근하지 못해도 다른 노드에서 서비스 데이터를 복원할 수 있는지 확인하고 싶었다. 백업 Job 생성부터 외부 저장소 전송, 격리 복원까지 순서대로 검증했다.

최종 백업 경로는 다음과 같다.

30분 주기 Kubernetes CronJob
  ├─ MySQL 논리 dump 생성 및 gzip 검증
  ├─ Ghost content PVC 읽기
  └─ restic snapshot 생성
       → rclone backend
       → Google Drive off-site repository
            → 격리된 DR namespace
            → 다른 아키텍처의 복구 노드

Local PV는 해당 저장소 노드에 묶여 있었다. 이 한계를 전제로 운영 구조를 Local Primary Storage + Off-site DR로 정했다.

백업 대상부터 구분하기

Ghost 운영 데이터는 두 종류다.

  • /var/lib/ghost/content: 이미지, 테마, 설정 등 파일 데이터
  • MySQL database: 게시물, 사용자, 설정 등 관계형 데이터

실행 중인 /var/lib/mysql raw directory를 파일 복사 도구로 동기화하면 일관된 백업을 보장하기 어렵다. 따라서 MySQL은 --single-transaction 논리 dump를 만들고, Ghost content와 dump를 하나의 restic snapshot에 넣었다.

mysqldump \
  --single-transaction \
  --quick \
  --no-tablespaces \
  -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" \
  | gzip > /backup/ghost.sql.gz

gzip -t /backup/ghost.sql.gz
test -s /backup/ghost.sql.gz

DB 계정에는 PROCESS 권한을 추가하지 않고 --no-tablespaces를 사용했다. dump 명령의 exit code와 gzip 무결성, 파일 크기를 함께 확인했다. 비어 있는 dump를 정상 백업으로 저장하지 않기 위해서다.

CronJob은 30분마다, 중복 실행은 금지

목표 RPO를 최대 30분으로 잡아 CronJob을 구성했다.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: ghost-backup
spec:
  schedule: "*/30 * * * *"
  timeZone: Asia/Seoul
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3

concurrencyPolicy: Forbid는 이전 백업이 아직 실행 중일 때 다음 Job이 겹치는 것을 막는다. 원격 저장소 지연이나 API 재시도로 백업 시간이 길어지면 중복 Job이 DB와 네트워크에 추가 부하를 만들 수 있기 때문이다.

CronJob 생성 직후에는 suspend 상태로 두고 다음 준비가 끝난 후 활성화했다.

  • MySQL dump 계정과 Secret
  • restic repository password Secret
  • rclone 설정 Secret
  • Google Drive repository 초기화
  • 수동 Job 성공
  • 실제 restore 성공

restic 보존 정책

당시 데이터 규모와 필요한 복구 기간을 보고 아래 보존 정책을 적용했다. 오래된 백업을 계속 쌓으면서 저장 공간과 검사 시간이 늘어나는 것도 관리해야 했다.

restic forget \
  --keep-within 48h \
  --keep-daily 14 \
  --keep-weekly 8 \
  --keep-monthly 12 \
  --prune
  • 최근 48시간: 30분 단위 복구 지점을 유지
  • 일간 14개: 최근 2주 상태 보존
  • 주간 8개: 약 2개월 범위
  • 월간 12개: 1년 범위

보존 기간은 RPO와 데이터 변경률, 저장소 비용을 보고 정했다. forget --prune은 repository를 변경하므로 backup과 겹치지 않는지 확인하고 실행 로그와 결과를 점검하도록 했다.

Google Drive는 PV가 아니라 off-site repository다

Google Drive를 MySQL data directory에 직접 mount하지 않았다. Google Drive는 데이터베이스가 기대하는 POSIX locking, fsync, rename 의미와 지연 시간을 제공하는 primary filesystem이 아니다.

restic이 파일과 논리 dump를 암호화·중복 제거한 snapshot으로 만들고, rclone은 이를 Google Drive로 전송하는 역할만 맡는다.

restic repository URL: rclone:<remote>:<repository-path>

이렇게 하면 원격 저장소에는 암호화된 restic 데이터가 올라가고, 백업 내용과 Google 계정 접근 권한을 분리할 수 있다.

전용 Google OAuth client

rclone 기본 공용 OAuth client를 사용할 때 quota 오류가 나타났다. 운영 백업이 다른 사용자의 공용 quota에 영향을 받지 않도록 전용 Desktop OAuth client를 만들었다.

구성 순서는 다음과 같다.

  1. 백업 전용 Google Cloud OAuth Desktop client 생성
  2. 같은 프로젝트에서 Google Drive API 활성화
  3. OAuth 동의 화면을 운영 상태로 전환
  4. rclone을 다시 승인해 새 refresh token 발급
  5. Kubernetes Secret과 DR 노드의 root 전용 설정 교체
  6. 기존 공용 rclone OAuth 연결 해제

OAuth 앱을 Production으로 게시한 것은 기존 Cloudflare Access용 Web client의 client ID, secret, JavaScript origin이나 callback URI를 회전시키는 작업이 아니었다. 그래도 같은 프로젝트의 다른 client가 무엇에 쓰이는지 목록을 만든 뒤 로그인 회귀 시험을 수행했다.

Testing 상태에서 받은 refresh token이 자동으로 장기 token으로 바뀐다고 가정하지 않고, 게시 후 새로 승인했다. client secret과 token JSON은 Git, 문서, 터미널 출력에 남기지 않았다.

수동 Job과 정기 Job을 따로 검증

수동 Job과 CronJob이 생성한 정기 Job을 각각 확인했다.

kubectl -n <namespace> create job \
  --from=cronjob/ghost-backup ghost-backup-manual-<timestamp>

kubectl -n <namespace> wait \
  --for=condition=complete job/ghost-backup-manual-<timestamp> \
  --timeout=30m

kubectl -n <namespace> logs \
  job/ghost-backup-manual-<timestamp> --all-containers

수동 Job에서는 다음을 확인했다.

  • dump와 gzip 검사 성공
  • restic snapshot 생성
  • quota 및 OAuth 오류 0건
  • Secret과 repository 경로가 production 값인지
  • restic snapshots에서 새 snapshot 확인

그다음 30분 경계에서 CronJob이 만든 실제 정기 Job을 다시 확인했다. 수동 생성 방식과 scheduler 실행 방식의 차이, time zone, suspend, ServiceAccount와 concurrency 설정을 검증하기 위해서다.

Job Complete가 DR 성공은 아니다

원격에 백업이 생성된 뒤에는 그 데이터를 다시 가져올 수 있는지 확인했다. 다음 세 단계로 검증했다.

restic snapshots
restic check
restic restore <snapshot-id> --target <isolated-path>

restic check로 repository index와 pack 상태를 검사하고, production namespace 및 Local PV와 분리된 환경에 restore했다. 원본 workload의 mount를 재사용하면 잘못된 restore 명령이 운영 데이터를 덮어쓸 수 있으므로 격리 namespace와 별도 경로를 사용했다.

다른 노드에서 수행한 DR 복원 시험

주 저장소가 있는 온프레미스 worker와 다른 노드에 DR Job을 배치했다. DR 노드는 ARM64 환경이므로 multi-architecture 이미지와 필요한 MySQL client/restic/rclone binary가 동작하는지도 함께 검증했다.

복원 절차는 다음과 같다.

  1. Google Drive의 최신 snapshot 선택
  2. restic check 실행
  3. 격리 경로로 restore
  4. Ghost content 파일 수와 주요 디렉터리 확인
  5. gzip dump 무결성 확인
  6. 임시 MySQL에 dump import
  7. 테이블 수와 게시물 수 비교
  8. 테스트 namespace와 임시 리소스 삭제

실제 검증에서는 59개 files/directories가 복원됐고, content 파일 39개, MySQL 테이블 92개, 게시물 12개가 원본과 일치했다. 임시 MySQL import도 성공했다.

복원 결과에서 확인한 범위는 다음과 같다.

  • 원본 Local PV에 접근하지 않아도 복원 가능
  • off-site repository의 자격 증명과 암호가 유효
  • ARM64 복구 노드에서 도구가 동작
  • 파일과 DB dump가 같은 snapshot에 포함
  • 문서화된 절차로 실제 데이터 검증 가능

현재 RPO와 RTO

  • 목표 RPO: 최대 30분
  • 예상 RTO: 현재 규모에서 60~120분

RTO에는 다운로드 시간만 포함되지 않는다. 장애 판단, primary 쓰기 차단, snapshot 선택, restore, MySQL import, DR manifest 적용, 외부 경로 전환과 최종 확인 시간이 모두 포함된다.

당시 구성에는 자동 failover가 없었다. Local PV 노드나 하이퍼바이저가 중단되면 운영자가 기존 쓰기를 차단해 split-brain을 막고 DR 절차를 실행해야 했다. 이 수동 작업 범위도 복구 문서에 남겼다.

운영 체크리스트

매일

  • 최근 CronJob과 Job의 성공 여부
  • 마지막 성공 시각이 RPO 범위 안인지
  • 최신 restic snapshot 존재
  • RATE_LIMIT, invalid_grant, accessNotConfigured 오류 여부

매주

  • restic check
  • 저장소 사용량과 prune 결과
  • 실패 Job 로그 보존 및 알림 시험

매월

  • 격리 namespace 또는 별도 노드 restore
  • gzip dump 검사와 임시 MySQL import
  • 게시물·테이블·content 파일 수 비교
  • RTO 측정과 runbook 수정

자격 증명 변경 시

  • Secret 교체 후 새 Pod/Job이 새 값을 사용하는지 확인
  • 수동 Job과 다음 정기 Job을 모두 확인
  • 기존 token 폐기
  • Cloudflare Access 등 같은 프로젝트의 다른 OAuth client 회귀 시험

남아 있는 개선 과제

  • CronJob 실패와 마지막 성공 시각에 대한 Alertmanager 알림
  • 월간 restore Job 자동화와 결과 보고
  • Secret을 SOPS 또는 External Secrets로 선언적 관리
  • 두 번째 저장소 노드가 준비되면 Longhorn, Rook-Ceph 또는 NAS CSI 비교
  • DR 전환 승인과 외부 hostname 변경을 runbook으로 더 구체화

정기 생성과 보존 정책, 인증 오류를 확인하는 절차를 만들고 다른 노드에서 서비스 데이터를 복원했다. 이후에도 같은 상태를 유지할 수 있도록 알림과 월간 복원 자동화는 개선 과제로 남겼다.

참고 자료

SEARCH JOURNAL

기록 검색

글 제목, 요약, 태그에서 찾습니다.

검색을 준비하고 있어요.

검색어를 입력해 주세요.

    Ctrl/⌘ K 검색 Esc 닫기 ↑ ↓ 결과 이동