From 44487d574611efd7a3edf578805e21a8373e8acd Mon Sep 17 00:00:00 2001 From: AWESOMEDEV Date: Fri, 17 Jul 2026 08:13:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(ops):=20DB=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B0=B1=EC=97=85=20=E2=80=94=20=EB=A7=A4=EC=9D=BC=20S3=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C,=2030=EC=9D=BC=20=EB=B3=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backup-db.sh: pg_dump | gzip | S3 업로드 + 로컬 7일 보관, 덤프 크기 검증 - restore-db.sh: 목록 조회·복원(확인 프롬프트·복원 전 자동 스냅샷) - systemd timer로 매일 03:00 KST 실행 (AL2023에 cron 없음, 이력이 journalctl에 남음) - S3 버킷: 퍼블릭 차단 + AES256 암호화 + 30일 수명주기 - IAM 역할(mirim-backup-role): 해당 버킷 postgres/ 에 PutObject만 — 최소 권한, 액세스 키 없음 - BACKUP.md: 구성·설계 이유·복원 리허설 절차 검증: 백업 성공(8.5MB→102KB), S3 업로드 확인, 임시 DB 복원 후 운영본과 건수 완전 일치 Co-Authored-By: Claude Opus 4.8 --- deploy/BACKUP.md | 80 ++++++++++++++++++++++++++++++++++++++++++++ deploy/backup-db.sh | 64 +++++++++++++++++++++++++++++++++++ deploy/restore-db.sh | 72 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 deploy/BACKUP.md create mode 100644 deploy/backup-db.sh create mode 100644 deploy/restore-db.sh diff --git a/deploy/BACKUP.md b/deploy/BACKUP.md new file mode 100644 index 0000000..4d7f49d --- /dev/null +++ b/deploy/BACKUP.md @@ -0,0 +1,80 @@ +# DB 백업·복원 운영 가이드 + +학습 플랫폼 DB(PostgreSQL)는 **매일 새벽 3시(KST)** 자동 백업되어 S3에 올라갑니다. + +## 구성 + +``` +EC2 (3.36.160.246) +├── mirim-postgres 컨테이너 ← 원본 DB +├── ~/backups/*.sql.gz ← 서버 로컬 사본 (7일 보관) +└── systemd timer (매일 03:00 KST) + ↓ pg_dump | gzip | aws s3 cp +S3: awesomedev-mirim-backup-apne2/postgres/ ← 30일 보관 후 자동 삭제 +``` + +**3-2-1 원칙**: 3벌(원본·로컬·S3) / 2종 매체(EC2 디스크·S3) / 1벌 원격(S3) + +## 왜 이렇게 만들었나 + +| 결정 | 이유 | +|---|---| +| **RDS 대신 컨테이너 + 백업** | 학생 5명 규모에 RDS는 월 $15~25가 계속 나감. 백업만 있으면 실질 위험은 같이 사라진다. 게다가 이 compose 파일이 "도커 입문" 코스의 교재다 | +| **액세스 키 대신 IAM 역할** | 키를 파일에 적으면 유출 시 끝. 역할(`mirim-backup-role`)은 이 서버에서만, 이 버킷의 `postgres/` 에만 쓸 수 있다 (최소 권한) | +| **cron 대신 systemd timer** | Amazon Linux 2023에 cron이 없기도 하고, 타이머는 실행 이력이 journalctl에 남고 `Persistent=true`로 서버가 꺼졌던 시간대 작업도 따라잡는다 | +| **SQL 덤프(pg_dump)** | 파일 복사가 아니라 "다시 만드는 SQL"이라 PG 버전이 달라도 복원되고, 텍스트라 gzip이 잘 먹는다 (8.5MB → 102KB) | + +## 자주 쓰는 명령 + +```bash +# 백업 목록 보기 +~/mirim-app/deploy/restore-db.sh + +# 지금 즉시 백업 +sudo systemctl start mirim-backup.service + +# 백업 이력·로그 확인 +sudo journalctl -u mirim-backup.service -n 30 + +# 다음 실행 예정 시각 +systemctl list-timers mirim-backup.timer + +# 복원 (확인 프롬프트 있음. 복원 전 현재 상태를 자동으로 먼저 백업함) +~/mirim-app/deploy/restore-db.sh mirim-2026-07-17_030000.sql.gz +docker compose -f docker-compose.prod.yml restart backend # 복원 후 재시작 +``` + +## 복원 리허설 (권장: 분기 1회) + +> **복원해 본 적 없는 백업은 백업이 아니다.** + +운영 DB를 건드리지 않고 임시 DB에 복원해 검증하는 방법: + +```bash +F=$(ls -t ~/backups/mirim-*.sql.gz | head -1) +docker exec mirim-postgres psql -U mirim -d postgres -c 'CREATE DATABASE restore_test;' +gunzip -c $F | docker exec -i mirim-postgres psql -U mirim -d restore_test +# 건수 대조 +docker exec mirim-postgres psql -U mirim -d restore_test -c \ + "SELECT count(*) FROM users; SELECT count(*) FROM quiz_question;" +docker exec mirim-postgres psql -U mirim -d postgres -c 'DROP DATABASE restore_test;' +``` + +**최초 검증 결과 (2026-07-17)**: 복원본과 운영 원본이 완전 일치 — +사용자 5 / 퀴즈문항 355 / 문서 17 / 체크리스트 46 / 코딩문제 20 ✅ + +## 무엇이 백업되고, 무엇이 안 되나 + +| 대상 | 백업 | 비고 | +|---|---|---| +| 학생 진도·제출물·퀴즈 성적·코딩 제출 | ✅ | **이게 진짜 자산** — 코드에 없는 유일한 데이터 | +| 사용자 계정(비밀번호 해시 포함) | ✅ | | +| 코스·문서·퀴즈 문항·체크리스트 | ✅ | (없어도 시드로 재생성 가능) | +| **Gitea 저장소** | ❌ | 별도 볼륨(`gitea-data`). 필요하면 백업 추가 검토 | +| 소스 코드 | ❌ | Gitea + 로컬에 있음 | + +## 비용 + +- S3 저장: 102KB × 30일 ≈ **3MB** → 사실상 $0 (월 몇 원) +- 데이터 전송: 업로드는 무료 +- **총 추가 비용 ≈ $0** diff --git a/deploy/backup-db.sh b/deploy/backup-db.sh new file mode 100644 index 0000000..205b0f1 --- /dev/null +++ b/deploy/backup-db.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# 이 파일이 하는 일: 학습 플랫폼 DB(PostgreSQL)를 하루 한 번 통째로 백업해 S3에 올린다. +# cron이 매일 새벽 3시(KST)에 실행한다. +# +# 학습 포인트 ① — 백업의 3-2-1 원칙 ("컴퓨터 문제 해결" 코스 참고) +# 3벌: 원본(도커 볼륨) + 서버 로컬 사본 + S3 사본 +# 2종: 서로 다른 매체(EC2 디스크 / S3) +# 1벌: 다른 장소(S3는 EC2와 별개 서비스 — EC2가 통째로 날아가도 S3는 남는다) +# +# 학습 포인트 ② — set -euo pipefail +# 기본 셸은 명령이 실패해도 다음 줄을 그냥 실행한다. 백업 스크립트에서 이건 최악이다: +# pg_dump가 실패했는데 빈 파일을 S3에 올리고 "성공"이라고 끝날 수 있다. +# -e: 명령 하나라도 실패하면 즉시 중단 +# -u: 정의 안 된 변수를 쓰면 중단 (오타로 빈 경로에 rm 하는 사고 방지) +# -o pipefail: 파이프(a | b)에서 앞 명령이 실패해도 잡아낸다 +set -euo pipefail + +# ── 설정 ── +BUCKET="awesomedev-mirim-backup-apne2" +CONTAINER="mirim-postgres" +DB_NAME="mirim" +DB_USER="mirim" +BACKUP_DIR="/home/ec2-user/backups" +LOCAL_KEEP_DAYS=7 # 서버 로컬 사본 보관 기간 (S3는 30일 — 수명주기 규칙이 자동 삭제) + +# 파일 이름에 날짜를 넣는다: mirim-2026-07-17_030000.sql.gz +# 학습 포인트: 날짜를 이름에 넣으면 정렬만 해도 시간순이 되고, 언제 것인지 열어보지 않아도 안다. +TIMESTAMP="$(date +%Y-%m-%d_%H%M%S)" +FILENAME="mirim-${TIMESTAMP}.sql.gz" +LOCAL_PATH="${BACKUP_DIR}/${FILENAME}" + +mkdir -p "$BACKUP_DIR" + +echo "[$(date '+%F %T')] 백업 시작" + +# ── 1) 덤프 + 압축 ── +# 학습 포인트: pg_dump는 DB 전체를 "다시 만들 수 있는 SQL 문장들"로 뽑아낸다. +# 파일 복사가 아니라 SQL이라 버전이 달라도 복원되고, 텍스트라 gzip이 아주 잘 먹는다(보통 1/5~1/10). +# docker exec로 컨테이너 안에서 실행하는 이유: pg_dump가 그 안에 있고, DB 포트를 밖에 열지 않았기 때문. +docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" | gzip > "$LOCAL_PATH" + +# 덤프가 정말 만들어졌는지 확인 — 크기가 0이면 실패다. +# 학습 포인트: "명령이 에러 없이 끝남"과 "결과물이 쓸모 있음"은 다르다. 결과를 검증한다. +SIZE=$(stat -c%s "$LOCAL_PATH") +if [ "$SIZE" -lt 1000 ]; then + echo "[오류] 덤프 파일이 너무 작습니다(${SIZE} bytes). 백업 실패로 간주하고 중단합니다." + rm -f "$LOCAL_PATH" + exit 1 +fi +echo " 덤프 완료: ${FILENAME} ($(numfmt --to=iec "$SIZE"))" + +# ── 2) S3 업로드 ── +# 학습 포인트: 액세스 키를 이 파일 어디에도 적지 않았다! +# EC2에 붙인 IAM 역할(mirim-backup-role)이 임시 자격증명을 자동으로 넣어 준다. +# 키를 파일에 적으면 그 파일이 유출되는 순간 끝이지만, 역할은 이 서버에서만, 이 버킷에만 쓸 수 있다. +aws s3 cp "$LOCAL_PATH" "s3://${BUCKET}/postgres/${FILENAME}" --only-show-errors +echo " S3 업로드 완료: s3://${BUCKET}/postgres/${FILENAME}" + +# ── 3) 오래된 로컬 사본 정리 ── +# S3는 수명주기 규칙이 30일 뒤 자동 삭제하므로, 여기선 서버 디스크만 관리한다. +find "$BACKUP_DIR" -name "mirim-*.sql.gz" -mtime "+${LOCAL_KEEP_DAYS}" -delete +echo " 로컬 사본 ${LOCAL_KEEP_DAYS}일 초과분 정리 완료" + +echo "[$(date '+%F %T')] 백업 성공 ✅" diff --git a/deploy/restore-db.sh b/deploy/restore-db.sh new file mode 100644 index 0000000..e3a2ad3 --- /dev/null +++ b/deploy/restore-db.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# 이 파일이 하는 일: 백업 파일로 DB를 되돌린다. +# +# 학습 포인트 — "복원해 본 적 없는 백업은 백업이 아니다." +# 백업 스크립트를 짜두고 안심하다가, 정작 사고가 났을 때 복원이 안 되는 일이 실제로 자주 있다. +# (덤프가 비어 있었거나, 복원 명령을 아무도 몰랐거나, 권한이 없었거나…) +# 그래서 백업 스크립트를 만들면 복원 스크립트도 같이 만들고, 한 번은 실제로 돌려봐야 한다. +# +# 사용법: +# ./restore-db.sh # 백업 목록 보기 +# ./restore-db.sh mirim-2026-07-17_030000.sql.gz # 그 파일로 복원 +set -euo pipefail + +BUCKET="awesomedev-mirim-backup-apne2" +CONTAINER="mirim-postgres" +DB_NAME="mirim" +DB_USER="mirim" +BACKUP_DIR="/home/ec2-user/backups" + +# 인자가 없으면 어떤 백업이 있는지 보여주고 끝낸다. +if [ $# -eq 0 ]; then + echo "=== 서버 로컬 백업 ===" + ls -lh "$BACKUP_DIR"/mirim-*.sql.gz 2>/dev/null || echo " (없음)" + echo "" + echo "=== S3 백업 (최근 10개) ===" + aws s3 ls "s3://${BUCKET}/postgres/" | tail -10 || echo " (없음)" + echo "" + echo "사용법: $0 <파일이름>" + echo " 예: $0 mirim-2026-07-17_030000.sql.gz" + exit 0 +fi + +FILENAME="$1" +LOCAL_PATH="${BACKUP_DIR}/${FILENAME}" + +# 로컬에 없으면 S3에서 받아온다. +if [ ! -f "$LOCAL_PATH" ]; then + echo "로컬에 없어 S3에서 내려받습니다: ${FILENAME}" + aws s3 cp "s3://${BUCKET}/postgres/${FILENAME}" "$LOCAL_PATH" +fi + +# ⚠️ 되돌릴 수 없는 작업 앞에는 반드시 확인을 세운다. +# 학습 포인트: rm -rf, DROP TABLE, 복원처럼 "되돌리기 어려운 명령"은 +# 사람이 한 번 더 눈으로 확인하게 만든다. 자동화의 예외를 두는 지점이다. +echo "" +echo "⚠️ 현재 DB(${DB_NAME})의 데이터를 백업 시점으로 되돌립니다." +echo " 파일: ${FILENAME}" +echo " 지금 DB에만 있는 최신 데이터(학생 진도·제출물)는 사라집니다." +read -p "정말 진행할까요? (yes 입력): " CONFIRM +if [ "$CONFIRM" != "yes" ]; then + echo "취소했습니다." + exit 0 +fi + +# 복원 전에 "지금 상태"를 한 번 더 백업해 둔다 — 복원이 잘못됐을 때 돌아올 자리. +# 학습 포인트: 복원도 실수할 수 있다. 되돌리기의 되돌리기를 준비해 두는 것이 프로다. +SAFETY="${BACKUP_DIR}/before-restore-$(date +%Y-%m-%d_%H%M%S).sql.gz" +echo "복원 전 현재 상태를 저장합니다: ${SAFETY}" +docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" | gzip > "$SAFETY" + +# 복원: 기존 스키마를 지우고 덤프를 그대로 흘려 넣는다. +# 학습 포인트: pg_dump 결과는 그냥 SQL 문장 모음이라, psql에 파이프로 먹이면 그대로 재현된다. +echo "복원 중..." +docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" > /dev/null +gunzip -c "$LOCAL_PATH" | docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" > /dev/null + +echo "복원 완료 ✅" +echo "확인:" +docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -t -c \ + "SELECT ' 사용자 '||count(*)||'명' FROM users UNION ALL SELECT ' 퀴즈문항 '||count(*)||'건' FROM quiz_question;" +echo "" +echo "백엔드를 재시작하세요: cd ~/mirim-app/deploy && docker compose -f docker-compose.prod.yml restart backend"