saip.learning notes

S3 백업과 복원 리허설, 운영 점검표

배포 · 마지막 수정 2026-10-24

백업은 복원해 봐야 백업이다

백업 파일을 쌓아 두기만 하면, 정작 필요할 때 "파일이 깨져 있다", "복원 순서를 모른다"가 된다. 그래서 백업을 만든 날 실제로 되살려 보는 것(복원 리허설) 까지 해야 끝이다.

이 블로그에서 잃으면 안 되는 것은 두 가지다.

무엇 어디 백업 방법
노트·댓글·이력 PostgreSQL(pgdata 볼륨) pg_dump -Fc (압축된 PostgreSQL 전용 형식, pg_restore로 복원)
업로드 이미지 images 볼륨 폴더째 복사

둘을 saip-YYYYMMDD.tar.gz 하나로 묶어 서버 밖(S3) 에 둔다. 서버가 통째로 사라져도 백업은 남는다.

flowchart LR
  cron([cron 매일 04:00]) --> sh["`**backup.sh**
  pg_dump -Fc
  이미지 복사
  tar czf`"]
  sh -- PutObject만 --> s3@{ shape: cyl, label: "S3 daily/ (서울)" }
  s3 -- 14일 뒤 --> gone([수명 주기 규칙이 삭제])
  s3 -. 콘솔에서 관리자가 받기 .-> pc["`**복원**
  pg_restore --clean`"]
  class s3 ext
  classDef ext stroke-dasharray: 4 3

S3와 수명 주기 규칙

  • S3: AWS의 파일 보관소. 수십 KB짜리 백업 14개면 요금은 사실상 0이다.
  • 수명 주기 규칙: "접두사 daily/, 객체 생성 14일 후 만료". 오래된 백업을 스크립트로 지우지 않아도 S3가 알아서 지운다.
  • 리전 분리: 서버는 시드니, 버킷은 서울. 한 리전에 장애가 나도 다른 쪽은 남는다.
  • 버킷 만들 때 퍼블릭 액세스 차단은 그대로, 기본 암호화(SSE-S3)도 그대로.

Note

버킷 네임스페이스에서 "계정 리전 네임스페이스(권장)"는 이름 뒤에 계정 ID와 리전이 붙어 남이 같은 이름을 선점할 수 없게 한다. 예측 가능한 이름(회사-logs)을 지웠다가 남이 같은 이름으로 만들어 데이터를 가로채는 일을 막는다. 이번엔 이름 끝에 임의 문자를 붙여 선점 걱정이 없어서 글로벌 네임스페이스로 했다.

IAM 최소 권한: 올리기만 되는 키

서버에 AWS 키를 둬야 S3에 올릴 수 있다. 그런데 서버가 털리면 키도 같이 털린다. 그래서 키 권한을 daily/에 파일 올리기 하나만 준다.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "s3:PutObject",
    "Resource": "arn:aws:s3:::<버킷>/daily/*"
  }]
}

실제로 확인한 결과:

이 키로 결과
올리기 (aws s3 cp → S3) ✅ 됨
목록 보기 (aws s3 ls) ❌ 거부
내려받기 ❌ 거부
삭제 (aws s3 rm) ❌ 거부

공격자가 키를 손에 넣어도 지난 백업을 지우거나 읽을 수 없다. 대신 복원할 때는 관리자가 콘솔에서 내려받는다.

키는 서버에서만 입력한다(대화창·저장소에 붙여 넣지 않는다).

sudo snap install aws-cli --classic
aws configure --profile saip-backup

backup.sh

#!/usr/bin/env bash
set -euo pipefail          # 한 줄이라도 실패하면 바로 멈추고 0이 아닌 종료 코드

cd ~/saip
BUCKET=$(sed -n 's/^BACKUP_BUCKET=//p' .env)
[ -n "$BUCKET" ] || { echo "backup FAILED: .env에 BACKUP_BUCKET이 없다" >&2; exit 1; }

work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT  # 성공하든 실패하든 임시 폴더는 지운다
name=saip-$(date +%Y%m%d).tar.gz

docker compose exec -T db pg_dump -U blog -Fc blog > "$work/db.dump"
docker compose cp app:/data/images "$work/images" > /dev/null
tar czf "$work/$name" -C "$work" db.dump images
aws s3 cp "$work/$name" "s3://$BUCKET/daily/$name" --profile saip-backup --only-show-errors

echo "$(date '+%F %T') backup ok $name $(du -h "$work/$name" | cut -f1)"
  • exec -T: 터미널 없이 실행(cron에는 터미널이 없다).
  • trap ... EXIT: 스크립트가 어떻게 끝나든 정리 코드를 실행한다.
  • 버킷 이름을 일부러 틀리게 해 보니 NoSuchBucket 오류와 종료 코드 1. 실패가 로그에 남는다.

cron

리눅스의 예약 실행. 다섯 칸은 분 시 일 월 요일이다.

# 매일 04:00. 서버 시간대를 Asia/Seoul로 맞춰 두어서 한국 시간
0 4 * * * bash ~/saip/backup.sh >> ~/saip/backup.log 2>&1

>> backup.log 2>&1로 정상 출력과 오류를 모두 로그 파일에 쌓는다. 확인은 tail ~/saip/backup.log.

복원 리허설

  1. S3 콘솔에서 백업 파일을 내려받는다(서버 키로는 못 받으니 관리자 권한으로).
  2. 로컬 앱을 멈추고 로컬 개발 DB에 덮어쓴다.
mkdir restore && tar xzf saip-YYYYMMDD.tar.gz -C restore
docker cp restore/db.dump saip-postgres-1:/tmp/db.dump
docker exec saip-postgres-1 pg_restore -U blog -d blog --clean --if-exists --no-owner /tmp/db.dump
cp -r restore/images/. data/images/
  • --clean --if-exists: 있는 테이블은 지우고 다시 만든다(없으면 조용히 넘어감).
  • --no-owner: 원래 소유자 정보 대신 지금 접속한 사용자로 만든다.
  1. 앱을 띄워 운영과 비교 → 노트 링크 목록과 본문 HTML이 완전히 같았다.

서버가 통째로 사라졌을 때의 순서(새 인스턴스 → 설치 → .env → DB만 띄워 복원 → 앱·Caddy → 배포 재실행)는 저장소의 deploy/restore.md에 적어 두었다.

운영 점검표

cd ~/saip
docker compose ps                     # 셋 다 Up, db는 healthy
docker compose logs --since 1h app | grep -iE "error|exception"
docker compose logs -f --since 10m app  # 실시간(Ctrl+C로 종료)
tail -3 backup.log                    # 매일 "backup ok"
free -m                               # 메모리, available 확인
df -h /                               # 디스크 사용률
docker system df                      # 이미지·볼륨이 차지하는 크기
curl -svI https://<도메인> 2>&1 | grep -i "expire date"   # 인증서 만료일

처음 돌려 본 결과: 메모리 909MB 중 available 280MB(스왑 222MB 사용), 디스크 17%, 인증서 2027-01-22까지.

겪은 문제

배포할 때마다 이미지가 쌓였다

docker system df를 보니 이미지가 7개, 1.38GB. 커밋 SHA마다 새 이미지가 남고, 태그가 붙어 있어서 docker image prune(태그 없는 것만 지움)으로도 안 지워진다. 40GB 디스크면 몇 달 뒤 꽉 찬다.

배포 끝에 쓰지 않는 이미지를 전부 지우게 했다.

docker compose pull app && docker compose up -d && docker image prune -af > /dev/null

-a는 태그가 있어도 실행 중이 아니면 지운다. 되돌리기에는 지장이 없다. 이전 SHA 이미지는 ghcr에 그대로 있고, docker compose up이 없는 이미지를 받아 온다. 정리 후 이미지는 3개(postgres, caddy, app).

브라우저가 .tar.gz를 .tar로 저장했다

S3 콘솔에서 받은 파일 이름이 .tar였다. 첫 두 바이트가 1f 8b(gzip 표시)라 내용은 그대로 gzip이고, tar xzf로 풀린다.

head -c 2 파일 | od -An -tx1   # 1f 8b 이면 gzip

댓글 0

로그인하고 댓글 달기