happyin.work / blog

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

Un flujo de ComfyUI como servicio público, sin exponer ComfyUI

Autora: Anastasiia Butova, ingeniera de ComfyUI y modelos de difusión, Belgrado · publicado 2026-09-23

Traducción de la versión en inglés: English

El estudio de coches de /mashinki/ permite a cualquiera meter un coche en una sala de exposición, pulsar renderizar y recibir un fotograma del pipeline real: FLUX.2 Klein 9B con nuestro LoRA de restauración de geometría, en la máquina con GPU de mashinki, en 30 a 60 segundos. El ComfyUI que lo renderiza no está publicado en internet en absoluto. Entre los dos hay un pequeño servicio en Python, y este post trata de lo que acepta, lo que rechaza y el túnel que no funcionó donde lo puse al principio.

Todo pipeline de ComfyUI en producción que he construido funciona así: el grafo lo envía código, no se hace clic en el editor (más sobre ese tipo de trabajo en /comfyui/). Normalmente quien llama es un backend. Aquí quien llama es cualquiera con un navegador, lo que agudiza cada pregunta de diseño.

Por qué /prompt nunca da a internet

La API HTTP de ComfyUI está pensada para la persona que es dueña de la máquina. POST /prompt recibe un grafo entero en formato API y lo ejecuta. Un grafo es un programa: un nodo cargador toma un nombre de archivo, un nodo de guardado escribe un archivo, y cada nodo personalizado instalado en el servidor es una función más que quien llama puede invocar con los argumentos que quiera. Así que quien pueda llegar a /prompt puede cargar cualquier modelo de la máquina, leer lo que el proceso de ComfyUI pueda leer y escribir donde pueda escribir. No hay ningún campo de esa petición que puedas validar, porque la petición es el código.

Para mí esto no es teórico. En febrero de 2026, un contenedor de ComfyUI en un servidor de GPU en el que trabajé estaba minando Monero. Su puerto de API estaba abierto a internet sin autenticación; alguien le envió un flujo con un nodo de eval de un paquete de terceros, y su Python reescribió el propio execution.py de ComfyUI. Escribí el runbook de limpieza, y la historia completa está en el post sobre nodos personalizados en producción. En abril, Censys describió una campaña contra ComfyUI expuesto a internet, del que todavía encuentra más de mil instancias, y el mismo nodo está en su lista.

Así que el ComfyUI de la caja de mashinki no se publica en ningún sitio. El servicio lo alcanza en la propia caja, en 127.0.0.1, y el público solo habla con el servicio, que no tiene forma de expresar un grafo.

Ruta de la petición para un render: navegador, túnel de Cloudflare en un host de relevo, Tailscale, y después studio-api en la caja de GPU, que responde 413, 400, 429 o 503 o construye un único grafo fijo para ComfyUI en 127.0.0.1; internet no tiene ruta hacia /prompt

Seis valores, y el grafo se construye de nuestro lado

El botón de renderizar envía POST /compose con cinco campos. El servicio conoce seis, porque una sala y tu propio fondo son alternativas. Cada uno está acotado antes de tocar nada:

campo aceptado
car uno de cuatro ids, usado como clave de diccionario
scene uno de siete ids de sala, usado como clave de diccionario; obligatorio salvo que se envíe bg
x número, limitado a 12–88 (por ciento del ancho del encuadre)
y número, limitado a 40–96 (la línea del suelo, por ciento de la altura)
scale número, limitado a 34–94 (ancho del coche, por ciento del encuadre)
bg imagen fija opcional en base64, ver más abajo

Un coche o una sala desconocidos, un JSON que no se parsea o una coordenada que no es un número reciben un 400. Los campos que el servicio no conoce nunca se leen. La comprobación es tan simple como parece; este es el código de producción:

car = str(spec.get("car", ""))
scene = str(spec.get("scene", ""))
bg = spec.get("bg")
if bg is not None and not isinstance(bg, str):
    return self._json(400, {"error": "bg must be a base64 string"})
if car not in CARS:
    return self._json(400, {"error": "unknown car"})
# A hall is required only when the caller did not bring their own plate.
if not bg and scene not in SCENES:
    return self._json(400, {"error": "unknown scene"})
try:
    x = float(spec.get("x", 50)); y = float(spec.get("y", 82)); sc = float(spec.get("scale", 62))
except (TypeError, ValueError):
    return self._json(400, {"error": "x, y and scale must be numbers"})
x = max(12.0, min(88.0, x)); y = max(40.0, min(96.0, y)); sc = max(34.0, min(94.0, sc))

El grafo es un diccionario de Python dentro del servicio, y todos sus valores menos uno vienen de producción y no de mí. Los archivos de modelo, el LoRA en el paso 2.000, 6 pasos, el muestreador euler con el planificador beta y el prompt de producción palabra por palabra se copiaron el 28 de agosto del contenedor del despachador en vivo, y el código indica de qué archivo sale cada uno. La influencia de quien llama sobre ese grafo es pequeña e indirecta: el compuesto que carga es un archivo que el servicio escribe con un nombre que genera, y el tipo de carrocería y las dimensiones del prompt salen de una tabla de cuatro entradas, los valores que nuestro servicio de identidad de coches devolvió para esos cuatro fotogramas. La semilla también es nuestra.

Una diferencia con producción es deliberada. Producción ejecuta el adaptador a 0,7. En la web lo ejecuto a 1,0, porque un fotograma de demostración puede cargar más las tintas que un lote de concesionario, y la página muestra ese número mientras se renderiza el trabajo.

Rechazar pronto, decodificar tarde

Gran parte del razonamiento fue al orden de las comprobaciones. Se ejecutan de la más barata a la más cara:

  1. Content-Length por encima de 8 MB: 413, sin parsear el cuerpo.
  2. Una dirección que ya ha gastado su hora y envía más de 2 KB: 429, también sin parsear el cuerpo. Esta comprobación no consume nada.
  3. Parsear el JSON y validar los campos.
  4. El presupuesto: ocho trabajos por dirección y hora, o 429. La dirección es la que Cloudflare pasa en CF-Connecting-IP. Un trabajo se cuenta aquí, así que un 503 en el paso siguiente también gasta uno de los ocho.
  5. La cola: se ejecuta un trabajo y como máximo tres esperan, o 503.
  6. Solo ahora, si se envió un fondo, decodificarlo.

Una imagen es lo más caro que hace el servicio por un desconocido, y no le debemos ese gasto a alguien a quien íbamos a rechazar de todas formas.

El 413 me enseñó algo que no esperaba. Si el servicio responde antes de que quien llama haya terminado de subir, la subida se corta y el túnel de delante devuelve su propio 502. Medido: un cuerpo de 20 MB recibió un 502 de Cloudflare, no nuestro 413, así que quien llamaba veía una pasarela rota en lugar de la razón. Ahora el servicio lee el cuerpo y lo descarta en fragmentos de 64 KB, hasta 64 MB, y después responde con su propio 413; para algo declarado más grande, se detiene en 64 MB, responde y cierra la conexión. Un timeout de socket de 20 segundos corta la conexión de quien se queda callado a mitad de la subida.

Lo único que puede enviar quien llama

Un visitante puede traer una foto de su propia sala. Llega en base64 dentro del mismo JSON y nunca se reenvía tal como llegó. Recortado de save_plate, con tres comentarios cortos míos añadidos:

im = Image.open(io.BytesIO(raw))              # reads the header, not the pixels
if im.format not in PLATE_FORMATS:            # JPEG, PNG, WEBP
    raise PlateRefused(400, "background must be a jpeg, png or webp still")
if getattr(im, "n_frames", 1) > 1:
    raise PlateRefused(400, "background must be a still image, not an animation")
w, h = im.size
if w < 64 or h < 64:
    raise PlateRefused(400, "background is too small to place a car in")
if w > MAX_SIDE or h > MAX_SIDE:              # 6,000 px
    raise PlateRefused(413, "background is larger than %d px on a side" % MAX_SIDE)
im = im.convert("RGB")                        # decodes here, inside the bound checked above
im.save(path, "JPEG", quality=92)             # our encoder, our bytes, from this point on

El tamaño se lee de la cabecera antes de descomprimir un solo píxel, así que un archivo de 79 KB que declara 8000×8000 se rechaza sin haberse inflado nunca. Solo se aceptan tres formatos, porque son los que un selector de archivos de navegador da realmente para una fotografía, y cualquier otro decodificador que lleva PIL es superficie que abriríamos en beneficio de nadie. Una animación se rechaza, así que el fotograma dos de cualquier cosa nunca se decodifica. Después la imagen se recodifica como nuestro propio JPEG. Lo que hubiera escondido el contenedor, una carga útil tras los datos de la imagen o un bloque de metadatos hostil, no sobrevive, porque nada de eso son píxeles.

El fondo se escribe con el id del trabajo y se borra cuando el trabajo termina, sea cual sea el resultado. Un trabajo todavía en cola cuando el proceso se reinicia nunca llega a su limpieza, así que un barrido elimina cualquier placa de más de una hora.

Un detalle más cambió el prompt. Para nuestras siete salas, el prompt lleva una pista de fondo donde hemos medido una: las dos salas oscuras reciben "preserve the dark drapes and dark ceiling exposure". Para la foto de un desconocido la pista está vacía en lugar de adivinada. No hemos medido nada de esa sala, y una pista equivocada empujaría la exposición de una habitación que el modelo ya puede ver.

Qué se guarda, y durante cuánto tiempo

Un id de trabajo son 16 caracteres hexadecimales recortados de un UUID aleatorio. /job/<id> y /img/<id>.jpg coinciden con un patrón estricto, y /img/ no sirve nada que no sea un trabajo terminado de este proceso. El JPEG que sirve se guarda una hora; un barrido cada cinco minutos lo borra y olvida el trabajo. El compuesto de 2048 píxeles que entró en la carpeta de entrada de ComfyUI se borra en un bloque finally. Antes de esa corrección, un render fallido dejaba su compuesto allí para siempre.

Access-Control-Allow-Origin está fijado a https://happyin.work. Conviene ser exacto sobre lo que eso aporta. CORS solo impide que un script de otra web lea nuestras respuestas en un navegador; no impide curl. Lo que de verdad limita el abuso es la entrada estrecha, el presupuesto por hora y la cola. CORS solo evita que la página de otra persona maneje el estudio como si fuera su propio backend.

Estados que puede mostrar la página

POST /compose responde al momento con 202 y un id de trabajo; el trabajo en la GPU ocurre después. La página consulta GET /job/<id> cada 2,5 segundos, y la etiqueta bajo el fotograma es el propio estado de la caja: waiting, compositing, queued-on-gpu, rendering, y luego done o error. Quería que las palabras salieran de la caja, porque un temporizador y la GPU no coinciden en cuanto la GPU está ocupada. La barra de progreso solo sigue el tiempo transcurrido, con una curva que nunca llega al 100 %: solo un fotograma devuelto puede terminarla.

Dentro, el servicio consulta /history de ComfyUI cada medio segundo. Antes era cada cuatro segundos. El despachador de producción consulta cada 0,2 s, y con cuatro el visitante esperaba hasta cuatro segundos extra después de que la GPU ya hubiera terminado, lo que en un pase de unos 45 segundos era la mayor parte de la sobrecarga que se veía. Dos pases medidos el 28 de agosto tardaron 29,5 s y 57,2 s.

Si la caja rechaza o se queda en silencio, la página compone el coche en el navegador y lo indica en el fotograma. Un render devuelto aparece etiquetado como rendered on the gpu, uno local como browser composite · draft, y el pie da el motivo según el código de estado: se han gastado los ocho fotogramas de esta hora, la cola de la GPU está llena, el archivo es demasiado grande, la caja rechazó este fotograma o la GPU no respondió. Tras cuatro minutos de consultas, la página deja de esperar. Un visitante nunca debería confundir un pegado del navegador con una salida del modelo.

El túnel, y dónde no pudo funcionar

El nombre público es studio.happyin.app, servido a través de un túnel de Cloudflare. El lugar obvio para cloudflared era la propia caja de GPU: un túnel abre la conexión hacia fuera, así que el NAT no debería importar. Sí importó. Detrás del NAT del host de mashinki, cloudflared registraba conexiones y las perdía en segundos, sobre QUIC (failed to dial to edge with quic: timeout: no recent network activity) y también sobre http2, y el edge respondía 1033 y 502 todo el rato.

No luché contra ese NAT. El túnel ahora corre en otra máquina nuestra con una conexión estable, como unidad de systemd alrededor de la imagen oficial de cloudflared con red del host, y llega a la caja por Tailscale. El servicio no sabe ni le importa dónde corre el túnel.

Aun así, el NAT no fue el error más caro. Antes de que nada de esto funcionara, había anotado que el bloqueo era un ámbito de token de Cloudflare que faltaba en la zona happyin.work. No lo era. Un token que ya tenía podía crear túneles y registros DNS en otra cuenta, y el endpoint nunca tuvo que vivir bajo happyin.work: un nombre de host en happyin.app hace el trabajo. El bloqueo era una suposición sobre mi propio acceso, y debí comprobar qué podían hacer mis credenciales antes de escribir "bloqueado".

Límites

  • El presupuesto y la tabla de trabajos viven en memoria. Un reinicio olvida quién gastó qué y descarta los trabajos en cola; el barrido solo garantiza que sus placas subidas no sobrevivan a la hora.
  • Releyendo el código para este post, encontré un archivo que nada eliminaba: el PNG que el nodo de guardado de ComfyUI escribe en su carpeta de salida, incluidos los renders sobre la foto propia de un visitante. El servicio ahora lo borra justo después de copiar el fotograma. El primer intento falló sin ruido, porque ComfyUI corre como root y crea esa carpeta propiedad de root; el borrado lanzaba un error de permisos y el código lo tragaba. Contar la carpeta antes y después de un render fue lo que lo detectó (7 archivos, luego 8). La carpeta ahora pertenece al usuario del servicio, y un borrado fallido se imprime en lugar de ignorarse.
  • Los renders de coches se ejecutan de uno en uno. Con un trabajo en marcha y tres esperando, un quinto trabajo recibe un 503 y el visitante recibe un borrador.
  • El timeout de socket detecta el silencio, no un goteo lento, así que un hilo podría quedar retenido de esa forma. El cuerpo ahora tiene un plazo propio, 90 segundos para toda la petición en lugar de 20 para cada lectura.
  • No hay cuentas. Cualquiera puede gastar ocho fotogramas por hora desde una dirección, lo cual, para una demostración, es el precio adecuado.

Lo que volvería a hacer

Construir el grafo a partir de constantes copiadas del pipeline en vivo, con un comentario que nombre el archivo de cada valor; cuando cambie producción, el diff lo mostrará. Comprobar en orden de coste y decodificar al final. Recodificar todo lo que suba un desconocido: son unas pocas líneas, y elimina toda una categoría de preguntas sobre qué más había en el archivo. Y etiquetar cada salida según de dónde salió realmente.

Y mantener ComfyUI fuera de internet. Nada de su API se diseñó para desconocidos, y el servicio estrecho que lo protege es un precio pequeño por ello. Si necesitas que un flujo propio se convierta en una API o en una herramienta pública, ese es el trabajo descrito en /comfyui/.

Más artículos

En otras partes del sitio

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face