0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

自宅の Mac mini を画像生成サーバーにして、家の外の iPhone から安全に使う

0
Last updated at Posted at 2026-08-31

こんにちは。
トライベック株式会社の岡山です。

前回、Mac mini(M4 Pro / 48GB)で「完全ローカル」のLLM + RAG環境を構築してみたという記事を書きました。社内資料を外に出さずに扱いたい、という動機で組んだ環境です。

同じ Mac mini には ComfyUI も入れています。日本語で書いたプロンプトをローカルLLMが英語に変換し、そのまま画像を生成する仕組みです。手元で完結するので、プロンプトも生成物も外に出ません。

夏休みの自由研究のつもりで、ひとつ試してみました。

自宅の Mac mini を画像生成サーバーにして、家の外の iPhone から使えるようにする。

そして自由研究らしく、素直には進みませんでした。 「自宅のサーバを外から使う」は、思っていたより慎重にやるべき作業だったのです。この記事はその記録です。

この記事で分かること

  1. 自宅のサーバを外から使うときに、やってはいけない繋ぎ方
  2. Tailscale でポートを開けずに繋ぐ手順
  3. tailscale serveSuccess. と表示しながら失敗する
  4. 「繋がった」だけでは使えなかった話

環境

項目
サーバ Mac mini (2024) / M4 Pro / 48GB
クライアント iPhone(Android でも可)
画像生成 ComfyUI + SDXL 1.0 base
LLM Ollama(gemma3:12b
ブリッジ FastAPI(自作)
VPN Tailscale

1. 作ったもの

01_flow.png

スマホで日本語を書いて送ると、Mac mini が英語プロンプトに変換して画像を生成し、手元に返してくれます。

入力の長さに制限はありません。ただし変換後のプロンプトは、システムプロンプトで75語以内に収めるよう指示しています。CLIP のテキストエンコーダが77トークンで頭打ちになるためです。長く書いても要約されるので、短く要点だけ書くほうが狙いどおりになります。

ルーターのポート開放はしていません。 クラウドのサービスも経由しません。

実際の画面です。左が入力、右が結果。

iphone_flow.png

日本語で「窓辺で丸くなる黒猫、朝の斜光、フィルム写真風」と書くと、black cat, curled up, windowsill, morning light, ... という英語のタグ列に変換され、そのまま画像になります。変換に使われたプロンプトも画面に出るようにしてあるので、狙いどおりに解釈されたかを確認できます。

2. 前置き:なぜローカルLLMを挟んでいるのか

SDXL のテキストエンコーダは英語で学習されていて、日本語は語彙として想定されていません。

ただし、日本語を入れてもエラーは出ません。それらしい画像が出てしまいます。 ここが厄介なところです。

compare_ja_vs_en.png

左が「夕暮れの海辺を歩く白い犬、逆光、フィルム写真風」をそのまま入れたもの。右が同じ文をローカル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 には、servefunnel という似た名前のコマンドがあります。serve は自分のネットワーク内への公開ですが、funnel はインターネット全体への公開です。認証の無いサーバを funnel で出したら、世界中の誰でも私の Mac で画像を作れてしまいます。

ドキュメントを流し読みしていると取り違えかねない距離感です。打つ前に必ず確認してください。

やりたいこと

  • ルーターのポートは開けない
  • 同じ Wi-Fi の他の端末からも見せない
  • インターネットには当然出さない
  • 自分の iPhone からだけ見える

access-scope.png

これを満たすのが 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 で受けるので、証明書が無いと通信が成立しません。接続拒否ではなくタイムアウトになるのは、そのためです。

serve-success-gap.png

教訓は「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 無し
証明書の管理 必要 不要 不要

01_tailscale-direct.png

失うのは 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();   // 途中だったジョブを追いかける
  }
});

job-recovery.png

これで、生成ボタンを押して 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));
  }
}

まとめ

  1. 自宅サーバを外に出すなら、まず「認証が無い」ことを直視する。 自宅のネットワークも安全ではありません。家族の端末も、来客のスマホも、スマート家電も同じネットワークにいます
  2. Tailscale はポート開放を不要にする。 ルーターも DDNS も触りません
  3. servefunnel を取り違えない。 前者は自分のネットワーク内、後者はインターネット全体です
  4. Success. を信じない。 実際に、別のデバイスからアクセスして確認する
  5. HTTPS を諦める判断は、合理的なこともある。 「HTTPSでないと駄目」と思い込む前に、何のために要るのかを確かめる
  6. 繋がっただけでは使えない。 待ち時間のある処理は、繋ぎっぱなしを前提にしない設計にしておく

前回の記事から通底しているのですが、ローカルで組むときの最大のコストは「エラーを出さずに静かに壊れる箇所」だと思っています。今回もそうでした。Success. と出るのに繋がらない、symlink で呼ぶと落ちる、画面が消えると結果を見失う。どれもログを眺めているだけでは気づけません。

次は、生成した画像を LLM に説明させて、そこから次のプロンプトを作るあたりを試すつもりです。それをスマホから回せると、かなり実用的になりそうです。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?