Add daily backup scripts for PostgreSQL, MariaDB, and Gitea.

Enable scheduled backups of ncue.net databases and git.ncue.net repository mirrors via .env-driven shell scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-26 17:02:53 +09:00
commit 3c9d9283bd
11 changed files with 866 additions and 0 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
## Database (PostgreSQL)
PG_DB_HOST=ncue.net
PG_DB_PORT=5432
PG_DB_NAME=ncue
PG_DB_USER=ncue
PG_DB_PASSWORD=your_pg_password
## Database (MariaDB)
MR_DB_HOST=ncue.net
MR_DB_PORT=3306
MR_DB_NAME=wordpress
MR_DB_USER=ncue
MR_DB_PASSWORD=your_mr_password
## Gitea
GIT_BASE_URL=https://git.ncue.net
GIT_USER=your_gitea_user
GIT_TOKEN=your_gitea_token
## Backup
BACKUP_DIR=/Users/dsyoon/workspace/backup/ncue
BACKUP_RETENTION_DAYS=30
## PostgreSQL backup options
PG_BACKUP_SCOPE=all
PG_BACKUP_GLOBALS=1
PG_BACKUP_SUPERUSER=postgres
PG_BACKUP_SUPERUSER_PASSWORD=
## MariaDB backup options
MR_BACKUP_SCOPE=all
## Gitea backup options
GITEA_REPO_LIMIT=100

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.env
backup/
*.log

100
README.md Normal file
View File

@@ -0,0 +1,100 @@
# ncue_backup
ncue.net PostgreSQL, MariaDB, Gitea 저장소를 매일 백업하는 스크립트 모음입니다.
백업 방식은 [ai_platform](https://git.xavis.co.kr/AI_Innovation_Team/ai_platform)의 `scripts/pg-backup.sh` 패턴을 따릅니다.
## 백업 대상
| 구분 | 대상 | 스크립트 |
|------|------|----------|
| PostgreSQL | 연결 가능한 모든 DB (`ai_web_platform`, `meeting_ai`, `ncue`, `tts` 등) | `scripts/pg-backup.sh` |
| MariaDB | 사용자 DB 전체 (`giteadb`, `wordpress`, `roundcube` 등) | `scripts/mr-backup.sh` |
| Gitea | 접근 가능한 모든 Git 저장소 mirror | `scripts/gitea-backup.sh` |
Gitea 메타데이터 DB(`giteadb`)는 MariaDB 백업에 포함됩니다. Git 소스는 mirror 백업으로 별도 보관합니다.
## 사전 요구사항
- `pg_dump`, `psql` (PostgreSQL 클라이언트)
- `mysqldump`, `mysql` (MariaDB 클라이언트)
- `git`, `curl`, `python3`, `gzip`
- `pymysql` (MariaDB 클라이언트 미설치 시 Python 폴백용, `pip install -r requirements.txt`)
macOS 예시:
```bash
brew install libpq mariadb
echo 'export PATH="/opt/homebrew/opt/libpq/bin:$PATH"' >> ~/.zshrc
```
## 설정
```bash
cp .env.example .env
# .env 편집 (DB 접속 정보, Gitea 토큰, BACKUP_DIR)
```
주요 환경 변수:
| 변수 | 설명 |
|------|------|
| `PG_DB_*` | PostgreSQL 접속 정보 |
| `MR_DB_*` | MariaDB 접속 정보 |
| `GIT_BASE_URL` | Gitea URL (기본 `https://git.ncue.net`) |
| `GIT_USER`, `GIT_TOKEN` | Gitea mirror 인증 |
| `BACKUP_DIR` | 백업 루트 (기본 `프로젝트/backup`) |
| `BACKUP_RETENTION_DAYS` | 보관 일수 (기본 30) |
## 실행
통합 백업 (권장):
```bash
bash scripts/daily-backup.sh
```
개별 백업:
```bash
bash scripts/pg-backup.sh
bash scripts/mr-backup.sh
bash scripts/gitea-backup.sh
```
## 백업 결과 구조
```text
backup/
├─ 20260526/
│ ├─ postgresql/
│ │ ├─ 00_manifest.txt
│ │ ├─ ncue.dump
│ │ └─ ...
│ ├─ mariadb/
│ │ ├─ 00_manifest.txt
│ │ ├─ giteadb.sql.gz
│ │ └─ ...
│ └─ gitea/
│ ├─ 00_manifest.txt
│ ├─ 00_repos.json
│ └─ mirrors -> ../../_gitea_mirrors/
├─ _gitea_mirrors/ # Gitea mirror 영구 저장 (증분 업데이트)
│ └─ owner__repo.git/
└─ latest -> 20260526/
```
## 매일 cron 등록
```cron
0 2 * * * cd /Users/dsyoon/workspace/ncue_backup && /usr/bin/bash scripts/daily-backup.sh >> /var/log/ncue-backup.log 2>&1
```
## 복원 참고
- PostgreSQL: `pg_restore -d dbname file.dump`
- MariaDB: `gunzip -c file.sql.gz | mysql -u user -p dbname`
- Gitea mirror: `git clone /path/to/mirrors/owner__repo.git restored-repo`
## 변경 이력
- 2026-05-26: PostgreSQL/MariaDB/Gitea 일일 백업 스크립트 초기 구성

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
pymysql>=1.1.0

50
scripts/daily-backup.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# PostgreSQL + MariaDB + Gitea 저장소 일일 통합 백업
# 운영 cron 예: 0 2 * * * cd /path/ncue_backup && bash scripts/daily-backup.sh >> /var/log/ncue-backup.log 2>&1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=scripts/lib/load-env.sh
source "$REPO_ROOT/scripts/lib/load-env.sh"
# shellcheck source=scripts/lib/common.sh
source "$REPO_ROOT/scripts/lib/common.sh"
load_project_env "$REPO_ROOT/.env"
BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}"
BACKUP_STAMP="${BACKUP_STAMP:-$(date +%Y%m%d)}"
BACKUP_RUN_DIR="$BACKUP_DIR/$BACKUP_STAMP"
export BACKUP_DIR BACKUP_STAMP BACKUP_RUN_DIR
mkdir -p "$BACKUP_RUN_DIR" "$BACKUP_DIR/latest"
echo "[$(log_ts)] daily-backup start → $BACKUP_RUN_DIR"
failures=0
run_step() {
local name="$1"
local script="$2"
echo "[$(log_ts)] daily-backup step: $name"
if bash "$script"; then
echo "[$(log_ts)] daily-backup step ok: $name"
else
echo "[$(log_ts)] daily-backup step failed: $name" >&2
failures=$((failures + 1))
fi
}
run_step "postgresql" "$REPO_ROOT/scripts/pg-backup.sh"
run_step "mariadb" "$REPO_ROOT/scripts/mr-backup.sh"
run_step "gitea" "$REPO_ROOT/scripts/gitea-backup.sh"
ln -sfn "$BACKUP_RUN_DIR" "$BACKUP_DIR/latest"
prune_old_backups "$BACKUP_DIR" "${BACKUP_RETENTION_DAYS:-30}"
if [[ "$failures" -gt 0 ]]; then
echo "[$(log_ts)] daily-backup finished with ${failures} failure(s)." >&2
exit 1
fi
echo "[$(log_ts)] daily-backup done → $BACKUP_DIR/latest"

145
scripts/gitea-backup.sh Executable file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# Gitea 저장소 mirror 백업. .env의 GIT_* 및 GITEA_* 사용.
# Gitea 메타 DB(giteadb 등)는 scripts/mr-backup.sh(MariaDB 전체 백업)에 포함됩니다.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=scripts/lib/load-env.sh
source "$REPO_ROOT/scripts/lib/load-env.sh"
# shellcheck source=scripts/lib/common.sh
source "$REPO_ROOT/scripts/lib/common.sh"
load_project_env "$REPO_ROOT/.env"
: "${GIT_USER:?GIT_USER is required in .env}"
: "${GIT_TOKEN:?GIT_TOKEN is required in .env}"
GIT_BASE_URL="${GIT_BASE_URL:-https://git.ncue.net}"
GITEA_API_URL="${GITEA_API_URL:-${GIT_BASE_URL%/}/api/v1}"
BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
GITEA_REPO_LIMIT="${GITEA_REPO_LIMIT:-100}"
if ! command -v git >/dev/null 2>&1; then
echo "gitea-backup: git not found." >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "gitea-backup: python3 not found." >&2
exit 1
fi
resolve_run_dir "gitea"
mkdir -p "$BACKUP_DIR/latest"
PERSISTENT_MIRROR_ROOT="${GITEA_MIRROR_DIR:-$BACKUP_DIR/_gitea_mirrors}"
MIRROR_ROOT="$PERSISTENT_MIRROR_ROOT"
mkdir -p "$MIRROR_ROOT"
echo "[$(log_ts)] gitea-backup start → $RUN_DIR (mirrors=$MIRROR_ROOT, api=${GITEA_API_URL})"
REPO_LIST_FILE="$RUN_DIR/00_repos.json"
MANIFEST="$RUN_DIR/00_manifest.txt"
mirror_result="$(python3 - "$GITEA_API_URL" "$GIT_TOKEN" "$GITEA_REPO_LIMIT" "$REPO_LIST_FILE" "$MIRROR_ROOT" "$GIT_USER" "$GIT_TOKEN" <<'PY'
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
api_base, token, limit_str, repo_list_file, mirror_root, git_user, git_token = sys.argv[1:8]
limit = int(limit_str)
headers = {"Authorization": f"token {token}"}
def fetch_json(url: str):
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.load(resp)
repos = []
page = 1
while True:
url = f"{api_base.rstrip('/')}/user/repos?limit={limit}&page={page}"
try:
batch = fetch_json(url)
except urllib.error.HTTPError as exc:
print(f"API error {exc.code} for {url}", file=sys.stderr)
raise SystemExit(1) from exc
if not batch:
break
repos.extend(batch)
if len(batch) < limit:
break
page += 1
with open(repo_list_file, "w", encoding="utf-8") as fh:
json.dump(repos, fh, ensure_ascii=False, indent=2)
ok = 0
fail = 0
manifest_lines = []
for repo in repos:
full_name = repo["full_name"]
clone_url = repo["clone_url"]
safe_name = full_name.replace("/", "__")
mirror_path = os.path.join(mirror_root, f"{safe_name}.git")
auth_url = clone_url.replace("https://", f"https://{git_user}:{git_token}@", 1)
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
if os.path.isdir(mirror_path):
subprocess.run(["git", "-C", mirror_path, "remote", "set-url", "origin", auth_url], check=True)
proc = subprocess.run(["git", "-C", mirror_path, "remote", "update", "--prune"], env=env)
action = "updated"
else:
proc = subprocess.run(["git", "clone", "--mirror", auth_url, mirror_path], env=env)
action = "cloned"
if proc.returncode == 0:
size_kb = max(1, sum(
os.path.getsize(os.path.join(root, name))
for root, _, files in os.walk(mirror_path)
for name in files
) // 1024)
manifest_lines.append(f"{full_name} {action} {size_kb}KB")
ok += 1
else:
manifest_lines.append(f"{full_name} failed")
fail += 1
print(json.dumps({"ok": ok, "fail": fail, "count": len(repos), "manifest": manifest_lines}))
PY
)"
repo_count="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['count'])" "$mirror_result")"
mirror_ok="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['ok'])" "$mirror_result")"
mirror_fail="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['fail'])" "$mirror_result")"
{
echo "# gitea-backup manifest $(log_ts)"
echo "api=${GITEA_API_URL}"
echo "user=${GIT_USER}"
echo "repo_count=${repo_count}"
python3 -c "import json,sys; [print(line) for line in json.loads(sys.argv[1])['manifest']]" "$mirror_result"
} > "$MANIFEST"
if [[ "$mirror_ok" -eq 0 ]]; then
echo "gitea-backup: all repository mirrors failed." >&2
exit 1
fi
if [[ "$mirror_fail" -gt 0 ]]; then
echo "[$(log_ts)] warning: ${mirror_fail} mirror(s) failed, ${mirror_ok} succeeded." >&2
fi
ln -sfn "$MIRROR_ROOT" "$RUN_DIR/mirrors"
update_latest_link "gitea"
if [[ -z "${BACKUP_RUN_DIR:-}" ]]; then
prune_old_backups "$BACKUP_DIR" "$BACKUP_RETENTION_DAYS"
fi
echo "[$(log_ts)] gitea-backup done: ${mirror_ok} repository mirror(s), latest → $BACKUP_DIR/latest/gitea"

66
scripts/lib/common.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# 백업 스크립트 공통 유틸
log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; }
# 오래된 YYYYMMDD 백업 디렉터리 삭제
# 사용: prune_old_backups "$BACKUP_ROOT" "$RETENTION_DAYS"
prune_old_backups() {
local backup_root="$1"
local retention_days="$2"
local cutoff removed=0 base day entry
[[ "$retention_days" =~ ^[0-9]+$ ]] || return 0
[[ "$retention_days" -le 0 ]] && return 0
if date -d "1 day ago" +%Y%m%d >/dev/null 2>&1; then
cutoff=$(date -d "${retention_days} days ago" +%Y%m%d)
else
cutoff=$(date -v-"${retention_days}"d +%Y%m%d)
fi
shopt -s nullglob
for entry in "$backup_root"/*; do
[[ -d "$entry" ]] || continue
base=$(basename "$entry")
[[ "$base" == "latest" ]] && continue
if [[ "$base" =~ ^([0-9]{8}) ]]; then
day="${BASH_REMATCH[1]}"
if [[ "$day" < "$cutoff" ]]; then
rm -rf "$entry"
removed=$((removed + 1))
echo "[$(log_ts)] retention: removed $entry (backup date $day < cutoff $cutoff)"
fi
fi
done
shopt -u nullglob
echo "[$(log_ts)] retention: pruned ${removed} dir(s); keeping backups from ${cutoff} onward (${retention_days} days)"
}
resolve_run_dir() {
local component="$1"
local stamp="${BACKUP_STAMP:-$(date +%Y%m%d)}"
local root="${BACKUP_DIR:-$REPO_ROOT/backup}"
if [[ -n "${BACKUP_RUN_DIR:-}" ]]; then
RUN_DIR="$BACKUP_RUN_DIR/$component"
else
RUN_DIR="$root/$stamp/$component"
fi
mkdir -p "$RUN_DIR"
}
update_latest_link() {
local component="$1"
local root="${BACKUP_DIR:-$REPO_ROOT/backup}"
local stamp="${BACKUP_STAMP:-$(date +%Y%m%d)}"
local target
if [[ -n "${BACKUP_RUN_DIR:-}" ]]; then
target="$BACKUP_RUN_DIR/$component"
else
target="$root/$stamp/$component"
fi
ln -sfn "$target" "$root/latest/$component"
}

24
scripts/lib/load-env.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 프로젝트 루트 .env에서 KEY=VALUE 줄만 export (섹션 헤더 [..]·주석 무시)
load_project_env() {
local env_file="$1"
if [[ ! -f "$env_file" ]]; then
echo "load_project_env: .env not found: $env_file" >&2
return 1
fi
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" || "$line" == \#* ]] && continue
[[ "$line" == \[* ]] && continue
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local val="${BASH_REMATCH[2]}"
if [[ "$val" =~ ^\"(.*)\"$ ]]; then
val="${BASH_REMATCH[1]}"
elif [[ "$val" =~ ^\'(.*)\'$ ]]; then
val="${BASH_REMATCH[1]}"
fi
export "$key=$val"
fi
done < "$env_file"
}

130
scripts/lib/mr_dump.py Normal file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""MariaDB logical dump fallback when mysqldump is unavailable."""
from __future__ import annotations
import argparse
import gzip
import sys
from typing import Iterable
import pymysql
SYSTEM_SCHEMAS = {"information_schema", "performance_schema", "sys", "mysql"}
def sql_quote(value) -> str:
"""Return a SQL literal for a Python value."""
if value is None:
return "NULL"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, (bytes, bytearray)):
return "0x" + value.hex()
escaped = (
str(value)
.replace("\\", "\\\\")
.replace("\0", "\\0")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("'", "\\'")
.replace('"', '\\"')
)
return f"'{escaped}'"
def list_databases(conn) -> list[str]:
"""List user databases excluding system schemas."""
with conn.cursor() as cur:
cur.execute(
"""
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT IN (%s, %s, %s, %s)
ORDER BY schema_name
""",
tuple(SYSTEM_SCHEMAS),
)
return [row[0] for row in cur.fetchall()]
def dump_database(host: str, port: int, user: str, password: str, database: str, output_path: str) -> None:
"""Dump one database to a gzip SQL file."""
conn = pymysql.connect(
host=host,
port=port,
user=user,
password=password,
database=database,
charset="utf8mb4",
connect_timeout=30,
)
try:
lines: list[str] = [
"-- MariaDB dump via ncue_backup Python fallback",
"SET NAMES utf8mb4;",
"SET FOREIGN_KEY_CHECKS=0;",
f"CREATE DATABASE IF NOT EXISTS `{database}`;",
f"USE `{database}`;",
]
with conn.cursor() as cur:
cur.execute("SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'")
tables = [row[0] for row in cur.fetchall()]
for table in tables:
cur.execute(f"SHOW CREATE TABLE `{table}`")
create_sql = cur.fetchone()[1]
lines.append(f"DROP TABLE IF EXISTS `{table}`;")
lines.append(f"{create_sql};")
cur.execute(f"SELECT * FROM `{table}`")
columns = [desc[0] for desc in cur.description]
col_list = ", ".join(f"`{col}`" for col in columns)
while True:
rows = cur.fetchmany(500)
if not rows:
break
for row in rows:
values = ", ".join(sql_quote(value) for value in row)
lines.append(f"INSERT INTO `{table}` ({col_list}) VALUES ({values});")
lines.append("SET FOREIGN_KEY_CHECKS=1;")
payload = "\n".join(lines) + "\n"
with gzip.open(output_path, "wt", encoding="utf-8") as fh:
fh.write(payload)
finally:
conn.close()
def main(argv: Iterable[str] | None = None) -> int:
"""CLI entrypoint."""
parser = argparse.ArgumentParser(description="MariaDB dump fallback")
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, default=3306)
parser.add_argument("--user", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args(list(argv) if argv is not None else None)
dump_database(
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
output_path=args.output,
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except pymysql.MySQLError as exc:
print(f"mr_dump.py: database error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc

160
scripts/mr-backup.sh Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
# MariaDB/MySQL 논리 백업 (mysqldump). .env의 MR_DB_* 및 MR_BACKUP_* 사용.
# MR_BACKUP_SCOPE=all 이면 접근 가능한 사용자 DB 전체를 각각 .sql.gz 로 저장.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=scripts/lib/load-env.sh
source "$REPO_ROOT/scripts/lib/load-env.sh"
# shellcheck source=scripts/lib/common.sh
source "$REPO_ROOT/scripts/lib/common.sh"
load_project_env "$REPO_ROOT/.env"
: "${MR_DB_HOST:?MR_DB_HOST is required in .env}"
: "${MR_DB_USER:?MR_DB_USER is required in .env}"
: "${MR_DB_PASSWORD:?MR_DB_PASSWORD is required in .env}"
MR_DB_PORT="${MR_DB_PORT:-3306}"
MR_DB_NAME="${MR_DB_NAME:-mysql}"
BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
MR_BACKUP_SCOPE="${MR_BACKUP_SCOPE:-all}"
MYSQL_BIN="${MYSQL_BIN:-mysql}"
MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-mysqldump}"
MR_DUMP_PY="$REPO_ROOT/scripts/lib/mr_dump.py"
use_python_fallback=0
if ! command -v "$MYSQLDUMP_BIN" >/dev/null 2>&1; then
if [[ -f "$MR_DUMP_PY" ]] && python3 -c "import pymysql" >/dev/null 2>&1; then
echo "[$(log_ts)] mr-backup: mysqldump not found; using Python fallback (pymysql)." >&2
use_python_fallback=1
else
echo "mr-backup: mysqldump not found. Install mariadb-client (apt) or mariadb (brew), or install pymysql." >&2
exit 1
fi
fi
if [[ "$use_python_fallback" -eq 0 ]] && ! command -v "$MYSQL_BIN" >/dev/null 2>&1; then
echo "mr-backup: mysql client not found. Install mariadb-client." >&2
exit 1
fi
mysql_common_args=(
-h "$MR_DB_HOST"
-P "$MR_DB_PORT"
-u "$MR_DB_USER"
--password="$MR_DB_PASSWORD"
--batch
--skip-column-names
)
list_all_databases() {
if [[ "$use_python_fallback" -eq 1 ]]; then
python3 - "$MR_DB_HOST" "$MR_DB_PORT" "$MR_DB_USER" "$MR_DB_PASSWORD" <<'PY'
import pymysql, sys
host, port, user, password = sys.argv[1:5]
conn = pymysql.connect(host=host, port=int(port), user=user, password=password, connect_timeout=30)
try:
with conn.cursor() as cur:
cur.execute(
"SELECT schema_name FROM information_schema.schemata "
"WHERE schema_name NOT IN ('information_schema','performance_schema','sys','mysql') "
"ORDER BY schema_name"
)
for row in cur.fetchall():
print(row[0])
finally:
conn.close()
PY
return
fi
"$MYSQL_BIN" "${mysql_common_args[@]}" -e \
"SELECT schema_name FROM information_schema.schemata
WHERE schema_name NOT IN ('information_schema','performance_schema','sys','mysql')
ORDER BY schema_name;"
}
dump_database() {
local db_name="$1"
local dump_file="$2"
if [[ "$use_python_fallback" -eq 1 ]]; then
python3 "$MR_DUMP_PY" \
--host "$MR_DB_HOST" \
--port "$MR_DB_PORT" \
--user "$MR_DB_USER" \
--password "$MR_DB_PASSWORD" \
--database "$db_name" \
--output "$dump_file"
return
fi
"$MYSQLDUMP_BIN" \
-h "$MR_DB_HOST" \
-P "$MR_DB_PORT" \
-u "$MR_DB_USER" \
--password="$MR_DB_PASSWORD" \
--single-transaction \
--routines \
--triggers \
--events \
--databases "$db_name" \
| gzip -c > "$dump_file"
}
resolve_run_dir "mariadb"
mkdir -p "$BACKUP_DIR/latest"
echo "[$(log_ts)] mr-backup start → $RUN_DIR (scope=${MR_BACKUP_SCOPE}, retention ${BACKUP_RETENTION_DAYS} days)"
declare -a TARGET_DBS=()
if [[ "$MR_BACKUP_SCOPE" == "single" ]]; then
TARGET_DBS=("$MR_DB_NAME")
else
while IFS= read -r db; do
[[ -n "$db" ]] && TARGET_DBS+=("$db")
done < <(list_all_databases)
if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then
echo "mr-backup: no databases found to backup." >&2
exit 1
fi
fi
MANIFEST="$RUN_DIR/00_manifest.txt"
{
echo "# mr-backup manifest $(log_ts)"
echo "scope=${MR_BACKUP_SCOPE}"
echo "host=${MR_DB_HOST}:${MR_DB_PORT}"
echo "user=${MR_DB_USER}"
} > "$MANIFEST"
dump_ok=0
dump_fail=0
for db_name in "${TARGET_DBS[@]}"; do
dump_file="$RUN_DIR/${db_name}.sql.gz"
echo "[$(log_ts)] dumping database: $db_name"
if dump_database "$db_name" "$dump_file"; then
bytes="$(wc -c < "$dump_file" | tr -d ' ')"
echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)"
echo "${db_name}.sql.gz ${bytes}" >> "$MANIFEST"
dump_ok=$((dump_ok + 1))
else
echo "[$(log_ts)] dump failed: $db_name" >&2
dump_fail=$((dump_fail + 1))
fi
done
if [[ "$dump_ok" -eq 0 ]]; then
echo "mr-backup: all database dumps failed." >&2
exit 1
fi
if [[ "$dump_fail" -gt 0 ]]; then
echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2
fi
update_latest_link "mariadb"
if [[ -z "${BACKUP_RUN_DIR:-}" ]]; then
prune_old_backups "$BACKUP_DIR" "$BACKUP_RETENTION_DAYS"
fi
echo "[$(log_ts)] mr-backup done: ${dump_ok} database(s), latest → $BACKUP_DIR/latest/mariadb"

153
scripts/pg-backup.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# PostgreSQL 논리 백업 (pg_dump -Fc). .env의 PG_DB_* 및 PG_BACKUP_* 사용.
# PG_BACKUP_SCOPE=all 이면 서버의 연결 가능·비템플릿 DB 전체를 각각 .dump 로 저장.
# 운영 cron 예: 0 2 * * * cd /path/ncue_backup && bash scripts/daily-backup.sh >> /var/log/ncue-backup.log 2>&1
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=scripts/lib/load-env.sh
source "$REPO_ROOT/scripts/lib/load-env.sh"
# shellcheck source=scripts/lib/common.sh
source "$REPO_ROOT/scripts/lib/common.sh"
load_project_env "$REPO_ROOT/.env"
: "${PG_DB_HOST:?PG_DB_HOST is required in .env}"
: "${PG_DB_USER:?PG_DB_USER is required in .env}"
: "${PG_DB_PASSWORD:?PG_DB_PASSWORD is required in .env}"
PG_DB_PORT="${PG_DB_PORT:-5432}"
PG_DB_NAME="${PG_DB_NAME:-postgres}"
BACKUP_DIR="${BACKUP_DIR:-$REPO_ROOT/backup}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
PG_BACKUP_SCOPE="${PG_BACKUP_SCOPE:-all}"
PG_BACKUP_GLOBALS="${PG_BACKUP_GLOBALS:-1}"
PG_BACKUP_SUPERUSER="${PG_BACKUP_SUPERUSER:-postgres}"
export PGHOST="$PG_DB_HOST"
export PGPORT="$PG_DB_PORT"
if ! command -v pg_dump >/dev/null 2>&1; then
echo "pg-backup: pg_dump not found. Install postgresql-client (apt) or postgresql (brew)." >&2
exit 1
fi
if ! command -v psql >/dev/null 2>&1; then
echo "pg-backup: psql not found. Install postgresql-client." >&2
exit 1
fi
set_backup_credentials() {
if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
export PGUSER="$PG_BACKUP_SUPERUSER"
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
else
export PGUSER="$PG_DB_USER"
export PGPASSWORD="$PG_DB_PASSWORD"
fi
}
list_all_databases() {
psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -tAc \
"SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname;"
}
dump_database() {
local db_name="$1"
local dump_file="$2"
pg_dump \
-h "$PGHOST" \
-p "$PGPORT" \
-U "$PGUSER" \
-d "$db_name" \
-Fc \
--no-password \
-f "$dump_file"
}
backup_globals() {
if [[ "$PG_BACKUP_GLOBALS" != "1" ]]; then
return 0
fi
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then
echo "[$(log_ts)] globals skipped: set PG_BACKUP_SUPERUSER_PASSWORD for role/global backup." >&2
return 0
fi
local prev_user="$PGUSER" prev_pass="$PGPASSWORD"
export PGUSER="$PG_BACKUP_SUPERUSER"
export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD"
pg_dumpall \
-h "$PGHOST" \
-p "$PGPORT" \
-U "$PGUSER" \
--globals-only \
--no-password \
-f "$RUN_DIR/00_globals.sql"
export PGUSER="$prev_user"
export PGPASSWORD="$prev_pass"
echo "[$(log_ts)] globals saved: $RUN_DIR/00_globals.sql"
}
set_backup_credentials
resolve_run_dir "postgresql"
mkdir -p "$BACKUP_DIR/latest"
echo "[$(log_ts)] pg-backup start → $RUN_DIR (scope=${PG_BACKUP_SCOPE}, retention ${BACKUP_RETENTION_DAYS} days)"
if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" && "$PG_BACKUP_SCOPE" == "all" ]]; then
echo "[$(log_ts)] note: PG_BACKUP_SUPERUSER_PASSWORD not set; backing up only databases visible to ${PG_DB_USER}." >&2
fi
backup_globals
declare -a TARGET_DBS=()
if [[ "$PG_BACKUP_SCOPE" == "single" ]]; then
TARGET_DBS=("$PG_DB_NAME")
else
while IFS= read -r db; do
[[ -n "$db" ]] && TARGET_DBS+=("$db")
done < <(list_all_databases)
if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then
echo "pg-backup: no databases found to backup." >&2
exit 1
fi
fi
MANIFEST="$RUN_DIR/00_manifest.txt"
{
echo "# pg-backup manifest $(log_ts)"
echo "scope=${PG_BACKUP_SCOPE}"
echo "host=${PGHOST}:${PGPORT}"
echo "user=${PGUSER}"
} > "$MANIFEST"
dump_ok=0
dump_fail=0
for db_name in "${TARGET_DBS[@]}"; do
dump_file="$RUN_DIR/${db_name}.dump"
echo "[$(log_ts)] dumping database: $db_name"
if dump_database "$db_name" "$dump_file"; then
bytes="$(wc -c < "$dump_file" | tr -d ' ')"
echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)"
echo "${db_name}.dump ${bytes}" >> "$MANIFEST"
dump_ok=$((dump_ok + 1))
else
echo "[$(log_ts)] dump failed: $db_name" >&2
dump_fail=$((dump_fail + 1))
fi
done
if [[ "$dump_ok" -eq 0 ]]; then
echo "pg-backup: all database dumps failed." >&2
exit 1
fi
if [[ "$dump_fail" -gt 0 ]]; then
echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2
fi
update_latest_link "postgresql"
if [[ -z "${BACKUP_RUN_DIR:-}" ]]; then
prune_old_backups "$BACKUP_DIR" "$BACKUP_RETENTION_DAYS"
fi
echo "[$(log_ts)] pg-backup done: ${dump_ok} database(s), latest → $BACKUP_DIR/latest/postgresql"