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:
2026-08-29 19:33:45 +09:00
parent 761666aade
commit da1911a036
3 changed files with 190 additions and 22 deletions

View File

@ -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 []