<?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>Python · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/python/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/python/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest-testmon：コードの変更が影響するテストだけを実行してpytestを高速化する</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmonは、変更の届くテストだけを推測ではなく記録から選んで実行します。2,248件のDjangoスイートに入れた手順、必要なラッパー、Python 3.14の落とし穴。</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a>は、変更が影響しうるテストだけを実行するpytestのプラグインです。各テストがどの行を実行したかをcoverage.pyで見ていて、テストが触れたコードのブロックごとにチェックサムを保存します。次の実行ではそのチェックサムをディスク上のコードと比べ、ブロックが変わったテストと、前回失敗したテストだけを実行します。つまり<code>pytest --lf</code>の役目も兼ねています。</p>
<p>この手法は一般にテスト影響分析（test impact analysis）と呼ばれ、testmonはそれをPythonで無料で使える、オープンソースの手段です。gitにはまったく依存しません。比べる相手は前回の実行で見たコードで、コミット済みかどうかは関係ありません。</p>
<p>今週、私が作っているマーケットプレイスアプリのPythonのテストにこれを入れました。理由は、速さそのものではありません。</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="この記事を語りで紹介する短い映像">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語" default>
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文">
  </video><figcaption>この記事を約8分の映像にしました。ナレーションは英語で、日本語字幕が付きます。</figcaption>
</figure>


<h2 class="relative group">なぜ入れたのか
    <div id="なぜ入れたのか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%aa%e3%81%9c%e5%85%a5%e3%82%8c%e3%81%9f%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>機能のリクエストは、たいてい小さなものです。画面が一つ、エンドポイントが一つ、挙動を変えたいルールが一つ。ところがテストスイートを丸ごと実行すると、そのどれもが実際より大きくなっていました。スイートはDjangoのテストが2,248件で、4プロセスで約3分半、1プロセスだと11分かかります。編集のたびにその全部を実行していましたが、そのほとんどはリクエストが一切触れていない部分を確認するものです。関係のないテストが落ちたり、単に実行に時間がかかったりするたびに、その機能は待たされました。機能リクエストとは関係のない事情で足止めされていて、足止めしていたのは自分のテストスイートだったわけです。</p>
<p>かといって、別のやり方がましだったわけでもありません。全体の実行は飛ばして、変更をカバーしていそうなテストファイルを当て推量で選び、ときどき外す。速くはありますが、正確ではありません。テストしていない変更をリリースすれば、その機能リクエストをもう一度やり直すことになります。</p>
<p>待つテストは、変更の届きうるテストとぴったり同じであるべきで、多すぎても少なすぎてもいけません。testmonはその集合を、推測ではなく記録から出してくれます。スイート全体は今もCIで実行していて、そこなら次に取りかかっている作業を止めることはありません。</p>

<h2 class="relative group">何を実行するかの決め方
    <div id="何を実行するかの決め方" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bd%95%e3%82%92%e5%ae%9f%e8%a1%8c%e3%81%99%e3%82%8b%e3%81%8b%e3%81%ae%e6%b1%ba%e3%82%81%e6%96%b9" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonは各Pythonファイルをブロックに分けます。モジュールのトップレベル、各クラス、各関数がそれぞれ一つのブロックで、ネストした関数の本体はそれを囲むブロックから切り離されます。保存するのは各ブロックのチェックサムだけで、コードそのものは保存しません。テストの実行中にcoverage.pyが実行された行を記録し、testmonはそれらの行がどのブロックに属するかを書き留めます。静的解析も、importからの推測もありません。テストが依存するのは、そのテストが実際に実行したブロックだけです。</p>
<p>たとえば、こんなモジュールがあるとします。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p><code>subtotal()</code>だけを呼ぶテストは、モジュールのトップレベルと<code>subtotal</code>の本体に依存します。<code>discount()</code>をどう書き換えても、testmonはそのテストを再実行しません。そのテストは<code>discount</code>の行を一行も実行していないからです。逆に<code>subtotal</code>の中を一文字でも変えれば、結果が変わりようのない変更でも再実行されます。testmonは副作用について賢く立ち回ろうとはしません。実行したブロックが変わったら、実行する。それだけです。</p>
<p>コードの周りにあるものも追跡します。記録にはPythonのバージョンと、インストール済みのパッケージとそのバージョンが含まれます。環境変数は見ていません。同じコードを二つの設定モジュールで動かすなら、<code>--testmon-env</code>（手順8）で名前を付けないと、一つの記録を共有してしまいます。Djangoをアップグレードすれば、Djangoに触れたものはすべてまた影響を受けます。Djangoアプリなら、それはつまり全部です。</p>

<h2 class="relative group">見えないもの
    <div id="見えないもの" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a6%8b%e3%81%88%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonが追跡するのは、テストが実行するPythonのコードです。テストが読むそれ以外のファイル、つまりテンプレートやJSON、テキストなど、ディスク上にあるほかのものは追跡しません。ネットワーク越しに呼び出すサービスも追跡しません。そして、テストが始まる前に一度だけ実行されるものを、特定のテストに結びつけることもできません。</p>
<p>Djangoのプロジェクトでいえば、まずマイグレーションです。テスト用データベースを作るときに一度だけ実行されるので、どのテストの記録にも含まれません（後で紹介するラッパーは、新しいマイグレーションがあるとテスト用データベースを作り直します）。次に設定と起動時のコードで、pytestの起動中に実行されるので、どのテストにも属しません。そしてテンプレートとデータファイルです。テンプレートはPythonではないので、編集しても、それを描画するテストは一つも再実行されません。</p>
<p>私のスイートには、ほかにもいくつかあります。モバイルアプリのテストと共有しているフィクスチャのJSONファイルを読むテスト。ビルドしたWebのバンドルを確認するテスト。同じリポジトリで生成している静的サイトの出力を確認するテスト。どれを変えても、testmonは「何も実行する必要はありません」と平然と答えます。だから以下の手順では、testmonをスクリプトで包んで、こうしたファイルに気づき、どれかが変わっていたら全部を実行するようにしています。</p>

<h2 class="relative group">Djangoのプロジェクトに入れる方法
    <div id="djangoのプロジェクトに入れる方法" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#django%e3%81%ae%e3%83%97%e3%83%ad%e3%82%b8%e3%82%a7%e3%82%af%e3%83%88%e3%81%ab%e5%85%a5%e3%82%8c%e3%82%8b%e6%96%b9%e6%b3%95" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここではpytestと<a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>を使い、依存関係はuvで管理している前提で書きます。pipを使っているなら、<code>uv add --dev</code>を<code>pip install</code>に置き換え、<code>uv run</code>を外してください。</p>
<p><strong>1. インストールする。</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv add --dev pytest-testmon</span></span></code></pre></div></div>
<p><strong>2. pytestでスイートを実行できるようにする。</strong> testmonはpytestのプラグインなので、<code>python manage.py test</code>では何もしません。pytest-djangoには、settingsモジュールと、テストが使っているファイル名のパターンを教える必要があります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># pytest.ini</span>
</span></span><span class="line"><span class="cl"><span class="k">[pytest]</span>
</span></span><span class="line"><span class="cl"><span class="na">DJANGO_SETTINGS_MODULE</span> <span class="o">=</span> <span class="s">myproject.settings</span>
</span></span><span class="line"><span class="cl"><span class="na">python_files</span> <span class="o">=</span> <span class="s">tests.py test_*.py *_tests.py</span>
</span></span><span class="line"><span class="cl"><span class="na">python_classes</span> <span class="o">=</span> <span class="s">Test* *Tests *TestCase</span></span></span></code></pre></div></div>
<p>これまでDjangoのランナーでしかスイートを実行したことがないなら、testmonを入れる前に一度pytestで実行し、違いが出たところを直しておきましょう。そうしないと、二つの問題を同時にデバッグする羽目になります。</p>
<p><strong>3. 記録をgitに入れない。</strong> testmonはpytestのルートディレクトリに<code>.testmondata</code>を書き出します。中身はSQLiteのデータベースなので、隣に<code>-shm</code>と<code>-wal</code>のファイルもでき、さらに後で出てくるスクリプトがスタンプファイルを足します。パターン一つで全部まとめて除外できます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># .gitignore
</span></span><span class="line"><span class="cl">.testmondata*</span></span></code></pre></div></div>
<p><strong>4. 一度スイート全体を記録する。</strong> testmonは、すべてのテストの実行を一度見るまで何も選べません。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv run pytest --testmon</span></span></code></pre></div></div>
<p>まっさらなチェックアウトでは、この最初の実行は1プロセスでのスイート全体の実行になります。私の環境では、2,248件のテストに約12分かかります。それ以降は、同じコマンドで影響を受けるテストだけが実行されます。何も変えていなければ、何も実行されません。</p>
<p>落とし穴が二つあります。一つ目に、testmon 2.2.0以前ではpytestのcacheプラグインが必要です。最初に試したときは<code>-p no:cacheprovider</code>を付けて実行していて、<code>KeyError: 'lf'</code>で落ちました（2.3.0では<code>--lf</code>がなければ未指定として扱われます）。二つ目に、Python 3.14以降では、記録する前に<code>COVERAGE_CORE=ctrace</code>を設定してください。理由は、この後の「成功に見えたバグ」で説明します。以下のスクリプトで、いちばん大事な一行です。</p>
<p>追記（11月7日）：11月2日にリリースされたtestmon 2.3.0は、coverageのCトレーサーを自分で強制するようになったので、このバージョンではこのexportは念のためのものになります。2.2.0以前なら残してください。そして、わざと壊して確かめるチェックは、バージョンにかかわらず続けてください。</p>
<p><strong>5. 見えないファイルのためにラップする。</strong> 毎日安心して使えるのは、この部分があるからです。スクリプトは、前回の実行が始まった時点のスタンプファイルを持っています。testmonが追跡できないファイルのどれかがスタンプより新しければ、全部を実行して記録し直します。新しいマイグレーションなら、テスト用データベースを作り直すだけです。そうでなければ、選ぶのはtestmonに任せます。削って例にしたものがこちらです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/test-changed.sh: the tests a change can affect, or all when in doubt.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh            # affected tests</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh --all      # everything, re-recorded</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh -k orders  # anything else goes to pytest</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$0</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># coverage&#39;s C tracer: from Python 3.14 coverage defaults to sys.monitoring,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># which reports a line only the first time a process runs it, so testmon</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would credit each line to one test. Changing this means --all.</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">COVERAGE_CORE</span><span class="o">=</span>ctrace
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span>.testmondata.started
</span></span><span class="line"><span class="cl"><span class="nv">full</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="nv">rebuild</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;--all&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span>true<span class="p">;</span> <span class="nb">shift</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="o">[</span> ! -f .testmondata <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> ! -f <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;No record yet: running everything once.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Changed since the last run started, and not traceable per test:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># anything that isn&#39;t Python, plus settings and conftest.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">newer</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>find myapp myproject fixtures pytest.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">        -type f -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> ! -path <span class="s1">&#39;*/migrations/*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        <span class="se">\(</span> ! -name <span class="s1">&#39;*.py&#39;</span> -o -path <span class="s1">&#39;myproject/settings*&#39;</span> -o -name <span class="s1">&#39;conftest.py&#39;</span> <span class="se">\)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        ! -path <span class="s1">&#39;*/__pycache__/*&#39;</span> <span class="p">|</span> head -5<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$newer</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;Changed, and not traceable per test:&#34;</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">&#39;  %s\n&#39;</span> <span class="nv">$newer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A new migration needs a fresh test database, not a full run.</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find myapp -path <span class="s1">&#39;*/migrations/*.py&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">|</span> head -1<span class="k">)</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">rebuild</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Stamp the start, so an edit made while the tests run counts next time.</span>
</span></span><span class="line"><span class="cl"><span class="nv">started</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>mktemp .testmondata.run.XXXXXX<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --create-db alone deletes the database when the run ends; with</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --reuse-db as well, the rebuilt one is kept for next time.</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rebuild</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$full</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon-noselect --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">uv run pytest <span class="s2">&#34;</span><span class="si">${</span><span class="nv">args</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> -q <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 0: passed, or nothing selected. 1: some failed (still recorded).</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 5: nothing collected at all.</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> in 0<span class="p">|</span>1<span class="p">|</span>5<span class="o">)</span> mv <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">;;</span> *<span class="o">)</span> rm -f <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">5</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>この中で説明しておきたい点が二つあります。<code>--reuse-db</code>は、テスト用データベースを実行の間で使い回すためのpytest-djangoのフラグです。一回の実行が数秒で済むようになると、これが効いてきます。毎回作り直していると、テストそのものより時間がかかることもあるからです。全体の実行や、新しいマイグレーションの後の実行では、データベースが現在のスキーマになるよう<code>--create-db</code>を足します。そのときも<code>--reuse-db</code>は外さないでください。<code>--create-db</code>だけだと、pytest-djangoは実行の終わりにデータベースを削除してしまい、次の実行でまた一から作ることになります。</p>
<p>もう一つは終了コードです。testmonがすべてのテストを選択から外したとき、実行は0で終わるので、「何も変わっていない」は成功として扱われます。終了コード5はpytestの「テストが一つも収集されなかった」という意味で、スクリプトはこれも問題なしとして扱います。</p>
<p>リストに入っていないものにも注目してください。<code>pyproject.toml</code>とロックファイルです。最初のバージョンはロックファイルが変わるたびに全部を実行していて、そのせいで遅いフォールバックがほぼ毎日発動し、入れる前より悪くなっていました。testmonはインストール済みのパッケージのバージョンを自分で追跡しているので、手助けはいりません。</p>
<p><strong>6. 専用のデータベースとキャッシュを持たせる。</strong> 同じマシンでほかにもテストを実行するもの、たとえば二つ目のターミナルやコーディングエージェント、<code>manage.py test</code>などがあるなら、スクリプトには専用のテスト用データベースと専用のキャッシュ番号を指定しておきます。そうすれば、使い回しているデータベースが他の誰かの実行と鉢合わせすることはありません。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">DATABASE_URL</span><span class="o">=</span><span class="s2">&#34;postgresql://myapp:myapp@localhost:5432/myapp_changed&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REDIS_URL</span><span class="o">=</span><span class="s2">&#34;redis://localhost:6379/8&#34;</span></span></span></code></pre></div></div>
<p><strong>7. 実行のたびにそのキャッシュを空にする。</strong> これは痛い目を見て覚えました。一部だけを実行したときに、全体の実行では出ない<code>KeyError</code>が出るようになったのです。カテゴリのラベルの一部が有効期限なしでキャッシュされていて、ある実行から使い回したテスト用データベースが、それより前の実行で温まったままのキャッシュと出会っていました。スクリプトの冒頭でキャッシュをクリアしたら直りました。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv run python -c <span class="s2">&#34;import os, redis; redis.Redis.from_url(os.environ[&#39;REDIS_URL&#39;]).flushdb()&#34;</span></span></span></code></pre></div></div>
<p>これはtestmonが、ドキュメントに書いてあるとおりのことをしているだけです。毎回違うサブセットを実行すると、前に何が実行されたかのおかげで通っていたにすぎないテストがあぶり出されます。直すべきはテストのセットアップで、testmonではありません。</p>
<p><strong>8. 選択が間違っているときのためのフラグを知っておく。</strong></p>
<ul>
<li><code>--testmon-noselect</code>はすべてのテストを実行しますが、落ちそうなものを先に回します。<code>-k</code>、<code>-m</code>、<code>--lf</code>やテストのnode IDを一緒に渡すと、testmonは自動的にこれに切り替えます。</li>
<li><code>--testmon-forceselect</code>は、影響を受けるテストのうち、<code>-k</code>や<code>-m</code>にも一致するものだけを実行します。</li>
<li><code>--testmon-nocollect</code>は既存の記録を使いますが、記録には何も追加しません。デバッガの下で動かしているときや、coverageがすでに動いているときは、強制的にこれになります。</li>
<li><code>--no-testmon</code>は、一回の実行だけtestmonを無効にします。<code>addopts</code>に入れているときに使います。</li>
<li><code>--testmon-env=&lt;name&gt;</code>は、一つのファイルの中に記録を別々に持ちます。二つのsettingsモジュールで実行するスイート向けです。</li>
</ul>
<p>プロジェクトにJavaScriptの側もあるなら、Jestには同じような機能が最初から入っています。<code>jest --changedSince=main</code>は、<code>main</code>以降に変わったものをimportしているテストを実行します。たどるのはcoverageではなくimportです。私はアプリの<code>package.json</code>に<code>npm run test:changed</code>として追加して、両側で同じ習慣が使えるようにしました。ユーティリティモジュールを一つ編集したときは、アプリの121個のテストファイルのうち7個が選ばれました。</p>

<h2 class="relative group">どれだけ節約できるか
    <div id="どれだけ節約できるか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%a9%e3%82%8c%e3%81%a0%e3%81%91%e7%af%80%e7%b4%84%e3%81%a7%e3%81%8d%e3%82%8b%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ラッパーを入れた後、次のセクションで書くバグを直してから、起動時間込みで計ってみました。</p>
<table>
	<thead>
			<tr>
					<th>何を変えたか</th>
					<th style="text-align: right">実行したテスト</th>
					<th style="text-align: right">時間</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>何も変えない</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>ホーム画面のバナーの裏にあるコードを一か所編集</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>その編集を元に戻す</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>比較用に、スイート全体（4プロセス）</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">約3½分</td>
			</tr>
	</tbody>
</table>
<p>ふだんの編集なら並列実行のスイートより約25倍速く、何も変わっていなければ約200倍速くなります。</p>
<p>記録も、もっと広い範囲で同じことを示しています。スイートが実行する、テスト以外のPythonファイル307個のうち、中央値にあたるファイルを変えたときに届くのはテスト22件、テスト時間にして約8秒です。反対の端にはいくつかのコアモジュールがあります。ほぼすべてのテストがこれらを実行するので、変更すると約1,500件のテストに届き、testmonはスイートの大半を再実行します。これは失敗ではなく、正しい答えです。これらの数字はファイルごとの上限です。testmonはブロック単位で動くので、関数を一つ変えれば、その関数を実行したテストだけが選ばれます。</p>
<p>コストが表に出るのは、ラッパーがフォールバックしたときです。全体の実行は最初と同じ1プロセスでの記録なので約12分かかり、置き換える前の並列実行のスイートより遅くなります。1プロセスなのは、testmonをpytest-xdistと組み合わせてまだ試していないからです。xdistは1.4から対応しているので、次に測るべきはそこでしょう。だからこそ、フォールバックのきっかけになるファイルのリストは、正直でいられる範囲でできるだけ短くしておくべきです。</p>

<h2 class="relative group">成功に見えたバグ
    <div id="成功に見えたバグ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%90%e5%8a%9f%e3%81%ab%e8%a6%8b%e3%81%88%e3%81%9f%e3%83%90%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初に出た数字は、これよりも良いものでした。一か所の編集でテスト6件、3.3秒。でもそれは間違っていて、画面のどこにもそうは書いてありませんでした。</p>
<p>Python 3.14から、coverage.pyはデフォルトで新しいコアを使います。インタプリタの<code>sys.monitoring</code>の上に作られたものです。このコアがある行を報告するのは、プロセスがその行を初めて実行したときだけです。testmonはどのテストがどの行を実行したかの記録をcoverageから作るので、各行は、たまたま最初にそれを実行したテスト一つだけのものとして記録され、ほかのどのテストにも結びつきませんでした。8件のテストが共有している行を編集しても、testmonはそのうち1件しか選びません。</p>
<p>気づいたのは、この仕組みをそのまま真似した、私の別の小さなプロジェクトでした。そちらに導入する作業の一環として、わざと一行を壊して結果を比べたところ、数字が合わなかったのです。こちらのスイートに戻って、同じ確認をしました。ホーム画面のバナーのコードで、スライドのタイトルの一行を壊します。素のpytestでは8件のテストが失敗しました。testmonが選んだのは4件です。壊れた残りの4件は一度も実行されず、出力のどこにも、それらが外されたことは書いてありませんでした。</p>
<p>直すのは一行で、<code>export COVERAGE_CORE=ctrace</code>です。これでcoverageは昔ながらのCトレーサーに戻ります。トレーサーが違えば記録も違うので、記録し直しました。2,248件のテストで12分3秒です。その後、同じように壊す編集をしたら、壊れた11件のテストがすべて実行されました。</p>
<p>追記（11月7日）：11月2日リリースのtestmon 2.3.0は、プロセス内でもサブプロセスでもCトレーサーを自分で設定するようになったので、新規に入れる場合はこのexportはもう要りません。次の段落のチェックは、今でも要ります。</p>
<p>この記事で何よりも先に伝えたい教訓はこれです。選ぶテストが少なすぎるテストセレクタは、成功とまったく同じに見えます。テストは少なく、実行は速く、すべて緑。だから導入するときは、共有されている何かをわざと壊し、素のスイートとセレクタの両方を実行して、同じテストが失敗するかを確かめてください。そして、Pythonやcoverage.pyのバージョンが変わるたびに、もう一度やってください。</p>

<h2 class="relative group">コーディングエージェントの出番
    <div id="コーディングエージェントの出番" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%b3%e3%83%bc%e3%83%87%e3%82%a3%e3%83%b3%e3%82%b0%e3%82%a8%e3%83%bc%e3%82%b8%e3%82%a7%e3%83%b3%e3%83%88%e3%81%ae%e5%87%ba%e7%95%aa" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>そのリポジトリの変更を、もう全部自分でしているわけではありません。小さな変更の多くはコーディングエージェント（私の場合はClaude Code）がしていて、良いエージェントは編集のたびにテストを実行します。11分かかるスイートを前にすると、エージェントにも私と同じ二つの悪い選択肢しかありません。待つか、どのテストファイルが関係するかを当て推量するかです。</p>
<p>そこで、リポジトリのエージェント向けの指示に、作業中のテストはこのラッパースクリプトで実行するのを基本とし、全体を実行するときは<code>--all</code>を付ける、と書きました。エージェントも私と同じ正確なテストの集合を、編集のたびに数秒で得られます。ステップ6でスクリプトに専用のデータベースとキャッシュを持たせているのも、これが理由です。忙しい日には同じマシンで複数のセッションがテストを実行していて、使い回しているデータベースが他の誰かの実行と鉢合わせすることは、絶対にあってはなりません。</p>
<p>どれも、testmonにAI固有の何かを求めているわけではありません。同じ記録、同じ選択です。ただ、変更を加えているのが何であれ、その変更の数がとても多いと、それだけ効き目も大きくなります。</p>

<h2 class="relative group">CIにはもっと粗いものを入れた理由
    <div id="ciにはもっと粗いものを入れた理由" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ci%e3%81%ab%e3%81%af%e3%82%82%e3%81%a3%e3%81%a8%e7%b2%97%e3%81%84%e3%82%82%e3%81%ae%e3%82%92%e5%85%a5%e3%82%8c%e3%81%9f%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonにはCIモードがありません。ランナーの上でもノートPCの上と同じように動きます。作者は、CIのランナー間で<code>.testmondata</code>を共有したりキャッシュしたりするのは、自分たちのホスティングサービスであるtestmon.netに比べてずっと効果が薄いと書いています。キャッシュした記録は上で挙げた点でどれも見えないままですし、私のCIはDjangoのランナーを4プロセスで動かしてスイートを実行しています。なので、testmonは入れませんでした。</p>
<p>代わりにCIに入れたのは、同じ考え方をファイル単位にしたものです。チェックのワークフローはまず小さなジョブでpushの差分を取り、リポジトリのどちら側に触れたかを判断します。PythonのAPIか、モバイルアプリか、両方か、どちらでもないか。これまでCIは、ドキュメントだけのものも含めて、pushのたびにPythonとアプリの両方のジョブを実行していました。今は、ドキュメントだけのpushなら何も実行せず、アプリだけへのpushならDjangoのスイートを飛ばします。実際のコミット5件に対してこのジョブを試しに走らせてみたところ、ドキュメントだけのコミットでは何も実行されず、アプリだけのコミットではPythonのジョブが、バックエンドだけのコミットではアプリのジョブが飛ばされました。削って例にしたものがこちらです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">python</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.diff.outputs.python }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">diff</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">needs.changes.outputs.python == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span></span></span></code></pre></div></div>
<p>実際のワークフローでは、比べる範囲がないとき、つまり新しいブランチやforce push、手動実行のときにも全部を実行します。</p>
<p>盲点は、形を変えただけで同じものです。testmonにはJSONファイルが見えませんが、パスのフィルタも、リストに書かない限り見えません。そこでパターンには共有のフィクスチャ、ビルドしたバンドル、生成したサイトを明示的に書き、編集する人にはリストを実態に合わせておくよう頼むコメントを添えています。ファイル単位のやり方は大雑把ですが、失敗するときは安全な方向に失敗します。Python側で何かが変わっていれば、スイート全体が実行されます。</p>

<h2 class="relative group">入れる価値はあるか
    <div id="入れる価値はあるか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%a5%e3%82%8c%e3%82%8b%e4%be%a1%e5%80%a4%e3%81%af%e3%81%82%e3%82%8b%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>実行に数分かかるスイートなら、あります。本当のコストは見えないものの方で、ラッパースクリプトはそのためにあります。それでも、testmonが活きるのはノートPCでの開発ループです。CIはPython側が変わるたびに今もスイート全体を実行していて、デプロイの前に私が信頼しているのはその実行です。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>GitHub ActionsのCIでDocker Composeをやめた話：代わりに何を入れ、いまどんなパイプラインを動かしているか</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>2026年に動かしているGitHub ActionsのCI/CD：CIではDocker Composeの代わりにサービスコンテナ、キャッシュはステップの所要時間を見て選び、デプロイはスクリプトで。</description>
      <content:encoded><![CDATA[<p>CI/CDパイプラインとは、変更が<code>git push</code>から本番に届くまでの自動化された道筋です。CIはすべてのpushをクリーンなマシンでビルドしてテストし、CDは通ったものを繰り返せる一つの手順でリリースします。GitHub Actionsでは<code>.github/workflows/</code>の下に置くYAMLファイルがそれで、私のものはわざと薄くしています。チェックは手元のノートPCで打つのと同じコマンドで、デプロイはどれもGitHubがなくても同じように動くスクリプトかCLIです。</p>
<p>GitHub ActionsでDocker Composeを動かす方法を探してここに来たなら、先に結論を。私はそれを2年やって、やめました。いまのCIは同じPostgresとValkeyのイメージを<code>services:</code>として宣言し、同じ<code>uv run …</code>と<code>npm run …</code>をランナー上で直接実行します。Composeは開発者が手元で動かすものに戻りました。以下はその理由と、実際のファイルです。</p>
<p>2024年に<a href="https://jared.lynskey.co.nz/ja/posts/2024/2024-06-02-simplifyingcicd/" >CI/CDのロジックはプラットフォーム固有のYAMLではなくDocker Composeに</a>という記事を書きました。ビルドのロジックはComposeに置き、CIツールにはそれを呼ぶことだけをさせる、という内容です。2年たって、実際にリリースに使っているリポジトリを全部見直しました。ワークフローがあるのは4つで、コミッターはどれも私一人です。そして、そのワークフローのうち<code>docker compose</code>を呼んでいるものは、もう一つもありません。原則は残り、やり方は残りませんでした。以下はその代わりに入ったものです。ファイルを削って例にしたもの、GitHubが記録したステップごとの所要時間、そしてCI/CDの図が期待するほどには自動化されていない部分も書いています。</p>

<h2 class="relative group">動かしているパイプラインを一つの表に
    <div id="動かしているパイプラインを一つの表に" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8b%95%e3%81%8b%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%82%92%e4%b8%80%e3%81%a4%e3%81%ae%e8%a1%a8%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>パイプライン</th>
					<th>起点</th>
					<th>CIが確認すること</th>
					<th>行き先</th>
					<th>リリースする人</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>このブログとクライアントのHugoサイト</td>
					<td>すべてのpush、ブランチを問わず</td>
					<td><code>hugo --gc</code>がビルドできること</td>
					<td>Cloudflare Pages：<code>main</code>は本番、ほかのブランチはプレビューURL</td>
					<td>なし。pushがそのままリリース</td>
			</tr>
			<tr>
					<td>私が作っているマーケットプレイスアプリのAPI</td>
					<td>デプロイスクリプト、手動</td>
					<td><code>main</code>へのpushごとにリント、型チェック、Djangoのテスト</td>
					<td>Fly.io、ローリングデプロイ</td>
					<td>私</td>
			</tr>
			<tr>
					<td>同じアプリのフロントエンド：Web、OTAアップデート、ストア向けビルド</td>
					<td>リリース用スクリプトとローカルのストア向けビルド、手動</td>
					<td><code>main</code>へのpushごとにTypeScriptとJest</td>
					<td>オブジェクトストレージ、OTAアップデートのサービス、二つのアプリストア</td>
					<td>私</td>
			</tr>
			<tr>
					<td>pyhtml2text（私のオープンソースのPythonパッケージ）</td>
					<td>すべてのpush、プルリクエスト、タグ</td>
					<td>15のビルドターゲット向けのwheel、その大半でテスト</td>
					<td>PyPI、タグを打ったとき</td>
					<td>タグ</td>
			</tr>
	</tbody>
</table>
<p>教科書の言葉でいえば、二つのHugoサイトは継続的デプロイメントで、アプリ（私が作っているマーケットプレイス兼コミュニティアプリ）は継続的インテグレーションにコマンド一つのリリースを足した継続的デリバリーです。チェックは何かがおかしいと教えてくれるだけで、デプロイを止めはしません。これは意図してそうしています。OTAアップデートは対象のバイナリバージョンを使っているすべての端末に届くので、マージの副作用ではなく判断であるべきです。そしてAPIはデータベースのマイグレーションが走る場所です。残り二つのDjangoアプリには、ワークフローがそもそもありません。</p>

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

<h2 class="relative group">Docker Composeファイルはどこへ行ったか
    <div id="docker-composeファイルはどこへ行ったか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#docker-compose%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%e3%81%af%e3%81%a9%e3%81%93%e3%81%b8%e8%a1%8c%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリの<code>docker-compose.yml</code>のうち、CIに関係する部分を例にしたものです。実物にはもっと多くのサービスと、ずっと多くの環境変数があります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p><code>checks.yml</code>の<code>services:</code>ブロックと並べると、同じイメージが二つです。Postgres 17（こちらはAlpine版、CIはDebian版）と<code>valkey/valkey:8-alpine</code>。いまの関係はこうです。Composeファイルは開発者が動かすもの。CIは同じイメージをサービスコンテナとして宣言し、開発者が打つのと同じコマンド、<code>uv run …</code>と<code>npm run …</code>をランナー上で直接実行します。</p>
<p>2024年の記事と比べると、いちばん大事にしていた部分は残りました。CIツールは薄く、チェックもデプロイも自分で実行できるコマンドです。残らなかったのは、CIからComposeを呼ぶやり方です。</p>
<p>アプリのCIはDockerイメージをまったくビルドしません。イメージはデプロイのときにFlyのビルダーが一度だけビルドし、CIはコードが大丈夫かどうかだけ答えればよく、ツールチェーンがキャッシュされていればそれは6秒のインストールで済みます。それにComposeファイルは作業コピーをバインドマウントし、データを投入しますが、それはテストの実行がすることではありません。CI用に別のComposeファイルが必要になっていたはずで、それは同じサービスの二つ目の定義を手で揃え続けるということです。</p>
<p>そして2024年の例には、当時気づかなかったバグがありました。<code>docker compose build</code>はイメージをビルドするだけで、サービスの<code>command</code>は実行しません。なので、あのファイルの<code>npm test</code>はCIで一度も走っておらず、ワークフローはテストが通ろうが落ちようが緑でした。</p>
<p>あの記事がいちばん心配していたリスク、つまりCIベンダーの乗り換えは、結局起きませんでした。実際に起きた移行は反対側でした。このブログとクライアントのサイトがホスティング先をCloudflare Pagesに移し、ブログのほうは45行のワークフローを一つ新しく書くだけで済みました。ビルドがもともとコマンド一つだったからです。それから、私が警告していた300行のワークフロー。一つあります。pyhtml2textのものです。wheelをLinux、macOS、Windowsのランナーで、それぞれ複数のアーキテクチャ向けにビルドしなければならないので長いのですが、だからこそプラットフォーム固有のYAMLが正しい道具になる唯一の仕事でもあります。</p>

<h2 class="relative group">キャッシュ：まずステップの所要時間を読む
    <div id="キャッシュまずステップの所要時間を読む" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%81%be%e3%81%9a%e3%82%b9%e3%83%86%e3%83%83%e3%83%97%e3%81%ae%e6%89%80%e8%a6%81%e6%99%82%e9%96%93%e3%82%92%e8%aa%ad%e3%82%80" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>GitHubはすべてのステップの所要時間を記録していて、<code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code>で取り出せます。以下は10月20日の、アプリで最新の成功した実行です。</p>
<table>
	<thead>
			<tr>
					<th>ジョブ</th>
					<th>ステップ</th>
					<th>秒</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>PostgresとValkeyの起動</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>、キャッシュあり</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Djangoのテスト</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>、キャッシュあり</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>依存関係のキャッシュは二つとも有効で、どちらも安い部分です。GitHubがこのリポジトリ用に保持しているuvのキャッシュは200KBほどで、setup-uvがダウンロードし直せば済むものを整理してしまうからです。npmのほうは400MB近くあります。時間がかかっているのはテストで、テストはすでに4並列で走っています。実行を速くしたいなら、キャッシュではなくテストスイートに手を入れます。</p>
<p>ブログは正反対です。Hugoが処理した画像はgitignoreされた<code>resources/_gen</code>にあるので、新しいランナーのたびにカバー、本文の画像、プレースホルダーを全部エンコードし直していました。後で載せるワークフローは<code>actions/cache</code>でこれを復元します。キーは画像そのもののハッシュで、合わなければいちばん新しい古いキャッシュで代用します。Hugoは足りない分しか処理しないからです。キャッシュ導入前の最後の8回では、ビルドのステップに33〜76秒かかっていました。導入後の26回は6〜20秒で、例外は94秒の一回だけ。しかも同じコミットでプレースホルダーと二つ目のカードサイズを追加したうえでの数字です。</p>
<p>アプリのイメージはFlyのビルダーでビルドされ、依存関係のファイルをコードより先にコピーし、uvのダウンロードキャッシュはBuildKitのキャッシュマウント（<code>RUN --mount=type=cache,target=/root/.cache/uv</code>）に置いています。ただ、もっと大きな改善はキャッシュではありませんでした。以前の<code>.dockerignore</code>はブロックリストで、追加のgit worktreeを置いたフォルダーを漏らしていました。それぞれが自分の依存関係とビルド成果物を抱えています。そのせいで27万7千ファイル、7.7GBが、デプロイのたびにビルダーへ送られていました。許可リストに書き直したいま、ビルドコンテキストは40MBほどです。パターンの例はこうです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

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

<h2 class="relative group">デプロイ：pushごとに、スクリプトで、タグで
    <div id="デプロイpushごとにスクリプトでタグで" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%87%e3%83%97%e3%83%ad%e3%82%a4push%e3%81%94%e3%81%a8%e3%81%ab%e3%82%b9%e3%82%af%e3%83%aa%e3%83%97%e3%83%88%e3%81%a7%e3%82%bf%e3%82%b0%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>

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

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

<h3 class="relative group">pyhtml2textはタグでリリース
    <div id="pyhtml2textはタグでリリース" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pyhtml2text%e3%81%af%e3%82%bf%e3%82%b0%e3%81%a7%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>リリースのジョブです。パッケージ名は一般的なものにしてあります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>リリースのジョブはすべてのビルドジョブを待ち、実際に公開するのはタグのときだけです。手動実行はビルドとアテステーションまではしますが、何もアップロードしません。<code>actions/attest</code>はどのワークフローが各wheelをビルドしたかを記録し、<code>uv publish --trusted-publishing always</code>はパスワードの代わりにOIDCトークンでアップロードします。パッケージそのものについては<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-10-18-pyhtml2text/" >閉じられたプルリクエスト、フォーク、そしてPyPIパッケージ</a>に書きました。</p>

<h2 class="relative group">定期ワークフローはベストエフォート
    <div id="定期ワークフローはベストエフォート" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%9a%e6%9c%9f%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc%e3%81%af%e3%83%99%e3%82%b9%e3%83%88%e3%82%a8%e3%83%95%e3%82%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリには定期ワークフローが一つあります。Flyがベースラインより多くのマシンを起動していないかを見るチェックで、放っておけば請求書で初めて気づくことです。通知はわざと失敗することで行います。定期実行が失敗すると、GitHubが所有者にメールを送るからです。</p>
<p>設定は<code>cron: '*/15 * * * *'</code>、1日96回です。ところがこの2週間は1日3〜8回しか動いておらず、実行間隔の中央値は3時間40分ほどでした。GitHubのドキュメントが挙げる理由は負荷だけです。Actionsが混んでいると定期実行は遅れることがあり、混み具合によっては落とされることもある、と。持続的なスケールアウトを見るなら数時間で十分です。数分以内に鳴らなければならないものにとって、GitHubのcronは監視ではありません。</p>

<h2 class="relative group">パイプラインに投げかけたい質問
    <div id="パイプラインに投げかけたい質問" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%ab%e6%8a%95%e3%81%92%e3%81%8b%e3%81%91%e3%81%9f%e3%81%84%e8%b3%aa%e5%95%8f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これから作る人も、すでにあるものを見直している人も：</p>
<ol>
<li>GitHub Actionsが丸一日止まったら、それでもリリースできますか？ できないなら、リリースのロジックがYAMLの中にあるということで、2024年の記事がまだ当てはまります。</li>
<li>CIは本番と同じインタープリタ、同じCPUアーキテクチャ、同じデータベースのメジャーバージョンで動いていますか？ 私のCIがコンパイラのバグを捕まえられたのは、ランナーと本番がどちらもx86_64で、ノートPCがそうではなかったからです。</li>
<li>まっさらなチェックアウトで通りますか？ 通らないとしたら、たいていは生成されてgitignoreされたファイルのせいです。</li>
<li>実行時間は実際どこに使われていますか？ キャッシュを足す前に、ステップの所要時間を読んでください。</li>
<li>シークレットはそれぞれ何か所にありますか？ そして本当にGitHubに置く必要がありますか？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>閉じられたプルリクエスト、フォーク、そしてPyPIパッケージ：Python向けの高速なHTML→Markdown変換</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>オープンソース</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>パフォーマンス</category>
      <description>AIエージェント向けにHTMLをMarkdownに変換するとき、相対リンクを解決する必要がありました。上流のRustクレートは、LLMで書かれたという理由で私のプルリクエストを受け入れられなかったので、フォークしてpyhtml2textとして公開しました。base_url付きのPythonバインディング、新しいhtml2markdown、プロファイルガイド最適化をかけたwheel、そして深くネストしたHTMLを線形時間で処理できるようにパッチを当てたパーサーです。なぜ作ったのか、中に何が入っているのか、そして出力をどう検証したかを紹介します。</description>
      <content:encoded><![CDATA[<p>10月15日、<a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a>をPyPIで公開しました。HTMLをプレーンテキストやMarkdownに変換するライブラリで、Chris EmersonさんのRustクレート<a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>をベースにしています。Markdownへの変換では、計測したページで、よく使われているPythonの変換ライブラリより3〜21倍高速でした。また、相対リンクをページのURLを基準に解決できるので、出力をページから切り離してもリンクがそのまま使えます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<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">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>始まりはrust-html2textへのプルリクエストでした。メンテナーはLLMで書かれたという理由でそれをクローズしたので、pyhtml2textはフォークの上で動いています。この記事では、その経緯、フォークとパッケージの中身、そしてMarkdownが正しいことをどう確かめたかを書きます。</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%aa%e3%81%9c%e5%bf%85%e8%a6%81%e3%81%a0%e3%81%a3%e3%81%9f%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私が作っているマーケットプレイス兼コミュニティアプリは、<code>Accept: text/markdown</code> のリクエストに、HTMLの代わりにMarkdownを返します。ナビゲーションバーやフッターをかき分けて読みたくないAIエージェントやLLMクライアントのためです。エッジ側の話は<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >クローラーとキャッシュについての記事</a>に書きました。アプリのデータから作るページはHTMLが空のJavaScriptシェルなので、Markdownを直接組み立てています。それ以外は静的なHTML（マーケティング、規約、ブログのページ）で、こちらは変換しています。</p>
<p>変換するうえで問題になるのはリンクです。静的サイトのページには <code>/en/posts/...</code> や <code>../setup/</code> のようなリンクがたくさんあります。ブラウザなら元のページが分かっているので問題ありません。でもMarkdownを読むエージェントにはその文脈がないので、<code>/login</code> をたどれません。必要なのは <code>https://example.com/login</code> です。</p>
<p>純粋なPythonで書かれた<a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>にはこのための <code>baseurl</code> オプションがあり、最初はそれを使っていました。ただ遅いうえに、Markdownの出力にも問題があります（後で説明します）。rust-html2textは高速でテストもしっかりしていましたが、リンクを解決する手段がありませんでした。</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%e4%b8%8a%e6%b5%81%e3%81%ab%e7%9b%b8%e8%ab%87" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>8月29日に<a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a>を立て、ユースケースと求める挙動を書きました。RFC 3986に従って解決し、絶対URLや <code>mailto:</code>、<code>data:</code>、<code>#fragment</code> のリンクはそのままにする、というものです。その数分後に<a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">プルリクエスト #288</a>を出しました。<code>Config::base_url(...)</code> というオプションを追加する変更で、issueには「たたき台のつもりなので、メンテナーが別のアプローチを取りたければそれでも構わない」と書き添えました。</p>
<p>10月11日、メンテナーはPRをクローズしました。</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn&rsquo;t previously stated)</p>
</blockquote><p>訳：「貢献ありがとうございます。ただ、LLMで生成されたもののように見えるため、受け入れることができません（README.mdに簡単なポリシーを追加しました。これまでは明記していませんでした）。」</p>
<p>issueにも返信がありました。</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>訳：「こんにちは。妥当な機能リクエストですね、ありがとうございます！ PRはLLMで書かれたように見え、それは受け入れられないのでクローズしましたが、取り組んでくれてありがとうございます。短いHTMLのサンプルと期待される出力があれば、機能を実装するうえで間違いなく助けになります！」</p>
<p>READMEには現在、次のポリシーが書かれています。</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>訳：「現時点では、著作権上の位置づけが不確かであるため、このプロジェクトは生成AIやLLMで作られたコードの貢献を受け入れることができません。」</p>
<p>その見立ては正しく、あのコードは私がAIコーディングツールで書いたものです。ポリシー自体も不当だとは思いません。LLMの出力の著作権上の扱いはまだ定まっていませんし、ほかのプロジェクトが依存しているMITライセンスのクレートにそれをマージするリスクを負うのはメンテナーです。メンテナーのプロジェクトですし、返信も丁寧でした。機能リクエストは今もオープンのままです。</p>
<p>それでも、私にはこの機能が必要でした。かといって、メンテナー自身の貢献ポリシーについて言い争うのは、機能を手に入れる良い方法ではありません。そこで変更はフォークに残し、その上にPythonパッケージを作りました。どちらにも、上流のものではないとはっきり書いてあります。</p>

<h2 class="relative group">フォークの中身
    <div id="フォークの中身" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%95%e3%82%a9%e3%83%bc%e3%82%af%e3%81%ae%e4%b8%ad%e8%ba%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>フォークは私のrust-html2textフォークの<a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> ブランチ</a>です。上流にきれいにリベースできるよう、小さなコミット3つだけにとどめています。中心はPRで出した <code>Config::base_url</code> オプションで、相対リンクを<a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>に従って解決します。base URLを渡さなければ出力は変わりません。もうひとつは、呼び出しのたびにスタイルルールを作り直さないようにした変更で、短いスニペットで7.3 µsが5.6 µsになりました。大きなページでは誤差ですが、数千回呼べば効いてきます。最後のひとつは、後述のMarkdownコンバーターがクレートから必要とする部分を公開しているだけです。</p>

<h2 class="relative group">Pythonパッケージ
    <div id="pythonパッケージ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#python%e3%83%91%e3%83%83%e3%82%b1%e3%83%bc%e3%82%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a>はPyO3とmaturinで作っていて、関数は2つです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</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">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</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="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code> はrust-html2textのプレーンテキストレンダラーで、ターミナルやメールの <code>text/plain</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="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></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">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>バインディングについて、すぐには分からないかもしれない点をいくつか挙げます。</p>
<ul>
<li><strong>どちらの関数も <code>str</code> かUTF-8の <code>bytes</code> を受け付けます。</strong> なので、<code>response.content</code> をデコードせずにそのまま渡せます。入力はコピーせずに借用します。</li>
<li><strong>変換中はGILを解放します。</strong> 最初から最後まで純粋なRustなので、スレッドは複数のコアにスケールします。モジュールはGILを必要としないことも宣言しているので、フリースレッド版のPython 3.14tでも、インポート時にGILが再び有効になることはありません。</li>
<li><strong>パニックは <code>ValueError</code> として返ります。</strong> PyO3はデフォルトで <code>PanicException</code> を送出しますが、これは <code>except Exception</code> では捕まえられません。変換エラーは処理済みのつもりのWebワーカーがそれで落ちてしまうので、バインディング側で捕まえて <code>ValueError</code> として送出し直しています。</li>
<li><strong>wheelは30個あります。</strong> CPython 3.10以降向けのabi3 wheelと、フリースレッド版3.14t向けのwheelを、Linux（glibcとmusl）、macOS、Windowsについて、maturinのCIがビルドできるすべてのアーキテクチャ向けに用意しています。なので、インストールにRustのツールチェーンは必要ありません。</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="#%e9%ab%98%e9%80%9f%e5%8c%96" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バージョン0.2.0は、実際のページで0.1.0の1.5〜1.7倍、短いスニペットでは2倍速くなり、出力はバイト単位で同じでした。大半はプロファイルガイド最適化（PGO）によるものです。CIで計測用のwheelをビルドし、ドキュメントのページとRFCからなる学習用セットで動かして、そのプロファイルを使って本番のwheelをビルドします。学習用セットに含まれないページで約25%速くなります。CIで計測用ビルドをネイティブに実行できるプラットフォームでだけ有効にしています。残りは、小さなメモリ確保が大量にあっても強いmimallocアロケーターへの切り替えと、フォークで入れたスタイルルールの変更によるものです。</p>
<p>高速化で何も変わっていないことを確かめるため、実際のページを集めたコーパスをすべてのオプションの組み合わせで変換し、その出力のハッシュを記録しました。空の入力や非常に深いネストといったエッジケースも含めています。どの変更も、ハッシュをひとつも変えないことが条件でした。</p>
<p>以下はApple SiliconのMacで計測した中央値です。まずMarkdownへの変換です。</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>百科事典サイトの記事（1 MB）</th>
					<th>ニュースサイトのトップページ（370 KB）</th>
					<th>Pythonドキュメントのページ（245 KB）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>（純粋なPython）</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>次にプレーンテキストへの変換です。</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>百科事典サイトの記事（780 KB）</th>
					<th>ニュースサイトのトップページ（370 KB）</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a>（純粋なPython）</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">深くネストしたHTMLとhtml5everへのパッチ
    <div id="深くネストしたhtmlとhtml5everへのパッチ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b7%b1%e3%81%8f%e3%83%8d%e3%82%b9%e3%83%88%e3%81%97%e3%81%9fhtml%e3%81%a8html5ever%e3%81%b8%e3%81%ae%e3%83%91%e3%83%83%e3%83%81" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>40,000階層のリストを手で書く人はいません。でも、自分が書いていないページを変換していれば、いずれ悪意のあるものや壊れたものが送られてきます。標準のパーサーでは、40,000階層にネストした <code>&lt;ul&gt;&lt;li&gt;</code> のパースに<strong>6秒</strong>かかりました。たった1つのリクエストでワーカーが止まってしまうということです。</p>
<p>原因は、rust-html2textが使っているHTML5パーサーの<a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>にありました。ほとんどのブロックレベルの開始タグは、開いている <code>&lt;p&gt;</code> 要素が「button scope内」にあるかを確認する必要があり、html5everはそのたびに開いている要素のスタック全体をたどっていました。ネストが深くなるほどタグ1つあたりの時間が延び、全体の時間は深さの2乗で増えていったわけです。</p>
<p>パッケージには、パッチを1つ当てたhtml5ever 0.39.0を同梱しています。要素が開いたり閉じたりするたびにその答えを更新しておくので、確認にかかる時間がネストの深さに左右されなくなりました。同じ文書のパースは<strong>0.14秒</strong>になりました。パッチがほかに何も変えていないことを確かめるため、html5libのツリー構築テストすべて、実際のページ約20件、ランダムなタグスープ文書300,000件で、元のパーサーとパッチ版のDOMを比較し、すべて一致しました。</p>
<p>Markdownコンバーターにも、同じ理由で上限を設けています。どんな入力でもスレッドのスタックをあふれさせることはなく、引用やリストは32階層を超えるとそれ以上インデントしないので、出力が2乗で膨らむこともありません。展開したグリッドが100,000セルを超える表は、セルの中身だけを書き出します。</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>バージョン0.3.0で <code>html2markdown</code> を追加しました。パッケージの作業の大半はこれに費やしています。rust-html2textのレンダラーはMarkdownに見えるテキストを作りますが、ターミナル向けに折り返されていて、パースされることは想定していません。そこで <code>html2markdown</code> はパーサーだけを借りて、出力は自前で<a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>に、GitHub形式のパイプテーブルと <code>~~strikethrough~~</code> を加えた形で書き出します。</p>
<ul>
<li><code>#</code> の見出し、<code>-</code> の箇条書き、<code>_emphasis_</code> と <code>**strong**</code>。</li>
<li>インラインのリンクと画像。<code>base_url</code> を指定すれば絶対URLになります。中身がアイコンだけのリンクは、<code>aria-label</code> か <code>title</code> をリンクテキストとして使います。</li>
<li>フェンス付きのコードブロック。ページが言語を指定していれば（<code>class=&quot;language-python&quot;</code>）、その言語を付けます。</li>
<li>パイプテーブル。行や列をまたぐセルは空のセルになり、ヘッダー行のない表には空のヘッダー行が付き、ページのレイアウトに使われている表は段落として書き出します。</li>
<li>行の折り返しはしません。隠しコンテンツ（<code>hidden</code>、<code>aria-hidden</code>、<code>display: none</code>）、スクリプト、スタイル、フォームのコントロールは除外します。</li>
</ul>

<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="#%e5%bf%85%e8%a6%81%e3%81%aa%e3%81%a8%e3%81%93%e3%82%8d%e3%81%a0%e3%81%91%e3%82%a8%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%97%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>HTMLからMarkdownへの変換ライブラリの中には、Markdownの構文になりうる文字をすべてエスケープするものがあります。たとえばmarkdownifyは、デフォルトで <code>_</code> と <code>*</code> をすべてエスケープします。出力は正しいのですが、<code>snake\_case</code> や <code>5 \* 3</code> だらけになり、人間には読みにくく、モデルにとってはトークンの無駄です。pyhtml2textは、CommonMarkのパーサーが構文として読んでしまう場所でだけ文字をエスケープします。</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">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</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="#%e9%9f%93%e5%9b%bd%e8%aa%9e%e3%81%ae%e5%8a%a9%e8%a9%9e%e3%81%ae%e5%89%8d%e3%81%ae%e5%a4%aa%e5%ad%97" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>これは私のアプリ自身のページで見つかった問題です。韓国語では、文法上の助詞がスペースなしで名詞の直後に付きます。そのため、括弧を含む語を太字にするとこうなります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>素直にMarkdownにすると <code>**타이타닉(Titanic)**의</code> で、markdownifyもこう書き出しますが、これは機能しません。CommonMarkのフランキング（flanking）ルールでは、<code>**</code> が句読点の直後で閉じるには、その後ろにスペースか別の句読点が続く必要があります。<code>의</code> は文字なので、アスタリスクはそのまま文字として残ってしまいます。純粋なPythonのhtml2textは、スペースを入れてこれを回避します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>これで太字にはなりますが、韓国語が変わってしまいます。助詞が独立した単語になり、綴りの誤りになるのです。pyhtml2textは代わりに、句読点をマーカーの外に出します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>閉じ括弧は太字ではなくなりますが、強調は正しくパースされ、元の文字はすべて残ります。</p>
<p>日本語でも同じことが起きます。日本語の助詞（の、を、は）も、韓国語と同じく前の語にスペースなしで付きます。全角の括弧もCommonMarkでは句読点として扱われるので、<code>**タイタニック（Titanic）**の</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="#%e8%aa%ad%e3%81%bf%e6%88%bb%e3%81%97%e3%81%a6%e5%87%ba%e5%8a%9b%e3%82%92%e7%a2%ba%e3%81%8b%e3%82%81%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ユニットテストが通るからといって、変換ライブラリを信用はしません。Markdownの出力のバグは、誰もテストを書こうと思わなかった組み合わせに潜んでいるからです。そこで、出力を読み戻して確かめています。</p>
<p>変換中は、段落ごとにCommonMarkとしてもう一度パースし、強調が意図どおりに読めるかを確認します。読めなければ、もう一方の強調スタイル（<code>_</code> の代わりに <code>*</code>、またはその逆）で書き直し、どちらもだめならテキスト、リンク、コードは残して書式だけを落とすので、単語が失われることはありません。テストスイートでは、別のMarkdownパーサーで往復チェックをして、表示される単語、リンク先、画像のソースをすべて元のHTMLと比較します。エスケープされなかった構文に飲み込まれたテキスト、パースされなかったリンク、紛れ込んだ <code>*</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="#%e6%9c%ac%e7%95%aa%e7%92%b0%e5%a2%83%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私のアプリのMarkdownネゴシエーションは、0.3.0が出たその日に、純粋なPythonのhtml2textからpyhtml2textに切り替えました。ガイドページのひとつでは、変換が4.2 msから0.36 msになり、約12倍高速になりました。リンクは絶対URLになり、表は本物のパイプテーブルになり、韓国語の太字は助詞がくっついたままです。abi3 wheelがx86_64とarm64のPython 3.14をすでにカバーしているので、コンテナイメージにはインストールのために何も追加する必要がありませんでした。</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%ac%e3%82%b8%e3%83%83%e3%83%88%e3%81%a8%e3%83%90%e3%82%b0%e5%a0%b1%e5%91%8a%e5%85%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>プレーンテキストのレンダリングは、Chris EmersonさんによるMITライセンスのrust-html2textです。pyhtml2textは、そのフォーク、パッチを当てたhtml5ever（MIT/Apache-2.0）、そしてMarkdownコンバーターを加えたものです。このパッケージはどちらのプロジェクトとも提携しておらず、承認も受けていません。なので、<strong>問題は上流ではなく<a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2textのissueトラッカー</a>に報告してください</strong>。このコードのバグを直すのは私の役目ですし、上流のメンテナーが受け入れを断ったコードについての報告を受けるべきではありません。</p>
<p>PythonでHTMLを変換していて、とくにモデルに読ませる用途なら、ぜひ試してみてください。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">pip install pyhtml2text</span></span></code></pre></div></div>
<p>ソースは<a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>にあり、0.1.0から0.3.0までの変更はすべてchangelogにまとめてあります。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/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>絵の値段は何で決まるのか：アートのオークション価格を予測するアルゴリズムを作る</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-04-01-art-evaluation-algorithm/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-04-01-art-evaluation-algorithm/</guid>
      <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Machine Learning</category>
      <category>XGBoost</category>
      <category>Art Market</category>
      <category>Python</category>
      <category>Data Science</category>
      <description>絵画の価値、つまりオークションでの値段は何で決まるのか。34,200件の落札データを使った2024年のXGBoost研究を再現すると、市場が値付けしているのは作品ではなく作家だと分かりました。使った特徴量と結果、そしてその周りに作ったアート評価プラットフォームについて。</description>
      <content:encoded><![CDATA[<p>オークションで絵の値段を決めているのは何か。答えは、ほとんどの場合、作品ではなく作家です。現代アートのオークション34,200件を分析した2024年の研究では、作品だけを見たモデルが説明できたのは、価格のばらつきのうち約5%でした。作家のキャリア、展示歴、過去の販売実績をもとにしたモデルなら約73%、さらにオークションハウスの事前予想額を加えると92%に達します。この記事では、その結果をどう再現したか、そしてアルゴリズムでアートを評価するとはどういうことかを書きます。</p>
<p>ここ数週間、関連する2つのプロジェクトに取り組んでいました。1つ目は、2024年に <em>Scientific Reports</em> に載った Lee et al. の論文 &ldquo;Social signals predict contemporary art prices better than visual features, particularly in emerging markets&rdquo; の再現です。存命作家590人、34,200件のオークション記録でXGBoostモデルを学習させる内容です。2つ目はアートのプラットフォーム作りで、作家・所有者・オークション記録をつなぐナレッジグラフが中心にあります。要するに、ああいうモデルで何か意味のあることをしようと思ったときに必要になるデータの配管です。</p>
<p>どちらのプロジェクトも、結局同じ問いに戻ってきました。作品がいくらで売れるかは本当に予測できるのか？答えは「できる」です。ただし、作品を見ても無理です。効く特徴量はほぼすべて社会的なものでした。</p>

<h2 class="relative group">アートの値付けが難しい理由
    <div id="アートの値付けが難しい理由" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%a2%e3%83%bc%e3%83%88%e3%81%ae%e5%80%a4%e4%bb%98%e3%81%91%e3%81%8c%e9%9b%a3%e3%81%97%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>コードを書く前に、鑑定士やオークションハウスが実際どう値付けしているのかを数日かけて読み込みました。考慮する要素は多く、そのほとんどはきれいに数値化できません。来歴（誰が所有し、どこで展示され、どのカタログに載ったか）。帰属と真贋──作家の財団が本物と認めた作品は、「〜の作と伝えられる」だけの作品より桁違いに高くなります。個展、ビエンナーレ、美術館収蔵、批評で積み上がる作家の評判。それに保存状態と修復履歴、作家の作品群の中での稀少性、媒体（キャンバスの油彩は紙の作品より、紙の作品は版画より高く売れる傾向があります）。大きな美術館が所蔵していれば、2次市場が信頼するお墨付きになります。タイミングも効きます。5年前の比較事例はもう古いのです。</p>
<p>オークションハウスの専門家は「感情価値」の話もします。コレクターの欲望、入札合戦の力学、個人的な思い入れ。これらは合理的な見積もりをはるかに超えてハンマープライスを押し上げることがあります。直接モデル化はできませんが、オークションハウスの事前予想額を通じて間接的にその輪郭を捉えることはできます。</p>
<p>実務で主流の評価手法は比較売買分析です。同じ作家の類似作品の直近のオークション結果を探して、差分を調整する。XGBoostがやっているのも本質的には同じことで、ただ人間の鑑定士が手で追える範囲をはるかに超えた規模と特徴量でやっているだけです。</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%ab%96%e6%96%87%e3%81%ae%e4%b8%ad%e5%bf%83%e7%9a%84%e4%b8%bb%e5%bc%b5" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Lee et al. の主張は直感に反します。作品の視覚的内容は、価格をほとんど説明しないというのです。視覚的特徴のみ（色、構図、エッジ密度、ResNet18の埋め込み）を使ったモデルはR²約0.055。平均値を答えるより少しマシな程度です。</p>
<p>一方、作家レベルの「社会的」特徴のみ（キャリアステージ、展示歴、過去のオークション価格、ArtFactsランキング）を使ったモデルはR² ≈ 0.73に達します。さらにオークションハウスの事前予想額を特徴量に加えると0.92まで上がります。</p>
<p>つまり、作品そのものはほとんど関係ないのです。重要なのは誰が作ったか、そして市場がその人物についてすでに何を語っているかです。</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%ab%96%e6%96%87%e3%82%92%e5%86%8d%e7%8f%be%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>依頼内容はシンプルでした。1人開発のMVPとして論文を再現する。本番デプロイなし、ライブのデータパイプラインなし。忠実な再実装と、プロトタイプ品質のコードと、所見をまとめたレポートです。</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%87%e3%83%bc%e3%82%bf" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>論文は補足資料としてクリーニング済みのCSVを提供しています。</p>
<table>
	<thead>
			<tr>
					<th>ファイル</th>
					<th>行数</th>
					<th>内容</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Df_mloutfull.csv</code></td>
					<td>86,221</td>
					<td>500列以上を持つ生データセット</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_up_to_2012.csv</code></td>
					<td>34,200</td>
					<td>メインのクリーニング済みデータセット、1996–2012</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_old_market.csv</code></td>
					<td>29,853</td>
					<td>既存市場のみ</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_new_market.csv</code></td>
					<td>4,346</td>
					<td>新興市場のみ</td>
			</tr>
			<tr>
					<td><code>transactions.csv</code></td>
					<td>114,283</td>
					<td>画像リンク付きの生オークション記録</td>
			</tr>
	</tbody>
</table>
<p>全部で約5 GB。RAMに余裕で収まりますし、この規模のデータセットならXGBoostの学習はラップトップのCPUで5分もかかりません。コアモデルにGPUは不要です。この規模の表形式データに対する勾配ブースティング木は、学習コストがほぼタダみたいなものです。ニューラルネットの世界に数年浸かっていると、つい忘れてしまいますが。</p>

<h3 class="relative group">特徴量
    <div id="特徴量" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%89%b9%e5%be%b4%e9%87%8f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>モデルは38個の特徴量を3カテゴリに分けて使います。</p>
<p><strong>作家について（30個）：</strong></p>
<ul>
<li>属性: 年齢、性別、学歴、トップスクール出身フラグ。</li>
<li>キャリア: ArtFactsランキング、個展、グループ展、ビエンナーレ参加、受賞。</li>
<li>収蔵: 個人・公的コレクションの数。</li>
<li>価格履歴: 直近5回・10回の販売の平均、中央値、最高、最低。</li>
<li>サイズ調整済み価格履歴（平方インチあたり価格）。</li>
<li>地理: 作家の活動・居住地を国フラグとしてエンコード。</li>
<li>マッチフラグ: 作品のジャンルは作家の通常のジャンルと一致するか？販売国は一致するか？</li>
</ul>
<p><strong>市場について（8個）：</strong></p>
<ul>
<li>オークションハウスのティア（1–4）。</li>
<li>その年の国・地域の価格水準（最小、平均、中央値、最大）。</li>
</ul>
<p><strong>作品そのものについて（視覚以外）：</strong></p>
<ul>
<li>幅、高さ、平方インチ換算の面積。</li>
<li>媒体カテゴリ（絵画、版画、写真、彫刻、その他）。</li>
</ul>
<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="#%e5%ad%a6%e7%bf%92%e3%83%86%e3%82%b9%e3%83%88%e3%81%ae%e5%88%86%e5%89%b2" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ランダムではなく時系列です。2011年5月より前を学習用（約80%、約27,360件）、それ以降をテスト用（約20%、約6,840件）に回します。ランダムに分割すると、作家の価格履歴特徴量を通じて未来の情報がリークするからです。同じ作家のレコードを学習とテストにばらまくと、テスト行はローリングウィンドウ越しにお互いの価格をすでに「見て」いる状態になり、R²が人為的に膨らみます。</p>
<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%a2%e3%83%87%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>XGBoost回帰で、<code>log10(price_usd)</code>を予測します。検証用スライスに対して<code>max_depth</code>と<code>learning_rate</code>のハイパーパラメータ探索。モデルの変種は2つです。</p>
<ol>
<li><strong>専門家予想額なし。</strong> 目標R² ≈ 0.73。</li>
<li><strong>専門家予想額あり。</strong> 目標R² ≈ 0.92。</li>
</ol>
<p>「予想額あり」の変種は、オークションハウスの事前予想額の下限と上限を追加の特徴量として使います。実はこの2つだけを単独で使ってもR² ≈ 0.90が出ます。その上に社会的特徴を載せて、さらに0.02。絶対値としては小さいですが、専門家がすでに知っていることの先で、実際に予測の仕事をしている部分がここです。</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%81%a9%e3%81%93%e3%81%be%e3%81%a7%e7%8b%99%e3%81%a3%e3%81%9f%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>論文を超えるつもりはありませんでした。全条件で論文のR²値の±0.03以内に収めるのが目標です。ヘッドラインの数値（メタデータのみで約0.73、予想額込みで約0.92）は再現性が高いはずです。XGBoostはシードを固定すれば決定論的ですし、特徴量の定義も明確です。ズレを予想したのは2か所。PCAの次元数や画像前処理のわずかな差で結果が動く視覚特徴のアブレーションと、4,346件しかなくノイズフロアが高い新興市場の分割です。</p>

<h2 class="relative group">視覚特徴がほとんど効かない理由
    <div id="視覚特徴がほとんど効かない理由" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a6%96%e8%a6%9a%e7%89%b9%e5%be%b4%e3%81%8c%e3%81%bb%e3%81%a8%e3%82%93%e3%81%a9%e5%8a%b9%e3%81%8b%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私にとっては、これが一番面白い結果でした。論文の視覚パイプラインは、画像1枚から8,971個の数値を抽出します。</p>
<ul>
<li><strong>GISTディスクリプタ</strong>（960次元）: 全体的な空間レイアウト。</li>
<li><strong>方向勾配ヒストグラム（HOG）</strong>（2,915次元）: エッジ構造。</li>
<li><strong>カラーヒストグラム</strong>（4,096次元）: パレットの内訳。</li>
<li><strong>ResNet18特徴</strong>（1,000次元）: 事前学習済みの高次埋め込み。</li>
<li><strong>カラフルネス</strong>（1次元）: 鮮やかさのスカラー。</li>
<li><strong>複雑度</strong>（1次元）: エッジ密度のスカラー。</li>
</ul>
<p>これをPCAで圧縮してXGBoostに入れると、R² ≈ 0.055。色だけで0.056、エッジ構造で0.029、ニューラルネット埋め込みに至っては0.009、実質ゼロです。</p>
<p>すっきりした説明はこうです。オークション価格は個々の作品ではなく、作家のレベルで決まる。同じ作家の絵は、キャンバスに何が描かれていようと似た値段で売れます。値付けされているのは署名だからです。</p>
<p>オークションカタログの解説を読んだことがあれば、腑に落ちるはずです。あの文章は来歴と展示歴と比較売買にほとんどの言葉を費やしていて、作品そのものの描写はごくわずかです。</p>
<p>この5%という数字を真に受けるなら、作品の美的内容は市場価値からほぼ切り離されています。市場が報いているものが何であれ、それはキャンバスの上にはありません。ピクセルレベルでアートを理解するモデルは、作家の経歴しか知らないモデルより価格予測が下手になる、ということです。</p>

<h3 class="relative group">Phase 3は条件付きだった
    <div id="phase-3は条件付きだった" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#phase-3%e3%81%af%e6%9d%a1%e4%bb%b6%e4%bb%98%e3%81%8d%e3%81%a0%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>論文の画像リンク列が指すURLは2012年時点で有効だったもので、2026年にはほとんど死んでいます。私が書いた計画では8日目にこのリスクを検証することにしていました。ランダムに100本URLを引いて、何本生きているか確かめる。半分以上死んでいたら視覚フェーズは丸ごとスキップして、理由を文書に残す。</p>
<p>そもそも視覚特徴が説明する価格分散は5%程度です。その程度の数字を確認するためにリンク切れと2週間格闘するのは、割に合いません。リンク切れの調査結果をレポートに書いて先へ進むほうがましです。</p>

<h2 class="relative group">既存市場 vs 新興市場
    <div id="既存市場-vs-新興市場" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a2%e5%ad%98%e5%b8%82%e5%a0%b4-vs-%e6%96%b0%e8%88%88%e5%b8%82%e5%a0%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>論文は既存市場（アメリカ、イギリス、フランス、ドイツ）と新興市場（それ以外の19か国）で別々のモデルを学習させています。中心的な結果はこうです。</p>
<table>
	<thead>
			<tr>
					<th>特徴セット</th>
					<th>既存市場 R²</th>
					<th>新興市場 R²</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>視覚のみ</td>
					<td>0.053</td>
					<td>0.056</td>
			</tr>
			<tr>
					<td>メタデータ（社会的シグナル）</td>
					<td>0.667</td>
					<td>0.750</td>
			</tr>
			<tr>
					<td>メタデータ + 予想額</td>
					<td>0.916</td>
					<td>0.859</td>
			</tr>
	</tbody>
</table>
<p>目を引く点は2つ。まず、社会的シグナルは新興市場のほうが効きます。データが豊富な既存市場のほうが社会特徴の性能も高いだろうと思っていたのですが、逆でした。</p>
<p>次に、新興市場では専門家予想額の精度が落ちます。予想額を加えたときの0.916対0.859の差がそれです。既存市場でのアルゴリズム予測は、数十年分の比較売買データを背負った大手オークションハウスの専門家と競うことになります。新興市場ではその専門家の持ちデータが薄く、アルゴリズムが価値を出せる余地が広がります。</p>
<p>このモデルを商売にするなら、稼ぎ場所は新興市場でしょう。</p>

<h2 class="relative group">明記しておくべき制約
    <div id="明記しておくべき制約" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%98%8e%e8%a8%98%e3%81%97%e3%81%a6%e3%81%8a%e3%81%8f%e3%81%b9%e3%81%8d%e5%88%b6%e7%b4%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これは論文の再現であって、本番システムではありません。ライブのデータパイプラインはなく、モデルは1996–2012年のオークションデータで学習したきり更新されません。デプロイもなし。成果物はノートブックとレポートで、予測APIではありません。ドリフト検出もないので、学習後のモデルは市場が変わったことに気づけません。データセットは凍結されていて、クリプトアートもNFTもコロナ後の市場変動も学習データには存在しません。韓国のオークションハウスの価格を予測したければ、オークションハウス固有の特徴量と、おそらく韓国市場専用のモデルが要りますが、そこまでの特化はしていません。</p>
<p>モデルは過去のパターンを映すものでもあります。現代美術市場が2015年前後に構造的に変わったのだとしたら（アートフェアが1次市場の主要な場になり、新しい世代のコレクターが参入したという議論があります）、1996–2012年のパターンは一般化しないかもしれません。</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%ab%96%e6%96%87%e3%81%8b%e3%82%89%e3%83%97%e3%83%a9%e3%83%83%e3%83%88%e3%83%95%e3%82%a9%e3%83%bc%e3%83%a0%e3%81%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>論文を読んだことで、2つ目のプロジェクトの見方が変わりました。このプラットフォームはもともと、アート展覧会のクラウドファンディング・マーケットプレイスとして構想していました。作家はこれからの展覧会の資金を確保し、投資家はオークション市場が通常許すより早い段階で新興作家へのエクスポージャーを得る、というものです。</p>
<p>プロダクトとしての機能は、意図的にシンプルにしています。作家は資金が必要な展覧会を投稿します（会場、日程、出展予定作品）。投資家は特定のショーを段階別の金額（$100、$500、$1k、$5k）で支援し、作品が売れたら上振れ分を分け合います。作家と投資家のチャットの大部分はAIエージェントが受け持つので、どちらかがオフラインでも会話は止まりません。韓国のギャラリーが14時間の時差を挟んでアメリカのコレクターと話しているときには、これが特に効きます。</p>
<p>資産は作品ではなく作家だ、ということが腹に落ちると、データモデルもそれに合わせて変わります。押さえるべきは、各作品を誰が所有してきたか、どこで展示されてきたか、比較作品がいくらで売れたか、そして同じ作家の作品をほかに誰が持っているか。所有の連鎖、機関による承認、オークションの比較事例、コレクターのネットワーク──ナレッジグラフの出番です。</p>
<p>データモデルはあえて小さくしています。作家、作品、オークション記録、所有期間、そしてデータそのものの来歴ログ。これで、どのレコードもどこからスクレイピングしたのかまでさかのぼれます。最後のものは帳簿付けのように聞こえますが、スクレイピングしたデータを扱うなら、これがあって初めてグラフを信用できます。</p>
<p>モバイルアプリは、これら全部をforce-directedグラフとして描画します。タイムレンジスライダーで年をスクラブすると、ネットワークが育っていく様子が見えます。どのコレクターがいつ入ってきたか、どの作家がどの美術館に拾われたか、2008年の下落局面でどの作品が持ち主を変えたか。</p>
<p>エンティティの種類ごとに形と色を分けています。作家はダイヤ、作品は角丸長方形、オークションハウスは六角形、コレクター・ディーラー・美術館・遺産といった所有者は色違いの円です。</p>
<p>形は飾りではありません。数百ノードまでズームアウトしたとき、ラベルの描画を待たなくてもシルエットだけでエンティティの種類が分かります。円に囲まれたダイヤは、コレクターネットワークを持つ作家。多くのダイヤとつながる六角形は、作家を多数抱えるオークションハウス。市場の構造がひと目で読めます。</p>
<p>2つ目のタブでは、同じデータをタイムラインで見せます。所有期間は水平バー、売買は点イベント。同じグラフを別のレンズで見る形です。ある時点のネットワークの形ではなく、誰が何をどの順で持っていたかを見たいときに便利です。</p>
<p>このプラットフォーム自体は価格予測ツールではありません。その下に敷くデータレイヤーです。凍結された2012年のCSVではなく、生きた最新データをLee et al. のようなモデルに供給したいなら、必要になるのはこういう構造です。</p>

<h2 class="relative group">価値評価をゲームにする
    <div id="価値評価をゲームにする" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%be%a1%e5%80%a4%e8%a9%95%e4%be%a1%e3%82%92%e3%82%b2%e3%83%bc%e3%83%a0%e3%81%ab%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>まだ作っていないけれど一番興味があるのが、アート評価のゲーム化です。</p>
<p>Lee et al. の論文はオークションハウスの事前予想額を特徴量として使い、R²を大きく引き上げています。あの予想額に価値があるのは、何千件もの比較作品を見てきて、結果に責任を負う訓練された専門家たちの、集約された判断だからです。しかしオークションハウスの専門家は、狭くて高価なリソースです。世界に数百人しかおらず、特定の委託案件にしか関わらず、まだ誰も委託していない作家のロングテールには手が届きません。</p>
<p>私が何度も戻ってくる問いはこれです。もっと広くて安い判断のプールから、同等のシグナルを合成できないか？</p>
<p>ざっくりした設計はこうです。</p>
<ul>
<li>ユーザーに作品を見せる。画像、寸法、制作年、作家名、簡単な経歴。</li>
<li>オークションでいくらで売れるかを尋ねる。</li>
<li>価格帯を選ばせるか、ハンマープライスを直接当てさせる。</li>
<li>回答を確定した後で、実際の落札価格を明かす。</li>
<li>Brierスコア方式の精度レーティングを時間をかけて付ける。</li>
<li>リーダーボード、デイリーストリーク、連続的中バッジを用意する。</li>
<li>各ユーザーの今後の予測を、過去の精度で重み付けする。</li>
</ul>
<p>アート価格の予測市場を、当てっこゲームの形にしたものです。ループは地理当てゲームやオンラインチェスのパズルレーティングと同じ。繰り返せて、採点できて、フィードバックを頼りに腕を磨けます。よく当てる人はリーダーボードを上がり、その人の今後の予測は集約シグナルの中でより重くなります。</p>
<p>これが本当に役立つのはモデル側です。重み付き集約された推測は、社会的シグナルと並べてXGBoostに入れられる特徴量になります。群衆の精度加重中央値がハンマープライスとよく相関するなら、本物の予想額が付いていない作品についても、合成オークションハウス予想額に近いものが手に入る。それこそ、Lee et al. のモデルが一番助けを必要としている作家ロングテールの穴です。</p>
<p>コールドスタートの観点もあります。モデルは価格履歴が豊富な作家ではよく学習できて、販売実績が3件しかない作家では苦しみます。販売3件＋群衆の推測1,000件なら、少なくとも扱える材料にはなります。クラウドソーシングによる評価は、元データがカバーしていないケースのための学習シグナルを生成する手段なのです。</p>
<p>ここでゲーム化が重要なのは、群衆を信頼できるものにする唯一の方法が、参加を楽しくしてたくさんやってもらうことだからです。一問きりのフォームでは、一度クリックして通り過ぎた人たちのノイズだらけのデータしか得られません。デイリーストリークとリーダーボードとキャリブレーションバッジがあれば、同じ人が1年かけて500回、真剣に考えた予測をしてくれます。量に自己選別（当てられる人は残り、当てられない人は飽きる）が掛かって、ノイズではなくシグナルになります。</p>
<p>作る前に解いておくべき設計上の問題もいくつかあります。ユーザーに何をどこまで見せるか──画像と経歴だけか、文脈として比較売買も出すか。文脈が多いほど推測の質は上がりますが、行きすぎるとユーザーはこちらが見せたものを反芻しているだけになります。価格フィードバックの粒度はどうするか。落札額そのままか、レンジか、±20%以内だったかどうかだけか。細かすぎると特定の数字にアンカーする癖を付けてしまい、曖昧すぎるとキャリブレーションができません。リーダーボードの攻略問題もあります。プレイヤーは知っている作家に流れますし、Banksyの推測は実力というよりほぼ雑学です。おそらく語学アプリがレッスンを選ぶような強制ランダムサンプリングと、作家名を伏せた「ブラインド」ラウンド用の別ランキングが現実解でしょう。そして後知恵バイアスは避けられません。バスキアが1億1千万ドルで売れたと知ってしまったら、知らなかった状態には戻れない。ループの誠実さを保つのは、まだ誰も見ていない作品の供給で、その供給は週あたり有限です。</p>
<p>ゲーム化という枠組みは、プラットフォームの両半分をつなぐ役目も果たします。クラウドファンディング側はショーが財務的に成功したときに投資家に報い、当てっこゲーム側は、投資家であろうとなかろうと、価格を当てるのがうまい人に報います。どちらも同じアイデアの変奏です。群衆の集合的判断を、取引可能なシグナルに変える。一方は資本を、もう一方は注意を取引します。本物の腕を磨いたプレイヤーは、検証可能な価格予測の実績を引っ提げて、いずれ投資家側に「卒業」できるかもしれません。「いい画商を知っていた」よりよほどまともなフィルタです。</p>

<h2 class="relative group">持ち帰ったこと
    <div id="持ち帰ったこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8c%81%e3%81%a1%e5%b8%b0%e3%81%a3%e3%81%9f%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>事前予想額は非常に優秀です。オークションハウスは不透明な値付けだと叩かれがちですが、あの予想額だけで価格分散の90%近くを捉えます。専門家が何をやっているにせよ、上手にやっている。その一方で、作品そのものは統計的にほとんど意味を持ちません。美的な理由でアートが好きな人間には居心地の悪い結論です。市場が値段を付けているのは目の前の作品ではなく、ほかの人たちが隣の作品にすでに払った金額なのです。</p>
<p>モデリング面で効いた習慣は2つ。時系列分割は価格モデルを評価する唯一の正直な方法で、ランダム分割が返すのは本番投入に耐えない見栄えのいいR²です。そして勾配ブースティング木は今でも正しい道具です。論文中でXGBoostはすべてのニューラルベースラインに勝っていて、この規模・この作り込まれた特徴構造の表形式データでトランスフォーマーに手を伸ばす理由はありません。</p>
<p>商売の観点では、面白い機会は新興市場にあります。専門家の値付けが最も薄く、アルゴリズム予測が埋められる差が最も大きい場所だからです。それから、群衆は使われていないデータソースです。Lee et al. のモデルで最大の単一特徴量はオークションハウスの事前予想額でした。キャリブレーションされた群衆が作家ロングテールについて同等のシグナルを作れるなら、それはモデル入力としても、それ自体プロダクトとしても、本物の商機です。</p>
<p>2つのプロジェクトは、当初の計画以上に補完的になりました。論文の再現は何が価格を予測するのかを教えてくれて、プラットフォームの作業はその知識を使うのに必要なデータ構造を教えてくれました。そしてゲーム化のアイデアが両者をつなぎます。ナレッジグラフが作品と過去の販売を供給し、当てっこゲームが群衆由来の予想額特徴を生成し、モデルがそれを社会的シグナルと並べて消費して、このままではモデルから見えない作家たちについても予測を出す。</p>
<p>どれも完成はしていません。論文の再現はレポート付きの動くプロトタイプ、プラットフォームはシードデータ入りのデモ、クラウドソーシングのゲームはいま読んでもらった設計メモの中にしかありません。それでも、通底する筋は十分一貫していて、あるべき形はそれなりにはっきり見えていると思います。作品ではなく作家に値を付ける。専門家だけに頼らず群衆を集約する。評価を、専門家が上から手渡すものではなく、市場が集合的に作り出すものとして扱う。</p>
<p>時間さえ作れれば、次に作るのはこのバージョンです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-04-01-art-evaluation-algorithm/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
