Skip to content
Merged
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
8 changes: 7 additions & 1 deletion infra/backup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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회 + 여유).
Expand Down
34 changes: 32 additions & 2 deletions infra/backup/backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
68 changes: 68 additions & 0 deletions infra/ops/README.md
Original file line number Diff line number Diff line change
@@ -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 <ok\|warn\|fail> <제목> <본문>` |
| `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 를 돌려준다.
62 changes: 62 additions & 0 deletions infra/ops/health-check.sh
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions infra/ops/notify.sh
Original file line number Diff line number Diff line change
@@ -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 <level: ok|warn|fail> <title> <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
}
Loading