<?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>Cloudflare · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/cloudflare/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/cloudflare/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>ノート PC から無料で公開: Cloudflare Tunnel の設定方法と安全性</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>自宅のマシンをポートを開けずにインターネットに公開。Cloudflare Tunnel とは何か、Docker Compose での設定方法、ingress の許可リストによる安全性、そして本当に無料なのかを、Mac から OTA 更新を配信した実例で解説します。</description>
      <content:encoded><![CDATA[<p>私のアイデアはいつも同じように始まります。ノート PC で動いて、ちゃんと動いて、誰かに見せたくなる。しばらくの間、その次の一歩は決まってクラウドの請求書でした。マシン、データベース、バケット、それを指すドメイン、そして三人しか見ないものに毎月かかる料金。サイドプロジェクトや、本気のプロジェクトでも最初の一か月なら、これは順番が逆です。本番があるかどうかもわからないうちに、本番の代金を払っているのですから。</p>
<p>この記事はその逆の順番の話です。サービスは作ったマシンの上で動き続け、Cloudflare Tunnel がそこに公開の HTTPS ホスト名を与えます。ノート PC の上でインターネットに向けて待ち受けているものは何もありません。ポートフォワーディングも、ルーターの設定も、むき出しの IP アドレスもなし。マシンのほうから Cloudflare に接続し、Cloudflare はその接続を通してリクエストを送り返してきます。トンネルは無料で、TLS 証明書、DDoS 対策、キャッシュは、おそらく既に持っている Cloudflare のゾーンに付いてきます。</p>
<p>例は実際のものです。私の<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/" >プロダクトのひとつ</a> は Fly.io で動いていて、そのモバイルアプリはバックエンドから JavaScript の OTA(無線)更新を受け取ります(セルフホストの Expo Updates)。本番はそのままにして、開発ビルド用にその更新サーバーの二つ目のコピーを自分の Mac で動かしたかったのです。以下の出力は、うまくいかなかった部分も含めて、その日の午後のものです。</p>

<h2 class="relative group">Cloudflare Tunnel とは実際のところ何なのか
    <div id="cloudflare-tunnel-とは実際のところ何なのか" 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="#cloudflare-tunnel-%e3%81%a8%e3%81%af%e5%ae%9f%e9%9a%9b%e3%81%ae%e3%81%a8%e3%81%93%e3%82%8d%e4%bd%95%e3%81%aa%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>普通のサーバーは接続を待ちます。インターネットからノート PC に届くようにするには、ルーターでポートを開け、それをノート PC に転送し、ISP がパブリックな IPv4 アドレスをくれることを祈るしかありません(多くの地域では、私のところも含めて、くれません)。そうなると、そのポートで待ち受けているものはすべて、インターネット中のスキャナーから一日中届くことになります。</p>
<p>トンネルはこれを逆にします。<code>cloudflared</code> はアプリの隣で動き、Cloudflare のエッジへ外向きの接続を開きます。近くの二つのデータセンターへ四本です。ゾーンの DNS レコードがホスト名をトンネルに向けます。そのホスト名へのリクエストが届くと、Cloudflare が TLS を終端し、リクエストをその接続のどれか一本に流します。あとは <code>cloudflared</code> が、設定で指定したローカルのサービスに渡します。</p>
<figure class="diagram">
  <div role="img" aria-label="リクエストは HTTPS で Cloudflare に届き、そのルールを通ってトンネルの終端へ、そして cloudflared が自分のマシンから開いた外向きの接続を下っていく。cloudflared はホスト名とパスに応じて API、エッジ、または 404 に振り分ける"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">インターネット</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">自分のマシン</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">スマホ / ブラウザ</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="19">TLS・リダイレクトルール・WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">トンネルの終端</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">外向きの QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">（開いたポートなし）</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress ルール: ホスト名 + パスの許可リスト</text>
</svg>
</div><figcaption>マシンは外に向かって接続するだけ。Cloudflare のゾーンのルールはトンネルより先に動き、何に届くかは ingress ルールが決める。</figcaption>
</figure>

<p>セキュリティの面で大事なのは、ルーターに受信ルールがひとつもなく、IP アドレスが DNS に一度も出てこないことです。自宅の回線をスキャンしても何も見つかりません。そこで待ち受けているものが何もないからです。</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="#%e8%a8%ad%e5%ae%9a%e6%96%b9%e6%b3%95%e6%9c%80%e5%88%9d%e3%81%8b%e3%82%89%e6%9c%80%e5%be%8c%e3%81%be%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>必要なのは Cloudflare に載せたドメイン(無料プランで十分)と、インストール済みの <code>cloudflared</code>(Mac なら <code>brew install cloudflared</code>)です。</p>
<p><strong>1. 一度だけログインする。</strong> ブラウザが開くのでゾーンを選ぶと、このマシンがそのゾーンにトンネルと DNS レコードを作れるようにする証明書が保存されます。</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">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. トンネルを作る。</strong> このプロジェクト用のものはすべて専用のフォルダに置いています。認証情報ファイルはトンネルの身元そのもので、それを持っている人なら誰でもあなたのホスト名のトラフィックを配信できてしまうからです。</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">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<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">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. ホスト名をトンネルに向ける。</strong> ホスト名ごとにコマンドを一回実行すると、<code>&lt;tunnel-id&gt;.cfargotunnel.com</code> へのプロキシ済み CNAME が作られます。</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">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. ingress ルールを書く。</strong> セキュリティにとって肝心な部分なので、下で節をひとつ設けます。</p>
<p><strong>5. 動かす。</strong> 私のアプリは既に Docker Compose で動いているので、<code>cloudflared</code> は同じネットワーク上のコンテナがひとつ増えるだけです。アプリにはサービス名で届くので、アプリ側はホストにポートを公開する必要すらありません。</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"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><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">cloudflared</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">cloudflare/cloudflared:2026.10.7</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="l">tunnel --config /etc/cloudflared/config.yml run</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}/.cloudflared/myapp:/etc/cloudflared:ro</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">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<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">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<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">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>ソウルへの接続が四本。これでノート PC は、外から見てインターネット上にいます。<code>restart: unless-stopped</code> のおかげで、再起動しても Docker が立ち上がればすぐに戻ってきます。</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%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%81%a7%e3%81%af%e3%81%aa%e3%81%8f%e3%83%91%e3%82%b9%e3%82%92%e5%85%ac%e9%96%8b%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここは私が危うくやりかけた間違いで、おそらく一番よくある間違いです。手軽な設定は一行で済みます。<code>hostname → http://localhost:8000</code>。これで公開されるのは<strong>開発サーバー丸ごと</strong>です。</p>
<p>開発サーバーはそんな使い方を想定して作られていません。私のものは <code>DEBUG=True</code> で動いていて、エラーが起きれば完全なトレースバックと設定が表示されます。認証も切ってあり(<code>AUTH_MODE=disabled</code>)、どんな Bearer トークンでもテストユーザーとしてログインできます。<code>localhost</code> ならまさにそれで正しく、公開ホスト名の上では大惨事です。あなたのところにも似たようなものがあるはずです。デフォルトパスワードのままの管理画面、データ投入用のエンドポイント、デバッグツールバー、あるいは呼ぶのが自分だけだからと呼び出し元を信用している API。</p>
<p>だからトンネルの設定は許可リストにします。<code>cloudflared</code> は各リクエストを ingress ルールと上から順に、ホスト名とパスの正規表現で照合します。最初に一致したものが勝ち、最後のルールが残りすべてを受け止めます。</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">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</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="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</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">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</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://api:8000</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">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</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://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</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">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</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://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</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>/api/expo-updates/import-update/</code>)にありますが、リストには入っていません。管理画面も、API の残りの部分も同じです。そうしたリクエストは Cloudflare から先へは出ていきません。</p>
<p>自分の正規表現を信じる必要はありません。URL がどのルールに一致するかは <code>cloudflared</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">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<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">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>このチェックはスクリプトにして、設定を変えるたびに走らせるのがおすすめです。数秒で終わりますし、意図より広く一致する正規表現は、他人に指摘されて初めて気づく類の間違いです。</p>
<p>経験則をあと二つ。</p>
<ul>
<li><strong>公開するものは自分で認証すること。</strong> OTA のエンドポイントが配るのは公開の読み取り専用データです。デバイスのチェックインはデバイスごとの鍵で署名されています。どちらも安全をネットワークに頼っていません。ログインのないページを誰かに見せたいなら、ログインを作るのではなく、そのホスト名の前に <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a> を置きましょう(50 ユーザーまで無料)。</li>
<li><strong>トンネルを開発ワークフローの裏で常時動かさないこと。</strong> 意図したホスト名のために意図して動かし、終わったら止めます。<code>docker compose stop cloudflared</code> のコマンド一発で、すべてがインターネットから消えます。</li>
</ul>

<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%83%9b%e3%82%b9%e3%83%88%e5%90%8d%e3%82%92%e9%a3%b2%e3%81%bf%e8%be%bc%e3%82%93%e3%81%a0%e3%83%aa%e3%83%80%e3%82%a4%e3%83%ac%e3%82%af%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>トンネルがつながり、ルールも確かめたところで、外から試してみました。</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">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>どのパスも同じ 301 を返しました。トンネルなら 404 を返すはずのものまでです。つまりリクエストは <code>cloudflared</code> に届いていません。<code>Location</code> ヘッダーは <code>https://www.example.com/</code> で、<code>server: cloudflare</code> ヘッダーで決まりでした。Cloudflare が自分で応答していたのです。</p>
<p>原因は自分でした。何か月も前に、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >でっち上げのサブドメインを歩き回るスキャナー向けのシンクホール</a>として、ゾーンレベルのリダイレクトルール「その他のサブドメイン → www」を追加していました。リダイレクトルールは Cloudflare のリクエスト処理の中で、リクエストがオリジンに送られる前に動きます。そしてトンネルはただのオリジンです。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >同じルールが私のプレビュー環境も捕まえて</a>、Workers に届く前に止めていました。対処も前回と同じで、ルールの式に例外をもうひとつ足すだけです。</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">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>トンネルのホスト名が、アプリではなく Cloudflare のリダイレクトやエラーページを返してくるなら、トンネルをデバッグする前に、そのゾーンの <strong>Rules → Redirect Rules</strong>、Page Rules、WAF のカスタムルールを確認してください。<code>cloudflared</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%81%a7%e3%81%8d%e3%81%aa%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li><strong>公開インターネットからの UDP。</strong> トンネルの公開ホスト名が運ぶのは HTTP(S)(と WebSocket、gRPC)です。私のプロダクトのデバイスは普段 UDP 上の CoAP でチェックインしますが、それはトンネルを通れません。ローカルのコピーにつながるデバイスは HTTPS のチェックインにフォールバックし、そのたびに数秒余計にかかります。アイデアがゲームサーバーや VoIP なら、トンネルは向いていません。</li>
<li><strong>稼働率。</strong> ノート PC です。蓋を閉じればサイトは落ち、Cloudflare が 530 のエラーページを出します。デモや十人規模のベータ、ステージング用のコピーならそれで構いません。誰かが頼りにしているものには向きません。</li>
<li><strong>ビルドに焼き込まれるもの。</strong> モバイルアプリの更新 URL はネイティブの設定なので、トンネルの URL で作ったビルドだけがそれを使います。ストア版のビルドは本番と話し続けるので、だからこそこの構成は安心して試せました。</li>
</ul>

<h2 class="relative group">コスト: Cloudflare Tunnel は無料?
    <div id="コスト-cloudflare-tunnel-は無料" 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%b3%e3%82%b9%e3%83%88-cloudflare-tunnel-%e3%81%af%e7%84%a1%e6%96%99" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>無料プランの Cloudflare ゾーン、トンネル、その証明書、DNS レコード、合わせて 0 ドル。マシンはもともと動いていました。本当のコストは、今やあなたが稼働率の担当チームになる、ということだけです。</p>
<p>その代わりに省けるのは、「自分のマシンでは動く」と「お金を払ってホストする価値がある」の間の段階です。テスターやデザイナー、最初のお客さんに、その日の午後のうちにリンクを送れます。誰も興味を示さなければ、失ったものは何もありません。興味を示してくれたら、本物のホストへの移行は変更ひとつで済みます。CNAME を別の場所に向けるだけです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Cloudflareの504エラーは障害ではなかった：Early Hints（HTTP 103）とは</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>Webパフォーマンス</category>
      <category>Preload</category>
      <category>オブザーバビリティ</category>
      <category>デバッグ</category>
      <description>Cloudflareに504エラーが何千件も記録されたのに、実際のユーザーに届いたものは1件もありませんでした。正体はEarly Hintsのキャッシュミスです。HTTP 103の仕組み、偽の504と本物の504の見分け方、そして問題を解決したヘッダーを解説します。</description>
      <content:encoded><![CDATA[<p>Cloudflareのダッシュボードは、私のリクエストの4分の1が504だと言っていました。そのうちユーザーに届いたものは1件もありません。正体はCloudflareのEarly Hints機能のキャッシュミスで、<code>ClientRequestSource = earlyHintsCache</code>、<code>OriginResponseStatus = 0</code>の504として記録されていたのです。この記事では、これを本物の504と見分ける方法と、ミスをヒットに変えるたったひとつのヘッダーを説明します。</p>
<p>最初に出会ったのは9月でした。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ヘルスチェックはすべて正常なのにCloudflareのダッシュボードではリクエストの22%が失敗していた</a>ときで、私はEarly Hintsをオフにして次へ進みました。11月に同じゾーンを見ると、Early Hintsがまたオンになっていて、1日で504が2,600件たまっていました。今回は、その正体を正確に知りたかったし、もう一度オフにするのが正解なのか、きちんと働かせるのが正解なのかも知りたかったのです。このゾーンはFly.ioのオリジン1台の前段にあり、そのオリジンがDjango API、Expo製のWebアプリ、Hugoで作ったマーケティングサイトを配信しています。</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="#%e7%9f%ad%e3%81%84%e7%ad%94%e3%81%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li>この504を書いているのは訪問者ではなく、CloudflareのEarly Hintsキャッシュです。Cloudflareのログでは<code>ClientRequestSource = earlyHintsCache</code>になっています。</li>
<li>ここでの504はキャッシュミスを意味します。そのURLに保存されたヒントがなかったということです。ヒットは2xxとして記録されます。</li>
<li>これらはオリジンに一切届きません。<code>OriginResponseStatus</code>は<code>0</code>です。</li>
<li>Early Hintsは<code>Link</code> HTTPレスポンスヘッダーで動きます。HTML内の<code>&lt;link rel=&quot;preload&quot;&gt;</code>タグでは動きません。</li>
<li>ヘッダーを送れば、プリロードするものがあるページでは、このミスがヒットに変わり、本物の<code>103 Early Hints</code>レスポンスが返るようになります。</li>
</ul>
<p>先に知っておくと役立つことが、あと3つあります。ヒントにするのはレンダリングに必須のCSS、スクリプト、フォントだけにして、フォントには<code>crossorigin</code>を残してください。確認は<code>curl -sv --http2</code>で、<code>200</code>の前に<code>103</code>が見えるはずです。そして、ほかに何を決めるにせよ<code>earlyHintsCache</code>はアラートから外しておき、今後もヘッダーを送らないなら、ログのノイズで代償を払うよりEarly Hintsをオフにしてください。</p>

<h2 class="relative group">偽のCloudflare 504と本物の504の見分け方
    <div id="偽のcloudflare-504と本物の504の見分け方" 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%81%bd%e3%81%aecloudflare-504%e3%81%a8%e6%9c%ac%e7%89%a9%e3%81%ae504%e3%81%ae%e8%a6%8b%e5%88%86%e3%81%91%e6%96%b9" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>LogpushやInstant Logsを使っているなら、まず2つのフィールドを見てください。<code>ClientRequestSource</code>は、実際の訪問者のリクエストなら<code>eyeball</code>、Early Hintsの参照なら<code>earlyHintsCache</code>です。<code>OriginResponseStatus</code>が<code>0</code>なら、オリジンには接続すらしていません。</p>
<p>GraphQL Analytics APIでは、同じ区別が<code>requestSource</code>ディメンションとして取れます。5xxの急増を信じる前に、私は今このクエリを実行するようにしています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>User-Agentでまとめても同じ話になります。9月、Early Hintsの参照は<code>bastion early hints</code>と<code>nginx-ssl early hints</code>として記録されていました。11月は<code>fl2 early hints</code>と<code>nginx-ssl early hints</code>でした（FL2はCloudflareのプロキシの新しい世代です）。24時間で、このゾーンの504の2,631件すべてがこれらのUser-Agentから来ていました。ブラウザ、アプリ、curl、検索クローラーからのものは1件もありませんでした。</p>
<p>訪問者が実際に見た本物の504は、ほぼ必ずCloudflareの外側、つまりあなたの側から来ています。アプリケーションの前段にあるプロキシやロードバランサーが待ちきれずに諦めたのです。nginxかもしれないし、クラウドのロードバランサーかもしれないし、スリープ中のマシンが起きるのを待つプラットフォームのプロキシかもしれません。Cloudflare自身のタイムアウトは別のコードを使います。<strong>522</strong>はオリジンに接続できなかったこと、<strong>524</strong>は接続はできたが125秒以内（Enterprise未満のプランのデフォルト）に応答がなかったことを意味します。</p>
<p>504のせいでCloudflareのタイムアウトを延ばす方法を探してこの記事に来たのなら：504の背後にあるタイムアウトは、ほぼ確実にCloudflareのものではありません。nginxの<code>proxy_read_timeout</code>、ロードバランサーのアイドルタイムアウト、あるいはプラットフォームがマシンを起こすのにかかる時間を確認してください。役に立つ手がかりがひとつあります。125秒よりかなり短い、30秒や60秒といったきりのいい数字でリクエストが失敗するなら、それはCloudflareではなく、あなたのプロキシの上限です。（Cloudflareの125秒の上限を最大6,000秒まで延ばせるのはEnterpriseプランだけで、Cloudflare自身もそれを応急処置と位置づけています。）</p>

<h2 class="relative group">この504の正体
    <div id="この504の正体" 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%93%e3%81%ae504%e3%81%ae%e6%ad%a3%e4%bd%93" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Early Hintsが有効なゾーンでは、HTMLページか拡張子のないURLへのリクエストごとに(APIの多いゾーンでは、API呼び出しのほとんどがこれに当たります)、内部の参照がひとつ追加されます。Cloudflareが、送るべきヒントがあるか自分のキャッシュを確認しているのです。この参照は<code>ClientRequestSource = earlyHintsCache</code>として独立した行に記録されます。エンドユーザーのリクエストではなく、オリジンに送られることもありません。</p>
<p>その行のステータスは、エラーではなくキャッシュ参照の結果です。</p>
<ul>
<li>2xxはヒットです。CloudflareにそのURL用の<code>Link</code>ヘッダーが保存されていて、それを<code>103</code>としてブラウザに送ったということです。私のゾーンでは、ヒットは<code>200</code>と<code>204</code>の両方で現れます。</li>
<li>504はミスです。何も保存されていなかったので、先に送るものがなかったということです。</li>
</ul>
<p>オリジンがEarly Hintsの使うヘッダーを一度も送らなければ、すべての参照がミスになり、すべてのリクエストが504の行を1行ずつ残します。私が何も変える前の3時間で、このゾーンは173件のEarly Hints参照を記録し、173件すべてがミスでした。機能がオンになってから、一度もヒントを送っていなかったのです。</p>
<p>9月の記事についての訂正もひとつ。私はこれを「Flyのオリジンから応答を得られない」プローブだと説明していました。それは正確ではありません。この参照はそもそもオリジンに行きません。504はタイムアウトではなく、キャッシュミスを記録したものです。</p>
<p>Cloudflareはこのことを<a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">Early Hintsのドキュメント</a>で簡潔に説明しています。<code>ClientRequestSource: earlyHintsCache</code> として記録されたEarly Hintsの参照は「expected and benign」(想定内で無害)であり、200はキャッシュヒット、504はミスです。Log Explorerでは <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code> で除外できます。Cache Analyticsはもともとこれらを除外していますが、私を怖がらせたHTTPトラフィックのグラフは除外していません。</p>

<h2 class="relative group">偽の504のせいでサーバーを増強してしまった
    <div id="偽の504のせいでサーバーを増強してしまった" 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%81%bd%e3%81%ae504%e3%81%ae%e3%81%9b%e3%81%84%e3%81%a7%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%82%92%e5%a2%97%e5%bc%b7%e3%81%97%e3%81%a6%e3%81%97%e3%81%be%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>この区別が大事な理由がここにあります。この記事に取りかかる前日の11月3日、Cloudflareには約13,300件の訪問者リクエストに対して3,292件の504が記録されていました。見かけ上のエラー率は25%です。APIはFly.ioの<code>shared-cpu-1x</code>マシンで動いていて、これはバーストクレジット方式です。保証されるCPUの取り分は小さく、速く動くにはクレジットを消費します。8月の負荷テストでは、持続的な負荷がかかると毎秒およそ20〜30リクエストまでスロットリングされることが分かっていました。「ピーク時にリクエストの4分の1がタイムアウトしている」のは、まさにそのスロットリングに見えました。そこでAPIを<code>shared-cpu-2x</code>に移しました。</p>
<p>ところが、その504はすべてEarly Hintsのキャッシュミスでした。<code>requestSource</code>で分け、オリジン自身のログも見直すと、こうなっていました。</p>
<ul>
<li>10月28日から11月4日までのCloudflareの5xxは、1日1件ほどの525を除いてすべて<code>earlyHintsCache</code>のルックアップでした（調べた525はセキュリティスキャナーからのものでした）。</li>
<li>10月24日から11月4日までのオリジンのアクセスログには5xxが1件もなく、10秒を超えたリクエストは1件だけでした。</li>
<li>Flyのメトリクスでは、最も多いときでも毎秒7リクエスト程度で、マシンが使ったCPUは1コアの5分の1以下でした。スロットリングの上限には一度も近づいていません。</li>
</ul>
<p>大きいマシンは何も直していませんでした。何も壊れていなかったからです。APIは<code>shared-cpu-1x</code>に戻しました。</p>
<p>request sourceで分けていない5xxチャートをもとに、キャパシティを判断しないでください。オリジン自身のログとも突き合わせてください。Cloudflareはリクエストの4分の1が失敗したと言っているのにサーバーのログにエラーがないなら、サーバーに手を入れる前に、Cloudflareが何を数えているのかを確かめましょう。</p>
<p><em>2026年11月5日更新：このセクションの最初の版では、その週にAPIが<code>shared-cpu-1x</code>で本当にダウンしたと書いていました。実際にはダウンしていませんでした。上の数字は、確認したときに分かったことです。</em></p>

<h2 class="relative group">HTTP 103はエラーなのか？
    <div id="http-103はエラーなのか" 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="#http-103%e3%81%af%e3%82%a8%e3%83%a9%e3%83%bc%e3%81%aa%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>違います。1xx台のステータスコードは情報レスポンス、つまりサーバーが本当のレスポンスの前に送れる中間レスポンスです。RFC 8297で定義された<code>103 Early Hints</code>は<code>Link</code>ヘッダーを運び、サーバーがまだページを組み立てている間に、ブラウザが重要なCSS、JavaScript、フォントの取得を始めたり、別のオリジンへの接続を開いたりできるようにします。最終的な<code>200</code>は同じリクエストの中で後から届きます。</p>
<p>対応状況はまちまちですが、安全です。Chromium系ブラウザ（Chrome 103以降、Edge）と最新のFirefoxは、<code>preload</code>と<code>preconnect</code>の両方のヒントを処理します。Safari(17以降)は<code>preconnect</code>だけを使います。ブラウザが<code>103</code>を処理するのはHTTP/2とHTTP/3の場合だけです。理解しないブラウザはいつも通り<code>200</code>を待つだけなので、<code>103</code>はほとんどの訪問者にとっては先回りになり、それ以外の訪問者には何の影響もありません。</p>

<h2 class="relative group">私の設定が空だった理由：Linkヘッダーと<code>&lt;link rel=preload&gt;</code>の違い
    <div id="私の設定が空だった理由linkヘッダーとlink-relpreloadの違い" 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="#%e7%a7%81%e3%81%ae%e8%a8%ad%e5%ae%9a%e3%81%8c%e7%a9%ba%e3%81%a0%e3%81%a3%e3%81%9f%e7%90%86%e7%94%b1link%e3%83%98%e3%83%83%e3%83%80%e3%83%bc%e3%81%a8link-relpreload%e3%81%ae%e9%81%95%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここに一番時間を使いました。すでに動いていて当然に見えたからです。</p>
<p>Early Hintsがキャッシュするのはただひとつ、<code>rel=preload</code>か<code>rel=preconnect</code>の関係を持つ**<code>Link</code> HTTPレスポンスヘッダー**です。Cloudflareは通常のレスポンス（HTMLまたは拡張子なしURLの<code>200</code>、<code>301</code>、<code>302</code>）からこのヘッダーを読み取って保存し、次の訪問者には完全なレスポンスの準備ができる前に<code>103</code>として再送します。</p>
<p>私のページにはすでにプリロードがありました。ただし、HTMLの<code>&lt;head&gt;</code>内のタグとしてです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>これらのタグにも意味はあります。ブラウザはこれを見て動きます。ただし、ブラウザがこのタグを目にするのはHTMLを受け取ってパースを始めた後で、それこそEarly Hintsが先回りしようとしている瞬間です。Expoのエクスポートも、私が使っているHugoのテーマも、このタグは自動で書いてくれますが、ヘッダーはどちらも設定しません。機能はオンで、プリロードも宣言されていたのに、両者は一度も出会っていなかったのです。</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="#%e8%a7%a3%e6%b1%ba%e7%ad%96%e3%81%99%e3%81%a7%e3%81%ab%e6%8c%81%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e3%83%87%e3%83%bc%e3%82%bf%e3%81%a7%e3%83%98%e3%83%83%e3%83%80%e3%83%bc%e3%82%92%e9%80%81%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>いちばんきれいな解決策は、バンドルのハッシュが変わるたびに古くなる重要アセットのリストを手で管理することではありません。各ページは、何をプリロードしたいかをすでにタグで宣言しています。だから、ページを配信するときにそれを<code>Link</code>ヘッダーに写せばいいのです。標準ライブラリの<code>html.parser</code>は引用符ありの属性もなしの属性も(Hugoのminifierは引用符を外します)扱ってくれるので、正規表現で間違える余地はありません。</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">from</span> <span class="nn">html.parser</span> <span class="kn">import</span> <span class="n">HTMLParser</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PreloadLinks</span><span class="p">(</span><span class="n">HTMLParser</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">handle_starttag</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">attrs</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">a</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">tag</span> <span class="o">!=</span> <span class="s2">&#34;link&#34;</span> <span class="ow">or</span> <span class="s2">&#34;preload&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;rel&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;href&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;href&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;as&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="s2">&#34;crossorigin&#34;</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hint</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">PreloadLinks</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">feed</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="n">parser</span><span class="o">.</span><span class="n">hints</span><span class="p">))[:</span><span class="n">limit</span><span class="p">])</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>そして、DjangoのHTMLレスポンスに付けます。</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="n">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>ひとつだけ、私がはまった細部を。フォントには<code>crossorigin</code>を残してください。これがないフォントのプリロードは別のリクエストとして扱われ、フォントが2回ダウンロードされて、ページは速くなるどころか遅くなります。</p>
<p><code>103</code>を自分で書く必要はありません。ASGIやGranian、uvicornに中間レスポンスのコードを足すこともありません。Cloudflareがあなたのヘッダーから<code>103</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%81%bb%e3%81%8b%e3%81%ae%e3%82%b9%e3%82%bf%e3%83%83%e3%82%af%e3%81%a7%e3%81%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ルールはどこでも同じです。HTMLレスポンスに<code>Link</code>ヘッダーを付け、残りはCloudflareに任せます。</p>
<ul>
<li>
<p>nginxでは、HTMLを配信するlocationに：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p>LaravelとViteの組み合わせには、このためのミドルウェア<code>AddLinkHeadersForPreloadedAssets</code>がすでにあり、Viteのプリロードを<code>Link</code>ヘッダーに変換してくれます。これに対して報告されている2つの問題は、上で書いた落とし穴と同じです。<code>modulepreload</code>のヒントが<code>crossorigin</code>なしで送られ、ブラウザがそれを捨ててスクリプトを2回ダウンロードしうること、そして大きなページではヘッダーがApacheの約8KBのヘッダー上限を超えたことです。お使いのバージョンで両方を確認してください。</p>
</li>
<li>
<p>それ以外：<code>Link</code>ヘッダー1つにカンマ区切りで書くか、<code>Link</code>ヘッダーを複数送ります。どちらでも動きます。</p>
</li>
</ul>
<p>みんながやりすぎるのは、何をヒントにするかです。レンダリングに必須のCSS、エントリースクリプト、ファーストビューで使うフォント1〜2個だけをヒントにして、すべてのチャンクを入れないでください。リストが長いと、ブラウザは今必要なものより先に、まだ必要ないものを取りに行ってしまいます。私は12個を上限にしていますが、それもExpoのエクスポートがプリロードのリストをすでに絞ってくれているからです。</p>
<p>Cloudflareの後ろにいない場合や、CDNがEarly Hintsに対応していない場合でも、<code>Link</code>ヘッダーは少しだけ役に立ちます。ボディをパースする前、レスポンスヘッダーが届いた時点でブラウザがそれらの取得を始められるからです。ただし、サーバーかCDNが送らない限り<code>103</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="#%e5%8b%95%e3%81%84%e3%81%a6%e3%81%84%e3%82%8b%e3%81%8b%e7%a2%ba%e8%aa%8d%e3%81%99%e3%82%8b%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>curl</code>なら<code>103</code>を直接見られます。HTTP/2でリクエストし、ステータス行と<code>Link</code>ヘッダーだけを残します。</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">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p>先に<code>103</code>が届き、その後に<code>200</code>が来ます。Cloudflareはヒント内の属性の順番を並べ替えますが、問題ありません。すべてのリクエストが<code>103</code>を受け取るとは思わないでください。私の場合、最初の3回のうち2回でした。変更後の最初のアクセスはヘッダーを保存するだけで、最終レスポンスのヘッダーがすでに用意できている場合、Cloudflareは<code>103</code>を省略します。</p>
<p>そのうえで、上の分析クエリに戻り、<code>requestSource: &quot;earlyHintsCache&quot;</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="#%e4%bd%95%e3%81%8c%e5%a4%89%e3%82%8f%e3%82%8a%e4%bd%95%e3%81%8c%e6%ae%8b%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ヘッダーを本番に出して数分後、このゾーンは初めてEarly Hintsのヒットを記録し、<code>curl</code>ではWebページに本物の<code>103</code>レスポンスが見えました。ヘッダーひとつで2つの問題が同時に片づきました。機能が動き始め、「504」の一部が消えたのです。</p>
<p>残った問題は、JSON APIにはプリロードするものがないことです。APIのエンドポイントはミスを出し続け、<code>504</code>の行を残し続けます。APIの比重が大きいゾーンでは、これが依然として大部分です。妥当な選択肢は3つあります。効果のあるページにヘッダーを送り、APIのパスがミスを記録し続けるのは受け入れる。機能は動き、チャートは静かになりますが、完全には静かになりません。ほかに何を決めるにせよ、<code>earlyHintsCache</code>を5xxのアラートとダッシュボードから除外する。内部のキャッシュ参照で誰かが呼び出されるべきではありません。あるいは、今後も<code>Link</code>ヘッダーを送らないなら、Early Hintsをオフにする（ダッシュボードのSpeed → Settings → Content Optimization、またはAPIのゾーン設定<code>early_hints</code>）。HTMLがとても小さいシングルページアプリでは得るものが少ないかもしれず、オフにすればすべての参照が止まり、ノイズも一緒に消えます。</p>
<p>私はオンのままにしました。Webページは先回りできるようになり、APIのミスはアラートが関わるすべての場所から除外しました。9月に機能をオフにする代わりにやるべきだったのは、まさにこれでした。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io上のDjangoをモニタリングする：残したDevOpsツールと、やめたもの</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>Fly.io上の小さなDjangoアプリ2つを月2ドルほどで監視する方法。エラーはチャットへ、ログはS3に置いてDuckDBで検索、Flyのメトリクス、クラッシュレポート、そしてやめたもの。</description>
      <content:encoded><![CDATA[<p>私が作っているマーケットプレイス兼コミュニティアプリと、現場で見積もりを出す職人向けの見積もりアプリ。どちらも小さなプロダクトで、どちらもFly.ioのいちばん安いマシンで動いています。私のDevOpsモニタリングは、突き詰めると5つです。ERRORログを流すチャットチャンネル、オブジェクトストレージに全部ためてDuckDBで問い合わせるログ、Flyに標準で付いてくるPrometheusメトリクス、SentryのオープンソースSDKで拾って自前のAPIに送るアプリのクラッシュレポート、そして毎朝1本のサマリー投稿です。</p>
<p>ホスティング型のAPMも、本番のトレーシングも、わざわざ思い出して開くダッシュボードもありません。追加でお金がかかっているのはログアーカイブだけで、月2ドルほどです。以下が全リストと、やめたもの・信じなくなったものです。そのうちのひとつのせいで、私はユーザーが誰ひとり体験していない障害を何日も追いかけていました。</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%93%e3%81%93%e3%81%a7%e8%a8%80%e3%81%86%e3%83%a2%e3%83%8b%e3%82%bf%e3%83%aa%e3%83%b3%e3%82%b0%e3%81%a8%e3%81%a9%e3%82%8c%e3%81%a0%e3%81%91%e5%b0%8f%e3%81%95%e3%81%aa%e8%a6%8f%e6%a8%a1%e3%81%ae%e8%a9%b1%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私がDevOpsモニタリングと言うときは、2つのことを指しています。誰かに言われる前に何かが壊れていると気づけること、そして当て推量なしに原因を突き止められることです。以下のツールはすべて、この2点で評価しています。</p>
<p>昨年、<a href="https://jared.lynskey.co.nz/ja/posts/2025/2025-10-13-devops-monitoring/" >開発者が本当に使ってくれるモニタリングの作り方</a>という記事を書きましたが、そのときはおもにチームを想定していました。小さなチームならクラウド事業者に組み込みのモニタリング、サービスが何百もあるならホスティング型のAPM、という具合です。今回はその同じ考え方の、いちばん小さい側の端です。2つのプロダクトはどちらも<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/" >いまの技術スタック</a>で書いたとおりFlyのshared-cpu-1xマシン上のDjangoで、開発者はどちらも私ひとりです。モニタリングがプロダクトで開発者がそのユーザーだとすれば、私のモニタリングのユーザーは1人だけということになります。9月にFlyのメトリクスからベースラインを取ったところ、マーケットプレイスのAPIのCPU使用率は平均1%でした。エンタープライズのスタックではありませんし、そう見せるつもりもありません。</p>
<p>メトリクス、ログ、トレースの3本柱のうち、私が持っているのは2本です。どちらのプロダクトにも分散トレーシングはありません。それぞれDjangoのコードベースひとつとデータベースひとつで、トレースがまたぐべきサービス境界がないからです。私が見ているトレースは、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >自分のワークステーション</a>で動かしているリバースプロキシのものだけです。</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%b8%80%e8%a6%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>ツール</th>
					<th>何を見ているか</th>
					<th>残したか、やめたか、その理由</th>
					<th>費用</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Djangoのロギングハンドラーから流すチームのチャットチャンネル</td>
					<td>API・WebSocketサーバー・ワーカーのERROR、デプロイ、間違ったサインインコードの集中</td>
					<td>残した。言われなくても見に行く唯一の場所</td>
					<td>無料</td>
			</tr>
			<tr>
					<td>FlyのログシッパーからS3互換のバケットへ、DuckDBで問い合わせ</td>
					<td>マーケットプレイスのアプリのログ全行</td>
					<td>残した。Fly自身のバッファは数分、こちらは全部、しかもSQLで</td>
					<td>月約2ドル+ストレージ</td>
			</tr>
			<tr>
					<td>Fly標準のメトリクス(Prometheus API)</td>
					<td>CPU、メモリ、バーストクレジット、レスポンス、マネージドPostgresのエクスポーター</td>
					<td>残した。DBの停止が自分のコードのせいではないと示してくれた</td>
					<td>Flyに含まれる</td>
			</tr>
			<tr>
					<td><code>/api/health/</code> のFlyヘルスチェック(両アプリ)</td>
					<td>マシンにトラフィックを送ってよいか</td>
					<td>ルーティング用には残した。ユーザーが無事な証拠としてはやめた</td>
					<td>Flyに含まれる</td>
			</tr>
			<tr>
					<td>DSNを自前APIに向けたSentryのSDK</td>
					<td>スマホで起きるアプリのクラッシュ</td>
					<td>SDKは残した。sentry.ioには登録していない</td>
					<td>API以外の追加なし</td>
			</tr>
			<tr>
					<td>チームのチャットへの朝のサマリー</td>
					<td>前日のエラー、利用状況、検索、チャット、ボード</td>
					<td>残した。CIのcronジョブから自分のMacへ移した</td>
					<td>無料</td>
			</tr>
			<tr>
					<td>アプリ用のアナリティクスSDK(両アプリ)と、検索エンジンのウェブマスター向けコンソール</td>
					<td>Web・iOS・Androidの利用状況、検索からのサイトの見え方</td>
					<td>残した。APIで読んでいる</td>
					<td>無料</td>
			</tr>
			<tr>
					<td>CloudflareのGraphQLアナリティクス</td>
					<td>ステータスコードやパスの裏に誰がいるか</td>
					<td>残した。ただし信じる前にユーザーエージェントでまとめる</td>
					<td>無料プラン、1クエリ1日分</td>
			</tr>
			<tr>
					<td>ホスティング型OTAアップデートサービスのダッシュボード</td>
					<td>端末がどのOTAリリースを動かしているか、クラッシュロールバック</td>
					<td>何も教えてくれなかった管理画面の一覧から移った</td>
					<td>アップデートサービスの一部</td>
			</tr>
			<tr>
					<td>小さなプローブ用スクリプト</td>
					<td>デプロイや再起動の最中に失敗したリクエスト</td>
					<td>残した。オリジンを直接叩くので、キャッシュが空白を隠せない</td>
					<td>無料</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>アプリ出力の直近数分</td>
					<td>ライブ表示用には残した。記録としてはやめた</td>
					<td>Flyに含まれる</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>何も見ていない。高速化の機能</td>
					<td>オリジンが <code>Link</code> ヘッダーを送るようにしてからは使っている。そのキャッシュミスがゾーンの504のすべてだった</td>
					<td>無料</td>
			</tr>
	</tbody>
</table>
<p>「両アプリ」と書いていないものは、すべてマーケットプレイスの話です。見積もりアプリはもっと少なく、Flyのヘルスチェック・ログ・メトリクス、iOSのアプリ解析、そしてデータベースを毎日Parquetに書き出してDuckDBで問い合わせる仕組みくらいです。クラッシュレポートもアラートのチャンネルもなく、この記事でいちばん大きな穴はここです。(アップデートサービスの行について開示しておくと、このサービスを提供している会社は、同社のブログに書く記事に対して私に報酬を払っています。)</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%a8%e3%83%a9%e3%83%bc%e3%83%81%e3%83%a3%e3%83%83%e3%83%88%e3%81%ab%e6%8a%95%e7%a8%bf%e3%81%99%e3%82%8b%e3%83%ad%e3%82%ae%e3%83%b3%e3%82%b0%e3%83%8f%e3%83%b3%e3%83%89%e3%83%a9%e3%83%bc" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>9月までは、処理されなかった500も、失敗したプッシュ通知も、壊れたWebSocketフレームも、出てくる場所は <code>flyctl logs</code> だけでした。そしてあのストリームを自分から読みに行く人はいません。いまはロギングハンドラーが、API・WebSocketサーバー・バックグラウンドワーカーのどこで起きたものでも、ERROR以上のレコードをすべてチャットチャンネルに送ります。WARNINGはあえて通知しません。</p>
<p>ハンドラーはバックグラウンドのスレッドで送信するので、リクエストを遅くしません。ハンドラーの中では何もログに書かないので、再帰しません。そして同じエラーは10分に1回まで、1時間に最大30件までしか送らないので、障害が起きても千件のメッセージではなく1件として読めます。</p>
<p>このチャンネルには、エラーと突き合わせて読みたいものも流れてきます。デプロイは開始時、立ち上がったとき、失敗した箇所でコミットと実行者付きで投稿し、OTAアップデートや<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-14-web-releases-without-redeploying/" >Webリリース</a>も同じです。ひとつのアカウントに間違ったサインインコードが集中したときもERRORで記録するので、誰かが当てずっぽうに試していれば障害と同じ場所に現れます。</p>
<p>10月、どのログにも対応する行がないアラートがひとつ届きました。それ以来、アラートには短いランダムなid、ホスト名、プロセスの起動コマンドを付け、同じ行をそのプロセスのログにも書くようにしています。idがどこにも見つからなければ、そのアラートはそこにログを書かないプロセス、たとえばSSHのシェルから来たということです。チャンネルに届くものはすべて、私が一緒に作業しているコーディングエージェントが読めるフィードにも入ります。コードを触っているエージェントも、私と同じタイミングでエラーを目にするわけです。</p>

<h2 class="relative group">ログ：FlyのシッパーでS3へ、DuckDBで検索
    <div id="ログflyのシッパーでs3へduckdbで検索" 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%ad%e3%82%b0fly%e3%81%ae%e3%82%b7%e3%83%83%e3%83%91%e3%83%bc%e3%81%a7s3%e3%81%b8duckdb%e3%81%a7%e6%a4%9c%e7%b4%a2" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Fly自身のバッファが持っているのは数分ぶんの出力、だいたい直近100行ほどです。デプロイを見守るには十分ですが、昨日のことには役に立ちません。そこで3つめのFlyアプリでFlyのログシッパーを動かしています。組織のログストリームを購読するVectorで、変えたのはS3シンクひとつだけです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>マーケットプレイスのアプリのログは1行残らず、gzipしたJSONとしてS3互換のバケットにたまります。シッパーは256MBのshared-cpu-1xマシン1台で、月2ドルほど、ストレージ代は小銭程度です。DuckDBはマクロひとつでファイルをその場で読み、1日分ならおよそ1秒で返ってきます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</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="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p><a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ゼロスケールの記事</a>でコールドスタートを測ったのも、このマクロでした。起動スクリプトがフェーズごとに名乗り、どの行にもマシンのidが付いているので、あとは <code>GROUP BY</code> だけです。</p>
<p>つまり、ひとつのストリームを読む人ごとに3段階に分けているわけです。アーカイブはINFO以上をすべて保持し(本番のアプリがINFOでログを出しているのはこのためです)、ライブ表示のスクリプトはWARNINGとERRORだけを見せ、チャットチャンネルはERRORを受け取ります。ただ、どれも呼び出し元はわかりません。アクセスログにはクライアントのIPもユーザーエージェントもないので、それはCloudflareで調べます。</p>

<h2 class="relative group">メトリクス：Flyに標準で付いてくるPrometheus
    <div id="メトリクスflyに標準で付いてくるprometheus" 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%88%e3%83%aa%e3%82%af%e3%82%b9fly%e3%81%ab%e6%a8%99%e6%ba%96%e3%81%a7%e4%bb%98%e3%81%84%e3%81%a6%e3%81%8f%e3%82%8bprometheus" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Flyは組織ごとにマネージドのPrometheusを動かしてくれるので、自前のメトリクス基盤は持っていません。知りたいことがあるときにAPIを叩きます。マシンごとのメモリ、CPU、バーストクレジット(共有CPUでは <code>fly_instance_cpu_balance</code> が効いてきます)、アプリごとのレスポンス数や同時実行数などです。最初に知っておきたかったことが2つあります。<code>fly tokens create</code> で作ったトークンは <code>Authorization: FlyV1 &lt;token&gt;</code> として渡します。<code>Bearer</code> が使えるのは <code>flyctl auth token</code> の個人トークンだけで、読み取り専用トークンだと &ldquo;something went wrong resolving organization&rdquo; が返ってきます。そして保持期間はおよそ2週間です(Flyの説明では15日ほど)。</p>
<p>これが本領を発揮したのは9月でした。ログに &ldquo;Could not open a database connection&rdquo; が1日に何度も出ていて、どう見てもアプリのバグに見えました。マネージドPostgresのクラスタも同じPrometheusに入っていて、そのエラーが出た分にはいつも、プライマリのスクレイプ時間が80ミリ秒ほどから1〜10秒に跳ね上がっていました。レプリカは平常で、Postgresの起動時刻は動いていません。Postgresが再起動したのではなく、その下のホストが止まっていたのです。タイムスタンプを添えてFlyのサポートに送り、10月初めのメンテナンス再起動でプライマリが別のホストに移ってからは、毎日の停止はなくなりました。</p>

<h2 class="relative group">クラッシュレポート：自前のAPIに向けたSentryのSDK
    <div id="クラッシュレポート自前のapiに向けたsentryのsdk" 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%af%e3%83%a9%e3%83%83%e3%82%b7%e3%83%a5%e3%83%ac%e3%83%9d%e3%83%bc%e3%83%88%e8%87%aa%e5%89%8d%e3%81%aeapi%e3%81%ab%e5%90%91%e3%81%91%e3%81%9fsentry%e3%81%aesdk" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>以前は、スマホでアプリが落ちても、私に見える場所のどこにも痕跡が残りませんでした。当然の答えはSentryで、私はその半分だけを取り入れました。アプリにはSentryのオープンソースSDKが入っていますが、DSNは私のDjango APIのエンドポイントを指しています。そのエンドポイントがSentryのenvelope形式を受け取り、クラッシュをまとめ、同じチャット用のハンドラーで通知します。トレーシング、セッション、リプレイはオフにしていて、受け取るのはエラーだけです。</p>
<p>sentry.ioに登録しなかったのは、1日に数件のレポートのために、ユーザーデータを預ける業者をひとつ、プライバシーポリシーの記述を1行、請求書を1枚増やしたくなかったからです。代わりに払っている代償はシンボリケーションで、これはまだ置き換えられていません。スタックはminifyされたJavaScriptか生のネイティブアドレスなので、イシューを見れば何がどのリリースと端末で落ちたのか、直前にユーザーが何をしていたのかはわかっても、ソースのどこなのかはわかりません。</p>

<h2 class="relative group">ダッシュボードはメッセージ1本
    <div id="ダッシュボードはメッセージ1本" 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%80%e3%83%83%e3%82%b7%e3%83%a5%e3%83%9c%e3%83%bc%e3%83%89%e3%81%af%e3%83%a1%e3%83%83%e3%82%bb%e3%83%bc%e3%82%b81%e6%9c%ac" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>どちらのアプリにもダッシュボードは作っていません。昨年の記事にダッシュボードの判定基準を書きました。誰にも自発的に開かれないダッシュボードは存在価値を証明できていない、というものです。勝手に届くメッセージなら開く必要がないので、私にとっていちばんダッシュボードに近いのは、毎朝チームのチャットに届く1本の投稿です。</p>
<p>最初は直近24時間の本番エラーです。チャットチャンネルではなくログアーカイブから読みます。アラートは件数を絞っているので、何かが何回起きたのかを教えてくれないからです。数字、id、アドレスを伏せてからまとめるので、同じ失敗が100件あっても件数付きの1行になり、メールアドレスがチャンネルに流れることもありません。</p>
<p>続いて失敗したヘルスチェック、前日の利用状況を1週間前の同じ曜日と比べたもの(日曜日が月曜日と同じになることはありません)、直近1週間の検索流入、返信待ちのチャット、タスクボード、そして支出です。読み込めなかったセクションはそう書かれ、投稿自体は届きます。</p>
<p>アプリ解析の数字をAPIで読んでいると、少し疑ってかかるようになります。8月にはアゼルバイジャンからAndroidの初回起動が121件あり、その後アンインストールが119件続きました。その月のAndroidの合計は、これを除いて読んでいます。</p>
<p>リリースには専用の画面があります。OTAアップデートを自前のサーバーから配っていたころ、リリースを見る手段は &ldquo;is active&rdquo; のチェックボックスが付いた管理画面の一覧だけで、何台がアップデートを受け取ったのかまったくわかりませんでした。いまのサービスのダッシュボードは、端末が実際にどのリリースを動かしているか、インストールがどうだったか、そして壊れたバンドルが落ちてSDKが前のものに戻したときにはそのクラッシュロールバックまで見せてくれます。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >ホスティング型のアップデートサービスへの移行</a>については別の記事にまとめています。</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%84%e3%82%81%e3%81%9f%e3%82%82%e3%81%ae%e4%bf%a1%e3%81%98%e3%81%aa%e3%81%8f%e3%81%aa%e3%81%a3%e3%81%9f%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>この中に有料のツールはひとつもありません。ほとんどはシグナルだと思っていたのに実はそうではなかったもので、あとひとつは置き場所を間違えていたジョブです。</p>

<h3 class="relative group">ユーザーエージェントでまとめるまでの、Cloudflareの504率
    <div id="ユーザーエージェントでまとめるまでのcloudflareの504率" 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%a6%e3%83%bc%e3%82%b6%e3%83%bc%e3%82%a8%e3%83%bc%e3%82%b8%e3%82%a7%e3%83%b3%e3%83%88%e3%81%a7%e3%81%be%e3%81%a8%e3%82%81%e3%82%8b%e3%81%be%e3%81%a7%e3%81%aecloudflare%e3%81%ae504%e7%8e%87" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>9月、Cloudflareのダッシュボードには、1日のリクエストの22%が504で失敗しているように見える数字が出ていました。アプリが30秒ごとにポーリングするエンドポイントに集中していて、ヘルスチェックはすべて緑、アプリ自身のログではそのリクエストは速く返っていました。何日もかけました。マシンのコールドスタートに25〜33秒かかり、それが1日92回起きていることを突き止めて直し、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ゼロスケールの記事</a>にそれが原因だと書きました。</p>
<p>翌朝、同じ504をユーザーエージェントでまとめてみました。9月5日から9日までの7,344件は、すべてCloudflare自身のEarly Hintsのキャッシュ参照(ユーザーエージェントは <code>bastion early hints</code> と <code>nginx-ssl early hints</code>)でした。この参照はオリジンには一切届きません。その行の504はタイムアウトではなく、キャッシュミスを記録したものです。実際のリクエストのパスをなぞるので、いちばん忙しいエンドポイントに集まり、本当にユーザーが失敗しているように見えました。アプリ、ブラウザ、curl、検索エンジンのクローラーには1件もありません。オリジンが <code>Link</code> ヘッダーを送っていなかったので、Early Hintsは何の役にも立っておらず、いったんオフにしました。その後、<a href="https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/" >ちゃんと動くようにしました</a>。</p>
<p>コールドスタートの対応自体は無駄になりませんでした。デプロイもスリープからの復帰も速くなっています。ただ、直しているつもりだった障害は、一度もユーザーに届いていませんでした。</p>
<p>だからこそ、いちばん伝えたいルールはこれです。CDNのエラー率を信じる前に、ユーザーエージェントでまとめて、CDN自身の仕組みから出たものではないか確かめてください。Cloudflareの無料プランなら <code>httpRequestsAdaptiveGroups</code> へのGraphQLクエリで、1日ずつ見られます。次の騒ぎも同じクエリで片付きました。ポーリング用のエンドポイントのひとつが1日70〜260件から2,880件に跳ね上がったのですが、スクレイパーではありませんでした。テストのあとiOSシミュレーターでアプリを起動したままにしていた、私自身のMacでした。</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="#%e7%b7%91%e3%81%ae%e3%83%98%e3%83%ab%e3%82%b9%e3%83%81%e3%82%a7%e3%83%83%e3%82%af" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>Flyは両アプリの <code>/api/health/</code> を30秒ごとに確認し、その結果でどのマシンにトラフィックを送るかを決めます。その仕事は得意です。ただ、ヘルスチェックはすでに起動しているマシンに対して動くので、マシンが起動する前に起きることは見えません。コールドスタート、プロキシの前の待ち行列、ついに届かないリクエストなどです。いまはルーティング以外のことは期待していません。</p>
<p>デプロイにはかわりにプローブのスクリプトを使います。デプロイのあいだずっとオリジンを叩き、ひとつでも失敗すれば終了コード1で終わります。このスクリプトが素の <code>flyctl deploy</code> で7.3秒の空白を捉え、先に予備のマシンを起こすデプロイスクリプトに切り替えてからは、2,514件中の失敗はゼロでした。</p>
<p>ヘルスチェックは、それ自体が原因の障害も2回起こしています。7月には、Djangoの <code>SECURE_SSL_REDIRECT</code> がFlyの平文HTTPのプローブに301で応え、すべてのマシンがチェックに失敗して、全員に503が返りました。8月には、ヘルス用ビューの <code>except Exception</code> を絞り込んだせいで &ldquo;connection already closed&rdquo; のエラーが500として漏れ、ビューのリトライがまさにそのケースのためにあったのに、Flyがマシンを外してしまいました。いまのチェックは、このプロセスがリクエストを処理できるかだけを問い、データベースが止まっても両アプリがルーティングから外れることはありません。</p>
<p>朝のサマリーも、数え方で同じ教訓を学びました。マーケットプレイスはアイドルのマシンをサスペンドするので、リクエストで起こされたマシンは起動後数百ミリ秒で最初のチェックに失敗し、次のチェックで通ります。それを数えていたせいで毎朝が障害のように読めたので、1分以内に再び通った失敗は、いまはマシンの復帰とデプロイとして別に表示しています。</p>

<h3 class="relative group">CIの定期実行ジョブ
    <div id="ciの定期実行ジョブ" 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="#ci%e3%81%ae%e5%ae%9a%e6%9c%9f%e5%ae%9f%e8%a1%8c%e3%82%b8%e3%83%a7%e3%83%96" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>朝のサマリーは、cronで動くCIのワークフローとして始まり、1日で引っ越しました。アプリ解析と検索のデータを読むようになると、必要な鍵がアナリティクスのプロジェクトのサービスアカウントキーになったからです。そのプロジェクトで何でもできる鍵なので、CIのシークレットには置きたくありませんでした。いまは私のMacのスケジューラーが20分ごとに動かし、オークランド時間の朝8時を過ぎていて、その日の投稿がまだのときだけ投稿します。8時に眠っていたMacは、目を覚ましたときに投稿するわけです。ワークフローは、アプリ解析と検索のセクションを抜いた手動用の予備として残しています。</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%82%e3%81%86%e4%b8%80%e5%ba%a6%e5%a7%8b%e3%82%81%e3%82%8b%e3%81%aa%e3%82%89" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初の障害のあとではなく、最初のユーザーが来る前に3つを用意します。チームがすでに読んでいるチャットチャンネルへのエラー通知(件数制限付き)、SQLで問い合わせられるオブジェクトストレージのログアーカイブ、そしてプラットフォームがすでに集めているメトリクス。月に数ドルで済みます。クラッシュレポートは、アプリがスマホに載る週に入れます。そうしないと、スマホで起きたクラッシュは誰にも何も伝えないからです。朝のサマリーは、確認する場所が増えすぎて、結局どこも確認しなくなりそうになったころに足せば十分です。</p>
<p>トレーシングは、リクエストがまたぐ2つめのサービスができるまで、ホスティング型のAPMは、共有ダッシュボードとオンコールの当番表、そして他社の請求書を払う予算が必要なチームができるまで待ちます。昨年の記事で紹介したツールが元を取り始めるのはそこからです。それまでのいちばん役に立つ習慣はタダです。数字が怖く見えたら、何が壊れたかを決める前に、その数字を何が生み出したのかを確かめること。この記事に出てきた騒ぎのいくつかは、プロダクトではなくモニタリングそのものから生まれていました。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>GitHub ActionsのCIでDocker Composeをやめた話：代わりに何を入れ、いまどんなパイプラインを動かしているか</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>2026年に動かしているGitHub ActionsのCI/CD：CIではDocker Composeの代わりにサービスコンテナ、キャッシュはステップの所要時間を見て選び、デプロイはスクリプトで。</description>
      <content:encoded><![CDATA[<p>CI/CDパイプラインとは、変更が<code>git push</code>から本番に届くまでの自動化された道筋です。CIはすべてのpushをクリーンなマシンでビルドしてテストし、CDは通ったものを繰り返せる一つの手順でリリースします。GitHub Actionsでは<code>.github/workflows/</code>の下に置くYAMLファイルがそれで、私のものはわざと薄くしています。チェックは手元のノートPCで打つのと同じコマンドで、デプロイはどれもGitHubがなくても同じように動くスクリプトかCLIです。</p>
<p>GitHub ActionsでDocker Composeを動かす方法を探してここに来たなら、先に結論を。私はそれを2年やって、やめました。いまのCIは同じPostgresとValkeyのイメージを<code>services:</code>として宣言し、同じ<code>uv run …</code>と<code>npm run …</code>をランナー上で直接実行します。Composeは開発者が手元で動かすものに戻りました。以下はその理由と、実際のファイルです。</p>
<p>2024年に<a href="https://jared.lynskey.co.nz/ja/posts/2024/2024-06-02-simplifyingcicd/" >CI/CDのロジックはプラットフォーム固有のYAMLではなくDocker Composeに</a>という記事を書きました。ビルドのロジックはComposeに置き、CIツールにはそれを呼ぶことだけをさせる、という内容です。2年たって、実際にリリースに使っているリポジトリを全部見直しました。ワークフローがあるのは4つで、コミッターはどれも私一人です。そして、そのワークフローのうち<code>docker compose</code>を呼んでいるものは、もう一つもありません。原則は残り、やり方は残りませんでした。以下はその代わりに入ったものです。ファイルを削って例にしたもの、GitHubが記録したステップごとの所要時間、そしてCI/CDの図が期待するほどには自動化されていない部分も書いています。</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="#%e5%8b%95%e3%81%8b%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%82%92%e4%b8%80%e3%81%a4%e3%81%ae%e8%a1%a8%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>パイプライン</th>
					<th>起点</th>
					<th>CIが確認すること</th>
					<th>行き先</th>
					<th>リリースする人</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>このブログとクライアントのHugoサイト</td>
					<td>すべてのpush、ブランチを問わず</td>
					<td><code>hugo --gc</code>がビルドできること</td>
					<td>Cloudflare Pages：<code>main</code>は本番、ほかのブランチはプレビューURL</td>
					<td>なし。pushがそのままリリース</td>
			</tr>
			<tr>
					<td>私が作っているマーケットプレイスアプリのAPI</td>
					<td>デプロイスクリプト、手動</td>
					<td><code>main</code>へのpushごとにリント、型チェック、Djangoのテスト</td>
					<td>Fly.io、ローリングデプロイ</td>
					<td>私</td>
			</tr>
			<tr>
					<td>同じアプリのフロントエンド：Web、OTAアップデート、ストア向けビルド</td>
					<td>リリース用スクリプトとローカルのストア向けビルド、手動</td>
					<td><code>main</code>へのpushごとにTypeScriptとJest</td>
					<td>オブジェクトストレージ、OTAアップデートのサービス、二つのアプリストア</td>
					<td>私</td>
			</tr>
			<tr>
					<td>pyhtml2text（私のオープンソースのPythonパッケージ）</td>
					<td>すべてのpush、プルリクエスト、タグ</td>
					<td>15のビルドターゲット向けのwheel、その大半でテスト</td>
					<td>PyPI、タグを打ったとき</td>
					<td>タグ</td>
			</tr>
	</tbody>
</table>
<p>教科書の言葉でいえば、二つのHugoサイトは継続的デプロイメントで、アプリ（私が作っているマーケットプレイス兼コミュニティアプリ）は継続的インテグレーションにコマンド一つのリリースを足した継続的デリバリーです。チェックは何かがおかしいと教えてくれるだけで、デプロイを止めはしません。これは意図してそうしています。OTAアップデートは対象のバイナリバージョンを使っているすべての端末に届くので、マージの副作用ではなく判断であるべきです。そしてAPIはデータベースのマイグレーションが走る場所です。残り二つのDjangoアプリには、ワークフローがそもそもありません。</p>

<h2 class="relative group">CIの側：ワークフロー一つ、ジョブ二つ
    <div id="ciの側ワークフロー一つジョブ二つ" 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="#ci%e3%81%ae%e5%81%b4%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc%e4%b8%80%e3%81%a4%e3%82%b8%e3%83%a7%e3%83%96%e4%ba%8c%e3%81%a4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリのCIは<code>.github/workflows/checks.yml</code>というファイル一つです。10月11日までは、これすらありませんでした。以下はその形を削って例にしたものです。</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"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</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">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</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">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">postgres</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">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</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 class="p">[</span><span class="s2">&#34;5432:5432&#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">options</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">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</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">valkey/valkey:8-alpine</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 class="p">[</span><span class="s2">&#34;6379:6379&#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">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</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="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>二つのジョブは並行して走ります。これまでに成功した35回の実行で、アプリのジョブの中央値は約7分、Pythonのジョブは約12分半です。</p>
<p><code>services:</code>ブロックが、Composeファイルの移った先です。これは次の節で書きます。環境変数はいくつかとフラグ一つだけです。ほかはすべて開発者が<code>docker compose up</code>で得る開発用のデフォルトに落ちるので、テストにシークレットは一つも要りません。本当にそうか確かめるために、まさにその環境だけで、ほかに何もない状態でテストの一部を走らせました。</p>
<p>CIは手元のノートPCや本番イメージと同じインタープリタ、つまりuv自身がビルドしたPython 3.15.0で動きます。<code>uv sync</code>が<code>.python-version</code>を読み、まさにそのビルドをダウンロードするからです。（実際のファイルではsetup-uvに<code>python-version-file</code>という入力も渡していますが、これは何もしていません。setup-uv v6にはそんな入力がなく、毎回の実行にそういう警告が付いています。この記事を書いていて初めて気づきました。）</p>
<p>インタープリタを揃えた甲斐は、ワークフローを入れて8日後に出ました。10月19日に3.15のJITコンパイラを有効にしたところ、有効にして最初の実行でテストが一つ落ちたのです。アプリが保存のたびに通るコードパスのごく普通の<code>yield from</code>が、<code>AttributeError: 'list' object has no attribute 'send'</code>を投げていました。同じコミットをJITなしで走らせると通ります。私のMacはarm64で一度もこれに当たらず、ランナーは本番マシンと同じx86_64です。JITはCIでもDockerfileでも再び無効にしてあり、このバグが本番に届くことはありませんでした。これはpython/cpython#155823で、3.15.0のリリース後の10月13日に上流で修正されています。</p>
<p>実は最初の実行も落ちていて、こちらの理由はもっと地味です。Expoは<code>expo start</code>のときに<code>expo-env.d.ts</code>を書き出し、このファイルはgitignoreされています。なので私が使ってきた作業コピーにはどれもあり、まっさらなチェックアウトにはありませんでした。TypeScriptは、ローカルでは一度も出たことのないエラーを五つ出しました。そのファイルを書き出すステップが対処です。</p>
<p><code>concurrency</code>は、同じブランチに新しいpushが来ると前の実行を取り消します。これまでに取り消された12回は、すべてこれによるものです。コミット前のフック（pre-commit）がリンターを走らせ、PythonかTypeScriptがステージされていれば型チェッカーも走らせるので、ミスの大半はpushまで行きません。</p>

<h2 class="relative group">Docker Composeファイルはどこへ行ったか
    <div id="docker-composeファイルはどこへ行ったか" 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-compose%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%e3%81%af%e3%81%a9%e3%81%93%e3%81%b8%e8%a1%8c%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリの<code>docker-compose.yml</code>のうち、CIに関係する部分を例にしたものです。実物にはもっと多くのサービスと、ずっと多くの環境変数があります。</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">valkey</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">valkey/valkey:8-alpine</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;6379:6379&#34;</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="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</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">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</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;55432:5432&#34;</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">postgres_data:/var/lib/postgresql/data</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-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#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">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">10</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="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</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="l">python manage.py runserver 0.0.0.0:8000</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">.:/app</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;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</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">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">postgres_data:</span></span></span></code></pre></div></div>
<p><code>checks.yml</code>の<code>services:</code>ブロックと並べると、同じイメージが二つです。Postgres 17（こちらはAlpine版、CIはDebian版）と<code>valkey/valkey:8-alpine</code>。いまの関係はこうです。Composeファイルは開発者が動かすもの。CIは同じイメージをサービスコンテナとして宣言し、開発者が打つのと同じコマンド、<code>uv run …</code>と<code>npm run …</code>をランナー上で直接実行します。</p>
<p>2024年の記事と比べると、いちばん大事にしていた部分は残りました。CIツールは薄く、チェックもデプロイも自分で実行できるコマンドです。残らなかったのは、CIからComposeを呼ぶやり方です。</p>
<p>アプリのCIはDockerイメージをまったくビルドしません。イメージはデプロイのときにFlyのビルダーが一度だけビルドし、CIはコードが大丈夫かどうかだけ答えればよく、ツールチェーンがキャッシュされていればそれは6秒のインストールで済みます。それにComposeファイルは作業コピーをバインドマウントし、データを投入しますが、それはテストの実行がすることではありません。CI用に別のComposeファイルが必要になっていたはずで、それは同じサービスの二つ目の定義を手で揃え続けるということです。</p>
<p>そして2024年の例には、当時気づかなかったバグがありました。<code>docker compose build</code>はイメージをビルドするだけで、サービスの<code>command</code>は実行しません。なので、あのファイルの<code>npm test</code>はCIで一度も走っておらず、ワークフローはテストが通ろうが落ちようが緑でした。</p>
<p>あの記事がいちばん心配していたリスク、つまりCIベンダーの乗り換えは、結局起きませんでした。実際に起きた移行は反対側でした。このブログとクライアントのサイトがホスティング先をCloudflare Pagesに移し、ブログのほうは45行のワークフローを一つ新しく書くだけで済みました。ビルドがもともとコマンド一つだったからです。それから、私が警告していた300行のワークフロー。一つあります。pyhtml2textのものです。wheelをLinux、macOS、Windowsのランナーで、それぞれ複数のアーキテクチャ向けにビルドしなければならないので長いのですが、だからこそプラットフォーム固有のYAMLが正しい道具になる唯一の仕事でもあります。</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%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%81%be%e3%81%9a%e3%82%b9%e3%83%86%e3%83%83%e3%83%97%e3%81%ae%e6%89%80%e8%a6%81%e6%99%82%e9%96%93%e3%82%92%e8%aa%ad%e3%82%80" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>GitHubはすべてのステップの所要時間を記録していて、<code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code>で取り出せます。以下は10月20日の、アプリで最新の成功した実行です。</p>
<table>
	<thead>
			<tr>
					<th>ジョブ</th>
					<th>ステップ</th>
					<th>秒</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>PostgresとValkeyの起動</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>、キャッシュあり</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Djangoのテスト</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>、キャッシュあり</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>依存関係のキャッシュは二つとも有効で、どちらも安い部分です。GitHubがこのリポジトリ用に保持しているuvのキャッシュは200KBほどで、setup-uvがダウンロードし直せば済むものを整理してしまうからです。npmのほうは400MB近くあります。時間がかかっているのはテストで、テストはすでに4並列で走っています。実行を速くしたいなら、キャッシュではなくテストスイートに手を入れます。</p>
<p>ブログは正反対です。Hugoが処理した画像はgitignoreされた<code>resources/_gen</code>にあるので、新しいランナーのたびにカバー、本文の画像、プレースホルダーを全部エンコードし直していました。後で載せるワークフローは<code>actions/cache</code>でこれを復元します。キーは画像そのもののハッシュで、合わなければいちばん新しい古いキャッシュで代用します。Hugoは足りない分しか処理しないからです。キャッシュ導入前の最後の8回では、ビルドのステップに33〜76秒かかっていました。導入後の26回は6〜20秒で、例外は94秒の一回だけ。しかも同じコミットでプレースホルダーと二つ目のカードサイズを追加したうえでの数字です。</p>
<p>アプリのイメージはFlyのビルダーでビルドされ、依存関係のファイルをコードより先にコピーし、uvのダウンロードキャッシュはBuildKitのキャッシュマウント（<code>RUN --mount=type=cache,target=/root/.cache/uv</code>）に置いています。ただ、もっと大きな改善はキャッシュではありませんでした。以前の<code>.dockerignore</code>はブロックリストで、追加のgit worktreeを置いたフォルダーを漏らしていました。それぞれが自分の依存関係とビルド成果物を抱えています。そのせいで27万7千ファイル、7.7GBが、デプロイのたびにビルダーへ送られていました。許可リストに書き直したいま、ビルドコンテキストは40MBほどです。パターンの例はこうです。</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"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<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%b7%e3%83%bc%e3%82%af%e3%83%ac%e3%83%83%e3%83%88%e3%81%af%e3%81%a7%e3%81%8d%e3%82%8b%e3%81%a0%e3%81%91%e5%b0%91%e3%81%aa%e3%81%84%e5%a0%b4%e6%89%80%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>シークレットは三か所にあります。GitHub Actionsのシークレットはワークフローがすることのためのもので、二つのHugoリポジトリにはそれぞれ<code>CLOUDFLARE_API_TOKEN</code>が一つあるだけです。Flyのシークレットはアプリの実行時設定で、<code>flyctl secrets set</code>で入れ、Actionsを経由することはありません。そしてノートPCのgitignoreされた<code>.env</code>に、デプロイスクリプトがCloudflareのキャッシュを消すときに使うトークンがあります。</p>
<p>PyPIにはシークレットがそもそも要りません。pyhtml2textはトラステッドパブリッシングを使っています。GitHubがワークフローに発行する短命のOIDCトークンをPyPIが受け付けるので、リリースのジョブは<code>id-token: write</code>を求めるだけで、リポジトリのシークレットのページは空です。漏れるものも、ローテーションするものもありません。</p>
<p>トークンには必要な分だけを与えます。GitHubにあるアプリのFlyトークンは、マシンの一覧を取るだけの定期チェック用なので、<code>fly tokens create readonly</code>で作っています。Pagesのデプロイが求めるのは<code>contents: read</code>と<code>deployments: write</code>だけで、それ以上はありません。</p>
<p>入力は直接スクリプトに入れず、必ず<code>env:</code>を通します。OTAのワークフローは自由記述のリリースノートを受け取ります。これを<code>run:</code>の中に<code>${{ inputs.notes }}</code>と書くと、bashが読む前にテキストがスクリプトにそのまま貼り込まれるので、引用符とセミコロンを含むノートはコードになってしまいます。環境変数として渡せば、データのままです。パターンの例です。</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="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>GitHubの外に置いたままのキーもあります。ある定期レポートには、アプリのサインイン基盤に対する管理者権限を持つキーが必要です。なのでそのレポートは定期ワークフローから、Mac上の定期ジョブに移しました。ワークフローは手動の予備として残してあり、そのキーが要るセクションを除いて投稿します。</p>

<h2 class="relative group">デプロイ：pushごとに、スクリプトで、タグで
    <div id="デプロイpushごとにスクリプトでタグで" 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%87%e3%83%97%e3%83%ad%e3%82%a4push%e3%81%94%e3%81%a8%e3%81%ab%e3%82%b9%e3%82%af%e3%83%aa%e3%83%97%e3%83%88%e3%81%a7%e3%82%bf%e3%82%b0%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hugoサイトはpushのたびにデプロイ
    <div id="hugoサイトはpushのたびにデプロイ" 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="#hugo%e3%82%b5%e3%82%a4%e3%83%88%e3%81%afpush%e3%81%ae%e3%81%9f%e3%81%b3%e3%81%ab%e3%83%87%e3%83%97%e3%83%ad%e3%82%a4" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ブログのワークフローを例にしたものです。初回だけPagesのプロジェクトを作るステップは省いています。</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</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">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</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">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</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">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">jobs</span><span class="p">:</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">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</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="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</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">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</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="w">      </span><span class="c"># ...</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="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</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="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code>によって<code>main</code>が本番になり、ほかのブランチはどれもプレビューURLを得ます。クライアントのサイトのワークフローはこれと同じ形で、ビルドの前に<code>rm -rf public</code>が一行加わっています。あちらは<code>public/</code>がコミットされているので、そうしないと削除したページがアップロードに残り続けるからです。</p>
<p>Hugoのバージョンはここでだけ固定しています。10月にパッケージマネージャーのアップグレードでノートPCのHugoが0.168に上がりましたが、これはテーマが対応する最新版より一つ新しいものです。デプロイは0.165のままで、何も起きませんでした。</p>
<p>そして、ビルドそのものがテストです。9月14日、あるブランチのプレビューデプロイが3回続けて失敗しました。そのブランチの記事が、Hugoの<code>ref</code>でまだ下書きの記事を指していたのです。CIは本番と同じように下書きを除いてビルドするので、リンク先は存在せず、Hugoは<code>REF_NOT_FOUND</code>で止まりました。その間も<code>main</code>は普通にデプロイされ続けていました。</p>

<h3 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="#%e3%82%a2%e3%83%97%e3%83%aa%e3%81%aeapi%e3%81%af%e3%82%b9%e3%82%af%e3%83%aa%e3%83%97%e3%83%88%e3%81%a7%e3%83%87%e3%83%97%e3%83%ad%e3%82%a4" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>デプロイスクリプトの中心部分を例にしたものです。</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">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>そして<code>fly.toml</code>の対応する部分です。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>イメージは一度だけビルドし、コミットと時刻からなるラベルを付けてFlyのレジストリに送り、そのラベルでデプロイします。なので、ロールアウトされるのはビルドしたものそのものです。</p>
<p>ゼロダウンタイムを実現しているのは、先に予備のマシンを起こしておくことです。<code>max_unavailable = 1</code>が数えるのは動いているマシンだけですが、アプリは暇なときに動いているマシンを一台も置きません（サスペンドしたマシンは1秒ほどで復帰します）。そのため、起きていた一台が寝ているマシンと同じバッチで置き換えられ、後ろに温まったマシンが一台もない、ということが起きていました。9月28日にオリジンへプローブを送り続けて測ったところ、素の<code>flyctl deploy</code>ではタイムアウトが7回と7.3秒の空白があり、先にビルドしてロールアウト開始時にはマシンを一台起こしておくスクリプトのデプロイでは、2,514リクエストで失敗はゼロでした。</p>
<p>スクリプトの残りは、エラーの通知が届くチームのチャットチャンネルにデプロイの印を残します。エラーが固まって出たとき、直前に何が変わったかと照らし合わせられるようにです。それからCloudflareのキャッシュを消し、マシンを温めておきます。Webフロントエンドはイメージをまったく通りません。それは<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-14-web-releases-without-redeploying/" >フロントエンドとバックエンドを別々にデプロイする</a>に書きました。</p>
<p>マイグレーションはパイプラインのステップではありません。Flyの<code>release_command</code>が私の環境では固まってしまったので、新しいイメージで最初に起動したマシンがロックを取ってマイグレーションし、ほかのマシンは待ちます。そのため、どのマイグレーションもまだ動いているコードと両立しなければなりません。カラムはあるデプロイで追加し、次のデプロイで使うのをやめ、三回目のデプロイで削除します。</p>
<p>このスクリプトに、私のノートPCでなければならない部分は特にありません。ただ、ノートPCにしかないファイルがいくつかあって、それが縛りになっています。Cloudflareの認証情報と、最後に何をデプロイしたかの記録で、まっさらなランナーにはこれがありません。コミットしていない変更もデプロイしてしまい、チャットでそう知らせますが、Webのリリース用スクリプトは汚れた作業ツリーを拒否します。</p>
<p><a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >ブランチごとのプレビュー環境</a>で私は、これを手でやるのは自分一人なら構わないが、二人目の開発者からはgitで何かが起きたから起きるようにしなければならない、と書きました。アプリの開発者はまだ一人です。あの記事のブランチ環境のワークフローは、作ったときのブランチにそのまま置いてあります。</p>
<p>OTAのワークフローは手動専用です。公開は判断であるべきですし、アップデートのサービスは部分ロールアウトが開いている間は新しいリリースを拒むので、pushトリガーではカナリアのたびに引っかかってしまいます。このワークフローは一度も実行されたことがありません。これまでのOTAアップデートはすべてノートPCから、ワークフローが呼ぶのと同じスクリプトで出しています。薄いワークフローにする理由は一言でこれです。リリースをどう始めても、同じスクリプトです。</p>

<h3 class="relative group">pyhtml2textはタグでリリース
    <div id="pyhtml2textはタグでリリース" 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="#pyhtml2text%e3%81%af%e3%82%bf%e3%82%b0%e3%81%a7%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>リリースのジョブです。パッケージ名は一般的なものにしてあります。</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="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</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">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>リリースのジョブはすべてのビルドジョブを待ち、実際に公開するのはタグのときだけです。手動実行はビルドとアテステーションまではしますが、何もアップロードしません。<code>actions/attest</code>はどのワークフローが各wheelをビルドしたかを記録し、<code>uv publish --trusted-publishing always</code>はパスワードの代わりにOIDCトークンでアップロードします。パッケージそのものについては<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-10-18-pyhtml2text/" >閉じられたプルリクエスト、フォーク、そしてPyPIパッケージ</a>に書きました。</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="#%e5%ae%9a%e6%9c%9f%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc%e3%81%af%e3%83%99%e3%82%b9%e3%83%88%e3%82%a8%e3%83%95%e3%82%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリには定期ワークフローが一つあります。Flyがベースラインより多くのマシンを起動していないかを見るチェックで、放っておけば請求書で初めて気づくことです。通知はわざと失敗することで行います。定期実行が失敗すると、GitHubが所有者にメールを送るからです。</p>
<p>設定は<code>cron: '*/15 * * * *'</code>、1日96回です。ところがこの2週間は1日3〜8回しか動いておらず、実行間隔の中央値は3時間40分ほどでした。GitHubのドキュメントが挙げる理由は負荷だけです。Actionsが混んでいると定期実行は遅れることがあり、混み具合によっては落とされることもある、と。持続的なスケールアウトを見るなら数時間で十分です。数分以内に鳴らなければならないものにとって、GitHubのcronは監視ではありません。</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%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%ab%e6%8a%95%e3%81%92%e3%81%8b%e3%81%91%e3%81%9f%e3%81%84%e8%b3%aa%e5%95%8f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これから作る人も、すでにあるものを見直している人も：</p>
<ol>
<li>GitHub Actionsが丸一日止まったら、それでもリリースできますか？ できないなら、リリースのロジックがYAMLの中にあるということで、2024年の記事がまだ当てはまります。</li>
<li>CIは本番と同じインタープリタ、同じCPUアーキテクチャ、同じデータベースのメジャーバージョンで動いていますか？ 私のCIがコンパイラのバグを捕まえられたのは、ランナーと本番がどちらもx86_64で、ノートPCがそうではなかったからです。</li>
<li>まっさらなチェックアウトで通りますか？ 通らないとしたら、たいていは生成されてgitignoreされたファイルのせいです。</li>
<li>実行時間は実際どこに使われていますか？ キャッシュを足す前に、ステップの所要時間を読んでください。</li>
<li>シークレットはそれぞれ何か所にありますか？ そして本当にGitHubに置く必要がありますか？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/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>
    <item>
      <title>ブランチごとのプレビュー環境: Fly.io と Cloudflare でステージング争奪戦を終わらせる</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>PR ごとにプレビュー環境を立てる方法です。ラベルひとつでブランチ名のハッシュを Fly アプリ、Postgres、Redis、バケット、ホスト名に刻み、インフラは共有のまま分け、PR を閉じれば片付けます。</description>
      <content:encoded><![CDATA[<p>プレビュー環境とは、ブランチや PR ごとに立ち上がり、用が済めば消える使い捨ての環境のことです。Fly.io でこれを作るとき、私はインフラを丸ごと複製せず、共有のまま切り分けています。PR に <code>env</code> ラベルを付けると GitHub Actions のワークフローがスクリプトをひとつ走らせ、ブランチ名から導いた名前(スラッグと短いハッシュ)を Fly アプリ、テンプレートから複製した Postgres データベース、Redis のキー接頭辞、バケットの接頭辞、生成したシークレット、Cloudflare のワイルドカード配下のホスト名に刻みます。push のたびに更新され、PR を閉じるかマージすれば片付き、漏れたものは毎晩のスイーパーが拾うので、「ステージング使ってる人いる?」と聞く必要はもうありません。</p>
<p>ひとつのアプリを作る小さなチームを思い浮かべてください。開発者は二人でも五人でも構いません。分野ごとの専門家ではなく、それぞれが自分のブランチで、データベースのマイグレーションから API を経てモバイルアプリの画面まで、機能をひとつ端から端まで受け持つ人たちです。チームの回し方としては良いやり方で、二人目の開発者が加わった瞬間から繰り返し起きる失敗がひとつあります。「今ステージング使ってる人いる?」というメッセージです。</p>
<p>そのメッセージの裏には、機能を作り終えてテスターやデザイナーに見せたいのに見せられない開発者がいます。ステージングには二人目の開発者のブランチが載っていて、dev には三人目のブランチが載っていて、そちらは二日前にログインを壊してから再デプロイされていません。数人、共有環境が二つ、そしてスケジュール調整を引き受けているチャットのスレッド。</p>
<p>これは人の問題ではありません。トポロジーの問題です。全員が環境を「自分のブランチが本物になる場所」として扱っているので、環境がマージのやるべき仕事をブランチひとつずつ順番に肩代わりし、残りの全員が列に並んでいるのです。</p>
<p>自分のスタックの本番の形は <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a> に書きました。Fly.io の最安マシン上の Django、コネクションプーラーの背後の Postgres、キャッシュと Channels レイヤー用の Redis、別立ての WebSocket アプリ、バックグラウンドワーカー、ファイル用の S3 互換バケット、ホスティング型の認証プロバイダー、前段の Cloudflare、そしてそれら全部と話すモバイルアプリです。この記事は、そのスタックを、どのブランチでも開発者が PR にラベルをひとつ付ければ数分のうちに HTTPS 付きの公開コピーを丸ごと手に入れられるものに変えた話です。他の誰かに頼むことも、誰かが用意してやることもありません。Postgres クラスタも Redis もバケットも、二つ目は用意しません。インフラは共有のままです。変わるのは、共有リソースのすべてが名前で切り分けられるようになることで、その名前はブランチから来ます。ここにチームの規模を前提にしたものはありません。開発者が二人でも二十人でも設計は同じで、増えるのはアプリの一覧だけ、それはスイーパーが面倒を見ます。作って、ブランチを三つ同時に動かして、予想していなかった二か所で壊して、片付けるまでを午後のうちに済ませました。以下の出力はその実行から取ったものです。</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="#%e5%85%b1%e6%9c%89%e7%92%b0%e5%a2%83%e3%81%b2%e3%81%a8%e3%81%a4%e3%81%8c%e9%96%8b%e7%99%ba%e8%80%85%e4%b8%80%e4%ba%ba%e5%88%86%e3%82%88%e3%82%8a%e5%85%88%e3%81%ab%e4%bc%b8%e3%81%b3%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>共有 dev 環境には前提がひとつ焼き込まれています。全員が見たがる「現在のコードのバージョン」がひとつだという前提です。開発者が一人のとき、あるいはチームが変更の流れをひとつ歩調を合わせて進めているときは正しい。二人目の開発者が別のスケジュールで機能に着手した瞬間に正しくなくなり、端から端までのオーナーシップではそれがすべての機能で起きます。それ以降、環境が抱えるものはすべて取り合いになります。</p>
<ul>
<li><strong>デプロイされたコード。</strong> 最後にデプロイした人が勝ちます。残りの人の作業は再デプロイするまで見えず、再デプロイすれば前の人のものが消えます。</li>
<li><strong>データベースのスキーマ。</strong> 各開発者が自分の機能のデータモデルを持つとき、いちばん強く噛みつく部分です。最初のブランチがカラムを足す。一時間後にデプロイされた二つ目のブランチはそれを知らず、ORM が insert で失敗し始める。あるいは両方のブランチが <code>0042_*</code> マイグレーションを持っていて、共有データベースが誰のマイグレーション履歴とも一致しない状態になり、誰かがリセットして、三人目が午後いっぱいかけて用意したテストデータを消す。</li>
<li><strong>キャッシュ。</strong> あるブランチのシリアライザ変更が、別のブランチのコードが読むのと同じ Redis キーの下にキャッシュされる。そこから出てくるバグ報告は不可解で、一件ごとに午前中がつぶれます。</li>
<li><strong>サードパーティ連携。</strong> OAuth コールバックがひとつ、Webhook URL がひとつ、プッシュ証明書がひとつ。誰のブランチがデプロイされていようと、そのブランチがイベントを受け取ります。</li>
<li><strong>テスター。</strong> デプロイされているものしかテストできないので、機能が準備できた順ではなく、環境がたまたま確保された順に一度にひとつずつテストします。</li>
</ul>
<p>チームは予約システムで応じます。チャットのスレッド、ピン留めメッセージ、<code>/claim staging</code> ボット。それはロックです。作業を見せられる唯一の場所にかかったロックは、デリバリー速度にかかったロックです。開発者二人なら苛立つ程度です。三人なら良い週には耐えられます。四人目が加わるか、機能ひとつがデザイナーとの三日間のやり取りでステージングを握り続ければ、耐えられなくなります。</p>
<p>人が最初に手を伸ばす対処は三つ目の共有環境、「dev2」や「uat」です。数週間は稼げます。列が二車線になっただけです。</p>
<p>本当の対処は、dev もステージングもそもそも環境ではなかったと気づくことです。あれは <em>誰かのブランチの現在の状態に URL が付いたもの</em> でした。それがそうなら、あるべき数は今チームが気にしているブランチの数で、あるべき寿命はブランチの寿命です。</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%83%a2%e3%83%87%e3%83%ab-%e3%83%96%e3%83%a9%e3%83%b3%e3%83%81%e3%81%8c%e7%92%b0%e5%a2%83%e3%81%a7%e3%81%82%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>目標の状態はこうです。</p>
<table>
	<thead>
			<tr>
					<th>環境</th>
					<th>数</th>
					<th>作る主体</th>
					<th>消す主体</th>
					<th>寿命</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ブランチ環境 (かつての「dev」)</td>
					<td>アクティブなブランチごとに一つ</td>
					<td>ブランチの PR に付く <code>env</code> ラベル、その後は push のたびに。PR のないスパイクなら同じ <code>./scripts/env up</code> を手で</td>
					<td>ブランチ削除、そして毎晩のスイーパー</td>
					<td>数日</td>
			</tr>
			<tr>
					<td>ステージング</td>
					<td>リリース候補ごとに一つ</td>
					<td><code>main</code> からタグが切られたときの CI</td>
					<td>同じイメージが本番に昇格したときの CI</td>
					<td>数時間</td>
			</tr>
			<tr>
					<td>本番</td>
					<td>一つ</td>
					<td>あなたが、一度だけ</td>
					<td>誰も</td>
					<td>永遠</td>
			</tr>
	</tbody>
</table>
<p>この表の、誰が作り誰が壊すかをもう一度見てください。どれも git のイベントか時計です。git がコントロールプレーンです。存在すべき環境の集合は存在するブランチの集合からそのまま決まり、自動化の仕事は現実をそれに合わせておくことだけです。PR のラベルが作り、その後の push のたびに更新し、PR を閉じるかマージすると壊し、毎晩のジョブが前の三つの取りこぼしを片付けます。すべてのブランチが環境を得るわけでもありません。ラベルがゲートで、開発者のクリックひとつなので、バックアップの push やボットの依存関係更新は費用ゼロです。誰も環境を用意しません。チケットを受けた DevOps エンジニアでも、チャットで呼ぶボットでも、毎回プロンプトを打つ AI エージェントでもありません。十人目の開発者の最初のブランチも、一人目のブランチとまったく同じやり方で、PR にラベルが付いたという理由で環境を得ます。人に頼まなければならないなら、それはスケールしたのではなく、行列の場所が変わっただけです。</p>
<p>そしてこれを成り立たせる規則はひとつです。<strong>環境が触れるすべてのリソースは、ブランチにちなんで決定論的に名付ける。</strong> 開発者名でもチケット番号でもなく、誰かが開くまで存在しない PR 番号でもありません。ブランチです。開発者が実際に扱うのがブランチだから、そして名前が決定論的なら、同じブランチで <code>./scripts/env up</code> を二度目に実行したとき、新しい環境を作る代わりに既に作った環境を見つけられるからです。</p>
<p>名前は人のためのスラッグと、一意性のための短いハッシュです。</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">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code> が環境です。この文字列が Fly アプリ名、Postgres データベース名(ハイフンはアンダースコアに)、Redis のキー接頭辞、バケットのオブジェクト接頭辞、エラートラッカーの environment、そしてホスト名の左端のラベルになります。ブランチ名を知っている人なら誰でも計算できます。どこかを引く必要はありません。</p>
<p>その最後の一文が設計です。環境の台帳もコーディネーターもありません。<code>env up</code>、<code>env down</code>、ホスト名をルーティングする Cloudflare Worker、毎晩のスイーパーが、それぞれ独立にブランチから名前を計算し直し、構造的に一致します。特に Cloudflare は何も調整していません。Worker はホスト名から Fly アプリへの純粋関数で、環境が作られたことも消されたことも知りません。アプリがあればリクエストは届き、なければ Fly が自分のエラーで答えます。</p>
<p>ここから三つのことが出てきます。</p>
<p>第一に、「ステージング誰が使ってる」が問いでなくなります。<code>feature-payments-retr-3f9a1c-dev.example.com</code> があり、<code>fix-login-redirect-8b21e0-dev.example.com</code> があり、それぞれそのブランチにいる人のものです。</p>
<p>第二に、ステージングがゴミ捨て場ではなく本番の正直なリハーサルになります。本番に行くまさにそのイメージで、数分前に本番の形のスナップショットから複製したデータベースを相手に組まれます。そこで動けば、本番に残る変数は本番のデータだけです。</p>
<p>第三に、各開発者が共有リソースの root を持たずに力を得ます。ブランチ環境の爆発半径はそのブランチ環境です。四人目の新人が初日に自分のブランチに何をデプロイしようと、最悪の結果は自分のブランチが壊れることです。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="組織の Fly.io ダッシュボード: フィーチャーブランチのアプリが三つ(うち二つは既に suspended)、本番のアプリと共有プレビュー用 Postgres・Redis の隣に並ぶ"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>ブランチ三つが上がった状態の組織のアプリ一覧。三つのうち二つはデプロイの一分後には既に suspended で、共有プレビュー用の Postgres と Redis が横に並びます。下の行が本番のアプリです。</figcaption>
</figure>


<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="#%e7%89%a9%e7%90%86%e7%9a%84%e3%81%ab%e3%81%af%e5%85%b1%e6%9c%89%e8%ab%96%e7%90%86%e7%9a%84%e3%81%ab%e3%81%af%e5%88%86%e9%9b%a2" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>「ブランチごとに環境」の魅力的な版は丸ごとコピーです。Postgres クラスタをひとつずつ、Redis をひとつずつ、バケットをひとつずつ。きれいですが、悪い取引です。Fly Postgres クラスタはマシンひとつにボリュームひとつにリストア一回で、Redis はもうひとつのマシン。環境ごとに数分待ち、遊んでいるデータベース十数個分を払うことになります。スイーパーが片付ける種類も一つではなく四つになります。</p>
<p>代わりに、各共有サービスはプレビュー組織全体で一度だけ、小さな本番くらいのサイズで用意し、すべての環境が <code>$ENV</code> をキーにその一切れを受け取ります。壁に貼っておきたい表です。</p>
<table>
	<thead>
			<tr>
					<th>リソース</th>
					<th>共有されるもの</th>
					<th>環境ごとのもの</th>
					<th>強制する仕組み</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>コンピュート</td>
					<td>Fly 組織 <code>myapp-preview</code></td>
					<td>Fly アプリひとつ <code>myapp-$ENV</code>、マシンひとつ</td>
					<td>Fly: アプリが隔離の単位</td>
			</tr>
			<tr>
					<td>シークレット</td>
					<td>Fly のアプリ別シークレットストア</td>
					<td>アプリ自身のシークレット。<code>SECRET_KEY</code>、<code>ENCRYPTION_KEY</code>、<code>ENV_TOKEN</code> はコピーではなく環境ごとに<em>生成</em></td>
					<td>Fly: シークレットはアプリに属し、マシンは自分のものしか読めない</td>
			</tr>
			<tr>
					<td>Postgres</td>
					<td>Fly Postgres クラスタひとつ <code>myapp-preview-pg</code></td>
					<td>データベースひとつ <code>env_feature_payments_retr_3f9a1c</code>、<code>seed_template</code> から複製</td>
					<td>Postgres: データベースは固い境界、データベース間クエリは不可</td>
			</tr>
			<tr>
					<td>Redis</td>
					<td>共有の Redis ひとつ</td>
					<td>キャッシュと Channels レイヤーにキー接頭辞 <code>feature-payments-retr-3f9a1c:</code></td>
					<td>Django のキャッシュの <code>KEY_PREFIX</code>、チャネルレイヤーの <code>prefix</code></td>
			</tr>
			<tr>
					<td>ファイル</td>
					<td>S3 互換バケットひとつ <code>myapp-preview</code></td>
					<td>オブジェクト接頭辞 <code>feature-payments-retr-3f9a1c/</code></td>
					<td>ストレージバックエンドの <code>location</code>; 署名付き URL は元からキー単位</td>
			</tr>
			<tr>
					<td>ホスト名</td>
					<td>ゾーンに既にあるプロキシ済み <code>*.example.com</code> レコード</td>
					<td>左端のラベル <code>&lt;env&gt;-dev</code></td>
					<td><code>*-dev.example.com/*</code> の Cloudflare Worker ルート</td>
			</tr>
			<tr>
					<td>認証</td>
					<td>認証プロバイダーの dev プロジェクトひとつ</td>
					<td>なし。ユーザーの identity は意図的に共有 (後述)</td>
					<td>承認済みドメイン <code>example.com</code></td>
			</tr>
			<tr>
					<td>エラーとログ</td>
					<td>エラートラッカー、Fly のログ</td>
					<td>すべてに <code>environment=$ENV</code> タグ</td>
					<td>設定</td>
			</tr>
	</tbody>
</table>
<p>このうち二つには一言添えておきます。</p>
<p><strong>Postgres: スキーマではなくデータベース。</strong> クラスタひとつを切り分けるもうひとつの方法は、環境ごとにスキーマを持ち、接続ごとに <code>search_path</code> を設定することです。接続の <code>options</code> に <code>-c search_path=...</code> を押し込めば Django もやってくれますが、トランザクションモードのコネクションプーラーは指示しない限り起動時オプションを捨てますし、そうすると結局プール内のすべての接続がひとつの環境に属さなければなりません。「なぜ自分のクエリが違うテーブルに当たるのか」系のバグの温床で、環境ごとにデータベースならそもそも存在しない問題です。データベースは固い境界で、<code>CREATE DATABASE ... TEMPLATE</code> は数秒でひとつ作り、<code>DROP DATABASE</code> は何も残さず消します。ブランチ環境はプーラーを通さず 5433 番ポートで Postgres に直接つなぎます。本番のバックグラウンドワーカーと同じやり方です。プーラーは本番の同時実行数で元を取るもので、ブランチ環境がそれを見ることはありません。</p>
<p><strong>シークレット: 共有ではなく生成。</strong> dev 用シークレット一式を作って全環境にコピーすれば楽でしょう。やめましょう。<code>env up</code> スクリプトは環境ごとに新しい <code>SECRET_KEY</code>(セッション署名)、<code>ENCRYPTION_KEY</code>(保存時に暗号化するもの全部)、<code>ENV_TOKEN</code>(モバイルアプリが提示する bearer トークン、後述)を生成し、その Fly アプリのシークレットストアにだけ保存します。あるブランチのセッションクッキーは別のブランチでは無価値で、漏れたプレビュー用トークンが開くのはプレビューひとつだけで、開発者はわざわざ探さない限り値を見ることがありません。Fly だけでなく別のシークレットマネージャーにシークレットを置くなら、同じ規則が当てはまります。パスは <code>myapp/preview/$ENV/</code> で、スイーパーが環境と一緒にそのパスを消します。環境をまたいで<em>本当に</em>共有されるシークレットは共有インフラ自体の認証情報(Postgres の管理者 URL、Redis の URL、バケットのキー)だけで、それらはどの環境でもなく CI に住んでいます。</p>
<p>Redis の行がいちばん疑いやすいので、試しました。ひとつの環境の中からキャッシュキーを入れ、もうひとつから読み、共有 Redis のすべてのキーを列挙します。</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">$ fly ssh console -a myapp-feature-profile-tagl-fa8832 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; cache.set(&#39;who-am-i&#39;, &#39;tagline-branch&#39;, 600)\&#34;&#34;
</span></span><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-webs-dcb2e7 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; print(cache.get(&#39;who-am-i&#39;))\&#34;&#34;
</span></span><span class="line"><span class="cl">None
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ # every key in the shared Redis
</span></span><span class="line"><span class="cl">  feature-profile-tagl-fa8832:1:who-am-i
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
<p>同じ Redis、同じキー名で、もう一方の環境からは何も見えません。</p>
<p>この記事の残りは、あの八行に <code>$ENV</code> を刻印するスクリプトと、それを正直に保つ規則です。</p>

<h2 class="relative group"><code>./scripts/env up</code>
    <div id="scriptsenv-up" 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="#scriptsenv-up" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>開発者が自分のブランチから実行するスクリプトにすると、考え方の全体はこうなります。CI も同じスクリプトを実行します。勝手にずれていく CI 専用の経路はありません。ラップトップに必要なのは <code>fly</code> と <code>git</code> だけ。SQL は <code>fly ssh</code> 経由で Postgres アプリへ行き、環境の Redis キーとバケットのオブジェクトは消す前に環境自身のマシンが削除するので、ラップトップは gitignore された env ファイル以外に共有 Redis やバケットの認証情報を持ちません。要点になる行だけに絞ると、次のとおりです。</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/env up|down|name|url: one environment per branch on shared preview infrastructure</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">PG_APP</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_PG_APP</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview-pg</span><span class="si">}</span><span class="p">;</span> <span class="nv">DOMAIN</span><span class="o">=</span><span class="si">${</span><span class="nv">BASE_DOMAIN</span><span class="k">:-</span><span class="nv">example</span><span class="p">.com</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="si">${</span><span class="nv">BRANCH</span><span class="k">:-$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;main has staging, not a branch environment&#34;</span> &gt;<span class="p">&amp;</span>2<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="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span><span class="p">;</span> <span class="nv">slug</span><span class="o">=</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">ENV_OVERRIDE</span><span class="k">:-</span><span class="si">${</span><span class="nv">slug</span><span class="si">}</span><span class="p">-</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> shasum -a <span class="m">256</span> <span class="p">|</span> cut -c1-6<span class="k">)</span><span class="si">}</span>   <span class="c1"># the sweeper passes the name</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP</span><span class="o">=</span><span class="s2">&#34;myapp-</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">DB</span><span class="o">=</span><span class="s2">&#34;env_</span><span class="si">${</span><span class="nv">ENV</span><span class="p">//-/_</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">-dev.</span><span class="si">${</span><span class="nv">DOMAIN</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pg<span class="o">()</span> <span class="o">{</span> fly ssh console -a <span class="s2">&#34;</span><span class="nv">$PG_APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;psql postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@localhost:5433/postgres -At -c \&#34;</span><span class="nv">$1</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">exists<span class="o">()</span> <span class="o">{</span> fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -q <span class="s2">&#34;\&#34;Name\&#34;: *\&#34;</span><span class="nv">$APP</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">up<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;== </span><span class="nv">$branch</span><span class="s2"> -&gt; </span><span class="nv">$ENV</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  exists <span class="o">||</span> fly apps create <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span>                         <span class="c1"># idempotent</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>pg <span class="s2">&#34;SELECT 1 FROM pg_database WHERE datname=&#39;</span><span class="nv">$DB</span><span class="s2">&#39;&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">||</span> pg <span class="s2">&#34;CREATE DATABASE </span><span class="nv">$DB</span><span class="s2"> TEMPLATE seed_template&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">have</span><span class="o">=</span><span class="k">$(</span>fly secrets list -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4 <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  gen<span class="o">()</span> <span class="o">{</span> grep -qx <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$have</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">=</span><span class="k">$(</span>openssl rand -hex 32<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>   <span class="c1"># generated once, never rotated by a later `up`</span>
</span></span><span class="line"><span class="cl">  <span class="o">{</span> gen SECRET_KEY<span class="p">;</span> gen ENCRYPTION_KEY<span class="p">;</span> gen ENV_TOKEN
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;ENV_NAME=</span><span class="nv">$ENV</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;ALLOWED_HOSTS=</span><span class="nv">$HOST</span><span class="s2">,</span><span class="nv">$APP</span><span class="s2">.fly.dev&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;DATABASE_URL=postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@</span><span class="si">${</span><span class="nv">PG_APP</span><span class="si">}</span><span class="s2">.flycast:5432/</span><span class="si">${</span><span class="nv">DB</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... plus the shared Redis URL and bucket credentials</span>
</span></span><span class="line"><span class="cl">  <span class="o">}</span> <span class="p">|</span> fly secrets import -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --stage
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  fly deploy -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --config fly.preview.toml --remote-only --ha<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --build-arg <span class="nv">GIT_SHA</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">&#34;</span> --build-arg <span class="nv">GIT_BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">down<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^myapp-<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;refusing to destroy </span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<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">if</span> exists<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    fly machine start -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>                 <span class="c1"># it may be suspended</span>
</span></span><span class="line"><span class="cl">    fly ssh console -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;python manage.py teardown_env&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;warning: cleanup failed; the sweeper will catch it&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    fly apps destroy <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --yes
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  pg <span class="s2">&#34;DROP DATABASE IF EXISTS </span><span class="nv">$DB</span><span class="s2"> WITH (FORCE)&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in up<span class="o">)</span> up <span class="p">;;</span> down<span class="o">)</span> down <span class="p">;;</span> name<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ENV</span><span class="s2">&#34;</span> <span class="p">;;</span> url<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span></span></span></code></pre></div></div>
<p>デモでは push する代わりに自分のラップトップから走らせました。三つのブランチはリポジトリの履歴に残したくない使い捨てで、出力が流れるのを見ていたかったからです。チームで使うときは誰もこれを打ちません。後で出てくるラベルのワークフローがまさにこのスクリプトを走らせ、ここから先の出力は Actions のログに残るはずのものです。ツール自体を載せたブランチで最初に実行したときの出力です。</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">$ ./scripts/env up
</span></span><span class="line"><span class="cl">== feature/branch-environments -&gt; feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">New app created: myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">CREATE DATABASE
</span></span><span class="line"><span class="cl">Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
</span></span><span class="line"><span class="cl">==&gt; Building image
</span></span><span class="line"><span class="cl">image size: 139 MB
</span></span><span class="line"><span class="cl">✔ Machine 1a2b3c4d5e6f78 [app] update finished: success
</span></span><span class="line"><span class="cl">https://feature-branch-envir-c65cb4-dev.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s https://feature-branch-envir-c65cb4-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">{&#34;environment&#34;: &#34;feature-branch-envir-c65cb4&#34;, &#34;branch&#34;: &#34;feature/branch-environments&#34;, &#34;commit&#34;: &#34;a1b2c3d4&#34;, &#34;region&#34;: &#34;syd&#34;}</span></span></code></pre></div></div>
<p>コマンドから URL まで二分半ほどで、大半はイメージのビルドです。コミットをもうひとつ積んで同じブランチで二度目の <code>up</code> をすると、「New app created」も「CREATE DATABASE」も出ず、同じシークレット名をステージしつつ生成したものは作り直さず、リビルドしました。同じ URL、新しいコミット。</p>
<p>中の選択のいくつかは一文ずつ値します。</p>
<p><strong><code>fly.preview.toml</code> は <code>fly.toml</code> ではない。</strong> 本番は API、WebSocket サーバー、バックグラウンドワーカーを別々の Fly アプリとプロセスグループで動かし、それぞれが自分の指標でスケールするようにしていて、それがスタックの記事の主張のすべてです。ブランチ環境はスケールする必要がありません。マシンひとつであるべきです。だからプレビュー設定はプロセスがひとつです。コンテナのエントリーポイントにプレビュー用のモードを足し、マイグレーションを走らせてから WebSocket を有効にした ASGI サーバーを<em>ひとつ</em>起動します。Channels はもともとひとつの ASGI アプリケーションの中で HTTP と WebSocket の両方をルーティングするので、プロセスひとつでアプリ全体を配信します。そのモードでは同期ビューが thread-sensitive で、だいたい一度にリクエストひとつずつ動きます。本番で自分が捨てたまさにその取引ですが、ここでは正確に正しい取引です。ブランチをテストする人は誰も気づかないからです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># fly.preview.toml (the app name comes from `fly deploy -a`)</span>
</span></span><span class="line"><span class="cl"><span class="nx">app</span> <span class="p">=</span> <span class="s1">&#39;placeholder&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">primary_region</span> <span class="p">=</span> <span class="s1">&#39;syd&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">processes</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">app</span> <span class="p">=</span> <span class="s2">&#34;/app/entrypoint.sh preview&#34;</span>   <span class="c"># migrate, then one ASGI server for HTTP and WebSockets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">env</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DJANGO_SETTINGS_MODULE</span> <span class="p">=</span> <span class="s1">&#39;myapp.settings&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DEBUG</span> <span class="p">=</span> <span class="s1">&#39;False&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">internal_port</span> <span class="p">=</span> <span class="mi">8000</span>
</span></span><span class="line"><span class="cl">  <span class="nx">force_https</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">[[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;/healthz&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">headers</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="nx">Host</span> <span class="p">=</span> <span class="s1">&#39;127.0.0.1&#39;</span>               <span class="c"># the app&#39;s own hostname isn&#39;t known to a static file</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">size</span> <span class="p">=</span> <span class="s1">&#39;shared-cpu-1x&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;1gb&#39;</span></span></span></code></pre></div></div>
<p><strong><code>auto_stop_machines = &quot;suspend&quot;</code>、<code>min_machines_running = 0</code>。</strong> 昼食以降誰も開いていないブランチ環境の費用はゼロであるべきです。stop でなく suspend なら、30 秒のコールドスタートの代わりに <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >504 を追いかけて</a> たどり着いた 3 秒ほどの再開が得られ、ここでは 30 秒でも許容できます。デモ環境三つのうち二つは、デプロイ完了の一分後には <code>fly apps list</code> で既に <code>suspended</code> でした。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/apps/myapp-feature-profile-tagl-fa8832</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d2f15e465075b4eb.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="ブランチアプリひとつの Fly.io 概要: SYD の shared-cpu-1x マシンひとつ、プロセスグループひとつ、自前の *.fly.dev ホスト名"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_381dc63e1f9d24c0.webp"></span></div>
  <figcaption>Fly から見たブランチ環境ひとつ: マシンひとつ、プロセスグループひとつ、リージョンひとつ。スケールする価値のあるものは何もありません。</figcaption>
</figure>

<p><strong><code>DEBUG = False</code>、本番と同じ settings モジュール。</strong> ブランチ環境は公開 URL です。テストに関わるすべての面(本物の HTTPS、本物のクッキー、同じヘルスチェック)で本番のように振る舞い、誰かを傷つけうるすべての面で本番と違うべきです。プレビュー用の設定ファイルを別に用意する代わりに、差分は <code>env up</code> が設定するシークレットが運びます。サンドボックスの決済キー、認証プロバイダーの管理者認証情報なし、そして以下のすべての接頭辞を有効にする <code>ENV_NAME</code>。</p>
<p><strong>設定ひとつがすべてを切り分ける。</strong> <code>ENV_NAME</code> は設定に新しく入った唯一のつまみで、四か所に適用されます。</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="n">ENV_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>channels の分を忘れると、二つのブランチの WebSocket サーバーがグループ名を共有し、ある環境で送ったメッセージが別の環境に現れます。一日を食う種類のバグです。</p>
<p>五か所目は、後から共有 Redis を読んで初めて見つけました。504 の記事の起動時ロック、つまりイメージごとにマシン一台がマイグレーションを走らせる間ほかが待つ仕組みは、イメージ参照をキーにしていました。同じコミットから組まれたブランチ環境二つは同じイメージを持ちます。二つ目は一つ目の「done」マーカーを見つけて、マイグレーションされていない自分のデータベースへの自分のマイグレーションを飛ばしていたでしょう。今は環境名がキーに含まれ、そのキーは接頭辞の下に住んでいます。共有インフラの設計が生み続ける種類の問題です。暗黙のうちに「デプロイごと」だったものはすべて明示的に環境ごとにしなければならず、何が暗黙だったかは見るまで分かりません。</p>
<p><strong>データベースは空からマイグレーションするのではなく複製する。</strong> 次の節です。</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%83%87%e3%83%bc%e3%82%bf%e3%83%99%e3%83%bc%e3%82%b9-%e6%95%b0%e7%a7%92%e3%81%a7%e8%a4%87%e8%a3%bd%e3%81%95%e3%82%8c%e3%82%8b%e3%83%86%e3%83%b3%e3%83%97%e3%83%ac%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ブランチ環境のデータベースは三つのうちのどれかです。全員と共有。ここまで我々を連れてきたやり方です。空からマイグレーションしてフィクスチャでシード。決定論的ですが、フィクスチャが実物の形をしたデータに敵ったことはなく、いつも十八か月は古い。あるいは本番の形のスナップショットの複製。現実的な行数、フィクスチャが常に埋まっていると仮定していたカラムの現実的な null、空のテーブルでは一瞬で終わるが実物では四十分かかるマイグレーションが姿を現します。</p>
<p>三つ目を採り、二つ目は CI 専用のチェックとして残します。そして三つ目をブランチごとにやれるほど速くするのは、多くの人が存在を忘れている Postgres の機能です。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</span><span class="p">;</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="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code> はクラスタ内のファイルレベルのコピーです。数ギガバイトなら <code>pg_restore</code> にかかる数分ではなく数秒です。複製中のテンプレートには開いた接続があってはならず、<code>datallowconn = false</code> がそれを強制します。夜間ジョブが生きているテンプレートの上にリストアするのではなく <code>seed_raw</code> に組んで最後にリネームするのもそのためです。</p>
<p>デモではテンプレートを本番ダンプからではなく手で作りました。<code>fly proxy</code> でプレビュークラスタにトンネルを掘り、<code>manage.py migrate</code>、それからリポジトリ自前のシードコマンドで、小さいながら現実的なデータ一式を入れました。十九メガバイト。ブランチが三つ上がるにつれてクラスタにその複製が三つ現れ、それぞれ 19 MB、それぞれ一秒未満でした。</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">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>プレビュークラスタは非マネージドの Fly Postgres ノードひとつ、1 GB ボリューム付きの <code>shared-cpu-1x</code> で、月に二ドルほどです。ブランチ環境はプライベートネットワークの <code>.flycast</code> 経由でつなぎます。公開されているものは何もありません。</p>
<p>匿名化は省略できません。本番データが <code>-dev</code> ホスト名で届く瞬間、それは本番データであってはなりません。名前、メール、電話番号、住所、自由記述、決済参照、トークン、すべてリネームの前に上書きします。匿名化が失敗したら前夜のテンプレートが残り、誰かにメッセージが飛びます。小さな仕事で、このアプローチ全体をプライバシー質問票に答える人が受け入れられるものにする、まさにその仕事です。</p>
<p>エントリーポイントがすべてのイメージの初回起動で <code>migrate</code> を走らせるので、ブランチ自身のマイグレーションがテンプレートの上に適用されます。「自分のマイグレーションは本番の形のデータで動くか」の最初の正直なテストで、誰かが URL を開く前に起きます。</p>

<h2 class="relative group">ホスト名: ワイルドカードひとつ、Worker ひとつ、そしてドキュメントが教えてくれなかった二つのこと
    <div id="ホスト名-ワイルドカードひとつworker-ひとつそしてドキュメントが教えてくれなかった二つのこと" 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%9b%e3%82%b9%e3%83%88%e5%90%8d-%e3%83%af%e3%82%a4%e3%83%ab%e3%83%89%e3%82%ab%e3%83%bc%e3%83%89%e3%81%b2%e3%81%a8%e3%81%a4worker-%e3%81%b2%e3%81%a8%e3%81%a4%e3%81%9d%e3%81%97%e3%81%a6%e3%83%89%e3%82%ad%e3%83%a5%e3%83%a1%e3%83%b3%e3%83%88%e3%81%8c%e6%95%99%e3%81%88%e3%81%a6%e3%81%8f%e3%82%8c%e3%81%aa%e3%81%8b%e3%81%a3%e3%81%9f%e4%ba%8c%e3%81%a4%e3%81%ae%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>すべての環境には公開の HTTPS ホスト名が必要で、「公開」が要点です。スマホを持ったテスター、別のネットワークのデザイナー、VPN を入れないプロダクトマネージャー。Fly はアプリごとに <code>myapp-$ENV.fly.dev</code> を無料でくれて、それだけでも動きます。ただ <code>*.fly.dev</code> ホスト名には問題が三つあります。送るには不格好で、前に Cloudflare がいないので本番にかけている保護がひとつも効かず、自分のドメインに置きたければ環境ごとに DNS レコードひとつと証明書ひとつを作ることになり、スイーパーの片付けものがひとつ増えます。</p>
<p>それを全部なくす版が、ワイルドカードひとつと Cloudflare Worker ひとつです。計画は <code>*.dev.example.com</code> でした。二つのことに止められ、どちらも同じ計画を立てる前に知っておく価値があります。</p>
<p><strong>Universal SSL が覆うワイルドカードは一段だけ。</strong> Cloudflare の無料証明書は <code>example.com</code> と <code>*.example.com</code> に発行されます。<code>*.dev.example.com</code> は覆いません。二段目のワイルドカードには月 10 ドルの Advanced Certificate Manager が要ります。<code>feature-branch-envir-c65cb4.dev.example.com</code> への最初のリクエストは、自分のものが何ひとつ動く前に SSL ハンドシェイク失敗で死にました。対処は環境を最初のラベルに置くことです。<code>feature-branch-envir-c65cb4-dev.example.com</code>。既にある証明書が覆い、既にあるプロキシ済みの <code>*.example.com</code> レコードが覆います。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >でっち上げのサブドメインを歩き回るスキャナー</a>向けのシンクホールとして自分が置いたあのレコードです。だから環境に DNS は一切要りません。</p>
<p><strong>Redirect Rules は Workers より先に走る。</strong> ルートを置いたところ、すべての <code>-dev</code> ホスト名が Worker が動くことなく www のトップページへ 301 で答えました。シンクホールはゾーンレベルのリダイレクトルール「その他のサブドメインはすべて → www」で、Cloudflare のリダイレクトフェーズは Workers フェーズより前に実行されます。ルールに節がひとつ必要でした。<code>and not ends_with(http.host, &quot;-dev.example.com&quot;)</code>。デプロイスクリプトが <code>up</code> で足し <code>down</code> で外します。誰かが手で編集したルールこそ忘れられるものだからです。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/rules/redirect-rules/…/edit</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_38e5e373828b8373.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Cloudflare ダッシュボードのリダイレクトルール。式が -dev の例外で終わっている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_71d546da5b5668b6.webp"></span></div>
  <figcaption>スクリプトが手を入れた後のシンクホールリダイレクト。ゾーン配下のすべては引き続き www へ跳ねますが、<code>-dev.example.com</code> で終わるホスト名だけが Worker へ流れます。</figcaption>
</figure>

<p>DNS 側は元からあったあのレコードです。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/dns/records</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_2c615b9e52444abd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="ワイルドカードで絞ったゾーンの DNS レコード: 192.0.2.1 を指す、ゾーンのワイルドカードのプロキシ済み A レコードがひとつ"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_952422604a6c8304.webp"></span></div>
  <figcaption>関係する唯一の DNS レコードで、この記事より前からあります。プレースホルダーアドレスを指すプロキシ済みワイルドカード。Cloudflare が応答し、その先はルールと Workers 次第です。</figcaption>
</figure>

<p>その二つを片付ければ、ルーティングは <code>*-dev.example.com/*</code> の Worker ひとつです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// dev-router.js: &lt;env&gt;-dev.example.com -&gt; myapp-&lt;env&gt;.fly.dev
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DEV_SUFFIX</span> <span class="o">=</span> <span class="s2">&#34;-dev.example.com&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ENV_NAME</span> <span class="o">=</span> <span class="sr">/^[a-z0-9-]+-[0-9a-f]{6}$/</span><span class="p">;</span>   <span class="c1">// slug-hash, as scripts/env names them
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">endsWith</span><span class="p">(</span><span class="nx">DEV_SUFFIX</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">&#34;not a dev hostname&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">env</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nx">DEV_SUFFIX</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ENV_NAME</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">env</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="sb">`no such environment: </span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">=</span> <span class="sb">`myapp-</span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">.fly.dev`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">upstream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">upstream</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Forwarded-Host&#34;</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="s2">&#34;&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">upstream</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="nx">res</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Robots-Tag&#34;</span><span class="p">,</span> <span class="s2">&#34;noindex, nofollow&#34;</span><span class="p">);</span>   <span class="c1">// a half-finished branch is not for search engines
</span></span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Dev-Environment&#34;</span><span class="p">,</span> <span class="nx">env</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">out</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="p">};</span></span></span></code></pre></div></div>
<p>Cloudflare がワイルドカードで TLS を終端し、Worker がラベルから Fly ホスト名を計算し、マシンまでの区間は Fly 自身の <code>*.fly.dev</code> 証明書が覆います。WebSocket のアップグレードは <code>fetch</code> をそのまま通ります。そしてルートがゾーンにあるので、Cloudflare が本番にしてくれることはすべて使えます。WAF ルール、レート制限、ボット対策モード、そして Access。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/workers</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_9996f094fe4379cd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="ゾーンの Cloudflare Workers Routes ページ: dev-router Worker に結び付いたルート *-dev.example.com/* がひとつ"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_78420fc686a1e6e3.webp"></span></div>
  <figcaption>環境の Cloudflare 側のすべて: ルートパターンひとつ、Worker ひとつ。このダッシュボードのどこにも環境名は出てきません。</figcaption>
</figure>

<p>ブランチ三つを同時に上げ、Worker ひとつ、各ホスト名が答えたもの。</p>
<table>
	<thead>
			<tr>
					<th>ホスト名</th>
					<th>HTTP</th>
					<th>ルーティング先</th>
					<th><code>branch</code></th>
					<th><code>commit</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>feature-branch-envir-c65cb4-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-branch-envir-c65cb4.fly.dev</code></td>
					<td><code>feature/branch-environments</code></td>
					<td><code>a1b2c3d4</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-tagl-fa8832-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-tagl-fa8832.fly.dev</code></td>
					<td><code>feature/profile-tagline</code></td>
					<td><code>e5f6a7b8</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-webs-dcb2e7-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-webs-dcb2e7.fly.dev</code></td>
					<td><code>feature/profile-website</code></td>
					<td><code>c9d0e1f2</code></td>
			</tr>
			<tr>
					<td><code>not-an-env-dev.example.com</code></td>
					<td>404</td>
					<td>なし。Worker がラベルを拒否</td>
					<td></td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>すべての 200 には Worker が付けた <code>X-Robots-Tag: noindex, nofollow</code> と <code>X-Dev-Environment: &lt;env&gt;</code> が添えられていました。404 は Fly に尋ねる前に Worker 自身が返したものです。</p>
<p>Cloudflare Access は「公開」を「届くべき人に届く」に変える装置です。<code>*-dev.example.com</code> にアプリケーションひとつ、「@example.com のメールなら誰でも」というポリシーひとつで、ブラウザからの訪問者はログインページを一度見て二度と気にしなくなります。ラップトップのデザイナー、PM、テスターはそれで済みます。デモには入れませんでした。環境は一時間、<code>noindex</code> を付けてシードデータ以外何もない状態で上がっていました。</p>
<p>モバイルアプリは覆えません。ネイティブアプリは Access のログインリダイレクトを完了できないからです。道は二つ。Access はログインを迂回するサービストークン(<code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code> のヘッダー対)をサポートしていて、モバイルアプリの dev メニューが dev ホスト名用の一対を持てます。あるいはもっと単純に、<code>/api/</code> に Access の迂回を入れ、ログイン済みのユーザーか環境の <code>ENV_TOKEN</code>、つまり <code>env up</code> がまさにこの環境のために生成したあのトークンのどちらもないリクエストを Django が拒否するようにします。どちらでも、モバイル側はベース URL とトークンの二つのフィールドを持つ dev メニュー画面で、テスターが既にインストールしているアプリがそのブランチと話すようになります。新しいビルドは要りません。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/" >OTA(無線)更新チャネル</a>はアプリがビルドされたときのものを指したままで、API のベース URL はビルド時ではなく実行時の設定であり、元々そうあるべきでした。</p>
<p>認証は意図的に共有する唯一の共有サービスです。identity は環境ごとのものではありません。テスターはどのブランチでも同じアカウントでログインしたい。承認済みドメインに <code>example.com</code> を入れた、認証プロバイダーの dev プロジェクトひとつがすべての <code>-dev</code> ホスト名を覆い、すべての環境がそれに対して ID トークンを検証します。各環境のデータベースのユーザーテーブルはテンプレートのユーザーのコピーなので、テスターのアカウントはテンプレートにあったすべての場所にあります。</p>

<h2 class="relative group">GitHub Actions でプレビュー環境を自動化する: イベントが入り、環境が出る
    <div id="github-actions-でプレビュー環境を自動化する-イベントが入り環境が出る" 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="#github-actions-%e3%81%a7%e3%83%97%e3%83%ac%e3%83%93%e3%83%a5%e3%83%bc%e7%92%b0%e5%a2%83%e3%82%92%e8%87%aa%e5%8b%95%e5%8c%96%e3%81%99%e3%82%8b-%e3%82%a4%e3%83%99%e3%83%b3%e3%83%88%e3%81%8c%e5%85%a5%e3%82%8a%e7%92%b0%e5%a2%83%e3%81%8c%e5%87%ba%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>上のすべてを手でやるのは、やるのが自分だけなら構いません。二人目の開発者がいる時点で、誰かが覚えていたからではなく git で何かが起きたから起きなければなりません。自動化の全体はワークフロー四つ、リポジトリ設定ひとつ、ルールセットひとつ、GitHub environment ひとつで、そのどれもが git イベントを、開発者が手で走らせるのと同じ <code>scripts/env</code> の呼び出しに対応させます。勝手にずれる CI 専用の経路はありません。</p>
<table>
	<thead>
			<tr>
					<th>Git イベント</th>
					<th>走るもの</th>
					<th>すること</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PR に <code>env</code> ラベルが付く</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>そのブランチの環境を作り、PR に URL を投稿</td>
			</tr>
			<tr>
					<td>ラベル付き PR のブランチへの push</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>その場で更新</td>
			</tr>
			<tr>
					<td>ラベル削除、PR のクローズまたはマージ</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>環境が自分の一切れを片付けて消える</td>
			</tr>
			<tr>
					<td>ブランチ削除</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>手で立ち上げて PR のなかったブランチのための保険; リポジトリ設定 <em>delete branch on merge</em> がマージ時にもこれを発火させる</td>
			</tr>
			<tr>
					<td>それ以外のブランチへの push</td>
					<td>何もしない</td>
					<td>バックアップの push やスパイクは、誰かが見たいと言うまで費用ゼロ</td>
			</tr>
			<tr>
					<td><code>main</code> への pull request</td>
					<td><code>migrations-check.yml</code></td>
					<td><code>main</code> をマージした後のマイグレーショングラフの葉がひとつか; 空からチェーン全体が適用できるか</td>
			</tr>
			<tr>
					<td><code>v*</code> タグの push</td>
					<td><code>release.yml</code></td>
					<td>タグからステージング環境; レビュアーの承認が同じイメージを本番に昇格しステージングを消す</td>
			</tr>
			<tr>
					<td>毎晩 02:00</td>
					<td><code>sweep-envs.yml</code> → <code>scripts/sweep-envs</code></td>
					<td>ブランチが消えたか一週間放置のものをすべて消す</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">ラベル、push、delete のワークフロー
    <div id="ラベルpushdelete-のワークフロー" 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%a9%e3%83%99%e3%83%abpushdelete-%e3%81%ae%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc" aria-label="アンカー">#</a>
    </span>
    
</h3>
<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"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </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">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</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">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</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="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</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">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>ゲートは <code>env</code> ラベルで、ここは自分がいちばん強く主張したい部分です。ゲートがないと、すべてのブランチがアプリひとつとデータベースの複製ひとつの費用を払います。ラップトップのバックアップ代わりに push したブランチ、明日 squash されるスパイク、ボットが午前三時に開いた依存関係の更新まで。だから push だけでは何も起きず、PR を開くだけでも何も起きません。PR に <code>env</code> を付けると環境が立ち上がり、ラベルが付いている間は push のたびに更新され、ラベルを外すか PR を閉じるかマージすると落ちます。オプトインは依然として GitHub のイベントであって人への依頼ではなく、誰かが見るかどうかを知っている唯一の人がするクリックひとつです。固定コメントがユーザーインターフェースです。PR にラベルを付けると、誰にでも送れる URL 付きのコメントが現れ、push のたびに積み上がるのではなくその場で更新されます。PR ができる前に立ち上げたいブランチは <code>workflow_dispatch</code> が、残りはラップトップからの <code>./scripts/env up</code> が受け持ちます。</p>
<p>知っておく価値のあるゲートが他に二つあります。ブランチ名のフィルタ (<code>branches: ['feature/**', 'fix/**']</code>) は PR が要りませんが、すべての機能ブランチが費用を払います。「ドラフトでないすべての PR」はいちばん自動的で、チームが PR を遅く、準備できたときにだけ開くなら正しい選択です。自分がラベルを選んだのは、費用の判断を文脈を知っている人に置けることと、何も閉じずに取り消せることからです。気に入っている副作用がひとつ。フォークからの <code>pull_request</code> の実行にはシークレットが渡らないので、フォークは環境を立ち上げられません。</p>
<p><code>concurrency</code> は見た目以上に重要です。ひとつのブランチへの立て続けの push は、先行のデプロイと競走させるのではなく取り消します。同じアプリへの <code>fly deploy</code> が二つ同時に走ると、片方が負ける結果になります。</p>
<p>PR があったものは、マージされたかどうかにかかわらず <code>closed</code> が片付けを受け持ちます。<code>delete</code> イベントは <code>workflow_dispatch</code> で立ち上げて結局 PR ができなかったブランチのための保険で、ブランチが実際に削除されたときにしか発火しません。それはリポジトリ設定 <em>Automatically delete head branches</em> で、既定ではオフです。オンにしておけば、Merge を押すことが PR を閉じてブランチを削除するので、どちらのイベント単独でも <code>env down</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">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code> がこのファイルで危険な行です。アプリを作り消せるので、<code>$APP</code> がブランチ環境以外の何かになった瞬間に本番を消せてしまいます。ガードは二つで、両方残します。トークンを別の Fly 組織に限定し、本番組織を文字どおり見えなくすること(デモでは新しい組織に課金情報を付ける手間を省くため本番組織を使い、二つ目のガードだけに頼りました)。そして <code>down()</code> が <code>slug-hash</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%96%e3%83%a9%e3%83%b3%e3%83%81%e7%92%b0%e5%a2%83%e3%81%8c%e8%87%aa%e5%88%86%e3%81%a7%e3%81%af%e3%81%a7%e3%81%8d%e3%81%aa%e3%81%84%e3%83%81%e3%82%a7%e3%83%83%e3%82%af" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ブランチは自分のマイグレーションしか見ません。だから肝心のチェックは pull request で <code>main</code> をマージした状態に対して走り、<code>main</code> が要求する唯一のステータスチェックです。</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"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><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">postgres</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">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</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 class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</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">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</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">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</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">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>三十秒。<code>0042</code> / <code>0042</code> の衝突をリリース当日の驚きから二人目の開発者の PR の赤いチェックに変えるのがこれです。下のマイグレーションの節で実際に発火するところを示します。</p>

<h3 class="relative group"><code>main</code> の規則
    <div id="main-の規則" 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="#main-%e3%81%ae%e8%a6%8f%e5%89%87" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ブランチが一か月生きたり、誰かが <code>main</code> に直接 push したりすれば、このどれも持ちません。規則は保護というより、環境を小さく、マイグレーションを最新に保つためのものです。</p>
<ul>
<li><strong><code>main</code> は保護され、常にデプロイ可能。</strong> <em>現在の <code>main</code> に対して</em> <code>migrations</code> チェックが緑の PR でだけマージします(strict なステータスチェックなので、先週緑だった PR は <code>main</code> が動けば再実行されます)。線形履歴、force-push 禁止、削除禁止。</li>
<li><strong>機能ブランチは短い。</strong> 二週間開いているブランチは <code>main</code> より二週間遅れた環境と、他人のものと衝突する確率が二週間分高いマイグレーションを持ちます。一か月がかりの機能はフラグの裏で小分けに <code>main</code> へマージします。長生きするのはフラグであってブランチではありません。</li>
<li><strong>ブランチひとつ、環境ひとつ、データベースひとつ。</strong> 既に命名が強制しています。</li>
<li><strong>本番に行くのはリリースタグだけ。</strong> イメージはタグから一度組まれ、ステージングと本番はそのイメージを動かします。リビルドはしません。</li>
<li><strong><code>migrations/</code> に触れる PR は残りの二人のどちらかがレビューする。</strong> 後述。</li>
</ul>
<p>ルールセットとしては、<code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.json</code> で一度だけ適用します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</span> <span class="p">}</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="p">}</span></span></span></code></pre></div></div>
<p>正直なひっかかりがひとつ。このリポジトリは GitHub Free プランのプライベートリポジトリで、Free ではブランチ保護とルールセットはパブリックリポジトリでしか使えません。API は <code>403 Upgrade to GitHub Pro or make this repository public</code> と答えます。<code>delete_branch_on_merge</code> 設定、environment、ワークフローは Free でもすべて動きます。だからプライベートな Free リポジトリの小さなチームにとって、上のルールセットは慣習です。<code>migrations</code> ワークフローは変わらず走り、変わらず赤くなりますが、マージボタンを灰色にするものはありません。月 4 ドルの判断で、自分なら二人目がマージできるようになった瞬間にそうします。</p>
<p><code>develop</code> ブランチも <code>release/*</code> ブランチもありません。Git-flow は統合が高くつき、まとめてやらざるを得ないという前提で設計されました。ブランチ環境が統合を安くするので、まとめる必要が消えます。</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%82%b9%e3%83%86%e3%83%bc%e3%82%b8%e3%83%b3%e3%82%b0%e3%81%af%e3%82%bf%e3%82%b0%e3%81%ae%e7%92%b0%e5%a2%83%e3%81%a7%e6%9c%ac%e7%95%aa%e3%81%af%e6%98%87%e6%a0%bc" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ステージングは仕組み全体を再利用します。<code>main</code> の <code>v*</code> タグがブランチ名 <code>staging/v1.42.0</code> で <code>scripts/env up</code> を走らせ、命名がそれを <code>staging-v1-42-0-&lt;hash&gt;</code> に変えます。同じテンプレート複製のデータベース、同じ放置で suspend されるマシン、同じホスト名の体系。違うのはその後です。</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"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p><code>production</code> ジョブは必須レビュアーのいる GitHub environment を待ちます。誰かがステージングを見て Approve を押すと、本番はステージングが動かした<em>同じイメージ</em>を参照で受け取ります。リビルドではありません。最後のステップがステージングを消すのは、役目を終えたからです。誰も承認しなければリリース候補は却下されたということで、その「ブランチ」(<code>staging/v1.42.0</code> は origin に存在したことがない)が生存チェックに落ちたときにスイーパーがステージングを片付けます。スイーパーが確認できる理由なしには何も存在できません。</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="#%e6%af%8e%e6%99%a9%e3%81%ae%e3%82%b9%e3%82%a4%e3%83%bc%e3%83%91%e3%83%bc" aria-label="アンカー">#</a>
    </span>
    
</h3>
<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"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code> なのは、スイーパーが Actions が既定でチェックアウトする一コミットではなく、すべてのリモートブランチとその最終コミット日時を必要とするからです。<code>workflow_dispatch</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="#%e9%96%8b%e7%99%ba%e8%80%85%e3%81%8c%e5%ae%9f%e9%9a%9b%e3%81%ab%e3%81%99%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ブランチを push し、PR を開き、誰かに見てほしくなったら <code>env</code> ラベルを付ける。リストはそれだけです。三分以内に PR に URL が現れ、push のたびに更新され、PR がマージされるか閉じられれば消えます。チームメイトと衝突していれば、マイグレーションチェックが誰より先に教えてくれます。取り合うステージングがないので誰もステージングを取り合わず、片付けがマージそのものなので誰も何も片付けません。</p>
<p>そして、これを組んだ人がその後ブランチごとにやることは、何もありません。共有の Postgres、Redis、バケットは一度だけ用意し、シークレットは Actions に一度だけ入れ、そこから先に繰り返す作業といえば月曜にスイーパーのログを読むことくらいです。チケットの列も、呼ぶボットも、プロンプトを打つ相手もありません。</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="#%e7%89%87%e4%bb%98%e3%81%91%e3%82%92%e4%bf%a1%e7%94%a8%e3%81%97%e3%81%aa%e3%81%84%e7%89%87%e4%bb%98%e3%81%91" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>env down</code> が通常の場合で、面白いのは<em>誰が</em>掃除するかです。Redis のキーとバケットのオブジェクトは共有サービスのもので、<code>down</code> を走らせるラップトップはどちらの認証情報も要りません。環境自身のマシンが既にそれを持ち、自分の接頭辞を既に知っているからです。だから <code>down</code> はマシンが suspend されていれば起こし、そこで小さな後片付け用のコマンドを走らせ、その後で初めてアプリを消しデータベースをドロップします。環境が自分で後片付けをします。</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">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>530 は調整についての要点をもう一度示しています。Worker は変わらずホスト名をルーティングし、Fly には何もなく、誰も Cloudflare に知らせる必要がありませんでした。</p>
<p>この後片付けのコマンドは <code>ENV_NAME</code> なしでは実行を拒否します。接頭辞が空なら共有バケットの全消去になってしまうからです。そのガードこそ、これがシェルスクリプトの三行ではなくアプリ内のコマンドとして存在する理由のすべてです。</p>
<p>それでも環境は漏れます。GitHub Actions の障害中にブランチが削除される。開発者が試作のためにラップトップから <code>env up</code> を走らせ、push はしないまま。<code>fly apps destroy</code> が一時的に失敗して <code>|| true</code> が飲み込む。三か月後にアプリが四十個あり、誰かに何が起きたのかと問わせる請求書が来ます。</p>
<p>だから一つ目を信用しない二つ目の仕組みがあります。上の表の毎晩のスイーパーです。すべての <code>myapp-*</code> アプリを列挙し、それぞれがどのブランチのものか割り出し、ブランチが消えたか七日間 push のないものをすべて消します。一週間放置されたブランチに生きた URL は要らず、次の push が二分で作り直します。ハッシュは逆算できないので、スイーパーは生きているすべてのブランチの環境名を計算し、その集合にないアプリをすべて消してから、背後にアプリのない <code>env_*</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>デモのブランチはどれも push されたことがなかったので (上のラップトップでの実行です)、スイーパーから見ればすべて消えたものでした。誰かがラップトップから立ち上げて結局 push しなかったスパイクが一週間後に置かれる状態が、まさにこれです。残りの二つを、tagline 環境が書き込んだ Redis キーも含めて解体しました。</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">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>(小さく噛まれたことがひとつ。macOS には連想配列のない bash 3 が付いてきます。スイーパーの最初の版はそれを使っていて、自分のラップトップで <code>declare -A</code> で死にました。上の版は改行区切りのリストを使い、どこでも動きます。)</p>
<p>費用は楽しい部分です。suspend されたマシンは rootfs 分だけで、月に数セント。データベースは既に払っているボリューム上の 19 MB。Redis は従量課金プランで、十万コマンドあたり 20 セント、ブランチテストの規模ではゼロに丸まります。バケットの接頭辞は保存した分だけ。共有 Postgres ノードが唯一の固定費で、月に二ドルほど。アクティブなブランチ二十本で常時稼働の <code>shared-cpu-1x</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%83%96%e3%83%a9%e3%83%b3%e3%83%81%e3%82%92%e3%81%be%e3%81%9f%e3%81%90%e3%83%9e%e3%82%a4%e3%82%b0%e3%83%ac%e3%83%bc%e3%82%b7%e3%83%a7%e3%83%b3%e8%ad%a6%e5%91%8a%e3%81%97%e3%81%a6%e3%81%8f%e3%82%8c%e3%82%8b%e5%85%b1%e6%9c%89%e3%83%87%e3%83%bc%e3%82%bf%e3%83%99%e3%83%bc%e3%82%b9%e3%81%aa%e3%81%97%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>各開発者が機能を端から端まで持つなら、すべてのブランチにマイグレーションが入っています。だからこの節が重要な節です。</p>
<p>共有環境の世界を離れるときに人が見落とすことがあります。全員がひとつの dev データベースにデプロイしていたとき、マイグレーションの衝突は即座に痛く表面化しましたが、ともかく<em>表面化しました</em>。マイグレーションがひとつ走り、次のデプロイが失敗し、誰かがその日の午後に直した。ブランチごとにデータベースがあると、各マイグレーションは自分の環境で完璧に動き、二つは二つ目が <code>main</code> にマージされたときに初めて出会います。不注意にやれば、衝突は安かった dev から安くないリリースへ移ります。</p>
<p>規則は四つ、すべて CI で強制できるので誰も覚えておく必要がありません。</p>
<p><strong>1. <code>main</code> をマージした状態でマイグレーショングラフの葉が二つなら CI が失敗する。</strong> <code>makemigrations --check</code> はモデルとマイグレーションの食い違いを捕まえますが、肝心の衝突は二つのブランチが両方とも <code>0042_*</code> を足していることです。ブランチ単体ではなく、<code>main</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">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#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">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>これは自分でわざと起こしたものです。デモブランチ三つのうち二つ、<code>feature/profile-tagline</code> と <code>feature/profile-website</code> が、それぞれマイグレーションひとつでユーザープロフィールのモデルにフィールドをひとつ足し、各環境は上がる途中で自分のものを適用しました。新しいカラムはあるべき場所にだけありました。</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">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>両方とも <code>0042_*</code> です。どちらのブランチも相手のものを見られないので、どちらの環境も問題があるとは教えてくれません。片方をもう片方にマージすると、チェックが教えてくれます。</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">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>二人目の作者が自分のブランチで、自分の環境で、誰の邪魔もせずにその <code>--merge</code> を走らせ、次の <code>env up</code> が自分の複製の上に <code>0043</code> を適用します。(この場合、git が先にテキストの競合も報告しました。両方のフィールドがモデルファイルの同じ行に足されていたからです。現実的で、テキストの競合が解消されるまでマイグレーションの衝突を隠すことは知っておく価値があります。)</p>
<p><strong>2. マイグレーションは前のリリースのコードと後方互換である。</strong> デプロイ中には新しいスキーマと古いコードが共存する窓があり、マシンがロールバックすればその逆もあります。カラムは nullable かデフォルト付きで足し、一度にリネームせず、現在のリリースがまだ読むものは決してドロップしない。足して、デプロイして、バックフィルして、コードを切り替えて、後のリリースでドロップする。expand and contract です。ここで<em>より</em>重要になる理由は、使い捨て環境がデプロイを頻繁にし、その窓の中にいる時間が増えるからです。タグごとのステージングがそれをリハーサルする場所です。</p>
<p><strong>3. CI は空のデータベースにゼロからのマイグレーションもする。</strong> テンプレートの複製は「自分のマイグレーションが現実的なデータに適用できるか」をテストします。空からの実行は「チェーン全体がまだ無から動くか」をテストし、前のデータマイグレーションが何かを埋めたと仮定していたマイグレーションを捕まえます。三十秒で、さもなければ次の新しいラップトップを待つ類のバグを捕まえます。</p>
<p><strong>4. スキーマ変更には二つ目の目が付く。</strong> 小さなチームに <code>CODEOWNERS</code> ファイルは要りませんが、規則は要ります。<code>migrations/</code> に触れる PR はマージ前にチームの誰かがレビューし、レビュアーの仕事は SQL を確認することではありません。自分かチームの他の誰かがこの二週間で同じテーブルに触れているかを知っていて、そう言うことです。開発者が十数人を超えるとその知識はひとりの頭に収まらなくなるので、そこで <code>migrations/</code> への <code>CODEOWNERS</code> の記述が元を取ります。どんなツールもこれの代わりにはなりません。機械的なものはツールが全部捕まえてくれるので二分の仕事で、古い共有環境の世界で残す価値のある唯一の部分です。二人が衝突しかけていると気づく瞬間。</p>

<h2 class="relative group">バージョニング、URL が何を動かしているか語るように
    <div id="バージョニングurl-が何を動かしているか語るように" 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%90%e3%83%bc%e3%82%b8%e3%83%a7%e3%83%8b%e3%83%b3%e3%82%b0url-%e3%81%8c%e4%bd%95%e3%82%92%e5%8b%95%e3%81%8b%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e3%81%8b%e8%aa%9e%e3%82%8b%e3%82%88%e3%81%86%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>共有環境がひとつのとき、「ステージングに何が載ってる」はチャットで飛び交う質問でした。複数になれば環境の属性でなければならず、さもなければすべてのバグ報告が考古学から始まります。</p>
<ul>
<li><strong>すべてのイメージがコミット SHA を持ち、アプリがそれを公開する。</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code> がイメージに環境変数として刻み、<code>/api/version/</code> が環境名とリージョンと一緒に返します(上の出力参照)。「feature-profile-tagl-fa8832 で壊れてる」に対して誰もが最初にするのは、思っているコミットを動かしているかの確認です。最初のデモ環境はツールがコミットされる一コミット前の作業ツリーからデプロイされていて、エンドポイントがそう言いました。返ってきたのは期待した SHA ではなく、ひとつ前のコミットでした。</li>
<li><strong><code>ENVIRONMENT=$ENV</code> がすべてのログ行とエラートラッカーの environment タグにある。</strong> ブランチのエラーが本番のエラー追跡を汚さず、ワンクリックで絞り込めます。</li>
<li><strong>ブランチは SHA を、リリースはタグを持つ。</strong> <code>feature-payments-retr-3f9a1c</code> にバージョン番号はありません。コミットがあります。ステージングと本番には <code>v1.42.0</code> があります。</li>
<li><strong>モバイルアプリは URL ではなく最小 API バージョンを持つ。</strong> dev メニューの URL フィールドがテスターが実機をブランチに向ける方法で、API スキーマバージョンを載せたバージョン確認用のエンドポイントが、アプリが理解できる相手と話しているかを知る方法です。</li>
</ul>

<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%93%e3%82%8c%e3%81%a7%e8%a7%a3%e6%b1%ba%e3%81%97%e3%81%aa%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>一緒でなければ意味をなさない二つの機能は解決しません。ブランチ環境はブランチひとつを見せます。答えは両方をフラグの裏でマージして <code>main</code> を見ることで、<code>main</code> を追いかける常設環境ひとつ(<code>main-dev.example.com</code>、マージのたびに再デプロイ)でできます。自分が残す唯一の長生きする非本番環境で、誰もそこ<em>へ</em>デプロイはしません。次のリリースが何になるかを見るためのものです。</p>
<p>登録済みコールバックを求めつつワイルドカードを受け付けないプロバイダーは解決しません。私が使っている認証プロバイダーは親ドメインを受け付けるので、<code>example.com</code> がすべての <code>-dev</code> ブランチホスト名を覆います。一部の OAuth や決済プロバイダーはそうではなく、その場合は Worker が最後に確保した環境へルーティングする共有ホスト名をひとつ置きます。小さなものにかかった小さなロックで、すべてにかかったロックよりずっとましです。</p>
<p>マイグレーションの人間側は解決しません。ツールは機械的な衝突を捕まえます。同じ二週間に二人が同じ概念を両立しない形でモデリングするのは捕まえられず、それを直す唯一の方法は、どちらかが気づくマイグレーションレビューです。</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%82%e3%81%aa%e3%81%9f%e3%81%ae%e7%92%b0%e5%a2%83%e3%81%a7%e7%a2%ba%e3%81%8b%e3%82%81%e3%81%a6%e3%81%bf%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>共有の dev やステージングがあり、それを予約するチャットのスレッドがあるなら:</p>
<ol>
<li>完成したブランチが、作者以外の誰かに動いているところを見てもらえるまでどれだけ待ちますか? 一時間を超えるなら、そのロックの代金をデリバリー時間で払っていて、小さなチームならその大きな割合が待っています。</li>
<li>共有データベースはどれくらいの頻度でリセットされ、そのとき誰が作業を失いますか?</li>
<li>新しい開発者が初日に、誰にも何も頼まずに自分のブランチが動く公開 URL を手に入れられますか? 頼まなければならないものが何であれ、それが最初に自動化するものです。</li>
<li>新しい環境は現実的なデータから始まりますか? 空から始まるなら、最初に捕まえ損ねるバグは、空のテーブルでは平気で実物のテーブルでは四十分かかるマイグレーションです。</li>
<li>非本番のホスティングに、全員が忘れて一か月経ってもまだ存在するものがありますか? あるなら、何より先にスイーパーを書いてください。漏れは初日から始まります。</li>
</ol>
<p>チャットのあの質問が消えるのは、人が尋ねるのをやめたからではなく、答えがいつも「使ってますよ、自分のを」だからです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>ChatGPTに引用されるには：小さなブログでやったこととログの数字</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</guid>
      <pubDate>Wed, 16 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>SEO</category>
      <category>Crawlers</category>
      <category>LLM</category>
      <category>Hugo</category>
      <category>Cloudflare</category>
      <category>content strategy</category>
      <category>Writing</category>
      <description>ChatGPTなどのAIアシスタントに引用されるために（GEO）このブログでやったこと。通すべきボット、ブラウザ不要のHTML、著者入りの構造化データ、誰も読んでいなかったllms.txt。</description>
      <content:encoded><![CDATA[<p>ChatGPTなどのAIアシスタントに引用されたいなら、まず回答時にページを取りに来る検索用のボットを通すことです。学習用のクローラーとは別物で、片方をブロックしてももう片方には影響しません。そのうえで、JavaScriptを動かさなくても読めるHTMLを、著者名入りの構造化データ付きで返します。llms.txtは、私のログを見る限り誰も取りに来ていません。いちばん大事なのは、各セクションが最初の一文で主張を言い、段落を単独で引用できるように書くことです。</p>
<p>このブログへの検索流入はここ2年ほど横ばいで、動くことを期待するのはやめました。そこで代わりに、訪問者がどこから来ているのか、その中にAIアシスタントはいるのかを見に行きました。いることはいます。かろうじて、ですが。2026年9月16日までの12か月で、アクセス解析が数えたこのサイトのセッションは5,372。そのうちAIアシスタント経由は27で、内訳はchatgpt.comから12、gemini.google.comから9、Perplexityから6、claude.aiからは0でした。0.5%です。ただ、ChatGPTから来た人は良い訪問者でした。平均の滞在時間は5分。直接アクセスの27秒、Google経由の3分と比べると長い。すでに答えを読んだうえで、詳細を知りたくて来ているからです。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="12か月間の参照元別セッション数。AIアシスタントを強調表示"
    width="2400"
    height="1164"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_d8da7a3f1d1c9190.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_b3dda7d23abf271d.webp"></span><figcaption>参照元別のセッション数、12か月分。青がAIアシスタントで、5,372セッション中27。</figcaption></figure>
</p>
<p>そこで、小さなブログがそういう回答の情報源に選ばれるにはどうすればいいのか、少し時間をかけて調べました。この記事はこのサイトでやったことと、それについてログが何を言っているかの話です。数字があるところは数字を出し、推測でしかないところはそう書きます。最初の草稿にあった3つの文は、実際に調べてみたら間違っていました。その訂正はそのまま残してあります。</p>

<h2 class="relative group">ボットは2種類あって、引用してくれるのは片方だけ
    <div id="ボットは2種類あって引用してくれるのは片方だけ" 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%9c%e3%83%83%e3%83%88%e3%81%af2%e7%a8%ae%e9%a1%9e%e3%81%82%e3%81%a3%e3%81%a6%e5%bc%95%e7%94%a8%e3%81%97%e3%81%a6%e3%81%8f%e3%82%8c%e3%82%8b%e3%81%ae%e3%81%af%e7%89%87%e6%96%b9%e3%81%a0%e3%81%91" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>まずはっきりさせておきたいのは、「AIボット」と呼ばれるものは仕事の違う2種類で、ユーザーエージェントも別、片方をブロックしてももう片方には何の影響もない、ということです。</p>
<p>学習用クローラーは、次のモデルを作るためにページを大量に持っていきます。OpenAIのはGPTBot、AnthropicのはClaudeBot、GoogleのはGoogle-Extendedというトークンで、AppleのはApplebot-Extendedで制御し、Common CrawlのCCBotは公開データセットの大半の元になっています。持っていかれたものは重みになって終わりで、こちらへのリンクは残りません。これらをブロックする理屈はあって、私も馬鹿げた話だとは思いません。</p>
<p>もう一方が検索取得用のボットです。誰かがChatGPTやClaudeやPerplexityに質問すると、アシスタントは検索を走らせ、その場で数ページを取得して読み、出典付きで答えを書きます。その取得はOAI-SearchBotとChatGPT-User、Claude-SearchBotとClaude-User、PerplexityBotとPerplexity-Userとして届きます。GoogleのAI Overviewsは普通のGooglebotを使うので、専用のスイッチはそもそも存在しません。訪問者を送り返してくれるのはこちらのボットです。引用されるのが目的なら最適化する相手はこちらで、GPTBotをブロックしてもChatGPTの検索結果には良くも悪くも何の影響もない、というのは知っておく価値があります。</p>
<p>このサイトのrobots.txtはHugoのデフォルトで、すべて許可です：</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">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /
</span></span><span class="line"><span class="cl">Sitemap: https://jared.lynskey.co.nz/sitemap.xml</span></span></code></pre></div></div>
<p>分けることも考えました。取得用のボットは通し、学習用は締め出す。でも、読まれることがそもそもの目的のブログでやる価値はないと判断しました。もしやるなら下のようになります。あえて載せるのは、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >Cloudflare が Googlebot に空のページを返していた</a>と同じ教訓があるからです。一覧に書くのはボット名であって、会社名ではありません。「OpenAI」と書ける行は存在しないのです。</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">User-agent: GPTBot
</span></span><span class="line"><span class="cl">User-agent: ClaudeBot
</span></span><span class="line"><span class="cl">User-agent: Google-Extended
</span></span><span class="line"><span class="cl">User-agent: Applebot-Extended
</span></span><span class="line"><span class="cl">User-agent: CCBot
</span></span><span class="line"><span class="cl">Disallow: /
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /</span></span></code></pre></div></div>
<p>サイトがCloudflareの後ろにあるなら、確認しておくことが1つあります。2025年半ば以降、新しいゾーンはデフォルトでAIクローラーをブロックしますし、ボット設定には既存のゾーンにワンクリックで同じことをするトグルがあります。これは両方の種類をブロックします。このブログは先週Cloudflare Pagesに移したので、私の確認リストにも入っていました。「AIボット」というのが締め出すべきものに聞こえて有効にしたのなら、引用も一緒に切ってしまっています。その後Cloudflareは、管理しているrobots.txtに<code>Content-Signal</code>の行を書き込むようになりました。<code>search</code>、<code>ai-input</code>、<code>ai-train</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="#%e5%ae%9f%e9%9a%9b%e3%81%ab%e3%83%89%e3%82%a2%e3%82%92%e5%8f%a9%e3%81%84%e3%81%a6%e3%81%84%e3%82%8b%e3%81%ae%e3%81%af%e8%aa%b0%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Cloudflareへの移行には副作用がありました。初めて、すべてのリクエストをユーザーエージェント別に見られるようになったのです。2026年9月13日から16日までの4日間に来たものを、偽物を除いたうえで並べたのが下の表です。偽物についてはあとで書きます。</p>
<table>
	<thead>
			<tr>
					<th>ボット</th>
					<th>リクエスト数</th>
					<th>やったこと</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ClaudeBot（学習用）</td>
					<td>1,777</td>
					<td>2日間で1,380の異なるページ、5言語すべて、加えてrobots.txtを37回</td>
			</tr>
			<tr>
					<td>meta-externalagent（学習用）</td>
					<td>1,748</td>
					<td>同じことを、1日遅れで</td>
			</tr>
			<tr>
					<td>OAI-SearchBot（ChatGPTの検索インデックス）</td>
					<td>59</td>
					<td>5言語すべてのページと、robots.txtを16回</td>
			</tr>
			<tr>
					<td>ChatGPT-User（回答中に取得）</td>
					<td>20</td>
					<td>コールドスタートの日本語記事、自前のアップデートサーバーの記事、トップページ</td>
			</tr>
			<tr>
					<td>Claude-User（回答中に取得）</td>
					<td>8</td>
					<td>アップデートサーバーの記事、contactページ、トップページ</td>
			</tr>
			<tr>
					<td>PerplexityBot</td>
					<td>2</td>
					<td>iOSデバッグの記事、英語版とベトナム語版</td>
			</tr>
			<tr>
					<td>Claude-SearchBot、Perplexity-User</td>
					<td>0</td>
					<td></td>
			</tr>
			<tr>
					<td>Googlebot / Bingbot / Applebot / CCBot</td>
					<td>168 / 71 / 45 / 28</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>目を引くことが2つあります。学習用クローラーは、取得用ボットが数十ページを読むあいだに、すべてを2回読んでいます。そして取得用ボットは手当たり次第に取っているわけではありません。ChatGPT-UserもClaude-Userも、このサイトでいちばん具体的な記事であるアップデートサーバーの記事を取りに来ていて、コールドスタートの記事は日本語版が取得されています。</p>
<p>次に偽物の話です。GPTBotを名乗るリクエスト20件のうち18件は、OpenAIが公開しているIPレンジの外から来ていて、<code>/.env</code>、<code>/.git/HEAD</code>、<code>/firebase-adminsdk.json</code>を要求していました。「ChatGPT-User」を名乗る36件のうち15件も同じ類のものです。「PerplexityBot」のトラフィックは大半が<code>/id_ed25519</code>を欲しがっていました。ユーザーエージェントは誰でも送れるただの文字列で、脆弱性スキャナーはAIボットの名前なら素通しされることに気づいています。OpenAI、Anthropic、PerplexityはいずれもIPレンジを公開しています。ボットを特別扱いするなら、通すにせよ止めるにせよ、名前ではなくアドレスを確認すること。上の20、59、8は、正しいアドレスから来たリクエストの数です。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="OpenAIのユーザーエージェントを名乗るリクエストを、OpenAI公開のIPレンジ由来かどうかで分けたもの"
    width="2400"
    height="722"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_638e8c6286f5c91f.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_11d44262308d7c6c.webp"></span><figcaption>同じユーザーエージェント文字列を送信元アドレスで分けたもの。「GPTBot」を名乗るトラフィックの大半はOpenAIから来ていません。</figcaption></figure>
</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%83%9a%e3%83%bc%e3%82%b8%e3%81%af%e3%83%96%e3%83%a9%e3%82%a6%e3%82%b6%e3%81%aa%e3%81%97%e3%81%a7%e8%aa%ad%e3%82%81%e3%81%aa%e3%81%91%e3%82%8c%e3%81%b0%e3%81%aa%e3%82%89%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>取得用のボットは回答時にタイムアウト付きで取りに来て、ほとんどはJavaScriptを実行しません。バンドルが読み込まれるまで空のdivでしかないページは、彼らには読めないページです。Hugoは完成したHTMLを書き出すので、このブログはそれをただで手に入れていて、もっと凝ったものに移そうと思ったことが一度もない最大の理由がこれです。私が運営しているマーケットプレイスはその正反対で、React NativeのWebアプリです。あそこでクローラーに本物のドキュメントを渡すには動的レンダリングの分岐が必要で、そのあとキャッシュのバグがそれを静かに台無しにしました。上のCloudflareの記事がその話です。</p>
<p>速度も同じように効きます。そしてここでは、ログに間違いを正されました。Cloudflareの後ろにある静的サイトは、いちばん近いエッジから配信されるものだと思い込んでいました。そうではありませんでした。このブログはCloudflare Pagesにあって、HTMLはキャッシュされずに出ていき、ソウルからだとロサンゼルスから0.5秒ほどで返ってきます。数秒のタイムアウトを持つボットにはそれでも十分ですが、私が言い張っていたであろう数十ミリ秒ではありませんし、それが分かったのは測ったからです。6ページから答えを組み立てているアシスタントは、3秒かかるページを落とします。落としたとは教えてくれません。</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%ae%b5%e8%90%bd%e3%82%92%e3%81%9d%e3%81%ae%e3%81%be%e3%81%be%e5%88%87%e3%82%8a%e5%87%ba%e3%81%9b%e3%82%8b%e3%82%88%e3%81%86%e3%81%ab%e6%9b%b8%e3%81%8f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>こうしたシステムのページの使い方は、読者の読み方とは違います。ページをチャンクに分け、質問に対して各チャンクを採点し、点の高いものを引用します。800語かけて結論に向かっていく記事は、どのチャンクでも点が低い。各セクションが最初の文で主張を述べてから裏付ける記事は、質問に合ったセクションで点を取ります。</p>
<p>これは、どんな技術的なことよりも、ここでの書き方を変えました。フロントマターのdescriptionは今、読ませるためのフックではなく、記事が何を言っているかの要約です。アシスタントが見せるのはdescriptionだけ、ということがよくあるからです。セクションの見出しは、そのセクションの結論を書く。数字はグラフではなく、単位と一緒に文の中に入れる。日付は書き出す。1年後にページを読むモデルにとって「先週」は何の意味もないからです。そして、1本の記事は1つのことについて書く。</p>
<p>もう半分は、引用する価値のある何かを持っていることで、ここはデータが割れています。ログを見ると、回答時のボットが取得するのは具体的な記事です。コールドスタート、アップデートサーバー、iOSのデバッグ。Naverのクローラーの名前はYetiで、DaumのはDaumoa。私のホスティング先の再起動は504が出る30秒の窓。CloudflareのオンザフライのBrotli圧縮は、私のバンドルではgzipより悪い数字だった。こういう事実は書かれている場所が少ないので、それを必要とする質問はここに着地します。ところが、アシスタントからクリックして来る人の大半が着地するのは、2023年と2024年のマネジメントについての記事で、それぞれ1、2セッションずつ。具体的な記事は機械に読まれ、一般的な記事はクリックされる。これにきれいな説明は持っていませんし、でっち上げるよりは、そのまま報告するほうがいい。効くところがあるとすれば、それでもやはり、今週自分が突き止めた、検索しても見つからなかった具体的なことを書き残すことです。機械が実際に読んでいるのはそれだと、ログが示しているからです。</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="#%e8%aa%b0%e3%81%8c%e6%9b%b8%e3%81%84%e3%81%9f%e3%81%8b%e3%82%92%e8%a8%80%e3%81%84%e5%90%8c%e3%81%98%e3%81%93%e3%81%a8%e3%82%92%e8%a8%80%e3%81%84%e7%b6%9a%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ページを引用するかどうか決めているアシスタントがやっているのは、注意深い読者がやることと似ています。誰がいつ書いたのか、他に見つけたものすべてと矛盾しないか。このサイトでその仕組みを担っているのは、ほとんどテーマです。すべての記事に、著者、公開日、更新日を持つArticleの構造化データが付いていて、著者は同じ公開プロフィールへのリンクを持つPersonです。テンプレートの数行ですが、それが「あるページ」と「経歴のある名前の付いた人物によるページ」の違いです。</p>
<p>テーマがやってくれないのは一貫性です。aboutページ、llms.txt、各種の公開プロフィール、すべての記事の先頭にある1行は、同じ人物を同じ職歴で説明しています。何かが変わればすべて変える。そうしないと、忘れた1つが間違っているものになります。細かい話に聞こえますが、あるページではエンジニアリングマネージャーで別のページではフリーランス、というのはまさに、モデルが言葉を濁したり、別の人を選んだりする種類の矛盾です。</p>
<p>更新日も正直でなければなりません。内容が変わったときに変わり、それ以外では触りません。新しく見せるために日付を上げるのは、一度は効いて、そのあとドメイン全体が割り引かれる類のことです。</p>

<h2 class="relative group">llms.txtとは何か、そして誰も読んでいないという事実
    <div id="llmstxtとは何かそして誰も読んでいないという事実" 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="#llmstxt%e3%81%a8%e3%81%af%e4%bd%95%e3%81%8b%e3%81%9d%e3%81%97%e3%81%a6%e8%aa%b0%e3%82%82%e8%aa%ad%e3%82%93%e3%81%a7%e3%81%84%e3%81%aa%e3%81%84%e3%81%a8%e3%81%84%e3%81%86%e4%ba%8b%e5%ae%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>llms.txtは2024年9月に提案されたもので、サイトのルートに置くMarkdownファイルに、サイトの要約と厳選したリンクの一覧を書いて言語モデルに渡す、というものです。今年の2月に追加しました。私が誰かについての段落が1つ、そのあとマネジメント、インフラ、モバイル、コミュニティのセクションがあって、記事ごとに1行。タイトル、URL、そして記事の結論を述べた1文です。急いでいる人のために書いた目次のように読めます。</p>
<p>但し書きというのは、誰も読んでいない、ということです。手元にある4日分のログで<code>/llms.txt</code>へのリクエストは、自分のものだけでした。Common Crawlは2026年8月のクロールでこのサイトの76ページを取り込んでいますが、このファイルは一度もありません。主要なアシスタントで読むと表明しているところもない。つまり、ここの何かが引用される理由がこれだということはなく、その根拠で誰かに勧めるのはやめておきます。それでも残しているのには2つ理由があります。かかったのは1時間で、記事ごとに「この記事は実際に何を結論づけているか」を1文で書く作業のほうがファイルそのものより価値がありました。どの記事が何も結論づけていないかが分かったからです。それに、このサイトが何のためにあるかを機械に伝える唯一のページでもあり、必要にならなくても持っておきたい。手で書いていて忘れるので、その後ずれてきていて、それがHugoの出力フォーマットとしてフロントマターから生成すべきだという論拠になっています。やることリストには入っています。</p>

<h2 class="relative group">フィード、サイトマップ、5つの言語
    <div id="フィードサイトマップ5つの言語" 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%95%e3%82%a3%e3%83%bc%e3%83%89%e3%82%b5%e3%82%a4%e3%83%88%e3%83%9e%e3%83%83%e3%83%975%e3%81%a4%e3%81%ae%e8%a8%80%e8%aa%9e" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>地味な配管も相変わらず効きます。サイトマップは、クローラーが新しい記事を公開当日に見つける手段です。RSSは、記事が変わったことを知る手段。どちらもHugoのデフォルトで、考えたことがありません。ClaudeBotは2日間でサイトマップを36回読んでいて、1,380ページをあれだけ速く見つけられたのはそのためです。</p>
<p>言語は、重要だと思っていなかった部分です。ここにあるものはすべて英語、日本語、韓国語、簡体字中国語、ベトナム語で、同じ記事だとクローラーに分かるようalternateリンクが付いています。クローラーはこれを5つのサイトとして扱います。ClaudeBotのクロールは5言語にほぼ均等に割れていて、Common Crawlが8月に取り込んだ73の記事ページのうち、英語版は4つだけでした。アシスタントは聞かれた言語で答え、その言語の情報源に寄るので、コールドスタートについての韓国語の質問は、英語ページをその場で翻訳したものではなく韓国語のページから答えられますし、ChatGPTが実際に取得したのは日本語のページでした。条件は、翻訳がその言語で書かれたもののように読めることです。今年の初めにネイティブスピーカーのチェックを入れてやった見直しは人間の読者のためでしたが、ベトナム語のページを珍しいものではなく情報源にしているのもそれです。</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%b8%ac%e3%82%8c%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>推測をやめてみたら、思っていたより多くのことが測れました。Cloudflareの無料プランでは、ユーザーエージェントとパス別のリクエストログが1クエリにつき1日分見られて、ボットの表はそこから来ています。リファラを見るには有料プランが要ります。リファラはアクセス解析ツールにあって、ダッシュボードを目を細めて眺めるのではなくレポートAPIで引き出したのが、冒頭の数字です。2つを合わせると全体の形が見えます。4日間で、取得用ボットはOpenAIとAnthropicのアドレスから90ページほどを取得しました。この1か月でChatGPTから来た人は2人。たくさん読まれ、ときどき引用され、めったにクリックされない。ページを使ったけれどクリックされなかった回答を見る方法は、相変わらず私の知るかぎりありません。いちばん役に立つ確認は今でも手作業です。記事が答えている質問をアシスタントに聞いて、誰のページを引用するか見る。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="13か月間の月別AIアシスタント経由セッション数。傾向なし"
    width="2400"
    height="968"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_7d401a84f177509c.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_5c308d89ad110ef7.webp"></span><figcaption>AIアシスタント経由の訪問を月別に。傾向と呼べるものはありません。</figcaption></figure>
</p>
<p>この中から1つだけ残すなら、書き方のルールです。robots.txtと構造化データとllms.txtは半日の作業で、終われば終わりです。各セクションが主張を最初に言うように書くこと、一般的なことではなく具体的なことを書くことは習慣で、ページに引用する価値があるかどうかを決めるのはその習慣です。2つ目は、主張する前に測ること。機械に信頼されるというのは、結局のところ人に信頼されるのとよく似ています。自分が誰かを言い、知っていることを言い、毎回同じように言う。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>フロントエンドとバックエンドを別々にデプロイする：APIを再デプロイせずにWebをリリース</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-14-web-releases-without-redeploying/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-14-web-releases-without-redeploying/</guid>
      <pubDate>Mon, 14 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>Deployment</category>
      <category>Object Storage</category>
      <description>フロントエンドとバックエンドを別々にデプロイする方法です。ビルドをバージョン付きでオブジェクトストレージに置き、Djangoが読むポインタを切り替え、CloudflareのキャッシュをAPIでパージします。</description>
      <content:encoded><![CDATA[<p>フロントエンドとバックエンドを別々にデプロイするために、WebのビルドをAPIのDockerイメージにコピーするのをやめました。今はスクリプトがフロントエンドをビルドし、バージョン付きプレフィックスでオブジェクトストレージにアップロードして、ポインタのオブジェクトを1つ書き換えます。Djangoはリクエストごとにそのポインタを読み、書かれているバージョンを配信します。最後にCloudflareのキャッシュをAPIでパージすれば、CSSの修正は1分ほどで、何も再起動せずに出せます。</p>
<p>少し前まで、私が作っているマーケットプレイスアプリのWeb側への変更はすべてDjango APIの変更と同じ道を通って出ていました。Dockerイメージをビルドし、プッシュし、Fly.io上のマシン全台にローリングで展開する。Webフロントエンド（モバイルアプリのWebエクスポートと、静的サイトジェネレーターがビルドするマーケティングページ）はAPIと同じイメージにコピーされていたので、他に出しようがなかったのです。CSSの修正1つに7分ほどかかり、11台のマシンが再起動しました。8月のある日は、これを6回やりました。</p>
<p>この記事では、それを何に置き換えたかを順に追います。デプロイスクリプトがフロントエンドの2つのツリーをビルドし、バージョン付きプレフィックスでオブジェクトストレージにアップロードし、ポインタのオブジェクトを1つ書き換え、全マシンが新しいバージョンを配信するまで待ってから、CloudflareのキャッシュをAPI経由でパージします。反対側ではDjangoがリクエストごとにポインタを読み、そこに書かれたバージョンを配信します。APIはまったく再起動しません。</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%bb%a5%e5%89%8d%e3%81%ae%e3%82%84%e3%82%8a%e6%96%b9%e3%81%ae%e4%bd%95%e3%81%8c%e5%95%8f%e9%a1%8c%e3%81%a0%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>いちばん大きなコストは再起動でした。11台がローリングするということはコールドブートが11回で、共有CPUではDjangoが立ち上がるのに13秒ほど、そのうち7秒はDjangoのimportだけに使われます。本物の訪問者にそのコストを払わせないよう、デプロイスクリプトの最後に全マシンを1回ずつ叩くウォームアップの工程を置いていましたが、そのせいでWebのリリースは毎回、いちばん遅いマシンを待って終わっていました。 そのコールドブートがFly.ioでどれだけ高くつくかは、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ゼロスケールと、再起動のたびに起きる30秒の障害</a>に書きました。要するに再起動のたびに訪問者が504を受け取りうる窓が開くので、何も再起動しないデプロイ経路はWebのリリースからその窓を丸ごとなくしてくれます。</p>
<p>2つ目の問題は、静的サイトの出力が分かれていたことです。HTMLはイメージに入るのに、RSSフィードと検索インデックスは直接バケットに行っていました。同じソースからビルドされてはいるものの同じ瞬間ではないので、食い違うことがありえて、食い違うまで誰も気づきません。</p>
<p>3つ目は習慣の話です。リリース1回に7分と再起動がかかると、小さなリリースをしなくなります。修正を3つ4つ溜めてからまとめて出すようになり、それこそが1行の変更が壊れた何かと一緒に出ていくパターンです。</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%83%90%e3%82%b1%e3%83%83%e3%83%88%e3%81%ae%e4%b8%ad%e3%81%ae%e9%85%8d%e7%bd%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>すべてS3互換バケットの<code>web/</code>プレフィックス以下にあります。</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">web/current                              ← a text file containing one version string
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/manifest.json ← every file in this version
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/app/...       ← the app&#39;s web export
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/site/...      ← the static site&#39;s output
</span></span><span class="line"><span class="cl">web/9f8e7d6-20260912093012/...           ← the previous version, still there</span></span></code></pre></div></div>
<p>バージョンは短いgit SHAとUTCのタイムスタンプなので、どのコミットがいつ上がったかがそのまま読めます。リリースが本番になるとき変わるのは<code>web/current</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%83%87%e3%83%97%e3%83%ad%e3%82%a4%e3%82%b9%e3%82%af%e3%83%aa%e3%83%97%e3%83%88%e3%81%8c%e3%82%84%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>全体は1本のスクリプトで、リポジトリのルートから実行します。順番に：</p>
<p><strong>1. 汚れたツリーを拒否する。</strong> 最初に<code>git status --porcelain</code>を実行し、何か出てきたら終了します。ディスクにあるものがそのままアップロードされるのですが、以前のマーケティングサイト用のデプロイスクリプトがその形でコミットしていない文言を出してしまったことがありました。分かってやる場合のために上書き用のフラグがありますが、使ったことはありません。その後<code>git rev-parse --short HEAD</code>と現在時刻からバージョン文字列を作ります。</p>
<p><strong>2. 2つのツリーをビルドする。</strong> アプリは本番のAPIとWebSocketのURLを埋め込んでWebエクスポートを実行し、そのあとにBrotliの事前圧縮が続きます。事前圧縮は説明しておく価値があります。Cloudflareはオンザフライでもブロトリ圧縮してくれますが品質レベルが低く、このバンドルではgzipより悪い結果でした（1.66MBに対して2.04MB）。ビルド時に品質11で一度だけ圧縮すると1.28MBになるので、スクリプトは1KBを超えるJS・CSS・JSONファイルごとに<code>.br</code>の双子を隣に書き出します。マーケティングサイトは静的サイトジェネレーターをminify有効で実行します。</p>
<p><strong>3. アップロードする。</strong> 2つのビルドディレクトリを走査し、全ファイルを16スレッドで<code>web/&lt;version&gt;/app/...</code>と<code>web/&lt;version&gt;/site/...</code>にアップロードします。各オブジェクトにはファイル名（先に<code>.br</code>を外したもの）から推定した<code>Content-Type</code>が付き、Brotliの双子には<code>Content-Encoding: br</code>も付きます。全ファイルが上がって初めて、相対パスのリスト2つを持つJSONである<code>manifest.json</code>を書きます。この順番が重要です。Django側はマニフェストのないバージョンを存在しないものとして扱うので、途中までしか上がっていないバージョンが現行になることはありえません。今は数百ファイルで、アップロードは6秒ほどです。</p>
<p><strong>4. ポインタを書き換える。</strong> <code>put_object</code>1回で<code>web/current</code>にバージョン文字列を書きます。これがリリースです。</p>
<p><strong>5. マシンを待つ。</strong> 各APIマシンはポインタを5秒キャッシュするので、書き換え直後の一瞬は一部がまだ古いバージョンです。スクリプトはAPIのバージョン確認用エンドポイントを0.5秒ごとにポーリングし、新しいバージョンを返す連続回答を数えます。Flyは全マシンに負荷分散するので、一致する回答が16回連続（マシン数より多い）で出れば、全台がポインタを読み直したと判断できます。古い回答が1つでも来るとカウントはリセットされます。90秒で諦めて手で確認するよう促します。最初の本番の切り替えが30秒強かかったので、上限はそれより下げていません。</p>
<p><strong>6. Cloudflareをパージする。</strong> API呼び出し1回で、後述します。失敗したら警告を出して先へ進みます。</p>
<p><strong>7. チームのチャットに投稿する。</strong> ランタイムのエラー通知と同じWebhookに、バージョンと実行者を1行で送ります。エラーがまとまって出たとき、何が変わった直後かと照らし合わせられるようにです。ロールバックは琥珀色で投稿されます。</p>
<p>最初から最後までで1分強、そのほとんどはWebエクスポートです。</p>

<h2 class="relative group">Django側
    <div id="django側" 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="#django%e5%81%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>フロントエンドを配信していたのはもともとDjangoです。API以外のURLにはすべて、そのルートに対応するWebエクスポートのプリレンダー済みページを返し、マーケティング・プライバシーポリシー・サポートのページは静的サイトの出力から来ます。これらのビューは以前はディスク上のファイルを開いていました。今はバケットのことを知っている小さな層を1つ経由して読みます。</p>
<p>リクエストごとに解決するのは3つです。現在のバージョン（<code>web/current</code>から読み、5秒キャッシュ）、そのバージョンのマニフェスト（そのバージョンが現在である間キャッシュ）、そしてファイル本体（ファイルの中身を持つ小さなインメモリのLRUなので、ページのHTMLとメインバンドルはメモリに残ります）。404が安く済むのはマニフェストのおかげです。パスが一覧になければ、バケットに聞かずに「ない」と答えます。クライアントがBrotliを受け付けるなら<code>.br</code>の双子ファイルを代わりに読んでエンコーディングヘッダを付けます。アセットはファイル名に内容のハッシュが付いているので、1年間のimmutableなキャッシュヘッダ付きで出ていきます。ルートとプリレンダー済みページの対応表のように、ファイル一覧から作るものもバージョンごとにキャッシュされるので、新しいデプロイが出ると最初のリクエストで作り直され、それより前には作られません。</p>
<p>それから、デプロイスクリプトがポーリングするエンドポイントがあります。</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="k">def</span> <span class="nf">web_version</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># bundle: the layer that reads web/current and the manifest</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;source&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">source</span><span class="p">(),</span> <span class="s2">&#34;version&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">version</span><span class="p">()})</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Cache-Control&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;no-store&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">response</span></span></span></code></pre></div></div>
<p>このエンドポイントは本番では<code>bucket:&lt;version&gt;</code>を、イメージから配信しているときは<code>filesystem</code>を返します。Flyのマシンはそれぞれ自分について答えるので、十数回叩けば全体が収束したか分かります。何かおかしいときに、どのマシンが何を本番だと思っているかを確かめる一番手っ取り早い方法でもあります。</p>

<h2 class="relative group">CloudflareのキャッシュをAPIでパージする
    <div id="cloudflareのキャッシュを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="#cloudflare%e3%81%ae%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%82%92api%e3%81%a7%e3%83%91%e3%83%bc%e3%82%b8%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>サイトのHTMLはCloudflareのエッジで5分間キャッシュされます。パージしないと、デプロイはマシン上では動いているのに、ほとんどの訪問者は最大5分間古いページを受け取り続けることになり、これでは意味がありません。</p>
<p>パージはAPI呼び出し1回です。</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="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="sa">f</span><span class="s2">&#34;https://api.cloudflare.com/client/v4/zones/</span><span class="si">{</span><span class="n">zone</span><span class="si">}</span><span class="s2">/purge_cache&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span><span class="o">=</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;hosts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;example.com&#34;</span><span class="p">,</span> <span class="s2">&#34;www.example.com&#34;</span><span class="p">]})</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Authorization&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Bearer </span><span class="si">{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
<p>ページを全部列挙しなくて済むように、URLではなくホスト単位でパージしています。アセットはそもそもパージ不要です。ファイル名に内容のハッシュが付いているので、新しいビルドは新しいファイル名を指し、古いものは参照されなくなるだけです。更新が必要なのは、そのファイル名を参照しているHTMLだけです。</p>
<p>スクリプトがパージの <em>前に</em> マシンを待つのは意図的です。先にパージすると、Cloudflareがまだ古いバージョンのマシンからキャッシュを埋め直してしまい、そのページがさらに5分間エッジに居座ることになります。</p>
<p>パージが失敗した場合、スクリプトは警告を出してそのまま完了します。キャッシュは5分以内に勝手に切れるので、最悪でもリリースが遅いだけで、壊れはしません。最後の工程でデプロイが中断されるよりはそのほうがいいと思っています。</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%e7%95%aa%e3%81%ab%e3%81%99%e3%82%8b%e5%89%8d%e3%81%ab%e3%83%93%e3%83%ab%e3%83%89%e3%82%92%e7%a2%ba%e8%aa%8d%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>書き換えなしのモードは、ステップ3までやって止まります。バージョンはバケットにあるけれど、何も配信していない状態です。中身を見るときは、マシン1台に環境変数を1つ設定します。<code>web/current</code>が何と言おうと、そのマシンはそのビルドに固定されます。問題なければロールバックのコマンドで現行にします。名前はロールバックですが実際は「<code>current</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%83%ad%e3%83%bc%e3%83%ab%e3%83%90%e3%83%83%e3%82%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>古いバージョンはバケットに残っているので、ロールバックは同じコマンドを前のバージョンで実行するだけで、一覧表示のコマンドで何があるかが新しい順に見られます。前に進むデプロイと同じ待機とパージをします。まだ使う機会はありません。今のところ古いバージョンを消す仕組みはなく、小さいですし、ないよりあるほうがいいと思っています。</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="#%e5%a4%89%e3%82%8f%e3%81%a3%e3%81%a6%e3%81%84%e3%81%aa%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>APIは今も専用のデプロイスクリプトで以前のやり方でデプロイしていますし、そうあるべきです。状態を持っていて、マイグレーションが走る場所ですから。Dockerfileも今まで通り2つのフロントエンドのビルドをイメージにコピーしています。これは意図的です。バケットの設定がない場合（ローカル開発やテストスイート）やバケットが読めない場合、配信の層はディスク上のそのディレクトリに倒れるので、ストレージ障害が起きても訪問者にはエラーページではなく最後のイメージと一緒に出たフロントエンドが届きます。バケットを一時的に外す必要があれば、環境変数1つで本番にその挙動を強制できます。</p>
<p>フロントエンドはAPIよりずっと頻繁に変わり、リリース1回が7分ではなく1分ほどになりました。意識しなくても、まとめて出す癖はなくなりました。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web の SEO：Cloudflare が Googlebot に空のページを返していた</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>React Native Web の SPA を Google と Naver のクローラーに見せるダイナミックレンダリングと、Googlebot に空の SPA シェルを返していた Cloudflare のキャッシュルールの話です。</description>
      <content:encoded><![CDATA[<p>React や React Native Web で作ったシングルページアプリは、そのままだと Naver のクローラー（Yeti）にも Bing にも JavaScript を実行してもらえず、Google も実行するとは限りません。対処法はダイナミックレンダリングで、User-Agent でクローラーを判定し、アプリが描くのと同じ内容のサーバーレンダリング HTML を返します。Cloudflare で避けるべきなのは、その URL を全員向けにキャッシュすることです。Cloudflare は HTML の <code>Vary</code> を無視するので、最初に届いた本文がその後の全員に配られ、Googlebot に空のシェルが返ることもあります。キャッシュするのはクローラーのリクエストだけにして、ルールを絞ったらパージしてください。</p>
<p>私が作っているマーケットプレイスアプリは、React Native Web で作ったシングルページアプリです。すべての出品ページは、JavaScript が動くまでは script タグ1つと空の div でしかありません。人間には問題なくても、検索には致命的です。Google は JS を実行することもありますが、Bing は実行しませんし、出品のほとんどが韓国語のサイトにとって最も重要な Naver のクローラーは、間違いなく実行しません。</p>
<p>これは、そのページを見えるようにした話と、そのあと私の予想しなかった形でそれを台無しにしたキャッシュルールの話です。</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%af%e3%83%ad%e3%83%bc%e3%82%ad%e3%83%b3%e3%82%b0%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84%e3%83%80%e3%82%a4%e3%83%8a%e3%83%9f%e3%83%83%e3%82%af%e3%83%ac%e3%83%b3%e3%83%80%e3%83%aa%e3%83%b3%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>対処法は古く、Google も認めています。クローラーを判定し、SPA が描いたはずのものと同じ内容を含むサーバーレンダリング HTML を返す。別の内容ではなく、同じ内容を、より早く。</p>
<p>そこで <code>/listings/view?id=...</code> のような出品 URL は分岐します。ブラウザには SPA のシェル。クローラーには本物のドキュメント。見出し、出品の主要な情報、alt 付きの写真ごとの画像、本文の抜粋、そしてこのページが孤立しないよう関連する出品いくつかへのリンク。</p>
<p>最後の項目が思っていたより効きました。内部リンクができる前は、約1,000件の出品ページに何もリンクしておらず、Search Console でクロールが1日7〜11リクエストまで沈んでいくのが見えました。ページを見つけられないクローラーは、その中身がどれだけ良いかを気にしません。</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="#%e7%bd%a0-%e3%83%99%e3%83%b3%e3%83%80%e3%83%bc%e5%90%8d%e3%81%af%e3%81%9d%e3%81%ae%e3%83%9c%e3%83%83%e3%83%88%e3%81%ae%e5%90%8d%e5%89%8d%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クローラー判定は user-agent のマッチで、ここでハマりました。</p>
<p>韓国のプラットフォームは、会社名とまったく似ていない名前でクロールします。Naver のクローラーは <strong>Yeti</strong>。Daum は <strong>Daumoa</strong>。Kakao のリンクスクレイパーは <strong>kakaotalk-scrap</strong>。3つとも、ベンダー名ではありません。</p>
<p>一方で同じベンダーが、埋め込みブラウザを持つアプリを出しています。そのブラウザは本物のブラウザ UA の末尾にアプリ名を足して送ります。KakaoTalk のアプリ内ブラウザは <code>KAKAOTALK/10.5.1</code>。Kakao のスクレイパーは <code>kakaotalk-scrap/1.0</code>。</p>
<p><code>kakao</code> でマッチすれば両方に当たります。私はまさにそれをやり、その結果リンクを開いた KakaoTalk・Naver アプリ・Daum アプリのユーザー全員がクローラー向けページを受け取りました。JavaScript もナビゲーションもログインもないページを。ドキュメントとしては良くできていて、人間にはまったく使えないものを。</p>
<p>修正は順序のルールで、ルールとして書き出す価値があります。</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">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># アプリ内ブラウザは、普通のブラウザ UA の後ろにアプリ名を付ける。</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ベンダー名ではなくボット名で照合する。Naver は Yeti、Daum は Daumoa、</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao のリンクプレビュー用スクレイパーは kakaotalk-scrap。</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># アプリ内ブラウザは決してクローラーではない。だからこの判定が先。</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>アプリ内ブラウザを先に見て、そちらが勝ちます。そしてクローラーのトークンは必ず<strong>ボット</strong>を指す必要があります。ベンダーではなく。</p>

<h2 class="relative group">そして前段に Cloudflare を置いた
    <div id="そして前段に-cloudflare-を置いた" 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%9d%e3%81%97%e3%81%a6%e5%89%8d%e6%ae%b5%e3%81%ab-cloudflare-%e3%82%92%e7%bd%ae%e3%81%84%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>オリジンはシドニーの共有 CPU マシン1台で、毎秒20〜30リクエストを支えます。Googlebot が巡回する URL は約995本。その全部がシドニーまで到達していました。すべての URL で <code>cf-cache-status</code> が <code>DYNAMIC</code> だったからです。<strong>Cloudflare はルールが指示しない限り HTML をキャッシュしません。</strong> ゾーンの約16%だけがキャッシュされ、肝心の部分は1つも入っていませんでした。</p>
<p>そこでクローラー向けページにキャッシュルールを。単純な話です。出品 URL の本文が1つではない、という点を除けば。本文は3つあります。</p>
<ul>
<li>HTML を求めるクローラーには、サーバーレンダリングされたページ。</li>
<li>人間には、SPA のシェル。</li>
<li><code>Accept: text/markdown</code> を送るエージェントには、データベースから組み立てた Markdown。空の HTML シェルを変換しても空のドキュメントにしかならないからです。</li>
</ul>
<p>オリジンは <code>Vary: Accept</code> を送り、user-agent に対する <code>Vary</code> はどのみち望み薄です。そしてここに、私が知らなかったことがあります。</p>
<blockquote><p><strong>Cloudflare は HTML では <code>Vary</code> を無視します。</strong> 3つの本文が1つのキャッシュキーを共有します。</p>
</blockquote><p>先に到着した本文がその URL を取り、同じエッジロケーションのその後の訪問者全員に対して、オリジンの <code>s-maxage</code> が許す1時間、さらに <code>stale-while-revalidate</code> の1日ぶん、それが出続けます。</p>
<p>本番で両方向とも観測できました。</p>
<ul>
<li>Naver アプリのユーザーがクローラー向けページで出品 URL を温め、そのあと素の iPhone Safari が HIT でそのページを受け取りました。JavaScript のないドキュメントを眺める人間の出来上がりです。</li>
<li>人間が SPA シェルで URL を温め、Googlebot が空のシェルを受け取りました。SEO の取り組み全体が防ごうとしていたまさにその失敗が、今度はより速く、エッジから提供されたわけです。</li>
</ul>

<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="#%e7%b5%9e%e3%82%8b%e3%81%93%e3%81%a8%e3%81%a7%e7%9b%b4%e3%81%99%e3%83%ab%e3%83%bc%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Cloudflare に HTML で <code>Vary</code> を尊重させることはできません。できるのは、3つの本文のうち<strong>1つだけ</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">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>クローラーの UA だけにマッチさせ、Markdown リクエストは除外します。これで人間のリクエストはどのルールにもマッチせず、以前どおり <code>DYNAMIC</code> を読んでオリジンへ行きます。キャッシュを読み書きするのはクローラーのリクエストだけで、そこに入りうる本文はクローラー向けの1つだけです。</p>
<p>エッジ TTL は <code>respect_origin</code>。寿命はダッシュボードに固定されるのではなく、ページをレンダリングするコードが持ち続けます。</p>
<p>見落としやすい運用上の細部を1つ。<strong>ルールを絞っても、すでにキャッシュにあるものは消えません。</strong> パージが必要です。さもないと、たった今生成を止めたばかりの汚染されたエントリを配り続けることになります。</p>

<h2 class="relative group">2つのリストと、その誤りが対称でない理由
    <div id="2つのリストとその誤りが対称でない理由" 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="#2%e3%81%a4%e3%81%ae%e3%83%aa%e3%82%b9%e3%83%88%e3%81%a8%e3%81%9d%e3%81%ae%e8%aa%a4%e3%82%8a%e3%81%8c%e5%af%be%e7%a7%b0%e3%81%a7%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このルールにはクローラー UA のリストが必要で、Django にはすでに1つあります。同じ知識が2つのシステムに2部あるのは普通なら臭いますが、ここで面白いのは、両者がずれたときに何が起きるかです。対称ではないのです。</p>
<ul>
<li><strong>Cloudflare 側のボットリストは、Django 側の部分集合であり続ける必要があります。</strong> Cloudflare がボットだと思い Django はそう思わないトークンがあると、クローラー専用のキーの下に SPA シェルがキャッシュされ、それが本物のクローラーに出ていきます。</li>
<li><strong>アプリ内ブラウザの除外リストは、Django 側の上位集合である必要があります。</strong> 除外しすぎても、失うのはキャッシュヒットだけです。</li>
</ul>
<p>どちらも安全な方向に間違えれば、キャッシュが少し減るだけ。1つ目を逆方向に間違えれば、また Googlebot に空のページを返すことになります。ちなみにこの非対称性こそ、リストが正規表現ではなく <code>contains</code> を使う理由でもあります。<code>matches</code> は Business プランが必要で、既知の安全な方向に大雑把でよいリストには <code>contains</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%83%9a%e3%83%bc%e3%82%b8%e3%81%a7%e3%81%af%e3%81%aa%e3%81%8f%e3%83%93%e3%83%ab%e3%83%89%e3%82%92%e8%90%bd%e3%81%a8%e3%81%99%e4%b8%8d%e5%a4%89%e6%9d%a1%e4%bb%b6" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クローラー向けページには、7日間有効な署名付き画像 URL が埋め込まれています。ページ自体はエッジで1時間、加えて stale-while-revalidate の1日。</p>
<p>この2つの数字は関係していて、キャッシュ寿命が画像の寿命を超えた瞬間、エッジは全写真が403になるページを配り始めます。それを知らせるアラートはありません。HTML 自体は正常なのですから。</p>
<p>そこで、この数値を定義するコードは、関係が壊れていればインポート自体を拒否します。</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="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># 署名付き画像 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># エッジキャッシュが画像 URL より長生きしうるなら import 時に失敗させる。</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>別々のファイルにある2つの定数のあいだの制約は、コードレビューでは見えず、プロセス起動時には明白です。プロセスが起動する場所に置きましょう。</p>

<h2 class="relative group">Cloudflare がやったことのうち、取り消す必要があった2つ
    <div id="cloudflare-がやったことのうち取り消す必要があった2つ" 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="#cloudflare-%e3%81%8c%e3%82%84%e3%81%a3%e3%81%9f%e3%81%93%e3%81%a8%e3%81%ae%e3%81%86%e3%81%a1%e5%8f%96%e3%82%8a%e6%b6%88%e3%81%99%e5%bf%85%e8%a6%81%e3%81%8c%e3%81%82%e3%81%a3%e3%81%9f2%e3%81%a4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> がバンドルをキャッシュから締め出していました。</strong> <code>django-cors-headers</code> は触れたすべてのレスポンスに <code>Vary: Origin</code> を刻み、Cloudflare は <code>Accept-Encoding</code> を超えて vary するレスポンスをキャッシュしません。その結果、クリティカルパス上で最大の 1.6MB の JavaScript バンドルが、まるごと静かにエッジから除外されていました。対処はミドルウェアをリストの先頭に置き、出ていく側で最後に走らせて、静的アセットのパスの <code>Vary</code> を書き換えることです。</p>
<p><strong>エッジでの圧縮はオリジンでの圧縮より悪かった。</strong> Cloudflare にその場で Brotli をやらせると 2,041,742 バイト、オリジン自身の gzip は 1,658,115 バイトでした。何も考えなくていい代わりに23%大きい。オンザフライ圧縮は圧縮率ではなく CPU を最適化します。オリジンからの事前圧縮済みアセットが勝ちます。</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%af%e3%83%ad%e3%83%bc%e3%83%ab%e3%82%92%e5%be%85%e3%81%9f%e3%81%9a%e3%81%ab%e6%a4%9c%e7%b4%a2%e3%82%a8%e3%83%b3%e3%82%b8%e3%83%b3%e3%81%b8%e7%9f%a5%e3%82%89%e3%81%9b%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>プロトコルは2つ、そして2つ合わせてもカバー範囲は残念なものです。</p>
<p><strong>IndexNow</strong> は ping を1つ受け取り、Bing、Yandex、Seznam をカバーします。Google と Naver は参加していません。出品が保存されるたびにバックグラウンドで短いタイムアウト付きの ping を送り、売却済み・終了済みの出品はスキップします。ネットワーク障害は警告を残すだけで、それを引き起こした保存を決して失敗させません。ベストエフォートのサイドチャネルに許される唯一の振る舞いです。</p>
<p><strong>Google の Indexing API</strong> はちょうど2つのスキーマ型しか受け付けず、その1つが私の持っている <code>JobPosting</code> です。生きている求人は <code>URL_UPDATED</code>、終了したものは <code>URL_DELETED</code>。その削除通知が誠実でいられるのは、終了した出品が <code>noindex</code> も出しているからで、コードにそう書いてあります。あとで noindex を外す人が、ほかに何を壊すのかを知れるように。</p>
<p>ここでは2つのことに時間を取られました。クォータは1日200 URL で、出品を一括インポートすると一度に数百回呼ばれます。だからインポートされた出品はスキップします。さもないと1回のインポートで1日分の予算を使い切ります。そしてエンドポイントはコロン入りの <code>urlNotifications:publish</code> です。スラッシュを使うと Google はただの HTML 404 を返し、それは API が無効になっているのと見分けがつきません。</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%a7%8b%e9%80%a0%e5%8c%96%e3%83%87%e3%83%bc%e3%82%bf%e3%81%8c%e6%95%99%e3%81%88%e3%81%a6%e3%81%8f%e3%82%8c%e3%81%9f%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>3つのルール、どれも Search Console に叱られて覚えました。</p>
<ol>
<li><strong>パースできないものは出さない。</strong> 給与は自由テキストで届きます。以前は生の文字列を <code>baseSalary.value</code> として出していて、それは無効で、すべての求人に単位欠落のフラグが立ちました。今はパースできない給与は単に存在しません。</li>
<li><strong>スキーマを満たすためにフィールドをでっち上げない。</strong> 求人広告に番地が載ることは稀です。<code>PostalAddress</code> にもっともらしいものを入れたくなります。求人広告のでっち上げの住所は、不完全な住所より悪い。だから地域と国だけを入れます。</li>
<li><strong>有効期限は任意ではない。</strong> Google は <code>validThrough</code> のない <code>JobPosting</code> を落とします。だから1つ導出し、古い出品が昨日期限切れになったと主張しないよう下限を置きます。</li>
</ol>
<p>そしてサイトマップについて4つ目。<strong>すべてに <code>changefreq: daily</code> を主張しない。</strong> 1,000件の出品を毎日再クロールしてくれという要求は、過負荷のホストとして読まれ、Google はサイト全体のクロールレートを下げることで応じます。実際にどれだけ最近変わったかから導出しましょう。</p>
<p>サイトマップには品質の下限もあります。タイトルと本文を合わせて40文字。Search Console が約1,000件を「クロール済み – インデックス未登録」と報告していて、ある中古品ページの固有コンテンツは、一語のタイトルと電話番号だけでした。40文字は意図的に低くしてあります。484件の出品のうち471件が韓国語で、韓国語は1文字あたりの情報量が多いからです。英語に合わせた閾値なら、マーケットプレイスの半分が落ちていたでしょう。薄い出品もインデックスページ経由でクロール可能なまま残り、誰かが編集すれば自動的にサイトマップへ戻ります。</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%93%e3%81%93%e3%81%8b%e3%82%89%e6%8c%81%e3%81%a1%e5%b8%b0%e3%82%8b%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li><strong>1つの URL で異なる本文を返すことは、何よりもまずキャッシュキーの問題です。</strong> 使っている CDN が何をキーにしているかを突き止め、そのコンテンツタイプで実証するまでは <code>Vary</code> を無視すると想定してください。</li>
<li><strong>2つのシステムが1つのリストで一致しなければならないなら、どちら向きの誤りが安全かを見極め</strong>、それを両方の写しの隣に書いてください。「ずれてよい、ただし許されるずれ方はこれだけ」のほうが、ずれない前提より優れています。</li>
<li><strong>ボットはベンダー名ではなくボット名でマッチさせること。</strong> 特に英語圏の外では、そのベンダーがユーザーの手元のブラウザも作っています。</li>
<li><strong>離れた定数のあいだの不変条件は、大きな音で失敗する場所に置くこと。</strong> インポート時はタダで、誰もスキップできません。</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.ioのコールドスタートが遅い理由と、33秒を3秒にした方法</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>パフォーマンス</category>
      <category>コールドスタート</category>
      <category>Redis</category>
      <category>デバッグ</category>
      <description>Fly.ioでゼロスケール（auto_stop_machines）を使うと、停止中のマシンへのリクエストは起動を待たされます。25〜33秒かかっていたコールドスタートを約3秒まで縮めた記録です。</description>
      <content:encoded><![CDATA[<p><strong>追記（2026年10月22日）</strong>：この記事の一番大事な数字は間違っています。公開したその日に、ゾーンの504をユーザーエージェントごとに集計したところ、9月5日から9日までの7,344件はすべてCloudflare自身のEarly Hintsのプローブ（<code>bastion early hints</code>と<code>nginx-ssl early hints</code>）によるものでした。これらはFlyのオリジンから応答を得られずに504として記録され、実際のリクエストと同じパスをなぞるので、ユーザー側の障害とまったく同じに見えます。同じ期間に、実際のクライアント（アプリ、ブラウザ、curl、検索エンジンのクローラー）が受け取った504はひとつもありませんでした。オリジンはpreloadヘッダーを送っておらず、Early Hintsは何の役にも立っていなかったので、その日のうちに無効にしました。以下のコールドスタートは本物で、対策も有効ですが、22%はユーザーが体験した数字ではありませんでした。Cloudflareの5xx率を障害と判断する前に、どのユーザーエージェントがそのエラーを受けているかを確認してください。</p>
<p><strong>追記（2026年11月4日）</strong>：もうひとつ訂正です。これらの504は、オリジンが応答できなかったプローブではありません。Cloudflare自身のEarly Hintsのキャッシュ参照で、オリジンには一切届かず、そこでの504はそのURLに何もキャッシュされていなかったことを意味するだけです。Early Hintsをオフにしたのも最善の対処ではありませんでした。<code>Link</code>レスポンスヘッダーを送ると機能が動き始め、これらのミスはヒットに変わりました。詳しくは「<a href="https://jared.lynskey.co.nz/ja/posts/2026/cloudflare-504-early-hints/" >Cloudflareの504エラーは障害ではなかった：Early Hints（HTTP 103）とは</a>」にまとめました。</p>
<p>結論から書くと、ゼロスケール（<code>auto_stop_machines</code>）のデメリットはコールドスタートの待ち時間です。停止中のFly.ioマシンにリクエストが来ると起動が終わるまで待たされます。私の環境では、マシンが起動するたびにentrypointがDjangoのマイグレーションと<code>collectstatic</code>を実行し、バイトコードもどこにもキャッシュされていなかったので、25〜33秒かかっていました。この作業をイメージごとに1回だけ（<code>FLY_IMAGE_REF</code>をキーにしたRedisのロックで）実行し、ビルド時に<code>compileall</code>を走らせるとコールドブートは約9秒に。さらに<code>auto_stop_machines</code>を<code>'stop'</code>から<code>'suspend'</code>に変えたところ（そのためにswapを外す必要がありました）、復帰は約3秒になりました。</p>
<p>Cloudflare のダッシュボードでトラフィックの急増を調べに行ったら、その下にもっとひどいものが埋まっていました。1日のうちにアプリが処理したリクエストの22%が失敗していたのです。アラートは一つも鳴っていません。ヘルスチェックはすべて緑。ログも完全に健全に見えました。失敗したリクエストが、そもそもアプリケーションに届いていなかったからです。</p>
<p>原因は自分で書いたコンテナの起動スクリプトでした。まっとうな処理を、まったく間違ったタイミングで実行していました。</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%80%a5%e5%a2%97%e3%81%af%e5%9b%ae%e3%81%a0%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>もともと調べたかったのは、中央値が毎時121件のところに約4,900件が集中した1時間です。ユニークな訪問者は42人。1人あたり110リクエストで、人間のブラウジングではありません。</p>
<p>スキャナーでした。ボットが1体、存在しないサブドメインをでっち上げながら、それぞれにシークレットを要求して回っていました。</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">api-stage-asia.example.com/.env.local
</span></span><span class="line"><span class="cl">api-us-east-1-demo.example.com/.env
</span></span><span class="line"><span class="cl">api-eu-uat.example.com/wp-config.php~
</span></span><span class="line"><span class="cl">app-test-us-east-1.example.com/actuator/configprops
</span></span><span class="line"><span class="cl">prod-api-use2.example.com/.env.local
</span></span><span class="line"><span class="cl">relay.example.com/.env.production</span></span></code></pre></div></div>
<p>30ほどのホスト名、思いつく限りの <code>.env</code> の変種、そして Django アプリに向けた、ある Java フレームワークの actuator エンドポイント。すべて正規ホストへの301になり、何も返していません。退屈で、実際に無害でした。</p>
<p>ただ、分析画面を開いたついでに同じ日をステータスコードで集計しました。本当の問題はそこにありました。</p>
<table>
	<thead>
			<tr>
					<th>ステータス</th>
					<th>24時間のリクエスト数</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>合計</td>
					<td>11,350</td>
			</tr>
			<tr>
					<td>504 Gateway Timeout</td>
					<td>2,474</td>
			</tr>
	</tbody>
</table>
<p>22%です。しかもサイト全体に均等ではなく、いちばん痛いところに正確に集中していました。</p>
<table>
	<thead>
			<tr>
					<th>エンドポイント</th>
					<th>504</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>通知の未読数</td>
					<td>621</td>
			</tr>
			<tr>
					<td>通知一覧</td>
					<td>614</td>
			</tr>
			<tr>
					<td>メッセージの未読数</td>
					<td>469</td>
			</tr>
			<tr>
					<td>フィードカーソル</td>
					<td>210</td>
			</tr>
	</tbody>
</table>
<p>どれもモバイルアプリが30秒ごとにポーリングしているエンドポイントです。つまりこれは抽象的なエラー率の数字ではありませんでした。ユーザーの端末が1日中、おそらく何週間も、未読バッジを静かに読み込めずにいたということです。</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%83%ad%e3%82%b0%e3%81%8c%e5%81%a5%e5%85%a8%e3%81%ab%e8%a6%8b%e3%81%88%e3%81%9f%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初はアプリが遅くなったのだろうと考えました。違いました。最悪の時間帯のアプリケーション側のリクエストログをアーカイブから取り出しました。</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">[2026-09-08 01:00:29] &#34;GET /api/notifications/unread/&#34; 304 61.615
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:29] &#34;GET /api/notifications/&#34;        304 63.568
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/unread/&#34; 304 58.742
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/&#34;        304 105.438</span></span></code></pre></div></div>
<p>44〜105ミリ秒、すべて304。オリジンは速かったのです。</p>
<p>これが重要な手がかりで、ルールとして書いておく価値があります。<strong>エッジがオリジンの知らないエラーを報告しているなら、リクエストは到達する前に死んでいます。</strong> アプリケーションログを読むのをやめて、その手前にあるものを見てください。</p>
<p>私の場合は Fly.io のプロキシで、答えはマシンのスケジューリング方法にありました。</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%bc%e3%83%ad%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%ab%e3%81%af%e3%82%b3%e3%82%b9%e3%83%88%e3%81%ae%e7%b4%84%e6%9d%9f%e3%81%a7%e3%81%82%e3%81%a3%e3%81%a6%e3%83%ac%e3%82%a4%e3%83%86%e3%83%b3%e3%82%b7%e3%81%ae%e7%b4%84%e6%9d%9f%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私の設定はこうです。一見すると妥当でしょう。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>アイドルのマシンは停止し、トラフィックが来れば再び起動する。使った分だけ払う。トラフィックにムラのある小さなアプリならまさに望む挙動で、だからこそマシンを10台定義していても普段は1台しか動いていませんでした。</p>
<p>この方式が隠している請求書はレイテンシです。停止中のマシンにリクエストが来れば、誰かがそのマシンの起動を待つことになります。起動がプロキシの我慢より長ければ、その誰かは504を受け取ります。</p>
<p>では私のマシンの起動には何秒かかっていたのか。一度も測っていませんでした。ログアーカイブは知っています。起動スクリプトが各フェーズを出力し、すべての行にマシンIDが付いているからです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">WITH</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">instance</span><span class="p">,</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">         </span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Migrations complete%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w">  </span><span class="s1">&#39;%&#34;GET /healthz%&#39;</span><span class="w">       </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="w"> </span><span class="k">END</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">phase</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">instance</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="p">)</span><span class="w">    </span><span class="k">AS</span><span class="w"> </span><span class="n">started</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">migrated</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="p">)</span><span class="w">  </span><span class="k">AS</span><span class="w"> </span><span class="n">first_serve</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">instance</span></span></span></code></pre></div></div>
<p>すべてのマシンで一貫して出た答えです。</p>
<table>
	<thead>
			<tr>
					<th>フェーズ</th>
					<th>所要時間</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>起動からマイグレーション完了まで</td>
					<td>10〜13秒</td>
			</tr>
			<tr>
					<td>マイグレーションから最初のリクエスト処理まで</td>
					<td>8〜19秒</td>
			</tr>
			<tr>
					<td><strong>合計</strong></td>
					<td><strong>25〜33秒</strong></td>
			</tr>
	</tbody>
</table>
<p>そして、それが何回起きているかを数えました。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">starts</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">instance</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">machines</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- starts: 92, machines: 10</span></span></span></code></pre></div></div>
<p>1日にコールドスタートが92回。そのたびに、そのマシンへ振られたリクエストがタイムアウトする30秒の窓が開いていました。22%はもう謎ではありません。</p>

<h2 class="relative group">原因は自分の entrypoint の中にあった
    <div id="原因は自分の-entrypoint-の中にあった" 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%8e%9f%e5%9b%a0%e3%81%af%e8%87%aa%e5%88%86%e3%81%ae-entrypoint-%e3%81%ae%e4%b8%ad%e3%81%ab%e3%81%82%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>すべてのマシンが、リクエストを1件処理する前に実行していたものです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># entrypoint.sh, run on every machine start</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running migrations...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running collectstatic in background...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span>    <span class="c1"># then start the app server</span></span></span></code></pre></div></div>
<p>「1日に92回」を思い浮かべながらもう一度読んでみてください。</p>
<p><code>migrate</code> は完全な Django の起動です。全モデルをインポートし、Postgres に接続し、マイグレーションテーブルを問い合わせ、やることがないと判断する。共有CPUでは、何も達成しないために10秒かかります。<code>collectstatic</code> は2回目の完全な Django 起動で、そのうえ静的ファイルをオブジェクトストレージにアップロードし、Web サーバーが立ち上がろうとしているまさに同じ単一コアを奪い合います。アプリサーバーが3回目の起動です。</p>
<p>Python インタプリタが3つ、うち2つはすでに済んだ作業を繰り返しながら、マシンが目覚めるたびに毎回。</p>
<p>そう書いた理由はまっとうで、同じような形になっているコードは多いはずです。マイグレーションはどこかで走らせる必要があり、Fly の <code>release_command</code> は以前ハングしたことがあり、entrypoint はデプロイ時に必ず実行される唯一の場所だからです。デプロイ作業を置く場所としては正しい。ただ<strong>マシン起動ごと</strong>に置く場所としては正しくない。そしてマシンが自分で止まったり起きたりし始めた瞬間、その2つはもう同じ出来事ではありません。</p>
<p>バグの全体はこれで、よくある種類だと思います。</p>
<blockquote><p><strong>リリース</strong>に属する作業が、<strong>マシン起動</strong>ごとに実行されていました。ゼロスケールが1つの出来事を92個に変えたのです。</p>
</blockquote>
<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="#%e8%a7%a3%e6%b3%95-%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9%e3%82%92%e4%b8%80%e5%ba%a6%e3%81%a0%e3%81%91%e7%a2%ba%e4%bf%9d%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>あるイメージで起動する8台目のマシンでマイグレーションが走る必要はありません。そのイメージについて一度走ればよく、残りはそれが済んだと知ればよいだけです。</p>
<p>つまり、デプロイごとに名前が変わるロックです。Fly はその名前を <code>FLY_IMAGE_REF</code> として無料で渡してくれますし、Redis はすでにありました。全体が Django より前に走る小さなスクリプトなので、よくあるケースの答えはフレームワークの起動ではなくミリ秒で済みます。</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="c1"># release_gate.py: runs before Django exists. Exit code 0 means &#34;prepare this release&#34;.</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PREPARE</span><span class="p">,</span> <span class="n">SKIP</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCK_TTL</span><span class="p">,</span> <span class="n">WAIT</span><span class="p">,</span> <span class="n">POLL</span> <span class="o">=</span> <span class="mi">300</span><span class="p">,</span> <span class="mi">240</span><span class="p">,</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="n">_sleep</span><span class="p">,</span> <span class="n">_monotonic</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span>    <span class="c1"># aliases, so tests can replace them</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">release_id</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Something that changes exactly when the deployed image does.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_IMAGE_REF&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_VERSION&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim</span><span class="p">(</span><span class="n">client</span><span class="p">,</span> <span class="n">release</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">release</span><span class="p">:</span>   <span class="c1"># no Redis, or no idea which release: behave as before</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>
</span></span><span class="line"><span class="cl">    <span class="n">done_key</span><span class="p">,</span> <span class="n">lock_key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:done&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:lock&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>            <span class="c1"># somebody already did it for this image</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">owner</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_ID&#34;</span><span class="p">,</span> <span class="s2">&#34;unknown&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>                  <span class="c1"># we won the race; we do the work</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Someone else is preparing. Wait for them, because serving requests</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># against a half-migrated schema is worse than starting slowly.</span>
</span></span><span class="line"><span class="cl">    <span class="n">deadline</span> <span class="o">=</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="n">WAIT</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">deadline</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_sleep</span><span class="p">(</span><span class="n">POLL</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">PREPARE</span>              <span class="c1"># the owner died and its lock expired; take over</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">PREPARE</span>                      <span class="c1"># waited long enough; do it ourselves</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ... plus `done` (sets done_key) and a main() that treats any Redis error as PREPARE</span></span></span></code></pre></div></div>
<p>entrypoint は <code>if</code> 一つになります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="k">if</span> python3 release_gate.py claim<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">    python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">    python3 release_gate.py <span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div></div>

<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%99%e3%81%b9%e3%81%a6%e3%81%ae%e5%a4%b1%e6%95%97%e3%81%af%e3%81%a8%e3%81%ab%e3%81%8b%e3%81%8f%e6%ba%96%e5%82%99%e3%81%99%e3%82%8b%e3%81%a7%e7%b5%82%e3%82%8f%e3%82%89%e3%81%9b%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>コードよりも、この部分こそ真似する価値があります。</p>
<p>このゲートはユーザーとスキーマのマイグレーションの間に立ちます。<strong>スキップする</strong>方向に間違えれば、マイグレーションされていないデータベースにリクエストを受けることになり、これは本物の障害です。<strong>準備する</strong>方向に間違えれば、冪等な no-op のマイグレーションを2回走らせて10秒を無駄にするだけです。</p>
<p>この2つの結果は対称ではないので、コードも対称に扱ってはいけません。不確かな経路はすべて <code>PREPARE</code> を返します。</p>
<ul>
<li><strong>Redis がない、または到達不能。</strong> 準備します。これは以前の挙動そのものなので、キャッシュ障害は「壊れる」ではなく「遅くなる」に落ちます。</li>
<li><strong>環境にイメージ参照がない。</strong> 準備します。どのリリースか分からない以上、準備済みかどうかも分かりません。</li>
<li><strong>ロックの持ち主がマイグレーション中に落ちた。</strong> ロックが期限切れになり、次の待機者が引き継いで準備します。</li>
<li><strong>タイムアウトより長く待った。</strong> それでも準備します。永遠にサービスできないマシンのほうが、重複マイグレーションより悪いからです。</li>
</ul>
<p>逆方向の非対称を1つだけ意図的に入れてあります。他のマシンが実際にロックを保持していると分かったマシンは、サービスを始めずに<strong>待ちます</strong>。デプロイ中に起動が遅いのは構いません。半分だけ適用されたスキーマに問い合わせを受けるのは困ります。</p>
<p>接続エラーを握りつぶすキャッシュラッパーの上にこれを組む場合、罠が1つあります（私も最初はそうしていました）。そういうラッパーは Redis に到達できないとき <code>add()</code> が <code>False</code> を返し、それは「他の誰かがロックを持っている」とまったく同じに読めます。そのままなら全マシンがマイグレーションをスキップしていました。まさに危険な方向です。ロックを読み返して区別してください。誰も保持していないロックは、競争に負けたのではなく、キャッシュが壊れているという意味です。</p>

<h2 class="relative group">Redis なしでテストする
    <div id="redis-なしでテストする" 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="#redis-%e3%81%aa%e3%81%97%e3%81%a7%e3%83%86%e3%82%b9%e3%83%88%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このロジックはユニットテストの価値があります。面白い経路ほど手作業では再現できないからです。メソッド2つのフェイクで足ります。</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="k">class</span> <span class="nc">FakeRedis</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Enough of a Redis client for the gate: get, and set with nx/ex.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">nx</span> <span class="ow">and</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div>
<p>あとは時間を自分の都合で進めて厄介なケースを動かします。ゲートが <code>time.sleep</code> を直接呼ばず、モジュールレベルの <code>_sleep</code> と <code>_monotonic</code> の別名を使っているのはこのためです。こうしておけば、同じプロセスの他のスレッドに影響する <code>time</code> モジュールごとのパッチを当てずに、テストで差し替えられます。</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="k">def</span> <span class="nf">test_a_waiter_takes_over_when_the_owner_disappears</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">redis</span> <span class="o">=</span> <span class="n">FakeRedis</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span>   <span class="c1"># the owner takes the lock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">owner_dies</span><span class="p">(</span><span class="n">_seconds</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">redis</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">pop</span><span class="p">(</span><span class="s2">&#34;release:img-1:lock&#34;</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>                              <span class="c1"># its lock expired</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="o">.</span><span class="n">object</span><span class="p">(</span><span class="n">release_gate</span><span class="p">,</span> <span class="s2">&#34;_sleep&#34;</span><span class="p">,</span> <span class="n">owner_dies</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span></span></span></code></pre></div></div>

<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="#%e5%8a%b9%e6%9e%9c%e3%81%af%e3%81%82%e3%81%a3%e3%81%9f%e3%81%8b%e5%8d%8a%e5%88%86%e3%81%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>デプロイしたあと、マシンを止めてもう一度起動し、時計を見ました。起動処理は自分の判断を出力して、すぐに道を空けます。</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">15:23:34  Starting app server...
</span></span><span class="line"><span class="cl">15:23:36  release gate: this release is already prepared, starting straight away
</span></span><span class="line"><span class="cl">15:23:44  &#34;GET /healthz HTTP/1.1&#34; 200</span></span></code></pre></div></div>
<p>10秒、2台目は11秒でした。以前の25〜33秒に対して、窓の3分の2が消えたことになります。ゲート自体は Django を一切インポートしないので2秒で済みます。</p>
<p>しかし10秒はやはり10秒です。その最初の1秒に届いたリクエストは相変わらず失敗します。そこで残りを探しに行きました。</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="#%e8%aa%b0%e3%82%82%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%81%97%e3%81%a6%e3%81%84%e3%81%aa%e3%81%8b%e3%81%a3%e3%81%9f%e3%83%90%e3%82%a4%e3%83%88%e3%82%b3%e3%83%bc%e3%83%89" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このイメージには、世の中のほぼすべての Python Dockerfile に書かれているあの一行があります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">PYTHONDONTWRITEBYTECODE</span><span class="o">=</span><span class="m">1</span></span></span></code></pre></div></div>
<p>良い助言です。コンテナがランタイムにレイヤーへ <code>.pyc</code> を書き散らすべきではありません。私が考え抜いていなかったのは、この取引のもう半分です。ランタイムがバイトコードを決して書かず、ビルドも書かないなら、何一つキャッシュされず、プロセスが起動するたびに依存関係ツリー全体をソースからコンパイルすることになります。</p>
<p>数えてみました。</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">site-packages .py  files : 5,707
</span></span><span class="line"><span class="cl">site-packages .pyc files : 0</span></span></code></pre></div></div>
<p>Django も REST フレームワークもすべてのライブラリも、1日92回の起動のたびに再コンパイルされていました。本番マシンでの計測値です。</p>
<table>
	<thead>
			<tr>
					<th><code>django.setup()</code></th>
					<th>時間</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>出荷時のまま</td>
					<td>5.15秒</td>
			</tr>
			<tr>
					<td><code>compileall</code> 後</td>
					<td>3.17秒</td>
			</tr>
	</tbody>
</table>
<p>修正は一行で、<code>compileall</code> は明示的に書き込むので上の環境変数があっても動きます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">RUN</span> python -m compileall -q /app/.venv/lib /app/myapp <span class="o">||</span> true</span></span></code></pre></div></div>
<p>ビルド時間16秒、一度きり、キャッシュされるレイヤーで。</p>
<p>ついでにもっと静かな問題も見つけました。<code>.dockerignore</code> にはこう書いてありました。</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">__pycache__/
</span></span><span class="line"><span class="cl">*.pyc</span></span></code></pre></div></div>
<p>Docker はこれらをパスの各要素ではなく、相対パス<strong>全体</strong>に対して照合します。したがってアンカーされていないパターンはビルドコンテキストのルートでしか一致しません。ネストした <code>__pycache__</code> はすべてイメージに載っていました。ノートパソコンの Python 3.15 がコンパイルした <code>.pyc</code> が218個、インタプリタが3.14のイメージの中に紛れ込み、すべて静かに無視されていたのです。パターンは <code>**/__pycache__/</code> と <code>**/*.pyc</code> であるべきでした。</p>
<p>正直な結果: 全体では2秒ではなく1秒ほどの改善でした。10秒が9秒になりました。隔離されたベンチマークは、いつもどおり過大に見せていたわけです。</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%b5%e3%82%b9%e3%83%9a%e3%83%b3%e3%83%89%e3%81%a8%e3%81%9d%e3%82%8c%e3%82%92%e5%a1%9e%e3%81%84%e3%81%a7%e3%81%84%e3%81%9f%e4%b8%80%e8%a1%8c" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>9秒まで来て、取り除ける作業が尽きました。残っていたのは避けられない起動1回分です。Python が立ち上がり、Django がインポートされ、WSGI アプリが共有コアの上で起き上がる時間。</p>
<p>ならば起動しなければいい。Fly はマシンをシャットダウンする代わりにメモリをスナップショットできます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>   <span class="c"># was &#39;stop&#39;</span></span></span></code></pre></div></div>
<p>試そうとしたら Fly に拒否され、今回の作業でいちばん役に立つエラーメッセージを受け取りました。</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">failed to suspend VM: failed_precondition:
</span></span><span class="line"><span class="cl">Machines with swap cannot be suspended</span></span></code></pre></div></div>
<p>私の設定の冒頭には、自分で書いて自分で信じていたコメントとともに、これがありました。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># Swap cushion so a memory spike swaps to disk instead of getting OOM-killed.</span>
</span></span><span class="line"><span class="cl"><span class="nx">swap_size_mb</span> <span class="p">=</span> <span class="mi">2048</span></span></span></code></pre></div></div>
<p>もっともな予防策です。実際に働いていたのか。全マシンの答えはこうでした。</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">MemTotal:   985220 kB
</span></span><span class="line"><span class="cl">SwapTotal: 2097148 kB
</span></span><span class="line"><span class="cl">SwapFree:  2097148 kB</span></span></code></pre></div></div>
<p>1ページたりともスワップされたことがありません。その日のログにも OOM kill はなく、&ldquo;oom&rdquo; に一致した11行は、コンテンツハッシュにたまたまその3文字を含む JavaScript バンドルへのリクエストでした。そして本当のメモリガードはまったく別の場所、アプリサーバー自身の設定にありました。800MB を超えたワーカーを、1GB の天井に届くはるか手前で再生成していたのです。</p>
<p>つまりこのクッションは、一度も起きたことのない事象への保険であり、すでに別の仕組みが担保しており、その保険料がサスペンド機能でした。外しました。</p>
<table>
	<thead>
			<tr>
					<th>復帰の経路</th>
					<th>応答までの時間</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>今朝のコールドブート</td>
					<td>25〜33秒</td>
			</tr>
			<tr>
					<td>ゲートとバイトコード後のコールドブート</td>
					<td>9〜10秒</td>
			</tr>
			<tr>
					<td><strong>サスペンドからの復帰</strong></td>
					<td><strong>2.5〜3.1秒</strong></td>
			</tr>
	</tbody>
</table>
<p>3回試して、ばらつきは0.5秒以内。復帰直後にすべてのエンドポイントが250〜435ミリ秒で200を返しました。</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%83%9e%e3%82%b7%e3%83%b3%e3%81%8c%e7%9c%a0%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e9%96%93%e3%82%bd%e3%82%b1%e3%83%83%e3%83%88%e3%81%af%e3%81%a9%e3%81%86%e3%81%aa%e3%82%8b%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ろくなことになりませんし、直すためのフックもありません。Fly は VM を凍結します。プロセスにシグナルは届かず、降りていく途中で何も閉じられません。握っていた接続は目覚めたときもメモリに残り、数分前に相手が捨てたソケットを指しています。</p>
<p>これは降りるときに解く問題ではありません。上がってくるときに解く問題で、その大半は、すでに正しい判断をしてあったかどうかで決まります。</p>
<ul>
<li><strong>データベース接続はリクエストごとに開く</strong>（<code>conn_max_age = 0</code>）。古びるほど長生きする接続がありません。</li>
<li><strong>Redis キャッシュは例外を投げずに縮退する。</strong> ラッパーが接続エラーを捕まえてミスとして返します。</li>
</ul>
<p>2つ目が働く瞬間を実際に見られました。ある復帰で、予想どおりの失敗がログに出ました。</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">redis.exceptions.ConnectionError: Error while reading from fly-...</span></span></code></pre></div></div>
<p>そしてそれが起きたリクエストは3.6秒で200を返しました。死んだキャッシュは遅いページであって、エラーページではないからです。以降の復帰では何も出ませんでした。</p>
<p>使っているキャッシュクライアントが死んだ接続で例外を投げるなら、サスペンドは復帰のたびに500の花火を打ち上げます。設定を切り替える<strong>前に</strong>確認してください。</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="#%e7%a7%81%e3%81%ab%e5%98%98%e3%82%92%e3%81%a4%e3%81%84%e3%81%9f%e3%83%86%e3%82%b9%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初のサスペンド計測は、この機能が大失敗だと告げました。502、30秒後、しかも2回続けて。</p>
<p>私は <code>fly-force-instance-id</code> でリクエストを特定のマシンに固定していました。インスタンス1台を負荷試験するには素晴らしいヘッダーですが、この用途には最悪です。インスタンスを強制すると、そのマシンを起こしてくれるはずのプロキシのロジックを迂回してしまうからです。Fly はちゃんと教えてくれていました。それをエラーではなく答えとして読んでいれば。</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">machine was recently stopped and is unavailable to service request</span></span></code></pre></div></div>
<p>意味のある数字は、実トラフィックが通る経路から出てきます。エッジ経由で同時40リクエスト、ソフトリミット12を大きく超えさせ、プロキシがサスペンド中のマシンへスケールアウトせざるを得ない状況にしました。</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">status codes:  40 × 200
</span></span><span class="line"><span class="cl">slowest:       2.1s</span></span></code></pre></div></div>
<p>教訓は2つ、そして高くつくのは2つ目です。計測しやすい経路ではなく、ユーザーが通る経路を測ること。そして、ツールが何かを拒否したら、その拒否文を読むこと。「Machines with swap cannot be suspended」という一文が、この午後の作業すべての突破口になりました。</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%82%e3%81%aa%e3%81%9f%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e3%81%a7%e7%a2%ba%e8%aa%8d%e3%81%99%e3%81%b9%e3%81%8d%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アイドルのインスタンスを停止するプラットフォームを使っているなら、次の問いに答えるのに私は半日かかりましたが、もっと早く確かめていれば、何週間も続いた静かな失敗を防げたはずです。</p>
<ol>
<li><strong>コールドスタートは何秒かかりますか。</strong> コンテナが走る時間ではなく、実際のリクエストに応答するまでの時間です。秒数で即答できないなら、必要になる前に測ってください。</li>
<li><strong>entrypoint が起動ごとに行っている処理のうち、デプロイに属するものは何ですか。</strong> マイグレーション、静的ファイル収集、キャッシュのウォームアップ、インデックス構築。1回ならどれも問題ありません。92回ならどれも高くつきます。</li>
<li><strong>バイトコードをキャッシュしているものはありますか。</strong> ビルドに <code>compileall</code> のない <code>PYTHONDONTWRITEBYTECODE</code> は、答えが「ノー」だという意味です。</li>
<li><strong>停止ではなくサスペンドにできますか。</strong> できないなら、何が邪魔していますか。私の場合は一度も使われなかったスワップでした。</li>
<li><strong>エッジには見えてオリジンには見えないエラーはありますか。</strong> 両者を比べてください。その差はレポートの癖ではなく、あいだで死んでいるリクエストであり、アプリケーションログは決してそれを見せてくれません。</li>
</ol>
<p>ちなみにヘルスチェックはその間ずっと緑でした。いつもそうです。ヘルスチェックはすでに立ち上がっているマシンに対して走るので、そのマシンが存在する前の30秒については何も報告できません。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
