From 10436afe8676fff84becb9ce341508b4615c1374 Mon Sep 17 00:00:00 2001 From: selee1012 Date: Thu, 15 Oct 2026 18:28:37 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B2=BD=EB=B3=B4=EB=A5=BC=201?= =?UTF-8?q?=EB=B6=84=20=ED=8F=89=EA=B0=80=EB=A1=9C=20=EC=A4=84=EC=9D=B4?= =?UTF-8?q?=EA=B3=A0=20RDS=20CPU=C2=B7=EB=A9=94=EB=AA=A8=EB=A6=AC=EB=A5=BC?= =?UTF-8?q?=20P0=20=EB=A1=9C=20=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 노션 「모니터링」 10/15 리뷰 반영. P0 는 조건 충족 후 1~2분 안에 호출, P1 은 대부분 5분 이내. - API-01 판정 창 5분 → 2분, API-02 → 3분(slo_core) - 공개 점검을 한 실행에 30초 간격 두 번 — EDGE-01 은 두 번 연속 실패, EDGE-02 는 2분(4회) 중 2회 - NATIVE-01 1분, NATIVE-02 2분, RES-01/03/04 1분, OBS-01 하트비트 2분 - 신규 P0: DB-10 RDS CPU ≥99% 1분, DB-11 Freeable ≤RAM 5% 1분 + Swap 증가(DbMemoryCritical) - OBS-04 를 Healthchecks.io 전까지 CloudWatch 하트비트 5분 누락 P0 로 대신 - P1 ECS·JVM·풀·RDS·Redis·SQS·외부 연동 창을 표대로 줄임 - 시험: P0 평가 창이 2분을 넘지 않는지 고정 Co-Authored-By: Claude Opus 5.5 --- infra/monitoring/ARCHITECTURE.md | 4 +- infra/monitoring/README.md | 13 +- infra/monitoring/alarm-catalog.yaml | 12 +- infra/monitoring/alerting/README.md | 12 +- infra/monitoring/alerting/catalog.py | 133 ++++++++++-------- infra/monitoring/alerting/dashboard.py | 7 +- .../monitoring/alerting/observe_dashboard.py | 3 +- infra/monitoring/alerting/tests_catalog.py | 23 ++- infra/monitoring/lambdas/deploy.py | 2 +- infra/monitoring/lambdas/edge_probe.py | 26 +++- infra/monitoring/lambdas/resource_core.py | 42 +++--- infra/monitoring/lambdas/resource_monitor.py | 9 +- infra/monitoring/lambdas/slo_core.py | 8 +- .../lambdas/tests/test_edge_probe.py | 23 +++ .../lambdas/tests/test_resource_core.py | 38 +++-- .../monitoring/lambdas/tests/test_slo_core.py | 22 ++- 16 files changed, 249 insertions(+), 128 deletions(-) diff --git a/infra/monitoring/ARCHITECTURE.md b/infra/monitoring/ARCHITECTURE.md index 5c6802c9..79ebd13c 100644 --- a/infra/monitoring/ARCHITECTURE.md +++ b/infra/monitoring/ARCHITECTURE.md @@ -36,11 +36,11 @@ flowchart LR sch --> res[λ resource-monitor
ECS·RES-01~04·RDS·백업] probe2[λ edge-probe 버지니아] cwv[CloudWatch RuleUp/SLO·Edge·Observe·Ops CriticalCapacityFlag] - a1{P0: SLO·API-01·EDGE-01·NATIVE-01·RES
P1: 하트비트·TLS·EDGE-02} + a1{P0: SLO·API-01·EDGE-01·NATIVE-01·RES·OBS-04
P1: 하트비트·TLS·EDGE-02} r53[Route 53 헬스체크 /health/public] sns2[SNS us-east-1] end - probe1[λ edge-probe 오리건] -->|ProbeFailure| cwv + probe1[λ edge-probe 오리건
30초 간격 2회] -->|ProbeFailure·ProbeAttemptFailures| cwv probe1 & probe2 -->|GET /health/public| cf r53 --> cf slo -->|GetMetricData 72h| cwm diff --git a/infra/monitoring/README.md b/infra/monitoring/README.md index 558976fe..18f8ab36 100644 --- a/infra/monitoring/README.md +++ b/infra/monitoring/README.md @@ -14,7 +14,7 @@ | 앱 원천 | `observability/sli` · `observability/ops` · `observability/metric` | CUJ 10개 Operation 의 `RuleUp/SLI`(Eligible·Bad·LatencyBad·DurationMs, **0 포함 매분**), `RuleUp/Ops`(JVM·Hikari·Tomcat·하드 메모리·공급자 호출·토큰·캐시·불변식), `RuleUp/DB`(연결·락·데드락·장기 트랜잭션), `RuleUp/Traffic`(FLOW·4xx), `RuleUp/Security`. 태스크마다 1분 버퍼 → PutMetricData | | 공개 점검 경로 | `GET /health/public` | 무인증·고정 본문 `ruleup-ok`·no-store. DB·Redis 를 묻지 않는다 | | 감시 Lambda | `lambdas/` | 버지니아 `slo-calculator`(Rule Flag·버짓·이상 탐지) · `resource-monitor`(ECS·RES·RDS·백업), 오리건·버지니아 `edge-probe` | -| 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04 | +| 경보 | `alerting/catalog.py` → `p0_p1.py` | 카탈로그 ID 그대로. P0 = SLO-01/02·API-01·EDGE-01·NATIVE-01/02·RES-01~04·DB-10/11·OBS-04. 1분 주기 평가, P0 는 충족 후 1~2분 안에 호출(10/15 리뷰) | | 전달 | `alerting/routing.py` · `slack_channels.py` · `p0_mention.py` · `slack_fallback.py` · `security_events.py` | 서울·버지니아 토픽 → Amazon Q Slack, P0 멘션, 전달 실패 → 이메일, 보안 이벤트 | | 대시보드 | `alerting/dashboard.py` · `observe_dashboard.py` · `business_dashboard.py` | `ruleup--ops`(OPS-1~5) · `ruleup--observe`(OBS-1~4) · `ruleup-prod-business` | | 배포 보호 | `.github/scripts/monitoring_preflight.py` | ALARM·stale·EligibleCount 누락·계측 불일치·BudgetFreeze (기본 warn) | @@ -44,10 +44,13 @@ - **분 버킷 DynamoDB 없음** — CloudWatch 가 1분 해상도를 15일 보관하고 GetMetricData 는 지표 개수로 과금돼, 72시간을 매분 다시 읽는 비용이 노션 추정(월 약 130만 조회 단위)과 같다. 30일 버짓은 1시간 해상도로 15분마다. - **DB 수집기는 앱 안**(ShedLock 1분) — 서울 전용 VPC Lambda·읽기 계정 대신. 앱과 함께 멈추는 한계는 DB 지표가 전부 P1 이라 받아들인다. `information_schema.innodb_trx`·`INNODB_METRICS` 는 PROCESS 권한이 필요하다 — 없으면 db-collector 하트비트가 빠져 OBS-01 이 운다. +- **30초 간격 점검은 한 실행 안에서 두 번** — Scheduler 최소 주기가 1분이라 edge-probe 가 30초 쉬고 한 번 더 부른다(함수 timeout 50초). EDGE-01 leaf 는 「그 분 두 번 모두 실패」, EDGE-02 는 「2분(4회) 중 2회」 — 노션의 「3회 중 2회」와 같은 민감도. +- **RDS CPU·메모리 P0(DB-10/11)** — 10/15 리뷰 결정. 카탈로그 §4.2 는 「RDS CPU 99% 는 단독 P0 아님」이었다. 메모리는 resource-monitor 가 Freeable ≤RAM 5% 1분 + Swap 증가를 `DbMemoryCritical` Flag 로 낸다(CloudWatch 지연 탓에 실제 호출은 충족 후 2~3분). +- **OBS-04 는 CloudWatch 로 대신** — Healthchecks.io 미계약. slo-calculator·resource-monitor 하트비트 5분 누락이 P0. 버지니아 리전 전체 장애는 못 잡는다. - **외부 점검은 도쿄 대신 오리건(us-west-2)** — 조직 SCP 가 아시아 리전(ap-northeast-1·3, ap-southeast-1)을 전부 막는다. 버지니아와 다른 리전이라 EDGE-01 의 「두 지점 독립 확인」은 그대로다. - **점검 릴레이 함수 없음** — edge-probe 가 결과를 검증하고 버지니아로 직접 PutMetricData. - **하드 메모리는 태스크가 직접 잰다**(ECS 메타데이터 v4) — Container Insights 태스크 지표 대신. TaskId 차원 없이 같은 시계열에 쓰고 Maximum/Minimum/SampleCount 로 「가장 찬·가장 여유 있는·보고한 태스크 수」를 본다. -- **EXT-01·TOKEN-01·EXT-02 는 경보 metric math**(10분 창·표본 게이트) — 별도 Flag 계산기 없이 같은 의미. +- **EXT-01·TOKEN-01·EXT-02 는 경보 metric math**(5분 창·표본 게이트) — 별도 Flag 계산기 없이 같은 의미. - **OR 조건은 행마다 경보를 따로**(Composite 과금 없이). EDGE-01 만 AND Composite. - **사전 점검의 계측 불일치는 ALB 요청 수 대신 앱의 `RuleUp/Traffic ApiRequestCount`**(`/api/**`, 합성 점검 제외)와 비교 — ALB 는 경로를 못 나눠 Route 53·외부 점검의 `/health/public` 호출(분당 십수 건)이 섞이고, 트래픽이 적을 때 늘 「계측 누락」으로 걸렸다. - **OPS-4 Redis 칸에 히트율** — 노션 §4.2 에는 없다. 경보 없이 화면에만. 앱 캐시(`@Cacheable`)는 Caffeine 이라 탐색 ZSET·rate limit 같은 Redis 직접 조회의 적중률이다. @@ -57,7 +60,7 @@ | 항목 | 막는 것 | 그 전까지 | |---|---|---| | PagerDuty(G01·G03), OBS-03/05/06 | 계약·계정 없음 | P0 = Slack 긴급 채널 멘션. 원장 `sns.pagerduty` 에 토픽 이름을 넣으면 P0 경보에 붙는다 | -| OBS-04 외부 watchdog | Healthchecks.io 없음 | 원장 `healthchecks.obs04_ping_url` 이 생기면 계산기가 바로 ping. OBS-01(slo-calculator) P1 | +| OBS-04 외부 watchdog | Healthchecks.io 없음 | CloudWatch 하트비트 5분 누락 P0 로 대신(같은 리전 장애는 못 잡음). 원장 `healthchecks.obs04_ping_url` 이 생기면 계산기가 외부 ping 도 한다 | | APP-01~04·ERR-01 (G06) | Crashlytics·Sentry 연결 미검증 | SLO·ERROR 로그 패널 | | AUTH-01 | 지점별 격리 시험 계정·Secrets | C01 SLO(실사용 로그인) | | COST-01/02 (G07) | 승인된 일 예산 없음 | — (LLM 호출 실패·지연은 EXT-01/02) | @@ -67,8 +70,8 @@ ## G02·G03 재현 (stg) -- API-01: stg 에 5분간 같은 Operation 으로 실패 ≥10건(예: 계약 회귀 400 을 내는 잘못된 sync 본문) → `ruleup-stg-P0-API-01-sync` ALARM → 정상 요청 20건 이상 → Flag=0 → OK 2회. -- 입력 중단 유지: slo-calculator 스케줄을 끄면 P0 는 ALARM 그대로(ignore), OBS-01(slo-calculator) 이 3분 뒤 운다. +- API-01: stg 에 2분간 같은 Operation 으로 요청 ≥20·실패 ≥10건(예: 계약 회귀 400 을 내는 잘못된 sync 본문) → `ruleup-stg-P0-API-01-sync` ALARM → 정상 요청 20건 이상 → Flag=0 → OK 2회. +- 입력 중단 유지: slo-calculator 스케줄을 끄면 P0 는 ALARM 그대로(ignore), OBS-01(slo-calculator) 이 2분 뒤, OBS-04(P0) 가 5분 뒤 운다. - 저표본 재무장: `lambdas/rearm.py stg --operation sync --rule API-01 --incident … --approver … --evidence …`. - EDGE-01: 한 지점만 실패 → EDGE-02 만, 두 지점 → EDGE-01. diff --git a/infra/monitoring/alarm-catalog.yaml b/infra/monitoring/alarm-catalog.yaml index d46d8061..bc4e939f 100644 --- a/infra/monitoring/alarm-catalog.yaml +++ b/infra/monitoring/alarm-catalog.yaml @@ -180,6 +180,14 @@ alarms: - id: DB-08 status: active severity: P1 + - id: DB-10 + status: active + severity: P0 + notes: 10/15 리뷰 — RDS CPU ≥99% 1분(AWS/RDS 네이티브). 노션 카탈로그 §4.2 의 「RDS CPU 99% 는 단독 P0 아님」을 뒤집었다 + - id: DB-11 + status: active + severity: P0 + notes: 10/15 리뷰 — Freeable ≤RAM 5% 1분 + Swap 증가(resource-monitor DbMemoryCritical, missing=ignore). DB-02 는 10% 5분 P1 - id: DB-09 status: followup severity: P1 @@ -309,9 +317,9 @@ alarms: severity: P0 notes: PagerDuty 시험 서비스·webhook 수신기·Healthchecks.io 가 모두 필요 - id: OBS-04 - status: blocked + status: active severity: P0 - notes: 계산기는 원장 healthchecks.obs04_ping_url 이 생기면 바로 ping 한다(lambdas/cwio.ping) + notes: Healthchecks.io 전까지 CloudWatch 대체 — slo-calculator·resource-monitor 하트비트 5분 누락(breaching). 버지니아 전체 장애는 못 잡는다(수용 리스크). 원장 healthchecks.obs04_ping_url 이 생기면 계산기가 외부 ping 도 한다 - id: OBS-05 status: blocked severity: P1 diff --git a/infra/monitoring/alerting/README.md b/infra/monitoring/alerting/README.md index 881b0a6e..afdb4be8 100644 --- a/infra/monitoring/alerting/README.md +++ b/infra/monitoring/alerting/README.md @@ -43,12 +43,14 @@ EventBridge 규칙이 서울·버지니아 모두 `ruleup-prod-P0-` 접두어로 | 등급 | ID | 조건(요약) | 리전 | |---|---|---|---| | P0 | SLO-01 / SLO-02 | BR(1h·5m) ≥14.4 / BR(6h·30m) ≥6, 짧은 창 표본·실패 게이트 | 버지니아 | -| P0 | API-01 | 5분 실패율 ≥50%·요청 ≥20·실패 ≥10 | 버지니아 | -| P0 | EDGE-01 | 오리건·버지니아 공개 점검 둘 다 1분×3 실패 | 버지니아 | -| P0 | NATIVE-01 / NATIVE-02 | Route 53 3분 / ALB Target 5xx ≥50%(5분·요청 ≥20·오류 ≥10) | 버지니아 / 서울 | +| P0 | API-01 | 2분 실패율 ≥50%·요청 ≥20·실패 ≥10 | 버지니아 | +| P0 | EDGE-01 | 오리건·버지니아 공개 점검 둘 다 30초 간격 2회 연속 실패 | 버지니아 | +| P0 | NATIVE-01 / NATIVE-02 | Route 53 1분 / ALB Target 5xx ≥50%(2분·요청 ≥20·오류 ≥10) | 버지니아 / 서울 | +| P0 | DB-10 / DB-11 | RDS CPU ≥99% 1분 / Freeable ≤RAM 5% 1분 + Swap 증가 (10/15 리뷰) | 서울 | +| P0 | OBS-04 | slo-calculator·resource-monitor 하트비트 5분 누락 (Healthchecks.io 전 대체) | 버지니아 | | P0 | RES-01~04 (`res_enabled`) | 마지막 정상 태스크까지 하드 메모리 ≥99% · OOM 반복 + 정상 ≤1 · CPU 99% + 확장 불능 + 포화 · RDS 소진 임박 + 확장 불가 | 버지니아 | -| P1 | SLO-03/04 · LAT-01 · API-02 | 느린 소진 · 지연 BR ≥3 · 실패율 ≥5% | 버지니아 | -| P1 | ECS-01~09 · JVM · POOL · THREAD | 카탈로그 §4·§5 (CPU 80/90/99%, 하드 메모리 80/90%, OOM ≥1 …) | 서울 | +| P1 | SLO-03/04 · LAT-01 · API-02 | 느린 소진 · 지연 BR ≥3 · 3분 실패율 ≥5% | 버지니아 | +| P1 | ECS-01~09 · JVM · POOL · THREAD | 카탈로그 §4·§5 (CPU 80% 3분/90% 1분/99%, 하드 메모리 80% 3분/90% 1분, OOM ≥1 즉시 …) | 서울 | | P1 | DB-01~08 · REDIS · QUEUE · JOB · EXT · TOKEN | 카탈로그 §5·§6 | 서울 | | P1 | SEC-01/05 · INV-01 · BACKUP-01~04 · OBS-01 | 카탈로그 §6.1·§7 | 서울·버지니아 | | P1 | SEC-02/03/04 | EventBridge 규칙(`security_events.py`) | 서울·버지니아 | diff --git a/infra/monitoring/alerting/catalog.py b/infra/monitoring/alerting/catalog.py index 380d4dfd..238e49c2 100644 --- a/infra/monitoring/alerting/catalog.py +++ b/infra/monitoring/alerting/catalog.py @@ -1,9 +1,12 @@ """경보 정의 — 노션 「알람 카탈로그 · SLO 구현 계약」의 ID 그대로. boto3 없이 만들고 시험한다(p0_p1.py 가 적용한다). -등급 규칙(노션 §3·§4.2·「운영 경보 상세」 §2.4) - P0 은 SLO-01/02 · API-01 · EDGE-01 · NATIVE-01/02 · RES-01~04 만이다. CPU·메모리·DB·Redis·SQS 사용률 하나로는 P0 를 만들지 않는다. - 커스텀 P0(SLO·API-01·RES·EDGE leaf)는 missing=ignore — 입력이 끊겼다고 정상으로 돌아가지 않는다. - 상시 하트비트(OBS-01)·NATIVE-01 은 missing=breaching. 나머지 P1·희소 이벤트는 notBreaching. +등급 규칙(노션 §3·§4.2·「운영 경보 상세」 §2.4, 10/15 리뷰) + P0 은 SLO-01/02 · API-01 · EDGE-01 · NATIVE-01/02 · RES-01~04 · DB-10/11 · OBS-04 만이다. + 10/15 리뷰로 RDS CPU ≥99% 1분(DB-10)·RDS 메모리 ≤RAM 5% 1분 + Swap 증가(DB-11)를 P0 로 올렸다(카탈로그 §4.2 의 + 「RDS CPU 99% 는 단독 P0 아님」을 뒤집은 결정). 그 밖의 CPU·메모리·Redis·SQS 사용률 하나로는 P0 를 만들지 않는다. + 커스텀 P0(SLO·API-01·RES·DB-11·EDGE leaf)는 missing=ignore — 입력이 끊겼다고 정상으로 돌아가지 않는다. + 상시 하트비트(OBS-01·OBS-04)·NATIVE-01 은 missing=breaching. 나머지 P1·희소 이벤트는 notBreaching. +평가 타이밍(10/15 리뷰): 모든 경보 1분 주기, P0 는 조건 충족 후 1~2분 안에 호출, P1 은 대부분 5분 이내. SLO 창은 계산식이라 그대로. 이름은 ruleup---[-<대상>] — 긴급 채널 멘션(EventBridge)이 ruleup-prod-P0- 접두어로 잡는다. """ import os @@ -12,7 +15,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'lambdas')) import slo_core # noqa: E402 -P0_IDS = {'SLO-01', 'SLO-02', 'API-01', 'EDGE-01', 'NATIVE-01', 'NATIVE-02', 'RES-01', 'RES-02', 'RES-03', 'RES-04'} +P0_IDS = {'SLO-01', 'SLO-02', 'API-01', 'EDGE-01', 'NATIVE-01', 'NATIVE-02', 'RES-01', 'RES-02', 'RES-03', 'RES-04', + 'DB-10', 'DB-11', 'OBS-04'} RB = {'SLO': 'RB-01', 'LAT': 'RB-01', 'API': 'RB-01', 'EDGE': 'RB-01', 'TLS': 'RB-01', 'NATIVE': 'RB-01', 'AUTH': 'RB-04', 'ECS': 'RB-02', 'RES': 'RB-02', 'JVM': 'RB-02', 'POOL': 'RB-02', 'THREAD': 'RB-02', 'DB': 'RB-02', 'REDIS': 'RB-02', 'CACHE': 'RB-02', 'QUEUE': 'RB-03', 'JOB': 'RB-03', 'EXT': 'RB-03', 'TOKEN': 'RB-04', 'FLOW': 'RB-04', 'HTTP': 'RB-04', @@ -95,8 +99,8 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho rules = slo_core.ALL_RULES if reg['rules'].get('slo_rules') == 'all' else tuple(reg['rules']['slo_rules']) texts = {'SLO-01': '가용성 버짓 급속 소진 — BR(1h)·BR(5m) ≥14.4', 'SLO-02': '가용성 버짓 소진 — BR(6h)·BR(30m) ≥6', 'SLO-03': '느린 버짓 소진 — BR(24h)·BR(2h) ≥3', 'SLO-04': '느린 버짓 소진 — BR(72h)·BR(6h) ≥1', - 'LAT-01': '지연 버짓 소진 — 지연 BR(1h)·BR(5m) ≥3', 'API-01': '급성 장애 — 5분 실패율 ≥50%·요청 ≥20·실패 ≥10', - 'API-02': 'API 실패율 ≥5%(5분·요청 ≥20)'} + 'LAT-01': '지연 버짓 소진 — 지연 BR(1h)·BR(5m) ≥3', 'API-01': '급성 장애 — 2분 실패율 ≥50%·요청 ≥20·실패 ≥10', + 'API-02': 'API 실패율 ≥5%(3분·요청 ≥20)'} for op in slo_core.OPERATIONS: for rule in rules: p0 = rule in P0_IDS @@ -110,31 +114,37 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho leaf = f'{p}-edge-leaf-{origin}' # 행동 없는 구성 요소 — P0 접두어를 붙이지 않아 멘션이 따로 울리지 않는다 leaves.append(leaf) out.append((mon, {'kind': 'metric', 'id': 'EDGE-01', 'severity': 'leaf', 'name': leaf, 'params': { - 'AlarmName': leaf, 'AlarmDescription': desc('EDGE-01', f'{origin} 공개 점검 3/3 실패(구성 요소, 단독 알림 없음)'), - **metric('RuleUp/Edge', 'ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Maximum', 60, 3, 3, 1, GE, 'ignore')}})) - add(mon, 'EDGE-02', origin, f'{origin} 한 지점 공개 점검 3회 중 2회 실패 — DNS·TLS·WAF·본문 단계는 edge_probe 로그', - **metric('RuleUp/Edge', 'ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Maximum', 60, 3, 2, 1, GE, 'notBreaching')) + 'AlarmName': leaf, 'AlarmDescription': desc('EDGE-01', f'{origin} 공개 점검 30초 간격 2회 연속 실패(구성 요소, 단독 알림 없음)'), + **metric('RuleUp/Edge', 'ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Maximum', 60, 1, 1, 1, GE, 'ignore')}})) + # 노션은 「최근 3회 중 2회」. 점검이 분당 2회라 2분(4회) 중 2회 실패로 맞췄다 — 한 번 더 기회를 주는 쪽이 아니라 같은 민감도 + add(mon, 'EDGE-02', origin, f'{origin} 한 지점 공개 점검 2분(4회) 중 2회 실패 — DNS·TLS·WAF·본문 단계는 edge_probe 로그', + **metric('RuleUp/Edge', 'ProbeAttemptFailures', {'Env': env, 'Probe': 'public', 'Origin': origin}, 'Sum', 120, 1, 1, 2, GE, 'notBreaching')) name = f'{p}-P0-EDGE-01' out.append((mon, {'kind': 'composite', 'id': 'EDGE-01', 'severity': 'P0', 'name': name, 'params': { - 'AlarmName': name, 'AlarmDescription': desc('EDGE-01', '공개 경로 전면 장애 — 두 지점 공개 무인증 점검 모두 1분×3회 실패'), + 'AlarmName': name, 'AlarmDescription': desc('EDGE-01', '공개 경로 전면 장애 — 두 지점 공개 무인증 점검 모두 30초 간격 2회 연속 실패'), 'AlarmRule': ' AND '.join(f'ALARM("{leaf}")' for leaf in leaves)}})) add(mon, 'TLS-01', None, '공개 인증서 잔여 ≤14일', **math('MIN([' + ','.join(f't{i}' for i in range(len(origins))) + '])', [(f't{i}', 'RuleUp/Edge', 'CertificateDaysLeft', {'Env': env, 'Origin': o}, 'Minimum') for i, o in enumerate(origins)], 3600, 14, LE, label='인증서 잔여 일수')) if health_check_id: - add(mon, 'NATIVE-01', None, 'Route 53 공개 헬스체크 비정상 3분 — 계산기·릴레이와 무관한 독립 백스톱', - **metric('AWS/Route53', 'HealthCheckStatus', {'HealthCheckId': health_check_id}, 'Minimum', 60, 3, 3, 1, LT, 'breaching')) + add(mon, 'NATIVE-01', None, 'Route 53 공개 헬스체크 비정상 1분 — 계산기·릴레이와 무관한 독립 백스톱', + **metric('AWS/Route53', 'HealthCheckStatus', {'HealthCheckId': health_check_id}, 'Minimum', 60, 1, 1, 1, LT, 'breaching')) if reg['rules'].get('res_enabled'): res_text = {'RES-01': '정상 태스크 전부 하드 메모리 ≥99%(마지막 1대 포함)', 'RES-02': 'OOM ≥2회/5분 + 정상 태스크 ≤1', - 'RES-03': 'CPU ≥99% 3분 + 확장 불가 + 처리 포화', 'RES-04': 'RDS 여유 ≤5% + 15분 내 소진 예상 + 확장 불가'} + 'RES-03': 'CPU ≥99% 1분 + 확장 불가 1분 + 처리 포화', 'RES-04': 'RDS 여유 ≤5% + 15분 내 소진 예상 + 확장 불가'} for rid, text in res_text.items(): add(mon, rid, None, f'임박한 자원 고갈 — {text}. 근거는 resource-monitor 로그 res_eval', **metric('RuleUp/Ops', 'CriticalCapacityFlag', {**base, 'Rule': rid}, 'Maximum', 60, 1, 1, 1, GE, 'ignore')) components = ['slo-calculator', 'resource-monitor'] + [f'edge-{o}' for o in origins] for comp in components: - add(mon, 'OBS-01', comp, f'{comp} 하트비트 3분 누락 — 관측 누락이지 서비스 장애 확정이 아니다', - **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': comp}, 'Minimum', 60, 3, 3, 1, LT, 'breaching')) + add(mon, 'OBS-01', comp, f'{comp} 하트비트 2분 누락 — 관측 누락이지 서비스 장애 확정이 아니다', + **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': comp}, 'Minimum', 60, 2, 2, 1, LT, 'breaching')) + # 감시망 자체 중단(P0). 노션 설계는 Healthchecks.io 외부 watchdog(OBS-03/04)인데 미계약이라, 그 전까지 같은 리전 + # CloudWatch 로 대신한다 — 버지니아 리전 전체 장애는 이 경보도 함께 죽는다(카탈로그 「수용 리스크」 그대로). + for comp in ('slo-calculator', 'resource-monitor'): + add(mon, 'OBS-04', comp, f'{comp} 핵심 계산 5분 누락 — P0 판정(SLO·API-01·RES)이 멈췄다. 서비스 장애 확정은 아니다', + **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': comp}, 'Minimum', 60, 5, 5, 1, LT, 'breaching')) if reg['rules'].get('detectors_enabled'): add(mon, 'OBS-01', 'anomaly-detector', '이상 탐지 계산기 하트비트 15분 누락', **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': 'anomaly-detector'}, 'Minimum', 300, 3, 3, 1, LT, 'breaching')) @@ -146,89 +156,94 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho # ===================== API 리전(서울) ===================== lb, tg = reg['load_balancer'], reg['target_group'] if env == 'prod': - add(api, 'NATIVE-02', None, 'ALB Target 5xx ≥50%(5분·요청 ≥20·오류 ≥10) — 독립 백스톱. ELB 5xx 는 섞지 않는다', + add(api, 'NATIVE-02', None, 'ALB Target 5xx ≥50%(2분·요청 ≥20·오류 ≥10) — 독립 백스톱. ELB 5xx 는 섞지 않는다', **math('IF(r >= 20, IF(FILL(t5, 0) >= 10, 100 * FILL(t5, 0) / r, 0), 0)', [('r', 'AWS/ApplicationELB', 'RequestCount', {'LoadBalancer': lb, 'TargetGroup': tg}, 'Sum'), ('t5', 'AWS/ApplicationELB', 'HTTPCode_Target_5XX_Count', {'LoadBalancer': lb, 'TargetGroup': tg}, 'Sum')], - 300, 50, label='Target 5xx 비율(%)')) + 120, 50, label='Target 5xx 비율(%)')) ecs = {'ClusterName': reg['cluster'], 'ServiceName': reg['service']} add(api, 'ECS-01', None, f'Desired < 승인 최소치({reg["min_expected_tasks"]}) 5분', **metric('RuleUp/Ops', 'DesiredCount', base, 'Minimum', 60, 5, 5, reg['min_expected_tasks'], LT, 'notBreaching')) - add(api, 'ECS-02', None, 'Running < Desired 5분', **metric('RuleUp/Ops', 'TaskShortfall', base, 'Maximum', 60, 5, 5, 1, GE, 'notBreaching')) + add(api, 'ECS-02', None, 'Running < Desired 2분', **metric('RuleUp/Ops', 'TaskShortfall', base, 'Maximum', 60, 2, 2, 1, GE, 'notBreaching')) add(api, 'ECS-03', None, '예상 밖 태스크 종료 ≥3건/5분(기동 실패·크래시 루프)', **metric('RuleUp/Ops', 'UnexpectedStopCount', base, 'Sum', 300, 1, 1, 3, GE, 'notBreaching')) - add(api, 'ECS-04', None, 'OOM 종료 ≥1건/5분(정상 여유가 있으면 P1)', - **metric('RuleUp/Ops', 'OOMStopCount', base, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) - add(api, 'ECS-05', '80', 'ECS CPU 평균 ≥80% 5분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 5, 5, 80, GE, 'notBreaching')) - add(api, 'ECS-05', '90', 'ECS CPU 평균 ≥90% 2분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 2, 2, 90, GE, 'notBreaching')) - add(api, 'ECS-06', '80', '가장 찬 태스크 하드 메모리 ≥80% 5분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) - add(api, 'ECS-06', '90', '가장 찬 태스크 하드 메모리 ≥90% 2분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 2, 2, 90, GE, 'notBreaching')) - add(api, 'ECS-07', None, '배포 실패 ≥1건/5분 — 자동 롤백 결과 확인', **metric('RuleUp/Ops', 'DeployFailureCount', base, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) + add(api, 'ECS-04', None, 'OOM 종료 ≥1건 즉시(정상 여유가 있으면 P1)', + **metric('RuleUp/Ops', 'OOMStopCount', base, 'Sum', 60, 1, 1, 1, GE, 'notBreaching')) + add(api, 'ECS-05', '80', 'ECS CPU 평균 ≥80% 3분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 3, 3, 80, GE, 'notBreaching')) + add(api, 'ECS-05', '90', 'ECS CPU 평균 ≥90% 1분', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'ECS-06', '80', '가장 찬 태스크 하드 메모리 ≥80% 3분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 3, 3, 80, GE, 'notBreaching')) + add(api, 'ECS-06', '90', '가장 찬 태스크 하드 메모리 ≥90% 1분', **metric('RuleUp/Ops', 'MaxMemoryHardPct', base, 'Maximum', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'ECS-07', None, '배포 실패 ≥1건 즉시 — 자동 롤백 결과 확인', **metric('RuleUp/Ops', 'DeployFailureCount', base, 'Sum', 60, 1, 1, 1, GE, 'notBreaching')) if reg['min_expected_tasks'] >= 2: add(api, 'ECS-08', None, f'정상 타깃 ≤1대 5분(목표 {reg["min_expected_tasks"]}대)', **metric('AWS/ApplicationELB', 'HealthyHostCount', {'LoadBalancer': lb, 'TargetGroup': tg}, 'Maximum', 60, 5, 5, 1, LE, 'notBreaching')) add(api, 'ECS-09', None, 'ECS CPU 평균 ≥99% 1분 — 긴급 P1(CPU 하나로는 P0 아님)', **metric('AWS/ECS', 'CPUUtilization', ecs, 'Average', 60, 1, 1, 99, GE, 'notBreaching')) - add(api, 'JVM-01', None, 'GC 직후 Old 영역 ≥80% 10분', **metric('RuleUp/Ops', 'OldGenAfterGcPct', base, 'Maximum', 60, 10, 10, 80, GE, 'notBreaching')) - add(api, 'JVM-02', None, 'GC 정지 비율 ≥10%(최근 5분)', **metric('RuleUp/Ops', 'GcPauseRatio5m', base, 'Maximum', 60, 1, 1, 10, GE, 'notBreaching')) + add(api, 'JVM-01', None, 'GC 직후 Old 영역 ≥80% 5분', **metric('RuleUp/Ops', 'OldGenAfterGcPct', base, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) + add(api, 'JVM-02', None, 'GC 정지 비율(최근 5분 창) ≥10% 3분', **metric('RuleUp/Ops', 'GcPauseRatio5m', base, 'Maximum', 60, 3, 3, 10, GE, 'notBreaching')) pool = {**base, 'Pool': 'ruleup-main'} - add(api, 'POOL-01', None, 'Hikari active/max ≥80% 5분', **metric('RuleUp/Ops', 'MaxPoolActivePct', pool, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) - add(api, 'POOL-02', None, '커넥션 대기 스레드 ≥1(5분 중 3분)', **metric('RuleUp/Ops', 'MaxPoolPending', pool, 'Maximum', 60, 5, 3, 1, GE, 'notBreaching')) - add(api, 'POOL-03', None, '커넥션 획득 timeout ≥1건/5분', **metric('RuleUp/Ops', 'PoolTimeoutDelta', pool, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) - add(api, 'THREAD-01', None, 'Tomcat busy/max ≥80% 10분', **metric('RuleUp/Ops', 'MaxBusyThreadPct', base, 'Maximum', 60, 10, 10, 80, GE, 'notBreaching')) + add(api, 'POOL-01', None, 'Hikari active/max ≥80% 3분', **metric('RuleUp/Ops', 'MaxPoolActivePct', pool, 'Maximum', 60, 3, 3, 80, GE, 'notBreaching')) + add(api, 'POOL-02', None, '커넥션 대기 스레드 ≥1(3분 중 2분)', **metric('RuleUp/Ops', 'MaxPoolPending', pool, 'Maximum', 60, 3, 2, 1, GE, 'notBreaching')) + add(api, 'POOL-03', None, '커넥션 획득 timeout ≥1건 즉시', **metric('RuleUp/Ops', 'PoolTimeoutDelta', pool, 'Sum', 60, 1, 1, 1, GE, 'notBreaching')) + add(api, 'THREAD-01', None, 'Tomcat busy/max ≥80% 5분', **metric('RuleUp/Ops', 'MaxBusyThreadPct', base, 'Maximum', 60, 5, 5, 80, GE, 'notBreaching')) rds = {'DBInstanceIdentifier': reg['rds_instance']} db = {'Env': env, 'Database': reg['rds_instance']} - add(api, 'DB-01', '80', 'RDS CPU ≥80% 10분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 10, 10, 80, GE, 'notBreaching')) - add(api, 'DB-01', '90', 'RDS CPU ≥90% 3분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 3, 3, 90, GE, 'notBreaching')) - add(api, 'DB-02', None, 'Freeable ≤RAM 10% + Swap 증가 10분', **metric('RuleUp/DB', 'DbMemoryPressure', db, 'Maximum', 60, 10, 10, 1, GE, 'notBreaching')) + add(api, 'DB-01', '80', 'RDS CPU ≥80% 5분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 5, 5, 80, GE, 'notBreaching')) + add(api, 'DB-01', '90', 'RDS CPU ≥90% 1분', **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'DB-10', None, 'RDS CPU ≥99% 1분 — DB 포화로 핵심 API 가 곧 멈춘다(10/15 리뷰로 P0)', + **metric('AWS/RDS', 'CPUUtilization', rds, 'Average', 60, 1, 1, 99, GE, 'notBreaching')) + # DbMemoryPressure·DbMemoryCritical 은 자원 감시기가 「N분 내내 + Swap 증가」를 이미 판정한 Flag 라 1/1 로 본다 + add(api, 'DB-02', None, 'Freeable ≤RAM 10% 5분 + Swap 증가', **metric('RuleUp/DB', 'DbMemoryPressure', db, 'Maximum', 60, 1, 1, 1, GE, 'notBreaching')) + add(api, 'DB-11', None, 'Freeable ≤RAM 5% 1분 + Swap 증가 — DB 메모리 고갈 임박(10/15 리뷰로 P0). 근거는 resource-monitor', + **metric('RuleUp/DB', 'DbMemoryCritical', db, 'Maximum', 60, 1, 1, 1, GE, 'ignore')) primary = {'Env': env, 'Database': 'primary'} - add(api, 'DB-03', '75', 'Threads_connected/max_connections ≥75% 10분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 10, 10, 75, GE, 'notBreaching')) - add(api, 'DB-03', '95', 'Threads_connected/max_connections ≥95% 3분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 3, 3, 95, GE, 'notBreaching')) - add(api, 'DB-04', 'read', 'RDS 읽기 지연 ≥100ms 10분', **metric('AWS/RDS', 'ReadLatency', rds, 'Average', 60, 10, 10, 0.1, GE, 'notBreaching')) - add(api, 'DB-04', 'write', 'RDS 쓰기 지연 ≥100ms 10분', **metric('AWS/RDS', 'WriteLatency', rds, 'Average', 60, 10, 10, 0.1, GE, 'notBreaching')) - add(api, 'DB-05', None, 'RDS 여유 저장공간 <15% 15분', **metric('RuleUp/DB', 'FreeStoragePct', db, 'Minimum', 60, 15, 15, 15, LT, 'notBreaching')) + add(api, 'DB-03', '75', 'Threads_connected/max_connections ≥75% 5분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 5, 5, 75, GE, 'notBreaching')) + add(api, 'DB-03', '95', 'Threads_connected/max_connections ≥95% 1분', **metric('RuleUp/DB', 'DbConnectionPct', primary, 'Maximum', 60, 1, 1, 95, GE, 'notBreaching')) + add(api, 'DB-04', 'read', 'RDS 읽기 지연 ≥100ms 5분', **metric('AWS/RDS', 'ReadLatency', rds, 'Average', 60, 5, 5, 0.1, GE, 'notBreaching')) + add(api, 'DB-04', 'write', 'RDS 쓰기 지연 ≥100ms 5분', **metric('AWS/RDS', 'WriteLatency', rds, 'Average', 60, 5, 5, 0.1, GE, 'notBreaching')) + add(api, 'DB-05', None, 'RDS 여유 저장공간 <15% 5분', **metric('RuleUp/DB', 'FreeStoragePct', db, 'Minimum', 60, 5, 5, 15, LT, 'notBreaching')) add(api, 'DB-06', None, '최장 락 대기 ≥5초 3분', **metric('RuleUp/DB', 'OldestLockWaitSec', primary, 'Maximum', 60, 3, 3, 5, GE, 'notBreaching')) add(api, 'DB-07', None, '데드락 ≥3건/5분', **metric('RuleUp/DB', 'DeadlockDelta', primary, 'Sum', 300, 1, 1, 3, GE, 'notBreaching')) add(api, 'DB-08', None, '최장 트랜잭션 ≥60초 5분', **metric('RuleUp/DB', 'OldestTransactionSec', primary, 'Maximum', 60, 5, 5, 60, GE, 'notBreaching')) for node in reg['redis_nodes']: n = {'CacheClusterId': node} - add(api, 'REDIS-01', f'{node}-80', 'Redis 엔진 CPU ≥80% 10분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 10, 10, 80, GE, 'notBreaching')) - add(api, 'REDIS-01', f'{node}-90', 'Redis 엔진 CPU ≥90% 3분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 3, 3, 90, GE, 'notBreaching')) - add(api, 'REDIS-02', node, 'Redis used_memory/maxmemory ≥85% 10분 — eviction 정책 확인', - **metric('AWS/ElastiCache', 'DatabaseMemoryUsagePercentage', n, 'Maximum', 60, 10, 10, 85, GE, 'notBreaching')) + add(api, 'REDIS-01', f'{node}-80', 'Redis 엔진 CPU ≥80% 5분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 5, 5, 80, GE, 'notBreaching')) + add(api, 'REDIS-01', f'{node}-90', 'Redis 엔진 CPU ≥90% 1분', **metric('AWS/ElastiCache', 'EngineCPUUtilization', n, 'Average', 60, 1, 1, 90, GE, 'notBreaching')) + add(api, 'REDIS-02', node, 'Redis used_memory/maxmemory ≥85% 5분 — eviction 정책 확인', + **metric('AWS/ElastiCache', 'DatabaseMemoryUsagePercentage', n, 'Maximum', 60, 5, 5, 85, GE, 'notBreaching')) for q in reg['queues']: - add(api, 'QUEUE-01', q.removeprefix(f'{p}-'), f'{q} 가장 오래된 메시지 >10분 5분 — 소비자 정지·오류', - **metric('AWS/SQS', 'ApproximateAgeOfOldestMessage', {'QueueName': q}, 'Maximum', 60, 5, 5, 600, GT, 'notBreaching')) + add(api, 'QUEUE-01', q.removeprefix(f'{p}-'), f'{q} 가장 오래된 메시지 >10분 1분 — 소비자 정지·오류', + **metric('AWS/SQS', 'ApproximateAgeOfOldestMessage', {'QueueName': q}, 'Maximum', 60, 1, 1, 600, GT, 'notBreaching')) for q in reg['dlqs']: add(api, 'QUEUE-02', q.removeprefix(f'{p}-'), f'{q} 에 재시도 끝에 실패한 메시지 ≥1', **metric('AWS/SQS', 'ApproximateNumberOfMessagesVisible', {'QueueName': q}, 'Maximum', 60, 1, 1, 1, GE, 'notBreaching')) app_ns = f'RuleUp/App/{env}' - add(api, 'JOB-01', 'outbox', 'Outbox 가장 오래된 대기 >10분', - **metric(app_ns, 'outbox.pending.oldest_age_seconds.value', {}, 'Maximum', 60, 1, 1, 600, GT, 'notBreaching')) + add(api, 'JOB-01', 'outbox', 'Outbox(Consumer) 가장 오래된 미처리 >5분', + **metric(app_ns, 'outbox.pending.oldest_age_seconds.value', {}, 'Maximum', 60, 1, 1, 300, GT, 'notBreaching')) add(api, 'JOB-01', 'outbox-dead-lettered', 'Outbox 작업이 재시도 끝에 포기됨(포기 누적 증가)', **math('DIFF(m)', [('m', app_ns, 'outbox.dead_lettered.count.value', {}, 'Maximum')], 300, 0, GT, label='포기 증가')) - add(api, 'JOB-02', 'finalize-overdue', '확정 시각이 1시간 넘게 지난 미확정 판정이 15분째 — 확정 배치 정지·반복 실패', - **metric(app_ns, 'verification.finalize.overdue.value', {}, 'Maximum', 300, 3, 3, 0, GT, 'notBreaching')) + add(api, 'JOB-02', 'finalize-overdue', '확정 시각이 1시간 넘게 지난 미확정 판정이 5분째 — 확정 배치 정지·반복 실패', + **metric(app_ns, 'verification.finalize.overdue.value', {}, 'Maximum', 60, 5, 5, 0, GT, 'notBreaching')) add(api, 'JOB-02', 'finalize-failed', '판정 확정이 건별로 실패해 격리됨(0건이어야 함)', **metric(app_ns, 'verification.finalize.failed.count', {}, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) add(api, 'JOB-02', 'materialize-failed', '무신호 귀속일 채우기 실패 — 그 멤버·날짜의 판정 행이 열리지 않음', **metric(app_ns, 'verification.materialize.failed.count', {}, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) for feature, provider in LLM_PROVIDERS + (('push', 'fcm'),): d = {**base, 'Feature': feature, 'Provider': provider} - add(api, 'EXT-01', f'{feature}-{provider}', f'{provider}({feature}) 기술 실패 ≥10%(10분·호출 ≥20, 429·쿼터 포함)', + add(api, 'EXT-01', f'{feature}-{provider}', f'{provider}({feature}) 기술 실패 ≥10%(5분·호출 ≥20, 429·쿼터 포함)', **math('IF(FILL(c, 0) >= 20, 100 * FILL(f, 0) / c, 0)', [('c', 'RuleUp/Ops', 'ProviderCallCount', d, 'Sum'), ('f', 'RuleUp/Ops', 'ProviderFailureCount', d, 'Sum')], - 600, 10, label='실패율(%)')) + 300, 10, label='실패율(%)')) for feature in ('generation', 'moderation'): d = {**base, 'Feature': feature} - add(api, 'EXT-02', feature, f'LLM({feature}) p95 >15초(10분·표본 ≥20)', + add(api, 'EXT-02', feature, f'LLM({feature}) p95 >15초(5분·표본 ≥20)', **math('IF(FILL(n, 0) >= 20, p95, 0)', [('p95', 'RuleUp/Ops', 'LlmLatencyMs', d, 'p95'), ('n', 'RuleUp/Ops', 'LlmLatencyMs', d, 'SampleCount')], - 600, 15000, GT, label='p95(ms)')) - add(api, 'TOKEN-01', None, '무효 FCM 토큰 ≥20%(10분·발송 ≥20) — 집단 무효화(프로젝트 이전·SENDER_ID_MISMATCH)', + 300, 15000, GT, label='p95(ms)')) + add(api, 'TOKEN-01', None, '무효 FCM 토큰 ≥20%(5분·발송 ≥20) — 집단 무효화(프로젝트 이전·SENDER_ID_MISMATCH)', **math('IF(FILL(a, 0) >= 20, 100 * FILL(i, 0) / a, 0)', [('a', 'RuleUp/Ops', 'ProviderCallCount', {**base, 'Feature': 'push', 'Provider': 'fcm'}, 'Sum'), - ('i', 'RuleUp/Ops', 'InvalidTokenCount', {**base, 'Provider': 'fcm'}, 'Sum')], 600, 20, label='무효 비율(%)')) + ('i', 'RuleUp/Ops', 'InvalidTokenCount', {**base, 'Provider': 'fcm'}, 'Sum')], 300, 20, label='무효 비율(%)')) for service in ('ruleup-admin', 'ruleup-api'): add(api, 'SEC-01', service.removeprefix('ruleup-'), '관리자 로그인 실패 ≥5회/10분 — 침해 확정이 아니다(RB-05 로 조사)', **metric('RuleUp/Security', 'AdminLoginFailedCount', {'Env': env, 'Service': service}, 'Sum', 600, 1, 1, 5, GE, 'notBreaching')) @@ -240,6 +255,6 @@ def math(expression, metrics, period, threshold, op='GreaterThanOrEqualToThresho add(api, 'BACKUP-02', None, '복구 가능 시점 지연 >15분(5분×3)', **metric('RuleUp/Backup', 'LatestRestorableLagSec', db, 'Maximum', 300, 3, 3, 900, GT, 'notBreaching')) add(api, 'BACKUP-03', None, '마지막 자동 스냅샷 >26시간', **metric('RuleUp/Backup', 'AutomatedSnapshotAgeSec', db, 'Maximum', 3600, 1, 1, 93600, GT, 'notBreaching')) add(api, 'BACKUP-04', None, '백업 실패 이벤트 ≥1건', **metric('RuleUp/Backup', 'BackupFailureCount', db, 'Sum', 300, 1, 1, 1, GE, 'notBreaching')) - add(api, 'OBS-01', 'db-collector', 'DB 진단 수집 3분 누락(권한 부족 포함) — 관측 누락', - **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': 'db-collector'}, 'Minimum', 60, 3, 3, 1, LT, 'breaching')) + add(api, 'OBS-01', 'db-collector', 'DB 진단 수집 2분 누락(권한 부족 포함) — 관측 누락', + **metric('RuleUp/Observe', 'CollectorHeartbeat', {'Env': env, 'Component': 'db-collector'}, 'Minimum', 60, 2, 2, 1, LT, 'breaching')) return out diff --git a/infra/monitoring/alerting/dashboard.py b/infra/monitoring/alerting/dashboard.py index 706c8ddc..7db93f95 100644 --- a/infra/monitoring/alerting/dashboard.py +++ b/infra/monitoring/alerting/dashboard.py @@ -124,7 +124,12 @@ def logs(title, query, width, x): ('종료·OOM·배포 실패', [ops_m('UnexpectedStopCount', 'Sum'), ops_m('OOMStopCount', 'Sum'), ops_m('DeployFailureCount', 'Sum')], None)) rds = lambda m, s='Average': ['AWS/RDS', m, 'DBInstanceIdentifier', REG['rds_instance'], {'stat': s}] dbm = lambda m, d, s='Maximum': ['RuleUp/DB', m, 'Database', d, 'Env', ENV, {'stat': s}] -row(('RDS CPU(%)·연결 사용률(%)', [rds('CPUUtilization'), dbm('DbConnectionPct', 'primary')], {'period': 60}), +row(('RDS CPU(%)·연결 사용률(%)', [rds('CPUUtilization'), dbm('DbConnectionPct', 'primary')], {'period': 60, + 'annotations': {'horizontal': [{'value': 80, 'label': 'DB-01'}, {'value': 99, 'label': 'DB-10 P0'}]}}), + ('RDS 메모리 — 여유(바이트)·Swap·Flag', [rds('FreeableMemory', 'Minimum'), rds('SwapUsage', 'Maximum'), + *[dbm(f, REG['rds_instance'])[:-1] + [{'stat': 'Maximum', 'label': f, 'yAxis': 'right'}] + for f in ('DbMemoryPressure', 'DbMemoryCritical')]], + {'period': 60}), ('RDS 여유 저장공간(%)·소진 예상(분)', [dbm('FreeStoragePct', REG['rds_instance'], 'Minimum'), dbm('StorageMinutesToFull', REG['rds_instance'], 'Minimum') + []], {'period': 60}), ('락 대기·장기 트랜잭션(초)·데드락', [dbm('OldestLockWaitSec', 'primary'), dbm('OldestTransactionSec', 'primary'), diff --git a/infra/monitoring/alerting/observe_dashboard.py b/infra/monitoring/alerting/observe_dashboard.py index 580520d0..131f9a47 100644 --- a/infra/monitoring/alerting/observe_dashboard.py +++ b/infra/monitoring/alerting/observe_dashboard.py @@ -39,7 +39,8 @@ def row(*ws, h=6): hc = REG.get('route53_health_check_id') text('## OBS-1 · native 백스톱·공개 점검') -row(('공개 점검 실패(1=실패)', [['RuleUp/Edge', 'ProbeFailure', 'Env', ENV, 'Probe', 'public', 'Origin', o, {'stat': 'Maximum', 'label': o}] for o in ORIGINS], None), +row(('공개 점검 — 분당 실패 횟수(0~2, 2=EDGE-01 leaf)', [['RuleUp/Edge', 'ProbeAttemptFailures', 'Env', ENV, 'Probe', 'public', 'Origin', o, {'stat': 'Sum', 'label': o}] + for o in ORIGINS], {'period': 60}), ('Route 53 헬스체크(1=정상)' + ('' if hc else ' — 원장 route53_health_check_id 미기록'), [['AWS/Route53', 'HealthCheckStatus', 'HealthCheckId', hc, {'stat': 'Minimum'}]] if hc else [], None), ('인증서 잔여 일수', [['RuleUp/Edge', 'CertificateDaysLeft', 'Env', ENV, 'Origin', o, {'stat': 'Minimum', 'label': o}] for o in ORIGINS], diff --git a/infra/monitoring/alerting/tests_catalog.py b/infra/monitoring/alerting/tests_catalog.py index 855e3435..687d54ff 100644 --- a/infra/monitoring/alerting/tests_catalog.py +++ b/infra/monitoring/alerting/tests_catalog.py @@ -24,7 +24,7 @@ def test_names_are_unique_and_fit(self): self.assertTrue(all(len(n) <= 255 for n in names)) def test_p0_only_for_contract_ids(self): - """CPU·메모리·DB·Redis·SQS 사용률 하나로 P0 를 만들지 않는다.""" + """CPU·메모리·DB·Redis·SQS 사용률 하나로 P0 를 만들지 않는다 — 10/15 리뷰가 올린 RDS CPU·메모리(DB-10/11)만 예외.""" for _, s in self.specs('prod', res_enabled=True, detectors_enabled=True): if s['severity'] == 'P0': self.assertIn(s['id'], catalog.P0_IDS, s['name']) @@ -37,9 +37,9 @@ def test_missing_data_policy(self): p = s['params'] if s['kind'] == 'composite': self.assertNotIn('TreatMissingData', p, 'Composite 에는 누락 정책을 넣지 않는다') - elif s['id'] in ('SLO-01', 'SLO-02', 'API-01', 'RES-01', 'RES-02', 'RES-03', 'RES-04') or s['severity'] == 'leaf': + elif s['id'] in ('SLO-01', 'SLO-02', 'API-01', 'RES-01', 'RES-02', 'RES-03', 'RES-04', 'DB-11') or s['severity'] == 'leaf': self.assertEqual(p['TreatMissingData'], 'ignore', s['name']) - elif s['id'] in ('OBS-01', 'NATIVE-01'): + elif s['id'] in ('OBS-01', 'OBS-04', 'NATIVE-01'): self.assertEqual(p['TreatMissingData'], 'breaching', s['name']) else: self.assertEqual(p['TreatMissingData'], 'notBreaching', s['name']) @@ -56,9 +56,24 @@ def test_regions(self): r = reg('prod') for region, s in self.specs('prod', res_enabled=True): central = s['id'].split('-')[0] in ('SLO', 'LAT', 'EDGE', 'TLS', 'RES') or s['id'] in ('NATIVE-01',) \ - or (s['id'] == 'OBS-01' and not s['name'].endswith('db-collector')) or s['id'] in ('API-01', 'API-02') + or (s['id'] == 'OBS-01' and not s['name'].endswith('db-collector')) or s['id'] in ('API-01', 'API-02', 'OBS-04') self.assertEqual(region, r['monitor_region'] if central else r['api_region'], s['name']) + def test_p0_pages_within_two_minutes(self): + """10/15 리뷰: P0 는 조건 충족 후 1~2분 안에 호출 — 평가 창(Period×DatapointsToAlarm)이 2분을 넘지 않는다. + OBS-04(5분 누락)는 「5분 누락」 자체가 조건이라 뺀다.""" + for _, s in self.specs('prod', res_enabled=True): + if s['severity'] != 'P0' or s['kind'] == 'composite' or s['id'] == 'OBS-04': + continue + p = s['params'] + period = p['Period'] if 'Period' in p else next(q['MetricStat']['Period'] for q in p['Metrics'] if 'MetricStat' in q) + self.assertLessEqual(period * p['DatapointsToAlarm'], 120, s['name']) + + def test_rds_cpu_and_memory_page(self): + names = {s['name'] for _, s in self.specs('prod')} + self.assertIn('ruleup-prod-P0-DB-10', names) + self.assertIn('ruleup-prod-P0-DB-11', names) + def test_res_and_detectors_are_gated(self): ids = {s['id'] for _, s in self.specs('prod', res_enabled=False, detectors_enabled=False)} self.assertFalse(ids & {'RES-01', 'FLOW-01', 'HTTP-04', 'CACHE-01'}) diff --git a/infra/monitoring/lambdas/deploy.py b/infra/monitoring/lambdas/deploy.py index d0a7d917..464da91d 100644 --- a/infra/monitoring/lambdas/deploy.py +++ b/infra/monitoring/lambdas/deploy.py @@ -37,7 +37,7 @@ def functions(reg): out = [dict(name=f'ruleup-{env}-slo-calculator', handler='slo_calculator.handler', region=mon, role='slo', timeout=55, memory=512), dict(name=f'ruleup-{env}-resource-monitor', handler='resource_monitor.handler', region=mon, role='resource', timeout=55, memory=256)] for origin, region in reg['probe_origins'].items(): - out.append(dict(name=f'ruleup-{env}-edge-probe', handler='edge_probe.handler', region=region, role='edge', timeout=20, + out.append(dict(name=f'ruleup-{env}-edge-probe', handler='edge_probe.handler', region=region, role='edge', timeout=50, memory=128, extra_env={'PROBE_ORIGIN': origin})) return out diff --git a/infra/monitoring/lambdas/edge_probe.py b/infra/monitoring/lambdas/edge_probe.py index 6b46627a..ca5f6a3b 100644 --- a/infra/monitoring/lambdas/edge_probe.py +++ b/infra/monitoring/lambdas/edge_probe.py @@ -1,10 +1,16 @@ """외부 공개 점검 — 노션 「알람 카탈로그」 §4 EDGE-01/02·TLS-01. 오리건·버지니아에 같은 코드를 하나씩 둔다(Scheduler 1분). +한 번 실행에 30초 간격으로 두 번 부른다(노션 10/15 리뷰: 「2회 연속 실패, 30초 간격, 약 1분」). Scheduler 의 최소 주기가 +1분이라 30초 점검은 함수 안에서 나눈다. 분 경계를 걸친 연속 실패(앞 분 두 번째 + 이번 분 첫 번째)는 세지 않는다 — +실제 장애는 다음 분 두 번도 실패하므로 늦어져도 1분이다. + 사용자와 같은 길(DNS → Cloudflare → ALB → 태스크)로 무인증 /health/public 을 부르고 DNS → TLS 검증 → HTTP → 본문 단계를 모두 통과해야 성공이다. timeout 5초, 성공은 200 + 고정 본문 토큰. 캐시 응답을 성공으로 오인하지 않게 no-cache 로 묻는다. 결과는 중앙 감시 리전(버지니아)의 RuleUp/Edge 로 직접 보낸다: - ProbeFailure(Env, Probe=public, Origin) 0/1 · CertificateDaysLeft(Env, Origin) · RuleUp/Observe CollectorHeartbeat(Component=edge-) + ProbeFailure(Env, Probe=public, Origin) 0/1 — 이번 분 두 번 모두 실패(EDGE-01 leaf) + ProbeAttemptFailures(Env, Probe=public, Origin) 0~2 — 이번 분 실패 횟수(EDGE-02) + CertificateDaysLeft(Env, Origin) · RuleUp/Observe CollectorHeartbeat(Component=edge-) 노션 설계의 「점검 릴레이」 함수는 두지 않았다 — 결과 검증은 이 함수가 하고, 리전 간 PutMetricData 로 충분해서다. 마지막 실행이 오래되면(하트비트 누락) 그건 실패가 아니라 관측 누락으로 OBS-01 이 본다. """ @@ -19,6 +25,7 @@ import cwio TIMEOUT = 5.0 +ATTEMPT_INTERVAL = 30.0 # 두 점검 사이(초). 함수 timeout 은 이 값 + 점검 두 번 최악(약 2×12초)보다 길어야 한다(deploy.py 50초) def probe(host, path, token, origin): @@ -72,15 +79,24 @@ def handler(event, context): origin = os.environ.get('PROBE_ORIGIN') or _origin_of(reg) now = cwio.event_time(event) stamp = now.replace(second=0, microsecond=0) - ok, stage, days = probe(reg['public_domain'], reg['public_health_path'], reg['public_health_token'], origin) - cwio.log('edge_probe', env=env, origin=origin, ok=ok, stage=stage, cert_days_left=days) + attempts = [] + started = time.monotonic() + for i in range(2): + if i: + time.sleep(max(0.0, ATTEMPT_INTERVAL - (time.monotonic() - started))) + attempts.append(probe(reg['public_domain'], reg['public_health_path'], reg['public_health_token'], origin)) + failures = sum(1 for ok, _, _ in attempts if not ok) + days = next((d for _, _, d in reversed(attempts) if d is not None), None) + cwio.log('edge_probe', env=env, origin=origin, ok=failures == 0, stages=[stage for _, stage, _ in attempts], cert_days_left=days) cw = boto3.client('cloudwatch', region_name=reg['monitor_region']) - data = [('ProbeFailure', {'Env': env, 'Probe': 'public', 'Origin': origin}, 0 if ok else 1, stamp, 'None')] + dims = {'Env': env, 'Probe': 'public', 'Origin': origin} + data = [('ProbeFailure', dims, 1 if failures == len(attempts) else 0, stamp, 'None'), + ('ProbeAttemptFailures', dims, failures, stamp, 'Count')] if days is not None: data.append(('CertificateDaysLeft', {'Env': env, 'Origin': origin}, days, stamp, 'None')) cwio.put(cw, 'RuleUp/Edge', data) cwio.put(cw, 'RuleUp/Observe', [('CollectorHeartbeat', {'Env': env, 'Component': f'edge-{origin}'}, 1, stamp, 'Count')]) - return {'ok': ok, 'stage': stage} + return {'failures': failures, 'stages': [stage for _, stage, _ in attempts]} def _origin_of(reg): diff --git a/infra/monitoring/lambdas/resource_core.py b/infra/monitoring/lambdas/resource_core.py index 20dc3a07..0de03a4e 100644 --- a/infra/monitoring/lambdas/resource_core.py +++ b/infra/monitoring/lambdas/resource_core.py @@ -65,13 +65,12 @@ class Capacity: def res01(c): - """정상 태스크 전부 하드 한도 ≥99%(1분 1/1), 또는 전부 ≥98% 2/2 + 확장 불가.""" - if not c.healthy or len(c.mem_min) < 1 or c.mem_samples[0] is None or c.mem_samples[0] < c.healthy: + """정상 태스크 전부 하드 한도 ≥99% 1분, 또는 전부 ≥98% 1분 + 확장 불가(노션 10/15: 2/2 → 1분).""" + if not c.healthy or len(c.mem_min) < 1 or c.mem_min[0] is None or c.mem_samples[0] is None or c.mem_samples[0] < c.healthy: return None if c.mem_min[0] >= 99: return 1 - if len(c.mem_min) >= 2 and None not in c.mem_min[:2] and min(c.mem_min[:2]) >= 98 \ - and c.mem_samples[1] is not None and c.mem_samples[1] >= c.healthy and c.scale_blocked_history[:1] == [1]: + if c.mem_min[0] >= 98 and c.scale_blocked_history[:1] == [1]: return 1 return 0 @@ -84,12 +83,12 @@ def res02(c): def res03(c): - """CPU 평균 ≥99% 3/3 AND 확장 3분 이상 막힘 AND (Tomcat busy ≥95% 2/2 OR CUJ p95 ≥목표 2배 2/2).""" - if len(c.cpu_avg) < 3 or None in c.cpu_avg[:3] or len(c.scale_blocked_history) < 4: + """CPU 평균 ≥99% 1분 AND 확장 1분 이상 막힘(지금·직전) AND (Tomcat busy ≥95% OR CUJ p95 ≥목표 2배) — 노션 10/15: 3분 → 1분.""" + if len(c.cpu_avg) < 1 or c.cpu_avg[0] is None or len(c.scale_blocked_history) < 2: return None - saturated = (len(c.busy_threads_max) >= 2 and None not in c.busy_threads_max[:2] and min(c.busy_threads_max[:2]) >= 95) \ + saturated = (len(c.busy_threads_max) >= 1 and c.busy_threads_max[0] is not None and c.busy_threads_max[0] >= 95) \ or bool(c.slow_cuj) - hit = min(c.cpu_avg[:3]) >= 99 and all(b == 1 for b in c.scale_blocked_history[:4]) and saturated + hit = c.cpu_avg[0] >= 99 and all(b == 1 for b in c.scale_blocked_history[:2]) and saturated return 1 if hit else 0 @@ -122,11 +121,11 @@ def minutes_to_full(free_bytes): def res04(s): - """여유 ≤5% 2/2 AND 소진 예상 ≤15분 AND 확장 불가.""" - if len(s.free_pct) < 2 or None in s.free_pct[:2] or s.expandable is None: + """여유 ≤5% 1분 AND 소진 예상 ≤15분 AND 확장 불가. 예상은 15분 추세라 여유 판정만 1분으로 줄였다.""" + if len(s.free_pct) < 1 or s.free_pct[0] is None or s.expandable is None: return None eta = minutes_to_full(s.free_bytes) - hit = max(s.free_pct[:2]) <= 5 and eta is not None and eta <= 15 and not s.expandable + hit = s.free_pct[0] <= 5 and eta is not None and eta <= 15 and not s.expandable return 1 if hit else 0 @@ -138,7 +137,7 @@ def expandable(instance): return instance.get('AllocatedStorage', 0) < max_alloc * 0.99 -# RDS 인스턴스 클래스 → RAM(GiB). DB-02 DbMemoryPressure 의 「RAM 의 10%」. 모르는 클래스는 판정하지 않는다. +# RDS 인스턴스 클래스 → RAM(GiB). DB-02(10%)·DB-11(5%) 의 RAM 기준. 모르는 클래스는 판정하지 않는다. RDS_RAM_GIB = { 'db.t4g.micro': 1, 'db.t4g.small': 2, 'db.t4g.medium': 4, 'db.t4g.large': 8, 'db.t4g.xlarge': 16, 'db.t3.micro': 1, 'db.t3.small': 2, 'db.t3.medium': 4, 'db.t3.large': 8, @@ -147,12 +146,19 @@ def expandable(instance): } -def db_memory_pressure(instance_class, freeable, swap): - """최근 10분 내내 Freeable ≤ RAM 10% 이고 그 사이 Swap 이 늘었으면 1. 입력이 모자라면 None.""" +# 메모리 판정 두 단계 — 노션 10/15 리뷰: P1 은 RAM 10% 5분, P0 는 RAM 5% 1분. 둘 다 Swap 이 늘고 있어야 한다. +DB_MEMORY_PRESSURE = (0.10, 5) # DB-02 P1 DbMemoryPressure +DB_MEMORY_CRITICAL = (0.05, 1) # DB-11 P0 DbMemoryCritical +SWAP_TREND_MINUTES = 5 # Swap 증가는 최근 5분 처음·끝으로 본다 — 1분 창에서는 두 점이 안 나온다 + + +def db_memory_flag(instance_class, freeable, swap, level): + """최근 level 분 내내 Freeable ≤ RAM×비율 이고 최근 5분 사이 Swap 이 늘었으면 1. 입력이 모자라면 None.""" + fraction, minutes = level ram = RDS_RAM_GIB.get(instance_class) - f = [v for v in freeable[:10] if v is not None] - w = [v for v in swap[:10] if v is not None] - if ram is None or len(f) < 10 or len(w) < 2: + f = [v for v in freeable[:minutes] if v is not None] + w = [v for v in swap[:max(minutes, SWAP_TREND_MINUTES)] if v is not None] + if ram is None or len(f) < minutes or len(w) < 2: return None - limit = ram * 1024 ** 3 * 0.10 + limit = ram * 1024 ** 3 * fraction return 1 if max(f) <= limit and w[0] > w[-1] else 0 diff --git a/infra/monitoring/lambdas/resource_monitor.py b/infra/monitoring/lambdas/resource_monitor.py index 5f599e2e..a710aac6 100644 --- a/infra/monitoring/lambdas/resource_monitor.py +++ b/infra/monitoring/lambdas/resource_monitor.py @@ -5,7 +5,7 @@ · 멈춘 태스크 → UnexpectedStopCount·OOMStopCount(ECS-03/04), 실패한 배포 → DeployFailureCount(ECS-07) · 오토스케일 상한·최근 실패 → ScaleOutBlocked · RES-01~04 판정 → 버지니아 RuleUp/Ops CriticalCapacityFlag(Env,Service,Rule). 입력이 불완전하면 그 Rule 은 내지 않는다 - · RDS → RuleUp/DB FreeStoragePct(DB-05)·StorageMinutesToFull(DB-09)·DbMemoryPressure(DB-02) + · RDS → RuleUp/DB FreeStoragePct(DB-05)·StorageMinutesToFull(DB-09)·DbMemoryPressure(DB-02 P1)·DbMemoryCritical(DB-11 P0) 5분마다 · RDS 백업 → RuleUp/Backup BackupEnabled·LatestRestorableLagSec·AutomatedSnapshotAgeSec·BackupFailureCount(BACKUP-01~04) 모든 원천을 읽은 실행만 RuleUp/Observe CollectorHeartbeat(Component=resource-monitor) 를 낸다(OBS-01). @@ -124,9 +124,10 @@ def recent(qid, n, start=last): eta = rc.minutes_to_full(free) if eta is not None: db_out.append(('StorageMinutesToFull', db, eta, stamp, 'None')) - pressure = rc.db_memory_pressure(inst['DBInstanceClass'], recent('fmem', 10), recent('swap', 10)) - if pressure is not None: - db_out.append(('DbMemoryPressure', db, pressure, stamp, 'None')) + for metric_name, level in (('DbMemoryPressure', rc.DB_MEMORY_PRESSURE), ('DbMemoryCritical', rc.DB_MEMORY_CRITICAL)): + flag = rc.db_memory_flag(inst['DBInstanceClass'], recent('fmem', 10), recent('swap', 10), level) + if flag is not None: + db_out.append((metric_name, db, flag, stamp, 'None')) if now.minute % 5 == 0: backup(boto3.client('rds', region_name=api), cw_api, inst, env, now, stamp) except Exception as e: # noqa: BLE001 diff --git a/infra/monitoring/lambdas/slo_core.py b/infra/monitoring/lambdas/slo_core.py index 8876022a..1808ea89 100644 --- a/infra/monitoring/lambdas/slo_core.py +++ b/infra/monitoring/lambdas/slo_core.py @@ -101,9 +101,13 @@ def evaluate_burn(rule, series, last_minute): return (1 if hit else 0), {**why, 'state': 'breach' if hit else 'ok'} +# 급성 판정 창(분) — 노션 10/15 리뷰: API-01 2분(P0 1~2분 내 호출), API-02 3분. +ACUTE_WINDOW_MIN = {'API-01': 2, 'API-02': 3} + + def evaluate_acute(rule_id, series, last_minute): - """API-01(P0): 5분 실패율 ≥50%·요청 ≥20·실패 ≥10 / API-02(P1): 실패율 ≥5%·요청 ≥20.""" - w = window(series, last_minute, 5) + """API-01(P0): 2분 실패율 ≥50%·요청 ≥20·실패 ≥10 / API-02(P1): 3분 실패율 ≥5%·요청 ≥20.""" + w = window(series, last_minute, ACUTE_WINDOW_MIN[rule_id]) why = {'rule': rule_id, 'short': _w(w, 'availability')} if not w.valid: return None, {**why, 'state': 'insufficient_coverage'} diff --git a/infra/monitoring/lambdas/tests/test_edge_probe.py b/infra/monitoring/lambdas/tests/test_edge_probe.py index 09a6d907..63aba908 100644 --- a/infra/monitoring/lambdas/tests/test_edge_probe.py +++ b/infra/monitoring/lambdas/tests/test_edge_probe.py @@ -53,5 +53,28 @@ def test_dns_failure(self): self.assertEqual(edge_probe.probe('x', '/', 't', 'tokyo'), (False, 'dns', None)) + +class HandlerTest(unittest.TestCase): + """한 실행에 두 번 점검 — 둘 다 실패해야 EDGE-01 leaf(ProbeFailure=1), 실패 횟수는 EDGE-02 로.""" + + def invoke(self, results): + put = fake_aws.install() + os.environ['RULEUP_REGISTRY'] = os.path.join(os.path.dirname(__file__), '..', '..', 'env', 'stg.json') + os.environ['PROBE_ORIGIN'] = 'oregon' + with mock.patch.object(edge_probe, 'probe', side_effect=results), mock.patch('time.sleep') as sleep: + edge_probe.handler({'time': '2026-10-15T10:15:00Z'}, None) + self.assertEqual(sleep.call_count, 1, '두 점검 사이에 한 번 쉰다') + return {d['MetricName']: d['Value'] for _, ns, data in put if ns == 'RuleUp/Edge' for d in data} + + def test_two_consecutive_failures(self): + m = self.invoke([(False, 'http', None), (False, 'http', None)]) + self.assertEqual((m['ProbeFailure'], m['ProbeAttemptFailures']), (1, 2)) + + def test_one_failure_is_not_edge01(self): + m = self.invoke([(False, 'http', None), (True, 'ok', 30)]) + self.assertEqual((m['ProbeFailure'], m['ProbeAttemptFailures']), (0, 1)) + self.assertEqual(m['CertificateDaysLeft'], 30) + + if __name__ == '__main__': unittest.main() diff --git a/infra/monitoring/lambdas/tests/test_resource_core.py b/infra/monitoring/lambdas/tests/test_resource_core.py index ca1496ce..4ad07409 100644 --- a/infra/monitoring/lambdas/tests/test_resource_core.py +++ b/infra/monitoring/lambdas/tests/test_resource_core.py @@ -50,8 +50,8 @@ def test_res01_missing_report_suspends(self): self.assertIsNone(r.res01(cap(mem_min=[99.5, 99.5], mem_samples=[1, 2]))) def test_res01_98_needs_blocked_scale_out(self): - self.assertEqual(r.res01(cap(mem_min=[98.5, 98.2])), 0) - self.assertEqual(r.res01(cap(mem_min=[98.5, 98.2], scale_blocked_history=[1, 1, 1, 1])), 1) + self.assertEqual(r.res01(cap(mem_min=[98.5, 50])), 0) + self.assertEqual(r.res01(cap(mem_min=[98.5, 50], scale_blocked_history=[1, 0, 0, 0])), 1, '1분이면 충분하다(10/15)') def test_res02(self): self.assertEqual(r.res02(cap(oom_5m=2, healthy=1)), 1) @@ -59,10 +59,13 @@ def test_res02(self): self.assertEqual(r.res02(cap(oom_5m=3, healthy=2)), 0) def test_res03_cpu_alone_is_not_p0(self): - self.assertEqual(r.res03(cap(cpu_avg=[99.5, 99.4, 99.9])), 0, '확장 가능하면 P1(ECS-09)') - self.assertEqual(r.res03(cap(cpu_avg=[99.5, 99.4, 99.9], scale_blocked_history=[1, 1, 1, 1])), 0, '처리 포화 증거 없음') - self.assertEqual(r.res03(cap(cpu_avg=[99.5, 99.4, 99.9], scale_blocked_history=[1, 1, 1, 1], - busy_threads_max=[96, 97])), 1) + self.assertEqual(r.res03(cap(cpu_avg=[99.5, 30, 30])), 0, '확장 가능하면 P1(ECS-09)') + self.assertEqual(r.res03(cap(cpu_avg=[99.5, 30, 30], scale_blocked_history=[1, 1, 0, 0])), 0, '처리 포화 증거 없음') + self.assertEqual(r.res03(cap(cpu_avg=[99.5, 30, 30], scale_blocked_history=[1, 1, 0, 0], busy_threads_max=[96, 10])), 1, + '1분이면 충분하다(10/15)') + + def test_res03_blocked_must_last_a_minute(self): + self.assertEqual(r.res03(cap(cpu_avg=[99.5], scale_blocked_history=[1, 0], busy_threads_max=[96])), 0) def test_scale_out_blocked(self): self.assertEqual(r.scale_out_blocked(6, 6, False), 1) @@ -81,20 +84,27 @@ def test_minutes_to_full_from_15_minute_trend(self): def test_res04_requires_unexpandable(self): free = [1 * self.GB + i * 0.1 * self.GB for i in range(16)] - self.assertEqual(r.res04(r.Storage([4, 4.5], free, expandable=True)), 0) - self.assertEqual(r.res04(r.Storage([4, 4.5], free, expandable=False)), 1) - self.assertIsNone(r.res04(r.Storage([4], free, expandable=False))) + self.assertEqual(r.res04(r.Storage([4, 6], free, expandable=True)), 0) + self.assertEqual(r.res04(r.Storage([4, 6], free, expandable=False)), 1) + self.assertIsNone(r.res04(r.Storage([None, 4], free, expandable=False))) def test_expandable(self): self.assertFalse(r.expandable({'AllocatedStorage': 100})) self.assertFalse(r.expandable({'AllocatedStorage': 200, 'MaxAllocatedStorage': 200})) self.assertTrue(r.expandable({'AllocatedStorage': 100, 'MaxAllocatedStorage': 200})) - def test_db_memory_pressure(self): - low = [0.3 * self.GB] * 10 # 4GiB 의 10% = 0.4GiB 아래 - self.assertEqual(r.db_memory_pressure('db.t4g.medium', low, [300, 200, 100]), 1) - self.assertEqual(r.db_memory_pressure('db.t4g.medium', low, [100, 100]), 0) - self.assertIsNone(r.db_memory_pressure('db.x9.huge', low, [300, 100])) + def test_db_memory_pressure_p1_five_minutes(self): + low = [0.3 * self.GB] * 5 # 4GiB 의 10% = 0.4GiB 아래 + self.assertEqual(r.db_memory_flag('db.t4g.medium', low, [300, 200, 100], r.DB_MEMORY_PRESSURE), 1) + self.assertEqual(r.db_memory_flag('db.t4g.medium', low, [100, 100], r.DB_MEMORY_PRESSURE), 0, 'Swap 이 늘지 않음') + self.assertIsNone(r.db_memory_flag('db.t4g.medium', low[:4], [300, 100], r.DB_MEMORY_PRESSURE), '5분이 안 찼다') + self.assertIsNone(r.db_memory_flag('db.x9.huge', low, [300, 100], r.DB_MEMORY_PRESSURE)) + + def test_db_memory_critical_p0_one_minute(self): + """P0 는 RAM 5% 1분 — 10% 선 아래여도 5% 를 넘으면 P1 만.""" + self.assertEqual(r.db_memory_flag('db.t4g.medium', [0.15 * self.GB], [300, 250, 200], r.DB_MEMORY_CRITICAL), 1) + self.assertEqual(r.db_memory_flag('db.t4g.medium', [0.3 * self.GB], [300, 250, 200], r.DB_MEMORY_CRITICAL), 0) + self.assertIsNone(r.db_memory_flag('db.t4g.medium', [0.15 * self.GB], [300], r.DB_MEMORY_CRITICAL), 'Swap 추세 두 점 필요') if __name__ == '__main__': diff --git a/infra/monitoring/lambdas/tests/test_slo_core.py b/infra/monitoring/lambdas/tests/test_slo_core.py index cfc418c7..68dd548f 100644 --- a/infra/monitoring/lambdas/tests/test_slo_core.py +++ b/infra/monitoring/lambdas/tests/test_slo_core.py @@ -65,15 +65,27 @@ def test_latency_rule_counts_slow_good_requests(self): flag, _ = s.evaluate_burn(s.BURN_RULES[4], series, L) self.assertEqual(flag, 1) - def test_acute_failure_needs_ten_failures(self): + def test_acute_failure_uses_two_minute_window(self): series = flat(4320, 10, 0) - for m in range(L - 4, L + 1): - series[m] = (4, 3, 3) # 20건 중 15건 실패 + for m in range(L - 1, L + 1): + series[m] = (10, 5, 5) # 최근 2분 20건 중 10건 실패 self.assertEqual(s.evaluate_acute('API-01', series, L)[0], 1) - for m in range(L - 4, L + 1): - series[m] = (4, 1, 1) # 20건 중 5건 = 25% 이지만 실패 10건 미만 + for m in range(L - 1, L + 1): + series[m] = (10, 4, 4) # 20건 중 8건 = 40% self.assertEqual(s.evaluate_acute('API-01', series, L)[0], 0) + def test_acute_low_sample_in_two_minutes_is_pending(self): + """2분 창에 요청 20건이 안 되면 판정하지 않는다 — 그 전 분의 실패는 세지 않는다.""" + series = flat(4320, 4, 3) # 분당 4건 → 2분 8건 + self.assertIsNone(s.evaluate_acute('API-01', series, L)[0]) + + def test_api02_uses_three_minute_window(self): + series = flat(4320, 10, 0) + series[L - 3] = (10, 10, 10) # 3분 창 밖의 실패는 보지 않는다 + self.assertEqual(s.evaluate_acute('API-02', series, L)[0], 0) + series[L - 2] = (10, 2, 2) # 30건 중 2건 = 6.7% + self.assertEqual(s.evaluate_acute('API-02', series, L)[0], 1) + class BudgetTest(unittest.TestCase):