diff --git a/.gitignore b/.gitignore index 306f6f3..161ddc8 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ PROJECT_STRUCTURE.md # Environment variables (sensitive) .env +.env** .env.local *.pem *.key @@ -74,4 +75,5 @@ __pypackages__/ # 실제 변수값 (민감 정보 포함) **/terraform.tfvars +**.tfvars **/*.auto.tfvars diff --git a/README.md b/README.md index 8bb3262..d8903c1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,14 @@ uv sync > `ATHENA_OUTPUT_LOCATION`은 CUR 데이터 위치가 아닌 Athena **쿼리 결과**가 저장되는 S3 경로다. > AWS 콘솔 → Athena → Settings → Query result location 값과 동일. +#### AI 분석 (Main 3) 전용 추가 변수 + +| 키 | 필수 | 설명 | +|----|------|------| +| `BEDROCK_MODEL_ID` | ✅ | Bedrock 모델 ID (예: `amazon.nova-micro-v1:0`) | +| `BEDROCK_REGION` | ✅ | Bedrock 리전 (예: `us-east-1`) | +| `NEW_COST_THRESHOLD` | ⬜ | 이번 달 신규 발생 판단 임계값 (기본: `10`, 단위: USD) | + #### IAM → Slack 사용자 매핑 (DM 발송용, 선택) `monitor_v2/iam_to_slack.json` 파일로 관리한다. @@ -101,6 +109,26 @@ uv run python -m monitor_v2.test_ec2_cur_to_slack --- +### AI 분석 리포트 (Main 3) + +CUR 데이터를 Bedrock Nova Micro로 분석해 자연어 한국어 요약을 Slack에 전송한다. + +```bash +# AI 분석 리포트 (Main 3) +uv run python -m monitor_v2.test_main3 +``` + +리포트 구성: + +- **상단 수치**: 어제 총비용 / 이번 달 누계 (N일 경과) / 월말 예상 +- **AI 요약 (3문단 통찰형)**: + 1. 결론 한 줄 + 월간 흐름 + 2. 어제 비용의 driver 분석 (서비스 비중 + IAM × 인스턴스 타입 + 1대 평균 가동 시간 + 서비스별 페이스 비교) + 3. 이번 달 들어 새로 비용이 발생한 서비스 / 동일 사용자 신호 (있을 때만, 없으면 생략) +- **Q9/Q10/Q11 raw 표**: 서비스별 / 타입별 / 리소스 ID별 어제 vs 그제 변화 — drill-down 검증용 + +--- + ### 리포트 전송 내용 (CE·CUR 공통) #### 비용 리포트 diff --git a/monitor_v2/cost/analysis.py b/monitor_v2/cost/analysis.py index 7ff6c0e..02c7cae 100644 --- a/monitor_v2/cost/analysis.py +++ b/monitor_v2/cost/analysis.py @@ -1,33 +1,70 @@ """ monitor_v2/cost/analysis.py -CUR Athena 기반 비용 증감 원인 분석 + Amazon Nova Micro LLM 요약. - -3단계 드릴다운: - Q9 서비스별 (product_product_name) - Q10 리소스 타입별 (line_item_usage_type) - Q11 리소스 ID별 (line_item_resource_id) +CUR Athena 기반 비용 분석 + Amazon Nova Micro LLM 요약. + +LLM 입력 데이터 (AI 요약용): + Q14 fetch_top_services_with_breakdown + 어제 절대 비용 Top N 서비스 + IAM × usage_type 분해 + → ■ 어제 비용 상위 (현황) 섹션 데이터 + Q15 fetch_month_new_costs + 이번 달 들어 처음 발생한 큰 비용 항목 + → ▲ 이번 달 신규 발생 섹션 데이터 + MTD fetch_mtd_total_cur + fetch_cost_forecast + 이번 달 누계 + 월말 예상 → 월간 맥락 단락 + +Slack 테이블 raw 데이터 (LLM 입력 X, 표만 노출): + Q9 fetch_service_diff 서비스별 어제 vs 그제 변화 + Q10 fetch_usage_type_diff usage_type별 변화 + Q11 fetch_resource_diff 리소스 ID별 변화 환경변수: - BEDROCK_MODEL_ID 기본: amazon.nova-micro-v1:0 - BEDROCK_REGION 기본: us-east-1 (Nova Micro 지원 리전) + BEDROCK_MODEL_ID 기본: amazon.nova-micro-v1:0 + BEDROCK_REGION 기본: us-east-1 + NEW_COST_THRESHOLD 기본: 10 (이번 달 신규 판단 — 어제 ≥ $X) """ import os import json -from pprint import pprint +import re +from collections import defaultdict +from datetime import date, timedelta import boto3 import logging -from datetime import date, timedelta -from .data_cur import _run_query, _partition +from .data_cur import ( + _run_query, _partition, + _ATHENA_DATABASE, _ATHENA_REGION, + fetch_mtd_total_cur, + _build_creator_case_sql, +) +from .data import fetch_cost_forecast log = logging.getLogger(__name__) -_BEDROCK_MODEL_ID = os.environ.get('BEDROCK_MODEL_ID') -_BEDROCK_REGION = os.environ.get('BEDROCK_REGION') -_TOP_N = 10 +_BEDROCK_MODEL_ID = os.environ.get('BEDROCK_MODEL_ID') +_BEDROCK_REGION = os.environ.get('BEDROCK_REGION') +_TOP_N = 10 +_NEW_COST_THRESHOLD = float(os.environ.get('NEW_COST_THRESHOLD', '10')) +_NEW_COST_PRIOR_CUT = 1.0 # 이번 달 1일~그제 누적 $1 미만이면 "사실상 안 쓴" 것으로 간주 +_TOP_SERVICES_N = 5 # ■ 현황 노출 서비스 수 +_TOP_BREAKDOWN_N = 5 # 한 서비스 내 IAM × usage_type drill-down 수 + + +# --------------------------------------------------------------------------- +# 공유 헬퍼 +# --------------------------------------------------------------------------- + +def _parse_iam_user(raw: str) -> str: + # SQL creator CASE 가 이미 파싱해 넘기므로 대부분 그대로 반환. + # 레거시 raw 포맷("IAMUser:AIDA...:user", "AssumedRole:...:role")만 추가 파싱. + if not raw: + return '' + if raw.startswith('IAMUser:') or raw.startswith('AssumedRole:'): + parts = raw.split(':') + return parts[2] if len(parts) >= 3 else raw + return raw # --------------------------------------------------------------------------- @@ -57,7 +94,7 @@ def fetch_service_diff(athena, d1_date: date, d2_date: date) -> list: THEN line_item_unblended_cost ELSE 0 END) - SUM(CASE WHEN DATE(line_item_usage_start_date) = DATE('{d2_date}') THEN line_item_unblended_cost ELSE 0 END) AS diff - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year_d1}' AND month IN ({months}) AND DATE(line_item_usage_start_date) IN (DATE('{d1_date}'), DATE('{d2_date}')) @@ -107,7 +144,7 @@ def fetch_usage_type_diff(athena, d1_date: date, d2_date: date) -> list: THEN line_item_unblended_cost ELSE 0 END) - SUM(CASE WHEN DATE(line_item_usage_start_date) = DATE('{d2_date}') THEN line_item_unblended_cost ELSE 0 END) AS diff - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year_d1}' AND month IN ({months}) AND DATE(line_item_usage_start_date) IN (DATE('{d1_date}'), DATE('{d2_date}')) @@ -146,13 +183,14 @@ def fetch_resource_diff(athena, d1_date: date, d2_date: date) -> list: year_d1, month_d1 = _partition(d1_date) year_d2, month_d2 = _partition(d2_date) months = f"'{month_d1}'" if month_d1 == month_d2 else f"'{month_d1}', '{month_d2}'" + creator_case_sql = _build_creator_case_sql(athena) sql = f""" SELECT product_product_name AS service, line_item_usage_type AS usage_type, line_item_resource_id AS resource_id, - COALESCE(resource_tags_aws_created_by, '') AS iam_user, + {creator_case_sql} AS iam_user, SUM(CASE WHEN DATE(line_item_usage_start_date) = DATE('{d1_date}') THEN line_item_unblended_cost ELSE 0 END) AS cost_d1, SUM(CASE WHEN DATE(line_item_usage_start_date) = DATE('{d2_date}') @@ -161,14 +199,13 @@ def fetch_resource_diff(athena, d1_date: date, d2_date: date) -> list: THEN line_item_unblended_cost ELSE 0 END) - SUM(CASE WHEN DATE(line_item_usage_start_date) = DATE('{d2_date}') THEN line_item_unblended_cost ELSE 0 END) AS diff - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year_d1}' AND month IN ({months}) AND DATE(line_item_usage_start_date) IN (DATE('{d1_date}'), DATE('{d2_date}')) AND line_item_resource_id IS NOT NULL AND line_item_resource_id != '' - GROUP BY product_product_name, line_item_usage_type, line_item_resource_id, - COALESCE(resource_tags_aws_created_by, '') + GROUP BY 1, 2, 3, 4 HAVING ABS( SUM(CASE WHEN DATE(line_item_usage_start_date) = DATE('{d1_date}') THEN line_item_unblended_cost ELSE 0 END) @@ -179,18 +216,13 @@ def fetch_resource_diff(athena, d1_date: date, d2_date: date) -> list: LIMIT {_TOP_N} """ - def _parse_iam_user(raw: str) -> str: - # "IAMUser:AIDA3OGATNBRMEBUIOEWO:mhsong" → "mhsong" - parts = raw.split(':') - return parts[2] if len(parts) >= 3 else raw - rows = _run_query(athena, sql) return [ { 'service': r['service'], 'usage_type': r.get('usage_type', ''), 'resource_id': r.get('resource_id', ''), - 'iam_user': _parse_iam_user(r['iam_user']) if r.get('iam_user') else '', + 'iam_user': _parse_iam_user(r.get('iam_user', '')), 'cost_d1': float(r.get('cost_d1') or 0), 'cost_d2': float(r.get('cost_d2') or 0), 'diff': float(r.get('diff') or 0), @@ -199,6 +231,376 @@ def _parse_iam_user(raw: str) -> str: ] +# --------------------------------------------------------------------------- +# Q14: 어제 절대 비용 Top N + 분해 / Q15: 이번 달 신규 발생 +# --------------------------------------------------------------------------- + +def fetch_top_services_with_breakdown( + athena, d1_date: date, top_n: int = 5, breakdown_top: int = 5, +) -> list: + """ + Q14: 어제(d1) 절대 비용 기준 Top N 서비스 + 각 서비스 내부 (IAM User × usage_type) 분해. + + "변화량은 적지만 지속적으로 비용이 큰 서비스"의 사용자/타입 분포를 LLM이 인지할 수 있도록 + Q9~Q11(변화량 축)과 별개로 절대값 축에서 데이터를 한 번 더 뽑는다. + + Returns: + [ + { + 'service': str, + 'cost_d1': float, + 'rank': int, + 'breakdowns': [ + { + 'iam_user': str, + 'usage_type': str, + 'usage_human': str, + 'cost_d1': float, + 'count': int, # distinct resource_id 개수 + 'usage_hours': float, # 사용 시간 (BoxUsage/SpotUsage 등 시간 단위 항목만 의미) + }, + ... + ] + }, + ... + ] + """ + year_d1, month_d1 = _partition(d1_date) + creator_case_sql = _build_creator_case_sql(athena) + + sql = f""" + WITH base AS ( + SELECT + product_product_name AS service, + line_item_usage_type AS usage_type, + {creator_case_sql} AS iam_user, + line_item_resource_id AS resource_id, + line_item_unblended_cost AS cost, + line_item_usage_amount AS usage_amount + FROM {_ATHENA_DATABASE}.cur_logs + WHERE year = '{year_d1}' + AND month = '{month_d1}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') + AND line_item_line_item_type != 'Tax' + ), + svc_total AS ( + SELECT service, SUM(cost) AS total + FROM base + GROUP BY service + HAVING SUM(cost) > 0.01 + ORDER BY total DESC + LIMIT {top_n} + ) + SELECT + b.service AS service, + b.usage_type AS usage_type, + b.iam_user AS iam_user, + COUNT(DISTINCT b.resource_id) AS resource_count, + SUM(b.cost) AS cost_d1, + SUM(b.usage_amount) AS usage_amount_total, + t.total AS service_total + FROM base b + JOIN svc_total t ON b.service = t.service + GROUP BY b.service, b.usage_type, b.iam_user, t.total + HAVING SUM(b.cost) > 0.01 + ORDER BY t.total DESC, cost_d1 DESC + """ + rows = _run_query(athena, sql) + + svc_total_map = {} + raw_acc = defaultdict(list) + for r in rows: + svc = r.get('service') + if not svc: + continue + svc_total_map[svc] = float(r.get('service_total') or 0) + raw_acc[svc].append({ + 'usage_type': r.get('usage_type', '') or '', + 'iam_user': _parse_iam_user(r.get('iam_user', '')), + 'cost_d1': float(r.get('cost_d1') or 0), + 'count': int(r.get('resource_count') or 0), + 'usage_amount': float(r.get('usage_amount_total') or 0), + }) + + result = [] + for svc, items in raw_acc.items(): + agg = defaultdict(lambda: {'cost_d1': 0.0, 'count': 0, 'usage_amount': 0.0}) + for item in items: + usage_human = _humanize_usage_type(item['usage_type']) + key = (item['iam_user'], usage_human, item['usage_type']) + agg[key]['cost_d1'] += item['cost_d1'] + agg[key]['count'] += item['count'] + agg[key]['usage_amount'] += item['usage_amount'] + + merged = [] + for (iu, uh, ut), v in agg.items(): + merged.append({ + 'iam_user': iu, + 'usage_human': uh, + 'usage_type': ut, + 'cost_d1': v['cost_d1'], + 'count': v['count'], + # 시간 단위가 의미 있는 항목(BoxUsage/SpotUsage/NatGateway-Hours 등)만 노출 + 'usage_hours': v['usage_amount'] if _is_hourly(ut) else 0.0, + }) + merged.sort(key=lambda x: x['cost_d1'], reverse=True) + result.append({ + 'service': svc, + 'cost_d1': svc_total_map[svc], + 'breakdowns': merged[:breakdown_top], + }) + + result.sort(key=lambda x: x['cost_d1'], reverse=True) + for idx, item in enumerate(result, 1): + item['rank'] = idx + return result + + +def fetch_month_new_costs(athena, d1_date: date) -> list: + """ + Q15: 이번 달 들어 처음 발생한 큰 비용 항목. + + 판단 단위: (service, IAM User, usage_type) + 조건: + - 이번 달 1일 ~ 그제 누적 < _NEW_COST_PRIOR_CUT (기본: $1) + - 어제 비용 ≥ _NEW_COST_THRESHOLD (기본: $10, 환경변수) + 정렬: 어제 비용 내림차순 + + Returns: + [ + { + 'service': str, + 'iam_user': str, + 'usage_type': str, + 'usage_human': str, + 'cost_d1': float, + 'prior_cost': float, # 이번 달 1일~그제 누적 + 'count': int, # distinct resource_id 개수 + }, + ... + ] + """ + mtd_start = d1_date.replace(day=1) + d2_date = d1_date - timedelta(days=1) + year_d1, month_d1 = _partition(d1_date) + + # mtd_start 부터 d1까지의 파티션 month 집합 (이번 달이므로 단일 month) + months = f"'{month_d1}'" + + # 그제(d2)가 전월에 속하면 (= 이번 달 1일이 d1) Q15 의미 없음 + if mtd_start > d2_date: + return [] + + creator_case_sql = _build_creator_case_sql(athena) + + sql = f""" + WITH month_to_yesterday AS ( + SELECT + product_product_name AS service, + line_item_usage_type AS usage_type, + {creator_case_sql} AS iam_user, + line_item_resource_id AS resource_id, + DATE(line_item_usage_start_date) AS dt, + line_item_unblended_cost AS cost + FROM {_ATHENA_DATABASE}.cur_logs + WHERE year = '{year_d1}' + AND month IN ({months}) + AND DATE(line_item_usage_start_date) BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + AND line_item_line_item_type != 'Tax' + ) + SELECT + service, + usage_type, + iam_user, + COUNT(DISTINCT resource_id) AS resource_count, + SUM(CASE WHEN dt = DATE('{d1_date}') THEN cost ELSE 0 END) AS cost_d1, + SUM(CASE WHEN dt < DATE('{d1_date}') THEN cost ELSE 0 END) AS prior_cost + FROM month_to_yesterday + GROUP BY service, usage_type, iam_user + HAVING SUM(CASE WHEN dt < DATE('{d1_date}') THEN cost ELSE 0 END) < {_NEW_COST_PRIOR_CUT} + AND SUM(CASE WHEN dt = DATE('{d1_date}') THEN cost ELSE 0 END) >= {_NEW_COST_THRESHOLD} + ORDER BY cost_d1 DESC + LIMIT {_TOP_N} + """ + rows = _run_query(athena, sql) + result = [] + for r in rows: + if not r.get('service'): + continue + result.append({ + 'service': r['service'], + 'usage_type': r.get('usage_type', '') or '', + 'iam_user': _parse_iam_user(r.get('iam_user', '')), + 'usage_human': _humanize_usage_type(r.get('usage_type', '') or ''), + 'cost_d1': float(r.get('cost_d1') or 0), + 'prior_cost': float(r.get('prior_cost') or 0), + 'count': int(r.get('resource_count') or 0), + }) + return result + + +def fetch_service_mtd_breakdown(athena, d1_date: date) -> dict: + """ + Q16: 이번 달 1일 ~ d1 까지 서비스별 누계 비용. + "지속적으로 큰 서비스" 판단을 위한 MTD 페이스 데이터. + + Returns: + {service_name: mtd_total_float, ...} + """ + mtd_start = d1_date.replace(day=1) + if mtd_start > d1_date: + return {} + + year_d1, month_d1 = _partition(d1_date) + + sql = f""" + SELECT + product_product_name AS service, + SUM(line_item_unblended_cost) AS mtd_total + FROM {_ATHENA_DATABASE}.cur_logs + WHERE year = '{year_d1}' + AND month = '{month_d1}' + AND DATE(line_item_usage_start_date) BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + AND line_item_line_item_type != 'Tax' + GROUP BY product_product_name + HAVING SUM(line_item_unblended_cost) > 0.01 + """ + rows = _run_query(athena, sql) + return { + r['service']: float(r.get('mtd_total') or 0) + for r in rows if r.get('service') + } + + +# --------------------------------------------------------------------------- +# Q17: 이번 달 누계 Top 사용자 + 서비스/타입 분해 +# --------------------------------------------------------------------------- + +def fetch_mtd_top_users_with_breakdown( + athena, d1_date: date, top_n: int = 5, breakdown_top: int = 5, +) -> list: + """ + Q17: 이번 달 1일 ~ d1 까지 누계 비용 Top N 사용자 + 각 사용자가 쓴 서비스/usage_type 분해. + + Q14 (어제 절대값 + IAM 분해) 의 누계 버전, 단 축이 user-first. + "이번 달 누구한테 비용이 가장 많이 쌓였는가" 를 LLM 이 인식하도록 함. + + creator 분류는 _build_creator_case_sql fallback 체인 사용 (Q3/Q5/Q11/Q14/Q15 와 동일). + + Returns: + [ + { + 'iam_user': str, # creator 라벨 (예: 'swjeong', '[Project] criu', '[EKS] ...') + 'mtd_total': float, # 이 사용자의 MTD 누계 + 'rank': int, + 'breakdowns': [ + { + 'service': str, + 'usage_type': str, + 'usage_human': str, # humanized + 'cost': float, + 'count': int, # distinct resource_id 개수 + }, + ... + ] + }, + ... + ] + """ + mtd_start = d1_date.replace(day=1) + if mtd_start > d1_date: + return [] + + year_d1, month_d1 = _partition(d1_date) + creator_case_sql = _build_creator_case_sql(athena) + + sql = f""" + WITH base AS ( + SELECT + product_product_name AS service, + line_item_usage_type AS usage_type, + {creator_case_sql} AS iam_user, + line_item_resource_id AS resource_id, + line_item_unblended_cost AS cost + FROM {_ATHENA_DATABASE}.cur_logs + WHERE year = '{year_d1}' + AND month = '{month_d1}' + AND DATE(line_item_usage_start_date) BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') + AND line_item_line_item_type != 'Tax' + ), + user_total AS ( + SELECT iam_user, SUM(cost) AS total + FROM base + WHERE iam_user IS NOT NULL AND iam_user != '' + GROUP BY iam_user + HAVING SUM(cost) > 0.1 + ORDER BY total DESC + LIMIT {top_n} + ) + SELECT + b.iam_user AS iam_user, + b.service AS service, + b.usage_type AS usage_type, + COUNT(DISTINCT b.resource_id) AS resource_count, + SUM(b.cost) AS cost, + ut.total AS user_total + FROM base b + JOIN user_total ut ON b.iam_user = ut.iam_user + GROUP BY 1, 2, 3, ut.total + HAVING SUM(b.cost) > 0.1 + ORDER BY ut.total DESC, cost DESC + """ + + rows = _run_query(athena, sql) + + user_total_map = {} + raw_acc = defaultdict(list) + for r in rows: + user = r.get('iam_user') + if not user: + continue + user_total_map[user] = float(r.get('user_total') or 0) + raw_acc[user].append({ + 'service': r.get('service', '') or '', + 'usage_type': r.get('usage_type', '') or '', + 'count': int(r.get('resource_count') or 0), + 'cost': float(r.get('cost') or 0), + }) + + result = [] + for user, items in raw_acc.items(): + # (service, usage_human) 단위로 묶음 — humanize 후 같은 라벨로 합산 + agg = defaultdict(lambda: {'cost': 0.0, 'count': 0, 'usage_type': ''}) + for item in items: + usage_human = _humanize_usage_type(item['usage_type']) + key = (item['service'], usage_human) + agg[key]['cost'] += item['cost'] + agg[key]['count'] += item['count'] + agg[key]['usage_type'] = item['usage_type'] # 대표 1건 + + merged = [] + for (svc, uh), v in agg.items(): + merged.append({ + 'service': svc, + 'usage_type': v['usage_type'], + 'usage_human': uh, + 'cost': v['cost'], + 'count': v['count'], + }) + merged.sort(key=lambda x: x['cost'], reverse=True) + + result.append({ + 'iam_user': user, + 'mtd_total': user_total_map[user], + 'breakdowns': merged[:breakdown_top], + }) + + result.sort(key=lambda x: x['mtd_total'], reverse=True) + for idx, item in enumerate(result, 1): + item['rank'] = idx + return result + + # --------------------------------------------------------------------------- # 프롬프트 구성 # --------------------------------------------------------------------------- @@ -208,18 +610,18 @@ def _fmt_sign(v: float) -> str: _REGION_CODES = { - 'APN1': '도쿄(ap-northeast-1)', - 'APN2': '서울(ap-northeast-2)', - 'APN3': '오사카(ap-northeast-3)', - 'APS1': '싱가포르(ap-southeast-1)', - 'APS2': '시드니(ap-southeast-2)', - 'USE1': '미국 동부(us-east-1)', - 'USE2': '미국 동부(us-east-2)', - 'USW1': '미국 서부(us-west-1)', - 'USW2': '미국 서부(us-west-2)', - 'EUW1': '유럽 서부(eu-west-1)', - 'EUC1': '유럽 중부(eu-central-1)', - 'SAE1': '상파울루(sa-east-1)', + 'APN1': 'ap-northeast-1', + 'APN2': 'ap-northeast-2', + 'APN3': 'ap-northeast-3', + 'APS1': 'ap-southeast-1', + 'APS2': 'ap-southeast-2', + 'USE1': 'us-east-1', + 'USE2': 'us-east-2', + 'USW1': 'us-west-1', + 'USW2': 'us-west-2', + 'EUW1': 'eu-west-1', + 'EUC1': 'eu-central-1', + 'SAE1': 'sa-east-1', } @@ -229,9 +631,9 @@ def _humanize_usage_type(raw: str) -> str: LLM이 raw 코드를 해석하지 않아도 되도록 Python에서 미리 처리. 예) - USW2-SpotUsage:c8gd.48xlarge → 미국 서부(us-west-2) c8gd.48xlarge Spot 인스턴스 - APN2-USW2-AWS-Out-Bytes → 미국 서부(us-west-2)에서 서울(ap-northeast-2)로 나가는 데이터 전송 - USE1-Bedrock-ModelUnit → 미국 동부(us-east-1) Bedrock 모델 호출 + USW2-SpotUsage:c8gd.48xlarge → us-west-2 c8gd.48xlarge Spot 인스턴스 + APN2-USW2-AWS-Out-Bytes → us-west-2에서 ap-northeast-2로 나가는 데이터 전송 + USE1-Bedrock-ModelUnit → us-east-1 Bedrock 모델 호출 """ if not raw: return '' @@ -269,9 +671,39 @@ def _humanize_usage_type(raw: str) -> str: if 'VpcEndpoint' in rest: return f"{region_prefix + ' ' if region_prefix else ''}VPC 엔드포인트 사용 시간" + if 'PublicIPv4:InUseAddress' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}사용 중 Public IPv4 주소" + + if 'PublicIPv4:IdleAddress' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}유휴 Public IPv4 주소" + + if 'NatGateway-Hours' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}NAT Gateway 사용 시간" + + if 'NatGateway-Bytes' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}NAT Gateway 데이터 처리" + + if 'SnapshotUsage' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}EBS 스냅샷" + + if 'TimedStorage' in rest or 'StorageObjectCount' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}S3 스토리지" + + if 'Requests-Tier1' in rest or 'Requests-Tier2' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}S3 요청" + if 'Bedrock' in rest and 'ModelUnit' in rest: return f"{region_prefix + ' ' if region_prefix else ''}Bedrock 모델 호출" + if 'AmazonEKS-Hours' in rest or 'EKS-Hours' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}EKS 클러스터 운영 시간" + + if 'EKS-Pod' in rest or 'AmazonEKS-vCPU' in rest or 'AmazonEKS-Memory' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}EKS 컴퓨팅 사용" + + if 'ECS' in rest: + return f"{region_prefix + ' ' if region_prefix else ''}ECS 사용" + if 'Lambda' in rest: return f"{region_prefix + ' ' if region_prefix else ''}Lambda 함수 실행" @@ -281,278 +713,696 @@ def _humanize_usage_type(raw: str) -> str: if 'CostExplorer' in rest or 'Cost-Explorer' in rest: return 'Cost Explorer API 조회' + if 'Route53' in rest or 'DNS-Queries' in rest: + return 'Route 53 DNS 쿼리' + if 'Bytes' in rest or 'DataTransfer' in rest: return f"{region_prefix + ' ' if region_prefix else ''}데이터 전송" return raw # 해석 불가 시 원본 반환 -def _get_change_type(cost_d1: float, cost_d2: float) -> str: - """ - 어제(d1)와 그제(d2) 비용으로 변화 유형 판별. +# usage_type 이 인스턴스/볼륨/스냅샷처럼 "개수" 단위로 셀 수 있는지 판별 +_COUNTABLE_USAGE_PATTERNS = ( + 'BoxUsage:', 'SpotUsage:', 'DedicatedUsage:', 'VolumeUsage', + 'SnapshotUsage', 'PublicIPv4', 'NatGateway-Hours', +) + + +def _is_countable(usage_type: str) -> bool: + if not usage_type: + return False + return any(p in usage_type for p in _COUNTABLE_USAGE_PATTERNS) + + +# IAM User 매핑이 본질적으로 불가능한(공통 인프라/요청 기반) 항목 +# → "(생성자 미상)" 라벨을 붙이지 않고 IAM 라인 자체를 생략 +_IAM_AGNOSTIC_PATTERNS = ( + 'DataTransfer', 'Bytes', 'Requests-Tier', 'CloudWatch', 'Metrics', 'Logs', + 'CostExplorer', 'Cost-Explorer', 'DNS-Queries', 'Route53', +) - Returns: - 'new' : 그제 $0 → 어제 처음 발생 - 'stopped' : 어제 $0 → 그제에만 사용, 어제 중단 - 'changed' : 양일 모두 비용 존재, 증감 - """ - if cost_d2 == 0 and cost_d1 > 0: - return 'new' - if cost_d1 == 0 and cost_d2 > 0: - return 'stopped' - return 'changed' +def _is_iam_agnostic(usage_type: str) -> bool: + if not usage_type: + return False + return any(p in usage_type for p in _IAM_AGNOSTIC_PATTERNS) -_CHANGE_LABEL = { - 'new': '어제 처음 발생', - 'stopped': '어제 사용 없음', - 'changed': '증감', + +# usage_amount 가 "시간(hours)" 단위로 의미 있는 항목만 식별 +# (인스턴스 운영 시간, NAT Gateway 운영 시간 등) +# VolumeUsage(GB-Month), DataTransfer(GB) 등은 시간 단위 아니므로 제외 +_HOURLY_USAGE_PATTERNS = ( + 'BoxUsage:', 'SpotUsage:', 'DedicatedUsage:', 'HostBoxUsage:', + 'NatGateway-Hours', 'LoadBalancerUsage', 'LCUUsage', + 'AmazonEKS-Hours', +) + + +def _is_hourly(usage_type: str) -> bool: + if not usage_type: + return False + return any(p in usage_type for p in _HOURLY_USAGE_PATTERNS) + + +_SYSTEM_PROMPT = """\ +당신은 AWS 비용 변화를 사내 슬랙으로 보고하는 분석가입니다. +이곳은 연구소이며 연구과제에 따라 일별 비용 변동이 큽니다. +표가 이미 함께 노출되므로, 당신의 역할은 "표가 못 하는 통찰" 만 한국어로 풀어 쓰는 것입니다. + +=== 출력 구조 — 정확히 다음 형식 === + +(첫 줄) 어제 총비용 + 이번 달 누계/월말 예상 한 줄 요약 + +(빈 줄) +■ 어제 비용 분석 +어제 비용의 어디로 갔는지 — 서비스별 비중%, 그 안의 IAM × 타입 분해, 페이스 라벨. +2~5문장. + +(빈 줄) +■ 이번 달 누계 분석 +이번 달 누계가 어떻게 분포돼 있는지 — 누계 기준 서비스 비중, 일평균. +1~3문장. + +각 섹션은 반드시 `■ 어제 비용 분석` / `■ 이번 달 누계 분석` 헤더로 시작. +헤더 앞뒤로 빈 줄 한 줄씩. + +⚠ Top 사용자 / 신규 발생 항목은 Python에서 별도로 렌더링되어 슬랙에 따로 노출되므로, +당신은 **절대로 출력하지 말 것**: + ❌ "▸ 이번 달 Top 사용자" 헤더와 그 아래 `• ...` 불릿 + ❌ "이번 달 들어 ~ 처음 등장 / 처음 비용 발생" 같은 신규 항목 언급 + ❌ "신규" / "처음" / "새로 등장" 같은 단어 자체 +누계 섹션은 **서비스별 누계 비중·일평균 산문 1~3문장으로만 마무리**. + +=== 첫 줄 작성 === + +한 줄로 어제 총비용 + 이번 달 누계 + 월말 예상. +형식: "어제(<날짜>) AWS 비용은 $X.XX였습니다. 이번 달 N일 동안 $X.XX를 사용했으며, 이 추세가 이어지면 월말 약 $Y가 예상됩니다." + +(빈 줄 한 줄) + +=== ■ 어제 비용 분석 섹션 작성 — 가장 중요 === + +목적: 어제 비용이 "어디로 갔는지" + "어떤 항목이 비중이 큰지" + **"평소 페이스 대비 어떤지"** 통찰형 서술. + +반드시 `■ 어제 비용 분석` 헤더 한 줄로 시작. + +다룰 범위 (반드시 준수): +- 비중 순으로 어제 비용 ≥ $5 인 서비스는 **모두** 다룬다 (보통 2~4개) +- 한 서비스 내에서는 비용 ≥ $5 인 (IAM × 타입) 조합을 **모두** 언급 +- $5 미만은 "그 외 EKS $2, Bedrock $1 등 합쳐 약 $X" 식으로 묶어서 한 문구로 짧게라도 언급 + +비중 % / 금액 표기 — 절대 강제 (가장 중요): +- 언급되는 **모든** 서비스 / IAM × 타입 항목에는 반드시 **(금액 $X, 비중% X%)** 둘 다 문장 안에 적는다. +- 서비스 헤더: "어제 비용의 X%(또는 어제의 X%) ($N)" — 입력의 `▸ 어제의 N%` 값을 그대로 사용 +- IAM × 타입 항목: "($N, X%)" 형식 — 입력 라인 끝의 `(N%)` 값을 그대로 사용. **"<서비스> 대비" prefix 붙이지 말 것**. + ✅ "swjeong의 inf2.24xlarge 1대($53, 47%)가 어제 1대 평균 8시간 가동되어 발생했습니다" + ❌ "swjeong의 inf2.24xlarge 1대($53, EC2 대비 47%)가 ..." (prefix 금지) + 맥락(상위 서비스 헤더 바로 다음)으로 47%가 EC2 안의 비중임이 자명하므로 prefix 없이도 명확. +- "차지했다" 보다 "발생했다" 권장 (둘 다 허용). +- 모호 양화 표현 절대 금지: + ❌ "비용의 대부분", "대부분을 만들었습니다", "상당 부분", "압도적인 비중", "거의 전부" + ❌ "일부를 차지", "일부에 해당", "약간의 비중" + → 반드시 정확한 %로 대체: "EC2 대비 48%", "EC2 대비 20%" +- 페이스 비교는 입력의 "[<서비스> 페이스]" 신호에 포함된 **흐름 라벨을 그대로 사용**: + * 입력에 "평소보다 낮은 흐름"이 있으면 그대로 "평소보다 낮은 흐름" + * 입력에 "평소 수준"이면 "평소 수준" + * 입력에 "평소보다 높은 흐름"이면 "평소보다 높은 흐름" + → 라벨을 임의로 반대로 해석하거나 새로 만들지 말 것 + 예: "EC2는 이번 달 일평균 $534 수준으로 발생 중이며, 어제 $230은 평소보다 낮은 흐름입니다." + +표현 다양화 — 같은 섹션 안에서 같은 표현 반복 금지: + 비중 강조: "가장 큰 비중", "주된 항목은", "전체의 N%를" + 원인 강조: "주된 원인은", "주도하고 있는 것은" + 보조/잔여: "그 외", "함께 비중", "합쳐 약 $X" + (외래어 driver / cost driver 사용 금지) + +묶음 패턴: +- 같은 인스턴스 타입이 여러 사용자에 분산되어 있으면 묶어서 서술 + (예: "m5.8xlarge 20대 (mhsong 10대 + 태그 없음 10대, EC2 대비 78%, $141)") +- 같은 사용자가 여러 서비스에 걸쳐 있어도 **합산하지 말 것** — 반드시 각 서비스 / IAM × 타입 + 항목별로 금액·비중·(EC2면) 가동 시간까지 그대로 풀어 서술. "한 프로젝트", "ML 워크로드" + 단정 표현도 금지. + ❌ "또한 mhsong이 EC2와 S3에 걸쳐 합산 $16를 사용한 점이 눈에 띕니다" (서비스 간 합산 금지) + ✅ "mhsong의 us-west-2 m5.8xlarge 30대($5.71, 20%)가 1대 평균 7분 가동되어 발생했고, + 같은 사용자의 us-west-2 S3 스토리지($2.53, 16%)도 함께 비중을 차지합니다" (항목별 분해) + +EC2 인스턴스 사용 시간 (반드시 준수): +- EC2 인스턴스(BoxUsage / SpotUsage 등) 항목에는 입력에 "1대 평균 N시간" 또는 "풀 가동" 표기가 옵니다. +- 입력에 들어온 형식 그대로 사용. 절대 합산하거나 다른 단위로 변환 금지. + ✅ "mhsong의 m5.8xlarge 10대가 1대 평균 7시간 가동" + ❌ "10대가 어제 240시간 운영" (합산 시간) +- 시간 정보가 입력에 없는 항목(EBS 볼륨, 데이터 전송, S3 스토리지, EKS 등)은 시간을 적지 마세요. + +서술 형식 강제 (표 형식 절대 금지): +- 모든 수치는 "**문장 안에**" 자연스럽게 녹여 서술 +- 헤더 라인이나 들여쓰기 나열, 불릿 포인트 형식 모두 금지 (산문으로만 서술) + +=== ■ 이번 달 누계 분석 섹션 작성 === + +반드시 `■ 이번 달 누계 분석` 헤더 한 줄로 시작. + +목적: 이번 달 누계의 분포 — 어떤 서비스가 얼마나 쌓였는지. + +다룰 범위: +- 입력의 `=== 이번 달 누계 분석 raw ===` 섹션을 활용해 주요 서비스의 누계 금액, 비중, 일평균을 서술 +- 형식 강제 — Top 사용자 불릿과 동일한 "**금액 먼저, 라벨 명시**" 패턴 사용: + `<서비스> $금액 (이번 달 누계의 X%, 일평균 $Y 수준)` + * 1위 서비스는 자연어로 흐름 강조: + ✅ "EC2 $3,432 (이번 달 누계의 86.0%, 일평균 $429 수준)가 가장 큰 비중을 차지합니다." + * 2위 이하는 한 문장에 묶어 서술: + ✅ "이외에 S3 $310 (이번 달 누계의 7.8%, 일평균 $39 수준), EKS $145 (이번 달 누계의 3.6%, 일평균 $18 수준)입니다." + * % 는 입력 라인의 `▸ 이번 달 누계의 N.N%` 값을 **그대로** 사용 (소수 1자리 보존) + * 한 괄호 안에 라벨 없이 (금액, %, 일평균)을 묶지 말 것 — 무엇이 무엇인지 모호해짐: + ❌ "S3는 이번 달 누계의 8%($310, 일평균 $39 수준)" ← % 와 $ 의 관계 모호 + ✅ "S3 $310 (이번 달 누계의 7.8%, 일평균 $39 수준)" +- 누계 비중이 매우 작은 서비스(예: 0.1% 미만)는 생략 가능 +- 누계 섹션 본문은 위 산문 1~3문장으로만 마무리한다. + Top 사용자 불릿이나 신규 발생 항목은 **Python이 별도로 추가**하므로 절대 LLM에서 생성하지 말 것. + +용어 강제: +- "누계" 단어 단독으로 쓰지 말 것 — 반드시 "**이번 달 누계**" 라고 풀어 쓸 것. + ❌ "S3는 누계의 5%" + ✅ "S3는 이번 달 누계의 5%" +- 일평균 표기 시 "수준" 단어 그대로 사용 — 입력 라인의 `일평균 $X 수준` 형식을 그대로 옮길 것. + ❌ 한글 오타 ("수줤", "수즌" 등) 절대 금지 + ✅ "일평균 $429 수준" + +- 사용자가 여러 서비스에 걸쳐 있다는 표현으로 "걸쳐 합산 $X" 식의 문장을 절대 추가하지 말 것. + ❌ "또한 mhsong이 EC2와 S3에 걸쳐 합산 $95를 사용한 점이 눈에 띕니다." (서비스 간 합산 금지) + +=== 두 섹션 공통 절대 금지 === + +- 어제 비용 분석 섹션에서 "이번 달 누계 X%" 같은 누계 표현 금지 — 그건 누계 섹션 전용 +- 누계 분석 섹션에서 "어제 비용의 X%" 만 단독으로 적기 금지 — 그건 어제 섹션 전용 +- 두 섹션을 한 문단으로 합쳐 헤더 없이 서술 금지 — 반드시 `■` 헤더로 분리 +- "신규", "처음 등장", "처음 발생", "새로 등장" 같은 단어 자체 출력 금지 (Python이 신규 발생 섹션을 별도로 출력함) +- "▸ 이번 달 Top 사용자" / `• ` 불릿 출력 금지 (Python이 Top 사용자 섹션을 별도로 출력함) + +=== 단정 금지 — 반드시 준수 === + +입력 데이터에 명시되지 않은 것을 단정하는 표현은 모두 금지합니다. +관찰은 가능하나, 의미 부여, 원인 단정은 약한 표현으로만 가능합니다. + +금지(단정): 허용(약한 표현): +"~입니다 (원인 단정)" "~로 보입니다", "~가능성이 있습니다" +"한 프로젝트의 비용 구조입니다" "동일 사용자에 집중되어 있습니다" +"ML 워크로드입니다" (입력에 없으면 언급 자체 금지) +"이는 ~ 때문입니다" "확인이 필요해 보입니다" +"~를 의미합니다" "~로 추정됩니다 (사실에 가까울 때만)" + +=== 절대 금지 === + +- 통계 표현: σ, μ, 평균, 정상 범위, 이상치, 표준편차 +- 시기 비교: 지난 달, 전월 동일일, 작년, 분기 +- 일별 비교: "전일 대비 X% 증가/감소", "그제 대비" +- 마크다운(# ## ### ** *) 사용 +- 입력에 없는 수치, 이름, 리소스 ID(i-xxx, vol-xxx, arn:...) 추가 +- $1 미만 항목 언급 +- "감소", "어제 사용 없음", "중단" 표현 +- 입력 라인에 "풀 가동" 또는 "1대 평균 N시간/분" 표기가 **없는** 항목에 시간/가동 표현 추가 금지. + EC2 인스턴스(BoxUsage/SpotUsage)가 아닌 항목 — 예: EKS, S3, EBS, 데이터 전송 — 은 + 대부분 시간 단위 입력이 없습니다. 그런 항목에 "풀 가동되었을 가능성", "24시간 가동", + "하루 종일 운영" 같은 표현을 **임의로 붙이지 말 것**. + ❌ 금지 예: "태그 없는 EKS 클러스터가 풀 가동되었을 가능성이 높습니다" + ✅ 허용 예 (입력에 시간 정보가 명시된 EC2 항목만): "jhpark의 inf2.24xlarge 3대가 1대 평균 3시간 가동" +- 항목별 라인 출력, 들여쓰기 나열, 헤더-디테일 형식 일체 금지 (산문으로만) +- "한 프로젝트", "ML 워크로드", "학습 작업", "추론 작업" 등 입력에 없는 추측 단어 +- 외래어 "driver" / "cost driver" / "핵심 driver" 단어 사용 (대신 "주된 항목", "가장 큰 비중" 같은 한국어 표현, 단 "비용의 대부분" 같은 모호 양화 표현은 % 로 대체) +- 입력에 사용된 시스템 라벨/구분자를 출력에 노출 금지: + "관찰된 신호", "관찰된 신호에서는", "신호에 따르면", "[동일 사용자]", "[페이스]", "[묶음]", + "===", "▸", "어제 비용 상위", "현황 raw", "월간 맥락" 같은 입력 섹션 이름 그대로 인용 금지. + 대신 자연스러운 한국어로 풀어서 서술. +- 부정 진술 / 우회 부정 진술 절대 출력 금지. 다음은 모두 금지: + • "특이사항 없음" / "특이사항은 없습니다" + • "신규는 없습니다" / "신규로 등장한 것은 없습니다" + • "이번 달 들어 신규로 등장한 것은 없습니다" + • "새로 비용이 발생한 서비스는 없습니다" + • "특별한 신호는 없습니다" + • 그 외 "(신규/새로/처음/특이) ... 없습니다/없음/없으며" 패턴 전부 + → 다룰 것이 없으면 그 화제를 꺼내지 말고 **문단 자체를 통째로 생략**. + → "이번 달 들어 ~" 같은 도입어를 시작했다가 부정문으로 끝내는 것도 금지. +- "특이사항으로 ~" 표현 자체 출력 금지. 도입어가 필요하면 "이번 달 들어 ~", "한편 ~", "또한 ~" 사용. +- "신규 발생 항목" / "발생 항목" / "신규 항목" / "등장한 것" 같은 어색한 명사구 출력 금지. + → "**것**" 대신 반드시 구체 명사 사용 ("**서비스**", "**비용 항목**", "**리소스**"). + → "신규로 비용이 발생한 서비스" / "이번 달 처음 등장한 서비스" / "새로 비용이 발생한 서비스" 식으로 + 풀어 서술. + +=== 표현 가이드 === + +- "(생성자 미상)" 라벨이 입력에 보이면 → "**태그 없는** {리소스명}" 으로 변환 + (예: "태그 없는 m5.8xlarge 10대"). "(생성자 미상)" 단어 출력 금지. +- 데이터 전송, CloudWatch 등 IAM agnostic 항목은 IAM 이름 빼고 서술 + (예: "us-west-2 데이터 전송") +- 리전 표기는 입력의 영문 코드 그대로 노출. "미국 서부", "서울" 같은 한글 치환 금지. + ✅ "us-west-2 데이터 전송", "swjeong의 us-west-2 m5.8xlarge" + ❌ "미국 서부 데이터 전송", "swjeong의 미국 서부 m5.8xlarge" +- 인스턴스/볼륨/스냅샷 등 개수가 의미 있는 항목만 "N대", "N개" 표기 +- 같은 서비스에 IAM User 여러 명이면 큰 순으로 1~2명만 언급 +- 단, 묶음 패턴(같은 인스턴스 타입이 분산)이 입력 신호로 들어왔다면 묶어서 표현 + +=== 출력 예시 (이 텍스트 자체는 출력 금지) === + +[입력] +어제(2026-05-08) AWS 비용 $126.14 +이번 달 8일 동안 $3,983.00 사용. 이대로 진행 시 월말 예상 약 $13,268.00. + +=== 어제 비용 분석 raw === +EC2 $111.00 ▸ 어제의 88% + swjeong: us-west-2 inf2.24xlarge 온디맨드 인스턴스 ×1개 1대 평균 8시간 $53.00 (48%) + jhpark: us-west-2 inf2.24xlarge 온디맨드 인스턴스 ×3개 1대 평균 1시간 $22.00 (20%) + mhsong: us-west-2 m5.8xlarge 온디맨드 인스턴스 ×5개 1대 평균 30분 $18.00 (16%) +S3 $9.00 ▸ 어제의 7% + swjeong: us-west-2 S3 스토리지 $3.00 (33%) + mhsong: us-west-2 S3 스토리지 $2.00 (22%) +EKS $2.00 ▸ 어제의 2% +Bedrock $1.00 ▸ 어제의 1% + +=== 어제 관찰된 신호 === +- [EC2 페이스] 이번 달 누계 $3,432.00 / 일평균 $429.00 / 어제 $111.00 (일평균의 26%) — 평소보다 낮은 흐름 +- [EC2] us-west-2 inf2.24xlarge 온디맨드 인스턴스 총 4대 ($75.00) — swjeong 1대, jhpark 3대 + +=== 이번 달 누계 분석 raw === +EC2 $3,432.00 ▸ 이번 달 누계의 86.0% 일평균 $429.00 수준 +S3 $310.00 ▸ 이번 달 누계의 7.8% 일평균 $39.00 수준 +EKS $145.00 ▸ 이번 달 누계의 3.6% 일평균 $18.00 수준 +Bedrock $96.00 ▸ 이번 달 누계의 2.4% 일평균 $12.00 수준 + +[모범 출력 — Top 사용자 / 신규 발생은 Python 측에서 별도 출력되므로 절대 포함하지 말 것] +어제(2026-05-08) AWS 비용은 $126.14였습니다. 이번 달 8일 동안 $3,983을 사용했으며, 이 추세가 이어지면 월말 약 $13,268이 예상됩니다. + +■ 어제 비용 분석 +어제 비용은 EC2가 88%($111)로 가장 큰 비중을 차지했습니다. 그 안에서는 swjeong의 us-west-2 inf2.24xlarge 1대($53, 48%)가 1대 평균 8시간 가동되어 가장 큰 비중을 만들었고, jhpark의 inf2.24xlarge 3대($22, 20%)가 1대 평균 1시간 가동되어 다음으로 큰 비중을 차지했으며, mhsong의 m5.8xlarge 5대($18, 16%)도 1대 평균 30분 가동되어 일부를 차지합니다. EC2는 이번 달 일평균 $429 수준으로 발생 중이며 어제 $111은 평소보다 낮은 흐름입니다. S3는 어제 $9가 발생했으며 swjeong의 us-west-2 스토리지($3, 33%)와 mhsong의 스토리지($2, 22%)가 주된 항목입니다. 그 외 EKS $2, Bedrock $1 등 합쳐 약 $3가 함께 발생했습니다. + +■ 이번 달 누계 분석 +EC2 $3,432 (이번 달 누계의 86.0%, 일평균 $429 수준)가 가장 큰 비중을 차지합니다. 이외에 S3 $310 (이번 달 누계의 7.8%, 일평균 $39 수준), EKS $145 (이번 달 누계의 3.6%, 일평균 $18 수준), Bedrock $96 (이번 달 누계의 2.4%, 일평균 $12 수준)입니다. + +(여기서 출력 끝 — 그 뒤 "▸ 이번 달 Top..." / "이번 달 들어 ... 처음 등장" 등은 일체 작성 금지. Python이 추가함.) +""" + + +# 서비스명 단축 — Python에서 미리 처리해 LLM에 전달 +_SVC_SHORT = { + 'Amazon Elastic Compute Cloud': 'EC2', + 'Amazon Elastic Compute Cloud - Compute': 'EC2', + 'EC2 - Other': 'EC2-Other', + 'Amazon Simple Storage Service': 'S3', + 'AWS Lambda': 'Lambda', + 'Elastic Load Balancing': 'ELB', + 'Amazon Virtual Private Cloud': 'VPC', + 'AWS Cost Explorer': 'Cost Explorer', + 'AmazonCloudWatch': 'CloudWatch', + 'Amazon CloudFront': 'CloudFront', + 'Amazon Bedrock': 'Bedrock', + 'Amazon Elastic Container Service for Kubernetes': 'EKS', + 'Amazon Elastic Container Service': 'ECS', + 'Amazon Relational Database Service': 'RDS', + 'Amazon DynamoDB': 'DynamoDB', + 'Amazon Route 53': 'Route 53', + 'Amazon Simple Notification Service': 'SNS', + 'Amazon Simple Queue Service': 'SQS', + 'Amazon SageMaker': 'SageMaker', + 'Amazon API Gateway': 'API Gateway', + 'AWS Key Management Service': 'KMS', + 'AWS Secrets Manager': 'Secrets Manager', } -def _aggregate_details(details: list) -> list: +def _format_breakdown_line(d: dict) -> str: """ - 동일 (usage_human, iam_user) 조합을 하나로 집계. - resource_id별 중복 행을 제거하고 비용을 합산한다. - """ - from collections import defaultdict - agg = defaultdict(lambda: {'diff': 0.0, 'cost_d1': 0.0, 'cost_d2': 0.0, 'count': 0}) - for d in details: - key = (d['usage_human'], d['iam_user']) - agg[key]['diff'] += d['diff'] - agg[key]['cost_d1'] += d['cost_d1'] - agg[key]['cost_d2'] += d['cost_d2'] - agg[key]['count'] += 1 + Q14 / Q15 의 한 (IAM User × usage_type) 행을 LLM 입력 라인으로 포맷. - result = [] - for (usage_human, iam_user), v in agg.items(): - result.append({ - 'usage_human': usage_human, - 'iam_user': iam_user, - 'diff': v['diff'], - 'cost_d1': v['cost_d1'], - 'cost_d2': v['cost_d2'], - 'change_type': _get_change_type(v['cost_d1'], v['cost_d2']), - 'count': v['count'], - }) - return sorted(result, key=lambda x: abs(x['diff']), reverse=True) + "(생성자 미상)" 라벨 회피: + - IAM agnostic usage_type (데이터 전송, CloudWatch 등) → IAM 정보 없이 usage_human만 + - 그 외 IAM 비어 있음 → "태그 없는 {usage_human}" (LLM이 그대로 사용) + - IAM 있음 → "{iam}: {usage_human}" + 카운트: + - countable usage_type (BoxUsage, SpotUsage, VolumeUsage, ...) 만 ×N개 표기 -def _merge_rows(service_rows: list, resource_rows: list) -> list: + 사용 시간: + - hourly usage_type (BoxUsage/SpotUsage/NAT Gateway 등) 만 표기 + - usage_amount 합산을 인스턴스 수로 나눠 "1대 평균 N시간" 형태로 노출 + (합산 단독은 24시간 초과 값이 나와 사용자에게 직관적이지 않음) + - 평균 ≥ 22시간이면 "풀 가동", 미만이면 평균 시간 표시 """ - Q9(서비스 총합)와 Q11(리소스+IAM User)를 service 기준으로 통합. + iam = d.get('iam_user', '') or '' + usage_type = d.get('usage_type', '') or '' + usage_human = d.get('usage_human', '') or usage_type + count = int(d.get('count', 1) or 1) + hours = float(d.get('usage_hours', 0) or 0) + + count_str = f" ×{count}개" if (count > 1 and _is_countable(usage_type)) else '' + + hours_str = '' + if hours > 0 and _is_hourly(usage_type): + avg_hours = hours / count if count > 0 else hours + if avg_hours >= 22: + hours_str = ' 풀 가동' + elif avg_hours >= 1: + hours_str = f' 1대 평균 {avg_hours:.0f}시간' + else: + # 1시간 미만 — 분 단위로 표현 + avg_minutes = avg_hours * 60 + hours_str = f' 1대 평균 {avg_minutes:.0f}분' + + suffix = count_str + hours_str + + if not iam: + if _is_iam_agnostic(usage_type): + return f"{usage_human}{suffix}" + return f"태그 없는 {usage_human}{suffix}" + return f"{iam}: {usage_human}{suffix}" - LLM이 섹션 간 cross-reference 추론 없이 한 블록에서 읽을 수 있도록 - Python 단에서 미리 join한다. + +def _fmt_top_services(rows: list, d1_total: float) -> str: """ - service_map = { - r['service']: { - 'service': r['service'], - 'total_diff': r['diff'], - 'cost_d1': r['cost_d1'], - 'cost_d2': r['cost_d2'], - 'change_type': _get_change_type(r['cost_d1'], r['cost_d2']), - 'details': [], - } - for r in service_rows - } + Q14 결과 → LLM 입력 텍스트. + 각 서비스 헤더에 어제 총비용 대비 비중(%) 포함. + 각 IAM × 타입 라인에 해당 서비스 비용 대비 비중(%)을 함께 표기. + + LLM 입력 라인 형식 예: + EC2 $111.00 ▸ 어제의 88% + swjeong: ... inf2.24xlarge ×1개 1대 평균 8시간 $53.00 (47%) + 위와 같이 IAM × 타입 라인 끝의 (X%)는 **상위 서비스 헤더 비중과 같은 맥락**. + LLM은 이 % 값을 "($53, 47%)" 처럼 금액과 함께 그대로 옮긴다. + """ + if not rows: + return '(없음)' + blocks = [] + for svc in rows: + short = _SVC_SHORT.get(svc['service'], svc['service']) + svc_cost = svc['cost_d1'] + share = (svc_cost / d1_total * 100) if d1_total > 0 else 0 + header = f"{short} ${svc_cost:,.2f} ▸ 어제의 {share:.0f}%" + lines = [] + for d in svc.get('breakdowns', []): + if d.get('cost_d1', 0) < 1.0: + continue + line = _format_breakdown_line(d) + sub_share = (d['cost_d1'] / svc_cost * 100) if svc_cost > 0 else 0 + lines.append( + f" {line} ${d['cost_d1']:,.2f} ({sub_share:.0f}%)" + ) + if lines: + blocks.append(header + '\n' + '\n'.join(lines)) + else: + blocks.append(header) + return '\n'.join(blocks) - for r in resource_rows: - svc = r['service'] - if svc in service_map: - service_map[svc]['details'].append({ - 'usage_human': _humanize_usage_type(r.get('usage_type', '')), - 'iam_user': r.get('iam_user', ''), - 'diff': r['diff'], - 'cost_d1': r['cost_d1'], - 'cost_d2': r['cost_d2'], - 'change_type': _get_change_type(r['cost_d1'], r['cost_d2']), - }) - for svc_data in service_map.values(): - svc_data['details'] = _aggregate_details(svc_data['details']) +def _fmt_mtd_breakdown(service_mtd: dict, mtd_total: float, mtd_days_elapsed: int) -> str: + """ + Q16 결과 → LLM 입력 텍스트 (이번 달 누계 섹션). + 서비스별 MTD 누계, 누계 대비 비중(%), 일평균을 함께 표기. - return sorted(service_map.values(), key=lambda x: abs(x['total_diff']), reverse=True) + "일평균 $X 수준" 형식으로 출력 — LLM이 "수준" 단어를 한글로 잘못 옮기지 않도록 + 입력에 직접 명시 (Nova Micro의 한글 typo 방어). + Returns: + 예) "EC2 $3,432.00 ▸ 이번 달 누계의 86.0% 일평균 $429.00 수준" + """ + if not service_mtd or mtd_total <= 0: + return '(없음)' + sorted_svcs = sorted(service_mtd.items(), key=lambda x: x[1], reverse=True)[:5] + lines = [] + for service, mtd in sorted_svcs: + if mtd < 0.01: + continue + short = _SVC_SHORT.get(service, service) + share = (mtd / mtd_total * 100) if mtd_total > 0 else 0 + daily_avg = (mtd / mtd_days_elapsed) if mtd_days_elapsed > 0 else 0 + lines.append( + f"{short} ${mtd:,.2f} ▸ 이번 달 누계의 {share:.1f}% 일평균 ${daily_avg:,.2f} 수준" + ) + return '\n'.join(lines) if lines else '(없음)' -_SYSTEM_PROMPT = """\ -당신은 AWS 비용 분석 요약 도우미입니다. -사용자가 어제/그제 AWS 비용 데이터를 제공하면, 아래 형식과 규칙에 따라 한국어로 요약합니다. -=== 출력 형식 === +# --------------------------------------------------------------------------- +# Slack 출력용 결정론 렌더러 (LLM 미경유) +# --------------------------------------------------------------------------- +# +# Top 사용자 / 신규 발생은 구조 데이터를 그대로 매핑하면 되는 항목이므로 +# LLM에 맡기지 않는다. Nova Micro 같은 작은 모델은 "입력 N개를 그대로 N개로 +# 출력하라" 류 카운팅 제약을 흔히 어겨 Top3/Top4 잘림, 통째로 누락 같은 +# 사고를 낸다. Python에서 직접 렌더링하면 100% 일관된 출력이 보장된다. -첫 줄: "전일 대비 $금액 (±X%) 증가/감소했으며, 주요 원인은 [서비스 2~3개]입니다." -빈 줄 -▲ 증가 원인 -서비스명 — $금액 설명 -▼ 감소 원인 -서비스명 — $금액 설명 +def format_top_users_section_for_slack(top_users_mtd: list, mtd_total: float) -> str: + """ + Q17 결과 → Slack 출력 문자열 (결정론). -증가/감소 어느 쪽이 없으면 해당 소제목(▲/▼) 전체를 생략하세요. -"비용이 증가한 서비스" 섹션의 항목은 반드시 ▲에, "비용이 감소한 서비스" 섹션의 항목은 반드시 ▼에 작성하세요. + 출력 형식: + ▸ 이번 달 Top{N} 사용자 + • — $<금액> (이번 달 누계의 X.X%): <서비스> $<금액> (본인 비용의 X.X%), ... -=== [타입] 별 표현 규칙 === + Args: + top_users_mtd: fetch_mtd_top_users_with_breakdown 반환값 + mtd_total: 이번 달 누계 총비용 -서비스 타입: - [신규] — 그제 $0이었다가 어제 처음 비용 발생. "처음 사용됨", "어제 처음 발생함" 등으로 서술. - [중단] — 그제까지 사용하다가 어제 $0. "어제 사용 없었음", "어제 이루어지지 않음" 등으로 서술. - [증가] — 어제/그제 모두 비용 있고 어제가 더 큼. "늘어남", "더 많이 사용됨" 등으로 서술. - [감소] — 어제/그제 모두 비용 있고 어제가 더 작음. "줄어듦", "덜 사용됨" 등으로 서술. + Returns: + 섹션 문자열 (헤더 1줄 + 사용자 N줄). 데이터 없으면 빈 문자열. + """ + if not top_users_mtd or mtd_total <= 0: + return '' -usage detail 타입 (서비스 내 세부 항목): - [신규 발생] — 이 usage가 어제 처음 등장. "처음 켜짐", "처음 시작됨" 등으로 서술. - [어제 중단] — 이 usage가 어제 사용 없음. "어제 없었음", "중단됨" 등으로 서술. - 절대 "처음"이라는 단어를 쓰지 말 것. - [증가] / [감소] — 양일 모두 사용. 방향에 맞게 "늘어남" / "줄어듦" 등으로 서술. + n = len(top_users_mtd) + lines = [f"▸ 이번 달 Top{n} 사용자"] + for u in top_users_mtd: + user = u['iam_user'] + user_cost = u['mtd_total'] + share = (user_cost / mtd_total * 100) if mtd_total > 0 else 0 + + breakdown_parts = [] + for d in u.get('breakdowns', []): + if d.get('cost', 0) < 1.0: + continue + short_svc = _SVC_SHORT.get(d['service'], d['service']) + usage_human = d.get('usage_human') or d.get('usage_type', '') + sub_share = (d['cost'] / user_cost * 100) if user_cost > 0 else 0 + count_str = ( + f" ×{d['count']}개" + if d.get('count', 0) > 1 and _is_countable(d.get('usage_type', '')) + else '' + ) + breakdown_parts.append( + f"{short_svc} {usage_human}{count_str} ${d['cost']:,.2f} (본인 비용의 {sub_share:.1f}%)" + ) -=== 설명 작성 규칙 === + line = f"• {user} — ${user_cost:,.2f} (이번 달 누계의 {share:.1f}%)" + if breakdown_parts: + line += ": " + ", ".join(breakdown_parts) + lines.append(line) + return '\n'.join(lines) -서비스 1줄 설명은 detail 항목들을 종합해 완전한 문장으로 작성하세요. -명사형 종결("~중단됨", "~줄어듦") 대신 동사형 서술("~중단되었습니다", "~줄었습니다")로 마무리하세요. -생성자가 있는 경우 반드시 이름을 포함해 누가 어떤 리소스를 어떻게 했는지 서술하세요. -=== 입출력 예시 (이 텍스트는 출력하지 말 것) === +def format_new_costs_section_for_slack(new_costs: list, top_services: list) -> str: + """ + Q15 결과 → Slack 출력 문자열 (결정론). -입력 예시: -어제(2026-04-09) AWS 비용: $32.69 -그제(2026-04-08) AWS 비용: $61.80 -전일 대비: -$29.11 (-47.1%) + 어제 ≥ _NEW_COST_THRESHOLD ($10 기본) 로 새로 등장한 (service, IAM, usage_type) 조합만 노출. + [신규 서비스] 라벨은 그 서비스가 어제 Top N에 없을 때, [신규 조합]은 있을 때. -=== 비용이 증가한 서비스 === + 출력 형식: + ▸ 이번 달 신규 발생 (어제 ≥ $10) + • [신규 서비스] EKS $12.40 — mhsong: us-west-2 EKS 클러스터 운영 시간 + • [신규 조합] EC2 $53.00 — swjeong: us-west-2 inf2.24xlarge 온디맨드 인스턴스 ×1개 1대 평균 8시간 -Bedrock $0.04 [신규] 어제 $0.04 / 그제 $0.00 + Args: + new_costs: fetch_month_new_costs 반환값 + top_services: 어제 Top N 서비스 (라벨 분류용) -=== 비용이 감소한 서비스 === + Returns: + 섹션 문자열. 데이터 없으면 빈 문자열. + """ + if not new_costs: + return '' -EC2 $12.87 [감소] 어제 $30.75 / 그제 $43.62 - mhsong: 미국 서부(us-west-2) c8gd.48xlarge Spot 인스턴스 ×2개 [감소] 어제 $8.19 / 그제 $24.82 - jhpark: 미국 서부(us-west-2) inf2.8xlarge 온디맨드 인스턴스 ×5개 [증가] 어제 $14.85 / 그제 $5.86 - yjjung: 미국 서부(us-west-2) g4dn.12xlarge 온디맨드 인스턴스 [어제 중단] 어제 $0.00 / 그제 $5.34 -S3 $12.14 [감소] 어제 $0.36 / 그제 $12.50 - mhsong: 서울(ap-northeast-2)에서 미국 서부(us-west-2)로 나가는 데이터 전송 [감소] 어제 $0.00 / 그제 $8.89 - swjeong: USW2-TimedStorage-ByteHrs [어제 중단] 어제 $0.00 / 그제 $3.16 -Lambda $3.59 [감소] 어제 $0.93 / 그제 $4.52 -ELB $0.25 [감소] 어제 $0.35 / 그제 $0.60 -VPC $0.13 [감소] 어제 $0.15 / 그제 $0.28 -Cost Explorer $0.10 [감소] 어제 $0.02 / 그제 $0.12 -CloudWatch $0.07 [감소] 어제 $0.11 / 그제 $0.18 + top_service_names = {svc['service'] for svc in (top_services or [])} -출력 예시: -전일 대비 $29.11 (-47.1%) 감소했으며, 주요 원인은 EC2와 S3입니다. + lines = [f"▸ 이번 달 신규 발생 (어제 ≥ ${_NEW_COST_THRESHOLD:.0f})"] + for r in new_costs: + short = _SVC_SHORT.get(r['service'], r['service']) + item_line = _format_breakdown_line(r) + label = '[신규 조합]' if r['service'] in top_service_names else '[신규 서비스]' + lines.append(f"• {label} {short} ${r['cost_d1']:,.2f} — {item_line}") + return '\n'.join(lines) -▲ 증가 원인 -Bedrock — $0.04 Bedrock 모델 호출이 어제 처음 발생했습니다. -▼ 감소 원인 -EC2 — $12.87 mhsong의 c8gd.48xlarge Spot 인스턴스 2대와 yjjung의 g4dn.12xlarge 인스턴스가 각각 사용량 감소 및 중단되었습니다. jhpark의 inf2.8xlarge 인스턴스 5대는 오히려 사용량이 늘었지만 전체적으로 EC2 비용이 줄었습니다. -S3 — $12.14 mhsong의 서울에서 미국 서부로 나가는 데이터 전송이 어제 이루어지지 않았고, swjeong의 스토리지 사용도 어제 없었습니다. -Lambda — $3.59 Lambda 함수 실행 비용이 전날보다 줄었습니다. -ELB — $0.25 로드 밸런서 사용 비용이 전날보다 줄었습니다. -VPC — $0.13 VPC 관련 비용이 전날보다 줄었습니다. -Cost Explorer — $0.10 Cost Explorer API 조회 비용이 전날보다 줄었습니다. -CloudWatch — $0.07 CloudWatch 모니터링 비용이 전날보다 줄었습니다. +# --------------------------------------------------------------------------- +# 그룹 묶음 / 패턴 신호 계산 +# --------------------------------------------------------------------------- +# +# LLM 입력에 미리 계산해 넣을 신호들 — LLM이 직접 추론하지 않도록 사실 기반으로 제공. +# 모든 신호는 "단정"이 아니라 "관찰된 사실"로만 표현됨. + +def _detect_signals( + top_services: list, + d1_total: float, + service_mtd: dict = None, + mtd_days_elapsed: int = 0, +) -> list: + """ + Q14 + Q16 데이터에서 LLM이 단독 추론하기 어려운 패턴/위험/페이스 신호를 + 사실 기반으로 추출. 단정 표현 없음. -=== 금지 사항 === + 반환되는 신호 종류: + [페이스] 서비스별 이번 달 누계 / 일평균 / 어제 비중 — "지속" 판단 근거 + [묶음] 같은 (서비스, usage_human) 안에서 IAM 분포 + [동일 사용자] 같은 IAM이 여러 서비스에 등장 + """ + signals = [] + if not top_services or d1_total <= 0: + return signals + + # 0) 서비스별 페이스 — Q14 어제 Top + Q16 MTD 누계로 "지속/돌발" 판단 근거 + # 흐름 라벨을 Python에서 직접 계산해 LLM이 임의 해석하지 않도록 한다. + if service_mtd and mtd_days_elapsed >= 2: + for svc in top_services: + mtd = service_mtd.get(svc['service'], 0.0) + if mtd <= 0.01: + continue + short = _SVC_SHORT.get(svc['service'], svc['service']) + daily_avg = mtd / mtd_days_elapsed + ratio_pct = (svc['cost_d1'] / daily_avg * 100) if daily_avg > 0 else 0 + + if ratio_pct < 80: + flow_label = '평소보다 낮은 흐름' + elif ratio_pct <= 120: + flow_label = '평소 수준' + else: + flow_label = '평소보다 높은 흐름' + + signals.append( + f"[{short} 페이스] 이번 달 누계 ${mtd:,.2f} / 일평균 ${daily_avg:,.2f} / " + f"어제 ${svc['cost_d1']:,.2f} (일평균의 {ratio_pct:.0f}%) — {flow_label}" + ) -설명 빈칸 금지. -리소스 ID(i-xxx, vol-xxx, arn:...) 포함 금지. -금액은 항상 양수. 부호는 ▲/▼ 소제목으로만 구분. -마크다운(** * #) 사용 금지. -"해당 서비스 사용량 변화" 사용 금지. -생성자가 있는데 이름 빠뜨리기 금지.""" + # 1) 인스턴스 타입 묶음 — 같은 (service, usage_human) 안에 여러 행이 있는지 + type_groups = defaultdict(list) + for svc in top_services: + for d in svc.get('breakdowns', []): + if d.get('cost_d1', 0) < 1.0: + continue + key = (svc['service'], d.get('usage_human', '')) + type_groups[key].append(d) + + for (service, usage_human), entries in type_groups.items(): + if len(entries) < 2: + continue + if not _is_countable(entries[0].get('usage_type', '')): + continue + total_cost = sum(e['cost_d1'] for e in entries) + total_count = sum(e.get('count', 0) for e in entries) + if total_count < 2: + continue + short = _SVC_SHORT.get(service, service) + parts = [] + for e in sorted(entries, key=lambda x: x['cost_d1'], reverse=True): + iam = e.get('iam_user') or '태그 없음' + parts.append(f"{iam} {e.get('count', 0)}대") + parts_str = ', '.join(parts) + signals.append( + f"[{short}] {usage_human} 총 {total_count}대 (${total_cost:,.2f}) — {parts_str}" + ) + # 2) 멀티 서비스 IAM — 같은 IAM이 2개 이상 서비스에 비용 ≥ $1로 등장 + iam_services = defaultdict(lambda: {'services': set(), 'cost': 0.0}) + for svc in top_services: + for d in svc.get('breakdowns', []): + iam = d.get('iam_user', '') + if not iam: + continue + if d.get('cost_d1', 0) < 1.0: + continue + iam_services[iam]['services'].add(_SVC_SHORT.get(svc['service'], svc['service'])) + iam_services[iam]['cost'] += d.get('cost_d1', 0) + for iam, info in iam_services.items(): + if len(info['services']) >= 2: + svc_list = ', '.join(sorted(info['services'])) + signals.append( + f"[동일 사용자] {iam}이 {svc_list}에 걸쳐 ${info['cost']:,.2f} 발생" + ) -# 서비스명 단축 — Python에서 미리 처리해 LLM에 전달 -_SVC_SHORT = { - 'Amazon Elastic Compute Cloud': 'EC2', - 'Amazon Simple Storage Service': 'S3', - 'AWS Lambda': 'Lambda', - 'Elastic Load Balancing': 'ELB', - 'Amazon Virtual Private Cloud': 'VPC', - 'AWS Cost Explorer': 'Cost Explorer', - 'AmazonCloudWatch': 'CloudWatch', - 'Amazon CloudFront': 'CloudFront', - 'Amazon Bedrock': 'Bedrock', -} + return signals -def _svc_label(svc: dict) -> str: - if svc['change_type'] == 'new': - return '신규' - if svc['change_type'] == 'stopped': - return '중단' - return '증가' if svc['total_diff'] > 0 else '감소' +def _fmt_signals(signals: list) -> str: + if not signals: + return '(특이 사항 없음)' + return '\n'.join(f"- {s}" for s in signals) -def _detail_label(d: dict) -> str: - if d['change_type'] == 'new': - return '신규 발생' - if d['change_type'] == 'stopped': - return '어제 중단' - return '증가' if d['diff'] > 0 else '감소' +def _calc_pace_context( + d1_total: float, mtd_total: float, mtd_days_elapsed: int, forecast_total: float, +) -> str: + """ + 어제 비용이 이번 달 페이스 대비 어떤 위치인지 사실로만 표현. + "이상치"·"평균보다 X% 큼" 같은 단정 표현 금지 — LLM이 자연어로 가공. + """ + if mtd_days_elapsed < 1 or mtd_total <= 0: + return '(이번 달 페이스 데이터 없음)' + daily_avg = mtd_total / mtd_days_elapsed + parts = [f"이번 달 일평균: ${daily_avg:,.2f}"] + if forecast_total > 0: + parts.append(f"월말 총 예상: ${forecast_total:,.2f}") + return ' / '.join(parts) -def _fmt_section(rows: list) -> str: - if not rows: - return ' (없음)' - blocks = [] - for svc in rows: - short = _SVC_SHORT.get(svc['service'], svc['service']) - amount = abs(svc['total_diff']) - label = _svc_label(svc) - header = ( - f"{short} ${amount:,.2f} [{label}]" - f" 어제 ${svc['cost_d1']:,.2f} / 그제 ${svc['cost_d2']:,.2f}" - ) - detail_lines = [] - for d in svc['details']: - who = d['iam_user'] if d.get('iam_user') else '(생성자 미상)' - count_str = f" ×{d['count']}개" if d.get('count', 1) > 1 else "" - dlabel = _detail_label(d) - detail_lines.append( - f" {who}: {d['usage_human']}{count_str}" - f" [{dlabel}] 어제 ${d['cost_d1']:,.2f} / 그제 ${d['cost_d2']:,.2f}" - ) - blocks.append(header + ('\n' + '\n'.join(detail_lines) if detail_lines else '')) - return '\n'.join(blocks) +# --------------------------------------------------------------------------- +# LLM 입력 메시지 구성 +# --------------------------------------------------------------------------- def _build_user_message( - d1_date: date, d2_date: date, - d1_total: float, d2_total: float, - service_rows: list, usage_type_rows: list, resource_rows: list, + d1_date: date, + d1_total: float, + top_services: list, + mtd_total: float, + mtd_days_elapsed: int, + forecast_total: float, + service_mtd: dict = None, ) -> str: - diff = d1_total - d2_total - pct = (diff / d2_total * 100) if d2_total else 0.0 - - merged = _merge_rows(service_rows, resource_rows) - #print("merged") - #pprint(merged) - - increase_rows = [s for s in merged if s['total_diff'] > 0] - decrease_rows = [s for s in merged if s['total_diff'] < 0] + """ + LLM 입력 메시지. - increase_text = _fmt_section(increase_rows) - decrease_text = _fmt_section(decrease_rows) + LLM에 raw 데이터 + 미리 계산된 그룹/패턴/페이스 신호를 같이 전달한다. + LLM은 이 데이터를 바탕으로 "어제 비용 분석" + "이번 달 누계 분석" 산문 요약만 생성. + Top 사용자 / 신규 발생은 Python에서 결정론적으로 렌더링하므로 LLM에 넣지 않는다. + """ + top_text = _fmt_top_services(top_services, d1_total) + mtd_break_text = _fmt_mtd_breakdown(service_mtd or {}, mtd_total, mtd_days_elapsed) + signals = _detect_signals( + top_services, d1_total, + service_mtd=service_mtd, mtd_days_elapsed=mtd_days_elapsed, + ) + signals_text = _fmt_signals(signals) - #print("increase_text") - #pprint(increase_text) - #print("decrease_text") - #pprint(decrease_text) + if mtd_days_elapsed >= 1 and mtd_total > 0: + mtd_line = f"이번 달 {mtd_days_elapsed}일 동안 ${mtd_total:,.2f} 사용." + else: + mtd_line = "이번 달 누계 데이터 없음." - return f"""어제({d1_date}) AWS 비용: ${d1_total:,.2f} -그제({d2_date}) AWS 비용: ${d2_total:,.2f} -전일 대비: {_fmt_sign(diff)} ({pct:+.1f}%) + forecast_line = ( + f" 이대로 진행 시 월말 예상 약 ${forecast_total:,.2f}." + if forecast_total > 0 else "" + ) + monthly_block = mtd_line + forecast_line -=== 비용이 증가한 서비스 === + # Top 사용자 / 신규 발생은 LLM에 넣지 않는다 — Python에서 직접 렌더링하므로 + # 입력에서 빠져야 LLM이 임의로 비슷한 섹션을 만들거나 잘라내는 사고가 없다. + return f"""어제({d1_date}) AWS 비용 ${d1_total:,.2f} +{monthly_block} -{increase_text} +=== 어제 비용 분석 raw === +{top_text} -=== 비용이 감소한 서비스 === +=== 어제 관찰된 신호 === +{signals_text} -{decrease_text} +=== 이번 달 누계 분석 raw === +{mtd_break_text} -위 데이터를 요약하세요.""" +위 입력만을 사용해 시스템 지시에 따라 2섹션 한국어 보고를 작성하세요.""" # --------------------------------------------------------------------------- @@ -560,45 +1410,91 @@ def _build_user_message( # --------------------------------------------------------------------------- def summarize( - d1_date: date, d2_date: date, - d1_total: float, d2_total: float, - service_rows: list, usage_type_rows: list, resource_rows: list, + d1_date: date, + d1_total: float, + top_services: list, + mtd_total: float, + mtd_days_elapsed: int, + forecast_total: float, + service_mtd: dict = None, ) -> str: """ - Nova Micro에게 비용 증감 원인 분석을 요청하고 요약 텍스트를 반환한다. + Nova Micro에 비용 요약 요청. - 실패 시 폴백 텍스트 반환 (Lambda 전체 실패 방지). + Bedrock 호출 실패 시 예외를 그대로 raise한다. 호출자(collect_all)가 + 이를 잡아 fallback 텍스트를 만들고 예외 객체는 llm_error로 보관한다. """ user_message = _build_user_message( - d1_date, d2_date, d1_total, d2_total, - service_rows, usage_type_rows, resource_rows, + d1_date=d1_date, + d1_total=d1_total, + top_services=top_services, + mtd_total=mtd_total, + mtd_days_elapsed=mtd_days_elapsed, + forecast_total=forecast_total, + service_mtd=service_mtd, ) - #print("user_message") - #pprint(user_message) - try: - bedrock = boto3.client('bedrock-runtime', region_name=_BEDROCK_REGION) - body = json.dumps({ - 'system': [{'text': _SYSTEM_PROMPT}], - 'messages': [{'role': 'user', 'content': [{'text': user_message}]}], - 'inferenceConfig': { - 'max_new_tokens': 512, - 'temperature': 0, - }, - }) - resp = bedrock.invoke_model( - modelId=_BEDROCK_MODEL_ID, - body=body, - contentType='application/json', - accept='application/json', - ) - result = json.loads(resp['body'].read()) - return result['output']['message']['content'][0]['text'].strip() + bedrock = boto3.client('bedrock-runtime', region_name=_BEDROCK_REGION) + body = json.dumps({ + 'system': [{'text': _SYSTEM_PROMPT}], + 'messages': [{'role': 'user', 'content': [{'text': user_message}]}], + 'inferenceConfig': { + # Nova Micro 의 최대 출력 토큰 (5,000) 까지 허용 — 2섹션 보고가 잘리지 않도록. + 'max_new_tokens': 5000, + 'temperature': 0, + }, + }) + resp = bedrock.invoke_model( + modelId=_BEDROCK_MODEL_ID, + body=body, + contentType='application/json', + accept='application/json', + ) + result = json.loads(resp['body'].read()) + text = result['output']['message']['content'][0]['text'].strip() + text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) + + # 어색한 부정 진술 / 시스템 용어 노출이 LLM 출력에 들어왔을 경우 자동 제거. + # 시스템 프롬프트에 금지를 명시했으나 LLM이 어길 수 있으므로 방어적 후처리. + # 한 줄 단위로 매칭 — 해당 줄 전체 삭제. + bad_line_patterns = [ + # 부정 진술 — "신규/새로/처음 + 없" 조합이면 줄 어디든 매칭, 줄 전체 삭제 + r'^.*(?:신규|새로\s*비용|새로\s*발생|새로\s*등장|처음\s*등장|처음\s*발생).*없(?:습니다|음|으며|었습니다|었음).*$', + # "특이사항 ~" 도입어 + 부정 진술 + r'^.*특이사항(?:으로|은)?.*없(?:습니다|음|으며).*$', + r'^\s*특이사항\s*없음\.?\s*$', + # 시스템 라벨 노출 — "관찰된 신호" 뒤 어떤 조사·동사가 와도 매칭 + r'^.*관찰된\s*신호.*$', + r'^.*\[동일\s*사용자\].*$', + r'^.*\[페이스\].*$', + r'^.*\[묶음\].*$', + # 어색한 명사구 + r'^.*(?:신규|발생)\s*항목(?:이|은|으로)?\s*없(?:습니다|음).*$', + # LLM이 Python 전용 영역(Top 사용자 / 신규 발생)을 흉내 내는 경우 차단. + # Python 측에서 결정론적으로 따로 출력하므로 LLM 본문에 새어 나오면 중복. + r'^\s*▸\s*이번\s*달\s*Top\s*\d+\s*사용자.*$', + r'^\s*▸\s*이번\s*달\s*신규\s*발생.*$', + r'^\s*•\s+.*\(이번\s*달\s*누계의.*\).*$', # • — $X (이번 달 누계의 ...) 패턴 + r'^\s*•\s+\[신규\s*(?:서비스|조합)\].*$', # • [신규 서비스/조합] ... 패턴 + r'^.*이번\s*달\s*들어.*(?:처음\s*(?:등장|발생|비용)|새로\s*(?:등장|비용\s*발생)).*$', + ] + for pat in bad_line_patterns: + text = re.sub(pat, '', text, flags=re.MULTILINE) - except Exception as e: - log.error("Bedrock 호출 실패: %s", e) - diff = d1_total - d2_total - direction = "증가" if diff >= 0 else "감소" - return f"LLM 분석 실패 (Bedrock 오류). 전일 대비 ${abs(diff):,.2f} {direction}." + # 한글 typo 방어 — Nova Micro가 "수준" 을 "수줤"·"수즌" 등으로 잘못 출력하는 경우. + # 입력 라인에 "$N 수준" 형식이 들어가지만 LLM이 한글 자모를 잘못 합치는 케이스 방어. + text = re.sub(r'(\$[\d,\.]+)\s*(수줤|수즌|숮준|수즁)', r'\1 수준', text) + + # 가독성: 한 문단 안에서 마침표 뒤 다음 문장 시작 글자가 오면 줄 바꿈 삽입. + # - "~다. 그 안에서는" → "~다.\n그 안에서는" (호흡 끊음) + # - "$229.53은" 같은 숫자 안의 마침표는 다음에 공백이 없어 영향 없음 + # - "$5.40를" 도 매칭 안 됨 (마침표 다음이 공백+글자가 아님) + # - "kernel-fusion-benchmark" 같은 영어 소문자 시작 문장도 잡도록 a-z 포함 + text = re.sub(r'\. ([가-힣a-zA-Z$])', r'.\n\1', text) + + # 연속된 빈 줄을 한 줄로 정리 + text = re.sub(r'\n{3,}', '\n\n', text) + + return text.strip() # --------------------------------------------------------------------------- @@ -607,45 +1503,111 @@ def summarize( def collect_all(d1_date: date) -> dict: """ - Athena 3개 쿼리 실행 + Nova Micro 요약 생성. + Athena 쿼리 + CE Forecast + Nova Micro 요약 일괄 수집. - Args: - d1_date: 리포트 기준일 (data_cur.py 와 동일한 d1_date 사용) + LLM 입력으로 사용: + Q14 fetch_top_services_with_breakdown — 어제 절대값 Top + IAM 분해 + Q15 fetch_month_new_costs — 이번 달 신규 발생 + Q16 fetch_service_mtd_breakdown — 서비스별 MTD pace + Q17 fetch_mtd_top_users_with_breakdown — 이번 달 누계 Top 사용자 + 서비스 분해 + MTD fetch_mtd_total_cur — 이번 달 누계 + FCST fetch_cost_forecast (CE) — 월말 예상 + + Slack 테이블 raw 데이터로만 사용 (LLM 미입력): + Q9, Q10, Q11 Returns: { - 'd1_date': date, - 'd2_date': date, - 'd1_total': float, - 'd2_total': float, - 'service_rows': list, - 'usage_type_rows': list, - 'resource_rows': list, - 'summary': str, # Nova Micro 요약 + 'd1_date': date, + 'd2_date': date, + 'd1_total': float, + 'd2_total': float, + 'service_rows': list, # Q9 + 'usage_type_rows': list, # Q10 + 'resource_rows': list, # Q11 + 'top_services': list, # Q14 + 'new_costs': list, # Q15 + 'service_mtd': dict, # Q16 + 'top_users_mtd': list, # Q17 + 'mtd_total': float, + 'mtd_days_elapsed': int, + 'forecast_total': float, + 'summary': str, + 'llm_error': Exception | None, # Bedrock 호출 실패 시 잡힌 예외 } """ d2_date = d1_date - timedelta(days=1) - athena = boto3.client('athena', region_name='ap-northeast-2') + athena = boto3.client('athena', region_name=_ATHENA_REGION) + ce = boto3.client('ce', region_name='us-east-1') service_rows = fetch_service_diff(athena, d1_date, d2_date) usage_type_rows = fetch_usage_type_diff(athena, d1_date, d2_date) resource_rows = fetch_resource_diff(athena, d1_date, d2_date) + top_services = fetch_top_services_with_breakdown( + athena, d1_date, + top_n=_TOP_SERVICES_N, breakdown_top=_TOP_BREAKDOWN_N, + ) + new_costs = fetch_month_new_costs(athena, d1_date) + service_mtd = fetch_service_mtd_breakdown(athena, d1_date) # Q16 + top_users_mtd = fetch_mtd_top_users_with_breakdown( # Q17 + athena, d1_date, + top_n=_TOP_SERVICES_N, breakdown_top=_TOP_BREAKDOWN_N, + ) d1_total = sum(r['cost_d1'] for r in service_rows) d2_total = sum(r['cost_d2'] for r in service_rows) - summary = summarize( - d1_date, d2_date, d1_total, d2_total, - service_rows, usage_type_rows, resource_rows, - ) + mtd_total = fetch_mtd_total_cur(athena, d1_date) + mtd_days_elapsed = d1_date.day + + # CE Forecast = "오늘부터 월말까지" 예상. mtd_total + forecast = 월말 총 예상 + try: + forecast = fetch_cost_forecast(ce) + except Exception as exc: + log.warning("CE forecast 실패 (무시): %s", exc) + forecast = 0.0 + forecast_total = mtd_total + forecast if forecast > 0 else 0.0 + + # LLM 호출 실패 시 메인 메시지 발송은 fallback 텍스트로 진행하되, + # 잡은 예외 객체를 llm_error 로 보관해 호출자(send_main3_report)가 + # 발송 후 별도 에러 알림을 만들 수 있도록 한다. + llm_error = None + try: + summary = summarize( + d1_date=d1_date, + d1_total=d1_total, + top_services=top_services, + mtd_total=mtd_total, + mtd_days_elapsed=mtd_days_elapsed, + forecast_total=forecast_total, + service_mtd=service_mtd, + ) + except Exception as e: + log.error("Bedrock 호출 실패: %s", e) + summary = f"LLM 분석 실패 (Bedrock 오류). 어제 총비용 ${d1_total:,.2f}." + llm_error = e + + # Top 사용자 / 신규 발생은 결정론적으로 렌더링 — LLM이 카운트를 흔드는 사고 회피. + top_users_section = format_top_users_section_for_slack(top_users_mtd, mtd_total) + new_costs_section = format_new_costs_section_for_slack(new_costs, top_services) return { - 'd1_date': d1_date, - 'd2_date': d2_date, - 'd1_total': d1_total, - 'd2_total': d2_total, - 'service_rows': service_rows, - 'usage_type_rows': usage_type_rows, - 'resource_rows': resource_rows, - 'summary': summary, - } + 'd1_date': d1_date, + 'd2_date': d2_date, + 'd1_total': d1_total, + 'd2_total': d2_total, + 'service_rows': service_rows, + 'usage_type_rows': usage_type_rows, + 'resource_rows': resource_rows, + 'top_services': top_services, + 'new_costs': new_costs, + 'service_mtd': service_mtd, + 'top_users_mtd': top_users_mtd, + 'mtd_total': mtd_total, + 'mtd_days_elapsed': mtd_days_elapsed, + 'forecast_total': forecast_total, + 'summary': summary, + 'top_users_section': top_users_section, + 'new_costs_section': new_costs_section, + 'llm_error': llm_error, + } \ No newline at end of file diff --git a/monitor_v2/cost/data.py b/monitor_v2/cost/data.py index e0f8003..174c826 100644 --- a/monitor_v2/cost/data.py +++ b/monitor_v2/cost/data.py @@ -79,7 +79,7 @@ def fetch_daily_by_service_and_creator(ce, period: dict) -> dict: Returns: {service: {creator_label: float}} - 미태깅 리소스의 creator_label = '(태그 없음 / 공용)' + aws:createdBy 태그없음 리소스는 결과에서 제외 (CUR fallback 동작과 일치) """ resp = ce.get_cost_and_usage( TimePeriod=period, @@ -95,7 +95,9 @@ def fetch_daily_by_service_and_creator(ce, period: dict) -> dict: service = group['Keys'][0] raw_tag = group['Keys'][1] # "aws:createdBy$" creator = raw_tag.split('$', 1)[1] if '$' in raw_tag else raw_tag - creator = creator or 'aws:createdBy 태그 없음' + # aws:createdBy 태그없음 → 표시하지 않음 (CUR fallback 동작과 일치) + if not creator: + continue amount = float(group['Metrics']['UnblendedCost']['Amount']) # IAM User별 비용에 Tax 포함 (Usage × 1.10) amount_with_tax = amount * 1.10 @@ -153,7 +155,9 @@ def fetch_mtd_by_service_and_creator(ce, period: dict) -> dict: service = group['Keys'][0] raw_tag = group['Keys'][1] creator = raw_tag.split('$', 1)[1] if '$' in raw_tag else raw_tag - creator = creator or 'aws:createdBy 태그 없음' + # aws:createdBy 태그없음 → 표시하지 않음 (CUR fallback 동작과 일치) + if not creator: + continue amount = float(group['Metrics']['UnblendedCost']['Amount']) # IAM User별 비용에 Tax 포함 (Usage × 1.10) amount_with_tax = amount * 1.10 diff --git a/monitor_v2/cost/data_cur.py b/monitor_v2/cost/data_cur.py index 97792e7..a86309b 100644 --- a/monitor_v2/cost/data_cur.py +++ b/monitor_v2/cost/data_cur.py @@ -39,11 +39,171 @@ _ATHENA_DATABASE = os.environ.get('ATHENA_DATABASE') _ATHENA_OUTPUT_LOCATION = os.environ.get('ATHENA_OUTPUT_LOCATION') _ATHENA_WORKGROUP = os.environ.get('ATHENA_WORKGROUP', 'primary') +_ATHENA_REGION = os.environ.get('ATHENA_REGION', 'ap-northeast-2') _POLL_INTERVAL = 1.5 # seconds _MAX_WAIT = 120 # seconds +# --------------------------------------------------------------------------- +# 공용 creator fallback CASE (Q3/Q5/Q11/Q14/Q15/Q17 공유) +# +# 우선순위: +# 0. 공통 서비스 예외 → '[공통] Data Transfer / Cost Explorer / Support' +# 1. aws:createdBy → IAM User name +# 2. lambda:createdBy → '[Lambda] ' +# 3. Requester/Username → '[Requester] / [User] ' +# 4. Project* → SPLIT_PART(value, '-', 1) # 첫 토큰만 +# 5. EKS → '[EKS] [/]' +# 6. Elastic Beanstalk → '[EB] ' +# 7. AWS 자동 관리 → '[MGN] / [SageMaker] managed-resource' +# 8. Service / Group → '[Service] / [Group] ' +# 9. Env / STAGE / Deploy → '[Env] / [Deploy] ' +# 10. Name / NAME → SPLIT_PART(value, '-', 1) # 첫 토큰만 +# 11. usage_type fallback (Usage 라인만) +# 12. 기타 +# +# CUR 은 그 계정 리소스가 한 번이라도 그 태그 키를 사용했을 때만 해당 +# resource_tags_* 컬럼을 생성한다. 따라서 계정별 cur_logs 스키마가 다름. +# information_schema.columns 로 사용 가능한 컬럼을 콜드스타트 시 1회 조회한 뒤, +# 존재하는 컬럼만 골라서 동적으로 CASE WHEN 을 조립한다. +# --------------------------------------------------------------------------- + +# 정적 prefix/suffix — 모든 계정에서 항상 사용 가능한 컬럼만 참조 +_CREATOR_CASE_PREFIX = """ CASE + WHEN product_product_name = 'AWS Data Transfer' THEN '[공통] Data Transfer' + WHEN product_product_name = 'AWS Cost Explorer' THEN '[공통] Cost Explorer' + WHEN product_product_name = 'AWS Support [Business]' THEN '[공통] Support' + +""" + +_CREATOR_CASE_SUFFIX = """ + WHEN line_item_line_item_type = 'Usage' + THEN CONCAT(product_product_name, ' - ', line_item_usage_type) + + ELSE CONCAT(product_product_name, ' - 기타') + END""" + +# EKS 는 cluster 필수 + nodegroup 옵셔널 구조라 별도 처리. 마커로 위치만 보존. +_EKS_RULE = '__EKS__' + +# 순서대로 평가되는 동적 룰: (필수 컬럼, WHEN-THEN SQL). +# 컬럼이 cur_logs 에 존재할 때만 해당 절을 포함한다. 기존 우선순위 유지. +_CREATOR_RULES_ORDERED: list[tuple[str, str]] = [ + ("resource_tags_aws_created_by", + "WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL\n" + " THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3)"), + ("resource_tags_user_lambda_created_by", + "WHEN NULLIF(resource_tags_user_lambda_created_by, '') IS NOT NULL\n" + " THEN CONCAT('[Lambda] ', resource_tags_user_lambda_created_by)"), + ("resource_tags_user_requester", + "WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL\n" + " THEN CONCAT('[Requester] ', resource_tags_user_requester)"), + ("resource_tags_user_username", + "WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL\n" + " THEN CONCAT('[User] ', resource_tags_user_username)"), + ("resource_tags_user_project", + "WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL\n" + " THEN SPLIT_PART(resource_tags_user_project, '-', 1)"), + ("resource_tags_user_project_name", + "WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL\n" + " THEN SPLIT_PART(resource_tags_user_project_name, '-', 1)"), + (_EKS_RULE, ""), + ("resource_tags_user_elasticbeanstalk_environment_name", + "WHEN NULLIF(resource_tags_user_elasticbeanstalk_environment_name, '') IS NOT NULL\n" + " THEN CONCAT('[EB] ', resource_tags_user_elasticbeanstalk_environment_name)"), + ("resource_tags_user_elasticbeanstalk_environment_id", + "WHEN NULLIF(resource_tags_user_elasticbeanstalk_environment_id, '') IS NOT NULL\n" + " THEN CONCAT('[EB] ', resource_tags_user_elasticbeanstalk_environment_id)"), + ("resource_tags_user_a_w_s_application_migration_service_managed", + "WHEN NULLIF(resource_tags_user_a_w_s_application_migration_service_managed, '') IS NOT NULL\n" + " THEN '[MGN] ApplicationMigrationService'"), + ("resource_tags_user_managed_by_amazon_sage_maker_resource", + "WHEN NULLIF(resource_tags_user_managed_by_amazon_sage_maker_resource, '') IS NOT NULL\n" + " THEN '[SageMaker] managed-resource'"), + ("resource_tags_user_service", + "WHEN NULLIF(resource_tags_user_service, '') IS NOT NULL\n" + " THEN CONCAT('[Service] ', resource_tags_user_service)"), + ("resource_tags_user_group", + "WHEN NULLIF(resource_tags_user_group, '') IS NOT NULL\n" + " THEN CONCAT('[Group] ', resource_tags_user_group)"), + ("resource_tags_user_environment", + "WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL\n" + " THEN CONCAT('[Env] ', resource_tags_user_environment)"), + ("resource_tags_user_s_t_a_g_e", + "WHEN NULLIF(resource_tags_user_s_t_a_g_e, '') IS NOT NULL\n" + " THEN CONCAT('[Env] ', resource_tags_user_s_t_a_g_e)"), + ("resource_tags_user_deploy", + "WHEN NULLIF(resource_tags_user_deploy, '') IS NOT NULL\n" + " THEN CONCAT('[Deploy] ', resource_tags_user_deploy)"), + ("resource_tags_user_name", + "WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL\n" + " THEN SPLIT_PART(resource_tags_user_name, '-', 1)"), + ("resource_tags_user_n_a_m_e", + "WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL\n" + " THEN SPLIT_PART(resource_tags_user_n_a_m_e, '-', 1)"), +] + +# 모듈 전역 캐시 (콜드스타트 시 1회 채워짐) +_AVAILABLE_TAG_COLUMNS: set[str] | None = None + + +def _load_available_tag_columns(athena) -> set[str]: + """cur_logs 의 resource_tags_* 컬럼 set 을 반환 (모듈 전역 캐시).""" + global _AVAILABLE_TAG_COLUMNS + if _AVAILABLE_TAG_COLUMNS is not None: + return _AVAILABLE_TAG_COLUMNS + sql = f""" + SELECT column_name + FROM information_schema.columns + WHERE table_schema = '{_ATHENA_DATABASE}' + AND table_name = 'cur_logs' + AND column_name LIKE 'resource_tags_%' + """ + rows = _run_query(athena, sql) + _AVAILABLE_TAG_COLUMNS = {r['column_name'] for r in rows if r.get('column_name')} + log.info("CUR resource_tags 컬럼 %d개 감지", len(_AVAILABLE_TAG_COLUMNS)) + return _AVAILABLE_TAG_COLUMNS + + +def _eks_clause(available: set[str]) -> str | None: + """EKS 룰 — cluster 필수, nodegroup 옵셔널.""" + cluster = "resource_tags_user_eks_cluster_name" + nodegroup = "resource_tags_user_eks_nodegroup_name" + if cluster not in available: + return None + if nodegroup in available: + return ( + f"WHEN NULLIF({cluster}, '') IS NOT NULL\n" + f" THEN CONCAT(\n" + f" '[EKS] ',\n" + f" {cluster},\n" + f" CASE WHEN NULLIF({nodegroup}, '') IS NOT NULL\n" + f" THEN CONCAT('/', {nodegroup})\n" + f" ELSE '' END\n" + f" )" + ) + return ( + f"WHEN NULLIF({cluster}, '') IS NOT NULL\n" + f" THEN CONCAT('[EKS] ', {cluster})" + ) + + +def _build_creator_case_sql(athena) -> str: + """계정에 존재하는 resource_tags_* 컬럼만 사용하는 CASE WHEN SQL 을 조립.""" + available = _load_available_tag_columns(athena) + middle: list[str] = [] + for col, when_then in _CREATOR_RULES_ORDERED: + if col == _EKS_RULE: + clause = _eks_clause(available) + if clause: + middle.append(clause) + elif col in available: + middle.append(when_then) + middle_sql = "".join(" " + c + "\n" for c in middle) + return _CREATOR_CASE_PREFIX + middle_sql + _CREATOR_CASE_SUFFIX + + # --------------------------------------------------------------------------- # Athena 실행 헬퍼 # --------------------------------------------------------------------------- @@ -130,12 +290,12 @@ def fetch_daily_by_service_cur(athena, target_date: date) -> dict: SELECT product_product_name AS service, SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) = DATE('{target_date}') GROUP BY product_product_name - HAVING SUM(line_item_unblended_cost) > 0.01 + HAVING SUM(line_item_unblended_cost) > 0 ORDER BY cost DESC """ rows = _run_query(athena, sql) @@ -149,7 +309,22 @@ def fetch_daily_by_service_cur(athena, target_date: date) -> dict: def fetch_daily_by_service_and_creator_cur(athena, d1_date: date) -> dict: """ Q3 해당. - D-1 서비스 + aws:createdBy 태그별 비용. + D-1 서비스 + 다단계 태그 fallback 으로 산출한 creator 별 비용. + + Fallback 우선순위 (위에서 아래): + 0. 공통 서비스 예외 → '[공통] Data Transfer / Cost Explorer / Support' + 1. aws:createdBy → IAM User name + 2. lambda:createdBy → '[Lambda] ' + 3. Requester / Username → '[Requester] / [User] ' + 4. Project / ProjectName → SPLIT_PART(value, '-', 1) # 첫 토큰만 + 5. eks:cluster-name(+nodegroup) → '[EKS] /' + 6. elasticbeanstalk:env-name/id → '[EB] ' + 7. AWS 자동 관리 (MGN, SageMaker) → '[MGN] / [SageMaker] managed-resource' + 8. Service / Group → '[Service] / [Group] ' + 9. Environment / STAGE / Deploy → '[Env] / [Deploy] ' + 10. Name / NAME → SPLIT_PART(value, '-', 1) # 첫 토큰만 + 11. usage_type → ' - ' + 12. 그 외 → ' - 기타' NOTE: IAM User별 비용에 Tax 포함 계산 (Usage × 1.10) @@ -157,75 +332,18 @@ def fetch_daily_by_service_and_creator_cur(athena, d1_date: date) -> dict: {service: {creator: float}} """ year, month = _partition(d1_date) + creator_case_sql = _build_creator_case_sql(athena) sql = f""" SELECT - product_product_name AS service, - CASE - WHEN line_item_line_item_type = 'Tax' - THEN CONCAT('[Tax] ', product_product_name) - WHEN product_product_name = 'AWS Data Transfer' - THEN '[공통] Data Transfer' - WHEN product_product_name = 'AWS Cost Explorer' - THEN '[공통] Cost Explorer' - WHEN product_product_name = 'AWS Support [Business]' - THEN '[공통] Support' - WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL - THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3) - WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL - THEN CONCAT('[username] ', resource_tags_user_username) - WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL - THEN CONCAT('[requester] ', resource_tags_user_requester) - WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL - THEN CONCAT('[project] ', resource_tags_user_project) - WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL - THEN CONCAT('[project_name] ', resource_tags_user_project_name) - WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL - THEN resource_tags_user_name - WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL - THEN CONCAT('[n_a_m_e] ', resource_tags_user_n_a_m_e) - WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL - THEN CONCAT('[environment] ', resource_tags_user_environment) - WHEN line_item_line_item_type = 'Usage' - THEN CONCAT(product_product_name, ' - ', line_item_usage_type) - ELSE CONCAT(product_product_name, ' - 기타') - END AS creator, - SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + product_product_name AS service, + {creator_case_sql} AS creator, + SUM(line_item_unblended_cost) AS cost + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) = DATE('{d1_date}') AND line_item_line_item_type != 'Tax' - GROUP BY - product_product_name, - CASE - WHEN line_item_line_item_type = 'Tax' - THEN CONCAT('[Tax] ', product_product_name) - WHEN product_product_name = 'AWS Data Transfer' - THEN '[공통] Data Transfer' - WHEN product_product_name = 'AWS Cost Explorer' - THEN '[공통] Cost Explorer' - WHEN product_product_name = 'AWS Support [Business]' - THEN '[공통] Support' - WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL - THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3) - WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL - THEN CONCAT('[username] ', resource_tags_user_username) - WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL - THEN CONCAT('[requester] ', resource_tags_user_requester) - WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL - THEN CONCAT('[project] ', resource_tags_user_project) - WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL - THEN CONCAT('[project_name] ', resource_tags_user_project_name) - WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL - THEN resource_tags_user_name - WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL - THEN CONCAT('[n_a_m_e] ', resource_tags_user_n_a_m_e) - WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL - THEN CONCAT('[environment] ', resource_tags_user_environment) - WHEN line_item_line_item_type = 'Usage' - THEN CONCAT(product_product_name, ' - ', line_item_usage_type) - ELSE CONCAT(product_product_name, ' - 기타') - END + GROUP BY 1, 2 HAVING SUM(line_item_unblended_cost) > 0.1 ORDER BY service, cost DESC """ @@ -257,7 +375,7 @@ def fetch_daily_by_service_and_region_cur(athena, d1_date: date) -> dict: product_product_name AS service, COALESCE(NULLIF(product_region_code, ''), 'global') AS region, SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) = DATE('{d1_date}') @@ -295,76 +413,19 @@ def fetch_mtd_by_service_and_creator_cur(athena, d1_date: date) -> dict: if mtd_start >= d1_date: return {} year, month = _partition(d1_date) + creator_case_sql = _build_creator_case_sql(athena) sql = f""" SELECT - product_product_name AS service, - CASE - WHEN line_item_line_item_type = 'Tax' - THEN CONCAT('[Tax] ', product_product_name) - WHEN product_product_name = 'AWS Data Transfer' - THEN '[공통] Data Transfer' - WHEN product_product_name = 'AWS Cost Explorer' - THEN '[공통] Cost Explorer' - WHEN product_product_name = 'AWS Support [Business]' - THEN '[공통] Support' - WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL - THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3) - WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL - THEN CONCAT('[username] ', resource_tags_user_username) - WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL - THEN CONCAT('[requester] ', resource_tags_user_requester) - WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL - THEN CONCAT('[project] ', resource_tags_user_project) - WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL - THEN CONCAT('[project_name] ', resource_tags_user_project_name) - WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL - THEN resource_tags_user_name - WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL - THEN CONCAT('[n_a_m_e] ', resource_tags_user_n_a_m_e) - WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL - THEN CONCAT('[environment] ', resource_tags_user_environment) - WHEN line_item_line_item_type = 'Usage' - THEN CONCAT(product_product_name, ' - ', line_item_usage_type) - ELSE CONCAT(product_product_name, ' - 기타') - END AS creator, - SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + product_product_name AS service, + {creator_case_sql} AS creator, + SUM(line_item_unblended_cost) AS cost + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) BETWEEN DATE('{mtd_start}') AND DATE('{d1_date}') AND line_item_line_item_type != 'Tax' - GROUP BY - product_product_name, - CASE - WHEN line_item_line_item_type = 'Tax' - THEN CONCAT('[Tax] ', product_product_name) - WHEN product_product_name = 'AWS Data Transfer' - THEN '[공통] Data Transfer' - WHEN product_product_name = 'AWS Cost Explorer' - THEN '[공통] Cost Explorer' - WHEN product_product_name = 'AWS Support [Business]' - THEN '[공통] Support' - WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL - THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3) - WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL - THEN CONCAT('[username] ', resource_tags_user_username) - WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL - THEN CONCAT('[requester] ', resource_tags_user_requester) - WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL - THEN CONCAT('[project] ', resource_tags_user_project) - WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL - THEN CONCAT('[project_name] ', resource_tags_user_project_name) - WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL - THEN resource_tags_user_name - WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL - THEN CONCAT('[n_a_m_e] ', resource_tags_user_n_a_m_e) - WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL - THEN CONCAT('[environment] ', resource_tags_user_environment) - WHEN line_item_line_item_type = 'Usage' - THEN CONCAT(product_product_name, ' - ', line_item_usage_type) - ELSE CONCAT(product_product_name, ' - 기타') - END + GROUP BY 1, 2 HAVING SUM(line_item_unblended_cost) > 0.1 ORDER BY service, cost DESC """ @@ -400,7 +461,7 @@ def fetch_mtd_by_service_and_region_cur(athena, d1_date: date) -> dict: product_product_name AS service, COALESCE(NULLIF(product_region_code, ''), 'global') AS region, SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) @@ -434,7 +495,7 @@ def fetch_mtd_total_cur(athena, d1_date: date) -> float: year, month = _partition(d1_date) sql = f""" SELECT SUM(line_item_unblended_cost) AS mtd_total - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) @@ -472,7 +533,7 @@ def collect_all(today_kst: date) -> dict: 'forecast': float, # CE API (0.0 = 예측 불가) } """ - athena = boto3.client('athena', region_name='ap-northeast-2') + athena = boto3.client('athena', region_name=_ATHENA_REGION) ce = boto3.client('ce', region_name='us-east-1') d1_date = today_kst #- timedelta(days=1) diff --git a/monitor_v2/cost/queries.sql b/monitor_v2/cost/queries.sql index 153033d..395f99e 100644 --- a/monitor_v2/cost/queries.sql +++ b/monitor_v2/cost/queries.sql @@ -62,7 +62,6 @@ WHERE year = '2026' AND month = '4' AND DATE(line_item_usage_start_date) = DATE('2026-04-04') GROUP BY product_product_name -HAVING SUM(line_item_unblended_cost) > 0.01 ORDER BY cost DESC; @@ -91,20 +90,8 @@ SELECT THEN '[공통] Support' WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3) - WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL - THEN CONCAT('[username] ', resource_tags_user_username) - WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL - THEN CONCAT('[requester] ', resource_tags_user_requester) - WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL - THEN CONCAT('[project] ', resource_tags_user_project) - WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL - THEN CONCAT('[project_name] ', resource_tags_user_project_name) WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL THEN resource_tags_user_name - WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL - THEN CONCAT('[n_a_m_e] ', resource_tags_user_n_a_m_e) - WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL - THEN CONCAT('[environment] ', resource_tags_user_environment) WHEN line_item_line_item_type = 'Usage' THEN CONCAT(product_product_name, ' - ', line_item_usage_type) ELSE CONCAT(product_product_name, ' - 기타') @@ -126,20 +113,8 @@ GROUP BY THEN '[공통] Support' WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL THEN SPLIT_PART(resource_tags_aws_created_by, ':', 3) - WHEN NULLIF(resource_tags_user_username, '') IS NOT NULL - THEN CONCAT('[username] ', resource_tags_user_username) - WHEN NULLIF(resource_tags_user_requester, '') IS NOT NULL - THEN CONCAT('[requester] ', resource_tags_user_requester) - WHEN NULLIF(resource_tags_user_project, '') IS NOT NULL - THEN CONCAT('[project] ', resource_tags_user_project) - WHEN NULLIF(resource_tags_user_project_name, '') IS NOT NULL - THEN CONCAT('[project_name] ', resource_tags_user_project_name) WHEN NULLIF(resource_tags_user_name, '') IS NOT NULL THEN resource_tags_user_name - WHEN NULLIF(resource_tags_user_n_a_m_e, '') IS NOT NULL - THEN CONCAT('[n_a_m_e] ', resource_tags_user_n_a_m_e) - WHEN NULLIF(resource_tags_user_environment, '') IS NOT NULL - THEN CONCAT('[environment] ', resource_tags_user_environment) WHEN line_item_line_item_type = 'Usage' THEN CONCAT(product_product_name, ' - ', line_item_usage_type) ELSE CONCAT(product_product_name, ' - 기타') @@ -404,6 +379,209 @@ SELECT LIMIT 10; +-- ----------------------------------------------------------------------------- +-- Q12. collect_instance_cost_cur (ec2/data_cur.py) +-- 인스턴스 ID별 D-1 On-Demand EC2 비용 + 실 사용 시간 (CUR 기반) +-- Spot 전환 절감 분석용. BoxUsage 행만 대상 (On-Demand 인스턴스만 포함). +-- +-- iam_user: resource_tags_aws_created_by → SPLIT_PART(..., ':', 3) +-- 예: 'IAMUser:AIDAXXX:alice' → 'alice' +-- usage_hours: line_item_usage_amount 합산 (시간 단위) +-- cost: line_item_unblended_cost 합산 (USD) +-- +-- spot_estimate = usage_hours × describe_spot_price_history 결과 +-- savings = cost - spot_estimate +-- ----------------------------------------------------------------------------- +SELECT + line_item_resource_id AS instance_id, + product_instance_type AS instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SPLIT_PART(COALESCE(resource_tags_aws_created_by, ''), ':', 3) AS iam_user, + SUM(line_item_usage_amount) AS usage_hours, + SUM(line_item_unblended_cost) AS cost +FROM hyu_ddps_logs.cur_logs +WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') + AND line_item_resource_id LIKE 'i-%' + AND product_instance_type != '' + AND line_item_usage_type LIKE '%BoxUsage%' +GROUP BY + line_item_resource_id, + product_instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global'), + SPLIT_PART(COALESCE(resource_tags_aws_created_by, ''), ':', 3) +HAVING SUM(line_item_unblended_cost) > 0; + + +-- ----------------------------------------------------------------------------- +-- Q13. fetch_weekend_ec2_by_week (분석용 — query.py) +-- EC2 + EC2-Other 서비스의 주말(토·일) 비용을 주 단위로 집계. +-- +-- 대상 기간: 2026년 3월 ~ 4월 +-- 대상 요일: day_of_week=6(토), day_of_week=7(일) ← Presto/Athena 기준 +-- (1=월 … 6=토 … 7=일) +-- EC2 서비스: +-- - 'Amazon Elastic Compute Cloud - Compute' (인스턴스 계산 비용) +-- - 'Amazon Elastic Compute Cloud' +-- - 'Amazon EC2' +-- - 'EC2 - Other' (EBS·NAT·전송 등) +-- +-- 출력 컬럼: +-- week_start 해당 주 월요일 (date_trunc 기준) +-- saturday 해당 주 토요일 (week_start + 5일) +-- sunday 해당 주 일요일 (week_start + 6일) +-- ec2_sat EC2 Compute 토요일 비용 +-- ec2_sun EC2 Compute 일요일 비용 +-- ec2_weekend EC2 Compute 주말 합계 +-- other_sat EC2-Other 토요일 비용 +-- other_sun EC2-Other 일요일 비용 +-- other_weekend EC2-Other 주말 합계 +-- weekend_total EC2 전체 주말 합계 +-- ----------------------------------------------------------------------------- +SELECT + DATE(date_trunc('week', DATE(line_item_usage_start_date))) + AS week_start, + DATE(date_trunc('week', DATE(line_item_usage_start_date))) + INTERVAL '5' DAY + AS saturday, + DATE(date_trunc('week', DATE(line_item_usage_start_date))) + INTERVAL '6' DAY + AS sunday, + SUM(CASE + WHEN product_product_name IN ( + 'Amazon Elastic Compute Cloud - Compute', + 'Amazon Elastic Compute Cloud', + 'Amazon EC2' + ) AND day_of_week(DATE(line_item_usage_start_date)) = 6 + THEN line_item_unblended_cost ELSE 0 + END) AS ec2_sat, + SUM(CASE + WHEN product_product_name IN ( + 'Amazon Elastic Compute Cloud - Compute', + 'Amazon Elastic Compute Cloud', + 'Amazon EC2' + ) AND day_of_week(DATE(line_item_usage_start_date)) = 7 + THEN line_item_unblended_cost ELSE 0 + END) AS ec2_sun, + SUM(CASE + WHEN product_product_name IN ( + 'Amazon Elastic Compute Cloud - Compute', + 'Amazon Elastic Compute Cloud', + 'Amazon EC2' + ) + THEN line_item_unblended_cost ELSE 0 + END) AS ec2_weekend, + SUM(CASE + WHEN product_product_name = 'EC2 - Other' + AND day_of_week(DATE(line_item_usage_start_date)) = 6 + THEN line_item_unblended_cost ELSE 0 + END) AS other_sat, + SUM(CASE + WHEN product_product_name = 'EC2 - Other' + AND day_of_week(DATE(line_item_usage_start_date)) = 7 + THEN line_item_unblended_cost ELSE 0 + END) AS other_sun, + SUM(CASE + WHEN product_product_name = 'EC2 - Other' + THEN line_item_unblended_cost ELSE 0 + END) AS other_weekend, + SUM(line_item_unblended_cost) AS weekend_total +FROM hyu_ddps_logs.cur_logs +WHERE year = '2026' + AND month IN ('3', '4') + AND product_product_name IN ( + 'Amazon Elastic Compute Cloud - Compute', + 'Amazon Elastic Compute Cloud', + 'Amazon EC2', + 'EC2 - Other' + ) + AND day_of_week(DATE(line_item_usage_start_date)) IN (6, 7) +GROUP BY date_trunc('week', DATE(line_item_usage_start_date)) +HAVING SUM(line_item_unblended_cost) > 0 +ORDER BY week_start; + + +-- ----------------------------------------------------------------------------- +-- Q14. fetch_weekday_ec2_by_week (분석용) +-- EC2 + EC2-Other 서비스의 평일(월~금) 비용을 주 단위로 집계하고 +-- LAG 윈도우 함수로 전주 대비 변화를 함께 표시한다. +-- +-- 대상 기간: 2026년 3월 ~ 4월 +-- 대상 요일: day_of_week IN (1,2,3,4,5) ← 월=1 … 금=5 (Presto/Athena 기준) +-- +-- 집계 컬럼: +-- week_start 해당 주 월요일 +-- friday 해당 주 금요일 (week_start + 4일) +-- ec2_weekday EC2 Compute 평일 합계 +-- other_weekday EC2-Other 평일 합계 +-- weekday_total EC2 전체 평일 합계 +-- +-- 전주 비교 컬럼 (LAG): +-- prev_ec2_weekday 전주 EC2 Compute 평일 합계 +-- prev_other_weekday 전주 EC2-Other 평일 합계 +-- prev_weekday_total 전주 전체 평일 합계 +-- diff_ec2 ec2_weekday - prev_ec2_weekday (증가 +, 감소 -) +-- diff_other other_weekday - prev_other_weekday +-- diff_total weekday_total - prev_weekday_total +-- pct_total diff_total / prev_weekday_total × 100 (NULL = 첫 주) +-- ----------------------------------------------------------------------------- +WITH weekday_base AS ( + SELECT + DATE(date_trunc('week', DATE(line_item_usage_start_date))) + AS week_start, + DATE(date_trunc('week', DATE(line_item_usage_start_date))) + INTERVAL '4' DAY + AS friday, + SUM(CASE + WHEN product_product_name IN ( + 'Amazon Elastic Compute Cloud - Compute', + 'Amazon Elastic Compute Cloud', + 'Amazon EC2' + ) + THEN line_item_unblended_cost ELSE 0 + END) AS ec2_weekday, + SUM(CASE + WHEN product_product_name = 'EC2 - Other' + THEN line_item_unblended_cost ELSE 0 + END) AS other_weekday, + SUM(line_item_unblended_cost) AS weekday_total + FROM hyu_ddps_logs.cur_logs + WHERE year = '2026' + AND month IN ('3', '4') + AND product_product_name IN ( + 'Amazon Elastic Compute Cloud - Compute', + 'Amazon Elastic Compute Cloud', + 'Amazon EC2', + 'EC2 - Other' + ) + AND day_of_week(DATE(line_item_usage_start_date)) IN (1, 2, 3, 4, 5) + GROUP BY date_trunc('week', DATE(line_item_usage_start_date)) + HAVING SUM(line_item_unblended_cost) > 0 +) +SELECT + week_start, + friday, + ec2_weekday, + other_weekday, + weekday_total, + LAG(ec2_weekday) OVER (ORDER BY week_start) AS prev_ec2_weekday, + LAG(other_weekday) OVER (ORDER BY week_start) AS prev_other_weekday, + LAG(weekday_total) OVER (ORDER BY week_start) AS prev_weekday_total, + ec2_weekday - LAG(ec2_weekday) OVER (ORDER BY week_start) AS diff_ec2, + other_weekday - LAG(other_weekday) OVER (ORDER BY week_start) AS diff_other, + weekday_total - LAG(weekday_total) OVER (ORDER BY week_start) AS diff_total, + CASE + WHEN LAG(weekday_total) OVER (ORDER BY week_start) IS NOT NULL + AND LAG(weekday_total) OVER (ORDER BY week_start) > 0 + THEN ROUND( + (weekday_total - LAG(weekday_total) OVER (ORDER BY week_start)) + / LAG(weekday_total) OVER (ORDER BY week_start) * 100, + 1 + ) + ELSE NULL + END AS pct_total +FROM weekday_base +ORDER BY week_start; + + -- ----------------------------------------------------------------------------- -- Q8. fetch_cost_forecast SELECT diff --git a/monitor_v2/cost/report_analysis.py b/monitor_v2/cost/report_analysis.py index 9eb7e5b..5c7ecd0 100644 --- a/monitor_v2/cost/report_analysis.py +++ b/monitor_v2/cost/report_analysis.py @@ -6,15 +6,16 @@ 구성: 헤더 — "AWS 비용 변화 분석 | {d1_date} | {account}" - 요약 수치 — 어제 총비용 / 전날 대비 변화 - Q9 테이블 — 서비스별 비용 변화 Top 10 - Q10 테이블 — 리소스 타입별 비용 변화 Top 10 - Q11 테이블 — 리소스 ID별 비용 변화 Top 10 + 요약 수치 — 어제 총비용 / 이번 달 누계(N일) / 월말 예상 + Q9 테이블 — 서비스별 비용 변화 Top + Q10 테이블 — 리소스 타입별 비용 변화 Top + Q11 테이블 — 리소스 ID별 비용 변화 Top AI 요약 — Nova Micro 요약 텍스트 context — 분석 기준 날짜 / 데이터 소스 / 모델 """ import os +import re from datetime import date from ..slack import client as slack @@ -57,24 +58,90 @@ def _resource_rows(rows: list) -> list: ] or [["(데이터 없음)", "", "", "", "", "", ""]] +def _split_summary(summary: str) -> tuple: + """ + LLM 요약 텍스트를 (opening, yesterday, mtd) 3-tuple 로 분할. + + LLM 출력 구조: + + + ■ 어제 비용 분석 + + + ■ 이번 달 누계 분석 + + + Returns: + (opening, yesterday_body, mtd_body) — 각 항목은 헤더 제외 본문만. + 섹션이 없으면 빈 문자열. + """ + parts = re.split(r'\n\s*■\s*', summary.strip()) + opening = parts[0].strip() if parts else summary.strip() + + yesterday_body, mtd_body = '', '' + for part in parts[1:]: + if not part.strip(): + continue + head, _, body = part.partition('\n') + head = head.strip() + body = body.strip() + if '어제' in head: + yesterday_body = body + elif '누계' in head: + mtd_body = body + return opening, yesterday_body, mtd_body + + def _build_main3(analysis: dict) -> list: - d1_date = analysis['d1_date'] - d2_date = analysis['d2_date'] - d1_total = analysis['d1_total'] - d2_total = analysis['d2_total'] - diff = d1_total - d2_total - pct = (diff / d2_total * 100) if d2_total else 0.0 - summary = analysis['summary'] - service_rows = analysis['service_rows'] - usage_rows = analysis['usage_type_rows'] - resource_rows = analysis['resource_rows'] + d1_date = analysis['d1_date'] + d1_total = analysis['d1_total'] + summary = analysis['summary'] + service_rows = analysis['service_rows'] + usage_rows = analysis['usage_type_rows'] + resource_rows = analysis['resource_rows'] + mtd_total = analysis.get('mtd_total', 0.0) + mtd_days_elapsed = analysis.get('mtd_days_elapsed', 0) + forecast_total = analysis.get('forecast_total', 0.0) + top_users_section = analysis.get('top_users_section', '') + new_costs_section = analysis.get('new_costs_section', '') + + fields = [ + f"*어제({d1_date}) 총비용*\n`${d1_total:,.2f}`", + ( + f"*이번 달 누계 ({mtd_days_elapsed}일 경과)*\n`${mtd_total:,.2f}`" + if mtd_total > 0 else "*이번 달 누계*\n`데이터 없음`" + ), + ( + f"*월말 예상*\n`${forecast_total:,.2f}`" + if forecast_total > 0 else "*월말 예상*\n`예측 불가`" + ), + ] + + opening, yesterday_body, mtd_body = _split_summary(summary) + + summary_blocks = [_section("*AI 요약*")] + if opening: + summary_blocks.append(_section(opening)) + if yesterday_body: + summary_blocks.append(_section(f"*■ 어제 비용 분석*\n{yesterday_body}")) + if mtd_body: + summary_blocks.append(_section(f"*■ 이번 달 누계 분석*\n{mtd_body}")) + if not (opening or yesterday_body or mtd_body): + # 분할 실패 fallback — 원문 그대로 + summary_blocks.append(_section(summary)) + + # Top 사용자 / 신규 발생은 Python에서 결정론적으로 렌더링한 결과. + # LLM이 카운트를 흔들거나 통째로 누락하는 사고를 회피하기 위해 별도 블록으로 발송. + if top_users_section: + summary_blocks.append(_section(top_users_section)) + if new_costs_section: + summary_blocks.append(_section(new_costs_section)) return [ _header(f"AWS 비용 변화 분석 | {d1_date} | {ACCOUNT_NAME}"), - _fields_section([ - f"*어제({d1_date}) 총비용*\n`${d1_total:,.2f}`", - f"*전날({d2_date}) 대비*\n`{_fmt_diff(diff)}` `({pct:+.1f}%)`", - ]), + _fields_section(fields), + _divider(), + *summary_blocks, _divider(), *_table_section( f"*[ 서비스별 비용 변화 Top {len(service_rows)} ]*", @@ -93,9 +160,6 @@ def _build_main3(analysis: dict) -> list: ["서비스", "타입", "리소스 ID", "생성자", "어제", "그제", "변화"], _resource_rows(resource_rows), ), - _divider(), - _section("*AI 요약*"), - _section(summary), _context( f"분석 기준: {d1_date} | 데이터 소스: CUR | 모델: {_BEDROCK_MODEL_ID}" ), @@ -104,13 +168,22 @@ def _build_main3(analysis: dict) -> list: def send_main3_report(d1_date: date) -> None: """ - Main 3 발송. + Main 3 발송. mtd_this / forecast 는 collect_all 내부에서 수집한다. + + Bedrock 호출이 실패한 경우 fallback 텍스트가 포함된 메인 메시지를 먼저 + 발송한 뒤, 보관해 둔 예외(llm_error)를 그대로 raise 한다. 호출자 + (lambda_handler 의 ai_analysis 단계) 가 이를 catch 해 slack.post_error 로 + 별도 에러 알림을 채널에 추가 발송한다. Args: - d1_date: 리포트 기준일 (lambda_handler에서 cost_data['d1_date'] 전달) + d1_date: 리포트 기준일 """ analysis = collect_all(d1_date) slack.post_blocks( _build_main3(analysis), fallback_text=f"AWS 비용 변화 분석 {d1_date} / {ACCOUNT_NAME}", ) + + llm_error = analysis.get('llm_error') + if llm_error is not None: + raise llm_error diff --git a/monitor_v2/ec2/data_cur.py b/monitor_v2/ec2/data_cur.py index 4406ace..003080b 100644 --- a/monitor_v2/ec2/data_cur.py +++ b/monitor_v2/ec2/data_cur.py @@ -6,6 +6,10 @@ data.py 대비 변경점: - collect_ec2_cost_by_type : CE API → Athena CUR 쿼리로 교체 - collect_instances / collect_unused_ebs / collect_unused_snapshots : 기존 data.py 재사용 + - 자원별 iam_user 는 CUR(resource_tags_aws_created_by 등) 로 보강 + → aws:createdBy 는 EC2 API Tags 에 노출되지 않는 system tag 이므로 + data.py 의 tag 기반 접근만으로는 항상 '' 가 됨. collect_all() 내부에서 + collect_resource_creators_cur() 결과를 주입해 보강한다. collect_all() 반환 구조는 data.py 와 동일 → ec2/report.py 그대로 사용 가능. @@ -16,7 +20,8 @@ """ import boto3 -from datetime import date, timedelta +from botocore.exceptions import ClientError +from datetime import date, timedelta, datetime, timezone import logging from .data import ( @@ -24,7 +29,10 @@ collect_unused_ebs, collect_unused_snapshots, ) -from ..cost.data_cur import _run_query, _partition +from ..cost.data_cur import ( + _run_query, _partition, _ATHENA_DATABASE, _ATHENA_REGION, + _build_creator_case_sql, +) log = logging.getLogger(__name__) @@ -44,7 +52,7 @@ def collect_ec2_cost_by_type_cur(athena, d1_date: date) -> dict: product_instance_type AS instance_type, COALESCE(NULLIF(product_region_code, ''), 'global') AS region, SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) = DATE('{d1_date}') @@ -82,7 +90,7 @@ def collect_ec2_cost_by_type_mtd_cur(athena, d1_date: date) -> dict: product_instance_type AS instance_type, COALESCE(NULLIF(product_region_code, ''), 'global') AS region, SUM(line_item_unblended_cost) AS cost - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{year}' AND month = '{month}' AND DATE(line_item_usage_start_date) @@ -121,7 +129,7 @@ def _query_spot(start: date, end: date) -> float: y, m = _partition(end) sql = f""" SELECT SUM(line_item_unblended_cost) AS spot_cost - FROM hyu_ddps_logs.cur_logs + FROM {_ATHENA_DATABASE}.cur_logs WHERE year = '{y}' AND month = '{m}' AND DATE(line_item_usage_start_date) @@ -138,6 +146,214 @@ def _query_spot(start: date, end: date) -> float: return spot_d1, spot_d2, spot_mtd +def collect_instance_cost_cur(athena, d1_date: date) -> dict: + """ + D-1 인스턴스 ID별 On-Demand EC2 실 비용 + 실 사용 시간 (Athena CUR, Q12). + + BoxUsage 행만 집계하므로 On-Demand 인스턴스만 포함된다. + Spot 절감 추정: usage_hours × describe_spot_price_history 결과 + + Returns: + { + instance_id: { + 'cost': float, # 실 On-Demand 비용 (USD) + 'usage_hours': float, # 실 사용 시간 (h) + 'instance_type': str, + 'region': str, + 'iam_user': str, # CUR aws:createdBy → 사용자명 + } + } + """ + year, month = _partition(d1_date) + sql = f""" + SELECT + line_item_resource_id AS instance_id, + product_instance_type AS instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global') AS region, + SPLIT_PART(COALESCE(resource_tags_aws_created_by, ''), ':', 3) AS iam_user, + SUM(line_item_usage_amount) AS usage_hours, + SUM(line_item_unblended_cost) AS cost + FROM {_ATHENA_DATABASE}.cur_logs + WHERE year = '{year}' + AND month = '{month}' + AND DATE(line_item_usage_start_date) = DATE('{d1_date}') + AND line_item_resource_id LIKE 'i-%' + AND product_instance_type != '' + AND line_item_usage_type LIKE '%BoxUsage%' + GROUP BY + line_item_resource_id, + product_instance_type, + COALESCE(NULLIF(product_region_code, ''), 'global'), + SPLIT_PART(COALESCE(resource_tags_aws_created_by, ''), ':', 3) + HAVING SUM(line_item_unblended_cost) > 0 + """ + rows = _run_query(athena, sql) + result = {} + for r in rows: + iid = r.get('instance_id', '') + if not iid: + continue + result[iid] = { + 'cost': float(r.get('cost', 0) or 0), + 'usage_hours': float(r.get('usage_hours', 0) or 0), + 'instance_type': r.get('instance_type', ''), + 'region': r.get('region') or 'global', + 'iam_user': r.get('iam_user', ''), + } + return result + + +def collect_spot_prices(regions: list, instance_types: list) -> dict: + """ + D-1 기준 인스턴스 타입별 Spot 시간당 단가 (리전 내 AZ 평균). + + EC2 describe_spot_price_history API 사용 (무료). + 리전 내 여러 AZ의 가격을 평균 내어 리전 대표 단가로 사용한다. + + Args: + regions: 조회할 리전 리스트 (On-Demand 인스턴스가 있는 리전) + instance_types: 조회할 인스턴스 타입 리스트 + + Returns: + {instance_type: {region: float}} ← 시간당 USD 평균 + """ + if not instance_types or not regions: + return {} + + now = datetime.now(timezone.utc) + d1_end = now.replace(hour=0, minute=0, second=0, microsecond=0) + d1_start = d1_end - timedelta(days=1) + + result: dict = {} + for region in regions: + if region == 'global': + continue + try: + ec2 = boto3.client('ec2', region_name=region) + resp = ec2.describe_spot_price_history( + StartTime=d1_start, + EndTime=d1_end, + InstanceTypes=instance_types, + ProductDescriptions=['Linux/UNIX'], + ) + by_type: dict = {} + for item in resp.get('SpotPriceHistory', []): + itype = item['InstanceType'] + price = float(item['SpotPrice']) + by_type.setdefault(itype, []).append(price) + + for itype, prices in by_type.items(): + result.setdefault(itype, {}) + result[itype][region] = sum(prices) / len(prices) + + except ClientError: + continue + + return result + + +def collect_resource_creators_cur(athena, d1_date: date) -> dict: + """ + CUR(Athena) 에서 EC2 인스턴스 / EBS 볼륨 / Snapshot 의 생성자(IAM User)를 일괄 조회. + + 배경: + aws:createdBy 는 EC2/EBS API 응답의 Tags 에 노출되지 않는 system tag. + Athena CUR 의 resource_tags_aws_created_by 컬럼에서만 접근 가능하다. + data.py 의 tag 기반 접근으로는 항상 '' 만 반환되므로 본 함수로 보강한다. + + 조회 윈도우: + 현재 월 + 직전 2개월 (총 3개월). stopped 인스턴스는 stop 이후 line_item 이 + 생성되지 않으므로 충분히 거슬러 올라가야 마지막 활성기의 row 에서 태그 회수가 + 가능하다. (CloudTrail lookup_events 의 ~90일 한계와 유사한 범위) + + 선택 규칙 (동일 resource_id 에 여러 row 가 있을 경우): + 1순위 — resource_tags_aws_created_by 가 실제로 채워져 있는 row + 2순위 — 그 중 가장 최근 row + + Returns: + { + 'i-0aaa...': 'jglee', + 'vol-0bb..': '[Lambda] my-fn', + 'snap-0cc.': '[EKS] cluster-x/ng-1', + ... + } + CUR 에서 찾지 못한 resource_id 는 dict 에 포함되지 않음 + (호출 측에서 빈 문자열 fallback 처리). + """ + months = [] + y, m = d1_date.year, d1_date.month + for _ in range(3): + months.append((str(y), str(m))) + m -= 1 + if m <= 0: + m = 12 + y -= 1 + + where_partitions = " OR ".join( + f"(year = '{yy}' AND month = '{mm}')" for yy, mm in months + ) + creator_case_sql = _build_creator_case_sql(athena) + + sql = f""" + WITH base AS ( + SELECT + line_item_resource_id AS resource_id, + {creator_case_sql} AS creator, + CASE WHEN NULLIF(resource_tags_aws_created_by, '') IS NOT NULL + THEN 0 ELSE 1 END AS priority, + line_item_usage_start_date AS ts + FROM {_ATHENA_DATABASE}.cur_logs + WHERE ({where_partitions}) + AND line_item_resource_id IS NOT NULL + AND line_item_resource_id != '' + AND ( + line_item_resource_id LIKE 'i-%' + OR line_item_resource_id LIKE 'vol-%' + OR line_item_resource_id LIKE 'snap-%' + ) + ), + ranked AS ( + SELECT + resource_id, + creator, + ROW_NUMBER() OVER ( + PARTITION BY resource_id + ORDER BY priority ASC, ts DESC + ) AS rn + FROM base + ) + SELECT resource_id, creator + FROM ranked + WHERE rn = 1 + """ + + rows = _run_query(athena, sql) + return { + r['resource_id']: r.get('creator', '') + for r in rows + if r.get('resource_id') + } + + +def _inject_creators( + instances: dict, unused_ebs: list, unused_snapshots: list, creators: dict, +) -> None: + """수집된 자원들의 iam_user 필드를 CUR creators 맵으로 in-place 덮어쓴다.""" + for region_instances in instances.values(): + for inst in region_instances: + c = creators.get(inst.get('instance_id')) + if c: + inst['iam_user'] = c + for vol in unused_ebs: + c = creators.get(vol.get('volume_id')) + if c: + vol['iam_user'] = c + for snap in unused_snapshots: + c = creators.get(snap.get('snapshot_id')) + if c: + snap['iam_user'] = c + + def collect_all(regions: list, account_id: str, d1_date: date) -> dict: """ Main 2 + 스레드에 필요한 EC2 데이터를 수집한다. @@ -160,18 +376,42 @@ def collect_all(regions: list, account_id: str, d1_date: date) -> dict: 'spot_d1': float, # Spot 당일 비용 'spot_d2': float, # Spot 전날 비용 'spot_mtd': float, # Spot 당월 누계 비용 + 'instance_cost': dict, # {instance_id: {'cost','usage_hours','instance_type','region','iam_user'}} + 'spot_prices': dict, # {instance_type: {region: float}} 시간당 Spot 단가 } """ - athena = boto3.client('athena', region_name='ap-northeast-2') + athena = boto3.client('athena', region_name=_ATHENA_REGION) spot_d1, spot_d2, spot_mtd = collect_spot_cost_cur(athena, d1_date) + instance_cost = collect_instance_cost_cur(athena, d1_date) + + # On-Demand 인스턴스 타입·리전 목록 추출 → Spot 가격 조회 대상 + od_types = list({v['instance_type'] for v in instance_cost.values() if v['instance_type']}) + od_regions = list({v['region'] for v in instance_cost.values() if v['region'] != 'global'}) + spot_prices = collect_spot_prices(od_regions, od_types) + + # 자원 수집 — data.py 의 iam_user 는 EC2 API Tags 기반이라 항상 '' 다. + instances = collect_instances(regions) + unused_ebs = collect_unused_ebs(regions) + unused_snapshots = collect_unused_snapshots(regions, account_id) + + # CUR 기반 creator 주입 — aws:createdBy 는 CUR 에서만 권위 있음. + # 실패해도 EC2 리포트 전체를 죽이지 않도록 try/except 보호 (빈 iam_user 폴백). + try: + creators = collect_resource_creators_cur(athena, d1_date) + _inject_creators(instances, unused_ebs, unused_snapshots, creators) + except Exception as exc: + log.warning("CUR creators lookup 실패 (iam_user 미보강): %s", exc) + return { - 'instances': collect_instances(regions), - 'unused_ebs': collect_unused_ebs(regions), - 'unused_snapshots': collect_unused_snapshots(regions, account_id), + 'instances': instances, + 'unused_ebs': unused_ebs, + 'unused_snapshots': unused_snapshots, 'type_cost': collect_ec2_cost_by_type_cur(athena, d1_date), 'type_cost_mtd': collect_ec2_cost_by_type_mtd_cur(athena, d1_date), 'spot_d1': spot_d1, 'spot_d2': spot_d2, 'spot_mtd': spot_mtd, + 'instance_cost': instance_cost, + 'spot_prices': spot_prices, } diff --git a/monitor_v2/ec2/report.py b/monitor_v2/ec2/report.py index b8815a1..89f4ea2 100644 --- a/monitor_v2/ec2/report.py +++ b/monitor_v2/ec2/report.py @@ -13,10 +13,14 @@ ACCOUNT_NAME: AWS 계정 별칭 (표시용) """ +import json +import logging import os from datetime import datetime, timedelta, timezone from pprint import pprint +import boto3 + from ..utils.blocks import ( header as _header, section as _section, divider as _divider, context as _context, md_table_blocks as _md_table_blocks, @@ -24,7 +28,7 @@ split_by_aggregate as _split_by_aggregate, calc_change, fmt_change, EC2_SERVICES, ) -from .iam_resolver import build_instance_creator_map, get_slack_user_id +from .iam_resolver import get_slack_user_id from ..slack import client as slack ACCOUNT_NAME = os.environ.get('ACCOUNT_NAME', 'hyu-ddps') @@ -32,6 +36,138 @@ SNAPSHOT_ALERT_DAYS = 60 KST = timezone(timedelta(hours=9)) +_BEDROCK_MODEL_ID = os.environ.get('BEDROCK_MODEL_ID', 'amazon.nova-micro-v1:0') +_BEDROCK_REGION = os.environ.get('BEDROCK_REGION', 'us-east-1') + +log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Spot 절감 AI 요약 (CUR 버전 전용) +# --------------------------------------------------------------------------- + +_SPOT_SYSTEM_PROMPT = """\ +당신은 AWS EC2 비용 최적화 분석 도우미입니다. +사용자가 On-Demand 인스턴스 사용 현황과 Spot 전환 시 절감 추정액을 제공하면, +한국어로 간결하게 요약합니다. + +=== 출력 형식 === + +첫 줄: "어제 On-Demand 총비용 $X.XX, Spot 전환 시 약 $Y.YY 절감 가능 (약 Z%)입니다." +빈 줄 +절감 기회 상위 항목 (절감액 큰 순, 최대 5개): +사용자명 — 인스턴스타입 실 비용 $X.XX → Spot 추정 ~$Y.YY (▼ Z%) + +=== 규칙 === +절감액이 $0.01 미만인 항목은 제외하세요. +생성자 이름은 반드시 포함하세요. +금액은 양수만 표시하세요. +인스턴스 ID(i-xxx) 포함 금지. +마크다운(** * #) 사용 금지.""" + + +def _build_spot_user_message( + instance_cost: dict, + spot_prices: dict, + d1_date, +) -> str | None: + """ + Bedrock에 전달할 Spot 절감 분석 user message 구성. + 절감 기회가 전혀 없으면 None 반환. + """ + total_od = 0.0 + total_spot = 0.0 + lines = [] + + for iid, ic in instance_cost.items(): + itype = ic.get('instance_type', '') + region = ic.get('region', '') + od_cost = ic.get('cost', 0.0) + usage_hrs = ic.get('usage_hours', 0.0) + iam_user = ic.get('iam_user', '') or '(생성자 미상)' + + if not itype or not region or usage_hrs <= 0: + continue + + sp_hr = spot_prices.get(itype, {}).get(region) + if not sp_hr: + continue + + spot_est = usage_hrs * sp_hr + savings = od_cost - spot_est + + total_od += od_cost + total_spot += spot_est + + if savings > 0.01: + pct = savings / od_cost * 100 if od_cost > 0 else 0 + lines.append({ + 'iam_user': iam_user, + 'itype': itype, + 'region': region, + 'od_cost': od_cost, + 'spot_est': spot_est, + 'savings': savings, + 'pct': pct, + }) + + if not lines: + return None + + lines.sort(key=lambda x: x['savings'], reverse=True) + + total_savings = total_od - total_spot + total_pct = (total_savings / total_od * 100) if total_od > 0 else 0 + + detail_text = '\n'.join( + f"- {l['iam_user']}: {l['itype']} ({l['region']}) | " + f"실 비용: ${l['od_cost']:.2f} | Spot 추정: ~${l['spot_est']:.2f} | " + f"절감 가능: ~${l['savings']:.2f} ({l['pct']:.0f}%)" + for l in lines[:10] + ) + + return ( + f"어제({d1_date}) EC2 On-Demand 인스턴스 사용 현황\n" + f"전체 On-Demand 비용: ${total_od:.2f}\n" + f"Spot 전환 시 예상 비용: ~${total_spot:.2f}\n" + f"절감 가능 총액: ~${total_savings:.2f} ({total_pct:.0f}%)\n\n" + f"인스턴스별 현황:\n{detail_text}\n\n" + f"위 데이터를 요약하세요." + ) + + +def _generate_spot_ai_summary( + instance_cost: dict, + spot_prices: dict, + d1_date, +) -> str: + """ + Nova Micro로 Spot 절감 기회 요약 생성. + 절감 기회 없거나 Bedrock 실패 시 빈 문자열 반환. + """ + user_message = _build_spot_user_message(instance_cost, spot_prices, d1_date) + if not user_message: + return '' + + try: + bedrock = boto3.client('bedrock-runtime', region_name=_BEDROCK_REGION) + body = json.dumps({ + 'system': [{'text': _SPOT_SYSTEM_PROMPT}], + 'messages': [{'role': 'user', 'content': [{'text': user_message}]}], + 'inferenceConfig': {'max_new_tokens': 800, 'temperature': 0}, + }) + resp = bedrock.invoke_model( + modelId=_BEDROCK_MODEL_ID, + body=body, + contentType='application/json', + accept='application/json', + ) + result = json.loads(resp['body'].read()) + return result['output']['message']['content'][0]['text'].strip() + + except Exception as e: + log.error("Spot AI 요약 Bedrock 호출 실패: %s", e) + return '' + def _region_label(region: str) -> str: return f"{region}" @@ -111,6 +247,7 @@ def _build_main2( spot_d1: float = 0.0, spot_d2: float = 0.0, spot_mtd: float = 0.0, + spot_ai_summary: str = '', ) -> list: """ 비용이 발생한 리전만 표시한다 (stopped 전용 리전 = $0, 미포함). @@ -144,10 +281,14 @@ def _build_main2( # 리전 fields (2열 그리드, 10개씩 분할) region_field_items = [f"*{r}*\n`${c:,.2f}`" for r, c in sorted_regions] - region_blocks = [ - _fields_section(region_field_items[i:i + 10]) - for i in range(0, max(len(region_field_items), 1), 10) - ] + region_blocks = ( + [ + _fields_section(region_field_items[i:i + 10]) + for i in range(0, len(region_field_items), 10) + ] + if region_field_items + else [_section("_(비용 발생 리전 없음)_")] + ) # Top 5 User sections user_blocks = [ @@ -156,7 +297,7 @@ def _build_main2( if cost > 0 ] or [_section("_(데이터 없음)_")] - return [ + blocks = [ _header(f"EC2 Instance Report | {d1_date} | {ACCOUNT_NAME}"), _section("*[ EC2 비용 ]*"), _fields_section(cost_fields), @@ -181,22 +322,71 @@ def _build_main2( _context("전체 인스턴스 상세는 스레드에서 확인하세요."), ] + if spot_ai_summary: + blocks.extend([ + _divider(), + _section("*[ Spot 전환 절감 기회 AI 요약 ]*"), + _section(spot_ai_summary), + ]) + + return blocks + # --------------------------------------------------------------------------- # Thread 1: 전체 인스턴스 상세 — 리전별 코드 블록 # --------------------------------------------------------------------------- +def _cost_comparison( + iid: str, + itype: str, + region: str, + purchase: str, + instance_cost: dict, + spot_prices: dict, +) -> str: + """ + On-Demand 인스턴스의 실 비용 vs Spot 추정 비용 비교 문자열. + + 공식: spot_estimate = CUR usage_hours × describe_spot_price_history 리전 평균 단가 + 절감액이 없거나 데이터 부족 시 실 비용만 표시. + """ + if purchase != 'On-Demand': + return '-' + ic = instance_cost.get(iid) + if not ic or ic.get('usage_hours', 0) <= 0: + return '-' + od_cost = ic['cost'] + sp_hr = spot_prices.get(itype, {}).get(region) + if not sp_hr: + return f"${od_cost:.2f}" + spot_est = ic['usage_hours'] * sp_hr + savings = od_cost - spot_est + if savings <= 0.01: + return f"${od_cost:.2f}" + pct = savings / od_cost * 100 if od_cost > 0 else 0 + return f"${od_cost:.2f}|Spot~${spot_est:.2f}(▼{pct:.0f}%)" + + def _format_region_instances_blocks( region: str, instances: list, creator_map: dict, dm_targets: list, now: datetime, + instance_cost: dict | None = None, + spot_prices: dict | None = None, ) -> list: """ 리전의 인스턴스를 region >> 구매유형 >> 상태 계층으로 Markdown 테이블 블록으로 포매팅. 업타임: hh:mm:ss (running), 실행시점 + 종료시점 KST 표시. + + Args (CUR 전용): + instance_cost: {instance_id: {'cost','usage_hours','instance_type','region','iam_user'}} + spot_prices: {instance_type: {region: float}} 시간당 Spot 단가 + 두 인자가 모두 존재할 때 "비용 비교" 컬럼이 테이블에 추가된다. """ + has_cost_cmp = bool(instance_cost) + by_purchase: dict = {} for inst in instances: by_purchase.setdefault(inst['purchase_option'], {}).setdefault(inst['state'], []).append(inst) @@ -241,11 +431,21 @@ def _format_region_instances_blocks( 'reason': f'stopped {int(stopped_hours)}시간 경과', }) - rows.append([name, iid, itype, launch_str, time_col, short]) + row = [name, iid, itype, launch_str, time_col, short] + if has_cost_cmp: + row.append(_cost_comparison( + iid, itype, region, purchase, + instance_cost or {}, spot_prices or {}, + )) + rows.append(row) + + headers = ["이름", "ID", "타입", "시작", "업타임/종료", "생성자"] + if has_cost_cmp: + headers.append("비용 비교") blocks.extend(_table_section( f"*[{purchase}] {state} ({len(inst_list)}개)*", - ["이름", "ID", "타입", "시작", "업타임/종료", "생성자"], + headers, rows, )) @@ -439,6 +639,13 @@ def send_main2_report(cost_data: dict, ec2_data: dict) -> None: spot_d1 = ec2_data.get('spot_d1', 0.0) spot_d2 = ec2_data.get('spot_d2', 0.0) spot_mtd = ec2_data.get('spot_mtd', 0.0) + instance_cost = ec2_data.get('instance_cost', {}) + spot_prices = ec2_data.get('spot_prices', {}) + + # Spot 절감 AI 요약 (CUR 버전에서만 — instance_cost 존재 시) + spot_ai_summary = '' + if instance_cost and spot_prices: + spot_ai_summary = _generate_spot_ai_summary(instance_cost, spot_prices, d1_date) # Main 2 main2_ts = slack.post_blocks( @@ -449,18 +656,18 @@ def send_main2_report(cost_data: dict, ec2_data: dict) -> None: ec2_d1, ec2_d2, ec2_mtd, ec2_user_mtd, spot_d1, spot_d2, spot_mtd, + spot_ai_summary, ), fallback_text=f"EC2 Instance Report {d1_date} / {ACCOUNT_NAME}", ) - # creator_map 조회 (CloudTrail 기반) - all_instance_ids = [ - inst['instance_id'] + # creator_map: EC2 태그(aws:createdBy) 기반 + creator_map = { + inst['instance_id']: inst['iam_user'] for instances in ec2_data['instances'].values() for inst in instances - ] - regions = list(ec2_data['instances'].keys()) - creator_map = build_instance_creator_map(all_instance_ids, regions) + if inst.get('iam_user') + } # Thread 1: 헤더 메시지 total_instances = sum(len(v) for v in ec2_data['instances'].values()) @@ -484,7 +691,10 @@ def send_main2_report(cost_data: dict, ec2_data: dict) -> None: for region, instances in sorted(ec2_data['instances'].items()): region_blocks = [_header(_region_label(region))] - region_blocks.extend(_format_region_instances_blocks(region, instances, creator_map, dm_targets, now)) + region_blocks.extend(_format_region_instances_blocks( + region, instances, creator_map, dm_targets, now, + instance_cost, spot_prices, + )) for batch in _split_by_aggregate(region_blocks): slack.post_blocks( diff --git a/monitor_v2/eventbridge/__init__.py b/monitor_v2/eventbridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/monitor_v2/eventbridge/get_eventbridge_list.py b/monitor_v2/eventbridge/get_eventbridge_list.py new file mode 100644 index 0000000..de5805b --- /dev/null +++ b/monitor_v2/eventbridge/get_eventbridge_list.py @@ -0,0 +1,98 @@ +import boto3 +from botocore.exceptions import ClientError, NoCredentialsError + + +def get_all_regions(): + """모든 AWS 리전 목록 가져오기""" + ec2 = boto3.client('ec2', region_name='us-east-1') + response = ec2.describe_regions(AllRegions=False) + return [r['RegionName'] for r in response['Regions']] + + +def get_eventbridge_rules(region): + """특정 리전의 EventBridge 규칙 가져오기""" + client = boto3.client('events', region_name=region) + rules = [] + paginator = client.get_paginator('list_rules') + for page in paginator.paginate(): + rules.extend(page['Rules']) + return rules + + +def get_eventbridge_schedules(region): + """특정 리전의 EventBridge Scheduler 가져오기""" + try: + client = boto3.client('scheduler', region_name=region) + schedules = [] + paginator = client.get_paginator('list_schedules') + for page in paginator.paginate(): + schedules.extend(page['Schedules']) + return schedules + except ClientError: + return [] + + +def get_current_account(): + """현재 활성화된 AWS 계정 정보""" + sts = boto3.client('sts') + identity = sts.get_caller_identity() + return identity['Account'], identity['Arn'] + + +def main(): + try: + account_id, arn = get_current_account() + print(f"{'=' * 60}") + print(f"현재 AWS 계정: {account_id}") + print(f"실행 주체: {arn}") + print(f"{'=' * 60}\n") + except NoCredentialsError: + print("AWS 자격증명이 설정되지 않았습니다. aws configure를 실행해주세요.") + return + + print("전체 리전 스캔 중...\n") + regions = get_all_regions() + + found_any = False + + for region in sorted(regions): + rules = get_eventbridge_rules(region) + schedules = get_eventbridge_schedules(region) + + if rules or schedules: + found_any = True + print(f"\n{'=' * 60}") + print(f"리전: {region}") + print(f"{'=' * 60}") + + if rules: + print(f"\n[EventBridge Rules] {len(rules)}개") + print(f"{'이름':<45} {'상태':<10} {'스케줄'}") + print("-" * 80) + for rule in rules: + name = rule.get('Name', 'N/A') + state = rule.get('State', 'N/A') + schedule = rule.get('ScheduleExpression', '-') + print(f"{name:<45} {state:<10} {schedule}") + + if schedules: + print(f"\n[EventBridge Scheduler] {len(schedules)}개") + print(f"{'이름':<45} {'상태':<10} {'그룹'}") + print("-" * 80) + for schedule in schedules: + name = schedule.get('Name', 'N/A') + state = schedule.get('State', 'N/A') + group = schedule.get('GroupName', 'default') + print(f"{name:<45} {state:<10} {group}") + else: + print(f"리전: {region} - 없음") + + if not found_any: + print("\nEventBridge 규칙/스케줄이 설정된 리전이 없습니다.") + else: + print(f"\n{'=' * 60}") + print("스캔 완료") + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/monitor_v2/infra/main.tf b/monitor_v2/infra/main.tf index 6be0bb2..56e5862 100644 --- a/monitor_v2/infra/main.tf +++ b/monitor_v2/infra/main.tf @@ -1,9 +1,9 @@ terraform { - required_version = ">= 1.5" + required_version = ">= 1.6" required_providers { aws = { source = "hashicorp/aws" - version = "~> 5.0" + version = ">= 5.0, < 5.100.0" } archive = { source = "hashicorp/archive" @@ -95,6 +95,7 @@ resource "aws_lambda_function" "monitor_v2" { ATHENA_OUTPUT_LOCATION = var.athena_output_location ATHENA_DATABASE = var.athena_database ATHENA_WORKGROUP = var.athena_workgroup + ATHENA_REGION = var.athena_region BEDROCK_MODEL_ID = var.bedrock_model_id BEDROCK_REGION = var.bedrock_region } @@ -114,82 +115,14 @@ resource "aws_lambda_function" "monitor_v2" { # KST 08:00 = UTC 23:00 전날 → cost 전날 데이터 # KST 08:10 = UTC 23:10 전날 → ec2 전날 데이터 # KST 08:15 = UTC 23:15 전날 → analysis AI 비용 변화 분석 (Main 3) -# KST 22:00 = UTC 13:00 → cost 당일 데이터 -# KST 22:10 = UTC 13:10 → ec2 당일 데이터 - -# ── KST 08:00 cost (전날) ───────────────────────────────────────────── -resource "aws_cloudwatch_event_rule" "morning_cost" { - name = "${local.function_name}-morning-cost" - description = "KST 08:00 cost report (yesterday)" - schedule_expression = "cron(0 23 * * ? *)" - state = "ENABLED" -} - -resource "aws_cloudwatch_event_target" "morning_cost" { - rule = aws_cloudwatch_event_rule.morning_cost.name - target_id = "morning-cost" - arn = aws_lambda_function.monitor_v2.arn - input = jsonencode({ report_type = "cost", date_mode = "yesterday" }) -} - -resource "aws_lambda_permission" "morning_cost" { - statement_id = "AllowEventBridgeMorningCost" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.monitor_v2.function_name - principal = "events.amazonaws.com" - source_arn = aws_cloudwatch_event_rule.morning_cost.arn -} - -# ── KST 08:10 ec2 (전날) ────────────────────────────────────────────── -resource "aws_cloudwatch_event_rule" "morning_ec2" { - name = "${local.function_name}-morning-ec2" - description = "KST 08:10 ec2 report (yesterday)" - schedule_expression = "cron(10 23 * * ? *)" - state = "ENABLED" -} - -resource "aws_cloudwatch_event_target" "morning_ec2" { - rule = aws_cloudwatch_event_rule.morning_ec2.name - target_id = "morning-ec2" - arn = aws_lambda_function.monitor_v2.arn - input = jsonencode({ report_type = "ec2", date_mode = "yesterday" }) -} +# KST 22:00 = UTC 13:00 → cost 전날 데이터 +# KST 22:10 = UTC 13:10 → ec2 전날 데이터 +# KST 22:15 = UTC 13:15 → analysis 전날 AI 비용 변화 분석 -resource "aws_lambda_permission" "morning_ec2" { - statement_id = "AllowEventBridgeMorningEc2" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.monitor_v2.function_name - principal = "events.amazonaws.com" - source_arn = aws_cloudwatch_event_rule.morning_ec2.arn -} - -# ── KST 08:15 analysis (Main 3: AI 비용 변화 분석) ────────────────────── -resource "aws_cloudwatch_event_rule" "morning_analysis" { - name = "${local.function_name}-morning-analysis" - description = "KST 08:15 AI cost analysis report (yesterday)" - schedule_expression = "cron(15 23 * * ? *)" - state = "ENABLED" -} - -resource "aws_cloudwatch_event_target" "morning_analysis" { - rule = aws_cloudwatch_event_rule.morning_analysis.name - target_id = "morning-analysis" - arn = aws_lambda_function.monitor_v2.arn - input = jsonencode({ report_type = "analysis", date_mode = "today" }) -} - -resource "aws_lambda_permission" "morning_analysis" { - statement_id = "AllowEventBridgeMorningAnalysis" - action = "lambda:InvokeFunction" - function_name = aws_lambda_function.monitor_v2.function_name - principal = "events.amazonaws.com" - source_arn = aws_cloudwatch_event_rule.morning_analysis.arn -} - -# ── KST 22:00 cost (당일) ───────────────────────────────────────────── +# ── KST 22:00 cost (전날) ───────────────────────────────────────────── resource "aws_cloudwatch_event_rule" "evening_cost" { name = "${local.function_name}-evening-cost" - description = "KST 22:00 cost report (today)" + description = "KST 22:00 cost report (yesterday)" schedule_expression = "cron(0 13 * * ? *)" state = "ENABLED" } @@ -198,7 +131,7 @@ resource "aws_cloudwatch_event_target" "evening_cost" { rule = aws_cloudwatch_event_rule.evening_cost.name target_id = "evening-cost" arn = aws_lambda_function.monitor_v2.arn - input = jsonencode({ report_type = "cost", date_mode = "today" }) + input = jsonencode({ report_type = "cost", date_mode = "yesterday" }) } resource "aws_lambda_permission" "evening_cost" { @@ -209,10 +142,10 @@ resource "aws_lambda_permission" "evening_cost" { source_arn = aws_cloudwatch_event_rule.evening_cost.arn } -# ── KST 22:10 ec2 (당일) ────────────────────────────────────────────── +# ── KST 22:10 ec2 (전날) ────────────────────────────────────────────── resource "aws_cloudwatch_event_rule" "evening_ec2" { name = "${local.function_name}-evening-ec2" - description = "KST 22:10 ec2 report (today)" + description = "KST 22:10 ec2 report (yesterday)" schedule_expression = "cron(10 13 * * ? *)" state = "ENABLED" } @@ -221,7 +154,7 @@ resource "aws_cloudwatch_event_target" "evening_ec2" { rule = aws_cloudwatch_event_rule.evening_ec2.name target_id = "evening-ec2" arn = aws_lambda_function.monitor_v2.arn - input = jsonencode({ report_type = "ec2", date_mode = "today" }) + input = jsonencode({ report_type = "ec2", date_mode = "yesterday" }) } resource "aws_lambda_permission" "evening_ec2" { @@ -232,6 +165,29 @@ resource "aws_lambda_permission" "evening_ec2" { source_arn = aws_cloudwatch_event_rule.evening_ec2.arn } +# ── KST 22:15 analysis (AI 비용 변화 분석, 전날) ────────────────────── +resource "aws_cloudwatch_event_rule" "evening_analysis" { + name = "${local.function_name}-evening-analysis" + description = "KST 22:15 AI cost analysis report (yesterday)" + schedule_expression = "cron(15 13 * * ? *)" + state = "ENABLED" +} + +resource "aws_cloudwatch_event_target" "evening_analysis" { + rule = aws_cloudwatch_event_rule.evening_analysis.name + target_id = "evening-analysis" + arn = aws_lambda_function.monitor_v2.arn + input = jsonencode({ report_type = "analysis", date_mode = "yesterday" }) +} + +resource "aws_lambda_permission" "evening_analysis" { + statement_id = "AllowEventBridgeEveningAnalysis" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.monitor_v2.function_name + principal = "events.amazonaws.com" + source_arn = aws_cloudwatch_event_rule.evening_analysis.arn +} + # ── CloudWatch Logs ─────────────────────────────────────────────────── resource "aws_cloudwatch_log_group" "lambda_logs" { name = "/aws/lambda/${local.function_name}" diff --git a/monitor_v2/infra/outputs.tf b/monitor_v2/infra/outputs.tf index c073f7d..04066fd 100644 --- a/monitor_v2/infra/outputs.tf +++ b/monitor_v2/infra/outputs.tf @@ -19,12 +19,11 @@ output "lambda_role_arn" { } output "eventbridge_rule_names" { - description = "EventBridge 규칙 이름 목록 (4개 스케줄)" + description = "EventBridge 규칙 이름 목록 (3개 스케줄, KST 17:xx)" value = { - morning_cost = aws_cloudwatch_event_rule.morning_cost.name - morning_ec2 = aws_cloudwatch_event_rule.morning_ec2.name - evening_cost = aws_cloudwatch_event_rule.evening_cost.name - evening_ec2 = aws_cloudwatch_event_rule.evening_ec2.name + evening_cost = aws_cloudwatch_event_rule.evening_cost.name + evening_ec2 = aws_cloudwatch_event_rule.evening_ec2.name + evening_analysis = aws_cloudwatch_event_rule.evening_analysis.name } } diff --git a/monitor_v2/infra/variables.tf b/monitor_v2/infra/variables.tf index 4170893..be96510 100644 --- a/monitor_v2/infra/variables.tf +++ b/monitor_v2/infra/variables.tf @@ -40,6 +40,12 @@ variable "athena_workgroup" { default = "primary" } +variable "athena_region" { + description = "Athena 클라이언트 리전 (e.g., ap-northeast-2, us-east-1)" + type = string + default = "ap-northeast-2" +} + # ── Bedrock (AI 분석) ────────────────────────────────────────────────── variable "bedrock_model_id" { diff --git a/monitor_v2/lambda_handler.py b/monitor_v2/lambda_handler.py index 7dac126..e596e99 100644 --- a/monitor_v2/lambda_handler.py +++ b/monitor_v2/lambda_handler.py @@ -1,7 +1,9 @@ +import os import boto3 from datetime import datetime, timedelta, timezone from .cost.data_cur import collect_all as collect_cost_data +from .cost.data import collect_all as collect_cost_data_ce from .ec2.data_cur import collect_all as collect_ec2_data from .cost.report_cur import send_cur_report from .ec2.report_cur import send_ec2_cur_report @@ -10,6 +12,9 @@ KST = timezone(timedelta(hours=9)) +# spotlake 계정은 CUR 미적재 → Cost Explorer(data.py) 경로로 임시 우회 +ACCOUNT_NAME = os.environ.get('ACCOUNT_NAME', '') + def lambda_handler(event, context): """ @@ -32,26 +37,47 @@ def lambda_handler(event, context): 'today' → today_kst 그대로 → d1_date = today - 1 (KST 22:00, CUR 당일 반영 후) 'yesterday' → today_kst - 1 → d1_date = today - 2 (KST 08:00, CUR 전날까지만 반영) + 에러 처리: + 각 단계(init / cost_collect / cost_report / ec2_collect / ec2_report / ai_analysis)를 + 개별 try/except로 감싼다. 단계 실패 시 slack.post_error로 알림을 보내고 + had_error 플래그를 세운 뒤 가능한 후속 단계는 계속 진행한다 (부분 실패 허용). + cost_collect는 EC2 단계의 선행 의존이라 실패 시 EC2 단계를 건너뛴다. + Returns: - 200 (성공) / 500 (실패) + 200 (전 단계 성공) / 500 (한 단계라도 실패) """ event = event or {} report_type = event.get('report_type', 'all') date_mode = event.get('date_mode', 'today') + base_meta = {'report_type': report_type} + had_error = False + + # ── 날짜 산정 ───────────────────────────────────────────────── try: - today_kst = datetime.now(KST).date() + today_actual = datetime.now(KST).date() # 디크리먼트 전 실제 오늘 (spotlake CE 경로용) + today_kst = today_actual if date_mode == 'yesterday': today_kst = today_kst - timedelta(days=1) + except Exception as e: + slack.post_error(context='init/date', error=e, meta=base_meta) + return 500 - # ── Main 3: 비용 변화 AI 분석 (08:15 KST, 독립 실행) ──────────── - if report_type == 'analysis': - # d1_date = today_kst - 1 (data_cur.py 와 동일한 CE 지연 보정) - from datetime import timedelta as td - d1_date = today_kst - td(days=1) - send_main3_report(d1_date) + # ── Main 3: 비용 변화 AI 분석 (08:15 KST, 독립 실행) ──────────── + if report_type == 'analysis': + try: + send_main3_report(today_kst) return 200 - + except Exception as e: + slack.post_error( + context='ai_analysis', + error=e, + meta={**base_meta, 'date': str(today_kst)}, + ) + return 500 + + # ── 공통 초기화: account / regions ────────────────────────── + try: sts = boto3.client('sts') account_id = sts.get_caller_identity()['Account'] @@ -65,22 +91,50 @@ def lambda_handler(event, context): }] )['Regions'] ] + except Exception as e: + slack.post_error(context='init/aws_clients', error=e, meta=base_meta) + return 500 - # ── 데이터 수집 (CUR / Athena 기반, forecast만 CE 사용) ────────── - cost_data = collect_cost_data(today_kst) - - # ── Slack 발송 ─────────────────────────────────────────────────── - if report_type in ('cost', 'all'): - send_cur_report(cost_data) - - if report_type in ('ec2', 'all'): - ec2_data = collect_ec2_data(ec2_regions, account_id, cost_data['d1_date']) - send_ec2_cur_report(cost_data, ec2_data) - - return 200 + base_meta['account_id'] = account_id + # ── Cost 데이터 수집 ────────────────────────────────────────── + # spotlake 계정: CUR 미적재 → Cost Explorer(data.py)로 우회. + # CE는 24~48h 지연 → collect_all 내부에서 항상 d1 = (인자) - 2. + # today_actual을 넘겨 date_mode와 무관하게 항상 D-2 리포트. + # 그 외 계정: 기존 CUR/Athena 경로 그대로 (forecast만 CE 사용). + try: + if ACCOUNT_NAME == 'spotlake': + cost_data = collect_cost_data_ce(today_actual) + else: + cost_data = collect_cost_data(today_kst) except Exception as e: - import traceback - slack.post_error(context="lambda_handler", error=e) - print(traceback.format_exc()) + slack.post_error(context='cost_collect', error=e, meta=base_meta) return 500 + + base_meta['date'] = str(cost_data.get('d1_date', '')) + + # ── Cost 리포트 발송 (Main 1) ─────────────────────────────── + if report_type in ('cost', 'all'): + try: + send_cur_report(cost_data) + except Exception as e: + slack.post_error(context='cost_report', error=e, meta=base_meta) + had_error = True + + # ── EC2 수집 + 리포트 발송 (Main 2) ────────────────────────── + if report_type in ('ec2', 'all'): + ec2_data = None + try: + ec2_data = collect_ec2_data(ec2_regions, account_id, cost_data['d1_date']) + except Exception as e: + slack.post_error(context='ec2_collect', error=e, meta=base_meta) + had_error = True + + if ec2_data is not None: + try: + send_ec2_cur_report(cost_data, ec2_data) + except Exception as e: + slack.post_error(context='ec2_report', error=e, meta=base_meta) + had_error = True + + return 500 if had_error else 200 diff --git a/monitor_v2/slack/client.py b/monitor_v2/slack/client.py index ce0d860..a457dae 100644 --- a/monitor_v2/slack/client.py +++ b/monitor_v2/slack/client.py @@ -21,12 +21,23 @@ """ import os +import traceback as _traceback from slack_sdk import WebClient from slack_sdk.errors import SlackApiError -BOT_TOKEN = os.environ['SLACK_BOT_TOKEN'] -CHANNEL_ID = os.environ['SLACK_CHANNEL_ID'] +from ..utils.blocks import ( + split_by_aggregate as _split_by_aggregate, + header as _header, + section as _section, + fields_section as _fields_section, +) + +BOT_TOKEN = os.environ['SLACK_BOT_TOKEN'] +CHANNEL_ID = os.environ['SLACK_CHANNEL_ID'] +# AWS 계정 별칭 (표시용). Terraform 의 var.account_name 에서 주입. +# 미설정 시에도 에러 알림 자체는 보내야 하므로 'unknown-account' 로 폴백. +ACCOUNT_NAME = os.environ.get('ACCOUNT_NAME', 'unknown-account') _client = WebClient(token=BOT_TOKEN) @@ -54,21 +65,30 @@ def post_blocks(blocks: list, fallback_text: str = '', thread_ts: str = None) -> """ Block Kit 블록 배열을 채널에 전송한다. + Slack은 한 메시지 내 markdown 블록 합산 10,000자를 초과하면 invalid_blocks 에러를 반환한다. + 이를 방지하기 위해 split_by_aggregate()로 블록을 안전한 단위로 분할해 순차 발송한다. + 분할이 일어나지 않는 경우 동작은 단일 발송과 동일하다. + Args: blocks: slack_sdk Block 객체 또는 dict 리스트 fallback_text: 알림 미리보기에 표시될 텍스트 (blocks 미지원 환경 대비) thread_ts: 스레드로 달 경우 부모 메시지의 ts. None이면 새 메인 메시지. Returns: - 전송된 메시지의 ts 문자열 + 첫 번째로 전송된 메시지의 ts 문자열 (스레드 부모로 재사용 가능) """ serialized = [b.to_dict() if hasattr(b, 'to_dict') else b for b in blocks] - kwargs = {'channel': CHANNEL_ID, 'blocks': serialized, 'text': fallback_text} - if thread_ts: - kwargs['thread_ts'] = thread_ts + batches = _split_by_aggregate(serialized) - response = _client.chat_postMessage(**kwargs) - return response['ts'] + first_ts = None + for batch in batches: + kwargs = {'channel': CHANNEL_ID, 'blocks': batch, 'text': fallback_text} + if thread_ts: + kwargs['thread_ts'] = thread_ts + response = _client.chat_postMessage(**kwargs) + if first_ts is None: + first_ts = response['ts'] + return first_ts def send_dm(slack_user_id: str, text: str) -> None: @@ -90,13 +110,42 @@ def send_dm(slack_user_id: str, text: str) -> None: print(f"[DM 발송 실패] user={slack_user_id}, error={e.response['error']}") -def post_error(context: str, error: Exception) -> None: +def post_error(context: str, error: Exception, meta: dict = None) -> None: """ - 에러 발생 시 채널에 알림을 전송한다. - 전송 자체가 실패해도 예외를 삼켜 Lambda 종료를 막지 않는다. + 에러 발생 시 채널에 Block Kit 알림을 전송한다. + + Args: + context: 에러 발생 단계 식별자 (예: 'cost_collect', 'ec2_report', 'ai_analysis') + error: 잡힌 예외 객체 + meta: 추가 메타데이터 (report_type / date_mode / account_id / d1_date 등) + + 동작: + - 헤더 + 메타 필드(2열) + 에러 메시지 + traceback 마지막 10줄 + - Block Kit 발송 실패 시 plain text 한 줄로 fallback + - 그래도 실패하면 조용히 삼켜 Lambda 종료를 막지 않는다. """ - msg = f"[monitor_v2] 오류 발생\n컨텍스트: {context}\n오류: {str(error)}" + error_type = type(error).__name__ + error_msg = (str(error) or '(메시지 없음)')[:500] + + tb_text = ''.join(_traceback.format_exception(type(error), error, error.__traceback__)) + tb_tail = '\n'.join(tb_text.splitlines()[-10:])[:2500] + + fields = [f"*단계*\n`{context}`", f"*에러 타입*\n`{error_type}`"] + for k, v in (meta or {}).items(): + fields.append(f"*{k}*\n`{v}`") + + blocks = [ + _header(f"🚨 Daily Report 오류 | {ACCOUNT_NAME}"), + _fields_section(fields), + _section(f"*에러 메시지*\n```{error_msg}```"), + _section(f"*Traceback (last 10 lines)*\n```{tb_tail}```"), + ] + + fallback = f"[{ACCOUNT_NAME}] Daily Report {context} 오류: {error_type}: {error_msg[:200]}" try: - post_message(msg) + post_blocks(blocks, fallback_text=fallback) except Exception: - pass + try: + post_message(fallback) + except Exception: + pass diff --git a/monitor_v2/test_ec2_cur_to_slack.py b/monitor_v2/test_ec2_cur_to_slack.py index 7777274..1e74247 100644 --- a/monitor_v2/test_ec2_cur_to_slack.py +++ b/monitor_v2/test_ec2_cur_to_slack.py @@ -37,7 +37,7 @@ KST = timezone(timedelta(hours=9)) if __name__ == "__main__": - today_kst = datetime.now(KST).date() + today_kst = datetime.now(KST).date() - timedelta(days=1) sts = boto3.client('sts') account_id = sts.get_caller_identity()['Account'] diff --git a/monitor_v2/test_error_alert.py b/monitor_v2/test_error_alert.py new file mode 100644 index 0000000..00ff0e1 --- /dev/null +++ b/monitor_v2/test_error_alert.py @@ -0,0 +1,110 @@ +""" +monitor_v2/test_error_alert.py + +slack.post_error 의 Block Kit 메시지 포맷을 Slack 채널에서 육안으로 확인하기 위한 +로컬 테스트 스크립트. + +실제 lambda_handler 흐름은 거치지 않고, 의도적으로 예외를 발생시킨 뒤 +post_error 를 직접 호출한다. AWS / Athena / Bedrock 호출 없이 Slack 발송만 일어난다. + +⚠️ 실행하면 .env 의 SLACK_CHANNEL_ID 채널로 진짜 메시지가 발송된다. + 운영 채널이면 테스트 채널로 임시 변경 후 실행할 것. + +실행: + uv run python -m monitor_v2.test_error_alert + uv run python -m monitor_v2.test_error_alert --stage cost_collect + uv run python -m monitor_v2.test_error_alert --stage all +""" + +import argparse + +from print_test.utils.environment import setup_environment +setup_environment() + +from monitor_v2.slack import client as slack + + +# --------------------------------------------------------------------------- +# 호출 스택을 깊게 만들어 traceback 마지막 10줄 노출 기능이 잘 보이도록 한다. +# 각 단계별로 서로 다른 예외 타입/메시지를 발생시켜 실제 운영 상황을 모사한다. +# --------------------------------------------------------------------------- + +def _athena_query(sql: str): + raise RuntimeError(f"Athena 쿼리 실패 — table 'hyu_ddps_logs.cur_logs' partition not found ({sql[:30]}...)") + + +def _bedrock_invoke(model_id: str): + raise RuntimeError(f"Bedrock {model_id} ThrottlingException: Rate exceeded") + + +def _slack_post(blocks): + raise ValueError(f"invalid_blocks: text length 12345 exceeds 10000 limit (blocks={len(blocks)})") + + +def _ec2_describe(): + d = {'instances': []} + return d['Instances'] # KeyError 유도 + + +def _scenario_cost_collect(): + _athena_query("SELECT ... FROM cur_logs WHERE year=2026 AND month=5") + + +def _scenario_cost_report(): + _slack_post([{'type': 'header'}, {'type': 'section'}] * 30) + + +def _scenario_ec2_collect(): + _ec2_describe() + + +def _scenario_ec2_report(): + _slack_poxst([{'type': 'markdown'}] * 50) + + +def _scenario_ai_analysis(): + _bedrock_invoke('amazon.nova-micro-v1:0') + + +SCENARIOS = { + 'cost_collect': (_scenario_cost_collect, {'report_type': 'all', + 'account_id': '320674564649'}), + 'cost_report': (_scenario_cost_report, {'report_type': 'all', + 'account_id': '320674564649', 'date': '2026-05-17'}), + 'ec2_collect': (_scenario_ec2_collect, {'report_type': 'all', + 'account_id': '320674564649', 'date': '2026-05-17'}), + 'ec2_report': (_scenario_ec2_report, {'report_type': 'ec2', + 'account_id': '320674564649', 'date': '2026-05-17'}), + 'ai_analysis': (_scenario_ai_analysis, {'report_type': 'analysis', + 'account_id': '786382940258', 'date': '2026-05-16'}), +} + + +def trigger(stage: str) -> None: + """주어진 단계의 시나리오 함수를 호출해 예외를 일으키고 post_error 로 알림 전송.""" + scenario_fn, meta = SCENARIOS[stage] + try: + scenario_fn() + except Exception as e: + slack.post_error(context=stage, error=e, meta=meta) + print(f"[test_error_alert] stage={stage} → Slack 알림 발송 완료 ({type(e).__name__})") + + +def main(): + parser = argparse.ArgumentParser(description='post_error Block Kit 포맷 테스트') + parser.add_argument( + '--stage', default='cost_collect', + choices=list(SCENARIOS.keys()) + ['all'], + help="발송할 시나리오. 'all' 지정 시 모든 단계를 순차 발송.", + ) + args = parser.parse_args() + + stages = list(SCENARIOS.keys()) if args.stage == 'all' else [args.stage] + for s in stages: + trigger(s) + + print(f"[test_error_alert] 완료 — 발송 단계: {stages}") + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index 59df430..c8290e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,8 @@ dependencies = [ "requests>=2.28.0", # HTTP library "adal>=1.2.7", # Azure AD authentication "slack_sdk>=3.19.0", # Slack Bot Token API (monitor_v2) + "pandas>=2.3.3", + "pyarrow>=21.0.0", ] [tool.setuptools.packages.find]