본문 바로가기
C.W.K.
Lesson 02 of 04 · published

킷 템플릿이 설치하고, 레지스트리가 자리를 정한다

~13 min · installer, launchd, tailscale-serve, family-registry, sibling-kit

Level 0밑준비
0 XP0/43 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

이 저장소에선 아무도 안 쓰는 파일

scripts/에 있는 서비스 설치 스크립트는 첫머리부터 자기가 cwkSiblingKit 템플릿에서 생성됐다고 밝혀. 고치려면 킷을 고치고 킷의 동기화 스크립트를 돌리라는 거야. scripts/family-topology.sh, scripts/configure-tailscale-serve.sh, scripts/kit_family_endpoints.py도 마찬가지야. 이 템플릿을 쓰는 가족 엔진은 다 같은 방식으로 설치돼. Sizzle만의 사실은 킷 매니페스트가 저장소별로 들고 있는 템플릿 변수 몇 개로 들어와. 앱 레이블 Sizzle, Serve 스크립트가 쓰는 헬스 경로 /api/health 같은 거. 나머지는 전부 돌 때 읽어. 이게 왜 패키지가 아니라 벤더링한 사본인지는 sibling-kit-quest의 vendor-dont-package 트랙이 설명해. 앱마다 다른 사실을 왜 수정 대신 변수로 넣는지는 같은 퀘스트의 parameterize-never-fork 트랙에 있어.

이 스크립트는 몇 번을 돌려도 결과가 같아. 엔진을 바꾼 뒤에 돌리면 Mac을 체크아웃에 있는 그대로 맞춰 줘. 환경, 의존성, 웹 빌드, launchd 작업, 헬스, tailnet 쌍둥이까지. 체크아웃이라는 말이 중요해. 스크립트는 작업 트리를 보이는 그대로 읽고 git한테는 아무것도 안 물어. 그래서 커밋 안 한 수정도 커밋한 것과 똑같이 배포돼. 돌고 있는 엔진이 origin과 같아야 한다면, 먼저 커밋하고 푸시해.

엉뚱한 Mac부터 거절해

첫 동작은 자기가 돌아도 되는지 레지스트리한테 묻는 거야. kit_family_endpoints.py runs-here cwkSizzle은 포트 원장이 이 Mac의 호스트 프로필을 거쳐 Sizzle을 여기 둔다고 할 때만 0으로 끝나. 멤버 id는 저장소 디렉터리 이름이야. 다른 곳에선 레지스트리가 cwkSizzle을 둔 곳에서만 돈다고 말하고, 아무것도 건드리기 전에 exit 1로 나가. 이 관문의 논리엔 Mac 이름이 없고, 자리 정보는 레지스트리에 있어. 2026-10-22에 레지스트리가 Sizzle을 다른 Mac에 두었을 때도, 관문의 답은 원장을 따라갔어.

이어서 네트워크 사실을 물어. family-topology.sh가 Firelink의 루프백 API에서 Sizzle의 멤버 카드를 받아서 zsh 대입문으로 찍어 줘. KIT_LOOPBACK_URL, KIT_LOOPBACK_PORT, KIT_TAILNET_HTTPS, KIT_HTTPS_PORT, 있으면 tailnet HTTP 주소, 호스트 배치, 그리고 그 호스트가 이 Mac인지까지. 카드에 포트 달린 루프백 주소나 포트 달린 tailnet HTTPS 주소가 없으면 그 자리에서 거절해. 이유는 주석에 아빠 말로 적혀 있어.

하드코딩은 죄악

스크립트에 박아 넣은 포트는 같은 사실의 두 번째 사본이야. 그리고 두 번째 사본은 썩어.

순서대로 보면

  1. 환경. conda-envs/cwk-sizzle.yml로 conda env update --prune을 돌려. 파이썬 3.12, pip, 그리고 버전을 고정한 backend/requirements.txt야(FastAPI, uvicorn, httpx, Pydantic, Pillow, pillow-heif, pytest). Sizzle엔 pyproject.toml이 없어서 pip install -r backend/requirements.txt도 한 번 더 돌아.
  2. 웹 빌드. frontend/에서 npm install과 npm run build. 엔진이 마운트하는 frontend/dist가 여기서 나와.
  3. plist 렌더링. kit_family_endpoints.py render-plist가 임시 파일에 실제 값이 든 사본을 써. @@HOME@@, @@DATA_ROOT@@, 그리고 @@PORT:<member>@@ 토큰이 있으면 그것까지 이 Mac 값으로 바뀌어. 이후 단계는 전부 템플릿 말고 이 사본을 읽어. 로그 디렉터리도 이 사본을 보고 만들어. launchd는 로그 디렉터리를 안 만들어 주고, 로그를 못 여는 작업은 아예 안 뜨거든.
  4. launchd 다시 올리기. 지금 도는 작업의 ExitTimeOut을 읽어(없으면 20초). 렌더링한 plist를 ~/Library/LaunchAgents에 복사하고 bootout을 한 다음, 그 시간에 10초를 더한 만큼 예전 작업이 사라지길 기다려. bootout은 작업이 다 내려가기 전에 돌아오고, uvicorn은 처리 중인 요청을 기다리면서 천천히 꺼지거든. 그 뒤에 bootstrap을 최대 세 번까지 시도해. 이 작업엔 RunAtLoad가 있으니까 bootstrap이 이미 작업을 켠 상태야. 그래서 kickstart -k는 안 붙어. 예전엔 붙어 있었는데, 그게 첫 부팅을 죽였어.
  5. 헬스. 루프백 주소의 /api/health에 2초 간격으로 curl을 최대 15번 보내. 실패하면 렌더링한 plist에 적힌 로그 경로를 알려줘.
  6. tailnet 쌍둥이. configure-tailscale-serve.sh를 돌려.

tailnet 쌍둥이

configure-tailscale-serve.sh도 같은 runs-here 관문을 거치고 같은 토폴로지를 읽어. 먼저 루프백에서 엔진 헬스를 확인하고, 루프백 주소 앞에 tailscale serve --bg --yes --https=$KIT_HTTPS_PORT를 세워(Sizzle은 7601). 마지막으로 tailnet HTTPS 주소로 /api/health를 받아 봐서 쌍둥이가 진짜 답하는지 증명해. HTTP 쌍둥이는 레지스트리가 tailnet HTTP 주소를 선언했고 엔진이 루프백에서만 들을 때만 붙어. 모든 인터페이스에 묶인 엔진은 tailnet HTTP에 직접 답하니까, 그 포트에 Serve 리스너를 세우면 부딪혀. Sizzle은 7601 HTTPS 쌍둥이로 가르치면 돼. 엔진은 127.0.0.1에 묶여 있고, 레지스트리가 Sizzle에 tailnet HTTP를 선언했는지는 이 저장소에 안 남아 있어.

원칙: 설치 스크립트는 알고 있는 게 아니라 묻는 거야. 자리, 포트, 주소, 데이터 루트는 돌 때 레지스트리에서 와. 그러면 서비스를 다른 Mac으로 옮기는 일이 원장 수정 하나가 되고, 같은 스크립트가 예전 Mac에선 거절하고 새 Mac에선 상태를 맞춰.

Code

cwkSizzle, scripts/의 서비스 설치 스크립트: 자리 관문, 토폴로지, 렌더링된 이름들 (원문 그대로, 13-36행)·shell
# This script lives in the repository's scripts/; the repository is its member
# id in the family registry, which owns the engine's port and tailnet URLs.
REPO="${0:A:h:h}"
# Only on the Mac the family registry places this member on, including a
# machine's host profile override (cwkPippa todo #549, Pippa's Ark).
/usr/bin/python3 "$REPO/scripts/kit_family_endpoints.py" runs-here "${REPO:t}" || {
  echo "${0:t} runs only where the family registry places ${REPO:t}" >&2
  exit 1
}
topology="$("$REPO/scripts/family-topology.sh" "${REPO:t}")"
eval "$topology"
ENV_NAME="cwk-sizzle"
ENV_BIN="$HOME/miniconda3/envs/$ENV_NAME/bin"
CONDA="$HOME/miniconda3/bin/conda"
LABEL="com.cwk.sizzle-serve"
DOMAIN="gui/$(id -u)"
PLIST_SRC="$REPO/deploy/$LABEL.plist"
PLIST_DST="$HOME/Library/LaunchAgents/$LABEL.plist"
PORT="$KIT_LOOPBACK_PORT"
HEALTH="${KIT_LOOPBACK_URL}/api/health"
# The family data root comes from the registry through the host profile
# (~/cwk-dbs with none, cwkPippa todo #549).
DATA_DIR="$(/usr/bin/python3 "$REPO/scripts/kit_family_endpoints.py" data-root)/sizzle-db"
cd "$REPO"
cwkSizzle, scripts/family-topology.sh: 스크립트 안의 파이썬이 멤버 카드를 KIT_ 대입문으로 바꾼다 (원문 그대로, 66-89행)·python
urls = {key: (value or "").rstrip("/") for key, value in (urls or {}).items()}
loopback, https, http = urls.get("localhost", ""), urls.get("tailscale_https", ""), urls.get("tailscale_http", "")
for label, url in (("loopback URL", loopback), ("tailnet HTTPS URL", https)):
    if not url or urlsplit(url).port is None:
        refuse(f"declares no {label} with a port")
if http and urlsplit(http).port is None:
    refuse("declares a tailnet HTTP URL without a port")
facts = {
    "KIT_LOOPBACK_URL": loopback,
    "KIT_LOOPBACK_PORT": urlsplit(loopback).port,
    "KIT_TAILNET_HTTPS": https,
    "KIT_TAILNET_HOST": urlsplit(https).hostname,
    "KIT_HTTPS_PORT": urlsplit(https).port,
    "KIT_TAILNET_HTTP": http,
    "KIT_TAILNET_HTTP_PORT": urlsplit(http).port if http else "",
    "KIT_HOST_AFFINITY": affinity,
}
try:
    here = (Path.home() / ".config" / "fleet-label").read_text().strip()
except OSError:
    here = ""
facts["KIT_RUNS_HERE"] = "1" if affinity and here and affinity == here else ""
for name, value in facts.items():
    print(f"{name}={shlex.quote(str(value))}")
cwkSizzle, scripts/configure-tailscale-serve.sh: HTTP 쌍둥이 판단, HTTPS 쌍둥이, 그리고 그 증명 (원문 그대로, 24-48행)·shell
# The registry says whether tailnet HTTP exists; where the engine listens says
# who answers it. An engine on loopback only needs a Serve HTTP twin. One bound
# to every interface answers tailnet HTTP itself, and a Serve listener on its
# port would collide (Bellows, PippaGo, Pulse and QuestKiln bind all interfaces).
# >>> http twin (tests/test_kit_templates.py runs this block alone)
lsof_bin=/usr/sbin/lsof
http_twin=no
if [[ -n "$KIT_TAILNET_HTTP" ]]; then
  listeners="$("$lsof_bin" -nP -iTCP:"$KIT_LOOPBACK_PORT" -sTCP:LISTEN 2>/dev/null \
    | /usr/bin/awk 'NR > 1 && $1 !~ /^([Tt]ailscale|IPNExtens)/ {print $9}' || true)"
  if [[ -n "$listeners" ]] && ! print -r -- "$listeners" | /usr/bin/grep -qvE '^(127\.0\.0\.1|\[::1\]):'; then
    http_twin=yes
  fi
fi
# <<< http twin

# Establish the new secure path before releasing any old same-port HTTPS
# listener; the HTTP route then reclaims the port without an engine rebind.
"$tailscale_bin" serve --bg --yes --https="$KIT_HTTPS_PORT" "$target"
if [[ "$http_twin" == "yes" ]]; then
  "$tailscale_bin" serve --https="$KIT_TAILNET_HTTP_PORT" off >/dev/null 2>&1 || true
  "$tailscale_bin" serve --bg --yes --http="$KIT_TAILNET_HTTP_PORT" "$target"
  /usr/bin/curl --noproxy '*' -fsS --max-time 10 "$KIT_TAILNET_HTTP$health" >/dev/null
fi
/usr/bin/curl --noproxy '*' -fsS --max-time 10 "$KIT_TAILNET_HTTPS$health" >/dev/null

External links

Exercise

네 서비스 하나를 위한 설치 스크립트를 써 봐. 두 번 돌려도 안전하고, 엉뚱한 기계에서 돌려도 안전해야 해. 첫 단계는 자리 확인이야. 이 서비스가 돌면 안 되는 곳에선 0이 아닌 값으로 끝나게 해. launchd plist는 홈 토큰을 넣은 템플릿으로 저장소에 두고, 임시 파일로 렌더링한 다음 이후 단계는 전부 그 사본을 읽게 해. bootout으로 내리고, 작업이 사라질 때까지 기다렸다가 bootstrap해. 마지막은 헬스 확인이야. 몇 번 다시 시도하고, 포기할 땐 로그 경로를 찍어 줘.
Hint
자리 정보는 if 문 안의 호스트 이름 말고, 스크립트가 돌 때 읽을 수 있는 곳에 둬. 작은 JSON 파일 하나면 충분해. 기다리는 부분은 launchctl print gui/$(id -u)/your.label이 실패할 때까지 도는 루프로 짜고, 마감은 작업의 ExitTimeOut보다 길게 잡아.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.