26S06f3

QK·약 9시간 전
#!/usr/bin/env python3
"""
aggregate_report.py
------------------------------------------------------------------------
목적: node_check.sh가 각 노드에서 생성한 summary.csv (파이프 '|' 구분)들을
      한 곳에 모은 뒤, 다음 3가지 리포트를 만든다.

  1) AGGREGATE_SUMMARY.md
     - 점검 항목(category/check)별로 전체 노드 통계(개수, PASS/WARN/FAIL/INFO
       분포, 숫자형 값이면 min/max/mean/stddev)를 보여준다.

  2) FAIL_WARN_NODES.csv
     - node_check.sh 자체가 이미 FAIL/WARN으로 판정한 항목을 노드별로 모아
       바로 조치할 수 있게 정렬한 목록.

  3) OUTLIERS.csv
     - node_check.sh 단계에서는 절대 기준이 없어 INFO로만 남긴 값들
       (fio IOPS, iperf3 처리량, ECC 등 노드간 비교가 필요한 값)을
       fleet 전체 분포와 비교해 z-score 기준으로 이상치를 잡아낸다.
       (판단 기준 상세는 README.md 참고)

사용법:
  # 1) 각 노드의 결과 디렉터리를 한 곳에 모은다 (예: rsync/scp)
  #    results/
  #      node01_20260101_120000/summary.csv
  #      node02_20260101_120500/summary.csv
  #      ...
  #
  # 2) 집계 실행
  python3 aggregate_report.py results/ --out report/ --zscore 2.0

옵션:
  results_root   summary.csv들이 담긴 노드별 하위 디렉터리들의 상위 경로
  --out          리포트 출력 디렉터리 (기본: results_root/_aggregate_report)
  --zscore       이상치 판정 z-score 임계값 (기본 2.0, 값이 클수록 덜 민감)

추가/삭제하기 쉽게 하려면:
  - 새 카테고리/체크를 aggregate에 특별 취급하고 싶다면 CATEGORY_HINTS에
    한 줄 추가 (예: 값이 클수록 좋은지/작을수록 좋은지 방향성 힌트).
  - 특정 체크를 이상치 분석에서 빼고 싶다면 EXCLUDE_FROM_OUTLIER에 추가.
"""

import argparse
import csv
import glob
import os
import statistics
import sys
from collections import defaultdict

# =========================================================================
# 설정 (필요시 이 섹션만 수정)
# =========================================================================

# 값이 "클수록 좋음" / "작을수록 좋음" 힌트 (이상치 리포트에서 방향 표기용)
# 여기 없는 항목은 방향 표기 없이 단순 |z-score| 기준으로만 이상치 판정.
CATEGORY_HINTS = {
    "storage_fio_randread_iops": "higher_better",
    "storage_fio_randwrite_iops": "higher_better",
    "nic_iperf3_throughput": "higher_better",
    "time_chrony_offset_sec": "lower_better",
}

# 통계/이상치 분석에서 제외할 (category, check) - 값이 텍스트라 의미 없는 것들
EXCLUDE_FROM_OUTLIER = {
    ("inventory", "system_vendor"),
    ("inventory", "system_model"),
    ("inventory", "bios_version"),
    ("cpu", "model"),
    ("os", "kernel_version"),
    ("os", "selinux_status"),
}

STATUS_ORDER = ["FAIL", "WARN", "INFO", "PASS"]


# =========================================================================
# 데이터 로딩
# =========================================================================

def load_all(results_root):
    """results_root 아래 모든 */summary.csv 를 읽어 레코드 리스트로 반환.

    각 레코드: dict(node, category, check, value, unit, status, note)
    node 이름은 디렉터리명에서 '_YYYYMMDD_HHMMSS' 타임스탬프 접미사를 뗀 값.
    """
    records = []
    pattern = os.path.join(results_root, "*", "summary.csv")
    files = sorted(glob.glob(pattern))
    if not files:
        print(f"[경고] {pattern} 에서 summary.csv를 찾지 못했습니다.", file=sys.stderr)

    for path in files:
        dirname = os.path.basename(os.path.dirname(path))
        node = strip_timestamp_suffix(dirname)
        with open(path, newline="", encoding="utf-8") as f:
            reader = csv.DictReader(f, delimiter="|")
            for row in reader:
                if not row.get("category"):
                    continue
                records.append({
                    "node": node,
                    "category": row["category"].strip(),
                    "check": row["check"].strip(),
                    "value": (row.get("value") or "").strip(),
                    "unit": (row.get("unit") or "").strip(),
                    "status": (row.get("status") or "").strip(),
                    "note": (row.get("note") or "").strip(),
                    "source_file": path,
                })
    return records


def strip_timestamp_suffix(dirname):
    # node_check.sh는 "<hostname>_<YYYYMMDD>_<HHMMSS>" 형식으로 디렉터리를 만든다.
    parts = dirname.rsplit("_", 2)
    if len(parts) == 3 and parts[1].isdigit() and parts[2].isdigit():
        return parts[0]
    return dirname


def try_float(value):
    """'12.3', '1.2k' 같은 fio류 표기도 최대한 숫자로 변환. 실패시 None."""
    if value is None or value == "" or value.lower() == "unknown":
        return None
    v = value.strip().lower()
    multiplier = 1.0
    if v.endswith("k"):
        multiplier = 1_000.0
        v = v[:-1]
    elif v.endswith("m"):
        multiplier = 1_000_000.0
        v = v[:-1]
    try:
        return float(v) * multiplier
    except ValueError:
        return None


# =========================================================================
# 리포트 1: 카테고리/체크별 통계
# =========================================================================

def build_summary(records):
    """(category, check) -> {values:[...], statuses:[...], nodes:[...]}"""
    grouped = defaultdict(lambda: {"values": [], "statuses": [], "nodes": [], "unit": ""})
    for r in records:
        key = (r["category"], r["check"])
        g = grouped[key]
        g["values"].append(r["value"])
        g["statuses"].append(r["status"])
        g["nodes"].append(r["node"])
        if r["unit"]:
            g["unit"] = r["unit"]
    return grouped


def write_summary_md(grouped, out_path, total_nodes):
    lines = []
    lines.append("# 노드 인수 점검 - 전체 집계 리포트\n")
    lines.append(f"- 집계 대상 노드 수: **{total_nodes}**")
    lines.append(f"- 집계 항목(체크) 수: **{len(grouped)}**\n")

    for (category, check), g in sorted(grouped.items()):
        lines.append(f"## {category} / {check}")

        status_counts = {s: g["statuses"].count(s) for s in STATUS_ORDER if g["statuses"].count(s) > 0}
        status_str = ", ".join(f"{s}={c}" for s, c in status_counts.items())
        lines.append(f"- 상태 분포: {status_str}  (unit: {g['unit'] or '-'})")

        numeric = [try_float(v) for v in g["values"]]
        numeric = [v for v in numeric if v is not None]
        if len(numeric) >= 2:
            mean = statistics.mean(numeric)
            stdev = statistics.pstdev(numeric)
            lines.append(
                f"- 숫자값 통계: n={len(numeric)}, min={min(numeric):.2f}, "
                f"max={max(numeric):.2f}, mean={mean:.2f}, stdev={stdev:.2f}"
            )
        elif len(numeric) == 1:
            lines.append(f"- 숫자값: {numeric[0]:.2f} (노드 1개뿐, 비교 불가)")

        # FAIL/WARN 노드만 별도로 뽑아서 바로 보이게
        bad_nodes = [
            (n, v, s) for n, v, s in zip(g["nodes"], g["values"], g["statuses"])
            if s in ("FAIL", "WARN")
        ]
        if bad_nodes:
            lines.append("- FAIL/WARN 노드:")
            for n, v, s in bad_nodes:
                lines.append(f"  - `{n}`: {v} ({s})")
        lines.append("")

    with open(out_path, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))


# =========================================================================
# 리포트 2: FAIL/WARN 목록 (조치용)
# =========================================================================

def write_fail_warn_csv(records, out_path):
    rows = [r for r in records if r["status"] in ("FAIL", "WARN")]
    # FAIL 먼저, 그 다음 WARN, 같은 상태면 category/check로 정렬
    rows.sort(key=lambda r: (STATUS_ORDER.index(r["status"]), r["category"], r["check"], r["node"]))

    with open(out_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["status", "node", "category", "check", "value", "unit", "note"])
        for r in rows:
            writer.writerow([r["status"], r["node"], r["category"], r["check"], r["value"], r["unit"], r["note"]])

    return len(rows)


# =========================================================================
# 리포트 3: 통계적 이상치 (z-score)
# =========================================================================

def write_outliers_csv(grouped, out_path, zscore_threshold):
    rows = []
    for (category, check), g in grouped.items():
        if (category, check) in EXCLUDE_FROM_OUTLIER:
            continue
        pairs = [(n, try_float(v)) for n, v in zip(g["nodes"], g["values"])]
        pairs = [(n, v) for n, v in pairs if v is not None]
        if len(pairs) < 3:
            continue  # 노드 3대 미만이면 통계적 의미가 약함

        values = [v for _, v in pairs]
        mean = statistics.mean(values)
        stdev = statistics.pstdev(values)
        if stdev == 0:
            continue  # 전부 같은 값이면 이상치 없음

        hint = CATEGORY_HINTS.get(f"{category}_{check}", "")

        for node, v in pairs:
            z = (v - mean) / stdev
            if abs(z) >= zscore_threshold:
                direction = "낮음(저성능 의심)" if z < 0 else "높음"
                if hint == "lower_better":
                    direction = "높음(악화 의심)" if z > 0 else "낮음"
                rows.append([
                    category, check, node, f"{v:.2f}", f"{mean:.2f}", f"{stdev:.2f}",
                    f"{z:.2f}", direction
                ])

    rows.sort(key=lambda r: -abs(float(r[6])))

    with open(out_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(["category", "check", "node", "value", "fleet_mean", "fleet_stdev", "zscore", "direction"])
        writer.writerows(rows)

    return len(rows)


# =========================================================================
# main
# =========================================================================

def main():
    ap = argparse.ArgumentParser(description="node_check.sh 결과 취합/통계 스크립트")
    ap.add_argument("results_root", help="노드별 summary.csv가 담긴 상위 디렉터리")
    ap.add_argument("--out", default=None, help="리포트 출력 디렉터리 (기본: <results_root>/_aggregate_report)")
    ap.add_argument("--zscore", type=float, default=2.0, help="이상치 판정 z-score 임계값 (기본 2.0)")
    args = ap.parse_args()

    out_dir = args.out or os.path.join(args.results_root, "_aggregate_report")
    os.makedirs(out_dir, exist_ok=True)

    records = load_all(args.results_root)
    if not records:
        print("집계할 데이터가 없습니다. results_root 경로를 확인하세요.", file=sys.stderr)
        sys.exit(1)

    nodes = sorted(set(r["node"] for r in records))
    grouped = build_summary(records)

    summary_path = os.path.join(out_dir, "AGGREGATE_SUMMARY.md")
    write_summary_md(grouped, summary_path, total_nodes=len(nodes))

    fw_path = os.path.join(out_dir, "FAIL_WARN_NODES.csv")
    fw_count = write_fail_warn_csv(records, fw_path)

    out_path = os.path.join(out_dir, "OUTLIERS.csv")
    out_count = write_outliers_csv(grouped, out_path, args.zscore)

    print(f"대상 노드 수     : {len(nodes)}  ({', '.join(nodes)})")
    print(f"집계 항목 수     : {len(grouped)}")
    print(f"FAIL/WARN 레코드 : {fw_count}건 -> {fw_path}")
    print(f"통계적 이상치    : {out_count}건 (|z|>={args.zscore}) -> {out_path}")
    print(f"전체 요약        : {summary_path}")


if __name__ == "__main__":
    main()
profile
engineer

0개의 댓글