happyin.work / blog

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

Um workflow do ComfyUI como serviço público, sem expor o ComfyUI

Autora: Anastasiia Butova, engenheira de ComfyUI e modelos de difusão, Belgrado · publicado em 2026-09-23

Tradução da versão em inglês: English

O estúdio de carros em /mashinki/ deixa qualquer pessoa colocar um carro num showroom, apertar renderizar e receber de volta um quadro do pipeline real: FLUX.2 Klein 9B com nossa LoRA de restauração de geometria, na máquina GPU do mashinki, em 30 a 60 segundos. O ComfyUI que faz a renderização não é publicado na internet de forma alguma. Entre os dois fica um pequeno serviço em Python, e este post é sobre o que ele aceita, o que recusa e o túnel que não funcionou no primeiro lugar em que o pus.

Todo pipeline de ComfyUI em produção que construí roda assim: o grafo é enviado por código, não clicado no editor (mais sobre esse tipo de trabalho em /comfyui/). Normalmente quem chama é um backend. Aqui quem chama é qualquer pessoa com um navegador, o que deixa todas as perguntas de projeto mais agudas.

Por que o /prompt nunca fica exposto à internet

A API HTTP do ComfyUI é feita para quem é dono da máquina. POST /prompt recebe um grafo inteiro em formato de API e o executa. Um grafo é um programa: um nó de carregamento recebe um nome de arquivo, um nó de salvamento escreve um arquivo, e cada nó customizado instalado no servidor é mais uma função que quem chama pode invocar com os argumentos que quiser. Então, quem consegue alcançar /prompt pode carregar qualquer modelo da máquina, ler o que o processo do ComfyUI conseguir ler e escrever onde ele conseguir escrever. Não há campo naquela requisição que você possa validar, porque a requisição é o código.

Para mim, isso não é teórico. Em fevereiro de 2026, um contêiner do ComfyUI num servidor de GPU com que eu trabalhava estava minerando Monero. A porta de API estava aberta à internet sem autenticação, alguém mandou um workflow com um nó de eval de um pacote de terceiros, e o Python dessa pessoa reescreveu o próprio execution.py do ComfyUI. Eu escrevi o runbook de limpeza, e a história completa está no post sobre nós customizados em produção. Em abril, a Censys descreveu uma campanha contra ComfyUI expostos à internet, da qual ainda encontra mais de mil instâncias, e o mesmo nó está na lista dela.

Então o ComfyUI da máquina do mashinki não é publicado em lugar nenhum. O serviço o alcança na própria máquina, em 127.0.0.1, e o público fala apenas com o serviço, que não tem como expressar um grafo.

Caminho da requisição de uma renderização: navegador, túnel Cloudflare em um host de relay, Tailscale, depois studio-api na máquina GPU, que responde 413, 400, 429 ou 503 ou monta um grafo fixo para o ComfyUI em 127.0.0.1; a internet não tem rota até /prompt

Seis valores, e o grafo é montado do nosso lado

O botão de renderizar envia POST /compose com cinco campos. O serviço conhece seis, porque um salão e o seu próprio fundo são alternativas. Cada um é limitado antes de tocar em qualquer coisa:

campo aceito
car um dos quatro ids, usado como chave de dicionário
scene um dos sete ids de salão, usado como chave de dicionário; obrigatório a menos que bg seja enviado
x número, limitado a 12–88 (por cento da largura do quadro)
y número, limitado a 40–96 (a linha do chão, por cento da altura)
scale número, limitado a 34–94 (largura do carro, por cento do quadro)
bg imagem still opcional em base64, veja abaixo

Um carro ou salão desconhecido, um JSON que não faz parse, ou uma coordenada que não é número recebe um 400. Campos que o serviço não conhece nunca são lidos. A checagem é tão simples quanto parece; este é o código de produção:

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))

O grafo é um dicionário Python dentro do serviço, e todos os seus valores, menos um, vêm da produção, e não de mim. Os arquivos de modelo, a LoRA no passo 2.000, 6 passos, o sampler euler com o scheduler beta e o prompt de produção, palavra por palavra, foram copiados em 28 de agosto do contêiner do despachante em produção, e o código diz de qual arquivo veio cada um. A influência de quem chama sobre esse grafo é pequena e indireta: o composite que ele carrega é um arquivo que o serviço escreveu sob um nome que ele mesmo gerou, e o tipo de carroceria e as dimensões no prompt vêm de uma tabela de quatro entradas, os valores que nosso serviço de identidade de carros devolveu para esses quatro quadros. A semente também é nossa.

Uma diferença em relação à produção é deliberada. A produção roda o adaptador em 0,7. No site eu rodo em 1,0, porque um quadro de demonstração pode forçar mais do que o lote de uma concessionária, e a página mostra esse número enquanto o job renderiza.

Recusar cedo, decodificar tarde

A maior parte do raciocínio foi na ordem das checagens. Elas rodam da mais barata para a mais cara:

  1. Content-Length acima de 8 MB: 413, sem nem parsear o corpo.
  2. Um endereço que já gastou a sua hora e envia mais de 2 KB: 429, também sem parsear o corpo. Esta checagem não consome nada.
  3. Parsear o JSON e validar os campos.
  4. O orçamento: oito jobs por endereço por hora, ou 429. O endereço é o que a Cloudflare passa em CF-Connecting-IP. Um job é contado aqui, então um 503 na etapa seguinte ainda consome um dos oito.
  5. A fila: um job roda e no máximo três esperam, ou 503.
  6. Só agora, se um fundo foi enviado, decodificá-lo.

Uma imagem é a coisa mais cara que o serviço faz por um desconhecido, e não devemos esse trabalho a alguém que já íamos recusar de qualquer forma.

O 413 me ensinou algo que eu não esperava. Se o serviço responde antes de o cliente terminar de enviar o upload, o upload é resetado e o túnel na frente responde com seu próprio 502. Medido: um corpo de 20 MB recebeu um 502 da Cloudflare, e não o nosso 413, então quem enviou viu um gateway quebrado em vez do motivo. Agora o serviço lê o corpo e o descarta em pedaços de 64 KB, até 64 MB, e então responde com o seu próprio 413; para qualquer coisa declarada maior, ele para em 64 MB, responde e fecha a conexão. Um timeout de socket de 20 segundos derruba quem fica em silêncio no meio do upload.

A única coisa que um cliente pode enviar

Um visitante pode trazer uma foto do próprio salão. Ela chega em base64 no mesmo JSON e nunca é repassada como chegou. Trecho de save_plate, com três comentários curtos meus acrescentados:

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

O tamanho é lido do cabeçalho antes que um único pixel seja descomprimido, então um arquivo de 79 KB que declara 8000×8000 é recusado sem nunca ser inflado. Só três formatos são aceitos, porque são os que um seletor de arquivos de navegador realisticamente entrega para uma fotografia, e cada outro decodificador que o PIL carrega é superfície que abriríamos sem benefício para ninguém. Uma animação é recusada, então o segundo quadro de qualquer coisa nunca é decodificado. Depois a imagem é recodificada como nosso próprio JPEG. O que quer que estivesse escondido no contêiner, um payload depois dos dados da imagem ou um bloco de metadados hostil, não sobrevive, porque nada disso são pixels.

O fundo é gravado sob o id do job e apagado quando o job termina, com sucesso ou não. Um job ainda na fila quando o processo reinicia nunca chega à sua limpeza, então uma varredura remove qualquer fundo com mais de uma hora.

Um detalhe a mais mudou o prompt. Para os nossos sete salões, o prompt carrega uma dica de fundo onde medimos uma: os dois salões escuros recebem "preserve the dark drapes and dark ceiling exposure". Para a foto de um desconhecido, a dica fica vazia em vez de ser chutada. Não medimos nada sobre aquela sala, e uma dica errada empurraria a exposição de um cômodo que o modelo já consegue ver.

O que é guardado, e por quanto tempo

Um id de job tem 16 caracteres hexadecimais, cortados de um UUID aleatório. /job/<id> e /img/<id>.jpg casam com um padrão rígido, e /img/ não serve nada que não seja um job finalizado deste processo. O JPEG que ele serve é guardado por uma hora; uma varredura a cada cinco minutos o apaga e esquece o job. O composite de 2048 pixels que foi para a pasta de entrada do ComfyUI é apagado em um bloco finally. Antes dessa correção, uma renderização com falha deixava o composite lá para sempre.

Access-Control-Allow-Origin é fixo em https://happyin.work. Vale ser exato sobre o que isso compra. O CORS só impede que um script de outro site leia nossas respostas num navegador; não impede um curl. O que de fato limita o abuso é a entrada estreita, o orçamento por hora e a fila. O CORS só impede que a página de outra pessoa conduza o estúdio como se fosse o backend dela.

Estados que a página pode mostrar

POST /compose responde na hora com 202 e um id de job; o trabalho na GPU acontece depois. A página consulta GET /job/<id> a cada 2,5 segundos, e o rótulo sob o quadro é o próprio estado da máquina: waiting, compositing, queued-on-gpu, rendering, depois done ou error. Quis que as palavras viessem da máquina, porque um cronômetro e a GPU discordam no momento em que a GPU fica ocupada. A barra de progresso só segue o tempo decorrido, numa curva que nunca chega a 100%: um quadro devolvido é a única coisa que pode encerrá-la.

Por dentro, o serviço consulta o /history do ComfyUI a cada meio segundo. Antes era a cada quatro segundos. O despachante em produção consulta a cada 0,2 s, e com quatro o visitante esperava até quatro segundos extras depois que a GPU já tinha terminado, o que, num passe de cerca de 45 segundos, era a maior parte do overhead que ele conseguia ver. Dois passes medidos em 28 de agosto levaram 29,5 s e 57,2 s.

Se a máquina recusar ou ficar em silêncio, a página compõe o carro no navegador e avisa isso no quadro. Uma renderização devolvida aparece como rendered on the gpu, uma local como browser composite · draft, e a legenda dá o motivo a partir do código de status: os oito quadros desta hora já foram gastos, a fila da GPU está cheia, o arquivo é grande demais, a máquina recusou este quadro ou a GPU não respondeu. Depois de quatro minutos de consulta, a página para de esperar. Um visitante nunca deve confundir uma colagem do navegador com saída de modelo.

O túnel, e onde ele não pôde rodar

O nome público é studio.happyin.app, servido por um túnel da Cloudflare. O lugar óbvio para o cloudflared era a própria máquina GPU: um túnel disca para fora, então o NAT não deveria importar. Importou. Atrás do NAT do host do mashinki, o cloudflared registrava conexões e as perdia em segundos, tanto sobre QUIC (failed to dial to edge with quic: timeout: no recent network activity) quanto sobre http2, e a borda respondia 1033 e 502 o tempo todo.

Não briguei com esse NAT. O túnel agora roda em outra máquina nossa, com conexão estável, como uma unit do systemd em volta da imagem oficial do cloudflared, com host networking, e alcança a máquina GPU pelo Tailscale. O serviço não sabe nem se importa onde o túnel roda.

O NAT não foi o erro mais caro, porém. Antes de qualquer coisa disso funcionar, eu tinha anotado que o bloqueio era um escopo de token da Cloudflare que faltava para a zona happyin.work. Não era. Um token que eu já tinha conseguia criar túneis e registros DNS em outra conta, e o endpoint nunca precisou morar sob happyin.work: um hostname em happyin.app faz o trabalho. O bloqueio era uma suposição sobre o meu próprio acesso, e eu deveria ter verificado o que minhas credenciais podiam fazer antes de escrever "bloqueado".

Limites

  • O orçamento e a tabela de jobs vivem na memória. Um reinício esquece quem gastou o quê e descarta jobs na fila; a varredura só garante que os fundos enviados não sobrevivam à hora.
  • Ao reler o código para este post, encontrei um arquivo que nada removia: o PNG que o nó de salvamento do ComfyUI escreve na sua pasta de saída, inclusive por cima da própria foto de um visitante. O serviço agora o apaga logo depois de copiar o quadro para fora. A primeira tentativa falhou sem barulho, porque o ComfyUI roda como root e cria essa pasta com dono root; o delete levantava um erro de permissão e o código o engolia. Contar a pasta antes e depois de uma renderização foi o que pegou isso (7 arquivos, depois 8). A pasta agora pertence ao usuário do serviço, e um delete que falha é impresso em vez de ignorado.
  • As renderizações de carro rodam uma de cada vez. Com um job rodando e três esperando, um quinto job recebe um 503 e o visitante recebe um rascunho.
  • O timeout de socket pega o silêncio, não um gotejamento lento, então uma thread poderia ser mantida aberta dessa forma. O corpo agora tem um prazo próprio, 90 segundos para a requisição inteira, em vez de 20 para cada leitura.
  • Não há contas. Qualquer um pode gastar oito quadros por hora a partir de um endereço, o que, para uma demonstração, é o preço certo.

O que eu faria de novo

Montar o grafo a partir de constantes copiadas do pipeline ao vivo, com um comentário dizendo o arquivo de onde cada valor veio; quando a produção mudar, o diff mostra. Verificar em ordem de custo e decodificar por último. Recodificar tudo o que um desconhecido envia: são poucas linhas, e elimina toda uma categoria de perguntas sobre o que mais havia no arquivo. Rotular cada saída pelo lugar de onde ela realmente veio.

E manter o ComfyUI fora da internet. Nada na API dele foi desenhado para desconhecidos, e o serviço estreito na frente dele é um preço pequeno por isso. Se você precisa transformar um workflow próprio numa API ou numa ferramenta pública, esse é o trabalho descrito em /comfyui/.

Mais artigos

Mais neste site

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