Intro

ComfyUI 는 노드 기반의 UI에서 쉽게 워크플로우를 구성하여 이미지 및 비디오를 생성할 수 있는 툴 입니다.

하지만 ComfyUI 를 SaaS 플랫폼으로서 제공하는 것은 무리입니다. 서비스를 만들고자 한다면 단순히 텍스트에서 이미지를 생성하는 간단한 워크플로우가 아니라 서비스의 목적에 따라 생성 앞, 뒤 단계에서 추가적인 작업이 필요 할 것입니다.

결국 이것을 UI 에서 구현한다고 하면 꽤 많은 숙련도를 필요로 하고 커스텀 노드에서 원하는 기능이 없다면 직접 코드를 작성해서 커스텀 노드를 개발해야 하는 상황도 발생합니다.

결국 ComfyUI 에서 작업한 워크플로우를 SaaS 플랫폼으로 제공하기 위해서는 ComfyUI 서버를 어딘가에서 잘 배포하고 API 로 어플리케이션과 통신할 수 있어야 합니다.

저는 사내에서 웹 기반 SaaS 플랫폼을 개발중이며 기존에 개발 환경(로컬 GPU 워크스테이션)에서 배포중이던 ComfyUI 서버를 실제 서비스 운영을 위해 클라우드 환경에 배포해야 하는 상황입니다.

오늘 포스팅에서는 이 ComfyUI 를 어떻게 클라우드 환경에 배포하여 어플리케이션에서 활용할 수 있는지에 대해 공유하고자 합니다.


Decision making

ComfyUI as Serving Frameworks?

ComfyUI의 Kickpoint

  • 활발한 커뮤니티
  • 개발속도

오픈소스 이미지 생성분야에서 무언가를 실험하거나 새로운 모델 및 기술을 구현할때 가장 먼저 ComfyUI에서 동작하도록 제공하는 추세입니다. 그리고 그 기술을 기존 워크플로우에서 일부만 수정하여 json파일로 export 하면 바로 서비스에 적용할 수 있어서 개발속도를 단축할 수 있다는 큰 장점이 있습니다.

Diffusers 라이브러리를 FastAPI, Triton, BentoML 같은 모델 서빙을 위한 프레임워크와 결합하여 좀 더 모델 서빙에 최적화된 API를 개발할 수 있지만 이 방식을 선택하게 되면 코드베이스로 이미지 생성 워크플로우를 구현해야 하기 때문에 개발 속도가 느려질수 밖에 없습니다.

스타트업에서 적은 인원으로 프로젝트를 진행하는 입장에서 개발속도를 단축하면서 빠르게 새로운 기술을 적용해서 사용자에게 제공하는 것이 중요하다고 판단되어 ComfyUI 서버 자체를 배포하는 방식을 선택하였습니다.


Serverless vs On-demand

서비스 측면

  • 해당 서비스를 필요로 하는 사람은 특정 기업의 디자이너 혹은 개인사업자이기 때문에 동시접속자가 많지 않습니다.
  • 해당 서비스는 Midjourney 처럼 이미지 생성이 메인인 서비스가 아니라 3D 에디터 기능을 포함한 웹 어플리케이션이기 때문에 이미지 생성은 서비스의 일부입니다.

모델 배포 측면

  • ComfyUI에서는 Queue 방식으로 요청을 처리합니다. 요청을 배치단위로 처리하지 못하고 하나씩 처리해야 하기 때문에 10번째에 요청한 사람은 앞에 9개의 queue가 모두 끝나야 실행되는 방식으로 동작합니다.
  • 이러한 지연시간을 방지하기 위해 사용자별로 ComfyUI 엔드포인트를 제공한다면 gpu가 1개인 워커가 있다고 가정했을때 서비스에 접속한 사용자에게 1개의 워커를 할당해야 하는 상황이 발생합니다.

위의 상황에서는 사실 반 강제적으로 Serverless 방식을 선택할 수 밖에 없었습니다. 언제 들어올지 모르는 요청을 위해 비싼 gpu 인스턴스를 준비시켜 놓기 위해 낭비되는 비용이 너무 컸습니다. 결국 ComfyUI 서버를 여러 워커에 배포하고 들어온 요청을 처리할때만 비용을 지불하게 되는 Serverless 방식을 선택했습니다.


Which Cloud?

웹 어플리케이션을 배포하려고 하는 클라우드 환경은 AWS 이기 때문에 AWS에서 모델서버를 배포하면 좋겠지만 AWS에서 제공하는 GPU는 생각보다 다양하지 않으며 비용도 충분히 저렴하지 않았습니다. 또한 Serverless를 고려한다고 하더라도 AWS의 Fargate 서비스는 여전히 GPU 인스턴스를 제공하지 않기 때문에 다른 클라우드 환경을 고려해야 했습니다.

제가 원하는 조건은 다음과 같았습니다.

  1. 상대적으로 저렴한 GPU 인스턴스
  2. Serverless 지원
  3. Auto Scaling 지원

이 세가지 조건에 부합하면서 안정적인 서비스를 제공할 수 있는 플랫폼은 RunPod와 Modal 이었습니다. 이중에 개인적으로 느끼기에 조금 더 커뮤니티가 활발하고 CI/CD를 적용하기 편한 RunPod를 선택하였습니다.


Prerequisites

  1. Dockerfile
  2. ComfyUI 및 Custom Node install 목록 리스트업 (requirements.txt)
  3. GitHub Connections in RunPod

Dockerfile

FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04

ENV DEBIAN_FRONTEND=noninteractive \
    PIP_PREFER_BINARY=1 \
    PYTHONUNBUFFERED=1 \
    CMAKE_BUILD_PARALLEL_LEVEL=8 \

RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        software-properties-common \
        git \
        wget \
        libgl1 \
        git-lfs && \
    add-apt-repository ppa:deadsnakes/ppa && \
    apt-get update && \
    apt-get install -y --no-install-recommends \
        python3.12 \
        python3-pip && \
    ln -sf /usr/bin/python3.12 /usr/bin/python && \
    ln -sf /usr/bin/pip3 /usr/bin/pip && \
    git lfs install && \
    apt-get autoremove -y && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir --upgrade pip wheel setuptools && \
    pip install --no-cache-dir \
        huggingface_hub[hf_transfer] \
        torch \
        torchvision \
        torchaudio \
        xformers \
        --index-url https://download.pytorch.org/whl/cu124 \
        --extra-index-url https://pypi.org/simple

COPY ComfyUI /ComfyUI

# 필요로 하는 모델 경로에 맞춰 수정해 주세요.
RUN HF_HUB_ENABLE_HF_TRANSFER=1 huggingface-cli download <repo path> <file path> --local-dir /ComfyUI/models

COPY ComfyUI/requirements.txt /tmp/requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install --no-cache-dir -r /tmp/requirements.txt

COPY start.sh /start.sh
COPY rp_handler.py /rp_handler.py

CMD ["bash", "/start.sh"]

위 Dockerfile에 대해 몇가지 살펴보겠습니다.

  1. Model-in-Docker
    • Huggingface 로부터 모델파일을 다운로드받는 부분이 있는 것으로 보아 도커파일에 모델을 포함시키는 Model-in-Docker 방식이라는 것을 짐작할 수 있습니다. 이 방식을 사용하면 모델 파일을 다운로드 받는 시간과 무거워진 이미지 크기로 인해 빌드 시간이 오래 걸리는 단점이 있으며 리소스적인 측면에서도 문제가 될 수 있습니다.
    • 이 문제를 벗어나기 위헤 Runpod의 Network Volume을 활용하여 NFS 방식으로 워커에서 해당 Volume에 있는 모델을 로드하는 방식으로 해결할 수 있지만, 테스트 결과 해당 방식을 사용하면 제가 사용하는 모델 기준으로 Cold Start가 1분 40초가 넘어가는 문제가 있었습니다.
    • 최근 RunPod에서 Beta 버전으로 이미지 CI/CD 자동화를 위해 Github Actions와 비슷한 기능을 자체적으로 제공하는 Deploying with GitHub 기능이 생겼습니다. 이 기능을 활용하면 별도의 추가비용 없이 특정 브랜치에 대한 push 에 대해 해당 브랜치의 Dockerfile 을 기반으로 자동으로 빌드 및 엔드포인트 워커들이 rolling update 되도록 하는 기능을 제공합니다.
  2. Custom Node
    • Custom Node를 사용하고 있다면 어떻게 이것들을 containerize 할지에 대한 고민이 필요합니다. 자체 개발한 Custom Node가 아니라면 모두 각각의 github repository가 있기 때문에 해당 폴더 내부에는 모두 .git 폴더를 보유하고 있습니다.
    • snapshot 방식으로 Custom Node를 Install 하는 방식을 사용할 수도 있지만 저는 배포의 일관성과 재현성 보장하고 싶어서 Custom Node의 .git 폴더를 제거하고 컨테이너에 포함시키는 방식을 선택했습니다.

         # 삭제할 .git 디렉토리 확인하기
         find custom_nodes -type d -name ".git"  
         # .git 디렉토리 삭제하기
         find custom_nodes -type d -name ".git" -exec rm -rf {} +
      
    • 다만 이 방식을 사용하기 위해서는 Custom Node를 실행시킬 수 있는 라이브러리가 requirements.txt에 정리되어 있어야 합니다.

rp_handler.py

import runpod
import json
import uuid
import base64
import websockets
import urllib.request
import urllib.parse
import time
import requests
import asyncio

COMFY_HOST = "127.0.0.1:8188"
COMFY_API_AVAILABLE_INTERVAL_MS = 50
COMFY_API_AVAILABLE_MAX_RETRIES = 500
WEBSOCKET_TIMEOUT = 180

# 중간 생략
async def handler(job):
    job_input = job["input"]

    if not job_input:
        return {"error": "No input provided."}

    if isinstance(job_input, str):
        job_input = json.loads(job_input)

    workflow = job_input.get("workflow")

    if not workflow:
        return {"error": "Missing 'workflow' parameter in input."}

    client_id = str(uuid.uuid4())

    try:
        output = await asyncio.wait_for(
            get_images_over_websocket(workflow, client_id, job),
            timeout=WEBSOCKET_TIMEOUT
        )
    except asyncio.TimeoutError:
        return {
            "status": "timeout",
            "images": None
        }
    except Exception as e:
        return {"error": str(e)}

    runpod.serverless.progress_update(job, "Finished. Returning results.")
    return output


if __name__ == "__main__":
    check_server(
        f"http://{COMFY_HOST}",
        COMFY_API_AVAILABLE_MAX_RETRIES,
        COMFY_API_AVAILABLE_INTERVAL_MS,
    )

    runpod.serverless.start({"handler": handler, "return_aggregate_stream": True})

rp_handler는 실제 클라이언트가 보낸 요청을 처리하는 함수입니다. serverless endpoint로 요청이 들어오면 이 파일내부의 handler 함수가 실행되는 구조로 설계되었습니다. 때문에 워커에서 실행되는 모든 코드는 이 함수 내부에 작성되어야 합니다. 여기서 몇가지 포인트에 대해 좀 더 살펴보겠습니다.

  1. 입력 형식
    • handler에서는 json을 받도록 설계되어 있으며 저는 아래와 같은 형식으로 설정했습니다.
    • {
       "input": {
       "workflow": workflow_api_json
       }
      }
      
    • workflow_api_json 에는 ComfyUI 에서 작업한 워크플로우를 api 형태로 export 한 json 파일이 입력되어야 합니다.
  2. ComfyUI WebSocket and polling
    • ComfyUI 에서는 WebSocket을 통해 현재 Queue의 상태를 가져올 수 있습니다. 하지만 여기서 ComfyUI 와 통신할 수 있는 주체는 핸들러 뿐이기 때문에 localhost:8188(ComfyUI 서버) 주소로 웹소켓을 연결하여 ComfyUI 서버로부터 이벤트(generation progress)를 가져옵니다.
    • 이후 핸들러에서 runpod.serverless.progress_update() 함수로 상태를 기록하고 클라이언트측에서 status polling 방식을 통해 비로소 클라이언트측에서 현재 작업상태를 업데이트 할 수 있습니다.
    • 이 방식으로 지속적으로 현재 progress 상태 혹은 latent image 를 클라이언트 어플리케이션에서 렌더링하여 좀 더 나은 사용자 경험을 제공할 수 있습니다.


RunPod Serverless

Runpod의 Serverless기능을 사용하기 위해서는 Runpod 콘솔에 접속하여 로그인과 Credit을 충전해야 합니다. (회원가입시 기본으로 1달러 크레딧이 충전되어 있음)

  1. 콘솔 좌측 사이드바에서 Serverless 메뉴에서 New Endpoint 버튼을 누릅니다.

    create endpoint

    엔드포인트를 생성할때 runpod 측에서 사전에 미리 정의된(vllm, stable diffusion) 이미지를 사용할 수 있으며 직접 커스텀 빌드한 도커허브의 이미지를 사용할 수 있습니다.

    여기서 저는 GitHib Repository를 활용하여 이미지를 자동으로 빌드하기 위해 GitHub Repo를 클릭하겠습니다.

    미리 Github 계정을 연동해 두었다면 계정의 모든 Repository가 보이게 됩니다. 여기서 사용할 repo를 선택하고 다음으로 넘어갑니다.

  2. 원하는 브랜치와 도커파일의 경로를 입력합니다. 저는 base라는 이름의 브랜치의 루트경로에 있는 Dockerfile 을 활용하겠습니다.

    create endpoint2

  3. 다음으로 엔드포인트 이름, GPU, 배포 옵션등을 설정합니다.

    create endpoint3

    각 GPU 카드에 보이는 것 처럼 비용은 초 단위로 계산되어 청구됩니다. 이는 워커가 실행되는 시간에 따라 비용이 청구되기 때문입니다.

    Max Workers 는 요청을 처리할 수 있는 대기중인 워커의 수를 의미합니다. 쉽게말해 3으로 설정하면 3개의 ComfyUI 서버가 준비된다는 의미입니다. (워커당 1개의 ComfyUI가 서버가 실행된다는 가정)

    Active Workers는 항상 Ready 상태인 워커를 의미합니다. 설정한 워커 수만큼의 워커들은 다운되지 않기 때문에 모든 시간에 대해 비용이 청구됩니다. Cold Start 문제에 예민한 task라면 고려해볼 수 있습니다.

  4. Advanced Options

    create endpoint4

    Advanced Options에서는 워커의 리전을 특정할 수 있으며 Network Volume을 연결할 수 있습니다.

    Network Volume을 사용하면 단점이 Network Volume을 생성할때 지정한 리전으로 워커의 리전이 고정되는 문제가 있습니다. 이렇게 되면 GPU 가용성이 안정적이지 못하기 때문에 상황에 따라 워커가 새로 생성되지 못하는 문제가 발생할 수 있습니다.

    Queue Delay는 어떤 요청으로 인해 Idle -> Ready 상태가 된 워커를 언제 다시 Idle 상태로 내릴지에 대한 시간을 설정합니다. 이 시간을 늘릴수록 cold start를 줄일 수 있지만 저 시간동안 비용은 그대로 청구되므로 상황에 맞게 조절해 주시면 됩니다.

  5. Build & Check Workers

    엔드포인트를 생성하면 자동으로 이미지가 Build되는 것을 확인할 수 있습니다.

    빌드가 완료된 예시

    빌드가 정상 완료되면 이어서 설정한 워커 수만큼 워커가 생성되는 것을 확인할 수 있습니다.

    create endpoint5

    이미지에 보이는 것 처럼 동일한 GPU를 여러 리전에서 다른 스펙의 워커가 생성된 것을 볼 수 있습니다.


Application(Client) Side

RunPod 에서는 클라이언트에서 엔드포인트와 쉽게 통신하도록 여러가지(Go, GraphQL, JavaScript, Python) SDK를 지원합니다.

저는 Next.js 기반의 웹 어플리케이션을 개발하고 있어 JavaScript SDK를 적용하였습니다.

runpod progress

전처리 + 생성1 + 생성2 총 3개의 스텝으로 나뉘어 있으며 각 스텝의 progress 상태를 지속적으로 polling하여 진행상황을 사용자에게 보여주도록 설계하였습니다.


Test

runpod request

몇번의 request 테스트 결과입니다.

왼쪽에서 순서대로 Task ID, Status, Delay Time, Execution Time, Worker ID 입니다.

delay time은 워커가 준비되는 시간, 즉 cold start이며 execution time은 task가 실행되는 시간입니다.

워커별로 첫 delay time이 평균 13초정도 소요되며 execution time은 ComfyUI 실행 + 모델 로딩 + 생성시간으로 인해 1분정도 소요되었습니다. 이후에는 정상화되어 deplay time과 execution time이 안정적으로 수렴되는 것을 확인할 수 있습니다.

지연시간과 실행시간은 GPU 스펙과 리전에 따라 차이가 있기 때문에 여러 방법의 테스트가 필요합니다.


Conclusion

RunPod Serverless를 테스트하면서 좋았던 점은 GitHub을 활용한 CI 자동화가 가능하다는 점이었습니다.

특히 Model-in-Docker 방식을 사용하기 때문에 이미지의 크기 문제로 Github Actions를 사용하기도 제한적이었으며 자체 서버에서 CI를 위해 별도의 리소스와 관리가 필요했을 것입니다.

하지만 아직 베타버전이어서 Dockerfile에서 secret(ex. Huggingface token)을 사용할 수 없으며 배포중 에러가 발생하거나 배포 시간이 오래 걸리는 문제가 있어 좀 더 지켜봐야 할 것 같습니다.

Keep Going.

Updated:

Leave a comment