diff --git a/infra/backup/README.md b/infra/backup/README.md index 8ee6ef6b..a6c80dc0 100644 --- a/infra/backup/README.md +++ b/infra/backup/README.md @@ -28,8 +28,14 @@ RabbitMQ 볼륨은 받지 않는다 — 큐는 재선언되고 메시지는 휘 | 실패 모드 | 감지 | 비고 | |---|---|---| -| **돌았는데 실패** (pg_dump 오류, 디스크 부족 등) | 스크립트가 **Discord 알림** | `DISCORD_WEBHOOK_URL` 필요. 알림 실패는 삼켜서 백업 결과를 덮지 않는다 | +| **돌았는데 실패** (pg_dump 오류, 디스크 부족 등) | 스크립트가 **Discord 알림** (`EXIT` trap → `infra/ops/notify.sh`) | `OPS_ALERT_WEBHOOK_URL`, 없으면 `DISCORD_WEBHOOK_URL`. 알림 실패는 삼켜서 백업 결과를 덮지 않는다 | | **아예 안 돎** (cron 죽음, 서버 재부팅 등) | 백엔드 헬스체크 `backup` 컴포넌트 | 스크립트가 안 돌았으니 스스로는 못 알린다 — 밖에서 봐야 한다 | +| **헬스체크를 아무도 안 봄** | `infra/ops/health-check.sh` (cron 5분) | 위 컴포넌트를 읽어 상태가 바뀔 때 알린다. 없으면 위 행이 무의미하다 | + +> **2026-09-27 정정.** 이 표의 첫 행은 2026-09-26 부터 "Discord 알림"이라고 적혀 있었지만 +> **backup.sh 에 그런 코드가 없었다.** 문서만 보고 "실패하면 연락이 오겠지" 라고 믿는 것이 +> 가장 나쁜 상태다 — 조용히 멈춘 백업과 정상 백업이 구분되지 않는다. 지금은 실제로 있고, +> 성공/실패 양쪽 경로를 운영 서버에서 확인했다(성공 시 0건, 실패 시 정확히 1건). `GET /api/system/health` 의 `backup` 컴포넌트가 `LAST_SUCCESS` 나이를 본다 (`backup.max-age-hours`, 기본 36시간 = 일 1회 + 여유). diff --git a/infra/backup/backup.sh b/infra/backup/backup.sh index 50117be6..6d2c48a3 100755 --- a/infra/backup/backup.sh +++ b/infra/backup/backup.sh @@ -5,10 +5,36 @@ # 기록·피드백·이력서 파일이 도커 볼륨 단일 사본에만 존재했고, `docker compose down -v` # 한 번이면 전부 사라지는 상태였다(infra/CLAUDE.md 가 그 명령을 초기화 절차로 안내한다). # -# 한계: 같은 디스크에 저장하므로 **논리적 사고**(실수 삭제·잘못된 마이그레이션·손상)만 -# 막는다. 디스크·호스트 장애는 못 막는다 — 원격 복제는 후속 과제(docs/operations.md). +# 한계: **같은 호스트**에 저장하므로 논리적 사고(실수 삭제·잘못된 마이그레이션·손상)와 +# 데이터 디스크 고장까지는 막지만, 호스트 자체를 잃으면 같이 사라진다 — 원격 복제는 +# 후속 과제. (운영 확인: 데이터는 /dev/nvme0n1p3, 백업은 /dev/nvme1n1p1 로 **다른 물리 +# 디스크**다. 이전 주석은 "같은 디스크"라고 적혀 있었는데 사실이 아니었다.) set -Eeuo pipefail +# ── 실패 알림 ───────────────────────────────────────────────────────────────── +# README 는 2026-09-26 부터 "실패 시 Discord 알림"을 안내했지만 **그 코드가 없었다.** +# 문서만 보고 "실패하면 연락이 오겠지" 라고 믿는 것이 가장 나쁜 상태다 — 조용히 멈춘 +# 백업과 정상 백업이 구분되지 않는다. 여기서 실제로 보낸다. +# +# trap 으로 건다: fail() 뿐 아니라 set -e 로 죽는 예상 못 한 실패(디스크 가득, docker +# 데몬 중단 등)까지 잡아야 한다. 그런 것들이 정확히 "아무도 모르게" 실패하는 종류다. +# +# ERR 이 아니라 **EXIT 하나만** 쓴다. 둘 다 걸면 set -e 로 죽을 때 ERR→EXIT 순으로 +# 연달아 떠서 같은 실패를 두 번 알린다. +# 로그 경로는 BACKUP_DIR 정의 **이전에** trap 이 걸리므로 기본값을 여기서도 펼친다 +# (set -u 라 미정의 변수를 그냥 참조하면 알림 함수 자체가 죽는다). +# shellcheck source=/dev/null +. "$(cd "$(dirname "${BASH_SOURCE[0]}")/../ops" && pwd)/notify.sh" + +backup_failed() { + local rc=$? + [ "$rc" -eq 0 ] && return 0 + local logfile="${BACKUP_LOG:-${BACKUP_DIR:-$HOME/backups}/backup.log}" + ops_notify fail "백업 실패 (exit=$rc)" \ + "$(tail -n 20 "$logfile" 2>/dev/null || echo '(로그 없음)')" +} +trap backup_failed EXIT + BACKUP_DIR="${BACKUP_DIR:-$HOME/backups}" RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" PG_CONTAINER="${PG_CONTAINER:-stackup-postgres}" @@ -57,3 +83,7 @@ find "$BACKUP_DIR" -maxdepth 1 \( -name 'pg-*.dump' -o -name 'minio-*.tar.gz' \) date -Iseconds > "$BACKUP_DIR/LAST_SUCCESS" log "done. pg=$(du -h "$pg_file" | cut -f1) (tables=$pg_tables), minio=$(du -h "$minio_file" | cut -f1), 보관=$deleted개, 보존=${RETENTION_DAYS}일" + +# 성공으로 끝났으니 trap 을 해제한다. (rc=0 이면 backup_failed 가 그냥 빠져나오지만, +# 명시적으로 풀어 두는 편이 의도가 드러난다.) +trap - EXIT diff --git a/infra/ops/README.md b/infra/ops/README.md new file mode 100644 index 00000000..59ee2df2 --- /dev/null +++ b/infra/ops/README.md @@ -0,0 +1,68 @@ +# 운영 알림 (`infra/ops`) + +만들어 둔 신호를 **읽는 쪽**이다. + +## 왜 있나 + +2026-09-27 전체 점검에서 두 가지가 드러났다. + +1. **`infra/backup/README.md` 가 "실패 시 Discord 알림"을 안내했는데 그 코드가 없었다.** + 문서만 보고 "실패하면 연락이 오겠지" 라고 믿는 상태가 알림이 아예 없다고 아는 것보다 나쁘다. +2. **`/api/system/health` 를 아무도 보고 있지 않았다.** `database`·`rabbitmq`·`s3`·`aiServer`· + `backup`·`scheduler` 여섯 컴포넌트를 만들어 두고도 DOWN 이 떠도 알 방법이 없었다. + 특히 `backup`(백업 신선도)과 `scheduler`(스위퍼 생존)는 애초에 **"조용히 멈추는 것"** 을 + 잡으려고 만든 신호인데, 읽는 쪽이 없으면 만들지 않은 것과 같다. + +## 파일 + +| 파일 | 역할 | +|---|---| +| `notify.sh` | 보내는 곳 한 군데. `ops_notify <제목> <본문>` | +| `health-check.sh` | 헬스 폴링 + **상태가 바뀔 때만** 알림 | + +`infra/backup/backup.sh` 도 `notify.sh` 를 소스해서 실패를 알린다. + +## 설치 + +```cron +*/5 * * * * /home/stackup/stackup/infra/ops/health-check.sh >> /home/stackup/backups/health.log 2>&1 +``` + +## 설계 결정 + +**상태가 바뀔 때만 보낸다.** 5분마다 같은 말을 보내면 아무도 안 읽게 되고, 그러면 진짜 장애 +때도 안 읽는다. 마지막 상태를 `~/.stackup-health-state` 에 두고 비교한다. + +**첫 실행이 정상이면 아무것도 안 보낸다.** "처음부터 정상"은 알릴 일이 아니다. + +**집계(`status`)가 아니라 개별 컴포넌트까지 본다.** `backup`·`scheduler` 는 의도적으로 +aggregate 에서 빠져 있어서(informational — "복구 수단이 없다"와 "서비스가 죽었다"는 다르다), +집계만 보면 정확히 이 감시가 잡아야 할 것을 놓친다. + +**도달 불가도 장애로 센다.** `UNREACHABLE` 은 "UP 인데 못 읽는" 것보다 나쁘다. + +**해석 실패도 장애로 센다.** 임베디드 파이썬이 깨지면 판정이 빈 값이 되고 감시가 조용히 +무력해진다 — 실제로 개발 중에 따옴표 이스케이프 때문에 이 상태가 났고 테스트가 잡았다. +`UNPARSEABLE` 로 떨어뜨린다. '모르는 상태'와 '정상'을 섞으면 감시가 아니다. + +**알림 실패가 호출자를 죽이지 않는다.** 백업이 성공했는데 알림이 안 갔다고 백업을 실패로 +만들면 안 된다. 웹훅이 없으면 조용히 넘어간다(로컬 개발에서 스크립트가 죽으면 안 된다). + +**JSON 은 python 으로 만든다.** 셸 문자열 연결로 만들면 `pg_dump` 오류 한 줄에 따옴표나 +백슬래시가 들어오는 순간 페이로드가 깨진다 — 알림이 가장 필요한 순간에. + +## 채널 + +`OPS_ALERT_WEBHOOK_URL` → 없으면 `DISCORD_WEBHOOK_URL`(현재 PR 알림 봇과 같은 채널). +운영 알림을 따로 받고 싶으면 전용 웹훅을 `.env` 에 넣으면 그쪽으로 옮겨간다. + +## 검증 방법 (팀 채널로 보내지 않고) + +```bash +# 로컬 수신기를 띄우고 그쪽으로 보낸다 +python3 -m http.server 8897 & # 실제로는 POST 를 받는 핸들러 필요 +OPS_ALERT_WEBHOOK_URL=http://127.0.0.1:8897/hook ./health-check.sh +``` + +웹훅 **유효성**은 게시 없이 확인할 수 있다 — Discord 웹훅 URL 에 `GET` 하면 이름과 +channel_id 를 돌려준다. diff --git a/infra/ops/health-check.sh b/infra/ops/health-check.sh new file mode 100755 index 00000000..82051ac3 --- /dev/null +++ b/infra/ops/health-check.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# /api/system/health 를 주기적으로 보고 상태가 바뀔 때만 알린다. +# +# 왜 있나 — 2026-09-27 점검 결과 **헬스 엔드포인트를 아무도 보고 있지 않았다.** +# database·rabbitmq·s3·aiServer·backup·scheduler 여섯 컴포넌트를 잘 만들어 두고도 +# DOWN 이 떠도 알 방법이 없었다. 백업 신선도(backup)와 스케줄러 생존(scheduler)은 +# 애초에 "조용히 멈추는 것"을 잡으려고 만든 신호인데, 그 신호를 읽는 쪽이 없으면 +# 만들지 않은 것과 같다. +# +# 상태가 **바뀔 때만** 보낸다. 5분마다 같은 말을 보내면 아무도 안 읽게 되고, +# 그러면 진짜 장애 때도 안 읽는다. +set -uo pipefail + +HEALTH_URL="${HEALTH_URL:-https://stack-up.shop/api/system/health}" +STATE_FILE="${OPS_STATE_FILE:-$HOME/.stackup-health-state}" +TIMEOUT="${HEALTH_TIMEOUT:-20}" + +# shellcheck source=/dev/null +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/notify.sh" + +body=$(curl -sS -m "$TIMEOUT" "$HEALTH_URL" 2>/dev/null) +curl_rc=$? + +if [ $curl_rc -ne 0 ] || [ -z "$body" ]; then + # 도달 불가도 장애다. 오히려 "UP 인데 못 읽는" 경우보다 나쁘다. + current="UNREACHABLE" + detail="curl exit=$curl_rc, url=$HEALTH_URL" +else + read -r current detail < <(BODY="$body" python3 -c " +import json, os, sys +try: + d = json.loads(os.environ['BODY']) +except Exception: + print('UNPARSEABLE', os.environ['BODY'][:200].replace(chr(10), ' ')) + sys.exit() +comps = d.get('components', {}) or {} +bad = [k + '=' + str(v.get('status')) for k, v in comps.items() if v.get('status') != 'UP'] +# 집계 상태만 보면 informational 컴포넌트(backup·scheduler)의 DOWN 을 놓친다 — +# 그게 정확히 이 감시가 잡으려는 것이다. 개별 컴포넌트까지 본다. +overall = d.get('status', 'UNKNOWN') +state = overall if not bad else 'DEGRADED' +detail = '전체=' + overall + (' / 비정상: ' + ', '.join(bad) if bad else '') +print(state, detail) +") + # 파싱 자체가 죽으면(문법 오류 등) current 가 비어 감시가 조용히 무력해진다. + # 빈 값은 장애로 취급한다 — '모르는 상태'와 '정상'을 섞으면 감시가 아니다. + [ -n "${current:-}" ] || { current="UNPARSEABLE"; detail="헬스 응답 해석 실패"; } +fi + +previous=$(cat "$STATE_FILE" 2>/dev/null || echo "") +printf '%s' "$current" > "$STATE_FILE" + +if [ "$current" = "$previous" ]; then + exit 0 # 같은 상태 반복 — 조용히 넘어간다 +fi + +if [ "$current" = "UP" ]; then + # 첫 실행(previous 없음)에 "복구됨"을 보내지 않는다 — 처음부터 정상인 게 정상이다. + [ -n "$previous" ] && ops_notify ok "헬스 복구 (이전: $previous)" "$detail" +else + ops_notify fail "헬스 이상: $current" "$detail" +fi diff --git a/infra/ops/notify.sh b/infra/ops/notify.sh new file mode 100755 index 00000000..9f7bd71a --- /dev/null +++ b/infra/ops/notify.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# 운영 알림 한 곳. 백업 스크립트와 헬스 감시가 같이 쓴다. +# +# 왜 파일로 빼는가 — 2026-09-27 점검에서 `infra/backup/README.md` 가 "실패 시 Discord +# 알림"을 **있다고 적어 뒀는데 스크립트에는 그런 코드가 없었다.** 문서만 보고 "실패하면 +# 연락이 오겠지" 라고 믿으면 백업이 조용히 멈춰도 아무도 모른다. 보내는 쪽을 한 곳에 +# 두고 양쪽이 같은 코드를 쓰게 한다. +# +# 규칙 두 가지: +# · 웹훅이 없으면 조용히 넘어간다(로컬 개발에서 스크립트가 죽으면 안 된다). +# · 알림 실패가 호출자를 죽이지 않는다 — 백업이 성공했는데 알림이 안 갔다고 +# 백업을 실패로 만들면 안 된다. 반대도 마찬가지다. + +ops_webhook() { + # 전용 키가 있으면 그걸 쓰고, 없으면 기존 Discord 웹훅으로 떨어진다. + # (현재 .env 의 DISCORD_WEBHOOK_URL 은 PR 알림 봇과 같은 채널이다 — 운영 알림을 + # 따로 받고 싶으면 OPS_ALERT_WEBHOOK_URL 을 채운다.) + local w="${OPS_ALERT_WEBHOOK_URL:-${DISCORD_WEBHOOK_URL:-}}" + if [ -z "$w" ] && [ -f "${OPS_ENV_FILE:-$HOME/stackup/.env}" ]; then + w=$(grep -m1 '^OPS_ALERT_WEBHOOK_URL=' "${OPS_ENV_FILE:-$HOME/stackup/.env}" 2>/dev/null | cut -d= -f2-) + [ -z "$w" ] && w=$(grep -m1 '^DISCORD_WEBHOOK_URL=' "${OPS_ENV_FILE:-$HOME/stackup/.env}" 2>/dev/null | cut -d= -f2-) + fi + printf '%s' "$w" +} + +# ops_notify <body> +ops_notify() { + local level="$1" title="$2" body="$3" + local webhook; webhook="$(ops_webhook)" + [ -n "$webhook" ] || return 0 + + local mark + case "$level" in + ok) mark="[복구]" ;; + warn) mark="[경고]" ;; + *) mark="[실패]" ;; + esac + + # JSON 은 python 으로 만든다 — 메시지에 따옴표·줄바꿈·백슬래시가 들어와도 안전하다. + # 셸 문자열 연결로 만들면 pg_dump 오류 문구 한 줄에 페이로드가 깨진다. + local payload + payload=$(TITLE="$mark $title" BODY="$body" HOST="$(hostname)" python3 -c ' +import json, os +print(json.dumps({"content": "**%s**\n호스트: `%s`\n```\n%s\n```" % ( + os.environ["TITLE"], os.environ["HOST"], os.environ["BODY"][:1500])}))') || return 0 + + curl -sS -m 15 -X POST -H 'Content-Type: application/json' \ + -d "$payload" "$webhook" >/dev/null 2>&1 || true + return 0 +}