포스트

고객사 RDS 통합 모니터링 플랫폼 구축 — Part 2: APScheduler 와 알림 매커니즘

고객사 RDS 통합 모니터링 플랫폼 구축 — Part 2: APScheduler 와 알림 매커니즘

1편에서 소개한 인프라 위에 9개의 주기 작업과 알림 매커니즘이 한 프로세스 안에서 어떻게 돌아가는지를 짚어 봅니다.

1편의 마지막에 APScheduler 9개 잡 이라는 표현이 한 번 등장했고, 알림 지연을 1분 이내로 묶었습니다 라는 한 문장도 있었습니다. 이 글은 그 한 줄들을 풀어 쓰는 글입니다. 어떤 잡들이 어떤 주기로 돌아가는지, 한 고객사가 죽어도 다른 고객사 수집이 왜 살아남는지, 알림이 어떻게 시끄럽지 않게 발사되고 어떻게 정확히 사람에게 도착하는지를 차례로 적어 봅니다.

왜 별도 스케줄러

먼저 왜 굳이 별도 스케줄러를 쓰는지 짧게 적습니다. 1편에서 “K3s 가 단일 노드라면 Docker Compose 만으로 충분하지 않냐” 는 질문을 한 번 다뤘던 것과 결이 비슷한 선택입니다. 후보는 셋이었습니다.

  • Celery + Redis 또는 RabbitMQ: 분산 처리에 강하지만 별도 브로커 인프라 가 추가됩니다. 단일 노드 t2.xlarge 안에서 portal-backend 와 같이 띄우자니 메모리가 아깝습니다.
  • 시스템 cron + 스크립트: 가볍지만 가시성이 없습니다. 잡이 실패해도 stderr 가 어디로 가는지 추적하기 번거롭고, 잡 사이의 의존성을 관리하기 어렵습니다.
  • APScheduler (in-process): FastAPI 와 같은 프로세스 안에서 비동기 잡을 돌립니다. 추가 인프라가 없고, 로그가 FastAPI 의 로그와 한 곳에 모이고, DB 세션 풀과 boto3 클라이언트를 같이 공유 할 수 있습니다.

선택지 세 가지 사이에서 APScheduler 가 단연 자연스러웠습니다. 한 사람이 운영하는 단일 노드 라는 조건에서는, 잡이 돌아가는 곳과 API 가 응답하는 곳이 같은 프로세스인 게 디버깅과 운영 모두에 유리한 것 같습니다.

9개의 잡, 한눈에

현재 포털에서 돌고 있는 잡은 7개의 주기 잡 과 2개의 수집 직후에 함께 도는 인라인 알람 평가 까지 합쳐 9개입니다. 잡 이름, 주기, 무엇을 가져와서 어디에 저장하는지를 한 표에 정리하면 이렇게 됩니다.

#잡 이름주기trigger대상 AWS API저장 테이블
1inventory_sync1분IntervalDescribeDBInstances + DescribeDBClustersdb_instances
2instance_status_sync30초IntervalDescribeDBInstances (status only)db_instances.db_instance_status
3event_collection15초IntervalDescribeEvents (1일 윈도우)rds_event_cache
4pi_collection1시간IntervalGetResourceMetrics + DescribeDimensionKeyspi_snapshots, top_sql_snapshots
5pi_load_collection3분IntervalGetResourceMetrics (Load 만)pi_snapshots + 즉시 알람
6cw_metrics_collection1분IntervalGetMetricStatistics × 6cw_metric_snapshots + 즉시 알람
7eos_refresh매주 월 00:00CronAWS 문서 HTML 파싱eos_versions
8(인라인) CW 알람 평가잡 6 직후inline(in-process)cw_alert_states, alert_history
9(인라인) PI 알람 평가잡 5 직후inline(in-process)cw_alert_states, alert_history

수평 시간축에 점을 찍어 보면 이렇게 분포합니다.

1
2
3
4
5
6
7
8
9
   0s    15s   30s   45s   60s    ...    3m    ...    1h
   │     │     │     │     │             │             │
   ●─────●─────●─────●─────●  event (15s 마다)
                           ●  status (30s 마다)
                           ●  cw (1m)  ──→ ❶ inline alert
                           ●  inventory (1m)
                                         ●  pi_load (3m)  ──→ ❷ inline alert
                                                       ●─ pi (1h)
                                                       ★ eos: 월요일 00:00 UTC

❶, ❷ 는 수집이 끝난 뒤 같은 세션에서 즉시 도는 알람 평가입니다. 별도 잡이 아닙니다.

15초마다 도는 잡이 가장 빠르고, 매주 월요일에 한 번 도는 잡이 가장 느립니다. 그 사이에 1분 잡 두 개, 30초 잡 하나, 3분 잡 하나, 한 시간 잡 하나가 들어가 있습니다.

이 9개를 한 곳에서 등록하는 부분만 살짝 보면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def start_scheduler():
    settings = get_settings()

    scheduler.add_job(run_inventory_sync,
        trigger=IntervalTrigger(minutes=settings.inventory_interval_minutes),
        id="inventory_sync", replace_existing=True)

    scheduler.add_job(run_event_collection,
        trigger=IntervalTrigger(seconds=settings.events_interval_seconds),
        id="event_collection", replace_existing=True,
        max_instances=1)  # 이전 수집 실행 중이면 다음 실행 스킵

    scheduler.add_job(run_cw_metrics_collection,
        trigger=IntervalTrigger(minutes=settings.cw_metrics_interval_minutes),
        id="cw_metrics_collection", replace_existing=True,
        max_instances=1,
        next_run_time=datetime.now(timezone.utc))  # 재시작 직후 즉시 1회

    scheduler.add_job(run_eos_refresh,
        trigger=CronTrigger(day_of_week="mon", hour=0, minute=0),
        id="eos_refresh", replace_existing=True)

    # ... (pi_collection, pi_load_collection, instance_status_sync 도 같은 패턴)
    scheduler.start()

portal/backend/app/tasks/scheduler.pystart_scheduler() 발췌. 주기 상수는 get_settings() 가 환경변수에서 읽습니다.

옵션 몇 가지가 눈에 들어옵니다. max_instances=1이전 사이클이 끝나지 않았으면 다음 사이클을 건너뛰는 안전장치입니다. 1분 잡이 1분 안에 못 끝나면 다음 1분에 또 시작되면서 중복 알람이 발생할 수 있는데, 그걸 막아 줍니다. next_run_time재시작 직후 첫 사이클을 기다리지 않고 즉시 한 번 실행 하는 옵션입니다. 재배포 직후 1분 간 메트릭 공백이 생기지 않게 해 줍니다.

한 고객사가 죽어도 다른 고객사는 살아남는다

수집 잡은 모두 고객사를 순회 합니다. 그런데 한 고객사의 IAM Role 이 깨져 있거나, 그 고객사 AWS API 가 일시적으로 5xx 를 던지면, 그 한 고객사 때문에 다른 모든 고객사 수집이 멈추면 곤란합니다.

이 부분은 단순한 패턴 한 가지로 풀었습니다. 고객사마다 DB 세션을 새로 열고, 예외는 그 안에서 잡습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async def run_inventory_sync():
    """전 고객사 인벤토리 동기화 (1분 주기).
    고객사별로 독립 세션 사용: 한 고객사의 오류가
    타 고객사 동기화에 영향 없도록 격리.
    """
    async with AsyncSessionLocal() as db:
        result = await db.execute(
            select(Customer).where(Customer.is_active == True))
        customers = result.scalars().all()

    for customer in customers:
        if customer.aws_role_arn:
            try:
                async with AsyncSessionLocal() as db:
                    await sync_inventory(customer, db)
            except Exception as e:
                print(f"[Inventory] {customer.name} 동기화 실패: {e}")

고객사 목록을 한 번 읽어 두고, 루프 안에서는 고객사마다 새 세션을 엽니다. 예외는 한 고객사만 격리.

처음에는 고객사 전체를 한 트랜잭션 안에서 처리했었는데, 그렇게 두니 한 고객사의 DB UPDATE 에서 데드락이 발생하면 그 뒤의 모든 고객사가 같이 멈춰 버렸습니다. 세션을 잡별로 각자 가지게 하고 예외를 잡 안에서 흡수하니 다른 고객사의 진행은 영향을 받지 않게 되었습니다.

같은 패턴이 instance_status_sync, event_collection, pi_collection, pi_load_collection, cw_metrics_collection 모두에 적용되어 있습니다.

즉시 알람 체크

여기서 한 가지 결정 이유를 더 적어 두고 싶습니다. 왜 알람 평가를 별도 잡으로 두지 않았는지 입니다.

가장 단순한 설계라면, 알람 평가 잡 (예: 1분 주기) 을 따로 띄워서 DB 에 쌓인 메트릭을 주기적으로 훑어 보면서 임계치 초과를 찾는 방식입니다. 그런데 이렇게 두면, 메트릭 수집 잡 (1분) 과 알람 평가 잡 (1분) 사이에 최악 1~2분의 지연 이 생깁니다. 지금 이상이 발생했어도 2분 뒤에야 Telegram 으로 메시지가 가는 셈입니다.

그래서 수집이 끝나면 같은 세션에서 곧바로 알람 평가를 도는 형태로 묶었습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
## (run_pi_load_collection 의 끝부분)
## 고객사별 그룹핑 → 수집 완료 후 고객사 단위로 즉시 알람 체크
target_list.sort(key=lambda x: x["customer_id"])
for cid, group_iter in groupby(target_list, key=lambda x: x["customer_id"]):
    group = list(group_iter)
    customer = group[0]["customer"]
    instances = [g["instance"] for g in group]

    ## 고객사 내 인스턴스 전체 수집
    for instance in instances:
        try:
            async with AsyncSessionLocal() as db:
                await fetch_and_cache_pi(customer, instance, db, start, now)
        except Exception as e:
            print(f"[PI-Load] 수집 실패: {e}")

    ## 고객사 내 전체 수집 완료 후 즉시 알람 체크
    try:
        async with AsyncSessionLocal() as db:
            await _query_pi_alerts(db, cid=cid, do_sync=True)
    except Exception as e:
        print(f"[PI-Load] 알람 체크 실패: {e}")

고객사별로 묶어 수집한 다음, 그 고객사가 끝나는 시점에 알람 평가를 한 번 부릅니다. 다음 고객사 수집은 그 다음에 시작됩니다.

cw_metrics_collection 도 같은 패턴입니다. 수집 → 커밋 → 새 세션으로 _query_cw_alerts() 호출. 이 한 가지 패턴으로 알람 지연이 1분 이내 로 묶입니다. 별도 잡을 띄울 때보다 코드도 단순하고, 잡 사이의 race condition 도 없습니다.

정말 중요한 알람만 선별

수집과 평가가 빠르면 좋은데, 빠른 만큼 시끄러울 수 있습니다. 임계치를 그대로 통과시키면 같은 알람이 계속 울리거나, 정기 배치 작업 같은 알면서 의도한 부하 가 매번 알람으로 잡히게 됩니다.

이걸 막기 위한 장치 네 가지를 두었습니다.

  • 1시간 자동 억제: 알람 메시지에서 확인 버튼을 누르면 (Telegram 인라인 키보드 또는 포털 UI), 해당 (고객사, 인스턴스, 메트릭) 조합이 1시간 동안 자동으로 억제됩니다.
  • 영구 억제 규칙: 개발 DB 처럼 의도적으로 부하를 주는 인스턴스, 또는 일부 메트릭 (예: 특정 인스턴스의 읽기 지연) 은 영구 억제 규칙에 등록합니다. 인스턴스 지정 없이 등록하면 그 고객사의 전체 인스턴스 에 적용됩니다.
  • 유지보수 시간대: 고객사 단위의 유지보수 윈도우 (시작 / 종료 시각) 안에서는 그 고객사 전체 알림이 잠시 멈춥니다. 유지보수가 끝나면 자동으로 재개됩니다.
  • 반복 억제 일정 (KST 기준): 매일 새벽 2시부터 4시까지 도는 ETL 잡 같은 패턴은 요일 × 시간대 로 정기 억제합니다. 자정을 넘는 범위 (예: 23:00 ~ 02:00) 도 지원합니다.

별도로 db_instances.is_dev 플래그가 있어서, 인스턴스 단위로 알림 대상에서 아예 빼는 비상등도 함께 둡니다. 이건 억제 가 아니라 대상에서 제외 입니다.

평가 시점에는 위 네 종류를 통합된 set 으로 모아 두고, 매 메트릭 평가마다 그 set 에 들어 있는지를 한 번에 확인합니다. 그림으로 그리면 이렇게 흐릅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   ┌─ CW collect (1m)         ─┐
   ├─ PI load collect (3m)    ─┤   → [임계치 평가]
   └─ RDS event collect (15s) ─┘     (전역 + 인스턴스 override)
                                            │
                                            ↓
                                  [4종 억제 통합 set]
                                   ├─ 1시간 자동 억제
                                   ├─ 영구 억제 규칙
                                   ├─ 유지보수 시간대
                                   └─ 반복 억제 일정 (KST)
                                            │
                                            ↓
                                  [채널 디스패치]
                                   ├─→ Telegram Bot (+ 인라인 키보드)
                                   └─→ Google Chat Webhook

평가 결과가 억제 set 에 들어 있지 않은 새 알람이면, 그제서야 채널 디스패처가 호출됩니다.

알람 응답 체크

Telegram 과 Google Chat 은 둘 다 외부 서비스 라서, 잘 동작하는 날이 대부분이지만 가끔 일시적인 rate limit 이나 응답 지연이 발생합니다. 알람이 가장 절실한 순간에 바로 그 지연이 겹치면 곤란합니다.

Telegram 쪽은 응답 본문 안의 retry_after 를 우선 존중하고, 없으면 지수 백오프 (1초, 2초, 4초) 로 최대 3회 재시도합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async def send_telegram(message: str, contacts: list) -> dict:
    """429 응답 시 retry_after 초 대기 후 최대 3회 재시도."""
    targets = [c for c in contacts if c.is_active and c.telegram_chat_id]
    sent, failed = 0, 0

    async with httpx.AsyncClient(timeout=10) as client:
        for contact in targets:
            for attempt in range(4):  # 최초 1회 + 재시도 3회
                resp = await client.post(
                    f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
                    json={"chat_id": contact.telegram_chat_id,
                          "text": message, "parse_mode": "HTML"})
                if resp.status_code == 200:
                    sent += 1
                    break
                elif resp.status_code == 429:
                    body = resp.json()
                    retry_after = body.get("parameters", {}).get(
                        "retry_after", 2 ** attempt)
                    retry_after = min(retry_after, 30)
                    await asyncio.sleep(retry_after)
                else:
                    failed += 1
                    break  # 4xx (429 제외) 는 재시도 불필요
    return {"sent": sent, "failed": failed}

Google Chat 쪽은 retry_after 가 응답에 명시되지 않는 경우가 있어서, 지수 백오프 1 → 2 → 4초 로 최대 3회 재시도합니다. 두 채널 모두 401, 403 같은 재시도해도 의미 없는 4xx 는 한 번 시도하고 멈춥니다.

이 재시도 로직 덕분에 간헐적 429 가 알람 누락으로 이어지는 일이 거의 없어졌습니다. 한 번이라도 다른 한 채널이 살아 있으면 사람에게 도착합니다.

알림 해소도 차분히

알람이 발사되는 것만큼이나 언제 끝났는지 도 중요합니다. 메트릭이 임계치 아래로 돌아왔다고 해서 모든 해소가 같은 의미 는 아닙니다. 그래서 해소 메시지를 세 가지로 구분해 보냅니다.

  • 자연 해소: 메트릭이 임계치 아래로 자연스럽게 돌아왔습니다. 가장 흔한 케이스입니다.
  • DBA 조치 해소: DBA 가 확인 + 조치 를 마쳤다는 시그널입니다. 알람 메시지 안의 인라인 키보드로 조치 완료 를 누른 경우입니다.
  • 억제 처리: 영구 억제 규칙이 새로 등록되어 기존 활성 알람이 함께 해소된 경우입니다. 문제가 사라진 게 아니라 우리가 더 이상 안 보겠다고 결정한 케이스입니다.

이 세 가지를 메시지 텍스트에서 ✅ / 🔧 / 🔕 같은 다른 이모지로 표시해서, 받는 사람이 어떤 결말인지 한눈에 알게 했습니다.

알람 메시지 예시 위 두 개는 RDS 이벤트 자동 알람, 아래 두 개는 DBA 확인 처리 후 발송된 해소 알림 예시.

마무리

처음에는 잡을 더 많이 분리할까 생각했었습니다. 수집 잡평가 잡 을 깔끔하게 떨어뜨리는 설계가 책에서는 자주 권장됩니다. 그런데 막상 운영 환경에서 한 사이클을 돌려보니, 수집과 평가가 한 자리에 같이 있는 쪽이 알람 지연도 짧고, 잡 사이의 상태 동기화도 신경 쓸 일이 적고, 무엇보다 문제가 났을 때 한 곳에서 디버깅 할 수 있어서 좋았던 것 같습니다.

다음 편 (3편) 에서는 이 위에 DBA 가 화면에서 보는 기능들, 즉 CW 시계열, Performance Insights, 그리고 PI 상대 비교 분석 을 차례로 풀어 볼 예정입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.