장기 실행 작업과 Machine 수명주기
이 페이지는 여러분의 Machine이 작업을 처리하느라 바쁜데 Fly가 이를 유휴 상태로 판단할 때 어떤 일이 일어나는지 다뤄요. 구체적으로는 auto_stop_machines가 무엇을 멈출지 어떻게 결정하는지, 왜 백그라운드 작업이 그 결정에 보이지 않는지, 그리고 작업이 죽는 것을 막아 주는 두 가지 패턴에 대해 설명할게요. 큐 기술이나 cron 실행기를 고르는 중이라면 work queues나 task scheduling 가이드부터 시작하는 게 좋아요. 이 페이지는 그 밑바탕이 되는 Machine 동작에 관한 내용이에요.
출처: 문서
본문
문제 상황
전형적인 설정을 가정해 볼게요. FastAPI 엔드포인트가 요청을 받아 보고서를 생성하는 비동기 작업을 만들고, 202 Accepted를 반환한 뒤 연결을 닫아요. 그러면 프록시가 활성 연결이 없는 것을 보고, 몇 분 뒤 Machine을 멈춰 버려요. 보고서는 절반만 생성된 채 죽고 맙니다.
이건 버그가 아니에요. auto_stop_machines가 문서에 적힌 그대로 정확히 동작한 거예요. 프록시는 인바운드 트래픽을 보죠. 컨테이너 안을 들여다보지는 않아요. 프록시의 관점에서 보면 20분 걸리는 작업을 실행 중인 Machine과 아무것도 하지 않는 Machine은 똑같아 보여요.
이를 해결할 방법은 두 가지예요. 작업이 버스트성인지 꾸준한지, 요청에 의해 시작되는지 큐에 의해 구동되는지에 따라 하나를 고르면 돼요.
autostop이 실제로 결정하는 방식
Fly 프록시는 몇 분마다 Machine을 평가해요. 정확한 규칙은 Machine 수에 따라 달라져요:
여러 Machine. 프록시는 soft_limit 동시성 설정을 사용해 초과 용량을 계산해요:
excess = num_machines − (num_machines_over_soft_limit + 1)
excess ≥ 1이면 프록시가 Machine 하나를 멈춰요. + 1은 들어오는 트래픽을 위한 유휴 Machine 버퍼 하나를 유지하는 거예요.
단일 Machine. 더 단순해요: 로드가 0이면 프록시가 Machine을 멈춰요.
두 경우 모두 "로드"는 프록시가 볼 수 있는 트래픽을 의미해요. Machine 안에서 실행되는 백그라운드 작업(비동기 워커든, cron 스타일 루프든, 인바운드 요청에 의해 구동되지 않는 그 어떤 것이든)은 집계되지 않아요. 또한 앱이 프록시에게 "지금 바빠요, 내버려 두세요"라고 알릴 방법도 없어요.
이것이 이후 내용의 중심 사실이에요. 아래의 모든 것은 이를 우회하는 방법이에요.
Stop vs suspend
auto_stop_machines는 "off", "stop", "suspend" 세 가지 값을 가져요.
- stop은 Machine을 완전히 꺼요. 재시작은 몇 초가 걸려요 (Rails 앱 기준 약 2초, 작은 바이너리는 더 짧아요).
- suspend는 전체 VM 상태(메모리, CPU, 네트워크)를 디스크로 덤프해요. 재개(resume)는 수백 밀리초가 걸려요.
Stop은 더 단순한 기본값이에요. Machine은 유휴 상태가 되면 꺼지고, 다시 필요해지면 콜드 스타트해요. 대부분의 앱에 적합한 절충이에요.
Suspend는 콜드 스타트가 너무 고통스러울 때(느린 프레임워크 부팅, 무거운 초기화, 큰 인메모리 상태) 적합한 선택이에요. 그리고 유휴 상태가 되길 원할 때요. 단점은 이렇습니다: suspend는 밑바탕 플랫폼에 더 가혹하고 제약이 더 많아요:
- Machine의 RAM은 4GB 이하여야 해요.
- Swap과 스케줄은 지원되지 않아요.
- 2024년 6월 20일 이전에 업데이트된 Machine은 suspend할 수 없어요.
- Suspend는 내구성이 없어요. Fly는 suspend된 Machine이 재개될 것이라고 보장하지 않아요. 호스트 마이그레이션, 유지보수, 용량 압박 때문에 재개가 콜드 스타트로 바뀔 수 있어요. suspend를 보장된 웜 재시작이 아니라 stop의 더 빠른 버전으로 취급하세요.
- suspend/resume 주기에서 로그 몇 줄이 유실될 수 있고, 재개 후 시스템 시계를 재동기화하는 데 1-2초 걸릴 수 있어요. 시계 오차에 대한 자세한 내용은 "Suspend vs. Stop"를 참조하세요.
요금은 둘 다 동일해요. 멈춘 Machine에 대해 suspend된 Machine처럼 요금을 지불해요.
이 가이드의 나머지 부분에서 "stop"과 "suspend"는 서로 바꿔 쓸 수 있어요. 두 패턴 모두 똑같이 작동해요.
패턴 A: autostop 비활성화, 앱에서 종료 관리
언제 쓰나: 앱에 장수 워커, 프로세스 내 작업 러너, 또는 앱 자체가 추적할 수 있는 백그라운드 작업이 있을 때요.
fly.toml에서 autostop을 끄세요:
[http_service]
internal_port = 8080
auto_stop_machines = "off"
auto_start_machines = true
autostop이 꺼지면 프록시는 유휴 상태라고 Machine을 멈추지 않아요. Machine들은 다른 무엇인가(배포, fly machine stop, 호스트 마이그레이션)가 멈출 때까지 계속 떠 있어요. 확장한 모든 region에서 Machine 24/7 요금을 내는 셈이니, 이 패턴을 채택하기 전에 그게 올바른 절충인지 확인하세요.
배포, 수동 중지, 호스트 마이그레이션이 Machine을 멈출 때 앱은 SIGTERM을 받고 kill_timeout 초 동안 정리에 사용할 수 있어요. 기본 5초는 거의 확실히 너무 짧아요. 늘려 주세요. 이들은 fly.toml의 최상위 키예요:
kill_signal = "SIGTERM"
kill_timeout = "30s"
최대값은 300초예요. kill_timeout은 드레인 창(drain window)이지 "작업을 끝내게 두는" 손잡이가 아니에요. 작업이 5분보다 오래 걸린다면, 체크포인트를 만들어 재개 가능하게 하거나, 새 작업 수락을 멈추고 진행 중인 작업이 타임아웃 전에 드레인되게 하세요. 모든 것이 끝나길 기다리지 마세요.
Node에서의 최소한의 종료 패턴:
let activeJobs = 0
let shuttingDown = false
async function runJob(payload) {
if (shuttingDown) throw new Error("shutting down")
activeJobs++
try {
await doWork(payload)
} finally {
activeJobs--
}
}
process.on("SIGTERM", () => {
shuttingDown = true
const start = Date.now()
const deadline = 25_000 // 5s under kill_timeout
const tick = setInterval(() => {
if (activeJobs === 0 || Date.now() - start > deadline) {
clearInterval(tick)
process.exit(0)
}
}, 200)
})
Python + asyncio에서는:
import asyncio, signal
active = 0
shutting_down = asyncio.Event()
async def run_job(payload):
global active
if shutting_down.is_set():
raise RuntimeError("shutting down")
active += 1
try:
await do_work(payload)
finally:
active -= 1
async def shutdown():
shutting_down.set()
try:
await asyncio.wait_for(_drain(), timeout=25)
except asyncio.TimeoutError:
pass
async def _drain():
while active > 0:
await asyncio.sleep(0.2)
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGTERM, lambda: asyncio.create_task(shutdown()))
두 패턴 모두 SIGTERM이 도착하는 즉시 새 작업을 거부하고, 진행 중인 작업을 kill_timeout보다 몇 초 짧은 마감까지 기다려요. 안전 여유가 중요해요. 30초를 꽉 채워 기다리면 Fly의 SIGKILL이 여러분의 exit(0)이 실행되기 전에 도착해요.
패턴 B: 웹과 워커를 별도 프로세스 그룹으로 분리
언제 쓰나: 웹 트래픽은 버스트성(autostop의 좋은 후보)인데 백그라운드 작업은 꾸준하거나 장기 실행(autostop의 나쁜 후보)일 때요.
fly.toml의 processes로 분리하세요:
[processes]
web = "bundle exec puma"
worker = "bundle exec sidekiq"
[http_service]
internal_port = 8080
auto_stop_machines = "suspend"
auto_start_machines = true
processes = ["web"]
워커 프로세스 그룹에는 [http_service]가 붙어 있지 않아서, 프록시가 그 Machine을 건드리지 않아요. autostop은 웹 계층에만 적용돼요.
독립적으로 확장하세요:
fly scale count web=2 worker=1
이것이 Sidekiq, Celery, BullMQ 워커가 실제로 원하는 패턴이에요. 웹 계층은 근무 시간 외에 0으로 확장되고, 워커 계층은 큐에 작업이 있을 때마다 실행돼요.
절충: 워커 Machine 하나를 계속 유지하는 요금을 내요. 작업이 충분히 배치성이라 온디맨드 워커가 말이 된다면, work queues 가이드의 온디맨드 워커 패턴을 대신 사용하세요. 그 패턴은 작업당 새 Machine을 띄우고 작업이 끝나면 멈추게 해요.
정상 종료: Fly가 보내는 것
무언가가 Machine을 멈출 때 — auto_stop_machines, fly machine stop, 배포, 호스트 마이그레이션 중 무엇이든 — Fly는 kill_signal(기본값: SIGTERM)을 PID 1로 보내요. kill_timeout 초를 기다린 뒤 SIGKILL을 보내요.
기본값은 보수적이에요:
| Option | Default | Max | Notes |
|---|---|---|---|
kill_signal |
SIGTERM |
n/a | SIGQUIT, SIGUSR1, SIGUSR2, SIGKILL, SIGSTOP도 허용 |
kill_timeout |
5s |
300s |
SIGKILL 전의 드레인 창 |
5초면 HTTP 서버가 keepalive를 닫기에는 충분해요. 장기 실행 작업이 끝나기에는 충분하지 않아요. 백그라운드 작업이 있다면, 일반적인 작업이 완료될 수 있도록 kill_timeout을 정해 주세요. 이 값은 여러분 쪽에서 정해야 해요. 두 키 모두 fly.toml의 최상위 위치에 있어요:
kill_signal = "SIGTERM"
kill_timeout = "30s"
PID 1이 신호를 받아요. 앱을 직접 실행하는 Docker 컨테이너에선 그게 여러분의 프로세스예요. 셸 래퍼(CMD ["sh", "-c", "..."])를 실행하는 컨테이너에선 셸이 PID 1이고 SIGTERM이 전파되지 않아요. exec 형식을 사용하세요: CMD ["myapp"], 또는 래퍼 안에서 exec myapp.
kill_timeout은 "작업을 끝내는" 타이머가 아니에요. 드레인 창이에요. 그 안에서 다음을 해야 해요:
- 새 작업 수락을 멈추기
- 진행 중인 작업을 끝내거나 체크포인트하기
- 깨끗하게 종료하기
작업이 5분보다 오래 걸린다면 kill_timeout 안에서 드레인할 수 없어요. 체크포인트/재개가 있는 패턴 A, 또는 절대 autostop되지 않는 워커 계층이 있는 패턴 B가 필요해요.
이 중 무엇에든 의존하기 전에 fly config validate --strict를 실행하세요. 기본적으로 fly config validate는 인식되지 않은 섹션과 키를 조용히 받아들여요. 오타나 오래된 섹션 이름이 검증을 통과한 뒤 런타임에 아무것도 하지 않을 수 있어요. Strict 모드가 그런 오류를 잡아줘요.
패턴 고르기
| 상황 | 패턴 |
|---|---|
| 작업이 짧음 (< 30초) | kill_timeout을 늘리고 나머지는 기본값 유지 |
| 장기 실행 작업, 꾸준한 웹 트래픽 | A: autostop 비활성화, 앱 내 드레인 |
| 장기 실행 작업, 버스트성 웹 트래픽 | B: 웹/워커 프로세스 분리 |
| Cron 스타일 스케줄 작업 | task scheduling 참조 |
| 큐 기반 워커 | B: work queues와 결합 |
| 일회성 작업 (요청당 fire and forget) | 온디맨드 워커; work queues 참조 |
| 지금은 재구성할 수 없음 | A: 지속 Machine 비용 수용 |
흔한 문제들
SIGTERM 핸들러는 실행되는데 작업이 여전히 죽어요. kill_timeout이 핸들러가 필요로 하는 것보다 짧은 거예요. 늘리고(최대 300초) 핸들러의 마감을 그보다 몇 초 아래로 설정하세요.
auto_stop_machines = "off"인데도 작업 도중 Machine이 멈춰요. autostop은 Machine을 멈추는 여러 것 중 하나일 뿐이에요. 배포, fly machine stop, 축소, 호스트 마이그레이션도 모두 멈춰요. fly logs에서 instance refused나 host migration 이벤트를 확인하세요. 패턴 A가 여전히 적용돼요. 유일한 차이는 autostop이 더 이상 트리거가 아니라는 점이에요.
셀프 핑(self-ping)이 Machine을 살려 두지 않나요? 그러지 않아요. autostop 참조는 유휴를 "로드 0"으로 정의하지만 무엇이 로드로 집계되는지는 명시하지 않아요. 경험적으로, Machine이 자기 자신의 <app>.fly.dev 호스트네임으로 60초마다 성공적인 HTTP 요청을 보내는 것은 autostop을 막지 못해요. 프록시는 여전히 5~10분 뒤 Machine을 멈춰요. 유휴 트래픽 중에도 Machine을 계속 실행하려면 autostop을 끄거나(패턴 A), 작업을 [http_service]가 없는 프로세스 그룹으로 옮기세요(패턴 B).
배포할 때 워커 Machine이 멈추지 않아요. [http_service]가 없는 프로세스 그룹(패턴 B의 워커 계층 같은)은 프록시에 보이지 않아요. flyctl이 그 Machine들과 직접 통신하므로 배포는 여전히 업데이트하지만, 프록시는 그 수명주기를 관리하지 않고 autostop도 할 수 없어요. 정상적으로 멈추려면 fly machine stop으로 신호를 보내거나 배포 중 fly deploy가 대체하게 두세요.
Suspend 재개가 문서보다 느려요. Suspend는 내구성이 없어요. Fly가 스냅샷을 복원하지 못하면(호스트 마이그레이션, 용량 압박) 콜드 스타트가 돼요. 어떤 일이 일어났는지 알려 주는 플래그는 없어요. 첫 요청 지연 시간을 확인하세요. 콜드 스타트가 중요하다면 min_machines_running = 1로 실행하세요.
다음으로 갈 곳
- Work queues 가이드: 큐 기술 고르기
- Task scheduling 가이드: cron 스타일 트리거와 스케줄된 Machine
- Autostart and autostop 참조: 프록시의 전체 결정 로직
- Configuration 참조:
kill_signal,kill_timeout,processes,auto_stop_machines - Machine states:
stopping,stopped,suspended가 실제로 무엇을 의미하는지