"""
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
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",
}
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):
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
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개뿐, 비교 불가)")
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))
def write_fail_warn_csv(records, out_path):
rows = [r for r in records if r["status"] in ("FAIL", "WARN")]
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)
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
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)
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()