← Start here · Infra note

▶ Run it live — 정적 페이지가 서버 앱을 띄우는 원리

HTML 문서는 그저 글자인데, 버튼 하나로 진짜 앱이 페이지 안에서 실행됩니다. 비밀은 페이지가 아니라 런처(launcher) — 요청이 오면 컨테이너를 깨우고, 준비되면 URL을 돌려주고, 놀고 있으면 꺼버리는 작은 API예요.

🧩 구성 요소 3개: docs/run.js(버튼) · infra/launcher/(스포너 API) · k3s의 scale-to-zero Deployment. 배포 절차는 infra/README.md.

1 · 왜 신개념처럼 보이나

"HTML 안에서 실행"은 착시예요 — 실행은 서버에서, 페이지는 깨우고·기다리고·담아 보여줄 뿐. 그런데 그 세 동작이면 정적 문서가 살아있는 데모가 됩니다.

보통 문서 사이트와 데모 서버는 별개죠. 여기선 문서의 버튼이 곧 데모의 전원 스위치입니다. 항상 켜두면 돈과 메모리가 새고, 항상 꺼두면 데모가 죽어있는데 — 필요할 때만 깨우는 게 정답이에요. 서버리스(scale-to-zero)의 아이디어를 교실 크기로 줄인 겁니다.

2 · The flow — 공을 따라가 보세요

초록 공이 가장 흔한 길(클릭→기동→폴링→준비)을 돕니다. 앱이 이미 깨어 있으면 노란 공이 지름길(즉시 ready)로 빠져요.

flowchart LR CLICK(["click: Run it live"]) --> LAUNCH["POST /launch {app}"] LAUNCH --> WARM{"already running?"} WARM -->|no| SCALE["kubectl scale --replicas=1"] SCALE --> POLL["poll GET /status/{app}
every 2.5s"] POLL --> READY["{state: ready, url}"] WARM -->|yes| READY READY --> EMBED(["iframe shows the app
right in this page"]) EMBED -. "idle 20 min → replicas=0" .-> CLICK class LAUNCH core;

점선이 마지막 조각이에요: 아무도 안 쓰면 리퍼(reaper)가 replicas=0으로 되돌립니다 — "쓸 때만 존재하는 앱". 다음 클릭이 다시 깨우니 데모는 영원히 안 죽어요.

3 · 진짜 타임라인 — 콜드 스타트의 15초

위의 흐름도는 어떤 길인지 보여줘요. 이건 언제를 보여줍니다 — 앱이 잠들어 있으면 첫 클릭은 컨테이너가 깨어나는 ~15초를 기다립니다. 그동안 앱 URL은 503을 뱉고, 페이지는 /status를 폴링하며 버팁니다.

sequenceDiagram autonumber participant P as Page · run.js participant L as Launcher participant K as k3s participant A as App pod · Streamlit P->>L: POST /launch {app:"rag"} L->>K: scale deploy/app-rag --replicas=1 L-->>P: {state:"starting"} Note over K,A: cold start ~15s — pull image,
load BGE model, read index.pkl loop poll every 2.5s — gives up after ~2 min P->>L: GET /status/rag L->>K: readyReplicas? K-->>L: 0 — not yet L-->>P: {state:"starting"} end K->>A: pod Ready (readyReplicas = 1) P->>L: GET /status/rag L-->>P: {state:"ready", url} P->>A: iframe src = rag.bit-habit.com A-->>P: the RAG UI, right in the page Note over P,A: idle 20 min → reaper scales back to 0

그래서 "이미 켜져 있을 때"(노란 지름길)는 즉시 뜨지만, 식은 뒤 첫 클릭은 기다림이 정상이에요. 폴링 예산이 2분이라 15초 콜드 스타트는 넉넉히 흡수합니다 — 중간에 포기하지만 않으면요.

4 · 세 조각의 역할

who does what
  • docs/run.js (브라우저) — 버튼을 그리고, /launch를 쏘고, /status를 폴링하고, ready가 오면 iframe으로 그 URL을 페이지 안에 담아 보여줘요. 런처가 없으면 "run it locally"로 안내하는 안전한 기본값.
  • 런처 API (infra/launcher/app.py, ~120줄 Flask) — 허용된 앱 이름만 받아 kubectl scale을 대신 눌러주는 손. 권한은 RBAC으로 이 네임스페이스의 deployment scale만.
  • k3s Deployment (replicas: 0) — 앱의 본체. 0이면 존재하지 않고(비용 0), 1이면 몇 초 만에 살아나요. Ingress가 agent.bit-habit.com으로 연결.
🔒 안전 설계: 런처는 시크릿도 못 읽고 파드도 못 만들어요 — 오직 정해진 deployment의 replicas 숫자만 만질 수 있습니다(5D의 "위험한 도구를 애초에 안 주기"와 같은 원리를 인프라에 적용한 것).

5 · 앱 안에는 뭐가 도나 — RAG를 예로

런처가 깨우는 컨테이너 안에는 진짜 앱이 있어요. 4B의 우승 RAG 앱은 두 국면이에요 — 한 번 색인을 만들어 디스크에 굳히고(offline), 질문마다 그 색인으로 근거를 찾아 Claude가 인용과 함께 답합니다(online). 초록 공이 온라인 질의 경로를 돕니다.

flowchart LR subgraph OFFLINE["build once — offline indexing"] DOCS[("14 CFR corpus
~1,297 pages")] --> CHUNK["chunk_text()"] CHUNK --> EMB1["BGE embed"] EMB1 --> INDEX[("index.pkl
on disk")] end subgraph ONLINE["every question — online query"] QRY(["your question"]) --> EMBQ["BGE embed query"] EMBQ --> RETR["hybrid retrieve
vectors + BM25"] INDEX --> RETR RETR --> CTX["top-k cited chunks"] CTX --> CLAUDE["Claude answers
from the context"] CLAUDE --> ANS(["answer + sources"]) end class RETR core;

디스크의 index.pkl이 두 국면을 잇는 이음매예요 — 컨테이너가 잠들어도(replicas=0) 색인은 이미지 안에 그대로라, 다시 깨면 다시 색인하지 않고 바로 답합니다.

컨테이너 레이아웃 — 왜 rag-starter/와 harness/가 형제여야 하나

앱 코드(rag-starter/)와 공유 라이브러리(harness/)는 서로를 가리킵니다. 그래서 이미지는 레포 레이아웃을 글자 그대로 미러링해야 해요 — 이름 하나만 바꿔도 import가 깨집니다.

flowchart TD SRV[("/srv")] --> RS["rag-starter/
streamlit_app.py · indexer.py"] SRV --> HN["harness/
retrieval.py · recorder.py"] RS -. "indexer.py: parent.parent → finds harness/" .-> HN HN -. "retrieval.py: parent.parent / 'rag-starter' → finds indexer" .-> RS class SRV core;

예전 이미지는 빌드 컨텍스트를 rag-starter/로 잡고 COPY . /srv만 했어요 — harness/는 컨텍스트 바깥 한 칸 위라 이미지에 아예 안 담겼죠. 컨텍스트를 Module_04_rag/로 올려 둘을 형제로 복사하면 양방향 import가 모두 풀립니다.

6 · 함정 — "200인데 크래시"

런처는 파드가 readyReplicas ≥ 1이면 "ready"라고 합니다. 그런데 떠 있는 것과 멀쩡한 것은 다릅니다 — Streamlit은 앱이 import에서 죽어도 그 트레이스백을 HTTP 200으로 렌더해요. 그래서 런처는 "준비됨"이라 외치는데 화면엔 파이썬 에러만 뜹니다.

liveness ≠ correctness
  • 증상 — "run live" 눌러도 홈조차 안 뜸. API key 문제로 보이지만 아님(호출은 시작도 못 함).
  • 진짜 원인 — from harness.recorder import …가 컨테이너에서 ModuleNotFoundError. import 단계 크래시라 앱이 통째로 죽음.
  • 왜 안 잡혔나 — Streamlit이 크래시 페이지를 200으로 서빙 → readyReplicas 기반 헬스체크가 "정상"으로 오판.
  • 교훈 — "포트가 200을 준다"는 "앱이 맞게 돈다"가 아니에요. 진짜 준비 신호는 앱 고유의 헬스 엔드포인트라야 합니다.

이 페이지의 다이어그램들이 곧 그 수리의 지도예요 — 타임라인은 콜드 스타트가 503을 내는 정상 구간을, 레이아웃은 import가 깨진 지점을 보여줍니다.

7 · 터미널 앱을 페이지에 심는 법 (ttyd)

5D의 agent는 웹앱이 아니라 터미널 REPL이에요. 래퍼를 새로 짜는 대신 ttyd로 진짜 터미널을 웹소켓으로 서빙합니다 — 학생이 로컬에서 보는 그 세션 그대로.

flowchart LR BROWSER["iframe in the docs page"] -->|websocket| TTYD["ttyd -W
terminal-over-web, one binary"] TTYD --> REPL["python agent.py
the real classroom artifact"] REPL --> DB[("data.db
read-only SQLite")] class TTYD core;

컨테이너도 학생이 받는 그 zip에서 빌드해요(infra/apps/agent/Dockerfile) — 라이브 데모와 수업 자료가 절대 어긋날 수 없는 구조입니다.

8 · The key idea

정적 페이지 + 작은 스포너 API + scale-to-zero = 문서가 곧 실행 환경. 페이지는 실행하지 않아요 — 깨우고, 기다리고, 담아서 보여줄 뿐입니다.

"HTML 안에서 앱이 실행된다"에서 실제로 코드가 도는 곳은?

페이지가 하는 일은 ① /launch 호출 ② /status 폴링 ③ iframe 표시, 셋뿐이에요. 실행은 k3s가 깨운 컨테이너에서 일어나고, ttyd가 그 터미널을 웹소켓으로 페이지까지 흘려보냅니다. "실행처럼 보이는 것"과 "실행되는 곳"을 가르는 게 이 패턴 이해의 전부예요.