From 548e341a4d0fccd1b7284677a6116d55d6dddd31 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 21 Sep 2026 16:06:41 +0900 Subject: [PATCH] =?UTF-8?q?[Chore]=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=EC=9D=84=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=97=B0=EA=B2=B0=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=EC=9D=98=20=EB=B9=88=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=EA=B3=BC=20=ED=8B=80=EB=A6=B0=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ono-alerts.yml 규칙 7개는 평가되고 있었지만 prometheus.prod.yml 에 alerting 설정이 없어서 어디로도 가지 않았다. Alertmanager 를 붙이고 앱 에러 알림과 같은 DISCORD_WEBHOOK_URL 로 보낸다. 웹훅 주소는 설정 파일에 적지 않으려고 컨테이너가 뜰 때 환경변수를 파일로 옮겨 webhook_url_file 로 읽는다 - Redis 와 RabbitMQ 는 떠 있는데 수집을 안 해서 Dependency 대시보드 패널 4개가 항상 비어 있었다. redis-exporter 를 추가하고, RabbitMQ 는 이미지에 내장된 rabbitmq_prometheus 의 detailed 엔드포인트에서 큐별 적체와 컨슈머 수를 가져온다. 합계만 보면 큐 하나의 컨슈머가 죽어도 티가 안 나기 때문이다 - 5xx 비율은 단위가 0~1 인데 경고 기준이 5 라서 에러율이 100% 여도 초록이었고, 평균 응답시간은 기준이 5ms 라 평소에도 빨강이었다. 기준을 고치고 평균 대신 p50 을 둔다 - Server/Process Memory 패널은 JVM 이 내보내지 않는 process_resident_memory_bytes 를 그려서 실제로는 mysql-exporter 메모리를 보여주고 있었다. JVM 메모리로 바꾸고, CPU 도 호스트 전체가 아닌 앱 프로세스 기준으로 바꾼다 - 하루 요청의 96% 가 스캐너라 요청 지표를 /api 로 한정하고, 행동 지표에서는 security_rejected, not_found, unmapped_request 를 뺀다. 트래픽이 적어 5분 창은 너무 튀기 때문에 1시간 창으로 보고 p99 는 뺐다 - 외부 API 성공률은 호출이 없으면 0% 빨강으로 보였다. 호출이 없을 때는 값을 비우고 호출 없음으로 표시한다 - promtail 이 Loki 자기 로그를 수집해서 하루 로그의 97% 를 차지하고 있었다. 수집 대상에서 빼고 Alertmanager 로그를 넣는다 - 배포와 모니터링 재시작 워크플로에 새 컨테이너를 추가하고, restart-monitoring 의 .env.prod 에 빠져 있던 DISCORD_WEBHOOK_URL 을 넣는다 Closes #335 --- .github/workflows/ci-dev.yml | 10 +- .github/workflows/ci-prod.yml | 10 +- .github/workflows/restart-monitoring.yml | 9 +- docker-compose.dev.yml | 13 ++ docker-compose.prod.yml | 37 ++++++ monitoring/alertmanager.prod.yml | 19 +++ .../dashboards/ono-backend-overview.json | 125 +++++++++--------- .../dashboards/ono-dependency-overview.json | 34 ++--- .../ono-user-behavior-overview.json | 15 ++- monitoring/prometheus.dev.yml | 12 ++ monitoring/prometheus.prod.yml | 20 +++ monitoring/promtail.dev.yml | 3 +- monitoring/promtail.prod.yml | 4 +- 13 files changed, 207 insertions(+), 104 deletions(-) create mode 100644 monitoring/alertmanager.prod.yml diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index cdbed3c0..ce9e31f4 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -158,13 +158,13 @@ jobs: - name: Deploy Monitoring Stack run: | docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring pull \ - mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev - docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring stop prometheus-dev grafana-dev loki-dev promtail-dev mysql-exporter-dev || true - docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring rm -f prometheus-dev grafana-dev loki-dev promtail-dev mysql-exporter-dev || true + mysql-exporter-dev redis-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring stop prometheus-dev grafana-dev loki-dev promtail-dev mysql-exporter-dev redis-exporter-dev || true + docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring rm -f prometheus-dev grafana-dev loki-dev promtail-dev mysql-exporter-dev redis-exporter-dev || true docker-compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring up -d --force-recreate \ - mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev docker-compose -f docker-compose.dev.yml --env-file .env.dev ps \ - mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev shell: bash - name: Verify Monitoring Integration diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index bad6a1b5..49f80516 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -219,13 +219,13 @@ jobs: - name: Deploy Monitoring Stack run: | docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring pull \ - mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod - docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring stop prometheus-prod grafana-prod loki-prod promtail-prod mysql-exporter-prod || true - docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring rm -f prometheus-prod grafana-prod loki-prod promtail-prod mysql-exporter-prod || true + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring stop prometheus-prod grafana-prod loki-prod promtail-prod mysql-exporter-prod redis-exporter-prod alertmanager-prod || true + docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring rm -f prometheus-prod grafana-prod loki-prod promtail-prod mysql-exporter-prod redis-exporter-prod alertmanager-prod || true docker-compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \ - mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod docker-compose -f docker-compose.prod.yml --env-file .env.prod ps \ - mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod shell: bash - name: Verify Monitoring Integration diff --git a/.github/workflows/restart-monitoring.yml b/.github/workflows/restart-monitoring.yml index 3ee6a7aa..231d67c7 100644 --- a/.github/workflows/restart-monitoring.yml +++ b/.github/workflows/restart-monitoring.yml @@ -45,6 +45,7 @@ jobs: RABBITMQ_PASSWORD=${{ secrets.RABBITMQ_PASSWORD }} GRAFANA_ADMIN_USER=${{ secrets.GRAFANA_ADMIN_USER }} GRAFANA_ADMIN_PASSWORD=${{ secrets.GRAFANA_ADMIN_PASSWORD }} + DISCORD_WEBHOOK_URL=${{ secrets.DISCORD_WEBHOOK_URL }} GRAFANA_PROD_ROOT_URL=https://ono-prod.seungminki.shop/grafana/ GRAFANA_PROD_SERVE_FROM_SUB_PATH=true PROMETHEUS_PROD_EXTERNAL_URL=https://ono-prod.seungminki.shop/prometheus/ @@ -56,16 +57,16 @@ jobs: if: ${{ github.event.inputs.target == 'dev' }} run: | docker compose -f docker-compose.dev.yml --env-file .env.dev --profile monitoring up -d --force-recreate \ - mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev docker compose -f docker-compose.dev.yml --env-file .env.dev ps \ - mysql-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev + mysql-exporter-dev redis-exporter-dev prometheus-dev loki-dev promtail-dev grafana-dev shell: bash - name: Restart monitoring stack (prod) if: ${{ github.event.inputs.target == 'prod' }} run: | docker compose -f docker-compose.prod.yml --env-file .env.prod --profile monitoring up -d --force-recreate \ - mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod docker compose -f docker-compose.prod.yml --env-file .env.prod ps \ - mysql-exporter-prod prometheus-prod loki-prod promtail-prod grafana-prod + mysql-exporter-prod redis-exporter-prod alertmanager-prod prometheus-prod loki-prod promtail-prod grafana-prod shell: bash diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2870d6fe..40224926 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -209,6 +209,19 @@ services: profiles: - monitoring + redis-exporter-dev: + image: oliver006/redis_exporter:v1.67.0 + container_name: ono-redis-exporter-dev + restart: unless-stopped + environment: + REDIS_ADDR: redis://redis-dev:6379 + depends_on: + - redis-dev + networks: + - ono-network + profiles: + - monitoring + # Grafana - DEVELOPMENT (Profile: monitoring) grafana-dev: image: grafana/grafana:latest diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b928c4ed..9816239c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -275,6 +275,41 @@ services: profiles: - monitoring + redis-exporter-prod: + image: oliver006/redis_exporter:v1.67.0 + container_name: ono-redis-exporter-prod + restart: unless-stopped + environment: + REDIS_ADDR: redis://redis-prod:6379 + depends_on: + - redis-prod + networks: + - ono-network + profiles: + - monitoring + + # Alertmanager - PRODUCTION (Profile: monitoring) + # Prometheus 알림을 디스코드로 보낸다. 웹훅 주소를 설정 파일에 적지 않으려고 + # 뜰 때 환경변수를 파일로 옮기고 alertmanager.prod.yml 의 webhook_url_file 로 읽는다. + alertmanager-prod: + image: prom/alertmanager:v0.28.1 + container_name: ono-alertmanager-prod + restart: unless-stopped + environment: + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} + entrypoint: ["/bin/sh", "-c"] + command: + - | + printf '%s' "$$DISCORD_WEBHOOK_URL" > /tmp/discord_webhook_url + exec /bin/alertmanager --config.file=/etc/alertmanager/alertmanager.yml --storage.path=/alertmanager --cluster.listen-address= + volumes: + - ./monitoring/alertmanager.prod.yml:/etc/alertmanager/alertmanager.yml:ro + - alertmanager_prod_data:/alertmanager + networks: + - ono-network + profiles: + - monitoring + # Grafana - PRODUCTION (Profile: monitoring) grafana-prod: image: grafana/grafana:latest @@ -320,3 +355,5 @@ volumes: driver: local grafana_prod_data: driver: local + alertmanager_prod_data: + driver: local diff --git a/monitoring/alertmanager.prod.yml b/monitoring/alertmanager.prod.yml new file mode 100644 index 00000000..fbb32722 --- /dev/null +++ b/monitoring/alertmanager.prod.yml @@ -0,0 +1,19 @@ +# Prometheus 가 평가한 알림(monitoring/alerts/ono-alerts.yml)을 디스코드로 보낸다. +# 웹훅 주소는 앱 에러 알림과 같은 DISCORD_WEBHOOK_URL 을 쓰고, 컨테이너가 뜰 때 +# 환경변수를 파일로 옮겨 둔다(docker-compose.prod.yml 의 alertmanager-prod 참고). +# 설정 파일에 비밀값을 적지 않기 위해 webhook_url 대신 webhook_url_file 을 쓴다. + +route: + receiver: discord + group_by: [alertname, job] + # 같은 알림이 여러 개 동시에 뜨면 30초 모아서 한 번에 보낸다. + group_wait: 30s + group_interval: 5m + # 해결되지 않은 알림은 4시간마다 다시 알린다. + repeat_interval: 4h + +receivers: + - name: discord + discord_configs: + - webhook_url_file: /tmp/discord_webhook_url + send_resolved: true diff --git a/monitoring/grafana/dashboards/ono-backend-overview.json b/monitoring/grafana/dashboards/ono-backend-overview.json index 9eadff30..90a48b86 100644 --- a/monitoring/grafana/dashboards/ono-backend-overview.json +++ b/monitoring/grafana/dashboards/ono-backend-overview.json @@ -37,12 +37,8 @@ "mode": "absolute", "steps": [ { - "color": "green", + "color": "blue", "value": null - }, - { - "color": "red", - "value": 1 } ] }, @@ -73,15 +69,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m])) or vector(0)", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])) or vector(0)", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "Request Rate (RPS)", - "type": "stat" + "title": "API 요청률 (1h)", + "type": "stat", + "description": "봇을 뺀 /api 요청만 센다. 최근 1시간 평균이라 0.004 req/s 는 1시간에 약 15건이다." }, { "datasource": { @@ -101,9 +96,13 @@ "color": "green", "value": null }, + { + "color": "orange", + "value": 300 + }, { "color": "red", - "value": 5 + "value": 1000 } ] }, @@ -134,15 +133,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "1000 * (sum(rate(http_server_requests_seconds_sum{job=~\"$job\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=~\"$job\"}[5m])))", + "expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "Avg Response Time", - "type": "stat" + "title": "API p50 응답시간 (1h)", + "type": "stat", + "description": "절반의 요청이 이 시간 안에 끝났다는 뜻이다. 평균과 달리 느린 요청 몇 개에 끌려가지 않는다." }, { "datasource": { @@ -162,9 +160,13 @@ "color": "green", "value": null }, + { + "color": "orange", + "value": 0.01 + }, { "color": "red", - "value": 5 + "value": 0.05 } ] }, @@ -195,15 +197,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{job=~\"$job\"}[5m]))", + "expr": "(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\",status=~\"5..\"}[1h])) or vector(0)) / clamp_min(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])), 1e-9)", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "5xx Error Ratio", - "type": "stat" + "title": "API 5xx 비율 (1h)", + "type": "stat", + "description": "최근 1시간 /api 요청 중 서버 오류 비율. 5xx 가 없으면 0% 로 보인다. 1% 주황, 5% 빨강." }, { "datasource": { @@ -260,15 +261,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "system_cpu_usage{job=~\"$job\"}", + "expr": "max(process_cpu_usage{job=~\"$job\"})", "legendFormat": "", - "range": true, "refId": "A" } ], - "title": "System CPU Usage", - "type": "stat" + "title": "앱 CPU", + "type": "stat", + "description": "백엔드 프로세스의 CPU 사용률. 호스트 전체 CPU(system_cpu_usage)가 아니다." }, { "datasource": { @@ -306,15 +306,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))", + "expr": "topk(10, sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])))", "legendFormat": "{{method}} {{uri}}", - "range": true, "refId": "A" } ], - "title": "Traffic by URI", - "type": "timeseries" + "title": "API 요청 (URI Top10, 1h)", + "type": "timeseries", + "description": "많이 불린 /api 10개. 1시간 창이라 선이 완만하다." }, { "datasource": { @@ -352,22 +351,19 @@ }, "targets": [ { - "editorMode": "code", - "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000", - "legendFormat": "p95", - "range": true, + "expr": "histogram_quantile(0.5, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000", + "legendFormat": "p50", "refId": "A" }, { - "editorMode": "code", - "expr": "histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000", - "legendFormat": "p99", - "range": true, + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000", + "legendFormat": "p95", "refId": "B" } ], - "title": "Latency (p95/p99)", - "type": "timeseries" + "title": "API 응답시간 p50 / p95 (1h)", + "type": "timeseries", + "description": "p50 은 보통 사용자, p95 는 느린 쪽 사용자가 겪는 시간. p50 은 그대로인데 p95 만 오르면 특정 API 나 특정 상황만 느린 것이다. 트래픽이 적어 p99 는 뺐다." }, { "datasource": { @@ -420,7 +416,8 @@ } ], "title": "JVM Heap", - "type": "timeseries" + "type": "timeseries", + "description": "Used 가 톱니 모양으로 오르내리는 건 GC 라서 정상이다. Max 의 70% 이상에 계속 붙어 있으면 메모리가 모자란 것이다." }, { "datasource": { @@ -480,7 +477,8 @@ } ], "title": "HikariCP Connections", - "type": "timeseries" + "type": "timeseries", + "description": "Pending 이 0 보다 크면 DB 커넥션을 기다리는 요청이 있다는 뜻이다. 느린 쿼리나 풀 부족 신호다." }, { "datasource": { @@ -517,22 +515,19 @@ }, "targets": [ { - "editorMode": "code", - "expr": "process_resident_memory_bytes{job=~\"$job\"}", - "legendFormat": "Process RSS", - "range": true, + "expr": "sum by (area) (jvm_memory_used_bytes{job=~\"$job\"})", + "legendFormat": "{{area}} used", "refId": "A" }, { - "editorMode": "code", - "expr": "jvm_memory_used_bytes{job=~\"$job\",area=\"heap\"}", - "legendFormat": "JVM Heap Used", - "range": true, + "expr": "sum(jvm_memory_max_bytes{job=~\"$job\",area=\"heap\"})", + "legendFormat": "heap max", "refId": "B" } ], - "title": "Server/Process Memory", - "type": "timeseries" + "title": "JVM 메모리 (heap, non-heap)", + "type": "timeseries", + "description": "JVM 이 쓰는 메모리. 이전에는 process_resident_memory_bytes 를 그렸는데 JVM 은 이 지표를 내보내지 않아 mysql-exporter 메모리가 그려지고 있었다." }, { "datasource": { @@ -569,15 +564,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[5m])) / clamp_min(sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[5m])), 1e-9)))", + "expr": "topk(20, 1000 * (sum by (uri, method) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri=~\"/api.*\"}[1h])) / clamp_min(sum by (uri, method) (rate(http_server_requests_seconds_count{job=~\"$job\",uri=~\"/api.*\"}[1h])), 1e-9)))", "legendFormat": "{{method}} {{uri}}", - "range": true, "refId": "A" } ], - "title": "API Avg Latency by URI (Top20)", - "type": "timeseries" + "title": "API 평균 응답시간 (URI Top20, 1h)", + "type": "timeseries", + "description": "URI 별 평균. 느린 API 를 찾는 용도다." }, { "datasource": { @@ -614,15 +608,14 @@ }, "targets": [ { - "editorMode": "code", - "expr": "topk(20, histogram_quantile(0.95, sum by (le, uri, method) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[5m]))) * 1000)", + "expr": "topk(20, histogram_quantile(0.95, sum by (le, uri, method) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri=~\"/api.*\"}[1h]))) * 1000)", "legendFormat": "p95 {{method}} {{uri}}", - "range": true, "refId": "A" } ], - "title": "API p95 Latency by URI (Top20)", - "type": "timeseries" + "title": "API p95 응답시간 (URI Top20, 1h)", + "type": "timeseries", + "description": "URI 별 p95. 호출이 적은 API 는 한두 건이 곧 p95 라서 튈 수 있다." } ], "refresh": "30s", @@ -644,7 +637,7 @@ "type": "prometheus", "uid": "prometheus" }, - "definition": "label_values(up, job)", + "definition": "label_values(up{job=~\"ono-backend-.*\"}, job)", "hide": 0, "includeAll": true, "label": "job", @@ -652,7 +645,7 @@ "name": "job", "options": [], "query": { - "query": "label_values(up, job)", + "query": "label_values(up{job=~\"ono-backend-.*\"}, job)", "refId": "PrometheusVariableQueryEditor-VariableQuery" }, "refresh": 1, diff --git a/monitoring/grafana/dashboards/ono-dependency-overview.json b/monitoring/grafana/dashboards/ono-dependency-overview.json index 032174e4..2e1bd0ae 100644 --- a/monitoring/grafana/dashboards/ono-dependency-overview.json +++ b/monitoring/grafana/dashboards/ono-dependency-overview.json @@ -298,20 +298,21 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rabbitmq_queue_messages_ready{job=~\"$rabbit_job\"})", - "legendFormat": "Ready", + "expr": "sum by (queue) (rabbitmq_detailed_queue_messages_ready{job=~\"$rabbit_job\"})", + "legendFormat": "{{queue}} ready", "range": true, "refId": "A" }, { "editorMode": "code", - "expr": "sum(rabbitmq_queue_messages_unacked{job=~\"$rabbit_job\"})", - "legendFormat": "Unacked", + "expr": "sum by (queue) (rabbitmq_detailed_queue_messages_unacked{job=~\"$rabbit_job\"})", + "legendFormat": "{{queue}} unacked", "range": true, "refId": "B" } ], - "title": "RabbitMQ Queue Depth", + "description": "큐별로 처리 대기(ready)와 처리 중(unacked) 메시지 수. ready 가 계속 늘면 컨슈머가 못 따라가거나 멈춘 것이다.", + "title": "RabbitMQ 큐 적체", "type": "timeseries" }, { @@ -332,20 +333,21 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rabbitmq_queue_consumers{job=~\"$rabbit_job\"})", - "legendFormat": "Consumers", + "expr": "sum by (queue) (rabbitmq_detailed_queue_consumers{job=~\"$rabbit_job\"})", + "legendFormat": "{{queue}} consumers", "range": true, "refId": "A" }, { "editorMode": "code", - "expr": "sum(rabbitmq_up{job=~\"$rabbit_job\"})", - "legendFormat": "Exporter Up", + "expr": "sum(up{job=~\"$rabbit_job\"})", + "legendFormat": "scrape up", "range": true, "refId": "B" } ], - "title": "RabbitMQ Consumers & Up", + "description": "큐별 컨슈머 수. 0 이 된 큐는 메시지를 아무도 처리하지 않는다. scrape up 이 0 이면 RabbitMQ 지표 자체를 못 가져오는 것이다.", + "title": "RabbitMQ 큐별 컨슈머", "type": "timeseries" }, { @@ -364,6 +366,7 @@ {"color": "green", "value": 0.99} ] }, + "noValue": "최근 1시간 호출 없음", "unit": "percentunit" }, "overrides": [] @@ -385,13 +388,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\",outcome=\"success\"}[5m])) / clamp_min(sum(rate(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m])), 1e-9)", + "expr": "sum(increase(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\",outcome=\"success\"}[1h])) / (sum(increase(ono_external_requests_seconds_count{job=~\"$backend_job\",dependency=~\"$dependency\"}[1h])) > 0)", "legendFormat": "Success Rate", "range": true, "refId": "A" } ], - "title": "External Dependency Success Rate", + "description": "최근 1시간 외부 API(OpenAI, S3 등) 호출 성공률. 호출이 없으면 0% 대신 호출 없음으로 표시한다.", + "title": "외부 API 성공률 (1h)", "type": "stat" }, { @@ -412,13 +416,13 @@ "targets": [ { "editorMode": "code", - "expr": "histogram_quantile(0.95, sum(rate(ono_external_requests_seconds_bucket{job=~\"$backend_job\",dependency=~\"$dependency\"}[5m])) by (le, dependency))", + "expr": "histogram_quantile(0.95, sum(rate(ono_external_requests_seconds_bucket{job=~\"$backend_job\",dependency=~\"$dependency\"}[1h])) by (le, dependency))", "legendFormat": "{{dependency}} p95", "range": true, "refId": "A" } ], - "title": "External Dependency Latency p95", + "title": "외부 API p95 응답시간 (1h)", "type": "timeseries" }, { @@ -449,7 +453,7 @@ "type": "timeseries" } ], - "refresh": "10s", + "refresh": "1m", "schemaVersion": 39, "style": "dark", "tags": ["ono", "dependencies", "infra"], diff --git a/monitoring/grafana/dashboards/ono-user-behavior-overview.json b/monitoring/grafana/dashboards/ono-user-behavior-overview.json index c0276093..e2bb37cf 100644 --- a/monitoring/grafana/dashboards/ono-user-behavior-overview.json +++ b/monitoring/grafana/dashboards/ono-user-behavior-overview.json @@ -58,7 +58,7 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])) or vector(0)", + "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m])) or vector(0)", "legendFormat": "actions/sec", "range": true, "refId": "A" @@ -104,13 +104,14 @@ "targets": [ { "editorMode": "code", - "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",outcome=~\"client_error|server_error\"}[5m])) / clamp_min(sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])), 1e-9)", + "expr": "sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\",outcome=~\"client_error|server_error\"}[1h])) / clamp_min(sum(rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[1h])), 1e-9)", "legendFormat": "error ratio", "range": true, "refId": "A" } ], - "title": "Action Error Ratio", + "description": "최근 1시간 실제 API 호출 중 4xx, 5xx 비율. 없는 경로를 긁는 스캐너 요청(security_rejected, not_found, unmapped_request)은 뺐다.", + "title": "행동 에러율 (1h, 봇 제외)", "type": "stat" }, { @@ -180,7 +181,7 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (authenticated, authority) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m]))", + "expr": "sum by (authenticated, authority) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m]))", "legendFormat": "{{authenticated}} / {{authority}}", "range": true, "refId": "A" @@ -210,7 +211,7 @@ "targets": [ { "editorMode": "code", - "expr": "topk(10, sum by (domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])))", + "expr": "topk(10, sum by (domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m])))", "legendFormat": "{{domain}}", "range": true, "refId": "A" @@ -240,7 +241,7 @@ "targets": [ { "editorMode": "code", - "expr": "topk(15, sum by (domain, action, method) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m])))", + "expr": "topk(15, sum by (domain, action, method) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m])))", "legendFormat": "{{domain}}.{{action}} {{method}}", "range": true, "refId": "A" @@ -300,7 +301,7 @@ "targets": [ { "editorMode": "code", - "expr": "sum by (outcome, domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\"}[5m]))", + "expr": "sum by (outcome, domain) (rate(ono_user_behavior_duration_seconds_count{job=~\"$backend_job\",action!~\"security_rejected|not_found|unmapped_request\"}[5m]))", "legendFormat": "{{outcome}} / {{domain}}", "range": true, "refId": "A" diff --git a/monitoring/prometheus.dev.yml b/monitoring/prometheus.dev.yml index eec31420..5a349b40 100644 --- a/monitoring/prometheus.dev.yml +++ b/monitoring/prometheus.dev.yml @@ -14,3 +14,15 @@ scrape_configs: - job_name: "ono-mysql-dev" static_configs: - targets: ["mysql-exporter-dev:9104"] + + - job_name: "ono-redis-dev" + static_configs: + - targets: ["redis-exporter-dev:9121"] + + # prod 와 같다. 큐별 적체와 컨슈머 수를 보려고 detailed 엔드포인트를 쓴다. + - job_name: "ono-rabbitmq-dev" + metrics_path: "/metrics/detailed" + params: + family: ["queue_coarse_metrics", "queue_consumer_count"] + static_configs: + - targets: ["rabbitmq-dev:15692"] diff --git a/monitoring/prometheus.prod.yml b/monitoring/prometheus.prod.yml index 423980b4..2c556803 100644 --- a/monitoring/prometheus.prod.yml +++ b/monitoring/prometheus.prod.yml @@ -5,6 +5,12 @@ global: rule_files: - /etc/prometheus/alerts/ono-alerts.yml +# 규칙이 평가돼도 받을 곳이 없으면 아무도 모른다. Alertmanager 가 디스코드로 보낸다. +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager-prod:9093"] + scrape_configs: - job_name: "ono-backend-prod-blue" metrics_path: "/actuator/prometheus" @@ -19,3 +25,17 @@ scrape_configs: - job_name: "ono-mysql-prod" static_configs: - targets: ["mysql-exporter-prod:9104"] + + - job_name: "ono-redis-prod" + static_configs: + - targets: ["redis-exporter-prod:9121"] + + # RabbitMQ 이미지에 내장된 rabbitmq_prometheus 플러그인을 쓴다. + # 기본 /metrics 는 전체 합계만 주기 때문에, 큐 하나의 컨슈머가 죽어도 티가 안 난다. + # 큐별 적체와 컨슈머 수를 보려고 detailed 엔드포인트에서 필요한 묶음만 가져온다. + - job_name: "ono-rabbitmq-prod" + metrics_path: "/metrics/detailed" + params: + family: ["queue_coarse_metrics", "queue_consumer_count"] + static_configs: + - targets: ["rabbitmq-prod:15692"] diff --git a/monitoring/promtail.dev.yml b/monitoring/promtail.dev.yml index 7fb01b99..5a494a36 100644 --- a/monitoring/promtail.dev.yml +++ b/monitoring/promtail.dev.yml @@ -15,6 +15,8 @@ scrape_configs: refresh_interval: 5s filters: - name: name + # ono-loki-dev 는 넣지 않는다. Loki 는 자기 로그를 많이 찍어서 + # 넣으면 하루 로그의 97%를 Loki 자기 로그가 차지한다(2026-09-21 기준 약 15만 줄 중 14.8만 줄). values: - ono-app-dev - ono-mysql-dev @@ -22,7 +24,6 @@ scrape_configs: - ono-rabbitmq-dev - ono-prometheus-dev - ono-grafana-dev - - ono-loki-dev - ono-promtail-dev - ono-mysql-exporter-dev relabel_configs: diff --git a/monitoring/promtail.prod.yml b/monitoring/promtail.prod.yml index 1083fcfa..7c319d27 100644 --- a/monitoring/promtail.prod.yml +++ b/monitoring/promtail.prod.yml @@ -15,6 +15,8 @@ scrape_configs: refresh_interval: 5s filters: - name: name + # ono-loki-prod 는 넣지 않는다. Loki 는 자기 로그를 많이 찍어서 + # 넣으면 하루 로그의 97%를 Loki 자기 로그가 차지한다(2026-09-21 기준 약 15만 줄 중 14.8만 줄). values: - ono-app-prod-blue - ono-app-prod-green @@ -23,9 +25,9 @@ scrape_configs: - ono-rabbitmq-prod - ono-prometheus-prod - ono-grafana-prod - - ono-loki-prod - ono-promtail-prod - ono-mysql-exporter-prod + - ono-alertmanager-prod relabel_configs: - source_labels: [__meta_docker_container_name] regex: '/(.*)'