Kubernetes Ghost 백업을 복구 가능한 운영 체계로 만든 과정
운영 안정화·검증 작업: 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는 빠르고 단순하지만 저장소 노드에 종속된다. 그래서 primary storage의 성격을 숨기지 않고 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를 만들었다.
구성 순서는 다음과 같다.
- 백업 전용 Google Cloud OAuth Desktop client 생성
- 같은 프로젝트에서 Google Drive API 활성화
- OAuth 동의 화면을 운영 상태로 전환
- rclone을 다시 승인해 새 refresh token 발급
- Kubernetes Secret과 DR 노드의 root 전용 설정 교체
- 기존 공용 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이 정상이라고 판단하지 않았다.
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 성공은 아니다
백업 Job은 데이터를 원격에 썼다는 사실만 알려 준다. 복구 가능한지는 별개의 질문이다. 다음 세 단계로 검증했다.
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가 동작하는지도 함께 검증했다.
복원 절차는 다음과 같다.
- Google Drive의 최신 snapshot 선택
restic check실행- 격리 경로로 restore
- Ghost content 파일 수와 주요 디렉터리 확인
- gzip dump 무결성 확인
- 임시 MySQL에 dump import
- 테이블 수와 게시물 수 비교
- 테스트 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 절차를 실행해야 한다. 설계의 한계를 문서에 명확히 적는 것이 잘못된 HA 기대를 만드는 것보다 안전하다.
운영 체크리스트
매일
- 최근 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으로 더 구체화
백업 시스템의 완성 기준은 snapshot 개수가 아니다. 정해진 주기로 생성되고, 보존 정책이 동작하며, 인증 오류를 감지하고, 원본이 없는 다른 노드에서 실제 서비스 데이터로 복구할 수 있어야 한다.