<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>youngkyoo_kim.log</title>
        <link>https://velog.io/</link>
        <description>engineer</description>
        <lastBuildDate>Fri, 03 Jul 2026 00:01:31 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>youngkyoo_kim.log</title>
            <url>https://velog.velcdn.com/images/youngkyoo_kim/profile/7ee4ca13-1034-49bb-b6ab-736c3065a14a/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. youngkyoo_kim.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/youngkyoo_kim" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[26Y03b4]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y03b4</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y03b4</guid>
            <pubDate>Fri, 03 Jul 2026 00:01:31 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step4_governance_analyzer.py — Adaptive Multi-Cluster FinOps Governance &amp; Risk Analyzer (Partition-Isolated Version)
&quot;&quot;&quot;
import os
import glob
import argparse
import pandas as pd
import numpy as np
from pathlib import Path</p>
<h1 id="───-📂-경로-표준화-설정-──────────────────────────────────────────────────">─── 📂 경로 표준화 설정 ──────────────────────────────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
MERGED_DIR    = BASE_DATA_DIR / &quot;merged&quot;
OUTPUT_DIR    = BASE_DATA_DIR / &quot;output&quot;</p>
<h1 id="출력-디렉토리-자동-생성">출력 디렉토리 자동 생성</h1>
<p>OUTPUT_DIR.mkdir(parents=True, exist_ok=True)</p>
<p>def parse_arguments():
    parser = argparse.ArgumentParser(description=&quot;FinOps Governance &amp; Anomaly Analyzer&quot;)
    # 💡 [교정] INTEGRATED/ALL 단계를 완전 폐기하고 타 스택과 완벽 대칭을 이루도록 대상 클러스터를 필수 요구
    parser.add_argument(&quot;--cluster&quot;, type=str, required=True, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;], help=&quot;Target cluster type&quot;)
    return parser.parse_args()</p>
<p>def load_partitioned_data(cluster_target):
    &quot;&quot;&quot;
    개편된 2단계 분할 정복 하위 폴더 위계를 스캔하여 일별 Parquet 파일들을 동적으로 결합합니다.
    &quot;&quot;&quot;
    # 💡 [교정] 타겟 클러스터 폴더 내부에 격리되어 저장된 일별 자산들만 정밀 타격 스캔
    cl_dir = MERGED_DIR / cluster_target
    if not cl_dir.exists():
        return pd.DataFrame(), pd.DataFrame(), pd.DataFrame()</p>
<pre><code>pod_files    = glob.glob(str(cl_dir / &quot;daily_enriched_*.parquet&quot;))
ns_files     = glob.glob(str(cl_dir / &quot;daily_ns_usage_*.parquet&quot;))
pareto_files = glob.glob(str(cl_dir / &quot;pareto_ns_*.parquet&quot;))

print(f&quot;🔍 [스캔 완료] {cluster_target} 파티션 하방 -&gt; Enriched: {len(pod_files)}개 | NS요약: {len(ns_files)}개 | Pareto: {len(pareto_files)}개&quot;)

# 파일이 단 하나도 없을 경우의 빈 데이터프레임 예외 예방 가드레일
df_pod = pd.concat([pd.read_parquet(f) for f in pod_files], ignore_index=True) if pod_files else pd.DataFrame()
df_ns  = pd.concat([pd.read_parquet(f) for f in ns_files], ignore_index=True) if ns_files else pd.DataFrame()
df_pt  = pd.concat([pd.read_parquet(f) for f in pareto_files], ignore_index=True) if pareto_files else pd.DataFrame()

return df_pod, df_ns, df_pt</code></pre><p>def main():
    args = parse_arguments()
    cluster_target = args.cluster.upper()
    print(f&quot;🚀 [Step4] Starting FinOps Governance Analyzer for Cluster: {cluster_target}...&quot;)</p>
<pre><code># 1. 쪼개져 있는 2단계 결과물 격리 로드
df_pod, df_ns, df_pt = load_partitioned_data(cluster_target)

if df_pod.empty:
    print(f&quot;❌ [중단] &#39;{cluster_target}&#39; 클러스터에 정산된 2단계 가공 원부가 존재하지 않습니다. 앞단 배치를 확인하세요.&quot;)
    return

print(f&quot;✅ 총 {len(df_pod):,}개의 컨테이너 타임라인 원부 통합 바인딩 완료.\n&quot;)

# 2. 🚨 [거버넌스 분석 레이어 1] 자원부족 및 OOM Killed 위험군 추출 (시트4 매핑용)
print(&quot;🚨 [Analysis 1] Extracting Critical Risk &amp; OOM Killed Pods...&quot;)
df_risk = df_pod[
    (df_pod[&quot;status&quot;] == &quot;💥 OOM장애발생&quot;) | 
    (df_pod[&quot;status&quot;] == &quot;⚠️ Request부족&quot;) | 
    (df_pod[&quot;oom_strike_sum&quot;] &gt; 0)
].copy()

# 💡 [교정] 인덱스 미스매칭 크래시 방지를 위해 np.where 벡터화 구문으로 안전하게 조치 가이드 주입
if not df_risk.empty:
    df_risk[&quot;recommendation&quot;] = np.where(
        df_risk[&quot;is_oom_killed&quot;],
        &quot;Upsize Memory Limit (OOM Detected)&quot;,
        &quot;Upsize CPU Request/Limit (Throttling Detected)&quot;
    )
else:
    df_risk[&quot;recommendation&quot;] = None

# 3. 📉 [거버넌스 분석 레이어 2] 과다 할당 및 유휴 자산 추출 (시트2, 3 하향 후보용)
print(&quot;📉 [Analysis 2] Extracting Over-Allocated &amp; Idle Infrastructure Assets...&quot;)
df_waste = df_pod[
    (df_pod[&quot;status&quot;] == &quot;📉 과다할당&quot;) &amp; 
    (df_pod[&quot;cpu_waste_core_hours&quot;] &gt; 24)  # 하루 이상 코어 하나 통째로 낭비한 기준
].sort_values(by=&quot;cpu_waste_core_hours&quot;, ascending=False).copy()

# 4. 🛡️ [거버넌스 분석 레이어 3] 자원 미설정 배포 위반군 추출 (시트5 매핑용)
print(&quot;🛡️ [Analysis 3] Scanning Non-Compliant Missing Resource Specification Pods...&quot;)
df_violations = df_pod[
    (df_pod[&quot;has_no_request&quot;] == True) | 
    (df_pod[&quot;has_no_limit&quot;] == True)
].copy()

# 5. 💾 [결과 마감] 6단계 엑셀 빌더가 다이렉트로 인식할 수 있도록 산출물 저장
print(f&quot;\n💾 [정산 마감] 분석 데이터 자산 output 레이어로 내보내기 진행 중...&quot;)

# 💡 [교정] 모놀리식 접미사를 전면 제거하고 명확한 클러스터 고유 타겟 식별자로 파일명 고정
master_gov_file = OUTPUT_DIR / f&quot;governance_master_{cluster_target}.parquet&quot;
risk_file       = OUTPUT_DIR / f&quot;gov_risk_oom_{cluster_target}.parquet&quot;
waste_file      = OUTPUT_DIR / f&quot;gov_waste_candidates_{cluster_target}.parquet&quot;
viol_file       = OUTPUT_DIR / f&quot;gov_violations_{cluster_target}.parquet&quot;

# Parquet 압축 저장
df_pod.to_parquet(master_gov_file, index=False)
df_risk.to_parquet(risk_file, index=False)
df_waste.to_parquet(waste_file, index=False)
df_violations.to_parquet(viol_file, index=False)

# 검증 서머리 출력
print(f&quot;  -&gt; 📦 [저장완료] 전사 {cluster_target} 마스터 원부 : {master_gov_file.name}&quot;)
print(f&quot;  -&gt; 💥 [저장완료] 고위험군/OOM 리스트    : {risk_file.name} (결과: {len(df_risk)}건)&quot;)
print(f&quot;  -&gt; 📉 [저장완료] 자원 하향조정 후보군  : {waste_file.name} (결과: {len(df_waste)}건)&quot;)
print(f&quot;  -&gt; 🛡️ [저장완료] 스펙 미설정 규격위반군: {viol_file.name} (결과: {len(df_violations)}건)&quot;)

print(f&quot;\n🏁 === [Step4 완수] &#39;{cluster_target}&#39; 거버넌스 가공 원부가 무결하게 갱신되었습니다. ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y03b3]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y03b3</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y03b3</guid>
            <pubDate>Fri, 03 Jul 2026 00:00:27 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step3_analytics.py — Long-term Time-series Infrastructure Data Visualization Engine (Partitioned &amp; Safe Version)
&quot;&quot;&quot;
import os
import re
import argparse
import pandas as pd
import numpy as np
from pathlib import Path</p>
<h1 id="───-🛡️-headless-environment-guard-prevents-crashes-in-k8s-pods-without-display-servers-───">─── 🛡️ [Headless Environment Guard] Prevents crashes in K8s pods without display servers ───</h1>
<p>import matplotlib
matplotlib.use(&#39;Agg&#39;) 
import matplotlib.pyplot as plt
import seaborn as sns</p>
<h1 id="───-📂-directory-path-standardization-data-──────────────────────────">─── 📂 Directory Path Standardization (./data) ──────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
MERGED_DIR    = BASE_DATA_DIR / &quot;merged&quot;
BASE_PLOT_DIR = BASE_DATA_DIR / &quot;output&quot; / &quot;plots&quot;</p>
<h1 id="ensure-base-plot-directory-exists">Ensure base plot directory exists</h1>
<p>BASE_PLOT_DIR.mkdir(parents=True, exist_ok=True)</p>
<h1 id="───-🔤-no-more-tofu-use-bulletproof-built-in-standard-fonts-───">─── 🔤 [No More Tofu] Use bulletproof built-in standard fonts ───</h1>
<p>plt.rcParams[&#39;font.family&#39;] = &#39;sans-serif&#39;
plt.rcParams[&#39;axes.unicode_minus&#39;] = False  # Prevents minus sign (-) corruption
sns.set_theme(style=&quot;whitegrid&quot;)</p>
<h1 id="───-🛡️-완치-가드레일-빈-데이터셋-감지-시-플레이스홀더-png-생성-함수-───">─── 🛡️ [완치 가드레일] 빈 데이터셋 감지 시 플레이스홀더 PNG 생성 함수 ───</h1>
<p>def check_and_handle_empty(df, output_path, chart_name):
    &quot;&quot;&quot;
    데이터가 비어있으면 True를 반환하고, 6단계 엑셀 빌더가 터지지 않도록
    &#39;No Data Available&#39; 문구가 적힌 플레이스홀더 이미지를 강제 생성합니다.
    &quot;&quot;&quot;
    if df is None or df.empty:
        print(f&quot;  ⚠️  [Empty Data] Generating placeholder for {chart_name} (Dataset is empty).&quot;)
        fig, ax = plt.subplots(figsize=(10, 5))
        ax.text(0.5, 0.5, f&quot;No Data Available\n({chart_name})&quot;, 
                ha=&#39;center&#39;, va=&#39;center&#39;, fontsize=14, color=&#39;gray&#39;, weight=&#39;bold&#39;)
        ax.axis(&#39;off&#39;)
        plt.tight_layout()
        plt.savefig(output_path, dpi=100, bbox_inches=&#39;tight&#39;)
        plt.close()
        return True
    return False</p>
<p>def main():
    parser = argparse.ArgumentParser(description=&quot;FinOps Analytics Visualization Engine&quot;)
    # 💡 [교정] INTEGRATED/ALL 단계를 배제하고 6단계와 완벽히 대칭되도록 명확한 대상 클러스터를 필수로 요구
    parser.add_argument(&quot;--cluster&quot;, type=str, required=True, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;], help=&quot;Target cluster type&quot;)
    args = parser.parse_args()</p>
<pre><code>cluster_target = args.cluster.upper()
print(f&quot;🚀 [Step3] Starting FinOps Time-series English Visualization Engine for Cluster: {cluster_target}...&quot;)

# ─── 🗂️ 클러스터 격리 디렉토리 매핑 및 가변 결합 (분할 정복) ───
cl_dir = MERGED_DIR / cluster_target

if not cl_dir.exists():
    print(f&quot;❌ Error: Target cluster directory &#39;{cl_dir}&#39; does not exist. Please run step2 first.&quot;)
    return

pod_files = list(cl_dir.glob(&quot;daily_enriched_*.parquet&quot;))
ns_files  = list(cl_dir.glob(&quot;pareto_ns_*.parquet&quot;))

if not pod_files:
    print(f&quot;❌ Error: No processed daily Parquet files found for cluster &#39;{cluster_target}&#39; under {cl_dir}. Please run step2 first.&quot;)
    return

df_pod = pd.concat([pd.read_parquet(f) for f in pod_files], ignore_index=True)
df_ns  = pd.concat([pd.read_parquet(f) for f in ns_files], ignore_index=True) if ns_files else pd.DataFrame()

# 💡 [교정] 출력 경로 역시 INTEGRATED 우회 레이어 없이 해당 클러스터 폴더명으로 완전 격리 분리
PLOT_DIR = BASE_PLOT_DIR / cluster_target
PLOT_DIR.mkdir(parents=True, exist_ok=True)

print(f&quot;✅ Data lake aggregated successfully -&gt; Container rows: {len(df_pod):,}&quot;)

# 데이터 정렬 오염 차단용 형변환 Guard
df_pod[&quot;date&quot;] = df_pod[&quot;date&quot;].astype(str)

# Injecting efficiency metric layers
df_pod[&quot;cpu_util&quot;] = np.where(df_pod[&quot;cpu_request_max&quot;] &gt; 0, (df_pod[&quot;cpu_usage_p95&quot;] / df_pod[&quot;cpu_request_max&quot;] * 100), 0)
df_pod[&quot;mem_util&quot;] = np.where(df_pod[&quot;mem_request_max&quot;] &gt; 0, (df_pod[&quot;mem_usage_p95&quot;] / df_pod[&quot;mem_request_max&quot;] * 100), 0)
df_pod[&quot;lim_req_ratio&quot;] = np.where(df_pod[&quot;cpu_request_max&quot;] &gt; 0, df_pod[&quot;cpu_limit_max&quot;] / df_pod[&quot;cpu_request_max&quot;], 0)

# ─── 📊 [Charts 1 &amp; 2] Workload Type Allocated vs Actual Peak ───
print(&quot;⏳ [1/19] Rendering chart1_cpu_req_vs_usage_by_workload...&quot;)
out1 = PLOT_DIR / &quot;chart1_cpu_req_vs_usage_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out1, &quot;chart1_cpu_req_vs_usage_by_workload&quot;):
    df_wl_cpu = df_pod.groupby(&quot;workload_type&quot;)[[&quot;cpu_request_max&quot;, &quot;cpu_usage_p95&quot;]].mean().reset_index()
    plt.figure(figsize=(10, 5))
    df_melt_cpu = df_wl_cpu.melt(id_vars=&quot;workload_type&quot;, value_vars=[&quot;cpu_request_max&quot;, &quot;cpu_usage_p95&quot;])
    sns.barplot(data=df_melt_cpu, x=&quot;workload_type&quot;, y=&quot;value&quot;, hue=&quot;variable&quot;, palette=&quot;Blues_r&quot;)
    plt.xticks(rotation=30, ha=&#39;right&#39;)
    plt.title(&quot;Average CPU Request vs P95 Peak Usage by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;CPU Cores&quot;)
    plt.tight_layout()
    plt.savefig(out1, dpi=100)
    plt.close()

print(&quot;⏳ [2/19] Rendering chart2_mem_req_vs_usage_by_workload...&quot;)
out2 = PLOT_DIR / &quot;chart2_mem_req_vs_usage_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out2, &quot;chart2_mem_req_vs_usage_by_workload&quot;):
    df_wl_mem = df_pod.groupby(&quot;workload_type&quot;)[[&quot;mem_request_max&quot;, &quot;mem_usage_p95&quot;]].mean().reset_index()
    plt.figure(figsize=(10, 5))
    df_melt_mem = df_wl_mem.melt(id_vars=&quot;workload_type&quot;, value_vars=[&quot;mem_request_max&quot;, &quot;mem_usage_p95&quot;])
    sns.barplot(data=df_melt_mem, x=&quot;workload_type&quot;, y=&quot;value&quot;, hue=&quot;variable&quot;, palette=&quot;Purples_r&quot;)
    plt.xticks(rotation=30, ha=&#39;right&#39;)
    plt.title(&quot;Average Memory Request vs P95 Peak Usage (GB) by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Memory Capacity (GB)&quot;)
    plt.tight_layout()
    plt.savefig(out2, dpi=100)
    plt.close()

# ─── 📊 [Chart 3] Daily CPU Waste Stacked Bar ───
print(&quot;⏳ [3/19] Rendering chart3_daily_waste_stack...&quot;)
out3 = PLOT_DIR / &quot;chart3_daily_waste_stack.png&quot;
df_daily_waste = df_pod.groupby([&quot;date&quot;, &quot;workload_type&quot;])[&quot;cpu_waste_core_hours&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_daily_waste, out3, &quot;chart3_daily_waste_stack&quot;):
    df_daily_waste.plot(kind=&#39;bar&#39;, stacked=True, figsize=(11, 5), cmap=&quot;tab20&quot;)
    plt.title(&quot;Daily Total CPU Waste Core-Hours Stacked by Workload (KST)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Waste Volume (Core-Hours)&quot;)
    plt.xticks(rotation=45)
    plt.tight_layout()
    plt.savefig(out3, dpi=100)
    plt.close()

# ─── 📊 [Charts 4 &amp; 18] Efficiency Heatmaps ───
print(&quot;⏳ [4/19] Rendering chart4_cpu_efficiency_heatmap...&quot;)
out4 = PLOT_DIR / &quot;chart4_cpu_efficiency_heatmap.png&quot;
df_heat_cpu = df_pod.groupby([&quot;workload_type&quot;, &quot;date&quot;])[&quot;cpu_util&quot;].mean().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_heat_cpu, out4, &quot;chart4_cpu_efficiency_heatmap&quot;):
    plt.figure(figsize=(10, 5))
    sns.heatmap(df_heat_cpu, annot=True, fmt=&quot;.1f&quot;, cmap=&quot;RdYlGn&quot;, cbar=True)
    plt.title(&quot;Mean CPU Utilization Heatmap (%) (Workload Type x Date)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Workload Type&quot;)
    plt.tight_layout()
    plt.savefig(out4, dpi=100)
    plt.close()

print(&quot;⏳ [5/19] Rendering chart18_mem_waste_heatmap...&quot;)
out18 = PLOT_DIR / &quot;chart18_mem_waste_heatmap.png&quot;
df_heat_mem = df_pod.groupby([&quot;workload_type&quot;, &quot;date&quot;])[&quot;mem_waste_gb_hours&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_heat_mem, out18, &quot;chart18_mem_waste_heatmap&quot;):
    plt.figure(figsize=(10, 5))
    sns.heatmap(df_heat_mem, annot=False, cmap=&quot;BuPu&quot;, cbar=True)
    plt.title(&quot;Total Memory Waste Volume Heatmap (GB-Hours)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Workload Type&quot;)
    plt.tight_layout()
    plt.savefig(out18, dpi=100)
    plt.close()

# ─── 📊 [Charts 5 &amp; 11] Pareto Governance Charts ───
print(&quot;⏳ [6/19] Rendering chart5_pareto_ns_waste...&quot;)
out5 = PLOT_DIR / &quot;chart5_pareto_ns_waste.png&quot;
if not check_and_handle_empty(df_ns, out5, &quot;chart5_pareto_ns_waste&quot;):
    df_ns_top = df_ns.groupby(&quot;namespace&quot;)[&quot;total_waste_core_hours&quot;].sum().reset_index().sort_values(&quot;total_waste_core_hours&quot;, ascending=False).head(15)
    df_ns_top[&quot;waste_share_pct&quot;] = (df_ns_top[&quot;total_waste_core_hours&quot;] / df_ns_top[&quot;total_waste_core_hours&quot;].sum() * 100).round(2)
    df_ns_top[&quot;waste_cumsum_pct&quot;] = df_ns_top[&quot;waste_share_pct&quot;].cumsum().round(2)

    fig, ax1 = plt.subplots(figsize=(11, 5))
    sns.barplot(data=df_ns_top, x=&quot;namespace&quot;, y=&quot;total_waste_core_hours&quot;, ax=ax1, color=&quot;steelblue&quot;)
    ax1.set_xticklabels(ax1.get_xticklabels(), rotation=45, ha=&quot;right&quot;)
    ax2 = ax1.twinx()
    ax2.plot(df_ns_top[&quot;namespace&quot;], df_ns_top[&quot;waste_cumsum_pct&quot;], color=&quot;crimson&quot;, marker=&quot;o&quot;, linewidth=2)
    ax2.set_ylim(0, 110)
    plt.title(&quot;Top 15 Namespace Cost-Waste Pareto Chart (Cumulative Share %)&quot;)
    ax1.set_xlabel(&quot;Tenant Namespace&quot;)
    ax1.set_ylabel(&quot;Waste Volume (Core-Hours)&quot;)
    plt.tight_layout()
    plt.savefig(out5, dpi=100)
    plt.close()

print(&quot;⏳ [7/19] Rendering chart11_pareto_workload_waste...&quot;)
out11 = PLOT_DIR / &quot;chart11_pareto_workload_waste.png&quot;
if not check_and_handle_empty(df_pod, out11, &quot;chart11_pareto_workload_waste&quot;):
    df_wl_waste = df_pod.groupby(&quot;workload_type&quot;)[&quot;cpu_waste_core_hours&quot;].sum().reset_index().sort_values(&quot;cpu_waste_core_hours&quot;, ascending=False)
    plt.figure(figsize=(10, 5))
    sns.barplot(data=df_wl_waste, x=&quot;workload_type&quot;, y=&quot;cpu_waste_core_hours&quot;, palette=&quot;Oranges_r&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;Total CPU Waste Volume by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Total Waste (Core-Hours)&quot;)
    plt.tight_layout()
    plt.savefig(out11, dpi=100)
    plt.close()

# ─── 📊 [Chart 6] Governance Status Donut Chart ───
print(&quot;⏳ [8/19] Rendering chart6_status_donut...&quot;)
out6 = PLOT_DIR / &quot;chart6_status_donut.png&quot;
if not check_and_handle_empty(df_pod, out6, &quot;chart6_status_donut&quot;):
    status_summary = df_pod[&quot;status&quot;].value_counts()
    plt.figure(figsize=(6, 5))
    colors = [&quot;#70AD47&quot;, &quot;#1F4E79&quot;, &quot;#FFC000&quot;, &quot;#C00000&quot;]
    plt.pie(status_summary, labels=status_summary.index, autopct=&#39;%1.1f%%&#39;, startangle=90, colors=colors[:len(status_summary)], wedgeprops=dict(width=0.4, edgecolor=&#39;w&#39;))
    plt.title(&quot;Infrastructure Resource Governance Status Ratio&quot;)
    plt.tight_layout()
    plt.savefig(out6, dpi=100)
    plt.close()

# ─── 📊 [Charts 7 &amp; 14] Bubble &amp; Scatter Plots ───
print(&quot;⏳ [9/19] Rendering chart7_waste_footprint_bubble...&quot;)
out7 = PLOT_DIR / &quot;chart7_waste_footprint_bubble.png&quot;
if not check_and_handle_empty(df_pod, out7, &quot;chart7_waste_footprint_bubble&quot;):
    df_bubble = df_pod.groupby(&quot;workload_type&quot;).agg(
        x_alloc=(&quot;cpu_allocated_core_hours&quot;, &quot;sum&quot;),
        y_util=(&quot;cpu_util&quot;, &quot;mean&quot;),
        z_waste=(&quot;cpu_waste_core_hours&quot;, &quot;sum&quot;)
    ).reset_index()
    plt.figure(figsize=(9, 6))
    sns.scatterplot(data=df_bubble, x=&quot;x_alloc&quot;, y=&quot;y_util&quot;, size=&quot;z_waste&quot;, hue=&quot;workload_type&quot;, sizes=(100, 2000), alpha=0.7, legend=&quot;brief&quot;)
    plt.title(&quot;Resource Footprint Bubble Chart (Allocated x Utilization x Waste Size)&quot;)
    plt.xlabel(&quot;Total Allocated Core-Hours&quot;)
    plt.ylabel(&quot;Average Utilization (%)&quot;)
    plt.legend(bbox_to_anchor=(1.05, 1), loc=&#39;upper left&#39;)
    plt.tight_layout()
    plt.savefig(out7, dpi=100)
    plt.close()

print(&quot;⏳ [10/19] Rendering chart14_cpu_mem_waste_scatter...&quot;)
out14 = PLOT_DIR / &quot;chart14_cpu_mem_waste_scatter.png&quot;
if not check_and_handle_empty(df_pod, out14, &quot;chart14_cpu_mem_waste_scatter&quot;):
    plt.figure(figsize=(8, 6))
    sns.scatterplot(data=df_pod.head(5000), x=&quot;cpu_waste_core_hours&quot;, y=&quot;mem_waste_gb_hours&quot;, hue=&quot;workload_type&quot;, alpha=0.5)
    plt.title(&quot;Co-relation Scatter Plot: CPU Waste vs Memory Waste (Sampled)&quot;)
    plt.xlabel(&quot;CPU Waste (Core-Hours)&quot;)
    plt.ylabel(&quot;Memory Waste (GB-Hours)&quot;)
    plt.tight_layout()
    plt.savefig(out14, dpi=100)
    plt.close()

# ─── 📊 [Chart 8] Resource Shortage Footprint ───
print(&quot;⏳ [11/19] Rendering chart8_shortfall_footprint...&quot;)
out8 = PLOT_DIR / &quot;chart8_shortfall_footprint.png&quot;
df_short = df_pod.groupby([&quot;workload_type&quot;, &quot;date&quot;])[&quot;cpu_shortage_cores&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_short, out8, &quot;chart8_shortfall_footprint&quot;):
    plt.figure(figsize=(10, 4))
    sns.heatmap(df_short, annot=False, cmap=&quot;YlOrRd&quot;, cbar=True)
    plt.title(&quot;Total CPU Shortfall (Deficit) Cores Footprint&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Workload Type&quot;)
    plt.tight_layout()
    plt.savefig(out8, dpi=100)
    plt.close()

# ─── 📊 [Charts 9 &amp; 10] Boxplot Distribution Analysis ───
print(&quot;⏳ [12/19] Rendering chart9_boxplot_cpu_util_by_workload...&quot;)
out9 = PLOT_DIR / &quot;chart9_boxplot_cpu_util_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out9, &quot;chart9_boxplot_cpu_util_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.boxplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;cpu_util&quot;, palette=&quot;Set3&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.ylim(-5, 105)
    plt.title(&quot;CPU Utilization P95 Boxplot Distribution by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;P95 Actual Utilization (%)&quot;)
    plt.tight_layout()
    plt.savefig(out9, dpi=100)
    plt.close()

print(&quot;⏳ [13/19] Rendering chart10_boxplot_mem_util_by_workload...&quot;)
out10 = PLOT_DIR / &quot;chart10_boxplot_mem_util_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out10, &quot;chart10_boxplot_mem_util_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.boxplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;mem_util&quot;, palette=&quot;Pastel1&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.ylim(-5, 105)
    plt.title(&quot;Memory Utilization P95 Boxplot Distribution by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;P95 Actual Utilization (%)&quot;)
    plt.tight_layout()
    plt.savefig(out10, dpi=100)
    plt.close()

# ─── 📊 [Charts 12 &amp; 15] Trend and Counts ───
print(&quot;⏳ [14/19] Rendering chart12_daily_waste_trend_by_workload...&quot;)
out12 = PLOT_DIR / &quot;chart12_daily_waste_trend_by_workload.png&quot;
df_trend_wl = df_pod.groupby([&quot;date&quot;, &quot;workload_type&quot;])[&quot;cpu_waste_core_hours&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_trend_wl, out12, &quot;chart12_daily_waste_trend_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.lineplot(data=df_trend_wl, markers=True, dashes=False, linewidth=2)
    plt.title(&quot;Daily CPU Waste Timeline Trend Line by Workload Type&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Waste Volume (Core-Hours)&quot;)
    plt.xticks(rotation=30)
    plt.tight_layout()
    plt.savefig(out12, dpi=100)
    plt.close()

print(&quot;⏳ [15/19] Rendering chart15_oom_status_by_workload...&quot;)
out15 = PLOT_DIR / &quot;chart15_oom_status_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out15, &quot;chart15_oom_status_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.countplot(data=df_pod, x=&quot;workload_type&quot;, hue=&quot;status&quot;, palette=&quot;muted&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;Governance Status Distribution Count per Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Pod Count&quot;)
    plt.legend(loc=&quot;upper right&quot;)
    plt.tight_layout()
    plt.savefig(out15, dpi=100)
    plt.close()

# ─── 📊 [Charts 13 &amp; 17] Violin and Overcommit Boxplot ───
print(&quot;⏳ [16/19] Rendering chart13_violin_cpu_util...&quot;)
out13 = PLOT_DIR / &quot;chart13_violin_cpu_util.png&quot;
if not check_and_handle_empty(df_pod, out13, &quot;chart13_violin_cpu_util&quot;):
    plt.figure(figsize=(10, 5))
    sns.violinplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;cpu_util&quot;, inner=&quot;quartile&quot;, palette=&quot;pastel&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;CPU Utilization Kernel Density Violin Plot&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;CPU Utilization (%)&quot;)
    plt.tight_layout()
    plt.savefig(out13, dpi=100)
    plt.close()

print(&quot;⏳ [17/19] Rendering chart17_cpu_limit_request_ratio...&quot;)
out17 = PLOT_DIR / &quot;chart17_cpu_limit_request_ratio.png&quot;
if not check_and_handle_empty(df_pod, out17, &quot;chart17_cpu_limit_request_ratio&quot;):
    plt.figure(figsize=(10, 5))
    sns.boxplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;lim_req_ratio&quot;, palette=&quot;vlag&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;Kubernetes Pod CPU Limit / Request Overcommit Ratio&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Limit / Request Ratio&quot;)
    plt.tight_layout()
    plt.savefig(out17, dpi=100)
    plt.close()

# ─── 📊 [Charts 19 &amp; 20] Capacity vs Actual Trends ───
print(&quot;⏳ [18/19] Rendering chart19_daily_cpu_per_workload...&quot;)
out19 = PLOT_DIR / &quot;chart19_daily_cpu_per_workload.png&quot;
if not check_and_handle_empty(df_pod, out19, &quot;chart19_daily_cpu_per_workload&quot;):
    df_daily_cpu_req = df_pod.groupby(&quot;date&quot;)[&quot;cpu_request_max&quot;].sum()
    df_daily_cpu_use = df_pod.groupby(&quot;date&quot;)[&quot;cpu_usage_p95&quot;].sum()
    plt.figure(figsize=(11, 5))
    plt.fill_between(df_daily_cpu_req.index, df_daily_cpu_req.values, label=&quot;Total CPU Request Cores&quot;, color=&quot;skyblue&quot;, alpha=0.4)
    plt.plot(df_daily_cpu_use.index, df_daily_cpu_use.values, label=&quot;Total CPU P95 Actual Cores&quot;, color=&quot;navy&quot;, linewidth=2.5, marker=&quot;o&quot;)
    plt.title(&quot;Daily Total CPU Capacity Allocation vs Actual Peak Consumption (KST)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Total CPU Cores&quot;)
    plt.xticks(rotation=30)
    plt.legend(loc=&quot;upper left&quot;)
    plt.tight_layout()
    plt.savefig(out19, dpi=100)
    plt.close()

print(&quot;⏳ [19/19] Rendering chart20_daily_mem_per_workload...&quot;)
out20 = PLOT_DIR / &quot;chart20_daily_mem_per_workload.png&quot;
if not check_and_handle_empty(df_pod, out20, &quot;chart20_daily_mem_per_workload&quot;):
    df_daily_mem_req = df_pod.groupby(&quot;date&quot;)[&quot;mem_request_max&quot;].sum()
    df_daily_mem_use = df_pod.groupby(&quot;date&quot;)[&quot;mem_usage_p95&quot;].sum()
    plt.figure(figsize=(11, 5))
    plt.fill_between(df_daily_mem_req.index, df_daily_mem_req.values, label=&quot;Total Memory Request (GB)&quot;, color=&quot;plum&quot;, alpha=0.4)
    plt.plot(df_daily_mem_use.index, df_daily_mem_use.values, label=&quot;Total Memory P95 Actual (GB)&quot;, color=&quot;purple&quot;, linewidth=2.5, marker=&quot;o&quot;)
    plt.title(&quot;Daily Total Memory Capacity Allocation vs Actual Peak Consumption (KST)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Total Memory (GB)&quot;)
    plt.xticks(rotation=30)
    plt.legend(loc=&quot;upper left&quot;)
    plt.tight_layout()
    plt.savefig(out20, dpi=100)
    plt.close()

print(f&quot;\n🏁 === [Step3 Success] All charts generated cleanly under ./data/output/plots/{cluster_target} ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y03b2]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y03b2</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y03b2</guid>
            <pubDate>Thu, 02 Jul 2026 23:49:22 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
[2단계-Polars 완치판] 멀티 코어 기반 일별 자원 마감 정산 및 AIStor 전송 파이프라인 (Cluster Isolated Version)
&quot;&quot;&quot;
import os
import argparse
import boto3
from botocore.exceptions import ClientError
import polars as pl
from datetime import datetime, timedelta, timezone
from pathlib import Path</p>
<p>import config
from config import (
    RAW_DIR, MERGED_DIR, CLUSTER_NODE_PATTERNS, 
    get_workload_type, classify_node_infrastructure
)</p>
<p>KST = timezone(timedelta(hours=9))
W_CPU, W_MEM, W_PV = 1.0, 0.11, 0.02</p>
<p>def parse_arguments():
    parser = argparse.ArgumentParser(description=&quot;FinOps Polars-Powered Reprocessing Engine&quot;)
    parser.add_argument(&quot;--days&quot;, type=int, default=None)
    parser.add_argument(&quot;--start-date&quot;, type=str, default=None)
    parser.add_argument(&quot;--end-date&quot;, type=str, default=None)
    parser.add_argument(&quot;--cluster&quot;, type=str, default=&quot;ALL&quot;, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;, &quot;ALL&quot;])
    parser.add_argument(&quot;--workload-domain&quot;, type=str, default=None)
    return parser.parse_args()</p>
<p>def get_minio_client():
    return boto3.client(
        &quot;s3&quot;, endpoint_url=os.getenv(&quot;MINIO_ENDPOINT&quot;), aws_access_key_id=os.getenv(&quot;MINIO_ACCESS_KEY&quot;),
        aws_secret_access_key=os.getenv(&quot;MINIO_SECRET_KEY&quot;), region_name=&quot;us-east-1&quot;,
        config=boto3.session.Config(signature_version=&quot;s3v4&quot;)
    )</p>
<p>def sync_down_raw_from_minio(s3_client, bucket_name, start_dt, end_dt):
    chunk_delta = timedelta(hours=1)
    current_start = start_dt
    print(f&quot;\n📡 [AIStor 스마트 캐시 엔진] 원천 데이터 가용성 체크 시작...&quot;)</p>
<pre><code>while current_start &lt; end_dt:
    chunk_str = current_start.strftime(&quot;%Y%m%d_%H&quot;)
    f_name = f&quot;prom_raw_{chunk_str}.parquet&quot;
    object_key = f&quot;raw/{f_name}&quot;
    local_path = RAW_DIR / f_name

    if local_path.exists() and local_path.stat().st_size &gt; 0:
        print(f&quot;   ⚡ [Cache Hit] 로컬 캐시 자산 활용 (다운로드 스킵): {f_name}&quot;)
    else:
        try:
            s3_client.head_object(Bucket=bucket_name, Key=object_key)
            print(f&quot;   📥 [Cache Miss] AIStor 백업으로부터 다운로드: {object_key}&quot;)
            s3_client.download_file(bucket_name, object_key, str(local_path))
        except ClientError as e:
            if e.response[&#39;Error&#39;][&#39;Code&#39;] != &quot;404&quot;:
                print(f&quot;   ❌ AIStor 통신 실패 에러 ({f_name}): {str(e)}&quot;)
        except Exception as e:
            print(f&quot;   ⚠️ 일반 다운로드 예외 발생 ({f_name}): {str(e)}&quot;)

    current_start += chunk_delta</code></pre><p>def main():
    args = parse_arguments()
    s3_client = get_minio_client()
    bucket_name = os.getenv(&quot;MINIO_RAW_BUCKET&quot;, &quot;devops-test&quot;)
    now_kst = datetime.now(KST)</p>
<pre><code>if args.start_date and args.end_date:
    start_dt = datetime.strptime(args.start_date, &quot;%Y-%m-%d&quot;).replace(tzinfo=KST)
    end_dt = (datetime.strptime(args.end_date, &quot;%Y-%m-%d&quot;) + timedelta(days=1)).replace(tzinfo=KST)
elif args.days:
    start_dt = (now_kst - timedelta(days=args.days)).replace(hour=0, minute=0, second=0, microsecond=0)
    end_dt = now_kst
else:
    yesterday = (now_kst - timedelta(days=1)).date()
    start_dt = datetime.combine(yesterday, datetime.min.time()).replace(tzinfo=KST)
    end_dt = datetime.combine(now_kst.date(), datetime.min.time()).replace(tzinfo=KST)

sync_down_raw_from_minio(s3_client, bucket_name, start_dt, end_dt)

raw_files = list(RAW_DIR.glob(&quot;prom_raw_*.parquet&quot;))
if not raw_files:
    print(&quot;\n🚨 [중단] 정산 가동할 물리 원천 파일이 존재하지 않습니다.&quot;)
    return

print(f&quot;\n📊 [Polars Lazy Engine 기동] {len(raw_files)}개 소스 파티션 스캔 링킹...&quot;)
lf_raw = pl.scan_parquet(str(RAW_DIR / &quot;prom_raw_*.parquet&quot;))

start_naive = start_dt.replace(tzinfo=None)
end_naive = end_dt.replace(tzinfo=None)

lf_filtered = lf_raw.filter(
    (pl.col(&quot;timestamp&quot;) &gt;= start_naive) &amp; (pl.col(&quot;timestamp&quot;) &lt; end_naive)
).with_columns(
    pl.col(&quot;timestamp&quot;).dt.strftime(&quot;%Y%m%d&quot;).alias(&quot;date&quot;)
)

print(&quot;⏳ 대용량 인프라 정산 메트릭 다차원 분산 집계 중 (Rust Core 스레드 가동)...&quot;)
df_raw = lf_filtered.collect()

if df_raw.is_empty():
    print(&quot;⚠️  해당 기간 내 정산할 데이터 로우가 없습니다.&quot;)
    return

df_raw = df_raw.with_columns([
    pl.col(&quot;node&quot;).map_elements(
        lambda x: {&quot;cluster_type&quot;: classify_node_infrastructure(x)[0], &quot;workload_domain&quot;: classify_node_infrastructure(x)[1]},
        return_dtype=pl.Struct({&quot;cluster_type&quot;: pl.String, &quot;workload_domain&quot;: pl.String})
    ).alias(&quot;infra_struct&quot;),
    pl.col(&quot;pod&quot;).map_elements(get_workload_type, return_dtype=pl.String).alias(&quot;workload_type&quot;)
]).with_columns([
    pl.col(&quot;infra_struct&quot;).struct.field(&quot;cluster_type&quot;).alias(&quot;cluster_type&quot;),
    pl.col(&quot;infra_struct&quot;).struct.field(&quot;workload_domain&quot;).alias(&quot;workload_domain&quot;)
]).drop(&quot;infra_struct&quot;)

target_dates = df_raw[&quot;date&quot;].unique().to_list()
target_clusters = [&quot;COMPUTE&quot;, &quot;STORAGE&quot;] if args.cluster.upper() == &quot;ALL&quot; else [args.cluster.upper()]

print(f&quot;🎯 분할 연산 타겟 스케줄러 가동 -&gt; 일자 풀: {target_dates} | 클러스터 풀: {target_clusters}&quot;)

GB_DIV = 1024 ** 3

for date_chunk in target_dates:
    for cluster_chunk in target_clusters:

        df_slice = df_raw.filter((pl.col(&quot;date&quot;) == date_chunk) &amp; (pl.col(&quot;cluster_type&quot;) == cluster_chunk))
        if df_slice.is_empty():
            continue

        if args.workload_domain:
            df_slice = df_slice.filter(pl.col(&quot;workload_domain&quot;) == args.workload_domain)
            if df_slice.is_empty(): continue

        print(f&quot;   ⏳ [Polars 집계 연산] {date_chunk} ➡️ {cluster_chunk} 고성능 그룹바이 컴파일...&quot;)

        target_fields = [&quot;cpu_request&quot;, &quot;cpu_limit&quot;, &quot;cpu_usage&quot;, &quot;cpu_throttled&quot;, &quot;mem_request&quot;, &quot;mem_limit&quot;, &quot;mem_usage&quot;, &quot;mem_rss&quot;, &quot;oom_event&quot;, &quot;pv_capacity&quot;, &quot;pv_used&quot;]
        for col in target_fields:
            if col not in df_slice.columns:
                df_slice = df_slice.with_columns(pl.lit(0.0).alias(col))

        # [테이블 1] 상세 메트릭 원부 집계
        df_pod = df_slice.group_by([
            &quot;date&quot;, &quot;cluster_type&quot;, &quot;workload_domain&quot;, &quot;namespace&quot;, &quot;workload_type&quot;, &quot;node&quot;, &quot;pod&quot;, &quot;container&quot;
        ]).agg([
            pl.len().alias(&quot;minutes_running&quot;),
            pl.col(&quot;cpu_request&quot;).max().alias(&quot;cpu_request_max&quot;),
            pl.col(&quot;cpu_limit&quot;).max().alias(&quot;cpu_limit_max&quot;),
            pl.col(&quot;cpu_usage&quot;).quantile(0.95).alias(&quot;cpu_usage_p95&quot;),
            pl.col(&quot;cpu_throttled&quot;).max().alias(&quot;cpu_throttled_max&quot;),
            (pl.col(&quot;mem_request&quot;).max() / GB_DIV).alias(&quot;mem_request_max&quot;),
            (pl.col(&quot;mem_limit&quot;).max() / GB_DIV).alias(&quot;mem_limit_max&quot;),
            (pl.col(&quot;mem_usage&quot;).quantile(0.95) / GB_DIV).alias(&quot;mem_usage_p95&quot;),
            (pl.col(&quot;mem_rss&quot;).quantile(0.95) / GB_DIV).alias(&quot;mem_rss_p95&quot;),
            pl.col(&quot;oom_event&quot;).sum().alias(&quot;oom_strike_sum&quot;),
            (pl.col(&quot;pv_capacity&quot;).max() / GB_DIV).alias(&quot;pv_capacity_max&quot;),

            # 💡 [교정] pv_used_p95 대신 표준 네이밍 규약인 pv_usage_p95 로 엘리어싱하여 하방 ColumnNotFoundError 완벽 차단
            (pl.col(&quot;pv_used&quot;).quantile(0.95) / GB_DIV).alias(&quot;pv_usage_p95&quot;)
        ]).fill_null(0.0)

        df_pod = df_pod.with_columns([
            (pl.col(&quot;cpu_request_max&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;cpu_allocated_core_hours&quot;),
            (pl.col(&quot;cpu_usage_p95&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;cpu_usage_core_hours&quot;),
            (pl.col(&quot;mem_request_max&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;mem_allocated_gb_hours&quot;),
            (pl.col(&quot;mem_usage_p95&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;mem_usage_gb_hours&quot;),
            (pl.col(&quot;pv_capacity_max&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;pv_allocated_gb_hours&quot;),
            (pl.col(&quot;pv_usage_p95&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;pv_usage_gb_hours&quot;),
            (pl.col(&quot;oom_strike_sum&quot;) &gt; 0).alias(&quot;is_oom_killed&quot;),
            (pl.col(&quot;cpu_request_max&quot;) == 0).alias(&quot;has_no_request&quot;),
            (pl.col(&quot;cpu_limit_max&quot;) == 0).alias(&quot;has_no_limit&quot;),
            ((pl.col(&quot;cpu_usage_p95&quot;) - pl.col(&quot;cpu_request_max&quot;)).clip(lower_bound=0)).alias(&quot;cpu_shortage_cores&quot;)
        ]).with_columns([
            ((pl.col(&quot;cpu_allocated_core_hours&quot;) - pl.col(&quot;cpu_usage_core_hours&quot;)).clip(lower_bound=0)).alias(&quot;cpu_waste_core_hours&quot;),
            ((pl.col(&quot;mem_allocated_gb_hours&quot;) - pl.col(&quot;mem_usage_gb_hours&quot;)).clip(lower_bound=0)).alias(&quot;mem_waste_gb_hours&quot;),
            ((pl.col(&quot;pv_allocated_gb_hours&quot;) - pl.col(&quot;pv_usage_gb_hours&quot;)).clip(lower_bound=0)).alias(&quot;pv_waste_gb_hours&quot;),
        ])

        df_pod = df_pod.with_columns(
            pl.when(pl.col(&quot;is_oom_killed&quot;)).then(pl.lit(&quot;💥 OOM장애발생&quot;))
            .when((pl.col(&quot;cpu_shortage_cores&quot;) &gt; 0.5) | (pl.col(&quot;cpu_throttled_max&quot;) &gt; 0.2)).then(pl.lit(&quot;⚠️ Request부족&quot;))
            .when((pl.col(&quot;cpu_waste_core_hours&quot;) &gt; 10) | (pl.col(&quot;pv_waste_gb_hours&quot;) &gt; 50)).then(pl.lit(&quot;📉 과다할당&quot;))
            .otherwise(pl.lit(&quot;✅ 최적화완료&quot;)).alias(&quot;status&quot;)
        )

        # 📅 [테이블 2] Namespace 사용량 요약 빌드
        df_daily_ns = df_pod.group_by([&quot;date&quot;, &quot;namespace&quot;]).agg([
            pl.col(&quot;cpu_usage_core_hours&quot;).sum().alias(&quot;cpu_used_ch&quot;),
            pl.col(&quot;cpu_allocated_core_hours&quot;).sum().alias(&quot;cpu_alloc_ch&quot;),
            pl.col(&quot;cpu_waste_core_hours&quot;).sum().alias(&quot;cpu_waste_ch&quot;),
            pl.col(&quot;mem_usage_gb_hours&quot;).sum().alias(&quot;mem_used_gh&quot;),
            pl.col(&quot;mem_allocated_gb_hours&quot;).sum().alias(&quot;mem_alloc_gh&quot;),
            pl.col(&quot;pv_usage_gb_hours&quot;).sum().alias(&quot;pv_used_gh&quot;),
            pl.col(&quot;pv_allocated_gb_hours&quot;).sum().alias(&quot;pv_alloc_gh&quot;)
        ]).with_columns(
            ((pl.col(&quot;cpu_alloc_ch&quot;) * W_CPU) + (pl.col(&quot;mem_alloc_gh&quot;) * W_MEM) + (pl.col(&quot;pv_alloc_gh&quot;) * W_PV)).round(1).alias(&quot;final_usage_score&quot;)
        )

        # 📉 [테이블 3] 파레토 원부 롤업 빌드 
        df_ns = df_pod.group_by(&quot;namespace&quot;).agg([
            pl.col(&quot;cpu_waste_core_hours&quot;).sum().alias(&quot;total_waste_core_hours&quot;),
            pl.col(&quot;minutes_running&quot;).sum().alias(&quot;minutes_running_sum&quot;),
            pl.col(&quot;container&quot;).count().alias(&quot;container_cnt&quot;),
            pl.col(&quot;cpu_allocated_core_hours&quot;).sum().alias(&quot;total_allocated_core_hours&quot;)
        ]).sort(&quot;total_waste_core_hours&quot;, descending=True)

        global_total_waste = df_ns[&quot;total_waste_core_hours&quot;].sum() or 0.1
        df_ns = df_ns.with_columns([
            pl.lit(date_chunk).alias(&quot;date&quot;),
            ((pl.col(&quot;total_waste_core_hours&quot;) / global_total_waste) * 100).round(2).alias(&quot;waste_share_pct&quot;)
        ]).with_columns(
            pl.col(&quot;waste_share_pct&quot;).cum_sum().round(2).alias(&quot;waste_cumsum_pct&quot;)
        )

        # ─── 💾 [로컬 저장 명세 고정 바인딩] ───
        # 💡 모호한 INTEGRATED 구조가 배제되고 COMPUTE, STORAGE 등 고유 클러스터 서브디렉토리 위계로 정확히 격리 적재됩니다.
        cluster_partition_dir = MERGED_DIR / cluster_chunk
        cluster_partition_dir.mkdir(parents=True, exist_ok=True)

        target_output_file = cluster_partition_dir / f&quot;daily_enriched_{cluster_chunk}_{date_chunk}.parquet&quot;
        ns_output_file     = cluster_partition_dir / f&quot;daily_ns_usage_{cluster_chunk}_{date_chunk}.parquet&quot;
        pareto_output_file = cluster_partition_dir / f&quot;pareto_ns_{cluster_chunk}_{date_chunk}.parquet&quot;

        df_pod.write_parquet(target_output_file)
        df_daily_ns.write_parquet(ns_output_file)
        df_ns.write_parquet(pareto_output_file)

        print(f&quot;     💾 [로컬 저장] 파티션 3대 원부 컴파일 성료 (클러스터: {cluster_chunk} / 날짜: {date_chunk})&quot;)

        # ─── 📡 [AIStor 영구 적재 가드레일 주입] ───
        print(f&quot;     Submitting artifacts to AIStor Tables storage system...&quot;)
        try:
            # 💡 원격 AIStor(MinIO) 오브젝트 키 역시 클러스터 이름별 경로 구획을 완벽하게 동기화하여 뒤섞임을 원천 차단합니다.
            s3_client.upload_file(str(target_output_file), bucket_name, f&quot;merged/{cluster_chunk}/{target_output_file.name}&quot;)
            s3_client.upload_file(str(ns_output_file), bucket_name, f&quot;merged/{cluster_chunk}/{ns_output_file.name}&quot;)
            s3_client.upload_file(str(pareto_output_file), bucket_name, f&quot;merged/{cluster_chunk}/{pareto_output_file.name}&quot;)
            print(f&quot;     ✅ [AIStor 업로드 완수] 테이블 자산 적재 완료 -&gt; merged/{cluster_chunk}/{pareto_output_file.name}&quot;)
        except Exception as e:
            print(f&quot;     ❌ [AIStor 통신장애] 오브젝트 전송 실패: {str(e)}&quot;)

print(&quot;\n🏁 === [Step2 Polars 배치 완료] 로컬 및 AIStor 테이블 연동이 완벽히 마감되었습니다. ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y03b6]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y03b6</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y03b6</guid>
            <pubDate>Thu, 02 Jul 2026 23:46:20 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step6 Excel Builder v2 — KST 보정 + 차트 20개 + 워크로드별 분석 시트
[최종 통합 완결판: res_usage_ 전환 + devops-test 버킷 + 11대 메트릭 + 일단위 NS 정산 시트 탑재]
(MinIO 네이티브 모듈 전환 및 동적 파티션 격리 패치 완료)
&quot;&quot;&quot;
import os
import glob
import re
import sys
import pandas as pd
import numpy as np
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image as XLImage
from openpyxl.formatting.rule import ColorScaleRule, DataBarRule
from minio import Minio  # ◀ [boto3 대체 미니오 상용 모듈 탑재]</p>
<h1 id="───-📂-모든-디렉토리-경로-data-상대-경로-표준화-──────────────────────────">─── 📂 모든 디렉토리 경로 ./data 상대 경로 표준화 ──────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
MERGED_DIR    = BASE_DATA_DIR / &quot;merged&quot;
PLOT_DIR      = BASE_DATA_DIR / &quot;output&quot; / &quot;plots&quot;
OUT_DIR       = BASE_DATA_DIR / &quot;output&quot;</p>
<p>OUT_DIR.mkdir(parents=True, exist_ok=True)</p>
<p>C = dict(
    hdr_dark=&quot;1F4E79&quot;, hdr_mid=&quot;2E75B6&quot;, hdr_light=&quot;BDD7EE&quot;,
    accent=&quot;ED7D31&quot;, red=&quot;C00000&quot;, green=&quot;70AD47&quot;,
    yellow=&quot;FFC000&quot;, gray_row=&quot;F2F2F2&quot;, white=&quot;FFFFFF&quot;,
    border=&quot;9DC3E6&quot;, summary_bg=&quot;EBF3FB&quot;, purple=&quot;7030A0&quot;,
    teal=&quot;008080&quot;,
)</p>
<p>def ft(bold=False, size=10, color=&quot;000000&quot;, name=&quot;Arial&quot;):
    return Font(name=name, bold=bold, size=size, color=color)</p>
<p>def fill(hex_color):
    return PatternFill(&quot;solid&quot;, fgColor=hex_color)</p>
<p>def thin_border():
    t = Side(style=&quot;thin&quot;, color=C[&quot;border&quot;])
    return Border(left=t, right=t, top=t, bottom=t)</p>
<p>def center(wrap=False):
    return Alignment(horizontal=&quot;center&quot;, vertical=&quot;center&quot;, wrap_text=wrap)</p>
<p>def left(wrap=False):
    return Alignment(horizontal=&quot;left&quot;, vertical=&quot;center&quot;, wrap_text=wrap)</p>
<p>def set_col_widths(ws, widths):
    for col, w in widths.items():
        ws.column_dimensions[col].width = w</p>
<p>def apply_header_row(ws, row_idx, headers, bg=None, fg=C[&quot;white&quot;], size=10):
    bg = bg or C[&quot;hdr_dark&quot;]
    for c, h in enumerate(headers, 1):
        cell = ws.cell(row=row_idx, column=c, value=h)
        cell.font = ft(bold=True, size=size, color=fg)
        cell.fill = fill(bg)
        cell.alignment = center(wrap=True)
        cell.border = thin_border()</p>
<p>def apply_data_rows(ws, df, start_row, num_formats=None, status_col_idx=None, zebra=True):
    nf = num_formats or {}
    for r_offset, row in enumerate(df.itertuples(index=False)):
        row_num = start_row + r_offset
        bg_hex = C[&quot;gray_row&quot;] if (r_offset % 2 == 1 and zebra) else C[&quot;white&quot;]
        for c_idx, val in enumerate(row, 1):
            cell = ws.cell(row=row_num, column=c_idx, value=val)
            cell.font = ft(size=9)
            cell.fill = fill(bg_hex)
            cell.alignment = left()
            cell.border = thin_border()
            if c_idx in nf:
                cell.number_format = nf[c_idx]
            if status_col_idx and c_idx == status_col_idx:
                v = str(val)
                if &quot;OOM&quot; in v or &quot;Killed&quot; in v:
                    cell.fill = fill(&quot;FFCCCC&quot;); cell.font = ft(bold=True, size=9, color=C[&quot;red&quot;])
                elif &quot;Shortage&quot; in v or &quot;부족&quot; in v:
                    cell.fill = fill(&quot;FFF2CC&quot;); cell.font = ft(bold=True, size=9, color=&quot;7F6000&quot;)
                elif &quot;Over&quot; in v or &quot;과다&quot; in v:
                    cell.fill = fill(&quot;DDEEFF&quot;); cell.font = ft(bold=True, size=9, color=C[&quot;hdr_dark&quot;])
                elif &quot;Optim&quot; in v or &quot;최적&quot; in v:
                    cell.fill = fill(&quot;E2EFDA&quot;); cell.font = ft(bold=True, size=9, color=&quot;375623&quot;)
    return start_row + len(df)</p>
<p>def freeze_and_filter(ws, row=2):
    ws.freeze_panes = ws.cell(row=row+1, column=1)
    ws.auto_filter.ref = ws.dimensions</p>
<h1 id="💡-교정-차트-픽업-시-클러스터별-하위-폴더infra_tag를-조준하도록-경로-인자-주입">💡 [교정] 차트 픽업 시 클러스터별 하위 폴더(infra_tag)를 조준하도록 경로 인자 주입</h1>
<p>def add_chart_image(ws, path_key, anchor_cell, infra_tag, w=860, h=400, label=None):
    path = PLOT_DIR / infra_tag / path_key
    if not path.exists():
        print(f&quot;  ⚠️  [차트 유실] {path_key} 파일이 {PLOT_DIR / infra_tag}에 없어 삽입을 건너뜁니다.&quot;)
        return
    row_num = int(&#39;&#39;.join(filter(str.isdigit, anchor_cell)))
    if label:
        col_letter = &quot;&quot;.join(filter(str.isalpha, anchor_cell))
        col_idx = 1 if col_letter == &quot;A&quot; else (9 if col_letter == &quot;I&quot; else 1)
        ws.cell(row=row_num-1, column=col_idx, value=label).font = ft(bold=True, size=11, color=C[&quot;hdr_dark&quot;])
    img = XLImage(str(path))
    img.width = w; img.height = h
    ws.add_image(img, anchor_cell)
    print(f&quot;  -&gt; 🎨 차트 맵핑 완료: {path_key} ➡️ {anchor_cell} 셀 ({infra_tag} 파티션)&quot;)</p>
<h1 id="───-sheet-0-전사-종합-요약-───────────────────────────────">─── Sheet 0: 전사 종합 요약 ───────────────────────────────</h1>
<p>def build_sheet_summary(wb, df_pod, df_ns, infra_tag):
    print(f&quot;⏳ [0/9] &#39;0. 전사종합요약&#39; 대시보드 탭 구축 개시... ({infra_tag})&quot;)
    ws = wb.active
    ws.title = &quot;0. 전사종합요약&quot;
    ws.sheet_view.showGridLines = False
    ws.row_dimensions[1].height = 42</p>
<pre><code>ws.merge_cells(&quot;A1:H1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Resource Governance Master Report [{infra_tag}]  (KST 기준)&quot;
t.font = ft(bold=True, size=15, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()

oom_cnt    = int(df_pod[&quot;is_oom_killed&quot;].sum())
no_req_cnt = int((df_pod[&quot;has_no_request&quot;] | df_pod[&quot;has_no_limit&quot;]).sum())
alloc_ch   = df_pod[&quot;cpu_allocated_core_hours&quot;].sum()
usage_ch   = df_pod[&quot;cpu_usage_core_hours&quot;].sum()
waste_ch   = df_pod[&quot;cpu_waste_core_hours&quot;].sum()
eff_pct    = usage_ch / max(alloc_ch, 0.001) * 100

mem_waste  = df_pod[&quot;mem_waste_gb_hours&quot;].sum()
pv_waste   = df_pod[&quot;pv_waste_gb_hours&quot;].sum()
throttle_risks = int((df_pod[&quot;cpu_throttled_max&quot;] &gt; 0.2).sum())
kst_dates  = sorted(df_pod[&quot;date&quot;].unique())
date_range = f&quot;{kst_dates[0]} ~ {kst_dates[-1]} (KST)&quot;

kpis = [
    (&quot;정산 대상 인프라 도메인&quot;,       infra_tag,                              &quot;002060&quot;,      C[&quot;white&quot;]),
    (&quot;분석 기간 (KST)&quot;,              date_range,                             C[&quot;hdr_dark&quot;], C[&quot;white&quot;]),
    (&quot;총 관측 컨테이너 볼륨&quot;,         f&quot;{len(df_pod):,} 개&quot;,                C[&quot;hdr_dark&quot;], C[&quot;white&quot;]),
    (&quot;OOMKilled 장애 컨테이너&quot;,       f&quot;{oom_cnt:,} 개&quot;,                    C[&quot;red&quot;],      C[&quot;white&quot;]),
    (&quot;연산 스로틀링 위험군 컨테이너&quot;,   f&quot;{throttle_risks:,} 개&quot;,                 &quot;C00000&quot;,      C[&quot;white&quot;]),
    (&quot;리소스 설정 규격 위반군&quot;,       f&quot;{no_req_cnt:,} 개&quot;,                  &quot;E26B0A&quot;,      C[&quot;white&quot;]),
    (&quot;전사 CPU 무효 선점 낭비량&quot;,     f&quot;{waste_ch:,.1f} Core-H&quot;,            C[&quot;hdr_mid&quot;],  C[&quot;white&quot;]),
    (&quot;전사 Memory 무효 선점 낭비량&quot;,   f&quot;{mem_waste:,.1f} GB-H&quot;,             &quot;6B4F9B&quot;,      C[&quot;white&quot;]),
    (&quot;전사 PV 스토리지 알박기 낭비량&quot;, f&quot;{pv_waste:,.1f} GB-H&quot;,              &quot;008080&quot;,      C[&quot;white&quot;]),
    (&quot;전사 CPU 평균 실효 활용률&quot;,     f&quot;{eff_pct:.1f} %&quot;,                    &quot;375623&quot;,      C[&quot;white&quot;]),
]

for i, (label, value, bg, fg) in enumerate(kpis):
    row = 3 + i
    ws.row_dimensions[row].height = 24
    lc = ws.cell(row=row, column=1, value=label)
    lc.font = ft(bold=True, size=10, color=C[&quot;hdr_dark&quot;])
    lc.fill = fill(C[&quot;summary_bg&quot;]); lc.alignment = left(); lc.border = thin_border()
    ws.merge_cells(f&quot;A{row}:C{row}&quot;)
    vc = ws.cell(row=row, column=4, value=value)
    vc.font = ft(bold=True, size=11, color=fg)
    vc.fill = fill(bg); vc.alignment = center(); vc.border = thin_border()
    ws.merge_cells(f&quot;D{row}:F{row}&quot;)

set_col_widths(ws, {&quot;A&quot;:28,&quot;B&quot;:14,&quot;C&quot;:14,&quot;D&quot;:22,&quot;E&quot;:14,&quot;F&quot;:14,&quot;G&quot;:20,&quot;H&quot;:20})

chart_row = 16
add_chart_image(ws, &quot;chart6_status_donut.png&quot;,   f&quot;A{chart_row}&quot;, infra_tag, w=420, h=300)
add_chart_image(ws, &quot;chart5_pareto_ns_waste.png&quot;, f&quot;E{chart_row}&quot;, infra_tag, w=560, h=300)</code></pre><h1 id="───-sheet-1-파레토-분석-ns-──────────────────────────────">─── Sheet 1: 파레토 분석 NS ──────────────────────────────</h1>
<p>def build_sheet_pareto(wb, df_ns, infra_tag):
    print(f&quot;⏳ [1/9] &#39;1. 파레토분석_NS&#39; 시트 렌더링 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;1. 파레토분석_NS&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:I1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Namespace별 CPU Waste 파레토 분석 [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;Namespace&quot;,&quot;실행시간 합계(분)&quot;,&quot;컨테이너 수&quot;,&quot;할당 Core-H&quot;,&quot;낭비 Core-H&quot;,&quot;낭비 비중(%)&quot;,&quot;누적 비중(%)&quot;,&quot;등급&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])

df_disp = df_ns.copy()
df_disp[&quot;등급&quot;] = df_disp[&quot;waste_cumsum_pct&quot;].apply(
    lambda x: &quot;Critical (Top 20%)&quot; if x&lt;=20 else (&quot;High (Top 50%)&quot; if x&lt;=50 else (&quot;Medium&quot; if x&lt;=80 else &quot;Low&quot;)))
col_order = [&quot;namespace&quot;,&quot;minutes_running_sum&quot;,&quot;container_cnt&quot;,&quot;total_allocated_core_hours&quot;,&quot;total_waste_core_hours&quot;,&quot;waste_share_pct&quot;,&quot;waste_cumsum_pct&quot;,&quot;등급&quot;]
df_out = df_disp[col_order]
nf = {4:&quot;#,##0.0&quot;, 5:&quot;#,##0.0&quot;, 6:&quot;0.00&quot;, 7:&quot;0.00&quot;}
end_row = apply_data_rows(ws, df_out, start_row=3, num_formats=nf)

ws.conditional_formatting.add(f&quot;E3:E{end_row}&quot;, DataBarRule(start_type=&quot;min&quot;, end_type=&quot;max&quot;, color=&quot;2E75B6&quot;, showValue=True))
set_col_widths(ws, {&quot;A&quot;:22,&quot;B&quot;:18,&quot;C&quot;:14,&quot;D&quot;:16,&quot;E&quot;:16,&quot;F&quot;:12,&quot;G&quot;:12,&quot;H&quot;:22})
freeze_and_filter(ws)</code></pre><h1 id="───-🎉-sheet-1-2-일단위-namespace별-자원-사용량-탭-───────────────────">─── 🎉 Sheet 1-2: 일단위 Namespace별 자원 사용량 탭 ───────────────────</h1>
<p>def build_sheet_ns_daily(wb, df_daily_ns, infra_tag):
    print(f&quot;⏳ [2/9] &#39;1-2. 일단위_NS별_사용량&#39; 종합 청산 장표 마크 중... (행 수: {len(df_daily_ns):,}건)&quot;)
    ws = wb.create_sheet(&quot;1-2. 일단위_NS별_사용량&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:J1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Namespace 일일 자원 소모량 및 통합 사용량 점수 지표 [{infra_tag}]&quot;
t.font = ft(bold=True, size=12, color=C[&quot;white&quot;])
t.fill = fill(&quot;1F4E79&quot;); t.alignment = center()
ws.row_dimensions[1].height = 35

headers = [
    &quot;관측 일자(KST)&quot;, &quot;네임스페이스(Namespace)&quot;, 
    &quot;CPU 사용(Core-H)&quot;, &quot;CPU 할당(Core-H)&quot;, &quot;CPU 낭비(Core-H)&quot;,
    &quot;Mem 사용(GB-H)&quot;, &quot;Mem 할당(GB-H)&quot;,
    &quot;PV 사용(GB-H)&quot;, &quot;PV 할당(GB-H)&quot;, &quot;통합 사용량 점수 (Score)&quot;
]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])
ws.row_dimensions[2].height = 26

col_order = [&quot;date&quot;, &quot;namespace&quot;, &quot;cpu_used_ch&quot;, &quot;cpu_alloc_ch&quot;, &quot;cpu_waste_ch&quot;, &quot;mem_used_gh&quot;, &quot;mem_alloc_gh&quot;, &quot;pv_used_gh&quot;, &quot;pv_alloc_gh&quot;, &quot;final_usage_score&quot;]
df_out = df_daily_ns[col_order].sort_values(by=[&quot;date&quot;, &quot;final_usage_score&quot;], ascending=[True, False])

nf = {3:&quot;#,##0.0&quot;, 4:&quot;#,##0.0&quot;, 5:&quot;#,##0.0&quot;, 6:&quot;#,##0.0&quot;, 7:&quot;#,##0.0&quot;, 8:&quot;#,##0.0&quot;, 9:&quot;#,##0.0&quot;, 10:&quot;#,##0.0&quot;}
end_row = apply_data_rows(ws, df_out, start_row=3, num_formats=nf)

ws.conditional_formatting.add(f&quot;J3:J{end_row}&quot;, DataBarRule(start_type=&quot;min&quot;, end_type=&quot;max&quot;, color=&quot;375623&quot;, showValue=True))

widths = {&quot;A&quot;:14, &quot;B&quot;:22, &quot;C&quot;:18, &quot;D&quot;:18, &quot;E&quot;:18, &quot;F&quot;:18, &quot;G&quot;:18, &quot;H&quot;:18, &quot;I&quot;:18, &quot;J&quot;:24}
set_col_widths(ws, widths)
freeze_and_filter(ws, row=2)</code></pre><h1 id="───-sheet-2-cpu-분석-────────────────────────────────────">─── Sheet 2: CPU 분석 ────────────────────────────────────</h1>
<p>def build_sheet_cpu(wb, df_pod, infra_tag):
    top30 = max(1, int(len(df_pod)*0.30))
    print(f&quot;⏳ [3/9] &#39;2. CPU Request_Usage 분석&#39; 시트 빌드 중... (상위 30% 격리: {top30}행)&quot;)
    ws = wb.create_sheet(&quot;2. CPU Request_Usage 분석&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:M1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;CPU Resource Efficiency Analysis [{infra_tag}] — Request / Limit / Usage / Throttling&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;CPU Request&quot;,&quot;CPU Limit&quot;,&quot;CPU P95&quot;,&quot;Throttle Peak&quot;,&quot;활용률(%)&quot;,&quot;낭비 Core-H&quot;,&quot;상태&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])

df_out = df_pod.sort_values(&quot;cpu_waste_core_hours&quot;, ascending=False).head(top30).copy()
df_out[&quot;util&quot;] = np.where(df_out[&quot;cpu_request_max&quot;]&gt;0, (df_out[&quot;cpu_usage_p95&quot;]/df_out[&quot;cpu_request_max&quot;]*100).round(1), 0)
df_out[&quot;status_en&quot;] = df_out[&quot;status&quot;].map({
    &quot;💥 OOM장애발생&quot;:&quot;OOM Killed&quot;,&quot;⚠️ Request부족&quot;:&quot;Request Shortage&quot;,
    &quot;📉 과다할당&quot;:&quot;Over-allocated&quot;,&quot;✅ 최적화완료&quot;:&quot;Optimized&quot;}).fillna(&quot;Unknown&quot;)

cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;cpu_request_max&quot;,&quot;cpu_limit_max&quot;,&quot;cpu_usage_p95&quot;,&quot;cpu_throttled_max&quot;,&quot;util&quot;,&quot;cpu_waste_core_hours&quot;,&quot;status_en&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {7:&quot;0.000&quot;,8:&quot;0.000&quot;,9:&quot;0.000&quot;,10:&quot;0.000&quot;,11:&quot;0.0&quot;,12:&quot;#,##0.0&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf, status_col_idx=13)

ws.conditional_formatting.add(f&quot;K3:K{end_row}&quot;, ColorScaleRule(start_type=&quot;num&quot;, start_value=0, start_color=&quot;FF0000&quot;, mid_type=&quot;num&quot;, mid_value=50, mid_color=&quot;FFFF00&quot;, end_type=&quot;num&quot;, end_value=100, end_color=&quot;00B050&quot;))
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:16,&quot;C&quot;:18,&quot;D&quot;:16,&quot;E&quot;:26,&quot;F&quot;:14,&quot;G&quot;:12,&quot;H&quot;:12,&quot;I&quot;:12,&quot;J&quot;:14,&quot;K&quot;:12,&quot;L&quot;:14,&quot;M&quot;:16})
freeze_and_filter(ws)

add_chart_image(ws, &quot;chart1_cpu_req_vs_usage_by_workload.png&quot;, f&quot;A{end_row+3}&quot;, infra_tag, w=860, h=400, label=&quot;[ CPU Request / Limit / P95 by Workload ]&quot;)
add_chart_image(ws, &quot;chart9_boxplot_cpu_util_by_workload.png&quot;,  f&quot;A{end_row+26}&quot;, infra_tag, w=860, h=400, label=&quot;[ CPU Utilization Boxplot by Workload ]&quot;)</code></pre><h1 id="───-sheet-3-memory-및-pv-스토리지-분석-─────────────────────────────────">─── Sheet 3: Memory 및 PV 스토리지 분석 ─────────────────────────────────</h1>
<p>def build_sheet_memory(wb, df_pod, infra_tag):
    top30 = max(1, int(len(df_pod)*0.30))
    print(f&quot;⏳ [4/9] &#39;3. Memory_PV 입체분석&#39; 고도화 시트 로딩 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;3. Memory_PV 입체분석&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:O1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Memory RSS &amp; Persistent Volume Storage Cross-Governance Analysis [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;Mem Request&quot;,&quot;Mem P95&quot;,&quot;Mem RSS&quot;,&quot;PV Cap(GB)&quot;,&quot;PV Used(GB)&quot;,&quot;PV Waste(GB-H)&quot;,&quot;활용률(%)&quot;,&quot;낭비 GB-H&quot;,&quot;상태&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])

df_out = df_pod.sort_values(by=[&quot;mem_waste_gb_hours&quot;, &quot;pv_waste_gb_hours&quot;], ascending=[False, False]).head(top30).copy()
df_out[&quot;util&quot;] = np.where(df_out[&quot;mem_request_max&quot;]&gt;0, (df_out[&quot;mem_usage_p95&quot;]/df_out[&quot;mem_request_max&quot;]*100).round(1), 0)
df_out[&quot;status_en&quot;] = df_out[&quot;status&quot;].map({
    &quot;💥 OOM장애발생&quot;:&quot;OOM Killed&quot;,&quot;⚠️ Request부족&quot;:&quot;Request Shortage&quot;,
    &quot;📉 과다할당&quot;:&quot;Over-allocated&quot;,&quot;✅ 최적화완료&quot;:&quot;Optimized&quot;}).fillna(&quot;Unknown&quot;)

cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;mem_request_max&quot;,&quot;mem_usage_p95&quot;,&quot;mem_rss_p95&quot;,&quot;pv_capacity_max&quot;,&quot;pv_used_p95&quot;,&quot;pv_waste_gb_hours&quot;,&quot;util&quot;,&quot;mem_waste_gb_hours&quot;,&quot;status_en&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {7:&quot;0.00&quot;, 8:&quot;0.00&quot;, 9:&quot;0.00&quot;, 10:&quot;0.00&quot;, 11:&quot;0.00&quot;, 12:&quot;#,##0.0&quot;, 13:&quot;0.0&quot;, 14:&quot;#,##0.0&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf, status_col_idx=15)

ws.conditional_formatting.add(f&quot;M3:M{end_row}&quot;, ColorScaleRule(start_type=&quot;num&quot;, start_value=0, start_color=&quot;FF0000&quot;, mid_type=&quot;num&quot;, mid_value=50, mid_color=&quot;FFFF00&quot;, end_type=&quot;num&quot;, end_value=100, end_color=&quot;00B050&quot;))
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:16,&quot;C&quot;:18,&quot;D&quot;:16,&quot;E&quot;:26,&quot;F&quot;:14,&quot;G&quot;:12,&quot;H&quot;:12,&quot;I&quot;:12,&quot;J&quot;:12,&quot;K&quot;:12,&quot;L&quot;:14,&quot;M&quot;:12,&quot;N&quot;:12,&quot;O&quot;:16})
freeze_and_filter(ws)

add_chart_image(ws, &quot;chart2_mem_req_vs_usage_by_workload.png&quot;, f&quot;A{end_row+3}&quot;, infra_tag, w=860, h=400, label=&quot;[ Memory Specs vs WorkingSet vs RSS Peak ]&quot;)
add_chart_image(ws, &quot;chart20_daily_mem_per_workload.png&quot;, f&quot;A{end_row+26}&quot;, infra_tag, w=960, h=540, label=&quot;[ Daily Memory &amp; PV Provisioning Trends ]&quot;)</code></pre><h1 id="───-sheet-4-oom-및-자원-부족-───────────────────────────">─── Sheet 4: OOM 및 자원 부족 ───────────────────────────</h1>
<p>def build_sheet_oom(wb, df_pod, infra_tag):
    print(f&quot;⏳ [5/9] &#39;4. 자원부족및OOM장애군&#39; 리스크 인덱스 추출 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;4. 자원부족및OOM장애군&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:L1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;OOMKilled / CPU CFS Throttled 병목 위험 워크로드 명세 [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;red&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;상태&quot;,&quot;CPU Request&quot;,&quot;CPU P95&quot;,&quot;Throttle Peak&quot;,&quot;Mem Limit(GB)&quot;,&quot;Mem P95(GB)&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;red&quot;])

df_out = df_pod[
    (df_pod[&quot;cpu_shortage_cores&quot;]&gt;0) | (df_pod[&quot;is_oom_killed&quot;]) | (df_pod[&quot;cpu_throttled_max&quot;] &gt; 0.2)
].sort_values([&quot;is_oom_killed&quot;,&quot;cpu_throttled_max&quot;,&quot;cpu_shortage_cores&quot;], ascending=[False,False,False]).copy()

df_out[&quot;status_en&quot;] = df_out[&quot;status&quot;].map({
    &quot;💥 OOM장애발생&quot;:&quot;OOM Killed&quot;,&quot;⚠️ Request부족&quot;:&quot;Request Shortage&quot;,
    &quot;📉 과다할당&quot;:&quot;Over-allocated&quot;,&quot;✅ 최적화완료&quot;:&quot;Optimized&quot;}).fillna(&quot;Unknown&quot;)

cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;status_en&quot;,&quot;cpu_request_max&quot;,&quot;cpu_usage_p95&quot;,&quot;cpu_throttled_max&quot;,&quot;mem_limit_max&quot;,&quot;mem_usage_p95&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {8:&quot;0.000&quot;,9:&quot;0.000&quot;,10:&quot;0.000&quot;,11:&quot;0.00&quot;,12:&quot;0.00&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf, status_col_idx=7)
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:18,&quot;C&quot;:20,&quot;D&quot;:18,&quot;E&quot;:28,&quot;F&quot;:16,&quot;G&quot;:16,&quot;H&quot;:12,&quot;I&quot;:12,&quot;J&quot;:14,&quot;K&quot;:14,&quot;L&quot;:14})
freeze_and_filter(ws)

add_chart_image(ws, &quot;chart8_shortfall_footprint.png&quot;, f&quot;A{end_row+3}&quot;, infra_tag, w=860, h=400, label=&quot;[ CPU CFS Throttling Risk Heatmap Footprint ]&quot;)</code></pre><h1 id="───-sheet-5-리소스-미설정-위반-─────────────────────────">─── Sheet 5: 리소스 미설정 위반 ─────────────────────────</h1>
<p>def build_sheet_violations(wb, df_pod, infra_tag):
    print(f&quot;⏳ [6/9] &#39;5. 리소스미설정위반군&#39; 규격 미설정 목록 마크 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;5. 리소스미설정위반군&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:L1&quot;)
t = ws[&quot;A1&quot;]
t.value = &quot;Resource Request / Limit 미설정 위반 컨테이너 (KST)&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(&quot;E26B0A&quot;); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;Request 미설정&quot;,&quot;Limit 미설정&quot;,&quot;CPU Request&quot;,&quot;CPU Limit&quot;,&quot;Mem Request(GB)&quot;,&quot;Mem Limit(GB)&quot;]
apply_header_row(ws, 2, headers, bg=&quot;E26B0A&quot;)

df_out = df_pod[df_pod[&quot;has_no_request&quot;]|df_pod[&quot;has_no_limit&quot;]].sort_values(&quot;minutes_running&quot;, ascending=False).copy()
df_out[&quot;req_flag&quot;] = df_out[&quot;has_no_request&quot;].map({True:&quot;MISSING&quot;,False:&quot;OK&quot;})
df_out[&quot;lim_flag&quot;] = df_out[&quot;has_no_limit&quot;].map({True:&quot;MISSING&quot;,False:&quot;OK&quot;})
cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;req_flag&quot;,&quot;lim_flag&quot;,&quot;cpu_request_max&quot;,&quot;cpu_limit_max&quot;,&quot;mem_request_max&quot;,&quot;mem_limit_max&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {9:&quot;0.000&quot;,10:&quot;0.000&quot;,11:&quot;0.00&quot;,12:&quot;0.00&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf)
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:18,&quot;C&quot;:20,&quot;D&quot;:18,&quot;E&quot;:28,&quot;F&quot;:16,&quot;G&quot;:14,&quot;H&quot;:14,&quot;I&quot;:14,&quot;J&quot;:14,&quot;K&quot;:14,&quot;L&quot;:14})
freeze_and_filter(ws)</code></pre><h1 id="───-sheet-6-일별-트렌드-kst-──────────────────────────">─── Sheet 6: 일별 트렌드 (KST) ──────────────────────────</h1>
<p>def build_sheet_trends(wb, df_pod, infra_tag):
    print(f&quot;⏳ [7/9] &#39;6. 일별트렌드_KST&#39; 누적 통계 분석 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;6. 일별트렌드_KST&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:M1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Daily Resource Governance &amp; Loss Trend [{infra_tag}] — KST&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

df_daily = df_pod.groupby(&quot;date&quot;).agg(
    containers=(&quot;container&quot;,&quot;count&quot;),
    cpu_alloc=(&quot;cpu_allocated_core_hours&quot;,&quot;sum&quot;),
    cpu_used=(&quot;cpu_usage_core_hours&quot;,&quot;sum&quot;),
    cpu_waste=(&quot;cpu_waste_core_hours&quot;,&quot;sum&quot;),
    mem_alloc=(&quot;mem_allocated_gb_hours&quot;,&quot;sum&quot;),
    mem_waste=(&quot;mem_waste_gb_hours&quot;,&quot;sum&quot;),
    pv_alloc=(&quot;pv_allocated_gb_hours&quot;,&quot;sum&quot;),
    pv_waste_sum=(&quot;pv_waste_gb_hours&quot;,&quot;sum&quot;),
    oom_cnt=(&quot;is_oom_killed&quot;,&quot;sum&quot;),
    shortage_cnt=(&quot;cpu_shortage_cores&quot;, lambda x: (x&gt;0).sum()),
).reset_index()

df_daily[&quot;cpu_util_pct&quot;] = (df_daily[&quot;cpu_used&quot;]/df_daily[&quot;cpu_alloc&quot;].clip(lower=0.001)*100).round(1)
df_daily[&quot;mem_util_pct&quot;] = ((df_daily[&quot;mem_alloc&quot;]-df_daily[&quot;mem_waste&quot;])/df_daily[&quot;mem_alloc&quot;].clip(lower=0.001)*100).round(1)

headers = [&quot;날짜(KST)&quot;,&quot;컨테이너 볼륨&quot;,&quot;CPU 할당 Core-H&quot;,&quot;CPU 사용 Core-H&quot;,&quot;CPU 낭비 Core-H&quot;,
           &quot;Mem 할당 GB-H&quot;,&quot;Mem 낭비 GB-H&quot;,&quot;PV 할당 GB-H&quot;,&quot;PV 낭비 GB-H&quot;,&quot;OOM 사살&quot;,&quot;자원부족군&quot;,&quot;CPU활용률(%)&quot;,&quot;Mem활용률(%)&quot;]
apply_header_row(ws, 3, headers, bg=C[&quot;hdr_mid&quot;])

nf = {3:&quot;#,##0.0&quot;,4:&quot;#,##0.0&quot;,5:&quot;#,##0.0&quot;,6:&quot;#,##0.0&quot;,7:&quot;#,##0.0&quot;,8:&quot;#,##0.0&quot;,9:&quot;#,##0.0&quot;,10:&quot;#,##0&quot;,11:&quot;#,##0&quot;,12:&quot;0.1&quot;,13:&quot;0.1&quot;}
df_disp = df_daily[[&quot;date&quot;,&quot;containers&quot;,&quot;cpu_alloc&quot;,&quot;cpu_used&quot;,&quot;cpu_waste&quot;,
                     &quot;mem_alloc&quot;,&quot;mem_waste&quot;,&quot;pv_alloc&quot;,&quot;pv_waste_sum&quot;,&quot;oom_cnt&quot;,&quot;shortage_cnt&quot;,&quot;cpu_util_pct&quot;,&quot;mem_util_pct&quot;]]
end_row = apply_data_rows(ws, df_disp, start_row=4, num_formats=nf)

set_col_widths(ws, {&quot;A&quot;:14,&quot;B&quot;:12,&quot;C&quot;:16,&quot;D&quot;:16,&quot;E&quot;:16,&quot;F&quot;:16,&quot;G&quot;:14,&quot;H&quot;:16,&quot;I&quot;:14,&quot;J&quot;:12,&quot;K&quot;:12,&quot;L&quot;:14,&quot;M&quot;:14})
freeze_and_filter(ws, row=4)

add_chart_image(ws, &quot;chart3_daily_waste_stack.png&quot;, f&quot;A{end_row+3}&quot;, infra_tag, 860, 400, &quot;[ Daily CPU Waste Stacked Timeline ]&quot;)
add_chart_image(ws, &quot;chart4_cpu_efficiency_heatmap.png&quot;, f&quot;A{end_row+26}&quot;, infra_tag, 860, 400, &quot;[ CPU Utilization Heatmap Grid ]&quot;)</code></pre><h1 id="───-sheet-7-워크로드별-심층-분석-─────────────────">─── Sheet 7: 워크로드별 심층 분석 ─────────────────</h1>
<p>def build_sheet_workload(wb, df_pod, infra_tag):
    print(f&quot;⏳ [8/9] &#39;7. 워크로드별_심층분석&#39; 오픈소스 스택 분석 표 작성 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;7. 워크로드별_심층분석&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:N1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Workload Type별 오픈소스 기술 스택 심층 자원 효율화 분석 [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;purple&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

df_wl = df_pod.groupby(&quot;workload_type&quot;).agg(
    containers=(&quot;container&quot;,&quot;count&quot;),
    cpu_req_avg=(&quot;cpu_request_max&quot;,&quot;mean&quot;),
    cpu_p95_avg=(&quot;cpu_usage_p95&quot;,&quot;mean&quot;),
    cpu_waste_sum=(&quot;cpu_waste_core_hours&quot;,&quot;sum&quot;),
    cpu_throttle_pk=(&quot;cpu_throttled_max&quot;,&quot;max&quot;),
    mem_req_avg=(&quot;mem_request_max&quot;,&quot;mean&quot;),
    mem_p95_avg=(&quot;mem_usage_p95&quot;,&quot;mean&quot;),
    mem_rss_avg=(&quot;mem_rss_p95&quot;,&quot;mean&quot;),
    mem_waste_sum=(&quot;mem_waste_gb_hours&quot;,&quot;sum&quot;),
    pv_capacity_avg=(&quot;pv_capacity_max&quot;,&quot;mean&quot;),
    pv_waste_sum=(&quot;pv_waste_gb_hours&quot;,&quot;sum&quot;),
    oom_cnt=(&quot;is_oom_killed&quot;,&quot;sum&quot;)
).reset_index()

df_wl[&quot;cpu_util_pct&quot;] = (df_wl[&quot;cpu_p95_avg&quot;]/df_wl[&quot;cpu_req_avg&quot;].replace(0,np.nan)*100).round(1).fillna(0)
df_wl[&quot;mem_util_pct&quot;] = (df_wl[&quot;mem_p95_avg&quot;]/df_wl[&quot;mem_req_avg&quot;].replace(0,np.nan)*100).round(1).fillna(0)
df_wl = df_wl.sort_values(&quot;cpu_waste_sum&quot;, ascending=False).reset_index(drop=True)

headers2 = [&quot;워크로드 타입&quot;,&quot;컨테이너 수&quot;,&quot;CPU Req(avg)&quot;,&quot;CPU P95(avg)&quot;,&quot;CPU 낭비 총합&quot;,&quot;Throttle Peak&quot;,
            &quot;Mem Req(avg)&quot;,&quot;Mem P95(avg)&quot;,&quot;Mem RSS(avg)&quot;,&quot;Mem 낭비 총합&quot;,&quot;PV Cap(avg)&quot;,&quot;PV 낭비 총합&quot;,&quot;CPU활용(%)&quot;,&quot;Mem활용(%)&quot;]
apply_header_row(ws, 3, headers2, bg=C[&quot;purple&quot;])

cols_out = [&quot;workload_type&quot;,&quot;containers&quot;,&quot;cpu_req_avg&quot;,&quot;cpu_p95_avg&quot;,&quot;cpu_waste_sum&quot;,&quot;cpu_throttle_pk&quot;,
            &quot;mem_req_avg&quot;,&quot;mem_p95_avg&quot;,&quot;mem_rss_avg&quot;,&quot;mem_waste_sum&quot;,&quot;pv_capacity_avg&quot;,&quot;pv_waste_sum&quot;,&quot;cpu_util_pct&quot;,&quot;mem_util_pct&quot;]
df_disp = df_wl[cols_out]

nf = {3:&quot;0.000&quot;, 4:&quot;0.000&quot;, 5:&quot;#,##0.0&quot;, 6:&quot;0.000&quot;, 7:&quot;0.00&quot;, 8:&quot;0.00&quot;, 9:&quot;0.00&quot;, 10:&quot;#,##0.0&quot;, 11:&quot;0.00&quot;, 12:&quot;#,##0.0&quot;, 13:&quot;0.1&quot;, 14:&quot;0.1&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=4, num_formats=nf)

set_col_widths(ws, {&quot;A&quot;:18,&quot;B&quot;:12,&quot;C&quot;:14,&quot;D&quot;:14,&quot;E&quot;:14,&quot;F&quot;:14,&quot;G&quot;:14,&quot;H&quot;:14,&quot;I&quot;:14,&quot;J&quot;:14,&quot;K&quot;:14,&quot;L&quot;:14,&quot;M&quot;:12,&quot;N&quot;:12})
freeze_and_filter(ws, row=4)

add_chart_image(ws, &quot;chart15_oom_status_by_workload.png&quot;, f&quot;A{end_row+3}&quot;, infra_tag, 860, 400, &quot;[ Status Distribution count per Open-Source Stack ]&quot;)
add_chart_image(ws, &quot;chart14_cpu_mem_waste_scatter.png&quot;, f&quot;A{end_row+26}&quot;, infra_tag, 760, 480, &quot;[ Multi-Resource Loss Correlation Bubble Map ]&quot;)</code></pre><h1 id="───-🎉-요청-반영-sheet-8-미사용-추가-차트-모음-전용-시트-─────────────────">─── 🎉 [요청 반영] Sheet 8: 미사용 추가 차트 모음 전용 시트 ─────────────────</h1>
<p>def build_sheet_extra_charts(wb, infra_tag, date_tag):
    print(f&quot;⏳ [9/9] &#39;8. 추가차트모음&#39; 시트 격리 배치 중... ({infra_tag})&quot;)
    ws = wb.create_sheet(&quot;8. 추가차트모음&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:Q1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Step 3 Additional Unused Dashboards &amp; Reports [{infra_tag} - {date_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 35

used_ids = [1, 2, 3, 5, 6, 9, 10, 15]
remaining_ids = [i for i in range(1, 20) if i not in used_ids]

current_row = 3
col_positions = [&quot;A&quot;, &quot;I&quot;]  # 2열 바둑판 교차 격자 구조
col_idx = 0

for cid in remaining_ids:
    found_file = None
    patterns = [f&quot;chart{cid}_*.png&quot;, f&quot;chart_{cid}_*.png&quot;, f&quot;chart{cid}.png&quot;, f&quot;chart_{cid}.png&quot;]
    for pat in patterns:
        # 💡 [교정] 추가 차트 탐색 시 클러스터별 하위 폴더 격리 스캔
        matches = list((PLOT_DIR / infra_tag).glob(pat))
        if matches:
            found_file = matches[0].name
            break

    if found_file:
        anchor = f&quot;{col_positions[col_idx]}{current_row}&quot;
        add_chart_image(ws, found_file, anchor, infra_tag, w=480, h=280, label=f&quot;Chart {cid}: {found_file}&quot;)
        col_idx += 1
        if col_idx &gt;= len(col_positions):
            col_idx = 0
            current_row += 16  # 차트 간격 오프셋
    else:
        print(f&quot;  ℹ️  [추가 차트] Chart ID {cid} 번 파일이 {infra_tag} 폴더에 없어 생략합니다.&quot;)</code></pre><h1 id="───-main-─────────────────────────────────────────────────">─── Main ─────────────────────────────────────────────────</h1>
<p>def main():
    print(&quot;🚀 [Step6 개시] res_usage_ 계열 고도화 엑셀 리포터 엔진 가동 (인프라/날짜 패턴 자동 검색)...&quot;)</p>
<pre><code># ─── 🔄 [교정] 2단계 개편안에 맞춰 하위 격리 디렉토리 위계(*/*)로 검색 와일드카드 변경 ───
search_pattern = str(MERGED_DIR / &quot;*&quot; / &quot;pareto_ns_*.parquet&quot;)
pareto_files = glob.glob(search_pattern)

if not pareto_files:
    print(f&quot;❌ 오류: 탐색된 파레토 가공원부 파일이 없습니다. 경로를 확인하세요: {search_pattern}&quot;)
    return

processed_count = 0
for file_path in pareto_files:
    filename = os.path.basename(file_path)
    match = re.search(r&quot;pareto_ns_(.*)_(.*)\.parquet&quot;, filename)
    if not match:
        continue

    infra_tag = match.group(1)   # 예: COMPUTE 또는 STORAGE
    date_tag  = match.group(2)   # 예: 20260702

    # 💡 [교정] 모놀리식 주소를 폐기하고 클러스터 격리 폴더명(/infra_tag/)을 경로 중간에 강제 주입
    p1 = MERGED_DIR / infra_tag / f&quot;daily_enriched_{infra_tag}_{date_tag}.parquet&quot;
    p2 = Path(file_path)
    p3 = MERGED_DIR / infra_tag / f&quot;daily_ns_usage_{infra_tag}_{date_tag}.parquet&quot;

    if not (p1.exists() and p3.exists()):
        print(f&quot;⚠️  [세트 불완전] {infra_tag} | {date_tag} 에 쌍이 되는 가공 데이터 폴더 스캔 실패로 건너뜜.&quot;)
        continue

    print(f&quot;\n&quot; + &quot;=&quot;*60)
    print(f&quot;🔄 [파티션 매칭 감지] Cluster/Infra: {infra_tag} | Date: {date_tag} 컴파일 가동&quot;)
    print(&quot;=&quot;*60)

    df_pod      = pd.read_parquet(p1)
    df_ns       = pd.read_parquet(p2)
    df_daily_ns = pd.read_parquet(p3)

    excel_name = f&quot;res_usage_report_{infra_tag.lower()}_{date_tag}.xlsx&quot;

    wb = Workbook()
    build_sheet_summary(wb, df_pod, df_ns, infra_tag)
    build_sheet_pareto(wb, df_ns, infra_tag)
    build_sheet_ns_daily(wb, df_daily_ns, infra_tag)
    build_sheet_cpu(wb, df_pod, infra_tag)
    build_sheet_memory(wb, df_pod, infra_tag)
    build_sheet_oom(wb, df_pod, infra_tag)
    build_sheet_violations(wb, df_pod, infra_tag)
    build_sheet_trends(wb, df_pod, infra_tag)
    build_sheet_workload(wb, df_pod, infra_tag)
    build_sheet_extra_charts(wb, infra_tag, date_tag)

    out_path = OUT_DIR / excel_name
    print(f&quot;💾 openpyxl 스트림 디스크 저장 가동 중... ➡️ {out_path}&quot;)
    wb.save(out_path)
    print(f&quot;📦 [로컬 컴파일 마감 완료]: {excel_name} ({out_path.stat().st_size/1024:.0f} KB)&quot;)

    # ─── 🪣 사내 MinIO AIStor &#39;devops-test&#39; 버킷 자동 배포 레이어 ───
    minio_endpoint   = os.getenv(&quot;MINIO_ENDPOINT&quot;)
    minio_access_key = os.getenv(&quot;MINIO_ACCESS_KEY&quot;)
    minio_secret_key = os.getenv(&quot;MINIO_SECRET_KEY&quot;)
    bucket_name      = os.getenv(&quot;MINIO_REPORT_BUCKET&quot;, &quot;devops-test&quot;)

    if all([minio_endpoint, minio_access_key, minio_secret_key]):
        try:
            endpoint_clean = minio_endpoint.replace(&quot;http://&quot;, &quot;&quot;).replace(&quot;https://&quot;, &quot;&quot;)
            secure_flag = minio_endpoint.startswith(&quot;https://&quot;)

            minio_client = Minio(
                endpoint_clean,
                access_key=minio_access_key,
                secret_key=minio_secret_key,
                secure=secure_flag
            )

            if not minio_client.bucket_exists(bucket_name):
                print(f&quot;🪣  [MinIO] 버킷이 존재하지 않아 신규 생성합니다: {bucket_name}&quot;)
                minio_client.make_bucket(bucket_name)

            object_key = f&quot;reports/{infra_tag.lower()}/{excel_name}&quot;
            print(f&quot;🪣  [오브젝트 스토리지 싱크] devops-test 버킷 배포 ➡️ MinIO://{object_key} 파일 스트림 업로드 중...&quot;)

            minio_client.fput_file(bucket_name, object_key, str(out_path))
            print(f&quot;🏁 === [전사 배포 마감 성공] 고도화 {infra_tag} 마스터 엑셀 리포트 배포 자산화 완료. ===&quot;)
        except Exception as e:
            print(f&quot;❌ [배포 에러] 사내 MinIO AIStor 원격 업로드 중 예외 발생: {str(e)}&quot;)
    else:
        print(&quot;⚠️ [안내] 접속 환경변수(MINIO_ENDPOINT 등) 생략으로 로컬 아카이빙 처리 후 해당 파티션을 마감합니다.&quot;)

    processed_count += 1

print(f&quot;\n🏁 === [전체 자동 매칭 빌드 완료] 총 {processed_count}개 세트 인프라 마스터 리포트 빌드 프로세스가 종결되었습니다. ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y03a1]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y03a1</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y03a1</guid>
            <pubDate>Thu, 02 Jul 2026 23:28:25 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step6_excel_builder.py — Partition-Aware FinOps Master Governance Report Builder (Cluster Isolated)
&quot;&quot;&quot;
import os
import argparse
import boto3
from botocore.exceptions import ClientError
import pandas as pd
from datetime import datetime, timedelta, timezone
from pathlib import Path</p>
<p>from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image
from openpyxl.formatting.rule import DataBarRule</p>
<h1 id="───-📂-경로-및-타임존-표준화-─────────────────────────────────────────────">─── 📂 경로 및 타임존 표준화 ─────────────────────────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
OUTPUT_DIR    = BASE_DATA_DIR / &quot;output&quot;
BASE_PLOT_DIR = OUTPUT_DIR / &quot;plots&quot;</p>
<p>KST = timezone(timedelta(hours=9))</p>
<h1 id="───-🎨-엔터프라이즈-muted-blue-테마-디자인-시트-스타일-정의-──────────────────">─── 🎨 엔터프라이즈 Muted Blue 테마 디자인 시트 스타일 정의 ──────────────────</h1>
<p>C = dict(
    hdr_dark=&quot;1F4E79&quot;, hdr_mid=&quot;2E75B6&quot;, hdr_light=&quot;BDD7EE&quot;,
    accent=&quot;ED7D31&quot;, red=&quot;C00000&quot;, green=&quot;70AD47&quot;,
    yellow=&quot;FFC000&quot;, gray_row=&quot;F2F2F2&quot;, white=&quot;FFFFFF&quot;,
    border=&quot;9DC3E6&quot;, summary_bg=&quot;EBF3FB&quot;
)</p>
<p>def ft(bold=False, size=10, color=&quot;000000&quot;, name=&quot;Arial&quot;):
    return Font(name=name, bold=bold, size=size, color=color)
def fill(hex_color):
    return PatternFill(&quot;solid&quot;, fgColor=hex_color)
def thin_border():
    t = Side(style=&quot;thin&quot;, color=C[&quot;border&quot;])
    return Border(left=t, right=t, top=t, bottom=t)
def center(wrap=False):
    return Alignment(horizontal=&quot;center&quot;, vertical=&quot;center&quot;, wrap_text=wrap)
def left(wrap=False):
    return Alignment(horizontal=&quot;left&quot;, vertical=&quot;center&quot;, wrap_text=wrap)</p>
<p>def parse_arguments():
    parser = argparse.ArgumentParser(description=&quot;FinOps Master Excel Sheet Builder&quot;)
    # 💡 INTEGRATED를 배제하고 무조건 명확한 대상 클러스터를 요구하도록 고정
    parser.add_argument(&quot;--cluster&quot;, type=str, required=True, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;], help=&quot;Target cluster type&quot;)
    return parser.parse_args()</p>
<p>def get_minio_client():
    return boto3.client(
        &quot;s3&quot;, endpoint_url=os.getenv(&quot;MINIO_ENDPOINT&quot;), aws_access_key_id=os.getenv(&quot;MINIO_ACCESS_KEY&quot;),
        aws_secret_access_key=os.getenv(&quot;MINIO_SECRET_KEY&quot;), region_name=&quot;us-east-1&quot;,
        config=boto3.session.Config(signature_version=&quot;s3v4&quot;)
    )</p>
<p>def main():
    args = parse_arguments()
    cluster_target = args.cluster.upper()</p>
<pre><code>print(f&quot;🚀 [Step6] Executing Excel Master Builder for Target Cluster: {cluster_target}...&quot;)

# ─── 🗂️ [적응형 매핑 주입 1] 4단계 거버넌스 산출 파일 바인딩 ───
risk_file  = OUTPUT_DIR / f&quot;gov_risk_oom_{cluster_target}.parquet&quot;
waste_file = OUTPUT_DIR / f&quot;gov_waste_candidates_{cluster_target}.parquet&quot;
viol_file  = OUTPUT_DIR / f&quot;gov_violations_{cluster_target}.parquet&quot;

# ─── 🎨 [적응형 매핑 주입 2] 3단계 차트 보관 디렉토리 격리 바인딩 ───
PLOT_DIR = BASE_PLOT_DIR / cluster_target

if not risk_file.exists():
    print(f&quot;❌ Error: Required governance data for &#39;{cluster_target}&#39; not found. Please run step4 first.&quot;)
    return

# 데이터 로드
df_risk  = pd.read_parquet(risk_file)
df_waste = pd.read_parquet(waste_file)
df_viol  = pd.read_parquet(viol_file)

wb = Workbook()

# -------------------------------------------------------------------------
# 시트 1: 0. 전사종합요약 &amp; 격리 대시보드 차트 탑재
# -------------------------------------------------------------------------
ws_dash = wb.active
ws_dash.title = &quot;0. 종합 대시보드&quot;
ws_dash.sheet_view.showGridLines = False

ws_dash.merge_cells(&quot;A1:N2&quot;)
title_cell = ws_dash[&quot;A1&quot;]
title_cell.value = f&quot;FinOps Infrastructure Governance Executive Summary [{cluster_target} CLUSTER]&quot;
title_cell.font = ft(bold=True, size=14, color=C[&quot;white&quot;])
title_cell.fill = fill(C[&quot;hdr_dark&quot;])
title_cell.alignment = center()

# 💡 격리된 폴더 경로에서 이미지 차트 서칭 및 임베딩
chart_map = {
    &quot;B4&quot;: &quot;chart6_status_donut.png&quot;,
    &quot;H4&quot;: &quot;chart1_cpu_req_vs_usage_by_workload.png&quot; if cluster_target == &quot;COMPUTE&quot; else &quot;chart2_mem_req_vs_usage_by_workload.png&quot;,
    &quot;B20&quot;: &quot;chart5_pareto_ns_waste.png&quot;,
    &quot;H20&quot;: &quot;chart4_cpu_efficiency_heatmap.png&quot; if cluster_target == &quot;COMPUTE&quot; else &quot;chart18_mem_waste_heatmap.png&quot;
}

print(&quot;🎨 Embedding cluster-isolated analytical charts into Dashboard sheet...&quot;)
for cell_loc, chart_name in chart_map.items():
    img_path = PLOT_DIR / chart_name
    if img_path.exists():
        img = Image(str(img_path))
        # 대시보드 레이아웃 균형을 위해 차트 해상도 스케일링 조율
        img.width, img.height = 420, 260
        ws_dash.add_image(img, cell_loc)
    else:
        print(f&quot;  ⚠️ Missing chart asset: {img_path.name} (Placeholder will be used in Excel view)&quot;)

# -------------------------------------------------------------------------
# 시트 2: 4. 자원부족및OOM장애군 (Step4 산출물 매핑)
# -------------------------------------------------------------------------
ws_risk = wb.create_sheet(&quot;1. 자원부족 및 OOM 장애군&quot;)
ws_risk.sheet_view.showGridLines = False

headers_risk = [&quot;Date&quot;, &quot;Namespace&quot;, &quot;Workload Type&quot;, &quot;Pod Name&quot;, &quot;Container&quot;, &quot;Minutes Running&quot;, &quot;Max Request&quot;, &quot;Max Limit&quot;, &quot;P95 Usage&quot;, &quot;OOM Count&quot;, &quot;Status&quot;, &quot;Recommendation&quot;]

# 헤더 렌더링
for col_idx, h in enumerate(headers_risk, 1):
    cell = ws_risk.cell(row=1, column=col_idx, value=h)
    cell.font = ft(bold=True, color=C[&quot;white&quot;])
    cell.fill = fill(C[&quot;hdr_mid&quot;])
    cell.alignment = center()
    cell.border = thin_border()

# 데이터 쓰기
for r_idx, row in enumerate(df_risk.itertuples(index=False), 2):
    bg_color = C[&quot;gray_row&quot;] if r_idx % 2 == 1 else C[&quot;white&quot;]
    for c_idx, val in enumerate(row, 1):
        cell = ws_risk.cell(row=r_idx, column=c_idx, value=val)
        cell.font = ft(size=9)
        cell.fill = fill(bg_color)
        cell.border = thin_border()
        if c_idx in [1, 2, 3, 11]: cell.alignment = center()
        if c_idx in [7, 8, 9]: cell.number_format = &#39;#,##0.2f&#39;

# -------------------------------------------------------------------------
# 시트 3: 2. CPU_Memory_과다할당 권고군 (Step4 산출물 매핑)
# -------------------------------------------------------------------------
ws_waste = wb.create_sheet(&quot;2. 과다할당 권고군&quot;)
ws_waste.sheet_view.showGridLines = False

headers_waste = [&quot;Date&quot;, &quot;Namespace&quot;, &quot;Workload Type&quot;, &quot;Pod Name&quot;, &quot;Container&quot;, &quot;Minutes Running&quot;, &quot;Max Request&quot;, &quot;Max Limit&quot;, &quot;P95 Usage&quot;, &quot;Throttled Max&quot;, &quot;Allocated Hours&quot;, &quot;Usage Hours&quot;, &quot;Waste Hours&quot;, &quot;Status&quot;]

for col_idx, h in enumerate(headers_waste, 1):
    cell = ws_waste.cell(row=1, column=col_idx, value=h)
    cell.font = ft(bold=True, color=C[&quot;white&quot;])
    cell.fill = fill(C[&quot;hdr_mid&quot;])
    cell.alignment = center()
    cell.border = thin_border()

for r_idx, row in enumerate(df_waste.itertuples(index=False), 2):
    bg_color = C[&quot;gray_row&quot;] if r_idx % 2 == 1 else C[&quot;white&quot;]
    for c_idx, val in enumerate(row, 1):
        cell = ws_waste.cell(row=r_idx, column=c_idx, value=val)
        cell.font = ft(size=9)
        cell.fill = fill(bg_color)
        cell.border = thin_border()
        if c_idx in [1, 2, 3, 14]: cell.alignment = center()
        if c_idx in [7, 8, 9, 11, 12, 13]: cell.number_format = &#39;#,##0.1f&#39;

# 📊 낭비 자산 가시성을 높이기 위한 데이터바 조건부 서식 부여 (Waste Hours 컬럼 저격)
if len(df_waste) &gt; 0:
    ws_waste.conditional_formatting.add(
        f&quot;M2:M{len(df_waste)+1}&quot;, 
        DataBarRule(start_type=&quot;num&quot;, start_value=0, end_type=&quot;max&quot;, color=&quot;F2DCDB&quot;, showValue=True)
    )

# -------------------------------------------------------------------------
# 시트 4: 5. 리소스 미설정 위반군 (Step4 산출물 매핑)
# -------------------------------------------------------------------------
ws_viol = wb.create_sheet(&quot;3. 스펙 미설정 위반군&quot;)
ws_viol.sheet_view.showGridLines = False

for col_idx, h in enumerate(headers_risk, 1): # 위험군과 데이터 구조가 동일하므로 헤더 재사용
    cell = ws_viol.cell(row=1, column=col_idx, value=h)
    cell.font = ft(bold=True, color=C[&quot;white&quot;])
    cell.fill = fill(C[&quot;hdr_dark&quot;])
    cell.alignment = center()
    cell.border = thin_border()

for r_idx, row in enumerate(df_viol.itertuples(index=False), 2):
    bg_color = C[&quot;gray_row&quot;] if r_idx % 2 == 1 else C[&quot;white&quot;]
    for c_idx, val in enumerate(row, 1):
        cell = ws_viol.cell(row=r_idx, column=c_idx, value=val)
        cell.font = ft(size=9)
        cell.fill = fill(bg_color)
        cell.border = thin_border()
        if c_idx in [1, 2, 3, 11]: cell.alignment = center()

# 📐 전 시트 스캔 및 글로벌 열 너비 최적화 자동화 구문
print(&quot;📐 Executing column auto-fit layout optimizer...&quot;)
for ws in wb.worksheets:
    if ws.title == &quot;0. 종합 대시보드&quot;: continue
    ws.freeze_panes = &quot;A2&quot;
    ws.auto_filter.ref = ws.dimensions
    for col in ws.columns:
        max_len = max(len(str(cell.value or &#39;&#39;)) for cell in col)
        col_letter = get_column_letter(col[0].column)
        ws.column_dimensions[col_letter].width = max(max_len + 3, 12)

# 💾 [최종 마감 및 S3 슈팅]
target_date_str = datetime.now(KST).strftime(&quot;%Y%m%d&quot;)
excel_filename = f&quot;res_usage_report_{cluster_target.lower()}_{target_date_str}.xlsx&quot;
local_excel_path = OUTPUT_DIR / excel_filename

wb.save(local_excel_path)
print(f&quot;✅ Local report compilation completed: {local_excel_path.name}&quot;)

# AIStor 원격 업로드 집행
s3_client = get_minio_client()
bucket_name = os.getenv(&quot;MINIO_REPORT_BUCKET&quot;, &quot;devops-test&quot;)
object_key = f&quot;reports/{cluster_target.lower()}/{excel_filename}&quot;

print(f&quot;📤 Uploading final executive ledger to AIStor Tables Ecosystem...&quot;)
try:
    s3_client.upload_file(str(local_excel_path), bucket_name, object_key)
    print(f&quot;🏁 [FinOps 완수] Report securely stored ➡️ S3://{bucket_name}/{object_key}&quot;)
except Exception as e:
    print(f&quot;❌ S3 Upload failed due to network anomaly: {str(e)}&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02x6]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02x6</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02x6</guid>
            <pubDate>Thu, 02 Jul 2026 16:04:39 GMT</pubDate>
            <description><![CDATA[<p>결론부터 말씀드리면 <strong>100% 구현 가능함.</strong> 다만, 앞서 언급하신 대로 <strong>하나의 버킷에 수억 건의 객체</strong>가 존재할 수 있는 고부하 환경이므로, 아무런 대책 없이 S3 <code>ListObjects</code> API를 순정 상태로 호출하면 API 서버와 MinIO 클러스터가 동시에 타임아웃으로 붕괴할 위험이 있음.</p>
<p>따라서 이 API는 <strong>1) 이전에 설계한 CNPG 메타 DB를 조회하는 방식(가장 추천)</strong> 또는 <strong>2) MinIO AIStor API를 극한으로 최적화하여 래핑하는 방식</strong> 중 하나를 선택하여 구현해야 함.</p>
<hr>
<h3 id="방안-1-이전에-기획한-cnpg-메타-db를-활용하는-방식-강력-추천">방안 1. 이전에 기획한 CNPG 메타 DB를 활용하는 방식 (강력 추천)</h3>
<p>Kafka ➡️ Redis ➡️ CNPG 파이프라인을 통해 메타데이터를 이미 적재하고 있다면, S3를 찌를 필요가 전혀 없음. API 서버는 단순한 SQL 쿼리 실행기 역할만 수행하므로 리스크가 제로에 가까움.</p>
<h4 id="①-특정-prefix-아래의-총-사용량-및-파일-수-조회-usage">① 특정 Prefix 아래의 총 사용량 및 파일 수 조회 (<code>/usage</code>)</h4>
<ul>
<li><strong>동작 방식:</strong> Prefix 테이블에서 ID를 찾고, 파티셔닝된 Object 테이블에서 <code>COUNT</code>와 <code>SUM</code>을 집계하여 리턴함.</li>
<li><strong>API 구현 로직 (SQL 기반):</strong>
```sql</li>
<li><ul>
<li>API 서버 내부에서 실행할 쿼리
SELECT COUNT(*) as total_objects, COALESCE(SUM(size), 0) as total_bytes
FROM storage_objects
WHERE prefix_id = (SELECT id FROM storage_prefixes WHERE full_path = :request_prefix);</li>
</ul>
</li>
</ul>
<pre><code>

* **이점:** 수억 건의 데이터가 있어도 인덱스 타겟 파티션 1개만 스캔하므로 **수 밀리초(ms) 안에 응답**이 끝남.

#### ② 특정 Prefix 아래의 객체 리스트 조회 (`/objects`)

* **위험 요소:** 만약 특정 prefix 아래에 수백만 개의 파일이 있다면, 이를 한 번에 JSON 배열로 리턴하는 순간 API 서버 메모리가 고갈(OOM)됨.
* **해결책:** Cursor 기반 페이지네이션(Pagination)을 API 스펙에 반드시 강제해야 함.
* **API 구현 스펙 예시:**
* **요청:** `GET /api/v1/storage/objects?prefix=dir1/&amp;limit=1000&amp;cursor_file=last_seen_file.txt`
* **쿼리:**
```sql
SELECT name, size, created_at
FROM storage_objects
WHERE prefix_id = :prefix_id AND name &gt; :cursor_file
ORDER BY name ASC
LIMIT :limit;
</code></pre><hr>
<h3 id="방안-2-db를-쓰지-않고-minio-aistor-api를-직접-래핑하는-방식">방안 2. DB를 쓰지 않고 MinIO AIStor API를 직접 래핑하는 방식</h3>
<p>만약 실시간 정합성을 위해 메타 DB를 거치지 않고 MinIO 스토리지를 직접 찔러야 한다면, API 서버 내부에서 MinIO Go/Python SDK를 사용할 때 S3 호환 파라미터를 세밀하게 통제해야 함.</p>
<h4 id="①-특정-prefix-아래의-사용량-조회-➡️-minio-admin-api-활용">① 특정 Prefix 아래의 사용량 조회 ➡️ MinIO Admin API 활용</h4>
<ul>
<li>S3 API에는 특정 폴더의 크기를 바로 리턴해주는 표준 API가 없음. 원래는 전체 리스팅을 해서 합산을 해야 하지만, 상용 <strong>MinIO AIStor는 Admin API를 통해 서버 측에서 계산된 프리픽스별 디스크 사용량(<code>DataUsageInfo</code>)을 제공함.</strong></li>
<li><strong>API 서버 구현 방식 (Python MinIO Admin SDK 예시):</strong><pre><code class="language-python"># API 서버 내부 가상 코드
from minio import Minio
# Admin 클라이언트를 통해 S3 리스팅 없이 서버 측 집계 데이터 반환 요청
# (주의: 이 또한 수억 건인 경우 백엔드 가비지 컬렉션 주기에 따라 약간의 레이턴시가 있을 수 있음)
usage_info = minio_admin_client.get_bucket_usage(bucket_name, prefix=target_prefix)
return {
  &quot;size_bytes&quot;: usage_info.size,
  &quot;object_count&quot;: usage_info.object_count
}
</code></pre>
</li>
</ul>
<pre><code>


#### ② 특정 Prefix 아래의 객체 리스트 조회 ➡️ S3 Delimiter &amp; ContinuationToken 활용

* **계층형 탐색 강제 (`Delimiter=&#39;/&#39;`):** 수억 개의 하위 파일을 다 가져오지 않고, 파일 시스템 탐색기처럼 딱 현재 레벨의 하위 폴더(Prefix)와 파일 목록만 끊어서 가져오도록 래핑해야 함.
* **ContinuationToken 토스:** MinIO가 리턴해주는 페이징용 포인터(`NextContinuationToken`)를 API 서버가 보관하지 말고, 최종 클라이언트(포털 또는 Workbench)에게 그대로 넘겨주어 무상태(Stateless)를 유지해야 함.
* **API 서버 구현 스펙 예시:**
* **요청:** `GET /api/v1/storage/raw-list?prefix=dir1/&amp;token=xyz...`
* **API 서버 내부 로직:**
```python
# API 서버가 MinIO SDK를 호출할 때 설정을 극단적으로 타이트하게 제한
objects = minio_client.list_objects(
    bucket_name, 
    prefix=requested_prefix, 
    recursive=False, # ◀ 수억 건 폭주를 막기 위해 하위 폴더 재귀 탐색 절대 금지
    delimiter=&#39;/&#39;,   # ◀ 딱 한 단계 아래의 구조만 단건 분리
    max_keys=100,    # ◀ 한 번에 최대 100개씩만 페이징
    start_after=requested_token
)
</code></pre><hr>
<h3 id="💡-devops-관점의-최종-권장-아키텍처-결론">💡 DevOps 관점의 최종 권장 아키텍처 결론</h3>
<ul>
<li><strong>사용량/용량 조회(<code>COUNT/SUM</code>):</strong> MinIO Admin API를 래핑하거나, Prometheus 메트릭 엔드포인트를 API 서버가 파싱해서 던져주는 방식이 안전함.</li>
<li><strong>객체 리스트 조회(<code>ListObjects</code>):</strong> 6,000개 테넌트 규모에서는 결국 유저들이 각자 자기가 구축한 메타 DB(방안 1)를 조회하게 유도하고, 진짜 S3 스토리지 다이렉트 리스팅 API는 아주 특수한 관리자 UI나 Workbench 초기 로딩 시점에만 Strict 페이징(Recursive=False, MaxKeys=100)을 걸어 제한적으로 제공하는 것이 리스크를 관리하는 정석임.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02x5]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02x5</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02x5</guid>
            <pubDate>Thu, 02 Jul 2026 16:00:43 GMT</pubDate>
            <description><![CDATA[<p>자체 메타 DB와 대규모 API 서버를 구축하는 아키텍처를 축소하고, 기존 공통 API 서버를 활용하는 방향으로 선회한 것은 DevOps의 운영 부담과 데이터 정합성 리스크를 고려했을 때 매우 현명한 결단임.</p>
<p>이 경우 API 서버의 역할은 무거운 데이터를 직접 적재하는 것이 아니라, MinIO AIStor가 내부적으로 이미 잘 만들어서 제공하고 있는 기본 기능들(S3 API, Admin API, Prometheus 메트릭, Audit Log Webhook)을 조합하여 상위에 보안과 거버넌스 레이어를 씌워주는 <strong>&quot;경량 API 게이트웨이(BFF, Backend for Frontend)&quot;</strong> 형태로 가져가는 것이 가장 이상적임.</p>
<p>기존 API 서버에 추가 개발하여 DevOps 거버넌스를 고도화할 수 있는 핵심 API 세트를 아래와 같이 제안함.</p>
<hr>
<h3 id="1-보안-및-인증-위임-레이어-access-control-api">1. 보안 및 인증 위임 레이어 (Access Control API)</h3>
<p>6,000명이 넘는 개인 테넌트 유저가 MinIO의 Raw AccessKey/SecretKey를 직접 소유하게 만드는 것은 보안상 매우 위험함. 이를 API 서버가 숨겨주고 제어해야 함.</p>
<ul>
<li><strong><code>POST /api/v1/storage/presigned</code> (임시 접근 URL 발급 API)</strong></li>
<li><strong>기능:</strong> Workbench 커스텀 앱이나 유저가 파일 업로드/다운로드가 필요할 때 API 서버 요청함. API 서버는 내부 마스터 권한으로 MinIO SDK를 호출하여 특정 Object에 대해 5분~30분간만 유효한 <strong>Presigned URL</strong>을 생성해 반환함.</li>
<li><strong>이점:</strong> 유저에게 영구 자격증명을 주지 않고도 S3 API 보안을 완벽하게 통제할 수 있음.</li>
</ul>
<ul>
<li><strong><code>GET /api/v1/storage/sts-token</code> (임시 자격증명 발급 API)</strong></li>
<li><strong>기능:</strong> AWS STS(AssumeRole) 호환 기능을 활용하여, 해당 유저의 개인 Namespace/Prefix 구역만 딱 1시간 동안 접근할 수 있는 임시 토큰을 발급함. Workbench 내 데이터 사이언스 코드(Python 등)가 대량의 S3 I/O를 직접 쳐야 할 때 유용함.</li>
</ul>
<hr>
<h3 id="2-무거운-db-없는-실시간-거버넌스-레이어-metrics-api-wrapper">2. 무거운 DB 없는 실시간 거버넌스 레이어 (Metrics API Wrapper)</h3>
<p>MinIO AIStor는 버킷별, 프리픽스별 용량 및 객체 수 통계를 내부적으로 이미 계산하여 Prometheus 엔드포인트(<code>/minio/v2/metrics/cluster</code>)로 실시간 제공하고 있음. 메타 DB를 안 만들어도 이 메트릭을 가공하면 원하는 조회가 가능함.</p>
<ul>
<li><strong><code>GET /api/v1/governance/tenant/{user_id}/summary</code> (개인별 용량/객체 수 즉시 조회 API)</strong></li>
<li><strong>기능:</strong> API 서버가 호출되면, MinIO의 Prometheus 메트릭 파이프라인에서 해당 유저의 프리픽스/버킷 관련 메트릭(<code>minio_bucket_usage_total_bytes</code>, <code>minio_bucket_usage_object_total_count</code>)만 긁어서 정제 후 JSON으로 리턴함.</li>
<li><strong>이점:</strong> 수억 건의 객체가 있어도 S3 <code>ListObjects</code>를 치지 않고 <strong>O(1) 성능</strong>으로 유저별 실시간 사용량과 쿼터 임박 여부를 대시보드(포탈)에 뿌려줄 수 있음.</li>
</ul>
<hr>
<h3 id="3-프로비저닝-및-자원-통제-레이어-lifecycle--quota-api">3. 프로비저닝 및 자원 통제 레이어 (Lifecycle &amp; Quota API)</h3>
<p>Workbench와 연동하여 사용자가 추가되거나 휴면 상태가 될 때 자원을 자동 제어하는 API임.</p>
<ul>
<li><strong><code>POST /api/v1/governance/tenant/setup</code> (테넌트 스토리지 자동 개설 API)</strong></li>
<li><strong>기능:</strong> 신규 유저가 Workbench를 할당받을 때 연동 호출됨. MinIO Admin API SDK를 이용해 1) 개인 버킷/프리픽스 생성, 2) 전용 정책(Policy) 바인딩, 3) 스토리지 용량 한도(Hard Quota) 설정을 코드 한 줄로 원스톱 처리함.</li>
</ul>
<ul>
<li><strong><code>PUT /api/v1/governance/tenant/lifecycle</code> (수명주기 동적 제어 API)</strong></li>
<li><strong>기능:</strong> 주간 분석(Step 6) 결과 낭비군으로 분류되거나 일정 기간 미접속한 유저의 프리픽스에 대해 미설정 상태였던 객체 만료 정책(Object Lifecycle Management, 예: 30일 지나면 자동 삭제)을 강제로 주입하거나 쿼터를 축소함.</li>
</ul>
<hr>
<h3 id="4-실시간-감사-및-킬러-쿼리-차단-레이어-audit-webhook-receiver-api">4. 실시간 감사 및 킬러 쿼리 차단 레이어 (Audit Webhook Receiver API)</h3>
<p>MinIO AIStor는 모든 S3 API 요청 이력을 JSON 형태로 외부 웹훅에 실시간 전송하는 기능(<strong>Audit Logs Webhook</strong>)을 내장하고 있음. 기존 API 서버에 이를 받아먹는 리시버 엔드포인트를 하나 뚫어두는 것을 강력히 권장함.</p>
<ul>
<li><strong><code>POST /api/v1/logs/audit-receiver</code> (감사 로그 웹훅 수신 및 필터링 API)</strong></li>
<li><strong>기능:</strong> MinIO가 쏴주는 실시간 실황 로그를 수신함. 데이터베이스에 다 저장할 필요 없이, API 서버 메모리 상에서 특정 위험 패턴만 필터링하여 이벤트를 발생시킴.</li>
<li><strong>추적 대상 패턴 리스트:</strong></li>
</ul>
<ol>
<li><strong><code>403 Access Denied</code> 폭주 유저:</strong> Workbench 내부에서 잘못된 스크립트나 해킹 시도로 타인의 영역을 찌르는 유저 탐지.</li>
<li><strong><code>Bulk Delete</code> 패턴:</strong> 특정 프리픽스 하위의 자산을 무단으로 대량 삭제하는 행위 실시간 인지.</li>
<li><strong><code>Slow S3 Operations</code> 탐지:</strong> 하나의 버킷에 몇억 건이 있는 상태에서 유저가 실수로 Delimiter 없이 전체 <code>ListObjects</code>를 날려 스토리지 클러스터 전체에 고부하를 주는 행위를 실시간으로 가로채어 해당 유저 정보를 DevOps 알람망(Slack 등)에 공유함.</li>
</ol>
<hr>
<h3 id="요약">요약</h3>
<p>무거운 아키텍처를 걷어낸 지금 상태에서는 <strong>&quot;MinIO가 이미 알고 있는 정보(Prometheus 메트릭, Webhook 로그)&quot;를 API 서버가 중간에서 가볍게 통역(가공)만 해주는 API</strong>들을 추가하는 것이 DevOps 관점에서 리스크가 제로에 가까우며 가장 가성비 높은 고도화 전략임.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02x4]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02x4</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02x4</guid>
            <pubDate>Thu, 02 Jul 2026 15:26:46 GMT</pubDate>
            <description><![CDATA[<p>수백 대의 노드와 6,000개가 넘는 개인 테넌트가 결합한 고부하 프로덕션 환경을 안정적으로 운영해야 하는 DevOps 및 SRE 엔체인저 관점에서, 인프라의 붕괴를 막기 위해 추가적으로 깊게 파고들어야 할 심화 체크리스트들을 정리함.</p>
<hr>
<h3 id="1-제어-평면control-plane-안정성-및-확장성-심화">1. 제어 평면(Control Plane) 안정성 및 확장성 심화</h3>
<p>대규모 환경에서는 워커 노드보다 마스터 노드의 제어부 병목으로 인해 클러스터 전체가 먹통이 되는 경우가 더 빈번하게 발생함.</p>
<ul>
<li><strong>API Priority and Fairness (APF) 세부 튜닝</strong></li>
<li>6,000여 명의 유저 및 각종 자동화 에이전트(ArgoCD, Prometheus 등)가 동시에 API 서버를 찌를 때 발생하는 병목을 제어해야 함.</li>
<li>크리티컬한 인프라 에이전트(Cilium, 코어 컨트롤러)의 요청이 개인 유저의 무분별한 <code>kubectl</code> 요청에 밀려 드롭되지 않도록 <code>FlowSchema</code>와 <code>PriorityLevelConfiguration</code>을 커스텀하게 분리 및 가중치 조정해야 함.</li>
</ul>
<ul>
<li><strong>etcd 쿼터 및 조각화(Fragmentation) 모니터링 자동화</strong></li>
<li>etcd 용량 상한을 8GB로 늘렸더라도 무분별한 객체 생성/삭제가 반복되면 DB 내부 단편화가 진행되어 성능이 폭락함.</li>
<li><code>etcdctl defrag</code> 명령을 상시 안전하게 실행할 수 있는 유지보수 런타임 워커를 구현하고, etcd의 <code>wal_fsync_duration_seconds</code> 메트릭을 통해 디스크 I/O 레이턴시를 밀리초(ms) 단위로 감시해야 함.</li>
</ul>
<hr>
<h3 id="2-노드-os-커널kernel-및-컨테이너-런타임-최적화">2. 노드 OS 커널(Kernel) 및 컨테이너 런타임 최적화</h3>
<p>쿠버네티스는 결국 리눅스 커널 위에서 돌기 때문에, 노드 OS 레벨의 자원 고갈(Resource Exhaustion) 방지책이 필수적임.</p>
<ul>
<li><strong>대규모 네트워크 커넥션 수용을 위한 호스트 <code>sysctl</code> 튜닝</strong></li>
<li>수만 개의 파드가 동시다발적으로 통신할 때 호스트 커널의 네트워크 버퍼가 가득 차 패킷 드롭이 발생함.</li>
<li>각 워커 노드의 <code>/etc/sysctl.conf</code>에 아래 파라미터를 강제 적용하는 데몬셋(DaemonSet) 또는 OS 이미지 튜닝이 요구됨.</li>
<li><code>net.core.somaxconn</code>: 최소 32768 이상 상향 (TCP 백로그 큐 확장)</li>
<li><code>net.ipv4.tcp_max_syn_backlog</code>: 대규모 인그레스 노드의 SYN Flood 방어 및 큐 확장</li>
<li><code>net.core.netdev_max_backlog</code>: 커널이 처리하기 전 네트워크 카드가 받아두는 패킷 큐 상향</li>
</ul>
<ul>
<li><strong>커널 스레드 및 메모리 맵 한계 확장</strong></li>
<li>개인 테넌트 파드가 스레드를 무분별하게 생성하거나 메모리를 과도하게 매핑할 때 노드 커널 락(Lock)이 걸리는 현상을 방지해야 함.</li>
<li><code>kernel.pid_max</code>(최소 4194304) 및 <code>vm.max_map_count</code>(최소 262144 이상)를 조정하여 프로세스/스레드 ID 고갈을 방지함.</li>
</ul>
<ul>
<li><strong>Kubelet <code>oomScoreAdj</code> 및 Eviction 정책 정교화</strong></li>
<li>노드에 메모리 고갈 발생 시 커널 OOM 킬러가 무작위로 인프라 파드를 죽이지 않도록 통제해야 함.</li>
<li>Kubelet 시스템 컴포넌트와 Cilium 에이전트, 스토리지 CSI의 <code>oomScoreAdj</code> 값을 가장 낮게 고정하여(-997~-998) 최후까지 생존하도록 방어벽을 쳐야 함.</li>
</ul>
<hr>
<h3 id="3-cilium-ebpf-대규모-환경-특화-고급-튜닝">3. Cilium eBPF 대규모 환경 특화 고급 튜닝</h3>
<p>Cilium은 대규모 환경에서 최상의 성능을 내지만, 설정 값을 대규모 카디널리티에 맞게 늘려주지 않으면 eBPF 맵(Map) 내부 공간 부족으로 네트워크가 통째로 단절됨.</p>
<ul>
<li><strong>Cilium eBPF Map Size 한계치 상향 고정</strong></li>
<li>6,000개 네임스페이스의 수많은 파드가 커넥션을 맺으면 Cilium 내부의 Connection Tracking(ct) 맵과 NAT 맵이 가득 참.</li>
<li><code>CiliumConfig</code>에서 아래 파라미터를 기본값보다 대폭 상향하여 <code>BPF map is full</code> 에러를 선제 차단해야 함.</li>
<li><code>bpf-ct-global-any-max</code>: 커넥션 추적 테이블 용량 확장 (최소 1,000,000 이상)</li>
<li><code>bpf-nat-global-max</code>: NAT 매핑 테이블 용량 확장</li>
</ul>
<ul>
<li><strong>Cilium Host Routing 및 eBPF 기반 마스커레이딩 활성화</strong></li>
<li>패킷이 노드의 iptables 서브시스템을 타게 되면 노드 CPU 사용량이 폭증하고 레이턴시가 늘어남.</li>
<li><code>bpf.masquerade=true</code> 및 <code>tunnel=disabled</code> (Direct Routing / BGP 환경) 설정을 통해 리눅스 네트워크 스택을 완전히 우회하고 eBPF가 네트워크 카드 장치에서 파드 가상 인터페이스(veth)로 패킷을 다이렉트 바이패스하도록 아키텍처를 고정해야 함.</li>
</ul>
<ul>
<li><strong>네트워크 정책(CiliumNetworkPolicy) 결합 오버헤드 통제</strong></li>
<li>수천 개의 테넌트가 개별적으로 네트워크 정책을 남발하면 eBPF 프로그램 컴파일 및 전파 오버헤드로 인해 Kubelet 파드 생성 속도가 극도로 저하됨.</li>
<li>개별 정책보다는 네임스페이스 레이블 패턴 기반의 글로벌 정책(<code>CiliumClusterwideNetworkPolicy</code>) 위주로 추상화하여 노드 전파 카디널리티를 최소화해야 함.</li>
</ul>
<hr>
<h3 id="4-대규모-gitops-아키텍처-및-sre-모니터링-부하-통제">4. 대규모 GitOps 아키텍처 및 SRE 모니터링 부하 통제</h3>
<p>6,000개가 넘는 애플리케이션 정의를 ArgoCD 등으로 동기화할 때 발생하는 성능 감쇄를 튜닝해야 함.</p>
<ul>
<li><strong>ArgoCD 컨트롤러 셔딩(Sharding) 및 API 쿼트 분산</strong></li>
<li>단일 ArgoCD Application Controller가 6,000개 테넌트의 상태 매칭(<code>Reconcile</code>)을 수행하면 메모리 고갈(OOM) 및 쿠버네티스 API 속도 제한(<code>Rate Limit</code>)에 걸려 동기화가 무한 지연됨.</li>
<li><code>ARGOCD_CONTROLLER_REPLICAS</code>를 증설하고 클러스터 셔딩 알고리즘을 도입하여 동기화 부하를 분산해야 하며, ArgoCD 설정에서 K8s API 자원 조회 시 주기적 풀링(Polling) 대신 웹훅(Webhook) 트리거 및 캐시 기반 매칭 구조로 전환해야 함.</li>
</ul>
<ul>
<li><strong>SRE 관점의 Cilium 핵심 텔레메트릭 메트릭 감시</strong></li>
<li>프로메테우스에서 단순히 파드 CPU/Mem만 볼 것이 아니라, 아래 Cilium 전용 저수준 메트릭을 SRE 골든 시그널 대시보드에 무조건 포함해야 함.</li>
<li><code>cilium_drop_count_total</code>: 커널 단에서 드롭된 패킷 수 및 원인 코드(<code>Reason</code>) 추적</li>
<li><code>cilium_bpf_map_ops_total</code>: eBPF 맵 연산 실패율 감시</li>
<li><code>cilium_errors_total</code>: 에이전트 내부 오류 발생 카운트</li>
</ul>
<hr>
<h3 id="5-스토리지minio-aistor-네트워크-io-병목-및-백업-거버넌스">5. 스토리지(MinIO AIStor) 네트워크 I/O 병목 및 백업 거버넌스</h3>
<p>데이터 레이크하우스 아키텍처의 핵심인 데이터 무결성 및 고속 전송을 위한 SRE 관점의 인프라 제어 지점임.</p>
<ul>
<li><strong>네트워크 인터페이스 카드(NIC) 본딩 및 인터럽트(IRQ) 분산</strong></li>
<li>MinIO 대규모 분산 환경에서 특정 노드의 CPU 0번 코어만 네트워크 인터페이스의 소프트웨어 인터럽트(softirq) 처리에 매몰되어 시스템이 멈추는 현상이 발생할 수 있음.</li>
<li>OS 레벨에서 <code>irqbalance</code> 설정을 최적화하고 다중 큐(Multi-queue NIC) 설정을 켜서 데이터 레이크하우스 트래픽 처리가 전체 CPU 코어에 균등 분산되도록 통제해야 함.</li>
</ul>
<ul>
<li><strong>테넌트별 자원 회수(Garbage Collection) 및 벨레로(Velero) 백업 세분화</strong></li>
<li>개인이 쓰다 버린 휴면 네임스페이스의 스토리지 알박기 자원을 정기적으로 회수하기 위해, 주기적으로 주간 분석 리포트의 낭비 지표와 연동하여 자동 <code>kubectl delete</code>를 수행하는 가비지 컬렉터 워커 가동이 필요함.</li>
<li>6,000개 전체를 한 번에 백업하면 스냅샷 레이어에서 타임아웃이 나므로, 자원 중요도(Tiering) 레이블에 따라 백업 주기와 대상을 세분화하여 오브젝트 스토리지에 격리 저장해야 함.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02x3]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02x3</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02x3</guid>
            <pubDate>Thu, 02 Jul 2026 15:25:47 GMT</pubDate>
            <description><![CDATA[<p>6,000개가 넘는 개인별 네임스페이스(Namespace)를 운영하는 다중 테넌시(Multi-tenancy) 환경은 쿠버네티스 제어 평면(Control Plane), 특히 etcd의 성능 한계와 설정 표류(Configuration Drift)를 정면으로 마주하게 됩니다. 이 정도 규모의 대규모 클러스터에서는 자원의 절대량 분배뿐만 아니라 API 서버의 병목을 방지하는 <strong>선언적 거버넌스(Declarative Governance)</strong> 가 강제됩니다.</p>
<p>구상 중인 클러스터 전체 자원 관리 및 거버넌스 고도화를 위한 아키텍처 수립 전략을 4가지 핵심 필라(Pillar)로 나누어 상세히 정리해 드립니다.</p>
<hr>
<h3 id="1-6000-개인-네임스페이스namespace-대규모-관리-전략">1. 6,000+ 개인 네임스페이스(Namespace) 대규모 관리 전략</h3>
<p>개인형 테넌트 환경의 핵심은 &quot;중앙 통제된 자동화&quot;입니다. 유저가 직접 네임스페이스를 제어하게 두지 않고, 포털이나 GitOps 파이프라인이 생성 시점부터 규격을 강제해야 합니다.</p>
<h4 id="①-메타데이터-표준화-labels--annotations">① 메타데이터 표준화 (Labels &amp; Annotations)</h4>
<p>고성능 모니터링 및 자동 청소(Garbage Collection)를 위해 네임스페이스에 반드시 다음 메타데이터를 주입해야 합니다.</p>
<ul>
<li><strong>Labels (스케줄링 및 비용 정산용):</strong></li>
<li><code>governance.io/tenant-owner: &quot;user_id&quot;</code> (개인 식별)</li>
<li><code>governance.io/tier: &quot;developer&quot;</code> (자원 할당 등급)</li>
<li><code>governance.io/sync-status: &quot;managed&quot;</code> (ArgoCD 동기화 대상 여부)</li>
</ul>
<ul>
<li><strong>Annotations (생명주기 및 거버넌스 제어용):</strong></li>
<li><code>governance.io/ttl-expire-date: &quot;2026-12-31&quot;</code> (휴면 계정 자동 삭제 워커용 배정 만료일)</li>
<li><code>governance.io/purpose: &quot;sandbox-testing&quot;</code></li>
</ul>
<h4 id="②-resourcequota-설계-etcd-폭발-방지책">② ResourceQuota 설계 (etcd 폭발 방지책)</h4>
<p>6,000개 네임스페이스에서 무분별하게 객체(Object)가 생성되면 etcd 메모리가 고갈됩니다. 자원 용량뿐만 아니라 객체 개수 제한(Object Count Quota)을 무조건 걸어야 합니다.</p>
<ul>
<li><strong>개인용 기본 ResourceQuota 프로필 예시:</strong><pre><code class="language-yaml">apiVersion: v1
kind: ResourceQuota
metadata:
name: personal-user-quota
spec:
hard:
  # 1. 컴퓨팅 자원 상한 (Tight하게 설정)
  requests.cpu: &quot;2&quot;
  requests.memory: &quot;4Gi&quot;
  limits.cpu: &quot;4&quot;
  limits.memory: &quot;8Gi&quot;
  # 2. etcd 보호를 위한 객체 개수 제약 (가장 중요)
  pods: &quot;5&quot;
  services: &quot;3&quot;
  secrets: &quot;10&quot;
  configmaps: &quot;10&quot;
  persistentvolumeclaims: &quot;2&quot;
  replicationcontrollers: &quot;0&quot; # 레거시 차단
</code></pre>
</li>
</ul>
<pre><code>


#### ③ LimitRange 설계 (기본값 강제화)

개발자가 `request/limit` 설정을 누락하고 파드를 배포하면 노드가 동반 고갈(Noisy Neighbor)됩니다. 네임스페이스 단위로 기본값을 주입합니다.

* **권장 비율:** CPU는 오버커밋을 허용하되(`Request:Limit = 1:2`), Memory는 시스템 안정성을 위해 최대한 동일하게(`Request:Limit = 1:1`) 가져가는 것이 대규모 환경의 OOM 방지에 유리합니다.

---

### 2. 주요 쿠버네티스 오브젝트 자원 거버넌스

각 핵심 리소스들이 무분별하게 슬롯을 차지하거나 모자라지 않도록 격리하는 세부 제어 가이드라인입니다.

| 오브젝트 종류 | 대규모 환경에서의 자원 관리 및 거버넌스 방법론 |
| --- | --- |
| **Service / Ingress** | • 개인 NS 내 `Type: LoadBalancer` 생성 권한을 RBAC으로 완전히 차단합니다.&lt;br&gt;

&lt;br&gt;• 오직 `ClusterIP`만 허용하고, 외부 노출은 중앙 플랫폼 팀이 관리하는 공유형 Ingress(또는 Gateway API) 프록시를 통해 라우팅 규칙만 연동합니다. |
| **Secret / ConfigMap** | • etcd 단일 객체 한계(1MB) 및 전체 용량 압박을 막기 위해 개인 NS당 총 용량(합산 크기)을 모니터링해야 합니다.&lt;br&gt;

&lt;br&gt;• ArgoCD 플러그인이나 외부 Secret Store CSI Driver(Vault 연동)를 사용하여 파드 메모리에 직접 주입하고, k8s 내부 Secret 객체 남발을 억제합니다. |
| **PV / PVC / StorageClass** | • `ResourceQuota`에 `requests.storage: &quot;50Gi&quot;` 형태로 네임스페이스당 스토리지 총량을 강력하게 제한합니다.&lt;br&gt;

&lt;br&gt;• 개인용 StorageClass는 프로덕션용 고성능 NVMe와 분리하여 용량 제한 및 Thin Provisioning이 적용된 저가형 스토리지 풀로 바인딩합니다.&lt;br&gt;

&lt;br&gt;• `ReclaimPolicy`는 `Delete`로 설정하여 개인 NS 삭제 시 디스크 잔재가 남지 않도록 자동화합니다. |
| **ServiceAccount / Role / RoleBinding** | • 개인 유저에게는 오직 해당 네임스페이스 내부만 제어할 수 있는 `Role`(`edit` 또는 `view` 수준)만 부여합니다.&lt;br&gt;

&lt;br&gt;• `ClusterRoleBinding` 권한을 철저히 차단하여, 개인 NS의 파드가 다른 사용자나 시스템 인프라 영역(kube-system 등)을 공격하거나 조회할 수 없도록 논리적 멀티테넌시(Soft Isolation)를 완성합니다. |

---

### 3. 성능 최적화 및 오토스케일링 (HPA, VPA, KEDA) 튜닝

대규모 환경에서 오토스케일러들이 서로 충돌(Flapping)하지 않도록 역할을 명확히 인프라 정책으로 정의해야 합니다.

#### ① VPA (Vertical Pod Autoscaler) ➡️ 개인 워크로드 전담

* **전략:** 6,000명의 개발자는 본인 파드의 적정 자원량을 모릅니다. 개인 NS에 배포되는 파드들은 VPA를 `updateMode: &quot;Auto&quot;` 또는 안심할 수 없다면 `Initial`(생성 시점에만 적용)로 지정합니다.
* **주의사항:** 무한 증설을 막기 위해 VPA `ResourcePolicy` 내부에 `maxAllowed` 값을 설정하여 개인이 선점할 수 있는 단일 파드의 최대 크기 상한을 걸어두어야 합니다.

#### ② HPA (Horizontal Pod Autoscaler) ➡️ 공유 플랫폼 서비스 전담

* **전략:** 데이터 레이크하우스 쿼리 엔진이나 공용 미들웨어는 HPA를 씁니다.
* **트래픽 급변(Spike) 대응 튜닝:** 급격한 스케일 업/다운으로 클러스터가 요동치는 것을 막기 위해 `behavior` 설정을 튜닝해야 합니다.
```yaml
behavior:
  scaleDown:
    stabilizationWindowSeconds: 600 # 다운스케일은 10분간 관측 후 보수적으로 진행
    policies:
    - type: Percent
      value: 10
      periodSeconds: 60
</code></pre><h4 id="③-keda-event-driven-autoscaler-➡️-비동기-파이프라인-전담">③ KEDA (Event-driven Autoscaler) ➡️ 비동기 파이프라인 전담</h4>
<ul>
<li>앞서 구상하신 <strong>AIStor ➡️ Kafka 이벤트를 받아 처리하는 컨수머(Consumer) 앱</strong> 영역에 KEDA를 전면 배치합니다. CPU 기준이 아닌 Kafka Lag(밀린 메시지 수)를 기준으로 파드 개수를 0개에서 수십 개까지 고속 스케일링하게 세팅합니다. (<code>activationScaleObject</code> 활용으로 0개 유지가 핵심 - 자원 절약)</li>
</ul>
<p>⚠️ <strong>절대 금지 규칙:</strong> 동일한 파드의 동일한 메트릭(예: CPU 사용량)에 HPA와 VPA를 동시에 인게이지하면 안 됩니다. 자원 크기를 늘릴지, 파드 수를 늘릴지 두 엔진이 경합하다가 시스템이 다운됩니다.</p>
<hr>
<h3 id="4-고부하-대규모-클러스터-및-cilium-고급-튜닝-요소-리스트">4. 고부하 대규모 클러스터 및 Cilium 고급 튜닝 요소 리스트</h3>
<p>6,000개 네임스페이스 구조에서 노드 수백 대가 안정적으로 구동되기 위한 플랫폼 인프라 최적화 체크리스트입니다.</p>
<h4 id="①-k8s-코어-및-os-커널-레벨-튜닝">① K8s 코어 및 OS 커널 레벨 튜닝</h4>
<ul>
<li><strong>etcd 성능 극대화:</strong></li>
<li>etcd 데이터 디렉토리를 반드시 가장 빠른 로컬 NVMe SSD 단독 드라이브로 격리합니다.</li>
<li><code>--quota-backend-bytes</code>: 기본 2GB에서 상한선인 <strong>8GB</strong>로 확장하여 대규모 네임스페이스 객체 수용 능력을 확보합니다.</li>
<li><code>--auto-compaction-retention</code>: etcd 단편화 방지를 위해 주기적 컴팩션을 <code>1</code> (1시간 단위) 혹은 분 단위로 타이트하게 설정합니다.</li>
</ul>
<ul>
<li><strong>API Server 포화 방지:</strong></li>
<li><code>--max-requests-inflight</code> 및 <code>--max-mutating-requests-inflight</code> 값을 인프라 사양에 맞춰 기본값보다 2~3배 상향하여 대규모 수집기(Prometheus 등)와 GitOps 동기화 요청 폭주 시 발생하는 429 에러를 방어합니다.</li>
</ul>
<ul>
<li><strong>Kubelet 이베이전(Eviction) 기준 고도화:</strong></li>
<li>노드 다운을 막기 위해 <code>imageGCHighThresholdPercent: 80</code>, <code>imageGCLowThresholdPercent: 70</code>으로 설정하여 디스크 부족 현상을 선제 관리합니다.</li>
</ul>
<h4 id="②-cilium-cni-기반-네트워크-및-보안-튜닝">② Cilium CNI 기반 네트워크 및 보안 튜닝</h4>
<ul>
<li><strong>XDP (eXpress Data Path) 활성화:</strong></li>
<li><code>bpf.masquerade=true</code> 및 <code>enable-xdp=true</code> 설정을 켭니다. 패킷 처리가 리눅스 커널 네트워킹 서브시스템(iptables/conntrack) 진입 전 네트워크 카드(NIC) 드라이버 레이어에서 eBPF로 처리되므로, 고부하 환경에서 네트워크 처리로 인한 커널 CPU 오버헤드가 극적으로 감소합니다.</li>
</ul>
<ul>
<li><strong>BGP 및 ECMP (Equal-Cost Multi-Path) 최적화:</strong></li>
<li>Cilium 내장 BGP Control Plane을 탑재하여 탑오브랙(ToR) 스위치와 다중 경로 라우팅(ECMP)을 맺습니다. 특정 네트워크 노드 장비 장애 시 서브밀리초 단위의 Failover를 보장합니다.</li>
</ul>
<ul>
<li><strong>Hubble 텔레메트릭 오버헤드 통제:</strong></li>
<li>Hubble 오픈소스 가시성은 매우 강력하지만, 6,000개 테넌트의 모든 L7 트래픽(HTTP/gRPC)을 파싱하면 모니터링 에이전트 자체가 노드 자원을 고갈시킵니다.</li>
<li><strong>가이드:</strong> 개인 NS 구역은 L3/L4 레벨로 흐름 감지를 제한하고, 오직 공유 플랫폼 구역에만 L7 파싱 및 Hubble 메트릭 수집을 선택적으로 활성화(<code>clean-up</code> 정책 연계)해야 합니다.</li>
</ul>
<ul>
<li><strong>ClusterMesh 최적화:</strong></li>
<li>멀티 클러스터 간 전역 서비스 동기화 시, 상태 전파 카디널리티를 줄이기 위해 <code>meshAuth</code> 기능을 커널단에서 최적화하고 에이전트 간 하트비트 주기를 조율합니다.</li>
</ul>
<hr>
<p>이 거버넌스 하이레벨 가이드라인을 플랫폼 구축 명세의 초석으로 삼으시면, 대규모 멀티테넌트 환경에서도 인프라 통제력을 잃지 않고 안정적인 데이터 플랫폼 레이크하우스를 완성하실 수 있습니다. 이 중 특정 컴포넌트(예: etcd 세부 파라미터나 Cilium BGP 연동 YAML 구성)의 구체적인 구현 단계로 진입하실 때 다시 말씀해 주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02x2]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02x2</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02x2</guid>
            <pubDate>Thu, 02 Jul 2026 14:53:55 GMT</pubDate>
            <description><![CDATA[<p>설계하신 데이터 파이프라인 스케줄링과 자원 거버넌스 아키텍처는 <strong>수백 대 이상의 대규모 멀티 클러스터 환경을 운영하는 엔터프라이즈 플랫폼 관점에서 매우 정석적이고 견고한 FinOps(비용 효율화)/SRE 프레임워크</strong>입니다.</p>
<p>구축하신 시스템의 목적 부합성을 최종 검증해 드리고, 매주 개발 부서 담당자들과 실무 협의(Communication) 시 무기로 활용하실 수 있는 ‘데이터 기반 자원 조정 가이드라인 핵심 자료’를 정리해 드립니다.</p>
<hr>
<h2 id="part-1-파이프라인-아키텍처-및-컨셉-점검-validation">Part 1. 파이프라인 아키텍처 및 컨셉 점검 (Validation)</h2>
<p>구상하신 운영 방식은 목적에 100% 부합하며, 다음과 같은 아키텍처적 강점을 가집니다.</p>
<ol>
<li><strong>글로벌 Fetch와 클러스터별 루프 분리 (<code>step1</code> ➡️ <code>step2~6</code>)</strong></li>
</ol>
<ul>
<li>대규모 환경에서 프로메테우스 스크랩(Scrape) 부하를 최소화하기 위해 글로벌 메트릭을 새벽에 단 한 번 가져오고(<code>step1</code>), 다운스트림 가공 과정만 도메인별(<code>COMPUTE</code>, <code>STORAGE</code>) 서브폴더 파티션으로 격리해 순회하는 구조는 데이터 레이크하우스 구축 시 성능 경합을 피할 수 있는 가장 훌륭한 선택입니다.</li>
</ul>
<ol start="2">
<li><strong>AIStor 내 데이터 생명주기 및 적재 규칙</strong></li>
</ol>
<ul>
<li><code>raw/</code>, <code>enrich/</code>, <code>excel/</code>로 일단위 보관 체계를 잡은 것은 원시 로그 추적 및 장기 트렌드 분석을 위해 완벽한 구조입니다.</li>
</ul>
<ol start="3">
<li><strong>배치 범위의 유연성 (Daily vs. Weekly/Monthly)</strong></li>
</ol>
<ul>
<li><code>start-date</code>와 <code>end-date</code>를 파라미터로 받아 하방 파이프라인을 재구동할 수 있게 설계한 덕분에, 주간 단위 배치 워크로드의 변동성(Spike)이나 <strong>월간 정산 주기</strong>에 맞춰 유연한 통계 윈도우(Time-window)를 가져갈 수 있습니다.</li>
</ul>
<hr>
<h2 id="part-2-개발-담당자-협의용-자원-조정-가이드라인-기본-자료">Part 2. 개발 담당자 협의용 자원 조정 가이드라인 (기본 자료)</h2>
<p>개발자들은 대개 서비스 안정성을 이유로 자원 축소(Downscaling)에 보수적(반발)입니다. 따라서 협의 시에는 감정적 설득이 아닌, &quot;우리가 수집한 P95 데이터와 커널 가시성(Throttling/OOM)&quot;을 기반으로 객체화된 가이드를 제시해야 합니다.</p>
<h3 id="1-자원-조정의-대원칙-platform--dev-shared-goal">1. 자원 조정의 대원칙 (Platform &amp; Dev Shared Goal)</h3>
<ul>
<li><strong>목적:</strong> 애플리케이션의 안정성(Reliability)을 최우선으로 확보하되, 무의미하게 버려지는 <strong>선점 자원(Locked Resource)을 회수</strong>하여 전체 인프라의 가용 슬롯을 확보함.</li>
<li><strong>약속:</strong> 가이드라인에 따라 자원을 조정한 후 모니터링 중 장애 징후가 보이면, 즉시 이전 상태로 Rollback 하거나 즉각 재조정(Upscaling)을 보장함.</li>
</ul>
<hr>
<h3 id="2-유형별-자원-조정-판단-기준-및-권장-액션-thresholds">2. 유형별 자원 조정 판단 기준 및 권장 액션 (Thresholds)</h3>
<p>주간 데이터에서 <strong>최소 7일 이상 지속(지속성 7회 이상)</strong> 감지된 워크로드를 대상으로 아래 기준을 적용합니다.</p>
<h4 id="🔴-유형-a-과다-할당-위반군-over-allocated--waste">🔴 유형 A. 과다 할당 위반군 (Over-allocated / Waste)</h4>
<p>개발팀이 안정성을 이유로 필요 이상의 자원을 선점해 둔 상태입니다.</p>
<ul>
<li><strong>판단 기준:</strong> 7일간 <code>CPU/Memory 평균 실효 활용률(Util) &lt; 15%</code>이면서 동시에 <code>P95 Usage가 Request의 30% 미만</code>인 경우.</li>
<li><strong>가이드 가치 (Talk):</strong> *&quot;현재 할당된 스펙의 80% 이상이 단 한 번도 사용되지 않고 낭비되고 있어 타 모듈의 파드 배치를 방해하고 있습니다.&quot;*</li>
<li><strong>조정 가이드:</strong> * <strong>Request 조정:</strong> 관측된 <code>P95 최대 사용량 * 1.25 (25% 마진)</code> 수준으로 Request 하향 조정을 권장합니다.</li>
<li><strong>Limit 설정:</strong> Limit은 현재 값을 유지하거나 Request의 2배 수준으로 확보하여 급작스러운 스파이크는 흡수할 수 있도록 안전장치를 제공합니다.</li>
</ul>
<h4 id="🟡-유형-b-자원-부족-및-병목-위험군-shortage--performance-risk">🟡 유형 B. 자원 부족 및 병목 위험군 (Shortage / Performance Risk)</h4>
<p>실제 서비스 장애(OOM)가 났거나 커널 단에서 연산 속도 제한이 걸려 성능이 저하되고 있는 위험 상태입니다.</p>
<ul>
<li><strong>판단 기준:</strong> 1. <strong>Memory:</strong> 관측 기간 내 <strong><code>OOMKilled(is_oom_killed) 1회 이상 발생</code></strong> 또는 <strong><code>Memory RSS 사용량이 Limit의 90% 상회</code></strong>.</li>
</ul>
<ol start="2">
<li><strong>CPU:</strong> <strong><code>CFS Throttling Peak(cpu_throttled_max) &gt; 20%</code></strong> 상태가 빈번하여 애플리케이션 지연(Latency) 유발 처리가 확인된 경우.</li>
</ol>
<ul>
<li><strong>가이드 가치 (Talk):</strong> *&quot;해당 모듈은 리소스 부족으로 인해 컨테이너가 강제 종료되거나 커널 단에서 연산 스로틀링(지연)이 걸려 서비스 품질에 악영향을 주고 있습니다.&quot;*</li>
<li><strong>조정 가이드:</strong></li>
<li><strong>Memory:</strong> 즉시 Limit 규격을 <code>기존 Limit * 1.3 (30% 상향)</code> 조정하거나, 가공 원부의 <code>Mem RSS</code> 최고점 대비 20% 마진을 두어 메모리 누수(Leak) 여부 확인을 요청합니다.</li>
<li><strong>CPU:</strong> Request를 <code>CPU P95</code> 수준으로 현실화하고, Limit을 대폭 늘려주거나 자바/Go의 런타임 스레드 풀 수치를 k8s 쿼터와 매칭하도록 가이드합니다.</li>
</ul>
<h4 id="🟠-유형-c-규격-미설정-위반군-violation--missing-settings">🟠 유형 C. 규격 미설정 위반군 (Violation / Missing Settings)</h4>
<p>Request나 Limit 중 하나라도 누락되어 노드 전반의 동반 자원 고갈(Noisy Neighbor)을 유발하는 상태입니다.</p>
<ul>
<li><strong>판단 기준:</strong> <strong><code>has_no_request == True</code></strong> 또는 <strong><code>has_no_limit == True</code></strong></li>
<li><strong>가이드 가치 (Talk):</strong> *&quot;자원 설정이 누락되어 해당 파드가 유실되거나 노드 내 다른 정상 서비스의 자원을 침범할 위험이 있습니다. 클러스터 거버넌스 규격 준수가 강제됩니다.&quot;*</li>
<li><strong>조정 가이드:</strong> 지난 7일간 수집된 실제 <code>daily_enriched</code> 데이터의 평균 사용량을 기반으로 최소한의 Request/Limit 세팅을 의무화합니다.</li>
</ul>
<hr>
<h3 id="3-개발-담당자-커뮤니케이션용-핵심-지표-용어-해석-사전">3. 개발 담당자 커뮤니케이션용 핵심 지표 용어 해석 사전</h3>
<p>개발자들이 인프라 메트릭을 쉽게 이해할 수 있도록 리포트 전달 시 아래 용어 설명을 동봉합니다.</p>
<ul>
<li><strong>P95 사용량 (95th Percentile):</strong> 전체 실행 시간 중 상위 5%에 해당하는 피크 수치입니다. 평균치만 보고 자원을 줄이면 피크 시 서비스가 터지므로, 플랫폼 팀은 개발팀의 안정성을 보장하기 위해 이 <strong>P95 수치를 기준선</strong>으로 대화합니다.</li>
<li><strong>CPU Throttling (스로틀링):</strong> 컨테이너가 배정된 CPU 한도(Limit)를 초과하여 커널이 강제로 프로세스를 멈추고 대기시킨 비율입니다. 이 수치가 높으면 소스 코드가 아무리 좋아도 인프라 쿼터 제약 때문에 API 응답 속도가 느려집니다.</li>
<li><strong>Memory RSS vs WorkingSet:</strong> <code>WorkingSet</code>은 커널 캐시를 포함한 느슨한 지표이지만, <strong><code>RSS</code>는 프로세스가 물리 메모리에 직접 상주 시킨 진짜 자원 소모량</strong>입니다. OOMKilled는 이 RSS가 Limit을 칠 때 발생하므로, 3번 시트의 RSS 데이터를 보고 메모리 증설 여부를 정확히 판단할 수 있습니다.</li>
</ul>
<hr>
<h2 id="part-3-주간-커뮤니케이션-가동-워크플로우-예시-일주일에-한번">Part 3. 주간 커뮤니케이션 가동 워크플로우 예시 (일주일에 한번)</h2>
<p>매주 월요일 아침, 주간 lookback 파이프라인을 돌려 나온 <code>excel</code> 결과물에서 가장 낭비가 심한 상위 5개 네임스페이스(Step6 파레토 Critical 등급)와 <strong>장애 위험 상위 5개 네임스페이스</strong>를 추출하여 아래 템플릿 형태로 던져 소통하시면 됩니다.</p>
<blockquote>
<p><strong>[주간 자원 거버넌스 공람 템플릿 예시]</strong></p>
<ul>
<li><strong>수신:</strong> 메인 모듈 개발 리드</li>
<li><strong>발신:</strong> 플랫폼 DevOps 파트</li>
<li><strong>내용:</strong> 지난 주간 <code>COMPUTE</code> 클러스터 정산 결과 기반 최적화 대상 권고</li>
</ul>
<ol>
<li><strong>대상 워크로드:</strong> <code>order-api-pod</code> (Namespace: <code>production-order</code>)</li>
<li><strong>인프라 진단 데이터:</strong></li>
</ol>
<ul>
<li>할당 자원: CPU Request 8 Cores / Memory Limit 16 GB</li>
<li>실제 사용량: CPU P95 0.8 Cores (활용률 10%) / Mem RSS Peak 4 GB (활용률 25%)</li>
<li>특이사항: 7일 연속 피크 타임에도 자원 소모량 변동 없음 (과다 할당 등급: Critical)</li>
</ul>
<ol start="3">
<li><strong>플랫폼 팀 제안 권고사항:</strong></li>
</ol>
<ul>
<li>CPU Request를 8 Cores ➡️ 2 Cores로 하향 조정 제안 (Limit은 8 Cores 유지하여 스파이크 보장)</li>
<li>회수된 자원은 다음 주 배포 예정인 신규 인프라 데이터 레이크 파드 배치 비용으로 재활용됩니다.</li>
</ul>
</blockquote>
<hr>
<p>이 정도의 데이터 무기와 기준선을 가지고 개발팀과 협의 체계를 정립하시면, 플랫폼 엔지니어로서 클러스터 전체 리소스 통제권을 강력하게 쥐고 가실 수 있습니다.</p>
<p>개발팀 소통 프로세스를 정립하시면서, 다음 단계인 &quot;K8s 전체 자원 관리 및 거버넌스 고도화 전략(VPA/HPA 연계, 오버커밋 제어 등)&quot;으로 넘어가고 싶으실 때 언제든 질문해 주세요!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02x1]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02x1</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02x1</guid>
            <pubDate>Thu, 02 Jul 2026 14:36:04 GMT</pubDate>
            <description><![CDATA[<p>```python</p>
<h1 id="1-기존-데이터-parquet-로드">1. 기존 데이터 Parquet 로드</h1>
<pre><code>    df_pod      = pd.read_parquet(p1)
    df_ns       = pd.read_parquet(p2)
    df_daily_ns = pd.read_parquet(p3)

    # ─── 🎯 [최종 통합 완결 패치] 모든 하방 시트의 KeyError를 여기서 종결 ───

    # [공통] cluster 컬럼 누락 방어 주입
    if &#39;cluster&#39; not in df_pod.columns:
        df_pod[&#39;cluster&#39;] = infra_tag
    if &#39;cluster&#39; not in df_daily_ns.columns:
        df_daily_ns[&#39;cluster&#39;] = infra_tag

    # 💡 [3번 탭 패치 흡수] pv_used_p95 명명 규칙 보정 및 기본값 방어
    if &quot;pv_used_p95&quot; not in df_pod.columns:
        if &quot;pv_usage_p95&quot; in df_pod.columns:
            df_pod[&quot;pv_used_p95&quot;] = df_pod[&quot;pv_usage_p95&quot;]
        else:
            df_pod[&quot;pv_used_p95&quot;] = 0.0  # PV 메트릭이 없는 노드 환경 대비
    if &quot;pv_capacity_max&quot; not in df_pod.columns:
        df_pod[&quot;pv_capacity_max&quot;] = 0.0

    # [5번 탭 대응] minutes_running 실행 시간 컬럼명 변형 방어
    if &quot;minutes_running&quot; not in df_pod.columns:
        for alt_col in [&quot;duration_minutes&quot;, &quot;running_minutes&quot;, &quot;duration&quot;]:
            if alt_col in df_pod.columns:
                df_pod[&quot;minutes_running&quot;] = df_pod[alt_col]
                break
        else:
            df_pod[&quot;minutes_running&quot;] = 0

    # [6번 탭 대응] mem_allocated_gb_hours 변형 방어
    if &quot;mem_allocated_gb_hours&quot; not in df_pod.columns:
        for alt_col in [&quot;mem_alloc_gb_hours&quot;, &quot;mem_alloc_gh&quot;, &quot;memory_allocated_gb_hours&quot;]:
            if alt_col in df_pod.columns:
                df_pod[&quot;mem_allocated_gb_hours&quot;] = df_pod[alt_col]
                break

    # [6번 탭 대응] pv_allocated_gb_hours 변형 방어
    if &quot;pv_allocated_gb_hours&quot; not in df_pod.columns:
        for alt_col in [&quot;pv_alloc_gb_hours&quot;, &quot;pv_alloc_gh&quot;, &quot;pv_capacity_gb_hours&quot;, &quot;pv_capacity_max&quot;]:
            if alt_col in df_pod.columns:
                df_pod[&quot;pv_allocated_gb_hours&quot;] = df_pod[alt_col]
                break

    # ─── 🏁 방어 레이어 마감 후 엑셀 컴파일 및 시트 빌드 로직 정상 진행 ───
    excel_name = f&quot;res_usage_report_{infra_tag.lower()}_{date_tag}.xlsx&quot;
    # ... (이하 동일) ...</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02n4]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02n4</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02n4</guid>
            <pubDate>Thu, 02 Jul 2026 07:54:43 GMT</pubDate>
            <description><![CDATA[<p>```python</p>
<h1 id="-중략-">... (중략) ...</h1>
<pre><code>    print(f&quot;\n&quot; + &quot;=&quot;*60)
    print(f&quot;🔄 [서브폴더 파티션 검증 완료] Cluster: {infra_tag} | Date: {date_tag} 컴파일 가동&quot;)
    print(&quot;=&quot;*60)

    # 1. 기존 데이터 로드
    df_pod      = pd.read_parquet(p1)
    df_ns       = pd.read_parquet(p2)
    df_daily_ns = pd.read_parquet(p3)

    # ─── 🎯 [KeyError 해결 패치] 누락된 cluster 컬럼을 동적으로 주입 ───
    if &#39;cluster&#39; not in df_pod.columns:
        df_pod[&#39;cluster&#39;] = infra_tag  # 명세서 작성을 위해 대문자 값(COMPUTE/STORAGE) 강제 주입

    if &#39;cluster&#39; not in df_daily_ns.columns:
        df_daily_ns[&#39;cluster&#39;] = infra_tag

    # 기존 excel_name 정의 및 하위 build_sheet 호출부로 이어짐...
    excel_name = f&quot;res_usage_report_{infra_tag.lower()}_{date_tag}.xlsx&quot;
    # ... (이하 동일) ...</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02n3]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02n3</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02n3</guid>
            <pubDate>Thu, 02 Jul 2026 07:42:42 GMT</pubDate>
            <description><![CDATA[<p>```python
import os
import glob
import re
import sys
import argparse  # ◀ [커맨드라인 인자 처리를 위한 모듈 추가]
import pandas as pd
import numpy as np</p>
<h1 id="-기존-openpyxl-및-minio-임포트-생략-">... 기존 openpyxl 및 minio 임포트 생략 ...</h1>
<h1 id="───-main-─────────────────────────────────────────────────">─── Main ─────────────────────────────────────────────────</h1>
<p>def main():
    print(&quot;🚀 [Step6 개시] res_usage_ 계열 고도화 엑셀 리포터 엔진 가동...&quot;)</p>
<pre><code># ─── 🎯 [요청 반영] --cluster Argument 파싱 레이어 구성 ───
parser = argparse.ArgumentParser(description=&quot;Step6 리소스 정산 엑셀 마스터 빌더&quot;)
parser.add_argument(
    &quot;--cluster&quot;, 
    type=str, 
    choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;, &quot;compute&quot;, &quot;storage&quot;],
    help=&quot;대상 클러스터 컨텍스트 명시 (COMPUTE 또는 STORAGE)&quot;
)
args = parser.parse_args()

# 1순위: --cluster 인자값, 2순위: CLUSTER_MODE 환경변수
cluster_mode = args.cluster or os.getenv(&quot;CLUSTER_MODE&quot;, &quot;&quot;)
cluster_mode = cluster_mode.upper()  # 대문자 정형화

if cluster_mode:
    print(f&quot;🎯 클러스터 Argument/컨텍스트 고정 완료: [merged/{cluster_mode}/]&quot;)
    search_pattern = str(MERGED_DIR / cluster_mode / f&quot;pareto_ns_{cluster_mode}_*.parquet&quot;)
else:
    print(&quot;🔍 클러스터 미지정: merged/ 하위 모든 클러스터 서브폴더(*)를 자동 순회 탐색합니다.&quot;)
    search_pattern = str(MERGED_DIR / &quot;*&quot; / &quot;pareto_ns_*.parquet&quot;)

pareto_files = glob.glob(search_pattern)

if not pareto_files:
    print(f&quot;❌ 오류: 탐색된 파레토 가공원부(*.parquet) 파일이 없습니다. 타겟 경로를 확인하세요: {search_pattern}&quot;)
    return

processed_count = 0
for file_path in pareto_files:
    filename = os.path.basename(file_path)
    match = re.search(r&quot;pareto_ns_(.*)_(.*)\.parquet&quot;, filename)
    if not match:
        continue

    infra_tag = match.group(1)   # 예: COMPUTE 또는 STORAGE
    date_tag  = match.group(2)   # 예: 20260702

    # 하위 클러스터 서브디렉토리 경로 명시적 조인
    p1 = MERGED_DIR / infra_tag / f&quot;daily_enriched_{infra_tag}_{date_tag}.parquet&quot;
    p2 = Path(file_path)
    p3 = MERGED_DIR / infra_tag / f&quot;daily_ns_usage_{infra_tag}_{date_tag}.parquet&quot;

    if not (p1.exists() and p3.exists()):
        print(f&quot;⚠️  [세트 불완전] merged/{infra_tag}/ 하위에 쌍이 되는 가공 데이터가 유실되어 작업을 건너뜁니다.&quot;)
        continue

    print(f&quot;\n&quot; + &quot;=&quot;*60)
    print(f&quot;🔄 [서브폴더 파티션 검증 완료] Cluster: {infra_tag} | Date: {date_tag} 컴파일 가동&quot;)
    print(&quot;=&quot;*60)

    df_pod = pd.read_parquet(p1)
    df_ns = pd.read_parquet(p2)
    df_daily_ns = pd.read_parquet(p3)

    excel_name = f&quot;res_usage_report_{infra_tag.lower()}_{date_tag}.xlsx&quot;

    wb = Workbook()
    build_sheet_summary(wb, df_pod, df_ns, infra_tag)
    build_sheet_pareto(wb, df_ns, infra_tag)
    build_sheet_ns_daily(wb, df_daily_ns, infra_tag)
    build_sheet_cpu(wb, df_pod, infra_tag)
    build_sheet_memory(wb, df_pod, infra_tag)
    build_sheet_oom(wb, df_pod, infra_tag)
    build_sheet_violations(wb, df_pod, infra_tag)
    build_sheet_trends(wb, df_pod, infra_tag)
    build_sheet_workload(wb, df_pod, infra_tag)
    build_sheet_extra_charts(wb, infra_tag, date_tag)

    out_path = OUT_DIR / excel_name
    print(f&quot;💾 openpyxl 스트림 디스크 저장 가동 중... ➡️ {out_path}&quot;)
    wb.save(out_path)
    print(f&quot;📦 [로컬 컴파일 마감 완료]: {excel_name} ({out_path.stat().st_size/1024:.0f} KB)&quot;)

    # ─── 🪣 사내 MinIO AIStor 자동 배포 레이어 ───
    minio_endpoint   = os.getenv(&quot;MINIO_ENDPOINT&quot;)
    minio_access_key = os.getenv(&quot;MINIO_ACCESS_KEY&quot;)
    minio_secret_key = os.getenv(&quot;MINIO_SECRET_KEY&quot;)
    bucket_name      = os.getenv(&quot;MINIO_REPORT_BUCKET&quot;, &quot;devops-test&quot;)

    if all([minio_endpoint, minio_access_key, minio_secret_key]):
        try:
            endpoint_clean = minio_endpoint.replace(&quot;http://&quot;, &quot;&quot;).replace(&quot;https://&quot;, &quot;&quot;)
            secure_flag = minio_endpoint.startswith(&quot;https://&quot;)

            minio_client = Minio(
                endpoint_clean,
                access_key=minio_access_key,
                secret_key=minio_secret_key,
                secure=secure_flag
            )

            if not minio_client.bucket_exists(bucket_name):
                minio_client.make_bucket(bucket_name)

            object_key = f&quot;reports/{infra_tag.lower()}/{excel_name}&quot;
            print(f&quot;🪣  [오브젝트 스토리지 싱크] devops-test 버킷 배포 ➡️ MinIO://{object_key} 업로드 중...&quot;)
            minio_client.fput_file(bucket_name, object_key, str(out_path))
            print(&quot;🏁 === [전사 배포 마감 성공] 고도화 res_usage_ 마스터 엑셀 리포트 배포 자산화 완료 ===&quot;)
        except Exception as e:
            print(f&quot;❌ [배포 에러] 사내 MinIO 원격 업로드 중 예외 발생: {str(e)}&quot;)
    else:
        print(&quot;⚠️ [안내] 접속 환경변수 생략으로 로컬 아카이빙 처리 후 해당 파티션을 마감합니다.&quot;)

    processed_count += 1

print(f&quot;\n🏁 === [전체 자동 매칭 빌드 완료] 총 {processed_count}개 세트 인프라 마스터 리포트 빌드 프로세스가 종결되었습니다. ===&quot;)</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02n2]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02n2</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02n2</guid>
            <pubDate>Thu, 02 Jul 2026 06:02:29 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step6 Excel Builder v2 — KST 보정 + 차트 20개 + 워크로드별 분석 시트
[최종 통합 완결판: res_usage_ 전환 + devops-test 버킷 + 11대 메트릭 + 일단위 NS 정산 시트 탑재]
(MinIO 네이티브 모듈 전환 및 동적 파티션 배치 패치 완료)
&quot;&quot;&quot;
import os
import glob
import re
import sys
import pandas as pd
import numpy as np
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image as XLImage
from openpyxl.formatting.rule import ColorScaleRule, DataBarRule
from minio import Minio  # ◀ [boto3 대체 미니오 상용 모듈 탑재]</p>
<h1 id="───-📂-모든-디렉토리-경로-data-상대-경로-표준화-──────────────────────────">─── 📂 모든 디렉토리 경로 ./data 상대 경로 표준화 ──────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
MERGED_DIR    = BASE_DATA_DIR / &quot;merged&quot;
PLOT_DIR      = BASE_DATA_DIR / &quot;output&quot; / &quot;plots&quot;
OUT_DIR       = BASE_DATA_DIR / &quot;output&quot;</p>
<p>OUT_DIR.mkdir(parents=True, exist_ok=True)</p>
<p>C = dict(
    hdr_dark=&quot;1F4E79&quot;, hdr_mid=&quot;2E75B6&quot;, hdr_light=&quot;BDD7EE&quot;,
    accent=&quot;ED7D31&quot;, red=&quot;C00000&quot;, green=&quot;70AD47&quot;,
    yellow=&quot;FFC000&quot;, gray_row=&quot;F2F2F2&quot;, white=&quot;FFFFFF&quot;,
    border=&quot;9DC3E6&quot;, summary_bg=&quot;EBF3FB&quot;, purple=&quot;7030A0&quot;,
    teal=&quot;008080&quot;,
)</p>
<p>def ft(bold=False, size=10, color=&quot;000000&quot;, name=&quot;Arial&quot;):
    return Font(name=name, bold=bold, size=size, color=color)</p>
<p>def fill(hex_color):
    return PatternFill(&quot;solid&quot;, fgColor=hex_color)</p>
<p>def thin_border():
    t = Side(style=&quot;thin&quot;, color=C[&quot;border&quot;])
    return Border(left=t, right=t, top=t, bottom=t)</p>
<p>def center(wrap=False):
    return Alignment(horizontal=&quot;center&quot;, vertical=&quot;center&quot;, wrap_text=wrap)</p>
<p>def left(wrap=False):
    return Alignment(horizontal=&quot;left&quot;, vertical=&quot;center&quot;, wrap_text=wrap)</p>
<p>def set_col_widths(ws, widths):
    for col, w in widths.items():
        ws.column_dimensions[col].width = w</p>
<p>def apply_header_row(ws, row_idx, headers, bg=None, fg=C[&quot;white&quot;], size=10):
    bg = bg or C[&quot;hdr_dark&quot;]
    for c, h in enumerate(headers, 1):
        cell = ws.cell(row=row_idx, column=c, value=h)
        cell.font = ft(bold=True, size=size, color=fg)
        cell.fill = fill(bg)
        cell.alignment = center(wrap=True)
        cell.border = thin_border()</p>
<p>def apply_data_rows(ws, df, start_row, num_formats=None, status_col_idx=None, zebra=True):
    nf = num_formats or {}
    for r_offset, row in enumerate(df.itertuples(index=False)):
        row_num = start_row + r_offset
        bg_hex = C[&quot;gray_row&quot;] if (r_offset % 2 == 1 and zebra) else C[&quot;white&quot;]
        for c_idx, val in enumerate(row, 1):
            cell = ws.cell(row=row_num, column=c_idx, value=val)
            cell.font = ft(size=9)
            cell.fill = fill(bg_hex)
            cell.alignment = left()
            cell.border = thin_border()
            if c_idx in nf:
                cell.number_format = nf[c_idx]
            if status_col_idx and c_idx == status_col_idx:
                v = str(val)
                if &quot;OOM&quot; in v or &quot;Killed&quot; in v:
                    cell.fill = fill(&quot;FFCCCC&quot;); cell.font = ft(bold=True, size=9, color=C[&quot;red&quot;])
                elif &quot;Shortage&quot; in v or &quot;부족&quot; in v:
                    cell.fill = fill(&quot;FFF2CC&quot;); cell.font = ft(bold=True, size=9, color=&quot;7F6000&quot;)
                elif &quot;Over&quot; in v or &quot;과다&quot; in v:
                    cell.fill = fill(&quot;DDEEFF&quot;); cell.font = ft(bold=True, size=9, color=C[&quot;hdr_dark&quot;])
                elif &quot;Optim&quot; in v or &quot;최적&quot; in v:
                    cell.fill = fill(&quot;E2EFDA&quot;); cell.font = ft(bold=True, size=9, color=&quot;375623&quot;)
    return start_row + len(df)</p>
<p>def freeze_and_filter(ws, row=2):
    ws.freeze_panes = ws.cell(row=row+1, column=1)
    ws.auto_filter.ref = ws.dimensions</p>
<p>def add_chart_image(ws, path_key, anchor_cell, w=860, h=400, label=None):
    path = PLOT_DIR / path_key
    if not path.exists():
        print(f&quot;  ⚠️  [차트 유실] {path_key} 파일이 {PLOT_DIR}에 없어 삽입을 건너뜁니다.&quot;)
        return
    row_num = int(&#39;&#39;.join(filter(str.isdigit, anchor_cell)))
    if label:
        col_letter = &quot;&quot;.join(filter(str.isalpha, anchor_cell))
        col_idx = 1 if col_letter == &quot;A&quot; else (9 if col_letter == &quot;I&quot; else 1)
        ws.cell(row=row_num-1, column=col_idx, value=label).font = ft(bold=True, size=11, color=C[&quot;hdr_dark&quot;])
    img = XLImage(str(path))
    img.width = w; img.height = h
    ws.add_image(img, anchor_cell)
    print(f&quot;  -&gt; 🎨 차트 맵핑 완료: {path_key} ➡️ {anchor_cell} 셀&quot;)</p>
<h1 id="───-sheet-0-전사-종합-요약-───────────────────────────────">─── Sheet 0: 전사 종합 요약 ───────────────────────────────</h1>
<p>def build_sheet_summary(wb, df_pod, df_ns, infra_tag):
    print(&quot;⏳ [0/9] &#39;0. 전사종합요약&#39; 대시보드 탭 구축 개시...&quot;)
    ws = wb.active
    ws.title = &quot;0. 전사종합요약&quot;
    ws.sheet_view.showGridLines = False
    ws.row_dimensions[1].height = 42</p>
<pre><code>ws.merge_cells(&quot;A1:H1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Resource Governance Master Report [{infra_tag}]  (KST 기준)&quot;
t.font = ft(bold=True, size=15, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()

oom_cnt    = int(df_pod[&quot;is_oom_killed&quot;].sum())
no_req_cnt = int((df_pod[&quot;has_no_request&quot;] | df_pod[&quot;has_no_limit&quot;]).sum())
alloc_ch   = df_pod[&quot;cpu_allocated_core_hours&quot;].sum()
usage_ch   = df_pod[&quot;cpu_usage_core_hours&quot;].sum()
waste_ch   = df_pod[&quot;cpu_waste_core_hours&quot;].sum()
eff_pct    = usage_ch / max(alloc_ch, 0.001) * 100

mem_waste  = df_pod[&quot;mem_waste_gb_hours&quot;].sum()
pv_waste   = df_pod[&quot;pv_waste_gb_hours&quot;].sum()
throttle_risks = int((df_pod[&quot;cpu_throttled_max&quot;] &gt; 0.2).sum())
kst_dates  = sorted(df_pod[&quot;date&quot;].unique())
date_range = f&quot;{kst_dates[0]} ~ {kst_dates[-1]} (KST)&quot;

kpis = [
    (&quot;정산 대상 인프라 도메인&quot;,       infra_tag,                              &quot;002060&quot;,      C[&quot;white&quot;]),
    (&quot;분석 기간 (KST)&quot;,              date_range,                             C[&quot;hdr_dark&quot;], C[&quot;white&quot;]),
    (&quot;총 관측 컨테이너 볼륨&quot;,         f&quot;{len(df_pod):,} 개&quot;,                C[&quot;hdr_dark&quot;], C[&quot;white&quot;]),
    (&quot;OOMKilled 장애 컨테이너&quot;,       f&quot;{oom_cnt:,} 개&quot;,                    C[&quot;red&quot;],      C[&quot;white&quot;]),
    (&quot;연산 스로틀링 위험군 컨테이너&quot;,   f&quot;{throttle_risks:,} 개&quot;,                 &quot;C00000&quot;,      C[&quot;white&quot;]),
    (&quot;리소스 설정 규격 위반군&quot;,       f&quot;{no_req_cnt:,} 개&quot;,                  &quot;E26B0A&quot;,      C[&quot;white&quot;]),
    (&quot;전사 CPU 무효 선점 낭비량&quot;,     f&quot;{waste_ch:,.1f} Core-H&quot;,            C[&quot;hdr_mid&quot;],  C[&quot;white&quot;]),
    (&quot;전사 Memory 무효 선점 낭비량&quot;,   f&quot;{mem_waste:,.1f} GB-H&quot;,             &quot;6B4F9B&quot;,      C[&quot;white&quot;]),
    (&quot;전사 PV 스토리지 알박기 낭비량&quot;, f&quot;{pv_waste:,.1f} GB-H&quot;,              &quot;008080&quot;,      C[&quot;white&quot;]),
    (&quot;전사 CPU 평균 실효 활용률&quot;,     f&quot;{eff_pct:.1f} %&quot;,                    &quot;375623&quot;,      C[&quot;white&quot;]),
]

for i, (label, value, bg, fg) in enumerate(kpis):
    row = 3 + i
    ws.row_dimensions[row].height = 24
    lc = ws.cell(row=row, column=1, value=label)
    lc.font = ft(bold=True, size=10, color=C[&quot;hdr_dark&quot;])
    lc.fill = fill(C[&quot;summary_bg&quot;]); lc.alignment = left(); lc.border = thin_border()
    ws.merge_cells(f&quot;A{row}:C{row}&quot;)
    vc = ws.cell(row=row, column=4, value=value)
    vc.font = ft(bold=True, size=11, color=fg)
    vc.fill = fill(bg); vc.alignment = center(); vc.border = thin_border()
    ws.merge_cells(f&quot;D{row}:F{row}&quot;)

set_col_widths(ws, {&quot;A&quot;:28,&quot;B&quot;:14,&quot;C&quot;:14,&quot;D&quot;:22,&quot;E&quot;:14,&quot;F&quot;:14,&quot;G&quot;:20,&quot;H&quot;:20})

chart_row = 16
add_chart_image(ws, &quot;chart6_status_donut.png&quot;,   f&quot;A{chart_row}&quot;, w=420, h=300)
add_chart_image(ws, &quot;chart5_pareto_ns_waste.png&quot;, f&quot;E{chart_row}&quot;, w=560, h=300)</code></pre><h1 id="───-sheet-1-파레토-분석-ns-──────────────────────────────">─── Sheet 1: 파레토 분석 NS ──────────────────────────────</h1>
<p>def build_sheet_pareto(wb, df_ns, infra_tag):
    print(f&quot;⏳ [1/9] &#39;1. 파레토분석_NS&#39; 시트 렌더링 중...&quot;)
    ws = wb.create_sheet(&quot;1. 파레토분석_NS&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:I1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Namespace별 CPU Waste 파레토 분석 [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;Namespace&quot;,&quot;실행시간 합계(분)&quot;,&quot;컨테이너 수&quot;,&quot;할당 Core-H&quot;,&quot;낭비 Core-H&quot;,&quot;낭비 비중(%)&quot;,&quot;누적 비중(%)&quot;,&quot;등급&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])

df_disp = df_ns.copy()
df_disp[&quot;등급&quot;] = df_disp[&quot;waste_cumsum_pct&quot;].apply(
    lambda x: &quot;Critical (Top 20%)&quot; if x&lt;=20 else (&quot;High (Top 50%)&quot; if x&lt;=50 else (&quot;Medium&quot; if x&lt;=80 else &quot;Low&quot;)))
col_order = [&quot;namespace&quot;,&quot;minutes_running_sum&quot;,&quot;container_cnt&quot;,&quot;total_allocated_core_hours&quot;,&quot;total_waste_core_hours&quot;,&quot;waste_share_pct&quot;,&quot;waste_cumsum_pct&quot;,&quot;등급&quot;]
df_out = df_disp[col_order]
nf = {4:&quot;#,##0.0&quot;, 5:&quot;#,##0.0&quot;, 6:&quot;0.00&quot;, 7:&quot;0.00&quot;}
end_row = apply_data_rows(ws, df_out, start_row=3, num_formats=nf)

ws.conditional_formatting.add(f&quot;E3:E{end_row}&quot;, DataBarRule(start_type=&quot;min&quot;, end_type=&quot;max&quot;, color=&quot;2E75B6&quot;, showValue=True))
set_col_widths(ws, {&quot;A&quot;:22,&quot;B&quot;:18,&quot;C&quot;:14,&quot;D&quot;:16,&quot;E&quot;:16,&quot;F&quot;:12,&quot;G&quot;:12,&quot;H&quot;:22})
freeze_and_filter(ws)</code></pre><h1 id="───-🎉-sheet-1-2-일단위-namespace별-자원-사용량-탭-───────────────────">─── 🎉 Sheet 1-2: 일단위 Namespace별 자원 사용량 탭 ───────────────────</h1>
<p>def build_sheet_ns_daily(wb, df_daily_ns, infra_tag):
    print(f&quot;⏳ [2/9] &#39;1-2. 일단위_NS별_사용량&#39; 종합 청산 피벗 장표 마크 중... (행 수: {len(df_daily_ns):,}건)&quot;)
    ws = wb.create_sheet(&quot;1-2. 일단위_NS별_사용량&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:J1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Namespace 일일 자원 소모량 및 통합 사용량 점수 지표 [{infra_tag}]&quot;
t.font = ft(bold=True, size=12, color=C[&quot;white&quot;])
t.fill = fill(&quot;1F4E79&quot;); t.alignment = center()
ws.row_dimensions[1].height = 35

headers = [
    &quot;관측 일자(KST)&quot;, &quot;네임스페이스(Namespace)&quot;, 
    &quot;CPU 사용(Core-H)&quot;, &quot;CPU 할당(Core-H)&quot;, &quot;CPU 낭비(Core-H)&quot;,
    &quot;Mem 사용(GB-H)&quot;, &quot;Mem 할당(GB-H)&quot;,
    &quot;PV 사용(GB-H)&quot;, &quot;PV 할당(GB-H)&quot;, &quot;통합 사용량 점수 (Score)&quot;
]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])
ws.row_dimensions[2].height = 26

col_order = [&quot;date&quot;, &quot;namespace&quot;, &quot;cpu_used_ch&quot;, &quot;cpu_alloc_ch&quot;, &quot;cpu_waste_ch&quot;, &quot;mem_used_gh&quot;, &quot;mem_alloc_gh&quot;, &quot;pv_used_gh&quot;, &quot;pv_alloc_gh&quot;, &quot;final_usage_score&quot;]
df_out = df_daily_ns[col_order].sort_values(by=[&quot;date&quot;, &quot;final_usage_score&quot;], ascending=[True, False])

nf = {3:&quot;#,##0.0&quot;, 4:&quot;#,##0.0&quot;, 5:&quot;#,##0.0&quot;, 6:&quot;#,##0.0&quot;, 7:&quot;#,##0.0&quot;, 8:&quot;#,##0.0&quot;, 9:&quot;#,##0.0&quot;, 10:&quot;#,##0.0&quot;}
end_row = apply_data_rows(ws, df_out, start_row=3, num_formats=nf)

ws.conditional_formatting.add(f&quot;J3:J{end_row}&quot;, DataBarRule(start_type=&quot;min&quot;, end_type=&quot;max&quot;, color=&quot;375623&quot;, showValue=True))

widths = {&quot;A&quot;:14, &quot;B&quot;:22, &quot;C&quot;:18, &quot;D&quot;:18, &quot;E&quot;:18, &quot;F&quot;:18, &quot;G&quot;:18, &quot;H&quot;:18, &quot;I&quot;:18, &quot;J&quot;:24}
set_col_widths(ws, widths)
freeze_and_filter(ws, row=2)</code></pre><h1 id="───-sheet-2-cpu-분석-────────────────────────────────────">─── Sheet 2: CPU 분석 ────────────────────────────────────</h1>
<p>def build_sheet_cpu(wb, df_pod, infra_tag):
    top30 = max(1, int(len(df_pod)*0.30))
    print(f&quot;⏳ [3/9] &#39;2. CPU Request_Usage 분석&#39; 시트 빌드 중... (상위 30% 격리: {top30}행)&quot;)
    ws = wb.create_sheet(&quot;2. CPU Request_Usage 분석&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:M1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;CPU Resource Efficiency Analysis [{infra_tag}] — Request / Limit / Usage / Throttling&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;CPU Request&quot;,&quot;CPU Limit&quot;,&quot;CPU P95&quot;,&quot;Throttle Peak&quot;,&quot;활용률(%)&quot;,&quot;낭비 Core-H&quot;,&quot;상태&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])

df_out = df_pod.sort_values(&quot;cpu_waste_core_hours&quot;, ascending=False).head(top30).copy()
df_out[&quot;util&quot;] = np.where(df_out[&quot;cpu_request_max&quot;]&gt;0, (df_out[&quot;cpu_usage_p95&quot;]/df_out[&quot;cpu_request_max&quot;]*100).round(1), 0)
df_out[&quot;status_en&quot;] = df_out[&quot;status&quot;].map({
    &quot;💥 OOM장애발생&quot;:&quot;OOM Killed&quot;,&quot;⚠️ Request부족&quot;:&quot;Request Shortage&quot;,
    &quot;📉 과다할당&quot;:&quot;Over-allocated&quot;,&quot;✅ 최적화완료&quot;:&quot;Optimized&quot;}).fillna(&quot;Unknown&quot;)

cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;cpu_request_max&quot;,&quot;cpu_limit_max&quot;,&quot;cpu_usage_p95&quot;,&quot;cpu_throttled_max&quot;,&quot;util&quot;,&quot;cpu_waste_core_hours&quot;,&quot;status_en&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {7:&quot;0.000&quot;,8:&quot;0.000&quot;,9:&quot;0.000&quot;,10:&quot;0.000&quot;,11:&quot;0.0&quot;,12:&quot;#,##0.0&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf, status_col_idx=13)

ws.conditional_formatting.add(f&quot;K3:K{end_row}&quot;, ColorScaleRule(start_type=&quot;num&quot;, start_value=0, start_color=&quot;FF0000&quot;, mid_type=&quot;num&quot;, mid_value=50, mid_color=&quot;FFFF00&quot;, end_type=&quot;num&quot;, end_value=100, end_color=&quot;00B050&quot;))
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:16,&quot;C&quot;:18,&quot;D&quot;:16,&quot;E&quot;:26,&quot;F&quot;:14,&quot;G&quot;:12,&quot;H&quot;:12,&quot;I&quot;:12,&quot;J&quot;:14,&quot;K&quot;:12,&quot;L&quot;:14,&quot;M&quot;:16})
freeze_and_filter(ws)

add_chart_image(ws, &quot;chart1_cpu_req_vs_usage_by_workload.png&quot;, f&quot;A{end_row+3}&quot;, w=860, h=400, label=&quot;[ CPU Request / Limit / P95 by Workload ]&quot;)
add_chart_image(ws, &quot;chart9_boxplot_cpu_util_by_workload.png&quot;,  f&quot;A{end_row+26}&quot;, w=860, h=400, label=&quot;[ CPU Utilization Boxplot by Workload ]&quot;)</code></pre><h1 id="───-sheet-3-memory-및-pv-스토리지-분석-─────────────────────────────────">─── Sheet 3: Memory 및 PV 스토리지 분석 ─────────────────────────────────</h1>
<p>def build_sheet_memory(wb, df_pod, infra_tag):
    top30 = max(1, int(len(df_pod)*0.30))
    print(f&quot;⏳ [4/9] &#39;3. Memory_PV 입체분석&#39; 고도화 시트 로딩 중...&quot;)
    ws = wb.create_sheet(&quot;3. Memory_PV 입체분석&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:O1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Memory RSS &amp; Persistent Volume Storage Cross-Governance Analysis [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;Mem Request&quot;,&quot;Mem P95&quot;,&quot;Mem RSS&quot;,&quot;PV Cap(GB)&quot;,&quot;PV Used(GB)&quot;,&quot;PV Waste(GB-H)&quot;,&quot;활용률(%)&quot;,&quot;낭비 GB-H&quot;,&quot;상태&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;hdr_mid&quot;])

df_out = df_pod.sort_values(by=[&quot;mem_waste_gb_hours&quot;, &quot;pv_waste_gb_hours&quot;], ascending=[False, False]).head(top30).copy()
df_out[&quot;util&quot;] = np.where(df_out[&quot;mem_request_max&quot;]&gt;0, (df_out[&quot;mem_usage_p95&quot;]/df_out[&quot;mem_request_max&quot;]*100).round(1), 0)
df_out[&quot;status_en&quot;] = df_out[&quot;status&quot;].map({
    &quot;💥 OOM장애발생&quot;:&quot;OOM Killed&quot;,&quot;⚠️ Request부족&quot;:&quot;Request Shortage&quot;,
    &quot;📉 과다할당&quot;:&quot;Over-allocated&quot;,&quot;✅ 최적화완료&quot;:&quot;Optimized&quot;}).fillna(&quot;Unknown&quot;)

cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;mem_request_max&quot;,&quot;mem_usage_p95&quot;,&quot;mem_rss_p95&quot;,&quot;pv_capacity_max&quot;,&quot;pv_used_p95&quot;,&quot;pv_waste_gb_hours&quot;,&quot;util&quot;,&quot;mem_waste_gb_hours&quot;,&quot;status_en&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {7:&quot;0.00&quot;, 8:&quot;0.00&quot;, 9:&quot;0.00&quot;, 10:&quot;0.00&quot;, 11:&quot;0.00&quot;, 12:&quot;#,##0.0&quot;, 13:&quot;0.0&quot;, 14:&quot;#,##0.0&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf, status_col_idx=15)

ws.conditional_formatting.add(f&quot;M3:M{end_row}&quot;, ColorScaleRule(start_type=&quot;num&quot;, start_value=0, start_color=&quot;FF0000&quot;, mid_type=&quot;num&quot;, mid_value=50, mid_color=&quot;FFFF00&quot;, end_type=&quot;num&quot;, end_value=100, end_color=&quot;00B050&quot;))
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:16,&quot;C&quot;:18,&quot;D&quot;:16,&quot;E&quot;:26,&quot;F&quot;:14,&quot;G&quot;:12,&quot;H&quot;:12,&quot;I&quot;:12,&quot;J&quot;:12,&quot;K&quot;:12,&quot;L&quot;:14,&quot;M&quot;:12,&quot;N&quot;:12,&quot;O&quot;:16})
freeze_and_filter(ws)

add_chart_image(ws, &quot;chart2_mem_req_vs_usage_by_workload.png&quot;, f&quot;A{end_row+3}&quot;, w=860, h=400, label=&quot;[ Memory Specs vs WorkingSet vs RSS Peak ]&quot;)
add_chart_image(ws, &quot;chart20_daily_mem_per_workload.png&quot;, f&quot;A{end_row+26}&quot;, w=960, h=540, label=&quot;[ Daily Memory &amp; PV Provisioning Trends ]&quot;)</code></pre><h1 id="───-sheet-4-oom-및-자원-부족-───────────────────────────">─── Sheet 4: OOM 및 자원 부족 ───────────────────────────</h1>
<p>def build_sheet_oom(wb, df_pod, infra_tag):
    print(&quot;⏳ [5/9] &#39;4. 자원부족및OOM장애군&#39; 리스크 인덱스 추출 중...&quot;)
    ws = wb.create_sheet(&quot;4. 자원부족및OOM장애군&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:L1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;OOMKilled / CPU CFS Throttled 병목 위험 워크로드 명세 [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;red&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;상태&quot;,&quot;CPU Request&quot;,&quot;CPU P95&quot;,&quot;Throttle Peak&quot;,&quot;Mem Limit(GB)&quot;,&quot;Mem P95(GB)&quot;]
apply_header_row(ws, 2, headers, bg=C[&quot;red&quot;])

df_out = df_pod[
    (df_pod[&quot;cpu_shortage_cores&quot;]&gt;0) | (df_pod[&quot;is_oom_killed&quot;]) | (df_pod[&quot;cpu_throttled_max&quot;] &gt; 0.2)
].sort_values([&quot;is_oom_killed&quot;,&quot;cpu_throttled_max&quot;,&quot;cpu_shortage_cores&quot;], ascending=[False,False,False]).copy()

df_out[&quot;status_en&quot;] = df_out[&quot;status&quot;].map({
    &quot;💥 OOM장애발생&quot;:&quot;OOM Killed&quot;,&quot;⚠️ Request부족&quot;:&quot;Request Shortage&quot;,
    &quot;📉 과다할당&quot;:&quot;Over-allocated&quot;,&quot;✅ 최적화완료&quot;:&quot;Optimized&quot;}).fillna(&quot;Unknown&quot;)

cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;status_en&quot;,&quot;cpu_request_max&quot;,&quot;cpu_usage_p95&quot;,&quot;cpu_throttled_max&quot;,&quot;mem_limit_max&quot;,&quot;mem_usage_p95&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {8:&quot;0.000&quot;,9:&quot;0.000&quot;,10:&quot;0.000&quot;,11:&quot;0.00&quot;,12:&quot;0.00&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf, status_col_idx=7)
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:18,&quot;C&quot;:20,&quot;D&quot;:18,&quot;E&quot;:28,&quot;F&quot;:16,&quot;G&quot;:16,&quot;H&quot;:12,&quot;I&quot;:12,&quot;J&quot;:14,&quot;K&quot;:14,&quot;L&quot;:14})
freeze_and_filter(ws)

add_chart_image(ws, &quot;chart8_shortfall_footprint.png&quot;, f&quot;A{end_row+3}&quot;, w=860, h=400, label=&quot;[ CPU CFS Throttling Risk Heatmap Footprint ]&quot;)</code></pre><h1 id="───-sheet-5-리소스-미설정-위반-─────────────────────────">─── Sheet 5: 리소스 미설정 위반 ─────────────────────────</h1>
<p>def build_sheet_violations(wb, df_pod, infra_tag):
    print(&quot;⏳ [6/9] &#39;5. 리소스미설정위반군&#39; 규격 미설정 목록 마크 중...&quot;)
    ws = wb.create_sheet(&quot;5. 리소스미설정위반군&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:L1&quot;)
t = ws[&quot;A1&quot;]
t.value = &quot;Resource Request / Limit 미설정 위반 컨테이너 (KST)&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(&quot;E26B0A&quot;); t.alignment = center()
ws.row_dimensions[1].height = 32

headers = [&quot;날짜(KST)&quot;,&quot;클러스터&quot;,&quot;네임스페이스&quot;,&quot;워크로드&quot;,&quot;Pod&quot;,&quot;컨테이너&quot;,
           &quot;Request 미설정&quot;,&quot;Limit 미설정&quot;,&quot;CPU Request&quot;,&quot;CPU Limit&quot;,&quot;Mem Request(GB)&quot;,&quot;Mem Limit(GB)&quot;]
apply_header_row(ws, 2, headers, bg=&quot;E26B0A&quot;)

df_out = df_pod[df_pod[&quot;has_no_request&quot;]|df_pod[&quot;has_no_limit&quot;]].sort_values(&quot;minutes_running&quot;, ascending=False).copy()
df_out[&quot;req_flag&quot;] = df_out[&quot;has_no_request&quot;].map({True:&quot;MISSING&quot;,False:&quot;OK&quot;})
df_out[&quot;lim_flag&quot;] = df_out[&quot;has_no_limit&quot;].map({True:&quot;MISSING&quot;,False:&quot;OK&quot;})
cols = [&quot;date&quot;,&quot;cluster&quot;,&quot;namespace&quot;,&quot;workload_type&quot;,&quot;pod&quot;,&quot;container&quot;,
        &quot;req_flag&quot;,&quot;lim_flag&quot;,&quot;cpu_request_max&quot;,&quot;cpu_limit_max&quot;,&quot;mem_request_max&quot;,&quot;mem_limit_max&quot;]
df_disp = df_out[cols].reset_index(drop=True)

nf = {9:&quot;0.000&quot;,10:&quot;0.000&quot;,11:&quot;0.00&quot;,12:&quot;0.00&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=3, num_formats=nf)
set_col_widths(ws, {&quot;A&quot;:12,&quot;B&quot;:18,&quot;C&quot;:20,&quot;D&quot;:18,&quot;E&quot;:28,&quot;F&quot;:16,&quot;G&quot;:14,&quot;H&quot;:14,&quot;I&quot;:14,&quot;J&quot;:14,&quot;K&quot;:14,&quot;L&quot;:14})
freeze_and_filter(ws)</code></pre><h1 id="───-sheet-6-일별-트렌드-kst-──────────────────────────">─── Sheet 6: 일별 트렌드 (KST) ──────────────────────────</h1>
<p>def build_sheet_trends(wb, df_pod, infra_tag):
    print(&quot;⏳ [7/9] &#39;6. 일별트렌드_KST&#39; 누적 손실액 분석 중...&quot;)
    ws = wb.create_sheet(&quot;6. 일별트렌드_KST&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:M1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Daily Resource Governance &amp; Loss Trend [{infra_tag}] — KST&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

df_daily = df_pod.groupby(&quot;date&quot;).agg(
    containers=(&quot;container&quot;,&quot;count&quot;),
    cpu_alloc=(&quot;cpu_allocated_core_hours&quot;,&quot;sum&quot;),
    cpu_used=(&quot;cpu_usage_core_hours&quot;,&quot;sum&quot;),
    cpu_waste=(&quot;cpu_waste_core_hours&quot;,&quot;sum&quot;),
    mem_alloc=(&quot;mem_allocated_gb_hours&quot;,&quot;sum&quot;),
    mem_waste=(&quot;mem_waste_gb_hours&quot;,&quot;sum&quot;),
    pv_alloc=(&quot;pv_allocated_gb_hours&quot;,&quot;sum&quot;),
    pv_waste_sum=(&quot;pv_waste_gb_hours&quot;,&quot;sum&quot;),
    oom_cnt=(&quot;is_oom_killed&quot;,&quot;sum&quot;),
    shortage_cnt=(&quot;cpu_shortage_cores&quot;, lambda x: (x&gt;0).sum()),
).reset_index()

df_daily[&quot;cpu_util_pct&quot;] = (df_daily[&quot;cpu_used&quot;]/df_daily[&quot;cpu_alloc&quot;].clip(lower=0.001)*100).round(1)
df_daily[&quot;mem_util_pct&quot;] = ((df_daily[&quot;mem_alloc&quot;]-df_daily[&quot;mem_waste&quot;])/df_daily[&quot;mem_alloc&quot;].clip(lower=0.001)*100).round(1)

headers = [&quot;날짜(KST)&quot;,&quot;컨테이너 볼륨&quot;,&quot;CPU 할당 Core-H&quot;,&quot;CPU 사용 Core-H&quot;,&quot;CPU 낭비 Core-H&quot;,
           &quot;Mem 할당 GB-H&quot;,&quot;Mem 낭비 GB-H&quot;,&quot;PV 할당 GB-H&quot;,&quot;PV 낭비 GB-H&quot;,&quot;OOM 사살&quot;,&quot;자원부족군&quot;,&quot;CPU활용률(%)&quot;,&quot;Mem활용률(%)&quot;]
apply_header_row(ws, 3, headers, bg=C[&quot;hdr_mid&quot;])

nf = {3:&quot;#,##0.0&quot;,4:&quot;#,##0.0&quot;,5:&quot;#,##0.0&quot;,6:&quot;#,##0.0&quot;,7:&quot;#,##0.0&quot;,8:&quot;#,##0.0&quot;,9:&quot;#,##0.0&quot;,10:&quot;#,##0&quot;,11:&quot;#,##0&quot;,12:&quot;0.1&quot;,13:&quot;0.1&quot;}
df_disp = df_daily[[&quot;date&quot;,&quot;containers&quot;,&quot;cpu_alloc&quot;,&quot;cpu_used&quot;,&quot;cpu_waste&quot;,
                     &quot;mem_alloc&quot;,&quot;mem_waste&quot;,&quot;pv_alloc&quot;,&quot;pv_waste_sum&quot;,&quot;oom_cnt&quot;,&quot;shortage_cnt&quot;,&quot;cpu_util_pct&quot;,&quot;mem_util_pct&quot;]]
end_row = apply_data_rows(ws, df_disp, start_row=4, num_formats=nf)

set_col_widths(ws, {&quot;A&quot;:14,&quot;B&quot;:12,&quot;C&quot;:16,&quot;D&quot;:16,&quot;E&quot;:16,&quot;F&quot;:16,&quot;G&quot;:14,&quot;H&quot;:16,&quot;I&quot;:14,&quot;J&quot;:12,&quot;K&quot;:12,&quot;L&quot;:14,&quot;M&quot;:14})
freeze_and_filter(ws, row=4)

add_chart_image(ws, &quot;chart3_daily_waste_stack.png&quot;, f&quot;A{end_row+3}&quot;, 860, 400, &quot;[ Daily CPU Waste Stacked Timeline ]&quot;)
add_chart_image(ws, &quot;chart4_cpu_efficiency_heatmap.png&quot;, f&quot;A{end_row+26}&quot;, 860, 400, &quot;[ CPU Utilization Heatmap Grid ]&quot;)</code></pre><h1 id="───-sheet-7-워크로드별-심층-분석-─────────────────">─── Sheet 7: 워크로드별 심층 분석 ─────────────────</h1>
<p>def build_sheet_workload(wb, df_pod, infra_tag):
    print(&quot;⏳ [8/9] &#39;7. 워크로드별_심층분석&#39; 오픈소스 기술 스택 평점표 작성 중...&quot;)
    ws = wb.create_sheet(&quot;7. 워크로드별_심층분석&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:N1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Workload Type별 오픈소스 기술 스택 심층 자원 효율화 분석 [{infra_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;purple&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 32

df_wl = df_pod.groupby(&quot;workload_type&quot;).agg(
    containers=(&quot;container&quot;,&quot;count&quot;),
    cpu_req_avg=(&quot;cpu_request_max&quot;,&quot;mean&quot;),
    cpu_p95_avg=(&quot;cpu_usage_p95&quot;,&quot;mean&quot;),
    cpu_waste_sum=(&quot;cpu_waste_core_hours&quot;,&quot;sum&quot;),
    cpu_throttle_pk=(&quot;cpu_throttled_max&quot;,&quot;max&quot;),
    mem_req_avg=(&quot;mem_request_max&quot;,&quot;mean&quot;),
    mem_p95_avg=(&quot;mem_usage_p95&quot;,&quot;mean&quot;),
    mem_rss_avg=(&quot;mem_rss_p95&quot;,&quot;mean&quot;),
    mem_waste_sum=(&quot;mem_waste_gb_hours&quot;,&quot;sum&quot;),
    pv_capacity_avg=(&quot;pv_capacity_max&quot;,&quot;mean&quot;),
    pv_waste_sum=(&quot;pv_waste_gb_hours&quot;,&quot;sum&quot;),
    oom_cnt=(&quot;is_oom_killed&quot;,&quot;sum&quot;)
).reset_index()

df_wl[&quot;cpu_util_pct&quot;] = (df_wl[&quot;cpu_p95_avg&quot;]/df_wl[&quot;cpu_req_avg&quot;].replace(0,np.nan)*100).round(1).fillna(0)
df_wl[&quot;mem_util_pct&quot;] = (df_wl[&quot;mem_p95_avg&quot;]/df_wl[&quot;mem_req_avg&quot;].replace(0,np.nan)*100).round(1).fillna(0)
df_wl = df_wl.sort_values(&quot;cpu_waste_sum&quot;, ascending=False).reset_index(drop=True)

headers2 = [&quot;워크로드 타입&quot;,&quot;컨테이너 수&quot;,&quot;CPU Req(avg)&quot;,&quot;CPU P95(avg)&quot;,&quot;CPU 낭비 총합&quot;,&quot;Throttle Peak&quot;,
            &quot;Mem Req(avg)&quot;,&quot;Mem P95(avg)&quot;,&quot;Mem RSS(avg)&quot;,&quot;Mem 낭비 총합&quot;,&quot;PV Cap(avg)&quot;,&quot;PV 낭비 총합&quot;,&quot;CPU활용(%)&quot;,&quot;Mem활용(%)&quot;]
apply_header_row(ws, 3, headers2, bg=C[&quot;purple&quot;])

cols_out = [&quot;workload_type&quot;,&quot;containers&quot;,&quot;cpu_req_avg&quot;,&quot;cpu_p95_avg&quot;,&quot;cpu_waste_sum&quot;,&quot;cpu_throttle_pk&quot;,
            &quot;mem_req_avg&quot;,&quot;mem_p95_avg&quot;,&quot;mem_rss_avg&quot;,&quot;mem_waste_sum&quot;,&quot;pv_capacity_avg&quot;,&quot;pv_waste_sum&quot;,&quot;cpu_util_pct&quot;,&quot;mem_util_pct&quot;]
df_disp = df_wl[cols_out]

nf = {3:&quot;0.000&quot;, 4:&quot;0.000&quot;, 5:&quot;#,##0.0&quot;, 6:&quot;0.000&quot;, 7:&quot;0.00&quot;, 8:&quot;0.00&quot;, 9:&quot;0.00&quot;, 10:&quot;#,##0.0&quot;, 11:&quot;0.00&quot;, 12:&quot;#,##0.0&quot;, 13:&quot;0.1&quot;, 14:&quot;0.1&quot;}
end_row = apply_data_rows(ws, df_disp, start_row=4, num_formats=nf)

set_col_widths(ws, {&quot;A&quot;:18,&quot;B&quot;:12,&quot;C&quot;:14,&quot;D&quot;:14,&quot;E&quot;:14,&quot;F&quot;:14,&quot;G&quot;:14,&quot;H&quot;:14,&quot;I&quot;:14,&quot;J&quot;:14,&quot;K&quot;:14,&quot;L&quot;:14,&quot;M&quot;:12,&quot;N&quot;:12})
freeze_and_filter(ws, row=4)

add_chart_image(ws, &quot;chart15_oom_status_by_workload.png&quot;, f&quot;A{end_row+3}&quot;, 860, 400, &quot;[ Status Distribution count per Open-Source Stack ]&quot;)
add_chart_image(ws, &quot;chart14_cpu_mem_waste_scatter.png&quot;, f&quot;A{end_row+26}&quot;, 760, 480, &quot;[ Multi-Resource Loss Correlation Bubble Map ]&quot;)</code></pre><h1 id="───-🎉-요청-반영-sheet-8-미사용-추가-차트-모음-전용-시트-─────────────────">─── 🎉 [요청 반영] Sheet 8: 미사용 추가 차트 모음 전용 시트 ─────────────────</h1>
<p>def build_sheet_extra_charts(wb, infra_tag, date_tag):
    print(&quot;⏳ [9/9] &#39;8. 추가차트모음&#39; 시트 배치 작업 진행 중...&quot;)
    ws = wb.create_sheet(&quot;8. 추가차트모음&quot;)
    ws.sheet_view.showGridLines = False</p>
<pre><code>ws.merge_cells(&quot;A1:Q1&quot;)
t = ws[&quot;A1&quot;]
t.value = f&quot;Step 3 Additional Unused Dashboards &amp; Reports [{infra_tag} - {date_tag}]&quot;
t.font = ft(bold=True, size=13, color=C[&quot;white&quot;])
t.fill = fill(C[&quot;hdr_dark&quot;]); t.alignment = center()
ws.row_dimensions[1].height = 35

# 유저 지정 주력 사용 8개 차트 외의 나머지 번호 자동 색출 및 다중 포맷 탐색
used_ids = [1, 2, 3, 5, 6, 9, 10, 15]
remaining_ids = [i for i in range(1, 20) if i not in used_ids]

current_row = 3
col_positions = [&quot;A&quot;, &quot;I&quot;]  # 2열 바둑판 교차 격자 배치 구조
col_idx = 0

for cid in remaining_ids:
    found_file = None
    # 동적/고정 프리픽스 호환성 검증 패치
    patterns = [f&quot;chart{cid}_*.png&quot;, f&quot;chart_{cid}_*.png&quot;, f&quot;chart{cid}.png&quot;, f&quot;chart_{cid}.png&quot;]
    for pat in patterns:
        matches = list(PLOT_DIR.glob(pat))
        if matches:
            found_file = matches[0].name
            break

    if found_file:
        anchor = f&quot;{col_positions[col_idx]}{current_row}&quot;
        # 차트 크기 및 타이틀 라벨 할당
        add_chart_image(ws, found_file, anchor, w=480, h=280, label=f&quot;Chart {cid}: {found_file}&quot;)
        col_idx += 1
        if col_idx &gt;= len(col_positions):
            col_idx = 0
            current_row += 16  # 차트 간 행 간격 오프셋 설정
    else:
        print(f&quot;  ℹ️  [추가 차트 보관함] Chart ID {cid} 번 이미지 파일이 없어 스킵합니다.&quot;)</code></pre><h1 id="───-main-─────────────────────────────────────────────────">─── Main ─────────────────────────────────────────────────</h1>
<p>def main():
    print(&quot;🚀 [Step6 개시] res_usage_ 계열 고도화 엑셀 리포터 엔진 가동 (인프라/날짜 패턴 자동 검색)...&quot;)</p>
<pre><code># ─── 🔄 [방법 B] 파레토 Parquet 파일 패턴을 기반으로 동적 파티션 검색 ───
search_pattern = str(MERGED_DIR / &quot;pareto_ns_*.parquet&quot;)
pareto_files = glob.glob(search_pattern)

if not pareto_files:
    print(f&quot;❌ 오류: 탐색된 파레토 가공원부(*.parquet) 파일이 없습니다. 경로를 확인하세요: {search_pattern}&quot;)
    return

processed_count = 0
for file_path in pareto_files:
    filename = os.path.basename(file_path)
    match = re.search(r&quot;pareto_ns_(.*)_(.*)\.parquet&quot;, filename)
    if not match:
        continue

    infra_tag = match.group(1)   # 예: COMPUTE 또는 STORAGE
    date_tag  = match.group(2)   # 예: 20260702

    # 고도화 패치된 분할정복 연동 파일명 매핑 규칙 바인딩
    p1 = MERGED_DIR / f&quot;daily_enriched_{infra_tag}_{date_tag}.parquet&quot;
    p2 = Path(file_path)
    p3 = MERGED_DIR / f&quot;daily_ns_usage_{infra_tag}_{date_tag}.parquet&quot;

    if not (p1.exists() and p3.exists()):
        print(f&quot;⚠️  [세트 불완전] {infra_tag} | {date_tag} 에 쌍이 되는 가공 데이터가 유실되어 작업을 건너뜁니다.&quot;)
        continue

    print(f&quot;\n&quot; + &quot;=&quot;*60)
    print(f&quot;🔄 [파티션 매칭 감지] Cluster/Infra: {infra_tag} | Date: {date_tag} 컴파일 가동&quot;)
    print(&quot;=&quot;*60)

    df_pod      = pd.read_parquet(p1)
    df_ns       = pd.read_parquet(p2)
    df_daily_ns = pd.read_parquet(p3)

    excel_name = f&quot;res_usage_report_{infra_tag.lower()}_{date_tag}.xlsx&quot;

    wb = Workbook()
    build_sheet_summary(wb, df_pod, df_ns, infra_tag)
    build_sheet_pareto(wb, df_ns, infra_tag)
    build_sheet_ns_daily(wb, df_daily_ns, infra_tag)
    build_sheet_cpu(wb, df_pod, infra_tag)
    build_sheet_memory(wb, df_pod, infra_tag)
    build_sheet_oom(wb, df_pod, infra_tag)
    build_sheet_violations(wb, df_pod, infra_tag)
    build_sheet_trends(wb, df_pod, infra_tag)
    build_sheet_workload(wb, df_pod, infra_tag)
    build_sheet_extra_charts(wb, infra_tag, date_tag)  # ◀ [신설 추가 차트 시트 연결]

    out_path = OUT_DIR / excel_name
    print(f&quot;💾 openpyxl 스트림 디스크 저장 가동 중... ➡️ {out_path}&quot;)
    wb.save(out_path)
    print(f&quot;📦 [로컬 컴파일 마감 완료]: {excel_name} ({out_path.stat().st_size/1024:.0f} KB)&quot;)

    # ─── 🪣 사내 MinIO AIStor &#39;devops-test&#39; 버킷 자동 배포 레이어 (boto3 -&gt; minio 상용 완전 교체) ───
    minio_endpoint   = os.getenv(&quot;MINIO_ENDPOINT&quot;)
    minio_access_key = os.getenv(&quot;MINIO_ACCESS_KEY&quot;)
    minio_secret_key = os.getenv(&quot;MINIO_SECRET_KEY&quot;)
    bucket_name      = os.getenv(&quot;MINIO_REPORT_BUCKET&quot;, &quot;devops-test&quot;)

    if all([minio_endpoint, minio_access_key, minio_secret_key]):
        try:
            # 엔드포인트 내 http/https 스키마 제거 프로토콜 보정 작업
            endpoint_clean = minio_endpoint.replace(&quot;http://&quot;, &quot;&quot;).replace(&quot;https://&quot;, &quot;&quot;)
            secure_flag = minio_endpoint.startswith(&quot;https://&quot;)

            minio_client = Minio(
                endpoint_clean,
                access_key=minio_access_key,
                secret_key=minio_secret_key,
                secure=secure_flag
            )

            # 원격 타겟 버킷 검증 및 유동적 생성 통제
            if not minio_client.bucket_exists(bucket_name):
                print(f&quot;🪣  [MinIO] 버킷이 존재하지 않아 신규 생성합니다: {bucket_name}&quot;)
                minio_client.make_bucket(bucket_name)

            object_key = f&quot;reports/{infra_tag.lower()}/{excel_name}&quot;
            print(f&quot;🪣  [오브젝트 스토리지 싱크] devops-test 버킷 배포 ➡️ MinIO://{object_key} 파일 스트림 업로드 중...&quot;)

            # fput_file을 이용한 안정적인 파일 스트림 배포 자산화
            minio_client.fput_file(bucket_name, object_key, str(out_path))
            print(&quot;🏁 === [전사 배포 마감 성공] 고도화 res_usage_ 마스터 엑셀 리포트 배포 자산화가 완료되었습니다. ===&quot;)
        except Exception as e:
            print(f&quot;❌ [배포 에러] 사내 MinIO AIStor 원격 업로드 중 예외 발생: {str(e)}&quot;)
    else:
        print(&quot;⚠️ [안내] 접속 환경변수(MINIO_ENDPOINT 등) 생략으로 로컬 아카이빙 처리 후 해당 파티션을 마감합니다.&quot;)

    processed_count += 1

print(f&quot;\n🏁 === [전체 자동 매칭 빌드 완료] 총 {processed_count}개 세트 인프라 마스터 리포트 빌드 프로세스가 종결되었습니다. ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02n1]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02n1</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02n1</guid>
            <pubDate>Thu, 02 Jul 2026 05:45:59 GMT</pubDate>
            <description><![CDATA[<p>```python
import os
import glob
import re
import sys
import logging
import pandas as pd
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.formatting.rule import CellIsRule
from openpyxl.drawing.image import Image as OpenpyxlImage
from minio import Minio
from minio.error import S3Error</p>
<h1 id="로깅-설정">로깅 설정</h1>
<p>logging.basicConfig(
    level=logging.INFO,
    format=&#39;%(asctime)s [%(levelname)s] %(message)s&#39;,
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(<strong>name</strong>)</p>
<h1 id="환경-설정-기본값-설정-및-환경변수-오버라이드-가능">환경 설정 (기본값 설정 및 환경변수 오버라이드 가능)</h1>
<p>DATA_DIR = os.getenv(&quot;DATA_DIR&quot;, &quot;./data/merged&quot;)
CHARTS_DIR = os.getenv(&quot;CHARTS_DIR&quot;, &quot;./data/charts&quot;) # 차트 이미지 폴더 기본값
OUTPUT_DIR = os.getenv(&quot;OUTPUT_DIR&quot;, &quot;./data&quot;)
MINIO_ENDPOINT = os.getenv(&quot;MINIO_ENDPOINT&quot;, &quot;minio.ai-stor.local:9000&quot;)
MINIO_ACCESS_KEY = os.getenv(&quot;MINIO_ACCESS_KEY&quot;, &quot;admin&quot;)
MINIO_SECRET_KEY = os.getenv(&quot;MINIO_SECRET_KEY&quot;, &quot;password&quot;)
MINIO_BUCKET = os.getenv(&quot;MINIO_BUCKET&quot;, &quot;devops-test&quot;)
USE_SSL = os.getenv(&quot;MINIO_USE_SSL&quot;, &quot;False&quot;).lower() in (&quot;true&quot;, &quot;1&quot;, &quot;yes&quot;)</p>
<p>def get_minio_client():
    &quot;&quot;&quot;MinIO AIStor 클라이언트 초기화&quot;&quot;&quot;
    try:
        return Minio(
            MINIO_ENDPOINT,
            access_key=MINIO_ACCESS_KEY,
            secret_key=MINIO_SECRET_KEY,
            secure=USE_SSL
        )
    except Exception as e:
        logger.error(f&quot;MinIO 클라이언트 초기화 실패: {e}&quot;)
        return None</p>
<p>def upload_to_aistor(local_file_path, object_name):
    &quot;&quot;&quot;생성된 엑셀 리포트를 MinIO AIStor devops-test 버킷에 업로드&quot;&quot;&quot;
    client = get_minio_client()
    if not client:
        logger.warning(&quot;MinIO 클라이언트를 사용할 수 없어 업로드를 건너뜁니다.&quot;)
        return False</p>
<pre><code>try:
    if not client.bucket_exists(MINIO_BUCKET):
        logger.info(f&quot;버킷이 존재하지 않아 생성합니다: {MINIO_BUCKET}&quot;)
        client.make_bucket(MINIO_BUCKET)

    logger.info(f&quot;AIStor 업로드 시작: {local_file_path} -&gt; bucket: {MINIO_BUCKET}/{object_name}&quot;)
    client.fput_file(MINIO_BUCKET, object_name, local_file_path)
    logger.info(&quot;AIStor 업로드 완료 성공.&quot;)
    return True
except S3Error as e:
    logger.error(f&quot;AIStor 업로드 중 S3 에러 발생: {e}&quot;)
except Exception as e:
    logger.error(f&quot;AIStor 업로드 중 알 수 없는 오류 발생: {e}&quot;)
return False</code></pre><p>def style_sheet(ws, title_text):
    &quot;&quot;&quot;시트 기본 스타일링 (헤더, 그리드라인, 폰트 등)&quot;&quot;&quot;
    ws.views.sheetView[0].showGridLines = True</p>
<pre><code>font_family = &quot;Arial&quot;
header_fill = PatternFill(start_color=&quot;2F4F4F&quot;, end_color=&quot;2F4F4F&quot;, fill_type=&quot;solid&quot;) # Muted Dark Slate
header_font = Font(name=font_family, size=11, bold=True, color=&quot;FFFFFF&quot;)
data_font = Font(name=font_family, size=10, color=&quot;000000&quot;)
zebra_fill = PatternFill(start_color=&quot;F9FBFB&quot;, end_color=&quot;F9FBFB&quot;, fill_type=&quot;solid&quot;)

thin_border = Border(
    left=Side(style=&#39;thin&#39;, color=&#39;E0E0E0&#39;),
    right=Side(style=&#39;thin&#39;, color=&#39;E0E0E0&#39;),
    top=Side(style=&#39;thin&#39;, color=&#39;E0E0E0&#39;),
    bottom=Side(style=&#39;thin&#39;, color=&#39;E0E0E0&#39;)
)

for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, min_col=1, max_col=ws.max_column), start=1):
    is_header = (row_idx == 1)
    for cell in row:
        if is_header:
            cell.fill = header_fill
            cell.font = header_font
            cell.alignment = Alignment(horizontal=&quot;center&quot;, vertical=&quot;center&quot;, wrap_text=True)
        else:
            cell.font = data_font
            cell.border = thin_border
            if row_idx % 2 == 0:
                cell.fill = zebra_fill

            if isinstance(cell.value, (int, float)):
                if cell.value == 0:
                    cell.number_format = &#39;#,##0&#39;
                elif &#39;pct&#39; in str(cell.column) or &#39;비율&#39; in str(ws.cell(1, cell.column).value or &#39;&#39;):
                    cell.number_format = &#39;0.0%&#39;
                elif isinstance(cell.value, float):
                    cell.number_format = &#39;#,##0.00&#39;
                else:
                    cell.number_format = &#39;#,##0&#39;

for col in ws.columns:
    max_len = 0
    col_letter = openpyxl.utils.get_column_letter(col[0].column)
    for cell in col:
        if cell.value:
            max_len = max(max_len, len(str(cell.value)))
    ws.column_dimensions[col_letter].width = max(max_len + 3, 12)

ws.freeze_panes = &#39;A2&#39;</code></pre><p>def add_pareto_chart(ws, data_len):
    &quot;&quot;&quot;네임스페이스별 CPU 낭비 현황 오픈pyxl 자체 파레토 차트 삽입&quot;&quot;&quot;
    if data_len &lt;= 0:
        logger.warning(&quot;차트를 생성할 데이터가 없어 차트 삽입을 건너뜁니다.&quot;)
        return</p>
<pre><code>try:
    chart = BarChart()
    chart.type = &quot;col&quot;
    chart.style = 10
    chart.title = &quot;Namespace CPU Waste Pareto Analysis (Programmatic)&quot;
    chart.y_axis.title = &quot;CPU Waste (Core-Hours)&quot;
    chart.x_axis.title = &quot;Namespace&quot;

    data = Reference(ws, min_col=2, min_row=1, max_row=data_len + 1)
    cats = Reference(ws, min_col=1, min_row=2, max_row=data_len + 1)

    chart.add_data(data, titles_from_data=True)
    chart.set_categories(cats)

    line_chart = LineChart()
    line_data = Reference(ws, min_col=3, min_row=1, max_row=data_len + 1)
    line_chart.add_data(line_data, titles_from_data=True)

    line_chart.y_axis.axId = 200
    line_chart.y_axis.title = &quot;Cumulative Percentage&quot;
    line_chart.y_axis.crosses = &quot;max&quot;

    chart += line_chart
    ws.add_chart(chart, f&quot;E2&quot;)
    logger.info(&quot;자체 스크립트 기반 파레토 차트가 시트(E2 위치)에 삽입되었습니다.&quot;)
except Exception as e:
    logger.error(f&quot;파레토 차트 생성 중 오류 발생: {e}&quot;)</code></pre><p>def inject_step3_external_charts(wb, cluster, date):
    &quot;&quot;&quot;
    Step 3에서 생성된 19개 차트 중 미사용 중인 나머지 11개 차트를 
    별도의 &#39;Charts&#39; 전용 시트를 생성하여 바둑판 배열로 자동 삽입합니다.
    - 기존 사용 8개: 1, 2, 3, 5, 6, 9, 10, 15
    - 신규 추가 11개: 나머지 번호들
    &quot;&quot;&quot;
    used_charts = [1, 2, 3, 5, 6, 9, 10, 15]
    all_charts = list(range(1, 20))
    remaining_charts = [c for c in all_charts if c not in used_charts]</p>
<pre><code>logger.info(f&quot;[{cluster} | {date}] 미사용 차트({len(remaining_charts)}개) 일괄 배치를 위한 &#39;Charts&#39; 시트 생성 중...&quot;)

ws_charts = wb.create_sheet(title=&quot;Charts&quot;)
ws_charts.views.sheetView[0].showGridLines = True

# 폰트 세팅
title_font = Font(name=&quot;Arial&quot;, size=14, bold=True, color=&quot;2F4F4F&quot;)
ws_charts[&quot;A1&quot;] = f&quot;Step 3 Additional Dashboards &amp; Charts ({cluster} - {date})&quot;
ws_charts[&quot;A1&quot;].font = title_font

# 배치를 위한 변수 (2열 구조 배열)
col_positions = [&#39;B&#39;, &#39;N&#39;] # B열, N열에 차트를 나란히 배치하여 겹침 방지
current_row = 3
col_idx = 0

for chart_id in remaining_charts:
    # 파일명 매핑 규칙 수용 (동적 파티션 이름 및 고정 이름 매칭 버퍼 확보)
    possible_filenames = [
        f&quot;chart_{chart_id}_{cluster}_{date}.png&quot;,
        f&quot;chart_{cluster}_{date}_{chart_id}.png&quot;,
        f&quot;chart_{chart_id}.png&quot;,
        f&quot;chart_fixed_{chart_id}.png&quot;
    ]

    chart_file_path = None
    # 설정된 차트 경로 및 데이터 통합 경로 검전
    for fname in possible_filenames:
        for d in [CHARTS_DIR, DATA_DIR, &quot;./data&quot;, &quot;.&quot;]:
            p = os.path.join(d, fname)
            if os.path.exists(p):
                chart_file_path = p
                break
        if chart_file_path:
            break

    if chart_file_path:
        try:
            img = OpenpyxlImage(chart_file_path)
            cell_address = f&quot;{col_positions[col_idx]}{current_row}&quot;
            ws_charts.add_image(img, cell_address)
            logger.info(f&quot; -&gt; 차트 {chart_id} 삽입 완료: {chart_file_path} -&gt; cell: {cell_address}&quot;)

            # 다음 이미지 위치 계산 (2열 배치)
            col_idx += 1
            if col_idx &gt;= len(col_positions):
                col_idx = 0
                current_row += 20 # 차트 높이를 고려하여 20행 아래로 이동
        except Exception as e:
            logger.error(f&quot; -&gt; 차트 {chart_id}({chart_file_path}) 삽입 중 openpyxl 예외 발생: {e}&quot;)
    else:
        # 실시간 로깅 상태창 제공
        logger.warning(f&quot; -&gt; 차트 {chart_id} 원부 이미지를 찾을 수 없어 건너뜁니다. (검색 패턴 샘플: chart_{chart_id}_*.png)&quot;)</code></pre><p>def generate_report(cluster, date):
    &quot;&quot;&quot;동적 매핑된 파일들을 결합하여 최종 엑셀 리포트 빌드&quot;&quot;&quot;
    usage_file = os.path.join(DATA_DIR, f&quot;daily_ns_usage_{cluster}<em>{date}.parquet&quot;)
    pareto_file = os.path.join(DATA_DIR, f&quot;pareto_ns</em>{cluster}<em>{date}.parquet&quot;)
    enriched_file = os.path.join(DATA_DIR, f&quot;daily_enriched</em>{cluster}_{date}.parquet&quot;)</p>
<pre><code># 파일 유실 여부 검증
missing_files = [f for f in [usage_file, pareto_file, enriched_file] if not os.path.exists(f)]
if missing_files:
    logger.error(f&quot;[{cluster} | {date}] 필수 가공원부가 유실되어 빌드를 건너뜁니다: {missing_files}&quot;)
    return

logger.info(f&quot;[{cluster} | {date}] 엑셀 리포트 가공 시작...&quot;)

# 데이터 로드
df_usage = pd.read_parquet(usage_file)
df_pareto = pd.read_parquet(pareto_file)
df_enriched = pd.read_parquet(enriched_file)

# 워크북 및 시트 구성
wb = openpyxl.Workbook()

# 기본 시트를 Summary로 변경
ws_summary = wb.active
ws_summary.title = &quot;Summary Metrics&quot;

# 요약 정보 작성
ws_summary.append([&quot;Metric Key&quot;, &quot;Value&quot;])
ws_summary.append([&quot;Cluster Name&quot;, cluster])
ws_summary.append([&quot;Analysis Date&quot;, date])
ws_summary.append([&quot;Total Namespaces&quot;, len(df_usage[&#39;namespace&#39;].unique()) if &#39;namespace&#39; in df_usage.columns else 0])
if &#39;cpu_waste&#39; in df_pareto.columns:
    ws_summary.append([&quot;Total CPU Waste (Core-Hours)&quot;, float(df_pareto[&#39;cpu_waste&#39;].sum())])
style_sheet(ws_summary, &quot;Summary Metrics&quot;)

# 1. Daily Namespace Usage 시트
ws_usage = wb.create_sheet(title=&quot;Daily Namespace Usage&quot;)
for r in dataframe_to_rows(df_usage, index=False, header=True):
    ws_usage.append(r)
style_sheet(ws_usage, &quot;Daily Namespace Usage&quot;)

# 2. Pareto Analysis 시트
ws_pareto = wb.create_sheet(title=&quot;Pareto Analysis&quot;)
for r in dataframe_to_rows(df_pareto, index=False, header=True):
    ws_pareto.append(r)
style_sheet(ws_pareto, &quot;Pareto Analysis&quot;)
# 파레토 차트 내부 렌더링 추가
add_pareto_chart(ws_pareto, len(df_pareto))

# 3. Enriched Metrics 시트
ws_enriched = wb.create_sheet(title=&quot;Enriched Metrics&quot;)
for r in dataframe_to_rows(df_enriched, index=False, header=True):
    ws_enriched.append(r)
style_sheet(ws_enriched, &quot;Enriched Metrics&quot;)

# [요구사항 반영] Step 3 외부 차트 19개 중 미사용 11개 차트 전용 시트 주입 로직
inject_step3_external_charts(wb, cluster, date)

if &#39;cpu_waste&#39; in df_pareto.columns:
    red_fill = PatternFill(start_color=&quot;FFC7CE&quot;, end_color=&quot;FFC7CE&quot;, fill_type=&quot;solid&quot;)
    ws_pareto.conditional_formatting.add(
        f&quot;B2:B{len(df_pareto)+1}&quot;,
        CellIsRule(operator=&#39;greaterThan&#39;, formula=[&#39;100&#39;], stopIfTrue=True, fill=red_fill)
    )

# 파일 저장 (요청된 프리픽스 매핑 반영)
output_filename = f&quot;res_usage_report_{cluster}_{date}.xlsx&quot;
local_output_path = os.path.join(OUTPUT_DIR, output_filename)

os.makedirs(OUTPUT_DIR, exist_ok=True)
wb.save(local_output_path)
logger.info(f&quot;로컬 엑셀 리포트 저장 성공: {local_output_path}&quot;)

# AIStor 자동 업로드 연계
upload_to_aistor(local_output_path, output_filename)</code></pre><p>def main():
    logger.info(f&quot;가공원부 자동 검색 스캔 시작 (경로: {DATA_DIR})&quot;)</p>
<pre><code># 방법 B: pareto_ns_*.parquet 패턴을 탐색하여 동적으로 cluster와 date 패러미터 추출
search_pattern = os.path.join(DATA_DIR, &quot;pareto_ns_*.parquet&quot;)
pareto_files = glob.glob(search_pattern)

if not pareto_files:
    logger.warning(f&quot;탐색된 파레토 가공원부(*.parquet) 파일이 없습니다. 경로를 확인하세요: {search_pattern}&quot;)
    return

processed_count = 0
for file_path in pareto_files:
    filename = os.path.basename(file_path)
    match = re.search(r&quot;pareto_ns_(.*)_(.*)\.parquet&quot;, filename)
    if match:
        cluster = match.group(1)
        date = match.group(2)

        logger.info(f&quot;파티션 감지 성공 -&gt; Cluster: {cluster}, Date: {date}&quot;)
        generate_report(cluster, date)
        processed_count += 1

logger.info(f&quot;전체 자동 매칭 빌드 프로세스 완료. (처리 완료 세트 수: {processed_count})&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02m4]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02m4</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02m4</guid>
            <pubDate>Thu, 02 Jul 2026 03:42:33 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
[2단계-Polars 완치판] 멀티 코어 기반 일별 자원 마감 정산 및 AIStor 전송 파이프라인
&quot;&quot;&quot;
import os
import argparse
import boto3
from botocore.exceptions import ClientError
import polars as pl
from datetime import datetime, timedelta, timezone
from pathlib import Path</p>
<p>import config
from config import (
    RAW_DIR, MERGED_DIR, CLUSTER_NODE_PATTERNS, 
    get_workload_type, classify_node_infrastructure
)</p>
<p>KST = timezone(timedelta(hours=9))
W_CPU, W_MEM, W_PV = 1.0, 0.11, 0.02</p>
<p>def parse_arguments():
    parser = argparse.ArgumentParser(description=&quot;FinOps Polars-Powered Reprocessing Engine&quot;)
    parser.add_argument(&quot;--days&quot;, type=int, default=None)
    parser.add_argument(&quot;--start-date&quot;, type=str, default=None)
    parser.add_argument(&quot;--end-date&quot;, type=str, default=None)
    parser.add_argument(&quot;--cluster&quot;, type=str, default=&quot;ALL&quot;, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;, &quot;ALL&quot;])
    parser.add_argument(&quot;--workload-domain&quot;, type=str, default=None)
    return parser.parse_args()</p>
<p>def get_minio_client():
    return boto3.client(
        &quot;s3&quot;, endpoint_url=os.getenv(&quot;MINIO_ENDPOINT&quot;), aws_access_key_id=os.getenv(&quot;MINIO_ACCESS_KEY&quot;),
        aws_secret_access_key=os.getenv(&quot;MINIO_SECRET_KEY&quot;), region_name=&quot;us-east-1&quot;,
        config=boto3.session.Config(signature_version=&quot;s3v4&quot;)
    )</p>
<p>def sync_down_raw_from_minio(s3_client, bucket_name, start_dt, end_dt):
    chunk_delta = timedelta(hours=1)
    current_start = start_dt
    print(f&quot;\n📡 [AIStor 스마트 캐시 엔진] 원천 데이터 가용성 체크 시작...&quot;)</p>
<pre><code>while current_start &lt; end_dt:
    chunk_str = current_start.strftime(&quot;%Y%m%d_%H&quot;)
    f_name = f&quot;prom_raw_{chunk_str}.parquet&quot;
    object_key = f&quot;raw/{f_name}&quot;
    local_path = RAW_DIR / f_name

    if local_path.exists() and local_path.stat().st_size &gt; 0:
        print(f&quot;   ⚡ [Cache Hit] 로컬 캐시 자산 활용 (다운로드 스킵): {f_name}&quot;)
    else:
        try:
            s3_client.head_object(Bucket=bucket_name, Key=object_key)
            print(f&quot;   📥 [Cache Miss] AIStor 백업으로부터 다운로드: {object_key}&quot;)
            s3_client.download_file(bucket_name, object_key, str(local_path))
        except ClientError as e:
            if e.response[&#39;Error&#39;][&#39;Code&#39;] != &quot;404&quot;:
                print(f&quot;   ❌ AIStor 통신 실패 에러 ({f_name}): {str(e)}&quot;)
        except Exception as e:
            print(f&quot;   ⚠️ 일반 다운로드 예외 발생 ({f_name}): {str(e)}&quot;)

    current_start += chunk_delta</code></pre><p>def main():
    args = parse_arguments()
    s3_client = get_minio_client()
    bucket_name = os.getenv(&quot;MINIO_RAW_BUCKET&quot;, &quot;devops-test&quot;)
    now_kst = datetime.now(KST)</p>
<pre><code>if args.start_date and args.end_date:
    start_dt = datetime.strptime(args.start_date, &quot;%Y-%m-%d&quot;).replace(tzinfo=KST)
    end_dt = (datetime.strptime(args.end_date, &quot;%Y-%m-%d&quot;) + timedelta(days=1)).replace(tzinfo=KST)
elif args.days:
    start_dt = (now_kst - timedelta(days=args.days)).replace(hour=0, minute=0, second=0, microsecond=0)
    end_dt = now_kst
else:
    yesterday = (now_kst - timedelta(days=1)).date()
    start_dt = datetime.combine(yesterday, datetime.min.time()).replace(tzinfo=KST)
    end_dt = datetime.combine(now_kst.date(), datetime.min.time()).replace(tzinfo=KST)

sync_down_raw_from_minio(s3_client, bucket_name, start_dt, end_dt)

raw_files = list(RAW_DIR.glob(&quot;prom_raw_*.parquet&quot;))
if not raw_files:
    print(&quot;\n🚨 [중단] 정산 가동할 물리 원천 파일이 존재하지 않습니다.&quot;)
    return

print(f&quot;\n📊 [Polars Lazy Engine 기동] {len(raw_files)}개 소스 파티션 스캔 링킹...&quot;)
lf_raw = pl.scan_parquet(str(RAW_DIR / &quot;prom_raw_*.parquet&quot;))

start_naive = start_dt.replace(tzinfo=None)
end_naive = end_dt.replace(tzinfo=None)

lf_filtered = lf_raw.filter(
    (pl.col(&quot;timestamp&quot;) &gt;= start_naive) &amp; (pl.col(&quot;timestamp&quot;) &lt; end_naive)
).with_columns(
    pl.col(&quot;timestamp&quot;).dt.strftime(&quot;%Y%m%d&quot;).alias(&quot;date&quot;)
)

print(&quot;⏳ 대용량 인프라 정산 메트릭 다차원 분산 집계 중 (Rust Core 스레드 가동)...&quot;)
df_raw = lf_filtered.collect()

if df_raw.is_empty():
    print(&quot;⚠️  해당 기간 내 정산할 데이터 로우가 없습니다.&quot;)
    return

df_raw = df_raw.with_columns([
    pl.col(&quot;node&quot;).map_elements(
        lambda x: {&quot;cluster_type&quot;: classify_node_infrastructure(x)[0], &quot;workload_domain&quot;: classify_node_infrastructure(x)[1]},
        return_dtype=pl.Struct({&quot;cluster_type&quot;: pl.String, &quot;workload_domain&quot;: pl.String})
    ).alias(&quot;infra_struct&quot;),
    pl.col(&quot;pod&quot;).map_elements(get_workload_type, return_dtype=pl.String).alias(&quot;workload_type&quot;)
]).with_columns([
    pl.col(&quot;infra_struct&quot;).struct.field(&quot;cluster_type&quot;).alias(&quot;cluster_type&quot;),
    pl.col(&quot;infra_struct&quot;).struct.field(&quot;workload_domain&quot;).alias(&quot;workload_domain&quot;)
]).drop(&quot;infra_struct&quot;)

target_dates = df_raw[&quot;date&quot;].unique().to_list()
target_clusters = [&quot;COMPUTE&quot;, &quot;STORAGE&quot;] if args.cluster.upper() == &quot;ALL&quot; else [args.cluster.upper()]

print(f&quot;🎯 분할 연산 타겟 스케줄러 가동 -&gt; 일자 풀: {target_dates} | 클러스터 풀: {target_clusters}&quot;)

GB_DIV = 1024 ** 3

for date_chunk in target_dates:
    for cluster_chunk in target_clusters:

        df_slice = df_raw.filter((pl.col(&quot;date&quot;) == date_chunk) &amp; (pl.col(&quot;cluster_type&quot;) == cluster_chunk))
        if df_slice.is_empty():
            continue

        if args.workload_domain:
            df_slice = df_slice.filter(pl.col(&quot;workload_domain&quot;) == args.workload_domain)
            if df_slice.is_empty(): continue

        print(f&quot;   ⏳ [Polars 집계 연산] {date_chunk} ➡️ {cluster_chunk} 고성능 그룹바이 컴파일...&quot;)

        target_fields = [&quot;cpu_request&quot;, &quot;cpu_limit&quot;, &quot;cpu_usage&quot;, &quot;cpu_throttled&quot;, &quot;mem_request&quot;, &quot;mem_limit&quot;, &quot;mem_usage&quot;, &quot;mem_rss&quot;, &quot;oom_event&quot;, &quot;pv_capacity&quot;, &quot;pv_used&quot;]
        for col in target_fields:
            if col not in df_slice.columns:
                df_slice = df_slice.with_columns(pl.lit(0.0).alias(col))

        # [테이블 1] 상세 메트릭 원부 집계
        df_pod = df_slice.group_by([
            &quot;date&quot;, &quot;cluster_type&quot;, &quot;workload_domain&quot;, &quot;namespace&quot;, &quot;workload_type&quot;, &quot;node&quot;, &quot;pod&quot;, &quot;container&quot;
        ]).agg([
            pl.len().alias(&quot;minutes_running&quot;),
            pl.col(&quot;cpu_request&quot;).max().alias(&quot;cpu_request_max&quot;),
            pl.col(&quot;cpu_limit&quot;).max().alias(&quot;cpu_limit_max&quot;),
            pl.col(&quot;cpu_usage&quot;).quantile(0.95).alias(&quot;cpu_usage_p95&quot;),
            pl.col(&quot;cpu_throttled&quot;).max().alias(&quot;cpu_throttled_max&quot;),
            (pl.col(&quot;mem_request&quot;).max() / GB_DIV).alias(&quot;mem_request_max&quot;),
            (pl.col(&quot;mem_limit&quot;).max() / GB_DIV).alias(&quot;mem_limit_max&quot;),
            (pl.col(&quot;mem_usage&quot;).quantile(0.95) / GB_DIV).alias(&quot;mem_usage_p95&quot;),
            (pl.col(&quot;mem_rss&quot;).quantile(0.95) / GB_DIV).alias(&quot;mem_rss_p95&quot;),
            pl.col(&quot;oom_event&quot;).sum().alias(&quot;oom_strike_sum&quot;),
            (pl.col(&quot;pv_capacity&quot;).max() / GB_DIV).alias(&quot;pv_capacity_max&quot;),
            (pl.col(&quot;pv_used&quot;).quantile(0.95) / GB_DIV).alias(&quot;pv_used_p95&quot;)
        ]).fill_null(0.0)

        df_pod = df_pod.with_columns([
            (pl.col(&quot;cpu_request_max&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;cpu_allocated_core_hours&quot;),
            (pl.col(&quot;cpu_usage_p95&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;cpu_usage_core_hours&quot;),
            (pl.col(&quot;mem_request_max&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;mem_allocated_gb_hours&quot;),
            (pl.col(&quot;mem_usage_p95&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;mem_usage_gb_hours&quot;),
            (pl.col(&quot;pv_capacity_max&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;pv_allocated_gb_hours&quot;),
            (pl.col(&quot;pv_usage_p95&quot;) * (pl.col(&quot;minutes_running&quot;) / 60.0)).alias(&quot;pv_usage_gb_hours&quot;),
            (pl.col(&quot;oom_strike_sum&quot;) &gt; 0).alias(&quot;is_oom_killed&quot;),
            (pl.col(&quot;cpu_request_max&quot;) == 0).alias(&quot;has_no_request&quot;),
            (pl.col(&quot;cpu_limit_max&quot;) == 0).alias(&quot;has_no_limit&quot;),
            ((pl.col(&quot;cpu_usage_p95&quot;) - pl.col(&quot;cpu_request_max&quot;)).clip(lower_bound=0)).alias(&quot;cpu_shortage_cores&quot;)
        ]).with_columns([
            ((pl.col(&quot;cpu_allocated_core_hours&quot;) - pl.col(&quot;cpu_usage_core_hours&quot;)).clip(lower_bound=0)).alias(&quot;cpu_waste_core_hours&quot;),
            ((pl.col(&quot;mem_allocated_gb_hours&quot;) - pl.col(&quot;mem_usage_gb_hours&quot;)).clip(lower_bound=0)).alias(&quot;mem_waste_gb_hours&quot;),
            ((pl.col(&quot;pv_allocated_gb_hours&quot;) - pl.col(&quot;pv_usage_gb_hours&quot;)).clip(lower_bound=0)).alias(&quot;pv_waste_gb_hours&quot;),
        ])

        df_pod = df_pod.with_columns(
            pl.when(pl.col(&quot;is_oom_killed&quot;)).then(pl.lit(&quot;💥 OOM장애발생&quot;))
            .when((pl.col(&quot;cpu_shortage_cores&quot;) &gt; 0.5) | (pl.col(&quot;cpu_throttled_max&quot;) &gt; 0.2)).then(pl.lit(&quot;⚠️ Request부족&quot;))
            .when((pl.col(&quot;cpu_waste_core_hours&quot;) &gt; 10) | (pl.col(&quot;pv_waste_gb_hours&quot;) &gt; 50)).then(pl.lit(&quot;📉 과다할당&quot;))
            .otherwise(pl.lit(&quot;✅ 최적화완료&quot;)).alias(&quot;status&quot;)
        )

        # 📅 [테이블 2] Namespace 사용량 요약 빌드
        df_daily_ns = df_pod.group_by([&quot;date&quot;, &quot;namespace&quot;]).agg([
            pl.col(&quot;cpu_usage_core_hours&quot;).sum().alias(&quot;cpu_used_ch&quot;),
            pl.col(&quot;cpu_allocated_core_hours&quot;).sum().alias(&quot;cpu_alloc_ch&quot;),
            pl.col(&quot;cpu_waste_core_hours&quot;).sum().alias(&quot;cpu_waste_ch&quot;),
            pl.col(&quot;mem_usage_gb_hours&quot;).sum().alias(&quot;mem_used_gh&quot;),
            pl.col(&quot;mem_allocated_gb_hours&quot;).sum().alias(&quot;mem_alloc_gh&quot;),
            pl.col(&quot;pv_usage_gb_hours&quot;).sum().alias(&quot;pv_used_gh&quot;),
            pl.col(&quot;pv_allocated_gb_hours&quot;).sum().alias(&quot;pv_alloc_gh&quot;)
        ]).with_columns(
            ((pl.col(&quot;cpu_alloc_ch&quot;) * W_CPU) + (pl.col(&quot;mem_alloc_gh&quot;) * W_MEM) + (pl.col(&quot;pv_alloc_gh&quot;) * W_PV)).round(1).alias(&quot;final_usage_score&quot;)
        )

        # 📉 [테이블 3] 파레토 원부 롤업 빌드 
        df_ns = df_pod.group_by(&quot;namespace&quot;).agg([
            pl.col(&quot;cpu_waste_core_hours&quot;).sum().alias(&quot;total_waste_core_hours&quot;),
            pl.col(&quot;minutes_running&quot;).sum().alias(&quot;minutes_running_sum&quot;),
            pl.col(&quot;container&quot;).count().alias(&quot;container_cnt&quot;),
            pl.col(&quot;cpu_allocated_core_hours&quot;).sum().alias(&quot;total_allocated_core_hours&quot;)
        ]).sort(&quot;total_waste_core_hours&quot;, descending=True)

        global_total_waste = df_ns[&quot;total_waste_core_hours&quot;].sum() or 0.1
        df_ns = df_ns.with_columns([
            pl.lit(date_chunk).alias(&quot;date&quot;),
            ((pl.col(&quot;total_waste_core_hours&quot;) / global_total_waste) * 100).round(2).alias(&quot;waste_share_pct&quot;)
        ]).with_columns(
            pl.col(&quot;waste_share_pct&quot;).cum_sum().round(2).alias(&quot;waste_cumsum_pct&quot;)
        )

        # ─── 💾 [로컬 저장 명세 고정 바인딩] ───
        cluster_partition_dir = MERGED_DIR / cluster_chunk
        cluster_partition_dir.mkdir(parents=True, exist_ok=True)

        target_output_file = cluster_partition_dir / f&quot;daily_enriched_{cluster_chunk}_{date_chunk}.parquet&quot;
        ns_output_file     = cluster_partition_dir / f&quot;daily_ns_usage_{cluster_chunk}_{date_chunk}.parquet&quot;
        pareto_output_file = cluster_partition_dir / f&quot;pareto_ns_{cluster_chunk}_{date_chunk}.parquet&quot;

        df_pod.write_parquet(target_output_file)
        df_daily_ns.write_parquet(ns_output_file)
        df_ns.write_parquet(pareto_output_file) # ◀ 파레토 로컬 디스크 정착

        print(f&quot;     💾 [로컬 저장] 파티션 3대 원부 컴파일 성료 (클러스터: {cluster_chunk})&quot;)

        # ─── 📡 [AIStor 영구 적재 가드레일 주입] ───
        print(f&quot;     Submitting artifacts to AIStor Tables storage system...&quot;)
        try:
            s3_client.upload_file(str(target_output_file), bucket_name, f&quot;merged/{cluster_chunk}/{target_output_file.name}&quot;)
            s3_client.upload_file(str(ns_output_file), bucket_name, f&quot;merged/{cluster_chunk}/{ns_output_file.name}&quot;)
            s3_client.upload_file(str(pareto_output_file), bucket_name, f&quot;merged/{cluster_chunk}/{pareto_output_file.name}&quot;)
            print(f&quot;     ✅ [AIStor 업로드 완수] 테이블 자산 적재 완료 -&gt; {pareto_output_file.name}&quot;)
        except Exception as e:
            print(f&quot;     ❌ [AIStor 통신장애] 오브젝트 전송 실패: {str(e)}&quot;)

print(&quot;\n🏁 === [Step2 Polars 배치 완료] 로컬 및 AIStor 테이블 연동이 완벽히 마감되었습니다. ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02m3]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02m3</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02m3</guid>
            <pubDate>Thu, 02 Jul 2026 03:36:50 GMT</pubDate>
            <description><![CDATA[<p>```python</p>
<h1 id="-기존-로컬-저장-구역-">... [기존 로컬 저장 구역] ...</h1>
<pre><code>        cluster_partition_dir = MERGED_DIR / cluster_chunk
        cluster_partition_dir.mkdir(parents=True, exist_ok=True)

        target_output_file = cluster_partition_dir / f&quot;daily_enriched_{cluster_chunk}_{date_chunk}.parquet&quot;
        ns_output_file     = cluster_partition_dir / f&quot;daily_ns_usage_{cluster_chunk}_{date_chunk}.parquet&quot;
        pareto_output_file = cluster_partition_dir / f&quot;pareto_ns_{cluster_chunk}_{date_chunk}.parquet&quot;

        # (로컬 쓰기 집행 완료 상태)
        # df_pod.to_parquet(target_output_file, index=False)
        # ...

        # ─── 📡 [신규 추가] AIStor(MinIO) 테이블 레이어로 즉시 슈팅 (Sync-Up) ───
        print(f&quot;     📤 [AIStor 적재] 정산 완료본 오브젝트 스토리지로 업로드 중...&quot;)
        try:
            # 1. 상세 원부 업로드 -&gt; s3://devops-test/merged/STORAGE/daily_enriched_STORAGE_20260630.parquet
            s3_client.upload_file(
                str(target_output_file), bucket_name, 
                f&quot;merged/{cluster_chunk}/{target_output_file.name}&quot;
            )
            # 2. 일별 사용량 요약 업로드
            s3_client.upload_file(
                str(ns_output_file), bucket_name, 
                f&quot;merged/{cluster_chunk}/{ns_output_file.name}&quot;
            )
            # 3. 파레토 지분 원부 업로드
            s3_client.upload_file(
                str(pareto_output_file), bucket_name, 
                f&quot;merged/{cluster_chunk}/{pareto_output_file.name}&quot;
            )
            print(f&quot;     ✅ [업로드 완수] AIStor Table 동기화 완료.&quot;)

        except Exception as e:
            print(f&quot;     ❌ [업로드 실패] AIStor 네트워크 통신 장애: {str(e)}&quot;)
        # ──────────────────────────────────────────────────────────────────────</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02m2]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02m2</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02m2</guid>
            <pubDate>Thu, 02 Jul 2026 03:35:57 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step4_governance_analyzer.py — Adaptive Multi-Cluster FinOps Governance &amp; Risk Analyzer (Pandas Standard)
&quot;&quot;&quot;
import os
import glob
import argparse
import pandas as pd
from pathlib import Path</p>
<h1 id="───-📂-경로-표준화-설정-──────────────────────────────────────────────────">─── 📂 경로 표준화 설정 ──────────────────────────────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
MERGED_DIR    = BASE_DATA_DIR / &quot;merged&quot;
OUTPUT_DIR    = BASE_DATA_DIR / &quot;output&quot;</p>
<h1 id="출력-디렉토리-자동-생성">출력 디렉토리 자동 생성</h1>
<p>OUTPUT_DIR.mkdir(parents=True, exist_ok=True)</p>
<p>def parse_arguments():
    parser = argparse.ArgumentParser(description=&quot;FinOps Governance &amp; Anomaly Analyzer&quot;)
    parser.add_argument(&quot;--cluster&quot;, type=str, default=&quot;ALL&quot;, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;, &quot;ALL&quot;])
    return parser.parse_args()</p>
<p>def load_partitioned_data(cluster_target):
    &quot;&quot;&quot;
    개편된 2단계 분할 정복 하위 폴더 위계를 스캔하여 일별 Parquet 파일들을 동적으로 결합합니다.
    &quot;&quot;&quot;
    target_clusters = [&quot;COMPUTE&quot;, &quot;STORAGE&quot;] if cluster_target.upper() == &quot;ALL&quot; else [cluster_target.upper()]</p>
<pre><code>pod_files = []
ns_files = []
pareto_files = []

# 💡 [적응형 픽업 핵심 구역] 지정된 클러스터 폴더 하방의 모든 일별 파일들을 수집
for cl in target_clusters:
    cl_dir = MERGED_DIR / cl
    if cl_dir.exists():
        pod_files.extend(glob.glob(str(cl_dir / &quot;daily_enriched_*.parquet&quot;)))
        ns_files.extend(glob.glob(str(cl_dir / &quot;daily_ns_usage_*.parquet&quot;)))
        pareto_files.extend(glob.glob(str(cl_dir / &quot;pareto_ns_*.parquet&quot;)))

print(f&quot;🔍 [스캔 완료] Enriched 파일: {len(pod_files)}개 | NS요약 파일: {len(ns_files)}개 | Pareto 파일: {len(pareto_files)}개&quot;)

# 파일이 단 하나도 없을 경우의 빈 데이터프레임 예외 예방 가드레일
df_pod = pd.concat([pd.read_parquet(f) for f in pod_files], ignore_index=True) if pod_files else pd.DataFrame()
df_ns  = pd.concat([pd.read_parquet(f) for f in ns_files], ignore_index=True) if ns_files else pd.DataFrame()
df_pt  = pd.concat([pd.read_parquet(f) for f in pareto_files], ignore_index=True) if pareto_files else pd.DataFrame()

return df_pod, df_ns, df_pt</code></pre><p>def main():
    args = parse_arguments()
    print(f&quot;🚀 [Step4] Starting FinOps Governance Analyzer for Cluster: {args.cluster}...&quot;)</p>
<pre><code># 1. 쪼개져 있는 2단계 결과물 전체 로드 (적응형 복구)
df_pod, df_ns, df_pt = load_partitioned_data(args.cluster)

if df_pod.empty:
    print(f&quot;❌ [중단] &#39;{args.cluster}&#39; 클러스터에 정산된 2단계 가공 원부가 존재하지 않습니다. 앞단 배치를 확인하세요.&quot;)
    return

cluster_suffix = args.cluster.upper() if args.cluster.upper() != &quot;ALL&quot; else &quot;INTEGRATED&quot;
print(f&quot;✅ 총 {len(df_pod):,}개의 컨테이너 타임라인 원부 통합 바인딩 완료.\n&quot;)

# 2. 🚨 [거버넌스 분석 레이어 1] 자원부족 및 OOM Killed 위험군 추출 (시트4 매핑용)
print(&quot;🚨 [Analysis 1] Extracting Critical Risk &amp; OOM Killed Pods...&quot;)
df_risk = df_pod[
    (df_pod[&quot;status&quot;] == &quot;💥 OOM장애발생&quot;) | 
    (df_pod[&quot;status&quot;] == &quot;⚠️ Request부족&quot;) | 
    (df_pod[&quot;oom_strike_sum&quot;] &gt; 0)
].copy()

# 조치 가이드 컬럼 인젝션
if not df_risk.empty:
    df_risk[&quot;recommendation&quot;] = pd.DataFrame(
        # OOM일 경우 Limit 상향, 쓰로틀링/자원부족일 경우 Request 상향 가이드 명시
        [ &quot;Upsize Memory Limit (OOM Detected)&quot; if x else &quot;Upsize CPU Request/Limit (Throttling Detected)&quot; for x in df_risk[&quot;is_oom_killed&quot;] ]
    )
else:
    df_risk[&quot;recommendation&quot;] = None

# 3. 📉 [거버넌스 분석 레이어 2] 과다 할당 및 유휴 알박기 자산 추출 (시트2, 3 하향 후보용)
print(&quot;📉 [Analysis 2] Extracting Over-Allocated &amp; Idle Infrastructure Assets...&quot;)
df_waste = df_pod[
    (df_pod[&quot;status&quot;] == &quot;📉 과다할당&quot;) &amp; 
    (df_pod[&quot;cpu_waste_core_hours&quot;] &gt; 24)  # 하루 이상 코어 하나 통째로 낭비한 기준
].sort_values(by=&quot;cpu_waste_core_hours&quot;, ascending=False).copy()

# 4. 🛡️ [거버넌스 분석 레이어 3] 자원 미설정 배포 위반군 추출 (시트5 매핑용)
print(&quot;🛡️ [Analysis 3] Scanning Non-Compliant Missing Resource Specification Pods...&quot;)
df_violations = df_pod[
    (df_pod[&quot;has_no_request&quot;] == True) | 
    (df_pod[&quot;has_no_limit&quot;] == True)
].copy()

# 5. 💾 [결과 마감] 6단계 엑셀 빌더가 다이렉트로 인식할 수 있도록 산출물 저장
print(f&quot;\n💾 [정산 마감] 분석 데이터 자산 output 레이어로 내보내기 진행 중...&quot;)

# 6단계 엑셀 빌더 경로 명명 규칙 통합 바인딩
master_gov_file = OUTPUT_DIR / f&quot;governance_master_{cluster_suffix}.parquet&quot;
risk_file       = OUTPUT_DIR / f&quot;gov_risk_oom_{cluster_suffix}.parquet&quot;
waste_file      = OUTPUT_DIR / f&quot;gov_waste_candidates_{cluster_suffix}.parquet&quot;
viol_file       = OUTPUT_DIR / f&quot;gov_violations_{cluster_suffix}.parquet&quot;

# Parquet 압축 저장
df_pod.to_parquet(master_gov_file, index=False)
df_risk.to_parquet(risk_file, index=False)
df_waste.to_parquet(waste_file, index=False)
df_violations.to_parquet(viol_file, index=False)

# 검증 서머리 출력
print(f&quot;  -&gt; 📦 [저장완료] 전사 통합 마스터 원부 : {master_gov_file.name}&quot;)
print(f&quot;  -&gt; 💥 [저장완료] 고위험군/OOM 리스트    : {risk_file.name} (결과: {len(df_risk)}건)&quot;)
print(f&quot;  -&gt; 📉 [저장완료] 자원 하향조정 후보군  : {waste_file.name} (결과: {len(df_waste)}건)&quot;)
print(f&quot;  -&gt; 🛡️ [저장완료] 스펙 미설정 규격위반군: {viol_file.name} (결과: {len(df_violations)}건)&quot;)

print(f&quot;\n🏁 === [Step4 완수] &#39;{cluster_suffix}&#39; 거버넌스 가공 원부가 무결하게 갱신되었습니다. ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26Y02m1]]></title>
            <link>https://velog.io/@youngkyoo_kim/26Y02m1</link>
            <guid>https://velog.io/@youngkyoo_kim/26Y02m1</guid>
            <pubDate>Thu, 02 Jul 2026 02:20:43 GMT</pubDate>
            <description><![CDATA[<p>```python
&quot;&quot;&quot;
step3_analytics.py — Long-term Time-series Infrastructure Data Visualization Engine (Partitioned &amp; Safe Version)
&quot;&quot;&quot;
import os
import re
import argparse
import pandas as pd
import numpy as np
from pathlib import Path</p>
<h1 id="───-🛡️-headless-environment-guard-prevents-crashes-in-k8s-pods-without-display-servers-───">─── 🛡️ [Headless Environment Guard] Prevents crashes in K8s pods without display servers ───</h1>
<p>import matplotlib
matplotlib.use(&#39;Agg&#39;) 
import matplotlib.pyplot as plt
import seaborn as sns</p>
<h1 id="───-📂-directory-path-standardization-data-──────────────────────────">─── 📂 Directory Path Standardization (./data) ──────────────────────────</h1>
<p>BASE_DATA_DIR = Path(&quot;./data&quot;)
MERGED_DIR    = BASE_DATA_DIR / &quot;merged&quot;
BASE_PLOT_DIR = BASE_DATA_DIR / &quot;output&quot; / &quot;plots&quot;</p>
<h1 id="ensure-base-plot-directory-exists">Ensure base plot directory exists</h1>
<p>BASE_PLOT_DIR.mkdir(parents=True, exist_ok=True)</p>
<h1 id="───-🔤-no-more-tofu-use-bulletproof-built-in-standard-fonts-───">─── 🔤 [No More Tofu] Use bulletproof built-in standard fonts ───</h1>
<p>plt.rcParams[&#39;font.family&#39;] = &#39;sans-serif&#39;
plt.rcParams[&#39;axes.unicode_minus&#39;] = False  # Prevents minus sign (-) corruption
sns.set_theme(style=&quot;whitegrid&quot;)</p>
<h1 id="───-🛡️-완치-가드레일-빈-데이터셋-감지-시-플레이스홀더-png-생성-함수-───">─── 🛡️ [완치 가드레일] 빈 데이터셋 감지 시 플레이스홀더 PNG 생성 함수 ───</h1>
<p>def check_and_handle_empty(df, output_path, chart_name):
    &quot;&quot;&quot;
    데이터가 비어있으면 True를 반환하고, 6단계 엑셀 빌더가 터지지 않도록
    &#39;No Data Available&#39; 문구가 적힌 플레이스홀더 이미지를 강제 생성합니다.
    &quot;&quot;&quot;
    if df is None or df.empty:
        print(f&quot;  ⚠️  [Empty Data] Generating placeholder for {chart_name} (Dataset is empty).&quot;)
        fig, ax = plt.subplots(figsize=(10, 5))
        ax.text(0.5, 0.5, f&quot;No Data Available\n({chart_name})&quot;, 
                ha=&#39;center&#39;, va=&#39;center&#39;, fontsize=14, color=&#39;gray&#39;, weight=&#39;bold&#39;)
        ax.axis(&#39;off&#39;)
        plt.tight_layout()
        plt.savefig(output_path, dpi=100, bbox_inches=&#39;tight&#39;)
        plt.close()
        return True
    return False</p>
<p>def main():
    parser = argparse.ArgumentParser(description=&quot;FinOps Analytics Visualization Engine&quot;)
    parser.add_argument(&quot;--cluster&quot;, type=str, default=&quot;ALL&quot;, choices=[&quot;COMPUTE&quot;, &quot;STORAGE&quot;, &quot;ALL&quot;])
    args = parser.parse_args()</p>
<pre><code>print(f&quot;🚀 [Step3] Starting FinOps Time-series English Visualization Engine for Cluster: {args.cluster}...&quot;)

# ─── 🗂️ 클러스터별 파티션 파일 서치 및 가변 결합 (분할 정복) ───
target_clusters = [&quot;COMPUTE&quot;, &quot;STORAGE&quot;] if args.cluster.upper() == &quot;ALL&quot; else [args.cluster.upper()]

all_pod_dfs = []
all_ns_dfs = []

for cl in target_clusters:
    cl_dir = MERGED_DIR / cl
    if cl_dir.exists():
        all_pod_dfs.extend([pd.read_parquet(f) for f in cl_dir.glob(&quot;daily_enriched_*.parquet&quot;)])
        all_ns_dfs.extend([pd.read_parquet(f) for f in cl_dir.glob(&quot;pareto_ns_*.parquet&quot;)])

if not all_pod_dfs:
    print(f&quot;❌ Error: No processed daily Parquet files found for cluster &#39;{args.cluster}&#39;. Please run step2 first.&quot;)
    return

df_pod = pd.concat(all_pod_dfs, ignore_index=True)
df_ns  = pd.concat(all_ns_dfs, ignore_index=True) if all_ns_dfs else pd.DataFrame()

# 출력 경로 역시 클러스터별 하위 디렉토리로 격리 파티셔닝
cluster_type_str = args.cluster.upper() if args.cluster.upper() != &quot;ALL&quot; else &quot;INTEGRATED&quot;
PLOT_DIR = BASE_PLOT_DIR / cluster_type_str
PLOT_DIR.mkdir(parents=True, exist_ok=True)

print(f&quot;✅ Data lake aggregated successfully -&gt; Container rows: {len(df_pod):,}&quot;)

# 데이터 정렬 오염 차단용 형변환 Guard
df_pod[&quot;date&quot;] = df_pod[&quot;date&quot;].astype(str)

# Injecting efficiency metric layers
df_pod[&quot;cpu_util&quot;] = np.where(df_pod[&quot;cpu_request_max&quot;] &gt; 0, (df_pod[&quot;cpu_usage_p95&quot;] / df_pod[&quot;cpu_request_max&quot;] * 100), 0)
df_pod[&quot;mem_util&quot;] = np.where(df_pod[&quot;mem_request_max&quot;] &gt; 0, (df_pod[&quot;mem_usage_p95&quot;] / df_pod[&quot;mem_request_max&quot;] * 100), 0)
df_pod[&quot;lim_req_ratio&quot;] = np.where(df_pod[&quot;cpu_request_max&quot;] &gt; 0, df_pod[&quot;cpu_limit_max&quot;] / df_pod[&quot;cpu_request_max&quot;], 0)

# ─── 📊 [Charts 1 &amp; 2] Workload Type Allocated vs Actual Peak ───
print(&quot;⏳ [1/19] Rendering chart1_cpu_req_vs_usage_by_workload...&quot;)
out1 = PLOT_DIR / &quot;chart1_cpu_req_vs_usage_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out1, &quot;chart1_cpu_req_vs_usage_by_workload&quot;):
    df_wl_cpu = df_pod.groupby(&quot;workload_type&quot;)[[&quot;cpu_request_max&quot;, &quot;cpu_usage_p95&quot;]].mean().reset_index()
    plt.figure(figsize=(10, 5))
    df_melt_cpu = df_wl_cpu.melt(id_vars=&quot;workload_type&quot;, value_vars=[&quot;cpu_request_max&quot;, &quot;cpu_usage_p95&quot;])
    sns.barplot(data=df_melt_cpu, x=&quot;workload_type&quot;, y=&quot;value&quot;, hue=&quot;variable&quot;, palette=&quot;Blues_r&quot;)
    plt.xticks(rotation=30, ha=&#39;right&#39;)
    plt.title(&quot;Average CPU Request vs P95 Peak Usage by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;CPU Cores&quot;)
    plt.tight_layout()
    plt.savefig(out1, dpi=100)
    plt.close()

print(&quot;⏳ [2/19] Rendering chart2_mem_req_vs_usage_by_workload...&quot;)
out2 = PLOT_DIR / &quot;chart2_mem_req_vs_usage_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out2, &quot;chart2_mem_req_vs_usage_by_workload&quot;):
    df_wl_mem = df_pod.groupby(&quot;workload_type&quot;)[[&quot;mem_request_max&quot;, &quot;mem_usage_p95&quot;]].mean().reset_index()
    plt.figure(figsize=(10, 5))
    df_melt_mem = df_wl_mem.melt(id_vars=&quot;workload_type&quot;, value_vars=[&quot;mem_request_max&quot;, &quot;mem_usage_p95&quot;])
    sns.barplot(data=df_melt_mem, x=&quot;workload_type&quot;, y=&quot;value&quot;, hue=&quot;variable&quot;, palette=&quot;Purples_r&quot;)
    plt.xticks(rotation=30, ha=&#39;right&#39;)
    plt.title(&quot;Average Memory Request vs P95 Peak Usage (GB) by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Memory Capacity (GB)&quot;)
    plt.tight_layout()
    plt.savefig(out2, dpi=100)
    plt.close()

# ─── 📊 [Chart 3] Daily CPU Waste Stacked Bar ───
print(&quot;⏳ [3/19] Rendering chart3_daily_waste_stack...&quot;)
out3 = PLOT_DIR / &quot;chart3_daily_waste_stack.png&quot;
df_daily_waste = df_pod.groupby([&quot;date&quot;, &quot;workload_type&quot;])[&quot;cpu_waste_core_hours&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_daily_waste, out3, &quot;chart3_daily_waste_stack&quot;):
    df_daily_waste.plot(kind=&#39;bar&#39;, stacked=True, figsize=(11, 5), cmap=&quot;tab20&quot;)
    plt.title(&quot;Daily Total CPU Waste Core-Hours Stacked by Workload (KST)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Waste Volume (Core-Hours)&quot;)
    plt.xticks(rotation=45)
    plt.tight_layout()
    plt.savefig(out3, dpi=100)
    plt.close()

# ─── 📊 [Charts 4 &amp; 18] Efficiency Heatmaps ───
print(&quot;⏳ [4/19] Rendering chart4_cpu_efficiency_heatmap...&quot;)
out4 = PLOT_DIR / &quot;chart4_cpu_efficiency_heatmap.png&quot;
df_heat_cpu = df_pod.groupby([&quot;workload_type&quot;, &quot;date&quot;])[&quot;cpu_util&quot;].mean().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_heat_cpu, out4, &quot;chart4_cpu_efficiency_heatmap&quot;):
    plt.figure(figsize=(10, 5))
    sns.heatmap(df_heat_cpu, annot=True, fmt=&quot;.1f&quot;, cmap=&quot;RdYlGn&quot;, cbar=True)
    plt.title(&quot;Mean CPU Utilization Heatmap (%) (Workload Type x Date)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Workload Type&quot;)
    plt.tight_layout()
    plt.savefig(out4, dpi=100)
    plt.close()

print(&quot;⏳ [5/19] Rendering chart18_mem_waste_heatmap...&quot;)
out18 = PLOT_DIR / &quot;chart18_mem_waste_heatmap.png&quot;
df_heat_mem = df_pod.groupby([&quot;workload_type&quot;, &quot;date&quot;])[&quot;mem_waste_gb_hours&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_heat_mem, out18, &quot;chart18_mem_waste_heatmap&quot;):
    plt.figure(figsize=(10, 5))
    sns.heatmap(df_heat_mem, annot=False, cmap=&quot;BuPu&quot;, cbar=True)
    plt.title(&quot;Total Memory Waste Volume Heatmap (GB-Hours)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Workload Type&quot;)
    plt.tight_layout()
    plt.savefig(out18, dpi=100)
    plt.close()

# ─── 📊 [Charts 5 &amp; 11] Pareto Governance Charts ───
print(&quot;⏳ [6/19] Rendering chart5_pareto_ns_waste...&quot;)
out5 = PLOT_DIR / &quot;chart5_pareto_ns_waste.png&quot;
if not check_and_handle_empty(df_ns, out5, &quot;chart5_pareto_ns_waste&quot;):
    # 단일 테넌트 중복 방지용 정렬 및 상위 스캔
    df_ns_top = df_ns.groupby(&quot;namespace&quot;)[&quot;total_waste_core_hours&quot;].sum().reset_index().sort_values(&quot;total_waste_core_hours&quot;, ascending=False).head(15)
    df_ns_top[&quot;waste_share_pct&quot;] = (df_ns_top[&quot;total_waste_core_hours&quot;] / df_ns_top[&quot;total_waste_core_hours&quot;].sum() * 100).round(2)
    df_ns_top[&quot;waste_cumsum_pct&quot;] = df_ns_top[&quot;waste_share_pct&quot;].cumsum().round(2)

    fig, ax1 = plt.subplots(figsize=(11, 5))
    sns.barplot(data=df_ns_top, x=&quot;namespace&quot;, y=&quot;total_waste_core_hours&quot;, ax=ax1, color=&quot;steelblue&quot;)
    ax1.set_xticklabels(ax1.get_xticklabels(), rotation=45, ha=&quot;right&quot;)
    ax2 = ax1.twinx()
    ax2.plot(df_ns_top[&quot;namespace&quot;], df_ns_top[&quot;waste_cumsum_pct&quot;], color=&quot;crimson&quot;, marker=&quot;o&quot;, linewidth=2)
    ax2.set_ylim(0, 110)
    plt.title(&quot;Top 15 Namespace Cost-Waste Pareto Chart (Cumulative Share %)&quot;)
    ax1.set_xlabel(&quot;Tenant Namespace&quot;)
    ax1.set_ylabel(&quot;Waste Volume (Core-Hours)&quot;)
    plt.tight_layout()
    plt.savefig(out5, dpi=100)
    plt.close()

print(&quot;⏳ [7/19] Rendering chart11_pareto_workload_waste...&quot;)
out11 = PLOT_DIR / &quot;chart11_pareto_workload_waste.png&quot;
if not check_and_handle_empty(df_pod, out11, &quot;chart11_pareto_workload_waste&quot;):
    df_wl_waste = df_pod.groupby(&quot;workload_type&quot;)[&quot;cpu_waste_core_hours&quot;].sum().reset_index().sort_values(&quot;cpu_waste_core_hours&quot;, ascending=False)
    plt.figure(figsize=(10, 5))
    sns.barplot(data=df_wl_waste, x=&quot;workload_type&quot;, y=&quot;cpu_waste_core_hours&quot;, palette=&quot;Oranges_r&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;Total CPU Waste Volume by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Total Waste (Core-Hours)&quot;)
    plt.tight_layout()
    plt.savefig(out11, dpi=100)
    plt.close()

# ─── 📊 [Chart 6] Governance Status Donut Chart ───
print(&quot;⏳ [8/19] Rendering chart6_status_donut...&quot;)
out6 = PLOT_DIR / &quot;chart6_status_donut.png&quot;
if not check_and_handle_empty(df_pod, out6, &quot;chart6_status_donut&quot;):
    status_summary = df_pod[&quot;status&quot;].value_counts()
    plt.figure(figsize=(6, 5))
    colors = [&quot;#70AD47&quot;, &quot;#1F4E79&quot;, &quot;#FFC000&quot;, &quot;#C00000&quot;]
    plt.pie(status_summary, labels=status_summary.index, autopct=&#39;%1.1f%%&#39;, startangle=90, colors=colors[:len(status_summary)], wedgeprops=dict(width=0.4, edgecolor=&#39;w&#39;))
    plt.title(&quot;Infrastructure Resource Governance Status Ratio&quot;)
    plt.tight_layout()
    plt.savefig(out6, dpi=100)
    plt.close()

# ─── 📊 [Charts 7 &amp; 14] Bubble &amp; Scatter Plots ───
print(&quot;⏳ [9/19] Rendering chart7_waste_footprint_bubble...&quot;)
out7 = PLOT_DIR / &quot;chart7_waste_footprint_bubble.png&quot;
if not check_and_handle_empty(df_pod, out7, &quot;chart7_waste_footprint_bubble&quot;):
    df_bubble = df_pod.groupby(&quot;workload_type&quot;).agg(
        x_alloc=(&quot;cpu_allocated_core_hours&quot;, &quot;sum&quot;),
        y_util=(&quot;cpu_util&quot;, &quot;mean&quot;),
        z_waste=(&quot;cpu_waste_core_hours&quot;, &quot;sum&quot;)
    ).reset_index()
    plt.figure(figsize=(9, 6))
    sns.scatterplot(data=df_bubble, x=&quot;x_alloc&quot;, y=&quot;y_util&quot;, size=&quot;z_waste&quot;, hue=&quot;workload_type&quot;, sizes=(100, 2000), alpha=0.7, legend=&quot;brief&quot;)
    plt.title(&quot;Resource Footprint Bubble Chart (Allocated x Utilization x Waste Size)&quot;)
    plt.xlabel(&quot;Total Allocated Core-Hours&quot;)
    plt.ylabel(&quot;Average Utilization (%)&quot;)
    plt.legend(bbox_to_anchor=(1.05, 1), loc=&#39;upper left&#39;)
    plt.tight_layout()
    plt.savefig(out7, dpi=100)
    plt.close()

print(&quot;⏳ [10/19] Rendering chart14_cpu_mem_waste_scatter...&quot;)
out14 = PLOT_DIR / &quot;chart14_cpu_mem_waste_scatter.png&quot;
if not check_and_handle_empty(df_pod, out14, &quot;chart14_cpu_mem_waste_scatter&quot;):
    plt.figure(figsize=(8, 6))
    sns.scatterplot(data=df_pod.head(5000), x=&quot;cpu_waste_core_hours&quot;, y=&quot;mem_waste_gb_hours&quot;, hue=&quot;workload_type&quot;, alpha=0.5)
    plt.title(&quot;Co-relation Scatter Plot: CPU Waste vs Memory Waste (Sampled)&quot;)
    plt.xlabel(&quot;CPU Waste (Core-Hours)&quot;)
    plt.ylabel(&quot;Memory Waste (GB-Hours)&quot;)
    plt.tight_layout()
    plt.savefig(out14, dpi=100)
    plt.close()

# ─── 📊 [Chart 8] Resource Shortage Footprint ───
print(&quot;⏳ [11/19] Rendering chart8_shortfall_footprint...&quot;)
out8 = PLOT_DIR / &quot;chart8_shortfall_footprint.png&quot;
df_short = df_pod.groupby([&quot;workload_type&quot;, &quot;date&quot;])[&quot;cpu_shortage_cores&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_short, out8, &quot;chart8_shortfall_footprint&quot;):
    plt.figure(figsize=(10, 4))
    sns.heatmap(df_short, annot=False, cmap=&quot;YlOrRd&quot;, cbar=True)
    plt.title(&quot;Total CPU Shortfall (Deficit) Cores Footprint&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Workload Type&quot;)
    plt.tight_layout()
    plt.savefig(out8, dpi=100)
    plt.close()

# ─── 📊 [Charts 9 &amp; 10] Boxplot Distribution Analysis ───
print(&quot;⏳ [12/19] Rendering chart9_boxplot_cpu_util_by_workload...&quot;)
out9 = PLOT_DIR / &quot;chart9_boxplot_cpu_util_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out9, &quot;chart9_boxplot_cpu_util_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.boxplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;cpu_util&quot;, palette=&quot;Set3&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.ylim(-5, 105)
    plt.title(&quot;CPU Utilization P95 Boxplot Distribution by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;P95 Actual Utilization (%)&quot;)
    plt.tight_layout()
    plt.savefig(out9, dpi=100)
    plt.close()

print(&quot;⏳ [13/19] Rendering chart10_boxplot_mem_util_by_workload...&quot;)
out10 = PLOT_DIR / &quot;chart10_boxplot_mem_util_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out10, &quot;chart10_boxplot_mem_util_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.boxplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;mem_util&quot;, palette=&quot;Pastel1&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.ylim(-5, 105)
    plt.title(&quot;Memory Utilization P95 Boxplot Distribution by Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;P95 Actual Utilization (%)&quot;)
    plt.tight_layout()
    plt.savefig(out10, dpi=100)
    plt.close()

# ─── 📊 [Charts 12 &amp; 15] Trend and Counts ───
print(&quot;⏳ [14/19] Rendering chart12_daily_waste_trend_by_workload...&quot;)
out12 = PLOT_DIR / &quot;chart12_daily_waste_trend_by_workload.png&quot;
df_trend_wl = df_pod.groupby([&quot;date&quot;, &quot;workload_type&quot;])[&quot;cpu_waste_core_hours&quot;].sum().unstack().fillna(0) if not df_pod.empty else pd.DataFrame()
if not check_and_handle_empty(df_trend_wl, out12, &quot;chart12_daily_waste_trend_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.lineplot(data=df_trend_wl, markers=True, dashes=False, linewidth=2)
    plt.title(&quot;Daily CPU Waste Timeline Trend Line by Workload Type&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Waste Volume (Core-Hours)&quot;)
    plt.xticks(rotation=30)
    plt.tight_layout()
    plt.savefig(out12, dpi=100)
    plt.close()

print(&quot;⏳ [15/19] Rendering chart15_oom_status_by_workload...&quot;)
out15 = PLOT_DIR / &quot;chart15_oom_status_by_workload.png&quot;
if not check_and_handle_empty(df_pod, out15, &quot;chart15_oom_status_by_workload&quot;):
    plt.figure(figsize=(11, 5))
    sns.countplot(data=df_pod, x=&quot;workload_type&quot;, hue=&quot;status&quot;, palette=&quot;muted&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;Governance Status Distribution Count per Workload Type&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Pod Count&quot;)
    plt.legend(loc=&quot;upper right&quot;)
    plt.tight_layout()
    plt.savefig(out15, dpi=100)
    plt.close()

# ─── 📊 [Charts 13 &amp; 17] Violin and Overcommit Boxplot ───
print(&quot;⏳ [16/19] Rendering chart13_violin_cpu_util...&quot;)
out13 = PLOT_DIR / &quot;chart13_violin_cpu_util.png&quot;
if not check_and_handle_empty(df_pod, out13, &quot;chart13_violin_cpu_util&quot;):
    plt.figure(figsize=(10, 5))
    sns.violinplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;cpu_util&quot;, inner=&quot;quartile&quot;, palette=&quot;pastel&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;CPU Utilization Kernel Density Violin Plot&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;CPU Utilization (%)&quot;)
    plt.tight_layout()
    plt.savefig(out13, dpi=100)
    plt.close()

print(&quot;⏳ [17/19] Rendering chart17_cpu_limit_request_ratio...&quot;)
out17 = PLOT_DIR / &quot;chart17_cpu_limit_request_ratio.png&quot;
if not check_and_handle_empty(df_pod, out17, &quot;chart17_cpu_limit_request_ratio&quot;):
    plt.figure(figsize=(10, 5))
    sns.boxplot(data=df_pod, x=&quot;workload_type&quot;, y=&quot;lim_req_ratio&quot;, palette=&quot;vlag&quot;)
    plt.xticks(rotation=30, ha=&quot;right&quot;)
    plt.title(&quot;Kubernetes Pod CPU Limit / Request Overcommit Ratio&quot;)
    plt.xlabel(&quot;Workload Type&quot;)
    plt.ylabel(&quot;Limit / Request Ratio&quot;)
    plt.tight_layout()
    plt.savefig(out17, dpi=100)
    plt.close()

# ─── 📊 [Charts 19 &amp; 20] Capacity vs Actual Trends ───
print(&quot;⏳ [18/19] Rendering chart19_daily_cpu_per_workload...&quot;)
out19 = PLOT_DIR / &quot;chart19_daily_cpu_per_workload.png&quot;
if not check_and_handle_empty(df_pod, out19, &quot;chart19_daily_cpu_per_workload&quot;):
    df_daily_cpu_req = df_pod.groupby(&quot;date&quot;)[&quot;cpu_request_max&quot;].sum()
    df_daily_cpu_use = df_pod.groupby(&quot;date&quot;)[&quot;cpu_usage_p95&quot;].sum()
    plt.figure(figsize=(11, 5))
    plt.fill_between(df_daily_cpu_req.index, df_daily_cpu_req.values, label=&quot;Total CPU Request Cores&quot;, color=&quot;skyblue&quot;, alpha=0.4)
    plt.plot(df_daily_cpu_use.index, df_daily_cpu_use.values, label=&quot;Total CPU P95 Actual Cores&quot;, color=&quot;navy&quot;, linewidth=2.5, marker=&quot;o&quot;)
    plt.title(&quot;Daily Total CPU Capacity Allocation vs Actual Peak Consumption (KST)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Total CPU Cores&quot;)
    plt.xticks(rotation=30)
    plt.legend(loc=&quot;upper left&quot;)
    plt.tight_layout()
    plt.savefig(out19, dpi=100)
    plt.close()

print(&quot;⏳ [19/19] Rendering chart20_daily_mem_per_workload...&quot;)
out20 = PLOT_DIR / &quot;chart20_daily_mem_per_workload.png&quot;
if not check_and_handle_empty(df_pod, out20, &quot;chart20_daily_mem_per_workload&quot;):
    df_daily_mem_req = df_pod.groupby(&quot;date&quot;)[&quot;mem_request_max&quot;].sum()
    df_daily_mem_use = df_pod.groupby(&quot;date&quot;)[&quot;mem_usage_p95&quot;].sum()
    plt.figure(figsize=(11, 5))
    plt.fill_between(df_daily_mem_req.index, df_daily_mem_req.values, label=&quot;Total Memory Request (GB)&quot;, color=&quot;plum&quot;, alpha=0.4)
    plt.plot(df_daily_mem_use.index, df_daily_mem_use.values, label=&quot;Total Memory P95 Actual (GB)&quot;, color=&quot;purple&quot;, linewidth=2.5, marker=&quot;o&quot;)
    plt.title(&quot;Daily Total Memory Capacity Allocation vs Actual Peak Consumption (KST)&quot;)
    plt.xlabel(&quot;Date (KST)&quot;)
    plt.ylabel(&quot;Total Memory (GB)&quot;)
    plt.xticks(rotation=30)
    plt.legend(loc=&quot;upper left&quot;)
    plt.tight_layout()
    plt.savefig(out20, dpi=100)
    plt.close()

print(f&quot;\n🏁 === [Step3 Success] All charts generated cleanly under ./data/output/plots/{cluster_type_str} ===&quot;)</code></pre><p>if <strong>name</strong> == &quot;<strong>main</strong>&quot;:
    main()</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[26J02g2]]></title>
            <link>https://velog.io/@youngkyoo_kim/26J02g2</link>
            <guid>https://velog.io/@youngkyoo_kim/26J02g2</guid>
            <pubDate>Wed, 01 Jul 2026 23:13:09 GMT</pubDate>
            <description><![CDATA[<p>```sh</p>
<h1 id="1-크론잡-마스터-등록">1. 크론잡 마스터 등록</h1>
<p>kubectl apply -f finops-daily-cronjob.yaml</p>
<h1 id="2-등록-상태-및-차기-스케줄-타임라인-확인">2. 등록 상태 및 차기 스케줄 타임라인 확인</h1>
<p>kubectl get cronjob finops-daily-pipeline -n devops-finops</p>
<h1 id="3-🔥-수동-즉시-트리거-크론잡-설정을-기반으로-일회성-job-강제-생성">3. 🔥 [수동 즉시 트리거] 크론잡 설정을 기반으로 일회성 Job 강제 생성</h1>
<p>kubectl create job --from=cronjob/finops-daily-pipeline finops-adhoc-test -n devops-finops</p>
<h1 id="4-생성된-팟의-실시간-스트리밍-로그-추적-step1의-레코드-출력과-step2의-starrocks-연동-상태-확인">4. 생성된 팟의 실시간 스트리밍 로그 추적 (step1의 레코드 출력과 step2의 StarRocks 연동 상태 확인)</h1>
<p>kubectl get pods -n devops-finops | grep finops-adhoc-test
kubectl logs -f &lt;조회된-파드명&gt; -n devops-finops</p>
<p>```yaml
spec:
  jobTemplate:
    spec:
      # ─── 🛡️ Job 전체의 최대 실패 허용 횟수를 0으로 제한 ───
      backoffLimit: 0 </p>
<pre><code>  template:
    spec:
      # ─── 🛡️ 팟이 죽었을 때 컨테이너를 다시 살리지 않고 무조건 종료 ───
      restartPolicy: Never 

      containers:
      - name: pipeline-runner
        image: harbor.internal.zone/devops/finops-pipeline:v2</code></pre><hr>
<p>spec:
  jobTemplate:
    spec:
      # ─── 🛡️ 최초 실패 후 딱 1번만 더 새 팟을 띄워 시도 ───
      backoffLimit: 1 </p>
<pre><code>  template:
    spec:
      # 💡 새 팟을 다른 노드에 스케줄링하기 위해 Never로 지정하는 것이 유리합니다.
      restartPolicy: Never 

      containers:
      - name: pipeline-runner</code></pre>]]></description>
        </item>
    </channel>
</rss>