Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/GSMSV_RUNTIME_ALERTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# GSMSV 런타임 장애 알림 운영 가이드

이 도구는 배포가 끝난 뒤 GSMSV VM에서 MUDDA 운영 컨테이너의 상태와 오류 신호를 1분 주기로 확인하고 Discord에 요약 알림을 보내기 위한 최소 감시 구성이다. 상태 확인과 알림만 수행하며 컨테이너 재시작, Compose 변경, volume 조작, 자동 복구는 수행하지 않는다.

## 감시 범위

- Compose project `mudda-prod`의 `app`, `postgres`, `redis` 컨테이너 존재·running·healthy 상태
- `http://127.0.0.1:8080/actuator/health/readiness` 응답
- 직전 검사 이후 app restart count 증가
- 최근 검사 구간의 app `ERROR` 로그
- 루트 디스크 사용률과 시스템 메모리 사용률

기본 임계치는 디스크 80% 경고/90% 위험, 메모리 85% 경고/90% 위험, readiness timeout 5초, 반복 알림 억제 10분이다. 설정은 스크립트의 환경변수로 조정할 수 있다. RAM 7.9GB·swap 없음 환경을 고려하지만 swap 생성이나 서버 자원 변경은 하지 않는다.

health failure, 컨테이너 종료, restart 증가를 주요 신호로 삼고 ERROR 로그는 보조 신호로만 사용한다. 동일 fingerprint는 기본 10분 동안 억제하며 장애가 정상으로 돌아오면 복구 알림을 한 번 보낸다. 최초 정상 실행에서는 정상 알림을 보내지 않는다.

## 설치 위치와 권한

저장소의 `ops/runtime/install.sh`를 서버에서 root로 실행하면 감시 파일을 `/usr/local/libexec/mudda-runtime-alert`, systemd unit을 `/etc/systemd/system`, 상태 파일을 `/var/lib/mudda/runtime-alerts`에 설치한다. Docker socket과 root:600 webhook 파일을 읽기 때문에 service는 root로 실행하지만, 읽기 전용 경로·NoNewPrivileges·PrivateTmp·보호 옵션으로 범위를 제한한다. Docker socket 접근 권한은 Docker daemon 전체 제어 권한에 준하므로 별도 운영 계정·호스트의 접근 통제를 유지해야 한다.

Webhook은 GitHub Secret이 아니라 서버 전용 파일로 보관한다.

```text
/etc/mudda/discord-alert.env
owner: root
mode: 600
DISCORD_RUNTIME_WEBHOOK_URL=실제 값
```

저장소의 `ops/runtime/discord-alert.env.example`에는 placeholder만 있다. 실제 URL을 문서·Issue·PR·로그에 붙여넣지 않는다. GitHub Secret은 서버 프로세스가 직접 읽을 수 없으며, 향후 CD가 값을 안전하게 전달할 확장 지점만 남겨 둔다. 배포 알림과 런타임 장애 알림은 서로 다른 webhook 사용을 권장한다.

## systemd 실행과 검증

```bash
sudo ops/runtime/install.sh
sudoedit /etc/mudda/discord-alert.env
sudo chmod 600 /etc/mudda/discord-alert.env
sudo systemctl enable --now mudda-runtime-alert.timer
systemctl status mudda-runtime-alert.timer
systemctl list-timers mudda-runtime-alert.timer
journalctl -u mudda-runtime-alert.service -n 50 --no-pager
```

수동 확인은 `sudo systemctl start mudda-runtime-alert.service`로 수행한다. 저장소 fixture 테스트는 `ops/runtime/tests/test.sh`이며 실제 Docker나 webhook을 호출하지 않는다. `MUDDA_RUNTIME_NO_SEND=true`와 fixture 디렉터리를 사용해 정상·unhealthy·readiness 실패·restart 증가·ERROR·임계치·중복 억제·복구·마스킹을 검증한다.

설정 해제는 `sudo ops/runtime/uninstall.sh`로 수행한다. 이 스크립트는 timer와 설치 파일만 제거하고 webhook·상태 파일은 보존한다. rollback은 unit을 제거하거나 이전 저장소 버전의 감시 파일을 재설치하는 범위이며 운영 컨테이너에는 명령을 보내지 않는다.

Discord는 전체 로그 저장소가 아니라 요약 알림 채널이다. 알림은 최근 일부 로그만 최대 약 2,500자로 정제해 포함하며, password·token·JWT·Authorization·Cookie·AWS key·이메일 등 알려진 패턴을 마스킹한다. 마스킹은 완전한 개인정보 탐지를 보장하지 않으므로 전체 로그는 `docker compose -f /opt/mudda/docker-compose.prod.yml logs` 또는 향후 Loki/Grafana에서 확인한다.

Webhook 미설정·Discord 장애는 감시 로그에 URL 없이 실패로 남고 운영 컨테이너 상태에는 영향을 주지 않는다. 실제 운영 Secret과 서버 통합 검증은 아직 수행하지 않았다.
2 changes: 2 additions & 0 deletions ops/runtime/discord-alert.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copy to /etc/mudda/discord-alert.env on the VM and replace the placeholder.
DISCORD_RUNTIME_WEBHOOK_URL=REPLACE_WITH_DISCORD_RUNTIME_WEBHOOK_URL
63 changes: 63 additions & 0 deletions ops/runtime/discord-runtime-notify.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
set -euo pipefail

status="${1:-}"
[[ "$status" == incident || "$status" == recovery ]] || { echo "invalid runtime notification status" >&2; exit 2; }
webhook_url="${DISCORD_RUNTIME_WEBHOOK_URL:-}"
[[ -n "$webhook_url" ]] || { echo "runtime Discord webhook is not configured" >&2; exit 1; }
[[ "$webhook_url" =~ ^https://discord(app)?\.com/api/webhooks/ ]] || { echo "runtime Discord webhook has an invalid host" >&2; exit 1; }
command -v jq >/dev/null || { echo "jq is required for runtime notifications" >&2; exit 1; }
command -v curl >/dev/null || { echo "curl is required for runtime notifications" >&2; exit 1; }

notify_dir="$(mktemp -d)"
payload_file="$notify_dir/payload.json"
config_file="$notify_dir/curl.conf"
error_file="$notify_dir/curl.err"
trap 'rm -rf "$notify_dir"' EXIT

logs="$(printf '%s' "${RUNTIME_LOG_EXCERPT:-}" | MAX_CHARS=2500 "$(dirname "$0")/redact-log.sh")"
summary="$(printf '%s' "${RUNTIME_SUMMARY:-unknown}" | MAX_CHARS=1000 "$(dirname "$0")/redact-log.sh")"
if [[ "$status" == recovery ]]; then
title="MUDDA 운영 장애 복구"
color=5763719
state_label="복구"
else
title="MUDDA 운영 장애 감지"
color=15548997
state_label="장애"
fi

jq -n \
--arg title "$title" \
--arg state "$state_label" \
--arg time "${RUNTIME_TIME:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" \
--arg server "${RUNTIME_SERVER:-GSMSV VM}" \
--arg environment "${RUNTIME_ENVIRONMENT:-production}" \
--arg service "${RUNTIME_SERVICE:-app, postgres, redis}" \
--arg container "${RUNTIME_CONTAINER_STATUS:-unknown}" \
--arg health "${RUNTIME_HEALTH_STATUS:-unknown}" \
--arg restarts "${RUNTIME_RESTART_COUNTS:-unknown}" \
--arg summary "$summary" \
--arg logs "$logs" \
--argjson color "$color" \
'{embeds: [{title: $title, color: $color, fields: [
{name: "상태", value: $state, inline: true},
{name: "발생 시각", value: $time, inline: true},
{name: "서버/환경", value: ($server + " / " + $environment), inline: false},
{name: "영향 서비스", value: $service, inline: false},
{name: "컨테이너 상태", value: $container, inline: false},
{name: "Health / Restart", value: ($health + " / " + $restarts), inline: false},
{name: "요약", value: $summary, inline: false},
{name: "최근 로그", value: (if $logs == "" then "(없음)" else ("```\\n" + $logs + "\\n```") end), inline: false}
]} ]}' > "$payload_file"

{
printf 'url = "'
printf '%s' "$webhook_url"
printf '"\nrequest = "POST"\nheader = "Content-Type: application/json"\ndata-binary = @%s\n' "$payload_file"
} > "$config_file"

if ! curl --silent --show-error --fail --max-time 15 --config "$config_file" >/dev/null 2>"$error_file"; then
echo "runtime Discord notification failed" >&2
exit 1
fi
25 changes: 25 additions & 0 deletions ops/runtime/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail

[[ "$(id -u)" -eq 0 ]] || { echo "run this installer as root" >&2; exit 1; }
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
install_dir=/usr/local/libexec/mudda-runtime-alert
state_dir=/var/lib/mudda/runtime-alerts
env_dir=/etc/mudda

[[ -f "$repo_dir/mudda-runtime-alert.sh" && -f "$repo_dir/discord-runtime-notify.sh" ]] || { echo "runtime alert sources are missing" >&2; exit 1; }
install -d -m 0750 "$install_dir" "$state_dir" "$env_dir"
install -m 0750 "$repo_dir/mudda-runtime-alert.sh" "$install_dir/mudda-runtime-alert.sh"
install -m 0750 "$repo_dir/discord-runtime-notify.sh" "$install_dir/discord-runtime-notify.sh"
install -m 0750 "$repo_dir/redact-log.sh" "$install_dir/redact-log.sh"
install -m 0644 "$repo_dir/mudda-runtime-alert.service" /etc/systemd/system/mudda-runtime-alert.service
install -m 0644 "$repo_dir/mudda-runtime-alert.timer" /etc/systemd/system/mudda-runtime-alert.timer

if [[ ! -e "$env_dir/discord-alert.env" ]]; then
install -m 0600 /dev/null "$env_dir/discord-alert.env"
echo "Created empty $env_dir/discord-alert.env; add DISCORD_RUNTIME_WEBHOOK_URL before enabling the timer."
fi
chmod 0600 "$env_dir/discord-alert.env"
systemctl daemon-reload
echo "Installed runtime alert files. Enable after configuring the root-owned webhook file:"
echo " systemctl enable --now mudda-runtime-alert.timer"
22 changes: 22 additions & 0 deletions ops/runtime/mudda-runtime-alert.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[Unit]
Description=MUDDA runtime health and error alert check
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/mudda-runtime-alert/mudda-runtime-alert.sh
User=root
Group=root
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadOnlyPaths=/opt/mudda /etc/mudda/discord-alert.env
ReadWritePaths=/var/lib/mudda/runtime-alerts
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
TimeoutStartSec=30
159 changes: 159 additions & 0 deletions ops/runtime/mudda-runtime-alert.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
compose_project="${MUDDA_COMPOSE_PROJECT:-mudda-prod}"
compose_file="${MUDDA_COMPOSE_FILE:-/opt/mudda/docker-compose.prod.yml}"
compose_env="${MUDDA_COMPOSE_ENV:-/opt/mudda/.env.production}"
state_dir="${MUDDA_RUNTIME_STATE_DIR:-/var/lib/mudda/runtime-alerts}"
webhook_env="${MUDDA_RUNTIME_WEBHOOK_ENV:-/etc/mudda/discord-alert.env}"
fixture_dir="${MUDDA_RUNTIME_FIXTURE_DIR:-}"
no_send="${MUDDA_RUNTIME_NO_SEND:-false}"
disk_warn="${MUDDA_DISK_WARN_PERCENT:-80}"
disk_critical="${MUDDA_DISK_CRITICAL_PERCENT:-90}"
memory_warn="${MUDDA_MEMORY_WARN_PERCENT:-85}"
memory_critical="${MUDDA_MEMORY_CRITICAL_PERCENT:-90}"
cooldown="${MUDDA_ALERT_COOLDOWN_SECONDS:-600}"
readiness_timeout="${MUDDA_READINESS_TIMEOUT_SECONDS:-5}"
now="${MUDDA_RUNTIME_NOW:-$(date +%s)}"

for value in "$now" "$cooldown" "$readiness_timeout" "$disk_warn" "$disk_critical" "$memory_warn" "$memory_critical"; do
[[ "$value" =~ ^[0-9]+$ ]] || { echo "runtime alert numeric configuration is invalid" >&2; exit 2; }
done
(( disk_critical >= disk_warn && memory_critical >= memory_warn )) || { echo "runtime alert thresholds are invalid" >&2; exit 2; }
mkdir -p "$state_dir"
exec 9>"$state_dir/.lock"
flock -n 9 || { echo "runtime alert check is already running"; exit 0; }

state_file="$state_dir/state.json"
state='{}'
[[ -f "$state_file" ]] && state="$(cat "$state_file")"
last_check="$(jq -r '.last_check_epoch // 0' <<< "$state")"
had_problem="$(jq -r '.had_problem // false' <<< "$state")"
old_fingerprint="$(jq -r '.active_fingerprint // empty' <<< "$state")"
last_alert="$(jq -r '.last_alert_epoch // 0' <<< "$state")"
log_window=60
if (( last_check > 0 && now > last_check )); then
log_window=$((now - last_check))
(( log_window <= 600 )) || log_window=600
fi

fixture_read() {
local name="$1" fallback="${2:-}"
if [[ -n "$fixture_dir" && -f "$fixture_dir/$name" ]]; then cat "$fixture_dir/$name"; else printf '%s' "$fallback"; fi
}

container_info() {
local service="$1" id
if [[ -n "$fixture_dir" ]]; then
fixture_read "$service.status" "missing|none|0"
return
fi
id="$(docker ps -a --filter "label=com.docker.compose.project=$compose_project" --filter "label=com.docker.compose.service=$service" --format '{{.ID}}' | head -n 1)"
[[ -n "$id" ]] || { printf 'missing|none|0'; return; }
docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}|{{.RestartCount}}' "$id"
}

readiness_ok() {
if [[ -n "$fixture_dir" ]]; then [[ "$(fixture_read readiness fail)" == ok ]]; return; fi
curl --fail --silent --max-time "$readiness_timeout" http://127.0.0.1:8080/actuator/health/readiness >/dev/null
}

metric() {
local name="$1"
if [[ -n "$fixture_dir" ]]; then fixture_read "$name" 0; return; fi
if [[ "$name" == disk_percent ]]; then df -P / | awk 'NR == 2 { gsub(/%/, "", $5); print $5 }'; else free | awk '/Mem:/ { printf "%d", ($2 - $7) * 100 / $2 }'; fi
}

logs=""
if [[ -n "$fixture_dir" ]]; then
logs="$(fixture_read app.logs "")"
else
app_id="$(docker ps -a --filter "label=com.docker.compose.project=$compose_project" --filter 'label=com.docker.compose.service=app' --format '{{.ID}}' | head -n 1)"
[[ -z "$app_id" ]] || logs="$(docker logs --since "${log_window}s" --tail 100 "$app_id" 2>&1 || true)"
fi
logs="$(printf '%s' "$logs" | MAX_CHARS=2500 "$script_dir/redact-log.sh")"

declare -a problems=()
declare -a service_rows=()
declare -a restart_rows=()
for service in app postgres redis; do
IFS='|' read -r status health restarts <<< "$(container_info "$service")"
service_rows+=("$service=$status/$health")
restart_rows+=("$service=$restarts")
[[ "$status" == running ]] || problems+=("$service container=$status")
[[ "$health" == healthy ]] || problems+=("$service health=$health")
previous_restart="$(jq -r --arg service "$service" '.restart_counts[$service] // 0' <<< "$state")"
[[ "$restarts" =~ ^[0-9]+$ && "$previous_restart" =~ ^[0-9]+$ && "$restarts" -le "$previous_restart" ]] || problems+=("$service restart_count=$restarts (previous=$previous_restart)")
done

if ! readiness_ok; then problems+=("app readiness=failed"); fi
disk="$(metric disk_percent)"
memory="$(metric memory_percent)"
if (( disk >= disk_critical )); then problems+=("disk critical=${disk}%"); elif (( disk >= disk_warn )); then problems+=("disk warning=${disk}%"); fi
if (( memory >= memory_critical )); then problems+=("memory critical=${memory}%"); elif (( memory >= memory_warn )); then problems+=("memory warning=${memory}%"); fi
if printf '%s' "$logs" | grep -Eqi '(^|[^[:alpha:]])ERROR([^[:alpha:]]|$)'; then problems+=("app ERROR log detected"); fi

problem_text="$(printf '%s\n' "${problems[@]:-}" | sed '/^$/d' | head -n 20)"
fingerprint=""
[[ -z "$problem_text" ]] || fingerprint="$(printf '%s' "$problem_text" | sha256sum | awk '{print $1}')"
service_text="$(IFS=', '; printf '%s' "${service_rows[*]}")"
restart_text="$(IFS=', '; printf '%s' "${restart_rows[*]}")"

write_state() {
local next="$1" temp
temp="$(mktemp "$state_dir/state.XXXXXX")"
chmod 600 "$temp"
printf '%s\n' "$next" > "$temp"
mv -f "$temp" "$state_file"
}

make_state() {
local active="$1" alerted="$2" had="$3"
jq -n --argjson now "$now" --arg fingerprint "$active" --argjson alerted "$alerted" --argjson had "$had" \
--argjson counts "$(printf '%s\n' "${restart_rows[@]}" | jq -Rn '[inputs | split("=") | {key: .[0], value: (.[1] | tonumber)}] | from_entries')" \
'{last_check_epoch: $now, active_fingerprint: (if $fingerprint == "" then null else $fingerprint end), last_alert_epoch: $alerted, had_problem: $had, restart_counts: $counts}'
}

notify() {
local status="$1"
[[ "$no_send" == true ]] && { echo "runtime notification skipped (test mode)"; return 0; }
local webhook
[[ -f "$webhook_env" ]] || { echo "runtime webhook env file is missing" >&2; return 1; }
webhook="$(sed -n 's/^DISCORD_RUNTIME_WEBHOOK_URL=//p' "$webhook_env" | head -n 1)"
webhook="${webhook#\"}"; webhook="${webhook%\"}"
[[ -n "$webhook" ]] || { echo "runtime webhook is not configured" >&2; return 1; }
DISCORD_RUNTIME_WEBHOOK_URL="$webhook" \
RUNTIME_SUMMARY="${problem_text:-normal}" \
RUNTIME_LOG_EXCERPT="$logs" \
RUNTIME_SERVICE="app, postgres, redis" \
RUNTIME_CONTAINER_STATUS="$service_text" \
RUNTIME_HEALTH_STATUS="readiness=$([[ -z "$fingerprint" ]] && echo healthy || echo failed); disk=${disk}%; memory=${memory}%" \
RUNTIME_RESTART_COUNTS="$restart_text" \
"$script_dir/discord-runtime-notify.sh" "$status"
}

if [[ -n "$fingerprint" ]]; then
alert_epoch="$last_alert"
should_alert=true
if [[ "$fingerprint" == "$old_fingerprint" && $((now - last_alert)) -lt cooldown ]]; then should_alert=false; fi
if [[ "$should_alert" == true ]]; then
if notify incident; then alert_epoch="$now"; else echo "runtime incident notification could not be sent" >&2; fi
write_state "$(make_state "$fingerprint" "$alert_epoch" true)"
echo "runtime status: incident"
else
write_state "$(make_state "$fingerprint" "$alert_epoch" true)"
echo "runtime status: incident suppressed"
fi
elif [[ "$had_problem" == true ]]; then
if notify recovery; then
write_state "$(make_state "" "$last_alert" false)"
echo "runtime status: recovery"
else
write_state "$(make_state "$old_fingerprint" "$last_alert" true)"
echo "runtime recovery notification could not be sent" >&2
fi
else
write_state "$(make_state "" "$last_alert" false)"
echo "runtime status: healthy"
fi
12 changes: 12 additions & 0 deletions ops/runtime/mudda-runtime-alert.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[Unit]
Description=Run MUDDA runtime alert check every minute

[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
AccuracySec=30s
Persistent=true
Unit=mudda-runtime-alert.service

[Install]
WantedBy=timers.target
15 changes: 15 additions & 0 deletions ops/runtime/redact-log.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail

max_chars="${MAX_CHARS:-2500}"
[[ "$max_chars" =~ ^[1-9][0-9]*$ ]] || max_chars=2500

sed -E \
-e 's#https://discord(app)?\.com/api/webhooks/[[:alnum:]_/-]+#[REDACTED_URL]#gI' \
-e 's/(Bearer[[:space:]]+)[^[:space:]]+/\1[REDACTED]/gI' \
-e 's/((password|passwd|secret|token|jwt|authorization|cookie|api[-_]?key|client[-_]?secret|access[-_]?key|secret[-_]?key)[[:space:]]*[:=][[:space:]]*)[^,[:space:]]+/\1[REDACTED]/gI' \
-e 's/AKIA[0-9A-Z]{16}/[REDACTED_AWS_KEY]/g' \
-e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g' \
-e 's/[[:alnum:]._%+-]+@[[:alnum:].-]+\.[A-Za-z]{2,}/[REDACTED_EMAIL]/g' \
-e 's/::/ : :/g' |
awk -v limit="$max_chars" 'BEGIN { ORS="" } { remaining=limit-length(out); if (remaining > 0) out=out substr($0, 1, remaining) "\n" } END { printf "%s", substr(out, 1, limit) }'
Loading