<?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>Expo · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/expo/</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/expo/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>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>自作の Expo アップデートサーバーを Codemagic Patch に置き換える</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/</guid>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0900</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Codemagic Patch</category>
      <category>CodePush</category>
      <category>セルフホスト</category>
      <category>モバイル開発</category>
      <category>CI/CD</category>
      <description>ネイティブプロジェクトごと管理している本物の Expo アプリを、expo-updates から Codemagic Patch へ移行しました。ローカルのスタック、移行作業、自分で作って OTA で直したバグ、そしてリリースが実際にクラッシュしたときのロールバックの様子まで。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>開示。</strong> この移行と記事の執筆には Codemagic から報酬を受けています。Codemagic のレビューは事実関係の確認だけで、論調には及ばず、先方の要望で書き換えた箇所はありません。問題にぶつかって報告した箇所はそう書いていますし、あなたが読む時点で直っているものは、直ったものとして書いてあります。</p>
</blockquote><blockquote><p><strong>2026年9月23日追記。</strong> 私が報告した問題には、Codemagic の開発者が順に対応してくれました。iOS のバージョン検出のバグ、二重に数えられたインストール、無音の初回セットアップ、うるさいデバッグコマンドを含めて大半はすでに修正済みで、ドキュメントにもコミット済みのネイティブプロジェクトの扱いとクラッシュ時のロールバックまでの予算が載りました。本文ではそれぞれ該当する箇所でそう書き添え、まだ残っているいくつかは元のままにしています。</p>
</blockquote><blockquote><p><strong>2026年10月16日追記。</strong> Patch に移ってから初めてのネイティブのアップグレード（Expo SDK 58）で、Patch の設定がまったく入っていない Android のリリースビルドができあがり、しかもどこもエラーを出しませんでした。何が起きたのかと、ファイル1つで済んだ修正は <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/#%e5%88%9d%e3%82%81%e3%81%a6%e3%81%ae%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%a2%e3%83%83%e3%83%97%e3%82%b0%e3%83%ac%e3%83%bc%e3%83%89" >初めてのネイティブアップグレード</a> に書きました。</p>
</blockquote><p>2月に、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/" >Expo のアップデートサーバーを自作した話</a> を書きました。あれは今も動いています。私が運営しているコミュニティアプリの maru は、それ以来すべての OTA 修正をあのサーバー経由で、両プラットフォームに、月1ドルほどで配信してきました。</p>
<p>それは同時に、部品を全部自分で抱えているということでもあります。データベースのテーブル、API エンドポイント、管理コマンド、パブリッシュスクリプト、共有シークレット、そして壊れたアップデートを一つずつ経験して覚えたハマりどころの数々。今回の計画を立てようと腰を据えたとき、スクリプトが書いた日からずっと、本番へパブリッシュするたびに &ldquo;Sent 0 push notifications&rdquo; と出力していたことに気づきました。このサーバーが一度も返したことのないフィールドを読んでいたのです。誰も気づいていませんでした。誰よりも私が。自前で運用するというのは、だいたいこういうことになります。</p>
<p>そこで Codemagic から、CodePush を<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>でセルフホストできる形にした <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a> を試してみないかと声をかけられたとき、条件を一つ付けて引き受けました。デモではなく本物のアプリでやること。本物の署名、本物のリリース手順、手で編集した箇所のあるコミット済みのネイティブプロジェクト、その全部です。EAS Update から移行する人の多くが動かしているのは、まっさらなテンプレートではなく、そういうアプリでしょう。</p>
<p>その顛末を、粗い部分もそのままにして書きます。</p>

<h2 class="relative group">Patch とは何か
    <div id="patch-とは何か" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#patch-%e3%81%a8%e3%81%af%e4%bd%95%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Patch は、自分で動かすサーバーと、アプリに組み込む SDK でできています。サーバー側は API、バックグラウンドワーカー、Postgres、S3 互換のオブジェクトストレージ、Web ダッシュボードで、Docker Compose のスタックとして配布されています。クライアント側は <code>@codemagic/react-native-patch</code> という iOS と Android 向けの TurboModule で、Expo の config plugin が付いています。それに加えて、JavaScript をバンドルしてパブリッシュする CLI の <code>cmpatch</code> があります。</p>
<p>ソースを読んで、設計上の判断が2つ目に留まりました。</p>
<p>1つ目は、アプリが API に「アップデートはあるか」と一度も尋ねないことです。リリースがパブリッシュされるとワーカーが素の JSON マニフェストをオブジェクトストレージに書き、アプリはそれを取りに行くだけ。アップデートの確認はストレージか CDN に対する静的な GET が2回で、API が受け取るのはメトリクスだけです。私の旧エンドポイントは、アプリを起動するたびにリクエスト経路の上に乗っていました。こちらではそれが起こり得ません。</p>
<p>2つ目は、すべてのリリースが対象とするバイナリのバージョンを厳密に指定することと、CLI がネイティブプロジェクトのフィンガープリントを計算して、ネイティブコードが一致しないバイナリへ JavaScript をパブリッシュするのを拒むことです。私のサーバーにあったのは バージョン文字列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%be%e3%81%9a%e3%81%af%e6%89%8b%e5%85%83%e3%81%ae%e3%83%8e%e3%83%bc%e3%83%88%e3%83%91%e3%82%bd%e3%82%b3%e3%83%b3%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<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">npm install -g @codemagic/patch-cli
</span></span><span class="line"><span class="cl">cmpatch selfhost local-eval up</span></span></code></pre></div></div>
<p>CLI のインストールは2秒でした。スタックのほうは初回に3分14秒かかり、大半はサーバーとダッシュボードのイメージをローカルでビルドしている時間です。非対話モードだとその間何も出力しないので、ハングしているようにしか見えません（その後修正され、今はステップごとに進捗を1行出します）。そのあと、healthy なコンテナが4つ、すべて localhost に束縛された状態で現れました。8080 にダッシュボード、3000 に API、Postgres、そして 9100 に MinIO です。私自身のスタックがすでに使っているポートは、きちんと避けてくれました。</p>
<p>このモードではサインインが無効になっていて、外部に公開するなという警告がすべてのページに出ます。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAACQBACdASogABQAPv1ko0eloqGYBgBbD8TgCzgCWNWXs6T53beCcoRI0PP/N6AA/K7n/8I/+iwVHjl71MVWujkm+9TgA0wAr+PZ+x6+cQcgCHdovZ75vexrOBrFPrvj8AA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_e5915d9b78052f15.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="ローカル評価用のサインインページ。メールアドレスが入力済みで、認証が無効だという警告が出ている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_8ae8b3cbcb1d7ae7.webp"></span></div>
  <figcaption>ローカル評価モードでは GitHub サインインの代わりに、入力済みのメールアドレスが使われます。</figcaption>
</figure>

<p>スタックには Example Data というアプリがあらかじめ入っています。何かをパブリッシュする前に一度眺めておく価値があります。ダッシュボードが何のためにあるのかが分かるからです。次の2枚のスクリーンショットの数字はそのシードデータで、実際のユーザーではありません。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQBACdASogABUAPw1uqkqmIyGYDAQAYxDE9IABUVMfRogwtMiXImNo4AD+1hmAfbeuR0QbIc0lZuw4PBjyhGJq5t/3NdR81x+BG5H496NeiObQODoB8gkWF2AAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_453fd7641ffbc69b.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1037"
      alt="シード済みの Example Data アプリのリリース履歴。10% のカナリアリリースと、リリースごとの成功数・失敗数が並んでいる"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_30abe30dd08f2234.webp"></span></div>
  <figcaption>シードされたデモデータ: 10% のカナリア、対象バージョン、リリースごとの成功数と失敗数を含むリリース履歴。</figcaption>
</figure>

<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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoQAAABXRUJQVlA4IHgAAABwBACdASogABcAPw1qrEsmIqIYDAQAYxDE9IBnsglwh49zWML+lyXzzjPEgAD+4AZDuD4GlZfO+rTnJJ+yBUjSHGV1a2gPc+tdTO7IIMf86boVjtRW8VJ7KXLfxZsJzumvn68JtLzOqetoXfqt/I/Omlo4DyEAAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_91146bb0b6c75d52.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1155"
      alt="シード済みアプリのデプロイメントメトリクス: バージョン分布、採用の推移、アップデートの結果"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_1f6ae6ce53428957.webp"></span></div>
  <figcaption>シードされたデモデータ: 端末が実際に動かしているリリースと、インストールの結果。</figcaption>
</figure>

<p>比較のために言うと、私のダッシュボードは、管理画面のアップデート一覧に「有効」のチェックボックスが付いただけのものでした。実際に何台の端末がアップデートを取り込んだのか、まったく分かりませんでした。</p>
<p>CLI のログインは PKCE と localhost へのコールバックを使ったまっとうなブラウザフローで、承認ページにはどのアカウントが要求しているかと、コードが1分ほどで失効することが書いてあります。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRn4AAABXRUJQVlA4IHIAAADwBACdASogABQAPv1ioUeloqGYBgBbD8TxAFnAEsa4FDLDrochGk8HT/WQ+e1SUMAA/K7n5mOlQFSkxh0DtgfJ9tHRhVgvwu2jEApXgWL2ekdKJHkUb1NzXgNcjv6IA1JC+dF2JqE73QqxENNNeYvZMAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c7af1bedbd1fa8b3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="CLI のサインインを承認するか尋ねるダッシュボード"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c267548bca14bf21.webp"></span></div>
  <figcaption>cmpatch login はダッシュボードにターミナルの承認を求めます。</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="#%e6%9c%ac%e7%89%a9%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e3%81%ab%e5%90%91%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Patch はプラットフォームごとに1アプリを求め、それぞれに Staging と Production のデプロイメントが作られます。<code>maru-ios</code> はダッシュボードで、<code>maru-android</code> は <code>cmpatch app create</code> で作りました。主に両方を見ておきたかったからです。アプリを作ったあとに出るダイアログは、移行にあたってこの製品でいちばん役に立つ画面です。両方のデプロイメントキー、SDK が必要とする2つの URL、そしてデプロイメントキーは秘密情報ではないという注記が載っています。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoIAAABXRUJQVlA4IHYAAAAQBQCdASogABQAPuFWmkekIqEb9ABDDgT0gF8kHTL70/EPugF6lMUZ0A+0pWU0WRlAAPpGgpz7VeOvAMdDs/q/I00JZTDCozGF/p+yUbXDUjfdJacA2ZbVan+1M3EfSih3MXlM1+RWV02wNY/nmH4Pte3keQAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_ec9ad24dac45b7f9.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="アプリ作成後のダイアログ。Staging と Production のデプロイメントキーと SDK の URL が表示されている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_bd9fb369cb1053b8.webp"></span></div>
  <figcaption>SDK に必要なものが、1画面に。</figcaption>
</figure>

<p>小さなことが2つ。<code>cmpatch init</code> はすでに存在するアプリにプロジェクトを紐付けるコマンドだったので、移行ガイドがどう匂わせていようと、最初のコマンドにはなりませんでした。これはその後修正され、今は対話モードならプラットフォームごとにアプリを1つずつ自分で作ってくれます。それから、ダイアログは API の URL を <code>http://localhost:8080</code> と示すのに、ドキュメントと CLI は 3000 と言います。どちらのポートも両方を配信しているのでどちらでも動きますが、首をかしげることにはなるでしょう。</p>
<p>次に、手を付けていないアプリに対して <code>cmpatch doctor</code> を走らせたところ、3秒で本物の問題を見つけてきました。iOS のバイナリバージョンを 2.0.0 と報告したのです。アプリは 2.5.1 です。</p>
<p>CLI は <code>ios/</code> 以下のすべての <code>Info.plist</code> を探し、パスに &ldquo;test&rdquo; を含むものがなければ、アルファベット順で最初のものを取ります。maru には WidgetKit の extension があり、そのフォルダがアプリ本体のフォルダより前に並ぶので、CLI は extension の <code>Info.plist</code> を拾いました。その plist が 2.0.0 のまま古くなっていたのは私のバグですが、その影響はたちが悪い。自動検出に任せると、iOS のリリースがすべて、どの端末も動かしていないバージョンを対象にしてしまい、しかも何もエラーになりません。アップデートが単に永遠に届かないだけ。まさにセルフホストの記事で警告した失敗です。Android は <code>versionName</code> を正しく読みました。回避策は <code>--target-binary-version</code> か <code>--plist-file</code> を渡すことで、私のパブリッシュスクリプトは今、<code>app.config.js</code> と両方のネイティブプロジェクトが一致していることを確認したうえで、バージョンを明示的に渡しています。あとになって同じバグのせいで、<code>doctor</code> 自身のマニフェスト確認が、正常に動いているデプロイメントに対して失敗しました。バージョン 2.0.0 のマニフェストを探しに行ったからです。古い plist を直したら、<code>doctor</code> は24項目すべてを通過しました。</p>
<p>これは Codemagic がその後修正しました。CLI は今、extension ではなくアプリターゲットの <code>Info.plist</code> からバージョンを読みますし、アプリの候補になるターゲットが複数あれば、どれがそうなのかを <code>cmpatch</code> が尋ねてきます。</p>

<h2 class="relative group">JavaScript 側
    <div id="javascript-側" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#javascript-%e5%81%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>expo-updates</code> を外して SDK を足すのは2行の変更です。アプリがアップデートに対してやっていたことを置き換えるほうは、もう少し考える必要がありました。</p>
<p>maru は起動時と、フォアグラウンドに戻るたびにアップデートを確認します。ただし最短でも15分おきです。見つかればダウンロードして再起動を提案し、断られれば次のコールドスタートで適用します。設定画面には、動作中のバンドルを表示する手動チェックもあり、エラーレポーターはすべてのレポートに、それが来たアップデートのタグを付けます。</p>
<p>SDK のソースから最初に学んだのは、インポート時に <code>TurboModuleRegistry.getEnforcing</code> でネイティブモジュールを探しに行き、モジュールがなければ例外を投げるということです。maru は Web ビルドも出荷しているので、トップレベルでインポートすると Web アプリが落ちます。そこで、SDK を遅延ロードする小さなモジュールを1つ用意し、すべてそこを通すようにしました。何も返さない <code>.web.ts</code> の双子を置いてあるので、SDK は Web バンドルから完全に外れます。</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="c1">// ota.ts (ota.web.ts exports a patchSdk() that always returns null)
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">Platform</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></span><span class="line"><span class="cl"><span class="kr">type</span> <span class="nx">PatchSdk</span> <span class="o">=</span> <span class="k">typeof</span> <span class="kr">import</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">sdk</span>: <span class="kt">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">|</span> <span class="kc">undefined</span><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">patchSdk</span><span class="p">()</span><span class="o">:</span> <span class="nx">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">!==</span> <span class="kc">undefined</span><span class="p">)</span> <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</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="p">)</span> <span class="k">return</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</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="nx">sdk</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">)</span> <span class="kr">as</span> <span class="nx">PatchSdk</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="nx">sdk</span> <span class="o">=</span> <span class="kc">null</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="nx">sdk</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>sync()</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">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">patch</span><span class="p">.</span><span class="nx">sync</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">installMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESTART&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">mandatoryInstallMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESUME&#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="k">if</span> <span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s2">&#34;update-installed&#34;</span><span class="p">)</span> <span class="nx">offerRestart</span><span class="p">();</span></span></span></code></pre></div></div>
<p>SDK の必須リリースのデフォルトは <code>IMMEDIATE</code> で、ユーザーがどこにいようと JavaScript をリロードします。私はアプリが次にバックグラウンドから戻ってきたときに適用するよう変えました。書きかけの投稿が消えるのは、たいていの修正の効き目より重いバグだからです。それから当時は、<code>InstallMode</code> がドキュメントの例から想像する enum オブジェクトではなく、文字列の union 型でした。今は定数オブジェクトとしてエクスポートされているので、<code>InstallMode.ON_NEXT_RESTART</code> はドキュメントどおりに動きます。</p>

<h2 class="relative group">ネイティブ側、ios/ と android/ をコミットしている場合
    <div id="ネイティブ側ios-と-android-をコミットしている場合" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%e5%81%b4ios-%e3%81%a8-android-%e3%82%92%e3%82%b3%e3%83%9f%e3%83%83%e3%83%88%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e5%a0%b4%e5%90%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>移行ガイドは、config plugin を追加したあとで <code>npx expo prebuild --clean</code> を実行するように言っています。別のチェックアウトで実行してみたところ、同梱の画像、Android のネットワークセキュリティ設定、iOS のプライバシーマニフェスト、<code>Podfile.lock</code> が消え、<code>build.gradle</code> のテストランナー向けの修正と <code>Info.plist</code> の数百行が元に戻りました。<code>--clean</code> なしでも、Expo はどのみち両方のディレクトリを空にしました。</p>
<p>これは Patch のせいではありません。maru は managed な Expo アプリとして始まり、ネイティブのカスタマイズが増えていったので、<code>ios/</code> と <code>android/</code> はコミットされていて、EAS はそれをそのままビルドします。ただ、そういうアプリはいくらでもあるのに、ガイドは警告してくれませんでした。今はしてくれます。Expo の移行ガイドには道が2つ用意されていて、生成されたネイティブプロジェクトなら prebuild、コミット済みのプロジェクトなら手で当てる変更をそのまま示してくれます。</p>
<p>plugin が実際に変える内容は、蓋を開けてみれば手作業で済む程度でした。iOS では <code>AppDelegate.swift</code> に import が1つと1行。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">return</span> <span class="n">CodemagicPatch</span><span class="p">.</span><span class="n">bundleURL</span><span class="p">()</span> <span class="p">??</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">url</span><span class="p">(</span><span class="n">forResource</span><span class="p">:</span> <span class="s">&#34;main&#34;</span><span class="p">,</span> <span class="n">withExtension</span><span class="p">:</span> <span class="s">&#34;jsbundle&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Android では <code>MainApplication.kt</code> に import が1つと引数が1つ（これは React Native 0.82 以降の形で、それより古いバージョンでは代わりに <code>getJSBundleFile()</code> をオーバーライドします）。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">ExpoReactHostFactory</span><span class="p">.</span><span class="n">getDefaultReactHost</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="n">jsBundleFilePath</span> <span class="p">=</span> <span class="nc">CodemagicPatch</span><span class="p">.</span><span class="n">getJSBundleFile</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...</span></span></span></code></pre></div></div>
<p>加えてプラットフォームごとに設定値が3つと、古い <code>expo-updates</code> の設定の削除。両プロジェクト合わせて13行の追加と20行の削除です。</p>
<p>plugin はこれらの設定値を prebuild 時にリテラルとして書き込みます。コミット済みのネイティブプロジェクトでは、ビルドごとにデプロイメントが1つハードコードされることになります。私は同じプロジェクトから、プレビュービルドは Staging に、ストア向けビルドは Production に向けたかったので、値はビルド環境から取るようにしました。<code>Info.plist</code> では Xcode がビルド設定を展開してくれます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;key&gt;</span>CodemagicPatchDeploymentKey<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;string&gt;</span>$(PATCH_IOS_DEPLOYMENT_KEY)<span class="nt">&lt;/string&gt;</span></span></span></code></pre></div></div>
<p><code>android/app/build.gradle</code> ではこうです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="cl"><span class="kt">def</span> <span class="n">patchEnv</span> <span class="o">=</span> <span class="o">{</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="n">System</span><span class="o">.</span><span class="na">getenv</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="o">(</span><span class="n">findProperty</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="n">resValue</span> <span class="s2">&#34;string&#34;</span><span class="o">,</span> <span class="s2">&#34;CodemagicPatchDeploymentKey&#34;</span><span class="o">,</span> <span class="n">patchEnv</span><span class="o">(</span><span class="s2">&#34;PATCH_ANDROID_DEPLOYMENT_KEY&#34;</span><span class="o">)</span></span></span></code></pre></div></div>
<p>キーは <code>eas.json</code> の各ビルドプロファイルに、<code>EXPO_PUBLIC_</code> の値と並べて置きます。値が空だと SDK は黙って何もしません。ローカルビルドならそれで構いませんが、ストア向けビルドでは大惨事なので、<code>app.config.js</code> は preview か production の EAS ビルドでキーが欠けていたり、localhost を向いたままだったりすると例外を投げるようにしました。</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%80%e5%88%9d%e3%81%ae%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%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-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --notes <span class="s2">&#34;Onboarding headline over the air&#34;</span></span></span></code></pre></div></div>
<p>30秒かかり、大半は Metro と Hermes です。サーバーのワーカーは、アップロード完了の0.6秒後にマニフェストをパブリッシュしました。アップデートは 7.3 MB の圧縮 tarball で、以前のエクスポートが生成していた 11 MB の Hermes バンドルとアセットに比べて小さくなっています。</p>
<p>ここで間違えてはいけないことが1つ。JavaScript バンドルはビルド時に <code>EXPO_PUBLIC_</code> の値をインライン展開するので、アップデートは着地先のバイナリと同じ値でビルドしなければなりません。さもないと、間違ったバックエンドに話しかける JavaScript を出荷することになります。私のスクリプトは、バイナリをビルドしたのと同じ <code>eas.json</code> のプロファイルから値を読み込みます。Patch の CI ドキュメントにも今はこの点が明記されていて、<code>release-react</code> 自体は EAS のプロファイルを読まないことも書かれています。</p>
<p>それからアプリを再起動しました。端末のログでは、アップデートの確認全体がオブジェクトストレージへの2リクエスト、<code>meta.json</code> と <code>2.5.1/manifest.json</code> として現れました。SDK は起動の3秒後に <code>Downloaded</code> イベントを書き、アップデートをステージングし、アプリが再起動を提案しました。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update-poster.jpg" style="background:url('data:image/webp;base64,UklGRoYBAABXRUJQVlA4IHoBAAAwCgCdASogAEYAPw1oq0omIqIWagaoYxDE9IBo3u6bOACEWXWx1HDG/AwrSOYwsJmgoDuewmmxiJfqdlf28aG4OgV3Vy0nk3lgu8Wi25BY3FxW+lY1wMNQ5gAA/uqWQwe/E/6WeZpIf2UpLel/fKpwqlcmjLMeA7F4ReOblq5eH4qIOx1w7ZF3v0mxWMH8e4hZoalFig747vzom14jgdSClOC5vthxNjGbdjdmv0JuF4Bd3uw2FZbc3Dlojmgl1fUvZqyymqEqzOWLtImgLAZjJhWZgigFmKgGd+GQ0SIKjnJHHdXOZrb/cZMTM1wrOB56DkWBsUxEsixjmVu+Vb3yqJNYglAA21u7aH9XnZ18oNyTlPWugRrsPfZ+/F1gGsmkViDzBGO0f1wXrNBJimlGcHkGkCt7LH9QNjPcy1A9c2dBEltQFPx6XWi1nm9wND9YTEPiTwnjvIduwaNn9YfPJS9b+I1LMURaw77It/AhHjT2gmi4ar24AAA=') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="同梱バンドルで再起動したアプリがアップデートを提案し、リリース v1 で再起動するまで">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>再起動、sync() が update-installed を返して出るアプリ自身のプロンプト、そして OTA で届いたリリース v1。</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%83%90%e3%82%b0%e3%81%af%e7%a7%81%e3%81%ae%e3%82%82%e3%81%ae%e3%81%a0%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>そこでダッシュボードを見ました。シミュレーター1台がリリース v1 をインストールしたのに、成功2件、アクティブユーザー2人と表示されています。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAwCdASogABQAPw1wqUqmIyGYDAQAYxDE9IABUVMd2rf8uKbf68owAP7WGYB/h1ia3xpTYkbuTZlMczaiJLdSTP+17jDH1qfCOJ+8A+IA+QScXzRlnhc+zceAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_13e5d2d984250bf3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="Staging デプロイメントが1台の端末に対してアクティブユーザー2人、成功2件を表示している"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_b455b1f0b69680b5.webp"></span></div>
  <figcaption>シミュレーター1台が、2回数えられています。</figcaption>
</figure>

<p>SDK は状態と未送信のメトリクスイベントをアプリのコンテナ内にファイルとして持っているので、追跡は簡単でした。タイムスタンプが同一で id が異なる <code>Success</code> イベントが2つありました。私のフックはマウント時に <code>notifyAppReady()</code> で動作中のバンドルを確定させ、その直後に <code>sync()</code> を呼んでいて、<code>sync()</code> は自分でも <code>notifyAppReady()</code> を呼びます。2つの呼び出しが重なり、どちらもリリースを pending と見なし、どちらもイベントを記録した。サーバーはイベント id で重複排除しますが、id が違うので両方数えられ、その重複は永久に残ります。</p>
<p>私の側の修正は、起動ごとに確認処理を1つだけ共有し、ほかはすべてそれを待つようにすることでした。SDK 自身が重なった呼び出しを防ぐこともできるはずで、そう提案しました。今はそうなっています。<code>sync()</code> の内部のものも含め、重なった <code>notifyAppReady()</code> の呼び出しは1つの確認処理を共有するので、インストールは1回だけ記録されます。SDK のリファレンスにも、<code>sync()</code> は自分で準備完了を確定させるので、<code>sync()</code> を使うアプリが別に <code>notifyAppReady()</code> を呼ぶ必要はない、と書かれるようになりました。</p>
<p>気持ちよかったのは、この修正の出荷です。JavaScript だけの変更なのでリリース v2 として出せて、v1 の端末にはバイナリ差分を送れるので、v2 は 7.3 MB のバンドルではなく 543 KB のパッチ、サイズにして約7% でダウンロードされました。再起動後: ダウンロード1件、インストール1件、成功1件、アクティブ端末1台。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAADwAwCdASogABQAPw1qqksmIqGYDAQAYxDE9IABUVMd2rfRI1O5mmXAAP7WGYB/h1ia3xpTYkbuTZlMczaiJLkoVKAckjyruR4hTL7NmaqeP1qAfIJOLnmwKkXiX44AAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_d07d239ec0e34973.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="リリース履歴。v1 は水増しされた数字、v2 は正しい数字"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_dab633c48fa0be46.webp"></span></div>
  <figcaption>v1 の重複は残り、v2 は正しく数えられます。</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%82%8f%e3%81%96%e3%81%a8%e5%a3%8a%e3%81%99" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ロールバックは私がいちばん気にしていた機能です。以前の構成には端末側のロールバックがまったくありませんでした。悪いアップデートを出せば、私が気づいてチェックボックスを外すまで、起動のたびにクラッシュし続けたはずです。</p>
<p>そこで、最初の描画の前に例外を投げる v3 をリリースしました。つまり <code>notifyAppReady()</code> を決して呼べないリリースです。アプリはそれをダウンロードし、再起動を提案し、ホーム画面へ落ちました。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed-poster.jpg" style="background:url('data:image/webp;base64,UklGRjACAABXRUJQVlA4ICQCAADQDgCdASogAEYAPw1+rU4mJCIhKbqpIMYhieIAuzPq4M7sW3lP/kPxexnET5ASwFv8Htr8DNovWXwo2ZQynTi8ui+9q1qzPLfv97/wPQgMDj/+e/pvE9miOHoCzZC5Gv/U0oN8n33d1NMZxA4+qT/uNT35EBYJZa/w3Ts1AUToAP5vfw4j/pjBP6ZaA8LejegsfSPzEPXc0mvX+J1dMRFcTwhgh8BttzpcI4CRjUDkbGoa6UAnxK5kCSatezNL4gQf4nZeHytilXmRtp2c1xohb5/y8HBhWsukQWCAk8rKnNMQYUQJEVbasq2JgftASZSBFtiUkjtFF9K+EYtO0DOtXBWDPeHJf1TqqEHHwl4CQ1zsv9Cx+l8KPR2tUhRR8Ctso49YsumZzdxVCAKQdb9pNx77EfSYM6ALaYnzRt4qTUeUBf3ENt66RmZKq5nGflJ9lEW0oDnWjx6zoAqeJS44t1nUDDIZm1gayu9+nhUWZE+InoYr7YlMmmXX2kklVMVsT0s5xOQqPPlW4UoSUv0NgFVDavqEtwMzPqNuUIjjfwXToJhzDbgUidZqS0gSOrBna2Xj2XcOmf2KfPjtTFgKpl74NNsPw+SkvLP+XIfGRP7f4DZ3SF5r4kITD9QK1Mb4Z3frLXQeA432gyyslKobZUjhHzgpw6Lsv1z4B+zVfYG/nF6OiGZ+SPJQMFRe2LmOB4/oyDsE7meakIUBZvS1zSAAAA==') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="壊れたリリースでアプリがクラッシュしたあとのシミュレーターのホーム画面">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>v3 が起動時にクラッシュ。</figcaption>
</figure>

<p>次の起動では再び v3 が立ち上がり、再びクラッシュしました。これには驚きました。ドキュメントには、<code>notifyAppReady()</code> の前にクラッシュしたバンドルはロールバックされると書いてあるからです。ソースが説明してくれました。SDK は未確認の起動を3回まで許してから、クラッシュだと判断します。未確認の起動1回は、JavaScript が動く前に iOS が健全なプロセスを殺しただけかもしれないからです。4回目の起動で v2 に戻ってきました。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back-poster.jpg" style="background:url('data:image/webp;base64,UklGRsABAABXRUJQVlA4ILQBAABQCgCdASogAEYAPw1mq0umIqGU2q7YYxDE9IBowO6mweBInrUPWk42PSTT65hMmOGGOXZWA/sd5q9BGdjSK2j84FuUL93nyJVQuphNZ6mwXQhv8NVWhs+Q6BIAAP74ukd3WXEH+9Z7Rrx8W8zZkkK5+g1e2Lc8Yi325om3Qy3bb7uYsUutPzhS99cwA1kptzZhyUOs2MW6lH9eP8+SPePVMJGvfSIxSO6tL43kYr+PfLzJw/oJabDsUcKslfz5t3nFCXsiVKRSWBi80g/4Y8CJZiDrEvRejhn6wF7DlE4cfLc64tC8OpWtwMjLNLzfe0pK5MjRdPbq4v5sKsY7T49CjMzJz033ddGNJ1Vmp+9decCnIDCYl2pLqRBhrM5E+D4Cw6TfkPAN/WpX0LXCs9Wr8ACcRRwajxQnbjxLU1tKX+yJyrshjPphgACyyYJKjidt2xw5zVQZcF6XykRe430a0dWMKdcBvnMqq2qBswoqRDNp4NIHmM7r87FSOG3y5ScC9wPZH2OoU+6Am86Nz+s2Tk/J+sj1gZFVQqfqXFWH4AEGF/cO+yLfluiKfZvqAOcwbgAA') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="自動ロールバック後、再び v2 で動いているアプリ">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>4回目の起動: 誰もサーバーに触らずに v2 へロールバック。</figcaption>
</figure>

<p>そして端末は自分から失敗を <code>crash_rollback</code> として報告し、ダッシュボードは v3 に対してそれを表示しました。理にかなった設計ですが、それが何を意味するかははっきり言っておく価値があります。悪いリリースは、ユーザー一人あたり3回のクラッシュ起動を代償にしてから回復するのであって、当時のドキュメントにはその回数が書かれていませんでした。今は書かれていますし、この予算は設定で変えられます。ネイティブなら <code>CodemagicPatchMaxLaunchAttempts</code>、Expo の plugin なら <code>maxLaunchAttempts</code> です。だからこそ、まず Staging、次に小さな Production ロールアウトなのです。</p>
<p>ほかの全員に対して悪いリリースを止めるのはコマンド1つで、1秒かかりませんでした。</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">cmpatch release rollback --app maru-ios --deployment Staging</span></span></code></pre></div></div>
<p>履歴を書き換えることはしません。前のリリースを新しいリリース（v4、v2 のロールバックとしてマーク）としてもう一度パブリッシュするので、クラッシュしたリリースは失敗数とともに一覧に残ります。これが正直な記録です。ダッシュボードからも、ダイアログで同じことができます。</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">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAABwBACdASogABQAPw1qp0emIyGYBABjEMT0gGfQEs2nvxX7EOeFbRn3H5Si4AD+1iXJGzIunVk/ewlUVdIUbFiwAGX5QAkFFcZDl2at4PkETvjCXCOVoXFdscz2ut9AAAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="ダッシュボードから Staging をロールバックする様子。前のリリースが v4 として再パブリッシュされる">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback.mp4" type="video/mp4">
    </video></div>
  <figcaption>ダッシュボードからのロールバック: v2 が v4 として戻り、v3 は一覧に残ります。</figcaption>
</figure>

<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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmoAAABXRUJQVlA4IF4AAAAwBACdASogABQAPw1op0emIyGYBABjEMTygAFRZbu1b6Gdq9jYHm/TLAAA/tYZgH+HWJrfGlNiRvwNU+RVn1Uhl5QjE2FNRxQPa3YDnfLX9O0wDNn7xOtAkLPDgAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5552e433d6a65a3f.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="ロールバック後のリリース履歴。v4 がロールバックとしてマークされ、v3 に失敗1件が表示されている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5066c51b1ba693c2.webp"></span></div>
  <figcaption>v4 がロールバック。v3 には失敗が残ります。</figcaption>
</figure>


<h2 class="relative group">Android
    <div id="android" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#android" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Android には驚きが2つあり、どちらも Patch のせいではありません。SDK 向けに足した R8 の keep ルールは <code>app.config.js</code> に置いてあって、これは prebuild を通してしかネイティブプロジェクトに届きません。つまり最初のリリースビルドは、keep ルールなしで SDK が R8 を生き延びるかどうかを、意図せず試すことになりました。生き延びます。R8 はクラスをすべてリネームしましたが、アップデートのダウンロード、インストール、ロールバックは問題なく動きました。もう1つはエミュレーターで、コールドブートしたばかりで Gradle がまだメモリを握っていたせいで極端に遅く、最初のテストは、SDK がすでに始めていたダウンロードを終える45秒前に諦めてしまいました。</p>
<p>リリース v1 は適用され、成功1件、アクティブ端末1台。クラッシュする v2 は、同じ3回の起動予算のあとにロールバックされました。</p>
<figure>
  <div class="phone-frame" style="aspect-ratio: 900 / 1892; --screen-radius: 9.85% / 4.43%"><span class="phone-frame__screen phone-frame__ph" aria-hidden="true" style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%; background:url('data:image/webp;base64,UklGRsgBAABXRUJQVlA4ILwBAABQCwCdASogAEcAPw1wr06mIqIhJngMAMYhiekAzRAKJZ22yvEtm7/GFRCOt1Z6YnyGNWf+i7i0ewagB55W+rOp9DBjbctVlnDXK6lGDu9FH315DSWJUMBYkPGJY2+xw/B5QDAA/vURG5LVrYKJoO2SXy+UxQiUlovs8pxF48eKxgmVxH4hV0qd23H4fTbOXId4reHVkO/ZtsoHNdCHkv0JWiIqSQvxGntYFegdbOEkel+at+Jvs/b4yghmeIXnBH7M31JdyN1H2WvMPx2TwLZfiUh7Vs/c0xs3ET28MPX4PV32jfA2Qo55LcWZBHZVnPmnDwZLLo2Dv++B0SqBKWqbTWPQKOgzF+6YGKB7vcORmV6iyLh5aW30GEJ6lcPq8nat/61jd9i9CZePrzdLJPqGGJyPCbsjPkfhTd6BEdIQraUcOjRfSxAroG5B9nslpJZA63tGk2R93MKzS48w8pEUJKeCNc9lq7Q91gnOgYXePvVHH49kJkJCf5DnRUSgdBqJS0Dh6nG2R7OcW67l22CzkV09rMSoVJlTLhktX5d3Ximn/zbkXSgGUos1pjcQR73ncJ+50rXoCD05SAA=') center/cover no-repeat"></span>
    <img
      class="phone-frame__screen"
      style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp"
      width="600" height="1333"
      alt="OTA で配信された見出しを表示している Android アプリ"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp">
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/pixel-8-frame_hu_52e6b4e761a590c9.webp" width="900" height="1892" alt="" aria-hidden="true" decoding="async">
  </div>
  <figcaption>Android: ロールバック後、再び v1 に。</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%83%8f%e3%83%83%e3%83%94%e3%83%bc%e3%83%91%e3%82%b9%e3%81%ae%e5%85%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アップデートが届いてロールバックされることが核ではありますが、チームが日々使うのはそれ以外の機能なので、一つずつ試していきました。</p>
<p>段階的ロールアウトは決定論的で、これは気に入りました。<code>md5(deviceId + &quot;-&quot; + releaseLabel)</code> の先頭8桁の16進数を 100 で割った余りがロールアウトのパーセンテージ未満なら対象になるので、同じ端末は同じリリースに対してずっと対象か対象外のままです。私のシミュレーターは次のリリースでバケット 91 に入ると計算できたので、25% でパブリッシュしたところ、シミュレーターには正しく何も来ませんでした。<code>cmpatch release patch</code> で 95% に上げると（1秒かかりません）、次の起動で更新されました。</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">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnIAAABXRUJQVlA4IGYAAAAQBACdASogABQAPw1qqEemIyIYBABjEMT0gGH0Es2nvxX9G/4/1g7tAAD+1iXJGzIunVcHqQBmnStoD1oAHJUjpBT6bKy8GQx+Yo2Ka5cGJlghQl6jWcyOWHRQDYW9x5JJDLafQAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="ダッシュボードでリリースのロールアウトを 25% から 95% に引き上げる様子">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout.mp4" type="video/mp4">
    </video></div>
  <figcaption>部分ロールアウトを、再パブリッシュせずに 25% から 95% へ。</figcaption>
</figure>

<p>すると次のリリースが 409 で失敗しました。&ldquo;deployment has an active rollout below 100 percent&rdquo;。デプロイメントごとに部分ロールアウトは1つしか許されないので、ほかの何かを出す前にカナリアを完了するか、無効化するか、ロールバックする必要があります。妥当ですし、ドキュメントにも書いてありますが、マージのたびにパブリッシュするパイプラインは、カナリアの途中でここにぶつかります。報告してからは、CLI がここで事情を説明してくれるようになりました。アクティブな部分ロールアウトが邪魔をしているなら、待っても解決しないことと、ロールアウトを完了または無効化する方法を教えてくれますし、別のリリースジョブがまだ動いているなら、<code>release inspect --wait</code> で待つように言ってくれます。ドキュメントにも、リリースの変更を順番に行うためのセクションができました。</p>
<p>必須リリースは SDK の <code>mandatoryInstallMode</code> を使います。私は次のレジューム時に適用する設定にしていたので、アプリはアップデートをダウンロードしてプロンプトを表示し、プロンプトを無視してアプリをバックグラウンドに送ってから戻すと、新しいバージョンがただそこにありました。</p>
<p>リリースの無効化は、提供をやめる以上のことをします。最新のリリースを無効化した瞬間、マニフェストはまだ有効な中でいちばん新しいリリースを指すようになり、無効化したリリースにいる端末には、そこへ戻るパッチが提供されました。あるバイナリバージョンのリリースをすべて無効化すると、マニフェストのターゲットは null になり、SDK はアプリを出荷時のバンドルに戻します。私のアップデート用フックは最初このケースでプロンプトを出していなかったので、今は出すようにしてあります。</p>
<p>Staging から Production への昇格は1秒かからず、テストしたのとまったく同じパッケージを、同じハッシュとノートのまま再利用しました。ビルドし直すのではありません。ロールアウトのパーセンテージも受け付けるので、「Production の 10% に昇格」がコマンド1つで済みます。</p>
<p>ビルドとパブリッシュを別々の手順に分けることもできます。<code>cmpatch bundle</code> が <code>.cmpatch</code> のアーティファクトを生成し、ダッシュボードの「Bundle upload」はアップロード前にそれを読み取ります。プラットフォーム、対象バージョン、フィンガープリント、署名の状態、サイズ、バンドラー。CI がアーティファクトをビルドし、いつパブリッシュするかは別の誰かが決める、という体制に向いています。</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">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnwAAABXRUJQVlA4IHAAAAAwBACdASogABQAPw14sE+mI6IhKAgAxiGJ6QAAM7aVPPd3tO2d2B9IEAAA/tYlyRsyLp1nLc5Hh5nniWZ+UIxN4AAKrWhTSPPLnDPJV90MvTw1QAmDXcvY3C3ghY+Uo+Wdxkjji3HNEgzIPqpIwAAA') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="ダッシュボードで .cmpatch アーティファクトをアップロードする様子。プラットフォーム、バージョン、フィンガープリント、サイズを読み取り、フィンガープリントの不一致を警告する">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload.mp4" type="video/mp4">
    </video></div>
  <figcaption>バンドルアップロード: ダッシュボードがアーティファクトを読み取り、続いてフィンガープリントのガードが働きます。</figcaption>
</figure>

<p>フィンガープリントのガードに本格的にぶつかったのもここです。古いウィジェットの plist を直したあと、サーバーは次のリリースを 409 で拒否しました。ネイティブプロジェクトのフィンガープリントが、2.5.1 に対して記録されていたものと一致しなくなったからです。Android では <code>app.config.js</code> のコメントを編集しただけで十分でした。Expo はアプリ設定をフィンガープリントに含めるからです。ダッシュボードは同じチェックを、両方のフィンガープリントと「Upload anyway」ボタンの付いた警告として表示します。どちらの場合もインストール済みのバイナリのネイティブ部分は何も変わっていないので、上書きが正解でした。ただ実運用では、正直な答えはほぼ必ずバージョンを上げてストア向けにビルドすることで、私のパブリッシュスクリプトはこれが起きたとき、今はまさにそう伝えます。</p>
<p>コード署名は RS256 です。CLI が RSA の秘密鍵でパッケージのハッシュに署名し、SDK がバイナリにコンパイルされた公開鍵でそれを検証します。鍵は base64 の1行として渡すので、私のビルド設定方式にそのまま収まり、両プラットフォームでテストしました。鍵をアプリに入れた状態では、署名なしのリリースはダウンロード前に拒否されました。アプリで「require code signing」を有効にすると、サーバーは署名なしのパブリッシュを、対処法を示しガイドへリンクするエラーで拒否しました。署名済みのリリースはインストールされ、SDK は署名を検証済みとして記録しました。署名済みのものを無効化したときにサーバーがフォールバックした、古い署名なしのリリースも拒否しています。注意点は、公開鍵が最初に出荷するバイナリに入っていなければならないことです。アップデートで後から足すことはできません。</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">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRnAAAABXRUJQVlA4IGQAAABwBACdASogAB0APw14sE+mI6IhKAgAxiGJ6QAAM+3OuAEp3oUrP1GNW2QgAAD+1hmAf4eqLtdnP/9v1g2ZTHRXrrujcZyW6apQhqigB3MdqokDYA7HESmkf+QYJtIlWU+VYAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_2173e76e5a1b2cec.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="1264"
      alt="リリース詳細。署名が Signed, sha256 と表示されている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_f18ca5bb6112ee2a.webp"></span></div>
  <figcaption>署名済みのリリース。</figcaption>
</figure>

<p>チームと CI もカバーされています。メンバーにはチーム全体で共通の4つのロール（owner、admin、developer、viewer）のいずれかが付き、招待は相手が最初にサインインするまで保留されます。パーソナルアクセストークンは空のホームディレクトリでも動き、<code>--token</code> でも環境変数でも渡せて、トークン一覧には最後に使われた日時が出ます。Linux ランナーで同じパブリッシュスクリプトを、シークレットから取り出した署名鍵で走らせる手動実行の CI ワークフローも書きましたが、ホスト済みのサーバーが要るので、まだ実行していません。</p>
<p>当時なら使わずにおいたツールが1つ、<code>cmpatch debug ios</code> です。システムログを &ldquo;OTA&rdquo; で大文字小文字を区別せずにフィルタしてストリームしていたので &ldquo;Rotation&rdquo; にもマッチしてしまい、20秒のアプリ起動で SpringBoard のメッセージが画面いっぱいに流れ、アプリからは何も出ませんでした。これは修正済みで、今は SDK のマーカーが付いた行だけを表示します。もっとも SDK はもともとあまりログを出さず、出るのはほとんどエラーと開発ビルドでの警告なので、静かなストリームだと思っておいてください。<code>getpwuid_r</code> の警告が出ることがありますが、これは Patch ではなく <code>xcrun simctl</code> から来ています。</p>

<h2 class="relative group">実際のところ、どれくらい大変か
    <div id="実際のところどれくらい大変か" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%9f%e9%9a%9b%e3%81%ae%e3%81%a8%e3%81%93%e3%82%8d%e3%81%a9%e3%82%8c%e3%81%8f%e3%82%89%e3%81%84%e5%a4%a7%e5%a4%89%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Codemagic から意見を求められたので、できるだけ率直に書きます。</p>
<p>試すのは簡単です。コマンド2つと数分で、本物のサーバー、デモデータ入りのダッシュボード、自分のアプリに向けられる CLI が手に入ります。最初の1時間でいちばん良かった部分だと思います。</p>
<p>SDK の組み込みはそこそこの作業で、難しさの大半は Patch ではなく自分のプロジェクトの側にあります。ネイティブフォルダを生成させたまっさらな Expo アプリなら、本当に plugin と、<code>expo-updates</code> の呼び出しを置き換える数行だけです。maru のように、コミット済みのネイティブプロジェクト、ウィジェット extension、Web ビルド、R8、ビルドプロファイルを抱えたアプリでは、作業は端のほうにありました。prebuild を避けるためにネイティブの変更を手で当て、Staging と Production のビルドが別物になるように設定をビルド時に決め、SDK を Web から遠ざける必要がありました。アップデートの JavaScript がバイナリと同じ環境でビルドされることも保証しなければなりません。理解してしまえばどれも難しくはありませんでした。私が作業した時点ではその大半がドキュメントにありませんでしたが、コミット済みのネイティブプロジェクトの手順とバンドル時の環境については、今は載っています。</p>
<p>日常の使い勝手は良いです。パブリッシュはコマンド1つと30秒ほど。ロールアウト、昇格、無効化、ロールバックは CLI からでもダッシュボードからでもすべて1秒未満で、エラーは何が悪くて何をすべきかを伝えてくれます。サーバーのガード（フィンガープリントのチェック、一度に1つのカナリア、署名）は厳格で、私が間違ったことをするのを何度も止めてくれました。</p>
<p>本番で運用するのは、より大きなコミットメントです。Postgres とオブジェクトストレージ付きの Docker Compose スタックで、ホスティング、バックアップ、アップグレードは自分でやらなければならず、ドメイン2つと OAuth アプリが必要です。すでにサーバーを運用しているチームなら静かな午後1回分。サーバーを持ちたくないからこそ EAS にいるチームにとっては、SDK の作業よりもここが乗り換えの本当のコストです。</p>
<p>実時間で言うと、12:29 に CLI をインストールし、13:34 にはシミュレーターで最初のアップデートが適用されていました。そのうち30分は、Patch と無関係な理由で止まった Xcode のビルドです。この記事にあることは両プラットフォームとも、ホスト済みのサーバーを除けば午後の半ばまでに終わっていました。</p>
<p>いちばん時間を取られたものを順に挙げると、<code>expo prebuild --clean</code> がコミット済みのネイティブプロジェクトを消してしまい、そのあと全部を手で配線する方法を考えたこと。iOS のバージョン検出のバグで、これは誰も動かしていないバージョンにアップデートを送るところでした。二重に数えられたインストールで、これは自分のせいですが、<code>sync()</code> が自分で <code>notifyAppReady()</code> を呼ぶ以上、誰でも簡単に再現してしまいます。そしてドキュメントにない3回の起動というクラッシュ予算で、テストの途中で驚かされました。その次に来るのが、正しいけれど計画が要るもの。無害なネイティブ周辺の編集のあとに発火するフィンガープリントのガード、デプロイメントごとに一度に1つのカナリア、重ねて実行できないライフサイクルジョブ。最後のはスクリプトが続けていくつか処理をした途端に効いてきます。残りは小さなものでした。無音の初回セットアップ、うるさいデバッグコマンド、当時コードと合っていなかったドキュメントの例がいくつか。</p>
<p>これらはすべて詳細とともに Codemagic に書いて渡し、9月23日の時点でその大半が直っていました。バージョン検出、重なった確認処理、prebuild についての警告、クラッシュ予算、無音のセットアップ、デバッグコマンド、<code>InstallMode</code> の例、そしてリリースジョブやカナリアが邪魔をしているときのエラーです。まだ残っているのは小さなものです。ほかがすべて <code>:3000</code> と言うなかでダッシュボードだけが API を <code>:8080</code> と表示すること、Web と Jest で SDK が import 時に例外を投げること、それにドキュメントの穴がいくつか。</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%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここまでの内容はすべて評価用スタックに対して動かしたものです。本番は同じアプリと同じスクリプトを本物のサーバーに向けるだけで、これが maru の計画です。</p>
<!-- 編集メモ: このセクションは手順。実際の切り替え後に、実際に起きたこと（ホスト、所要時間、壊れたもの）で置き換えること。 -->
<p>サーバーを動かすサポート対象の方法は Docker Compose でのインストールです。ポート 80 と 443 が開いた Linux マシン、ドメイン2つ（API とダッシュボード用、ダウンロード用）、そしてサインイン用の GitHub OAuth アプリ。OAuth アプリがないとサーバーは起動しません。あとはインストーラーが TLS まで含めてやってくれます。</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">scripts/selfhost/install.sh <span class="se">\
</span></span></span><span class="line"><span class="cl">  --api-domain updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --storage-domain storage.updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --email you@example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --github-oauth-client-id &lt;id&gt; --github-oauth-client-secret &lt;secret&gt;</span></span></code></pre></div></div>
<p>データベースとストレージのシークレットを書いた <code>.env.selfhost</code> が生成されるので、パスワードマネージャーに入れておきましょう。</p>
<p>とはいえ同梱のデータベースとストレージを使う必要はなく、それが私のようなスタックにも合う理由です。maru の API はすでに Fly.io で動いていて、ファイルは Tigris にあり、Patch はどちらでも動きます。Compose ファイルの下にあるのは <code>MODE=all</code>（API とリリースワーカーを一緒に）で動くコンテナ1つで、必要なのは Postgres のデータベース、S3 互換ストレージ、ファイル用の公開 HTTPS URL です。つまり、リポジトリの Dockerfile からビルドした Fly アプリ（公開されたイメージはまだないので <code>fly deploy</code> がビルドします）、Fly Postgres のデータベース、Tigris のバケットです。</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">MODE</span><span class="o">=</span>all
</span></span><span class="line"><span class="cl"><span class="nv">DATABASE_URL</span><span class="o">=</span>postgresql://…            <span class="c1"># Fly Postgres</span>
</span></span><span class="line"><span class="cl"><span class="nv">STORAGE_ADAPTER</span><span class="o">=</span>s3
</span></span><span class="line"><span class="cl"><span class="nv">S3_ENDPOINT</span><span class="o">=</span>https://fly.storage.tigris.dev
</span></span><span class="line"><span class="cl"><span class="nv">S3_REGION</span><span class="o">=</span>auto
</span></span><span class="line"><span class="cl"><span class="nv">S3_BUCKET</span><span class="o">=</span>myapp-patch
</span></span><span class="line"><span class="cl"><span class="nv">S3_FORCE_PATH_STYLE</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">PUBLIC_BASE_URL</span><span class="o">=</span>https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</span></span></code></pre></div></div>
<p>注意点が2つ。Compose の外で動かす方法はリポジトリに参考資料として書かれているだけで、この最初のオープンソースリリースではサポート対象の経路ではないので、Fly 上でのアップグレードとバックアップは自己責任です。私もまだその形ではデプロイしていません。これは計画であって、報告ではありません。それから、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly のゼロスケール</a> の記事を読んだ方は、マシンを最低1台は動かしておいてください。マニフェストはストレージから直接来るので、端末がアップデートの確認で API を待つことはありませんが、そうしないとメトリクスのアップロードとリリースジョブがすべてコールドブートから始まることになります。</p>
<p>Tigris はいちばん楽しみにしている部分です。maru のファイルがすでにある場所ですし、アプリの起動のたびに叩かれる URL の前に CDN を置いてくれます。これをやるならマニフェストのキャッシュには気をつけてください。素の base-URL 配信アダプターでは、Patch は CDN をパージできないためマニフェストを <code>no-cache</code> で配信します。マニフェストが独自の TTL でキャッシュされると、ロールバックしたばかりのリリースを配り続けてしまうからです。設定リファレンスには今、この点が <code>MANIFEST_CACHE_CONTROL</code> の項に書かれていて、ドキュメントサイトでも S3 互換ストレージが扱われています。</p>
<p>ホスト済みのサーバーでもう一度アプリを作り、新しいキーを <code>eas.json</code> の <code>preview</code> と <code>production</code> プロファイルに（Staging のキーと Production のキー）入れ、パブリッシュ用のトークンを作ります。</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">cmpatch token create --name ci</span></span></code></pre></div></div>
<p>コード署名をするなら、今決めてください。本番用の鍵ペアを生成し、秘密鍵は CI のシークレットとして保管し、公開鍵をビルドプロファイルに入れ、両アプリで「require code signing」を有効にします。Patch 入りの最初のバイナリには新しいバージョン番号も必要です。ストアにすでにあるバイナリは <code>expo-updates</code> を動かしているからです。まず preview プロファイルで TestFlight と Play の内部テスト向けにビルドし、誰かに渡す前に、キーが実際にビルド済みの <code>Info.plist</code> と Android のリソースに入ったかを確認してください。環境変数を手でエクスポートしていたので、ここだけはローカルのテストで検証できていない繋ぎ目です。それから Staging にパブリッシュし、テスター1人につきダウンロード1件と成功1件を確認して、そこで初めてストア向けにビルドします。</p>
<p>Production のリリースは小さく始めて広げます。</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">./scripts/publish-update.sh ios --production --rollout <span class="m">10</span>
</span></span><span class="line"><span class="cl">cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage <span class="m">100</span></span></span></code></pre></div></div>
<p>その間、古いサーバーは動かしたままにします。まだアップデートしていない 2.5.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%88%9d%e3%82%81%e3%81%a6%e3%81%ae%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%a2%e3%83%83%e3%83%97%e3%82%b0%e3%83%ac%e3%83%bc%e3%83%89" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><em>2026年10月16日追記。</em></p>
<p>10月初めから、maru の本番のアップデートは Patch が運んでいます。それ以降で初めてのネイティブリリースが Expo SDK 58 への移行でした。React Native 0.88、そして Android では Gradle プラグインの新しいメジャーバージョンです。ネイティブの変更は OTA では届けられないので新しいバイナリ 2.6.0 になり、私のパブリッシュスクリプトはそのバイナリが存在するまで 2.6.0 向けのアップデートを送ろうとしません。ビルドは両プラットフォームとも一発で通りました。</p>
<p>上の本番のセクションでは、誰かにビルドを渡す前に、キーが実際にビルド済みの <code>Info.plist</code> と Android のリソースに入ったかを確認するよう書きました。今回はちゃんと確認しましたし、このセクションがあるのはその確認のおかげです。iOS のビルドには Patch の設定が4つともありました。Android のビルドには1つもありませんでした。</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">bundletool dump resources --bundle<span class="o">=</span>build.aab --values <span class="p">|</span> grep -A1 <span class="s2">&#34;string/Codemagic&#34;</span></span></span></code></pre></div></div>
<p>このコマンドは 2.5.11 のビルドではキーを4つ出力し、2.6.0 では何も出しませんでした。環境変数のせいではありません。Gradle の <code>generateReleaseResValues</code> は4つとも書き出しています。消しているのはその後に走るリリースのリソースシュリンカーで、Android Gradle Plugin 9 ではずっと攻撃的になりました。同じアプリでリソースが 9,240 個から 7,116 個に減っています。SDK は設定を <code>getIdentifier</code> で名前から読みますが、シュリンカーにはそれが見えないので、4つの文字列を未使用とみなして消しました。存在しない文字列は空として読まれ、空は「アップデートなし」を意味します。何も失敗しなかったはずです。Android の 2.6.0 はインストールされ、普通に動き、アップデートを一度も受け取らず、唯一の兆候はダッシュボードに Android 2.6.0 の端末が1台もないことだけだったでしょう。</p>
<p>修正は、シュリンカーに4つの文字列を残すよう伝えるリソースファイル1つです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="c">&lt;!-- android/app/src/main/res/raw/codemagic_patch_keep.xml --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;resources</span> <span class="na">xmlns:tools=</span><span class="s">&#34;http://schemas.android.com/tools&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="na">tools:keep=</span><span class="s">&#34;@string/CodemagicPatch*&#34;</span> <span class="nt">/&gt;</span></span></span></code></pre></div></div>
<p><code>keep.xml</code> という名前にはしないでください。React Native のバンドルタスクが JavaScript のアセット用にすでに <code>raw/keep.xml</code> を生成していて（画像やフォントが生き残ったのはそのためです）、2つがぶつかります。ライブラリの keep ファイルはアプリに自動でマージされるので、このルールの置き場所としては SDK 自体のほうがふさわしく、デプロイメントキーなしでリリースビルドが起動したときに警告が1つ出るだけでも、黙って起きていたことが目に見えるようになります。前に出てきた iOS のバージョン検出のバグと同じ種類の失敗です。何もエラーにならず、アップデートがただ届かない。</p>
<p>同じ確認で、Patch とは関係のない2つ目の問題も見つかりました。こちらは別の理由で知っておく価値があります。Android のリリースビルドは起動直後にクラッシュしました。それも2回、Expo 自身のモジュールの中にある別々の2つの原因によってです。まず、Expo のウィジェット用モジュールがネイティブクラスの登録に失敗し、JavaScript が動く前にアプリが落ちていました。それを直すと、アプリは JavaScript の起動までは進んだものの、そこで止まりました。Expo の新しいモジュールシステムに移った3つのモジュールが登録されていなかったからです。どちらにも Patch のクラッシュロールバックは役に立ちません。ロールバックすべきアップデートがないからです。守ってくれるのは悪いアップデートからであって、悪いバイナリからではありません。Patch があってもなくても、ネイティブのアップグレードはストアに出す前に実機かエミュレーターにインストールして開いてみる必要があります。</p>
<p>同じテストから、もう1つ小さなことが出てきました。<code>sync()</code> は失敗すると <code>&quot;error&quot;</code> を返し、アプリが開発モードで動いていない限り理由は教えてくれません。なのでリリースビルドでは、何かがうまくいかなかったことしかログに残せません。今は私のフックが、sync が失敗したあとに一度だけ <code>checkForUpdate()</code> を呼んで、そこで投げられたものをログに出します。そうして初めて、サーバーが壊れていたのではなく、ローカルのシミュレータービルドの Patch の URL が空だった（<code>unsupported URL</code>）とわかりました。最後のエラーがリリースビルドでも読めれば助かります。</p>
<p>ネイティブリリースのチェックリストには、以前はなかった3行が加わりました。ビルド済みの <code>.ipa</code> と <code>.aab</code> から Patch の設定をダンプすること、デプロイメントキーをビルドプロファイルと突き合わせること、そして提出前にデバッグビルドではなくリリースビルドそのものを起動すること。</p>

<h2 class="relative group">Expo の請求書を抱えている人に伝えたいこと
    <div id="expo-の請求書を抱えている人に伝えたいこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expo-%e3%81%ae%e8%ab%8b%e6%b1%82%e6%9b%b8%e3%82%92%e6%8a%b1%e3%81%88%e3%81%a6%e3%81%84%e3%82%8b%e4%ba%ba%e3%81%ab%e4%bc%9d%e3%81%88%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>サーバーを作るのが大変そうに見えたから EAS Update にお金を払っているのなら、Patch は本物の答えです。サーバーは動かしますが、それは Compose スタックとインストーラーであって、自分でメンテナンスするコードではありません。私が作ったものと比べると、段階的ロールアウト、リリースごとのインストール数と失敗数、バイナリパッチ、フィンガープリントのガード、コード署名、端末側でのクラッシュロールバック、ロールバックボタンが手に入ります。私はどれも作っていませんでした。</p>
<p>代償は、SDK をアプリに入れる必要があるので、誰かに Patch のアップデートが届く前に新しいバイナリが要ること。アップグレードとバックアップを続けるサーバー。リリースが十分ひどければ、デフォルトでユーザー一人あたり3回のクラッシュ起動。ネイティブプロジェクトをコミットしているなら、いくらかの手作業。それに、まだ若い。今回のバージョンは 0.3.0 で、バージョン検出のバグ、うるさいデバッグコマンド、ドキュメントの穴をいくつか踏み、SDK が防げたはずのバグを自分でも作りました。バージョン検出とデバッグコマンドはその後修正され、ドキュメントの穴も大半が埋まり、私がやった間違いも今は SDK が防いでくれます。どれも移行を止めるものではなく、すべてが CLI、ダッシュボード、あるいは端末上の SDK 自身のファイルで見えていました。これは以前の構成には言えなかったことです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/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>
