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

View File

@ -41,6 +41,8 @@ CTX = os.environ.get("YAK_DEV_CONTEXT", "kind-yak-dev")
KIND_CLUSTER = os.environ.get("YAK_DEV_CLUSTER", "yak-dev")
RUNTIME = os.environ.get("YAK_DEV_RUNTIME", "docker")
HOST_SUFFIX = os.environ.get("YAK_DEV_HOST_SUFFIX", "dev.localhost")
DEV_NAME = os.environ.get("YAK_DEV_NAME", "dev") # 여러 dev 클러스터 구분(에이전트 pidfile 키)
HTTP_PORT = os.environ.get("YAK_DEV_HTTP_PORT", "80") # 인그레스 host 포트(이름 붙은 클러스터는 비80)
TAG = os.environ.get("TAG", "dev")
# 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB.
@ -364,6 +366,12 @@ def _wl_hosts(project: str, w: dict) -> list:
return hosts or [f"{project}.{HOST_SUFFIX}"]
def _disp_hosts(project: str, w: dict) -> list:
"""콘솔 표시용 호스트 — 비80 인그레스 포트면 host:port(인그레스 규칙 자체엔 포트 안 붙임)."""
sfx = "" if HTTP_PORT == "80" else f":{HTTP_PORT}"
return [h + sfx for h in _wl_hosts(project, w)]
def _report_to_console(project: str, reqs: list, wls: list) -> None:
"""dev deploy 결과(소스·워크로드 요약)를 콘솔에 best-effort 리포트 — LOCAL 클러스터 데이터소스·앱 탭 렌더용.
로그인(URL+TOKEN)+등록명(YAK_REG_NAME) 없으면 조용히 스킵(오프라인 dev 불변식)."""
@ -378,9 +386,9 @@ def _report_to_console(project: str, reqs: list, wls: list) -> None:
"sources": [{"name": r["name"], "type": str(r.get("type", "")).upper(),
"secret": f"yak-dev-src-{sanitize(r['name'])}"} for r in reqs],
"workloads": [{"name": sanitize(w["name"]), "image": w["image"].replace("${TAG}", TAG),
"hosts": _wl_hosts(project, w), "port": int(w.get("port", 8080))} for w in wls],
"hosts": _disp_hosts(project, w), "port": int(w.get("port", 8080))} for w in wls],
}
body = json.dumps({"name": name, "displayName": "로컬 개발 (kind)", "report": report}).encode()
body = json.dumps({"name": name, "displayName": name, "report": report}).encode()
req = urllib.request.Request(url.rstrip("/") + "/api/v1/clusters/local", data=body, method="POST",
headers={"Authorization": "Bearer " + tok, "content-type": "application/json"})
try:
@ -398,7 +406,8 @@ def _agent_dir() -> str:
def _agent_pidfile(ns: str) -> str:
return os.path.join(_agent_dir(), f"{ns}.pid")
# 클러스터별 구분 — 서로 다른 dev 클러스터가 같은 ns(프로젝트)를 가져도 pidfile 이 안 겹치게.
return os.path.join(_agent_dir(), f"{DEV_NAME}-{ns}.pid")
def _stop_agent(ns: str) -> None:
@ -493,8 +502,9 @@ def cmd_deploy(m: dict) -> None:
log("✓ 로컬 배포 완료(직선 dev). 접속:")
first_host = results[0][0] if results else None
if first_host:
print(f" http://{first_host}/ (kind 인그레스 80 포트)")
print(f" curl -s http://{first_host}/ | python3 -m json.tool")
psfx = "" if HTTP_PORT == "80" else f":{HTTP_PORT}"
print(f" http://{first_host}{psfx}/ (kind 인그레스 포트 {HTTP_PORT})")
print(f" curl -s http://{first_host}{psfx}/ | python3 -m json.tool")
print(f" ↑ 응답의 bound_sources 에 바인딩 소스 <ALIAS>_URL 키가 보이면 직선 파리티 성립")
print(f" kubectl --context {CTX} -n {ns} get deploy,svc,ing,pods")
print(f" 승격: yakcloud project deploy --local <tag> (레지스트리 push) → yakcloud project promote --to val")