<?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>Django · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/django/</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/django/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>S3に直接アップロードしても画像圧縮は手放さない：Tigrisのオブジェクト通知とDjango</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/tigris-object-notifications/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/tigris-object-notifications/</guid>
      <pubDate>Sat, 07 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Tigris</category>
      <category>S3</category>
      <category>S3 Event Notifications</category>
      <category>Presigned URLs</category>
      <category>Image Uploads</category>
      <category>Image Compression</category>
      <category>Object Storage</category>
      <category>Webhooks</category>
      <category>Django</category>
      <category>Fly.io</category>
      <category>Discord</category>
      <description>署名付きURLでS3に直接アップロードすれば速いのですが、ファイルが届いたことをバックエンドは知りようがなく、画像圧縮やサムネイル生成が厄介になります。Tigrisのオブジェクト通知がそれを解決します。バケットがDjangoのWebhookにPOSTし、ユーザーがまだ入力しているあいだに写真が処理されます。同じ仕組みで、ログファイルをDiscordのアラートに変えました。</description>
      <content:encoded><![CDATA[<p>私がこれまで作ってきた画像アップロードには、いつも同じ綱引きがありました。アップロードする側は、写真をとにかく速く上げたい。私のほうは、サーバーで画像圧縮をかけ、サムネイルを作り、blurhashと色をいくつか抜き出し、入っていてはいけないメタデータを落として、アプリのほかの部分が期待する形で保存したい。私の要望リストをいちばん手軽に片づける方法はファイルをAPI経由で送ることで、それこそが相手の要望を遅くしている張本人なのです。</p>
<p>速くする方法は署名付きURLです。APIがURLに署名し、クライアントはそのURLにファイルを直接PUTして、バイト列はAPIにまったく触れません。定番の答えになっているのには、それだけの理由があります。ただ、そうした瞬間にバックエンドは蚊帳の外に置かれます。URLを渡したきり、何の音沙汰もありません。ファイルは届いたのか。想定どおりのサイズなのか。そもそもJPEGなのか。誰かがバックエンドに教えなければならないのですが、よくある選択肢はどれも、どこか物悲しいものです。クライアントが「終わりました」と知らせてくれるのを信じる（クライアントは嘘をつき、落ち、電波を失います）。バケットを定期的にポーリングする（遅くて無駄が多い）。最初に読まれたときに遅延処理する（最初の閲覧者が代償を払ううえ、ファイルをいつから安全に参照できるのかは相変わらず分かりません）。S3に直接アップロードするのが実際より難しそうに見えるのは、このせいです。アップロード自体は簡単です。足りないのは合図のほうです。</p>
<p>オブジェクト通知が、その合図です。私がFly.ioで使っているS3互換ストレージのTigrisは、バケットでオブジェクトが作成または削除されるたびに、小さなJSONをアプリにPOSTしてくれます。AWSで相当するのはS3イベント通知ですが、こちらは自分のコードに何かが届く前に、LambdaやSQSのキュー、EventBridgeを配線することになるのが普通です（SNSならHTTPSエンドポイントにPOSTできますが、その前にサブスクリプションの確認と署名の検証が待っています）。Tigrisの場合は、自分が持っているURLへのHTTPリクエスト1本で済みます。ストレージからバックエンドへの合図としては、これ以上ないくらい単純です。バケットがバックエンドと話せるように、こちらが橋を架けなくて済む。この機能はそのためにあります。</p>
<p>この記事では、流れを最初から最後まで追います。設定、イベントを受け取るDjangoのエンドポイント、サーバー側の処理を一切手放さない署名付きURLでのアップロード、そして落とし穴（CORS、サイズ制限、有効期限、複数ファイル）です。最後に、同じ合図を裏方の仕事に使った話をします。バケットに届いたログファイルがDiscordのアラートになり、何か月も面倒を見てきた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="#%e7%9f%ad%e3%81%84%e7%ad%94%e3%81%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Tigrisのダッシュボードでバケットを開き、Settings、Object notificationsと進みます。アプリ上のHTTPSのURLを指定し、Basic認証を選びます（Tigrisが<code>Authorization</code>ヘッダーに載せて送るユーザー名とパスワードです。もうひとつの選択肢はBearerトークンです）。関心のあるキーについてだけ通知が来るよう、フィルターも追加します。フィルターはSQLに似た式で、「この記事で扱う3つのプレフィックスだけ」ならこう書きます。</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">WHERE</span><span class="w"> </span><span class="o">`</span><span class="k">key</span><span class="o">`</span><span class="w"> </span><span class="n">REGEXP</span><span class="w"> </span><span class="s2">&#34;^(uploads/raw|logs/api|media)/&#34;</span></span></span></code></pre></div></div>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigrisのバケット設定、Notificationsタブ：オブジェクト通知がオン、Webhook URL、ユーザー名とパスワードのBasic認証、SQLライクなキーのフィルター"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp"></span><figcaption>設定はこれで全部です。アプリのHTTPS URL、Basic認証、キーのフィルター。バケット名とURLは例に差し替えてあります。</figcaption></figure>
</p>
<p>あとは変更があるたびに、Tigrisがこんなバッチを送ってきます。</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;events&#34;</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="nt">&#34;eventName&#34;</span><span class="p">:</span> <span class="s2">&#34;OBJECT_CREATED_PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;eventTime&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-11-07T09:12:44Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;bucket&#34;</span><span class="p">:</span> <span class="s2">&#34;example-bucket&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;uploads/raw/1f3c.jpg&#34;</span><span class="p">,</span> <span class="nt">&#34;size&#34;</span><span class="p">:</span> <span class="mi">48213</span><span class="p">,</span> <span class="nt">&#34;eTag&#34;</span><span class="p">:</span> <span class="s2">&#34;9b2c…&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>目にするイベント名は<code>OBJECT_CREATED_PUT</code>、<code>OBJECT_CREATED_MULTIPART</code>、<code>OBJECT_CREATED_COPY</code>、<code>OBJECT_DELETED</code>、それにリネームとストレージ階層の移行です。エンドポイント側では認証を確認し、各イベントをキーのプレフィックスで振り分けて、10秒以内に2xxを返します。最初から織り込んでおくべきことが3つあります。配信は「少なくとも1回」なので、同じイベントがまた来ること。イベントはバッチで届くので、1回のリクエストに複数入っていること。そして順序どおりに届くとは限らないので、届いた順番から「最新」を読み取ってはいけないことです。</p>

<h2 class="relative group">バケットのイベントを受け取るDjangoのWebhook
    <div id="バケットのイベントを受け取るdjangoのwebhook" 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%e3%82%a4%e3%83%99%e3%83%b3%e3%83%88%e3%82%92%e5%8f%97%e3%81%91%e5%8f%96%e3%82%8bdjango%e3%81%aewebhook" aria-label="アンカー">#</a>
    </span>
    
</h2>
<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">base64</span><span class="o">,</span> <span class="nn">hmac</span><span class="o">,</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.http</span> <span class="kn">import</span> <span class="n">JsonResponse</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.csrf</span> <span class="kn">import</span> <span class="n">csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.http</span> <span class="kn">import</span> <span class="n">require_POST</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">.bucket_events</span> <span class="kn">import</span> <span class="n">handle</span><span class="p">,</span> <span class="n">parse</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</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">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</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="n">header</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Authorization&#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="k">if</span> <span class="ow">not</span> <span class="n">header</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;Basic &#34;</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">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">password</span> <span class="o">=</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">header</span><span class="p">[</span><span class="mi">6</span><span class="p">:])</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="o">.</span><span class="n">partition</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="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">UnicodeDecodeError</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="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">user</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="sa">b</span><span class="s2">&#34;tigris&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">password</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">secret</span><span class="o">.</span><span class="n">encode</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></span><span class="line"><span class="cl"><span class="nd">@csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="nd">@require_POST</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">bucket_webhook</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">secret</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">BUCKET_WEBHOOK_SECRET</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">secret</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;unauthorised&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">body</span> <span class="ow">or</span> <span class="sa">b</span><span class="s2">&#34;</span><span class="si">{}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;invalid json&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">400</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">event</span> <span class="ow">in</span> <span class="n">parse</span><span class="p">(</span><span class="n">body</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">handle</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;bucket event failed: </span><span class="si">%s</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">})</span></span></span></code></pre></div></div>
<p><code>compare_digest</code>を使っているのは、何を送られても照合にかかる時間を同じにするためです（比較はバイト列で行っています。文字列だと非ASCIIの入力で例外を投げるので、細工したヘッダーが401ではなく500を受け取ることになるからです）。たとえば立ち上げたばかりのステージング環境などでシークレットが未設定なら、エンドポイントはすべてを受け入れるのではなく、すべてを拒否します。そしてハンドラーが例外を投げても、ログに残したうえで200を返します。Tigrisは2xx以外なら何でもリトライしますが、リトライしても同じバグに当たるだけですし、その頃にはイベントはすでに確保済み（2つ下のセクションで説明します）なので、リトライしても何も起きません。直すきっかけになるのは、ログの1行のほうです。ただしこれは、失敗が決定的なもの、つまり何度やっても同じ結果になる場合の話です。ストレージの一時的なタイムアウトだと、イベントはそれきり消費されてしまいます。写真なら気にしなくて構いません。保存処理が自分で処理できるからです。でもログファイルのアラートは失われます。それが困るなら、<code>except</code>の中で確保を解放して500を返し、Tigrisにリトライさせてください。その代わり、本物のバグは直るまでリトライされ続けることになります。</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%97%e3%83%ac%e3%83%95%e3%82%a3%e3%83%83%e3%82%af%e3%82%b9%e3%81%a7%e6%8c%af%e3%82%8a%e5%88%86%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バケットの中身はもともとプレフィックスで整理されているので、振り分けもそれでやるのが自然です。用途ごとに関数が1つ、テーブルに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">HANDLERS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;uploads/raw/&#34;</span><span class="p">,</span>   <span class="n">on_raw_photo</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;logs/api/&#34;</span><span class="p">,</span>      <span class="n">on_api_log_file</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;media/&#34;</span><span class="p">,</span>         <span class="n">on_media_change</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></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">prefix</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">HANDLERS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">prefix</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">claim</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>  <span class="c1"># 後述</span>
</span></span><span class="line"><span class="cl">                <span class="n">fn</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span></span></span></code></pre></div></div>
<p>あとで4つ目を足したくなっても、関数と行を1つずつ増やすだけです。これが思っていた以上に気に入っています。</p>

<h2 class="relative group">「少なくとも1回」は「2回かもしれない」
    <div id="少なくとも1回は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="#%e5%b0%91%e3%81%aa%e3%81%8f%e3%81%a8%e3%82%821%e5%9b%9e%e3%81%af2%e5%9b%9e%e3%81%8b%e3%82%82%e3%81%97%e3%82%8c%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>エンドポイントの応答が遅かったりエラーを返したりすると、Tigrisはリトライします。そして多くのWebhook送信側と同じく、イベントが1回しか届かないという保証はありません。そこでハンドラーを動かす前に、<code>(key, eTag, eventName)</code>でイベントを確保します。ポイントはeTagです。ファイルが上書きされれば新しいeTagを持つ新しいバージョンなので、もう一度処理すべきです。一方、同じバージョンの重複は処理すべきではありません。</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">django.core.cache</span> <span class="kn">import</span> <span class="n">cache</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">claim</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">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="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="n">k</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;bucket-event:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">etag</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">cache</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">ttl</span><span class="p">)</span>  <span class="c1"># 誰かが先に確保していればFalse</span></span></span></code></pre></div></div>
<p><code>cache.add</code>はキーがまだ存在しないときだけ値をセットします。Redisではこれがアトミックなので、同じリトライを受け取った2台のマシンが両方とも勝つことはありません。本番ではRedisがフラッシュされても生き残るようにデータベースのリースを使っていますが、考え方はまったく同じです。</p>

<h2 class="relative group">画像はS3に直接アップロード、パイプラインはそのまま
    <div id="画像はs3に直接アップロードパイプラインはそのまま" 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%94%bb%e5%83%8f%e3%81%afs3%e3%81%ab%e7%9b%b4%e6%8e%a5%e3%82%a2%e3%83%83%e3%83%97%e3%83%ad%e3%83%bc%e3%83%89%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%af%e3%81%9d%e3%81%ae%e3%81%be%e3%81%be" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここまでの配管が報われるのは、この部分です。</p>
<p>以前は、出品用の写真を選ぶとアプリがそれを手元に保持し、投稿ボタンを押した時点で全部をbase64でAPIに送っていました。APIはリサイズし、サムネイルを作り、色とblurhashを計算して、それからようやく応答します。遅い回線で写真が10枚もあると、スピナーをずいぶん長く眺めることになります。それに、base64の写真を10枚抱えたリクエストは、APIサーバーがメモリに抱えるには大きくて遅いボディです。実はそれが、私が写真を小さく抑え続けていたひそかな理由でした。</p>
<p>今は4つのステップになり、そのすべてがバックエンドに伝わります。</p>

<h3 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="#1-%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%81%8c%e5%86%99%e7%9c%9f%e3%82%92%e7%b8%ae%e5%b0%8f%e3%81%97%e3%82%a2%e3%83%83%e3%83%97%e3%83%ad%e3%83%bc%e3%83%89%e6%9e%a0%e3%82%92%e8%a6%81%e6%b1%82%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>アプリは今でも端末上でリサイズします。サーバーが保持する長辺（1080px）に合わせ、品質0.7のJPEGにします。このリサイズはユーザーの通信量のためであって、それ以外の理由はありません。そのうえで、APIにアップロード枠を要求します。</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">uuid</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">s3</span> <span class="o">=</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span><span class="s2">&#34;s3&#34;</span><span class="p">,</span> <span class="n">endpoint_url</span><span class="o">=</span><span class="s2">&#34;https://fly.storage.tigris.dev&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET</span> <span class="o">=</span> <span class="s2">&#34;example-bucket&#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="nf">create_upload_slot</span><span class="p">(</span><span class="n">user</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">public_id</span> <span class="o">=</span> <span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;uploads/raw/</span><span class="si">{</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.jpg&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">public_id</span><span class="o">=</span><span class="n">public_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw_key</span><span class="o">=</span><span class="n">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">expires_at</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">+</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">2</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="n">url</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;put_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">BUCKET</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">key</span><span class="p">,</span> <span class="s2">&#34;ContentType&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">15</span> <span class="o">*</span> <span class="mi">60</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="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;upload_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;url&#34;</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;method&#34;</span><span class="p">:</span> <span class="s2">&#34;PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;headers&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;expires_in&#34;</span><span class="p">:</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span></span></span></code></pre></div></div>
<p>枠は、ファイルになる前にまず1行のレコードです。だからサーバーは、誰が要求したのか、いつ期限が切れるのか、そして後になって実際に使われたのかを知ることができます。</p>

<h3 class="relative group">2. クライアントがバケットに直接PUTする
    <div id="2-クライアントがバケットに直接putする" 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%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%81%8c%e3%83%90%e3%82%b1%e3%83%83%e3%83%88%e3%81%ab%e7%9b%b4%e6%8e%a5put%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">slot</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">api</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="s2">&#34;/photo-uploads/&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">slot</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">method</span>: <span class="kt">slot.method</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">headers</span>: <span class="kt">slot.headers</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">body</span>: <span class="kt">blob</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="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;upload refused&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// 保存時のためにslot.upload_idを控えておく
</span></span></span></code></pre></div></div>
<p>APIはバイト列を一切見ません。ブラウザからでも、React Nativeからでも、ネイティブアプリからでも同じです。唯一の違いは、ブラウザの場合はバケットにCORSルールが必要なことで、これは後述の落とし穴で触れます。</p>

<h3 class="relative group">3. Tigrisがバックエンドに知らせ、パイプラインが動く
    <div id="3-tigrisがバックエンドに知らせパイプラインが動く" 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="#3-tigris%e3%81%8c%e3%83%90%e3%83%83%e3%82%af%e3%82%a8%e3%83%b3%e3%83%89%e3%81%ab%e7%9f%a5%e3%82%89%e3%81%9b%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%8c%e5%8b%95%e3%81%8f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>数秒のうちにTigrisが<code>uploads/raw/…</code>の<code>OBJECT_CREATED_PUT</code>をPOSTしてきて、ハンドラーがbase64経路でずっと使ってきたのと同じ画像パイプラインを回します。ユーザーがまだタイトルを入力しているあいだにです。</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">io</span> <span class="kn">import</span> <span class="n">BytesIO</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span><span class="p">,</span> <span class="n">ImageOps</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.core.files.base</span> <span class="kn">import</span> <span class="n">ContentFile</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">blurhash</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">MAX_BYTES</span> <span class="o">=</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</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">on_raw_photo</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">event</span><span class="o">.</span><span class="n">name</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;OBJECT_CREATED&#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="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;</span> <span class="n">MAX_BYTES</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</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">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">raw_key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">,</span> <span class="n">processed_at</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">upload</span> <span class="ow">is</span> <span class="kc">None</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">data</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</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">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="n">longest</span><span class="p">,</span> <span class="n">quality</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="n">longest</span><span class="p">,</span> <span class="n">longest</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">buf</span> <span class="o">=</span> <span class="n">BytesIO</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="s2">&#34;WEBP&#34;</span><span class="p">,</span> <span class="n">quality</span><span class="o">=</span><span class="n">quality</span><span class="p">,</span> <span class="n">icc_profile</span><span class="o">=</span><span class="n">im</span><span class="o">.</span><span class="n">info</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;icc_profile&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">ContentFile</span><span class="p">(</span><span class="n">buf</span><span class="o">.</span><span class="n">getvalue</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">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">Image</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">data</span><span class="p">))</span> <span class="k">as</span> <span class="n">im</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span> <span class="o">=</span> <span class="n">ImageOps</span><span class="o">.</span><span class="n">exif_transpose</span><span class="p">(</span><span class="n">im</span><span class="p">)</span><span class="o">.</span><span class="n">convert</span><span class="p">(</span><span class="s2">&#34;RGB&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="mi">1080</span><span class="p">,</span> <span class="mi">1080</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">width</span><span class="p">,</span> <span class="n">upload</span><span class="o">.</span><span class="n">height</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">dominant_color</span> <span class="o">=</span> <span class="s2">&#34;#</span><span class="si">%02x%02x%02x</span><span class="s2">&#34;</span> <span class="o">%</span> <span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span><span class="o">.</span><span class="n">getpixel</span><span class="p">((</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">image</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">1080</span><span class="p">,</span> <span class="mi">80</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">thumbnail</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">_t.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">320</span><span class="p">,</span> <span class="mi">70</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">blurhash</span> <span class="o">=</span> <span class="n">blurhash</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">32</span><span class="p">,</span> <span class="mi">32</span><span class="p">)),</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">save</span><span class="p">()</span></span></span></code></pre></div></div>
<p><code>on_raw_photo</code>の最初の行は、見た目以上に大事です。ルーターは<code>uploads/raw/</code>配下のイベントをすべてここに送ってきます。孤立ファイルの掃除処理による削除も、サイズチェック自身による削除も含めてです。消えたキーに<code>get_object</code>しても、例外が返ってくるだけです。</p>
<p>つまりクライアント側の縮小は、あくまで回線のためだけのものでした。フォーマット（WebP）も、サイズも、サムネイルも、プレースホルダーも、保存する寸法も、決めるのは今でもサーバーです。Pillowで再エンコードするとEXIFブロックも落ちます。スマートフォンのカメラがGPS位置情報を書き込むのはそこなので、プライバシーのためのメタデータ除去はおまけで付いてきます。ICCプロファイルも同じ道をたどるところですが、そうなると広色域のスマートフォン写真の色が少し転ぶので、<code>to_webp</code>ではそのまま引き継いでいます。ピクセル単位で元画像を残したいなら、クライアントで縮小しなければいいだけです。流れは同じで、PUTが大きくなるだけ。そして大きなPUTこそ、この設計が安くしてくれるものです。ひとつ注意点があります。iPhoneの元画像はHEICで、Pillowがこれを開けるのは<code>pillow-heif</code>を入れたときだけです。</p>

<h3 class="relative group">4. 保存時に写真を紐づけ、Webhookは待たない
    <div id="4-保存時に写真を紐づけwebhookは待たない" 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="#4-%e4%bf%9d%e5%ad%98%e6%99%82%e3%81%ab%e5%86%99%e7%9c%9f%e3%82%92%e7%b4%90%e3%81%a5%e3%81%91webhook%e3%81%af%e5%be%85%e3%81%9f%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>投稿ボタンが押されたときのリクエストには、画像データの代わりに<code>upload_ids</code>が入っています。サーバーはそれぞれを調べ、結果は3通りに分かれます。</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">take</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">upload_ids</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">uploads</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span> <span class="n">public_id__in</span><span class="o">=</span><span class="n">upload_ids</span><span class="p">,</span> <span class="n">expires_at__gt</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">uploads</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)):</span>     <span class="c1"># 期限切れか、この人のものではない</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">upload</span> <span class="ow">in</span> <span class="n">uploads</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>          <span class="c1"># Webhookがまだ届いていない</span>
</span></span><span class="line"><span class="cl">            <span class="n">data</span> <span class="o">=</span> <span class="n">read_raw</span><span class="p">(</span><span class="n">upload</span><span class="p">)</span>              <span class="c1"># PUTが届いていなければNone</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">data</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">uploads</span></span></span></code></pre></div></div>
<p>通知がすでに処理を済ませていれば、処理済みのファイルをそのまま紐づけます。ファイルはバケットにあるのに通知がまだ届いていなければ、保存処理がその場でインラインで処理します。だから投稿がWebhookを待つことはありません。通知は先回りであって、依存先ではないのです。そしてファイルがそもそも存在しない場合や、IDが期限切れだったり他人のものだったりする場合は、保存はバリデーションエラーを返し、アプリが少し待ってリトライします。アプリは自分のPUTが終わるのを待ってから送信するので、実際にはめったに起きません。</p>
<p>これを安定させるために効いた細かい点がいくつかあります。枠はユーザーのものなので、サーバーは同じ人が作成したアップロードIDしか受け付けません。そうしないと、IDを見かけた誰でも、あなたの写真を自分の出品に付けられてしまいます。すべてにフォールバックがあります。枠の要求に失敗した、PUTが拒否された、APIが古いバージョンだった、といった場合、アプリは従来どおりbase64で送ります。アップロード経路を使うのは新しい写真のすべてに枠があるときだけなので、1つの投稿の写真は全部アップロードか全部base64かのどちらかになり、並び順も1つのリストのまま保たれます。そして孤立したアップロードは期限切れになります。写真を選んだものの途中でやめる人はいるので、どこにも紐づかなかったアップロードは2日後に期限切れになり、既存の孤立メディアの掃除処理がファイルを片づけます。</p>

<h2 class="relative group">署名付きURLアップロードの落とし穴：CORS、サイズ制限、有効期限、複数ファイル
    <div id="署名付きurlアップロードの落とし穴corsサイズ制限有効期限複数ファイル" 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%b2%e5%90%8d%e4%bb%98%e3%81%8durl%e3%82%a2%e3%83%83%e3%83%97%e3%83%ad%e3%83%bc%e3%83%89%e3%81%ae%e8%90%bd%e3%81%a8%e3%81%97%e7%a9%b4cors%e3%82%b5%e3%82%a4%e3%82%ba%e5%88%b6%e9%99%90%e6%9c%89%e5%8a%b9%e6%9c%9f%e9%99%90%e8%a4%87%e6%95%b0%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>署名付きURLでのアップロードでつまずくことの大半は、通知が飛ぶより前の段階で起きます。1ページにまとめておきたいものを挙げます。</p>

<h3 class="relative group">CORSはブラウザだけの話
    <div id="corsはブラウザだけの話" 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="#cors%e3%81%af%e3%83%96%e3%83%a9%e3%82%a6%e3%82%b6%e3%81%a0%e3%81%91%e3%81%ae%e8%a9%b1" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ネイティブアプリは気にしません。ブラウザはPUTの前にプリフライトの<code>OPTIONS</code>をバケットに送り、バケットのCORSルールがあなたのオリジンからの<code>PUT</code>を<code>Content-Type</code>ヘッダー付きで許可していなければ、アップロードはCORSエラーで失敗します。Tigrisは標準のS3 CORS設定をそのまま受け付けるので、ルールの書き方はAWSと同じです。署名付きPUTが<code>curl</code>からは動くのにページからは動かないとき、最初に確認すべきはこれです。</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Tigrisのバケット設定、CORS and headersタブ：2つのオリジンからのPUTとContent-Typeヘッダーを許可するルールが1つ"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp"></span><figcaption>ブラウザからのアップロードに必要なCORSルールはこれ1つです。自分のオリジン、PUT、そして署名が期待するContent-Typeヘッダー。</figcaption></figure>
</p>

<h3 class="relative group">Content-Typeは署名の一部
    <div id="content-typeは署名の一部" 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="#content-type%e3%81%af%e7%bd%b2%e5%90%8d%e3%81%ae%e4%b8%80%e9%83%a8" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>ContentType</code>を含めて署名すると、クライアントはそのヘッダーを寸分違わず送らなければならず、さもなければバケットは403の<code>SignatureDoesNotMatch</code>を返します。枠がURLと一緒にヘッダーも返しているのはそのためです。意図して署名に含めるのはいいのですが、それで何が手に入るのかは知っておいてください。強制されるのはヘッダーであって中身ではないので、<code>image/jpeg</code>と名乗ったPDFは通ります。本当のチェックはパイプラインです。Pillowは画像でないものを開こうとせず、レコードが処理済みになることもありません。</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%b5%e3%82%a4%e3%82%ba%e5%88%b6%e9%99%90" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>署名付きPUT URLにはサイズ制限がありません。バイト列が届く前に確実に止めたいなら、<code>content-length-range</code>のポリシー条件を付けた署名付きPOSTがそれをやってくれます。私はそこまでしませんでした。通知にオブジェクトの<code>size</code>が入っているからです。ハンドラーは上限を超えるものを読む前に削除し、保存時の経路は判別できるよう上限プラス1バイトまでしか読みません。アプリが送るのは数百キロバイトで、15メガバイト近いものはうちのものではありません。</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%9c%89%e5%8a%b9%e6%9c%9f%e9%99%90" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>時計は2つあります。署名付きURLの有効期間は15分です。PUT 1回には十分で、漏れたURLがすぐに使い物にならなくなる程度には短い長さです。枠のレコードは2日間有効で、写真を選んでから端末を置き、翌日に出品を仕上げる、という使い方をカバーします。クライアントは保留中のアップロードを1日経ったら古いものとみなし、祈るのではなく新しい枠を取り直します。</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="#%e8%a4%87%e6%95%b0%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%e3%81%a8%e3%82%82%e3%81%a3%e3%81%a8%e5%a4%a7%e3%81%8d%e3%81%aa%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ファイル1つに枠1つです。写真が10枚なら枠が10個、並列のPUTが10本で、それぞれに進捗があります（<code>XMLHttpRequest</code>なら<code>upload.onprogress</code>が使えます。<code>fetch</code>にはありません）。巨大なbase64のPOST 1本では手に入らないものです。数メガバイトを超えるならマルチパートを使います（パートは最低5 MiBなので、それをはるかに超えるファイル向けです）。サーバーでマルチパートアップロードを作成し、<code>upload_part</code>ごとに署名し、パートが揃ったら完了させます。アップロードが完了した時点でTigrisが<code>OBJECT_CREATED_MULTIPART</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%a0%86%e5%ba%8f%e3%81%af%e4%bf%9d%e8%a8%bc%e3%81%95%e3%82%8c%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>イベントは順序どおりに届くとは限りません。オブジェクトが複数のリージョンで書き込まれていることがあるからです。アップロードでは問題になりません。キー1つにイベント1つです。上書きされるキーについては、Webhookが届いた順番ではなく、eTagとオブジェクトの<code>Last-Modified</code>を信じてください。</p>

<h2 class="relative group">同じ合図をログにも：Discordへのアラート
    <div id="同じ合図をログにもdiscordへのアラート" 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%90%8c%e3%81%98%e5%90%88%e5%9b%b3%e3%82%92%e3%83%ad%e3%82%b0%e3%81%ab%e3%82%82discord%e3%81%b8%e3%81%ae%e3%82%a2%e3%83%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バケットがバックエンドと話せるようになれば、そこに届くものには何でも同じやり方で反応できます。2つ目に向けた先はログでした。</p>
<p>アプリのログは、小さなgzipファイルとしてバケットに送られています。アプリごとにだいたい5分に1ファイルです。今年の大半は、15分おきのcronで動くGitHub Actionsのワークフローが、DuckDBでそのアーカイブの直近20分を読み、遅いリクエストの行を見つけて、Discordにまとめを投稿していました。理屈の上では問題ありません。ところが実際には、GitHubのスケジュール実行はプラットフォームが混んでいると時間どおりに動きません。「15分おき」はむしろ「数時間おき」で、やっと動いたと思えば処理する範囲が広すぎて、たまにメモリ不足で落ちていました。実行の合間に取りこぼさないよう重ねておいた時間範囲のせいで、同じ遅いリクエストが2回出てくることもありました。「遅いリクエストがあったら教えて」のためにしては、ずいぶん大がかりな仕掛けです。</p>
<p>今は各ログファイルを、届いた時点で1回だけ読みます。gzipで数キロバイトです。ハンドラーはそこから遅いリクエストの行を抜き出し、数が多いか、極端に遅いものが1件あれば、Discord webhook経由でチャンネルにまとめを投稿します。1つのファイルに5xxレスポンスが集中していた場合も同じです。PythonからDiscord webhookを叩くのは、呼び出し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="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</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">discord_alert</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</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="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">settings</span><span class="o">.</span><span class="n">DISCORD_WEBHOOK_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">[:</span><span class="mi">1900</span><span class="p">]},</span>  <span class="c1"># Discordのメッセージ上限は2000文字</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>cronも、範囲の重なりも、重複もありません。アラートは、GitHubの手が空いたときではなく、リクエストから数分以内に届きます。あのActionはもうありません。</p>
<p>どうせファイルを開いているので、中身（エラー、警告、ヘルスチェック）を数えて、1時間ごとの集計テーブルにも書き込んでいます。以前は日次サマリーのたびにアーカイブを丸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="#%e5%89%8a%e9%99%a4%e3%81%ae%e8%a6%8b%e5%bc%b5%e3%82%8a" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これは計画していたわけではなく、<code>OBJECT_DELETED</code>イベントが手に入ったことで自然に生まれました。メディアファイルが削除されると、ハンドラーはそれを参照している行がまだ残っていないかを確認します。残っていたら、それはどこかのバグ（あるいはバケットのコンソールで指がすべった誰か）です。そこでそのカラムをクリアして、アプリが壊れた画像ではなくプレースホルダーを表示するようにし、報告を上げます。</p>
<p>1時間あたりの削除数も数えています。通常のハウスキーピングで消えるのはせいぜい数件です。もし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="#%e7%9f%a5%e3%81%a3%e3%81%a6%e3%81%8a%e3%81%8f%e3%81%a8%e3%82%88%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>応答は速く。Tigrisが1回の配信に与える時間は10秒です。5KBのログファイルを読んだり、スマートフォンの写真を1枚処理したりするぶんには収まります。それより長くかかりそうなものはキューに渡してビューは200を返すようにしないと、Tigrisがタイムアウトしてリトライし、また重複排除の世界に逆戻りです。</p>
<p>フィルターはバケット側で。私のログ転送は、アプリごとに数分おきに永遠にファイルを書き続けます。関心があるのが1つのプレフィックスだけなら、通知のフィルターでそう宣言しておけば、それ以外のことでアプリが起こされずに済みます。</p>
<p>テストは偽のボディで。ペイロードは小さくて安定しているので、テストでは正しい<code>Authorization</code>ヘッダーを付けてビューにJSONをPOSTし、ハンドラーが何をしたかをアサートします。本物のストレージは関わりません。</p>
<p>初日から頼り切りにしない。ここで挙げた用途にはどれもフォールバックがあります。保存処理は自分で写真を処理できますし、日次サマリーは今でもアーカイブをスキャンできますし、アプリは今でもbase64で送れます。通知のおかげでどれも速く安くなりましたが、Webhookが1つ届かなかったくらいで倒れるものはありません。</p>
<p>今週いちばん気持ちよかったのは、あのワークフローのファイルを消した瞬間でした。40行のYAML、Pythonスクリプト、リポジトリのシークレット2つ、そして定期的にやってくる「なんでアラートが鳴らなかったの？」がまとめて消え、代わりに残ったのは、小さなファイルが届いたらそれを1つ読むだけのハンドラー1つです。とはいえ、本題はアップロードのほうでした。写真はネットワークが許す限りの速さで上がり、その1枚1枚についてサーバーが今でも最終決定権を持ち、バケットとバックエンドのあいだに橋を架ける必要は誰にもありませんでした。バケットが、何が起きたかを自分で言ってくれるようになったからです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>pytest-testmon：コードの変更が影響するテストだけを実行してpytestを高速化する</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmonは、変更の届くテストだけを推測ではなく記録から選んで実行します。2,248件のDjangoスイートに入れた手順、必要なラッパー、Python 3.14の落とし穴。</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a>は、変更が影響しうるテストだけを実行するpytestのプラグインです。各テストがどの行を実行したかをcoverage.pyで見ていて、テストが触れたコードのブロックごとにチェックサムを保存します。次の実行ではそのチェックサムをディスク上のコードと比べ、ブロックが変わったテストと、前回失敗したテストだけを実行します。つまり<code>pytest --lf</code>の役目も兼ねています。</p>
<p>この手法は一般にテスト影響分析（test impact analysis）と呼ばれ、testmonはそれをPythonで無料で使える、オープンソースの手段です。gitにはまったく依存しません。比べる相手は前回の実行で見たコードで、コミット済みかどうかは関係ありません。</p>
<p>今週、私が作っているマーケットプレイスアプリのPythonのテストにこれを入れました。理由は、速さそのものではありません。</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="この記事を語りで紹介する短い映像">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語" default>
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文">
  </video><figcaption>この記事を約8分の映像にしました。ナレーションは英語で、日本語字幕が付きます。</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="#%e3%81%aa%e3%81%9c%e5%85%a5%e3%82%8c%e3%81%9f%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>機能のリクエストは、たいてい小さなものです。画面が一つ、エンドポイントが一つ、挙動を変えたいルールが一つ。ところがテストスイートを丸ごと実行すると、そのどれもが実際より大きくなっていました。スイートはDjangoのテストが2,248件で、4プロセスで約3分半、1プロセスだと11分かかります。編集のたびにその全部を実行していましたが、そのほとんどはリクエストが一切触れていない部分を確認するものです。関係のないテストが落ちたり、単に実行に時間がかかったりするたびに、その機能は待たされました。機能リクエストとは関係のない事情で足止めされていて、足止めしていたのは自分のテストスイートだったわけです。</p>
<p>かといって、別のやり方がましだったわけでもありません。全体の実行は飛ばして、変更をカバーしていそうなテストファイルを当て推量で選び、ときどき外す。速くはありますが、正確ではありません。テストしていない変更をリリースすれば、その機能リクエストをもう一度やり直すことになります。</p>
<p>待つテストは、変更の届きうるテストとぴったり同じであるべきで、多すぎても少なすぎてもいけません。testmonはその集合を、推測ではなく記録から出してくれます。スイート全体は今もCIで実行していて、そこなら次に取りかかっている作業を止めることはありません。</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%82%92%e5%ae%9f%e8%a1%8c%e3%81%99%e3%82%8b%e3%81%8b%e3%81%ae%e6%b1%ba%e3%82%81%e6%96%b9" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonは各Pythonファイルをブロックに分けます。モジュールのトップレベル、各クラス、各関数がそれぞれ一つのブロックで、ネストした関数の本体はそれを囲むブロックから切り離されます。保存するのは各ブロックのチェックサムだけで、コードそのものは保存しません。テストの実行中にcoverage.pyが実行された行を記録し、testmonはそれらの行がどのブロックに属するかを書き留めます。静的解析も、importからの推測もありません。テストが依存するのは、そのテストが実際に実行したブロックだけです。</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="c1"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</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">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p><code>subtotal()</code>だけを呼ぶテストは、モジュールのトップレベルと<code>subtotal</code>の本体に依存します。<code>discount()</code>をどう書き換えても、testmonはそのテストを再実行しません。そのテストは<code>discount</code>の行を一行も実行していないからです。逆に<code>subtotal</code>の中を一文字でも変えれば、結果が変わりようのない変更でも再実行されます。testmonは副作用について賢く立ち回ろうとはしません。実行したブロックが変わったら、実行する。それだけです。</p>
<p>コードの周りにあるものも追跡します。記録にはPythonのバージョンと、インストール済みのパッケージとそのバージョンが含まれます。環境変数は見ていません。同じコードを二つの設定モジュールで動かすなら、<code>--testmon-env</code>（手順8）で名前を付けないと、一つの記録を共有してしまいます。Djangoをアップグレードすれば、Djangoに触れたものはすべてまた影響を受けます。Djangoアプリなら、それはつまり全部です。</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%a6%8b%e3%81%88%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonが追跡するのは、テストが実行するPythonのコードです。テストが読むそれ以外のファイル、つまりテンプレートやJSON、テキストなど、ディスク上にあるほかのものは追跡しません。ネットワーク越しに呼び出すサービスも追跡しません。そして、テストが始まる前に一度だけ実行されるものを、特定のテストに結びつけることもできません。</p>
<p>Djangoのプロジェクトでいえば、まずマイグレーションです。テスト用データベースを作るときに一度だけ実行されるので、どのテストの記録にも含まれません（後で紹介するラッパーは、新しいマイグレーションがあるとテスト用データベースを作り直します）。次に設定と起動時のコードで、pytestの起動中に実行されるので、どのテストにも属しません。そしてテンプレートとデータファイルです。テンプレートはPythonではないので、編集しても、それを描画するテストは一つも再実行されません。</p>
<p>私のスイートには、ほかにもいくつかあります。モバイルアプリのテストと共有しているフィクスチャのJSONファイルを読むテスト。ビルドしたWebのバンドルを確認するテスト。同じリポジトリで生成している静的サイトの出力を確認するテスト。どれを変えても、testmonは「何も実行する必要はありません」と平然と答えます。だから以下の手順では、testmonをスクリプトで包んで、こうしたファイルに気づき、どれかが変わっていたら全部を実行するようにしています。</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%e3%81%ae%e3%83%97%e3%83%ad%e3%82%b8%e3%82%a7%e3%82%af%e3%83%88%e3%81%ab%e5%85%a5%e3%82%8c%e3%82%8b%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここではpytestと<a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>を使い、依存関係はuvで管理している前提で書きます。pipを使っているなら、<code>uv add --dev</code>を<code>pip install</code>に置き換え、<code>uv run</code>を外してください。</p>
<p><strong>1. インストールする。</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">uv add --dev pytest-testmon</span></span></code></pre></div></div>
<p><strong>2. pytestでスイートを実行できるようにする。</strong> testmonはpytestのプラグインなので、<code>python manage.py test</code>では何もしません。pytest-djangoには、settingsモジュールと、テストが使っているファイル名のパターンを教える必要があります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># pytest.ini</span>
</span></span><span class="line"><span class="cl"><span class="k">[pytest]</span>
</span></span><span class="line"><span class="cl"><span class="na">DJANGO_SETTINGS_MODULE</span> <span class="o">=</span> <span class="s">myproject.settings</span>
</span></span><span class="line"><span class="cl"><span class="na">python_files</span> <span class="o">=</span> <span class="s">tests.py test_*.py *_tests.py</span>
</span></span><span class="line"><span class="cl"><span class="na">python_classes</span> <span class="o">=</span> <span class="s">Test* *Tests *TestCase</span></span></span></code></pre></div></div>
<p>これまでDjangoのランナーでしかスイートを実行したことがないなら、testmonを入れる前に一度pytestで実行し、違いが出たところを直しておきましょう。そうしないと、二つの問題を同時にデバッグする羽目になります。</p>
<p><strong>3. 記録をgitに入れない。</strong> testmonはpytestのルートディレクトリに<code>.testmondata</code>を書き出します。中身はSQLiteのデータベースなので、隣に<code>-shm</code>と<code>-wal</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"># .gitignore
</span></span><span class="line"><span class="cl">.testmondata*</span></span></code></pre></div></div>
<p><strong>4. 一度スイート全体を記録する。</strong> testmonは、すべてのテストの実行を一度見るまで何も選べません。</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">uv run pytest --testmon</span></span></code></pre></div></div>
<p>まっさらなチェックアウトでは、この最初の実行は1プロセスでのスイート全体の実行になります。私の環境では、2,248件のテストに約12分かかります。それ以降は、同じコマンドで影響を受けるテストだけが実行されます。何も変えていなければ、何も実行されません。</p>
<p>落とし穴が二つあります。一つ目に、testmon 2.2.0以前ではpytestのcacheプラグインが必要です。最初に試したときは<code>-p no:cacheprovider</code>を付けて実行していて、<code>KeyError: 'lf'</code>で落ちました（2.3.0では<code>--lf</code>がなければ未指定として扱われます）。二つ目に、Python 3.14以降では、記録する前に<code>COVERAGE_CORE=ctrace</code>を設定してください。理由は、この後の「成功に見えたバグ」で説明します。以下のスクリプトで、いちばん大事な一行です。</p>
<p>追記（11月7日）：11月2日にリリースされたtestmon 2.3.0は、coverageのCトレーサーを自分で強制するようになったので、このバージョンではこのexportは念のためのものになります。2.2.0以前なら残してください。そして、わざと壊して確かめるチェックは、バージョンにかかわらず続けてください。</p>
<p><strong>5. 見えないファイルのためにラップする。</strong> 毎日安心して使えるのは、この部分があるからです。スクリプトは、前回の実行が始まった時点のスタンプファイルを持っています。testmonが追跡できないファイルのどれかがスタンプより新しければ、全部を実行して記録し直します。新しいマイグレーションなら、テスト用データベースを作り直すだけです。そうでなければ、選ぶのはtestmonに任せます。削って例にしたものがこちらです。</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/test-changed.sh: the tests a change can affect, or all when in doubt.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh            # affected tests</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh --all      # everything, re-recorded</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh -k orders  # anything else goes to pytest</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="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$0</span><span class="s2">&#34;</span><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"># coverage&#39;s C tracer: from Python 3.14 coverage defaults to sys.monitoring,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># which reports a line only the first time a process runs it, so testmon</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would credit each line to one test. Changing this means --all.</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">COVERAGE_CORE</span><span class="o">=</span>ctrace
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span>.testmondata.started
</span></span><span class="line"><span class="cl"><span class="nv">full</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="nv">rebuild</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</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> <span class="o">=</span> <span class="s2">&#34;--all&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span>true<span class="p">;</span> <span class="nb">shift</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="o">[</span> ! -f .testmondata <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> ! -f <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;No record yet: running everything once.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Changed since the last run started, and not traceable per test:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># anything that isn&#39;t Python, plus settings and conftest.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">newer</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>find myapp myproject fixtures pytest.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">        -type f -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> ! -path <span class="s1">&#39;*/migrations/*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        <span class="se">\(</span> ! -name <span class="s1">&#39;*.py&#39;</span> -o -path <span class="s1">&#39;myproject/settings*&#39;</span> -o -name <span class="s1">&#39;conftest.py&#39;</span> <span class="se">\)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        ! -path <span class="s1">&#39;*/__pycache__/*&#39;</span> <span class="p">|</span> head -5<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$newer</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;Changed, and not traceable per test:&#34;</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">&#39;  %s\n&#39;</span> <span class="nv">$newer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A new migration needs a fresh test database, not a full run.</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find myapp -path <span class="s1">&#39;*/migrations/*.py&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">|</span> head -1<span class="k">)</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">rebuild</span><span class="o">=</span><span class="nb">true</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="c1"># Stamp the start, so an edit made while the tests run counts next time.</span>
</span></span><span class="line"><span class="cl"><span class="nv">started</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>mktemp .testmondata.run.XXXXXX<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --create-db alone deletes the database when the run ends; with</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --reuse-db as well, the rebuilt one is kept for next time.</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rebuild</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$full</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon-noselect --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">uv run pytest <span class="s2">&#34;</span><span class="si">${</span><span class="nv">args</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> -q <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 0: passed, or nothing selected. 1: some failed (still recorded).</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 5: nothing collected at all.</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> in 0<span class="p">|</span>1<span class="p">|</span>5<span class="o">)</span> mv <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">;;</span> *<span class="o">)</span> rm -f <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">5</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>この中で説明しておきたい点が二つあります。<code>--reuse-db</code>は、テスト用データベースを実行の間で使い回すためのpytest-djangoのフラグです。一回の実行が数秒で済むようになると、これが効いてきます。毎回作り直していると、テストそのものより時間がかかることもあるからです。全体の実行や、新しいマイグレーションの後の実行では、データベースが現在のスキーマになるよう<code>--create-db</code>を足します。そのときも<code>--reuse-db</code>は外さないでください。<code>--create-db</code>だけだと、pytest-djangoは実行の終わりにデータベースを削除してしまい、次の実行でまた一から作ることになります。</p>
<p>もう一つは終了コードです。testmonがすべてのテストを選択から外したとき、実行は0で終わるので、「何も変わっていない」は成功として扱われます。終了コード5はpytestの「テストが一つも収集されなかった」という意味で、スクリプトはこれも問題なしとして扱います。</p>
<p>リストに入っていないものにも注目してください。<code>pyproject.toml</code>とロックファイルです。最初のバージョンはロックファイルが変わるたびに全部を実行していて、そのせいで遅いフォールバックがほぼ毎日発動し、入れる前より悪くなっていました。testmonはインストール済みのパッケージのバージョンを自分で追跡しているので、手助けはいりません。</p>
<p><strong>6. 専用のデータベースとキャッシュを持たせる。</strong> 同じマシンでほかにもテストを実行するもの、たとえば二つ目のターミナルやコーディングエージェント、<code>manage.py 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="nb">export</span> <span class="nv">DATABASE_URL</span><span class="o">=</span><span class="s2">&#34;postgresql://myapp:myapp@localhost:5432/myapp_changed&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REDIS_URL</span><span class="o">=</span><span class="s2">&#34;redis://localhost:6379/8&#34;</span></span></span></code></pre></div></div>
<p><strong>7. 実行のたびにそのキャッシュを空にする。</strong> これは痛い目を見て覚えました。一部だけを実行したときに、全体の実行では出ない<code>KeyError</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">uv run python -c <span class="s2">&#34;import os, redis; redis.Redis.from_url(os.environ[&#39;REDIS_URL&#39;]).flushdb()&#34;</span></span></span></code></pre></div></div>
<p>これはtestmonが、ドキュメントに書いてあるとおりのことをしているだけです。毎回違うサブセットを実行すると、前に何が実行されたかのおかげで通っていたにすぎないテストがあぶり出されます。直すべきはテストのセットアップで、testmonではありません。</p>
<p><strong>8. 選択が間違っているときのためのフラグを知っておく。</strong></p>
<ul>
<li><code>--testmon-noselect</code>はすべてのテストを実行しますが、落ちそうなものを先に回します。<code>-k</code>、<code>-m</code>、<code>--lf</code>やテストのnode IDを一緒に渡すと、testmonは自動的にこれに切り替えます。</li>
<li><code>--testmon-forceselect</code>は、影響を受けるテストのうち、<code>-k</code>や<code>-m</code>にも一致するものだけを実行します。</li>
<li><code>--testmon-nocollect</code>は既存の記録を使いますが、記録には何も追加しません。デバッガの下で動かしているときや、coverageがすでに動いているときは、強制的にこれになります。</li>
<li><code>--no-testmon</code>は、一回の実行だけtestmonを無効にします。<code>addopts</code>に入れているときに使います。</li>
<li><code>--testmon-env=&lt;name&gt;</code>は、一つのファイルの中に記録を別々に持ちます。二つのsettingsモジュールで実行するスイート向けです。</li>
</ul>
<p>プロジェクトにJavaScriptの側もあるなら、Jestには同じような機能が最初から入っています。<code>jest --changedSince=main</code>は、<code>main</code>以降に変わったものをimportしているテストを実行します。たどるのはcoverageではなくimportです。私はアプリの<code>package.json</code>に<code>npm run test:changed</code>として追加して、両側で同じ習慣が使えるようにしました。ユーティリティモジュールを一つ編集したときは、アプリの121個のテストファイルのうち7個が選ばれました。</p>

<h2 class="relative group">どれだけ節約できるか
    <div id="どれだけ節約できるか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%a9%e3%82%8c%e3%81%a0%e3%81%91%e7%af%80%e7%b4%84%e3%81%a7%e3%81%8d%e3%82%8b%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ラッパーを入れた後、次のセクションで書くバグを直してから、起動時間込みで計ってみました。</p>
<table>
	<thead>
			<tr>
					<th>何を変えたか</th>
					<th style="text-align: right">実行したテスト</th>
					<th style="text-align: right">時間</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>何も変えない</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>ホーム画面のバナーの裏にあるコードを一か所編集</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>その編集を元に戻す</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>比較用に、スイート全体（4プロセス）</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">約3½分</td>
			</tr>
	</tbody>
</table>
<p>ふだんの編集なら並列実行のスイートより約25倍速く、何も変わっていなければ約200倍速くなります。</p>
<p>記録も、もっと広い範囲で同じことを示しています。スイートが実行する、テスト以外のPythonファイル307個のうち、中央値にあたるファイルを変えたときに届くのはテスト22件、テスト時間にして約8秒です。反対の端にはいくつかのコアモジュールがあります。ほぼすべてのテストがこれらを実行するので、変更すると約1,500件のテストに届き、testmonはスイートの大半を再実行します。これは失敗ではなく、正しい答えです。これらの数字はファイルごとの上限です。testmonはブロック単位で動くので、関数を一つ変えれば、その関数を実行したテストだけが選ばれます。</p>
<p>コストが表に出るのは、ラッパーがフォールバックしたときです。全体の実行は最初と同じ1プロセスでの記録なので約12分かかり、置き換える前の並列実行のスイートより遅くなります。1プロセスなのは、testmonをpytest-xdistと組み合わせてまだ試していないからです。xdistは1.4から対応しているので、次に測るべきはそこでしょう。だからこそ、フォールバックのきっかけになるファイルのリストは、正直でいられる範囲でできるだけ短くしておくべきです。</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%88%90%e5%8a%9f%e3%81%ab%e8%a6%8b%e3%81%88%e3%81%9f%e3%83%90%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初に出た数字は、これよりも良いものでした。一か所の編集でテスト6件、3.3秒。でもそれは間違っていて、画面のどこにもそうは書いてありませんでした。</p>
<p>Python 3.14から、coverage.pyはデフォルトで新しいコアを使います。インタプリタの<code>sys.monitoring</code>の上に作られたものです。このコアがある行を報告するのは、プロセスがその行を初めて実行したときだけです。testmonはどのテストがどの行を実行したかの記録をcoverageから作るので、各行は、たまたま最初にそれを実行したテスト一つだけのものとして記録され、ほかのどのテストにも結びつきませんでした。8件のテストが共有している行を編集しても、testmonはそのうち1件しか選びません。</p>
<p>気づいたのは、この仕組みをそのまま真似した、私の別の小さなプロジェクトでした。そちらに導入する作業の一環として、わざと一行を壊して結果を比べたところ、数字が合わなかったのです。こちらのスイートに戻って、同じ確認をしました。ホーム画面のバナーのコードで、スライドのタイトルの一行を壊します。素のpytestでは8件のテストが失敗しました。testmonが選んだのは4件です。壊れた残りの4件は一度も実行されず、出力のどこにも、それらが外されたことは書いてありませんでした。</p>
<p>直すのは一行で、<code>export COVERAGE_CORE=ctrace</code>です。これでcoverageは昔ながらのCトレーサーに戻ります。トレーサーが違えば記録も違うので、記録し直しました。2,248件のテストで12分3秒です。その後、同じように壊す編集をしたら、壊れた11件のテストがすべて実行されました。</p>
<p>追記（11月7日）：11月2日リリースのtestmon 2.3.0は、プロセス内でもサブプロセスでもCトレーサーを自分で設定するようになったので、新規に入れる場合はこのexportはもう要りません。次の段落のチェックは、今でも要ります。</p>
<p>この記事で何よりも先に伝えたい教訓はこれです。選ぶテストが少なすぎるテストセレクタは、成功とまったく同じに見えます。テストは少なく、実行は速く、すべて緑。だから導入するときは、共有されている何かをわざと壊し、素のスイートとセレクタの両方を実行して、同じテストが失敗するかを確かめてください。そして、Pythonやcoverage.pyのバージョンが変わるたびに、もう一度やってください。</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%b3%e3%83%bc%e3%83%87%e3%82%a3%e3%83%b3%e3%82%b0%e3%82%a8%e3%83%bc%e3%82%b8%e3%82%a7%e3%83%b3%e3%83%88%e3%81%ae%e5%87%ba%e7%95%aa" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>そのリポジトリの変更を、もう全部自分でしているわけではありません。小さな変更の多くはコーディングエージェント（私の場合はClaude Code）がしていて、良いエージェントは編集のたびにテストを実行します。11分かかるスイートを前にすると、エージェントにも私と同じ二つの悪い選択肢しかありません。待つか、どのテストファイルが関係するかを当て推量するかです。</p>
<p>そこで、リポジトリのエージェント向けの指示に、作業中のテストはこのラッパースクリプトで実行するのを基本とし、全体を実行するときは<code>--all</code>を付ける、と書きました。エージェントも私と同じ正確なテストの集合を、編集のたびに数秒で得られます。ステップ6でスクリプトに専用のデータベースとキャッシュを持たせているのも、これが理由です。忙しい日には同じマシンで複数のセッションがテストを実行していて、使い回しているデータベースが他の誰かの実行と鉢合わせすることは、絶対にあってはなりません。</p>
<p>どれも、testmonにAI固有の何かを求めているわけではありません。同じ記録、同じ選択です。ただ、変更を加えているのが何であれ、その変更の数がとても多いと、それだけ効き目も大きくなります。</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%ab%e3%81%af%e3%82%82%e3%81%a3%e3%81%a8%e7%b2%97%e3%81%84%e3%82%82%e3%81%ae%e3%82%92%e5%85%a5%e3%82%8c%e3%81%9f%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonにはCIモードがありません。ランナーの上でもノートPCの上と同じように動きます。作者は、CIのランナー間で<code>.testmondata</code>を共有したりキャッシュしたりするのは、自分たちのホスティングサービスであるtestmon.netに比べてずっと効果が薄いと書いています。キャッシュした記録は上で挙げた点でどれも見えないままですし、私のCIはDjangoのランナーを4プロセスで動かしてスイートを実行しています。なので、testmonは入れませんでした。</p>
<p>代わりにCIに入れたのは、同じ考え方をファイル単位にしたものです。チェックのワークフローはまず小さなジョブでpushの差分を取り、リポジトリのどちら側に触れたかを判断します。PythonのAPIか、モバイルアプリか、両方か、どちらでもないか。これまでCIは、ドキュメントだけのものも含めて、pushのたびにPythonとアプリの両方のジョブを実行していました。今は、ドキュメントだけのpushなら何も実行せず、アプリだけへのpushならDjangoのスイートを飛ばします。実際のコミット5件に対してこのジョブを試しに走らせてみたところ、ドキュメントだけのコミットでは何も実行されず、アプリだけのコミットではPythonのジョブが、バックエンドだけのコミットではアプリのジョブが飛ばされました。削って例にしたものがこちらです。</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">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">changes</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">outputs</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 class="l">${{ steps.diff.outputs.python }}</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">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</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">diff</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">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</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">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</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">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">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</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">needs.changes.outputs.python == &#39;true&#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@v7</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></span></code></pre></div></div>
<p>実際のワークフローでは、比べる範囲がないとき、つまり新しいブランチやforce push、手動実行のときにも全部を実行します。</p>
<p>盲点は、形を変えただけで同じものです。testmonにはJSONファイルが見えませんが、パスのフィルタも、リストに書かない限り見えません。そこでパターンには共有のフィクスチャ、ビルドしたバンドル、生成したサイトを明示的に書き、編集する人にはリストを実態に合わせておくよう頼むコメントを添えています。ファイル単位のやり方は大雑把ですが、失敗するときは安全な方向に失敗します。Python側で何かが変わっていれば、スイート全体が実行されます。</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%a5%e3%82%8c%e3%82%8b%e4%be%a1%e5%80%a4%e3%81%af%e3%81%82%e3%82%8b%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>実行に数分かかるスイートなら、あります。本当のコストは見えないものの方で、ラッパースクリプトはそのためにあります。それでも、testmonが活きるのはノートPCでの開発ループです。CIはPython側が変わるたびに今もスイート全体を実行していて、デプロイの前に私が信頼しているのはその実行です。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/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>Sentryをセルフホストしない選択：SDKはそのまま、React Nativeのクラッシュレポートは自前サーバーで</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>クラッシュレポート</category>
      <category>セルフホスト</category>
      <category>iOS</category>
      <category>Android</category>
      <category>プライバシー</category>
      <description>Sentryをセルフホストせず、sentry.ioの費用もかけずに、React Nativeアプリのクラッシュレポートを受ける方法です。オープンソースのSDKはそのままに、DSNを自前のDjango APIに向け、グルーピング、アラート、データの除去まで自分で行います。</description>
      <content:encoded><![CDATA[<p>React Nativeアプリのクラッシュレポートを自分で受けたいなら、Sentryのサーバーをセルフホストする必要も、sentry.ioに費用を払う必要もありません。SentryのSDKはオープンソースで、DSNはどのホストにも向けられます。私は自前のDjango APIのエンドポイントに向け、そこでレポートを受け取り、グルーピングし、アラートを出し、保存しています。費用は、もともと動いていたAPIの分だけです。まだ置き換えられていないのはシンボリケーションで、スタックトレースからはソースの行まではたどれません。</p>
<p>今週まで、うちのアプリがクラッシュしたことを知る手段は、誰かが教えてくれることだけでした。ほとんどの人は教えてくれません。アプリを開き直し、また落ちたら、もう開かなくなるだけです。API側は把握できていました（ERRORログはすべてチームのチャットチャンネルに流れます）。でも、スマートフォン上でのクラッシュは、私に見える場所には何の痕跡も残していませんでした。</p>
<p>実は、アプリにはすでに報告の仕組みがひとつありました。アプリが誰かの前にエラーメッセージを表示すると、そのメッセージをAPIの小さなエンドポイントに送り、サーバーがERRORでログに残すので、これもチャットチャンネルに届きます。きっかけは、削除が <em>成功していた</em> のに行がリストに残り、もう一度タップすると「No Item matches the given query」と表示された件でした。サーバーはごく普通の404を2回記録しただけで、何も言いませんでした。この仕組みでカバーできるのは、アプリが捕まえて表示したエラーです。クラッシュは事情が違います。報告を送るはずのJavaScriptそのものが、たった今死んだのですから。</p>
<p>素直な解決策はSentryです。SentryのSDKはとても気に入っていて、React Nativeのクラッシュレポートが「解決済みの問題」になっているのはこのSDKのおかげです。ただ、ユーザーデータを預ける業者がまたひとつ増え、プライバシーポリシーの行がまたひとつ増え、うちの規模なら多くても1日数件のレポートのためにまた請求書が増えるのは避けたいところでした。そこでSDKはそのままに、サービスの方を置き換えました。アプリのDSNは自前のAPIを指していて、そこのDjangoアプリの小さな一部分が、送られてきたものを受け取り、グルーピングし、アラートを出し、保存します。</p>
<p>この記事では、その仕組みと、本物のネイティブクラッシュが実際に届くことをどう確かめたかを紹介します。</p>

<h2 class="relative group">DSNのからくり
    <div id="dsnのからくり" 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="#dsn%e3%81%ae%e3%81%8b%e3%82%89%e3%81%8f%e3%82%8a" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Sentry の DSN は <code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code> という形をしています。SDKはこれを <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code> へのPOSTに変換し、キーは <code>X-Sentry-Auth</code> ヘッダーに入れます。sentry.io固有の要素はどこにもありません。なので、アプリに埋め込むDSNはこうなります。</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://&lt;CRASH_REPORT_KEY&gt;@api.example.com/1</span></span></code></pre></div></div>
<p>そしてDjango側には、SDKが呼び出すパスに一致するルートを用意します。</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">path</span><span class="p">(</span><span class="s2">&#34;api/&lt;int:project_id&gt;/envelope/&#34;</span><span class="p">,</span> <span class="n">crash_envelope</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;crash_envelope&#34;</span><span class="p">),</span></span></span></code></pre></div></div>
<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">crash_envelope</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">project_id</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">expected</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">CRASH_REPORT_KEY</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">expected</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Crash reporting is not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">_key</span><span class="p">(</span><span class="n">request</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">expected</span><span class="o">.</span><span class="n">encode</span><span class="p">()):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Unknown key&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span></span></span></code></pre></div></div>
<p><code>_key</code> は <code>X-Sentry-Auth</code> ヘッダーから <code>sentry_key=</code> を読み取り、なければクエリ文字列を見ます。SDKによってはそちらを使うためです。</p>

<h2 class="relative group">SDKが送ってくるもの：エンベロープ
    <div id="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="#sdk%e3%81%8c%e9%80%81%e3%81%a3%e3%81%a6%e3%81%8f%e3%82%8b%e3%82%82%e3%81%ae%e3%82%a8%e3%83%b3%e3%83%99%e3%83%ad%e3%83%bc%e3%83%97" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Sentryの通信フォーマットは<a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">エンベロープ</a>です。JSONのヘッダー行が1行あり、その後に任意の数のアイテムが続きます。各アイテムはJSONのヘッダー行とペイロードの組です。アイテムヘッダーに <code>length</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">{&#34;event_id&#34;:&#34;9ec79c33...&#34;,&#34;dsn&#34;:&#34;https://...@api.example.com/1&#34;}
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;event&#34;,&#34;length&#34;:4123}
</span></span><span class="line"><span class="cl">{ ...the event JSON, 4123 bytes... }
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;client_report&#34;}
</span></span><span class="line"><span class="cl">{&#34;timestamp&#34;:1791400000.5,&#34;discarded_events&#34;:[]}</span></span></code></pre></div></div>
<p>引っかかった点が二つあります。</p>
<ul>
<li><strong>ネイティブSDKは本文をgzipしますが、JavaScriptのSDKはしません。</strong> <code>@sentry/react-native</code> が捕捉したJSエラーはプレーンテキストで届き、sentry-cocoaやsentry-androidからのネイティブクラッシュは <code>Content-Encoding: gzip</code> 付きで届きます。デコーダーはgzip、zlibでラップされたdeflate、生のdeflateに対応し、展開は1MBで打ち切ります。クラッシュイベントは数KBなので、それより大きいものはクラッシュイベントではありません。</li>
<li><strong>長さ指定のペイロードは改行を含むことがあります。</strong> <code>\n</code> で分割すると、ネイティブSDKが送るイベントがすべて壊れます。<code>length</code> があるときは必ずそれに従う必要があります。</li>
</ul>
<p>パーサーは短いものです。残すのは <code>event</code> アイテムだけで、セッション、トランザクション、添付ファイル、クライアントレポートは受け取ったうえで捨てます。</p>

<h2 class="relative group">SDKが理解できる形で応答する
    <div id="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="#sdk%e3%81%8c%e7%90%86%e8%a7%a3%e3%81%a7%e3%81%8d%e3%82%8b%e5%bd%a2%e3%81%a7%e5%bf%9c%e7%ad%94%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これは思っていたより大事でした。SDKはディスク上にキューを持ち、届いたか確信が持てないものはすべて再送します。なので、サーバーの応答次第で、SDKが送り続けるかどうかが決まります。</p>
<ul>
<li><strong>200</strong>：保存できたとき、 <em>または意図的に捨てたとき</em> 。セッションアイテムに4xxを返すと、SDKはそれを保持したまま永遠に再送します。</li>
<li><strong>429と <code>Retry-After</code></strong> ：スロットリングしたとき。エンドポイントにはIPごとの制限（1時間に120件）があります。クラッシュループに入ったスマートフォンは起動のたびに1件送ってくるからです。SDKは429を受けると、期待どおりに間隔を空けてくれます。</li>
<li><strong>400</strong>：壊れたエンベロープ。ログはエラーではなく警告にしています。壊れたレポートが教えてくれるのは「どこかのクライアントがおかしい」ということで、次のまともなレポートが何がクラッシュしたかを教えてくれます。</li>
</ul>
<p>SDKは最初の送信が届いたか確信が持てないと同じイベントを再送するので、<code>event_id</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%bd%e3%83%bc%e3%82%b9%e3%83%9e%e3%83%83%e3%83%97%e3%81%aa%e3%81%97%e3%81%a7%e3%81%ae%e3%82%b0%e3%83%ab%e3%83%bc%e3%83%94%e3%83%b3%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここは、自作の受信側がSentryなら代わりにやってくれる判断を自分でしなければならない部分です。Sentryはスタックトレースでクラッシュをグルーピングします。でも、リリース版のHermesバンドルは難読化されているので、フレーム名は1文字になり、行や列の位置はビルドのたびに変わります。フレームでグルーピングすると、同じバグがリリースのたびに新しいissueになってしまいます。</p>
<p>ソースマップがない状態（最後に詳しく書きます）では、JavaScriptのエラーで一番安定しているのは型とメッセージです。そこでフィンガープリントは、例外の型とメッセージを組み合わせ、発生ごとに変わる部分をマスクしたものにしました。</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">_MASKS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</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;0x[0-9a-fA-F]+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;addr&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</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;\b[0-9a-fA-F]</span><span class="si">{8}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{12}</span><span class="s2">\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;uuid&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</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;\b[0-9a-fA-F]{16,}\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;hex&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</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;\d+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;n&gt;&#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><code>Cannot read property 'title' of undefined (reading index 3)</code> と <code>(reading index 17)</code> は同じissueになります。SDKが <code>{{ default }}</code> を含まない独自の <code>fingerprint</code> を送ってきたらそちらを優先するので、デフォルトのグルーピングが合わないときはクライアント側から強制できます。例外を持たないイベントはマスクしたログメッセージで、どちらもないイベントはアプリ内の一番上のフレームでグルーピングします。ネイティブのシグナルクラッシュではメッセージが <code>Signal 6, Code 0</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%81%e3%83%a3%e3%83%b3%e3%83%8d%e3%83%ab%e3%82%92%e5%9f%8b%e3%82%81%e5%b0%bd%e3%81%8f%e3%81%95%e3%81%aa%e3%81%84%e3%82%a2%e3%83%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>フィンガープリントごとにissueが1つでき、各レポートはその下にイベントとして保存されます。issueがERRORでログを出す（チャットのアラートが待ち受けているのはこれです）のは、次の場合だけです。</p>
<ul>
<li><strong>新しい</strong> issue</li>
<li><strong>解決済み</strong> のissueが <strong>再発</strong> したとき（issueは再オープンされます）</li>
<li><strong>10回目、100回目、1,000回目、10,000回目</strong> の発生</li>
</ul>
<p>通常の繰り返しはカウンターを増やすだけです。 <em>無視</em> にしたissueはカウントは続けますが、アラートは一切出しません。その上にチャットのハンドラー自体のレート制限があるので、ダメなリリースでクラッシュループが起きても、届くのは壁のような大量のメッセージではなく、数件で済みます。</p>
<p>保存量も同じように上限を設けています。各issueは <strong>直近50件</strong> のイベントだけを残し、新しいものが来るたびに古いものを削除します。これで最初のグラフが壊れました。50件のイベントでは、先週どれくらいの頻度で起きていたかは分からないからです。そこで各issueに90日分の日ごとの件数も持たせ、カウンターを増やすのと同じトランザクションの中で更新するようにしました。グラフはこの件数から描き、イベントは読むためだけのものです。</p>
<p>自分で作るなら、小さな競合状態が二つ気にかける価値があります。まったく新しいクラッシュが同時に何台ものスマートフォンで起きると、二つのレポートが両方ともissueを作ろうとすることがあるので、フィンガープリントをユニークにして、負けた方が既存の行を読み直すようにします。また、90日の範囲は一番新しいレポートからではなく、サーバーの今日から数えます。時計がずれたスマートフォンは、来年の日付も含めてどんな日付のレポートでも送ってくる可能性があるからです。</p>
<p>ペイロードは128KBが上限です。超えたイベントはまずパンくずリストとデバッグ用のメタデータを削り、それでも大きければ例外、メッセージ、リリース、プラットフォームだけを残して、削ったことが分かる印を付けます。</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%81%ae%e9%99%a4%e5%8e%bb" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>SDKは <code>sendDefaultPii: false</code> で動いているので、原理的にはIPアドレスもCookieもリクエストボディも送られません。それでもサーバーはこれに頼りません。保存する前に <code>user</code> からは <code>id</code> と <code>username</code> だけを残し、<code>request</code> は丸ごと捨てます。将来のSDKのバージョンや設定でこれらが送られ始めたときのためです。</p>
<p>実機でテストしたところ、除去すべきものがさらに二つ見つかりました。</p>
<ul>
<li><strong>パンくずリストはクエリ文字列も含めたURL全体を記録します。</strong> アプリの検索リクエストは検索語をクエリ文字列に載せて送るので、検索直後にクラッシュすると、その人が何を検索したかまで保存されていたことになります。今はサーバーがパンくずリストのURLをすべて <code>?</code> で切っています。</li>
<li><strong>コンソールのパンくずリストには <code>console.log</code> に渡された生の引数が入っています。</strong> アプリがたまたまログに出した任意のオブジェクトが入りうるということです。メッセージ本文は役に立つので残し、引数は捨てます。</li>
</ul>
<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%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e5%81%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリでは、すべてが小さなモジュール1つを通ります。OTAアップデートのSDKをラップしているのと同じ理由で、OTAバンドルはネイティブモジュールが存在する前にビルドされたバイナリの上でも動くからです。<code>RNSentry</code> を持たないバイナリでトップレベルに <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> と書くとimportの時点で例外が投げられ、画面が真っ白になります。なので、SDKは遅延して <code>require</code> し、それもネイティブモジュールが本当にあることを確認してからにしています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">NativeModules</span><span class="p">,</span> <span class="nx">Platform</span><span class="p">,</span> <span class="nx">TurboModuleRegistry</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">Constants</span> <span class="kr">from</span> <span class="s2">&#34;expo-constants&#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">DSN</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPO_PUBLIC_CRASH_DSN</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">nativeModulePresent</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">TurboModuleRegistry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;RNSentry&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="nx">NativeModules</span><span class="p">.</span><span class="nx">RNSentry</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">false</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><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">initCrashReporting</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</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">DSN</span> <span class="o">||</span> <span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="nx">nativeModulePresent</span><span class="p">())</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">Sentry</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@sentry/react-native&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">dsn</span>: <span class="kt">DSN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">release</span>: <span class="kt">Constants.expoConfig?.version</span> <span class="o">??</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sendDefaultPii</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">tracesSampleRate</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAutoSessionTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...turn off the other tracing options the same way
</span></span></span><span class="line"><span class="cl">    <span class="nx">maxBreadcrumbs</span>: <span class="kt">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">beforeSend</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* tag the OTA bundle */</span> <span class="k">return</span> <span class="nx">event</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="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>扱うのはエラーだけです。サーバーはトレース、セッション、プロファイル、リプレイを保存しないので、どれも有効にしていません。DSNがなければ（開発時やE2Eのビルド）SDKは起動すらしません。ビルドプロファイルがDSNと環境（<code>staging</code> か <code>production</code>）を設定するので、どちらのビルドも同じエンドポイントに報告しつつ、区別できます。ログイン中のユーザーはアカウントIDで設定し、名前やメールアドレスは一切使いません。</p>
<p>エラーバウンダリには明示的な呼び出しが必要でした。Expo Routerのルートの <code>ErrorBoundary</code> はレンダリングエラーを捕まえて独自の画面を表示するので、そのエラーはSDKがフックしているグローバルハンドラーまで届きません。解決策は小さなラッパーで、<code>useEffect</code> の中で <code>captureException</code> を呼んでから、これまでどおりルーターのバウンダリを描画します。ルートのバウンダリにもルートごとのバウンダリにも同じものを使います。レンダリング中ではなくエフェクトの中で呼んでいるのは、再レンダリングのたびにではなく1回だけ報告するためです。ルートコンポーネントは <code>Sentry.wrap</code> でもラップしていて、それが例外を投げたら素のコンポーネントに戻ります。</p>
<p>Web向けには、全関数を無効にした <code>.web.ts</code> 版のモジュールを置いています。Metroがプラットフォーム別のファイルを選ぶので、SDKがWebのバンドルに入ることはありません。Webアプリには動かすべきネイティブモジュールがないからです。</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%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%af%e3%83%a9%e3%83%83%e3%82%b7%e3%83%a5%e3%81%8c%e5%b1%8a%e3%81%8f%e3%81%93%e3%81%a8%e3%82%92%e8%a8%bc%e6%98%8e%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ユニットテストは手書きのエンベロープをPOSTするもので、証明できるのは、SDKが送ってくると <em>私が思っている</em> ものをパーサーが処理できる、ということだけです。リリースから2日後、本番のクラッシュレポートはゼロでした。アップデートしたのはまだ3人だけだったので、ゼロでもおかしくはありません。でも「誰もクラッシュしていない」と「クラッシュが届いていない」は、サーバーから見るとまったく同じに見えます。本物を見たいと思いました。</p>
<p>そこで、DSNをポート8000のローカルDjangoと使い捨てのキーに向けた、Releaseビルドのシミュレーター用バイナリを作りました。</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">EXPO_PUBLIC_CRASH_DSN</span><span class="o">=</span>http://localcrashtestkey0000@localhost:8000/1 <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_ENVIRONMENT</span><span class="o">=</span>local-verify <span class="se">\
</span></span></span><span class="line"><span class="cl">xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release <span class="se">\
</span></span></span><span class="line"><span class="cl">  -destination <span class="s1">&#39;platform=iOS Simulator,id=&lt;udid&gt;&#39;</span> -derivedDataPath /tmp/iosbuild</span></span></code></pre></div></div>
<p>（Debugではなく Release です。Debugビルドはdev launcherをリンクするので挙動が違いますし、ユーザーが使っているのはReleaseです。）それからインストールして起動し、数秒待ってから、本物の障害と同じやり方で強制終了させました。</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">xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
</span></span><span class="line"><span class="cl">xcrun simctl launch booted com.example.myapp      <span class="c1"># prints the pid</span>
</span></span><span class="line"><span class="cl"><span class="nb">kill</span> -ABRT &lt;pid&gt;</span></span></code></pre></div></div>
<p>シミュレーターのアプリは普通のmacOSのプロセスなので、シグナルは実機とまったく同じようにSentryCrashのハンドラーに届きます。ハンドラーはレポートをディスクに書き込むだけで、プロセスが死にかけている間は何も送りません。 <strong>次の起動時</strong> にSDKがそれを見つけて送信します。再起動すると、1秒後にシミュレーターのネットワークログに <code>:8000</code> への接続が出ました。</p>
<p>ところがDjangoのログには何も出ず、少しの間混乱しました。開発サーバーは、私が設定していたログレベルでは2xx以外の応答しかログに出さないのです。データベースにはちゃんと入っていました。</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">SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 iOS 27.1 iPhone19,4 handled=False
</span></span><span class="line"><span class="cl">received 09:19:53, crashed 09:19:38
</span></span><span class="line"><span class="cl">contexts: app, device, os, ota_updates, trace · 24 breadcrumbs</span></span></code></pre></div></div>
<p>クラッシュから保存まで15秒で、そのほとんどは私が再起動していた時間です。そして本物のペイロードを見たことで、テストでは分からなかったことがいくつも分かりました。</p>
<ul>
<li><strong>ユーザーIDがアカウントIDではありませんでした。</strong> テスト用ビルドは実際にはログインしていないので、sentry-cocoaはインストールごとの独自IDを使っていました。知っておく価値があり、プライバシーの説明にも書くべきことです。レポートには、ログインしていればアカウントのID、していなければランダムなインストールIDが入ります。</li>
<li><strong>タイムゾーンがありません。</strong> cultureコンテキストがあるものと思い込んでいました。deviceコンテキストには <code>locale</code>、メモリ、画面サイズ、省電力モード、カーネルのバージョンはありますが、タイムゾーンはありません。</li>
<li><strong>ネイティブクラッシュは <code>beforeSend</code> を通りません。</strong> JSの <code>beforeSend</code> で実行中のOTAバンドルを示す <code>ota</code> タグを付けていますが、ネイティブクラッシュは次の起動時にネイティブSDKが組み立てるので、JSのフックを一度も通りません。ネイティブクラッシュにタグを付けるには、バンドルが分かった時点で <code>setTag</code> を使ってスコープに設定し、ネイティブ層に同期させる必要があります。</li>
<li><strong>パンくずリストからアプリの動きが見えました。</strong> コンソールの警告、ビューコントローラーのライフサイクル、HTTPの呼び出し。その中には分析SDK自身による収集用エンドポイントへのリクエストも含まれていて、アプリが他にどこと通信しているかを思い出させてくれます。そしてURLにはクエリ文字列が付いていて、これで先ほどの検索語の問題に気づきました。</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%ac%e3%83%9d%e3%83%bc%e3%83%88%e3%82%92%e8%aa%ad%e3%82%80" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>レポートはDjangoの管理画面で読みます。Sentryをゆるく真似たissueページで、上部に件数とResolve / Ignore / Reopenボタン、日ごとの棒グラフ、リリース・OS・端末・OTAバンドル別の内訳があり、その下でイベントを1件ずつ、例外、フレーム（アプリのフレームは強調し、ライブラリのフレームは折りたたみます）、パンくずリスト、端末のコンテキストと一緒に表示します。新しい依存関係は要りませんでした。</p>
<p>作っている途中で一つ修正が出ました。ネイティブクラッシュでは「原因（culprit）」フレームが端末上のバイナリのパスになっていて、そこにインストールごとのUUIDが含まれるため、同じクラッシュが端末ごとに違って見えていました。今は <code>0x1000a1b2c (MyApp)</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%97%e3%83%a9%e3%82%a4%e3%83%90%e3%82%b7%e3%83%bc%e3%83%9d%e3%83%aa%e3%82%b7%e3%83%bc%e3%82%92%e3%81%be%e3%82%8b%e3%81%94%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クラッシュレポートは、ほぼ個人情報です。アカウントID、スマートフォンの機種、そしてアプリが死ぬ直前の1分間にその人が何をしていたか。なので、プライバシーポリシーに1段落必要でした。きちんと書くにはレポートに何が入っているかを正確に書く必要があったので、さっき見たばかりのペイロードから列挙しました。エラー、アプリのバージョンとOTAアップデート、機種、OSと言語、空きメモリと画面サイズ、直前の数十ステップ（開いた画面、 <code>?</code> 以降を取り除いたWebアドレス、アプリ自身のログ）、そしてアカウントIDかランダムなインストールID。ポリシーには、レポートが自社のサーバーに届き、Sentryやその他のクラッシュレポート会社には送られないことも書いています。保存期間は表に専用の行を設けました。問題ごとに直近50件のレポートと、90日分の日ごとの件数です。</p>
<p>一つの段落をコードと突き合わせ始めると、残りを確認しないわけにはいきませんでした。既存のポリシーは耐えられませんでした。アカウント削除ページには、アカウントを閉じるとプッシュ通知のトークンとパスキーは即座に削除され、サーバーログは90日以内に消える、と書かれていました。どれも事実ではありませんでした。アカウントを閉じるとサインイン方法の紐付けが外れてプロフィールは匿名化されますが、プッシュ通知のトークンとパスキーは誰かが手で消すまで残りますし、ログのアーカイブにはまだ有効期限がありません。そこでポリシー全体を、コードが実際に何をしているかの棚卸しから書き直し、それらの行は今は「まだ自動では削除されません。メールでご連絡ください」となっています。安心感は減りますが、正確です。</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="#%e8%b6%b3%e3%82%8a%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong>シンボリケーション。</strong> これが本当の穴で、sentry.ioがやっていて私がまだ置き換えていない唯一のものです。スタックはJavaScriptでは難読化され、ネイティブコードではシンボル化されていません。</p>
<ul>
<li><strong>JavaScript：</strong> フレームはHermesバンドルの中を指しています。直すには、ストア向けビルドとOTAバンドルのすべてについて合成済みのHermesソースマップをリリースとバンドルをキーに保存しておき、ページを描画するときにJSのフレームに適用する必要があります。うちのクラッシュの大半はJavaScriptになるはずなので、次に取りかかるのはこれです。</li>
<li><strong>iOSのネイティブ：</strong> 命令アドレスにはビルドのdSYMが必要です。</li>
<li><strong>Android：</strong> 難読化（minification）を有効にするとJavaのフレームはR8で難読化され（<code>mapping.txt</code> が必要）、C++のフレームにはNDKのシンボルが必要です。</li>
</ul>
<p>先ほど見つけた、もう少し小さな穴もあります。OTAタグは <code>beforeSend</code> でしか付けていないので、ネイティブクラッシュにはまだ付きません。実行中のバンドルが確定した時点でスコープに設定するのは数行ですが、まだ書いていません。</p>
<p>今のところ、issueから分かるのは、 <em>何が</em> クラッシュしたか、どのリリース、OTAバンドル、端末でか、そしてその直前に何をしていたかです。ソースの <em>どこで</em> かは分かりません。週に数回リリースする小さなアプリなら、これまでは型とメッセージとパンくずリストで十分に原因を見つけられています。</p>
<p>受信側、管理画面、テストを合わせてもコードは多くなく、すでに動いていたAPIの上に追加のコストはかかっていません。難しい部分はSDKがやってくれます。スマートフォン上でクラッシュを捕まえ、次の起動時にレポートを送り出すことです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/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>フロントエンドとバックエンドを別々にデプロイする：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>
    <item>
      <title>Fly.ioでDjangoをオートスケール：負荷テストで小さなマシン1台の限界を測る</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>DjangoアプリをFly.ioのshared-cpu-1xで動かしています。1台を速くし、負荷テストでレイテンシが崩れる同時実行数を測り、その手前にオートスケールの閾値を置く方法です。</description>
      <content:encoded><![CDATA[<p>Fly.ioでDjangoを安く動かすために、私はshared-cpu-1xの小さなマシン1台をできるだけ速くし、レイテンシが崩れるまで負荷テストをして、その少し手前でFlyのオートスケーラーが2台目を起動するように設定しています。負荷テストはステップ状に負荷を上げるLocust（もう一方のアプリでは同時実行数を順に変える小さなPythonスクリプト）で行い、p95とエラー率を見ます。トラフィックが落ちればアイドルのマシンは自動で止まるので、普段のコストはアプリごとに小さなVM 1台です。</p>
<p>私は現在、個人開発者として2つのプロダクトを運用しています。ひとつは現場で見積もりを出す職人向けの<strong>現場見積もりアプリ</strong>、もうひとつは出品とリアルタイムメッセージングを備えた<strong>マーケットプレイス兼コミュニティアプリ</strong>です。</p>
<p>プロダクトもユーザーも全く違いますが、技術スタックはほぼ同一です。そしてどちらも、Fly.ioの最安ティアである<strong>shared-cpu-1xマシン</strong>だけで動いています。この制約は意図的なものです。ピーク負荷に合わせてサーバーを大きくするのではなく、小さなマシン1台から絞り出せる性能を最大化し、どこで崩壊するかを正確に測定し、その実測された限界を軸にFlyのオートスケーラーにマシンの追加と削除を任せます。この記事では、それを可能にしている最適化、閾値の根拠となった負荷テストの数値、そしてFly.ioでスケールアップ/ダウンが実際にどう動くのかを紹介します。</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="#%e5%93%b2%e5%ad%a61%e5%8f%b0%e3%82%92%e6%b8%ac%e5%ae%9a%e3%81%97%e3%81%9d%e3%82%8c%e3%82%92%e6%8e%9b%e3%81%91%e7%ae%97%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>両アプリともスケーリング戦略は同じ3ステップです。</p>
<ol>
<li>小さなマシン1台を、合理的な範囲で可能な限り速くする。</li>
<li>壊れるまで負荷テストを行い、レイテンシが崩壊する同時実行数を記録する。</li>
<li>その直前で2台目のマシンが起動するようFlyのオートスケーラーを設定し、トラフィックが減ったらアイドルマシンを停止させる。</li>
</ol>
<p>こうすればキャパシティはマシン数にほぼ線形に比例し、ベースラインコストはアプリごとに小さなVM 1台です。以下の内容はすべて、この3ステップのいずれかのためにあります。</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="#%e5%b0%8f%e3%81%95%e3%81%aa%e3%83%9e%e3%82%b7%e3%83%b31%e5%8f%b0%e3%82%92%e9%80%9f%e3%81%8f%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian：APIはWSGI、WebSocketはuvloop ASGI
    <div id="granianapiはwsgiwebsocketはuvloop-asgi" 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="#granianapi%e3%81%afwsgiwebsocket%e3%81%afuvloop-asgi" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>両バックエンドともDjango 6 + Django REST Frameworkです。APIはRustベースのサーバー<strong>Granian</strong>がサービングしますが、ASGIではなく<strong>ブロッキングスレッドプールを使うWSGI</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">granian --interface wsgi myapp.wsgi:application <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers <span class="m">1</span> --blocking-threads <span class="m">16</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-max-rss <span class="m">1400</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-kill-timeout <span class="m">15</span> --respawn-failed-workers</span></span></code></pre></div></div>
<p>最初は<code>--interface asgi --loop uvloop</code>で始めましたが、誤った選択でした。これらのアプリのHTTPビューはすべて同期（sync）で、DjangoのASGIハンドラは同期ビューを<code>thread_sensitive</code>で—ワーカーあたり一度に1つずつ—実行します。つまりASGIは、ワーカーあたりの同時リクエストを実質1つに絞りながら、asyncio/uvloop/Channelsのスタック一式をただメモリに載せていただけでした。<code>--blocking-threads</code>を使うWSGIは、同じ同期ビューを本物のスレッドプールで走らせます。ワーカー1つ × スレッド16 = I/Oバウンドなリクエスト（DB読み取り、S3のプリサイン）16件を同時に、非同期スタックより低い常駐メモリでさばきます。</p>
<p>なぜワーカーが1つなのか？VMが<code>shared-cpu-1x</code>—1コアだからです。2つ目のワーカーは1コアではPythonを並列に走らせられない（GIL + 1コア）くせに、常駐するDjangoプロセスをまるごと1つ余分に食います。Granianはまさにこの点を警告します。ワーカー1つにスレッド16が、I/Oバウンドな処理における1 vCPUマシンの正直な上限です。<code>--workers-max-rss 1400</code>はワーカーがメモリ上限を超えると再生成するので、暴走したPDFや画像処理がマシンをOOMへ引きずっていく代わりにリサイクルされます。</p>
<p>WebSocketは自前のGranianプロセスに切り出しました。uvloopと<code>--ws</code>を使う<strong>ASGI</strong>アプリで、長寿命コネクションのためにワーカー1つで動かします。同期APIにはASGIの経路はまったく不要です。ビューがソケットへプッシュしたいときはRedis経由で<code>async_to_sync(channel_layer.group_send)</code>を呼ぶので、メッセージは変わらずWebSocketプロセスへ渡ります。2つのサーバーが、それぞれ本当に得意なインターフェースを走らせているわけです。ローカルのDockerも同じWSGIスレッドプールで動かしているため、テスト時は私のラップトップがプロダクションマシン1台の近似になります。</p>

<h3 class="relative group">ホットパスにはRust
    <div id="ホットパスにはrust" 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%83%83%e3%83%88%e3%83%91%e3%82%b9%e3%81%ab%e3%81%afrust" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>PythonのホットパスにRustベースの代替があれば、必ず採用します。</p>
<ul>
<li><strong>orjson</strong>（<code>drf-orjson-renderer</code>経由）— すべてのAPIレスポンスが標準ライブラリのJSONエンコーダーではなくRustでシリアライズされます。</li>
<li><strong>blurhash-rs</strong> — 画像プレースホルダーをアップロード時に計算。</li>
<li><strong>uuid-utils</strong> — RustによるUUID生成。</li>
<li><strong>psycopg 3 (binary)</strong> — Cで高速化されたPostgresドライバー。</li>
</ul>
<p>これらはアーキテクチャを変えません。2台目のマシンが必要になる前に、1 vCPUがこなせる上限を引き上げるだけです。</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%9a%a0%e3%82%8c%e3%81%9f%e3%83%ac%e3%82%a4%e3%83%86%e3%83%b3%e3%82%b7%e7%a8%8e%e3%82%92%e6%bd%b0%e3%81%99" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>どちらのコードベースでも最大の改善はアルゴリズムではなく、コネクションの再利用でした。Granianのスレッドプールでは、新しいスレッドごとにboto3のS3クライアントを作り直しており、写真やPDFを返すすべてのエンドポイントがそのコストを払っていました。小さなカスタムストレージクラスでクライアントをプロセス単位にキャッシュするようにしたところ：</p>
<blockquote><p>新規スレッドでの署名付きURL生成が<strong>約150msから約1ms</strong>になりました。</p>
</blockquote><p>同じ教訓はデータベースにも当てはまります。FlyのPostgresはPgBouncerの背後にあるので、アプリは<code>conn_max_age=0</code>にしてコネクション再利用をプーラーに任せ、古くなったSSLコネクションでワーカーが固まらないよう<code>connect_timeout</code>を5秒にして素早く失敗させます。</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%9c%ac%e5%bd%93%e3%81%ae%e3%83%9c%e3%83%88%e3%83%ab%e3%83%8d%e3%83%83%e3%82%af%e3%81%ae%e5%89%8d%e3%81%ab%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%82%92%e7%bd%ae%e3%81%8f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マーケットプレイスの負荷テストで、覚えておく価値のある事実が判明しました。<strong>天井はCPUではなくPostgresのコネクションだった</strong>のです。閲覧トラフィックは圧倒的に匿名でユーザー間で同一であり、負荷がかかると、その同一のリストクエリ群がCPUは暇なままコネクションプールを飽和させます。</p>
<p>対策は匿名リストページ用のRedisキャッシュです。無効化はネームスペースごとのバージョンカウンターで行います。書き込みが起きたらカウンターをひとつ進めるだけで（O(1)、キーの管理は不要）、そのリソースのキャッシュ済みページはすべて即座に無効になります。短いTTLが取りこぼしをカバーします。閲覧ページをRedisから直接返すことで本当のボトルネックが消え、小さなマシンはコネクションをログイン済みトラフィックに使えるようになります。</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%ad%e3%83%bc%e3%82%ab%e3%83%bc%e3%81%aa%e3%81%97%e3%81%ae%e3%83%90%e3%83%83%e3%82%af%e3%82%b0%e3%83%a9%e3%82%a6%e3%83%b3%e3%83%89%e5%87%a6%e7%90%86" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>両アプリともCeleryではなく、Djangoのデータベースバックエンドのタスクフレームワーク<strong>django-tasks-db</strong>を使っています。独立した<code>db_worker</code>プロセスがデータベースをポーリングし、画像処理、PDF生成、機械翻訳、リマインダーといったジョブを処理します。ブローカーはなく、サイズを見積もって監視すべき追加サービスもなく、APIプロセスが重い処理でブロックされることもありません。さらにワーカーはPgBouncerをバイパスしてPostgresに直接接続します。長時間走るジョブはトランザクションプーリングと相性が悪いからです。</p>

<h3 class="relative group">メモリ：jemallocとスワップのクッション
    <div id="メモリjemallocとスワップのクッション" 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%a2%e3%83%aajemalloc%e3%81%a8%e3%82%b9%e3%83%af%e3%83%83%e3%83%97%e3%81%ae%e3%82%af%e3%83%83%e3%82%b7%e3%83%a7%e3%83%b3" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>画像とPDFの処理は、定常状態ベースの見積もりでは予測できないメモリスパイクを起こします。安上がりな緩和策が2つ。Dockerイメージに<strong>jemalloc</strong>をプリロードして断片化を抑えること、そして<strong>全マシンにスワップ</strong>を持たせることです（APIマシンに2GB、WebSocketマシンに1GB）。スパイクが来てもプロセスがOOMで殺される代わりにディスクへスワップされます。スワップは性能のための機能ではなく、メモリ割り当てを小さく保つための保険です。</p>

<h2 class="relative group">Djangoの負荷テスト：膝（knee）を見つける
    <div id="djangoの負荷テスト膝kneeを見つける" 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%e3%81%ae%e8%b2%a0%e8%8d%b7%e3%83%86%e3%82%b9%e3%83%88%e8%86%9dknee%e3%82%92%e8%a6%8b%e3%81%a4%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>閾値の根拠として通用するのは、実測した値だけです。プロジェクトごとに専用のハーネスがあります。</p>
<ul>
<li>見積もりアプリには、同時実行数を掃引してRPS、p50/p95/p99、エラー率を報告する小さなPythonのストレステストスクリプトがあります。生成した数百件のサンプルデータに対して実行するので、リスト系エンドポイントは現実的なデータ量でクエリされます。</li>
<li>マーケットプレイスは<strong>Locust</strong>で30秒刻みのステップ負荷により50 → 500ユーザーまでランプして飽和点を探し、実際の閲覧中心トラフィックに合わせたエンドポイント重み付けを使っています。さらに別建ての小さなテストスイートが、仮想ユーザー5 → 50をp95 &lt; 2秒の閾値付きでプロダクションに対してランプします。</li>
</ul>
<p>見積もりアプリのスイープの代表的な数値です（Granianワーカー1 × ブロッキングスレッド16、開発マシン上のDocker）：</p>
<table>
	<thead>
			<tr>
					<th>エンドポイント</th>
					<th>同時実行</th>
					<th>RPS</th>
					<th>p95</th>
					<th>エラー</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>重い一覧</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>軽い一覧</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>重い一覧</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>そして他のすべてを決める数字が、<strong>shared-cpu-1xマシン1台</strong>でマーケットプレイスを負荷テストした結果から出てきます。旧来のASGIビルドでは、これが固い壁でした。同期ビューが実質<strong>3スロット</strong>ほどに直列化されるため、p95は同時リクエスト約5までは300ms未満を保ち、すべてがthread_sensitiveのロックの後ろに並ぶことで約8〜10を超えると崩壊していました。この膝を外側へ押し出したのが、APIをWSGIスレッドプールへ移したことです—マシンあたりの実質同時スロットが約3ではなく<strong>16</strong>になり、この新しい天井がオートスケーリング設定全体の根拠です。</p>

<h2 class="relative group">Fly.ioでのスケーリングの仕組み
    <div id="flyioでのスケーリングの仕組み" 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="#flyio%e3%81%a7%e3%81%ae%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%aa%e3%83%b3%e3%82%b0%e3%81%ae%e4%bb%95%e7%b5%84%e3%81%bf" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Flyのモデルはシンプルです。アプリはプロキシの背後にある同一マシンの集合で、プロキシがマシンごとの負荷を数えます。宣言するのは2つの数字だけです。</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 class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;requests&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">12</span>   <span class="c"># これを超えたら別のマシンを起動</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">24</span>   <span class="c"># これを超えたらキューイングせず負荷を遮断</span>
</span></span><span class="line"><span class="cl">
</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><strong>スケールアップ：</strong> あるマシンの処理中（in-flight）リクエストがsoft limitを超えると、プロキシは停止中のマシンを起動し、新しいトラフィックをそちらへ流します。WSGIスレッドプールによってマシンあたり実質16の同時スロットがあるので、soft limitは12—プールの約75%で、ユーザーが数秒のレイテンシを体験した後ではなく、スレッドにまだ余裕があるうちに2台目が起動します。hard limitの24（プールの1.5倍）はサーキットブレーカーです。短いバーストはスレッド上でキューイングされ、それを超えたら、1台のマシンが溶け落ちるのを許すのではなく、プロキシが負荷を遮断します。（旧来のASGIビルドは実質スロットが約3で8/20でした。プールを広げたことで両方の数字を引き上げられたのです。）</p>
<p><strong>スケールダウン：</strong> トラフィックが減ると、Flyはアイドルマシンを自動的に停止し、<code>min_machines_running = 1</code>まで縮小します。停止中のマシンはコンピューティング費用がかからず rootfs のストレージ分だけなので、各アプリのベースラインコストは文字通り小さなVM 1台で、バースト用のキャパシティは常に待機しています。</p>
<p>私はここに、停止中のマシンは「1秒未満でコールドスタートする」と書いていました。それは間違いで、しかも高くつく間違いでした。想定ではなく実際に計測したところ、コールドスタートには<strong>25〜33秒</strong>かかっていて、まだ起動中のマシンに振られたリクエストはその時間をまるごと待たされます。当初は、ヘルスチェックがずっと緑のままCDNのダッシュボードに出ていた22パーセントの失敗率を、このコールドスタートのせいだと考えていました。ところがその数字は、CDN自身のEarly Hintsのプローブがオリジンで失敗していたもので、実際のクライアントが受け取った504はひとつもありませんでした。それでも、コールドスタートそのものは本物でした。</p>
<p>対処は上のスケーリングの数値にはありません。あの数値は問題ないのです。マシンが立ち上がる途中で何をしているか、そして Fly にマシンを停止ではなくサスペンドさせるかどうかにあります。そうすれば復帰は約3秒のメモリ復元になります。別記事にまとめました。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ゼロスケールと、再起動のたびに起きる30秒の障害</a>で、診断の過程と変更すべき点を扱っています。<code>auto_stop_machines</code> を使って Fly で何かを動かしているなら、この段落を信じる前にそちらを読んでください。</p>
<p>ひとつ微妙なポイント：同時実行のタイプが<code>connections</code>ではなく<code>requests</code>であることです。現代のクライアントはkeep-aliveコネクションを開きっぱなしにするため、コネクションを数えても実負荷でほとんど変動せず、オートスケーラーが発火しません。処理中リクエストを数えることで、実際の仕事量が追跡できます。</p>

<h3 class="relative group">WebSocketは別の軸でスケールする
    <div id="websocketは別の軸でスケールする" 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="#websocket%e3%81%af%e5%88%a5%e3%81%ae%e8%bb%b8%e3%81%a7%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%ab%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マーケットプレイスはWebSocketサーバーを、同じDockerイメージを共有する<strong>2つ目のFlyアプリ</strong>に分離しています。APIアプリはWSGIでワーカー1つ × ブロッキングスレッド16を動かし、<em>リクエスト</em>を数えます（soft limit 12）。WebSocketアプリは<strong>512MB</strong>のマシンでASGIでuvloopとともにワーカー1つを動かし、<em>コネクション</em>を数えます。</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 class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>長寿命のソケットは維持コストこそほぼゼロですが、スロットを何時間も占有します。一方HTTPリクエストがスロットを占有するのはミリ秒単位です。同じオートスケーリングシグナルで両方を賄うことはできません。分離すれば各フリートがそれぞれに正直なメトリクスでスケールし、アイドル状態のWebSocketキャパシティのコストは2GBではなく512MBで済みます。Channels側では、Redisレイヤーを<code>capacity: 1500</code>、メッセージ有効期限60秒で動かし、30秒ごとにソケットのkeepaliveを送ります。FlyのRedisプロキシが、Channelsの依存する長寿命のBRPOPソケットをアイドルと見なして切断してしまうからです。</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%b3%e3%83%b3%e3%83%94%e3%83%a5%e3%83%bc%e3%83%86%e3%82%a3%e3%83%b3%e3%82%b0%e3%82%92%e3%82%a8%e3%83%83%e3%82%b8%e3%81%a8%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%81%b8%e3%82%aa%e3%83%95%e3%83%ad%e3%83%bc%e3%83%89%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最も安いサーバー処理は、サーバーが一切やらない処理です。</p>
<ul>
<li><strong>静的ファイルとメディア</strong>はFlyのS3互換オブジェクトストレージに置き、非公開メディアは署名付きURLでCDN経由配信します。画像のバイト列はDjangoマシンを一切通りません。</li>
<li><strong>AI推論はユーザーのスマートフォン上で動きます。</strong> 両アプリとも小さなローカルLLM（量子化された4Bモデル、約2.7GB）を組み込み、端末上で動かしています。サーバー側の推論コストはゼロで、オフラインでも動作します。</li>
<li><strong>オフラインファーストのモバイル</strong>（端末上のSQLiteと、リトライ付きの同期キュー）のおかげで、アプリはキー入力のたびに細かなリクエストで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="#%e6%ae%8b%e3%82%8a%e3%81%ae%e3%82%b9%e3%82%bf%e3%83%83%e3%82%af%e3%82%92%e6%89%8b%e7%9f%ad%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>認証はホスティング型のIDプロバイダーとWebAuthnパスキーで、WebSocketコネクションはアップグレード完了前に認証します。プロダクションはPostgres、開発はSQLite。管理画面と監査履歴には既製のDjangoパッケージをいくつか使っています。モバイルはExpo上のReact NativeとTypeScriptにExpo RouterとTailwindを組み合わせ、OTAアップデートはセルフホストのExpo Updatesサーバーから配信し、アプリ内課金はサブスクリプション管理サービスに任せています。品質ゲートはバックエンドがpytestとmypy、クライアントがユニットテストとE2Eテストです。</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%8c%81%e3%81%a1%e5%b8%b0%e3%81%a3%e3%81%a6%e3%81%bb%e3%81%97%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>移植できるアイデアは特定のライブラリではなく、ループそのものです。安いマシン1台を本当に速くする（Rustのホットパス、コネクション再利用、本当のボトルネックの前のキャッシュ、CDNとクライアントへのオフロード）。どの同時実行数で壊れるかを特定できるまで負荷テストする。オートスケーラーのsoft limitをその膝に置き、アイドルマシンは停止させ、最小フリートは1に保つ。プロダクションアプリ2つ、リアルタイム機能、AIアシスタント — それでも定常時の請求額は、プラットフォームで最安のVM数台分です。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>EAS Updateを使わずにExpoのOTAアップデートをセルフホスト：DjangoとTigrisでアップデートサーバーを自作する</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Django</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <category>Self-Hosted</category>
      <category>Tigris</category>
      <category>S3</category>
      <description>ExpoのOTAアップデートの仕組みと、EAS Updateを使わずにセルフホストする方法。Expo Updatesプロトコルに対応したDjango REST Frameworkのサーバーに、バンドルの置き場はTigris S3。運用費は月1ドルほどです。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>2026年9月13日追記。</strong> この記事の公開後、CodemagicがCodePush系の<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>でセルフホストできるOTAサーバー、<a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>をリリースしました。サーバーを自分で作りたくなくてここにたどり着いた方には、いまや現実的な選択肢です。私自身、その後<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >この記事の構成からPatchへアプリを移行</a>してその顛末を書きましたし、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/#%e3%81%8a%e3%82%8f%e3%82%8a%e3%81%ab" >おわりに</a>の下に短い補足も足してあります。開示: その移行と執筆にはCodemagicから報酬を受けていて、ここで触れることも先方からの依頼です。本文のコードは、その後短い例に絞りました。</p>
</blockquote><p>私が開発している見積もりアプリでは、バグ修正をOTA（Over-the-Air）で配信しています。JavaScriptの変更が、App Storeの審査を通さずにユーザーの端末へ届くわけです。ExpoのホストサービスであるEAS Updateを使えば同じことができるのですが、私はDjango REST FrameworkとTigris S3で自前のサーバーを作りました。以来、iOS・Androidのアップデートはすべてここから配信しています。</p>
<p>ExpoのOTAアップデートの仕組みはこうです。アプリに入っている<code>expo-updates</code>ライブラリが、プラットフォームとランタイムバージョンをヘッダーに載せて、アップデートサーバーにマニフェストを要求します。サーバーは互換性のある最新のアップデートを、そのJavaScriptバンドルとアセットのURLと一緒に返し、アプリはそれらをダウンロードして、次回の起動時から新しいバンドルで動きます。EAS Updateはこのプロトコルを実装したExpoのホストサーバーですが、同じプロトコルに対応していればどんなサーバーでも構いません。私が作ったのは、まさにそういうサーバーです。</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%9d%e3%82%82%e3%81%9d%e3%82%82%e3%81%aa%e3%81%9c%e8%87%aa%e5%89%8d%e3%81%a7%e3%83%9b%e3%82%b9%e3%83%86%e3%82%a3%e3%83%b3%e3%82%b0%e3%81%99%e3%82%8b%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私の場合、一番の理由はコストでした。EAS Updateの料金は使用量に応じて増えていくので、ユーザーが増えて更新頻度も上がるとそれなりの金額になります。一方、TigrisのようなS3互換ストレージならほとんどタダみたいなものです。ほかの理由が効いてくる場合もあるでしょう。アプリケーションのアセットをすべて自社インフラ内に置くことが求められる業界もありますし、サーバーを自分で持てばアップデートの流れを丸ごと制御できるので、特定のユーザー層への配信やバンドルのA/Bテストも自由にできます。Expoの稼働状況や次の料金改定に振り回されずに済むのも、地味に大きいところです。</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%a8%e4%bd%93%e3%81%ae%e6%a7%8b%e6%88%90" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>構成要素は4つです。アップデートのマニフェストを返しメタデータを保存するDjangoバックエンド、バンドルとアセットの実体を置くTigris、エクスポート・アップロード・登録をまとめて行うパブリッシュ用スクリプト、そしてExpoのサーバーではなく自分のサーバーを向くように設定したモバイルアプリ本体です。</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">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Mobile App    │
</span></span><span class="line"><span class="cl">│  (expo-updates) │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 1. Request manifest
</span></span><span class="line"><span class="cl">         │    (with headers: platform, runtime-version)
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│  Django Server  │
</span></span><span class="line"><span class="cl">│  /api/expo-     │◄─── 2. Query DB for latest update
</span></span><span class="line"><span class="cl">│   updates/      │
</span></span><span class="line"><span class="cl">│   manifest/     │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 3. Generate presigned URLs
</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></span><span class="line"><span class="cl">│   Tigris S3     │
</span></span><span class="line"><span class="cl">│  (Asset Files)  │◄─── 4. App downloads bundles directly
</span></span><span class="line"><span class="cl">└─────────────────┘</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%ae%9f%e8%a3%85" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 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%a2%e3%83%87%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>すべての土台になるのが<code>ExpoUpdate</code>と<code>ExpoUpdateAsset</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">class</span> <span class="nc">ExpoUpdate</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">choices</span><span class="o">=</span><span class="p">[(</span><span class="s2">&#34;ios&#34;</span><span class="p">,</span> <span class="s2">&#34;iOS&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;android&#34;</span><span class="p">,</span> <span class="s2">&#34;Android&#34;</span><span class="p">)],</span>
</span></span><span class="line"><span class="cl">        <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">is_active</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">JSONField</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">created_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>設計上のポイントがいくつかあります。<code>runtime_version</code>は<code>app.json</code>の<code>runtimeVersion</code>に対応するフィールドで、クライアントは自分のランタイムバージョンに一致するアップデートしかダウンロードしないため、ここが要になります。バンドルの中身が違うので、iOSとAndroidは別レコードです。<code>is_active</code>はロールバックの仕組みで、問題のあるアップデートを無効化すればクライアントはひとつ前のものに戻ります。そして<code>manifest_data</code>にはExpo Updates v1プロトコルのマニフェストを丸ごとJSONで保存しておくので、配信時は取り出すだけで済みます。</p>
<p><code>ExpoUpdateAsset</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">class</span> <span class="nc">ExpoUpdateAsset</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="s2">&#34;assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">hash</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">content_type</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_extension</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_path</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_size</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span></span></span></code></pre></div></div>
<p>アセットはSHA-256ハッシュで参照するので、不変でキャッシュも効きます。同じアセットを複数のアップデートで共有することも可能です。</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%9e%e3%83%8b%e3%83%95%e3%82%a7%e3%82%b9%e3%83%88%e3%82%a8%e3%83%b3%e3%83%89%e3%83%9d%e3%82%a4%e3%83%b3%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>アプリとサーバーが実際にやり取りするのが<code>/api/expo-updates/manifest/</code>です。<a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates v1プロトコル</a>を実装しています。</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;get&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;manifest&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manifest</span><span class="p">(</span><span class="bp">self</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"># Extract required headers</span>
</span></span><span class="line"><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PROTOCOL_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PLATFORM&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_RUNTIME_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Validate protocol version</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">protocol_version</span> <span class="o">!=</span> <span class="s2">&#34;1&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Unsupported protocol version: </span><span class="si">{</span><span class="n">protocol_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="n">status</span><span class="o">=</span><span class="mi">400</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Find latest active update for this runtime + platform</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">order_by</span><span class="p">(</span><span class="s2">&#34;-created_at&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># No update available - client uses embedded bundle</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">update</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</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;expo-protocol-version&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate presigned URLs for all assets</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">build_manifest</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># fills in presigned asset URLs</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">manifest_data</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span></span></span></code></pre></div></div>
<p>ポイントは3つあります。アップデートがないときは204を返し、アプリは組み込みのバンドルをそのまま使い続けます。マニフェスト内のアセットURLは署名付きURL（presigned URL）なので、Djangoを経由せずTigrisのCDNから直接ダウンロードされます。そしてレスポンスの<code>expo-protocol-version</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%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88%e3%81%ae%e3%83%91%e3%83%96%e3%83%aa%e3%83%83%e3%82%b7%e3%83%a5" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>パブリッシュはDjangoの管理コマンドと、それを包むシェルスクリプトで行います。管理コマンドは<code>expo export</code>の出力を読み取り、各アセットのSHA-256ハッシュを計算し、バンドルとアセットをTigrisへ並列アップロードし、データベースにレコードを作り、必要ならプロダクション同期用のインポートJSONもバケットに置きます。要点だけに絞るとこうなります。</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">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.db</span> <span class="kn">import</span> <span class="n">transaction</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">publish</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">runtime_version</span><span class="p">,</span> <span class="n">manifest</span><span class="p">,</span> <span class="n">files</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload the bundle and assets in parallel (keys are content hashes)</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">list</span><span class="p">(</span><span class="n">executor</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">files</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Swap the active update in one transaction</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">manifest_data</span><span class="o">=</span><span class="n">manifest</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span></span></span></code></pre></div></div>
<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="c1"># Publish to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Dry run to validate</span>
</span></span><span class="line"><span class="cl">./publish.sh --dry-run</span></span></code></pre></div></div>
<p>エクスポート前にプロダクションの環境変数を読み込み、プラットフォームは片方でも両方でも指定でき、次に説明するプロダクション同期まで面倒を見てくれます。</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%97%e3%83%ad%e3%83%80%e3%82%af%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%b8%e3%81%ae%e5%90%8c%e6%9c%9f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>プロダクションの認証情報を手元のマシンに置きたくなかったので、本番へのデプロイは2段階にしました。まずローカルでパブリッシュし（アセットはTigrisへ、メタデータのJSONスナップショットも一緒に）、そのあとS3のパスを添えてプロダクションのAPIを呼び、サーバー側でメタデータを取り込んでもらいます。</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;post&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;import-update&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">import_update</span><span class="p">(</span><span class="bp">self</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"># Authenticate via Bearer token</span>
</span></span><span class="line"><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_AUTHORIZATION&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;Bearer &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">settings</span><span class="o">.</span><span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Download the import JSON from the bucket</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_key</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;s3_key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</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">loads</span><span class="p">(</span><span class="n">obj</span><span class="p">[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Import into the production database</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">update</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;id&#34;</span><span class="p">],</span> <span class="n">defaults</span><span class="o">=</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">asset_data</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s2">&#34;assets&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ExpoUpdateAsset</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</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="c1"># Clean up the import JSON</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;update_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">update</span><span class="o">.</span><span class="n">id</span><span class="p">)})</span></span></span></code></pre></div></div>

<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%a2%e3%83%97%e3%83%aa%e3%82%92%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%81%ab%e5%90%91%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>app.json</code>でアップデートURLとランタイムバージョンを設定します。</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;expo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;runtimeVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;1.0.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updates&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://api.example.com/api/expo-updates/manifest/&#34;</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>runtimeVersion</code>はアプリとサーバーで厳密に揃えてください。ネイティブコードを変えたときやExpo SDKを上げたときは、ランタイムバージョンを上げて、そのバージョン向けにアップデートを出し直します。</p>

<h2 class="relative group">Tigrisでのストレージ
    <div id="tigrisでのストレージ" 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="#tigris%e3%81%a7%e3%81%ae%e3%82%b9%e3%83%88%e3%83%ac%e3%83%bc%e3%82%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>TigrisはS3互換のオブジェクトストレージで、本家AWS S3よりかなり安く、グローバルなエッジキャッシュも込みです。数メガバイト級のバンドルをユーザーがダウンロードすることを考えると、これが効いてきます。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"># settings.py</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET_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;BUCKET_NAME&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ENDPOINT_URL_S3</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;AWS_ENDPOINT_URL_S3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ACCESS_KEY_ID</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;AWS_ACCESS_KEY_ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_SECRET_ACCESS_KEY</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;AWS_SECRET_ACCESS_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_REGION</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;AWS_REGION&#34;</span><span class="p">,</span> <span class="s2">&#34;auto&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>クライアントの生成はごく普通のboto3です。</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">boto3</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">create_s3_client</span><span class="p">(</span><span class="n">endpoint_url</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="n">access_key</span><span class="p">,</span> <span class="n">secret_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;s3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">endpoint_url</span><span class="o">=</span><span class="n">endpoint_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">region_name</span><span class="o">=</span><span class="n">region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_access_key_id</span><span class="o">=</span><span class="n">access_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_secret_access_key</span><span class="o">=</span><span class="n">secret_key</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を使うため、アプリはCDNから直接取得します。</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">presigned_url</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;get_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">bucket_name</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">3600</span><span class="p">,</span>  <span class="c1"># 1 hour</span>
</span></span><span class="line"><span class="cl"><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="#%e3%82%bb%e3%82%ad%e3%83%a5%e3%83%aa%e3%83%86%e3%82%a3" aria-label="アンカー">#</a>
    </span>
    
</h2>
<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">IMPORT_TOKEN</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;IMPORT_TOKEN&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Constant-time comparison prevents timing attacks</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span></span></span></code></pre></div></div>
<p>アセットの整合性は、この設計なら勝手についてきます。すべてSHA-256ハッシュで検証されるので、改ざんされたアセットは単に失敗するだけです。署名付きURLも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="#%e9%80%9f%e5%ba%a6%e3%81%be%e3%82%8f%e3%82%8a" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>(runtime_version, platform, is_active, -created_at)</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">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>パブリッシュ用スクリプトはアセットを並列でアップロードします。</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">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">asset</span><span class="p">):</span> <span class="n">asset</span> <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">assets</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="n">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Track progress</span></span></span></code></pre></div></div>
<p>アセット50個ほどの典型的なアップデートで、パブリッシュ時間は約2分から15秒になりました。配信側でやることは何もありません。Tigrisが勝手にユーザーの近くのエッジへキャッシュしてくれます。</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%97%a5%e3%80%85%e3%81%ae%e9%81%8b%e7%94%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>

<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%e4%b8%ad" aria-label="アンカー">#</a>
    </span>
    
</h3>
<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="c1"># 1. Make code changes in the app</span>
</span></span><span class="line"><span class="cl">git commit -am <span class="s2">&#34;Fix bug&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Publish OTA update to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Test on device</span>
</span></span><span class="line"><span class="cl"><span class="c1"># App automatically downloads and applies update</span></span></span></code></pre></div></div>

<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%97%e3%83%ad%e3%83%80%e3%82%af%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%b8%e3%81%ae%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9" aria-label="アンカー">#</a>
    </span>
    
</h3>
<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="c1"># 1. Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Monitor</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Check Django admin for update records</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Verify assets in Tigris dashboard</span></span></span></code></pre></div></div>

<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%ad%e3%83%bc%e3%83%ab%e3%83%90%e3%83%83%e3%82%af" aria-label="アンカー">#</a>
    </span>
    
</h3>
<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="c1"># Mark problematic update as inactive in Django admin</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or via management shell:</span>
</span></span><span class="line"><span class="cl">python manage.py shell
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; from updates.models import ExpoUpdate
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; <span class="nv">bad_update</span> <span class="o">=</span> ExpoUpdate.objects.get<span class="o">(</span><span class="nv">id</span><span class="o">=</span><span class="s2">&#34;uuid-here&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.is_active <span class="o">=</span> False
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.save<span class="o">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clients will now receive the previous active update</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%8b%e3%81%8b%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e8%b2%bb%e7%94%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このアプリのアクティブユーザーは500人ほどです。Tigrisでは、過去のアップデート約200MB分のストレージが月$0.02ほど、アップデートのダウンロードによる月約50GBのエグレスが$1.00ほど。Django側はもともとAPIを動かしているサーバーに相乗りしているので、追加コストはゼロです。全部で月1ドルといったところ。同じ規模でEAS Updateを使うと年間$300〜500ほどになるはずなので、元はほぼ即座に取れました。</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%8b%e3%82%bf%e3%83%aa%e3%83%b3%e3%82%b0%e3%81%a8%e3%83%87%e3%83%90%e3%83%83%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>凝ったことはしていません。ViewSetがマニフェストのリクエストをすべてログに残します。</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">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Manifest request: platform=</span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2">, runtime=</span><span class="si">{</span><span class="n">runtime_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>実質的なダッシュボードはDjango adminです。モデルを登録しておけば、確認も絞り込みも一通りできます。</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="nd">@admin.register</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAdmin</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">ModelAdmin</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_display</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;created_at&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_filter</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">search_fields</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;description&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>クライアント側では、<code>expo-updates</code>が何を見ているかを教えてくれます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="o">*</span> <span class="kr">as</span> <span class="nx">Updates</span> <span class="kr">from</span> <span class="s1">&#39;expo-updates&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Updates</span><span class="p">.</span><span class="nx">checkForUpdateAsync</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">update</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Update available:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">isAvailable</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Manifest:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">manifest</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><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="#%e3%83%8f%e3%83%9e%e3%82%8a%e3%81%a9%e3%81%93%e3%82%8d" aria-label="アンカー">#</a>
    </span>
    
</h2>

<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%a9%e3%83%b3%e3%82%bf%e3%82%a4%e3%83%a0%e3%83%90%e3%83%bc%e3%82%b8%e3%83%a7%e3%83%b3%e3%81%ae%e4%b8%8d%e4%b8%80%e8%87%b4" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>一番よくある失敗が、一番静かに起きます。クライアントは自分のランタイムバージョンに一致するアップデートしかダウンロードしません。インストール済みのアプリがランタイム<code>1.0.0</code>なのに<code>1.0.1</code>向けにパブリッシュすると、何も届かず、エラーも出ません。ランタイムバージョンはビルドと同期させ、上げるのはネイティブコードが変わったときだけにしてください。</p>

<h3 class="relative group">createdAtタイムスタンプ
    <div id="createdatタイムスタンプ" 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="#createdat%e3%82%bf%e3%82%a4%e3%83%a0%e3%82%b9%e3%82%bf%e3%83%b3%e3%83%97" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>expo-updates</code>クライアントはマニフェストの<code>createdAt</code>を組み込みバンドルの<code>commitTime</code>と比較し、<code>createdAt</code>のほうが新しいアップデートだけを適用します。ViewSetではデータベースのタイムスタンプで上書きしています。</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">manifest_data</span><span class="p">[</span><span class="s2">&#34;createdAt&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">update</span><span class="o">.</span><span class="n">created_at</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&#34;%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%S.</span><span class="si">%f</span><span class="s2">Z&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>ローカル開発では、OTAをパブリッシュした<em>あとに</em>バイナリをビルドした場合、タイムスタンプが新しくなるようにOTAを出し直してください。</p>

<h3 class="relative group">マニフェストのアセット<code>key</code>
    <div id="マニフェストのアセットkey" 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%83%8b%e3%83%95%e3%82%a7%e3%82%b9%e3%83%88%e3%81%ae%e3%82%a2%e3%82%bb%e3%83%83%e3%83%88key" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マニフェスト内の各アセットの<code>key</code>フィールドは<code>expo-updates</code>がキャッシュに使うもので、ランダムなUUIDや適当な文字列ではなく、決定論的なハッシュ（ファイル名のMD5など）でなければなりません。ここを間違えると、クライアントがアセットを正しくキャッシュ・取得できなくなり、最初の1回だけ成功したあとアップデートが黙って壊れます。この問題を報告してくれた読者のRaphael Mutschlerさんに感謝します。原因を突き止めるまで、彼の環境ではアップデートがちょうど1回だけ動いていたそうです。</p>

<h3 class="relative group"><code>expoClient</code>のアプリ設定
    <div id="expoclientのアプリ設定" 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="#expoclient%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e8%a8%ad%e5%ae%9a" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>Linking</code>や<code>Constants</code>など、実行時にアプリ設定を読むものを使っている場合は、マニフェストの<code>extra.expoClient</code>フィールドにアプリ設定を含める必要があります。これがないと、最初は普通に起動するのに、アプリを閉じたあと再起動できなかったりクラッシュしたりします。<code>expo-updates</code>が組み込みマニフェストをOTAのものに置き換えるため、<code>expoClient</code>が欠けているとこれらのAPIが頼りにしている設定へアクセスできなくなるのです。これもRaphael Mutschlerさんが見つけてくれました。</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%a2%e3%82%bb%e3%83%83%e3%83%88%e3%81%ae%e6%8e%83%e9%99%a4" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>古いアップデートはTigrisに溜まっていきます。今のところ、掃除は手作業です。</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"># Delete updates older than 30 days</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">cutoff</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">old_updates</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">created_at__lt</span><span class="o">=</span><span class="n">cutoff</span><span class="p">,</span> <span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">old_updates</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete assets from S3</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">update</span><span class="o">.</span><span class="n">assets</span><span class="o">.</span><span class="n">all</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete DB records</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span><span class="o">.</span><span class="n">delete</span><span class="p">()</span></span></span></code></pre></div></div>
<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%93%e3%81%ae%e5%85%88%e3%82%84%e3%82%8a%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><em>(2026年9月: いまではPatchがこの3つも、それ以上もやってくれます。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >移行の記事</a>を参照してください。)</em></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%ae%b5%e9%9a%8e%e7%9a%84%e3%83%ad%e3%83%bc%e3%83%ab%e3%82%a2%e3%82%a6%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>rollout_percentage</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">rollout_percentage</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the manifest view:</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Hash user ID and check if they&#39;re in rollout group</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_hash</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(),</span> <span class="mi">16</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">user_hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>  <span class="c1"># No update</span></span></span></code></pre></div></div>

<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%e7%94%a8%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88%e3%81%ae%e5%88%86%e9%9b%a2" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>environment</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">environment</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Client sends environment in custom header</span>
</span></span><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_X_UPDATE_ENVIRONMENT&#34;</span><span class="p">,</span> <span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">environment</span><span class="o">=</span><span class="n">environment</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span></span></span></code></pre></div></div>

<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%80%e3%82%a6%e3%83%b3%e3%83%ad%e3%83%bc%e3%83%89%e3%81%ae%e8%a8%88%e6%b8%ac" aria-label="アンカー">#</a>
    </span>
    
</h3>
<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">class</span> <span class="nc">ExpoUpdateDownload</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">null</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloaded_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</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="#%e3%81%8a%e3%82%8f%e3%82%8a%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>システム全体で、Djangoのモデルが2つとビューがいくつか、パブリッシュ用スクリプトがひとつ、インフラ費用が月1ドル。始める前に想像していたよりずっと小さく収まり、以来両プラットフォームで淡々と動き続けています。</p>
<p>ここに載せたコードはそのプロダクションアプリのコードを例として簡略化したものなので、作るものに合わせて手を入れてください。読者のRaphael Mutschlerさんがスタンドアロンの実装を<a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>として公開してくれているので、すぐ動くものから始めたい方はそちらもどうぞ。</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%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%82%92%e8%87%aa%e5%88%86%e3%81%a7%e6%9b%b8%e3%81%8d%e3%81%9f%e3%81%8f%e3%81%aa%e3%81%84%e3%81%aa%e3%82%89" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>この記事を書いたあと、Djangoなしで同じ領域をカバーするものが2つ登場しました。1つ目は、上で触れたRaphaelさんの<code>expo-ota-server</code>。2つ目は<a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Codemagic Patch</a>です。<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>で、Docker Composeのインストールで自分のマシン上で動き、Expo UpdatesプロトコルではなくCodePushを話すので、<code>expo-updates</code>をそのSDKに、<code>eas update</code>を<code>cmpatch release-react</code>に置き換えることになります。S3互換ストレージを使えるので、この記事に出てくるようなTigrisのバケットもそのまま使えます。</p>
<p>「この先やりたいこと」に挙げて結局作らなかったものが、最初から揃っています。ステージングとプロダクションのデプロイメント、パーセンテージ指定のロールアウト、リリースごとのダウンロード数とインストール数、必須アップデート、バイナリパッチ、コード署名、そして新しいバンドルがアプリの準備完了を報告する前にクラッシュしたときの端末側でのロールバック。引き換えになるのは、SDKをアプリに入れる必要があるため誰かにPatchのアップデートが届く前に新しいネイティブバイナリが要ることと、管理コマンドの代わりに運用するサーバーが要ることです。</p>
<p>私は自分のアプリの1つを、コミット済みのネイティブプロジェクトと本物のリリースパイプラインごと、この記事の構成からPatchへ移行しました。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >その記事</a>では、うまくいったこと、いかなかったこと、実際どれくらい大変かを書いています。この作業はCodemagicのスポンサードで、先方のレビューは事実確認のみで、編集的なものではありません。</p>
<hr>
<p><em>仕様の全文は<a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates Protocol Specification</a>を参照してください。</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-22-self-hosted-ota-updates/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
