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
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env bash
# ==========================================
# arasystem 복원 · 복원 연습 (13단계 — mail/README.md "백업" · "복원")
#
# arasystem-restore.sh snapshots final 스냅숏 목록 (복원에 쓰는 것 — warm 은 쓰지 않는다)
# arasystem-restore.sh check [5%|100%] 저장소 검사 — 데이터를 그만큼 내려받아 본다 (기본 5%)
# arasystem-restore.sh drill [스냅숏|latest] [--keep]
# 복원 연습 — 운영을 건드리지 않는다. 임시 볼륨에 풀고, 네트워크 없이(--network none)
# 같은 판의 메일 엔진 · DB 를 띄워 계정 · 메일 수 · DB 행 수 · 업로드 · env 를 지금 운영과 견준 뒤 지운다(--keep 이면 남긴다).
# 백업과 같은 잠금을 잡는다 — 함께 돌지 않는다
# arasystem-restore.sh drill-clean 남은 연습 자원(<PROJECT_NAME>-drill*)을 지운다
# arasystem-restore.sh files <스냅숏> <폴더> DB 덤프 · env 파일만 폴더로 꺼낸다 (새 서버 — env 를 제자리에 둘 때)
# arasystem-restore.sh volumes <스냅숏> 메일 엔진 · 업로드 볼륨을 되살린다 (새 서버 — 볼륨이 없거나 비었고, 쓰는 컨테이너가 멈춰 있을 때만)
# arasystem-restore.sh db <덤프 파일> 빈 DB 에 덤프를 넣는다 (db 만 띄우고 backend 를 띄우기 전에 — 표가 있으면 멈춘다)
#
# 설정은 백업과 같다 (/etc/arasystem/ops.env · restic-password). root 로 돈다.
# snapshots · check · files 는 저장소의 .env.production 없이 돈다 — 새 서버 복원의 첫 단계가 그 파일을 꺼낸다. 그때 PROJECT_NAME(스냅숏의 host 이름)은
# ops.env 의 값이다(옛 서버의 값을 적는다). volumes · db · drill 은 .env.production 이 있어야 한다(볼륨 · 컨테이너 이름이 그 PROJECT_NAME 이다)
# 스냅숏은 final 만 받는다 — id 를 직접 줘도 태그를 본다(warm 은 켜 둔 엔진에서 받아 RocksDB 가 일관되지 않을 수 있다)
# ⚠️ 연습의 메일 엔진은 운영 설정(호스트 이름 · 인증서 · 발송 대기열 · 웹훅)을 그대로 안고 뜬다 — 그래서 네트워크 없이(루프백뿐) 띄운다
# (대기열의 메일을 다시 보내거나 인증서를 새로 받으러 나가지 않게). 포트도 열지 않는다. 세는 쪽(백엔드 이미지의 node)은 그 엔진의 네트워크 이름공간을 함께 쓴다
# (--network container:…). 도커의 주소 풀이 바닥나도(all predefined address pools have been fully subnetted) 연습이 멈추지 않는다
# 보조 컨테이너는 모두 권한을 버리고(--cap-drop ALL · no-new-privileges) 꼭 필요한 것만 더한다 — 볼륨 들여다보기는 파일 읽기(DAC_READ_SEARCH)만,
# 복원(restic)은 쓰기 · 소유자 되살리기, 연습 DB 는 postgres 의 시작 스크립트가 쓰는 것(소유자 바꾸기 · 사용자 바꾸기)
# ==========================================
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_IMAGE=${RESTIC_IMAGE:-restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510}
SEQ=0
# 볼륨을 읽기만 하는 보조 컨테이너 (restic 이미지의 sh)
PROBE=(--rm --network none --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges)
prepare() { # prepare [optional] — optional 이면 .env.production 이 없어도 된다 (snapshots · check · files)
load_config "${1:-required}"
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 이 없거나 비어 있습니다"
export RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
BACKUP_HOST=${BACKUP_HOST:-$PROJECT_NAME}
DRILL=${PROJECT_NAME}-drill
WORK_DIR=$(ops_work_dir)
}
# restic 을 컨테이너로 — restic_run [docker 인자 …] -- [restic 인자 …]. 복원은 소유자(메일 UID 2000 · 업로드 UID 1000)를 되살려야 해
# 파일 쓰기 · 소유자 바꾸기 권한을 더한다(백업은 읽기만)
restic_run() {
local dargs=()
while [ "$1" != "--" ]; do dargs+=("$1"); shift; done
shift
local net=()
[ -n "${BACKUP_DOCKER_NETWORK:-}" ] && net=(--network "$BACKUP_DOCKER_NETWORK")
# shellcheck disable=SC2086 # RESTIC_OPTIONS 는 낱말로 나눠 넘긴다
docker run --rm --name "${PROJECT_NAME}-restore-restic-$$-$((SEQ += 1))" \
--cap-drop ALL --cap-add DAC_READ_SEARCH --cap-add DAC_OVERRIDE --cap-add CHOWN --cap-add FOWNER --security-opt no-new-privileges \
--memory 1g "${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:-} "$@"
}
# 스냅숏 이름 → 64자 id (latest 는 이 서버의 마지막 final). id 를 직접 주면 final 인지 본다
resolve_snapshot() {
local want=${1:-latest} json id
if [ "$want" = latest ]; then
json=$(restic_run -- snapshots --json --host "$BACKUP_HOST" --tag final --latest 1)
else
[[ $want =~ ^[0-9a-f]{8,64}$ ]] || die "스냅숏은 id(8자 이상) 또는 latest 입니다"
json=$(restic_run -- snapshots --json "$want")
fi
id=$(grep -o '"id":"[0-9a-f]\{64\}"' <<< "$json" | tail -n 1 | cut -d'"' -f4)
[ -n "$id" ] || die "스냅숏을 찾지 못했습니다 ($want · host $BACKUP_HOST · 태그 final — 새 서버면 ops.env 의 PROJECT_NAME 이 옛 서버의 값인지 본다)"
grep -q '"tags":\[[^]]*"final"' <<< "$json" ||
die "스냅숏 ${id:0:8} 은 final 이 아닙니다(warm 등 — 켜 둔 엔진에서 받은 것) — 복원에는 final 만 쓴다 ($0 snapshots)"
printf '%s' "$id"
}
# 볼륨이 없거나 비었으면 0 · 무엇이 있으면 1. 들여다보지 못하면(도커 · 이미지 오류) 멈춘다 — 실패를 "비었다"로 보고 덮어쓰지 않게
volume_empty() {
docker volume inspect "$1" >/dev/null 2>&1 || return 0
local out rc=0
out=$(docker run "${PROBE[@]}" -v "$1:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'ls -A /v') || rc=$?
[ "$rc" = 0 ] || die "볼륨 $1 을 들여다보지 못했습니다 (docker 종료 $rc) — 비었는지 모르므로 멈춘다"
[ -z "$out" ]
}
volume_in_use() { # 그 볼륨을 붙인 컨테이너가 떠 있으면 이름
docker ps --filter "volume=$1" --format '{{.Names}}' | head -n 1
}
cmd_snapshots() {
prepare optional
restic_run -- snapshots --host "$BACKUP_HOST" --tag final
}
cmd_check() {
prepare optional
local subset=${1:-5%}
[[ $subset =~ ^[0-9]{1,3}%$ ]] || die "check 의 범위는 5% 처럼 적는다"
log "저장소 검사 — 데이터 ${subset}"
restic_run -- check --read-data-subset="$subset"
}
cmd_files() {
prepare optional
[ $# -eq 2 ] || die "사용법: $0 files <스냅숏> <폴더>"
local id dir
id=$(resolve_snapshot "$1")
dir=$2
if [ -e "$dir" ] && [ -n "$(ls -A "$dir")" ]; then die "$dir 가 비어 있지 않습니다"; fi
mkdir -p "$dir"
chmod 0700 "$dir"
# docker -v 는 절대 경로만 받는다 (C:/… 는 Git Bash 시험용)
case $dir in /* | [A-Za-z]:/*) ;; *) dir="$PWD/$dir" ;; esac
log "스냅숏 ${id:0:8} 의 DB 덤프 · env 파일을 $dir 로"
restic_run -v "$dir:/restore" -- restore "$id" --target /restore --include /backup/db --include /backup/env
log "[완료] $dir/backup/db/db.dump · $dir/backup/env/ (backend__.env.production 은 backend/.env.production 자리) — 비밀이 들어 있다, 쓴 뒤 지운다"
}
cmd_volumes() {
prepare
[ $# -eq 1 ] || die "사용법: $0 volumes <스냅숏>"
local id volume
id=$(resolve_snapshot "$1")
for volume in "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" "${PROJECT_NAME}_uploads"; do
[ -z "$(volume_in_use "$volume")" ] || die "$volume 을 쓰는 컨테이너($(volume_in_use "$volume"))가 떠 있습니다 — 멈춘 뒤에"
volume_empty "$volume" || die "$volume 이 비어 있지 않습니다 — 되살리려는 서버가 맞는지 확인하고, 맞으면 그 볼륨을 직접 지운 뒤(docker volume rm $volume) 다시 실행한다"
done
# 없는 볼륨은 compose 의 이름표(프로젝트 · 볼륨 이름)를 붙여 만든다 — 그냥 만들어도 compose 가 그 볼륨을 쓰지만
# up 마다 "already exists but was not created by Docker Compose" 경고를 낸다 (compose 2.40 — 2026-11-02 확인)
for volume in mail-config mail-data uploads; do
docker volume inspect "${PROJECT_NAME}_${volume}" >/dev/null 2>&1 ||
docker volume create --label "com.docker.compose.project=${PROJECT_NAME}" --label "com.docker.compose.volume=${volume}" "${PROJECT_NAME}_${volume}" >/dev/null
done
log "스냅숏 ${id:0:8} → 볼륨 ${PROJECT_NAME}_mail-config · _mail-data · _uploads"
local started
started=$(date +%s)
restic_run -v "${PROJECT_NAME}_mail-config:/backup/mail-config" -v "${PROJECT_NAME}_mail-data:/backup/mail-data" -v "${PROJECT_NAME}_uploads:/backup/uploads" \
-- restore "$id" --target / --include /backup/mail-config --include /backup/mail-data --include /backup/uploads
log "[완료] $(( $(date +%s) - started ))초 — 다음은 compose up (mail/README.md \"복원\")"
}
cmd_db() {
load_config
[ $# -eq 1 ] && [ -r "$1" ] || die "사용법: $0 db <덤프 파일>"
local db=${PROJECT_NAME}-db tables
[ "$(container_state "$db")" = healthy ] || die "DB($db)가 healthy 가 아닙니다 — db 만 먼저 띄운다 (compose up -d db)"
# shellcheck disable=SC2016 # $POSTGRES_* 는 컨테이너 안에서 푼다
tables=$(docker exec "$db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select count(*) from information_schema.tables where table_schema = '\''public'\''"')
[ "$tables" = 0 ] || die "DB 에 표가 이미 $tables 개 있습니다 — 덤프는 빈 DB 에만 넣는다 (backend 가 먼저 떠 마이그레이션을 돌렸다면 db 볼륨을 새로 만들고 db 만 띄운 뒤)"
log "덤프를 $db 에 넣는다 (표준 입력으로)"
# shellcheck disable=SC2016
docker exec -i "$db" sh -c 'exec pg_restore --no-owner --exit-on-error -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$1"
# shellcheck disable=SC2016
tables=$(docker exec "$db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select count(*) from information_schema.tables where table_schema = '\''public'\''"')
log "[완료] 표 ${tables}개 — 다음은 compose up -d (backend 는 마이그레이션이 이미 적용된 것을 보고 그대로 뜬다)"
}
drill_resources() {
docker ps -aq --filter "name=^${DRILL}-" || true
}
cmd_drill_clean() {
load_config
DRILL=${PROJECT_NAME}-drill
local ids
ids=$(drill_resources)
# shellcheck disable=SC2086
[ -z "$ids" ] || docker rm -f $ids >/dev/null
local volume
for volume in mail-config mail-data uploads db env; do
docker volume rm "${DRILL}_${volume}" >/dev/null 2>&1 || true
done
log "[정리] 연습 자원(${DRILL}*)을 지웠습니다"
}
# 표마다 행 수 — "표|행" 줄들. 표마다 count(*) 문을 만들어(첫 psql) 그대로 다시 넣는다(둘째 psql — 표준 입력)
table_counts() { # table_counts <컨테이너> <사용자> <DB>
local sql="select format('select %L || ''|'' || count(*) from %I.%I;', table_name, table_schema, table_name) from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name"
docker exec "$1" psql -U "$2" -d "$3" -At -c "$sql" | docker exec -i "$1" psql -U "$2" -d "$3" -At
}
cmd_drill() {
prepare
local want=latest keep=0 arg
for arg in "$@"; do
case $arg in --keep) keep=1 ;; *) want=$arg ;; esac
done
mkdir -p "$WORK_DIR"
chmod 0700 "$WORK_DIR"
take_backup_lock "$WORK_DIR" || die "백업이나 다른 복원 연습이 돌고 있습니다 — 끝난 뒤에 (journalctl -u arasystem-backup)"
[ -z "$(drill_resources)" ] || die "지난 연습이 남아 있습니다 — $0 drill-clean"
local mail_image db_image backend_image
mail_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-mail" 2>/dev/null || env_get "$APP_ENV_FILE" MAIL_IMAGE || true)
db_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-db" 2>/dev/null || env_get "$APP_ENV_FILE" DB_IMAGE || true)
backend_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-backend" 2>/dev/null || true)
[ -n "$mail_image" ] && [ -n "$db_image" ] || die "메일 · DB 이미지를 알 수 없습니다"
local id t0 t_restore t_ready
id=$(resolve_snapshot "$want")
[ "$keep" = 1 ] || trap cmd_drill_clean EXIT
log "복원 연습 — 스냅숏 ${id:0:8} · 메일 $mail_image · DB $db_image"
# 네트워크를 만들지 않는다 — 연습 엔진은 --network none(루프백뿐 — 바깥으로 한 걸음도 나가지 않는다), 세는 쪽은 그 네트워크 이름공간을 함께 쓴다
t0=$(date +%s)
restic_run -v "${DRILL}_mail-config:/backup/mail-config" -v "${DRILL}_mail-data:/backup/mail-data" -v "${DRILL}_uploads:/backup/uploads" \
-v "${DRILL}_db:/backup/db" -v "${DRILL}_env:/backup/env" -- restore "$id" --target / >/dev/null
t_restore=$(( $(date +%s) - t0 ))
log "[1] 풀기 ${t_restore}초"
# 메일 엔진 — 복구 관리자(연습마다 무작위 — 출력하지 않고, 명령줄에도 싣지 않는다 — -e 이름만)로 계정 · 메일을 센다.
# 운영 관리자 · 게이트웨이는 허용 IP(앱 네트워크)라 여기서는 쓰지 못한다. 엔진 이미지는 stalwart 사용자로 돈다 — 다만 실행 파일에
# 낮은 포트용 파일 권한(cap_net_bind_service=ep)이 붙어 있어 그 권한이 없으면 exec 가 거절된다(operation not permitted — 2026-11-02 확인). 그것만 둔다
local recovery
recovery=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')
STALWART_RECOVERY_ADMIN="drill:$recovery" docker run -d --name "${DRILL}-mail" --network none --memory 1g \
--cap-drop ALL --cap-add NET_BIND_SERVICE --security-opt no-new-privileges \
-e STALWART_RECOVERY_ADMIN -v "${DRILL}_mail-config:/etc/stalwart" -v "${DRILL}_mail-data:/var/lib/stalwart" "$mail_image" >/dev/null
local waited=0
until docker exec "${DRILL}-mail" curl -fsS -m 3 -o /dev/null http://127.0.0.1:8080/healthz/ready >/dev/null 2>&1; do
sleep 1
waited=$((waited + 1))
[ "$waited" -lt 180 ] || die "연습 메일 엔진이 180초 안에 준비되지 않았습니다 (docker logs ${DRILL}-mail)"
done
t_ready=$waited
log "[2] 메일 엔진 준비 ${t_ready}초 (RocksDB 를 열었다)"
if [ -n "$backend_image" ]; then
# 백엔드 이미지의 node(node 사용자)로 센다 (fetch) — 연습 네트워크 안에서. 직원마다 메일 수를 대리 접근(<주소>%drill)으로
R="$recovery" docker run --rm --network "container:${DRILL}-mail" --memory 512m --cap-drop ALL --security-opt no-new-privileges \
-e R -e H=127.0.0.1 -e CHECK="${RESTORE_CHECK_ACCOUNT:-}" --entrypoint node "$backend_image" -e '
const base = `http://${process.env.H}:8080`, b = (u) => "Basic " + Buffer.from(u + ":" + process.env.R).toString("base64")
const call = async (auth, using, calls) => (await (await fetch(base + "/jmap/", { method: "POST", headers: { authorization: auth, "content-type": "application/json" }, body: JSON.stringify({ using, methodCalls: calls }) })).json()).methodResponses
;(async () => {
const s = await (await fetch(base + "/jmap/session", { headers: { authorization: b("drill") } })).json()
const x = s.primaryAccounts["urn:stalwart:jmap"], X = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
const [[, q]] = await call(b("drill"), X, [["x:Account/query", { accountId: x, limit: 1000 }, "q"]])
const [[, g]] = await call(b("drill"), X, [["x:Account/get", { accountId: x, ids: q.ids, properties: ["name", "emailAddress", "@type"] }, "g"]])
const [[, d]] = await call(b("drill"), X, [["x:Domain/get", { accountId: x, ids: null, properties: ["name"] }, "d"]])
let total = 0, withMail = 0; const rows = []
for (const a of g.list.filter((a) => a["@type"] === "User" && a.emailAddress)) {
const r = await fetch(base + "/jmap/session", { headers: { authorization: b(a.emailAddress + "%drill") } })
if (r.status !== 200) continue
const m = (await r.json()).primaryAccounts["urn:ietf:params:jmap:mail"]; if (!m) continue
const res = await call(b(a.emailAddress + "%drill"), ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], [["Email/query", { accountId: m, calculateTotal: true, limit: 1 }, "e"], ["Mailbox/get", { accountId: m, ids: null, properties: ["name", "totalEmails"] }, "b"]])
const n = res[0][1].total ?? 0; total += n; if (n > 0) withMail += 1
rows.push(a.emailAddress.split("@")[0] + " " + n)
if (process.env.CHECK && a.emailAddress.toLowerCase() === process.env.CHECK.toLowerCase()) console.log(" 확인 계정 " + a.emailAddress + " — " + res[1][1].list.map((x) => x.name + " " + x.totalEmails).join(" · "))
}
console.log(" 도메인 " + d.list.map((x) => x.name).join(", ") + " · 계정 " + g.list.length + "개(직원 꼴 " + rows.length + ") · 메일 " + total + "통(메일이 있는 계정 " + withMail + ")")
console.log(" 계정마다 메일 수: " + rows.join(" · "))
})().catch((e) => { console.log(" [실패] " + e.message); process.exitCode = 1 })' \
|| log "[주의] 연습 메일 엔진의 계정 · 메일 수를 세지 못했습니다"
else
log "[건너뜀] 백엔드 이미지가 없어 계정 · 메일 수는 세지 않았다 (엔진이 RocksDB 를 연 것까지 확인)"
fi
# DB — 같은 이미지의 임시 postgres(네트워크 없음)에 덤프를 넣고 표마다 행 수를 지금 운영과 견준다.
# postgres 의 시작 스크립트는 root 로 데이터 폴더의 소유자를 바꾼 뒤 postgres 사용자로 내려간다 — 그 권한만 더한다
local db_pass
db_pass=$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n')
POSTGRES_PASSWORD=$db_pass docker run -d --name "${DRILL}-db" --network none --memory 512m \
--cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER --cap-add SETUID --cap-add SETGID --security-opt no-new-privileges \
-e POSTGRES_PASSWORD -e POSTGRES_USER=drill -e POSTGRES_DB=drill -v "${DRILL}_db:/restore:ro" "$db_image" >/dev/null
waited=0
until docker exec "${DRILL}-db" pg_isready -U drill -d drill >/dev/null 2>&1 && docker exec "${DRILL}-db" psql -U drill -d drill -Atc 'select 1' >/dev/null 2>&1; do
sleep 1
waited=$((waited + 1))
[ "$waited" -lt 120 ] || die "연습 DB 가 뜨지 않았습니다 (docker logs ${DRILL}-db)"
done
docker exec "${DRILL}-db" pg_restore --no-owner --no-privileges --exit-on-error -U drill -d drill /restore/db.dump
local restored live=""
restored=$(table_counts "${DRILL}-db" drill drill)
if [ "$(container_state "${PROJECT_NAME}-db")" = healthy ]; then
live=$(table_counts "${PROJECT_NAME}-db" "$(docker exec "${PROJECT_NAME}-db" printenv POSTGRES_USER)" "$(docker exec "${PROJECT_NAME}-db" printenv POSTGRES_DB)" || true)
fi
log "[3] DB — 표 $(grep -c . <<< "$restored")개 (표 · 백업 · 지금)"
local table count now
while IFS='|' read -r table count; do
[ -n "$table" ] || continue
now=$(grep "^${table}|" <<< "$live" | cut -d'|' -f2 || true)
printf ' %-34s %8s %8s\n' "$table" "$count" "${now:--}"
done <<< "$restored"
# 업로드 · env — 파일 수 · 크기, env 는 지금 파일과 같은지(sha256 — 값은 보지 않는다)
local up_restored up_live
up_restored=$(docker run "${PROBE[@]}" -v "${DRILL}_uploads:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'echo "$(find /v -type f | wc -l)개 $(du -sh /v | cut -f1)"')
up_live=$(docker run "${PROBE[@]}" -v "${PROJECT_NAME}_uploads:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'echo "$(find /v -type f | wc -l)개 $(du -sh /v | cut -f1)"' 2>/dev/null || echo -)
log "[4] 업로드 — 백업 $up_restored · 지금 $up_live"
local env_report file name
env_report=""
for file in $(docker run "${PROBE[@]}" -v "${DRILL}_env:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'ls -A /v'); do
name=${file//__//}
if [ -r "$ARASYSTEM_DIR/$name" ] && [ "$(docker run "${PROBE[@]}" -v "${DRILL}_env:/v:ro" --entrypoint sha256sum "$RESTIC_IMAGE" "/v/$file" | cut -d' ' -f1)" = "$(sha256sum "$ARASYSTEM_DIR/$name" | cut -d' ' -f1)" ]; then
env_report+=" $name(지금과 같음)"
else
env_report+=" $name(지금과 다름 · 또는 지금 없음)"
fi
done
log "[5] env —${env_report:- 없음}"
log "[완료] 복원 연습 — 스냅숏 ${id:0:8} · 풀기 ${t_restore}초 · 메일 엔진 준비 ${t_ready}초$( [ "$keep" = 1 ] && printf ' · 남겨 둠(%s drill-clean 으로 지운다)' "$0")"
}
main() {
case ${1:-} in
snapshots) shift; cmd_snapshots "$@" ;;
check) shift; cmd_check "$@" ;;
drill) shift; cmd_drill "$@" ;;
drill-clean) shift; cmd_drill_clean ;;
files) shift; cmd_files "$@" ;;
volumes) shift; cmd_volumes "$@" ;;
db) shift; cmd_db "$@" ;;
*) sed -n '3,14p' "$0" >&2; exit 2 ;;
esac
}
# source 하면(시험) 함수만 읽는다
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi
+141
View File
@@ -0,0 +1,141 @@
# shellcheck shell=bash
# ==========================================
# 운영 스크립트 공용 함수 (백업 · 복원 · 15분 점검이 source 한다 — 이 파일만 따로 실행하지 않는다)
#
# 설정은 /etc/arasystem/ops.env(예시 ops/ops.env.example)와 저장소의 .env.production 에서 읽는다 — 셸로 실행(source)하지 않고 줄마다 읽는다
# (값에 $ · 따옴표 · 공백이 있어도 명령이 되지 않게). 비밀(비밀번호 · 키)은 출력 · 로그 · 알림 어디에도 싣지 않는다.
# ==========================================
# Git Bash(Windows)에서 시험할 때만 뜻이 있다 — docker 인자의 컨테이너 경로(/backup/…)를 Windows 경로로 바꾸지 않게. 리눅스에서는 아무 일도 하지 않는다
export MSYS_NO_PATHCONV=1
OPS_CONFIG=${ARASYSTEM_OPS_CONFIG:-/etc/arasystem/ops.env}
log() { printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"; }
die() { log "[실패] $*" >&2; exit 1; }
trim() {
local value=$1
value=${value#"${value%%[![:space:]]*}"}
value=${value%"${value##*[![:space:]]}"}
printf '%s' "$value"
}
# KEY=값 한 줄의 값 — Node 의 --env-file 과 같게 읽는다(따옴표로 감싸지 않은 값은 # 부터 주석)
parse_env_value() {
local value
value=$(trim "$1")
case $value in
\"*\") value=${value#\"}; value=${value%\"} ;;
\'*\') value=${value#\'}; value=${value%\'} ;;
*) value=$(trim "${value%%#*}") ;;
esac
printf '%s' "$value"
}
# env_get <파일> <KEY> — 마지막 정의의 값(없으면 빈 글, 종료 코드 1). KEY 는 이 스크립트들이 정한 이름뿐이다(영문 대문자 · 숫자 · _)
env_get() {
local line
line=$(grep -E "^[[:space:]]*(export[[:space:]]+)?$2[[:space:]]*=" "$1" 2>/dev/null | tail -n 1) || true
[ -n "$line" ] || return 1
parse_env_value "${line#*=}"
}
# /etc/arasystem/ops.env 를 읽어 KEY 마다 환경 변수로 — 이미 셸에 있는 값은 덮어쓰지 않는다(시험에서 하나만 바꿔 돌릴 수 있게)
# load_config 저장소의 .env.production 이 있어야 한다 (백업 · 점검 · 볼륨 · DB 복원)
# load_config optional 없어도 된다 — 새 서버 복원의 첫 단계(restore.sh files · snapshots · check)는 바로 그 파일을 백업에서 꺼낸다.
# 그때 PROJECT_NAME(백업 스냅숏의 host 이름 · 볼륨 이름)은 ops.env 의 값이다
# PROJECT_NAME 이 ops.env 와 .env.production 에 둘 다 있고 다르면 멈춘다 — 다른 이름의 볼륨을 받거나 되살리지 않게
load_config() {
local app_env=${1:-required}
[ -r "$OPS_CONFIG" ] || die "설정 파일 $OPS_CONFIG 를 읽을 수 없습니다 — ops/ops.env.example 을 복사해 채운다 (mail/README.md \"백업\")"
local line key
while IFS= read -r line || [ -n "$line" ]; do
case $line in '' | '#'*) continue ;; esac
line=$(trim "$line")
[[ $line =~ ^(export[[:space:]]+)?([A-Z_][A-Z0-9_]*)[[:space:]]*= ]] || continue
key=${BASH_REMATCH[2]}
[ -n "${!key+x}" ] && continue
printf -v "$key" '%s' "$(parse_env_value "${line#*=}")"
export "${key?}"
done < "$OPS_CONFIG"
[ -n "${ARASYSTEM_DIR:-}" ] || die "$OPS_CONFIG 에 ARASYSTEM_DIR(저장소 폴더)이 없습니다"
APP_ENV_FILE=${APP_ENV_FILE:-$ARASYSTEM_DIR/.env.production}
local from_file=""
if [ -r "$APP_ENV_FILE" ]; then
from_file=$(env_get "$APP_ENV_FILE" PROJECT_NAME || true)
elif [ "$app_env" = optional ]; then
APP_ENV_FILE=""
else
die "$APP_ENV_FILE 을 읽을 수 없습니다 (ARASYSTEM_DIR 확인 — 새 서버 복원이면 restore.sh files 로 먼저 꺼낸다, mail/README.md \"복원\")"
fi
if [ -n "${PROJECT_NAME:-}" ] && [ -n "$from_file" ] && [ "$PROJECT_NAME" != "$from_file" ]; then
die "PROJECT_NAME 이 $OPS_CONFIG(${PROJECT_NAME})와 $APP_ENV_FILE(${from_file})에서 다릅니다 — 같게 맞춘다(볼륨 · 컨테이너 · 백업 스냅숏의 이름이 이 값이다)"
fi
PROJECT_NAME=${PROJECT_NAME:-$from_file}
[[ ${PROJECT_NAME:-} =~ ^[a-z0-9][a-z0-9_-]*$ ]] ||
die "PROJECT_NAME 을 알 수 없습니다 — $OPS_CONFIG 에 적거나(새 서버 복원 — 옛 서버의 값) 저장소에 .env.production 을 둔다"
export PROJECT_NAME APP_ENV_FILE
}
# 작업 폴더(상태 파일 · 잠금 · 표시 파일 · DB 덤프) — systemd 의 StateDirectory(/var/lib/arasystem-ops, root 만)
ops_work_dir() {
local dir=${BACKUP_WORK_DIR:-$(env_get "$OPS_CONFIG" BACKUP_WORK_DIR || true)}
printf '%s' "${dir:-/var/lib/arasystem-ops}"
}
# 백업과 복원 연습이 함께 돌지 않게 — 작업 폴더의 같은 잠금 파일. 잡지 못하면 1 (flock 이 없는 곳 — 시험용 Git Bash — 은 잠그지 않는다)
take_backup_lock() {
command -v flock >/dev/null 2>&1 || return 0
exec 9> "$1/backup.lock"
flock -n 9
}
# 예시 값(change-me)이 남은 설정을 거절한다 — 이름만 알린다(값은 출력하지 않는다)
require_set() {
local key
for key in "$@"; do
[ -n "${!key:-}" ] || die "$OPS_CONFIG 에 $key 가 비어 있습니다"
case ${!key} in *[Cc][Hh][Aa][Nn][Gg][Ee]-[Mm][Ee]* | *[Cc][Hh][Aa][Nn][Gg][Ee][Mm][Ee]*) die "$OPS_CONFIG 의 $key 가 예시 값 그대로입니다" ;; esac
done
}
# curl 설정 글(-K)의 따옴표 값 — \ · " 는 이스케이프, 줄바꿈 · 탭은 \n · \t (curl 이 따옴표 안에서 푼다)
curl_config_quote() {
local value=$1
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value=${value//$'\r'/}
value=${value//$'\n'/\\n}
value=${value//$'\t'/\\t}
printf '%s' "$value"
}
# hc_ping <주소> [/fail|/start] [본문] — Healthchecks.io 에 알린다. 주소가 비면 아무것도 하지 않는다. 실패해도 스크립트를 멈추지 않는다.
# 주소 · 본문은 curl 의 설정 글로 표준 입력에 준다(-K -) — 명령줄(ps · /proc)에 ping 주소가 남지 않게 (주소를 아는 사람은 거짓 신호를 보낼 수 있다)
hc_ping() {
local url=$1 suffix=${2:-} body=${3:-}
[ -n "$url" ] || return 0
if command -v curl >/dev/null 2>&1; then
# 응답은 셸이 버린다(-o /dev/null 은 쓰지 않는다 — 위 MSYS_NO_PATHCONV 때문에 Windows 의 curl 이 /dev/null 을 열지 못한다)
printf 'url = "%s"\ndata-raw = "%s"\n' "$(curl_config_quote "${url%/}${suffix}")" "$(curl_config_quote "${body:0:9000}")" \
| curl -fsS -m 15 --retry 4 --retry-delay 5 -K - >/dev/null 2>&1 \
|| log "[주의] Healthchecks 에 알리지 못했습니다 (${suffix:-성공})"
else
log "[주의] curl 이 없어 Healthchecks 에 알리지 못했습니다"
fi
}
# 컨테이너가 떠 있으면 0 · 멈춰 있으면 1 · 도커에 닿지 못하거나 컨테이너가 없으면 2
container_running() {
local running
running=$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null) || return 2
[ "$running" = true ]
}
# 컨테이너 상태 한 낱말 — healthy · unhealthy · starting · running(healthcheck 없음) · exited · … · missing
container_state() {
local state
state=$(docker inspect -f '{{if .State.Running}}{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}{{else}}{{.State.Status}}{{end}}' "$1" 2>/dev/null) || state=""
printf '%s' "${state:-missing}"
}
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env bash
# ==========================================
# arasystem 15분 점검 (13단계 — mail/README.md "감시") — systemd 타이머 arasystem-check.timer
#
# 서버 안에서만 볼 수 있는 것을 보고 Healthchecks.io 에 알린다 — 정상이면 요약 한 줄, 아니면 /fail + 까닭 한 줄.
# 바깥에서 보는 것(웹 · 메일 포트가 열리나)은 UptimeRobot 이 맡는다 — 서버가 통째로 죽으면 이 스크립트도 못 돈다(Healthchecks 가 늦은 신호로 알린다).
# 1. 디스크 — / 와 도커 데이터 폴더(또는 CHECK_DISKS)의 사용률
# 2. 컨테이너 — frontend · backend · db · mail · mail-resolver · clamav · clamav-updater 가 떠 있고 healthy
# (clamav-updater 의 unhealthy = 악성 검사 서명이 이틀 넘게 그대로 — compose 의 healthcheck)
# 3. 백엔드 /api/health — DB 까지 닿는지 (백엔드 컨테이너 안에서 — 바깥에서는 404 다)
# 4. 메일 대기열 — 쌓인 수 · 가장 오래 머문 시간 (관리 API x:QueuedMessage — 관리자 비밀번호는 실행할 때 .env.production 에서 읽어
# 백엔드 컨테이너의 node 로 http://mail:8080 에 묻는다(관리자 허용 IP = 앱 네트워크 안). 값은 docker exec 의 -e 이름으로만 넘기고 출력하지 않는다)
# 5. 인증서 남은 날 — mx1:993(이 서버의 127.0.0.1 로 — 공인 IP 로 되돌아오는 길이 막힌 곳이 있다) · 포털 · files:443, 믿을 수 있는 인증서인지도
# 6. 마지막 백업 성공에서 지난 시간 (arasystem-backup 의 상태 파일) · 백업 타이머가 켜져 있는지
# 7. Spamhaus 키(MAIL_SPAMHAUS_DQS_KEY 가 있을 때) — setup 과 같은 시험 항목이 메일 전용 리졸버로 나오는지(키가 만료되면 답이 멈춘다).
# 키는 표준 입력으로만 넘긴다(docker exec 의 명령줄 · 도커 이벤트에 남지 않게)
# 상주하지 않는다(메모리 0) — 15분마다 몇 초 돈다. 종료 코드 0 정상 · 1 문제 있음
# ==========================================
set -Euo pipefail
umask 077
OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)
# shellcheck source=../lib/common.sh
. "$OPS_ROOT/lib/common.sh"
load_config
WORK_DIR=$(ops_work_dir)
PROBLEMS=()
NOTES=()
problem() { PROBLEMS+=("$*"); }
note() { NOTES+=("$*"); }
P=$PROJECT_NAME
# 백업이 메일 엔진을 잠깐 멈춘 동안(몇 초 — arasystem-backup 의 표시 파일)은 메일 쪽 확인을 건너뛴다.
# 표시만 믿지 않는다 — 백업 서비스가 지금 돌고 있거나(systemd) 표시가 15분 안에 생긴 것일 때만 "백업 중"이다.
# 그 밖의 남은 표시는 문제다(끊긴 백업이 엔진을 멈춘 채 남겼을 수 있다 — 표시만 남아 점검이 메일 쪽을 끝없이 건너뛰지 않게)
MARKER="$WORK_DIR/mail-stopped-by-backup"
MARKER_FRESH_SECONDS=900
backup_service_running() {
command -v systemctl >/dev/null && [ -d /run/systemd/system ] || return 1
case $(systemctl show --property=ActiveState --value arasystem-backup.service 2>/dev/null) in
activating | active | deactivating | reloading) return 0 ;;
*) return 1 ;;
esac
}
BACKUP_HOLDS_MAIL=0
if [ -f "$MARKER" ]; then
marker_age=$(( $(date +%s) - $(stat -c %Y "$MARKER" 2>/dev/null || echo 0) ))
if backup_service_running || [ "$marker_age" -lt "$MARKER_FRESH_SECONDS" ]; then
BACKUP_HOLDS_MAIL=1
else
problem "백업 표시가 $((marker_age / 60))분째 남아 있음(백업은 돌지 않는다 — 메일 엔진이 멈춘 채일 수 있다: sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running)"
fi
fi
# 1. 디스크
check_disks() {
local paths=${CHECK_DISKS:-} path used seen=" "
[ -n "$paths" ] || paths="/ $(docker info -f '{{.DockerRootDir}}' 2>/dev/null || true)"
for path in $paths; do
[ -e "$path" ] || { problem "디스크 $path 없음"; continue; }
local fs
# --output 칸을 쓴다 — 마운트 경로에 빈칸이 있어도 칸이 밀리지 않게 (GNU df)
fs=$(df --output=source "$path" | tail -n 1)
case $seen in *" $fs "*) continue ;; esac
seen+="$fs "
used=$(df --output=pcent "$path" | tail -n 1 | tr -dc '0-9')
if [ "${used:-0}" -ge "${CHECK_DISK_MAX_PERCENT:-85}" ]; then problem "디스크 $path ${used}%"; else note "디스크 $path ${used}%"; fi
done
}
# 2. 컨테이너
check_containers() {
local service state healthy=0
for service in ${CHECK_CONTAINERS:-frontend backend db mail mail-resolver clamav clamav-updater}; do
state=$(container_state "$P-$service")
if [ "$service" = mail ] && [ "$BACKUP_HOLDS_MAIL" = 1 ]; then note "mail 은 백업 중(잠깐 멈춤)"; continue; fi
case $state in
healthy | running) healthy=$((healthy + 1)) ;;
unhealthy)
if [ "$service" = clamav-updater ]; then problem "컨테이너 $service unhealthy(악성 검사 서명이 이틀 넘게 그대로)"; else problem "컨테이너 $service unhealthy"; fi
;;
*) problem "컨테이너 $service $state" ;;
esac
done
note "컨테이너 ${healthy}개 정상"
}
# 3. 백엔드 /api/health — 무엇이 죽었는지(error 의 키)만 옮긴다 (본문의 오류 글은 싣지 않는다)
check_backend_health() {
local out
# shellcheck disable=SC2016 # process.env.PORT 는 컨테이너 안에서 푼다
out=$(docker exec "$P-backend" node -e 'fetch("http://127.0.0.1:" + process.env.PORT + "/api/health", { signal: AbortSignal.timeout(10000) }).then(async (r) => { const j = await r.json().catch(() => ({})); console.log(r.status + " " + (j.status ?? "-") + " " + Object.keys(j.error ?? {}).join(",")) }).catch((e) => console.log("000 " + e.name))' 2>/dev/null || echo "000 exec")
case $out in
"200 ok"*) note "백엔드 health 정상" ;;
*) problem "백엔드 /api/health ${out}" ;;
esac
}
# 4. 메일 대기열 — "수 가장오래(시간)" (관리자 비밀번호는 -e 이름으로만)
check_queue() {
local pw out total oldest
pw=$(env_get "$APP_ENV_FILE" MAIL_ADMIN_PASSWORD || true)
[ -n "$pw" ] || { problem "대기열 — .env.production 의 MAIL_ADMIN_PASSWORD 를 읽지 못함"; return; }
out=$(QUEUE_ADMIN_PASSWORD=$pw docker exec -i -e QUEUE_ADMIN_PASSWORD "$P-backend" node - 2>/dev/null <<'EOF' || echo "error exec"
const base = process.env.MAIL_JMAP_URL || 'http://mail:8080'
const user = `${(process.env.MAIL_ADMIN_LOCAL_PART || 'admin').trim().toLowerCase()}@${(process.env.MAIL_DOMAIN || '').trim().toLowerCase()}`
const auth = 'Basic ' + Buffer.from(`${user}:${process.env.QUEUE_ADMIN_PASSWORD}`).toString('base64')
const using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap']
;(async () => {
const s = await fetch(`${base}/jmap/session`, { headers: { authorization: auth }, signal: AbortSignal.timeout(10000) })
if (s.status !== 200) return console.log(`error session-${s.status}`)
const accountId = (await s.json()).primaryAccounts['urn:stalwart:jmap']
const call = async (method, args) => {
const r = await fetch(`${base}/jmap/`, { method: 'POST', headers: { authorization: auth, 'content-type': 'application/json' }, body: JSON.stringify({ using, methodCalls: [[method, { accountId, ...args }, 'c']] }), signal: AbortSignal.timeout(15000) })
const [[name, result]] = (await r.json()).methodResponses
if (name === 'error') throw new Error(result.type)
return result
}
const q = await call('x:QueuedMessage/query', { calculateTotal: true, limit: 200 })
let oldest = 0
if (q.ids.length > 0) {
const g = await call('x:QueuedMessage/get', { ids: q.ids, properties: ['createdAt'] })
oldest = Math.max(0, ...g.list.map((m) => (Date.now() - Date.parse(m.createdAt)) / 3600000))
}
console.log(`${q.total ?? q.ids.length} ${oldest.toFixed(1)}`)
})().catch((e) => console.log(`error ${e.message}`))
EOF
)
read -r total oldest <<< "$out"
if ! [[ ${total:-} =~ ^[0-9]+$ ]]; then problem "대기열 — 읽지 못함(${oldest:-?})"; return; fi
if [ "$total" -gt "${CHECK_QUEUE_MAX:-50}" ]; then problem "메일 대기열 ${total}통"; fi
if awk -v a="$oldest" -v m="${CHECK_QUEUE_MAX_AGE_HOURS:-24}" 'BEGIN { exit !(a > m) }'; then problem "메일 대기열에 ${oldest}시간 머문 메일"; fi
note "대기열 ${total}통"
}
host_of() { # 출처(https://호스트[:포트]) → 호스트
local value=${1#*://}
value=${value%%/*}
printf '%s' "${value%%:*}"
}
# 5. 인증서 — 대상 꼴: 이름:포트 또는 이름:포트@붙을주소
check_certs() {
local targets=${CHECK_CERTS:-} target name port addr out end days verify
if [ -z "$targets" ]; then
local mail cors share
mail=$(env_get "$APP_ENV_FILE" MAIL_HOSTNAME || true)
cors=$(env_get "$APP_ENV_FILE" CORS_ORIGIN || true)
share=$(env_get "$APP_ENV_FILE" SHARE_PUBLIC_URL || true)
[ -n "$mail" ] && targets+=" $mail:993@127.0.0.1"
[ -n "$cors" ] && targets+=" $(host_of "${cors%%,*}"):443"
[ -n "$share" ] && targets+=" $(host_of "$share"):443"
fi
command -v openssl >/dev/null || { problem "인증서 — openssl 이 없음"; return; }
for target in $targets; do
name=${target%%:*}
port=${target#*:}
addr=$name
case $port in *@*) addr=${port#*@}; port=${port%@*} ;; esac
if [ "$addr" = 127.0.0.1 ] && [ "$BACKUP_HOLDS_MAIL" = 1 ]; then continue; fi
out=$(timeout 20 openssl s_client -connect "$addr:$port" -servername "$name" -verify_hostname "$name" </dev/null 2>/dev/null || true)
end=$(openssl x509 -noout -enddate <<< "$out" 2>/dev/null | cut -d= -f2)
verify=$(sed -n 's/^ *Verify return code: \([0-9]*\).*/\1/p' <<< "$out" | tail -n 1)
if [ -z "$end" ]; then problem "인증서 $name:$port — 연결 · 읽기 실패"; continue; fi
days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
if [ "${verify:-1}" != 0 ]; then
problem "인증서 $name — 믿을 수 없음(검증 코드 ${verify:-?} · 자체 서명이면 ACME 실패)"
elif [ "$days" -lt "${CHECK_CERT_MIN_DAYS:-14}" ]; then
problem "인증서 $name ${days}일 남음"
else
note "인증서 ${name%%.*} ${days}일"
fi
done
}
# 6. 백업 — 마지막 성공에서 지난 시간 · 타이머
check_backup() {
local last hours
last=$(sed -n 's/^last_success_epoch=//p' "$WORK_DIR/status" 2>/dev/null || true)
if [ -z "$last" ]; then
problem "백업 — 성공한 기록 없음($WORK_DIR/status)"
else
hours=$(( ( $(date +%s) - last ) / 3600 ))
if [ "$hours" -ge "${CHECK_BACKUP_MAX_AGE_HOURS:-26}" ]; then problem "백업 — 마지막 성공 ${hours}시간 전"; else note "백업 ${hours}시간 전"; fi
fi
if command -v systemctl >/dev/null && [ -d /run/systemd/system ]; then
systemctl is-active --quiet arasystem-backup.timer || problem "백업 타이머(arasystem-backup.timer)가 꺼져 있음"
fi
}
# 7. Spamhaus 키 — 메일 컨테이너의 perl 로 리졸버에 A 를 묻는다(이름은 표준 입력으로). 줄마다 "이름의 시험 결과 주소들"
SPAMHAUS_PROBE=$(cat <<'EOF'
use strict; use IO::Socket::INET; use IO::Select;
my @in = <STDIN>; chomp @in; my $server = shift @in;
for my $name (@in) {
my $id = int(rand(65536)); my $q = pack('n6', $id, 0x0100, 1, 0, 0, 0);
$q .= pack('C', length $_) . $_ for grep { length } split /\./, $name;
$q .= pack('Cnn', 0, 1, 1);
my $s = IO::Socket::INET->new(PeerAddr => $server, PeerPort => 53, Proto => 'udp', Timeout => 10) or do { print "error\n"; next };
send($s, $q, 0); my $r = '';
unless (IO::Select->new($s)->can_read(10) && defined recv($s, $r, 4096, 0) && length($r) >= 12) { print "timeout\n"; next }
my ($rid, $flags, $qd, $an) = unpack('n4', $r); my $o = 12;
my $skip = sub { while ($o < length $r) { my $l = unpack('C', substr($r, $o, 1)); if (($l & 0xC0) == 0xC0) { $o += 2; return } $o += 1 + $l; return if $l == 0 } };
for (1 .. $qd) { $skip->(); $o += 4 }
my @a; for (1 .. $an) { $skip->(); my ($t, $c, $ttl, $len) = unpack('nnNn', substr($r, $o, 10)); $o += 10; push @a, join('.', unpack('C4', substr($r, $o, 4))) if $t == 1 && $len == 4; $o += $len }
print 'rcode' . ($flags & 0x0F) . ' ' . join(',', @a) . "\n";
}
EOF
)
check_spamhaus() {
local key resolver out zen dbl
key=$(env_get "$APP_ENV_FILE" MAIL_SPAMHAUS_DQS_KEY || true)
[ -n "$key" ] || { note "Spamhaus 키 없음(꺼 둠)"; return; }
resolver=$(env_get "$APP_ENV_FILE" MAIL_RESOLVER_IP || true)
key=$(tr '[:upper:]' '[:lower:]' <<< "$key")
# 프로그램은 명령줄로(비밀 없음), 키가 든 조회 이름은 표준 입력으로
out=$(printf '%s\n' "${resolver:-172.16.73.254}" "2.0.0.127.$key.zen.dq.spamhaus.net" "dbltest.com.$key.dbl.dq.spamhaus.net" \
| docker exec -i "$P-mail" perl -e "$SPAMHAUS_PROBE" 2>/dev/null || echo error)
zen=$(sed -n 1p <<< "$out")
dbl=$(sed -n 2p <<< "$out")
case "$zen|$dbl" in
*127.0.0.2*"|"*127.0.1.2*) note "Spamhaus 키 정상" ;;
*) problem "Spamhaus 키 — 시험 항목이 안 나옴(ZEN ${zen%% *} · DBL ${dbl%% *} — 키 만료면 setup 이 끄고 종료 1, mail/README.md \"DNS 리졸버와 스팸 차단 목록\")" ;;
esac
}
check_disks
check_containers
check_backend_health
if [ "$BACKUP_HOLDS_MAIL" = 0 ]; then check_queue; fi
check_certs
check_backup
if [ "$BACKUP_HOLDS_MAIL" = 0 ]; then check_spamhaus; fi
if [ "${#PROBLEMS[@]}" -gt 0 ]; then
message="문제 ${#PROBLEMS[@]} — $(printf '%s · ' "${PROBLEMS[@]}")"
message=${message% · }
log "[문제] $message"
hc_ping "${HC_CHECK_URL:-}" /fail "$message"
exit 1
fi
message="정상 — $(printf '%s · ' "${NOTES[@]}")"
message=${message% · }
log "[정상] $message"
hc_ping "${HC_CHECK_URL:-}" "" "$message"
+65
View File
@@ -0,0 +1,65 @@
# ==========================================
# 운영 서버의 백업 · 감시 설정 (13단계 — mail/README.md "백업" · "감시")
#
# 이 파일은 예시다. 서버에서는 저장소 밖에 둔다 — root 만 읽게:
# sudo install -d -m 0700 -o root -g root /etc/arasystem
# sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env # 그다음 값을 채운다 (sudoedit /etc/arasystem/ops.env)
# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로:
# sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password'
# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다.
#
# 형식: KEY=값 한 줄씩. 스크립트는 이 파일을 셸로 실행(source)하지 않고 줄마다 읽는다 — 값에 공백이 있으면 작은따옴표로 감싼다.
# ==========================================
# 저장소 폴더 (git clone 한 곳) — 스크립트가 여기의 .env.production · backend/.env.production 을 읽는다
# (PROJECT_NAME · 메일 도메인 · 관리자 비밀번호(대기열 확인) · Spamhaus 키 — 따로 복사해 두지 않는다)
ARASYSTEM_DIR=/home/ubuntu/arasystem
# 프로젝트 이름 — 볼륨 · 컨테이너 이름(<PROJECT_NAME>_mail-data · <PROJECT_NAME>-mail)과 백업 스냅숏의 host 이름이 이 값이다.
# 비우면 위 저장소의 .env.production 에서 읽는다. ⚠️ 새 서버를 되살릴 때는 적는다(옛 서버의 값 — 예: arasystem) — 그 단계에는 .env.production 이
# 아직 없다(restore.sh files 가 백업에서 꺼낸다). 둘 다 있고 다르면 스크립트가 멈춘다
PROJECT_NAME=
# 백업 스냅숏의 host 이름 — 비우면 PROJECT_NAME. 다른 이름으로 받던 백업을 이어 쓸 때만 적는다
BACKUP_HOST=
# ------------------------------------------
# 백업 (ops/backup/arasystem-backup.sh — systemd 타이머 매일 03:30 KST)
# ------------------------------------------
# restic 저장소 — NCP Object Storage(S3 호환). 버킷은 콘솔에서 먼저 만든다(공개 안 함). 뒤의 /arasystem 은 버킷 안의 접두어
RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/change-me-bucket/arasystem
# NCP 의 S3 옵션 — 지역 kr-standard(서명 V4 에 쓴다) · 경로 방식(https://<엔드포인트>/<버킷> — NCP 는 경로 · 가상 호스트 방식 둘 다 받는다)
# (근거: NCP API 가이드 Object Storage — https://api.ncloud-docs.com/docs/storage-objectstorage · …-listobjects)
RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'
# NCP 서브 계정의 API 인증키 — 이 버킷만 쓰게 좁힌 계정으로 (콘솔 › Sub Account)
AWS_ACCESS_KEY_ID=change-me
AWS_SECRET_ACCESS_KEY=change-me
# 보존 — 매일 7 · 매주 4 · 매달 6 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다)
BACKUP_KEEP_DAILY=7
BACKUP_KEEP_WEEKLY=4
BACKUP_KEEP_MONTHLY=6
# 메일 엔진을 멈추고 올리는 final 한 번의 시간 상한(숫자 + s · m · h) — 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다.
# 평소 final 은 몇 초다(바뀐 것만 올린다). 바깥 대역폭이 아주 느리면 늘린다
BACKUP_FINAL_TIMEOUT=15m
# 사전 확인에서 메일 엔진 · DB 가 healthy 가 되기를 기다리는 시간(초) — 부팅 직후 타이머가 놓친 백업을 바로 할 때 엔진이 아직 starting 이다
BACKUP_HEALTHY_WAIT=300
# ------------------------------------------
# 알림 — Healthchecks.io (무료). 체크 둘을 만들고 각자의 ping 주소를 넣는다 (mail/README.md "감시" — 체크 만드는 차례)
# 비우면 알리지 않는다(로그 · 상태 파일은 그대로). 주소를 아는 사람은 거짓 신호를 보낼 수 있다 — 이 파일 밖에 적어 두지 않는다
# ------------------------------------------
HC_BACKUP_URL=
HC_CHECK_URL=
# ------------------------------------------
# 15분 점검 (ops/monitor/arasystem-check.sh) — 비운 값은 기본값
# ------------------------------------------
# 디스크 — 볼 마운트 지점(공백으로) · 넘으면 알리는 사용률(%). 비우면 / 와 도커 데이터 폴더(docker info 의 DockerRootDir)
CHECK_DISKS=
CHECK_DISK_MAX_PERCENT=85
# 인증서 — host:port (공백으로). 비우면 .env.production 의 MAIL_HOSTNAME:993 · CORS_ORIGIN 의 호스트:443 · SHARE_PUBLIC_URL 의 호스트:443
CHECK_CERTS=
CHECK_CERT_MIN_DAYS=14
# 메일 대기열 — 쌓인 수 · 가장 오래 머문 시간(시간)
CHECK_QUEUE_MAX=50
CHECK_QUEUE_MAX_AGE_HOURS=24
# 마지막 백업 성공에서 지난 시간(시간)
CHECK_BACKUP_MAX_AGE_HOURS=26
+28
View File
@@ -0,0 +1,28 @@
# arasystem 백업 (13단계 — mail/README.md "백업") — arasystem-backup.timer 가 매일 03:30 KST 에 부른다
# 설치: mail/README.md "백업" › "설치 (서버 B — 한 번)" (스크립트는 /opt/arasystem-ops 에 root 소유로 둔다 — 저장소 폴더를 바로 부르지 않는다)
[Unit]
Description=arasystem 백업 — 메일 엔진 · DB · 업로드 · env 를 restic 저장소로
# docker 가 없으면 스크립트가 스스로 실패해 알린다 — Requires 로 묶으면 docker 를 다시 시작할 때 이 서비스도 함께 멈춘다(엔진을 멈춘 채로)
Wants=docker.service network-online.target
After=docker.service network-online.target
[Service]
Type=oneshot
ExecStart=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh
# 스크립트가 어떻게 끝나든(죽어도) 백업이 멈춘 메일 엔진이 남아 있으면 다시 띄운다
ExecStopPost=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running
# 상태 파일 · DB 덤프 자리 (/var/lib/arasystem-ops — root 만)
StateDirectory=arasystem-ops
StateDirectoryMode=0700
# 처음 백업은 메일 전체를 올려 오래 걸릴 수 있다 (그 뒤로는 바뀐 것만 — 분 단위). 메일 엔진이 멈추는 final 은 스크립트가 따로 묶는다(BACKUP_FINAL_TIMEOUT)
TimeoutStartSec=6h
# 멈출 때(stop · 시간 초과) 스크립트가 정리할 시간 — 사본 지우기 → 엔진 띄우기(멈춤 끝내기 60초) → /fail 알림(재시도 포함 최대 1분 반) → 준비 180초
TimeoutStopSec=8min
# 스크립트 · docker CLI 에만 걸린다 — restic 컨테이너는 dockerd 아래라 물려받지 않는다(스크립트가 docker run --cpus 1 --cpu-shares 512 --memory 1g 로 묶는다)
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
+13
View File
@@ -0,0 +1,13 @@
# 매일 03:30 KST — 엔진의 휴지통 · 스팸 정리(03:00)와 데이터 · 원문 정리(04:00 · 04:30 — setup 이 맞춘다) 사이
[Unit]
Description=arasystem 백업 — 매일 03:30 KST
[Timer]
OnCalendar=*-*-* 03:30:00 Asia/Seoul
# 서버가 꺼져 있어 놓쳤으면 켜진 뒤 바로 한 번 (메일 엔진이 몇 초 멈춘다). 그때 엔진은 아직 starting 이다 —
# 스크립트가 healthy 를 5분(BACKUP_HEALTHY_WAIT)까지 기다리고, 부팅 유닛(arasystem-mail-recover)이 먼저 돈다
Persistent=true
AccuracySec=1min
[Install]
WantedBy=timers.target
+16
View File
@@ -0,0 +1,16 @@
# arasystem 15분 점검 (13단계 — mail/README.md "감시") — arasystem-check.timer 가 부른다
[Unit]
Description=arasystem 점검 — 디스크 · 컨테이너 · 대기열 · 인증서 · 백업 · Spamhaus 키
# docker 가 없으면 점검이 그것을 문제로 알린다 — Requires 로 묶지 않는다
Wants=docker.service network-online.target
After=docker.service network-online.target
[Service]
Type=oneshot
ExecStart=/bin/bash /opt/arasystem-ops/monitor/arasystem-check.sh
TimeoutStartSec=5min
Nice=10
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
+10
View File
@@ -0,0 +1,10 @@
# 15분마다 — 매시 07 · 22 · 37 · 52분 (백업이 시작하는 03:30 과 겹치지 않게 비켜 둔다)
[Unit]
Description=arasystem 점검 — 15분마다
[Timer]
OnCalendar=*-*-* *:07/15:00
AccuracySec=30s
[Install]
WantedBy=timers.target
@@ -0,0 +1,28 @@
# arasystem — 부팅 때 백업이 멈춘 메일 엔진을 다시 띄운다 (13단계 — mail/README.md "백업")
# 백업이 메일 엔진을 멈춘 몇 초 사이에 서버가 꺼지면 엔진은 멈춘 채 남는다 — docker stop 으로 멈춘 컨테이너는 restart: unless-stopped 여도
# 도커가 다시 띄우지 않는다. 표시 파일(/var/lib/arasystem-ops/mail-stopped-by-backup)이 있을 때만 띄우고, 떠 있는 것을 본 뒤 표시를 지운다
[Unit]
Description=arasystem — 백업이 멈춘 메일 엔진 다시 띄우기 (부팅 때)
Wants=docker.service
After=docker.service
# 부팅 뒤 따라잡는 백업(타이머의 Persistent)보다 먼저 — 백업이 이 엔진을 멈춘 채 받지 않게
Before=arasystem-backup.service
# 도커가 늦게 뜨면 실패한다 — 30초마다 다시, 10분 동안 10번까지
StartLimitIntervalSec=10min
StartLimitBurst=10
[Service]
Type=oneshot
ExecStart=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running
Restart=on-failure
RestartSec=30
StateDirectory=arasystem-ops
StateDirectoryMode=0700
TimeoutStartSec=5min
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only
[Install]
WantedBy=multi-user.target