모니터링 (Prometheus BE-70 WBS 7.2.2 · Loki/Alloy BE-71 WBS 7.2.3 · Grafana COMMON-43 WBS 7.2.4 · 경보 7.3.1 · 7.3.2)
Prometheus로 백엔드 Actuator 메트릭(7.2.1)을 수집하고, Loki + Grafana Alloy로 백엔드 JSON 로그(7.2.3)를 적재하고, Grafana로 대시보드 4종(7.2.4)을 띄운다. Prometheus가 경보 규칙(7.3.1)을 평가해 Alertmanager로 보내고, Alertmanager가 그룹핑 · 억제한 뒤 Slack으로 알린다(7.3.2).
Grafana는 데이터를 저장하지 않는다. 데이터는 Prometheus(메트릭) · Loki(로그)에 있고 Grafana는 그걸 읽어 그리는 조회 계층일 뿐이다. 그래서 Grafana가 죽어도 데이터는 남지만, Prometheus · Loki가 죽은 시간대의 데이터는 영영 없다 — 아래 "별도 EC2" 배치가 이 차이를 전제로 한다.
| 파일 | 내용 |
|---|---|
docker-compose.yml |
로컬 전체 스택(Alertmanager · Prometheus · Loki · Alloy · Grafana · node_exporter). backend/docker-compose.yml(DB)과 독립적으로 켜고 끈다 |
prometheus.yml |
로컬 scrape 대상 · 간격 · 경보 규칙 파일 · Alertmanager 주소. 보존 기간은 docker-compose.yml의 --storage.tsdb.retention.time |
alert-rules.yml |
경보 규칙(7.3.1). 로컬 · 운영 공용 — 5xx 비율 · p95 지연 · DB 커넥션 고갈 · AI 호출 실패율 · 사건 등록 배치 지연 |
alertmanager.yml |
Alertmanager 라우팅 · 그룹핑 · 억제 · Slack 채널(7.3.2). 로컬 · 운영 공용. 웹훅 URL은 여기 없다(아래) |
secrets/ |
Slack 웹훅 URL(slack-webhook-url, 커밋 안 함, .example 참고). monitoring/targets/와 같은 원칙 |
loki-config.yml |
Loki 단일 바이너리 구성(파일시스템 저장), 보존 기간. 로컬 · 운영 공용 |
alloy-config.alloy |
로컬 — backend/logs의 JSON 로그를 tail해서 Loki로 보낸다 (Promtail 대체, 아래 참고). (BE-63) 웹 429 전용 로그 규칙도 같이 들어 있지만, 로컬 Compose에는 웹이 없어 비어 있다 |
grafana/provisioning/ |
데이터소스(Prometheus · Loki) · 대시보드 공급자 설정. 로컬 · 운영 공용 |
grafana/dashboards/*.json |
대시보드 4종. 로컬 · 운영 공용이고 7.3.3 Grafana Cloud에도 같은 파일을 가져간다(데이터 소스 uid만 바꿔서, 아래 "Grafana Cloud 운영 설정") |
cloud/ |
Grafana Cloud 운영 설정 기록 — 경보 규칙 · 음소거 · 알림 정책 · Slack 양식. 원본은 Cloud이고 이 파일은 재현용 사본이다(COMMON-64) |
docker-compose.prod.yml |
모니터링 전용 EC2용(Alertmanager · Prometheus · Loki · Grafana) |
prometheus.prod.yml |
운영 scrape 설정 · 경보 규칙 파일 · Alertmanager 주소. 대상 주소는 targets/(커밋 안 함)에서 읽는다 |
.env.example |
운영 환경변수 예시(Grafana 계정 · 보존 기간 등) |
메인 EC2 쪽 에이전트(Alloy · node_exporter)는 deploy/docker-compose.monitoring-agent.yml · deploy/alloy-agent.alloy에 있다.
메인 서버에는 저장소에서 deploy/만 두기 때문이다(deploy/README.md).
Grafana 폴더 "남불법정"에 들어간다. 각 패널의 ⓘ에 "이 수치를 어떻게 읽는지"를 적어 뒀다.
| 대시보드 | 보는 것 | 비고 |
|---|---|---|
| 서비스 | 처리량 · 5xx 비율 · p95 지연 · 라우트별 p95 · ERROR 로그 | 7.3.1 경보(5xx · p95)와 같은 식을 쓴다 |
| AI 파이프라인 | AI 호출 성공률 · 체험 시작 · 판결 확정 · 전환율 | 지금 계측된 AI 호출은 RAG 임베딩뿐이다(아래 참고) |
| DB | HikariCP 커넥션 사용률 · 대기 · 획득 실패 · 획득/사용 시간 | PostgreSQL 서버 내부 지표는 RDS 전환 후(아래 참고) |
| 인프라 | CPU · 메모리 · 디스크 · 네트워크 · 수집 상태 · JVM 힙 · GC | 메모리를 맨 앞에 둔 이유는 t3.small 여유가 250MB뿐이라서다 |
대시보드는 파일이 원본이다. 화면에서 고친 내용은 저장되지 않는다(allowUiUpdates: false) — 고칠 때는 JSON을
고쳐 커밋한다. 화면에서 손으로 만들면 그 서버의 Grafana DB에만 남아 7.3.3에서 처음부터 다시 만들어야 한다.
- AI 파이프라인:
lawnambul_ai_call_total이 재는 것은 RAG 유사 판례 검색의 임베딩 호출(GeminiQueryEmbeddingClient)뿐이다. 판결문 생성은 오프라인 파이프라인(tools/ai-judgment)에서 돌아 서버 메트릭에 잡히지 않는다. 생성 단계 계측은 COMMON-40과 함께 본다. - DB:
postgres_exporter를 붙이지 않았다. 7.1.2 RDS 전환(10/28 완료, BE-59) 전에 붙이면 연결 대상이 바뀌어 다시 해야 했기 때문이다. 전환 뒤 RDS 지표(CloudWatch ·postgres_exporter)는 아직 정하지 않았다(deploy/README.md"다음 작업에서 바꿀 곳"). 대신 7.3.1 경보가 실제로 쓸 지표인 HikariCP 커넥션 풀로 먼저 만들었다.
Prometheus가 alert-rules.yml의 규칙을 15초마다(평가 주기) 다시 계산하고, 조건이 일정 시간(for:) 계속
참이면 Alertmanager로 알린다. Alertmanager는 그걸 묶고(group_by) 걸러서(inhibit_rules) Slack으로 보낸다.
운영에서 이 규칙 · Alertmanager는 어디서 도나 (PR #262 리뷰 9번): 지금은 안 돈다. 이 저장소의
docker-compose.yml(로컬) · docker-compose.prod.yml(전용 EC2 차선, 지금 안 씀)에서만 로컬 확인용으로
띄운다. 운영 EC2는 1대뿐이고 별도 모니터링 서버를 두지 않기로 했다(COMMON-43 1순위 = Grafana Cloud).
7.3.3에서 메트릭은 메인 EC2의 Alloy가 Cloud로 push하고(deploy/alloy-agent.alloy), 경보 평가 ·
Alertmanager도 운영에서는 Grafana Cloud 쪽(Cloud Alerting)으로 옮긴다 — 메인 서버가 죽으면 그 서버
안의 Alertmanager도 같이 죽어 정작 가장 필요한 순간에 조용해지기 때문이다(COMMON-62). Cloud 쪽 반영은
완료했다(COMMON-63 · 64, 아래 "Grafana Cloud 운영 설정" 절).
| 규칙 | 조건 | 지속 | 심각도 |
|---|---|---|---|
HighErrorRate |
5분간 5xx 비율 > 5% (요청이 5분에 30건은 있을 때만) | 5m | critical |
HighLatencyP95 |
5분간 p95 응답 시간 > 2초 (관리자 경로 제외) | 5m | warning |
DbConnectionPoolNearExhaustion |
HikariCP 사용률 > 90% | 3m | critical |
DbConnectionPending |
커넥션을 기다리는 스레드 있음 | 2m | warning (위 경보가 울리는 동안은 억제됨) |
HighAiCallFailureRate |
10분간 AI 호출 실패율 > 30% (호출이 10분에 30번은 있을 때만) | 5m | warning |
CaseImportStuck |
진행 중인 사건 등록 작업이 10분 넘게 안 끝남 | 2m | warning |
BackendDown |
backend 타깃이 스크레이프에 응답 안 함 | 1m | critical |
BackendScrapeTargetMissing |
backend job 자체가 설정에서 사라짐 | 1m | critical |
Watchdog |
항상 참(vector(1)) — 경보 경로 자체가 살아 있는지 확인용, Slack에는 안 보냄 |
— | none |
AlertDeliveryFailing |
Alertmanager의 알림 전송이 계속 실패함 | 5m | warning |
CaseImportStuck이 보는 지표(lawnambul_case_import_oldest_running_seconds)는 기존에 없던 걸 7.3.1에서
새로 추가했다(CaseImportMetrics). 스크레이프 때마다 DB를 직접 조회하면 DB 커넥션 풀이 가득 찬 순간(=
DbConnectionPoolNearExhaustion이 울려야 할 순간)에 그 조회가 커넥션을 기다리다 스크레이프 전체가
타임아웃될 수 있어(PR #262 리뷰), 1분마다 백그라운드(@Scheduled)로 미리 계산해 캐시해 두고
스크레이프 때는 캐시만 읽는다.
BackendDown · BackendScrapeTargetMissing이 없으면, 위 다른 규칙은 전부 backend 지표가 들어와야
계산되므로 backend가 죽으면 지표 자체가 끊겨 아무 경보도 안 울린다 — 가장 큰 장애에서 오히려
조용해지는 구멍이었다(PR #262 리뷰, 머지 전 필수 수정). Watchdog은 null 수신자로 보내 외부에
생존 신호를 전달하지 않아, 이 로컬 구성만으로는 "신호가 조용해졌다"는 걸 알아챌 외부 관찰자가 없다
— 운영은 서버 밖(Grafana Cloud)에서 평가해, 서버 전체가 멈춰도 BackendScrapeTargetMissing이 알린다. Cloud의 Watchdog은 하루 한 번 Slack에 생존 신호를 보내, 경보 경로 자체가 멈춘 것도 알 수 있게 했다(아래 "Grafana Cloud 운영 설정" 절).
AlertDeliveryFailing은 로컬 전용이다 — Cloud에는 옮기지 않았다(같은 절 참고).
- Slack에서 수신 웹훅(Incoming Webhook)을 만들고 보낼 채널을 고른다 — 이 채널이 그 웹훅의 고정 채널이다.
alertmanager.yml에channel:을 따로 지정하지 않는다(다른 채널 이름을 주면 그 채널이 실제로 있어야 하고, 없으면404 channel_not_found로 실패한다 — 직접 겪은 문제). monitoring/secrets/slack-webhook-url.example을 복사해slack-webhook-url로 만들고 웹훅 URL을 넣는다.Mac · Windows(Docker Desktop)는 이대로 된다 — 직접 테스트해 Slack 수신까지 확인함. Docker Desktop의 파일 공유 계층이 번역해 주기 때문이다. 운영(Linux EC2)은 다르다 — "운영 적용" 절 참고,cp secrets/slack-webhook-url.example secrets/slack-webhook-url vi secrets/slack-webhook-url # 발급받은 https://hooks.slack.com/services/... 값으로 교체 chmod 600 secrets/slack-webhook-urlchmod 600그대로 두면 컨테이너가 파일을 못 읽어 경보가 조용히 안 간다. 이 파일은.gitignore에 있다 — 커밋되지 않는다.docker compose up -d(또는alertmanager만docker compose restart alertmanager)로 반영한다.
- http://localhost:9090/alerts 에서 규칙 상태(
inactive·pending·firing)를 본다. - http://localhost:9093 에서 Alertmanager가 받은 경보를 본다.
- 실제로 Slack까지 가는지 테스트 경보를 직접 보낼 수 있다(Prometheus 조건을 기다리지 않고):
curl -X POST http://localhost:9093/api/v2/alerts -H 'Content-Type: application/json' -d '[{ "labels": {"alertname": "TestAlert", "severity": "warning"}, "annotations": {"summary": "테스트", "description": "Alertmanager → Slack 연결 확인"}, "startsAt": "'"$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"'" }]'
group_wait(30s) 뒤에 Slack 채널에 메시지가 오면 정상이다. 안 오면docker compose logs alertmanager에서Notify for alerts failed줄을 찾는다(웹훅 URL 오타 · 채널 문제가 대부분이다).
운영 경보 평가 · 알림은 위 로컬 Alertmanager가 아니라 Grafana Cloud에서 돈다(이유는 "경보" 절 참고 —
메인 서버가 죽으면 그 안의 Alertmanager도 같이 죽는다). 설정의 원본은 Grafana Cloud이고, 저장소의
monitoring/cloud/는 "지금 Cloud에 무엇이 있는지"의 기록이자 스택을 다시 만들 때 쓰는 재현용 사본이다.
화면에서 고쳤으면 아래 파일도 같이 고친다.
| 파일 | 내용 | 다시 넣는 법 |
|---|---|---|
cloud/alert-rules.grafana.yaml |
경보 규칙 8개(폴더 lawnambul, 그룹 lawnambul-service) + 생존 신호 Watchdog(그룹 lawnambul-meta), 1분 평가. 화면 Export(YAML) 결과 그대로 |
화면 New alert rule에서 같은 값으로, 또는 API(아래) |
cloud/notification.grafana.yaml |
음소거 시간 운영시간외 · 알림 정책 |
Mute timings · Notification policies 화면에서 같은 값으로 |
cloud/slack-template.tmpl |
Slack 메시지 양식(템플릿 lawnambul-slack) |
Contact points → Notification templates → New |
규칙을 API로 다시 넣을 때(10/26에 쓴 방법): 폴더 lawnambul을 만든 뒤 그 uid로
POST /api/ruler/grafana/api/v1/rules/<폴더 uid>에 그룹(name: lawnambul-service, interval: 1m, rules)을 보낸다.
규칙마다 grafana_alert(title · condition · data · no_data_state · exec_err_state) 아래에 이 파일의 값을 옮긴다.
Grafana Cloud는 파일 프로비저닝을 쓸 수 없고, 화면의 "Import"는 Prometheus 형식 규칙을 가져오는 기능이라
이 내보내기 형식을 그대로 넣는 곳이 아니다.
연락처 lawnambul-slack(Slack Incoming Webhook)은 주소가 비밀값이라 파일로 두지 않는다. 다시 만들 때는
Contact points → New → Slack → Webhook URL에 직접 넣고, Optional Slack settings의 Title에
{{ template "lawnambul.slack.title" . }}, Text Body에 {{ template "lawnambul.slack.text" . }}를 넣은 뒤
Test로 Slack 수신을 확인하고 저장한다.
| 항목 | Cloud | 이유 |
|---|---|---|
규칙 · 임계값 · for |
로컬과 같음(8개) | 같은 기준으로 판단 |
모든 식에 env="prod" |
추가 | 로컬에서 Cloud로 시험 전송한 값이 섞이지 않게(Alloy가 env 라벨을 붙인다, COMMON-64) |
| 데이터가 없을 때(No data) | 정상(OK) | 비율 · 지연 식은 요청이 없으면 결과가 비는 게 당연하다. 장애로 보면 밤 · 주말마다 울린다 |
| 서버 전체 정지 | BackendScrapeTargetMissing(absent(up))이 잡는다 |
서버 · Alloy가 꺼지면 up 자체가 사라져 BackendDown(up == 0)은 울리지 않는다 |
Watchdog |
하루 한 번 Slack으로(전용 경로 repeat_interval 24h) |
로컬처럼 항상 음소거하면 전송 경로를 증명하지 못하고, 그대로 보내면 4시간마다 쌓인다. 하루 한 번 💚 [생존 신호]로 보내 "운영일에 하루 넘게 안 오면 경보 경로 고장"으로 쓴다(PR #286 리뷰) |
AlertDeliveryFailing |
넣지 않음 | 로컬 Alertmanager 자신의 지표라 Cloud에는 값이 없다 |
억제(inhibit_rules) |
넣지 않음 | 커넥션 고갈 때 DbConnectionPoolNearExhaustion · DbConnectionPending이 함께 올 수 있지만, 규칙 8개 규모라 감수했다. 필요하면 Grafana 억제 규칙으로 추가한다. 로컬에는 그대로 있다 |
조건은 A(PromQL, instant) → C(threshold > -1)다. PromQL이 이미 비교식이라 조건에 맞을 때만 값이 나오므로
"값이 하나라도 있으면 발생"으로 둔다. BackendDown은 값이 0이라 > 0으로 두면 울리지 않는다.
- 기본 받는 곳:
lawnambul-slack(처음엔empty— 아무 데도 안 갔다) - 맨 앞 경로
alertname=Watchdog: 음소거운영시간외·repeat_interval 24h— 먼저 맞는 경로 하나만 타므로 아래 폴더 경로보다 앞에 둔다 grafana_folder=lawnambul하위 경로: 음소거운영시간외·group_wait 30s·group_interval 5m·repeat_interval 4h- 음소거
운영시간외: Asia/Seoul 평일 00:00 ~ 09:10 · 18:00 ~ 24:00, 토 · 일 종일- 서버가 꺼진 동안
BackendScrapeTargetMissing이 계속 울리는 것을 막는다(처음엔 서버가 평일 09:00 ~ 18:00에만 켜져 있었다) - 아침은 09:10까지 — 09:00 부팅 후 backend 준비까지 1분 남짓 걸려, 09:00에 풀면 매일 "발생 → 해결"이 온다
- 시간대(
location)를 비우면 UTC로 계산돼 9시간 어긋난다 - 운영 시간에 서버를 끄는 날(공휴일 등)은 음소거가 걸리지 않는다 → 그날만 Silence를 건다
- 반대로 저녁(18시 뒤)에 서버를 켜고 작업하면 알림이 오지 않는다(음소거 시간이라서). 그동안 장애를 받아야 하면
Notification policies에서
lawnambul경로의 음소거를 잠시 빼고, 끝나면 다시 건다
- 서버가 꺼진 동안
규칙 · 연락처가 통째로 사라지면 알림은 그냥 조용해진다 — 10/26에 실제로 그랬고 아무도 몰랐다(아래 "처음 연동에서 바뀐 것").
그래서 생존 신호 Watchdog이 운영일마다 한 번 Slack에 💚 [생존 신호] 경보 경로 정상을 보낸다.
전송 시각은 "마지막 전송 + 24시간"이고, 그 시각이 음소거(저녁)에 걸리면 다음 운영 시간 시작(09:10 무렵)에 나간다.
운영일에 하루 넘게 생존 신호가 없으면 경보 경로가 멈춘 것이다 — 아래를 바로 확인한다.
| 언제 | 확인 | 방법 |
|---|---|---|
| 매 운영일 | 생존 신호가 왔는지 | Slack 채널에 그날(또는 전날 저녁) 💚 [생존 신호]가 있는지 |
| 생존 신호가 없을 때 · 주 1회(월요일 오전) | 규칙 · 경로가 그대로인지 | Alert rules에 폴더 lawnambul의 서비스 규칙 8개(평소 Normal) + Watchdog(항상 Firing이 정상) · Notification policies의 경로 2개(Watchdog 24h · lawnambul 4h) · 기본 경로 lawnambul-slack |
| 같이 | Slack 전송 경로 | Contact points → lawnambul-slack → Test → Slack 채널에 시험 메시지가 오는지 |
| 설정을 바꾼 뒤 | 저장소 기록과 같은지 | 바꾼 것을 Export해 monitoring/cloud/에 반영 |
생존 신호가 지키지 못하는 것: Grafana Cloud 자체 장애(평가가 통째로 멈춤)나 Slack 쪽 장애는 "신호가 안 온다"로만 드러난다. 그래서 위 매일 확인이 필요하다.
| 단계 | 결과 |
|---|---|
| Contact point Test | Slack 수신 |
docker stop lawnambul-backend-1 |
약 1분 뒤 Pending → 1분 뒤 Firing → 정지 후 약 2분 40초에 Slack 발생 알림 |
docker start lawnambul-backend-1 |
복구 후 최대 5분 안에 해결 알림(같은 경보 알림을 group_interval 5분 단위로 묶는다) |
| 새 양식 | 요약 · 내용 · 시작/해결 시각(KST) · 심각도 · 규칙 보기 / 음소거 링크가 보임 |
| 로그(Loki) | backend 로그 파일에 시험 한 줄 → 15초 안에 {job="backend", env="prod"}로 조회 |
원본은 grafana/dashboards/*.json 하나만 둔다(Cloud용 사본을 따로 커밋하지 않는다 — 두 벌을 맞추는 일이 생긴다).
파일의 데이터 소스 uid가 로컬 기준(prometheus · loki)이라, 가져올 때 Cloud uid로만 바꾼다.
mkdir -p /tmp/cloud-dashboards
for f in monitoring/grafana/dashboards/*.json; do
sed -e 's/"uid": "prometheus"/"uid": "grafanacloud-prom"/g' \
-e 's/"uid": "loki"/"uid": "grafanacloud-logs"/g' "$f" > "/tmp/cloud-dashboards/$(basename "$f")"
done
# Grafana Cloud → Dashboards → New → Import → 위 파일 업로드 → 폴더 lawnambul- 패널 쿼리는 그대로 Cloud에서 실행된다(10/26, 4종 패널 33개 · 쿼리 50개 모두 오류 없이 실행 확인. 429 · ERROR 로그처럼 비어 보이는 패널은 그날 발생 0건이라서다).
- Cloud에 가져온 대시보드는 파일과 자동으로 맞춰지지 않는다. 파일을 고치면 같은 방법으로 다시 가져온다(같은
uid라 가져오기 화면에서 덮어쓰기를 고른다).
COMMON-63에서는 alert-rules.yml을 Grafana의 "Convert Prometheus rules" API로 변환해 올리고
(docs/decisions/COMMON-63.md), Watchdog은 항상 켜진 음소거로 막는 구성이었다. 10/26 확인 때 이 스택
(<stack-slug>.grafana.net)에는 그 규칙 · 연락처 · 정책이 하나도 없었고(규칙 0개, 연락처 empty뿐, 기본 경로 empty)
— 백엔드가 죽어도 알림이 가지 않는 상태였다. 위 구성으로 다시 만들고 실제 장애 시험까지 마쳤다(docs/decisions/COMMON-64.md).
WBS 7.2.3은 "Promtail(또는 Grafana Alloy)"로 둘 다 열어 뒀지만, **Promtail은 2026-03-02에 공식 EOL(지원 종료)**됐다 (커뮤니티 LTS가 2025-02부터였고, 상용 지원도 2026-02-28에 끝났다). 새로 시작하는 작업에 이미 수명이 끝난 도구를 고를 이유가 없어 Grafana Alloy로 진행했다. 설정 형식(River/Alloy 구문)은 다르지만 역할은 같다 — 로그 파일을 tail해서 Loki로 보낸다.
- 백엔드를 먼저 띄운다:
cd backend && ./gradlew bootRun(관리 포트 8081이 열린다, 7.2.1) - Prometheus를 띄운다:
cd monitoring docker compose up -d - http://localhost:3000 에서 대시보드를 본다 (초기 계정
admin/admin, 로컬 전용). 왼쪽 대시보드 목록 → 폴더 "남불법정"에 4종이 들어 있다. 데이터소스도 함께 자동 등록된다. - http://localhost:9090/targets 에서
backendjob이 UP인지 확인한다.host.docker.internal:8081에 연결하므로, 백엔드가 아직 안 떠 있으면 DOWN으로 보이는 게 정상이다 — 백엔드를 띄운 뒤 몇 초 안에 UP으로 바뀐다. (nodejob은 백엔드와 무관하게 바로 UP이다.) - http://localhost:9090/graph 에서
lawnambul_experience_started_total같은 커스텀 메트릭을 바로 조회할 수 있다. - 로그 확인: http://localhost:3100/ready 로 Loki 기동을 확인한 뒤, LogQL로 직접 조회할 수 있다.
같은 LogQL을 Grafana의 Explore나 "서비스" 대시보드의 로그 패널에서 그대로 쓸 수 있다.
curl -sG http://localhost:3100/loki/api/v1/query_range --data-urlencode 'query={job="backend"}' # 특정 요청만 보고 싶으면 traceId로 필터 (응답 헤더 X-Trace-Id 값을 그대로 쓰면 된다) curl -sG http://localhost:3100/loki/api/v1/query_range --data-urlencode 'query={job="backend"} | json | traceId="..."'
로컬 node_exporter가 보여주는 건 Mac 호스트가 아니라 Docker Desktop VM의 자원이다. 인프라 대시보드가
제대로 그려지는지 확인하는 용도로만 보고, 실제 수치는 운영(메인 EC2)에서 본다.
로컬에서 요청 몇 건만 흘려보낸 값이라 참고용이다 (count({job="backend"}), prometheus_tsdb_head_series).
| 상태 | backend job 시계열 | 전체(backend + prometheus 자신) |
|---|---|---|
http.server.requests 히스토그램 끄기 전 |
152 | 886 |
켠 뒤, 라우트 2개(/api/v1/cases, /actuator/health) 호출 (범위 전체 · actuator 포함) |
265 | 1004 |
버킷 범위 10ms~5s로 좁히고 /actuator/** 제외한 뒤 (#120 리뷰), 라우트 1개(/api/v1/cases) 호출 |
255 (라우트 자체는 47) | 896 |
라우트 하나당 히스토그램 버킷이 69개(전체 범위) → 44개(10ms~5s로 좁힘)로 줄었다. /actuator/** 스크레이프 자체가
만드는 시계열도 빠졌다. 그래도 API 수가 늘어나면 비용은 여전히 선형으로 커지므로, Grafana Cloud 무료 등급으로
remote_write할 때(7.3.3, R9) 이 메트릭을 전부 보낼지는 D와 따로 정해야 한다(PR 본문 "D와 협의할 것" 참고).
- 지금 포함:
backend(Actuator),prometheus(자기 자신),node(호스트 자원 — 7.2.4 인프라 대시보드가 쓴다) - 보류:
postgres_exporter— 7.1.2 RDS 전환(10/28 완료)으로 연결 대상이 정해졌다. CloudWatch와 어느 쪽으로 볼지 정한 뒤 추가한다 - 결정됨 (COMMON-63 · 64): 운영 서버에는 Prometheus를 두지 않는다. Alloy가 지표 · 로그를 Grafana Cloud로 보내고, 규칙 평가 · 알림도 Cloud에서 한다
(
deploy/docker-compose.monitoring-agent.yml, 아래 "Grafana Cloud 운영 설정"). 이 폴더의 Prometheus · Alertmanager는 로컬 확인용이다
- traceId는 Loki 라벨이 아니다. Loki는 Prometheus와 똑같이 라벨 조합마다 별도 스트림(=별도 인덱스)을 만든다.
요청마다 다른 traceId를 라벨로 쓰면 스트림이 무한히 늘어나 Loki가 못 버틴다(7.2.1의 메트릭 카디널리티 규칙과 같은 이유).
traceId는 **로그 내용(JSON 필드)**으로만 남기고, 조회는
| json | traceId="..."로 한다. 라벨로 두는 건job·level처럼 값의 종류가 적은 것뿐이다(alloy-config.alloy). - (BE-63) 웹 429 전용 로그는
job="web", 라벨은route(값 4개:auth_token·admin_login·oauth·api)뿐이다. 막힌 IP · 경로는 JSON 필드라{job="web"} | json으로 본다. 운영에서는 모니터링 에이전트의web-ratelimit-logs볼륨으로 읽는다 (deploy/README.md"429 로그 수집"). IP는 막힌 쪽을 찾기 위해 남긴다 — 백엔드 마스킹(토큰 · 이메일) 대상이 아니다. - 민감정보 마스킹은
backend/src/main/resources/logback-spring.xml의MaskingJsonGeneratorDecorator가 한다. 필드 이름(password·token등)과 메시지 속 패턴(Bearer 토큰·이메일) 둘 다 가린다. 지금 코드는 이미 비밀번호 · API 키를 로그에 남기지 않지만, 이 마스킹은 그 규칙이 깨졌을 때의 마지막 안전망이다.
host.docker.internal은 Mac · Windows Docker Desktop만 기본 지원한다. Linux(Docker Engine)에서는 docker-compose.yml의
prometheus 서비스에 아래를 추가해야 호스트의 8081에 붙는다.
extra_hosts:
- "host.docker.internal:host-gateway"secrets/slack-webhook-url의 chmod 600도 Linux에서는 다르게 동작한다 — Docker Desktop의 파일 공유
계층(권한 번역)이 없어서, 로컬이라도 Linux면 "경보" 절 대신 "운영 적용"의 chown 65534:65534 절차를
따라야 한다.
모니터링 스택을 메인 서버에 함께 올리는 선택지는 없다. 근거는 docs/decisions/COMMON-43.md에 정리했고, 요약하면
(1) 메인은 t3.small 2GB에 1.75GB가 이미 할당돼 올릴 자리가 없고, (2) 감시자가 감시 대상과 같이 죽으면 정작 장애
순간에 원인을 볼 수 없고, (3) Grafana는 사람이 붙는 UI라 메인 보안 그룹(22 · 80 · 443)에 노출을 더 만들고 싶지 않다.
| 상태 | |
|---|---|
| 1순위 — Grafana Cloud 무료 등급 | 적용 중. 지표 remote_write(COMMON-62) · 경보(COMMON-63) · 로그 Cloud Loki 전환과 운영 반영(COMMON-64). 서버 절차는 deploy/README.md "모니터링 에이전트 (Grafana Cloud)" |
| 차선 — 전용 EC2 추가 | Cloud가 막히면 이쪽. 아래 구성이 그 대비다 |
Cloud가 1순위인 결정적 이유는 비용이 아니라 노출면이다. Cloud 방식에서는 메인 서버의 Alloy가 Docker 내부
네트워크로 backend:8081을 직접 읽어 밖으로 내보내므로(push), 8081 · 9100 · 3100을 하나도 열 필요가 없다.
아래 보안 그룹 표와 8081 노출이 통째로 사라지고, 팀원 Grafana 접근 문제(SSH 22번이 D의 IP만 열려 있어 사실상 못 쓴다)도
없어진다. 남는 비용은 메인 서버에 Alloy · node_exporter가 올라가는 메모리 150MB 정도뿐이다.
아래 전용 EC2 구성은 지우지 않고 차선으로 남겨 둔다. 어느 쪽으로 가든 대시보드 JSON은 그대로 쓴다.
[메인 EC2] [모니터링 EC2]
backend:8081 (Actuator) ◀──── pull ─────── Prometheus
node_exporter:9100 ◀──── pull ─────── (같은 Prometheus)
Alloy (에이전트) ───── push ──────▶ Loki
Grafana ──▶ 사람 (SSH 터널)
deploy/docker-compose. monitoring/docker-compose.
prod.yml + monitoring-agent.yml prod.yml
메트릭은 pull, 로그는 push다. 메트릭은 "현재 값"이라 Prometheus가 주기적으로 긁어오면 되지만, 로그는 지나간 줄을 나중에 가져올 수 없어서 만든 쪽이 보내야 한다. 그래서 Alloy만 메인 서버에 남는다 — Alloy는 읽어서 전달만 하는 에이전트라 가볍고(100MB 미만), 무거운 쪽인 저장·인덱싱(Loki)이 빠지는 게 핵심이다.
| 통신 | 열 곳 | 허용 범위 |
|---|---|---|
| Prometheus → Actuator | 메인 SG 8081 |
모니터링 EC2 사설 IP만 |
| Prometheus → node_exporter | 메인 SG 9100 |
모니터링 EC2 사설 IP만 |
| Alloy → Loki | 모니터링 SG 3100 |
메인 EC2 사설 IP만 |
| 사람 → Grafana | 열지 않음 | SSH 터널 (ssh -N -L 3000:localhost:3000 <모니터링 EC2>) |
둘 다 같은 VPC 사설 통신이라 인터넷을 타지 않는다. Loki에는 인증이 없고 Actuator도 인증이 없으므로, 보안 그룹이
유일한 보호선이다 — 규칙을 0.0.0.0/0으로 열면 메트릭·로그가 그대로 공개된다.
그래서 컨테이너 포트도 0.0.0.0이 아니라 사설 IP에만 바인딩한다(MAIN_PRIVATE_IP · MONITORING_PRIVATE_IP).
Docker가 연 포트는 서버 방화벽(ufw)을 우회하므로, 보안 그룹 규칙 하나가 잘못 열리면 곧바로 공개되기 때문이다
(D 지적, COMMON-43). 기본값을 두지 않아서 값이 없으면 컨테이너가 아예 뜨지 않는다.
Grafana 접근은 SSH 터널이 1차안이지만 실제로는 쓰기 어렵다 — 메인 서버 22번이 D의 IP만 열려 있어 팀원마다
보안 그룹에 IP를 추가해야 하고, IP가 바뀔 때마다 고쳐야 한다(IAM 권한이 없어 SSM도 못 쓴다). 퍼블릭 공개는
도메인·HTTPS(BE-75) 이후다 — HTTPS 없이 열면 로그인 비밀번호가 평문으로 오간다. 이 문제도 Cloud로 가면 사라진다.
# --- 모니터링 EC2 ---
cd monitoring
cp .env.example .env && chmod 600 .env # GRAFANA_ADMIN_PASSWORD 채우기
# 스크레이프 대상(메인 EC2 사설 IP). 사설 IP는 커밋하지 않으므로 서버에서 만든다
mkdir -p targets
cat > targets/backend.json <<'EOF'
[{"targets": ["<메인 EC2 사설 IP>:8081"], "labels": {"instance": "main"}}]
EOF
cat > targets/node.json <<'EOF'
[{"targets": ["<메인 EC2 사설 IP>:9100"], "labels": {"instance": "main"}}]
EOF
# Slack 웹훅 (7.3.2). 커밋하지 않으므로 서버에서 만든다 — "경보" 절의 웹훅 설정 참고.
# ⚠️ Linux(EC2)는 chmod 600으로 두면 안 된다 — Alertmanager · Prometheus 컨테이너가 nobody(uid 65534)로
# 실행되는데, 로컬(Mac · Windows Docker Desktop)과 달리 파일 공유 계층의 권한 번역이 없어서
# 소유자(배포 사용자)만 읽게 두면 컨테이너가 못 읽는다. 그대로 두면 경보가 울려도 Slack으로 조용히 안
# 간다(Alertmanager 로그에 "Notify for alerts failed … permission denied"만 남고 설정 자체는 정상으로
# 읽힌 것처럼 보여 알아채기 어렵다 — 직접 재현해서 확인함, PR #262 리뷰).
mkdir -p secrets
echo -n "https://hooks.slack.com/services/..." > secrets/slack-webhook-url
sudo chown 65534:65534 secrets/slack-webhook-url
sudo chmod 400 secrets/slack-webhook-url
docker compose -f docker-compose.prod.yml up -d --no-build
# --- 메인 EC2 ---
# ⚠️ 차선으로 다시 간다면: COMMON-64에서 메인 쪽 오버레이를 Grafana Cloud 전용으로 바꿨다(8081 · 9100 포트 ·
# MAIN_PRIVATE_IP · LOKI_URL 제거). 전용 EC2로 돌아가려면 그 오버레이 · alloy-agent.alloy · deploy.sh 검사를
# 함께 되돌려야 한다 — 이 절은 구성 기록으로만 남긴다운영 compose 파일에는 build:가 있어서, 이미지가 없으면 서버가 직접 빌드를 시도한다. deploy.sh와 같은 기준으로
--no-build를 붙여 서버에서는 항상 GHCR에서 받은 이미지만 쓰게 한다 (PR #168 리뷰 반영).
targets/*.json은 Prometheus가 알아서 다시 읽는다 — 인스턴스를 중지했다 켜서 사설 IP가 바뀌어도 이 파일만 고치면
되고, 재시작도 설정 리로드도 필요 없다. 설정 파일(prometheus.prod.yml)을 고쳤을 때만 리로드가 필요하다
(--web.enable-lifecycle을 켜 뒀으므로 curl -X POST http://localhost:9090/-/reload).
base 파일만으로 backend를 다시 올리면 로그 볼륨이 빠진 채 컨테이너가 재생성되고, 에이전트 컨테이너만 연결 없이 남는다. 오류가 나지 않아 눈치채기 어렵다(D 지적, COMMON-43).
BE-60 CD가 들어온 지금은 상시 위험이다 — deploy/cd-agent.sh가 2분마다 GHCR main 이미지를 확인해
deploy.sh로 자동 배포하므로, 수동으로 오버레이를 붙여 올려 두어도 다음 배포 한 번이면 원래대로 돌아간다.
(PR #168 리뷰 반영) 이 분기를 넣었다 — deploy.sh의 COMPOSE 배열이 .env의 MONITORING_AGENT=1을 보고
이 오버레이를 포함한다(real-seed 오버레이 바로 뒤). 에이전트를 쓰는 서버의 .env에 넣을 값(Grafana Cloud 주소 · ID ·
토큰)과 순서는 deploy/README.md "모니터링 에이전트 (Grafana Cloud)"에 있다(COMMON-64 — 옛 MAIN_PRIVATE_IP ·
LOKI_URL은 더 쓰지 않는다).
MONITORING_AGENT가 비어 있는 서버는 분기를 타지 않으므로 기존 배포 동작이 그대로다. 1인데 필수 값이
없거나 주소가 https가 아니면 배포를 시작하기 전에 멈춘다(exit 4) — 자동 배포가 "이미지를 받지 못했다"처럼 엉뚱한 원인으로 보이지 않게
하기 위해서다. 원래 deploy.sh는 D 소유 영역이라(7-2-1) 머지 전에 D 확인이 필요하다.
- Cloud 세 번째 좌석 — 무료 등급은 시각화 사용자 3명이다. C · D는 확정이고 남은 한 자리를 A · B 중 누구에게 줄지 팀에서 정한다. 계정 공유는 하지 않는다.
→ PR #168 리뷰에서 반영했다(위deploy.sh에MONITORING_AGENT플래그 추가⚠️ 절). D 소유 파일을 대신 고친 것이라 머지 전 확인만 받으면 된다.Grafana Cloud 실제 연동은 7.3.3 범위다→ 지표 remote_write(COMMON-62) · 경보(COMMON-63) · 로그 Cloud Loki 전환과 운영 서버 반영(COMMON-64)으로 진행했다. 경보는 10/26 다시 만들고 장애 시험까지 마쳤다. 대시보드 JSON은 데이터 소스 uid만 바꿔 Cloud에 가져온다("Grafana Cloud 운영 설정").- 전용 EC2로 갈 경우에만: 실제 EC2 생성 · 보안 그룹 변경(D), Prometheus 보존 30일/8GB 디스크 여유 확인(D), 에이전트 적용 타이밍(backend 재생성에 20~40초 중단 — 월요일 첫 배포 이후). 사설 IP는 저장소·채팅이 아니라 DM으로 받는다.