feat(ops): Gitea 자동 백업 추가 — 저장소·이슈·PR·계정 보호
- backup-gitea.sh: gitea dump(저장소+SQLite+설정을 일관된 시점으로) | S3 업로드 볼륨 tar 대신 gitea dump를 쓴 이유를 주석에 명시 (실행 중 tar는 시점이 어긋날 수 있음) - zip 무결성(unzip -t)·크기 검증 후에만 업로드 - systemd timer 매일 03:10 KST (DB 백업 10분 뒤 — 겹치지 않게) - IAM 정책에 gitea/ 경로 추가 + 복원용 GetObject (여전히 해당 버킷 두 경로로만 제한) - S3 수명주기에 gitea/ 30일 규칙 추가 - BACKUP.md: Gitea 복원 절차·검증법·'무엇이 이 서버에만 있는가' 관점 정리 검증: 백업 2.8MB, zip 무결성 OK, 백업 속 저장소에서 clone → HEAD·커밋20·파일269 운영과 일치 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
44487d5746
commit
387180dbfc
109
deploy/BACKUP.md
109
deploy/BACKUP.md
@ -1,16 +1,21 @@
|
|||||||
# DB 백업·복원 운영 가이드
|
# 백업·복원 운영 가이드
|
||||||
|
|
||||||
학습 플랫폼 DB(PostgreSQL)는 **매일 새벽 3시(KST)** 자동 백업되어 S3에 올라갑니다.
|
학습 플랫폼 **DB(PostgreSQL)**와 **Git 서버(Gitea)**가 **매일 새벽** 자동 백업되어 S3에 올라갑니다.
|
||||||
|
|
||||||
## 구성
|
## 구성
|
||||||
|
|
||||||
```
|
```
|
||||||
EC2 (3.36.160.246)
|
EC2 (3.36.160.246)
|
||||||
├── mirim-postgres 컨테이너 ← 원본 DB
|
├── mirim-postgres 컨테이너 ← 원본 DB
|
||||||
├── ~/backups/*.sql.gz ← 서버 로컬 사본 (7일 보관)
|
├── mirim-gitea 컨테이너 ← 원본 Git 서버 (저장소 + SQLite + 설정)
|
||||||
└── systemd timer (매일 03:00 KST)
|
├── ~/backups/ ← 서버 로컬 사본 (7일 보관)
|
||||||
↓ pg_dump | gzip | aws s3 cp
|
└── systemd timer
|
||||||
S3: awesomedev-mirim-backup-apne2/postgres/ ← 30일 보관 후 자동 삭제
|
├── 03:00 KST → backup-db.sh : pg_dump | gzip | s3 cp
|
||||||
|
└── 03:10 KST → backup-gitea.sh : gitea dump | s3 cp (10분 뒤 — 겹치지 않게)
|
||||||
|
↓
|
||||||
|
S3: awesomedev-mirim-backup-apne2/
|
||||||
|
├── postgres/*.sql.gz ← 30일 보관 후 자동 삭제
|
||||||
|
└── gitea/*.zip ← 30일 보관 후 자동 삭제
|
||||||
```
|
```
|
||||||
|
|
||||||
**3-2-1 원칙**: 3벌(원본·로컬·S3) / 2종 매체(EC2 디스크·S3) / 1벌 원격(S3)
|
**3-2-1 원칙**: 3벌(원본·로컬·S3) / 2종 매체(EC2 디스크·S3) / 1벌 원격(S3)
|
||||||
@ -23,27 +28,62 @@ S3: awesomedev-mirim-backup-apne2/postgres/ ← 30일 보관 후 자동 삭제
|
|||||||
| **액세스 키 대신 IAM 역할** | 키를 파일에 적으면 유출 시 끝. 역할(`mirim-backup-role`)은 이 서버에서만, 이 버킷의 `postgres/` 에만 쓸 수 있다 (최소 권한) |
|
| **액세스 키 대신 IAM 역할** | 키를 파일에 적으면 유출 시 끝. 역할(`mirim-backup-role`)은 이 서버에서만, 이 버킷의 `postgres/` 에만 쓸 수 있다 (최소 권한) |
|
||||||
| **cron 대신 systemd timer** | Amazon Linux 2023에 cron이 없기도 하고, 타이머는 실행 이력이 journalctl에 남고 `Persistent=true`로 서버가 꺼졌던 시간대 작업도 따라잡는다 |
|
| **cron 대신 systemd timer** | Amazon Linux 2023에 cron이 없기도 하고, 타이머는 실행 이력이 journalctl에 남고 `Persistent=true`로 서버가 꺼졌던 시간대 작업도 따라잡는다 |
|
||||||
| **SQL 덤프(pg_dump)** | 파일 복사가 아니라 "다시 만드는 SQL"이라 PG 버전이 달라도 복원되고, 텍스트라 gzip이 잘 먹는다 (8.5MB → 102KB) |
|
| **SQL 덤프(pg_dump)** | 파일 복사가 아니라 "다시 만드는 SQL"이라 PG 버전이 달라도 복원되고, 텍스트라 gzip이 잘 먹는다 (8.5MB → 102KB) |
|
||||||
|
| **Gitea는 볼륨 tar가 아니라 `gitea dump`** | Gitea는 저장소 파일 + SQLite DB(이슈·PR·계정) + 설정이 한 몸이다. 돌아가는 중에 tar로 묶으면 "파일은 새 것, DB는 옛 것"처럼 시점이 어긋날 수 있다. `gitea dump`는 셋을 일관된 시점으로 묶는다. → **앱이 자기 백업 도구를 제공하면 그걸 먼저 찾아본다** |
|
||||||
|
|
||||||
## 자주 쓰는 명령
|
## 자주 쓰는 명령
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 백업 목록 보기
|
# 백업 목록 보기 (로컬 + S3)
|
||||||
~/mirim-app/deploy/restore-db.sh
|
~/mirim-app/deploy/restore-db.sh
|
||||||
|
|
||||||
# 지금 즉시 백업
|
# 지금 즉시 백업
|
||||||
sudo systemctl start mirim-backup.service
|
sudo systemctl start mirim-backup.service # DB
|
||||||
|
sudo systemctl start mirim-gitea-backup.service # Gitea
|
||||||
|
|
||||||
# 백업 이력·로그 확인
|
# 백업 이력·로그 확인
|
||||||
sudo journalctl -u mirim-backup.service -n 30
|
sudo journalctl -u mirim-backup.service -n 30
|
||||||
|
sudo journalctl -u mirim-gitea-backup.service -n 30
|
||||||
|
|
||||||
# 다음 실행 예정 시각
|
# 다음 실행 예정 시각 (둘 다)
|
||||||
systemctl list-timers mirim-backup.timer
|
systemctl list-timers 'mirim-*'
|
||||||
|
|
||||||
# 복원 (확인 프롬프트 있음. 복원 전 현재 상태를 자동으로 먼저 백업함)
|
# DB 복원 (확인 프롬프트 있음. 복원 전 현재 상태를 자동으로 먼저 백업함)
|
||||||
~/mirim-app/deploy/restore-db.sh mirim-2026-07-17_030000.sql.gz
|
~/mirim-app/deploy/restore-db.sh mirim-2026-07-17_030000.sql.gz
|
||||||
docker compose -f docker-compose.prod.yml restart backend # 복원 후 재시작
|
docker compose -f docker-compose.prod.yml restart backend # 복원 후 재시작
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Gitea 복원 방법
|
||||||
|
|
||||||
|
Gitea 복원은 자주 쓸 일이 아니고 단계가 많아 스크립트 대신 절차로 둔다.
|
||||||
|
(자동화하지 않는 것도 선택이다 — 드물게 쓰는 위험한 작업은 사람이 한 단계씩 확인하는 편이 안전하다.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 백업 받아서 풀기
|
||||||
|
aws s3 cp s3://awesomedev-mirim-backup-apne2/gitea/gitea-2026-07-17_031000.zip /tmp/
|
||||||
|
cd /tmp && unzip -q gitea-2026-07-17_031000.zip -d gitea-restore
|
||||||
|
|
||||||
|
# 2) Gitea 중지
|
||||||
|
cd ~/mirim-app/deploy && docker compose -f docker-compose.prod.yml stop gitea
|
||||||
|
|
||||||
|
# 3) 저장소·설정·DB 되돌리기 (볼륨에 직접 씀 — sudo 필요)
|
||||||
|
V=/var/lib/docker/volumes/deploy_gitea-data/_data
|
||||||
|
sudo cp -a /tmp/gitea-restore/repos/. $V/git/repositories/
|
||||||
|
sudo cp -a /tmp/gitea-restore/data/. $V/gitea/
|
||||||
|
sudo cp /tmp/gitea-restore/app.ini $V/gitea/conf/app.ini
|
||||||
|
# gitea-db.sql → SQLite로 되돌리기
|
||||||
|
sudo rm -f $V/gitea/gitea.db
|
||||||
|
docker run --rm -v deploy_gitea-data:/data -v /tmp/gitea-restore:/r alpine/sqlite \
|
||||||
|
sh -c "sqlite3 /data/gitea/gitea.db < /r/gitea-db.sql"
|
||||||
|
sudo chown -R 1000:1000 $V
|
||||||
|
|
||||||
|
# 4) 다시 시작
|
||||||
|
docker compose -f docker-compose.prod.yml start gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
> 저장소만 급히 살리면 되는 경우엔 훨씬 간단하다 — zip 안의 `repos/awesomedev/mirim-app.git`은
|
||||||
|
> 그 자체로 완전한 bare 저장소라, `git clone repos/awesomedev/mirim-app.git` 하면 바로 소스가 나온다.
|
||||||
|
> **실제로 이 방법으로 복원 검증을 했다** (아래 참고).
|
||||||
|
|
||||||
## 복원 리허설 (권장: 분기 1회)
|
## 복원 리허설 (권장: 분기 1회)
|
||||||
|
|
||||||
> **복원해 본 적 없는 백업은 백업이 아니다.**
|
> **복원해 본 적 없는 백업은 백업이 아니다.**
|
||||||
@ -60,21 +100,46 @@ docker exec mirim-postgres psql -U mirim -d restore_test -c \
|
|||||||
docker exec mirim-postgres psql -U mirim -d postgres -c 'DROP DATABASE restore_test;'
|
docker exec mirim-postgres psql -U mirim -d postgres -c 'DROP DATABASE restore_test;'
|
||||||
```
|
```
|
||||||
|
|
||||||
**최초 검증 결과 (2026-07-17)**: 복원본과 운영 원본이 완전 일치 —
|
Gitea도 같은 방식으로 검증할 수 있다 (백업 속 저장소에서 직접 clone):
|
||||||
사용자 5 / 퀴즈문항 355 / 문서 17 / 체크리스트 46 / 코딩문제 20 ✅
|
|
||||||
|
```bash
|
||||||
|
F=$(ls -t ~/backups/gitea-*.zip | head -1)
|
||||||
|
mkdir -p ~/gtest && cd ~/gtest && unzip -qo $F 'repos/*'
|
||||||
|
docker run --rm --entrypoint sh -v ~/gtest:/w -w /w alpine/git:latest -c '
|
||||||
|
git config --global --add safe.directory "*"
|
||||||
|
git clone -q repos/awesomedev/mirim-app.git restored && cd restored
|
||||||
|
git log --oneline -3; git rev-list --count HEAD'
|
||||||
|
sudo rm -rf ~/gtest
|
||||||
|
```
|
||||||
|
|
||||||
|
**최초 검증 결과 (2026-07-17)**
|
||||||
|
|
||||||
|
| 대상 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| PostgreSQL | 임시 DB에 복원 → 운영 원본과 **완전 일치** (사용자 5 / 퀴즈 355 / 문서 17 / 체크리스트 46 / 코딩 20) ✅ |
|
||||||
|
| Gitea | 백업 속 저장소에서 clone 성공 → HEAD·커밋 20개·파일 269개가 운영과 **완전 일치** ✅ |
|
||||||
|
|
||||||
## 무엇이 백업되고, 무엇이 안 되나
|
## 무엇이 백업되고, 무엇이 안 되나
|
||||||
|
|
||||||
| 대상 | 백업 | 비고 |
|
| 대상 | 백업 | 비고 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 학생 진도·제출물·퀴즈 성적·코딩 제출 | ✅ | **이게 진짜 자산** — 코드에 없는 유일한 데이터 |
|
| 학생 진도·제출물·퀴즈 성적·코딩 제출 | ✅ postgres | **이게 진짜 자산** — 코드에 없는 유일한 데이터 |
|
||||||
| 사용자 계정(비밀번호 해시 포함) | ✅ | |
|
| 플랫폼 사용자 계정(비밀번호 해시) | ✅ postgres | |
|
||||||
| 코스·문서·퀴즈 문항·체크리스트 | ✅ | (없어도 시드로 재생성 가능) |
|
| 코스·문서·퀴즈 문항·체크리스트 | ✅ postgres | (없어도 시드로 재생성 가능) |
|
||||||
| **Gitea 저장소** | ❌ | 별도 볼륨(`gitea-data`). 필요하면 백업 추가 검토 |
|
| Git 저장소(소스·전체 이력) | ✅ gitea | 개발자 PC에도 clone 사본이 있어 이중 안전 |
|
||||||
| 소스 코드 | ❌ | Gitea + 로컬에 있음 |
|
| **Gitea 이슈·PR 리뷰 코멘트** | ✅ gitea | **Gitea에만 있는 것** — 잃으면 복구 불가 |
|
||||||
|
| Gitea 계정·권한·설정 | ✅ gitea | |
|
||||||
|
| EC2 서버 설정 자체 | ❌ | 재구축은 `deploy/` + `DEPLOY` 문서로 가능 |
|
||||||
|
|
||||||
|
> 백업 설계의 출발점은 **"무엇이 이 서버에만 있는가"**를 묻는 것이다.
|
||||||
|
> 소스 코드는 git의 분산 구조 덕에 개발자 PC마다 사본이 있지만,
|
||||||
|
> **이슈·PR 코멘트·학생 제출물**은 이 서버에만 있다 — 그래서 이 둘을 지킨다.
|
||||||
|
|
||||||
## 비용
|
## 비용
|
||||||
|
|
||||||
- S3 저장: 102KB × 30일 ≈ **3MB** → 사실상 $0 (월 몇 원)
|
| 항목 | 크기 | 비용 |
|
||||||
- 데이터 전송: 업로드는 무료
|
|---|---|---|
|
||||||
- **총 추가 비용 ≈ $0**
|
| postgres 백업 | 102KB × 30일 ≈ 3MB | ~$0 |
|
||||||
|
| gitea 백업 | 2.8MB × 30일 ≈ 84MB | ~$0.002/월 |
|
||||||
|
| 업로드 전송 | — | 무료 |
|
||||||
|
| **합계** | ~87MB | **월 몇 원 (≈$0)** |
|
||||||
|
|||||||
67
deploy/backup-gitea.sh
Normal file
67
deploy/backup-gitea.sh
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 이 파일이 하는 일: 사내 Git 서버(Gitea)를 통째로 백업해 S3에 올린다.
|
||||||
|
# systemd timer가 매일 새벽 3시 10분(KST)에 실행한다. (DB 백업 10분 뒤 — 겹치지 않게)
|
||||||
|
#
|
||||||
|
# 학습 포인트 ① — 왜 볼륨을 그냥 tar로 묶지 않고 `gitea dump`를 쓰나?
|
||||||
|
# Gitea는 세 가지가 한 몸이다: 저장소 파일(/data/git) + SQLite DB(이슈·PR·계정) + 설정.
|
||||||
|
# 돌아가는 중에 tar로 묶으면 "파일은 새 것, DB는 옛 것" 같은 어긋난 시점이 섞일 수 있다.
|
||||||
|
# gitea dump는 이 셋을 일관된 시점으로 묶어 zip 하나로 만들어 준다.
|
||||||
|
# → 교훈: 애플리케이션이 자기 백업 도구를 제공하면 그걸 먼저 찾아본다.
|
||||||
|
# "파일만 복사하면 되겠지"가 통하지 않는 경우가 생각보다 많다(DB, 검색엔진 등).
|
||||||
|
#
|
||||||
|
# 학습 포인트 ② — 무엇을 지키려는 백업인가?
|
||||||
|
# 소스 코드 자체는 개발자 PC마다 clone 사본이 있어 사실 덜 급하다(git의 분산 구조!).
|
||||||
|
# 진짜 잃으면 아픈 건 Gitea에만 있는 것들: 이슈, PR 리뷰 코멘트, 계정, 권한 설정.
|
||||||
|
# "무엇이 이 서버에만 있는가"를 묻는 것이 백업 설계의 출발점이다.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BUCKET="awesomedev-mirim-backup-apne2"
|
||||||
|
CONTAINER="mirim-gitea"
|
||||||
|
BACKUP_DIR="/home/ec2-user/backups"
|
||||||
|
LOCAL_KEEP_DAYS=7 # 로컬 사본 보관 (S3는 30일 — 수명주기 규칙이 자동 삭제)
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y-%m-%d_%H%M%S)"
|
||||||
|
FILENAME="gitea-${TIMESTAMP}.zip"
|
||||||
|
LOCAL_PATH="${BACKUP_DIR}/${FILENAME}"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "[$(date '+%F %T')] Gitea 백업 시작"
|
||||||
|
|
||||||
|
# ── 1) 컨테이너 안에서 dump 생성 ──
|
||||||
|
# 학습 포인트: -u git — Gitea 프로세스를 돌리는 사용자로 실행해야 파일 권한이 꼬이지 않는다.
|
||||||
|
# --file 로 이름을 지정하고, /tmp에 만든다(컨테이너가 지워지면 같이 사라지는 임시 공간).
|
||||||
|
docker exec -u git "$CONTAINER" gitea dump --file "/tmp/${FILENAME}" --quiet --work-path /data/gitea
|
||||||
|
|
||||||
|
# ── 2) 컨테이너 밖으로 꺼내기 ──
|
||||||
|
# 학습 포인트: docker cp 는 컨테이너 안팎으로 파일을 옮긴다.
|
||||||
|
# 컨테이너는 언제든 지워질 수 있는 존재라, 백업을 그 안에 두면 백업이 아니다.
|
||||||
|
docker cp "${CONTAINER}:/tmp/${FILENAME}" "$LOCAL_PATH"
|
||||||
|
docker exec -u git "$CONTAINER" rm -f "/tmp/${FILENAME}" # 컨테이너 안 임시본은 지운다
|
||||||
|
|
||||||
|
# ── 3) 결과 검증 ──
|
||||||
|
# 명령이 성공했다고 결과물이 쓸모 있는 건 아니다. 크기와 zip 무결성을 확인한다.
|
||||||
|
SIZE=$(stat -c%s "$LOCAL_PATH")
|
||||||
|
if [ "$SIZE" -lt 10000 ]; then
|
||||||
|
echo "[오류] 백업 파일이 너무 작습니다(${SIZE} bytes). 실패로 간주하고 중단합니다."
|
||||||
|
rm -f "$LOCAL_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# unzip -t : 압축 파일이 깨지지 않았는지 검사 (풀지 않고 검증만)
|
||||||
|
if ! unzip -tq "$LOCAL_PATH" > /dev/null 2>&1; then
|
||||||
|
echo "[오류] zip 무결성 검사 실패. 손상된 백업이므로 올리지 않습니다."
|
||||||
|
rm -f "$LOCAL_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " 덤프 완료: ${FILENAME} ($(numfmt --to=iec "$SIZE")) — zip 무결성 OK"
|
||||||
|
|
||||||
|
# ── 4) S3 업로드 ──
|
||||||
|
# 액세스 키가 이 파일 어디에도 없다 — EC2에 붙인 IAM 역할이 임시 자격증명을 자동 제공한다.
|
||||||
|
aws s3 cp "$LOCAL_PATH" "s3://${BUCKET}/gitea/${FILENAME}" --only-show-errors
|
||||||
|
echo " S3 업로드 완료: s3://${BUCKET}/gitea/${FILENAME}"
|
||||||
|
|
||||||
|
# ── 5) 오래된 로컬 사본 정리 ──
|
||||||
|
find "$BACKUP_DIR" -name "gitea-*.zip" -mtime "+${LOCAL_KEEP_DAYS}" -delete
|
||||||
|
echo " 로컬 사본 ${LOCAL_KEEP_DAYS}일 초과분 정리 완료"
|
||||||
|
|
||||||
|
echo "[$(date '+%F %T')] Gitea 백업 성공 ✅"
|
||||||
Loading…
x
Reference in New Issue
Block a user