generated from ttipo/project-base
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:
@@ -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"
|
||||
Reference in New Issue
Block a user