"""
[신규 2단계 - MinIO 데이터 레이크 백엔드 동적 리싱크 및 KST 장기 후처리 엔진]
설명: 분석하고자 하는 과거 7일/30일치 기간을 주면, 타노스를 치지 않고 사내 MinIO AIStor에 누적되어 있는
고해상도 원천 Parquet 파일들을 고속 다운로드(Sync-Down)하여 로컬에서 일괄 병합 재처리합니다.
실행: python step2_pipeline.py --days 30
"""
import os
import argparse
import re
import boto3
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, timezone
from pathlib import Path
from config import RAW_DIR, MERGED_DIR, MINIO_RAW_BUCKET, NODE_PREFIX_PATTERN, get_workload_type
KST = timezone(timedelta(hours=9))
def parse_arguments():
parser = argparse.ArgumentParser(description="FinOps Data Lakehouse Re-processing Engine")
parser.add_argument("--days", type=int, default=7, help="과거 며칠간의 원천 데이터를 레이크에서 내려받아 재처리할지 정의 (기본 7일)")
parser.add_argument("--start-date", type=str, default=None, help="재처리 시작 절대 날짜 (KST YYYY-MM-DD)")
parser.add_argument("--end-date", type=str, default=None, help="재처리 종료 절대 날짜 (KST YYYY-MM-DD)")
return parser.parse_args()
def get_minio_client():
minio_endpoint = os.getenv("MINIO_ENDPOINT")
minio_access_key = os.getenv("MINIO_ACCESS_KEY")
minio_secret_key = os.getenv("MINIO_SECRET_KEY")
if not all([minio_endpoint, minio_access_key, minio_secret_key]):
raise ValueError("❌ 오류: MinIO 접속 자격증명 환경변수가 누락되었습니다.")
return boto3.client(
"s3",
endpoint_url=minio_endpoint,
aws_access_key_id=minio_access_key,
aws_secret_access_key=minio_secret_key,
config=boto3.session.Config(signature_version="s3v4")
)
def sync_down_raw_from_minio(s3_client, start_dt, end_dt):
"""🌟 [사용자 제안 핵심 반영] 지정한 장기 분석 기간에 필터링되는 파일만 MinIO에서 고속 다운로드"""
print(f"🪣 [MinIO AIStor 레이크 리싱크] 분석 기간 내 원천 Parquet 파일 스캔 및 다운로드 개시...")
chunk_delta = timedelta(hours=6)
current_start = start_dt
download_cnt = 0
skip_cnt = 0
while current_start < end_dt:
chunk_str = current_start.strftime("%Y%m%d_%H")
file_candidates = [f"prom_raw_{chunk_str}.parquet", f"prom_raw_{chunk_str}_active.parquet"]
for file_name in file_candidates:
local_file_path = RAW_DIR / file_name
object_key = f"raw/{file_name}"
try:
s3_client.head_object(Bucket=MINIO_RAW_BUCKET, Key=object_key)
if local_file_path.exists() and local_file_path.stat().st_size > 0 and "_active" not in file_name:
skip_cnt += 1
else:
s3_client.download_file(MINIO_RAW_BUCKET, object_key, str(local_file_path))
download_cnt += 1
except Exception:
pass
current_start += chunk_delta
print(f" -> 📊 레이크 리싱크 완수: 신규 다운로드 {download_cnt}개 / 기존 로컬 캐시 활용 {skip_cnt}개")
def filter_target_nodes(df):
node_pattern = NODE_PREFIX_PATTERN
print(f"🌐 [노드 정규식 필터 적용] 대상 기조: '{node_pattern}'")
initial_rows = len(df)
df_filtered = df[df["node"].str.contains(node_pattern, regex=True, na=False, case=False)].reset_index(drop=True)
print(f" -> ✂️ 노드 압축: {initial_rows:,}행 -> {len(df_filtered):,}행 생존")
return df_filtered
def derive_cluster_from_node(node_name):
n = str(node_name).lower()
if "icdlh" in n or "prod" in n: return "prod-data-lakehouse"
elif "stage" in n or "stg" in n: return "stage-lakehouse"
return "dev-sandbox"
def main():
args = parse_arguments()
s3_client = get_minio_client()
now_kst = datetime.now(KST)
if args.start_date and args.end_date:
start_dt = datetime.strptime(args.start_date, "%Y-%m-%d").replace(tzinfo=KST)
end_dt = (datetime.strptime(args.end_date, "%Y-%m-%d") + timedelta(days=1)).replace(tzinfo=KST)
else:
start_dt = (now_kst - timedelta(days=args.days)).replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = now_kst
sync_down_raw_from_minio(s3_client, start_dt, end_dt)
raw_files = list(RAW_DIR.glob("prom_raw_*.parquet"))
if not raw_files:
print("❌ 오류: 재처리 기간에 부합하는 원천 Parquet 데이터가 로컬 공간에 수렴되지 않았습니다.")
return
print(f"📦 다운로드된 총 {len(raw_files)}개의 원천 시계열 파티션 파일 로드 및 통합 매싱 중...")
df_raw = pd.concat([pd.read_parquet(f) for f in raw_files], ignore_index=True)
df_raw["date"] = df_raw["timestamp"].dt.strftime("%Y-%m-%d")
df_raw = filter_target_nodes(df_raw)
if df_raw.empty:
print("⚠️ 필터 조건에 부합하는 노드 시계열이 전혀 없습니다. 연산을 종료합니다.")
return
df_raw["cluster"] = df_raw["node"].apply(derive_cluster_from_node)
df_raw["workload_type"] = df_raw["pod"].apply(get_workload_type)
for col in ["cpu_limit", "mem_limit", "oom_event"]:
if col not in df_raw.columns: df_raw[col] = 0.0
print("📊 장기 고해상도 시계열 기반 팟 유닛 레벨 다차원 롤업 집계 가동...")
df_pod = df_raw.groupby(["date", "cluster", "namespace", "workload_type", "node", "pod", "container"]).agg(
minutes_running = ("timestamp", "size"),
cpu_request_max = ("cpu_request", "max"),
cpu_limit_max = ("cpu_limit", "max"),
cpu_usage_p95 = ("cpu_usage", lambda x: x.quantile(0.95)),
mem_request_max = ("mem_request", "max"),
mem_limit_max = ("mem_limit", "max"),
mem_usage_p95 = ("mem_usage", lambda x: x.quantile(0.95)),
oom_strike_sum = ("oom_event", "sum")
).reset_index().fillna(0)
for col in ["mem_request_max", "mem_limit_max", "mem_usage_p95"]:
df_pod[col] = df_pod[col] / (1024**3)
df_pod["cpu_allocated_core_hours"] = df_pod["cpu_request_max"] * (df_pod["minutes_running"] / 60.0
df_pod["cpu_usage_core_hours"] = df_pod["cpu_usage_p95"] * (df_pod["minutes_running"] / 60.0)
df_pod["cpu_waste_core_hours" ] = (df_pod["cpu_allocated_core_hours"] - df_pod["cpu_usage_core_hours"]).clip(lower=0)
df_pod["mem_allocated_gb_hours"] = df_pod["mem_request_max"] * (df_pod["minutes_running"] / 60.0)
df_pod["mem_usage_gb_hours"] = df_pod["mem_usage_p95"] * (df_pod["minutes_running"] / 60.0)
df_pod["mem_waste_gb_hours"] = (df_pod["mem_allocated_gb_hours"] - df_pod["mem_usage_gb_hours"]).clip(lower=0)
df_pod["is_oom_killed"] = df_pod["oom_strike_sum"] > 0
df_pod["has_no_request"] = (df_pod["cpu_request_max"] == 0) | (df_pod["mem_request_max"] == 0)
df_pod["has_no_limit"] = (df_pod["cpu_limit_max"] == 0) | (df_pod["mem_limit_max"] == 0)
df_pod["cpu_shortage_cores"] = (df_pod["cpu_usage_p95"] - df_pod["cpu_request_max"]).clip(lower=0)
df_pod["status"] = np.where(df_pod["is_oom_killed"], "💥 OOM장애발생",
np.where(df_pod["cpu_shortage_cores"] > 0.5, "⚠️ Request부족",
np.where(df_pod["cpu_waste_core_hours"] > 10, "📉 과다할당", "✅ 최적화완료")))
df_pod.to_parquet(MERGED_DIR / "enriched_fixed_7d.parquet", index=False)
print(f"💾 장기 시계열 통합 마스터 파티션 원부 저장 완료 (총 팟 명세 건수: {len(df_pod):,}개)")
df_ns = df_pod.groupby("namespace").agg(
minutes_running_sum = ("minutes_running", "sum"),
container_cnt = ("container", "count"),
total_allocated_core_hours = ("cpu_allocated_core_hours", "sum"),
total_waste_core_hours = ("cpu_waste_core_hours", "sum")
).reset_index().sort_values(by="total_waste_core_hours", ascending=False).reset_index(drop=True)
global_total_waste = df_ns["total_waste_core_hours"].sum() if df_ns["total_waste_core_hours"].sum() > 0 else 0.1
df_ns["waste_share_pct"] = (df_ns["total_waste_core_hours"] / global_total_waste * 100).round(2)
df_ns["waste_cumsum_pct"] = df_ns["waste_share_pct"].cumsum().round(2)
df_ns["is_top_80_percent_offender"] = df_ns["waste_cumsum_pct"] <= 80.5
df_ns.to_parquet(MERGED_DIR / "pareto_fixed_ns.parquet", index=False)
print("💾 Namespace 거버넌스 파레토 빌딩 완료.\n")
if __name__ == "__main__":
raw_code_fix = """
df_pod["cpu_allocated_core_hours"] = df_pod["cpu_request_max"] * (df_pod["minutes_running"] / 60.0)
"""
main()