feat(dev): 다중 로컬 dev 클러스터(--name/--port) + 노드 리소스 사용현황 릴레이
`yakcloud dev up --name dev2 --port 8081` 로 머신에 여러 kind dev 클러스터(공유 소스 컨테이너 재사용). agent 가 노드 CPU/MEM/DISK(예약·실측)를 릴레이 → 콘솔이 원격과 동일한 노드 리소스 카드로 표시. dev up 이 metrics-server 자동 설치(실측용). 리뷰 반영(pidfile 정리·포트 범위·phase·디스크). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -272,7 +272,111 @@ def _kubectl(argv: list[str]) -> str:
|
||||
return r.stdout
|
||||
|
||||
|
||||
def _cpu_m(v) -> int:
|
||||
"""CPU 수량 → millicores. '25m'→25, '1'→1000, '1.5'→1500."""
|
||||
if not v:
|
||||
return 0
|
||||
v = str(v)
|
||||
if v.endswith("m"):
|
||||
try:
|
||||
return int(float(v[:-1]))
|
||||
except ValueError:
|
||||
return 0
|
||||
if v.endswith("n"): # nanocores(top 일부)
|
||||
return int(float(v[:-1]) / 1_000_000)
|
||||
try:
|
||||
return int(float(v) * 1000)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _mem_b(v) -> int:
|
||||
"""메모리 수량 → bytes. Ki/Mi/Gi/Ti + K/M/G 접미사."""
|
||||
if not v:
|
||||
return 0
|
||||
v = str(v).strip()
|
||||
units = {"Ki": 1024, "Mi": 1024**2, "Gi": 1024**3, "Ti": 1024**4,
|
||||
"K": 1000, "M": 1000**2, "G": 1000**3, "T": 1000**4}
|
||||
for u, mult in units.items():
|
||||
if v.endswith(u):
|
||||
try:
|
||||
return int(float(v[:-len(u)]) * mult)
|
||||
except ValueError:
|
||||
return 0
|
||||
try:
|
||||
return int(float(v))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _h_kube(action: str, params: dict) -> dict:
|
||||
if action == "usage":
|
||||
nodes_j = json.loads(_kubectl(["get", "nodes", "-o", "json"]) or "{}").get("items", [])
|
||||
pods_j = json.loads(_kubectl(["get", "pods", "-A", "-o", "json"]) or "{}").get("items", [])
|
||||
# 노드별 예약(스케줄된 비종료 파드 컨테이너 requests 합)
|
||||
req = {}
|
||||
for p in pods_j:
|
||||
nn = (p.get("spec", {}) or {}).get("nodeName")
|
||||
if not nn or (p.get("status", {}) or {}).get("phase") in ("Succeeded", "Failed"):
|
||||
continue
|
||||
acc = req.setdefault(nn, [0, 0])
|
||||
for c in (p.get("spec", {}) or {}).get("containers", []) or []:
|
||||
rq = ((c.get("resources", {}) or {}).get("requests", {}) or {})
|
||||
acc[0] += _cpu_m(rq.get("cpu"))
|
||||
acc[1] += _mem_b(rq.get("memory"))
|
||||
# 실측(metrics-server 있으면): kubectl top nodes
|
||||
top = {}
|
||||
r = subprocess.run(["kubectl", "--context", CTX, "top", "nodes", "--no-headers"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
for ln in r.stdout.splitlines():
|
||||
pt = ln.split()
|
||||
if len(pt) >= 5: # NAME CPU(cores) CPU% MEM MEM%
|
||||
top[pt[0]] = {"cpu": pt[1], "cpu_pct": pt[2].rstrip("%"), "mem": pt[3], "mem_pct": pt[4].rstrip("%")}
|
||||
out = []
|
||||
for n in nodes_j:
|
||||
meta, st = n.get("metadata", {}) or {}, n.get("status", {}) or {}
|
||||
name = meta.get("name")
|
||||
labels = meta.get("labels", {}) or {}
|
||||
roles = [k.split("/", 1)[1] for k in labels if k.startswith("node-role.kubernetes.io/")]
|
||||
cap, alloc = st.get("capacity", {}) or {}, st.get("allocatable", {}) or {}
|
||||
cpu_alloc = round(_cpu_m(alloc.get("cpu")) / 1000.0, 2)
|
||||
mem_alloc = round(_mem_b(alloc.get("memory")) / (1024**3), 2)
|
||||
rq = req.get(name, [0, 0])
|
||||
cpu_req = round(rq[0] / 1000.0, 2)
|
||||
mem_req = round(rq[1] / (1024**3), 2)
|
||||
ready = any(c.get("type") == "Ready" and c.get("status") == "True"
|
||||
for c in (st.get("conditions", []) or []))
|
||||
t = top.get(name, {})
|
||||
# 디스크 실측 — kubelet Summary API(metrics-server 불필요, kind 지원). 원격(백엔드)과 동일 필드.
|
||||
disk = disk_pct = disk_cap = None
|
||||
ds = subprocess.run(["kubectl", "--context", CTX, "get", "--raw",
|
||||
f"/api/v1/nodes/{name}/proxy/stats/summary"], capture_output=True, text=True)
|
||||
if ds.returncode == 0:
|
||||
try:
|
||||
fs = ((json.loads(ds.stdout).get("node", {}) or {}).get("fs", {})) or {}
|
||||
used, capb = fs.get("usedBytes"), fs.get("capacityBytes")
|
||||
if used is not None and capb:
|
||||
disk = f"{round(used / 1024**3, 1)}Gi"
|
||||
disk_cap = round(capb / 1024**3, 1)
|
||||
disk_pct = str(round(used / capb * 100))
|
||||
except (ValueError, ZeroDivisionError, TypeError):
|
||||
pass
|
||||
out.append({
|
||||
"name": name, "roles": roles or ["control-plane"], "joined": ready, "ready": ready,
|
||||
"pending": False, "maas_status": None, # kind 노드 — MAAS 프로비저닝 개념 없음
|
||||
"phase": "Ready" if ready else "NotReady", # NodeMetricDTO.phase(필수) — 상태 배지
|
||||
"cpu_cap": round(_cpu_m(cap.get("cpu")) / 1000.0, 2), "cpu_alloc": cpu_alloc,
|
||||
"cpu_req": cpu_req, "cpu_req_pct": round(cpu_req / cpu_alloc * 100) if cpu_alloc else None,
|
||||
"cpu": t.get("cpu"), "cpu_pct": t.get("cpu_pct"),
|
||||
"mem_cap": round(_mem_b(cap.get("memory")) / (1024**3), 2), "mem_alloc": mem_alloc,
|
||||
"mem_req": mem_req, "mem_req_pct": round(mem_req / mem_alloc * 100) if mem_alloc else None,
|
||||
"mem": t.get("mem"), "mem_pct": t.get("mem_pct"),
|
||||
"disk": disk, "disk_pct": disk_pct, "disk_cap": disk_cap,
|
||||
})
|
||||
return {"metrics_available": bool(top), "nodes": out,
|
||||
"autoscale": {"enabled": False, "min": None, "max": None},
|
||||
"provisioning": False, "history": []}
|
||||
if action == "pods":
|
||||
app = params.get("app") or ""
|
||||
sel = ["-l", f"app={app}"] if app else []
|
||||
|
||||
Reference in New Issue
Block a user