From d19242029f7abd0f64374514483e5000fe40a3da Mon Sep 17 00:00:00 2001 From: yakenator Date: Thu, 27 Aug 2026 18:43:37 +0900 Subject: [PATCH] =?UTF-8?q?cli=20v0.5.0:=20deploy=EA=B0=80=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=A6=AC=EC=BB=A8=EC=8B=A4=20+=20?= =?UTF-8?q?=EC=86=8C=EC=8A=A4=20405=20=EC=95=A1=EC=85=98=ED=98=95=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20+=20--local=20=ED=8F=B4=EB=B0=B1/=EB=9F=AC?= =?UTF-8?q?=EB=84=88=20=ED=94=84=EB=A6=AC=ED=94=8C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20+=20=EC=97=90=EB=9F=AC=20=ED=9E=8C=ED=8A=B8/pyyaml?= =?UTF-8?q?=20=EA=B0=80=EB=93=9C;=20init=EC=97=90=EC=84=9C=20bin/install?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0;=20=EB=AC=B8=EC=84=9C(=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=C2=B7healthz=C2=B7=EB=9F=AC=EB=84=88)=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/yakcloud-deploy/SKILL.md | 4 ++ README.md | 69 +++++++--------------- bin/yakcloud | 77 +++++++++++++++++++++---- scripts/yakcloud_deploy.py | 64 ++++++++++++++++++-- yakcloud.yaml | 39 ++++++++----- 5 files changed, 173 insertions(+), 80 deletions(-) diff --git a/.claude/skills/yakcloud-deploy/SKILL.md b/.claude/skills/yakcloud-deploy/SKILL.md index 00665eb..265ac08 100644 --- a/.claude/skills/yakcloud-deploy/SKILL.md +++ b/.claude/skills/yakcloud-deploy/SKILL.md @@ -111,3 +111,7 @@ git tag v0.1.0 && git push origin v0.1.0 # v* 태그만 배포 트리거(일 | `docker create` 실패(잡 시작 안 됨) | 러너 config에서 docker.sock **중복 마운트** → act 기본 마운트만(수동 제거) | | 배포 중 `429 RATE_LIMITED` | CLI가 `retryAfterSec` 백오프로 재시도(내장) | | 다른 러너가 잡 가져감 | 라벨 충돌 → 전용 라벨(`runs-on`) 사용 | +| 소스 프로비저닝 `405` | 배포 토큰엔 소스 **생성 권한 없음** → 콘솔에서 소스 만들고 `requires[].name`·`binds[].source`를 그 소스명으로. `yakcloud project check`가 미리 경고(READY 소스 목록 출력) | +| 태그 push 했는데 아무 일도 안 남 | 레포에 **러너 0개**(잡이 조용히 큐잉) → `yakcloud project deploy`가 배포 전 러너 프리플라이트 경고. 러너 없으면 `yakcloud project deploy --local`(로컬 빌드·push 후 API 배포) | +| 헬스체크 `/healthz` 404/이상 | 엣지가 `/healthz`를 **가로챔** → 배포 검증은 앱 실제 경로(`/`)로. 컨테이너 readiness엔 여전히 `health:` 사용 OK | +| `ModuleNotFoundError: yaml`(직접 배포) | `pip install pyyaml`(또는 `pip3 install --break-system-packages pyyaml`). CI 이미지엔 포함됨 | diff --git a/README.md b/README.md index cfe2ab4..d39a6e3 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,29 @@ -# yakcloud-starter — YakCloud 프로젝트 기반 + CLI + Claude Code 스킬 +# yakcloud 스타터 (프레임워크 중립) -빈 폴더에서 **한 명령으로 프로젝트를 초기화**하고, **태그 push로 자동 배포**하며, **배포 환경을 CLI로 상세 설정**한다. -프레임워크 중립 스타터 앱 + CI + 매니페스트 + **`yakcloud` CLI** + **Claude Code 스킬**(`.claude/skills/yakcloud-deploy/`) 포함. +의존성 0의 최소 HTTP 서버. **배포 계약**만 지키면 어떤 스택으로 바꿔도 된다: +- `PORT` 환경변수로 리슨(기본 8080) +- `GET /healthz` → 200 (매니페스트 `health` 경로) +- (선택) 바인딩된 소스는 `_URL` 등 env로 접속 -## CLI 설치 (1회) +## 로컬 실행 ```sh -curl -fsSL https://gitea.yakenator.io/yakenator/yakcloud-starter/raw/branch/main/install.sh | sh -export PATH="$HOME/.local/bin:$PATH" +PORT=8080 python3 app.py +curl localhost:8080/healthz # {"ok":true} +curl localhost:8080/ # 앱 정보 + 연결된 소스 키 ``` -## 빈 폴더 → 배포까지 -```sh -yakcloud project init my-app # 스타터+CI+매니페스트+Claude 스킬 스캐폴딩 -cd my-app -# (앱 개발 — Claude Code: "yakcloud 로 배포 붙여줘") -yakcloud project info # 프로젝트 개괄(매니페스트 + 라이브 상태) -yakcloud project deploy # 커밋 + 태그 push (버전 미지정 시 자동 버전업) → 자동 배포 +## 매니페스트 매핑 (`yakcloud.yaml`) +```yaml +workloads: + - name: web + build: ./ # 이 디렉터리(Dockerfile)를 CI 가 빌드 + image: gitea.yakenator.io//-web:${TAG} + port: 8080 + health: /healthz + binds: + - { alias: db, source: appdb } # → 앱에서 os.environ["DB_URL"] 로 접속 ``` -> **Claude Code 흐름**: 빈 폴더에서 "프로젝트 초기화해줘" → `yakcloud project init` → 개발 → "yakcloud에 배포해줘" → `yakcloud project deploy`. -## 명령 -``` -project init [name] 빈 폴더 스캐폴딩 -project deploy [vX.Y.Z] 커밋+태그 push → 자동 배포 (버전 생략 시 최신 태그 patch+1 자동 버전업) -project info 프로젝트 개괄(매니페스트 + 라이브 상태) -project check dry-run(직접 API 계획만) -─ 배포환경 설정(앱별): 매니페스트 수정 + 배포중이면 재빌드 없이 라이브 반영 ─ -domain [wl] 도메인 등록 + 워크로드 할당(관리형=즉시 활성) -scale replicas -set --image/--port/--health/--cpu/--mem/--path/--rewrite -env KEY=VAL … [--secret KEY] [--unset KEY] -source add [plan] | source rm -bind | unbind -``` -필요 env: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`(배포토큰 yakd_…), (선택) `YAKCLOUD_CLUSTER` - -## 배포 전 1회 준비 -1. 콘솔 → 설정 → **배포 토큰** 발급(`yakd_…`). -2. Gitea 레포 만들고 `git remote add origin …`. **Secrets**: `YAKCLOUD_URL`, `YAKCLOUD_TOKEN`, `YAKCLOUD_CLUSTER`, `REGISTRY_TOKEN`. -3. `yakcloud.yaml` 의 `cluster`·`image`(``) 수정. - -## 구성 -``` -bin/yakcloud # CLI 디스패처 -install.sh # CLI 설치 -scripts/yakcloud_deploy.py # 리컨실 배포 CLI(매니페스트→콘솔 API) -scripts/yakcloud_ctl.py # info/설정 엔진(매니페스트 편집 + 라이브 PATCH) -.claude/skills/yakcloud-deploy/ # Claude Code 스킬 -app.py Dockerfile # 프레임워크 중립 스타터 -yakcloud.yaml # 배포 매니페스트 -.gitea/workflows/deploy.yml # 태그 push CI(매니페스트 구동) -DATA-SOURCES.md # 소스별 주입 env 표 -``` +## 다른 스택으로 교체 +`app.py`(+`Dockerfile`)를 원하는 언어/프레임워크로 교체하되 위 3계약 유지. +소스별 주입 env 전체 목록은 `../reference/DATA-SOURCES.md` 참고. diff --git a/bin/yakcloud b/bin/yakcloud index ff68ea1..a6b2f2b 100755 --- a/bin/yakcloud +++ b/bin/yakcloud @@ -18,7 +18,7 @@ set -uo pipefail # -e 미사용: 'test && action' 관용구가 값 없을 때 REPO="${YAKCLOUD_STARTER_REPO:-https://gitea.yakenator.io/yakenator/yakcloud-starter}" BRANCH="${YAKCLOUD_STARTER_BRANCH:-main}" -VERSION="0.4.0" +VERSION="0.5.0" CONFIG_DIR="${YAKCLOUD_CONFIG_DIR:-$HOME/.config/yakcloud}" CONFIG_FILE="$CONFIG_DIR/config" @@ -147,6 +147,8 @@ cmd_project_init() { rm -rf "$tmp/s/.git" cp -R "$tmp/s/." . rm -rf "$tmp" + # CLI 배포 파일(전역 yakcloud 사용)은 사용자 프로젝트에 불필요 → 스캐폴드에서 제거. + rm -rf bin install.sh [ -n "$name" ] && [ -f yakcloud.yaml ] && { sed -i.bak "s/^project: .*/project: $name/" yakcloud.yaml && rm -f yakcloud.yaml.bak; } git rev-parse --git-dir >/dev/null 2>&1 || git init -q echo "✓ 스캐폴딩 완료 ($(pwd))" @@ -192,28 +194,77 @@ _maybe_login_prompt() { } cmd_project_deploy() { + local local_mode=0 ver="" + for a in "$@"; do case "$a" in --local) local_mode=1 ;; *) [ -z "$ver" ] && ver="$a" ;; esac; done [ -f yakcloud.yaml ] || die "yakcloud.yaml 없음 — 'yakcloud project init' 먼저" + if [ "$local_mode" = 1 ]; then cmd_project_deploy_local "$ver"; return; fi git rev-parse --git-dir >/dev/null 2>&1 || git init -q git remote get-url origin >/dev/null 2>&1 \ - || die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin ' + Secrets 설정 후 다시" + || die "git 원격(origin) 없음 — Gitea 레포 만들어 'git remote add origin ' + Secrets 설정 후 다시 (또는 'yakcloud project deploy --local')" git fetch -q --tags origin 2>/dev/null || true - # 버전: 인자 있으면 그것, 없으면 최신 v* 태그에서 패치 +1(없으면 v0.1.0) — 배포마다 자동 버전업. - local ver="${1:-}" if [ -z "$ver" ]; then local last; last="$(git tag -l 'v*' --sort=-v:refname 2>/dev/null | head -1)" - if [ -n "$last" ]; then - ver="$(printf '%s' "$last" | awk -F. 'BEGIN{OFS="."} {$NF=$NF+1; print}')" - else - ver="v0.1.0" - fi + if [ -n "$last" ]; then ver="$(printf '%s' "$last" | awk -F. 'BEGIN{OFS="."} {$NF=$NF+1; print}')"; else ver="v0.1.0"; fi fi case "$ver" in v*) : ;; *) ver="v$ver" ;; esac + _runner_preflight # 러너 없으면 경고(비차단) info "배포 버전: $ver (커밋 + 태그 push)" git add -A; git commit -q -m "deploy $ver" 2>/dev/null || true git push -q origin HEAD 2>/dev/null || true git tag "$ver" 2>/dev/null || die "태그 $ver 이미 존재 — 'yakcloud project deploy <다음버전>'" git push -q origin "$ver" - echo "✓ $ver push — Gitea Actions 에서 빌드·배포. 콘솔 앱 탭/도메인 확인." + echo "✓ $ver push — Gitea Actions 에서 빌드·배포. Actions 탭에서 결과 확인." + echo " 실패/러너 없음이면: yakcloud project deploy --local (러너 없이 로컬 빌드/푸시/리컨실)" +} + +# 러너 프리플라이트(best-effort) — origin 이 Gitea 면 레포 러너 유무 확인. 없으면 경고(비차단). +_runner_preflight() { + local url base owner_repo n + url="$(git remote get-url origin 2>/dev/null)" || return 0 + base="${GITEA_URL:-https://gitea.yakenator.io}" + case "$url" in *"${base#https://}"*|*gitea.yakenator.io*) : ;; *) return 0 ;; esac + [ -n "${GITEA_TOKEN:-}" ] || return 0 + owner_repo="$(printf '%s' "$url" | sed -E 's#^[a-z]+://[^@]*@?[^/]+/##; s#\.git$##')" + n="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" "$base/api/v1/repos/$owner_repo/actions/runners" 2>/dev/null \ + | python3 -c 'import sys,json +try: + d=json.load(sys.stdin); print(len(d if isinstance(d,list) else d.get("runners",[]))) +except Exception: print(-1)' 2>/dev/null || echo -1)" + if [ "$n" = "0" ]; then + echo " ⚠ 이 레포에 등록된 Actions 러너가 없습니다 — 공유 러너가 라벨을 못 맞추면 태그 push 잡이 조용히 실패할 수 있습니다." + echo " 확실히 하려면: yakcloud project deploy --local" + fi +} + +# CI 없이 로컬에서 build/push/리컨실 — 러너가 없거나 죽었을 때의 확실한 대안. +cmd_project_deploy_local() { + : "${YAKCLOUD_URL:?YAKCLOUD_URL 필요 — 'yakcloud login'}"; : "${YAKCLOUD_TOKEN:?YAKCLOUD_TOKEN 필요 — 'yakcloud login'}" + command -v docker >/dev/null 2>&1 || die "docker 필요(--local 로컬 빌드/푸시)" + ensure_pyyaml + local tag="${1:-}"; [ -z "$tag" ] && tag="dev-$(date +%Y%m%d-%H%M%S)"; case "$tag" in v*|dev-*) : ;; *) tag="v$tag" ;; esac + local reg="${GITEA_URL:-https://gitea.yakenator.io}"; reg="${reg#https://}"; reg="${reg#http://}" + if [ -n "${GITEA_USER:-}" ] && [ -n "${GITEA_TOKEN:-}" ]; then + printf '%s' "$GITEA_TOKEN" | docker login "$reg" -u "$GITEA_USER" --password-stdin >/dev/null 2>&1 \ + || info "docker login 경고($reg) — 레지스트리 push 실패 가능(yakcloud login 확인)" + else + info "Gitea 자격 없음 — 'yakcloud login' 후 --local 권장(레지스트리 push)" + fi + info "로컬 빌드/푸시 (tag=$tag)…" + TAG="$tag" python3 - <<'PY' +import os, subprocess, yaml +tag = os.environ["TAG"]; m = yaml.safe_load(open("yakcloud.yaml")) +for w in m.get("workloads", []) or []: + ctx = w.get("build") + if not ctx: + continue + img = w["image"].replace("${TAG}", tag) + print("▸ build %s: %s -> %s" % (w["name"], ctx, img), flush=True) + subprocess.check_call(["docker", "build", "-t", img, ctx]) + subprocess.check_call(["docker", "push", img]) +PY + info "리컨실 배포(콘솔 API)…" + TAG="$tag" python3 scripts/yakcloud_deploy.py yakcloud.yaml + echo "✓ 로컬 배포 완료(tag=$tag) — CI 없이 직접 리컨실." } cmd_project_check() { @@ -258,14 +309,16 @@ cmd_project_update() { rm -rf .claude/skills/yakcloud-deploy cp -R "$tmp/s/.claude/skills/yakcloud-deploy" .claude/skills/ 2>/dev/null || true rm -rf "$tmp" + rm -rf bin install.sh # 옛 스캐폴드 잔재(CLI 배포 파일) 정리 echo "✓ 최신화 완료 — scripts/·.gitea/·.claude 스킬 갱신(app.py·yakcloud.yaml·Dockerfile 그대로)" } usage() { cat < di wait = 2 time.sleep(max(1, int(wait)) + 1) return api(method, path, body, _retry - 1) - raise SystemExit(f"[api] {method} {path} -> {e.code}: {payload[:400]}") + hint = { + 401: " — 배포 토큰(YAKCLOUD_TOKEN)이 없거나 만료/오류", + 403: " — 이 토큰 권한으로는 불가(엔드포인트 권한 확인)", + 404: " — 경로의 id/이름 확인", + 405: " — 이 배포 토큰/엔드포인트로 허용되지 않는 작업(콘솔에서 처리 필요)", + }.get(e.code, "") + raise SystemExit(f"[api] {method} {path} -> {e.code}{hint}: {payload[:300]}") def unwrap(r): @@ -78,6 +87,10 @@ def cluster_deployments() -> list[dict]: return unwrap(api("GET", f"/clusters/{CLUSTER}/deployments")) or [] +def _ready_sources() -> str: + return ", ".join(s.get("name", "?") for s in cluster_services() if s.get("status") == "READY") or "(없음)" + + def reconcile_source(req: dict) -> str | None: name, stype, plan = req["name"], req["type"].upper(), req.get("plan", "small") match = next((s for s in cluster_services() if s.get("name") == name), None) @@ -85,12 +98,25 @@ def reconcile_source(req: dict) -> str | None: log(f"source '{name}' ({stype}) 이미 READY → 스킵 (id={match['id']})") return match["id"] if DRY: - log(f"source '{name}' ({stype}, {plan}) 없음 → 프로비저닝 예정") + # 배포 토큰으로는 소스 생성이 막힐 수 있으므로, 없으면 미리 경고(실패 예측). + if match: + log(f"source '{name}' 상태={match.get('status')} — READY 대기 필요") + else: + log(f"⚠ source '{name}' 없음 — 배포 토큰으론 생성 불가할 수 있음. " + f"콘솔에서 만들거나 requires[].name/binds[].source 를 기존 READY 소스로 지정. 현재 READY: {_ready_sources()}") return None if not match: - log(f"source '{name}' ({stype}, {plan}) 프로비저닝…") - api("POST", f"/clusters/{CLUSTER}/services", - {"clusterId": CLUSTER, "type": stype, "name": name, "mode": "local", "size": plan}) + log(f"source '{name}' ({stype}, {plan}) 프로비저닝 시도…") + try: + api("POST", f"/clusters/{CLUSTER}/services", + {"clusterId": CLUSTER, "type": stype, "name": name, "mode": "local", "size": plan}) + except SystemExit as e: + # 405/403 등 — 이 토큰으로 생성 불가. 행동 가능한 안내로 전환. + raise SystemExit( + f"소스 '{name}' 자동 생성 불가(배포 토큰 권한). 콘솔에서 소스를 만든 뒤 " + f"requires[].name·binds[].source 를 기존 소스명으로 지정하세요.\n" + f" 현재 READY 소스: {_ready_sources()}\n" + f" (원인: {e})") for _ in range(120): # ~10분 s = next((s for s in cluster_services() if s.get("name") == name), None) if s and s.get("status") == "READY": @@ -102,6 +128,29 @@ def reconcile_source(req: dict) -> str | None: raise SystemExit(f"source '{name}' READY 대기 초과") +def reconcile_domain(fqdn: str) -> None: + """워크로드 expose host 를 도메인 레지스트리에 리컨실 — 없으면 등록. + 관리형 도메인(yakenator.io/openrepublic.club/sapiens.inc 등)은 즉시 ACTIVE, 외부는 TXT 검증 안내.""" + if not fqdn or fqdn == CLUSTER_HOST: + return # 클러스터 기본 도메인은 이미 등록·라우팅됨 + doms = unwrap(api("GET", f"/clusters/{CLUSTER}/domains")) or [] + if any(d.get("fqdn") == fqdn for d in doms): + log(f"domain '{fqdn}' 이미 등록됨") + return + if DRY: + log(f"domain '{fqdn}' 없음 → 등록 예정(관리형=즉시 ACTIVE, 외부=TXT 검증)") + return + try: + d = unwrap(api("POST", f"/clusters/{CLUSTER}/domains", {"fqdn": fqdn})) + except SystemExit as e: + log(f"⚠ domain '{fqdn}' 등록 실패 — {e}") + return + log(f"domain '{fqdn}' 등록: status={d.get('status')} cert={d.get('certStatus')}") + v = d.get("verify") + if v: + log(f' 외부 도메인 — DNS 에 TXT 추가 후 검증: {v["host"]} TXT "{v["value"]}"') + + def deploy_workload(w: dict) -> tuple[str | None, bool]: image = w["image"].replace("${TAG}", TAG) ex = w.get("expose", {}) or {} @@ -126,6 +175,9 @@ def deploy_workload(w: dict) -> tuple[str | None, bool]: env = [{"key": k, "value": v} for k, v in env.items()] body["env"] = [{"key": e["key"], "value": str(e.get("value", "")), "secret": bool(e.get("secret", False))} for e in env] + # 도메인 리컨실 — expose 에 명시한 host(들)를 레지스트리에 등록(없으면). 배포 하나로 '도달 가능한 앱'이 되게. + for h in (ex.get("hosts") or ([ex["host"]] if ex.get("host") else [])): + reconcile_domain(h) if DRY: log(f"deploy '{w['name']}' 예정: image={image} port={body['port']} path={body['path']} " f"replicas={body['replicasDesired']} hosts={hosts or body.get('exposeHost')}") diff --git a/yakcloud.yaml b/yakcloud.yaml index 58680da..d625c69 100644 --- a/yakcloud.yaml +++ b/yakcloud.yaml @@ -1,24 +1,33 @@ -# YakCloud 선언적 배포 매니페스트 — 스타터(최소, 데이터소스 없이 단독 배포 가능) -# 태그(v*) push → Gitea Actions → build/push → 리컨실 배포. 자세한 절차: .claude/skills/yakcloud-deploy/SKILL.md +# YakCloud 선언적 배포 매니페스트 (범용 템플릿) +# 태그(v*) push → Gitea Actions → 이 매니페스트로 배포: +# 1) requires 리컨실 — 논리 이름의 소스가 READY 면 스킵, 없으면 type/plan 대로 프로비저닝 후 Ready 대기 +# 2) workloads 빌드/배포 — build 컨텍스트를 image 로 빌드·push, 각 워크로드가 소스를 alias 로 바인딩 +# 3) 앱 탭 등록 + 데이터소스 연결(_URL 등 env 자동 주입) apiVersion: yakcloud/v1 -project: yakcloud-starter +project: my-app # 프로젝트 이름(이미지 경로/표시에 사용) — 소문자·숫자·하이픈 -# 배포 대상 클러스터 — 이름 또는 콘솔 id 로 변경. (YAKCLOUD_CLUSTER 시크릿이 있으면 그게 우선) +# 배포 대상 클러스터 — 이름 또는 콘솔 id. (우선순위: YAKCLOUD_CLUSTER 시크릿 > 이 필드) cluster: my-service-19 -# 데이터 소스 필요하면 추가(없으면 빈 목록): -# - { name: appdb, type: postgresql, plan: small } -requires: [] +# 필요한 데이터 소스(논리 이름). 없으면 [] 로 둬도 됨. +# type ∈ postgresql·mysql·mariadb·mongodb·redis·minio·rabbitmq·solr·oracle, plan ∈ small·medium·large +# ⚠ 이름이 클러스터의 기존 READY 소스와 일치하면 스킵·바인딩. 없을 때 자동 프로비저닝은 +# "배포 토큰에 소스 생성 권한이 있을 때"만 됩니다(없으면 405). 권한이 없으면 콘솔에서 소스를 +# 먼저 만들고 이 name 을 그 소스명으로 지정하세요. ('yakcloud project check' 로 미리 확인) +requires: + - { name: appdb, type: postgresql, plan: small } +# 워크로드(앱 컨테이너). CI 는 각 workload.build 를 도커 빌드해 image 로 push 한다(${TAG}=릴리스 태그). workloads: - name: web - build: ./ # 루트 Dockerfile 을 CI 가 빌드 - image: gitea.yakenator.io/yakenator/yakcloud-starter-web:${TAG} # /-web 로 변경 + build: ./ # 도커 빌드 컨텍스트(Dockerfile 위치). 예: ./ 또는 ./api + image: gitea.yakenator.io/CHANGE_ME_GITEA_USER/my-app-web:${TAG} port: 8080 - replicas: 2 # ≥2 → 무중단 롤링 + replicas: 2 # ≥2 → 무중단 롤링(readiness 게이트) health: /healthz - resources: { cpu: 25m, mem: 64Mi } - expose: { path: /, rewrite: false } # host: <등록도메인> 지정 시 그 도메인으로 노출. 여러 개는 hosts: [a, b] - # 데이터 소스를 쓰면 requires 에 추가하고 아래처럼 바인딩(앱은 DB_URL 등으로 접속): - # binds: - # - { alias: db, source: appdb } + resources: { cpu: 25m, mem: 96Mi } + # host 지정 시 등록 도메인으로 노출(미지정=클러스터 기본 도메인). 여러 개는 hosts: [a, b] + expose: { path: /, rewrite: false } + binds: + # source=위 requires 이름, alias=env 프리픽스(대문자화). 예 alias=db → DB_URL/DB_HOST/DB_PORT/… + - { alias: db, source: appdb }