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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,29 @@ jobs:
- name: Test
working-directory: ./realtime
run: go test -race ./...

infra:
name: Infra (ops 스크립트)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# 운영 알림·헬스 감시는 셸이라 컴파일러도 타입체커도 없다. 그런데 이 스크립트들은
# **조용히 깨지는 종류**다 — 만드는 동안 실제로 두 번 깨졌고 둘 다 눈에 안 띄었다:
# · 임베디드 파이썬 따옴표가 깨져 판정이 빈 값 → 감시가 그냥 통과
# · `[ -n "$x" ] && notify` 단락으로 정상인데 exit 1 → cron 이 실패로 봄
# selftest 는 로컬 수신기로만 보낸다(팀 채널로 아무것도 나가지 않는다).
- name: Shell syntax
run: |
for f in infra/ops/*.sh infra/backup/backup.sh; do
bash -n "$f" || exit 1
done

- name: ShellCheck
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
shellcheck -S warning infra/ops/notify.sh infra/ops/health-check.sh \
infra/ops/selftest.sh infra/backup/backup.sh

- name: Self test
run: bash infra/ops/selftest.sh
17 changes: 12 additions & 5 deletions infra/ops/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
|---|---|
| `notify.sh` | 보내는 곳 한 군데. `ops_notify <ok\|warn\|fail> <제목> <본문>` |
| `health-check.sh` | 헬스 폴링 + **상태가 바뀔 때만** 알림 |
| `selftest.sh` | 위 둘의 자가 테스트. 로컬 수신기로만 보낸다 — **팀 채널로 아무것도 안 나간다** |

`infra/backup/backup.sh` 도 `notify.sh` 를 소스해서 실패를 알린다.

Expand Down Expand Up @@ -56,13 +57,19 @@ aggregate 에서 빠져 있어서(informational — "복구 수단이 없다"와
`OPS_ALERT_WEBHOOK_URL` → 없으면 `DISCORD_WEBHOOK_URL`(현재 PR 알림 봇과 같은 채널).
운영 알림을 따로 받고 싶으면 전용 웹훅을 `.env` 에 넣으면 그쪽으로 옮겨간다.

## 검증 방법 (팀 채널로 보내지 않고)
**cron 은 정상일 때 0 으로 끝나야 한다.** 감시 도구가 정상 상태에서 실패 코드를 내면
"이 cron 은 원래 그래" 가 되고, 진짜 실패도 같이 무시된다. 실제로 `[ -n "$previous" ] &&
ops_notify ...` 로 썼다가 첫 실행마다 exit 1 이 나던 것을 잡았다 — 단락 평가의 마지막
상태가 그대로 종료 코드가 된다.

## 검증

```bash
# 로컬 수신기를 띄우고 그쪽으로 보낸다
python3 -m http.server 8897 & # 실제로는 POST 를 받는 핸들러 필요
OPS_ALERT_WEBHOOK_URL=http://127.0.0.1:8897/hook ./health-check.sh
bash infra/ops/selftest.sh # 14개 케이스, python3 + curl 만 필요
```

CI 의 `Infra (ops 스크립트)` 잡이 `bash -n` · `shellcheck -S warning` · selftest 를 돌린다.
셸은 컴파일러도 타입체커도 없고, 이 스크립트들은 **조용히 깨지는 종류**라서 CI 가 대신 본다.

웹훅 **유효성**은 게시 없이 확인할 수 있다 — Discord 웹훅 URL 에 `GET` 하면 이름과
channel_id 를 돌려준다.
channel_id 를 돌려준다(`http=200`, `name=…`).
13 changes: 12 additions & 1 deletion infra/ops/health-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ fi

if [ "$current" = "UP" ]; then
# 첫 실행(previous 없음)에 "복구됨"을 보내지 않는다 — 처음부터 정상인 게 정상이다.
[ -n "$previous" ] && ops_notify ok "헬스 복구 (이전: $previous)" "$detail"
#
# `[ -n "$previous" ] && ops_notify ...` 로 쓰면 안 된다. previous 가 비었을 때
# && 가 단락되어 **스크립트가 1로 끝난다** — 정상인데 cron 이 실패로 보고,
# 매 첫 실행마다 메일/로그 소음이 난다. 감시 도구가 정상 상태에서 실패 코드를
# 내면 "이 cron 이 원래 그래" 가 되고, 진짜 실패도 같이 무시된다.
if [ -n "$previous" ]; then
ops_notify ok "헬스 복구 (이전: $previous)" "$detail"
fi
else
ops_notify fail "헬스 이상: $current" "$detail"
fi

# 알림 전송 실패가 이 스크립트의 종료 코드를 오염시키지 않게 명시적으로 끝낸다.
# (ops_notify 는 항상 0 을 돌려주지만, 마지막 명령의 상태에 의존하지 않는다.)
exit 0
95 changes: 95 additions & 0 deletions infra/ops/selftest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# infra/ops 자가 테스트. **팀 채널로 아무것도 보내지 않는다** — 로컬 수신기로만 보낸다.
#
# 왜 레포에 두는가 — 이 스크립트들을 만들면서 실제로 두 번 깨졌고 둘 다 조용한 실패였다:
# 1. 임베디드 파이썬 따옴표 이스케이프가 깨져 판정이 빈 값 → 감시가 그냥 통과
# 2. `[ -n "$previous" ] && ops_notify ...` 단락으로 정상인데 exit 1 → cron 이 실패로 봄
# 셸 스크립트는 "돌려보면 되겠지" 로 넘어가기 쉬운데, 이 둘은 돌려봐도 눈에 안 띈다.
#
# 사용: bash infra/ops/selftest.sh (python3, curl 필요)
set -u
set +m # 리스너를 죽일 때 "Terminated" 잡 제어 메시지가 결과에 섞이지 않게
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

PORT="${SELFTEST_PORT:-8896}"
RECV=$(mktemp); HEALTH=$(mktemp); STATE=$(mktemp -u)
pass=0; fail=0

check() { # check <설명> <기대> <실제>
if [ "$2" = "$3" ]; then printf ' ok %s\n' "$1"; pass=$((pass+1))
else printf ' FAIL %s (기대=%s 실제=%s)\n' "$1" "$2" "$3"; fail=$((fail+1)); fi
}

RECV="$RECV" HEALTH="$HEALTH" PORT="$PORT" python3 - <<'PY' &
import http.server, socketserver, os
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
body = open(os.environ["HEALTH"], "rb").read()
self.send_response(200); self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
with open(os.environ["RECV"], "a") as f:
f.write(self.rfile.read(n).decode() + "\n")
self.send_response(204); self.end_headers()
def log_message(self, *a): pass
socketserver.TCPServer.allow_reuse_address = True
socketserver.TCPServer(("127.0.0.1", int(os.environ["PORT"])), H).serve_forever()
PY
LISTENER=$!
disown $LISTENER 2>/dev/null || true # 종료 시 셸이 "Terminated" 를 찍지 않게
trap 'kill $LISTENER 2>/dev/null; rm -f "$RECV" "$HEALTH" "$STATE"' EXIT
sleep 1

export OPS_ALERT_WEBHOOK_URL="http://127.0.0.1:$PORT/hook"
export OPS_ENV_FILE=/dev/null
export HEALTH_URL="http://127.0.0.1:$PORT/health"
export OPS_STATE_FILE="$STATE"

sent() { local n=0; [ -s "$RECV" ] && n=$(grep -c . "$RECV"); printf '%s' "$n"; }
UP='{"status":"UP","components":{"database":{"status":"UP"},"backup":{"status":"UP"}}}'
DEGRADED='{"status":"UP","components":{"database":{"status":"UP"},"backup":{"status":"DOWN"}}}'

echo "[notify.sh]"
. ./notify.sh
ops_notify fail '따옴표 "큰" 작은'"'"' 백슬래시 \x' '두 줄
째'
check 'JSON 이 깨지지 않는다' 1 "$(python3 -c "
import json,sys
try:
json.loads(open('$RECV').readline()); print(1)
except Exception: print(0)")"
: > "$RECV"
( unset OPS_ALERT_WEBHOOK_URL DISCORD_WEBHOOK_URL; . ./notify.sh; ops_notify fail x y )
check '웹훅 없으면 무발송' 0 "$(sent)"
( export OPS_ALERT_WEBHOOK_URL="http://127.0.0.1:9/dead"; . ./notify.sh; set -e
ops_notify fail x y ) >/dev/null 2>&1
check '죽은 엔드포인트가 호출자를 죽이지 않는다' 0 $?

echo "[health-check.sh]"
: > "$RECV"; rm -f "$STATE"
echo "$UP" > "$HEALTH"
./health-check.sh; check '첫 실행 정상: exit 0' 0 $?
check '첫 실행 정상: 무발송' 0 "$(sent)"
./health-check.sh; check '정상 반복: exit 0' 0 $?
check '정상 반복: 무발송' 0 "$(sent)"

echo "$DEGRADED" > "$HEALTH"
./health-check.sh; check '집계 UP·backup DOWN 포착: exit 0' 0 $?
check '집계 UP·backup DOWN 포착: 1건' 1 "$(sent)"
./health-check.sh
check '같은 이상 반복: 발송 늘지 않음' 1 "$(sent)"

echo "$UP" > "$HEALTH"
./health-check.sh; check '복구: exit 0' 0 $?
check '복구: 2건' 2 "$(sent)"

# 리스너를 죽이는 대신 죽은 포트를 가리킨다 — kill 하면 셸이 "Terminated" 를
# 결과 사이에 끼워 넣어 읽기 나빠진다(리스너는 EXIT trap 이 정리한다).
HEALTH_URL="http://127.0.0.1:9/health" ./health-check.sh
check '도달 불가: exit 0' 0 $?
check '도달 불가: UNREACHABLE 로 기록' UNREACHABLE "$(cat "$STATE")"

echo
echo "통과 $pass, 실패 $fail"
[ "$fail" -eq 0 ]
Loading