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 os
import secrets
import hashlib
import subprocess
import sys
import time
import urllib.request
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")
TAG = os.environ.get("TAG", "dev")
# 소스 타입별 로컬 컨테이너 스펙 — 관리형과 동일한 인클러스터 시크릿 키(_bind_env_for 입력)를 세팅.
# port = 백엔드 _SVC_PORT 와 동일. img = 공개 오피셜 이미지(로컬 dev 는 파리티보다 기동성 우선).
# 유저명은 백엔드와 동일하게 고정 'appuser'(소스명 파생 금지 — 'root' 등 예약어 충돌·환경 간 값 발산 방지).
APP_USER = "appuser"
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306,
"MONGODB": 27017, "REDIS": 6379}
# 로컬 데이터 소스 = kind '밖'의 **공유** 도커/포드만 컨테이너(타입별 1개) + 프로젝트별 격리 DB.
# 관리형 shared 모델과 동일 계약 — 이미 있는 공유 서버에 <project>_<src> 격리 DB/유저만 민팅(소스 생성비 최소).
# 클러스터(kind)는 앱(비즈니스)만. 앱은 no-selector Service+Endpoints(host=소스명)로 공유 컨테이너의 격리 DB 접속.
_SVC_PORT = {"POSTGRESQL": 5432, "MYSQL": 3306, "MARIADB": 3306, "MONGODB": 27017, "REDIS": 6379}
_IMG = {"POSTGRESQL": "postgres:16-alpine", "MYSQL": "mysql:8", "MARIADB": "mariadb:11",
"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)
MANAGED = "app.kubernetes.io/managed-by=yakcloud-dev"
@ -109,128 +113,129 @@ def ensure_ns(ns: str) -> None:
# ── 소스 자격: 최초 생성 시 랜덤(유저=고정 appuser, db=소스별), 이후 재사용(idempotent) ──────
def get_or_make_creds(ns: str, name: str) -> dict:
"""프로젝트-소스별 비밀번호 — 기존 Secret 이 있으면 재사용(멱등), 없으면 랜덤 생성."""
sec = f"yak-dev-src-{sanitize(name)}"
r = kubectl(["get", "secret", sec, "-n", ns, "-o", "json"], check=False)
if r.returncode == 0:
data = json.loads(r.stdout).get("data", {})
c = {k: base64.b64decode(v).decode() for k, v in data.items()}
c["secret"] = sec # 재사용 경로도 secret 이름을 포함(멱등 재배포 시 KeyError 방지)
return c
return {"user": APP_USER, "password": secrets.token_hex(16),
"db": sanitize(name)[:24] or "app", "secret": sec}
pw = (json.loads(r.stdout).get("data", {}) or {}).get("password")
if pw:
return {"password": base64.b64decode(pw).decode(), "secret": sec}
return {"password": secrets.token_hex(16), "secret": sec}
def _source_container(stype: str, c: dict) -> dict:
"""소스 타입별 컨테이너 정의 + 인클러스터 시크릿 키(관리형과 동일) + '앱-유저 인증' readiness.
def _rt(args: list[str], **kw) -> subprocess.CompletedProcess:
return sh([RUNTIME, *args], **kw)
readiness 를 tcp 가 아니라 **앱 유저 로그인 exec** 으로 게이트한다 — 초기화(유저/DB 생성) 중
포트만 열린 상태를 'Ready' 로 오판해 workload 가 먼저 붙어 인증 실패로 크래시하는 것을 막는다."""
port = _SVC_PORT[stype]
rootpw = c["password"] + "root"
env: list[dict] = []
args: list[str] | None = None
init_cm: dict | None = None
vol_mounts: list[dict] = []
def _dexec(cname: str, shell_cmd: str, check: bool = True) -> subprocess.CompletedProcess:
return _rt(["exec", cname, "sh", "-c", shell_cmd], check=check)
def _dbname(project: str, source: str) -> str:
"""프로젝트별 격리 단위(DB·유저)명 — DB 안전(소문자·영숫자·언더스코어)."""
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":
env = [{"name": "POSTGRES_USER", "value": c["user"]},
{"name": "POSTGRES_PASSWORD", "value": c["password"]},
{"name": "POSTGRES_DB", "value": c["db"]},
{"name": "PGDATA", "value": "/var/lib/postgresql/data/pgdata"}]
probe = ["sh", "-c", 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h 127.0.0.1 -U "$POSTGRES_USER" '
'-d "$POSTGRES_DB" -c "SELECT 1" >/dev/null 2>&1']
elif stype == "MYSQL":
env = [{"name": "MYSQL_USER", "value": c["user"]},
{"name": "MYSQL_PASSWORD", "value": c["password"]},
{"name": "MYSQL_DATABASE", "value": c["db"]},
{"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']
_dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname=\'{unit}\'" | grep -q 1 '
f'|| psql -U postgres -c "CREATE ROLE {unit} LOGIN PASSWORD \'{pw}\'"')
_dexec(cname, f'psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname=\'{unit}\'" | grep -q 1 '
f'|| psql -U postgres -c "CREATE DATABASE {unit} OWNER {unit}"')
elif stype in ("MYSQL", "MARIADB"):
client = "mysql" if stype == "MYSQL" else "mariadb"
sql = (f"CREATE DATABASE IF NOT EXISTS {unit}; "
f"CREATE USER IF NOT EXISTS '{unit}'@'%' IDENTIFIED BY '{pw}'; "
f"GRANT ALL ON {unit}.* TO '{unit}'@'%'; FLUSH PRIVILEGES;")
_dexec(cname, f'{client} -uroot -p{SHARED_ADMIN} -e "{sql}"')
elif stype == "MONGODB":
# 루트 유저(root) + 앱 db 에 앱 유저(appuser) 생성(authSource=db 파리티) via init.js
env = [{"name": "MONGO_INITDB_ROOT_USERNAME", "value": "root"},
{"name": "MONGO_INITDB_ROOT_PASSWORD", "value": rootpw},
{"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}
js = (f'try{{db.getSiblingDB("{unit}").createUser({{user:"{unit}",pwd:"{pw}",'
f'roles:[{{role:"readWrite",db:"{unit}"}}]}})}}catch(e){{if(!/already exists/i.test(e.message))throw e}}')
_dexec(cname, f'mongosh "mongodb://root:{SHARED_ADMIN}@127.0.0.1:27017/admin" --quiet --eval \'{js}\'')
def provision_source(ns: str, req: dict) -> dict | None:
"""requires 소스를 로컬 컨테이너 프로비저닝(idempotent) → 접속정보 반환."""
def provision_source(ns: str, project: str, req: dict) -> dict | None:
"""requires 소스 → 공유 도커 컨테이너 프로젝트별 격리 DB + no-selector Service/Endpoints. 접속정보 반환."""
name = req["name"]
stype = req["type"].upper()
if req.get("mode") == "shared":
warn(f"source '{name}': mode=shared 는 로컬 dev 에서 일반 로컬 컨테이너로 대체(격리 DB 민팅 없음).")
if req.get("mode") == "local":
warn(f"source '{name}': mode=local(인클러스터)는 로컬 dev 에서 공유 컨테이너로 대체됩니다.")
if stype not in SUPPORTED:
die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}). "
f"관리형(deploy/promote)에서는 지원됩니다.")
dep = sanitize(f"src-{name}")
die(f"source '{name}' 타입 {stype} — 로컬 dev 미지원(지원: {', '.join(sorted(SUPPORTED))}).")
svc = sanitize(name)
creds = get_or_make_creds(ns, name)
spec = _source_container(stype, creds)
labels = {"app": dep, "app.kubernetes.io/managed-by": "yakcloud-dev",
"yakcloud.dev/role": "source", "yakcloud.dev/source": svc}
objs: list[dict] = [{
"apiVersion": "v1", "kind": "Secret",
"metadata": {"name": creds["secret"], "namespace": ns, "labels": labels},
"stringData": {"user": creds["user"], "password": creds["password"], "db": creds["db"]},
}]
volumes: list[dict] = []
if spec["init_cm"]:
cm = f"{dep}-init"
objs.append({"apiVersion": "v1", "kind": "ConfigMap",
"metadata": {"name": cm, "namespace": ns, "labels": labels},
"data": {"init.js": spec["init_cm"]["js"]}})
volumes.append({"name": "initdb", "configMap": {"name": cm}})
objs.append({
"apiVersion": "apps/v1", "kind": "Deployment",
"metadata": {"name": dep, "namespace": ns, "labels": labels},
"spec": {"replicas": 1, "selector": {"matchLabels": {"app": dep}},
"strategy": {"type": "Recreate"},
"template": {"metadata": {"labels": labels},
"spec": {"containers": [spec["container"]], "volumes": volumes}}},
})
objs.append({
"apiVersion": "v1", "kind": "Service",
"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"]}
unit = _dbname(project, name)
port = _SVC_PORT[stype]
pw = get_or_make_creds(ns, name)["password"] # 프로젝트-소스별 비밀번호(Secret 재사용=멱등)
cname, ip = _ensure_shared(stype)
if stype == "REDIS":
dbnum = str(int(hashlib.sha1(unit.encode()).hexdigest(), 16) % 16) # DB 번호 격리(0~15)
conn = {"stype": stype, "host": svc, "port": port, "user": "", "password": SHARED_ADMIN, "db": dbnum}
log(f"source '{name}' (REDIS) → 공유 '{cname}' · DB#{dbnum} · 서비스 {svc}:{port}")
else:
_mint(stype, cname, unit, pw)
conn = {"stype": stype, "host": svc, "port": port, "user": unit, "password": pw, "db": unit}
log(f"source '{name}' ({stype}) → 공유 '{cname}' · 격리 DB '{unit}' · 서비스 {svc}:{port}")
labels = {"app.kubernetes.io/managed-by": "yakcloud-dev", "yakcloud.dev/role": "source", "yakcloud.dev/source": svc}
# no-selector Service + 수동 Endpoints → kind 밖 공유 컨테이너 IP 로 라우팅.
apply([
{"apiVersion": "v1", "kind": "Secret",
"metadata": {"name": f"yak-dev-src-{svc}", "namespace": ns, "labels": labels},
"stringData": {"password": pw, "unit": unit}},
{"apiVersion": "v1", "kind": "Service",
"metadata": {"name": svc, "namespace": ns, "labels": labels},
"spec": {"ports": [{"port": port, "targetPort": port}]}},
{"apiVersion": "v1", "kind": "Endpoints",
"metadata": {"name": svc, "namespace": ns, "labels": labels},
"subsets": [{"addresses": [{"ip": ip}], "ports": [{"port": port}]}]},
])
return conn
# ── 바인딩 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,
f"{p}_URL": f"mongodb://{qu}:{qp}@{host}:{port}/{db}?authSource={db}"})
elif st == "REDIS":
out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": "0",
f"{p}_URL": f"redis://:{qp}@{host}:{port}/0"})
out.update({f"{p}_USERNAME": "", f"{p}_PASSWORD": pw, f"{p}_DB": db,
f"{p}_URL": f"redis://:{qp}@{host}:{port}/{db}"})
elif st == "MYSQL":
out.update({f"{p}_USERNAME": user, f"{p}_PASSWORD": pw, f"{p}_DB": 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:
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)
if r.returncode != 0:
return
@ -405,7 +410,7 @@ def cmd_deploy(m: dict) -> None:
ensure_ns(ns)
src_conns: dict[str, dict] = {}
for req in reqs:
conn = provision_source(ns, req)
conn = provision_source(ns, project, req)
if conn:
src_conns[req["name"]] = conn
results = [deploy_workload(project, ns, w, src_conns) for w in wls]