Skip to content

Dev Preview Plane (Bare Process + Signed Preview URL)

FractalOps의 개발 프리뷰(dev preview)는 Daytona 샌드박스 안에서 도는 맨몸(bare) 프로세스로 실행되고, daytona-proxy의 서명된(signed) 프리뷰 URL을 통해 외부로 노출됩니다. 샌드박스 안에는 컨테이너 런타임이 없으며, 개발 루프 안에 compose 기반 빌드·배포(ship) 플레인도 존재하지 않습니다.

이 문서는 in-sandbox 빌드/배포 플레인이 삭제된 이후의 정식 모델입니다. 예전 모델 (샌드박스 안에서 compose 빌드가 클러스터 이미지 빌드와 Dokploy 프리뷰 라우트를 몰아주던 방식)은 더 이상 존재하지 않습니다. 자세한 내력은 git history를 참고하세요.

FractalOps 워크스페이스는 소스 편집, 에이전트, 테스트, 그리고 수명이 짧은 클라이언트 세션을 위한 곳이지 — 축소판 프로덕션 스택이 아닙니다. 샌드박스 안에서 이미지를 빌드하고 배포하던 방식은 썩어버렸습니다(rotted). 그것은 플랫폼 릴리스 파이프라인을 중복으로 갖고, 샌드박스가 절대 가져서는 안 될 컨테이너 런타임을 요구했으며, 내부 개발 루프를 클러스터 빌드 인프라에 묶어버렸습니다.

그래서 docker는 에이전트 샌드박스 안에서 영구적인 exit-127 벽입니다(세션 중에도 stub로 남는 구워진 stub). 개발 프리뷰는 그 대신 프로젝트 자체의 dev 서버를 맨몸 프로세스로 띄우고, 기존 daytona-proxy 게이트웨이로 노출합니다.

Daytona 샌드박스 (docker 없음)
-> 맨몸 dev 서버 프로세스 (vite / next / uvicorn, HOST=0.0.0.0, PORT=<port>)
-> daytona-proxy 서명된 프리뷰 URL (https://{port}-{token}.proxy.monstore.io)
-> 프로젝트별 공개 호스트 (https://<slug>.monstore.io, 라이브일 때)

LIVE 경로의 전체 흐름 — 에이전트가 dev-preview MCP를 호출하면, MCP가 --host 0.0.0.0으로 맨몸 dev 서버를 띄우고, daytona-proxy의 서명된 URL을 발급(mint)해 사용자에게 돌려줍니다.

flowchart LR
  agent["에이전트\n(샌드박스 안)"]
  mcp["dev-preview MCP\nmcp__dev-preview__start\n(identity + project 서버측 바인딩)"]

  subgraph sandbox["Daytona 샌드박스 (docker 없음)"]
    dev["맨몸 dev 서버 프로세스\nvite / next / uvicorn\nHOST=0.0.0.0 PORT=&lt;port&gt;\nsetsid nohup ... &amp; disown"]
  end

  proxy["daytona-proxy 게이트웨이\nHTTP + websocket 리버스 프록시"]
  signed["서명된 프리뷰 URL\nhttps://&#123;port&#125;-&#123;token&#125;.proxy.monstore.io\n(짧고, 무인증, 공유 가능)"]
  user["외부 사용자 / 클라이언트"]

  agent -->|dev_preview_start| mcp
  mcp -->|launch + port가 응답할 때까지 poll| dev
  mcp -->|mint| signed
  dev --> proxy
  proxy --> signed
  signed --> user

  classDef wall fill:#fde8e8,stroke:#c0392b,color:#7b241c;
  docker["docker = exit-127 벽\n(구워진 stub, 절대 런타임 아님)"]:::wall
  sandbox -.->|in-sandbox 빌드/배포 없음| docker
  • 맨몸 프로세스. dev 서버는 분리된(detached) 맨몸 프로세스로 띄워집니다 (setsid nohup ... & disown, stdin은 /dev/null). 0.0.0.0에 바인딩해 프록시가 도달할 수 있게 합니다. 이 launch는 별개의 샌드박스 exec 호출 사이에서도 살아남습니다 — 그래서 나중의 호출이 포트를 poll해서 “응답 중(serving)“임을 확인할 수 있습니다.
  • 서명된 프리뷰 URL. 프록시는 짧고, 외부 공유 가능하고, 무인증인 토큰 호스트 ({port}-{token}.proxy.monstore.io)를 발급합니다. signed=false이면 Keycloak으로 보호되는 {port}-{sandboxId}.proxy... 호스트가 됩니다.
  • 지속(persistent) 백킹 서비스. 데이터베이스, 정적 사이트(vercel-sim) 호스팅, 대형 설비용(big-facility) compose는 프로젝트의 Dokploy 플레인에 남습니다. Dokploy는 더 이상 dev 서버에 쓰이지 않습니다.
관심사소유자
맨몸 dev 서버 launch, listen 대기, URL 발급DaytonaWorkspaceService.start_dev_preview
이미 떠 있는 포트에 대해 프리뷰 URL 발급DaytonaWorkspaceService.mint_preview_url
dev 서버 launch (분리 맨몸 프로세스)DaytonaWorkspaceService.start_dev_server
dev 서버 종료 (marker pkill)DaytonaWorkspaceService.stop_dev_server
프리뷰-프록시 게이트웨이 (HTTP + websocket 리버스 프록시)daytona_preview_gateway
에이전트 셀프서비스dev-preview MCP (mcp__dev-preview__*): dev_preview_start / dev_preview_url / dev_preview_stop

기본 dev 명령은 에이전트가 따로 넘기지 않으면 조직 기본값(pnpm dev, daytona_dev_preview.DEFAULT_DEV_COMMAND)으로 떨어집니다. start_dev_preview는 명령을 결정하고, 띄우고, 포트가 응답할 때까지 poll한 다음 서명된 URL을 발급하며 — {serving, previewUrl, token, port, command, repoDir}를 돌려줍니다.

dev-preview MCP는 identity와 project를 서버측에서 바인딩합니다(프로젝트 slug는 spoof-proof 게이트웨이 X-Authenticated-User identity, 즉 gateway_project_slug에서 옵니다). 그래서 에이전트는 워크스페이스 id나 org id를 파라미터로 절대 넘기지 않습니다. 라이브 샌드박스 id는 ExecutionWorkspaceRepository.current_daytona_sandbox(project_slug=...)로 서버측에서 해석됩니다(그 row의 provider_workspace_id).

실제 메서드 시그니처 (코드 그대로)

Section titled “실제 메서드 시그니처 (코드 그대로)”
DaytonaWorkspaceService.start_dev_preview
# 감지(필요 시) -> launch -> listen 대기 -> 프리뷰 URL 발급
def start_dev_preview(
self,
*,
workspace_id: str,
repo_dir: str = "/workspace",
port: int = 5173,
command: str = "",
env: dict[str, str] | None = None,
signed: bool = True,
wait_seconds: int = 40,
organization_id: str = "",
) -> dict[str, Any]:
... # -> {"serving", "previewUrl", "token", "port", "command", "repoDir"}
# DaytonaWorkspaceService.mint_preview_url
# signed=True -> GET .../ports/{port}/signed-preview-url -> https://{port}-{token}.proxy...
# signed=False -> GET .../ports/{port}/preview-url -> https://{port}-{sandboxId}.proxy...
def mint_preview_url(
self,
*,
workspace_id: str,
port: int,
signed: bool = True,
organization_id: str = "",
) -> DaytonaPreviewUrl:
...

dev 서버 launch는 start_dev_server가 담당합니다. PORTHOST=0.0.0.0을 export하고, setsid nohup ... & disown으로 분리 실행하며, 명령줄에 포트별 marker (fractalops-dev-preview-<port>)를 박아 넣어 stop_dev_serverpkill -f가 정확히 그 프로세스만 죽일 수 있게 합니다. ss는 이미지에 없으므로 “listen 중인가?”는 curl로만 판정합니다 — curl -s -o /dev/null -w '%{http_code}'000이면(연결 거부) 더 기다리고, 어떤 HTTP 코드(2xx/3xx/4xx)든 나오면 누군가 응답 중이라는 뜻입니다.

에이전트가 보는 도구는 단 3개입니다. 모두 identity·project가 서버측에 바인딩되어 있어 repo_dir? / port? / command?만 받습니다.

# 1) dev 서버를 띄우고 공유 가능한 URL을 받는다
mcp__dev-preview__start # repo_dir=/workspace, port=5173 가 기본
{ "repo_dir": "/workspace", "port": 5173, "command": "pnpm dev" }
-> { "status": "ok", "serving": true,
"previewUrl": "https://5173-<token>.proxy.monstore.io",
"token": "<token>", "port": 5173,
"project_slug": "<slug>", "edge_route": { ... } }
# 2) 이미 떠 있는 포트의 서명 URL을 다시 발급
mcp__dev-preview__url # port 필수
{ "port": 5173 }
-> { "status": "ok", "project_slug": "<slug>",
"url": "...", "token": "...", "port": 5173, "signed": true }
# 3) dev 서버 종료 (port 지정 시 해당 포트만, 생략 시 이 도구가 띄운 전부)
mcp__dev-preview__stop
{ "port": 5173 }
-> { "status": "ok", "stopped": true, "project_slug": "<slug>", "edge_route": { ... } }

구조적(structured) 에러 처리 — 프로젝트가 바인딩되지 않으면 {"status":"denied","reason":"project_required"}, materialize된 샌드박스가 없으면 {"status":"blocked","reason":"no_active_workspace"}를 돌려줍니다(절대 cross-project로 새지 않음). 백엔드 RuntimeError_guard 데코레이터가 {"status":"blocked","reason": ...}로 변환해 MCP transport로 raise되지 않게 합니다.

프로젝트별 공개 프리뷰 (<slug>.monstore.io)

Section titled “프로젝트별 공개 프리뷰 (<slug>.monstore.io)”

각 프로젝트는 하나의 공개 호스트를 받습니다: <slug>.<base>. 여기서 baseFRACTALOPS_DAYTONA_PROJECT_PREVIEW_BASE_DOMAIN(기본 monstore.io)입니다.

  • 프로젝트에 라이브 Daytona dev 프리뷰가 있으면, 게이트웨이가 그 호스트를 차지(claim)하고 돌고 있는 dev 서버로 프록시합니다.
  • 라이브 프리뷰가 없으면, 기존 Dokploy 딜리버리가 같은 호스트를 그대로 서빙합니다. 이중 도메인은 없습니다 — 하나의 호스트, 두 개의 가능한 백엔드, 그리고 라이브 프리뷰가 우선합니다.
flowchart TD
  req["요청: &lt;slug&gt;.monstore.io"]
  edge{"이 slug에 대한 edge 라우트가 있나?\n(프리뷰 lifecycle에 추가됨,\n와일드카드 아님)"}
  gw["daytona-preview 게이트웨이\n(daytona_origin_proxy)"]
  check{"active_dev_preview(slug)\n라이브 & 비-stale 인가?"}
  proxy["돌고 있는 맨몸\ndev 서버로 프록시\n{port}-{token} (or {port}-{sandbox})"]
  delivery["Dokploy 딜리버리가\n호스트를 그대로 서빙"]

  req --> edge
  edge -->|yes| gw
  edge -->|no| delivery
  gw --> check
  check -->|yes| proxy
  check -->|no / registry 문제\n(딜리버리로 fail-closed)| delivery

라이브 프리뷰는 프로젝트별로 영속화됩니다(execution-workspace 레지스트리의 project_dev_preview 컬럼: port / sandbox id / signed / token / updated-at). dev_preview_start가 이를 기록하고 (ExecutionWorkspaceRepository.record_dev_preview), dev_preview_stop이 비웁니다 (clear_dev_preview). 게이트웨이는 ExecutionWorkspaceRepository.active_dev_preview(project_slug)로 이를 읽고, 어떤 registry 문제가 있으면 딜리버리로 fail-closed 됩니다 — 호스트 자체를 절대 에러로 만들지 않습니다.

게이트웨이의 호스트 라우팅 진입점은 daytona_preview_gateway.daytona_origin_proxy입니다. 요청 호스트가 프로젝트 프리뷰 호스트(_is_project_preview_host)이면 _project_preview_proxy_host가 slug → 라이브 프리뷰 upstream 호스트로 해석합니다. upstream 호스트는 signed이고 토큰이 있으면 {port}-{token} 호스트, 아니면 {port}-{sandbox_id} 호스트로 — 기존 프리뷰 라우트와 같은 메커니즘입니다. 라이브 프리뷰가 없는데 호스트가 게이트웨이까지 도달하면, 게이트웨이는 조용히 잘못 서빙하지 않고 딜리버리 호스트를 명시한 503(no_active_preview)을 반환합니다.

Edge 라우팅 규칙 (와일드카드 hijack 금지)

Section titled “Edge 라우팅 규칙 (와일드카드 hijack 금지)”

Edge-fronting(Cloudflare / edge-bridge가 <slug>.monstore.io를 이 게이트웨이로 보내는 것)은 slug별, 프리뷰 lifecycle에 따라 이뤄집니다 — 프로젝트에 활성 프리뷰가 생기면 추가하고, 멈추면 제거합니다. 이것은 와일드카드가 아닙니다. 이미 딜리버리되고 있는 호스트(예: sanmopia-modernization, teamclaw)는 와일드카드 프리뷰 라우트에 의해 절대 hijack되어서는 안 됩니다.

Edge 라우트의 절반(EDGE half)은 ProjectPreviewEdgeService가 소유합니다. 이 토폴로지에서 cloudflared 터널의 ingress는 cloudflared CT 위에 로컬로 렌더된 /etc/cloudflared/config.yml 파일이지 — Cloudflare-API 리소스가 아닙니다(CF API는 여기서 DNS/Access만 관리). 그래서 가장 깔끔한 런타임 라우트 추가/제거는 그 한 파일을 변형(mutate)하고 cloudflared를 reload(SIGHUP)하는 것입니다.

  • ProjectPreviewEdgeService.ensure_preview_route(slug)<slug>.<base> ingress 규칙 하나를 게이트웨이 upstream으로 정확히 삽입하고 reload. originRequest.httpHostHeader: <slug>.<base>로 게이트웨이가 host에서 slug를 해석하게 합니다.
  • ProjectPreviewEdgeService.remove_preview_route(slug) — 그 규칙을 제거해 호스트를 Dokploy 딜리버리로 되돌리고 reload.

세 가지 안전 장치가 코드에 강제되어 있습니다:

  • 마스터 플래그. 모든 라이브 변형은 settings.daytona_preview_edge_route_enabled (기본 FALSE) 뒤에 있습니다. off이면 두 호출 모두 disabled를 보고하는 순수 no-op입니다 — 코드·배선·테스트가 라이브 edge를 자동 변형하지 않고 머지됩니다. 운영자가 플래그를 켜야 활성화됩니다.
  • 정확한 host만. 호출자의 바인딩된 slug에 대한 <slug>.<base>만 건드립니다(slugify된 레이블 + 설정된 base로 만들어진 단일 구체 호스트 — 절대 와일드카드/cross-base 아님).
  • marker-scoped 제거. 이 서비스가 추가한 모든 규칙은 관리 marker (managed_by: fractalops-project-preview-edge)를 답니다. 제거는 marker와 정확한 호스트명을 둘 다 가진 규칙만 떨어뜨리므로 — 시스템이 추가하지 않은 딜리버리 규칙(예: 토폴로지가 렌더한 sanmopia-modernization.monstore.io Dokploy 규칙)은 절대 제거되지 않습니다.

ProjectPreviewEdgeService는 best-effort입니다 — 라우트 실패는 applied=Falsereason을 돌려주고 절대 raise하지 않으므로 edge 문제로 에이전트의 dev_preview_start/dev_preview_stop이 실패하지 않습니다. 운영용 _OperationAssetConfigMutator는 cloudflared CT를 operation asset locator로 도달합니다(런타임 자산 컨트롤러와 같은 경계). config I/O는 주입되는 ConfigMutator seam이라 marker/idempotency/safety 로직이 인메모리 stub으로 완전히 유닛테스트됩니다.

프리뷰-프록시 도메인은 proxy.monstore.io (라이브) 입니다 — 예전 daytona-subdomain 프록시 호스트에서 cutover됐습니다. 모든 참조에서 proxy.monstore.io를 쓰세요.

TTL: 활성 프리뷰가 샌드박스를 따뜻하게(warm) 유지한다

Section titled “TTL: 활성 프리뷰가 샌드박스를 따뜻하게(warm) 유지한다”

Daytona의 네이티브 autoStop은 유휴 샌드박스를 hibernate(절전)시킵니다(자원을 회수하므로 올바른 동작). 그러나 hibernation은 분리된 runner와 프리뷰를 죽입니다. 그래서 활성·비-stale dev 프리뷰는 기존 observe keep-alive 템포를 넓혀줍니다(rides) — 프리뷰가 라이브인 동안 runner가 에이전트 턴 사이에도 샌드박스 TTL을 연장(refresh_workspace_activity)하므로, 외부 뷰어가 샌드박스를 따뜻하게 유지합니다. 프리뷰 레코드는 freshness-gated 입니다 — 나이가 들거나 dev_preview_stop이 비우면 keep-alive가 멈추고 네이티브 autoStop이 샌드박스를 회수합니다. 이후 새 지시가 off→resume으로 다시 깨웁니다.

stateDiagram-v2
  [*] --> Warm: dev_preview_start (활성, 비-stale)
  Warm --> Warm: observe 틱이 TTL 연장\n(refresh_workspace_activity)\n프리뷰가 라이브 OR 턴 실행 중인 동안
  Warm --> Stale: 프리뷰 레코드가 나이 듦\n또는 dev_preview_stop 이 비움
  Stale --> Hibernated: 네이티브 autoStop 이 샌드박스 회수
  Hibernated --> Warm: 새 지시 (off->resume)
  Hibernated --> [*]

이 keep-alive 게이트의 실제 판정은 두 갈래입니다(session_runner.py의 observe 경로):

# pid_alive==true 이고 status가 running 인 동안만 — 즉 턴이 실제 실행 중일 때.
# preview_live 가 True 면 같은 게이트를 넓혀 (턴 사이에도) 프리뷰 뷰어를 위해 warm 유지.
if (pid_alive == "true" and raw_status in {"", "running"}) or preview_live:
DaytonaWorkspaceService().refresh_workspace_activity(
workspace_id=sandbox_id, organization_id=organization_id,
)

preview_live는 배치당 한 번, 싸게 결정됩니다(studio_run_execution_command._run_project_preview_live): 포트 curl이 아니라 영속화된 프리뷰 레코드의 freshness 읽기 (ExecutionWorkspaceRepository.active_dev_preview_is_fresh)로 — 레코드가 있고 dev_preview_updated_atFRACTALOPS_DAYTONA_PROJECT_PREVIEW_FRESHNESS_MINUTES(기본 30분) 창 안일 때 True입니다. 멈췄지만 row를 비우지 않은 프리뷰는 stamp가 창보다 오래되면 age-out되어 warm 유지를 멈춥니다.

이것이 샌드박스를 깨어 있게 둘 수 있는 유일한 루프입니다. 샌드박스가 단지 도달 가능하다는 이유만으로 매 틱 refresh하는 per-tick keep-alive는 다시 도입하지 마세요(네이티브 TTL을 무력화하고 유휴 샌드박스를 leak합니다).

키(FRACTALOPS_*)기본값의미
..._DAYTONA_PROJECT_PREVIEW_BASE_DOMAINmonstore.io<slug>.<base> 의 공개 base
..._DAYTONA_PREVIEW_PROXY_HOST_DOMAIN""{port}-{token}.<host> 프리뷰 호스트 도메인
..._DAYTONA_PREVIEW_PROXY_INTERNAL_URL""게이트웨이가 프록시하는 daytona-proxy 내부 URL
..._DAYTONA_PROJECT_PREVIEW_FRESHNESS_MINUTES30활성 프리뷰가 샌드박스를 warm 유지하는 창
..._DAYTONA_PREVIEW_EDGE_ROUTE_ENABLEDfalseedge 라우트 자동 변형 마스터 플래그
..._DAYTONA_PREVIEW_EDGE_GATEWAY_UPSTREAMhttp://10.10.10.47:18080<slug>.<base> 가 향하는 게이트웨이 upstream
  • docker는 샌드박스에서 하드-월(hard-wall)입니다. in-sandbox compose/build 플레인이나 Docker build router를 다시 도입하지 마세요.
  • 개발 프리뷰는 0.0.0.0에 바인딩된 맨몸 프로세스이며, daytona-proxy 서명 URL로 노출됩니다 — Dokploy가 아닙니다.
  • Dokploy는 지속 백킹 서비스 전용입니다: 데이터베이스, 정적 사이트(vercel-sim) 호스팅, 대형 설비용 compose.
  • 프로젝트별 edge 라우트는 slug별, 프리뷰 lifecycle에 따라 갑니다. 딜리버리되는 호스트를 잡아챌 수 있는 와일드카드를 절대 추가하지 마세요.
  • FractalOps 플랫폼 자체의 이미지 빌드는 별개의 CI 소유 릴리스 관심사로 GitOps를 핀(pin)합니다 — 개발자 dev-preview 루프의 일부가 아닙니다.