NFS 자동 백업 인프라 구축 — WAL 안전 스냅샷부터 복구 검증까지

SELF-HOSTING 운영기 · 3/3

💾 NFS 자동 백업 인프라 구축 — WAL 안전 스냅샷부터 복구 검증까지

“백업이 있다는 착각”을 진짜 백업으로 바꾸는 과정. SQLite WAL 모드의 함정, NFS 마운트가 풀렸을 때의 사고, 하드링크 증분, cron 환경 차이 — 셀프호스팅 백업에서 실제로 발목 잡는 디테일 전부.

#NFS #백업자동화 #SQLite #WAL #rsync #cron #복구

📋 이 글에서 다루는 것

  • 1. SPOF 진단 — 같은 디스크 위의 백업은 백업이 아니다
  • 2. WAL 모드 함정 — DB 파일을 그냥 복사하면 안 되는 이유
  • 3. NFS 마운트의 숨은 사고 — 마운트 풀림 가드
  • 4. 완성된 백업 스크립트 (하드링크 증분 + 세대 관리)
  • 5. 백업의 90% — 복구 검증과 cron 환경 차이
  • 6. 전체 작업 총정리
2편에서 디렉토리는 가벼워졌지만 마지막 리스크가 남았다. 라이브 데이터의 유일한 사본이 같은 서버, 같은 디스크에 있었다. 디스크 하나가 죽으면 원본과 백업이 동시에 사라진다. 이건 백업이 아니라 “백업이 있다는 착각”이다. 이 편은 그 단일 장애점을 NFS 기반 자동 백업으로 제거하면서, 그 과정에서 마주친 함정들을 기록한다.

1. SPOF 진단 — df로 드러난 진실

“외부 저장소가 있다”는 믿음은 df로 검증해야 한다. 마운트 지점이 어느 물리 디스크에 속하는지 봐야 진짜 분리됐는지 알 수 있다.

백업 위치와 라이브가 같은 디스크인지 확인
df -h /opt/_archive /opt/my-flashcard/data
⚠ 결과: 둘 다 동일 LVM (같은 바구니의 계란)

두 경로 모두 /dev/mapper/ubuntu--vg-ubuntu--lv로 나왔다. 즉 임시 tar 스냅샷은 라이브와 운명을 공유한다. 디스크/LVM 장애, 실수 rm -rf, 파일시스템 손상 — 어느 시나리오든 원본과 백업이 함께 증발한다. 외부로 한 카피만 빠져도 리스크가 1/100로 준다.

다행히 이 서버에는 NAS가 NFS4로 마운트돼 있었다. 별도 물리 장비(11.11.11.1)의 3.5T 볼륨이고 서버 LVM과 완전히 분리돼 있다 — 진짜 외부 백업이 가능하다. NFS4라 하드링크도 지원해 증분 백업까지 된다.

NFS 마운트 확인 + 쓰기 권한 테스트
NFS는 root_squash 설정으로 root 쓰기가 막히는 경우가 있어 미리 검증한다.
mount | grep -E "cifs|nfs"
touch /mnt/docker/flashcard-backup/_write_test \
  && echo "쓰기 OK" && rm /mnt/docker/flashcard-backup/_write_test \
  || echo "쓰기 실패 - NFS 권한 문제"

2. WAL 모드 함정 — DB를 그냥 복사하면 안 된다

이 앱의 SQLite는 성능을 위해 WAL(Write-Ahead Logging) 모드를 쓴다. WAL 모드에서는 라이브 .db 파일을 단순 파일 복사하면 위험하다. 커밋되지 않은 트랜잭션이 별도 -wal 파일에 있을 수 있어, 복사한 시점에 따라 일관성이 깨진 DB를 백업하게 된다.

방식문제 / 안전성
cp flashcards.dbWAL 미반영 → 트랜잭션 일관성 깨질 수 있음
tar (라이브 db)복사 순간 쓰기 중이면 미세하게 불안정
sqlite3 .backup잠금 안전한 일관 스냅샷 (정석)
Python sqlite3.backup()CLI 없어도 가능 — 라이브러리 내장 API
⚠ 함정: 컨테이너 안에 sqlite3 CLI가 없었다

처음엔 sqlite3 ... ".backup"으로 짰지만 executable file not found 에러. python:slim 기반 이미지에 sqlite3 CLI를 따로 설치하지 않았기 때문. 해결은 Python 내장 모듈 — sqlite3.backup() API는 별도 설치 없이 WAL 안전 스냅샷을 만든다.

WAL 안전 스냅샷 (Python 방식, CLI 불필요)
docker compose exec -T flashcard-app python3 -c \
"import sqlite3; \
 s=sqlite3.connect('/app/data/flashcards.db'); \
 d=sqlite3.connect('/app/data/_backup_snapshot.db'); \
 s.backup(d); d.close(); s.close(); print('snapshot ok')"

3. NFS 마운트의 숨은 사고

NFS/SMB 기반 백업에서 가장 위험한 시나리오는 따로 있다. NAS가 꺼지거나 네트워크가 끊기면 마운트가 조용히 풀린다. 그 상태에서 백업 스크립트가 돌면 — 마운트 지점인 로컬 디렉토리에 데이터를 그대로 쏟아붓는다. 외부 백업은 0건인데 로컬 디스크만 차오르는 최악의 상황이다.

마운트 생존 가드 (스크립트 맨 앞)
mountpoint -q로 NFS가 실제 마운트돼 있는지 확인. 풀려 있으면 즉시 중단해 로컬 오염을 막는다.
if ! mountpoint -q /mnt/docker; then
    echo "ERROR: /mnt/docker NFS 미마운트 — 백업 중단"
    exit 1
fi
💡 이 한 줄이 가드의 핵심

백업 스크립트에서 “쓰기 직전에 목적지가 정말 외부인지 확인”하는 가드는 선택이 아니라 필수다. 이게 없으면 NAS 다운 시점에 돌아간 백업이 전부 로컬에 쌓이고, 정작 복구가 필요한 순간 외부엔 아무것도 없다.

4. 완성된 백업 스크립트

앞의 모든 디테일(마운트 가드 + WAL 안전 스냅샷 + 하드링크 증분 + 세대 관리)을 하나로 합친다.

📄 /opt/my-flashcard/backup_to_nas.sh
#!/bin/bash
set -euo pipefail
# cron 환경 대비 PATH 명시 (5장에서 설명)
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

APP_DIR=/opt/my-flashcard
NAS_PATH=/mnt/docker/flashcard-backup
DATE=$(date +%Y%m%d-%H%M)

# 0) NFS 마운트 생존 확인 (로컬 오염 방지)
if ! mountpoint -q /mnt/docker; then
    echo "ERROR: NFS 미마운트 — 백업 중단"; exit 1
fi

# 1) DB WAL-안전 스냅샷 (Python sqlite3.backup)
docker compose -f "$APP_DIR/docker-compose.yml" exec -T flashcard-app \
  python3 -c "import sqlite3; \
  s=sqlite3.connect('/app/data/flashcards.db'); \
  d=sqlite3.connect('/app/data/_backup_snapshot.db'); \
  s.backup(d); d.close(); s.close(); print('snapshot ok')"

# 2) rsync 하드링크 증분 (변경분만 복사)
rsync -a --delete --link-dest="$NAS_PATH/latest" \
  "$APP_DIR/data/" "$NAS_PATH/$DATE/"

# 3) latest 심볼릭 갱신 (다음 증분 기준점)
ln -sfn "$NAS_PATH/$DATE" "$NAS_PATH/latest"

# 4) N세대 초과분 자동 삭제 (쌓임 방지)
ls -1dt "$NAS_PATH"/2* 2>/dev/null | tail -n +15 | xargs -r rm -rf

# 5) 임시 스냅샷 정리
rm -f "$APP_DIR/data/_backup_snapshot.db"
echo "백업 완료: $NAS_PATH/$DATE"
설계 요소역할
–link-dest이전 백업과 동일한 파일은 하드링크로 연결 → 변경분만 실제 디스크 차지. 이미지가 SHA 파일명이라 궁합 완벽
latest 심볼릭다음 백업의 증분 비교 기준점. 매 백업 후 최신으로 갱신
tail -n +1515번째 이후(=14세대 초과) 자동 삭제. “백업 쌓임” 사이클 차단
_backup_snapshot.dbWAL 안전 사본. 복구 시 이걸 사용 (라이브 db 아님)
💡 첫 백업의 link-dest 경고는 정상

최초 실행 시 --link-dest arg does not exist: .../latest 경고가 뜬다. 에러가 아니다 — 비교 기준이 아직 없어서 전체 복사로 진행한 것. 두 번째 백업부터 latest가 존재하니 하드링크 증분이 작동한다. 이후엔 변경분만 복사돼 디스크를 거의 안 먹는다.

⚠ 보관 기간은 주기에 따라 의미가 바뀐다

tail -n +15는 14세대 보관이다. 매일 백업이면 14일치, 주 1회면 14주(약 3.5개월)치가 된다. 하드링크 증분이라 세대를 늘려도 디스크 부담은 미미하니, 줄일 이유가 없으면 넉넉히 두는 게 안전하다. 주기를 바꾸면 이 숫자의 실제 의미를 다시 계산해야 한다.

5. 백업의 90% — 복구 검증과 cron 환경

5-1. 복구 검증 — 가장 자주 생략되는 단계

파일이 복사됐다고 백업이 완성된 게 아니다. 그 DB가 실제로 열리고 데이터가 온전한지 확인해야 진짜 백업이다. “복사는 됐는데 복구가 안 되는” 경우가 백업 실패의 대부분이다.

NAS 스냅샷 DB 무결성 검증
레코드 수 + PRAGMA integrity_check로 실제 복구 가능 여부 확인
docker run --rm -v /mnt/docker/flashcard-backup/latest:/b python:3.11-slim \
  python3 -c "import sqlite3; c=sqlite3.connect('/b/_backup_snapshot.db'); \
  print('cards:', c.execute('SELECT COUNT(*) FROM cards').fetchone()[0]); \
  print('integrity:', c.execute('PRAGMA integrity_check').fetchone()[0])"

integrity: ok가 나와야 비로소 “복구 가능한 진짜 백업”이라고 말할 수 있다.

5-2. cron 환경은 로그인 셸과 다르다

손으로 돌리면 잘 되던 스크립트가 cron에서 조용히 실패하는 일이 흔하다. 원인은 cron의 PATH가 로그인 셸과 다르다는 것. docker 명령 경로가 cron 환경에 없을 수 있다. 그래서 스크립트 맨 위에 PATH를 명시했고, 등록 전에 cron 환경을 시뮬레이션으로 미리 테스트한다.

cron 환경 시뮬레이션 (env -i = 깨끗한 환경)
env -i로 환경변수를 비운 채 실행 → cron과 동일 조건. 여기서 성공해야 실제 cron에서도 돈다.
env -i /bin/bash /opt/my-flashcard/backup_to_nas.sh \
  && echo "cron환경 OK" || echo "cron환경 실패 - PATH 추가 필요"
cron 등록 (매주 일요일 04:00)
(crontab -l 2>/dev/null; \
 echo "0 4 * * 0 /opt/my-flashcard/backup_to_nas.sh >> /var/log/fc-backup.log 2>&1") \
 | crontab -

5-3. 복구 절차 문서화 + 코드도 외부 보존

⚠ 복구 시엔 _backup_snapshot.db를 써라

백업엔 라이브 flashcards.db와 안전 스냅샷 _backup_snapshot.db가 둘 다 들어간다. 복원 시엔 반드시 스냅샷을 flashcards.db로 복사해야 한다 — 트랜잭션 일관성이 보장된 사본이기 때문. 위기 순간에 이걸 기억할 리 없으니 RESTORE.md로 문서화해 git에 커밋해 둔다.

git 저장소도 NAS에 미러 (코드 외부 보존)
데이터만 외부 백업하면 코드(.git 포함)는 여전히 단일 디스크에 있다. bare 미러로 코드도 외부에 둔다.
git clone --bare /opt/my-flashcard /mnt/docker/flashcard-backup/repo.git
# 백업 스크립트에 미러 갱신을 추가하면 코드도 주기적으로 따라온다

6. 전체 작업 총정리

“필요 없는 파일 지우고 싶다”는 한 문장에서 출발해, 프로덕션급 백업/복구 인프라까지 도달했다. 단계별 변화:

영역BeforeAfter
디렉토리 용량529M133M (75%↓)
버전 관리없음 (파일 복사 흉내)git 커밋 + NAS bare 미러
데이터 백업같은 디스크 1개 (=사실상 없음)NAS 자동 · 세대관리 · 검증완료
복구불가 / 미문서RESTORE.md 문서화
무결성미확인integrity_check ok 확인
청소 사이클무한 반복자동 세대정리로 종결
🎯 이 시리즈를 관통한 원칙

① 추측이 아니라 명령어로 증거를 모은다. docker inspect, du, comm, df — 모든 판단의 근거를 출력으로 만들었다.

② 표면 증상과 근본 원인을 분리한다. “파일 많음”의 진짜 원인은 git 부재였고, “무거움”의 진범은 중복 백업이었다.

③ 안전망을 확인하기 전엔 원본을 지우지 않는다. tar 검증 → 삭제, 복구 검증 → 백업 완성. 순서가 안전의 전부다.

④ 자동화로 사이클을 끊는다. 수동 백업은 결국 쌓여서 다시 청소 대상이 된다. cron + 세대관리로 반복을 종결했다.

셀프호스팅의 진짜 난이도는 “앱을 띄우는 것”이 아니라 “데이터를 잃지 않게 운영하는 것”에 있다. 화려한 기능보다 묵묵한 백업 한 줄이 결국 데이터를 지킨다. 이 시리즈가 비슷한 개인 서버를 굴리는 누군가의 디스크 사고 한 건을 막는다면 충분히 값어치를 했다.