네, 당연히 가능하며 이것이 진정한 엔터프라이즈급 멀티 클러스터 FinOps 거버넌스의 표준 모델입니다. 타노스(Thanos)가 사내 모든 클러스터의 원천 데이터를 한곳으로 집합시키는 중앙 집중식 수집기(Aggregator) 역할을 하므로, 1단계(step1)는 필터 없이 모든 클러스터 데이터를 넓은 그물로 한 번에 긁어와 MinIO 원천 레이크에 일자별로 적재하는 것이 아키텍처적으로 가장 고효율입니다.
그 이후 후처리 단계(step2 이후)에서 CLI 인자(Argument)를 조율하여 ① 기본 일배치 기동 시 전일(Yesterday) 24시간 데이터만 콤팩트하게 정산하거나, ② 필요 시 특정 날짜 범위(Range)와 특정 클러스터 유형(Compute vs Storage)을 타겟팅하여 유연하게 맞춤형 성적표를 리빌드하는 구조로 전면 고도화했습니다.
이를 완벽하게 실현하기 위해 중앙 설정(config.py), 재처리 엔진(step2_pipeline.py), 그리고 동적 파일 명명 규칙이 탑재된 마스터 엑셀 빌더(step6_excel_builder.py)의 최종 개정판 명세를 제공합니다.
config.py) 고도화클러스터의 호스트네임 컨벤션을 기반으로 해당 클러스터가 연산 전용(Compute)인지 스토리지 전용(Storage)인지 계통 분류하는 사양을 추가했습니다.
"""
config.py (멀티 클러스터 및 용도별 계통 분류가 통합된 공통 설정 원부)
"""
import re
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
RAW_DIR = BASE_DIR / "data" / "raw"
MERGED_DIR = BASE_DIR / "data" / "merged"
OUT_DIR = BASE_DIR / "data" / "output"
for d in [RAW_DIR, MERGED_DIR, OUT_DIR, OUT_DIR / "plots"]:
d.mkdir(parents=True, exist_ok=True)
DEFAULT_THANOS_URL = "http://thanos-query.internal.zone:9090"
MINIO_RAW_BUCKET = "enterprise-finops-raw-lake"
MINIO_REPORT_BUCKET = "enterprise-finops-reports"
NODE_PREFIX_PATTERN = r"name1wk\d+"
# ── 🌐 [신규 추가] 노드네임 기반 클러스터 식별 및 용도별(Compute/Storage) 계통 분류 매크로 ──
def classify_cluster_infrastructure(node_name):
"""
노드 이름을 판석하여 소속 클러스터 실명과 그 클러스터의 속성(COMPUTE / STORAGE)을 동시 반환합니다.
사내 명명 규칙(컨벤션)에 맞게 조건을 보완하세요.
"""
n = str(node_name).lower()
# 1. 스토리지 전용 클러스터 판정 (예: minio-storage-node01, aistor-prod-wk02 등)
if "aistor" in n or "storage" in n or "lake-pool" in n:
cluster_name = "prod-storage-cluster"
cluster_type = "STORAGE"
# 2. 연산 전용 클러스터 판정 (예: icdlh-prod-wk01, name1wk01 등)
elif "icdlh" in n or "prod" in n or "name1wk" in n:
cluster_name = "prod-compute-cluster"
cluster_type = "COMPUTE"
# 3. 스테이징/테스트 인프라
elif "stage" in n or "stg" in n:
cluster_name = "stage-lakehouse"
cluster_type = "COMPUTE"
else:
cluster_name = "unclassified-cluster"
cluster_type = "COMPUTE"
return cluster_name, cluster_type
def get_finops_promql_queries(selector):
return {
"cpu_request": f"kube_pod_container_resource_requests{selector.replace('container!=', 'resource=\"cpu\", container!=')}",
"cpu_limit": f"kube_pod_container_resource_limits{selector.replace('container!=', 'resource=\"cpu\", container!=')}",
"cpu_usage": f"rate(container_cpu_usage_seconds_total{selector}[1m])",
"mem_request": f"kube_pod_container_resource_requests{selector.replace('container!=', 'resource=\"memory\", container!=')}",
"mem_limit": f"kube_pod_container_resource_limits{selector.replace('container!=', 'resource=\"memory\", container!=')}",
"mem_usage": f"container_memory_working_set_bytes{selector}",
"oom_event": f"kube_pod_container_status_terminated_reason{selector.replace('container!=', 'reason=\"OOMKilled\", container!=')}"
}
def get_workload_type(pod_name):
p = str(pod_name).lower()
if "spark" in p or "-exec-" in p or "-driver" in p:
if "executor" in p or "-exec-" in p: return "SPARK_EXECUTOR"
if "driver" in p or "-driver" in p: return "SPARK_DRIVER"
return "SPARK_SYSTEM"
if "airflow" in p or "statsd" in p:
if "worker" in p: return "AIRFLOW_WORKER"
if "scheduler" in p: return "AIRFLOW_SCHEDULER"
if "dag-processor" in p: return "AIRFLOW_DAG_PROC"
if "triggerer" in p: return "AIRFLOW_TRIGGERER"
return "AIRFLOW_SYSTEM"
if "starrocks" in p or "-be-" in p or "-fe-" in p or "-cn-" in p:
if "-be" in p: return "STARROCKS_BE"
if "-fe" in p: return "STARROCKS_FE"
if "-cn" in p: return "STARROCKS_CN"
return "STARROCKS_SYSTEM"
if "postgres" in p or "pgpool" in p:
if "postgresql-backup" in p: return "POSTGRES_BACKUP"
if "pgpool" in p: return "POSTGRES_POOL"
return "POSTGRESQL"
if "hyperset" in p or "superset" in p or re.search(r"hyperset-\d{7,8}-redis", p):
return "HYPERSET"
if "jupyter" in p or "notebook" in p: return "JUPYTERLAB"
if "minio" in p or "aistor" in p or "lake-pool" in p: return "MINIO_STORAGE"
if "cilium" in p: return "CILIUM_CNI"
return "GENERAL_APPS"
step2_pipeline.py)핵심 고도화:
--cluster-type (COMPUTE | STORAGE | ALL) 옵션 및 특정 클러스터 실명 지정을 연동하여 상방 필터를 기동합니다."""
[신규 2단계 - 동적 멀티클러스터 정산판] 기본 전일 정산 및 온디맨드 윈도우/클러스터 타겟팅 제어 엔진
실행 (기본 일배치 - 전일 전체): python step2_pipeline.py
실행 (특정 범위 + 연산클러스터): python step2_pipeline.py --start-date 2026-06-01 --end-date 2026-06-07 --cluster-type COMPUTE
"""
import os
import argparse
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, classify_cluster_infrastructure, get_workload_type
KST = timezone(timedelta(hours=9))
def parse_arguments():
parser = argparse.ArgumentParser(description="FinOps Multi-Cluster Micro-Filtering Engine")
# ⏱️ 기간 제어 파라미터 (지정 안 하면 자동으로 전일 24시간 세팅)
parser.add_argument("--days", type=int, default=None)
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")
# 🌐 클러스터 도메인 제어 파라미터
parser.add_argument("--cluster-type", type=str, default="ALL", choices=["COMPUTE", "STORAGE", "ALL"], help="인프라 용도별 격리 필터")
parser.add_argument("--cluster", type=str, default=None, help="특정 클러스터 실명 콕 집어 정산 시 사용")
return parser.parse_args()
def get_minio_client():
return boto3.client(
"s3", endpoint_url=os.getenv("MINIO_ENDPOINT"),
aws_access_key_id=os.getenv("MINIO_ACCESS_KEY"),
aws_secret_access_key=os.getenv("MINIO_SECRET_KEY"),
config=boto3.session.Config(signature_version="s3v4")
)
def sync_down_raw_from_minio(s3_client, start_dt, end_dt):
chunk_delta = timedelta(hours=6)
current_start = start_dt
print(f"🪣 [MinIO 레이크 리싱크] {start_dt.strftime('%m-%d')} ~ {end_dt.strftime('%m-%d')} 범위 파일 동기화 스캔...")
while current_start < end_dt:
chunk_str = current_start.strftime("%Y%m%d_%H")
for f_name in [f"prom_raw_{chunk_str}.parquet", f"prom_raw_{chunk_str}_active.parquet"]:
local_path = RAW_DIR / f_name
try:
s3_client.head_object(Bucket=MINIO_RAW_BUCKET, Key=f"raw/{f_name}")
if not (local_path.exists() and local_path.stat().st_size > 0 and "_active" not in f_name):
s3_client.download_file(MINIO_RAW_BUCKET, f"raw/{f_name}", str(local_path))
except Exception:
pass
current_start += chunk_delta
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)
date_label = f"{args.start_date}_to_{args.end_date}"
elif args.days:
start_dt = (now_kst - timedelta(days=args.days)).replace(hour=0, minute=0, second=0, microsecond=0)
end_dt = now_kst
date_label = f"recent_{args.days}days"
else:
# 💡 완전 무옵션 기동 시: 어제 00:00:00 ~ 어제 23:59:59 완벽한 전일 정산
yesterday = (now_kst - timedelta(days=1)).date()
start_dt = datetime.combine(yesterday, datetime.min.time()).replace(tzinfo=KST)
end_dt = datetime.combine(yesterday, datetime.max.time()).replace(tzinfo=KST)
date_label = yesterday.strftime("%Y-%m-%d")
# MinIO 원천 레이크로부터 소요 파티션 파일 백업 다운로드
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("⚠️ 해당 기간에 적재된 원천 파일이 레이크하우스에 없습니다.")
return
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 = df_raw[(df_raw["timestamp"] >= start_dt.replace(tzinfo=None)) & (df_raw["timestamp"] < end_dt.replace(tzinfo=None))].reset_index(drop=True)
# ── 🌐 [멀티 클러스터 용도별 동적 격리 필터 적용] ──
print("🌐 config 계통 규칙 기반 멀티 클러스터 인프라 매핑 중...")
# 분류 함수로부터 클러스터 실명과 COMPUTE/STORAGE 타입을 한 번에 파싱 컬럼화
cluster_meta = df_raw["node"].apply(classify_cluster_infrastructure)
df_raw["cluster"] = [x[0] for x in cluster_meta]
df_raw["cluster_type"] = [x[1] for x in cluster_meta]
# CLI 인풋 조건에 맞춰 메모리 상방에서 로우 데이터 스크리닝 필터 집행
if args.cluster:
df_raw = df_raw[df_raw["cluster"] == args.cluster].reset_index(drop=True)
print(f" -> 🎯 특정 클러스터 단독 격리 단속: [{args.cluster}]")
elif args.cluster_type != "ALL":
df_raw = df_raw[df_raw["cluster_type"] == args.cluster_type].reset_index(drop=True)
print(f" -> 🎯 인프라 용도별 도메인 단독 격리 단속: [{args.cluster_type} CLUSTERS]")
if df_raw.empty:
print("⚠️ 필터 매칭 조건을 통과한 클러스터 데이터가 존재하지 않아 연산을 조기 마감합니다.")
return
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", "cluster_type", "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)
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.to_parquet(MERGED_DIR / "pareto_fixed_ns.parquet", index=False)
# 💡 하방 엑셀 빌더가 파일명을 식별할 수 있도록 메타데이터 텍스트 파일 저장 캐싱 체계 가동
with open(MERGED_DIR / "meta_run_info.txt", "w") as mf:
target_info = args.cluster if args.cluster else args.cluster_type
mf.write(f"{target_info}@{date_label}")
print(f"💾 [{target_info}] 관련 {date_label} 파티션 중간 정산 원부 마감 세이브 완료.\n")
if __name__ == "__main__":
main()
step6_excel_builder.py)핵심 고도화:
2단계가 남겨놓은 정산 식별자 메타(meta_run_info.txt)를 읽어 들여, 최종 산출물 파일명을 finops_report_인프라종류_날짜범위.xlsx 형태로 다이네믹하게 인덱싱합니다. 일배치와 임시 범위 조회가 덮어써 지는 참사를 완벽히 방어하며 완료 즉시 MinIO 리포트 버킷으로 자동 배포합니다.
"""
[신규 6단계 - 동적 리포트 배포판] 조건별 파일명 자동 인덱싱 및 MinIO Report 버킷 최종 배포 엔진
실행: python step6_excel_builder.py
"""
import os
import boto3
import pandas as pd
from pathlib import Path
from config import MERGED_DIR, OUT_DIR, MINIO_REPORT_BUCKET
def main():
enriched_file = MERGED_DIR / "enriched_fixed_7d.parquet"
pareto_file = MERGED_DIR / "pareto_fixed_ns.parquet"
meta_file = MERGED_DIR / "meta_run_info.txt"
if not enriched_file.exists():
print("❌ 가공 완료된 중간 Parquet 원부가 존재하지 않습니다.")
return
# 1. 2단계의 구동 메타 데이터 추출 파싱
if meta_file.exists():
with open(meta_file, "r") as f:
meta_str = f.read().strip()
infra_tag, date_tag = meta_str.split("@")
else:
infra_tag, date_tag = "ALL", "unknown"
df_pod = pd.read_parquet(enriched_file)
df_ns = pd.read_parquet(pareto_file)
# 탭 0. 총괄 요약
df_summary = pd.DataFrame({
"인프라 거버넌스 핵심 평가지표": [
"정산 대상 인프라 도메인 타겟", "지정 관측 분석 타임라인 범위",
"총 추적 컨테이너 볼륨 (개)", "최적화 권고 대상 범위 (전체 Pod의 30% 규격)",
"리소스 설정 규격 위반 좀비 팟 (개)", "해당 기간 OOMKilled 장애 유발 팟 (개)"
],
"FinOps 실측 통계치": [
infra_tag, date_tag,
len(df_pod), int(len(df_pod) * 0.30),
len(df_pod[df_pod["has_no_request"] | df_pod["has_no_limit"]]),
len(df_pod[df_pod["is_oom_killed"] == True])
]
})
# 탭 2. 동적 30% 낭비 실명 저격
top_30p_limit = max(1, int(len(df_pod) * 0.30))
df_waste_top30p = df_pod.sort_values(by="cpu_waste_core_hours", ascending=False).head(top_30p_limit)[
["date", "cluster", "cluster_type", "namespace", "workload_type", "pod", "container", "minutes_running", "cpu_request_max", "cpu_usage_p95", "cpu_waste_core_hours"]
].reset_index(drop=True)
# 탭 3. 부족 및 OOM
df_shortage = df_pod[(df_pod["cpu_shortage_cores"] > 0) | (df_pod["is_oom_killed"] == True)].sort_values(
by=["is_oom_killed", "cpu_shortage_cores"], ascending=[False, False]
)[
["date", "cluster", "namespace", "workload_type", "pod", "container", "status", "cpu_request_max", "cpu_usage_p95", "mem_limit_max", "mem_usage_p95"]
].reset_index(drop=True)
# 탭 4. 리소스 미설정 위반 목록
df_violators = df_pod[df_pod["has_no_request"] | df_pod["has_no_limit"]].sort_values(by="minutes_running", ascending=False)[
["date", "cluster", "namespace", "workload_type", "pod", "container", "has_no_request", "has_no_limit", "cpu_request_max", "cpu_limit_max", "mem_request_max", "mem_limit_max"]
].reset_index(drop=True)
# ── 📝 [핵심 반영] 아규먼트 조건에 부합하는 유일무이한 엑셀 파일명 생성 ──
excel_name = f"finops_report_{infra_tag.lower()}_{date_tag}.xlsx"
local_excel_path = OUT_DIR / excel_name
with pd.ExcelWriter(local_excel_path, engine="openpyxl") as writer:
df_summary.to_excel(writer, sheet_name="0. 전사종합요약", index=False)
df_ns.to_excel(writer, sheet_name="1. 파레토분석_NS", index=False)
df_waste_top30p.to_excel(writer, sheet_name="2. 컨테이너낭비Top30P", index=False)
df_shortage.to_excel(writer, sheet_name="3. 자원부족및OOM장애군", index=False)
df_violators.to_excel(writer, sheet_name="4. 리소스미설정위반군", index=False)
print(f"🚀 [로컬 빌드] 최종 보고서 생성 완료: {excel_name}")
# 🪣 사내 MinIO AIStor 리포트 전용 배포 버킷으로 이식 가동
try:
s3_client = boto3.client(
"s3", endpoint_url=os.getenv("MINIO_ENDPOINT"),
aws_access_key_id=os.getenv("MINIO_ACCESS_KEY"),
aws_secret_access_key=os.getenv("MINIO_SECRET_KEY")
)
object_key = f"reports/{excel_name}"
print(f"🪣 [MinIO 리포트 배포] 버킷 [{MINIO_REPORT_BUCKET}] -> {object_key} 업로드 중...")
s3_client.upload_file(str(local_excel_path), MINIO_REPORT_BUCKET, object_key)
print("✅ FinOps 성적표 자산 배포가 완벽하게 마감되었습니다.")
except Exception as e:
print(f"❌ MinIO 원격 전송 실패: {str(e)}")
if __name__ == "__main__":
main()
이제 스크립트 수정이 완전히 종결되었으므로, 터미널 명령을 조합하는 것만으로 원하는 보고서 파티션을 자유자재로 축출할 수 있습니다.
python step2_pipeline.py
python step6_excel_builder.py
# ➡️ 산출파일명 예시: finops_report_all_2026-06-29.xlsx
python step2_pipeline.py --start-date 2026-06-01 --end-date 2026-06-07 --cluster-type COMPUTE
python step6_excel_builder.py
# ➡️ 산출파일명 예시: finops_report_compute_2026-06-01_to_2026-06-07.xlsx
python step2_pipeline.py --days 7 --cluster "prod-storage-cluster"
python step6_excel_builder.py
# ➡️ 산출파일명 예시: finops_report_prod-storage-cluster_recent_7days.xlsx
아키텍처적 마감 강점:
이 구조를 통해 인프라팀은 매일 새벽 가벼운 전일 결산 리포트를 상시 자동 수집하면서도, 분기 공청회나 부서 비용 갈등 발생 시 원하는 기간과 타겟 인프라 도메인을 정밀 타격하여 언제든 증명 가능한 팩트 시트를 즉시 리빌드할 수 있는 최고 수준의 유연성을 확보하게 되었습니다.