feat(docker): 13단계 운영 기반 — 백업 · 감시 · 관리자 단단히 · 휴지통 30일 · 메일 프로그램 자동 설정

전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다.
- 관리자 단단히(setup): 관리자 역할에서 impersonate 를 빼고 관리자 허용 IP 를 앱 네트워크로 — 관리자 비밀번호로 바깥 993 · JMAP 에서 직원 메일함에 대리 로그인되던 길을 막았다.
  운영에서 처음 걸 때는 되돌릴 복구 관리자가 있을 때만 건다(관리자 잠김 방지).
- 휴지통 · 스팸 30일 정리를 KST 03:00 으로 명시. imapsync 로 옮긴 메일도 옮긴 때부터 센다(임시 엔진 실측).
- restic 백업(호스트 systemd 03:30 — 메일 볼륨 warm → 엔진 멈춤 9초 안팎 → final, pg_dump, 업로드, env) · 복원(새 서버 차례를 빈 클론에서 실측) · 15분 점검 · 부팅 되살리기, Healthchecks 하트비트.
- 메일 프로그램 자동 설정: frontend nginx 의 autoconfig · autodiscover 고정 응답(엔진 것은 POP3 를 알리고 입력을 되돌려 쓴다).
- 모든 서비스 docker 로그 보존, DB 이미지 판 · digest 고정.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 13:44:44 +09:00
parent f0e7890bf1
commit 73babeaa2c
21 changed files with 2249 additions and 34 deletions
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env bash
# ==========================================
# arasystem 백업 — 메일 엔진 볼륨 · DB · 업로드 · env 를 restic 저장소(NCP Object Storage)로 (13단계 — mail/README.md "백업")
#
# arasystem-backup.sh 매일 백업 (systemd 타이머 arasystem-backup.timer — 03:30 KST)
# arasystem-backup.sh init 저장소를 처음 만든다 (한 번)
# arasystem-backup.sh ensure-mail-running
# 백업이 멈춘 메일 엔진이 남아 있으면 다시 띄운다 — systemd 의 ExecStopPost(스크립트가 죽어도)와
# 부팅 때(arasystem-mail-recover.service — 멈춘 채 서버가 다시 켜져도). 떠 있는 것을 본 뒤에만 표시를 지운다
#
# 흐름 — 메일 엔진(Stalwart · RocksDB)은 켜 둔 채 파일을 복사하면 일관되지 않는다(공식 문서 — 실행 중 백업 없음):
# 1. DB 를 pg_dump(-Fc, 멈추지 않는다) → pg_restore -l 로 읽히는지 본다. env 파일 둘을 작업 폴더에 복사한다
# 2. warm — 엔진을 켜 둔 채 restic 으로 한 번 올린다(크게 올릴 것은 여기서 다 올린다 — 이 스냅숏은 복원에 쓰지 않는다).
# 엔진이 도는 동안 RocksDB 가 지운 파일은 읽지 못한다 — restic 의 종료 3(일부를 읽지 못함)은 warm 에서만 받아들인다
# 3. 엔진을 멈추고(docker stop) final — 같은 경로를 다시 올린다. RocksDB 의 SST · blob 은 한 번 쓰면 바뀌지 않아 바뀐 것만 올라간다.
# 시간 상한(BACKUP_FINAL_TIMEOUT, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게
# 4. 엔진을 다시 띄우고 /healthz/ready 까지 기다린다 → 멈춘 시간을 남긴다
# 5. 보존(forget) — final 은 매일 7 · 매주 4 · 매달 6, warm 은 마지막 1. 일요일(KST)에 prune 과 check(데이터 5%)
# 6. 상태 파일(15분 점검이 읽는다)과 Healthchecks 에 알린다(성공 · /fail + 까닭 한 줄)
# 받지 않는 것: 대용량 첨부 볼륨(_files — 30일짜리 전송물), 악성 검사 서명(_clamav-db), 리졸버 · frontend(상태 없음), 로그
#
# 설정 /etc/arasystem/ops.env (예시 ops/ops.env.example) · restic 비밀번호 /etc/arasystem/restic-password. root 로 돈다(docker · 볼륨을 읽는다).
# restic 은 고정한 이미지로 띄운다 — 판을 올릴 때는 docker 스킬 "이미지 버전 고정"의 표와 함께
# ==========================================
set -Eeuo pipefail
umask 077
OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)
# shellcheck source=../lib/common.sh
. "$OPS_ROOT/lib/common.sh"
# restic 0.19.1(2026-07-05) — manifest list digest 까지 고정한다 (docker 스킬 "이미지 버전 고정")
RESTIC_IMAGE=${RESTIC_IMAGE:-restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510}
STEP="시작"
# 메일 엔진을 어디까지 다뤘나 — 비었음(건드리지 않음) · stopping(docker stop 을 불렀다) · starting(docker start 를 불렀다). on_exit 가 이것으로 되살린다
MAIL_PHASE=""
# restic 한 번의 시간 상한 (timeout 의 길이 — 비면 없음). final 에서만 건다
RESTIC_TIMEOUT=""
SEQ=0
# restic 을 컨테이너로 — restic_run [docker 인자 …] -- [restic 인자 …]
# root 로 돌되 권한(capability)은 파일 읽기(DAC_READ_SEARCH)만 남긴다 — 메일(UID 2000) · 업로드(UID 1000) 파일을 읽어야 한다. 볼륨은 읽기 전용으로 붙인다.
# 비밀은 -e 이름만 넘긴다(값이 명령줄 · docker 이벤트에 남지 않게). 캐시는 이름 있는 볼륨(<PROJECT_NAME>-backup-cache)에 둔다.
# CPU 1 · 메모리 1g · CPU 몫 512(다른 컨테이너의 절반 — 켜 둔 메일 엔진을 앞세운다). 스크립트의 Nice · IO 우선(systemd)은 dockerd 아래의 컨테이너가 물려받지 않는다.
# RESTIC_TIMEOUT 이 있으면 timeout 으로 감싼다 — 넘으면 docker 가 restic 에 SIGTERM 을 넘기고, 그래도 남은 컨테이너는 지운다(종료 124).
# --foreground — timeout 이 따로 프로세스 그룹을 만들지 않게. 손으로 돌릴 때 Ctrl-C 가 docker CLI 에 닿아(restic 에 넘긴다) 바로 멈추고 정리로 간다
# (그룹이 다르면 Ctrl-C 가 상한까지 기다린다). systemd 는 그룹과 상관없이 cgroup 전체에 보낸다
restic_run() {
local dargs=()
while [ "$1" != "--" ]; do dargs+=("$1"); shift; done
shift
local net=() limit=() rc=0 name="${PROJECT_NAME}-backup-restic-$$-$((SEQ += 1))"
[ -n "${BACKUP_DOCKER_NETWORK:-}" ] && net=(--network "$BACKUP_DOCKER_NETWORK")
[ -n "$RESTIC_TIMEOUT" ] && limit=(timeout --foreground -k 30 "$RESTIC_TIMEOUT")
# shellcheck disable=SC2086 # RESTIC_OPTIONS 는 낱말로 나눠 넘긴다 (-o s3.region=… -o s3.bucket-lookup=…)
"${limit[@]}" docker run --rm --name "$name" \
--cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges \
--memory 1g --cpus 1 --cpu-shares 512 "${net[@]}" \
-e RESTIC_REPOSITORY -e RESTIC_PASSWORD_FILE=/run/secrets/restic-password -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
-v "$RESTIC_PASSWORD_FILE:/run/secrets/restic-password:ro" -v "${PROJECT_NAME}-backup-cache:/root/.cache/restic" \
"${dargs[@]}" "$RESTIC_IMAGE" ${RESTIC_OPTIONS:-} "$@" || rc=$?
if [ "$rc" = 124 ] || [ "$rc" = 137 ]; then
# docker CLI 가 끊겨도 데몬이 만들던 컨테이너는 뒤늦게 뜰 수 있다 — 몇 초 동안 보이는 대로 지운다
local _
for _ in 1 2 3 4 5; do
docker rm -f "$name" >/dev/null 2>&1 || true
sleep 1
done
return 124
fi
return "$rc"
}
write_status() { # write_status <ok|fail> <글>
local now
now=$(date +%s)
{
if [ "$1" = ok ]; then
echo "last_success_epoch=$now"
elif [ -f "$WORK_DIR/status" ]; then
grep '^last_success_epoch=' "$WORK_DIR/status" || true
fi
echo "last_run_epoch=$now"
echo "last_result=$1"
echo "last_message=$2"
} > "$WORK_DIR/status.tmp"
mv -f "$WORK_DIR/status.tmp" "$WORK_DIR/status"
chmod 0644 "$WORK_DIR/status"
}
mail_ready() { # 엔진의 /healthz/ready (이미지의 curl)
docker exec "$MAIL_CONTAINER" curl -fsS -m 3 -o /dev/null http://127.0.0.1:8080/healthz/ready >/dev/null 2>&1
}
wait_mail_ready() { # MAIL_READY_TIMEOUT 초까지 — 준비되면 0
local waited=0
until mail_ready; do
sleep 1
waited=$((waited + 1))
[ "$waited" -lt "$MAIL_READY_TIMEOUT" ] || return 1
done
}
# 엔진을 다시 띄운다 — 떠 있는 것을 본 뒤에만 표시를 지운다(15분 점검 · ensure-mail-running 이 표시를 본다)
start_mail() {
STEP="메일 엔진 다시 띄우기"
MAIL_PHASE=starting
docker start "$MAIL_CONTAINER" >/dev/null
container_running "$MAIL_CONTAINER" || die "docker start 뒤에도 메일 엔진($MAIL_CONTAINER)이 떠 있지 않습니다"
rm -f "$MARKER"
MAIL_PHASE=""
wait_mail_ready || die "메일 엔진이 ${MAIL_READY_TIMEOUT}초 안에 준비되지 않았습니다 (docker logs $MAIL_CONTAINER)"
}
# 끊긴 백업이 멈춘 엔진을 다시 띄운다 (on_exit) — 띄웠으면 0. 준비(/healthz/ready)는 여기서 기다리지 않는다(on_exit 가 알린 뒤에 기다린다).
# 멈추는 중이었으면 멈춤이 끝나기를 기다린 뒤에 띄운다 — docker 가 stop 을 처리하는 동안 start 를 부르면 아무 일도 하지 않고(아직 떠 있다),
# 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다
restart_stopped_mail() {
log "[복구] 메일 엔진을 다시 띄운다 (끊긴 단계: $MAIL_PHASE)"
if [ "$MAIL_PHASE" = stopping ]; then
docker stop -t "$MAIL_STOP_TIMEOUT" "$MAIL_CONTAINER" >/dev/null 2>&1 || true
fi
if ! docker start "$MAIL_CONTAINER" >/dev/null 2>&1 || ! container_running "$MAIL_CONTAINER"; then
log "[실패] 메일 엔진을 다시 띄우지 못했습니다 — 표시($MARKER)를 둔다. docker start $MAIL_CONTAINER"
return 1
fi
rm -f "$MARKER"
MAIL_PHASE=""
}
# 정리 차례 — systemd 가 시간 상한(TimeoutStopSec)으로 끊어도 중요한 것부터 끝나게:
# 1. 비밀이 든 사본(env · DB 덤프)을 지운다 2. 멈춘 엔진을 띄운다(준비는 기다리지 않는다) 3. 실패를 알린다(상태 파일 · Healthchecks /fail)
# 4. 그 뒤에 엔진 준비를 기다린다 — 오래 걸려도 /fail 은 이미 나갔다
on_exit() {
local rc=$?
# 정리 중에는 다시 끊기지 않게 (systemd 는 SIGTERM 한 번 뒤 TimeoutStopSec 까지 기다린다)
trap '' TERM INT
rm -rf "${WORK_DIR:?}/env" "${WORK_DIR:?}/db"
local restarted=0 mail_note=""
if [ -n "$MAIL_PHASE" ]; then
if restart_stopped_mail; then
restarted=1
mail_note=" · 메일 엔진은 다시 띄웠다"
else
mail_note=" · ⚠️ 메일 엔진을 다시 띄우지 못했다(ensure-mail-running)"
fi
fi
if [ "$rc" -ne 0 ] && [ "${MODE:-run}" = run ]; then
write_status fail "$STEP" || true
hc_ping "${HC_BACKUP_URL:-}" /fail "arasystem-backup 실패 — ${STEP} (종료 코드 ${rc}${mail_note} · journalctl -u arasystem-backup 로 본다)"
fi
if [ "$restarted" = 1 ]; then
if wait_mail_ready; then log "[복구] 메일 엔진 준비됨"; else log "[주의] 메일 엔진이 ${MAIL_READY_TIMEOUT}초 안에 준비되지 않았습니다 (docker logs $MAIL_CONTAINER)"; fi
fi
}
# 부팅 직후(타이머의 Persistent — 꺼져 있던 동안 놓친 백업을 켜지자마자 한다)에는 엔진 · DB 가 아직 starting 이다 —
# healthy 를 BACKUP_HEALTHY_WAIT 초(기본 300)까지 기다린 뒤 판단한다. 멈췄거나 없으면(exited · missing) 기다리지 않는다
wait_healthy() { # wait_healthy <컨테이너> — healthy 면 0
local waited=0 state
while :; do
state=$(container_state "$1")
case $state in
healthy) return 0 ;;
starting | unhealthy | running) [ "$waited" -lt "$BACKUP_HEALTHY_WAIT" ] || return 1 ;;
*) return 1 ;;
esac
[ "$waited" -gt 0 ] || log "[대기] $1 이(가) $state — healthy 를 ${BACKUP_HEALTHY_WAIT}초까지 기다린다"
sleep 5
waited=$((waited + 5))
done
}
# restic backup --json 의 요약 줄에서 값 하나
summary_field() { sed -n "s/.*\"message_type\":\"summary\".*\"$1\":\"\{0,1\}\([^\",}]*\).*/\1/p" <<< "$2" | tail -n 1; }
human_bytes() {
awk -v b="${1:-0}" 'BEGIN { split("B KB MB GB TB", u); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ } printf (i == 1 ? "%d%s" : "%.1f%s"), b, u[i] }'
}
prepare() {
load_config
WORK_DIR=$(ops_work_dir)
MARKER="$WORK_DIR/mail-stopped-by-backup"
# 메일 엔진이 SIGTERM 뒤 RocksDB 를 닫고 끝낼 시간 — 넘으면 docker 가 죽인다(그래도 WAL 로 일관되게 다시 뜬다)
MAIL_STOP_TIMEOUT=${MAIL_STOP_TIMEOUT:-60}
# 다시 띄운 엔진이 /healthz/ready 를 줄 때까지 기다리는 시간(초)
MAIL_READY_TIMEOUT=${MAIL_READY_TIMEOUT:-180}
# final(엔진 멈춤) 한 번의 시간 상한 — timeout 의 길이(숫자 + s · m · h)
BACKUP_FINAL_TIMEOUT=${BACKUP_FINAL_TIMEOUT:-15m}
[[ $BACKUP_FINAL_TIMEOUT =~ ^[1-9][0-9]*[smh]?$ ]] || die "$OPS_CONFIG 의 BACKUP_FINAL_TIMEOUT 은 15m 처럼 적는다 (숫자 + s · m · h)"
# 사전 확인에서 엔진 · DB 가 healthy 가 되기를 기다리는 시간(초) — 부팅 직후 따라잡는 백업
BACKUP_HEALTHY_WAIT=${BACKUP_HEALTHY_WAIT:-300}
[[ $BACKUP_HEALTHY_WAIT =~ ^[0-9]+$ ]] || die "$OPS_CONFIG 의 BACKUP_HEALTHY_WAIT 은 초(숫자)로 적는다"
require_set RESTIC_REPOSITORY
case $RESTIC_REPOSITORY in s3:*) require_set AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY ;; esac
RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE:-/etc/arasystem/restic-password}
[ -s "$RESTIC_PASSWORD_FILE" ] || die "restic 비밀번호 파일 $RESTIC_PASSWORD_FILE 이 없거나 비어 있습니다 (ops/ops.env.example 머리말)"
export RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
MAIL_CONTAINER=${PROJECT_NAME}-mail
DB_CONTAINER=${PROJECT_NAME}-db
BACKUP_HOST=${BACKUP_HOST:-$PROJECT_NAME}
BACKUP_ENV_FILES=${BACKUP_ENV_FILES:-"$APP_ENV_FILE $ARASYSTEM_DIR/backend/.env.production"}
mkdir -p "$WORK_DIR"
chmod 0700 "$WORK_DIR"
command -v docker >/dev/null || die "docker 가 없습니다"
}
cmd_init() {
prepare
log "restic 저장소를 만든다 — ${RESTIC_REPOSITORY%%\?*}"
restic_run -- init
log "[완료] 저장소를 만들었습니다. restic 비밀번호($RESTIC_PASSWORD_FILE)를 서버 밖(비밀번호 관리자)에도 둔다 — 잃으면 백업을 풀 수 없다"
}
# 표시 파일이 남아 있으면 그 메일 엔진이 떠 있게 한다 — 설정을 다 읽지 않는다(설정이 깨졌어도 돈다).
# 백업과 같은 잠금을 잡는다 — 백업이 돌고 있으면(손으로 불렀을 때) 아무것도 하지 않고 그만둔다: 그 백업이 끝나며 엔진을 띄운다.
# final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다.
# (ExecStopPost 때는 백업 본체가 끝나 잠금이 풀려 있다 · 부팅 유닛은 백업보다 먼저 돈다 — Before=)
# 떠 있는 것을 본 뒤에만 표시를 지운다. 도커에 닿지 못하면 표시를 둔 채 실패한다(15분 점검이 남은 표시를 알린다 · 부팅 유닛은 다시 한다).
# 방금 멈추기 시작한 엔진(표시가 MAIL_STOP_TIMEOUT 보다 새것)은 멈춤이 끝날 때까지 기다린 뒤에 본다 — 아직 떠 보여도 곧 멈춘다
cmd_ensure_mail_running() {
WORK_DIR=$(ops_work_dir)
local marker="$WORK_DIR/mail-stopped-by-backup"
[ -f "$marker" ] || exit 0
if ! take_backup_lock "$WORK_DIR"; then
log "[건너뜀] 백업이나 복원 연습이 돌고 있다(잠금) — 백업이면 끝나며 엔진을 스스로 다시 띄운다. 끝난 뒤에도 표시가 남아 있으면 다시 부른다(journalctl -u arasystem-backup)"
exit 0
fi
# 잠금을 잡는 사이 백업이 끝나 표시를 지웠을 수 있다
[ -f "$marker" ] || exit 0
local container stop_timeout age
container=$(cat "$marker")
[[ $container =~ ^[a-z0-9][a-z0-9_-]*-mail$ ]] || die "표시 파일($marker)의 내용이 메일 컨테이너 이름이 아닙니다 — 확인하고 지운다"
stop_timeout=${MAIL_STOP_TIMEOUT:-$(env_get "$OPS_CONFIG" MAIL_STOP_TIMEOUT || true)}
stop_timeout=${stop_timeout:-60}
[[ $stop_timeout =~ ^[0-9]+$ ]] || stop_timeout=60
age=$(( $(date +%s) - $(stat -c %Y "$marker") ))
[ "$age" -ge 0 ] || age=0
if [ "$age" -lt $((stop_timeout + 15)) ]; then
log "[대기] 표시가 ${age}초 전 것이다 — 멈춤이 끝날 때까지 $((stop_timeout + 15 - age))초 기다린 뒤 본다"
sleep $((stop_timeout + 15 - age))
fi
local state=0
container_running "$container" || state=$?
case $state in
0) ;;
1)
log "[복구] 백업이 멈춘 메일 엔진($container)이 남아 있어 다시 띄운다"
docker start "$container" >/dev/null || die "docker start $container 에 실패했습니다 — 표시($marker)를 둔다"
container_running "$container" || die "docker start 뒤에도 $container 가 떠 있지 않습니다 — 표시($marker)를 둔다"
;;
*) die "도커에 닿지 못했거나 $container 가 없습니다 — 표시($marker)를 둔다" ;;
esac
rm -f "$marker"
log "[확인] $container 가 떠 있다 — 표시를 지웠다"
}
cmd_run() {
prepare
if ! take_backup_lock "$WORK_DIR"; then
log "[건너뜀] 다른 백업 · 복원 연습이 돌고 있습니다"
hc_ping "${HC_BACKUP_URL:-}" /fail "arasystem-backup 건너뜀 — 다른 백업 · 복원 연습이 돌고 있다(잠금 $WORK_DIR/backup.lock)"
exit 0
fi
trap on_exit EXIT
# systemd 가 멈추거나(시간 초과 · stop) Ctrl-C 여도 EXIT 정리(멈춘 메일 엔진 다시 띄우기)를 탄다
trap 'exit 143' TERM
trap 'exit 130' INT
local started rc
started=$(date +%s)
hc_ping "${HC_BACKUP_URL:-}" /start
STEP="사전 확인"
local volume
for volume in "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" "${PROJECT_NAME}_uploads"; do
docker volume inspect "$volume" >/dev/null 2>&1 || die "볼륨 $volume 이 없습니다 (PROJECT_NAME 확인)"
done
wait_healthy "$MAIL_CONTAINER" || die "메일 엔진($MAIL_CONTAINER)이 healthy 가 아니라 백업하지 않았습니다 ($(container_state "$MAIL_CONTAINER"))"
wait_healthy "$DB_CONTAINER" || die "DB($DB_CONTAINER)가 healthy 가 아닙니다 ($(container_state "$DB_CONTAINER"))"
STEP="restic 저장소 확인"
restic_run -- cat config >/dev/null || die "restic 저장소에 닿지 못했습니다 — 주소 · 키 · 비밀번호, 처음이면 arasystem-backup init"
# 죽은 restic 이 남긴 오래된 잠금만 지운다 (살아 있는 잠금은 두고)
restic_run -- unlock >/dev/null 2>&1 || true
STEP="DB 덤프"
rm -rf "${WORK_DIR:?}/db" "${WORK_DIR:?}/env"
mkdir -p "$WORK_DIR/db" "$WORK_DIR/env"
# shellcheck disable=SC2016 # $POSTGRES_* 는 컨테이너 안에서 푼다(비밀번호 없이 소켓으로)
docker exec "$DB_CONTAINER" sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$WORK_DIR/db/db.dump.tmp"
docker exec -i "$DB_CONTAINER" pg_restore -l < "$WORK_DIR/db/db.dump.tmp" > /dev/null || die "DB 덤프를 pg_restore 가 읽지 못했습니다"
mv -f "$WORK_DIR/db/db.dump.tmp" "$WORK_DIR/db/db.dump"
local dump_bytes
dump_bytes=$(wc -c < "$WORK_DIR/db/db.dump")
STEP="env 파일 복사"
# 저장소 기준 경로의 / 를 __ 로 — .env.production · backend__.env.production (복원 때 제자리를 안다)
local file name
for file in $BACKUP_ENV_FILES; do
[ -r "$file" ] || die "env 파일 $file 을 읽을 수 없습니다"
name=${file#"$ARASYSTEM_DIR"/}
cp -f "$file" "$WORK_DIR/env/${name//\//__}"
done
local mounts=(
-v "${PROJECT_NAME}_mail-config:/backup/mail-config:ro" -v "${PROJECT_NAME}_mail-data:/backup/mail-data:ro"
-v "${PROJECT_NAME}_uploads:/backup/uploads:ro" -v "$WORK_DIR/db:/backup/db:ro" -v "$WORK_DIR/env:/backup/env:ro"
)
local paths=(/backup/mail-config /backup/mail-data /backup/uploads /backup/db /backup/env)
STEP="warm(켜 둔 채 미리 올리기)"
log "[1/4] warm — 메일 엔진을 켜 둔 채 올린다"
rc=0
restic_run "${mounts[@]}" -- backup --host "$BACKUP_HOST" --tag warm --quiet "${paths[@]}" || rc=$?
case $rc in
0) ;;
# 켜 둔 RocksDB 가 그사이 지운 파일(압축으로 사라진 SST 등)은 읽지 못한다 — warm 은 미리 올리기라 괜찮다. final 은 멈춘 엔진에서 다시 다 읽는다
3) log "[참고] warm — 도는 엔진이 그사이 지운 파일을 읽지 못했다(restic 종료 3). final 이 멈춘 엔진에서 다시 받는다" ;;
*) die "warm 의 restic 이 종료 $rc 로 끝났습니다" ;;
esac
STEP="final(메일 엔진 멈춤)"
log "[2/4] final — 메일 엔진을 멈추고 바뀐 것만 올린다 (상한 $BACKUP_FINAL_TIMEOUT)"
local stopped_at down out=""
stopped_at=$(date +%s)
printf '%s\n' "$MAIL_CONTAINER" > "$MARKER"
MAIL_PHASE=stopping
docker stop -t "$MAIL_STOP_TIMEOUT" "$MAIL_CONTAINER" >/dev/null
rc=0
RESTIC_TIMEOUT=$BACKUP_FINAL_TIMEOUT
out=$(restic_run "${mounts[@]}" -- backup --host "$BACKUP_HOST" --tag final --json "${paths[@]}") || rc=$?
RESTIC_TIMEOUT=""
case $rc in
0) ;;
124) die "final 이 시간 상한($BACKUP_FINAL_TIMEOUT)을 넘어 멈췄습니다 — 메일 엔진을 다시 띄운다 (바깥 대역폭 · NCP · BACKUP_FINAL_TIMEOUT)" ;;
*) die "final 의 restic 이 종료 $rc 로 끝났습니다 (3 이면 멈춘 엔진의 파일을 읽지 못한 것 — 받지 않는다)" ;;
esac
start_mail
down=$(( $(date +%s) - stopped_at ))
local snapshot added
snapshot=$(summary_field snapshot_id "$out")
added=$(summary_field data_added "$out")
[ -n "$snapshot" ] || die "final 스냅숏 id 를 읽지 못했습니다"
log "[3/4] 메일 엔진 다시 준비됨 — 멈춘 시간 ${down}초 · final 스냅숏 ${snapshot:0:8} · 새로 올린 양 $(human_bytes "$added")"
STEP="보존 정리(forget)"
log "[4/4] 보존 — 매일 ${BACKUP_KEEP_DAILY:-7} · 매주 ${BACKUP_KEEP_WEEKLY:-4} · 매달 ${BACKUP_KEEP_MONTHLY:-6}"
restic_run -- forget --host "$BACKUP_HOST" --tag final --keep-daily "${BACKUP_KEEP_DAILY:-7}" --keep-weekly "${BACKUP_KEEP_WEEKLY:-4}" --keep-monthly "${BACKUP_KEEP_MONTHLY:-6}" --quiet
restic_run -- forget --host "$BACKUP_HOST" --tag warm --keep-last 1 --quiet
# 요일은 한국 시각으로 — 서버의 시간대가 UTC 여도 03:30 KST(전날 18:30 UTC)가 토요일로 읽히지 않게
if [ "$(TZ=Asia/Seoul date +%u)" = "${BACKUP_PRUNE_WEEKDAY:-7}" ] || [ "${BACKUP_FORCE_PRUNE:-0}" = 1 ]; then
STEP="prune · check"
log "[주간] prune · check(데이터 ${BACKUP_CHECK_SUBSET:-5%})"
restic_run -- prune --quiet
restic_run -- check --read-data-subset="${BACKUP_CHECK_SUBSET:-5%}" --quiet
fi
local summary
summary="arasystem-backup 성공 — 스냅숏 ${snapshot:0:8} · 메일 엔진 멈춤 ${down}초 · 새로 올림 $(human_bytes "$added") · DB 덤프 $(human_bytes "$dump_bytes") · 전체 $(( $(date +%s) - started ))초"
write_status ok "$summary"
log "[완료] $summary"
hc_ping "${HC_BACKUP_URL:-}" "" "$summary"
}
main() {
MODE=${1:-run}
case $MODE in
run) cmd_run ;;
init) cmd_init ;;
ensure-mail-running) cmd_ensure_mail_running ;;
*) echo "사용법: $0 [run|init|ensure-mail-running]" >&2; exit 2 ;;
esac
}
# source 하면(시험) 함수만 읽는다
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi