Repository navigation
feat(infra): 백업 실패 알림을 실제로 구현 + 헬스 감시 추가 - #260
Merged
Merged
Conversation
"지금 모두 제대로 구축되어 있나" 를 확인하다 두 가지가 드러났다.
## 1. README 가 없는 기능을 있다고 적어놨다
infra/backup/README.md 는 2026-09-26 부터 "돌았는데 실패 → 스크립트가 Discord
알림" 이라고 안내했다. 그런데 backup.sh 에는 그런 코드가 없다.
$ grep -c 'DISCORD\|webhook\|notify\|curl' infra/backup/backup.sh
0 (레포·서버 md5 동일, 둘 다 없음)
fail() 이 로그를 남기고 exit 1 하는 게 전부였다. 문서만 보고 "실패하면 연락이
오겠지" 라고 믿는 상태는 알림이 없다고 아는 것보다 나쁘다 — 조용히 멈춘 백업과
정상 백업이 구분되지 않는다.
EXIT trap 으로 건다. fail() 뿐 아니라 set -e 로 죽는 예상 못 한 실패(디스크
가득, docker 데몬 중단)까지 잡아야 하고, 그런 것들이 정확히 "아무도 모르게"
실패하는 종류다. ERR 을 같이 걸면 ERR→EXIT 로 두 번 나가서 EXIT 만 쓴다.
## 2. 헬스 엔드포인트를 아무도 보고 있지 않았다
여섯 컴포넌트(database·rabbitmq·s3·aiServer·backup·scheduler)를 만들어 두고도
DOWN 이 떠도 알 방법이 없었다. 특히 backup(신선도)과 scheduler(스위퍼 생존)는
애초에 "조용히 멈추는 것"을 잡으려고 만든 신호인데, 읽는 쪽이 없으면 만들지
않은 것과 같다.
infra/ops/health-check.sh 가 5분마다 폴링하고 **상태가 바뀔 때만** 알린다.
같은 말을 5분마다 보내면 아무도 안 읽게 되고, 그러면 진짜 장애 때도 안 읽는다.
집계(status)가 아니라 개별 컴포넌트까지 본다 — backup·scheduler 는 의도적으로
aggregate 에서 빠져 있어서(informational) 집계만 보면 정확히 이 감시가 잡아야
할 것을 놓친다.
## 검증 (팀 채널로 보내지 않고)
웹훅 유효성은 게시 없이 확인했다 — Discord 웹훅 URL 에 GET 하면 메타데이터를
돌려준다: http=200, name=PR-자동화봇, channel_id 확인.
나머지는 로컬 수신기로:
notify.sh 따옴표·작은따옴표·백슬래시·줄바꿈 포함 메시지 → JSON 무손상
웹훅 미설정 → 0건 발송, 반환 0
엔드포인트 죽음 → set -e 아래에서도 호출자 생존
backup.sh 실패(없는 PG 컨테이너) → exit 1, 정확히 1건 (이중발송 없음)
성공(운영 컨테이너, 임시 디렉토리) → exit 0, 0건,
실제 덤프 696K/20테이블 + minio 248M 생성
health 첫실행 정상 → 무음 / 반복 → 무음 /
집계 UP 인데 backup DOWN → 포착 / 복구 → 1건 /
도달 불가 → UNREACHABLE
개발 중 health-check.sh 의 임베디드 파이썬 따옴표 이스케이프가 깨져 **판정이 빈
값이 되고 감시가 조용히 무력해지는** 상태가 났다. 테스트가 잡았고, 이제 빈 값과
해석 실패를 UNPARSEABLE 장애로 떨어뜨린다. '모르는 상태'와 '정상'을 섞으면
감시가 아니다.
## 곁가지 정정
backup.sh 헤더 주석의 "같은 디스크에 저장하므로" 도 사실이 아니었다. 데이터는
/dev/nvme0n1p3, 백업은 /dev/nvme1n1p1 로 다른 물리 디스크다. 데이터 디스크
고장까지는 견디고, 못 견디는 것은 호스트 자체를 잃는 경우다.
This was referenced Sep 28, 2026
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Oct 6, 2026
Team-StackUp#260 을 배포하고 cron 환경(env -i)으로 실행해 보니 판정은 UP 인데 종료 코드가 1 이었다. [ -n "$previous" ] && ops_notify ok ... 첫 실행에는 previous 가 비어 있어 && 가 단락되고, 그 마지막 상태가 그대로 스크립트 종료 코드가 된다. 감시 도구가 정상 상태에서 실패 코드를 내면 "이 cron 은 원래 그래" 가 되고, 진짜 실패도 같이 무시된다. if 문으로 바꾸고 끝에 명시적 exit 0 을 둔다. ## 자가 테스트를 레포로 이 스크립트들을 만들면서 실제로 두 번 깨졌고 **둘 다 조용한 실패**였다: 1. 임베디드 파이썬 따옴표 이스케이프가 깨져 판정이 빈 값 → 감시가 그냥 통과 2. 위의 exit 1 → cron 이 정상을 실패로 보고 셸은 컴파일러도 타입체커도 없고, 이 둘은 돌려봐도 눈에 안 띈다. 그래서 검증을 스크래치패드에 두지 않고 infra/ops/selftest.sh 로 옮겼다. 14개 케이스를 로컬 수신기로만 확인한다 — **팀 채널로 아무것도 보내지 않는다.** notify.sh 따옴표·백슬래시·줄바꿈 → JSON 무손상 / 웹훅 없으면 무발송 / 죽은 엔드포인트가 호출자를 죽이지 않음 health-check.sh 첫 실행 정상(exit 0, 무발송) / 정상 반복(무발송) / 집계 UP 인데 backup DOWN 포착 / 같은 이상 반복 무발송 / 복구 알림 / 도달 불가 → UNREACHABLE ※ 모든 케이스에서 exit 0 을 함께 확인한다 ## CI `Infra (ops 스크립트)` 잡 추가: bash -n · shellcheck -S warning · selftest. 로컬에서 도커로 shellcheck 를 돌려 clean 을 확인했고, YAML 도 파싱 검증했다 (Team-StackUp#259 에서 CI 와 다른 명령으로 확인해 타입 에러를 놓친 뒤의 습관).
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Oct 6, 2026
문서가 주장하는 동작이 코드에 있는지 전수 대조하다 나왔다(Team-StackUp#260 의 "README 는 Discord 알림이 있다는데 코드엔 없던" 건과 같은 종류를 찾던 중). 분산 추적은 **배선은 돼 있는데 쓸 수가 없는** 상태였다. 세 군데가 끊겨 있었다. ## 1. MDC 를 채우는데 로그에 안 나온다 TraceIdFilter 가 요청마다 MDC 에 traceId 를 넣지만, **로그 패턴이 MDC 를 참조하지 않았다.** logback 설정 파일도 logging 설정도 없어 Spring Boot 기본 패턴이 그대로 쓰였다. 그래서 운영 로그에서 traceId 가 보이는 줄은 메시지 문자열에 직접 박아 넣은 SecurityConfig·GlobalExceptionHandler 몇 줄뿐이고 나머지는 전부 추적 불가였다. MDC 를 채우는 코드가 사실상 아무 일도 하지 않고 있었다. logging.pattern.console 에 %X{traceId:-no-trace} 를 넣었다. traceId 가 없는 흐름(스케줄러 스위퍼)은 no-trace 로 뜬다 — 빈칸이면 "추적이 끊긴 것"과 "원래 요청이 없는 것"이 구분되지 않는다. JSON 은 넣지 않았다. docs 가 JSON 이라고 적어 뒀지만 그런 설정이 존재한 적이 없고, 로그 수집기도 없다. 실제로 읽는 경로는 docker logs 라 평문이 낫다. 문서를 사실에 맞게 고쳤다. ## 2. 인증 실패에는 traceId 가 null 이었다 운영 로그: Authentication failed. code=AUTH_INVALID_TOKEN, traceId=null, uri=/api/.env @order 가 없어 Spring Boot 가 LOWEST_PRECEDENCE 로 등록했고, Spring Security 체인은 -100 이라 보안 체인이 먼저 돈다. 인증 실패는 그 안에서 끝나므로 TraceIdFilter 에 닿지 못한다. **추적 ID 가 가장 필요한 순간(인증 실패·공격 스캔)에 정확히 빠져 있었다.** @order(HIGHEST_PRECEDENCE) 를 붙였다. ## 3. 큐를 건너면 추적이 끊겼다 엔벨로프는 처음부터 traceId 를 실어 왔는데 **컨슈머가 MDC 로 되돌리지 않았다.** 그래서 콜백 처리 로그에 traceId 가 없고, 이어서 발행하는 메시지는 RabbitMessagePublisher 가 새 traceId 를 만들어 붙였다 — 그 코드의 주석도 "RabbitListener / EventListener 등에서 MDC 미주입 흐름" 이라고 적고 있다. docs/observability.md §1 의 "한 요청의 모든 후속 처리에 동일 traceId 전파" 는 성립하지 않았다. @RabbitListener 5개를 TraceContext.runWithTraceId 로 감쌌다. finally 로 반드시 지운다 — 스레드 풀이 재사용되므로 남기면 다음 메시지 로그에 엉뚱한 traceId 가 묻는다. ## 테스트 TraceContextTest 4개 (작업 중 설정·종료 후 정리·예외 시에도 정리·예외 전파· 빈 값이면 새로 생성), TraceIdFilterOrderTest 1개. 순서 애노테이션은 지워져도 컴파일·테스트가 통과하고 **운영에서도 조용하다** — 그래서 테스트로 고정했다. 백엔드 전체 333건 통과, 0 실패. ## 남은 것 AI 서버 로그에는 아직 traceId 가 없다(uvicorn 기본 액세스 로그). userId 도 MDC 에 넣지 않는다. 둘 다 문서에 남겼다.
i3months
added a commit
to i3months/stackup
that referenced
this pull request
Oct 6, 2026
Team-StackUp#262 로 Core 쪽 추적을 고치고 "AI 서버 로그에는 아직 traceId 가 없다" 를 남은 과제로 적어 뒀는데, 그 이유가 따로 있었다. ai/CLAUDE.md 는 컨슈머 패턴으로 이렇게 안내해 왔다: with trace_context(envelope.trace_id): await resume_analyzer.handle(envelope.payload) **그런데 trace_context 가 존재한 적이 없다.** 문서를 따라 쓰면 ImportError 가 나고, 실제 컨슈머들은 traceId 를 로그에 전혀 싣지 않았다. 오늘만 세 번째다 — infra/backup/README 의 Discord 알림(Team-StackUp#260), Core 의 "동일 traceId 전파"(Team-StackUp#262), 그리고 이것. 문서가 주장하는 동작은 grep 으로 확인해야 한다. ## 구현 observability/trace.py. structlog 기본 프로세서 체인의 첫 번째가 merge_contextvars 인 것을 실측으로 확인하고 그 위에 올렸다: 기본 프로세서: ['merge_contextvars', 'add_log_level', 'StackInfoRenderer', 'set_exc_info', 'TimeStamper', ...] 한 번 묶으면 그 구간의 **모든 로그 줄에 자동으로** 실린다 — 호출부마다 인자로 넘길 필요가 없다. 적용 지점 3곳: consume_with_failure_signal(생성·분석 컨슈머 공통) + voice/tts(직접-발행 모델). 비어 있으면 새로 만든다. 끝나면 반드시 되돌린다 — 워커 태스크가 재사용되면 남은 값이 다음 메시지 로그에 묻는다. ## 테스트 "바인딩했다"가 아니라 **"출력에 나온다"** 를 본다. Core 에서 겪은 함정이 정확히 그거였다 — MDC 는 채우는데 로그 패턴이 참조하지 않아 아무 데도 안 나왔다. trace_id 가 실제 로그 엔트리에 실린다 / 빠져나오면 지워진다 비어 있으면 생성하고 서로 다르다 예외로 빠져나와도 지워진다(컨슈머는 예외를 다시 던져 DLQ 로 보낸다) **동시 메시지 3건이 서로의 traceId 를 오염시키지 않는다** — 추적이 틀린 것은 없는 것보다 나쁘다 AI 전체 422건 통과.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
1. README 가 없는 기능을 있다고 적어놨다
infra/backup/README.md는 2026-09-26 부터 이렇게 안내했다:그런데 그 코드가 없다:
fail()이 로그를 남기고exit 1하는 게 전부였다. cron 출력은backup.log로 갈 뿐이다.문서만 보고 "실패하면 연락이 오겠지" 라고 믿는 상태는, 알림이 없다고 아는 것보다 나쁘다.
조용히 멈춘 백업과 정상 백업이 구분되지 않는다.
EXITtrap 으로 건다.fail()뿐 아니라set -e로 죽는 예상 못 한 실패(디스크 가득,docker 데몬 중단)까지 잡아야 하고, 그런 것들이 정확히 "아무도 모르게" 실패하는 종류다.
ERR을 같이 걸면ERR→EXIT로 두 번 나가므로EXIT만 쓴다.2. 헬스 엔드포인트를 아무도 보고 있지 않았다
여섯 컴포넌트를 만들어 두고도 DOWN 이 떠도 알 방법이 없었다. 특히
backup(신선도)과scheduler(스위퍼 생존, #257)는 애초에 "조용히 멈추는 것"을 잡으려고 만든 신호인데,읽는 쪽이 없으면 만들지 않은 것과 같다.
infra/ops/health-check.sh가 5분마다 폴링하고 상태가 바뀔 때만 알린다.같은 말을 5분마다 보내면 아무도 안 읽게 되고, 그러면 진짜 장애 때도 안 읽는다.
집계(
status)가 아니라 개별 컴포넌트까지 본다 —backup·scheduler는 의도적으로aggregate 에서 빠져 있어서(informational), 집계만 보면 정확히 이 감시가 잡아야 할 것을 놓친다.
검증 — 팀 채널로 보내지 않고
웹훅 유효성은 게시 없이 확인된다. Discord 웹훅 URL 에
GET하면 메타데이터를 돌려준다:나머지는 로컬 수신기로:
notify.shset -e아래에서도 호출자 생존backup.shhealth-check.shUNREACHABLEUP판정, 0건개발 중 잡힌 것
health-check.sh의 임베디드 파이썬에서 따옴표 이스케이프가 깨져 판정이 빈 값이 되고감시가 조용히 통과하는 상태가 났다. 테스트가 잡았다. 지금은 빈 값과 해석 실패를
UNPARSEABLE장애로 떨어뜨린다 — '모르는 상태'와 '정상'을 섞으면 감시가 아니다.곁가지 정정
backup.sh헤더의 "같은 디스크에 저장하므로" 도 사실이 아니었다. 데이터는/dev/nvme0n1p3,백업은
/dev/nvme1n1p1로 다른 물리 디스크다(장치 ID 66305 vs 66309). 데이터 디스크고장까지는 견딘다. 못 견디는 것은 호스트 자체를 잃는 경우고, 그게 남은 과제다.
머지 후 일어나는 일
.env에OPS_ALERT_WEBHOOK_URL을 넣으면 그쪽으로 간다.