Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infra/monitoring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@

| 항목 | 막는 것 | 그 전까지 |
|---|---|---|
| PagerDuty(G01·G03), OBS-03/05/06 | 계약·계정 없음 | P0 = Slack 긴급 채널 멘션. 원장 `sns.pagerduty` 에 토픽 이름을 넣으면 P0 경보에 붙는다 |
| PagerDuty 개통(G01·G03), OBS-03/05/06 | 계약·계정·API 키 없음 | 설정은 `alerting/pagerduty.py` 로 준비됨(1차 → 10분 미ACK → 팀원). 개통 전 P0 = Slack 긴급 채널 멘션 |
| 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(실사용 로그인) |
Expand Down
2 changes: 1 addition & 1 deletion infra/monitoring/alarm-catalog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
schema_version: 2
status: proposed
resource_registry: infra/monitoring/env/<env>.json
pager: PagerDuty # 미계약 — 원장 sns.pagerduty 가 null 인 동안 P0 는 Slack 긴급 채널 멘션만
pager: PagerDuty # alerting/pagerduty.py — 1차 담당 → 10분 미ACK → 팀원(2회 반복). 미계약이라 원장 sns.pagerduty 가 null 인 동안 P0 는 Slack 긴급 채널 멘션만
watchdog: Healthchecks.io # 미계약 — 원장 healthchecks.* 가 null
slo:
window_days: 30
Expand Down
13 changes: 11 additions & 2 deletions infra/monitoring/alerting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,14 @@ EventBridge 규칙이 서울·버지니아 모두 `ruleup-prod-P0-` 접두어로
누락 정책: 커스텀 P0·EDGE leaf = `ignore`(입력이 끊겨도 정상으로 돌아가지 않는다 — 재무장은 정상 Flag=0 또는 `../lambdas/rearm.py`),
하트비트·NATIVE-01 = `breaching`, 나머지 = `notBreaching`.

**온콜**: Incident Manager(`ssm-contacts`·`ssm-incidents`)는 조직 SCP 가 막는다. 노션은 PagerDuty 를 채택했지만 아직 계약 전이라,
지금 P0 의 호출 수단은 긴급 채널 멘션(`p0_mention.py`·`routing.py`)뿐이다. 원장 `sns.pagerduty` 가 생기면 P0 경보 AlarmActions 에 붙는다.
**온콜**(`pagerduty.py`, prod 만): **1차 담당(이성은)에게 먼저 → 10분 안에 ACK 가 없으면 팀원 → 그래도 없으면 처음부터 다시(2회)**.
PagerDuty 서비스 「RuleUp prod P0」 — 긴급도 항상 high, ACK 후 15분 안에 해결 안 되면 다시 울림, 시간 기반 자동 종료 끔.
CloudWatch → 같은 리전 SNS `ruleup-prod-pagerduty`(서울·버지니아) → PagerDuty 공식 CloudWatch 연동. 경보가 OK 로 돌아오면 사건이 닫힌다.
Incident Manager(`ssm-contacts`·`ssm-incidents`)는 조직 SCP 가 막아 쓰지 않는다. 긴급 채널 멘션(`p0_mention.py`)은 보조로 그대로 둔다.

개통 순서(PagerDuty 계정·API 키가 생긴 뒤):
1. 두 사람 모두 PagerDuty 앱 설치 + 프로필 알림 규칙에 **High-urgency: 즉시 푸시 + 1분 뒤 전화**를 넣는다(서비스가 아니라 사람마다 설정이다).
2. `PAGERDUTY_API_TOKEN=… python3 infra/monitoring/alerting/pagerduty.py setup --primary <내 PD 이메일> --secondary <팀원 PD 이메일>` — 원장 `env/prod.json` 이 채워진다 → 커밋.
3. `python3 infra/monitoring/alerting/p0_p1.py prod` — P0 경보 AlarmActions·OKActions 에 PagerDuty 토픽이 붙는다.
4. 실수신 시험(G01): `pagerduty.py test-trigger` → 내 폰이 울리면 **ACK 하지 않고** 10분 기다려 팀원 폰이 울리는지 → `pagerduty.py test-resolve`.
노션 「대시보드·알림 요약표」 P0 온콜 연결 체크표 12종은 경보별로 `aws cloudwatch set-alarm-state` 로 한 번씩 확인한다(다음 평가에서 OK 로 돌아온다).
187 changes: 187 additions & 0 deletions infra/monitoring/alerting/pagerduty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
"""P0 온콜 — PagerDuty 에스컬레이션과 CloudWatch 연동. 재실행 안전. prod 만(stg 의 P0 는 시험 채널에만 간다).

호출 순서(2026-10-15 결정): 1차 담당(이성은)에게 바로 → 10분 안에 ACK 가 없으면 팀원 → 그래도 없으면 처음부터 다시(repeat_loops).
노션 카탈로그 「페이징 도구 결정」의 5분 지원자 통보·10분 재호출을 이 순서로 바꿨다.

export PAGERDUTY_API_TOKEN=... # PagerDuty REST API 키(User 또는 General Access, 쓰기). 저장소·원장에 넣지 않는다
python3 infra/monitoring/alerting/pagerduty.py setup --primary <1차 PD 계정 이메일> --secondary <팀원 PD 계정 이메일>
python3 infra/monitoring/alerting/p0_p1.py prod # 원장 sns.pagerduty 가 채워졌으니 P0 경보에 토픽이 붙는다
python3 infra/monitoring/alerting/pagerduty.py test-trigger # 시험 사고 — ACK 하지 말고 10분 뒤 팀원 폰이 울리는지 본다
python3 infra/monitoring/alerting/pagerduty.py test-resolve

setup 이 하는 일
1. 이메일로 PagerDuty 사용자를 찾는다(두 사람 모두 PagerDuty 계정·앱 설치·전화 알림 규칙이 먼저 있어야 한다).
2. 에스컬레이션 정책 「RuleUp prod P0」 — 규칙 1: 1차 담당, 10분 / 규칙 2: 팀원, 10분 / repeat_loops 회 반복.
3. 서비스 「RuleUp prod P0」 — 긴급도 항상 high, ACK 후 15분 안에 해결이 없으면 다시 울림, 시간 기반 자동 종료 끔(노션 §2).
4. 서비스에 공식 Amazon CloudWatch 연동을 붙이고, 서울·버지니아에 SNS 토픽 ruleup-prod-pagerduty 를 만들어 HTTPS 로 구독시킨다
(CloudWatch → 같은 리전 SNS → PagerDuty 직결 — 자체 Lambda·릴레이를 거치지 않는다). 구독 확인은 PagerDuty 가 자동으로 한다.
5. 원장 env/prod.json 에 사용자·정책·서비스 ID 와 sns.pagerduty 를 적는다(이메일·연동 키는 적지 않는다) → 커밋한다.
경보가 OK 로 돌아오면 같은 토픽(OKActions)으로 PagerDuty 사건이 닫힌다.
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
LEDGER = os.path.join(HERE, '..', 'env', 'prod.json')
API = 'https://api.pagerduty.com'
NAME = 'RuleUp prod P0'
TOPIC = 'ruleup-prod-pagerduty'
TEST_ALARM = 'ruleup-prod-P0-ONCALL-TEST'


def escalation_policy(primary_id, secondary_id, delay_minutes, loops):
"""1차 담당 → delay 분 미ACK → 팀원 → delay 분 미ACK → 처음부터(loops 회)."""
rule = lambda uid: {'escalation_delay_in_minutes': delay_minutes, 'targets': [{'id': uid, 'type': 'user_reference'}]}
return {'type': 'escalation_policy', 'name': NAME, 'num_loops': loops,
'description': f'RuleUp prod P0: primary first, teammate after {delay_minutes} min without ACK',
'escalation_rules': [rule(primary_id), rule(secondary_id)]}


def service(policy_id, ack_timeout_minutes):
return {'type': 'service', 'name': NAME, 'description': 'CloudWatch P0 alarms (ruleup-prod-P0-*) via SNS',
'escalation_policy': {'id': policy_id, 'type': 'escalation_policy_reference'},
'incident_urgency_rule': {'type': 'constant', 'urgency': 'high'},
'acknowledgement_timeout': ack_timeout_minutes * 60,
'auto_resolve_timeout': None,
'alert_creation': 'create_alerts_and_incidents'}


def cloudwatch_message(account, region, state, now):
"""CloudWatch 경보가 SNS 로 보내는 본문과 같은 모양 — PagerDuty CloudWatch 연동은 AlarmArn 으로 같은 사건을 묶는다."""
return {'AlarmName': TEST_ALARM, 'AlarmDescription': '온콜 시험 — 1차 담당이 ACK 하지 않으면 10분 뒤 팀원에게 간다',
'AWSAccountId': account, 'NewStateValue': state, 'OldStateValue': 'OK' if state == 'ALARM' else 'ALARM',
'NewStateReason': 'pagerduty.py on-call escalation test', 'StateChangeTime': now.strftime('%Y-%m-%dT%H:%M:%S.000+0000'),
'Region': region, 'AlarmArn': f'arn:aws:cloudwatch:{region}:{account}:alarm:{TEST_ALARM}',
'Trigger': {'MetricName': 'OnCallTest', 'Namespace': 'RuleUp/Observe', 'Statistic': 'MAXIMUM', 'Period': 60,
'EvaluationPeriods': 1, 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', 'Threshold': 1.0}}


class PagerDuty:
def __init__(self, token):
self.token = token

def call(self, method, path, body=None, query=None):
url = API + path + ('?' + urllib.parse.urlencode(query, doseq=True) if query else '')
req = urllib.request.Request(url, method=method, data=None if body is None else json.dumps(body).encode(), headers={
'Authorization': f'Token token={self.token}', 'Accept': 'application/vnd.pagerduty+json;version=2',
'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read() or b'{}')
except urllib.error.HTTPError as e:
sys.exit(f'PagerDuty {method} {path} → {e.code}: {e.read().decode(errors="replace")[:300]}')

def user_id(self, email):
users = [u for u in self.call('GET', '/users', query={'query': email})['users'] if u['email'].lower() == email.lower()]
if not users:
sys.exit(f'PagerDuty 사용자가 없다 — 먼저 초대·가입시킨다: {email}')
return users[0]['id']

def find(self, kind, name):
key = kind.rstrip('s') if kind != 'escalation_policies' else 'escalation_policy'
hits = [x for x in self.call('GET', f'/{kind}', query={'query': name})[kind] if x['name'] == name]
return hits[0]['id'] if hits else None, key

def upsert(self, kind, body):
found, key = self.find(kind, body['name'])
if found:
return self.call('PUT', f'/{kind}/{found}', {key: body})[key]['id']
return self.call('POST', f'/{kind}', {key: body})[key]['id']

def cloudwatch_integration_key(self, service_id):
svc = self.call('GET', f'/services/{service_id}', query={'include[]': 'integrations'})['service']
for i in svc.get('integrations', []):
if i.get('type') == 'aws_cloudwatch_inbound_integration':
return self.call('GET', f'/services/{service_id}/integrations/{i["id"]}')['integration']['integration_key']
vendors = [v for v in self.call('GET', '/vendors', query={'query': 'Amazon CloudWatch'})['vendors'] if v['name'] == 'Amazon CloudWatch']
if not vendors:
sys.exit('PagerDuty 에서 Amazon CloudWatch 연동(vendor)을 찾지 못했다')
body = {'type': 'aws_cloudwatch_inbound_integration', 'name': 'CloudWatch via SNS',
'vendor': {'id': vendors[0]['id'], 'type': 'vendor_reference'}}
return self.call('POST', f'/services/{service_id}/integrations', {'integration': body})['integration']['integration_key']


def load():
with open(LEDGER, encoding='utf-8') as f:
return json.load(f)


def save(reg):
with open(LEDGER, 'w', encoding='utf-8') as f:
json.dump(reg, f, ensure_ascii=False, indent=2)
f.write('\n')


def regions(reg):
return [reg['api_region'], reg['monitor_region']]


def subscribe(reg, endpoint):
import boto3
sys.path.insert(0, HERE)
import routing # 토픽 정책(계정 소유자 + CloudWatch 발행)을 같은 모양으로
for region in regions(reg):
sns = boto3.client('sns', region_name=region)
arn = routing.topic(sns, TOPIC, reg['account_id'], ['cloudwatch.amazonaws.com'])
subs = [s for p in sns.get_paginator('list_subscriptions_by_topic').paginate(TopicArn=arn) for s in p['Subscriptions']]
for s in subs: # 연동을 다시 만들어 키가 바뀌었으면 옛 구독을 지운다
if s['Protocol'] == 'https' and s['Endpoint'] != endpoint and s['SubscriptionArn'].startswith('arn:'):
sns.unsubscribe(SubscriptionArn=s['SubscriptionArn'])
print(' unsubscribed old endpoint', region)
if not any(s['Endpoint'] == endpoint for s in subs):
sns.subscribe(TopicArn=arn, Protocol='https', Endpoint=endpoint)
print(' subscribed', region, TOPIC)


def setup(args):
token = os.environ.get('PAGERDUTY_API_TOKEN') or sys.exit('PAGERDUTY_API_TOKEN 이 없다')
reg = load()
pd_cfg = reg['pagerduty']
pd = PagerDuty(token)
primary, secondary = pd.user_id(args.primary), pd.user_id(args.secondary)
policy_id = pd.upsert('escalation_policies', escalation_policy(primary, secondary, pd_cfg['escalation_delay_minutes'], pd_cfg['repeat_loops']))
print('escalation policy', policy_id, f"(1차 → {pd_cfg['escalation_delay_minutes']}분 → 팀원, {pd_cfg['repeat_loops']}회 반복)")
service_id = pd.upsert('services', service(policy_id, pd_cfg['ack_timeout_minutes']))
print('service', service_id)
key = pd.cloudwatch_integration_key(service_id)
subscribe(reg, f'https://events.pagerduty.com/integration/{key}/enqueue')
pd_cfg.update(primary_user_id=primary, secondary_user_id=secondary, escalation_policy_id=policy_id, service_id=service_id)
reg['sns']['pagerduty'] = TOPIC
save(reg)
print('원장 env/prod.json 갱신 — 커밋하고 p0_p1.py prod 를 다시 돌려 P0 경보에 토픽을 붙인다')


def publish_test(state):
import boto3
reg = load()
if not reg['sns'].get('pagerduty'):
sys.exit('원장 sns.pagerduty 가 비어 있다 — setup 먼저')
region = reg['api_region']
arn = f"arn:aws:sns:{region}:{reg['account_id']}:{reg['sns']['pagerduty']}"
msg = cloudwatch_message(reg['account_id'], region, state, datetime.now(timezone.utc))
boto3.client('sns', region_name=region).publish(TopicArn=arn, Subject=f'{state}: "{TEST_ALARM}"', Message=json.dumps(msg, ensure_ascii=False))
print(state, TEST_ALARM, '→', arn)
if state == 'ALARM':
print('ACK 하지 말 것 — 10분 뒤 팀원에게 가는지 확인하고 test-resolve 로 닫는다', time.strftime('%H:%M:%S'))


if __name__ == '__main__':
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest='cmd', required=True)
s = sub.add_parser('setup')
s.add_argument('--primary', required=True, help='1차 담당 PagerDuty 계정 이메일')
s.add_argument('--secondary', required=True, help='10분 미응답 시 받을 팀원 PagerDuty 계정 이메일')
sub.add_parser('test-trigger')
sub.add_parser('test-resolve')
a = ap.parse_args()
if a.cmd == 'setup':
setup(a)
else:
publish_test('ALARM' if a.cmd == 'test-trigger' else 'OK')
2 changes: 1 addition & 1 deletion infra/monitoring/alerting/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
ruleup-prod-p0-alerts (us-east-1) → 경보 AlarmActions 용(구독 없음 — 서울과 같은 구조)
ruleup-prod-p0-slack (us-east-1) → EventBridge(us-east-1) 가 P0 상태 변경을 멘션 알림으로 바꿔 보낸다 → ruleup-p0-urgent
서울 쪽 구성(slack_channels.py·p0_mention.py)은 그대로 두고 토픽 목록에 us-east-1 것을 더한다.
SNS 는 전달만 한다 — ACK·에스컬레이션은 PagerDuty 개통 후(원장 sns.pagerduty)에 생긴다.
SNS 는 전달만 한다 — ACK·에스컬레이션(1차 → 10분 미ACK → 팀원)은 pagerduty.py 가 만드는 PagerDuty 서비스가 한다.
"""
import json
import os
Expand Down
39 changes: 39 additions & 0 deletions infra/monitoring/alerting/tests_pagerduty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""pagerduty.py 계약 시험 — python3 infra/monitoring/alerting/tests_pagerduty.py (PagerDuty·AWS 호출 없음)"""
import json, os, sys, unittest
from datetime import datetime, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import pagerduty as pd


class PagerDutyTest(unittest.TestCase):

def test_primary_first_then_teammate_after_ten_minutes(self):
"""나에게 먼저, 10분 안에 ACK 가 없으면 팀원에게 — 규칙 순서가 곧 호출 순서다."""
reg = pd.load()['pagerduty']
body = pd.escalation_policy('PRIMARY', 'TEAMMATE', reg['escalation_delay_minutes'], reg['repeat_loops'])
rules = body['escalation_rules']
self.assertEqual([r['targets'][0]['id'] for r in rules], ['PRIMARY', 'TEAMMATE'])
self.assertEqual(rules[0]['escalation_delay_in_minutes'], 10)
self.assertGreaterEqual(body['num_loops'], 1, '팀원도 안 받으면 처음부터 다시 울린다')

def test_service_never_auto_resolves_and_always_high(self):
body = pd.service('POLICY', 15)
self.assertIsNone(body['auto_resolve_timeout'], '시간이 지났다고 사건을 닫지 않는다(노션 §2)')
self.assertEqual(body['acknowledgement_timeout'], 900)
self.assertEqual(body['incident_urgency_rule'], {'type': 'constant', 'urgency': 'high'})

def test_test_message_looks_like_cloudwatch(self):
m = pd.cloudwatch_message('123', 'ap-northeast-2', 'ALARM', datetime(2026, 10, 15, tzinfo=timezone.utc))
self.assertTrue({'AlarmName', 'NewStateValue', 'AlarmArn', 'StateChangeTime', 'AWSAccountId', 'Trigger'} <= set(m))
self.assertTrue(m['AlarmName'].startswith('ruleup-prod-P0-'), '긴급 채널 멘션과 같은 접두어')
json.dumps(m)

def test_ledger_keeps_no_secrets(self):
text = json.dumps(pd.load(), ensure_ascii=False)
self.assertNotIn('@', text, '원장에 이메일을 넣지 않는다')
self.assertNotIn('integration_key', text)


if __name__ == '__main__':
unittest.main()
42 changes: 35 additions & 7 deletions infra/monitoring/env/prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
"account_id": "961178969292",
"api_region": "ap-northeast-2",
"monitor_region": "us-east-1",
"probe_origins": {"oregon": "us-west-2", "virginia": "us-east-1"},
"probe_origins": {
"oregon": "us-west-2",
"virginia": "us-east-1"
},
"public_domain": "prod.ruleup.co.kr",
"public_health_path": "/health/public",
"public_health_token": "ruleup-ok",
Expand All @@ -16,19 +19,44 @@
"load_balancer": "app/ruleup-prod-alb/30526d51ec29a005",
"target_group": "targetgroup/ruleup-prod-tg/d3db371c903b8892",
"rds_instance": "ruleup-prod-mysql-v2",
"redis_nodes": ["ruleup-prod-redis-001", "ruleup-prod-redis-002"],
"queues": ["ruleup-prod-moderation", "ruleup-prod-notifications"],
"dlqs": ["ruleup-prod-moderation-dlq", "ruleup-prod-notifications-dlq"],
"redis_nodes": [
"ruleup-prod-redis-001",
"ruleup-prod-redis-002"
],
"queues": [
"ruleup-prod-moderation",
"ruleup-prod-notifications"
],
"dlqs": [
"ruleup-prod-moderation-dlq",
"ruleup-prod-notifications-dlq"
],
"sns": {
"alerts": "ruleup-prod-alerts",
"p0": "ruleup-prod-p0-alerts",
"pagerduty": null
},
"slack_p0_mention_member": "U0AVBAWH05Q",
"route53_health_check_id": "26710503-030a-4b93-8df5-e7339b675632",
"healthchecks": {"obs03_ping_url": null, "obs04_ping_url": null},
"pagerduty": {"service_id": null, "api_key_secret": null},
"dashboards": {"ops": "ruleup-prod-ops", "observe": "ruleup-prod-observe", "business": "ruleup-prod-business"},
"healthchecks": {
"obs03_ping_url": null,
"obs04_ping_url": null
},
"pagerduty": {
"_note": "pagerduty.py setup 이 채운다. 1차 담당 → escalation_delay_minutes 분 미ACK → 팀원 → repeat_loops 회 반복. API 키·연동 키·이메일은 넣지 않는다",
"service_id": null,
"escalation_policy_id": null,
"primary_user_id": null,
"secondary_user_id": null,
"escalation_delay_minutes": 10,
"repeat_loops": 2,
"ack_timeout_minutes": 15
},
"dashboards": {
"ops": "ruleup-prod-ops",
"observe": "ruleup-prod-observe",
"business": "ruleup-prod-business"
},
"runbook_url": "https://app.notion.com/p/3f5ad4145fb6811da317e294cf339757",
"catalog_url": "https://app.notion.com/p/3f5ad4145fb681bbabccefd603336193",
"llm_daily_budget_usd": null,
Expand Down
Loading
Loading