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 形式のグラフ全体を受け取って実行します。グラフはプログラムです。ローダーのノードはファイル名を受け取り、保存のノードはファイルを書き、サーバーにインストールされたカスタムノードは1つひとつが、呼び出し側が引数を選んで呼び出せる関数です。つまり、/prompt に届く人は誰でも、そのボックス上の任意のモデルを読み込め、ComfyUI のプロセスが読める任意のものを読み、書ける任意の場所へ書けます。リクエストの中に検証できるフィールドはありません。リクエストそのものがコードだからです。
私にとって、これは理論上の話ではありません。2026年2月、私が扱っていた GPU サーバーの ComfyUI のコンテナが Monero をマイニングしていました。その API のポートは、認証なしでインターネットに開いていて、誰かがサードパーティのパックの eval ノードを含むワークフローを送ってきました。そして、その人の Python が ComfyUI 自身の execution.py を書き換えました。私はクリーンアップのランブックを書きました。全体の話は、本番のカスタムノードについての記事にあります。4月には、Censys が、インターネットに公開された ComfyUI を狙ったキャンペーンを記述しました。Censys は今でもそうしたインスタンスを1,000以上見つけていて、そのリストには同じノードも載っています。
そのため、mashinki のボックス上の ComfyUI は、どこにも公開されていません。サービスはボックス自体の 127.0.0.1 で ComfyUI に到達し、公開側はサービスとだけ話します。サービスには、グラフを表現する方法がありません。

6つの値。グラフは私たちの側で組み立てられる
レンダーのボタンは、5つのフィールドを持つ POST /compose を送ります。サービスが知っているのは6つです。なぜなら、ホールと自分の背景は代替の関係にあるからです。それぞれは、何かに触れる前に制限されます。
| フィールド | 受け入れる値 |
|---|---|
car |
4つの ID のどれか。辞書のキーとして使う |
scene |
7つのホール 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日に、稼働中のディスパッチャーのコンテナからそのままコピーしたものです。コードは、各値がどのファイルから来たかを名指ししています。そのグラフに対する呼び出し側の影響は、小さく間接的です。読み込まれる合成画像は、サービスが生成した名前でサービスが書いたファイルであり、プロンプトのボディの種類と寸法は、4つのエントリーのテーブルから来ます。それは、車の ID サービスが、その4フレームに対して返した値です。シードも私たちのものです。
本番とは1点だけ意図的に違えています。本番は、アダプターを 0.7 で走らせます。サイトでは 1.0 で走らせています。デモのフレームは、ディーラーのバッチよりも強く寄せてもよいと考えるからです。レンダリング中には、ページがその数字を表示します。
早く拒否し、遅くデコードする
チェックの順序には、大半の思考を費やしました。コストの低いものから、高いものへ順に実行されます。
Content-Lengthが 8 MB を超える:ボディを解析せずに 413。- すでに1時間の枠を使い切っているアドレスが、2 KB を超えて送ってくる:これもボディを解析せずに 429。この検査は何も消費しません。
- JSON を解析し、フィールドを検証する。
- 予算:アドレスごとに1時間に8ジョブ、超えると 429。アドレスは、Cloudflare が
CF-Connecting-IPで渡すものです。ジョブはここで数えられるので、次の段階で 503 になっても、8つのうち1つを使っています。 - キュー:1ジョブが動き、最大3つが待てる。超えると 503。
- ここで初めて、背景が送られていればデコードする。
画像は、見知らぬ人のためにサービスがする最も高くつくことです。どうせ追い返す人のために、それを払う義務はありません。
413 から、私が予期していなかったことを学びました。サービスが、呼び出し側のアップロードが終わる前に答えると、アップロードはリセットされ、その前にあるトンネルは独自の 502 を報告します。測定したところ、20 MB のボディは、サービスの 413 ではなく、Cloudflare の 502 を受け取りました。呼び出し側は、理由ではなく壊れたゲートウェイを見ることになります。今では、サービスはボディを最大 64 MB まで 64 KB のチャンクで読んでは捨て、その後で自分の 413 を返します。宣言された大きさが64 MB より大きいものについては、64 MB で止まり、答えを返して接続を閉じます。20秒のソケットのタイムアウトは、アップロードの途中で黙ってしまう呼び出し側を切ります。
呼び出し側が送れる唯一のもの
閲覧者は、自分のホールの写真を持ち込めます。それは同じ JSON の中に base64 で届きますが、届いたそのままの形では決して渡されません。以下は save_plate から、私が3つの短いコメントを加えて切り出したものです。
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
大きさはヘッダーから、ピクセルが1つも展開される前に読まれます。そのため、8000×8000 と宣言している 79 KB のファイルは、膨らまされることなく拒否されます。受け入れるのは3つの形式だけです。ブラウザのファイルピッカーが写真として現実的に渡してくるものだからで、それ以外の PIL が持つデコーダは、誰の利益にもならない攻撃面になります。アニメーションは拒否されるので、2コマ目以降は決してデコードされません。そして、画像は私たち自身の JPEG として再エンコードされます。コンテナに何が隠されていても、画像データの後ろにあるペイロードも、悪意あるメタデータのブロックも、残りません。ピクセル以外のものは、何も残らないからです。
プレートはジョブの ID の下に書かれ、成功しても失敗しても、ジョブが終わると削除されます。キューに入ったままのジョブは、プロセスの再起動で後片付けに到達できません。そのため、スイープが1時間より古いプレートをすべて削除します。
もう1つの細部が、プロンプトを変えました。7つのホールのそれぞれについて、測定したものがあれば、プロンプトには背景のヒントが入ります。暗い2つのホールには、「preserve the dark drapes and dark ceiling exposure」というヒントがあります。見知らぬ人の写真では、ヒントは推測せず、空にしています。その部屋については何も測っていませんし、間違ったヒントは、モデルがすでに見ている部屋の露出を押し動かしてしまうからです。
何を保存し、どれくらい保存するか
ジョブの ID は、ランダムな UUID から切り出した16桁の16進数です。/job/<id> と /img/<id>.jpg は厳密なパターンに一致し、/img/ は、このプロセスの完了したジョブ以外は何も返しません。そこで返される JPEG は1時間保存されます。5分ごとのスイープが、それを削除し、ジョブを忘れます。ComfyUI の入力フォルダに入った 2048 ピクセルの合成画像は、finally ブロックで削除されます。その修正の前は、失敗したレンダーの合成画像が、そのまま残っていました。
Access-Control-Allow-Origin は https://happyin.work に固定されています。それが何を買うのかを正確にしておくことには、価値があります。CORS が止めるのは、別のウェブサイトのスクリプトがブラウザでレスポンスを読むことだけです。curl は止めません。実際に濫用を制限しているのは、狭い入力、1時間の予算、そしてキューです。CORS は、他人のページがスタジオを自分のバックエンドのように動かすことを防ぐだけです。
ページに表示できる状態
POST /compose は、すぐに 202 とジョブの ID を返し、GPU の作業は後で行われます。ページは GET /job/<id> を2.5秒ごとにポーリングします。フレームの下のラベルは、ボックス自身の状態です。waiting、compositing、queued-on-gpu、rendering、そして done か error。言葉をボックスから来るものにしたかったのは、タイマーと GPU は、GPU が忙しくなった瞬間に食い違うからです。進捗バーは経過時間に従うだけで、100%には決して届かない曲線に沿って動きます。返されたフレームだけが、それを終わらせてよいものです。
内部では、サービスは ComfyUI の /history を0.5秒ごとにポーリングします。以前は4秒ごとでした。本番のディスパッチャーは 0.2 秒でポーリングします。4秒だと、GPU がもう終わっているのに、閲覧者は最大4秒余分に待つことになり、約45秒のパスでは、閲覧者に見えていたオーバーヘッドのほとんどがそれでした。8月28日に計測した2回のパスは、29.5秒と57.2秒でした。
ボックスが拒否するか、沈黙すると、ページは代わりにブラウザ上で車を合成し、フレームにそう表示します。返されたレンダーには rendered on the gpu と表示され、ローカルのものには browser composite · draft と表示されます。キャプションは、ステータスコードから理由を伝えます。この時間の8フレームは使い切った、GPU のキューがいっぱい、ファイルが大きすぎる、ボックスがこのフレームを拒否した、GPU が応答しなかった、のいずれかです。4分間ポーリングしたあと、ページは待つのをやめます。閲覧者は、ブラウザの貼り付けを、モデルの出力と取り違えてはなりません。
トンネル、そしてそれが動かなかった場所
公開の名前は 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 と戦いませんでした。トンネルは今、安定した接続を持つ、私たちの別のマシン上で動いています。公式の cloudflared イメージを包んだ systemd のユニットで、ホストのネットワーク上で動き、Tailscale 経由でボックスに到達します。サービスは、トンネルがどこで動いているかを知らないし、気にもしません。
もっと高くついた間違いは、NAT ではありませんでした。すべてが動く前に、私はブロッカーは happyin.work のゾーンに対する Cloudflare のトークンのスコープが足りないことだと書き留めていました。そうではありませんでした。すでに持っていたトークンで、別のアカウントのトンネルと DNS レコードを作ることができました。エンドポイントは happyin.work の下にある必要もありませんでした。happyin.app のホスト名で目的は果たせます。ブロッカーは、私自身のアクセスについての思い込みでした。「ブロックされている」と書く前に、自分の資格情報が何をできるかを確認すべきでした。
限界
- 予算とジョブのテーブルはメモリにあります。再起動すると、誰が何を使ったかを忘れ、キューのジョブは消えます。スイープが保証するのは、アップロードされたプレートが1時間以上残らないことだけです。
- この記事のためにコードを読み直したところ、何も削除していないファイルが1つ見つかりました。ComfyUI の保存ノードが出力フォルダに書く PNG で、閲覧者自身の写真の上のレンダーも含まれます。今では、サービスがフレームを取り出した直後に削除します。最初の試みは、音もなく失敗しました。ComfyUI は root として動き、そのフォルダを root 所有で作るからです。削除は権限エラーを出しましたが、コードはそれを握りつぶしていました。1回のレンダーの前後でフォルダを数えたことで、それが見つかりました(7ファイルから8ファイルへ)。今では、フォルダはサービスのユーザーのものになり、削除の失敗は無視されずに表示されます。
- カーのレンダーは一度に1つずつ動きます。1ジョブが動き、3つが待つ状態で、5つ目のジョブは 503 を受け取り、閲覧者はドラフトを受け取ります。
- ソケットのタイムアウトが捉えるのは沈黙であって、ゆっくりした細い流れではありません。そのため、そのようにしてスレッドが開いたまま保持されることがありえます。今では、ボディ自体に期限があり、リクエスト全体で90秒です。各読み取りに20秒ではありません。
- アカウントはありません。誰でも、1つのアドレスから1時間に8フレームを使えます。デモとしては、それが正しい値です。
今ならもう一度やること
ComfyUI の本番のパイプラインから取った定数で、グラフを組み立てます。それぞれの値がどのファイルから来たかを、コメントで名指しします。本番が変われば、差分にそれが現れます。コストの順に、低いものから検査し、デコードは最後にします。見知らぬ人がアップロードしたものはすべて、再エンコードします。数行で済み、そのファイルに他に何が入っていたかという問いのカテゴリー全体を取り除けます。そして、すべての出力に、実際にどこから来たかのラベルを付けます。
そして、ComfyUI をインターネットから遠ざけておいてください。その API は、見知らぬ人のために設計されたものではありません。その前に立つ狭いサービスは、そのための小さな代償です。ご自身のワークフローを API や公開ツールに変えたい場合は、/comfyui/ に書いた仕事がそれです。