26F26I

Young-Kyoo Kim·2026년 2월 25일
# scripts/check_clustermesh_connectivity.py

"""
Cilium ClusterMesh 환경에서 Node → Prometheus / OpenSearch 접근 확인 스크립트

실행:
  python scripts/check_clustermesh_connectivity.py
  python scripts/check_clustermesh_connectivity.py --prometheus-url http://10.96.x.x:9090
  python scripts/check_clustermesh_connectivity.py --verbose
"""

import argparse
import json
import socket
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ── 결과 색상 출력 ─────────────────────────────────────
GREEN  = "\033[92m"
YELLOW = "\033[93m"
RED    = "\033[91m"
CYAN   = "\033[96m"
RESET  = "\033[0m"
BOLD   = "\033[1m"

def ok(msg):   print(f"  {GREEN}{RESET} {msg}")
def warn(msg): print(f"  {YELLOW}{RESET} {msg}")
def fail(msg): print(f"  {RED}{RESET} {msg}")
def info(msg): print(f"  {CYAN}{RESET} {msg}")


# ── 결과 수집 ──────────────────────────────────────────
@dataclass
class CheckResult:
    name: str
    status: str          # OK / WARN / FAIL
    latency_ms: float = 0.0
    detail: str = ""
    sub_checks: list = field(default_factory=list)


results: list[CheckResult] = []


# ── 공통 유틸 ──────────────────────────────────────────
def check_dns(hostname: str) -> Optional[str]:
    """DNS 해석 시도"""
    try:
        ip = socket.gethostbyname(hostname)
        return ip
    except socket.gaierror:
        return None


def check_tcp(host: str, port: int, timeout: int = 5) -> tuple[bool, float]:
    """TCP 연결 + latency 측정"""
    start = time.time()
    try:
        sock = socket.create_connection((host, port), timeout=timeout)
        sock.close()
        return True, round((time.time() - start) * 1000, 2)
    except (socket.timeout, ConnectionRefusedError, OSError):
        return False, 0.0


def http_get(url: str, timeout: int = 10, auth=None,
             verify: bool = False, headers: dict = None) -> tuple[bool, int, float, str]:
    """HTTP GET + latency 측정"""
    start = time.time()
    try:
        r = requests.get(
            url, timeout=timeout, auth=auth,
            verify=verify, headers=headers or {},
            allow_redirects=True,
        )
        latency = round((time.time() - start) * 1000, 2)
        return True, r.status_code, latency, r.text[:500]
    except requests.exceptions.ConnectionError as e:
        return False, 0, 0.0, f"ConnectionError: {e}"
    except requests.exceptions.Timeout:
        return False, 0, 0.0, "Timeout"
    except Exception as e:
        return False, 0, 0.0, str(e)


# ══════════════════════════════════════════════════════
#  Prometheus 체크
# ══════════════════════════════════════════════════════
def check_prometheus(base_url: str, verbose: bool = False):
    print(f"\n{BOLD}{'─'*55}{RESET}")
    print(f"{BOLD}🔥 Prometheus 연결 확인{RESET}")
    print(f"   URL: {base_url}")
    print(f"{'─'*55}")

    host = base_url.split("//")[-1].split(":")[0]
    port_str = base_url.split(":")[-1].rstrip("/").split("/")[0]
    port = int(port_str) if port_str.isdigit() else 9090
    sub = []

    # 1. DNS
    print("\n  [1] DNS 해석")
    ip = check_dns(host)
    if ip:
        ok(f"DNS 해석 성공: {host}{ip}")
        sub.append(("DNS", "OK", ip))
        # ClusterMesh FQDN 패턴 힌트
        if "svc.cluster.local" in host:
            info("ClusterMesh FQDN 형식 감지 — 클러스터 내 DNS 사용 중")
        elif "svc" in host:
            info("서비스 도메인 형식 감지")
    else:
        fail(f"DNS 해석 실패: {host}")
        warn("ClusterMesh FQDN이라면 Node의 /etc/resolv.conf 에 cluster DNS 서버가 없을 수 있음")
        info(f"대안: ClusterIP 직접 사용 또는 /etc/hosts 에 {host} 추가")
        sub.append(("DNS", "FAIL", "해석 불가"))

    # 2. TCP
    print("\n  [2] TCP 연결 (포트 {port})")
    tcp_ok, tcp_ms = check_tcp(host, port)
    if tcp_ok:
        ok(f"TCP 연결 성공 (latency: {tcp_ms}ms)")
        sub.append(("TCP", "OK", f"{tcp_ms}ms"))
        if tcp_ms > 100:
            warn(f"latency {tcp_ms}ms — 100ms 초과, 클러스터 간 네트워크 지연 의심")
    else:
        fail(f"TCP 연결 실패 ({host}:{port})")
        info("확인 사항:")
        info("  cilium clustermesh status  → 클러스터 연결 상태")
        info("  kubectl get svc -A | grep prometheus  → 서비스 존재 여부")
        info("  NetworkPolicy가 Node → Pod 접근을 차단하는지 확인")
        sub.append(("TCP", "FAIL", "연결 불가"))
        results.append(CheckResult("Prometheus", "FAIL", 0, "TCP 연결 불가", sub))
        return

    # 3. HTTP Health
    print("\n  [3] HTTP Health Check")
    ok_flag, code, ms, body = http_get(f"{base_url}/-/healthy")
    if ok_flag and code == 200:
        ok(f"/-/healthy → {code} ({ms}ms)")
        sub.append(("Health", "OK", f"{ms}ms"))
    else:
        fail(f"/-/healthy → code={code}, {ms}ms")
        sub.append(("Health", "FAIL", body[:100]))

    # 4. Ready
    print("\n  [4] HTTP Ready Check")
    ok_flag, code, ms, body = http_get(f"{base_url}/-/ready")
    if ok_flag and code == 200:
        ok(f"/-/ready → {code} ({ms}ms)")
        sub.append(("Ready", "OK", f"{ms}ms"))
    else:
        warn(f"/-/ready → code={code} (아직 초기화 중일 수 있음)")
        sub.append(("Ready", "WARN", str(code)))

    # 5. 실제 PromQL 쿼리
    print("\n  [5] PromQL 쿼리 테스트")
    test_queries = {
        "up 메트릭":      "up",
        "scrape 대상 수": "count(up)",
    }
    for label, promql in test_queries.items():
        ok_flag, code, ms, body = http_get(
            f"{base_url}/api/v1/query",
            headers={"Content-Type": "application/x-www-form-urlencoded"},
        )
        # GET 방식으로 재시도
        ok_flag, code, ms, body = http_get(
            f"{base_url}/api/v1/query?query={requests.utils.quote(promql)}"
        )
        if ok_flag and code == 200:
            try:
                data = json.loads(body.split("\n")[0] if "\n" in body else body)
                result_len = len(data.get("data", {}).get("result", []))
                ok(f"{label}: {result_len}개 시리즈 ({ms}ms)")
                sub.append((f"PromQL:{label}", "OK", f"{result_len} series"))
                if verbose:
                    info(f"    응답 미리보기: {body[:200]}")
            except Exception:
                ok(f"{label}: 응답 수신 ({ms}ms)")
        else:
            warn(f"{label}: 응답 오류 code={code}")

    # 6. ClusterMesh 관련 메트릭 확인
    print("\n  [6] ClusterMesh 관련 메트릭")
    ok_flag, code, ms, body = http_get(
        f"{base_url}/api/v1/query?query=cilium_clustermesh_remote_clusters"
    )
    if ok_flag and code == 200:
        try:
            data = json.loads(body)
            result = data.get("data", {}).get("result", [])
            if result:
                ok(f"ClusterMesh 메트릭 존재: {len(result)}개 시리즈")
                for r in result[:3]:
                    info(f"    {r.get('metric')} = {r.get('value', ['', ''])[1]}")
            else:
                info("ClusterMesh 메트릭 없음 (Cilium metrics가 수집 안될 수 있음)")
        except Exception:
            pass
    else:
        info("ClusterMesh 메트릭 조회 불가 (선택적 확인)")

    overall = "OK" if all(s[1] == "OK" for s in sub[:4]) else "WARN"
    results.append(CheckResult("Prometheus", overall, tcp_ms, base_url, sub))


# ══════════════════════════════════════════════════════
#  OpenSearch 체크
# ══════════════════════════════════════════════════════
def check_opensearch(
    host: str, port: int,
    user: str, password: str,
    use_ssl: bool = False,
    index_pattern: str = "logs-*",
    verbose: bool = False,
):
    scheme = "https" if use_ssl else "http"
    base_url = f"{scheme}://{host}:{port}"
    auth = (user, password) if user else None

    print(f"\n{BOLD}{'─'*55}{RESET}")
    print(f"{BOLD}🔍 OpenSearch 연결 확인{RESET}")
    print(f"   URL: {base_url}")
    print(f"   Auth: {'yes' if auth else 'no'} / SSL: {use_ssl}")
    print(f"{'─'*55}")

    sub = []

    # 1. DNS
    print("\n  [1] DNS 해석")
    ip = check_dns(host)
    if ip:
        ok(f"DNS 해석 성공: {host}{ip}")
        sub.append(("DNS", "OK", ip))
    else:
        fail(f"DNS 해석 실패: {host}")
        info(f"ClusterMesh FQDN이면 Node DNS에 cluster DNS 설정 필요")
        sub.append(("DNS", "FAIL", "해석 불가"))

    # 2. TCP
    print(f"\n  [2] TCP 연결 (포트 {port})")
    tcp_ok, tcp_ms = check_tcp(host, port)
    if tcp_ok:
        ok(f"TCP 연결 성공 ({tcp_ms}ms)")
        sub.append(("TCP", "OK", f"{tcp_ms}ms"))
    else:
        fail(f"TCP 연결 실패 ({host}:{port})")
        info("확인 사항:")
        info("  kubectl get svc -A | grep opensearch")
        info("  cilium clustermesh status")
        sub.append(("TCP", "FAIL", "연결 불가"))
        results.append(CheckResult("OpenSearch", "FAIL", 0, "TCP 연결 불가", sub))
        return

    # 3. Cluster Health
    print("\n  [3] Cluster Health")
    ok_flag, code, ms, body = http_get(
        f"{base_url}/_cluster/health",
        auth=auth, verify=False,
    )
    if ok_flag and code == 200:
        try:
            data = json.loads(body)
            status = data.get("status", "unknown")
            nodes = data.get("number_of_nodes", 0)
            shards = data.get("active_shards", 0)
            color = {"green": GREEN, "yellow": YELLOW, "red": RED}.get(status, RESET)
            ok(f"Cluster status: {color}{status.upper()}{RESET} | nodes: {nodes} | active_shards: {shards} ({ms}ms)")
            sub.append(("ClusterHealth", status.upper(), f"nodes={nodes}"))
            if status == "red":
                warn("Cluster 상태 RED — 일부 primary shard 미할당 상태")
            elif status == "yellow":
                info("Cluster 상태 YELLOW — replica shard 미할당 (단일 노드면 정상)")
        except Exception:
            ok(f"응답 수신 code={code} ({ms}ms)")
    else:
        fail(f"/_cluster/health → code={code}")
        if code == 401:
            fail("인증 실패 — OPENSEARCH_USER/PASSWORD 확인")
        elif code == 0:
            fail(f"연결 오류: {body}")
        sub.append(("ClusterHealth", "FAIL", str(code)))

    # 4. Node 정보
    print("\n  [4] Node 정보")
    ok_flag, code, ms, body = http_get(
        f"{base_url}/_nodes/stats/os,jvm",
        auth=auth, verify=False,
    )
    if ok_flag and code == 200:
        try:
            data = json.loads(body)
            nodes = data.get("nodes", {})
            for nid, ninfo in list(nodes.items())[:2]:
                name = ninfo.get("name", nid[:8])
                heap_used = ninfo.get("jvm", {}).get("mem", {}).get("heap_used_percent", "?")
                cpu = ninfo.get("os", {}).get("cpu", {}).get("percent", "?")
                ok(f"Node '{name}': heap={heap_used}% cpu={cpu}%")
                if isinstance(heap_used, int) and heap_used > 85:
                    warn(f"  heap 사용률 {heap_used}% 높음 — GC 압박 가능")
        except Exception:
            ok(f"Node stats 수신 ({ms}ms)")
    else:
        warn(f"Node stats 조회 실패 code={code}")

    # 5. 인덱스 확인
    print(f"\n  [5] 인덱스 확인 ({index_pattern})")
    ok_flag, code, ms, body = http_get(
        f"{base_url}/_cat/indices/{index_pattern}?h=index,health,docs.count,store.size&s=docs.count:desc&format=json",
        auth=auth, verify=False,
    )
    if ok_flag and code == 200:
        try:
            indices = json.loads(body)
            if indices:
                ok(f"{len(indices)}개 인덱스 발견")
                for idx in indices[:5]:
                    h = idx.get("health", "?")
                    color = {"green": GREEN, "yellow": YELLOW, "red": RED}.get(h, RESET)
                    print(f"      {color}{h:6}{RESET} | {idx.get('index', ''):<40} | docs: {idx.get('docs.count', 0):>10} | size: {idx.get('store.size', '?')}")
                if verbose and len(indices) > 5:
                    info(f"  ... 외 {len(indices)-5}개")
                sub.append(("Indices", "OK", f"{len(indices)}개"))
            else:
                warn(f"'{index_pattern}' 패턴에 매칭되는 인덱스 없음")
                sub.append(("Indices", "WARN", "0개"))
        except Exception as e:
            warn(f"인덱스 목록 파싱 실패: {e}")
    else:
        warn(f"인덱스 조회 실패 code={code}")

    # 6. 최근 로그 쿼리 테스트
    print(f"\n  [6] 최근 로그 쿼리 테스트")
    query = {
        "query": {"range": {"@timestamp": {"gte": "now-30m", "lte": "now"}}},
        "size": 1,
        "_source": ["@timestamp", "message", "level"],
    }
    try:
        start = time.time()
        r = requests.post(
            f"{base_url}/{index_pattern}/_search",
            json=query,
            auth=auth,
            verify=False,
            timeout=10,
        )
        ms = round((time.time() - start) * 1000, 2)
        if r.status_code == 200:
            data = r.json()
            total = data.get("hits", {}).get("total", {}).get("value", 0)
            took = data.get("took", 0)
            ok(f"최근 30분 로그: {total}건 (ES took: {took}ms, 응답: {ms}ms)")
            hits = data.get("hits", {}).get("hits", [])
            if hits and verbose:
                src = hits[0].get("_source", {})
                info(f"  최신 로그: {src.get('@timestamp')} [{src.get('level', '')}] {str(src.get('message', ''))[:100]}")
            sub.append(("LogQuery", "OK", f"total={total}"))
        else:
            warn(f"로그 쿼리 응답 code={r.status_code}")
            sub.append(("LogQuery", "WARN", str(r.status_code)))
    except Exception as e:
        fail(f"로그 쿼리 실패: {e}")
        sub.append(("LogQuery", "FAIL", str(e)[:80]))

    overall = "OK" if tcp_ok and all(
        s[1] in ("OK", "WARN") for s in sub
    ) else "FAIL"
    results.append(CheckResult("OpenSearch", overall, tcp_ms, base_url, sub))


# ══════════════════════════════════════════════════════
#  ClusterMesh 상태 확인 (kubectl)
# ══════════════════════════════════════════════════════
def check_clustermesh_status():
    import subprocess

    print(f"\n{BOLD}{'─'*55}{RESET}")
    print(f"{BOLD}🌐 Cilium ClusterMesh 상태{RESET}")
    print(f"{'─'*55}")

    commands = {
        "ClusterMesh 상태":         ["cilium", "clustermesh", "status"],
        "Cilium 전체 상태":          ["cilium", "status", "--brief"],
        "Remote cluster 엔드포인트": ["kubectl", "get", "ciliumnodes", "-A", "--no-headers"],
    }

    for label, cmd in commands.items():
        print(f"\n  [{label}]")
        try:
            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=10
            )
            if result.returncode == 0:
                for line in result.stdout.strip().split("\n")[:10]:
                    if line.strip():
                        ok(line.strip())
            else:
                warn(f"명령 실패: {result.stderr.strip()[:100]}")
        except FileNotFoundError:
            info(f"'{cmd[0]}' 명령 없음 (Node에 미설치)")
        except subprocess.TimeoutExpired:
            warn("타임아웃 (10s)")
        except Exception as e:
            warn(str(e))


# ══════════════════════════════════════════════════════
#  최종 리포트
# ══════════════════════════════════════════════════════
def print_summary():
    print(f"\n{BOLD}{'='*55}{RESET}")
    print(f"{BOLD}📋 최종 결과 요약{RESET}")
    print(f"{BOLD}{'='*55}{RESET}")
    print(f"  시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")

    all_ok = True
    for r in results:
        color = {
            "OK": GREEN, "WARN": YELLOW, "FAIL": RED
        }.get(r.status, RESET)
        icon = {"OK": "✓", "WARN": "⚠", "FAIL": "✗"}.get(r.status, "?")
        lat = f" ({r.latency_ms}ms)" if r.latency_ms else ""
        print(f"  {color}{icon} {r.name:<15}{RESET} {color}{r.status}{RESET}{lat}")
        print(f"    └ {r.detail}")

        for name, status, detail in r.sub_checks:
            c = {
                "OK": GREEN, "WARN": YELLOW,
                "FAIL": RED
            }.get(status, RESET)
            print(f"       {'✓' if status=='OK' else '⚠' if status=='WARN' else '✗'} {name}: {c}{status}{RESET}{detail}")

        if r.status == "FAIL":
            all_ok = False

    print(f"\n{'─'*55}")
    if all_ok:
        print(f"  {GREEN}{BOLD}✅ 모든 서비스 접근 가능 — config.py에 반영하세요{RESET}")
    else:
        print(f"  {RED}{BOLD}❌ 일부 서비스 접근 불가 — 위 항목 확인 필요{RESET}")
        print(f"\n  {YELLOW}config.py 권장 설정 (접근 가능한 서비스만):{RESET}")
        for r in results:
            if r.status in ("OK", "WARN"):
                print(f"    {r.name}: {r.detail}")

    print(f"{'='*55}\n")


# ══════════════════════════════════════════════════════
#  CLI 진입점
# ══════════════════════════════════════════════════════
if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="ClusterMesh 환경 Prometheus/OpenSearch 연결 확인"
    )
    parser.add_argument("--prometheus-url",   default="http://prometheus:9090")
    parser.add_argument("--opensearch-host",  default="opensearch")
    parser.add_argument("--opensearch-port",  type=int, default=9200)
    parser.add_argument("--opensearch-user",  default="admin")
    parser.add_argument("--opensearch-pass",  default="admin")
    parser.add_argument("--opensearch-ssl",   action="store_true")
    parser.add_argument("--index-pattern",    default="logs-*")
    parser.add_argument("--skip-clustermesh", action="store_true",
                        help="cilium/kubectl 명령 스킵")
    parser.add_argument("--verbose", "-v",    action="store_true")
    args = parser.parse_args()

    print(f"\n{BOLD}ClusterMesh 서비스 접근 진단{RESET}")
    print(f"{'='*55}")

    if not args.skip_clustermesh:
        check_clustermesh_status()

    check_prometheus(args.prometheus_url, args.verbose)

    check_opensearch(
        host=args.opensearch_host,
        port=args.opensearch_port,
        user=args.opensearch_user,
        password=args.opensearch_pass,
        use_ssl=args.opensearch_ssl,
        index_pattern=args.index_pattern,
        verbose=args.verbose,
    )

    print_summary()

0개의 댓글