Workflow ComfyUI как публичный сервис, без выставления ComfyUI наружу
Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23
Перевод английской версии: English
Студия автомобилей на /mashinki/ позволяет любому поставить машину в шоурум, нажать «рендер» и получить кадр из настоящего конвейера: FLUX.2 Klein 9B с нашей LoRA восстановления геометрии, на GPU-машине mashinki, за 30–60 секунд. ComfyUI, который это рендерит, вообще не опубликован в интернете. Между ними стоит небольшой Python-сервис, и этот пост о том, что он принимает, что отклоняет и о туннеле, который не заработал там, где я впервые его поставила.
Каждый продакшен-конвейер ComfyUI, который я строила, работает именно так: граф отправляет код, а не кто-то, кликающий в редакторе (больше о такой работе — на /comfyui/). Обычно вызывающий — бэкенд. Здесь вызывающий — любой человек с браузером, и это делает каждый вопрос дизайна острее.
Почему /prompt никогда не смотрит в интернет
HTTP API ComfyUI создан для того, кто владеет машиной. POST /prompt принимает целый граф в API-формате и запускает его. Граф — это программа: узел загрузки принимает имя файла, узел сохранения пишет файл, а каждый пользовательский узел, установленный на сервере, — это ещё одна функция, которую вызывающий может запустить с аргументами по своему выбору. Значит, любой, кто достучится до /prompt, может загрузить любую модель на машине, читать всё, что может читать процесс ComfyUI, и писать туда, куда он может писать. В этом запросе нет поля, которое можно было бы проверить, потому что сам запрос — это код.
Для меня это не теория. В феврале 2026 года контейнер ComfyUI на GPU-сервере, с которым я работала, майнил Monero. Его API-порт был открыт в интернет без аутентификации, кто-то отправил ему воркфлоу с узлом eval из стороннего пакета, и их Python переписал execution.py самого ComfyUI. Runbook очистки написала я, а полная история — в посте о пользовательских узлах в продакшене. В апреле Censys описал кампанию против ComfyUI, открытых в интернет; он всё ещё находит больше тысячи экземпляров, и тот же узел есть в его списке.
Поэтому ComfyUI на машине mashinki нигде не опубликован. Сервис обращается к нему на самой машине, по 127.0.0.1, а публика говорит только с сервисом, у которого нет способа выразить граф.

Шесть значений, а граф строится на нашей стороне
Кнопка рендера отправляет POST /compose с пятью полями. Сервис знает шесть, потому что зал и собственный фон — альтернативы. Каждое ограничено до того, как что-либо трогает:
| поле | допустимо |
|---|---|
car |
один из четырёх id, используется как ключ словаря |
scene |
один из семи id залов, используется как ключ словаря; обязателен, если не передан bg |
x |
число, ограничено диапазоном 12–88 (проценты ширины кадра) |
y |
число, ограничено диапазоном 40–96 (линия земли, проценты высоты) |
scale |
число, ограничено диапазоном 34–94 (ширина машины, проценты кадра) |
bg |
необязательное base64-изображение, см. ниже |
Неизвестная машина или зал, JSON, который не парсится, или координата, не являющаяся числом, получает 400. Поля, которые сервис не знает, никогда не читаются. Проверка такая же простая, как выглядит; это продакшен-код:
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))
Граф — это Python-словарь внутри сервиса, и все его значения, кроме одного, пришли из продакшена, а не от меня. Файлы моделей, LoRA на шаге 2000, 6 шагов, сэмплер euler с планировщиком beta и продакшен-промпт слово в слово были скопированы 28 августа из живого контейнера диспетчера, а в коде указано, из какого файла взято каждое значение. Влияние вызывающего на этот граф малое и косвенное: составной кадр, который он загружает, — это файл, который сервис записал под сгенерированным им именем, а тип кузова и размеры в промпте берутся из таблицы из четырёх записей — значений, которые наш сервис идентификации автомобилей вернул для этих четырёх кадров. Сид тоже наш.
Одно отличие от продакшена сознательное. Продакшен запускает адаптер с силой 0.7. На сайте я использую 1.0, потому что демонстрационный кадр может быть смелее пакета дилера, а страница печатает это число, пока идёт рендер.
Отклонять рано, декодировать поздно
Больше всего размышлений ушло на порядок проверок. Они идут от самой дешёвой к самой дорогой:
Content-Lengthбольше 8 МБ: 413, без разбора тела.- Адрес, который уже израсходовал свой час и шлёт больше 2 КБ: 429, тоже без разбора тела. Эта проверка ничего не расходует.
- Разобрать JSON и проверить поля.
- Бюджет: восемь заданий на адрес в час, иначе 429. Адрес — тот, который Cloudflare передаёт в
CF-Connecting-IP. Задание считается здесь, поэтому 503 на следующем шаге всё равно расходует одно из восьми. - Очередь: одно задание выполняется и не более трёх ждут, иначе 503.
- Только теперь, если был передан фон, декодировать его.
Изображение — самое дорогое, что сервис делает для незнакомца, и мы не должны его никому, кого всё равно собирались отклонить.
Ответ 413 научил меня тому, чего я не ожидала. Если сервис отвечает до того, как вызывающий закончил загрузку, загрузка сбрасывается, и туннель перед ним отдаёт собственную 502. Измерено: тело в 20 МБ получило 502 от Cloudflare, а не наш 413, так что вызывающий увидел сломанный шлюз вместо причины. Теперь сервис читает тело и выбрасывает его кусками по 64 КБ, до 64 МБ, а затем отвечает собственным 413; для объявленного большего размера он останавливается на 64 МБ, отвечает и закрывает соединение. Сокет-таймаут в 20 секунд отключает вызывающего, который замолчал посреди загрузки.
Единственное, что вызывающий может прислать
Зритель может принести фото собственного зала. Оно приходит как base64 в том же JSON и никогда не передаётся дальше в исходном виде. Вот функция save_plate, сокращённая, с тремя моими короткими комментариями:
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
Размер читается из заголовка до того, как распаковывается хоть один пиксель, поэтому файл в 79 КБ, который объявляет 8000×8000, отклоняется, так и не будучи раздутым. Принимаются только три формата, потому что именно они реалистично дают файловый выбор браузера для фотографии, а каждый другой декодер, который несёт PIL, — поверхность атаки, которую мы бы открывали без всякой пользы для кого-либо. Анимация отклоняется, поэтому второй кадр чего-либо никогда не декодируется. Затем картинка перекодируется в наш собственный JPEG. Что бы ни было спрятано в контейнере — полезная нагрузка после данных изображения или враждебный блок метаданных, — от этого ничего не остаётся, потому что ничто из этого не является пикселями.
Файл фона записывается под id задания и удаляется, когда задание заканчивается, успешно или нет. Задание, которое ещё стоит в очереди, когда процесс перезапускается, до своей очистки не доходит, поэтому фоновая очистка удаляет любой фон старше часа.
Ещё одна деталь изменила промпт. Для наших семи залов в промпт попадает подсказка о фоне, там где мы её измерили: для двух тёмных залов — «сохрани тёмные драпировки и экспозицию тёмного потолка». Для фото незнакомца подсказка пустая, а не угаданная. Мы ничего не измеряли в этой комнате, а неверная подсказка подтолкнула бы экспозицию комнаты, которую модель и так видит.
Что сохраняется и как долго
id задания — 16 шестнадцатеричных символов, взятых из случайного UUID. /job/<id> и /img/<id>.jpg соответствуют строгому шаблону, а /img/ отдаёт только готовое задание этого процесса. JPEG, который он отдаёт, хранится час; очистка каждые пять минут удаляет его и забывает задание. 2048-пиксельный составной кадр, ушедший во входную папку ComfyUI, удаляется в блоке finally. До этого исправления неудавшийся рендер оставлял свой составной кадр там навсегда.
Access-Control-Allow-Origin зафиксирован на https://happyin.work. Важно точно понимать, что это даёт. CORS лишь не даёт скрипту на другом сайте читать наши ответы в браузере; он не останавливает curl. Что реально ограничивает злоупотребления — узкий вход, почасовой бюджет и очередь. CORS лишь не даёт чужой странице управлять студией как собственным бэкендом.
Состояния, которые может показать страница
POST /compose сразу отвечает 202 и id задания; работа на GPU происходит позже. Страница опрашивает GET /job/<id> каждые 2.5 секунды, а подпись под кадром — это собственное состояние машины: waiting, compositing, queued-on-gpu, rendering, затем done или error. Я хотела, чтобы слова приходили от машины, потому что таймер и GPU расходятся, как только GPU занят. Полоса прогресса следует только за прошедшим временем, по кривой, которая никогда не доходит до 100 %: только возвращённый кадр может её завершить.
Внутри сервис опрашивает /history ComfyUI каждые полсекунды. Раньше это было каждые четыре секунды. Продакшен-диспетчер опрашивает с интервалом 0.2 с, а при четырёх секундах зритель ждал до четырёх лишних секунд после того, как GPU уже закончил, что на проходе около 45 секунд было большей частью накладных расходов, которые он видел. Два прохода, замеренные 28 августа, заняли 29.5 с и 57.2 с.
Если GPU-сервер отказал или замолчал, страница компонует машину в браузере и говорит об этом на кадре. Возвращённый рендер помечен как rendered on the gpu, локальный — browser composite · draft, а подпись даёт причину по коду статуса: восемь кадров этого часа израсходованы, очередь GPU полна, файл слишком большой, машина отказала в этом кадре или GPU не ответил. Через четыре минуты опроса страница перестаёт ждать. Зритель никогда не должен принять вставку из браузера за результат модели.
Туннель и место, где он не мог работать
Публичное имя — studio.happyin.app, обслуживается через туннель Cloudflare. Очевидное место для cloudflared — сама GPU-машина: туннель сам устанавливает исходящее соединение, так что NAT не должен мешать. Он мешал. За NAT хоста mashinki cloudflared регистрировал соединения и терял их в течение секунд, и по QUIC (failed to dial to edge with quic: timeout: no recent network activity), и по http2, а edge всё это время отвечал 1033 и 502.
Я не стала бороться с NAT. Теперь туннель работает на другой нашей машине со стабильным соединением, как systemd-юнит вокруг официального образа cloudflared с сетью хоста, и достучивается до машины через Tailscale. Сервис не знает и не заботится о том, где работает туннель.
Но NAT — не самая дорогая ошибка. До того, как что-либо из этого заработало, я записала, что блокером была недостающая область токена Cloudflare для зоны happyin.work. Это было не так. Токен, который у меня уже был, мог создавать туннели и DNS-записи в другом аккаунте, а конечной точке вообще не нужно было жить под happyin.work: хватает имени на happyin.app. Блокером было предположение о моём собственном доступе, и мне следовало проверить, что умеют мои учётные данные, прежде чем писать «заблокировано».
Ограничения
- Бюджет и таблица заданий живут в памяти. Перезапуск забывает, кто что потратил, и сбрасывает задания из очереди; очистка гарантирует лишь то, что загруженные фоны не переживут свой час.
- Перечитывая код для этого поста, я нашла один файл, который ничто не удаляло: PNG, который узел сохранения ComfyUI записывает в свою выходную папку, — в том числе поверх фото зрителя. Теперь сервис удаляет его сразу после копирования кадра. Первая попытка провалилась без звука, потому что ComfyUI работает от root и создаёт эту папку с владельцем root; удаление выдавало ошибку доступа, а код её проглатывал. Подсчёт файлов в папке до и после одного рендера поймал это (7 файлов, затем 8). Теперь папка принадлежит пользователю сервиса, а неудачное удаление печатается, а не игнорируется.
- Рендеры машин выполняются по одному. С одним выполняющимся заданием и тремя ожидающими пятое получает 503, и зритель получает черновик.
- Сокет-таймаут ловит молчание, но не медленную капельную загрузку, так что таким способом может удерживаться поток. Теперь у тела есть собственный дедлайн: 90 секунд на весь запрос вместо 20 на каждое чтение.
- Аккаунтов нет. Любой может потратить восемь кадров в час с одного адреса, что для демо — правильная цена.
Что я бы сделала снова
Строить граф из констант, скопированных из живого конвейера, с комментарием, в котором назван файл каждого значения; тогда, когда продакшен меняется, diff это покажет. Проверять по порядку стоимости и декодировать в конце. Перекодировать всё, что загружает незнакомец: это несколько строк, и это снимает целый класс вопросов о том, что ещё было в файле. Подписывать каждый выход тем, откуда он на самом деле пришёл.
И держать ComfyUI вне интернета. Ничто в его API не создавалось для незнакомцев, а узкий сервис перед ним — небольшая цена. Если вам нужен собственный воркфлоу, превращённый в API или публичный инструмент, это та работа, которая описана на /comfyui/.