こんにちは。
トライベック株式会社の岡山です。
前回、Mac mini(M4 Pro / 48GB)で「完全ローカル」のLLM + RAG環境を構築してみたという記事を書きました。社内資料を外に出さずに扱いたい、という動機で組んだ環境です。
同じ Mac mini には ComfyUI も入れています。日本語で書いたプロンプトをローカルLLMが英語に変換し、そのまま画像を生成する仕組みです。手元で完結するので、プロンプトも生成物も外に出ません。
夏休みの自由研究のつもりで、ひとつ試してみました。
自宅の Mac mini を画像生成サーバーにして、家の外の iPhone から使えるようにする。
そして自由研究らしく、素直には進みませんでした。 「自宅のサーバを外から使う」は、思っていたより慎重にやるべき作業だったのです。この記事はその記録です。
この記事で分かること
- 自宅のサーバを外から使うときに、やってはいけない繋ぎ方
- Tailscale でポートを開けずに繋ぐ手順
-
tailscale serveがSuccess.と表示しながら失敗する話 - 「繋がった」だけでは使えなかった話
環境
| 項目 | 値 |
|---|---|
| サーバ | Mac mini (2024) / M4 Pro / 48GB |
| クライアント | iPhone(Android でも可) |
| 画像生成 | ComfyUI + SDXL 1.0 base |
| LLM | Ollama(gemma3:12b) |
| ブリッジ | FastAPI(自作) |
| VPN | Tailscale |
1. 作ったもの
スマホで日本語を書いて送ると、Mac mini が英語プロンプトに変換して画像を生成し、手元に返してくれます。
入力の長さに制限はありません。ただし変換後のプロンプトは、システムプロンプトで75語以内に収めるよう指示しています。CLIP のテキストエンコーダが77トークンで頭打ちになるためです。長く書いても要約されるので、短く要点だけ書くほうが狙いどおりになります。
ルーターのポート開放はしていません。 クラウドのサービスも経由しません。
実際の画面です。左が入力、右が結果。
日本語で「窓辺で丸くなる黒猫、朝の斜光、フィルム写真風」と書くと、black cat, curled up, windowsill, morning light, ... という英語のタグ列に変換され、そのまま画像になります。変換に使われたプロンプトも画面に出るようにしてあるので、狙いどおりに解釈されたかを確認できます。
2. 前置き:なぜローカルLLMを挟んでいるのか
SDXL のテキストエンコーダは英語で学習されていて、日本語は語彙として想定されていません。
ただし、日本語を入れてもエラーは出ません。それらしい画像が出てしまいます。 ここが厄介なところです。
左が「夕暮れの海辺を歩く白い犬、逆光、フィルム写真風」をそのまま入れたもの。右が同じ文をローカルLLMに変換させたものです。シードは両方同じ値で固定しています。
左も「海辺」「夕暮れ」「犬」は出ています。当たっているのはそこまでです。
| 指定 | 日本語のまま | 変換後 |
|---|---|---|
| 白い犬 | ✗ 白黒の犬 | ✓ |
| 歩く | ✗ 座っている | ✓ |
| 逆光 | ✗ 順光ぎみ | ✓ |
| フィルム写真風 | ✗ | ✓ |
4つの指定のうち、通ったのはゼロでした。
かといって機械翻訳の文章を貼っても駄目です。画像生成が反応するのは文章ではなくタグの列だからです。A white dog is walking on the beach at sunset. より white dog walking, seaside at dusk, backlit, 35mm film grain のほうが効きます。
つまり必要なのは翻訳ではなく、「日本語の意図を汲んで、画像生成が反応する英語タグに組み替える」役です。これは LLM の仕事で、しかも手元で動いています。
変換に使うモデルは
gemma3:12bにしました。 同じ文を変換させる時間を測ると、qwen3:14bが約32秒、gpt-oss:20bが約25秒。gemma3:12bは約10秒で、モデルが読み込み済みなら約4秒でした。差の正体は「思考」です。前の2つは推論モデルで、プロンプト変換のような単純な作業にも2,000文字近い思考を書いていました。
thinkを切るとqwen3:14bは3秒台まで縮みます。少なくともこの用途では、推論モデルを使う利点はありませんでした。
ここから本題です。
3. このサーバは、そのまま外に出してはいけない
ブリッジはただの HTTP サーバなので、待ち受けアドレスを変えれば同じ Wi-Fi のスマホからは見えます。
export BRIDGE_HOST=0.0.0.0 # ← これはやめました
やめた理由は、このブリッジに認証が無いからです。
自分だけで使う前提で作ったので、ログイン画面もパスワードもありません。0.0.0.0 にした瞬間、同じネットワークにいる全員から開けるようになります。
「自宅のLANだから大丈夫」と思いかけました。でも自宅のネットワークには、家族の端末があり、来客のスマホがあり、テレビがあり、スマート家電があります。そのどれかを踏み台にされたら、私の Mac の GPU を勝手に回されます。
そして今回やりたいのは外出先からです。カフェの Wi-Fi で同じことをしたら、話になりません。
もうひとつ、もっとやってはいけないものがあります。
tailscale funnel --bg 8189 # ← 絶対にやらない
このあと使う Tailscale には、serve と funnel という似た名前のコマンドがあります。serve は自分のネットワーク内への公開ですが、funnel はインターネット全体への公開です。認証の無いサーバを funnel で出したら、世界中の誰でも私の Mac で画像を作れてしまいます。
ドキュメントを流し読みしていると取り違えかねない距離感です。打つ前に必ず確認してください。
やりたいこと
- ルーターのポートは開けない
- 同じ Wi-Fi の他の端末からも見せない
- インターネットには当然出さない
- 自分の iPhone からだけ見える
これを満たすのが VPN です。
4. Tailscale で繋ぐ
Tailscale を使いました。WireGuard のメッシュを張って NAT を越えるサービスで、ルーターの設定は基本的に要りません。
自分のデバイスだけが所属する「tailnet」というプライベートなネットワークを作り、その中でだけ通信します。個人利用なら無料枠で足ります。
Mac 側
brew install --cask tailscale
アプリを起動してログインします。「Start at login」はオンにしてください。 停電やアップデートで Mac が再起動したとき Tailscale が上がらないと、外出先から復旧できなくなります。
CLI も使うので、コマンドを呼べるようにします。
echo 'alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"' >> ~/.zshrc
source ~/.zshrc
tailscale status
iPhone 側
App Store から Tailscale を入れて、Mac と同じアカウントでログイン。VPN 構成の追加を許可します。
Android でも同じです。 Google Play から Tailscale を入れて、同じアカウントでログインするだけ。ブラウザで開くページなので端末は問いません。この記事で iPhone だけの話なのは、最後に出てくる入力欄のフォントサイズくらいです。
これで2台が同じ tailnet に入り、それぞれに 100.x.x.x のアドレスが振られました。
| デバイス | Tailscale IP |
|---|---|
mac-mini |
100.85.201.31 |
iphone |
100.89.25.98 |
ここまで、ポート開放も DDNS も証明書の設定もしていません。
5. 転んだ4つのこと
ここからが実際の作業記録です。すんなりとは行きませんでした。
① symlink で CLI を呼ぶと落ちる
さっきはエイリアスを設定しましたが、最初は素直にシンボリックリンクを貼りました。
sudo ln -sf /Applications/Tailscale.app/Contents/MacOS/Tailscale /usr/local/bin/tailscale
tailscale status
Fatal error: The current bundleIdentifier is unknown to the registry
既知の不具合です。 symlink 経由だと Tailscale が自分のアプリを特定できなくなります(tailscale#11510)。エイリアスかフルパスで呼べば回避できます。
ネット上の手順には ln -s を案内しているものもあるので、そのままやると踏みます。
② Success. と出るのに繋がらない
Tailscale には、ローカルのポートを tailnet 内に HTTPS で公開する serve という機能があります。ブリッジを 127.0.0.1 のままにしておけるので、これが本命でした。
tailscale serve --bg 8189
Success.
Available within your tailnet:
https://mac-mini.xxxx.ts.net/
|-- proxy http://127.0.0.1:8189
Success. と出ています。設定も入っています。
でもブラウザで開くと ERR_TIMED_OUT でした。
出力の冒頭に「Serve is not enabled on your tailnet」という案内が出るので、指示されたURLから有効化もしました。管理画面には「Serve is ready to use」と表示されます。それでも変わりませんでした。
証明書を明示的に取りに行かせて、原因が分かりました。
tailscale cert mac-mini.xxxx.ts.net
acme: order https://acme-v02.api.letsencrypt.org/acme/order/... status: invalid
Let's Encrypt の証明書発行に失敗していました。 serve は HTTPS で受けるので、証明書が無いと通信が成立しません。接続拒否ではなくタイムアウトになるのは、そのためです。
教訓は「Success. を信じない」ことでした。 このコマンドは「設定の登録に成功した」と言っているだけで、「実際に繋がる」とは言っていません。
③ Mac から自分自身に繋がらない
切り分けのあいだ、ずっと Mac のブラウザから確認していました。
これは判定材料になりません。
macOS 版の Tailscale はネットワーク拡張として動くため、自分自身の tailnet アドレスに自分からアクセスする経路が通らないことがあります。Mac 側で「繋がらない」と言っていても、iPhone からは繋がるかもしれません。
到達確認は必ず別のデバイスからやってください。ここで時間を使いました。
④ 証明書が出ない → HTTPS を諦めた
証明書の再発行を何度か試しましたが、通りませんでした。深追いすることもできますが、いったん立ち止まって考えます。
そもそも HTTPS は何のために要るのか。 通信を暗号化して盗聴と改ざんを防ぐためです。
しかし今回、iPhone と Mac のあいだは既に WireGuard で暗号化されています。 Tailscale がそういうものだからです。つまり serve の HTTPS は、暗号化されたトンネルの中でもう一度暗号化する、という話でしかありません。あれば綺麗ですが、無くても裸で流れるわけではありません。
そこで方針を変えました。serve をやめて、ブリッジを Tailscale の IP に直接待ち受けさせます。
tailscale serve off
cd <ブリッジのディレクトリ>
BRIDGE_HOST=tailscale ./run.sh
Tailscale IP にbindします: 100.85.201.31
INFO: Uvicorn running on http://100.85.201.31:8189
ここが肝です。0.0.0.0 との違いは決定的でした。
0.0.0.0 は「すべてのネットワークで待ち受ける」なので、Wi-Fi にも有線にも口が開きます。一方 100.85.201.31 は Tailscale が作った仮想ネットワークのアドレスなので、そこにしか口が開きません。 同じ Wi-Fi にいる端末は、そもそも辿り着く経路を持ちません。
| 項目 |
serve(HTTPS) |
IP直接(採用) | 0.0.0.0 |
|---|---|---|---|
| 同じWi-Fiの他人から | 不可 | 不可 | 可 |
| インターネットから | 不可 | 不可 | 不可 |
| 通信の暗号化 | TLS + WireGuard | WireGuard | 無し |
| 証明書の管理 | 必要 | 不要 | 不要 |
失うのは TLS の一層だけでした。
iPhone からは、この2つのどちらでも開けます。
http://100.85.201.31:8189/
http://mac-mini.xxxx.ts.net:8189/
Safari のアドレスバーに
100.85.201.31:8189とだけ打つと、検索語とみなされて Google 検索に飛びます。http://から入力してください。これも一度やりました。
おまけ:Mac を寝かせない
サーバにする以上、スリープすると繋がりません。
sudo pmset -a sleep 0 # 本体を自動スリープさせない
sudo pmset -a womp 1 # ネットワークアクセスでスリープ解除
GUI なら システム設定 → エネルギーです。「ロック画面」にある 使用していない場合はディスプレイをオフにする はディスプレイのスリープなので別物でした。ここでも迷いました。
6. 繋がっただけでは、使えなかった
ここまでで iPhone から画面は開きます。しかし実用にはなりませんでした。
生成ボタンを押して、待っているあいだに別のアプリを見る。戻ってくると 何も無い。
原因:画面が消えると接続が切れる
ブリッジは進捗を SSE(Server-Sent Events)で流しています。生成に40〜80秒かかるので、プログレスバーを動かすためです。
デスクトップでは何の問題もありませんでした。ところが、Safari がバックグラウンドに回ると、この接続が切られます。
厄介なのは、Mac 側では生成が完了して画像も保存されていることです。壊れているのは手元のブラウザだけ。でも iPhone の画面には何も出ません。
「同じ HTTP サーバなんだからスマホでも動く」と思っていましたが、繋ぎっぱなしを前提にした設計は、スマホでは成り立ちませんでした。
これは iPhone に限りません。Android の Chrome もバックグラウンドのタブを凍結するので、同じ対策が要ります。
対策:後から結果を拾えるようにする
やったことは単純で、投入した直後にジョブのIDをクライアントへ渡しておくことです。接続が切れても、このIDがあれば後から結果を回収できます。
# ComfyUI に投入した直後
yield sse({"type": "queued", "prompt_id": prompt_id})
回収用のエンドポイントを2本足しました。
@app.get("/api/job/{prompt_id}") # そのジョブの状態と画像URLを返す
@app.get("/api/recent") # 直近の生成結果を新しい順で返す
クライアント側は ID を控えておき、画面に戻ってきたときに問い合わせます。
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
loadRecent(); // ギャラリーを埋め直す
resumePending(); // 途中だったジョブを追いかける
}
});
これで、生成ボタンを押して iPhone をポケットに入れ、後で開くと画像が並んでいる、という動きになりました。
待ち時間が数十秒ある処理では、繋ぎっぱなしを前提にしないほうがいい。 書いてみれば当たり前ですが、スマホで使うまで気づきませんでした。
iOS 特有の調整
ついでに踏んだものを2つ。ここだけは iPhone に固有の話で、Android では気にしなくて構いません。
入力欄のフォントは16px以上にする。 未満だと、タップした瞬間に Safari がページを勝手に拡大します。デスクトップ前提で13pxにしていたので綺麗に踏みました。
生成ボタンがホームバーに重なる。 env(safe-area-inset-bottom) で避けました。
@media (max-width: 860px) {
textarea, input, select { font-size: 16px; } /* 自動ズーム対策 */
button.go {
position: sticky;
bottom: calc(8px + env(safe-area-inset-bottom));
}
}
まとめ
- 自宅サーバを外に出すなら、まず「認証が無い」ことを直視する。 自宅のネットワークも安全ではありません。家族の端末も、来客のスマホも、スマート家電も同じネットワークにいます
- Tailscale はポート開放を不要にする。 ルーターも DDNS も触りません
-
serveとfunnelを取り違えない。 前者は自分のネットワーク内、後者はインターネット全体です -
Success.を信じない。 実際に、別のデバイスからアクセスして確認する - HTTPS を諦める判断は、合理的なこともある。 「HTTPSでないと駄目」と思い込む前に、何のために要るのかを確かめる
- 繋がっただけでは使えない。 待ち時間のある処理は、繋ぎっぱなしを前提にしない設計にしておく
前回の記事から通底しているのですが、ローカルで組むときの最大のコストは「エラーを出さずに静かに壊れる箇所」だと思っています。今回もそうでした。Success. と出るのに繋がらない、symlink で呼ぶと落ちる、画面が消えると結果を見失う。どれもログを眺めているだけでは気づけません。
次は、生成した画像を LLM に説明させて、そこから次のプロンプトを作るあたりを試すつもりです。それをスマホから回せると、かなり実用的になりそうです。






