<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Ollama · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/ollama/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Fri, 30 Oct 2026 18:20:44 +0900</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/ollama/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>vLLM と Ollama の比較：ローカルLLMを用途でどう使い分けるか</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-30-vllm-vs-ollama/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-30-vllm-vs-ollama/</guid>
      <pubDate>Fri, 30 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>vLLM</category>
      <category>Ollama</category>
      <category>LLM</category>
      <category>Self-Hosted</category>
      <category>MLOps</category>
      <category>DevOps</category>
      <category>Docker</category>
      <category>Kubernetes</category>
      <description>vLLM と Ollama を両方使う立場からの比較です。一人で使うなら Ollama、GPU サーバーで大量のリクエストをさばくなら vLLM。ローカルLLMの導入、Docker・Kubernetes 構成、ハマりどころまで。</description>
      <content:encoded><![CDATA[<p>一人で、あるいは一台のマシン上のひとつのアプリで、数分のうちにモデルを動かしたいなら Ollama を使ってください。GPU サーバーが大量のリクエストを同時にさばく必要があり、スループットが大事なら vLLM です。Ollama はモデルランナーです。量子化されたモデルを取ってきて、頼まれたときにロードし、使い終われば手放します。vLLM は推論サーバーです。起動時に GPU の大半を確保し、処理中のリクエストをすべてバッチにまとめて GPU を遊ばせません。どちらも OpenAI API を話すので、呼び出す側のコードはまったく同じにできます。</p>
<p>私はどちらも毎日使っています。Ollama はラップトップとオフィスの GPU ワークステーションで動いていて、開発と自分用のツールに使っています。vLLM はフリーランスの案件で、96 GB の GPU 一枚からチャットモデルを配信しています。最初は Docker Compose で、今は小さな Kubernetes クラスタの上です。この記事では、それぞれが何に向いているか、どうインストールするか、そして vLLM 側で時間を取られたことを書きます。</p>

<h2 class="relative group">vLLM と Ollama の比較表
    <div id="vllm-と-ollama-の比較表" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#vllm-%e3%81%a8-ollama-%e3%81%ae%e6%af%94%e8%bc%83%e8%a1%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Ollama</th>
					<th>vLLM</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>向いている用途</td>
					<td>一人で使う: ラップトップ、開発マシン、個人用のツール</td>
					<td>多数のユーザーや忙しいアプリ、GPU サーバー上で</td>
			</tr>
			<tr>
					<td>動く環境</td>
					<td>macOS、Linux、Windows。Apple silicon、NVIDIA、AMD、または CPU</td>
					<td>NVIDIA GPU を積んだ Linux がいちばん楽。ほかのハードウェアでも手間をかければ動く</td>
			</tr>
			<tr>
					<td>モデル</td>
					<td>独自ライブラリの量子化済み GGUF(<code>qwen3:8b</code>)</td>
					<td>Hugging Face の重み。BF16、FP8、AWQ、GPTQ(<code>Qwen/Qwen3-8B</code>)</td>
			</tr>
			<tr>
					<td>インストール</td>
					<td>インストーラーを入れて <code>ollama run</code></td>
					<td><code>pip install vllm</code>、または Docker イメージ <code>vllm/vllm-openai</code></td>
			</tr>
			<tr>
					<td>GPU メモリ</td>
					<td>初回利用時にモデルをロードし、5 分間使われなければアンロード</td>
					<td>起動時にカードの一定割合(デフォルトで 90% 超)を確保し、そのまま保持</td>
			</tr>
			<tr>
					<td>同時リクエスト</td>
					<td>並列スロットがいくつか</td>
					<td>連続バッチ処理(continuous batching)。これが本領</td>
			</tr>
			<tr>
					<td>起動</td>
					<td>小さなモデルなら数秒</td>
					<td>大きなモデルなら数分</td>
			</tr>
			<tr>
					<td>LoRA アダプター</td>
					<td>Modelfile で新しいモデルに焼き込む</td>
					<td>サーバーひとつに複数載せ、リクエストごとに選ぶ</td>
			</tr>
			<tr>
					<td>API</td>
					<td>独自 API に加えて、<code>:11434/v1</code> で OpenAI 互換</td>
					<td><code>:8000/v1</code> で OpenAI 互換</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">Ollama とは
    <div id="ollama-とは" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ollama-%e3%81%a8%e3%81%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>自分のマシンでモデルに答えさせる方法として、Ollama は私の知る限りいちばん簡単です。llama.cpp から生まれたもので、量子化された GGUF モデルを動かします。8B のモデルが約 5 GB に収まり、MacBook でも快適に動くのはそのためです。モデルを名前で pull すると、最初に呼び出したときにロードされ、5 分間何もすることがなければメモリから外れます。なので、ほかに動かしているものと行儀よくマシンを分け合ってくれます。</p>
<p>その行儀の良さがトレードオフです。Ollama は一度に一人を相手にする前提で作られています。いくつかのリクエストを並列に処理することはできますが、並列スロットひとつごとにメモリを食いますし、何十人ものユーザーを一枚の GPU にバッチでまとめるためのものでは、もともとありません。</p>

<h2 class="relative group">vLLM とは
    <div id="vllm-とは" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#vllm-%e3%81%a8%e3%81%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>vLLM はサービングエンジンで、まさにそのバッチ処理のために作られています。それを支えているのは主に二つのアイデアです。PagedAttention は、リクエストごとの KV キャッシュ(モデルがトークンを読み書きしながら積み上げていく attention の状態)を小さな固定サイズのブロックに格納するので、パディングや断片化で GPU メモリが無駄になりません。連続バッチ処理では、新しいリクエストが今のバッチの終わりを待たずに、生成ステップの合間にバッチへ加わります。この二つのおかげで、vLLM を動かした GPU 一枚は、同じ GPU でリクエストを一件ずつ処理するよりはるかに多くの同時ユーザーをさばけます。</p>
<p>代償として、vLLM はカードを自分のものだと思っているかのように振る舞います。起動時に GPU メモリの取り分を確保し、重みに要らない分を KV キャッシュで埋め、誰も呼び出していなくても全部を握ったままです。それに、必要なのは Hugging Face のフルの重みか、FP8 のような GPU 向けの量子化で、Ollama が使う GGUF ファイルではありません。</p>

<h2 class="relative group">API はひとつ、サーバーは二つ
    <div id="api-はひとつサーバーは二つ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#api-%e3%81%af%e3%81%b2%e3%81%a8%e3%81%a4%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%81%af%e4%ba%8c%e3%81%a4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>どちらも OpenAI の chat completions API を公開しているので、私のコードは相手がどちらなのかを知りません。URL とモデル名は環境変数から取ります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">openai</span> <span class="kn">import</span> <span class="n">OpenAI</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ラップトップ: LLM_URL=http://localhost:11434/v1  LLM_MODEL=qwen3:8b</span>
</span></span><span class="line"><span class="cl"><span class="c1"># GPU サーバー: LLM_URL=http://localhost:8000/v1   LLM_MODEL=qwen3-8b</span>
</span></span><span class="line"><span class="cl"><span class="n">client</span> <span class="o">=</span> <span class="n">OpenAI</span><span class="p">(</span><span class="n">base_url</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;LLM_URL&#34;</span><span class="p">],</span> <span class="n">api_key</span><span class="o">=</span><span class="s2">&#34;not-needed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">reply</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">completions</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;LLM_MODEL&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">messages</span><span class="o">=</span><span class="p">[{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;Say hello in Korean.&#34;</span><span class="p">}],</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">reply</span><span class="o">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">content</span><span class="p">)</span></span></span></code></pre></div></div>
<p>大きな GPU のないラップトップでは、アプリは Ollama を向きます。サーバーでは vLLM を向きます。ほかは何も変わらず、GPU マシンの電源が入っていなくてもアプリを開発できます。vLLM 側では <code>--served-model-name</code> でモデルに短い名前を付けておけば、クライアントが Hugging Face のフルパスを送らずに済みます。</p>
<p>忘れやすいので、注意をひとつ。同じモデルでも、両者で同じ答えが返るわけではありません。Ollama の <code>qwen3:8b</code> は 4 ビット量子化ですが、vLLM では普通 16 ビットか FP8 の重みを動かします。片方に合わせて調整したプロンプトが、もう片方では違う振る舞いをすることがあります。なので測っているものは、回答の品質であれ、拒否であれ、JSON がパースできるかどうかであれ、本番で実際に使うサーバーで確かめる必要があります。</p>

<h2 class="relative group">Ollama のインストール方法
    <div id="ollama-のインストール方法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ollama-%e3%81%ae%e3%82%a4%e3%83%b3%e3%82%b9%e3%83%88%e3%83%bc%e3%83%ab%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Mac か Windows なら、ollama.com からアプリをダウンロードします。Linux ならこうです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL https://ollama.com/install.sh <span class="p">|</span> sh
</span></span><span class="line"><span class="cl">ollama run qwen3:8b</span></span></code></pre></div></div>
<p>これで終わりです。API は <code>http://localhost:11434</code> で使えるようになっていて、<code>OLLAMA_HOST</code> を設定しない限り localhost でしか待ち受けません。それが望ましい挙動です。私の Ollama はオフィスのワークステーションで <code>127.0.0.1</code> にバインドして動いていて、どこからでもトンネル経由で届くようにしています(<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >Cloudflare WARP でそれを組んだ方法</a>)。</p>

<h2 class="relative group">vLLM のインストール方法
    <div id="vllm-のインストール方法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#vllm-%e3%81%ae%e3%82%a4%e3%83%b3%e3%82%b9%e3%83%88%e3%83%bc%e3%83%ab%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>NVIDIA の GPU と新しめのドライバーがある Linux なら、こうです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python -m venv .venv <span class="o">&amp;&amp;</span> <span class="nb">source</span> .venv/bin/activate
</span></span><span class="line"><span class="cl">pip install vllm
</span></span><span class="line"><span class="cl">vllm serve Qwen/Qwen3-8B --served-model-name qwen3-8b --max-model-len <span class="m">16384</span></span></span></code></pre></div></div>
<p>初回の実行では Hugging Face から重みをダウンロードします。ログに <code>Application startup complete</code> と出たら、<code>curl localhost:8000/v1/models</code> でモデルが一覧に出て、上の Python がそのまま動きます。</p>

<h2 class="relative group">Docker で vLLM を動かす
    <div id="docker-で-vllm-を動かす" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#docker-%e3%81%a7-vllm-%e3%82%92%e5%8b%95%e3%81%8b%e3%81%99" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ずっと動いていてほしいものには、pip install ではなく公式イメージを使っています。主な理由は、CUDA、PyTorch、vLLM がセットで固定された状態で手に入るからです。汎用的な Compose のサービスはこうなります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">vllm</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.30.0  </span><span class="w"> </span><span class="c"># タグを固定する。イメージは約 30 GB</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ipc</span><span class="p">:</span><span class="w"> </span><span class="l">host                         </span><span class="w"> </span><span class="c"># PyTorch が共有メモリを必要とする</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      --model Qwen/Qwen3-8B
</span></span></span><span class="line"><span class="cl"><span class="sd">      --served-model-name qwen3-8b
</span></span></span><span class="line"><span class="cl"><span class="sd">      --gpu-memory-utilization 0.85
</span></span></span><span class="line"><span class="cl"><span class="sd">      --max-model-len 16384</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cache/huggingface:/root/.cache/huggingface</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8000:8000&#34;</span><span class="w">          </span><span class="c"># LAN ではなく ssh -L 経由でアクセスする</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">reservations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">nvidia</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">count</span><span class="p">:</span><span class="w"> </span><span class="l">all</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">capabilities</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">gpu]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;python3&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;import urllib.request; urllib.request.urlopen(&#39;http://localhost:8000/health&#39;)&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">90</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">120s</span></span></span></code></pre></div></div>
<p>タグは固定してください。vLLM は動きが速く、リリースのたびにフラグやデフォルトが変わります。それがいつ起きるかは、自分で決められるようにしておきたいところです。<code>ipc: host</code>(あるいは十分な <code>shm_size</code>)は vLLM 自身の Docker の手順に書かれています。長い <code>start_period</code> が必要な理由は、次の節のひとつ目です。</p>

<h2 class="relative group">vLLM の運用でハマったこと
    <div id="vllm-の運用でハマったこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#vllm-%e3%81%ae%e9%81%8b%e7%94%a8%e3%81%a7%e3%83%8f%e3%83%9e%e3%81%a3%e3%81%9f%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong>起動に数分かかる。</strong> 重みのロード、コンパイル、CUDA グラフのキャプチャで、私のサーバーでは 190 秒かかりました。大きなモデルなら 3〜5 分は普通です。Web アプリ向けに書いたヘルスチェックだと、準備ができる前に殺してしまいます。Compose なら長い <code>start_period</code>、Kubernetes なら startup probe が必要です。それに、再起動のたびにモデルのいない時間が数分できるので、気軽に再起動するものではありません。</p>
<p><strong>GPU メモリは、与えた分を起動時に持っていく。</strong> <code>--gpu-memory-utilization</code> は vLLM が使ってよいカードの割合で、最近のリリースのデフォルトは 0.92、重みに要らない分はすべて KV キャッシュになります。vLLM がカードを独り占めしているなら、それで問題ありません。私の場合はそうではなく、横で小さなモデルが動いているので、96 GB のカードで 0.70 にしています。モデルは FP8 の 35B の mixture-of-experts で、ロードすると 47.7 GiB、KV キャッシュには 15 GiB が残りました。16k のコンテキストなら十分な量です。最大シーケンス長が KV キャッシュに収まらないと言って vLLM が起動を拒むなら、大きな GPU を探しに行く前に <code>--max-model-len</code> を下げてみてください。</p>
<p><strong>LoRA のスロットは先にメモリを取る。</strong> vLLM はひとつのベースモデルに複数の LoRA アダプターを載せて配信でき、リクエストごとにモデル名で切り替えられます。これは Ollama には実質できないことです。ただし vLLM は起動時に、ランク <code>--max-lora-rank</code> のアダプター <code>--max-loras</code> 個分の領域を確保します。mixture-of-experts のモデルではこの確保がエキスパートにも及ぶので、すぐに膨らみます。0.70 では、ランク 64 のアダプター 4 つは収まりませんでした。2 つなら収まりました。</p>
<p><strong>デフォルトのシーケンス上限がハイブリッドモデルに合わなかった。</strong> 私が配信しているモデルは attention の層に Mamba 系の層が混ざっていて、シーケンスごとにその層用のキャッシュブロックがひとつずつ必要です。そのカードでは vLLM のデフォルトの <code>--max-num-seqs</code> だと起動に失敗し、<code>--max-num-seqs 256</code> で直りました。新しめのアーキテクチャが起動しないときは、もっと込み入った原因を疑う前にこのフラグを確認してください。</p>
<p><strong><code>vllm</code> という名前の Kubernetes Service で vLLM がクラッシュする。</strong> これが個人的にいちばん気に入っています。Kubernetes は namespace 内のすべての Service について、その名前を元にした環境変数を注入します。なので <code>vllm</code> という Service があると、すべての Pod に <code>VLLM_PORT=tcp://&lt;cluster-ip&gt;:8000</code> が渡ります。vLLM は <code>VLLM_PORT</code> を自分の設定のひとつとして読み、そこにある URL を解釈できずに落ちました。もちろん、モデルを丸ごとロードしたあとでです。なので試すたびに 3 分かかりました。vLLM のドキュメントにはこの注意がちゃんと書いてあって、私はそれを後から見つけました。直し方は、Pod に <code>enableServiceLinks: false</code> を付けるか、Service を別の名前にすることです。</p>
<p>動く Deployment の骨格を、大事な部分だけに絞って載せます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">strategy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">Recreate              </span><span class="w"> </span><span class="c"># 先に古い Pod が GPU を解放する必要がある</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">enableServiceLinks</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">  </span><span class="c"># Service 由来の VLLM_PORT=tcp://... を入れない</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.30.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">args</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;--model=Qwen/Qwen3-8B&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--served-model-name=qwen3-8b&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">nvidia.com/gpu</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">32Gi      </span><span class="w"> </span><span class="c"># ロード時は配信時よりはるかに多くのホスト RAM が要る</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">volumeMounts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- {<span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="nt">shm, mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/shm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">startupProbe</span><span class="p">:</span><span class="w">          </span><span class="c"># モデルがロードされるまでは判定しない</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w"> </span>{<span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="nt">/health, port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">initialDelaySeconds</span><span class="p">:</span><span class="w"> </span><span class="m">120</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">90</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">livenessProbe</span><span class="p">:</span><span class="w">         </span><span class="c"># 再起動はフルリロードになるので気長に待つ</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w"> </span>{<span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="nt">/health, port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">timeoutSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">shm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">emptyDir</span><span class="p">:</span><span class="w"> </span>{<span class="nt">medium</span><span class="p">:</span><span class="w"> </span><span class="nt">Memory, sizeLimit</span><span class="p">:</span><span class="w"> </span><span class="l">16Gi}</span></span></span></code></pre></div></div>
<p>真似する価値があるのはプローブの部分です。startup probe は 2 分待ってから、さらに 15 分、モデルが上がってくるのを待ち、それでもだめなら Kubernetes が死んだと判断します。liveness probe が再起動するのは、失敗が 5 分ほど続いてからです。負荷の高いエンジンでは <code>/health</code> が遅くなることがあり、どのみち再起動はフルリロードになるからです。GPU 一枚の環境では <code>Recreate</code> が効いてきます。ローリングアップデートは新しい Pod を先に起動するので、新しい Pod は古い Pod が握ったままの GPU を待って止まってしまいます。メモリの上限が大きく見えるのは、実際に大きいからです。重みが GPU に載ってしまえば、私の環境ではホスト RAM は約 7 GiB に落ち着きますが、ロードそのものにはもっとずっと多く必要です。</p>

<h2 class="relative group">Ollama について知っておきたいこと
    <div id="ollama-について知っておきたいこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ollama-%e3%81%ab%e3%81%a4%e3%81%84%e3%81%a6%e7%9f%a5%e3%81%a3%e3%81%a6%e3%81%8a%e3%81%8d%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>驚かされることは少なめです。主に、やっていることが少ないからです。</p>
<p>しばらく静かだったあとの最初のリクエストは遅くなります。モデルがアンロードされていて、もう一度ロードしなければならないからです。<code>OLLAMA_KEEP_ALIVE</code> は、最後のリクエストのあとモデルがどれだけメモリに残るかを決めます。その待ち時間が気になるなら値を上げるか、ほかに何もしないマシンなら <code>-1</code> にしてください。</p>
<p>コンテキストウィンドウは GPU メモリの量で変わります。24 GiB 未満だと Ollama のデフォルトは 4k トークンで、ほとんどのモデルが対応している長さよりずっと短く、収まらないプロンプトはエラーにならずに黙って切り詰められます。長いプロンプトの冒頭が無視されているように見えたら、<code>OLLAMA_CONTEXT_LENGTH</code>(またはリクエストごとの <code>num_ctx</code>)を実際に必要な長さに設定してください。</p>
<p>リモートの Ollama をラップトップにフォワードするなら、11434 は避けてください。Ollama のデフォルトのポートなので、ローカルの Ollama がいつか起動すると二つがポートを奪い合い、気づかないまま違うほうと話していた、ということになりかねません。私は 11435 にフォワードしています。</p>
<p>それから、Ollama はマルチユーザーのサーバーではありません。数人が使う社内ツールなら問題ありませんが、本当に同時アクセスが出てきたら、そこが vLLM に移るタイミングです。</p>

<h2 class="relative group">必要な GPU メモリの目安
    <div id="必要な-gpu-メモリの目安" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bf%85%e8%a6%81%e3%81%aa-gpu-%e3%83%a1%e3%83%a2%e3%83%aa%e3%81%ae%e7%9b%ae%e5%ae%89" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>重みだけのおおよそのサイズです。</p>
<table>
	<thead>
			<tr>
					<th>モデルサイズ</th>
					<th>16 ビット(BF16)</th>
					<th>8 ビット(FP8)</th>
					<th>4 ビット GGUF(Q4_K_M)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>8B</td>
					<td>~16 GB</td>
					<td>~8 GB</td>
					<td>~5 GB</td>
			</tr>
			<tr>
					<td>32B</td>
					<td>~64 GB</td>
					<td>~32 GB</td>
					<td>~20 GB</td>
			</tr>
			<tr>
					<td>70B</td>
					<td>~140 GB</td>
					<td>~70 GB</td>
					<td>~43 GB</td>
			</tr>
	</tbody>
</table>
<p>16 ビットならパラメータあたり約 2 バイト、8 ビットなら 1 バイト、Q4_K_M なら 0.5 バイトを少し超えるくらいです。重みに加えて KV キャッシュが乗り、これはコンテキスト長と処理中のリクエスト数に応じて増えます。同じモデルでも、50 人に配信する vLLM のほうが、あなた一人に応える Ollama よりずっと多くのメモリを必要とするのは、この二つ目の部分のせいです。</p>

<h2 class="relative group">どちらを使うか
    <div id="どちらを使うか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%a9%e3%81%a1%e3%82%89%e3%82%92%e4%bd%bf%e3%81%86%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>自分のマシンでモデルを動かしたいなら、Ollama をインストールしてそこで終わりにしてください。10 分もあれば何かが答えてくれるようになります。GPU サーバーがあって、それを一日中呼び出すユーザーやアプリがいるなら vLLM を使い、遅い起動と持っていかれるメモリを受け入れて、プローブをきちんと設定してください。結局両方を使う構成も多く、私のところもそうです。コードを書く場所には Ollama、リクエストが届く場所には vLLM です。</p>
<p>ほかの選択肢もあります。vLLM の代わりによく挙がるのは SGLang ですが、本番で動かしたことはないので、推測で書くよりは外しておきました。モデルをスマホで動かす必要があるなら、それはまた llama.cpp の出番で、これについては <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-01-30-on-device-ai/" >React Native でオンデバイスの LLM を動かす記事</a>に書きました。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-30-vllm-vs-ollama/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Cloudflare WARP で個人用のプライベートネットワークを作る: ラップトップ、スマホ、ワークステーションをつなぐ</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-01-cloudflare-warp-dev-network/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-01-cloudflare-warp-dev-network/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>WARP</category>
      <category>Zero Trust</category>
      <category>Cloudflare Tunnel</category>
      <category>Networking</category>
      <category>SSH</category>
      <category>DevOps</category>
      <category>AI Agents</category>
      <category>Ollama</category>
      <category>Mobile Development</category>
      <category>IPv6</category>
      <description>Cloudflare WARP と二本の Cloudflare Tunnel(Zero Trust)で、ラップトップ、5G のスマホ、オフィスの GPU ワークステーションを個人用のプライベートネットワークにまとめた方法です。固定のプライベート IP、開けるポートはゼロ、dev サーバーは Access の背後に。</description>
      <content:encoded><![CDATA[<p>Cloudflare WARP を使うと、ポートをひとつも開けずに、自分のマシンたちをひとつのプライベートネットワークにまとめられます。届かせたいマシンそれぞれで Cloudflare Tunnel(<code>cloudflared</code>)を動かして各トンネルにプライベートな IP 範囲を広告させ、ラップトップとスマホは WARP クライアントを include モードにして Zero Trust 組織に登録します。それ以降は、5G につながったスマホから <code>198.18.42.2</code> に素の接続を張るだけで、オフィスのワークステーションにたどり着きます。以下は私の環境の組み立て方と、途中でつまずいた四つのことです。</p>
<p>私の作業環境は、同じ場所にそろうことがほとんどない三台のマシンでできています。一台目は MacBook Air で、私の行くところにはどこへでもついてきます。二台目はオフィスにある devbox という名前のワークステーションで、ちゃんとした GPU を積んでいて、Ollama、ファインチューニング用のツール、専用のコンテナレジストリを持つ小さな Kubernetes クラスタ、そして長いジョブをこつこつ処理させたまま置いてきたエージェントが動いています。三台目はスマホです。私が作ったアプリが実際に使われるのはここで、一日の大半を wifi ではなくモバイルデータで過ごしています。</p>
<p>長いあいだ、この三台はまともに会話できていませんでした。ラップトップから devbox に届くのはオフィスの LAN にいるときだけ。スマホからラップトップの dev サーバーに届くのは両方が同じ wifi にいるときだけで、しかも <code>0.0.0.0</code> にバインドするのを忘れず、その朝 DHCP が Mac に割り当てたアドレスを打ち込んだ場合に限られました。ワークステーション上の面白いものはすべて、Ollama の API も、クラスタのダッシュボードも、トレースも、レジストリも、localhost かクラスタのネットワークにバインドしてありました。わざとです。どれもインターネットに置くべきものではないからです。</p>
<p>この記事は、Cloudflare WARP と二本の Cloudflare Tunnel でこれらをつなげた方法と、それで AI 機能やモバイルアプリの作り方がどう変わったか、そして何も言わずに壊れたいくつかのことについて書いたものです。</p>

<h2 class="relative group">本当に欲しかったもの
    <div id="本当に欲しかったもの" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ac%e5%bd%93%e3%81%ab%e6%ac%b2%e3%81%97%e3%81%8b%e3%81%a3%e3%81%9f%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>要件として書き出すと短いです。</p>
<ol>
<li>ラップトップはシンクライアントにする。GPU や大量の RAM、何時間もの実行時間が必要なものはすべて devbox で動かし、ラップトップからはオフィスでも自宅でもカフェでも同じ方法で届くようにする。</li>
<li>スマホからは、どこにいても、モバイルデータでも、dev サーバーとシェルに届くようにする。いちいちオンオフを覚えておかなければならない VPN は使わない。</li>
<li>システムの振る舞いを見るためのもの、つまりダッシュボード、トレース、<code>docker info</code>、モデル一覧は、私だけが見られて、ほかの誰にも見られないようにし、公開 URL は決して付けない。</li>
<li>インバウンドのポートは開けない。オフィスのルーターにも、自宅のルーターにも、どこにも。</li>
</ol>
<p>四つ目で、昔ながらの答えであるポートフォワードとダイナミック DNS は候補から外れます。凝った手の多くも外れます。devbox もラップトップも自分では管理できない NAT の内側にいて、ラップトップにいたっては数時間ごとに違う NAT の内側にいるからです。</p>

<h2 class="relative group">以前: SSH トンネルのリポジトリ
    <div id="以前-ssh-トンネルのリポジトリ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bb%a5%e5%89%8d-ssh-%e3%83%88%e3%83%b3%e3%83%8d%e3%83%ab%e3%81%ae%e3%83%aa%e3%83%9d%e3%82%b8%e3%83%88%e3%83%aa" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初の版は自分用の小さなリポジトリで、今もこれが土台の層です。解決するのは、ラップトップ上で「SSH 越しに届くものにきれいな名前を付ける」という問題だけです。dnsmasq が <code>*.test</code> に <code>127.0.0.1</code> を返し、Caddy がホスト名でローカルのポートへ振り分けつつ <code>tls internal</code> でブラウザを満足させ、autossh の launch agent 群が SSH のフォワードを開いたまま保ち、切れたらつなぎ直します。ホスト名を見ればそれがどこにあるかが分かります。</p>
<table>
	<thead>
			<tr>
					<th>サフィックス</th>
					<th>意味</th>
					<th>運ぶもの</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>*.local.test</code></td>
					<td>この Mac</td>
					<td>なし、dev サーバーそのもの</td>
			</tr>
			<tr>
					<td><code>*.ssh.test</code></td>
					<td>サーバー</td>
					<td>autossh のフォワード</td>
			</tr>
			<tr>
					<td><code>*.cloud.test</code></td>
					<td>ホスティング先のアプリ</td>
					<td>ホスティング事業者自身のプロキシ</td>
			</tr>
	</tbody>
</table>
<p>つまり <code>https://devbox-ollama.ssh.test/api/tags</code> はワークステーション上のモデル一覧を返し、<code>https://api.local.test</code> は自分のマシン上で動いている、開発中のアプリの API です。</p>
<p><code>.ssh</code> や <code>.cloud</code> のような新しいトップレベル名ではなく <code>.test</code> のサブドメインにしていて、<code>.local</code> も使いません。RFC 6761 はまさにこの用途のために <code>.test</code> を予約していて、<code>.local</code> は mDNS のものです。<code>.local</code> を乗っ取ると Mac で Bonjour の検出が動かなくなります。dnsmasq はもともと <code>*.test</code> 全体をワイルドカードで受けているので、ラベルがひとつ増えても何の負担もありません。<code>127.0.0.1</code> を指す <code>/etc/resolver/test</code> ファイルがひとつあれば、その後 DNS の変更は一切不要です。</p>

<h3 class="relative group">存在しない名前で HTTPS
    <div id="存在しない名前で-https" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ad%98%e5%9c%a8%e3%81%97%e3%81%aa%e3%81%84%e5%90%8d%e5%89%8d%e3%81%a7-https" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>これらの名前はすべて、ブラウザが信頼する証明書付きの HTTPS で配信しています。私のラップトップの中にしか存在しないホスト名でそれは無理そうに聞こえますが、Caddy は自前の内部認証局でこれをやってのけます。サイトに <code>tls internal</code> と書くと、Caddy は Let&rsquo;s Encrypt に頼む代わりにローカルの CA からそのサイトの証明書を発行します。インストーラーが一度だけ実行する <code>caddy trust</code> が、その CA のルートを macOS のシステムキーチェーン(Chrome と Safari はここを読みます)と Firefox 独自のストアに入れます。それ以降は <code>https://anything.test</code> は証明書の警告なしに開き、素の <code>http://</code> はそちらへリダイレクトされ、マシンの外には何も出ていきません。</p>
<p>見た目だけの話ではありません。今の Web プラットフォームは、素の HTTP では動かないものがたくさんあります。secure cookie、service worker、クリップボード API、<code>SameSite=None</code> が絡むものすべて。Web ビルドを素の <code>http://localhost</code> でテストすると、本番の HTTPS ドメインで出るバグが隠れてしまいます。<code>https://app.local.test</code> でテストすれば、それが自分のラップトップの上で表に出てきます。</p>
<p>サイトブロックはこうです。ポート以外はどのサービスでも同じです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">devbox-ollama.ssh.test</span>, <span class="gh">devbox-ollama.test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="k">tls</span> <span class="no">internal</span>
</span></span><span class="line"><span class="cl">	<span class="k">import</span> cors
</span></span><span class="line"><span class="cl">	<span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">11435</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">		<span class="k">import</span> proxyhdr
</span></span><span class="line"><span class="cl">		<span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="k">keepalive</span> <span class="no">off</span>
</span></span><span class="line"><span class="cl">			<span class="k">dial_timeout</span> <span class="mi">5s</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>面倒な部分は二つのスニペットが受け持っています。<code>cors</code> は <code>*</code> を返す代わりに呼び出し元の <code>Origin</code> をそのまま返します。認証情報が絡むと <code>*</code> は許されないからです。そして <code>OPTIONS</code> のプリフライトには自分で 204 を返すので、上流が相手をする必要はありません。<code>proxyhdr</code> は <code>Host</code> ヘッダーを接続先のアドレスに書き換えます。ルーターの管理画面や Chrome のリモートデバッグ用エンドポイントのような気難しいバックエンドは <code>Host</code> を検証するので、書き換えなければ <code>devbox-ollama.ssh.test</code> を聞いたこともない名前として弾いてしまいます。Ollama 自身のオリジンチェックはどのポートでもループバックなら通すので、<code>Host</code> を <code>127.0.0.1:11435</code> に書き換えれば通ります。</p>
<p><code>keepalive off</code> は、私なら間違えていたはずの設定です。上流はどれも SSH のフォワードで、ネットワークが変わるたびに autossh が壊して作り直します。keep-alive がオンだと、Caddy はとっくに死んだトンネルへのプール済みコネクションを握り続け、リロードするまで訳の分からない 404 を返します。オフにすればリクエストごとに新しく接続し、ループバックならそのコストは測れないほど小さいです。</p>
<p>各サービスは昔のフラットな名前(<code>devbox-ollama.test</code>)でも応答するので、サフィックスの仕組みを入れる前のブックマークもそのまま使えます。</p>

<h3 class="relative group">メニューバー
    <div id="メニューバー" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%a1%e3%83%8b%e3%83%a5%e3%83%bc%e3%83%90%e3%83%bc" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>launchd が十数本のフォワードを開いたまま保っていると、私が繰り返し気にするのは「これ、生きてる?」ということでした。その答えは <code>launchctl list</code>、<code>lsof</code>、<code>curl</code> の三か所に散らばっていました。そこで小さなメニューバーのプラグインでひとつにまとめ、すべてのホスト名の横に色付きの丸を出すようにしました。</p>
<ul>
<li>🟢 トンネル経由でサービスがリクエストに応答した</li>
<li>🟡 トンネルは開いているが、その先のサービスが黙っている</li>
<li>🔴 ローカルのポートで何も待ち受けていない、つまりトンネル自体が落ちている</li>
</ul>
<p>役に立つのは黄色です。緑と赤だけのチェックでは「SSH のフォワードが死んだ」と「向こう側で Ollama が落ちた」を区別できず、この二つは直し方がまったく違います。プラグインはサービスの一覧を自分では持たず、ほかと同じプロキシと SSH の設定を読むので、そこにサービスを足せばメニューにも足されます。</p>
<p>最初の版は欲張りすぎでした。十秒ごとに更新し、サービスごとにいくつもプロセスを起こしていたので、一回の実行で七十個ほどになり、macOS はすぐにこのプラグインを「エネルギー消費の大きいアプリ」に並べました。実行ごとにスナップショットを一回だけ取り、更新を数分おきにしたところ、コア一つの 0.1% ほどに収まりました。バッテリーを食う状態表示では本末転倒なので、最初からコストを見積もっておく価値があります。</p>
<p>穴がひとつあって、それがいちばん肝心なところでした。devbox には公開された名前がありません。その SSH フォワードは <code>192.168.x</code> のアドレスに接続していたので、ラップトップがオフィスの LAN にいるあいだしか動きませんでした。オフィスを離れるとメニューバーは赤くなって赤いままで、ラップトップを楽にするためにワークステーションへ移したものすべてに、まさにその隣に座っていないときに限って届かなかったのです。</p>

<h2 class="relative group">正しい向きの二本のトンネル
    <div id="正しい向きの二本のトンネル" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%ad%a3%e3%81%97%e3%81%84%e5%90%91%e3%81%8d%e3%81%ae%e4%ba%8c%e6%9c%ac%e3%81%ae%e3%83%88%e3%83%b3%e3%83%8d%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>解決策は三つの部分からなり、最初の二つが Cloudflare Tunnel です。</p>
<p>Cloudflare Tunnel とは、<code>cloudflared</code> が Cloudflare のエッジへのアウトバウンド接続を保ち続けるものです。Cloudflare はその接続を通してリクエストを送り返してくるので、何重の NAT の内側にあるマシンでも、ルーターで何かを待ち受けさせることなく到達できるようになります。ホテルの wifi に移ったラップトップにもついていき、CGNAT も乗り越えます。</p>
<p>私は二本、マシンごとに一本ずつ動かしていて、その理由は一言書いておく価値があります。手っ取り早いのは、ラップトップに一本だけトンネルを置いて、オフィス LAN 上の devbox の SSH ポートを指す ingress ルールをひとつ足すことでした。これなら devbox には何もインストールしなくて済みます。ただしラップトップを経由することになり、オフィスから出ていくのはそのラップトップです。解決するために作ったはずのまさにその状況で壊れていたでしょう。devbox は動かないので、devbox が自分でエッジへの接続を持ちます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># config.yml on devbox (runs as a systemd service)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">auto       </span><span class="w"> </span><span class="c"># prefer QUIC, fall back to HTTP/2 over TCP 443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ha-connections</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">     </span><span class="c"># mains-powered, its job is to stay reachable</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">warp-routing</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">connectTimeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ssh-devbox.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">tcp://127.0.0.1:22</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p><code>ssh://</code> ではなく <code>tcp://</code> にするのは、間違えやすいところです。<code>ssh://</code> を選ぶと Cloudflare のブラウザ上で描画するターミナルになり、ストリームは Web ページ用に包まれます。そこに普通の <code>ssh</code> クライアントを通すと、バナーまでは届いて、鍵交換で死にます。<code>tcp://</code> は <code>cloudflared access ssh</code> が期待する素のバイトの土管で、おまけに <code>scp</code>、<code>rsync</code>、SSH 越しの git、ポートフォワードも通ります。</p>
<p>この最後の点が、SSH の層の穴を塞ぎました。SSH の設定は今、接続ごとに経路を選びます。</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-sshconfig" data-lang="sshconfig">Match originalhost devbox !exec &#34;nc -z -G 1 &lt;office-lan-ip&gt; 22 &gt;/dev/null 2&gt;&amp;1&#34;
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...</code></pre></div>
<p>オフィスの LAN にいれば確認が成功し、SSH は直接、1 ホップで接続します。それ以外の場所では確認が失敗し、SSH は Cloudflare を経由します。認証には Access のサービストークンを使うので、ブラウザでのログインが割り込むこともありません。<code>Match host</code> ではなく <code>Match originalhost</code> です。<code>host</code> は <code>HostName</code> が置き換えられた後にマッチするので、その時点で名前はもう IP になっていて、ブロックが発動しません。結果として、<code>devbox-ollama.ssh.test</code> はオフィスでも自宅でも電車の中でも動き、メニューバーの丸は緑のままです。</p>
<p>ラップトップのトンネルは逆向きです。Mac 上の dev サーバーに本物の公開 HTTPS ホスト名を与えます。開発中のアプリそれぞれについて、その API、WebSocket サーバー、Web ビルドです。どれも Cloudflare Access の背後にいます。これについては後で戻ってきます。ここが、この仕組みを無謀ではなく安全なものにしている部分だからです。</p>
<p>メニューバーも両方のトンネルを把握するようになりましたが、確認の問い合わせではそれができませんでした。インバウンドのトンネルには確認すべきローカルのポートがなく、二つのコネクタの片方はそもそもラップトップで動いていません。すぐ思いつくテスト、<code>ssh-devbox.example.com</code> に curl することは、コネクタがつながっていてもいなくても Cloudflare のエッジから応答が返るので、死んだトンネルでも緑と読んでしまいます。launchd もあてになりません。<code>cloudflared</code> はエッジへの接続をすべて失っていても「loaded, exit 0」のまま居座ります。そこでトンネルについては、それぞれに健全なコネクタがあるかを Cloudflare の API に尋ねます。<code>ssh</code> がたどり着くかどうかを決めるのはそれだからです。API に届かなければ、その部分は赤で描くのではなく丸ごと表示しません。メニューはひと目で読むものなので、そこでの誤報は一行欠けるよりも高くつきます。</p>

<h2 class="relative group">三つ目の部分: WARP でネットワークにする
    <div id="三つ目の部分-warp-でネットワークにする" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%89%e3%81%a4%e7%9b%ae%e3%81%ae%e9%83%a8%e5%88%86-warp-%e3%81%a7%e3%83%8d%e3%83%83%e3%83%88%e3%83%af%e3%83%bc%e3%82%af%e3%81%ab%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ホスト名付きのトンネルは、HTTPS を話すものと、<code>cloudflared</code> を動かせるマシンからの SSH には最高です。スマホにはそれができません。それにトンネルのホスト名は TCP のエンドポイントではありません。Cloudflare のエッジがそこで終端するのは HTTPS と WebSocket だけなので、<code>ssh.example.com</code> のポート 22 は閉じていて、Access をどういじっても開きません。</p>
<p>そこで WARP の出番です。Cloudflare One Agent(エンタープライズ向けの WARP クライアントです。組織に参加できないコンシューマー向けの「1.1.1.1」アプリではありません)を、ラップトップとスマホで私の Zero Trust 組織に登録しています。各トンネルがプライベートルートを広告し、WARP を動かしているデバイスはそのアドレスに素のソケットを開けば正しいマシンにたどり着けます。「このサービスに名前で届く」ではなく、「これらのマシンが私と同じネットワークにいる」になるわけです。</p>
<table>
	<thead>
			<tr>
					<th>アドレス</th>
					<th>マシン</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>198.18.42.1</code> / <code>fd12:3456:789a::1</code></td>
					<td>MacBook Air</td>
			</tr>
			<tr>
					<td><code>198.18.42.2</code> / <code>fd12:3456:789a::2</code></td>
					<td>devbox</td>
			</tr>
			<tr>
					<td><code>198.18.42.3</code> / <code>fd12:3456:789a::3</code></td>
					<td>もう一台の Mac</td>
			</tr>
	</tbody>
</table>
<p>これらのアドレスは各マシンのループバックのエイリアスで、起動時に作られます。なので DHCP にも、どのルーターの内側にいるかにも左右されません。<code>198.18.42.2</code> は、オフィスでも、自宅からでも、5G のスマホからでも devbox です。Mac の本当の LAN アドレスは移動するたびに変わりますが、こちらは決して変わりません。</p>
<p>スプリットトンネルは <strong>include モード</strong> にしていて、中身はこれらのルート(と、WARP 自身が必要とする Cloudflare の範囲)だけです。これがクライアントを常用できるものにしています。デフォルトは exclude モードで、WARP はプライベートな範囲のリスト以外のすべてを運ぶので、スマホが送るすべてのバイトが Cloudflare を回り道します。これにはバッテリーとレイテンシに実際のコストがあって、必要なときだけオンにするようになっていたはずで、それでは意味がありません。include モードなら、三台のマシン宛ての通信は WARP を通り、それ以外はいつもの経路で手つかずのまま出ていきます。だからクライアントはつけっぱなしです。ラップトップの <code>warp-cli status</code> は一日中 <code>Connected</code> と言っていて、私はそのことを考えもしません。</p>
<p>トランスポートは、UDP 2408 の WireGuard ではなく、UDP 443 上の HTTP/3 で HTTP/2 へのフォールバックを持つ MASQUE にしています。キャリアやゲストネットワークの中には 2408 を塞ぐところがありますが、443 を塞ぐところはまずありません。私の組織のポリシーでは MASQUE の耐量子鍵合意もオンになっています。探しに行ったわけではありませんが、断る理由もありません。</p>

<h2 class="relative group">なぜ 198.18 なのか、なぜ IPv6 アドレスがあるのか
    <div id="なぜ-19818-なのかなぜ-ipv6-アドレスがあるのか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%aa%e3%81%9c-19818-%e3%81%aa%e3%81%ae%e3%81%8b%e3%81%aa%e3%81%9c-ipv6-%e3%82%a2%e3%83%89%e3%83%ac%e3%82%b9%e3%81%8c%e3%81%82%e3%82%8b%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このアドレスは妙に見えますし、最初に選んだアドレスは、学ぶところのある形で間違っていました。</p>
<p>最初に試したのは <code>10.10.0.1</code> でした。誰でもまずそれに手を伸ばすからです。これはスプリットトンネルのどちらのモードでも失敗します。exclude モードでは、WARP のデフォルトのリストが RFC 1918 の範囲すべてと <code>100.64.0.0/10</code> を除外するので、クライアントはその宛先への通信をデバイスから出る前に捨ててしまいます。include モードでは、たまたま参加した本物の <code>10.x</code> ネットワークとぶつかります。カフェもコワーキングスペースも、多くのオフィスも、まさにその範囲を使っています。<code>198.18.0.0/15</code> は RFC 2544 のベンチマーク用の範囲です。予約済みで、公開インターネットでルーティングされることはなく、WARP の除外リストにも入っておらず、普通のネットワークでは使われていません。IPv6 のアドレスが ULA(<code>fd00::/8</code>)なのも同じ理由です。</p>
<p>そもそもなぜ IPv6 があるのか、のほうが面白い話です。v4 のアドレスが動くようになったあと、wifi につないだスマホから試して問題なかったので、完了にしました。5G では動きませんでした。Agent は connected と言っていて、トンネルは健全で、Access も問題なし。wifi でのテストがそのすべてを証明していました。問題は、今のモバイルネットワークはほとんどが IPv6 オンリーだということでした。スマホにはネイティブの IPv4 がまったくなく、v4 のリテラルにはキャリアの 464XLAT トランスレーター経由で届いていました。include リストに v4 のプレフィックスしかないと、このトランスレーターはトンネルの <em>外側</em> にいます。通信は公開インターネットに出ていき、そこでは <code>198.18.0.0/15</code> はルーティングされないので、音もなく捨てられていました。スマホにはエラーもなく、コネクタのログにも何もありません。</p>
<p>解決策は、スマホが IPv6 でネイティブに届く二つ目のアドレスをすべてのマシンに持たせ、それをルートと include リストの両方に足すことです。今では、モバイルデータのときにスマホの SSH クライアントが実際に使っているのは <code>fd12:3456:789a::</code> のアドレスのほうです。</p>

<h2 class="relative group">オフロード: 端末としてのラップトップ
    <div id="オフロード-端末としてのラップトップ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%aa%e3%83%95%e3%83%ad%e3%83%bc%e3%83%89-%e7%ab%af%e6%9c%ab%e3%81%a8%e3%81%97%e3%81%a6%e3%81%ae%e3%83%a9%e3%83%83%e3%83%97%e3%83%88%e3%83%83%e3%83%97" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>この仕組み全体は、この部分のためにあります。</p>
<p><strong>モデルとファインチューニング。</strong> Ollama は devbox 上で動き、<code>127.0.0.1:11434</code> にバインドしています。オフィス LAN のほかのマシンからも見えませんし、インターネットからなど言うまでもありません。ラップトップ上では <code>https://devbox-ollama.ssh.test</code> として、あるいはポートそのものを欲しがるツール向けには素のポートとして現れます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">OLLAMA_HOST</span><span class="o">=</span>127.0.0.1:11435 ollama list</span></span></code></pre></div></div>
<p>11434 ではなく 11435 なのはわざとです。11434 は Ollama 自身のデフォルトで、もしラップトップでもローカルの Ollama を動かすことがあれば、二つがポートを奪い合い、どちらが先に起動したかによって「ローカル」のはずのものから devbox の答えが黙って返ってくることになります。</p>
<p>実際の効果として、AI 機能を作っているとき、呼び出すモデルはどこにいてもラップトップではなくワークステーションの GPU で動きます。評価の実行、大量のドキュメントの埋め込み、ファインチューニング。これらは devbox で動き、ラップトップのファンは回らないままです。エージェントを試作しているときは、ワークステーション上のローカルモデルを指して、API の請求を気にせずツール呼び出しを何度でも繰り返し、ループが固まってからホスト型のモデルに切り替えられます。</p>
<p><strong>長時間動くエージェント。</strong> 一時間かかるエージェントのセッションが、ラップトップの蓋が開いているかどうかに左右されるべきではありません。代わりに devbox で動かせば、ラップトップはそれを覗く窓にすぎません。蓋を閉じて、あとでどこからでも、あるいはスマホからでも <code>ssh devbox</code> すれば、まだそこにいます。トンネル以前はこれがオフィスでしか使えませんでした。まさに必要のない場所です。</p>
<p><strong>ビルドとクラスタ。</strong> devbox のクラスタは専用のレジストリを持っているので、イメージはラップトップからホテルの wifi でアップロードするのではなく、ワークステーション上で、それが動く場所のすぐ隣でビルドして push できます。クラスタのダッシュボード、ingress のダッシュボード、レジストリの UI は、どれもラップトップ上では <code>*.ssh.test</code> のホスト名です。</p>
<p><strong>モバイルの開発ループ。</strong> スマホはラップトップと同じプライベートネットワークにいて、ラップトップの dev サーバーには Access の背後の公開ホスト名もあります。なのでアプリのネイティブビルドを建物の外へ持ち出して、5G で、自分の机で動いている API と話させることができます。あるいは devbox 上のほうと話させることも、そちらに置いてきたのなら。実機で本物のモバイルネットワーク越しに回すフィードバックループは、以前は共有のステージングサーバーへのデプロイを意味していました。今はファイルを保存することを意味します。</p>
<p>ここにはひとつ、アプリのバグとしてデバッグし始める前に知っておく価値のある罠があります。Access は WebSocket のホスト名も守っていて、ネイティブの WebSocket クライアントはブラウザではありません。<code>CF_Authorization</code> の cookie を持っていないので、ネイティブビルドから WebSocket サーバーへの接続はエッジで 403 で拒否され、その 403 は自分のサーバーのエラーとは似ても似つきません。ブラウザのタブは、ログインしたときの cookie をすでに持っているので問題ありません。ブラウザ以外のもの(ネイティブアプリ、スクリプト、dev API を呼ぶエージェント)への答えは Access の <strong>サービストークン</strong> で、二つのヘッダーとして送ります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">CF-Access-Client-Id:     &lt;client_id&gt;
</span></span><span class="line"><span class="cl">CF-Access-Client-Secret: &lt;client_secret&gt;</span></span></code></pre></div></div>
<p>トークンはそれ自体では何の権限も与えません。Access がそれを受け入れるのは、ポリシーでそのトークンを指名しているアプリだけなので、届く範囲はちょうど dev ホスト名の一覧で、それより広くはありません。私はそのポリシーを <code>allow</code> ではなく <code>non_identity</code> の判定に分類しています。こうすると監査ログの中で、ログインした人ではなくベアラーシークレットとして見分けがつきます。</p>

<h2 class="relative group">インターネットが決して見ない統計
    <div id="インターネットが決して見ない統計" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%a4%e3%83%b3%e3%82%bf%e3%83%bc%e3%83%8d%e3%83%83%e3%83%88%e3%81%8c%e6%b1%ba%e3%81%97%e3%81%a6%e8%a6%8b%e3%81%aa%e3%81%84%e7%b5%b1%e8%a8%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>欲しかったもののもう半分は、可視性でした。システムの振る舞いを教えてくれるものの大半は、正しく、公開されていません。devbox では次のものです。</p>
<ul>
<li>ingress プロキシのダッシュボードと、そのトレースが入ったトレーシング UI</li>
<li>Kubernetes クラスタ用のダッシュボード</li>
<li>コンテナレジストリの UI(読み取り専用)</li>
<li>Ollama のモデル一覧と、今ロードされているもの</li>
<li><code>docker info</code> とコンテナの統計</li>
</ul>
<p>どれにも公開ホスト名はなく、これからも付けることはありません。ループバックかクラスタのサービス IP にバインドされていて、届くのは SSH のフォワード経由で、そのフォワード自体がトンネルに乗っています。スマホの監視アプリは、WARP をまたいだ SSH で <code>docker info --format '{{json .}}'</code> を読むので、コンテナが動いているか、何かがメモリを食い尽くしていないかを、どこからでも確認できます。</p>
<p>このアプリのおかげで、書いておく価値のある小さなバグを見つけました。Mac のトンネル用 sshd が、デバッグしていたときのまま <code>LogLevel DEBUG2</code> になっていました。<code>-e</code> を付けると sshd は stderr にログを出し、セッションにとってその stderr は SSH チャネルの stderr <em>そのもの</em> です。すべてのコマンドの出力に <code>debug2: do_setup_env: set TMPDIR</code> がくっついて返ってきて、二つのストリームをまとめるスマホのアプリは、正しい JSON の後ろにログの行が続いたものを受け取り、パースに失敗して、全体をエラーとして表示していました。<code>LogLevel INFO</code> で直りました。デバッグが終わったらログレベルは戻しましょう。</p>
<p>同じくらい意図的なのが、このネットワークに <em>載せない</em> ものです。昔の autossh トンネルは、私が面倒を見るのを手伝っている、よそのネットワークのルーターにもいくつか届きます。それらには公開ホスト名も WARP のルートも与えません。インターネット上のルーター管理画面は、ログインの背後にあってもリスクですし、私が広げてよいネットワークでもありません。</p>

<h2 class="relative group">Access は省略できない
    <div id="access-は省略できない" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#access-%e3%81%af%e7%9c%81%e7%95%a5%e3%81%a7%e3%81%8d%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Cloudflare Tunnel のホスト名は、DNS レコードができた瞬間に公開インターネット上に存在します。その前にファイアウォールはありません。それこそがポートを開けないことの要点です。守りは Cloudflare のエッジから来なければならず、それをするのが Access です。訪問者が誰であるかを証明するまで、リクエストを転送すること自体を拒否するので、認証されていないスキャナーがラップトップに届くことはありません。これは重要です。相手は dev サーバーだからです。設定が載ったデバッグモードのトレースバック、開きっぱなしの JavaScript バンドラー。これらのホスト名の背後にあるものは、どれもインターネットに面するように作られていません。</p>
<p>ポリシーは私自身のメールアドレスだけを許可し、ほかは何も許可しません。証明の手段は、メールでのワンタイム PIN とシングルサインオンの二つです。セッションは八時間です。現実的な脅威は、誰かが PIN を総当たりすることではなく、一日有効な cookie を持ったままカフェで開きっぱなしにしたラップトップだからです。</p>
<p>SSH は、お互いを知らない二つの仕組みによって二重に認証されます。Access は TCP のストリームが sshd に届いてよいかどうかを決め、sshd はそれでも私の鍵を要求します。どちらかを失っても、マシンは開きません。Mac のトンネル用 sshd は Cloudflare の SSH CA も信頼しているので、Access は SSO のセッションから発行した短命の証明書で私をログインさせられます。一方で <code>authorized_keys</code> もそれと並んで使えるままなので、Access の設定が壊れても自分のラップトップから締め出されることはありません。</p>
<p>いちばん気に入っているのは小さな部品です。公開は三つのことがそろったときに起きます。DNS レコード、動いているコネクタ、アクティブなゾーン。この三つは別々のタイミングで、私が制御できない順番で成り立ちます。ゾーンはネームサーバーを変えてからしばらくして勝手にアクティブになります。Mac がスリープしているあいだかもしれません。そこで launch agent は <code>cloudflared</code> を直接動かしません。代わりにプリフライトを動かし、トンネル自身の設定からホスト名を読み取り、Access の API にどれが保護されているかを尋ね、保護されていないものがひとつでもあれば起動を拒否します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">HOSTNAMES</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^\s*-\s*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*hostname:\s*//&#39;</span> <span class="p">|</span> sort -u<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROTECTED</span><span class="o">=</span><span class="k">$(</span>curl -fsS -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CLOUDFLARE_API_TOKEN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;https://api.cloudflare.com/client/v4/accounts/</span><span class="nv">$CLOUDFLARE_ACCOUNT_ID</span><span class="s2">/access/apps?per_page=100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> python3 -c <span class="s1">&#39;import sys,json; print(&#34;\n&#34;.join(a.get(&#34;domain&#34;,&#34;&#34;) for a in json.load(sys.stdin)[&#34;result&#34;]))&#39;</span><span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;could not reach the Access API. refusing (fail closed).&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="nv">$HOSTNAMES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    grep -qxF <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$PROTECTED</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;NOT starting: </span><span class="nv">$h</span><span class="s2"> has no Access app&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> cloudflared tunnel --config <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> run</span></span></code></pre></div></div>
<p>launch agent には <code>KeepAlive</code> が付いているので、拒否されると 30 秒ごとに自分で再試行し、Access のアプリができればトンネルは勝手に上がります。フェイルクローズの代償は停止です。フェイルオープンの代償は露出です。どちらを説明するほうがましか、私は分かっています。</p>

<h2 class="relative group">つまずいた四つのこと
    <div id="つまずいた四つのこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%a4%e3%81%be%e3%81%9a%e3%81%84%e3%81%9f%e5%9b%9b%e3%81%a4%e3%81%ae%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここまでに書いたことは、今はすべて動いています。そこにたどり着くまでに、四つのうち三つは同じパターンでした。壊れているものはどこにもエラーを出さず、自分に見える部分はすべて健全だと報告していました。</p>
<p><strong>ルートと include リストは別々のリスト。</strong> トンネルのルートは、Cloudflare があるアドレスを正しいコネクタまで運ぶ <em>気になる</em> ようにするものです。WARP の include リストは、そもそもクライアントがそれを <em>送る</em> ようにするものです。devbox にスマホから届かなかったのは、ルートは存在していたのに、include リストにはまだ Mac のアドレスしかなかったからです。スマホはパケットをローカルで捨て、コネクタは通信をひとつも見ず、ダッシュボードはすべて緑でした。新しいマシンを足すたびに、そのアドレスを両方に足す必要があります。</p>
<p><strong>IPv6 オンリーのモバイルネットワークでの IPv4 リテラル。</strong> 上で書いたとおりです。wifi では動き、5G では失敗し、エラーはなし。WARP のルートが wifi では動いてモバイルデータでは動かず、Agent が connected と表示しているなら、原因はトンネルではなくルーティングです。</p>
<p><strong>cloudflared の背後の macOS の sshd。</strong> macOS は sshd を launchd のソケットアクティベーションで起動し、接続ごとに短命の <code>sshd -i</code> をひとつ立てます。cloudflared の背後だと、バナーはクライアントに届くのに、クライアントの識別文字列が戻る前にストリームが壊され、すべてのログインが <code>kex_exchange_identification</code> で死にます。両端から確認し、素の TCP リレーでも再現したので、Cloudflare は原因から外れました。解決策は、二つ目の独立した <code>sshd -D</code> を <code>127.0.0.1:2222</code> と WARP のループバックアドレスで、自分のユーザーとして動かすことです。トンネルのオリジンになるためだけに存在する sshd です。システム設定のリモートログインには手を付けていません。devbox は Linux で、普通の長生きする sshd が動いているので、この問題は一度も起きませんでした。</p>
<p><strong>存在しなくなる設定フィールド。</strong> 古い例には、トンネルの設定に <code>warp-routing: enabled: true</code> と書いてあります。cloudflared 2026.8 はこのフィールドをはっきり拒否します。WARP ルーティングは、ブロックが存在し、ルートがトンネルを指していれば常にオンです。少なくともこれはエラーを出してくれたので、ほかの三つのあとでは、ほとんど親切に感じました。</p>

<h2 class="relative group">何が変わったか
    <div id="何が変わったか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bd%95%e3%81%8c%e5%a4%89%e3%82%8f%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>正直にまとめると、ハードウェアがどこにあるかで作業場所を選ぶのをやめました。ラップトップは良いキーボードと良い画面と長いバッテリーで、それ以上である必要はありません。ワークステーションは、私がどこにいても力仕事を引き受けます。スマホは、オフィスの wifi につながれたものではなく、本物のネットワーク上の本物のテスト端末です。そして、システムが何をしているかを見るための部分は、私にとってはクリックひとつの距離にあり、ほかの誰にとっても存在しません。</p>
<p>この規模なら Cloudflare の無料の Zero Trust プランに収まります。かかったのは時間と、書いておいてよかったと思っている README です。上に書いた失敗はどれも、二回目にはまた新鮮な謎になっていたはずだからです。</p>
<p>似たようなことを考えているなら、自分の環境にこう問いかけてみてください。</p>
<ol>
<li>ラップトップで動いているもののうち、ラップトップがいつでも届く唯一のマシンだという理由だけでそこにあるものは何か?</li>
<li>スマホから dev サーバーに、オフィスの wifi だけでなくモバイルデータでも届くか? wifi ではなく 5G で試してください。</li>
<li>ダッシュボードのうち、外から見るのにそれが楽だったという理由で公開しているものはどれか? どれもプライベートルートに置き換える候補です。</li>
<li>明日、あるトンネルのホスト名が Access のアプリなしで上がってきたら、何がそれを止めるか? 答えが「自分が覚えているはず」なら、プリフライトを書いてください。</li>
<li>プライベートアドレスは、訪れる先のネットワークが決して使わない範囲にあるか?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
