운영계 세팅 회고 ep3 - DB 백업 전략 (pgBackRest)

인프라9분English

English version: Production Server Setup Notes, Ep. 3 - DB Backup Strategy (pgBackRest)

요구사항

  • 장애가 났을 때 원하는 시점으로 데이터를 복구할 수 있어야 함(Point-in-Time Recovery, PITR)
  • 백업은 DB와 다른 디스크(별도 HDD)에 보관함
OS 디스크 OS · k3s SSD DB 데이터 HDD 백업 저장소 WAL 백업 DB와 백업을 서로 다른 디스크에 둔다
DB 데이터는 SSD에, 백업 저장소는 별도 HDD에 둔다.

DB 덤프(pg_dump)만으로는 부족한 이유

pg_dump로 만든 덤프는 실행한 시점의 데이터만 담는다.
매일 새벽에 덤프한다면, 오후에 장애가 났을 때 되돌릴 수 있는 지점은 그날 새벽뿐이다.
그 사이에 생긴 변경은 복구할 수 없다.

반면 PITR은 다르게 동작한다.
PostgreSQL은 데이터를 바꾸기 전에 변경 내용을 먼저 WAL(Write-Ahead Log)에 기록한다.
베이스 백업을 해 두고 그 뒤의 WAL을 빠짐없이 보관하면, 베이스 백업에서 시작해 WAL을 원하는 시각까지 재생할 수 있다.

매일 덤프 베이스 백업 + WAL 1일차 덤프 2일차 덤프 장애 마지막 덤프 뒤의 변경은 잃음 되돌릴 수 있는 지점 베이스 백업 WAL을 이어 재생해 이 사이 어느 시각으로든 복구
덤프는 덤프한 시점으로만 되돌릴 수 있다. 베이스 백업과 WAL이 있으면 장애 직전 어느 시각으로든 되돌릴 수 있다.

pgBackRest를 고른 이유

베이스 백업과 WAL 보관은 PostgreSQL 기본 기능(pg_basebackup, archive_command)만으로도 할 수 있다.
다만 오래된 백업과 WAL을 정리하고, 백업이 깨지지 않았는지 확인하고, 복구 절차를 맞추는 일은 직접 만들어야 한다.
pgBackRest는 아래 목록을 한 도구로 제공한다.

  • Full / Differential / Incremental 백업
  • 병렬 백업과 복구(process-max)
  • zstd 압축
  • 보관 정책(retention)에 따른 백업·WAL 정리
  • 저장소 점검(check, verify)

백업 구성

  • 매주 일요일 Full 백업, 월요일부터 토요일까지 매일 02:00(예시 시각) Incremental 백업
  • Full 백업은 2세트를 보관(repo1-retention-full=2)
  • WAL은 archive_command로 백업 저장소에 계속 아카이빙
일주일 스케줄 일 Full 월 Incr 화 Incr 수 Incr 목 Incr 금 Incr 토 Incr 백업 WAL WAL은 쉬지 않고 아카이빙 · Full 백업은 2세트 보관 · 02:00은 예시 시각 백업이 실행되는 경로 CronJob Full · Incr 예약 RBAC: DB 파드 get·exec만 DB 파드 PostgreSQL pgBackRest archive_command kubectl exec 백업 저장소 별도 HDD WAL Full · Incr
일요일 Full 백업 위에 매일 Incremental 백업이 쌓이고, WAL 아카이빙은 그 사이에도 계속된다.

전체 백업(Full)과 증분 백업(Incremental)을 섞는 이유

Incremental(증분) 백업은 직전 백업 이후의 변경분만 담아서, 읽고 쓰는 양이 적어 백업 시간이 짧고 저장 공간도 적게 쓴다.
대신 직전 백업들에 의존하기 때문에, 그중 하나가 깨지거나 사라지면 그 뒤의 Incremental도 쓸 수 없다.
그래서 매주 Full 백업을 새로 해서, 하나의 Full에 의존하는 Incremental이 6개를 넘지 않게 했다.

매일 Incremental을 두는 이유는 복구 시간이다.
복구할 때는 가장 가까운 백업을 복원한 뒤 그 이후의 WAL만 재생한다.
백업이 주 1회뿐이면 최악의 경우 6일 치 WAL을 재생해야 하지만, 매일 백업하면 복구 때 재생하는 WAL은 하루 치를 넘지 않는다.
WAL 자체는 보관 정책에 따라 약 7~14일 치를 남긴다.

Incremental은 직전 백업에 의존한다 일 Full 월 Incr 화 Incr 수 손상 목 Incr 금 Incr 토 Incr 목~토 백업은 복구에 쓸 수 없음 매주 Full에서 의존이 새로 시작 목요일 오후 장애 때 재생하는 WAL 장애 주 1회 Full 일요일 뒤로 4일 넘게 재생 매일 Incr 목 02:00 뒤만 재생
Incremental은 앞의 백업에 의존하고, 복구 때는 가장 가까운 백업 뒤의 WAL만 재생한다.

Differential 백업은 마지막 Full 이후 바뀐 파일을 모두 담는다. 주 후반으로 갈수록 커져서, 매일 하는 백업에는 Incremental을 썼다.

백업과 WAL 보관 정책

pgBackRest는 Full 백업이 만료되면 그 Full에 딸린 Incremental도 함께 만료하고, 남은 가장 오래된 Full보다 오래된 WAL도 지운다.
예를 들어 repo1-retention-full=2면 세 번째 Full이 끝나는 순간 가장 오래된 Full과 그 Incremental, 그보다 오래된 WAL이 정리된다.

그래서 PITR로 되돌릴 수 있는 범위는 고정된 2주가 아니다.
새 Full 직후에는 지난주 Full부터, 다음 Full 직전에는 2주 가까이 전까지로, 약 7~14일 사이를 오간다.
Full을 1세트만 두면 새 Full 직후 이전 Full이 지워져, 새 Full이 깨졌을 때 대신 쓸 백업이 없다.
그래서 새 Full이 깨져도 이전 Full로 되돌릴 수 있게 2세트를 남겼다.

3주차 Full 직후 1주차 · 만료 2주차 되돌릴 수 있는 범위 약 7일 3주차 토요일 1주차 · 만료 2주차 3주차 되돌릴 수 있는 범위 약 13일 ■ Full ▭ Incremental과 그 사이의 WAL
Full 백업 2세트를 남기면, 되돌릴 수 있는 범위가 약 7~14일 사이를 오간다.

WAL 아카이빙과 archive_timeout

archive_command는 다 찬 WAL 파일에만 호출된다.
쓰기가 적으면 파일이 한참 차지 않아, 그동안의 변경은 백업 저장소에 없다.
archive_timeout을 두면 그 시간이 지났을 때 덜 찬 파일이라도 넘기고 새 파일로 바꾼다.

0분 5분 10분 15분 WAL 파일 덜 차도 같은 크기로 아카이빙 장애 잃는 구간 백업 저장소 아카이빙된 파일은 모두 같은 크기 ■ 파일에 실제로 쓴 양 archive_timeout이 짧을수록 잃는 구간은 줄지만, 아카이빙되는 파일 수가 늘어난다
archive_timeout이 지나면 덜 찬 WAL 파일도 닫아서 아카이빙한다. 장애가 나면 아직 아카이빙되지 않은 마지막 구간을 잃는다.

짧게 잡을수록 잃는 폭은 줄지만, 일찍 닫힌 파일도 다 찬 파일과 같은 크기라 백업 저장소 사용량이 늘어난다.
PostgreSQL 공식 문서에서도 너무 짧은 값은 피하고, 1분 안팎이면 대개 무난하다고 적고 있다.
먼저 갑자기 꺼졌을 때 잃어도 되는 폭(RPO)을 5분으로 정하고, 그에 맞춰 archive_timeout을 300초로 두었다.
문서 권고(1분 안팎)보다 길지만, 그만큼 아카이빙되는 WAL 파일 수가 줄어든다.
그래서 서버가 갑자기 꺼지면 최대 약 5분의 변경을 잃을 수 있다.
정상 종료할 때는 PostgreSQL이 남은 WAL까지 아카이빙하므로 잃는 것이 없다.

백업은 Kubernetes CronJob이 DB 파드에 kubectl exec로 들어가 실행한다.
CronJob의 RBAC 권한은 DB 파드 하나에 대한 get·exec만 주도록 제한했다.

복구 방법

복구는 지금 DB의 데이터 디렉터리를 지정한 시점의 데이터로 덮어쓴다.
잘못 실행되면 지금 데이터를 잃기 때문에, 실수로 돌지 않도록 인터락(interlock)을 걸었다.
서버의 백업 디렉터리에 복구 요청 파일이 있고, helm 값에 같은 복구 ID가 들어 있을 때만 DB 파드가 복구 모드로 동작한다.
둘 중 하나라도 없으면 평소처럼 기동한다.

복구 요청 파일 서버의 백업 디렉터리 helm 값 복구 ID 둘 다 있고 ID가 같은가 복구 모드로 기동 기존 데이터 디렉터리 보존 평소처럼 기동 예 아니요
복구 요청 파일과 helm 값이 모두 맞아야 복구 모드로 기동한다.

복구 전에는 기존 데이터 디렉터리를 지우지 않고 이름을 바꿔 남겨 둔다.
복구가 끝나면 요청 파일은 자동으로 지워지고, helm 값에서 복구 항목을 빼서 일반 모드로 되돌린다.

운영 서버에서도 검증을 완료했다

데이터를 옮기기 전에, 실데이터 사본과, 그 테이블을 n배로 복제해 만든 대용량 테스트 데이터로 백업과 복구를 확인했다.

항목실데이터 사본대용량 테스트 데이터
Full 백업8.3초26초
병렬 수(process-max)-4개 26초 · 8개 19.7초 · 16개 19.9초
복구지정 시각 복구 5.1초최신 시점 복구 37.2초 (DB 준비까지 46.6초)
  • 첫 예약 Incremental 백업 → 정상 동작
  • 지정 시각 복구 → 일부러 지운 데이터가 모두 복구된 것을 확인
  • 최신 시점 복구 → 아직 아카이빙되지 않았던 마지막 변경까지 복구됨. 정상 종료할 때 PostgreSQL이 남은 WAL을 아카이빙하기 때문임

장애 테스트도 했다.
백업 저장소의 WAL 디렉터리 권한을 chmod 000으로 막아 아카이빙을 일부러 실패시켰다.
그동안에도 DB는 쓰기 요청을 정상 처리했고, 아카이빙하지 못한 WAL은 pg_wal 디렉터리에 쌓였다.
디렉터리 권한을 원래 값(750)으로 복구하자 30초 안에 밀린 WAL을 모두 아카이빙했다.

특이사항

postStart 훅이 SELinux에 막히는 현상

DB 파드가 뜰 때 postStart 훅에서 pgBackRest stanza를 만들도록 했다.
훅의 출력을 컨테이너 로그로 보려고 /proc/1/fd/1로 보냈는데, SELinux(Enforcing)가 이 쓰기를 막아(Permission denied) 명령이 시작조차 못 했다.
그래서 stanza가 만들어지지 않았고, WAL 아카이빙도 실패하고 있었다.

개발 서버는 SELinux가 Permissive라 드러나지 않았던 문제다.

다음과 같이 조치했다.

  1. 먼저 DB 파드 안에서 pgbackrest stanza-create를 직접 실행해 stanza를 만들었다. 밀려 있던 WAL이 바로 아카이빙되기 시작했고, pgbackrest check도 통과했다.
  2. 훅의 출력은 /proc/1/fd/1 대신 파드 안의 로그 파일로 남기도록 Helm 차트를 고쳤다. 다시 배포한 뒤 DB 파드가 재기동될 때 훅이 stanza를 확인하고 check가 통과하는 것까지 확인했다.

참고