happyin.work / 博客

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

把 ComfyUI 工作流作为公共服务,同时不暴露 ComfyUI

作者: Anastasiia Butova, ComfyUI 与扩散模型工程师,贝尔格莱德 · 发布于 2026-09-23

译自英文版: English

/mashinki/ 上的汽车工作室让任何人把一辆车放进展厅,按下渲染,然后拿回一帧来自真实流水线的画面:FLUX.2 Klein 9B 加上我们的几何修复 LoRA,运行在 mashinki 的 GPU 主机上,耗时 30 到 60 秒。渲染它的 ComfyUI 根本不发布到互联网上。两者之间是一个小型 Python 服务。本文讲的是它接受什么、拒绝什么,以及我最初把隧道放在哪里却行不通。

我所做的每一条生产 ComfyUI 流水线都是这样运行的:图由代码提交,而不是在编辑器里点出来的(这类工作的更多内容见 /comfyui/)。通常调用者是一个后端。而这里的调用者是任何拿着浏览器的人,这让每一个设计问题都更加尖锐。

为什么 /prompt 永远不面向互联网

ComfyUI 的 HTTP API 是为机器的所有者设计的。POST /prompt 接受一整张 API 格式的图并运行它。图就是一个程序:一个加载节点接受文件名,一个保存节点写入文件,而服务器上安装的每个自定义节点,都是调用者可以带着自选参数调用的又一个函数。因此,任何能访问 /prompt 的人,都可以在机器上加载任何模型,读取 ComfyUI 进程能读的任何东西,并写入它能写的任何地方。那个请求中没有一个字段是你可以校验的,因为请求本身就是代码。

对我来说,这并非纸上谈兵。2026 年 2 月,我参与维护的一台 GPU 服务器上的 ComfyUI 容器在挖门罗币。它的 API 端口未经认证就暴露在互联网上,有人向它发送了一个包含第三方包中 eval 节点的工作流,而他们的 Python 改写了 ComfyUI 自己的 execution.py。我写了清除运行手册,完整经过见 关于生产环境中自定义节点的文章。4 月,Censys 描述了一场针对暴露在互联网上的 ComfyUI 的攻击活动,至今它仍能找到一千多个实例,而同一个节点也在它的列表上。

因此 mashinki 主机上的 ComfyUI 没有发布在任何地方。服务在主机本地的 127.0.0.1 上访问它,公众只与服务通信,而服务没有任何方式表达一张图。

Request path for one render: browser, Cloudflare tunnel on a relay host, Tailscale, then studio-api on the GPU box, which answers 413, 400, 429 or 503 or builds one fixed graph for ComfyUI at 127.0.0.1; the internet has no route to /prompt

六个值,图在我们这边构建

渲染按钮发送 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 字典,其中除一个值之外,其余全部来自生产环境,而不是我手写的。模型文件、第 2,000 步的 LoRA、6 步、采用 beta 调度器的 euler 采样器,以及逐字照搬的生产提示词,于 8 月 28 日从正在运行的调度器容器中复制而来,代码中注明了每个值来自哪个文件。调用者对这张图的影响很小,而且是间接的:它所加载的合成图是服务以自己生成的名字写下的文件;提示词中的车身类型和尺寸,则来自一张包含四个条目的表,即我们的车辆识别服务对那四帧返回的值。种子也是我们自己的。

有一处与生产环境的差异是刻意为之的。生产环境以 0.7 的强度运行适配器。在本站上,我以 1.0 运行,因为演示画面允许比经销商批量渲染更进一步,而页面在渲染时会把这个数字打印出来。

尽早拒绝,尽晚解码

大部分思考都花在检查的顺序上。它们按照从最便宜到最昂贵的顺序运行:

  1. Content-Length 超过 8 MB:返回 413,不解析请求体。
  2. 某个地址已经用完它一小时的额度,还在发送超过 2 KB 的数据:返回 429,同样不解析请求体。这项检查不消耗任何东西。
  3. 解析 JSON 并校验字段。
  4. 额度:每个地址每小时八个作业,否则返回 429。地址取自 Cloudflare 在 CF-Connecting-IP 中传递的值。任务在此计数,因此下一步返回的 503 仍然会消耗一个额度。
  5. 队列:一个任务运行,最多三个等待,否则返回 503。
  6. 只有到了这一步,如果发送了背景,才会解码它。

图像是服务为陌生人所做的最昂贵的事情,而我们不欠那些本来就会被拒绝的人任何解码工作。

413 教会了我一件我没有预料到的事。如果服务在调用者还没有上传完毕时就作出回应,上传会被重置,而前面的隧道会返回它自己的 502。实测:一个 20 MB 的请求体得到的是 Cloudflare 的 502,而不是我们的 413,调用者看到的是一个损坏的网关,而不是原因。现在服务会读取请求体,并以 64 KB 为单位丢弃,最多读到 64 MB,然后返回它自己的 413;对于声明更大的请求,它在 64 MB 处停止、作出回应并关闭连接。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

尺寸是在解压任何像素之前从文件头中读取的,因此一个声明为 8000×8000、只有 79 KB 的文件,在被解压之前就会被拒绝。只接受三种格式,因为这就是浏览器文件选择器对一张照片实际能给出的格式,而 PIL 携带的其他每一种解码器,都是我们白白暴露、对谁都没有好处的攻击面。动画会被拒绝,所以任何东西的第二帧都永远不会被解码。然后,图片被重新编码为我们自己的 JPEG。无论文件容器里藏了什么,比如图像数据之后的有效载荷,或者恶意的元数据块,都无法保留下来,因为它们都不是像素。

背景以作业 id 命名,在作业结束后(无论成功与否)被删除。在进程重启时仍在排队的作业永远到不了它的清理步骤,因此一个定期清扫会删除任何超过一小时的背景文件。

还有一个细节改变了提示词。对于我们的七个大厅,只要我们测量过,提示词就会带上背景提示:两个深色大厅会得到"保留深色帷幕和深色天花板的曝光"。而对于陌生人的照片,提示词是空的,而不是猜测。我们从未测量过那个房间,错误的提示会推动模型已经能看到的一个房间的曝光。

保留什么,保留多久

作业 id 是从一个随机 UUID 中截取的 16 个十六进制字符。/job/<id> 和 /img/<id>.jpg 符合严格的模式,而 /img/ 只提供本进程中已完成作业的内容。它提供的 JPEG 保留一小时;每五分钟运行一次的清扫会删除它并忘记该作业。送入 ComfyUI 输入文件夹的 2048 像素合成图,在 finally 块中被删除。在修复之前,失败的渲染会把合成图永久留在那里。

Access-Control-Allow-Origin 固定为 https://happyin.work。值得准确地说明它买到了什么。CORS 只能阻止另一个网站上的脚本在浏览器中读取我们的响应;它阻止不了 curl。真正限制滥用的,是狭窄的输入、每小时的额度和队列。CORS 只是防止别人的页面把工作室当作它自己的后端来驱动。

页面可以显示的状态

POST /compose 立即以 202 作答,并返回一个作业 id;GPU 的工作稍后才进行。页面每 2.5 秒轮询一次 GET /job/<id>,画面下方的标签就是主机自己的状态:waiting、compositing、queued-on-gpu、rendering,然后是 done 或 error。我希望这些文字来自主机,因为计时器和 GPU 一旦 GPU 忙碌起来就会不一致。进度条只跟随经过的时间,并沿着一条永远不会到达 100% 的曲线走;只有返回的画面才能让它结束。

在服务内部,它每半秒轮询一次 ComfyUI 的 /history。它过去是每四秒一次。生产调度器以 0.2 秒轮询,而四秒的间隔意味着 GPU 已经完成后,观看者还要多等最多四秒,在一次约 45 秒的渲染中,这占了他们能看到的大部分额外开销。8 月 28 日测得的两次渲染耗时分别为 29.5 秒和 57.2 秒。

如果主机拒绝或失去响应,页面会改在浏览器中合成汽车,并在画面上说明。返回的渲染图标注为 rendered on the gpu,本地的标注为 browser composite · draft,说明文字则根据状态码给出原因:本小时的八帧已经用完,GPU 队列已满,文件太大,主机拒绝了这一帧,或者 GPU 没有响应。轮询四分钟之后,页面就停止等待。访问者绝不应该把浏览器里的粘贴误认为模型的输出。

隧道,以及它无法运行的地方

公开的名字是 studio.happyin.app,通过 Cloudflare 隧道提供服务。cloudflared 的显而易见的位置是 GPU 主机本身:隧道是向外拨号的,所以 NAT 不应该有影响。它确实有。在 mashinki 主机的 NAT 之后,cloudflared 注册了连接,却在几秒之内就丢失了它们,无论是通过 QUIC(failed to dial to edge with quic: timeout: no recent network activity)还是 http2,边缘节点一直返回 1033 和 502。

我没有去对抗那个 NAT。隧道现在运行在我们的另一台拥有稳定连接的机器上,作为一个 systemd 单元,包裹官方的 cloudflared 镜像,使用主机网络,并通过 Tailscale 连接到主机。服务不知道、也不关心隧道运行在哪里。

不过,NAT 并不是代价最大的错误。在这一切能工作之前,我曾经写下,阻碍因素是 happyin.work 区域缺少一个 Cloudflare 令牌的权限范围。事实并非如此。我已经拥有的一个令牌,就能在另一个账户上创建隧道和 DNS 记录;而且端点根本不需要位于 happyin.work 之下:happyin.app 上的一个主机名就能完成任务。那个阻碍是我对自己访问权限的一个假设,我本应在写下"被阻塞"之前,先检查我的凭据能做什么。

限制

  • 额度和作业表都保存在内存中。进程重启会忘记谁用掉了多少额度,并丢弃排队中的作业;清扫只保证它们上传的背景不会超过一小时。
  • 为这篇文章重读代码时,我发现有一个文件没有被任何东西删除:ComfyUI 的保存节点写入其输出文件夹中的 PNG,包括对观看者自己照片的渲染。服务现在会在把画面复制出来之后立即删除它。第一次尝试悄无声息地失败了,因为 ComfyUI 以 root 身份运行,并创建了一个属于 root 的文件夹;删除时抛出了权限错误,而代码把它吞掉了。是在一次渲染前后各统计文件夹内的文件数(先 7 个,然后 8 个)才发现的。现在该文件夹归服务的用户所有,删除失败会被打印出来,而不是被忽略。
  • 汽车渲染一次只运行一个。一个作业运行、三个等待时,第五个作业会得到 503,而观看者得到的是草稿。
  • 套接字超时只能捕捉静默,而无法捕捉缓慢的涓流,因此线程有可能以这种方式被长期占用。现在请求体有它自己的期限:整个请求 90 秒,而不是每次读取 20 秒。
  • 没有账户。任何人都可以从一个地址每小时用掉八帧,对于一个演示来说,这是恰当的代价。

我会再做一次的做法

根据实时流水线中复制出来的常量构建图,并附上注释说明每个值来自哪个文件;当生产环境改变时,差异会显示出来。按成本从低到高检查,最后才解码。把陌生人上传的任何东西都重新编码:这只需要几行,却消除了一整类关于文件中还藏着什么的问题。给每个输出标注它实际来自哪里。

而且要让 ComfyUI 远离互联网。它的 API 从来不是为陌生人设计的,而挡在它前面的那个狭窄服务只是一点很小的代价。如果你需要把自己的工作流变成 API 或公共工具,那正是 /comfyui/ 上所描述的工作。

更多文章

本站其他内容

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