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):