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
+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"