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 []
|
||||
|
||||
@ -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")
|
||||
|
||||
Reference in New Issue
Block a user