본문으로 바로가기 본문 바로가기
WIKI 기술 지식 베이스

사용자 Machine 웜 풀(Warm pools)

원문 보기 위키 갱신

이 페이지는 사용자별 인프라를 즉시 느껴지게 만드는 방법을 다뤄요. 백그라운드에서 범용 Fly Machine 풀을 미리 프로비저닝한 뒤, 사용자가 나타나면 하나를 가져와(claim) 개인화하는 방식이에요. 사용자별 개발 환경의 "이런 것들의 풀을 미리 만들어 두라"는 조언을 데이터베이스 스키마, API 호출, 가져오기 쿼리, 유지보수 루프까지 완전히 풀어낸 내용이에요.

출처: 문서

본문

이 페이지는 사용자별 인프라를 즉시 느껴지게 만드는 방법을 다뤄요: 백그라운드에서 범용 Fly Machine 풀을 미리 프로비저닝한 다음, 사용자가 나타나면 하나를 가져와 개인화하는 방식이에요. 사용자별 개발 환경의 "이런 것들의 풀을 미리 만들어 두라"는 조언을 데이터베이스 스키마, API 호출, 가져오기 쿼리, 유지보수 루프까지 완전히 풀어낸 내용이에요.

문제

사용자별 앱(개발 환경, AI 에이전트, 코드 샌드박스)을 운영한다면, 사용자 온보딩은 Fly 앱, 볼륨, Machine을 만들고 그 안의 소프트웨어가 부팅될 때까지 기다리는 것을 의미해요. 각 단계는 빠르지만, 합해지면 사용자가 대화할 무언가를 얻기까지 1분 이상 걸려요. 사용자는 몇 초를 기대해요.

그 작업의 대부분은 범용적이에요. 앱, 볼륨, Machine, 부팅 모두 사용자가 누군지에 의존하지 않아요. 마지막 단계인 구성만이 사용자별이에요. 그래서 범용 작업을 미리 하세요. 백그라운드 워커가 N개의 인스턴스를 프로비저닝·부팅·헬스 체크된 상태로 유지해요. 사용자가 가입하면 풀에서 하나를 가져와 그들의 구성을 쓰고 넘겨줘요. 할당은 약 1초로 줄어들어요. 풀이 비어 있으면 직접 프로비저닝으로 폴백해요. 느리지만 아무것도 망가지지 않아요.

풀 항목당 하나의 앱

각 풀 항목은 공유 앱 안의 단순한 Machine이 아니라 전용 Fly 앱이에요. 앱이 격리 경계예요. 고유한 네트워크 이름으로 만들면 각 항목이 자체 프라이빗 6PN 네트워크에 놓여, 한 사용자의 Machine이 다른 사용자의 Machine에 절대 닿지 못해요. Flycast 주소는 공용 IP 없이도 제어 평면이 Machine에 프라이빗 라우팅을 하게 해 줘요. 그리고 앱 삭제는 Machine과 볼륨으로 연쇄(cascade)되므로, 축소(teardown)가 단일 API 호출이 돼요. 이게 들리는 것보다 중요한데, 크래시 복구 정리(crash-recovery cleanup)도 같은 호출을 사용하기 때문이에요.

이 제어 평면은 앱을 계속 생성·파괴하므로, 앱 범위 배포 토큰이 아니라 조직 범위 API 토큰(fly tokens create org)이 필요해요. 서버 측에 보관하고 절대 사용자 Machine에 노출하지 마세요.

# Create the app on its own private network
curl -X POST "https://api.machines.dev/v1/apps" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{
    "app_name": "pool-x7f3k2",
    "org_slug": "my-org",
    "network": "pool-x7f3k2"
  }'

# Allocate a Flycast (private) address into the app's own network
curl -X POST "https://api.machines.dev/v1/apps/pool-x7f3k2/ip_assignments" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{"type": "private_v6", "network": "pool-x7f3k2"}'

앱을 만들 때 사용한 것과 같은 network 이름을 전달하세요. 할당의 network 필드는 Flycast 주소가 어느 6PN 네트워크 안에서 라우팅되는지를 지정하며, 앱 고유의 네트워크로 기본 설정되지 않아요. 비워 두면 주소는 조직의 기본 네트워크에 놓여, 앱의 격리된 앱별 네트워크에 사는 Machine에 닿을 수 없어요.

이름이 이미 사용 중이면 앱 생성은 422를 반환해요. 워커는 크래시 후 재시도하므로 이를 멱등적으로 처리하세요: GET /v1/apps/{name}로 앱이 존재하는지 확인하고 재사용해요. 존재하지 않는다면 422는 실제 검증 오류이므로 다시 던지세요.

데이터베이스에 풀 상태 추적하기

Machines API는 인프라의 진실 공급원(source of truth)이에요. 작은 테이블은 누가 무엇을 소유하는지의 진실 공급원이에요. 풀 항목당 한 행:

column purpose
id your identifier
app_name the entry's dedicated Fly app
machine_id written as soon as the Machine is created
volume_id written as soon as the volume is created
region the concrete region Fly actually placed it in
status provisioning → ready → failed
provisioned_at set when the entry becomes ready
allocated_at NULL = available; set = claimed
allocated_to the user or instance that claimed it
provision_version bump to invalidate the pool when your image changes

두 가지 규칙이 이걸 크래시 안전하게 만들어요. 첫째, Machines API를 건드리기 전에 행을 삽입해서, Fly.io에 데이터베이스가 볼 수 없는 것이 존재하지 않게 해요. 둘째, 리소스 ID를 점진적으로 다시 써요: 볼륨 생성 직후 볼륨 ID, Machine 생성 직후 Machine ID. 워커가 프로비저닝 중간에 죽으면 스위퍼(sweeper)가 반쯤 끝난 행을 찾아 앱을 삭제하고(만들어진 것은 무엇이든 연쇄 삭제) 계속 진행할 수 있어요.

백그라운드에서 프로비저닝하기

각 항목의 순서: 앱 생성 → Flycast IP 할당 → 볼륨 생성 → Machine 생성 → 부팅 대기 → 헬스 체크 → ready 표시.

# Create the volume first; Fly resolves the region
curl -X POST "https://api.machines.dev/v1/apps/pool-x7f3k2/volumes" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{"name": "user_data", "region": "iad", "size_gb": 1}'

# Create the Machine in the *volume's* region
curl -X POST "https://api.machines.dev/v1/apps/pool-x7f3k2/machines" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "pool",
    "region": "iad",
    "config": {
      "image": "registry.fly.io/my-user-app@sha256:...",
      "guest": {"cpu_kind": "shared", "cpus": 1, "memory_mb": 2048},
      "init": {"swap_size_mb": 2048},
      "mounts": [{"volume": "vol_abc123", "path": "/data"}],
      "services": [{
        "internal_port": 8080,
        "ports": [{"port": 80, "handlers": ["http"]}],
        "autostart": true,
        "autostop": "off"
      }],
      "checks": {
        "health": {"type": "tcp", "port": 8080, "interval": "15s",
                   "timeout": "5s", "grace_period": "120s"}
      },
      "restart": {"policy": "on-failure", "max_retries": 3},
      "metadata": {"pool_entry": "fp_abc123"}
    }
  }'

# Wait for the VM to boot (timeout defaults to 60s, which is also the max)
curl "https://api.machines.dev/v1/apps/pool-x7f3k2/machines/${MACHINE_ID}/wait?state=started&timeout=60" \
  -H "Authorization: Bearer ***"

그 구성의 몇 가지 설정이 보이는 것보다 더 중요해요.

볼륨과 Machine은 같은 곳에 있어야 해요. 볼륨은 하나의 물리 호스트에 고정되고, 그것을 마운트하는 Machine은 같은 호스트에 놓여야 해요. 볼륨을 먼저 만든 다음, config.mounts에 볼륨 ID를 사용해 Machine을 만들고 볼륨이 반환한 리전을 사용하세요. 마운트가 Machine을 볼륨의 호스트에 배치하는 것이에요. 리전만 맞추는 것만으로는 충분하지 않고, Machine을 먼저 만들면 올바른 리전에서도 연결에 실패할 수 있어요. 실제로 놓인 곳이므로 Machine이 반환한 리전도 데이터베이스에 저장하세요.

태그가 아니라 다이제스트(digest)로 이미지를 고정하세요. provision_version은 균일한 풀 세대를 표시하기 위한 것이에요. :latest 같은 움직이는 태그로 빌드하면 같은 버전의 두 항목이 다른 이미지를 실행하게 될 수 있어요. 불변 다이제스트(@sha256:...)를 참조하고 변경될 때마다 provision_version을 올리세요.

autostop: "off"가 핵심이에요. 풀 Machine은 설계상 유휴 상태예요. Autostop은 도움을 주려고 이들을 종료시키는데, 정지된 풀 Machine은 추가 단계가 붙은 콜드 스타트일 뿐이에요.

started가 준비된 것을 의미하지 않아요. wait 엔드포인트는 VM이 부팅됐다는 것을 알려주지만, 앱 내부가 서빙 중이라는 것은 아니에요. 항목을 ready로 표시하기 전에 exec 엔드포인트를 통해 앱의 헬스 엔드포인트를 폴링하세요:

curl -X POST "https://api.machines.dev/v1/apps/pool-x7f3k2/machines/${MACHINE_ID}/exec" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{"command": ["curl", "--fail", "--silent", "--max-time", "5", "http://localhost:8080/healthz"]}'

exec 호출은 내부 명령이 실패해도 HTTP 200을 반환하므로, API 상태가 아니라 응답 본문의 exit_code를 확인하세요. --fail은 5xx에서 내부 curl이 0이 아닌 값을 반환하게 만들어, 막힌 앱이 준비됨이 아니라 비정상으로 읽히게 해요. (사용하는 프로브는 이미지에 존재해야 해요.)

이것이 나중에 가져오기(claim)를 즉시 만드는 이유예요. ready 항목은 앱이 이미 서빙 중인 것이므로, 가져올 때 남은 작업은 구성뿐이에요. 이걸 건너뛰면 "즉시" 가져오기가 여전히 부팅 중인 Machine을 사용자에게 넘겨주게 돼요.

풀 Machine은 아직 자신의 사용자를 모르므로, 최소한의 플레이스홀더 구성(엔트리포인트가 요구하는 환경 변수는 더미 값으로)으로 부팅하세요. 실제 구성은 가져올 때 도착해요. 프로비저닝 단계가 실패하면 앱을 삭제하고(연쇄됨) 스위퍼를 위해 행을 failed로 표시해요.

SKIP LOCKED로 원자적으로 가져오기

사용자가 가입하면 하나의 원자적 문으로 항목을 가져와요:

UPDATE pool SET allocated_at = NOW(), allocated_to = $user_id
WHERE id = (
  SELECT id FROM pool
  WHERE status = 'ready' AND allocated_at IS NULL
  ORDER BY provisioned_at ASC   -- oldest first
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *

FOR UPDATE SKIP LOCKED가 무거운 작업을 해줘요: 동시 가입이 같은 항목을 잡지도, 서로의 락을 기다리지도 않아요. 어드바이저리 락도, 큐도, 코디네이터도 필요 없어요.

사용자의 리전을 선호하되, 고집하지 마세요. 먼저 가까운 리전으로 필터링된 쿼리를 실행하고, 그다음 필터 없이 실행해요. 오른쪽 리전의 콜드 스타트보다는 잘못된 리전의 웜 Machine이 나아요. 쿼리가 0행을 반환하면 백그라운드 워커와 같은 순서로 직접 프로비저닝해요. 풀은 최적화일 뿐, 의존성이 아니에요.

실패한 가져오기를 되돌리는 방법에 주의하세요. 사용자별 무언가를 쓰기 전에 가져오기가 실패하면 allocated_at을 지워 항목을 안전하게 풀로 돌려줘요. 사용자의 구성이나 시크릿을 볼륨에 쓴 뒤에는 그 항목은 오염된 것이에요. 풀로 돌려주면 다음 사용자에게 이전 사용자의 데이터가 넘어갈 수 있어요. 사용자별 쓰기 후에는 항목을 failed로 표시하고 유지보수 루프가 파괴하게 하세요. 재활용하지 마세요.

Machine 업데이트가 아니라 exec로 개인화하기

범용 풀 Machine을 이 사용자의 Machine으로 바꾸는 방법은 두 가지이고, 그 차이가 수익(payoff)의 대부분이에요:

approach mechanism latency
exec write write the user's config to the volume via exec; the app hot-reloads it ~1-2s
Machine update POST /machines/{id} with new config, full Machine restart ~20s

앱이 구성 파일을 지켜보고 리로드할 수 있다면, 파일을 쓰면 끝나요. 사용자 구성을 Base64로 인코딩해 exec 명령이 디코드하는 곳에 넣고, 임시 파일에 먼저 쓴 다음 이름을 바꿔 독자가 절반만 쓰인 구성을 보지 않게 하세요:

# CONFIG_B64 = base64 of the user's config; splice it into the command below
curl -X POST "https://api.machines.dev/v1/apps/pool-x7f3k2/machines/${MACHINE_ID}/exec" \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{"command": ["sh", "-c", "umask 077; echo CONFIG_B64 | base64 -d > /data/config.json.tmp && mv /data/config.json.tmp /data/config.json"]}'

이것은 사용자별 시크릿이 어디에 사는지도 결정해요: Machine 환경 변수가 아니라 볼륨의 구성 파일에 있어요. 환경 변경에는 Machine 업데이트와 재시작이 필요해요. 파일 쓰기는 그렇지 않아요.

실제 Machine 업데이트(이름 바꾸기, 메타데이터, 환경 변수)가 필요할 때는 리스(lease) 흐름을 사용해 동시 업데이터가 서로 덮어쓰지 못하게 하세요: 리스를 획득하고, fly-machine-lease-nonce 헤더로 업데이트를 보내고, finally에서 리스를 해제해요. 그리고 Machine 업데이트는 전체 구성을 교체한다는 것을 알아두세요. 병합하지 않아요. 현재 Machine을 GET한 뒤 전체 구성을 필드만 바꿔 다시 보내지 않으면, 마운트, 서비스, 체크, 환경을 조용히 지워버려요.

풀 유지보수

작은 워커(가급적 자체 프로세스 그룹)가 간격을 두고 루프를 돌아요. 각 틱은 순서대로 네 가지를 해요:

  1. 사이클(Cycle). provision_version이 현재 것과 일치하지 않는 미할당 항목을 삭제해요. 이미지나 Machine 구성이 바뀔 때마다 버전을 올리면, 풀이 다음 몇 틱에 걸쳐 스스로 교체돼요.
  2. 정리(Clean). failed 항목에 대해 앱과 행을 삭제해요.
  3. 검증(Validate). 각 ready 항목에 대해 GET /machines/{id}를 하고, state가 started가 아닌 것은 버려요. 약 10분 이상 provisioning에 갇힌 행도 버려요. 그것은 크래시된 프로비저너이며, 점진적으로 쓰인 ID들이 남긴 것을 정리하게 해 줘요.
  4. 보충(Replenish). 사용 가능한 항목(status = 'ready' AND allocated_at IS NULL) 수에 진행 중인 provisioning 항목을 더해 목표와 비교하고, 부족분을 만들어요. 여기서 가져왔지만 여전히 ready인 행을 세는 것이 전형적인 버그예요. 풀이 꽉 찬 것처럼 보여 사용자가 가져오면서 조용히 채우기를 멈춰요. 새 항목을 가중치에 따라 리전에 분산하세요(예: us에 3개, eu에 1개, apac에 1개 — 여기서 이것들은 iad, ams, sin 같은 구체적인 Fly 리전의 자체 버킷이에요). 진행 중인 provisioning 항목을 세는 것은 가입 폭주 기간에 과잉 프로비저닝하는 것을 막아줘요. 중첩 실행을 방지하세요.

포인터와 함정

  • 웜 Machine은 비용이 들어요. 풀 크기는 최대 가입 속도와 콜드 스타트 폴백에 대한 허용도의 내기예요. 작게 시작하고(2-5), 매 틱 ready/provisioning 수를 로그하고, 실제 트래픽에서 폴백이 보일 때만 키우세요. 앱이 빨리 부팅된다면, 가져올 때 start하는 정지된 Machine 풀이 더 나은 트레이드가 될 수 있어요. 정지된 Machine은 실행 중인 것보다 비용이 훨씬 적기 때문이에요.
  • 항목별 고유 시크릿을 만들어 프로비저닝 시점에(auth 토큰, 게이트웨이 비밀번호) 데이터베이스에 암호화해 저장하세요. 가져온 Machine은 다른 항목의 자격 증명으로는 절대 도달할 수 없어야 해요.
  • 요청한 값이 아니라 API가 반환한 값을 기록하세요. 리전 입력은 생성 시점에 결정돼요. Machine의 실제 리전이 가져오기 쿼리가 매칭해야 하는 값이에요.
  • Machine은 물리 하드웨어에 묶여 있어요. 호스트 장애는 Machine과 볼륨을 파괴해요. 풀 항목에서는 유지보수 루프가 교체하므로 괜찮지만, 사용자가 가져온 뒤 만든 지속성 있는 것은 무엇이든 역시 Machine 밖(Tigris나 S3 같은 곳)으로 백업해야 해요.
  • 풀 앱에는 HTTP 체크보다 TCP 체크가 나아요. 앱의 HTTP 엔드포인트는 가져오기 전에는 존재하지 않는 인증이나 사용자별 구성을 요구할 수 있어요. 수신 포트에 대한 넉넉한 grace_period가 있는 TCP 체크가 신뢰할 수 있는 신호예요.

관련 자료

더 알아보기 (Learn more)