ローカルLLMをTyping Mindから使う。Docker Model Runner + Gemma 3 連携を Caddy で実現するまで

今週はローカル環境で大規模言語モデル (LLM) を動かして遊んでました。特に Docker Desktop に統合された「Model Runner」機能は、コンテナ技術を使って手軽に LLM を試せるので捗ります。今回は、この Docker Model Runner で Google の最新モデル Gemma 3 を動かし、AI チャット UI「Typing Mind」から利用しようとした際の試行錯誤と、その解決策について紹介します。

今回の例では「Typing Mind」という特定のアプリケーションを取り上げますが、ローカルで動作する API サーバーと、Web 技術をベースにしたフロントエンドアプリケーション(Web アプリや、今回のようなデスクトップアプリ)を連携させようとする際に直面する「HTTPS 化」や「CORS (Cross-Origin Resource Sharing) エラー」といった課題は、非常に多くの開発シーンで共通して見られます。 そのため、ローカル LLM を手軽に試したいけれど、Web ベースの UI との連携で「http 接続ができない」「CORS エラーが出る」といった問題にぶつかっている場合は参考になるかもしれません。

やりたかったこと:Docker Model Runner + Typing Mind

今回の目標はシンプルです。

  1. Docker Desktop の Model Runner を使って、ローカルマシン上で Gemma 3 モデルを起動する。
  2. Typing Mind ( macOS でPWAで動作) から、このローカル Gemma 3 をカスタムモデルとして登録し、チャットできるようにする。

これができれば、外部 API の利用制限やコストを気にせず、プライベートな環境で色々遊べます。(ただし、OpenRouterとかで無料のがあったりお遊び用だと割り切ってください。)

最初のステップとハマったところ

まずは、基本的なセットアップから始めます。

こちらの内容にも関連しています。

  1. Docker Model Runner の有効化と Gemma 3 のプル: Docker Desktop で Model Runner を有効化し、ai/gemma3 モデルをダウンロード。
  2. TCP アクセスの有効化: 以下のコマンドで、Model Runner がポート 12434 でリクエストを受け付けるように設定しました。 Bashdocker desktop enable model-runner --tcp 12434
  3. curl での疎通確認: まずは基本の curl で、API エンドポイントにアクセスできるか確認します。

    Bash
    • curl -X POST http://localhost:12434/engines/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "ai/gemma3", "messages": [{"role": "user", "content": "こんにちは!"}], "stream": false}'
  4. 最初は接続エラーが出ましたが、TCP アクセス有効化コマンドを正しく実行することで、無事に http://localhost:12434 への curl 通信は成功しました。これで、Model Runner 自体は動作していることが確認できました。

さて、いよいよ Typing Mind に登録です。API エンドポイントとして http://localhost:12434/engines/v1/chat/completions を設定しようとしました。しかし…

  • ハマりポイント1: HTTP 接続のブロック: Typing Mind からの接続テストが失敗します。調べてみると、Ollama (別のローカル LLM 実行ツール) の連携ガイドなどから、Typing Mind (特に macOS アプリ版) はセキュリティ上の理由で http への直接接続を許可していない可能性が高いことが分かりました。つまり、https でアクセスできる必要があるようです。
  • ハマりポイント2: CORS (Cross-Origin Resource Sharing) の壁: 仮に https が使えたとしても、Web ブラウザや Web 技術ベースのアプリ (Typing Mind もその一種) から別のドメイン (今回は localhost と Typing Mind のオリジン) にアクセスするには、サーバー側がそれを許可する「CORS ヘッダー」を返す必要があります。Docker Model Runner は、デフォルトではこの CORS 設定を提供していません。

これらの課題を解決するため、リバースプロキシの導入を決定しました。

今回は、設定がシンプルで https 化も容易な Caddy を採用することにしました。

Caddy 登場!HTTPS化とCORS対策の試行錯誤

Caddy の役割は、https://localhost:12435 のような https のリクエストを受け取り、CORS ヘッダーを適切に付与した上で、内部的に http://localhost:12434 へ転送することです。

試行1: シンプルな HTTPS プロキシ + CORS

まずは、以下のような Caddyfile を用意しました。

コード スニペット

ShellScript
localhost:12435 {
    tls internal  # ローカル用の自己署名証明書で HTTPS 化
    log { output stderr; level INFO } # ログ出力

    # CORS ヘッダーをとりあえず全部許可
    header Access-Control-Allow-Origin "*"
    header Access-Control-Allow-Methods "GET, POST, OPTIONS"
    header Access-Control-Allow-Headers "*"

    # /engines/v1/* へのアクセスを localhost:12434 へ転送
    reverse_proxy /engines/v1/* http://localhost:12434 {
        header_up Host {http.request.host} # Host ヘッダーを維持
    }
}

これで Typing Mind に https://localhost:12435/engines/v1/chat/completions を設定してみましたがブラウザのコンソール (Typing Mind アプリでも内部的に Web 技術が使われているため、デバッグが可能です) にはエラーメッセージが出ていました。

Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

これは、CORS のプリフライトリクエスト (OPTIONS メソッド) が失敗していることを示しています。

試行2: OPTIONS リクエストへの対応

CORS では、実際のリクエスト (POST など) を送信する前に、OPTIONS メソッドを使ってサーバーに「この種類のリクエストを送ってもいい?」とお伺いを立てます (これがプリフライト)。

先ほどのエラーは、Caddy が OPTIONS リクエストをそのまま Docker Model Runner に転送し、Model Runner が OPTIONS を処理できずにエラー (非 200 OK) を返したために発生していました。

対策として、OPTIONS リクエストは Caddy 自身が受け取って、「大丈夫だよ」という意味の 204 No Content を返すように Caddyfile を修正します。

コード スニペット

ShellScript
localhost:12435 {
    tls internal
    log { output stderr; level INFO }

    header Access-Control-Allow-Origin "*"
    header Access-Control-Allow-Methods "GET, POST, OPTIONS"
    header Access-Control-Allow-Headers "*"

    # OPTIONS リクエストをハンドルする設定を追加
    @options method OPTIONS
    handle @options {
        respond 204
    }

    # それ以外のリクエストをハンドルする
    handle {
        reverse_proxy /engines/v1/* http://localhost:12434 {
            header_up Host {http.request.host}
        }
    }
}

これで再度テストすると、OPTIONS は通るようになりましたが、今度は POST リクエストで新たな CORS エラーが発生しました。

The 'Access-Control-Allow-Origin' header contains multiple values '*, https://www.typingmind.com', but only one is allowed.

どうやら、Docker Model Runner 自身も Access-Control-Allow-Origin (ACAO) ヘッダーを返すようで、Caddy が追加する ACAO ヘッダーと重複してしまっているようです。ACAO ヘッダーは 1 つしか許されません。

試行3: ACAO ヘッダーの重複解消

最後の仕上げです。Caddy の reverse_proxy 設定で、バックエンド (Docker Model Runner) から返ってくる ACAO ヘッダーを削除するように指示します。

コード スニペット

ShellScript
localhost:12435 {
    tls internal

    log {
        output stderr
        level INFO
    }

    # すべての応答に適用する CORS ヘッダー
    header Access-Control-Allow-Origin "*"
    header Access-Control-Allow-Methods "GET, POST, OPTIONS"
    header Access-Control-Allow-Headers "*"

    # OPTIONS リクエストを特別に処理する
    @options method OPTIONS
    handle @options {
        respond 204
    }

    # OPTIONS 以外のリクエストを処理する
    handle {
        reverse_proxy /engines/v1/* http://localhost:12434 {
            header_up Host {http.request.host}
            
            # Docker Model Runner が返すかもしれない ACAO ヘッダーを削除する
            header_down -Access-Control-Allow-Origin
        }
    }
}

この最終版の Caddyfile を使って Caddy を起動し、Typing Mind で接続テストを行ったところうまくいきました。 Typing Mind の画面から、ローカルで動いている Gemma 3 とチャットができるようになりました。

typemindの設定はこちら

まとめ

Docker Model Runner を使えばローカル LLM 環境の構築は簡単ですが、Typing Mind のような Web ベースのアプリから利用するには、HTTPSCORS という 2 つの壁が存在します。

今回の調査を通じて、リバースプロキシ (Caddy) がこれらの課題を解決してくれることが分かりました。

  • tls internal: ローカル環境での HTTPS 化を簡単に実現。
  • header ディレクティブ: CORS ヘッダーを柔軟に制御。
  • handle@matcher: OPTIONS プリフライトリクエストのような特定のケースを的確に処理。
  • header_down: バックエンドからの不要なヘッダーを削除。

curl での疎通確認から始め、ブラウザのデベロッパーツールでエラーを丹念に追うことで、一歩ずつ問題を解決できました。もし、ローカル AI モデルと Web アプリの連携で悩んでいるなら、Caddy のリバースプロキシで問題解決できるかもしれません。