
내 홈랩에는 RTX 5090이 한 장 있다. 32GB VRAM이면 넉넉해 보이지만 vLLM(LLM 서빙), Whisper(ASR), ComfyUI(이미지 생성)를 돌려야 하니 동시에 올리면 바로 터진다. 그래서 규칙을 하나 정했다. 한 번에 하나만.
vLLM이나 ASR은 K8s에서 스케줄링되지만 ComfyUI는 좀 다르다. 노드 기반 UI라 WSL 위에서 직접 띄우는데, 매번 터미널 열고 프로세스 시작하고 끝나면 kill하는 게 너무 번거롭다. 특히 이미지 생성 자동화를 붙이려면 이 과정이 프로그래밍 가능해야 했다.
그래서 만든 게 comfyui_ctl.py다. GPU 상태 확인, 원격 시작/종료, health check를 하나의 CLI로 묶었다.
스크립트의 start 명령은 이런 순서로 동작한다.
GPU API(localhost:9090)로 VRAM 사용량과 프로세스 목록을 먼저 확인한다. idle 상태가 아니면 다른 서비스가 GPU를 쓰고 있다는 뜻이니 시작을 거부한다. idle이면 JupyterLab 터미널을 통해 ComfyUI를 원격으로 띄우고, /system_stats 엔드포인트를 5초 간격으로 폴링해서 ready 상태를 확인한다.
원격으로 명령을 실행하는 방법은 여러 가지다. SSH가 가장 먼저 떠오르겠지만 내 환경에선 JupyterLab이 이미 HTTPS + 인증까지 갖춰져서 돌아가고 있었다. 굳이 SSH 키 관리를 추가할 이유가 없었다.
JupyterLab 터미널은 WebSocket으로 통신한다. stdin에 명령을 JSON으로 보내면 실행된다. 인증 과정이 조금 독특한데, XSRF 토큰을 먼저 받고 로그인해서 세션 쿠키를 확보한 뒤 WebSocket을 연결해야 한다.
def jupyter_send_command(cmd: str) -> None:
# 1. GET /login -> XSRF 토큰 획득
client = httpx.Client(timeout=10, verify=False)
login_page = client.get(f"https://{JUPYTER_URL}/login")
xsrf = login_page.cookies.get("_xsrf", "")
# 2. POST /login -> 세션 쿠키 확보
# 3. GET /api/terminals -> 터미널 이름 가져오기
# 4. WebSocket으로 명령 전송 (fire-and-forget)
ws = websocket.create_connection(ws_url, header={"Cookie": cookie_header})
ws.send(json.dumps(["stdin", f"{cmd}\n"]))
ws.close()핵심은 fire-and-forget이라는 점이다. 명령을 보내고 WebSocket을 닫는다. 실행 결과를 직접 받아올 방법이 없으니 ComfyUI가 실제로 떴는지는 health check로 따로 확인해야 한다.
GPU 상태는 별도로 만들어둔 API(localhost:9090/status)에서 가져온다. VRAM 사용량, 프로세스 목록, idle 여부를 JSON으로 내려준다.
def cmd_start() -> int:
if check_comfyui():
print("ComfyUI is already running.")
return 0
gpu = check_gpu()
if not gpu.get("is_idle", False):
print("ERROR: GPU is busy.")
return 1
jupyter_send_command(COMFYUI_START_CMD)
# health check: 5초 간격, 최대 60초
for elapsed in range(0, 60, 5):
time.sleep(5)
if check_comfyui():
print(f"ComfyUI is ready. (took ~{elapsed + 5}s)")
return 0이미 돌고 있으면 바로 리턴하고, GPU가 바쁘면 에러를 뱉는다. 정상이면 JupyterLab으로 시작 명령을 보내고 health check 루프에 들어간다. 5초마다 /system_stats를 찌르면서 최대 60초를 기다린다.
stop은 반대다. kill 명령을 보내고 VRAM이 실제로 해제됐는지 GPU API로 확인한다.
def cmd_stop() -> int:
jupyter_send_command(COMFYUI_STOP_CMD)
# 프로세스 종료 대기 후 VRAM 확인
gpu = check_gpu()
if gpu and gpu.get("is_idle", False):
print("GPU is idle. VRAM freed.")처음엔 ComfyUI의 /interrupt API를 호출하면 VRAM이 해제될 줄 알았다. 안 된다. /interrupt는 현재 작업만 중단할 뿐 프로세스 자체는 살아 있고, 모델을 GPU 메모리에 캐시해둔다. 20GB짜리 모델이 VRAM에 그대로 남아 있으면 vLLM을 올릴 수가 없다.
결국 프로세스를 kill해야 한다. 우아하진 않지만 확실하다.
JupyterLab WebSocket으로 명령을 보내면 실행 결과를 돌려받을 수 없다. stdout을 읽으려면 WebSocket을 열어두고 메시지를 파싱해야 하는데, 터미널 출력에는 ANSI 이스케이프 시퀀스가 섞여 있어서 파싱이 까다롭다. 그냥 "보내고 잊어버리기"로 가고 결과는 health check로 간접 확인하는 게 훨씬 깔끔했다.
ComfyUI 프로세스가 뜨는 건 금방인데, 첫 워크플로우 실행 시 12GB + 7.5GB 모델을 디스크에서 GPU로 올려야 한다. 약 400초. health check timeout을 60초로 잡았는데 이건 "프로세스가 떴는지"만 확인하는 거라 괜찮다. 모델 로딩은 첫 번째 이미지 생성 요청에서 일어나니까.
한번 로딩되면 그 다음부턴 장당 4초 정도면 된다. 그래서 배치 작업 시에는 첫 이미지만 기다리면 나머지는 순식간이다.
돌이켜보면 이 스크립트의 핵심은 "새로운 걸 만들지 않았다"는 점이다. GPU 상태 확인은 이미 있던 API를 썼고, 원격 실행은 이미 돌아가던 JupyterLab의 WebSocket을 빌렸다. health check도 ComfyUI가 원래 제공하는 /system_stats를 그대로 활용했다.
새 데몬을 띄우거나 Ansible 플레이북을 짜는 대신, 이미 존재하는 인터페이스 세 개를 275줄 파이썬으로 엮은 것이다. 홈랩처럼 리소스가 한정된 환경에서는 이런 접근이 유지보수 부담을 크게 줄여준다.
이 스크립트 덕에 블로그 이미지 생성을 자동화하는 파이프라인까지 연결할 수 있게 됐다. 그 이야기는 다음 글에서.