feat(dev): 로컬 dev 소스를 공유 도커 컨테이너+격리 DB로 재작성(클러스터=비즈니스만)

로컬 kind 개발에서 데이터 소스를 타입별 공유 도커 컨테이너(yak-dev-*)로 올리고
프로젝트-소스별 격리 DB·계정(<project>_<source>)을 민팅한다. 클러스터에는
no-selector Service + 수동 Endpoints 로 kind 밖 컨테이너를 가리켜 앱만 배포한다.
관리형 shared 모드와 동일한 바인딩 env 파리티 유지. dev down 시 공유 컨테이너 정리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:32:34 +09:00
parent 2f4d79bc6e
commit 1ce0cf3871
2 changed files with 126 additions and 118 deletions

View File

@ -24,8 +24,10 @@ import base64
import json import json
import os import os
import secrets import secrets
import hashlib
import subprocess import subprocess
import sys import sys
import time
import urllib.request import urllib.request
from urllib.parse import quote from urllib.parse import quote
@ -40,14 +42,16 @@ RUNTIME = os.environ.get("YAK_DEV_RUNTIME", "docker")
HOST_SUFFIX = os.environ.get("YAK_DEV_HOST_SUFFIX", "dev.localhost") HOST_SUFFIX = os.environ.get("YAK_DEV_HOST_SUFFIX", "dev.localhost")
TAG = os.environ.get("TAG", "dev") TAG = os.environ.get("TAG", "dev")
# 소스 타입별 로컬 컨테이너 스펙 — 관리형과 동일한 인클러스터 시크릿 키(_bind_env_for 입력)를 세팅. # 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB.
# port = 백엔드 _SVC_PORT 와 동일. img = 공개 오피셜 이미지(로컬 dev 는 파리티보다 기동성 우선). # 관리형 shared 모델과 동일 계약 — 이미 있는 공유 서버에 <project>_<src> 격리 DB/유저만 민팅(소스 생성비 최소).
# 유저명은 백엔드와 동일하게 고정 'appuser'(소스명 파생 금지 — 'root' 등 예약어 충돌·환경 간 값 발산 방지). # 클러스터(kind)는 앱(비즈니스)만. 앱은 no-selector Service+Endpoints(host=소스명)로 공유 컨테이너의 격리 DB 접속.
APP_USER = "appuser" _SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379}
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306,
"MONGODB": 27017, "REDIS": 6379}
_IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11", _IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11",
"MONGODB": "mongo:7", "REDIS": "redis:7-alpine"} "MONGODB": "mongo:7", "REDIS": "redis:7-alpine"}
_SHARED_CT = {"POSTGRESQL": "yak-dev-postgres", "MYSQL": "yak-dev-mysql", "MARIADB": "yak-dev-mariadb",
"MONGODB": "yak-dev-mongo", "REDIS": "yak-dev-redis"}
SHARED_NET = os.environ.get("YAK_DEV_NET", "kind") # kind 가 쓰는 도커 네트워크
SHARED_ADMIN = "yakdevadmin" # dev 전용 공유 컨테이너 관리자 비밀번호(로컬 한정)
SUPPORTED = set(_SVC_PORT) SUPPORTED = set(_SVC_PORT)
MANAGED = "app.kubernetes.io/managed-by=yakcloud-dev" MANAGED = "app.kubernetes.io/managed-by=yakcloud-dev"
@ -109,128 +113,129 @@ def ensure_ns(ns: str) -> None:
# ── 소스 자격: 최초 생성 시 랜덤(유저=고정 appuser, db=소스별), 이후 재사용(idempotent) ────── # ── 소스 자격: 최초 생성 시 랜덤(유저=고정 appuser, db=소스별), 이후 재사용(idempotent) ──────
def get_or_make_creds(ns: str, name: str) -> dict: def get_or_make_creds(ns: str, name: str) -> dict:
"""프로젝트-소스별 비밀번호 — 기존 Secret 이 있으면 재사용(멱등), 없으면 랜덤 생성."""
sec = f"yak-dev-src-{sanitize(name)}" sec = f"yak-dev-src-{sanitize(name)}"
r = kubectl(["get", "secret", sec, "-n", ns, "-o", "json"], check=False) r = kubectl(["get", "secret", sec, "-n", ns, "-o", "json"], check=False)
if r.returncode == 0: if r.returncode == 0:
data = json.loads(r.stdout).get("data", {}) pw = (json.loads(r.stdout).get("data", {}) or {}).get("password")
c = {k: base64.b64decode(v).decode() for k, v in data.items()} if pw:
c["secret"] = sec # 재사용 경로도 secret 이름을 포함(멱등 재배포 시 KeyError 방지) return {"password": base64.b64decode(pw).decode(), "secret": sec}
return c return {"password": secrets.token_hex(16), "secret": sec}
return {"user": APP_USER, "password": secrets.token_hex(16),
"db": sanitize(name)[:24] or "app", "secret": sec}
def _source_container(stype: str, c: dict) -> dict: def _rt(args: list[str], **kw) -> subprocess.CompletedProcess:
"""소스 타입별 컨테이너 정의 + 인클러스터 시크릿 키(관리형과 동일) + '앱-유저 인증' readiness. return sh([RUNTIME, *args], **kw)
readiness 를 tcp 가 아니라 **앱 유저 로그인 exec** 으로 게이트한다 — 초기화(유저/DB 생성) 중
포트만 열린 상태를 'Ready' 로 오판해 workload 가 먼저 붙어 인증 실패로 크래시하는 것을 막는다.""" def _dexec(cname: str, shell_cmd: str, check: bool = True) -> subprocess.CompletedProcess:
port = _SVC_PORT[stype] return _rt(["exec", cname, "sh", "-c", shell_cmd], check=check)
rootpw = c["password"] + "root"
env: list[dict] = []
args: list[str] | None = None def _dbname(project: str, source: str) -> str:
init_cm: dict | None = None """프로젝트별 격리 단위(DB·유저)명 — DB 안전(소문자·영숫자·언더스코어)."""
vol_mounts: list[dict] = [] raw = f"{project}_{source}".lower()
out = "".join(c if (c.isalnum() or c == "_") else "_" for c in raw).strip("_")
return (out or "app")[:40]
def _container_ip(cname: str) -> str:
r = _rt(["inspect", "-f",
'{{(index .NetworkSettings.Networks "' + SHARED_NET + '").IPAddress}}', cname], check=False)
return (r.stdout or "").strip()
def _ensure_shared(stype: str) -> tuple[str, str]:
"""타입별 '공유' 컨테이너를 kind 네트워크에 보장(idempotent, 관리자 자격) → (이름, IP)."""
cname = _SHARED_CT[stype]
if not _rt(["ps", "-q", "-f", f"name=^{cname}$"], check=False).stdout.strip():
_rt(["rm", "-f", cname], check=False) # 죽은 동명 컨테이너 정리
img = _IMG[stype]
run = ["run", "-d", "--name", cname, "--network", SHARED_NET,
"--label", "yakcloud.dev/shared=1", "--label", f"yakcloud.dev/type={stype}"]
if stype == "POSTGRESQL":
run += ["-e", f"POSTGRES_PASSWORD={SHARED_ADMIN}", img]
ready = "pg_isready -U postgres >/dev/null 2>&1"
elif stype == "MYSQL":
run += ["-e", f"MYSQL_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = f'mysql -uroot -p{SHARED_ADMIN} -e "SELECT 1" >/dev/null 2>&1'
elif stype == "MARIADB":
run += ["-e", f"MARIADB_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = f'mariadb -uroot -p{SHARED_ADMIN} -e "SELECT 1" >/dev/null 2>&1'
elif stype == "MONGODB":
run += ["-e", "MONGO_INITDB_ROOT_USERNAME=root", "-e", f"MONGO_INITDB_ROOT_PASSWORD={SHARED_ADMIN}", img]
ready = (f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet '
'--eval "db.runCommand({ping:1}).ok" | grep -q 1')
else: # REDIS
run += [img, "redis-server", "--requirepass", SHARED_ADMIN]
ready = f'redis-cli -a {SHARED_ADMIN} ping 2>/dev/null | grep -q PONG'
log(f"공유 소스 컨테이너 '{cname}' ({stype}) 기동 ({RUNTIME}, net={SHARED_NET})")
_rt(run, capture=False)
for _ in range(90):
if _dexec(cname, ready, check=False).returncode == 0:
break
time.sleep(2)
else:
die(f"공유 컨테이너 '{cname}' 준비 대기 초과 — '{RUNTIME} logs {cname}' 확인")
ip = _container_ip(cname)
if not ip:
die(f"공유 컨테이너 '{cname}' IP 조회 실패 — 도커 네트워크 '{SHARED_NET}' 확인")
return cname, ip
def _mint(stype: str, cname: str, unit: str, pw: str) -> None:
"""공유 컨테이너에 프로젝트별 격리 DB/유저 생성(idempotent). unit=DB·유저명(영숫자·언더스코어)."""
if stype == "POSTGRESQL": if stype == "POSTGRESQL":
env = [{"name": "POSTGRES_USER", "value": c["user"]}, _dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname=\'{unit}\'" | grep -q 1 '
{"name": "POSTGRES_PASSWORD", "value": c["password"]}, f'|| psql -U postgres -c "CREATE ROLE {unit} LOGIN PASSWORD \'{pw}\'"')
{"name": "POSTGRES_DB", "value": c["db"]}, _dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname=\'{unit}\'" | grep -q 1 '
{"name": "PGDATA", "value": "/var/lib/postgresql/data/pgdata"}] f'|| psql -U postgres -c "CREATE DATABASE {unit} OWNER {unit}"')
probe = ["sh", "-c", 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" ' elif stype in ("MYSQL", "MARIADB"):
'-d "$POSTGRES_DB" -c "SELECT 1" >/dev/null 2>&1'] client = "mysql" if stype == "MYSQL" else "mariadb"
elif stype == "MYSQL": sql = (f"CREATE DATABASE IF NOT EXISTS {unit}; "
env = [{"name": "MYSQL_USER", "value": c["user"]}, f"CREATE USER IF NOT EXISTS '{unit}'@'%' IDENTIFIED BY '{pw}'; "
{"name": "MYSQL_PASSWORD", "value": c["password"]}, f"GRANT ALL ON {unit}.* TO '{unit}'@'%'; FLUSH PRIVILEGES;")
{"name": "MYSQL_DATABASE", "value": c["db"]}, _dexec(cname, f'{client} -uroot -p{SHARED_ADMIN} -e "{sql}"')
{"name": "MYSQL_ROOT_PASSWORD", "value": rootpw}]
probe = ["sh", "-c", 'mysql -h127.0.0.1 -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" '
'"$MYSQL_DATABASE" -e "SELECT 1" >/dev/null 2>&1']
elif stype == "MARIADB":
env = [{"name": "MARIADB_USER", "value": c["user"]},
{"name": "MARIADB_PASSWORD", "value": c["password"]},
{"name": "MARIADB_DATABASE", "value": c["db"]},
{"name": "MARIADB_ROOT_PASSWORD", "value": rootpw}]
probe = ["sh", "-c", 'mariadb -h127.0.0.1 -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" '
'"$MARIADB_DATABASE" -e "SELECT 1" >/dev/null 2>&1']
elif stype == "REDIS":
args = ["redis-server", "--requirepass", c["password"]]
env = [{"name": "REDISCLI_AUTH", "value": c["password"]}]
probe = ["sh", "-c", 'redis-cli ping | grep -q PONG']
elif stype == "MONGODB": elif stype == "MONGODB":
# 루트 유저(root) + 앱 db 에 앱 유저(appuser) 생성(authSource=db 파리티) via init.js js = (f'try{{db.getSiblingDB("{unit}").createUser({{user:"{unit}",pwd:"{pw}",'
env = [{"name": "MONGO_INITDB_ROOT_USERNAME", "value": "root"}, f'roles:[{{role:"readWrite",db:"{unit}"}}]}})}}catch(e){{if(!/already exists/i.test(e.message))throw e}}')
{"name": "MONGO_INITDB_ROOT_PASSWORD", "value": rootpw}, _dexec(cname, f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet --eval \'{js}\'')
{"name": "MONGO_INITDB_DATABASE", "value": c["db"]},
{"name": "MONGO_APP_USER", "value": c["user"]},
{"name": "MONGO_APP_PASSWORD", "value": c["password"]},
{"name": "MONGO_APP_DB", "value": c["db"]}]
js = (f'db.getSiblingDB("{c["db"]}").createUser({{user:"{c["user"]}",'
f'pwd:"{c["password"]}",roles:[{{role:"readWrite",db:"{c["db"]}"}}]}});')
init_cm = {"js": js}
vol_mounts = [{"name": "initdb", "mountPath": "/docker-entrypoint-initdb.d"}]
probe = ["sh", "-c", 'mongosh "mongodb://$MONGO_APP_USER:$MONGO_APP_PASSWORD@127.0.0.1:'
f'{port}/$MONGO_APP_DB?authSource=$MONGO_APP_DB" --quiet '
'--eval "db.runCommand({ping:1}).ok" | grep -q 1']
else: # pragma: no cover
die(f"내부오류: 미지원 타입 {stype}")
cont = {"name": "db", "image": _IMG[stype], "imagePullPolicy": "IfNotPresent",
"ports": [{"containerPort": port}], "env": env,
"readinessProbe": {"exec": {"command": probe}, "initialDelaySeconds": 5,
"periodSeconds": 5, "failureThreshold": 40, "timeoutSeconds": 5},
"volumeMounts": vol_mounts}
if args:
cont["args"] = args
return {"container": cont, "port": port, "init_cm": init_cm, "vol_mounts": vol_mounts}
def provision_source(ns: str, req: dict) -> dict | None: def provision_source(ns: str, project: str, req: dict) -> dict | None:
"""requires 소스를 로컬 컨테이너 프로비저닝(idempotent) → 접속정보 반환.""" """requires 소스 → 공유 도커 컨테이너 프로젝트별 격리 DB + no-selector Service/Endpoints. 접속정보 반환."""
name = req["name"] name = req["name"]
stype = req["type"].upper() stype = req["type"].upper()
if req.get("mode") == "shared": if req.get("mode") == "local":
warn(f"source '{name}': mode=shared 는 로컬 dev 에서 일반 로컬 컨테이너로 대체(격리 DB 민팅 없음).") warn(f"source '{name}': mode=local(인클러스터)는 로컬 dev 에서 공유 컨테이너로 대체됩니다.")
if stype not in SUPPORTED: if stype not in SUPPORTED:
die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}). " die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}).")
f"관리형(deploy/promote)에서는 지원됩니다.")
dep = sanitize(f"src-{name}")
svc = sanitize(name) svc = sanitize(name)
creds = get_or_make_creds(ns, name) unit = _dbname(project, name)
spec = _source_container(stype, creds) port = _SVC_PORT[stype]
labels = {"app": dep, "app.kubernetes.io/managed-by": "yakcloud-dev", pw = get_or_make_creds(ns, name)["password"] # 프로젝트-소스별 비밀번호(Secret 재사용=멱등)
"yakcloud.dev/role": "source", "yakcloud.dev/source": svc} cname, ip = _ensure_shared(stype)
objs: list[dict] = [{ if stype == "REDIS":
"apiVersion": "v1", "kind": "Secret", dbnum = str(int(hashlib.sha1(unit.encode()).hexdigest(), 16) % 16) # DB 번호 격리(0~15)
"metadata": {"name": creds["secret"], "namespace": ns, "labels": labels}, conn = {"stype": stype, "host": svc, "port": port, "user": "", "password": SHARED_ADMIN, "db": dbnum}
"stringData": {"user": creds["user"], "password": creds["password"], "db": creds["db"]}, log(f"source '{name}' (REDIS) → 공유 '{cname}' · DB#{dbnum} · 서비스 {svc}:{port}")
}] else:
volumes: list[dict] = [] _mint(stype, cname, unit, pw)
if spec["init_cm"]: conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "db": unit}
cm = f"{dep}-init" log(f"source '{name}' ({stype}) → 공유 '{cname}' · 격리 DB '{unit}' · 서비스 {svc}:{port}")
objs.append({"apiVersion": "v1", "kind": "ConfigMap", labels = {"app.kubernetes.io/managed-by": "yakcloud-dev", "yakcloud.dev/role": "source", "yakcloud.dev/source": svc}
"metadata": {"name": cm, "namespace": ns, "labels": labels}, # no-selector Service + 수동 Endpoints → kind 밖 공유 컨테이너 IP 로 라우팅.
"data": {"init.js": spec["init_cm"]["js"]}}) apply([
volumes.append({"name": "initdb", "configMap": {"name": cm}}) {"apiVersion": "v1", "kind": "Secret",
objs.append({ "metadata": {"name": f"yak-dev-src-{svc}", "namespace": ns, "labels": labels},
"apiVersion": "apps/v1", "kind": "Deployment", "stringData": {"password": pw, "unit": unit}},
"metadata": {"name": dep, "namespace": ns, "labels": labels}, {"apiVersion": "v1", "kind": "Service",
"spec": {"replicas": 1, "selector": {"matchLabels": {"app": dep}}, "metadata": {"name": svc, "namespace": ns, "labels": labels},
"strategy": {"type": "Recreate"}, "spec": {"ports": [{"port": port, "targetPort": port}]}},
"template": {"metadata": {"labels": labels}, {"apiVersion": "v1", "kind": "Endpoints",
"spec": {"containers": [spec["container"]], "volumes": volumes}}}, "metadata": {"name": svc, "namespace": ns, "labels": labels},
}) "subsets": [{"addresses": [{"ip": ip}], "ports": [{"port": port}]}]},
objs.append({ ])
"apiVersion": "v1", "kind": "Service", return conn
"metadata": {"name": svc, "namespace": ns, "labels": labels},
"spec": {"selector": {"app": dep},
"ports": [{"port": spec["port"], "targetPort": spec["port"]}]},
})
log(f"source '{name}' ({stype}) 프로비저닝 → 서비스 {svc}:{spec['port']}")
apply(objs)
r = kubectl(["rollout", "status", f"deployment/{dep}", "-n", ns, "--timeout=300s"], check=False)
if r.returncode != 0:
die(f"source '{name}' 기동/인증 준비 대기 실패 — 'kubectl --context {CTX} -n {ns} get pods' 확인")
return {"stype": stype, "host": svc, "port": spec["port"],
"user": creds["user"], "password": creds["password"], "db": creds["db"]}
# ── 바인딩 env: 백엔드 _bind_env_for 와 동일(하이픈 alias → 언더스코어 프리픽스 포함) ──────── # ── 바인딩 env: 백엔드 _bind_env_for 와 동일(하이픈 alias → 언더스코어 프리픽스 포함) ────────
@ -245,8 +250,8 @@ def bind_env(alias: str, conn: dict) -> dict:
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
f"{p}_URL": f"mongodb://{qu}:{qp}@{host}:{port}/{db}?authSource={db}"}) f"{p}_URL": f"mongodb://{qu}:{qp}@{host}:{port}/{db}?authSource={db}"})
elif st == "REDIS": elif st == "REDIS":
out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": "0", out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": db,
f"{p}_URL": f"redis://:{qp}@{host}:{port}/0"}) f"{p}_URL": f"redis://:{qp}@{host}:{port}/{db}"})
elif st == "MYSQL": elif st == "MYSQL":
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db, out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": db,
f"{p}_URL": f"mysql://{qu}:{qp}@{host}:{port}/{db}"}) f"{p}_URL": f"mysql://{qu}:{qp}@{host}:{port}/{db}"})
@ -338,7 +343,7 @@ def deploy_workload(project: str, ns: str, w: dict, src_conns: dict) -> tuple[st
# ── 고아 정리: 매니페스트에 없는(라벨된) 소스/워크로드 리소스 삭제(직선 리컨실) ────────────── # ── 고아 정리: 매니페스트에 없는(라벨된) 소스/워크로드 리소스 삭제(직선 리컨실) ──────────────
def _prune(ns: str, role: str, label_key: str, desired: set) -> None: def _prune(ns: str, role: str, label_key: str, desired: set) -> None:
r = kubectl(["get", "deployment,service,secret,configmap,ingress", "-n", ns, r = kubectl(["get", "deployment,service,endpoints,secret,configmap,ingress", "-n", ns,
"-l", f"{MANAGED},yakcloud.dev/role={role}", "-o", "json"], check=False) "-l", f"{MANAGED},yakcloud.dev/role={role}", "-o", "json"], check=False)
if r.returncode != 0: if r.returncode != 0:
return return
@ -405,7 +410,7 @@ def cmd_deploy(m: dict) -> None:
ensure_ns(ns) ensure_ns(ns)
src_conns: dict[str, dict] = {} src_conns: dict[str, dict] = {}
for req in reqs: for req in reqs:
conn = provision_source(ns, req) conn = provision_source(ns, project, req)
if conn: if conn:
src_conns[req["name"]] = conn src_conns[req["name"]] = conn
results = [deploy_workload(project, ns, w, src_conns) for w in wls] results = [deploy_workload(project, ns, w, src_conns) for w in wls]

View File

@ -492,6 +492,9 @@ cmd_dev() {
local rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman" local rt kenv=""; rt="$(_dev_runtime)"; [ "$rt" = podman ] && kenv="KIND_EXPERIMENTAL_PROVIDER=podman"
info "kind 클러스터 '$DEV_CLUSTER' 삭제…" info "kind 클러스터 '$DEV_CLUSTER' 삭제…"
env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료" env $kenv kind delete cluster --name "$DEV_CLUSTER" && echo "✓ 삭제 완료"
# 공유 데이터 소스 컨테이너(yak-dev-*)도 정리(머신 레벨 전체 teardown).
local scs; scs="$("${rt:-docker}" ps -aq -f label=yakcloud.dev/shared=1 2>/dev/null)"
[ -n "$scs" ] && { info "공유 소스 컨테이너 정리…"; echo "$scs" | xargs "${rt:-docker}" rm -f >/dev/null 2>&1; }
_dev_deregister _dev_deregister
;; ;;
*) echo "yakcloud dev <up|deploy [tag]|status|clean|down>"; exit 1 ;; *) echo "yakcloud dev <up|deploy [tag]|status|clean|down>"; exit 1 ;;