<?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 · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/python/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/python/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest-testmon: chỉ chạy những bài kiểm thử mà thay đổi code của bạn ảnh hưởng tới</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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 chỉ chạy các bài kiểm thử mà thay đổi có thể chạm tới, dựa trên bản ghi coverage chứ không phỏng đoán. Thêm vào bộ 2,248 bài Django, script bọc, và một bẫy ở Python 3.14.</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a> là một plugin của pytest, chỉ chạy những bài kiểm thử mà thay đổi của bạn có thể ảnh hưởng tới. Nó dùng coverage.py để theo dõi xem mỗi bài kiểm thử thực thi những dòng code nào, rồi lưu lại mã kiểm tra (checksum) của từng khối code mà bài đó đã chạm vào. Ở lần chạy sau, nó đem các checksum này so với code đang nằm trên đĩa, và chỉ chạy những bài có khối code đã thay đổi, cộng thêm những bài thất bại ở lần trước, nên nó làm luôn cả việc của <code>pytest --lf</code>.</p>
<p>Kỹ thuật chung này gọi là phân tích tác động kiểm thử (test impact analysis), và testmon là một cách miễn phí, mã nguồn mở để có được nó trong Python. Nó cũng hoàn toàn không phụ thuộc vào git: thứ nó đem ra so là những gì nó thấy ở lần chạy trước, dù code đó đã commit hay chưa.</p>
<p>Tuần này tôi đã thêm nó vào bộ kiểm thử Python của ứng dụng chợ mà tôi đang làm, và lý do không đơn thuần là muốn chạy nhanh hơn.</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="Video ngắn có lời dẫn kể lại bài viết này">
    <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="日本語">
    <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" default>
    <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>Bài viết này dưới dạng video dài khoảng tám phút. Lời dẫn bằng tiếng Anh, có phụ đề tiếng Việt.</figcaption>
</figure>


<h2 class="relative group">Vì sao tôi thêm nó
    <div id="vì-sao-tôi-thêm-nó" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-t%c3%b4i-th%c3%aam-n%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một yêu cầu tính năng (feature request) thường rất nhỏ: một màn hình, một endpoint, hoặc một quy tắc cần hoạt động khác đi. Thế nhưng việc phải chạy toàn bộ bộ kiểm thử khiến yêu cầu nào cũng phình to hơn thực tế. Bộ kiểm thử có 2,248 bài kiểm thử Django, chạy với bốn tiến trình mất khoảng ba phút rưỡi, còn trên một tiến trình là 11 phút, và lần sửa nào cũng chạy hết toàn bộ, dù gần như tất cả các bài đều kiểm tra những thứ mà yêu cầu đó chưa hề động tới. Khi một bài chẳng liên quan gì bị thất bại, hoặc đơn giản là lần chạy cứ thong thả kéo dài, thì tính năng phải ngồi chờ. Nó bị giữ lại bởi những chuyện nằm ngoài yêu cầu tính năng, và thứ giữ nó lại lại chính là bộ kiểm thử của tôi.</p>
<p>Cách còn lại cũng chẳng tốt hơn: bỏ qua lần chạy toàn bộ, tự đoán xem tệp kiểm thử nào bao phủ thay đổi, và thỉnh thoảng đoán sai. Làm vậy thì nhanh, nhưng không chính xác, mà một thay đổi chưa được kiểm thử đã bị tôi đem phát hành thì cũng là một yêu cầu tính năng tôi sẽ phải làm lại lần thứ hai.</p>
<p>Những bài kiểm thử tôi phải chờ phải đúng là những bài mà thay đổi đó có thể chạm tới, không thừa một bài, không thiếu một bài. testmon cho tôi đúng tập hợp đó dựa trên dữ liệu đã ghi lại, chứ không phải dựa trên phỏng đoán. Bộ kiểm thử đầy đủ thì vẫn chạy, nhưng chạy trong CI, ở một chỗ không cản trở việc tiếp theo tôi đang làm.</p>

<h2 class="relative group">Cách nó quyết định chạy bài nào
    <div id="cách-nó-quyết-định-chạy-bài-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1ch-n%c3%b3-quy%e1%ba%bft-%c4%91%e1%bb%8bnh-ch%e1%ba%a1y-b%c3%a0i-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>testmon chia mỗi tệp Python thành các khối: phần cấp cao nhất của module, từng class, từng hàm, trong đó thân của các hàm lồng bên trong được tách ra khỏi khối bao quanh nó. Nó lưu checksum của từng khối chứ không bao giờ lưu bản thân code. Trong lúc một bài kiểm thử chạy, coverage.py ghi lại những dòng được thực thi, còn testmon ghi nhận các dòng đó thuộc về khối nào. Ở đây không có phân tích tĩnh (static analysis), cũng không có chuyện đoán mò dựa vào các câu import. Một bài kiểm thử phụ thuộc vào đúng những khối mà nó đã chạy qua.</p>
<p>Lấy ví dụ một module như sau:</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>Một bài kiểm thử chỉ gọi <code>subtotal()</code> sẽ phụ thuộc vào phần cấp cao nhất của module và thân hàm <code>subtotal</code>. Bạn có viết lại <code>discount()</code> thế nào đi nữa thì testmon cũng không chạy lại bài đó, vì nó chưa từng thực thi dòng nào của <code>discount</code>. Ngược lại, chỉ cần đổi một ký tự bên trong <code>subtotal</code> là bài đó chạy lại, kể cả khi thay đổi ấy không thể làm kết quả khác đi. testmon không cố tỏ ra thông minh với các tác dụng phụ: khối nào bạn đã thực thi mà bị thay đổi thì bạn chạy lại.</p>
<p>Nó còn theo dõi cả những thứ bao quanh code. Bản ghi bao gồm phiên bản Python và các gói đã cài cùng phiên bản của chúng. Nó không theo dõi biến môi trường: nếu cùng một bộ code chạy dưới hai module settings khác nhau, bạn phải đặt tên cho chúng bằng <code>--testmon-env</code> (bước 8), không thì chúng dùng chung một bản ghi. Nâng cấp Django thì mọi bài từng chạm vào Django lại bị tính là bị ảnh hưởng, mà trong một ứng dụng Django thì đó là tất cả.</p>

<h2 class="relative group">Những gì nó không nhìn thấy
    <div id="những-gì-nó-không-nhìn-thấy" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-g%c3%ac-n%c3%b3-kh%c3%b4ng-nh%c3%acn-th%e1%ba%a5y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>testmon theo dõi phần code Python mà bài kiểm thử thực thi. Nó không theo dõi những tệp khác mà bài kiểm thử đọc: template, JSON, văn bản, hay bất cứ thứ gì khác trên đĩa. Nó không theo dõi các dịch vụ được gọi qua mạng. Và những gì chạy một lần trước khi các bài kiểm thử bắt đầu thì nó không thể gắn vào một bài cụ thể nào.</p>
<p>Trong một dự án Django, trước hết đó là migration: chúng chỉ chạy một lần, lúc tạo cơ sở dữ liệu kiểm thử, nên không bài kiểm thử nào có chúng trong bản ghi của mình (script bọc ở bên dưới sẽ dựng lại cơ sở dữ liệu kiểm thử khi có migration mới). Tiếp đến là settings và code khởi động, chạy trong lúc pytest khởi động, nằm ngoài mọi bài kiểm thử riêng lẻ. Và cuối cùng là template cùng các tệp dữ liệu: template không phải là Python, nên sửa một template sẽ không làm chạy lại bài nào render nó.</p>
<p>Bộ kiểm thử của tôi còn có thêm vài trường hợp riêng. Một số bài đọc một tệp JSON chứa dữ liệu mẫu (fixture) dùng chung với bộ kiểm thử của ứng dụng di động. Một số bài kiểm tra gói web đã dựng (bundle), và một số khác kiểm tra đầu ra của một trang tĩnh được sinh ra ngay trong cùng kho mã. Đổi bất cứ thứ nào trong số đó, testmon sẽ vui vẻ báo cho bạn rằng chẳng cần chạy gì cả. Đó là lý do phần hướng dẫn bên dưới bọc testmon trong một script, để script nhận ra những tệp này và chạy toàn bộ khi có tệp nào đã thay đổi.</p>

<h2 class="relative group">Cách thêm nó vào một dự án Django
    <div id="cách-thêm-nó-vào-một-dự-án-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="#c%c3%a1ch-th%c3%aam-n%c3%b3-v%c3%a0o-m%e1%bb%99t-d%e1%bb%b1-%c3%a1n-django" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các bước dưới đây giả định bạn dùng pytest và <a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>, cùng với uv để quản lý thư viện phụ thuộc. Nếu dùng pip thì thay <code>uv add --dev</code> bằng <code>pip install</code> và bỏ <code>uv run</code> đi.</p>
<p><strong>1. Cài đặt.</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. Bảo đảm pytest chạy được bộ kiểm thử.</strong> testmon là một plugin của pytest, nên khi chạy bằng <code>python manage.py test</code> thì nó không làm gì cả. pytest-django cần biết module settings và các mẫu tên tệp mà bài kiểm thử của bạn dùng:</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>Nếu từ trước đến giờ bộ kiểm thử chỉ chạy bằng trình chạy kiểm thử của Django, hãy chạy nó bằng pytest một lần và sửa hết những chỗ khác biệt trước khi thêm testmon. Nếu không, bạn sẽ phải gỡ lỗi hai thứ cùng một lúc.</p>
<p><strong>3. Đừng để bản ghi lọt vào git.</strong> testmon ghi tệp <code>.testmondata</code> vào thư mục gốc của pytest. Đây là một cơ sở dữ liệu SQLite, nên bạn sẽ thấy cả các tệp <code>-shm</code> và <code>-wal</code> nằm cạnh nó, và script bên dưới còn thêm một tệp đánh dấu thời điểm (stamp). Chỉ một mẫu là đủ cho tất cả:</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. Ghi lại toàn bộ bộ kiểm thử một lần.</strong> testmon chưa chọn được gì cho đến khi nó đã thấy mọi bài kiểm thử chạy qua:</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>Trong một bản checkout mới tinh, lần chạy đầu tiên đó là toàn bộ bộ kiểm thử trên một tiến trình. Ở dự án của tôi, lần đó mất khoảng 12 phút cho 2,248 bài. Từ đó trở đi, cũng lệnh này sẽ chỉ chạy những bài kiểm thử bị ảnh hưởng. Không có gì thay đổi thì nó không chạy gì cả.</p>
<p>Có hai chỗ dễ vấp. Với testmon 2.2.0 trở về trước, nó cần plugin cache của pytest: lần thử đầu tiên của tôi chạy với <code>-p no:cacheprovider</code> và bị sập với lỗi <code>KeyError: 'lf'</code> (bản 2.3.0 coi việc thiếu <code>--lf</code> là chưa đặt). Còn nếu bạn dùng Python 3.14 trở lên, hãy đặt <code>COVERAGE_CORE=ctrace</code> trước khi ghi. Lý do được giải thích ở phần &ldquo;Lỗi trông như một lần chạy toàn xanh&rdquo; phía dưới. Đây là dòng quan trọng nhất trong script bên dưới.</p>
<p>Cập nhật ngày 7 tháng 11: testmon 2.3.0, phát hành ngày 2 tháng 11, tự ép coverage dùng tracer C, nên với phiên bản này dòng export chỉ còn là cho chắc. Nếu bạn đang ở 2.2.0 trở về trước thì cứ giữ nó. Còn bước cố ý làm hỏng code để kiểm tra thì phiên bản nào cũng nên làm.</p>
<p><strong>5. Bọc nó lại để lo những tệp nó không nhìn thấy.</strong> Đây là phần giúp bạn yên tâm dùng nó hằng ngày. Script giữ một tệp stamp từ lúc bắt đầu lần chạy trước. Nếu có tệp nào mà testmon không lần theo được lại mới hơn tệp stamp, script sẽ chạy toàn bộ và ghi lại từ đầu. Một migration mới thì chỉ khiến script dựng lại cơ sở dữ liệu kiểm thử. Còn không thì để testmon tự chọn. Một ví dụ đã cắt gọn:</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>Có hai chi tiết trong đó đáng giải thích. <code>--reuse-db</code> là cờ của pytest-django để giữ lại cơ sở dữ liệu kiểm thử giữa các lần chạy, và điều này trở nên quan trọng khi một lần chạy chỉ còn mất vài giây: tạo lại cơ sở dữ liệu mỗi lần có khi còn lâu hơn chính các bài kiểm thử. Lần chạy toàn bộ, hoặc lần chạy ngay sau khi có migration mới, thì thêm <code>--create-db</code> để cơ sở dữ liệu có đúng cấu trúc bảng (schema) hiện tại. Hãy giữ <code>--reuse-db</code> đi kèm với nó: nếu chỉ có <code>--create-db</code> thì pytest-django sẽ xóa cơ sở dữ liệu khi lần chạy kết thúc, và lần chạy sau lại phải dựng lại tất cả từ đầu.</p>
<p>Còn khi testmon bỏ chọn mọi bài, lần chạy sẽ thoát với mã 0, nên &ldquo;không có gì thay đổi&rdquo; được tính là qua. Mã thoát (exit code) 5 là cách pytest báo &ldquo;không thu thập được bài kiểm thử nào&rdquo;, và script cũng coi trường hợp đó là ổn.</p>
<p>Hãy để ý những gì không có trong danh sách: <code>pyproject.toml</code> và tệp khóa phiên bản (lock file). Phiên bản đầu tiên của tôi chạy toàn bộ mỗi khi tệp lock thay đổi, khiến phương án dự phòng chậm chạp kia bị kích hoạt gần như ngày nào cũng có, và mọi thứ còn tệ hơn cả lúc chưa có script. testmon vốn đã tự theo dõi phiên bản của các gói đã cài, nên chuyện này nó không cần ai giúp.</p>
<p><strong>6. Cho nó cơ sở dữ liệu và bộ nhớ đệm riêng.</strong> Nếu trên cùng máy còn có thứ khác chạy kiểm thử, chẳng hạn một terminal thứ hai, một agent viết code (coding agent) hay <code>manage.py test</code>, hãy trỏ script tới một cơ sở dữ liệu kiểm thử riêng và một số hiệu bộ nhớ đệm riêng (trong ví dụ là database số 8 của Redis), để cơ sở dữ liệu được giữ lại không bao giờ đụng phải lần chạy của người khác:</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. Xóa sạch bộ nhớ đệm đó trước mỗi lần chạy.</strong> Chuyện này tôi phải vấp mới biết. Các lần chạy một phần bắt đầu thất bại với những lỗi <code>KeyError</code> mà lần chạy toàn bộ không hề gặp. Nguyên do là một số nhãn danh mục được cache mà không có thời hạn hết hạn, và một cơ sở dữ liệu kiểm thử giữ lại từ lần chạy này lại gặp phải bộ nhớ đệm vẫn còn &ldquo;ấm&rdquo; từ một lần chạy trước đó nữa. Xóa cache ngay đầu script là hết lỗi:</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>Đó chính là testmon làm đúng như tài liệu của nó đã nói: mỗi lần chạy một tập con khác nhau sẽ làm lộ ra những bài kiểm thử chỉ qua được nhờ những gì đã chạy trước chúng. Chỗ cần sửa là phần thiết lập kiểm thử, không phải testmon.</p>
<p><strong>8. Nắm các cờ để dùng khi testmon chọn sai.</strong></p>
<ul>
<li><code>--testmon-noselect</code> chạy mọi bài kiểm thử nhưng đưa những bài có khả năng thất bại lên trước. testmon tự chuyển sang chế độ này khi bạn truyền thêm <code>-k</code>, <code>-m</code>, <code>--lf</code> hoặc node ID của một bài kiểm thử.</li>
<li><code>--testmon-forceselect</code> chỉ chạy những bài bị ảnh hưởng mà đồng thời khớp với <code>-k</code> hoặc <code>-m</code> của bạn.</li>
<li><code>--testmon-nocollect</code> dùng bản ghi hiện có nhưng không ghi thêm gì vào. Chế độ này bị bật bắt buộc khi chạy dưới trình gỡ lỗi (debugger) hoặc khi coverage đang chạy sẵn.</li>
<li><code>--no-testmon</code> tắt nó cho một lần chạy, dùng khi bạn đã đặt nó trong <code>addopts</code>.</li>
<li><code>--testmon-env=&lt;name&gt;</code> giữ các bản ghi riêng biệt trong cùng một tệp, dành cho bộ kiểm thử mà bạn chạy dưới hai module settings khác nhau.</li>
</ul>
<p>Nếu dự án có cả phần JavaScript thì Jest có sẵn một phiên bản riêng của tính năng này. <code>jest --changedSince=main</code> chạy những bài kiểm thử import bất cứ thứ gì đã thay đổi kể từ <code>main</code>, dựa theo các câu import chứ không dựa theo coverage. Tôi đã thêm nó vào <code>package.json</code> của ứng dụng dưới tên <code>npm run test:changed</code>, để cả hai nửa của dự án có cùng một thói quen. Một lần sửa ở một module tiện ích đã chọn ra 7 trong số 121 tệp kiểm thử của ứng dụng.</p>

<h2 class="relative group">Nó tiết kiệm được gì
    <div id="nó-tiết-kiệm-được-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%c3%b3-ti%e1%ba%bft-ki%e1%bb%87m-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi script bọc đã vào chỗ, và sau khi sửa lỗi được kể ở phần tiếp theo, tôi đo thời gian của nó, tính cả phần khởi động:</p>
<table>
	<thead>
			<tr>
					<th>Thứ đã thay đổi</th>
					<th style="text-align: right">Số bài đã chạy</th>
					<th style="text-align: right">Thời gian</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Không có gì</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>Một lần sửa trong phần code đứng sau một banner ở màn hình chính</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>Hoàn tác lần sửa đó</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>Toàn bộ bộ kiểm thử, để so sánh (4 tiến trình)</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">khoảng 3½ phút</td>
			</tr>
	</tbody>
</table>
<p>Với một lần sửa thường ngày, như vậy là nhanh hơn khoảng 25 lần so với bộ kiểm thử chạy song song, còn khi không có gì thay đổi thì nhanh hơn khoảng hai trăm lần.</p>
<p>Bản ghi cũng cho thấy điều tương tự, ở phạm vi rộng hơn. Trong 307 tệp Python không phải tệp kiểm thử mà bộ kiểm thử chạy qua, thay đổi ở tệp trung vị chạm tới 22 bài kiểm thử, tức khoảng 8 giây thời gian kiểm thử. Ở đầu bên kia là vài module lõi: gần như bài kiểm thử nào cũng chạy qua chúng, nên một thay đổi ở đó chạm tới khoảng 1,500 bài, và testmon sẽ chạy lại phần lớn bộ kiểm thử. Đó là câu trả lời đúng, chứ không phải một thất bại. Các con số này là giới hạn trên tính theo từng tệp: testmon làm việc theo từng khối, nên đổi một hàm sẽ chỉ chọn những bài đã chạy qua hàm đó.</p>
<p>Cái giá lộ ra khi script bọc phải quay về phương án dự phòng. Một lần chạy toàn bộ cũng là một lần ghi trên một tiến trình giống như lần đầu, mất khoảng 12 phút, tức là còn chậm hơn bộ kiểm thử chạy song song mà nó thay thế. Nó chạy trên một tiến trình chỉ vì tôi chưa thử testmon cùng pytest-xdist; testmon đã hỗ trợ xdist từ bản 1.4, nên đó là thứ tiếp theo cần đo. Vì vậy, danh sách các tệp kích hoạt nó nên ngắn hết mức có thể, miễn là vẫn trung thực.</p>

<h2 class="relative group">Lỗi trông như một lần chạy toàn xanh
    <div id="lỗi-trông-như-một-lần-chạy-toàn-xanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%97i-tr%c3%b4ng-nh%c6%b0-m%e1%bb%99t-l%e1%ba%a7n-ch%e1%ba%a1y-to%c3%a0n-xanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Những con số đầu tiên của tôi còn đẹp hơn thế này: một lần sửa, 6 bài kiểm thử, 3.3 giây. Chúng sai, và không có gì trên màn hình cho biết điều đó.</p>
<p>Từ Python 3.14, coverage.py mặc định dùng một lõi (core) mới, xây dựng trên <code>sys.monitoring</code> của trình thông dịch. Lõi này chỉ báo một dòng ở lần đầu tiên một tiến trình chạy qua dòng đó. testmon dựng bản ghi bài nào đã chạy dòng nào từ dữ liệu của coverage, nên mỗi dòng chỉ được tính cho bài kiểm thử đầu tiên tình cờ chạy qua nó, chứ không tính cho bài nào khác. Sửa một dòng mà tám bài kiểm thử cùng dùng, testmon sẽ chỉ chọn một trong số đó.</p>
<p>Lỗi này lộ ra trên một dự án khác của tôi, nhỏ hơn, nơi tôi chép lại cách thiết lập này. Một phần của việc áp dụng nó ở đó là cố ý làm hỏng một dòng rồi so sánh kết quả, và các con số không khớp nhau. Quay lại bộ kiểm thử này, tôi làm đúng phép thử đó. Tôi làm hỏng một dòng trong phần code của banner ở màn hình chính, cụ thể là tiêu đề của một slide. Chạy pytest bình thường thì có 8 bài thất bại. testmon chỉ chọn 4. 4 bài hỏng còn lại không hề được chạy, và đầu ra không có dòng nào cho biết chúng đã bị bỏ qua.</p>
<p>Cách sửa chỉ là một dòng, <code>export COVERAGE_CORE=ctrace</code>, để đưa coverage về lại trình theo dõi (tracer) cổ điển viết bằng C của nó. Đổi tracer thì bản ghi cũng khác, nên tôi ghi lại từ đầu: 12 phút 3 giây cho 2,248 bài. Sau đó, cùng một kiểu sửa làm hỏng code như vậy đã chạy đủ cả 11 bài mà nó làm hỏng.</p>
<p>Cập nhật ngày 7 tháng 11: testmon 2.3.0, phát hành ngày 2 tháng 11, giờ tự đặt tracer C, cả trong tiến trình lẫn cho các tiến trình con, nên cài mới thì không cần dòng export này nữa. Phần kiểm tra ở đoạn tiếp theo thì vẫn cần.</p>
<p>Nếu phải truyền lại một bài học trước mọi thứ khác trong bài này, tôi sẽ chọn bài học này. Một công cụ chọn bài kiểm thử mà chọn quá ít thì trông y hệt như thành công: ít bài hơn, chạy nhanh hơn, toàn màu xanh. Vì vậy, khi thiết lập một công cụ như thế, hãy cố ý làm hỏng một thứ dùng chung, chạy cả bộ kiểm thử thông thường lẫn công cụ chọn bài, rồi kiểm tra xem hai bên có báo thất bại đúng những bài giống nhau không. Sau đó, làm lại việc này mỗi khi Python hoặc coverage.py lên phiên bản mới.</p>

<h2 class="relative group">Khi agent viết code vào cuộc
    <div id="khi-agent-viết-code-vào-cuộc" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#khi-agent-vi%e1%ba%bft-code-v%c3%a0o-cu%e1%bb%99c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi không còn tự tay làm mọi thay đổi trong kho mã đó nữa. Một agent viết code, trong trường hợp của tôi là Claude Code, làm khá nhiều thay đổi nhỏ, và một agent tốt sẽ chạy kiểm thử sau mỗi lần sửa. Đứng trước một bộ kiểm thử 11 phút, nó cũng chỉ có đúng hai lựa chọn tồi như tôi từng có: ngồi chờ, hoặc đoán xem tệp kiểm thử nào mới quan trọng.</p>
<p>Vì vậy, phần hướng dẫn dành cho agent trong kho mã giờ ghi rằng script bọc kia là cách mặc định để chạy kiểm thử trong lúc làm việc, còn <code>--all</code> dành cho lần chạy toàn bộ. Sau mỗi lần sửa, agent nhận được đúng tập bài kiểm thử chính xác như tôi, chỉ trong vài giây. Đây cũng là lý do bước 6 cho script một cơ sở dữ liệu và bộ nhớ đệm riêng: vào một ngày bận rộn, sẽ có nhiều hơn một phiên làm việc cùng chạy kiểm thử trên một máy, và cơ sở dữ liệu được giữ lại tuyệt đối không được đụng phải lần chạy của người khác.</p>
<p>Không phần nào trong số này đòi hỏi testmon phải có gì dành riêng cho AI. Vẫn là bản ghi đó, vẫn là cách chọn đó. Chỉ là nó trở nên quan trọng hơn khi thứ đang tạo ra thay đổi lại tạo ra rất, rất nhiều thay đổi.</p>

<h2 class="relative group">Vì sao CI chỉ nhận một phiên bản thô hơn
    <div id="vì-sao-ci-chỉ-nhận-một-phiên-bản-thô-hơn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-ci-ch%e1%bb%89-nh%e1%ba%adn-m%e1%bb%99t-phi%c3%aan-b%e1%ba%a3n-th%c3%b4-h%c6%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>testmon không có chế độ dành riêng cho CI. Trên runner nó hoạt động y như trên laptop, và chính tác giả của nó viết rằng việc chia sẻ hoặc cache <code>.testmondata</code> giữa các runner CI kém hiệu quả hơn nhiều so với dịch vụ lưu trữ sẵn (hosted) của họ là testmon.net. Một bản ghi được cache lại cũng sẽ mù theo đúng những cách đã kể ở trên, mà CI của tôi lại chạy bộ kiểm thử bằng trình chạy của Django trên bốn tiến trình, nên tôi không đưa testmon vào CI.</p>
<p>Thay vào đó, CI nhận cùng ý tưởng này nhưng ở cấp độ tệp. Workflow kiểm tra giờ bắt đầu bằng một job nhỏ, lấy diff của lần push rồi xác định nó đã chạm vào nửa nào của kho mã: API Python, ứng dụng di động, cả hai, hay không bên nào. Trước đây, CI chạy cả job Python lẫn job ứng dụng ở mọi lần push, kể cả những lần chỉ sửa tài liệu. Giờ thì một lần push chỉ sửa tài liệu không chạy gì cả, còn một lần push chỉ đụng tới ứng dụng thì bỏ qua bộ kiểm thử Django. Tôi đã chạy thử (dry run) job này trên năm commit thật: commit chỉ sửa tài liệu không chạy gì, commit chỉ sửa ứng dụng bỏ qua job Python, còn commit chỉ sửa backend thì bỏ qua job ứng dụng. Một ví dụ đã cắt gọn:</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>Workflow thật còn chạy toàn bộ khi không có khoảng commit nào để so sánh: một nhánh mới, một lần force push hoặc một lần chạy tay.</p>
<p>Điểm mù vẫn là điểm mù cũ, chỉ mang một hình dạng khác. testmon không nhìn thấy tệp JSON, và bộ lọc theo đường dẫn cũng vậy, trừ khi bạn liệt kê tệp đó ra. Vì thế, mẫu ở trên ghi rõ tên tệp fixture dùng chung, gói web đã dựng và trang được sinh ra, kèm một dòng chú thích nhắc người sửa nó giữ cho danh sách luôn đúng. Phiên bản cấp tệp thô hơn, nhưng khi sai thì sai về phía an toàn: chỉ cần bên phía Python có gì thay đổi là cả bộ kiểm thử sẽ chạy.</p>

<h2 class="relative group">Có đáng không?
    <div id="có-đáng-không" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%b3-%c4%91%c3%a1ng-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với một bộ kiểm thử chạy mất vài phút thì có. Cái giá thật sự nằm ở những gì nó không nhìn thấy, và script bọc sinh ra là để lo việc đó. Dù vậy, chỗ của testmon là vòng lặp làm việc trên laptop. CI vẫn chạy toàn bộ bộ kiểm thử mỗi khi phía Python có thay đổi, và đó mới là lần chạy tôi tin tưởng trước khi triển khai.</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>Docker Compose trong CI GitHub Actions: tôi đã thay nó bằng gì, và các pipeline tôi đang chạy</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Các pipeline GitHub Actions tôi chạy năm 2026: service container thay cho Docker Compose trong CI, cache chọn theo thời gian từng bước, và triển khai bằng script.</description>
      <content:encoded><![CDATA[<p>Quy trình CI/CD (CI/CD pipeline) là con đường tự động mà một thay đổi đi từ lúc <code>git push</code> cho đến khi lên production: CI dựng (build) và kiểm thử mọi lần push trên một máy sạch, còn CD phát hành những gì đã qua bằng một bước duy nhất, lặp lại được. Trên GitHub Actions, đó là một tệp YAML trong <code>.github/workflows/</code>, và các quy trình của tôi được cố ý làm mỏng: phần kiểm tra chạy đúng những lệnh tôi vẫn gõ trên laptop, còn mọi khâu triển khai đều là script hoặc CLI, không có GitHub thì vẫn chạy y như vậy.</p>
<p>Nếu bạn vào đây để tìm cách chạy Docker Compose trong GitHub Actions: tôi đã làm thế suốt hai năm rồi bỏ. Giờ CI khai báo cùng các image Postgres và Valkey đó dưới dạng <code>services:</code> và chạy thẳng trên runner đúng các lệnh <code>uv run …</code> và <code>npm run …</code>; Compose quay về làm thứ lập trình viên chạy ở máy mình. Phần còn lại là lý do, kèm các tệp.</p>
<p>Năm 2024 tôi có viết bài <a href="https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/" >Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng</a>, với ý chính là để logic dựng trong Compose, còn công cụ CI chỉ việc gọi nó. Hai năm sau, tôi xem lại toàn bộ các kho mã mà tôi đang dùng để phát hành. Có bốn kho có workflow, kho nào cũng chỉ có một người commit là tôi, và trong số các workflow đó không còn cái nào gọi <code>docker compose</code> nữa. Nguyên tắc thì vẫn đứng vững, còn cách làm thì không. Dưới đây là những thứ đã thay thế nó: phiên bản ví dụ đã cắt gọn của các tệp, thời gian từng bước mà GitHub ghi lại, và cả những phần ít tự động hơn so với mong đợi của mấy sơ đồ CI/CD.</p>

<h2 class="relative group">Mọi quy trình tôi đang chạy, gói trong một bảng
    <div id="mọi-quy-trình-tôi-đang-chạy-gói-trong-một-bảng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%8di-quy-tr%c3%acnh-t%c3%b4i-%c4%91ang-ch%e1%ba%a1y-g%c3%b3i-trong-m%e1%bb%99t-b%e1%ba%a3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Quy trình</th>
					<th>Bắt đầu bởi</th>
					<th>CI kiểm tra gì</th>
					<th>Đi đến đâu</th>
					<th>Ai phát hành</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Blog này và trang Hugo của một khách hàng</td>
					<td>Mọi lần push, trên bất kỳ nhánh nào</td>
					<td><code>hugo --gc</code> dựng được hay không</td>
					<td>Cloudflare Pages: <code>main</code> là production, các nhánh khác có URL xem trước</td>
					<td>Không ai cả; push chính là phát hành</td>
			</tr>
			<tr>
					<td>API của ứng dụng chợ mà tôi đang làm</td>
					<td>Một script triển khai, chạy tay</td>
					<td>Lint, kiểm tra kiểu và bộ kiểm thử Django, mỗi lần push lên <code>main</code></td>
					<td>Fly.io, triển khai cuốn chiếu (rolling deploy)</td>
					<td>Tôi</td>
			</tr>
			<tr>
					<td>Giao diện của chính ứng dụng đó: web, cập nhật OTA, bản dựng cho store</td>
					<td>Các script phát hành và bản dựng store trên máy, chạy tay</td>
					<td>TypeScript và Jest, mỗi lần push lên <code>main</code></td>
					<td>Kho lưu trữ đối tượng (object storage), một dịch vụ cập nhật OTA và hai kho ứng dụng</td>
					<td>Tôi</td>
			</tr>
			<tr>
					<td>pyhtml2text, gói Python mã nguồn mở của tôi</td>
					<td>Mọi lần push, pull request và tag</td>
					<td>Wheel cho 15 nền tảng đích, kiểm thử trên phần lớn trong số đó</td>
					<td>PyPI, khi gắn tag</td>
					<td>Cái tag</td>
			</tr>
	</tbody>
</table>
<p>Nói theo sách vở thì hai trang Hugo là triển khai liên tục, còn ứng dụng kia (một ứng dụng chợ kiêm cộng đồng mà tôi đang làm) là tích hợp liên tục cộng với phát hành bằng đúng một lệnh, tức là chuyển giao liên tục: phần kiểm tra báo cho tôi biết khi có gì sai, nhưng không chặn tôi triển khai. Tôi cố ý chia như vậy. Một bản cập nhật OTA sẽ đến mọi điện thoại đang dùng phiên bản ứng dụng mà nó nhắm tới, nên việc phát hành phải là một quyết định, chứ không phải tác dụng phụ của một lần merge. Còn API là nơi chạy migration cơ sở dữ liệu. Hai ứng dụng Django còn lại của tôi thì hoàn toàn không có workflow nào.</p>

<h2 class="relative group">Phần CI: một workflow, hai job
    <div id="phần-ci-một-workflow-hai-job" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%e1%ba%a7n-ci-m%e1%bb%99t-workflow-hai-job" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ CI của ứng dụng nằm trong một tệp, <code>.github/workflows/checks.yml</code>. Trước ngày 11 tháng 10, ngay cả tệp này cũng chưa có. Đây là một ví dụ đã cắt gọn cho thấy hình dạng của nó:</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>Hai job chạy song song. Trong 35 lần chạy thành công tính đến giờ, trung vị của job ứng dụng là khoảng bảy phút, còn job Python là khoảng mười hai phút rưỡi.</p>
<p>Khối <code>services:</code> chính là chỗ tệp Compose đã chuyển tới, phần sau sẽ nói rõ. Môi trường chỉ có vài biến và một cờ. Mọi thứ còn lại đều rơi về giá trị mặc định cho môi trường phát triển mà lập trình viên có được khi chạy <code>docker compose up</code>, nên bộ kiểm thử không cần bí mật (secret) nào. Để chắc chắn, tôi đã chạy thử một phần bộ kiểm thử với đúng môi trường đó và không có gì khác.</p>
<p>CI chạy cùng trình thông dịch với laptop của tôi và với image production, tức là bản Python 3.15.0 do chính uv dựng, vì <code>uv sync</code> đọc <code>.python-version</code> và tải về đúng bản đó. (Tệp thật của tôi còn truyền cho setup-uv một tham số <code>python-version-file</code>, thật ra chẳng làm gì cả: setup-uv v6 không có tham số nào như vậy, và lần chạy nào cũng kèm một cảnh báo nói đúng điều đó. Viết bài này tôi mới để ý.)</p>
<p>Việc dùng chung trình thông dịch đã chứng minh giá trị chỉ tám ngày sau khi workflow ra đời. Ngày 19 tháng 10, tôi bật trình biên dịch JIT của 3.15, và ngay lần chạy đầu tiên có JIT, một bài kiểm thử đã thất bại: một câu <code>yield from</code> rất bình thường, nằm trên đoạn code mà lần lưu dữ liệu nào của ứng dụng cũng đi qua, lại ném ra <code>AttributeError: 'list' object has no attribute 'send'</code>. Cùng commit đó mà tắt JIT thì qua. Máy Mac của tôi là arm64 nên chưa bao giờ gặp lỗi này; còn máy chạy CI (runner) là x86_64, giống các máy production. JIT giờ đã được tắt lại, cả trong CI lẫn trong Dockerfile, và lỗi này chưa bao giờ lọt lên production. Đó là lỗi python/cpython#155823, đã được sửa ở upstream vào ngày 13 tháng 10, sau khi 3.15.0 phát hành.</p>
<p>Thật ra ngay lần chạy đầu tiên cũng đã thất bại, nhưng vì một lý do tẻ nhạt hơn. Expo tạo tệp <code>expo-env.d.ts</code> khi bạn chạy <code>expo start</code>, mà tệp này lại nằm trong gitignore. Vì vậy bản làm việc nào tôi từng dùng cũng có nó, còn một bản checkout mới tinh thì không. TypeScript báo năm lỗi chưa từng xuất hiện trên máy tôi. Bước tạo tệp đó chính là cách sửa.</p>
<p><code>concurrency</code> sẽ hủy lần chạy cũ khi có một lần push mới hơn lên cùng nhánh. Cả mười hai lần chạy bị hủy tính đến giờ đều kết thúc theo cách này. Một hook trước commit (pre-commit) chạy công cụ lint, và chạy thêm bộ kiểm tra kiểu khi có tệp Python hoặc TypeScript được stage, nên phần lớn lỗi không đi được tới lúc push.</p>

<h2 class="relative group">Tệp Docker Compose đã đi đâu
    <div id="tệp-docker-compose-đã-đi-đâu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%87p-docker-compose-%c4%91%c3%a3-%c4%91i-%c4%91%c3%a2u" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là phần trong tệp <code>docker-compose.yml</code> của ứng dụng mà CI quan tâm, viết lại dưới dạng ví dụ. Tệp thật còn nhiều dịch vụ hơn và nhiều biến môi trường hơn hẳn.</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>Đặt nó cạnh khối <code>services:</code> trong <code>checks.yml</code> thì thấy đó là cùng hai image: Postgres 17 (ở đây là bản Alpine, trong CI là bản Debian) và <code>valkey/valkey:8-alpine</code>. Quan hệ giữa hai bên bây giờ là vậy. Tệp Compose là thứ lập trình viên chạy; còn CI khai báo cùng các image đó dưới dạng service container, rồi chạy thẳng trên runner đúng những lệnh mà lập trình viên vẫn chạy, <code>uv run …</code> và <code>npm run …</code>.</p>
<p>So với bài năm 2024, phần tôi quan tâm nhất vẫn đứng vững: công cụ CI vẫn mỏng, và các bước kiểm tra lẫn triển khai đều là những lệnh tôi tự chạy được. Thứ không trụ lại được là để CI gọi Compose.</p>
<p>CI của ứng dụng hoàn toàn không dựng Docker image. Image được dựng đúng một lần, lúc triển khai, trên máy dựng (builder) của Fly, còn CI chỉ cần trả lời code có ổn không, và khi bộ công cụ đã có sẵn trong bộ nhớ đệm (cache) thì việc đó chỉ tốn sáu giây cài đặt. Ngoài ra, tệp Compose còn bind-mount bản làm việc và nạp dữ liệu mẫu, những việc một lần chạy kiểm thử không nên làm. Vậy nên CI sẽ cần một tệp Compose riêng, tức là một bản mô tả thứ hai cho cùng các dịch vụ, phải giữ cho khớp nhau bằng tay.</p>
<p>Thêm nữa, ví dụ năm 2024 có một lỗi mà lúc đó tôi không để ý. <code>docker compose build</code> chỉ dựng image, chứ không bao giờ chạy <code>command</code> của dịch vụ. Thế nên lệnh <code>npm test</code> trong tệp đó chưa từng chạy trong CI, và workflow vẫn xanh dù kiểm thử có qua hay không.</p>
<p>Rủi ro mà bài đó lo nhất, tức là đổi nhà cung cấp CI, rốt cuộc chưa bao giờ xảy ra. Cuộc chuyển dịch thật sự lại diễn ra ở đầu bên kia: blog này và trang của khách hàng kia đã đổi nơi lưu trữ sang Cloudflare Pages, và với blog thì chỉ cần viết mới một workflow dài 45 dòng, vì khâu dựng vốn đã chỉ là một lệnh. Còn cái workflow ba trăm dòng mà tôi từng cảnh báo? Tôi có đúng một cái, của pyhtml2text. Nó dài vì wheel phải được dựng trên runner Linux, macOS và Windows, mỗi loại cho nhiều kiến trúc, và cũng vì vậy mà đây là việc duy nhất mà YAML riêng của nền tảng là công cụ đúng.</p>

<h2 class="relative group">Bộ nhớ đệm: đọc thời gian từng bước trước đã
    <div id="bộ-nhớ-đệm-đọc-thời-gian-từng-bước-trước-đã" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-%c4%91%e1%bb%8dc-th%e1%bb%9di-gian-t%e1%bb%abng-b%c6%b0%e1%bb%9bc-tr%c6%b0%e1%bb%9bc-%c4%91%c3%a3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>GitHub ghi lại thời gian của từng bước, và <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> sẽ trả về cho bạn. Đây là số liệu từ lần chạy thành công gần nhất của ứng dụng, ngày 20 tháng 10:</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>Bước</th>
					<th>Giây</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Khởi động Postgres và Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, đã có cache</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Kiểm thử Django</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, đã có cache</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>Cả hai bộ nhớ đệm cho thư viện phụ thuộc đều đang bật, và cả hai đều là phần rẻ. Bộ nhớ đệm uv mà GitHub giữ cho kho này chỉ khoảng 200 KB, vì setup-uv dọn đi những gì uv chỉ cần tải lại là xong; còn của npm thì gần 400 MB. Thời gian nằm ở bộ kiểm thử, mà bộ kiểm thử thì đã chạy song song bốn luồng rồi. Muốn chạy nhanh hơn, tôi sẽ sửa bộ kiểm thử chứ không phải bộ nhớ đệm.</p>
<p>Blog thì ngược lại. Ảnh đã được Hugo xử lý nằm trong thư mục <code>resources/_gen</code> vốn bị gitignore, nên runner mới nào cũng phải mã hóa lại toàn bộ ảnh bìa, ảnh trong bài và ảnh giữ chỗ. Workflow ở phía dưới khôi phục chúng bằng <code>actions/cache</code>, với khóa là mã băm của chính các bức ảnh; nếu khóa không khớp thì dùng bản cache cũ mới nhất, vì Hugo chỉ xử lý phần còn thiếu. Trong tám lần chạy cuối cùng trước khi có cache, bước dựng mất từ 33 đến 76 giây; trong 26 lần kể từ đó là 6 đến 20 giây, chỉ một lần ngoại lệ là 94 giây, dù chính commit đó còn thêm ảnh giữ chỗ và một cỡ thẻ thứ hai.</p>
<p>Image của ứng dụng được dựng trên builder của Fly: các tệp khai báo thư viện phụ thuộc được chép vào trước code, còn bộ nhớ đệm tải về của uv nằm trong một cache mount của BuildKit (<code>RUN --mount=type=cache,target=/root/.cache/uv</code>). Nhưng cải thiện lớn nhất ở đây lại không phải là bộ nhớ đệm. Tệp <code>.dockerignore</code> cũ là danh sách chặn (blocklist), và nó bỏ sót một thư mục chứa các git worktree phụ, mỗi worktree lại có thư viện phụ thuộc và sản phẩm dựng của riêng nó. Kết quả là 7,7 GB với 277.000 tệp bị đẩy lên builder mỗi lần triển khai. Viết lại thành danh sách cho phép (allowlist), ngữ cảnh dựng giờ chỉ còn khoảng 40 MB. Một ví dụ cho cách làm này:</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">Bí mật, đặt ở càng ít chỗ càng tốt
    <div id="bí-mật-đặt-ở-càng-ít-chỗ-càng-tốt" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c3%ad-m%e1%ba%adt-%c4%91%e1%ba%b7t-%e1%bb%9f-c%c3%a0ng-%c3%adt-ch%e1%bb%97-c%c3%a0ng-t%e1%bb%91t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bí mật nằm ở ba nơi. Bí mật của GitHub Actions dành cho những việc workflow làm, và hai kho Hugo mỗi kho chỉ có đúng một cái, <code>CLOUDFLARE_API_TOKEN</code>. Bí mật của Fly là cấu hình lúc chạy của ứng dụng, được đặt bằng <code>flyctl secrets set</code>, và không bao giờ đi qua Actions. Cuối cùng là tệp <code>.env</code> bị gitignore trên laptop của tôi, chứa token mà script triển khai dùng để xóa cache của Cloudflare.</p>
<p>PyPI thì hoàn toàn không cần bí mật. pyhtml2text dùng cơ chế trusted publishing: PyPI chấp nhận token OIDC ngắn hạn mà GitHub cấp cho workflow, nên job phát hành chỉ xin quyền <code>id-token: write</code>, và trang bí mật của kho thì trống trơn. Không có gì để rò rỉ, cũng không có gì phải xoay vòng.</p>
<p>Token chỉ được cấp đúng quyền cần thiết. Token Fly của ứng dụng trên GitHub phục vụ một bước kiểm tra định kỳ chỉ liệt kê máy, nên nó được tạo bằng <code>fly tokens create readonly</code>. Các lần triển khai lên Pages chỉ xin <code>contents: read</code> và <code>deployments: write</code>, không hơn.</p>
<p>Dữ liệu đầu vào luôn đi qua <code>env:</code>, không bao giờ nhét thẳng vào script. Workflow OTA nhận ghi chú phát hành dạng văn bản tự do. Nếu viết <code>${{ inputs.notes }}</code> ngay trong <code>run:</code>, đoạn văn bản sẽ được dán thẳng vào script trước khi bash đọc, nên một ghi chú có dấu nháy và dấu chấm phẩy sẽ biến thành code. Truyền qua biến môi trường thì nó vẫn chỉ là dữ liệu. Một ví dụ cho cách làm này:</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>Có những khóa tôi để hẳn bên ngoài GitHub. Một bản báo cáo định kỳ cần một khóa có quyền quản trị phần đăng nhập của ứng dụng, nên nó đã chuyển từ một workflow định kỳ sang một tác vụ định kỳ trên máy Mac của tôi. Workflow vẫn còn đó làm phương án dự phòng chạy tay, và bỏ qua những mục cần đến khóa đó.</p>

<h2 class="relative group">Triển khai: mỗi lần push, bằng script, và theo tag
    <div id="triển-khai-mỗi-lần-push-bằng-script-và-theo-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tri%e1%bb%83n-khai-m%e1%bb%97i-l%e1%ba%a7n-push-b%e1%ba%b1ng-script-v%c3%a0-theo-tag" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Các trang Hugo được triển khai mỗi lần push
    <div id="các-trang-hugo-được-triển-khai-mỗi-lần-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="#c%c3%a1c-trang-hugo-%c4%91%c6%b0%e1%bb%a3c-tri%e1%bb%83n-khai-m%e1%bb%97i-l%e1%ba%a7n-push" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đây là phiên bản ví dụ của workflow cho blog, đã lược bỏ bước tạo dự án Pages ở lần chạy đầu tiên:</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> làm cho <code>main</code> thành production và cho mọi nhánh khác một URL xem trước. Workflow cho trang của khách hàng có cùng cấu trúc, chỉ thêm một dòng <code>rm -rf public</code> trước khi dựng, vì bên đó thư mục <code>public/</code> được commit, nếu không thì các trang đã xóa sẽ cứ nằm lại trong bản tải lên.</p>
<p>Phiên bản Hugo chỉ được ghim ở đây, không ở đâu khác. Tháng 10, một lần cập nhật của trình quản lý gói đã nâng Hugo trên laptop của tôi lên 0.168, cao hơn một bậc so với bản mới nhất mà theme hỗ trợ. Các lần triển khai vẫn ở 0.165 và chẳng hề hấn gì.</p>
<p>Và bước dựng chính là bài kiểm thử. Ngày 14 tháng 9, bản triển khai xem trước của một nhánh thất bại ba lần liên tiếp: một bài viết trên nhánh đó dùng <code>ref</code> của Hugo để liên kết tới một bài vẫn còn là bản nháp. CI dựng đúng như production dựng, tức là bỏ bản nháp ra, nên liên kết trỏ vào hư không và Hugo dừng lại với lỗi <code>REF_NOT_FOUND</code>. Trong lúc đó, <code>main</code> vẫn triển khai bình thường.</p>

<h3 class="relative group">API của ứng dụng được triển khai bằng script
    <div id="api-của-ứng-dụng-được-triển-khai-bằng-script" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#api-c%e1%bb%a7a-%e1%bb%a9ng-d%e1%bb%a5ng-%c4%91%c6%b0%e1%bb%a3c-tri%e1%bb%83n-khai-b%e1%ba%b1ng-script" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần cốt lõi của script triển khai, viết dưới dạng ví dụ:</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>Và các dòng tương ứng trong <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>Image được dựng đúng một lần, đẩy lên registry của Fly với một nhãn ghép từ commit và thời điểm, rồi được triển khai theo đúng nhãn đó, nên thứ được tung ra chính xác là thứ đã được dựng.</p>
<p>Thứ giúp triển khai không gián đoạn (zero-downtime) là việc đánh thức một máy dự phòng trước. <code>max_unavailable = 1</code> chỉ đếm những máy đang chạy, mà ứng dụng lại không giữ máy nào chạy khi rảnh (một máy đang tạm dừng chỉ mất khoảng một giây để tỉnh lại). Vì vậy, trước đây cái máy duy nhất đang thức bị thay cùng đợt với các máy đang ngủ, và đằng sau nó không còn máy nào sẵn sàng. Ngày 28 tháng 9, tôi cho một đầu dò gửi yêu cầu liên tục tới origin: khi chạy <code>flyctl deploy</code> trơn thì có 7 lần hết thời gian chờ và một khoảng trống 7,3 giây, còn với script triển khai, vốn dựng trước và có sẵn một máy đang thức trước khi bắt đầu tung ra, thì không có lỗi nào trong 2.514 yêu cầu.</p>
<p>Phần còn lại của script đánh dấu lần triển khai trên kênh chat của đội, nơi các cảnh báo lỗi gửi về, để khi lỗi dồn dập thì có thể đối chiếu với thứ vừa thay đổi, rồi xóa cache Cloudflare và làm nóng các máy. Giao diện web thì hoàn toàn không đi qua image; chuyện đó tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/" >Triển khai frontend và backend riêng biệt, không cần triển khai lại API</a>.</p>
<p>Migration không phải là một bước trong quy trình. <code>release_command</code> của Fly bị treo trong trường hợp của tôi, nên máy đầu tiên khởi động với image mới sẽ giữ khóa và chạy migration, trong khi các máy khác chờ. Vì thế, migration nào cũng phải tương thích với code vẫn đang chạy: thêm cột ở lần triển khai này, ngừng dùng nó ở lần sau, và xóa nó ở lần thứ ba.</p>
<p>Script này không có chỗ nào bắt buộc phải là laptop của tôi, nhưng có vài tệp chỉ nằm trên máy đang giữ nó ở đó: thông tin đăng nhập Cloudflare, và một bản ghi về thứ đã được triển khai lần gần nhất, thứ mà một runner mới tinh sẽ không có. Nó còn triển khai cả những thay đổi chưa commit, và có báo điều đó trên kênh chat, trong khi script phát hành web thì từ chối một cây làm việc chưa sạch.</p>
<p>Trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Môi trường xem trước cho từng nhánh</a>, tôi viết rằng làm những việc này bằng tay thì ổn khi chỉ có một mình bạn làm, nhưng từ lập trình viên thứ hai trở đi thì nó phải xảy ra vì có gì đó xảy ra trong git. Ứng dụng vẫn chỉ có một lập trình viên. Các workflow môi trường theo nhánh trong bài đó vẫn nằm trên chính nhánh mà tôi đã dựng chúng.</p>
<p>Workflow OTA chỉ chạy tay, vì việc phát hành phải là một quyết định, và vì dịch vụ cập nhật từ chối bản phát hành mới khi đang có một đợt tung ra từng phần (partial rollout) còn mở, nên nếu kích hoạt theo push thì đợt canary nào cũng sẽ vướng. Workflow này chưa từng chạy lần nào. Mọi bản cập nhật OTA cho đến nay đều được đẩy đi từ laptop của tôi, qua chính script mà workflow gọi. Lý do nên giữ workflow mỏng gói gọn trong một câu: phát hành bắt đầu bằng cách nào thì cũng là cùng một script.</p>

<h3 class="relative group">pyhtml2text phát hành theo tag
    <div id="pyhtml2text-phát-hành-theo-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-ph%c3%a1t-h%c3%a0nh-theo-tag" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Job phát hành, với tên gói đã được đổi thành tên chung chung:</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>Job phát hành chờ mọi job dựng xong, và chỉ khi có tag mới thật sự xuất bản; chạy tay thì chỉ dựng và tạo chứng thực (attestation) chứ không tải gì lên. <code>actions/attest</code> ghi lại workflow nào đã dựng từng wheel, còn <code>uv publish --trusted-publishing always</code> tải chúng lên bằng token OIDC thay vì mật khẩu. Bản thân gói thư viện này tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-18-pyhtml2text/" >Một pull request bị đóng, một bản fork và một gói PyPI</a>.</p>

<h2 class="relative group">Workflow định kỳ chỉ là &ldquo;cố gắng hết sức&rdquo;
    <div id="workflow-định-kỳ-chỉ-là-cố-gắng-hết-sức" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#workflow-%c4%91%e1%bb%8bnh-k%e1%bb%b3-ch%e1%bb%89-l%c3%a0-c%e1%bb%91-g%e1%ba%afng-h%e1%ba%bft-s%e1%bb%a9c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng có một workflow chạy định kỳ, kiểm tra xem Fly có bật nhiều máy hơn mức cơ bản hay không, chuyện mà nếu không kiểm tra thì sẽ chỉ lộ ra trên hóa đơn. Nó cảnh báo bằng cách cố ý thất bại, vì GitHub sẽ gửi email cho chủ kho khi một lần chạy định kỳ thất bại.</p>
<p>Lịch được đặt là <code>cron: '*/15 * * * *'</code>, tức 96 lần một ngày. Vậy mà trong hai tuần qua, mỗi ngày nó chỉ chạy từ ba đến tám lần, với khoảng cách trung vị giữa các lần chạy khoảng ba tiếng bốn mươi phút. Lời giải thích duy nhất trong tài liệu của GitHub là tải: lần chạy định kỳ có thể bị trễ khi Actions bận, và bị bỏ qua khi bận đến một mức nào đó. Với một đợt mở rộng quy mô kéo dài, vài tiếng là đủ. Còn với thứ gì cần báo trong vòng vài phút, cron của GitHub không phải là công cụ giám sát.</p>

<h2 class="relative group">Những câu tôi sẽ hỏi về một quy trình CI/CD
    <div id="những-câu-tôi-sẽ-hỏi-về-một-quy-trình-cicd" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-c%c3%a2u-t%c3%b4i-s%e1%ba%bd-h%e1%bb%8fi-v%e1%bb%81-m%e1%bb%99t-quy-tr%c3%acnh-cicd" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Dù bạn đang dựng một quy trình mới hay xem lại quy trình đang có:</p>
<ol>
<li>Nếu GitHub Actions ngừng hoạt động cả một ngày, bạn có còn phát hành được không? Nếu không, logic phát hành của bạn đang nằm trong YAML, và bài năm 2024 vẫn còn đúng.</li>
<li>CI có chạy cùng trình thông dịch, cùng kiến trúc CPU và cùng phiên bản chính của cơ sở dữ liệu với production không? CI của tôi bắt được một lỗi trình biên dịch chỉ vì runner và production đều là x86_64, còn laptop của tôi thì không.</li>
<li>Một bản checkout mới tinh có qua được không? Nếu không, thủ phạm thường là các tệp được sinh tự động và bị gitignore.</li>
<li>Thời gian của một lần chạy thật ra tiêu vào đâu? Hãy đọc thời gian từng bước trước khi thêm bộ nhớ đệm.</li>
<li>Mỗi bí mật đang nằm ở bao nhiêu chỗ, và nó có thật sự cần nằm trên GitHub không?</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>Một pull request bị đóng, một bản fork và một gói PyPI: chuyển HTML sang Markdown thật nhanh cho Python</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Open Source</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>Performance</category>
      <description>Tôi cần phân giải các liên kết tương đối khi chuyển HTML sang Markdown cho các AI agent. Crate Rust gốc không thể nhận pull request của tôi vì nó được viết bằng LLM, nên tôi fork nó và phát hành pyhtml2text: bản binding Python có base_url, một hàm html2markdown mới, các wheel được tối ưu theo profile, và một parser đã vá để xử lý HTML lồng nhau rất sâu trong thời gian tuyến tính. Bài này kể vì sao nó ra đời, bên trong có gì, và tôi đã kiểm tra đầu ra như thế nào.</description>
      <content:encoded><![CDATA[<p>Ngày 15 tháng 10, tôi phát hành <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a> trên PyPI. Thư viện này chuyển HTML sang văn bản thuần (plain text) hoặc Markdown, và được xây dựng trên <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>, crate Rust của Chris Emerson. Khi chuyển sang Markdown, nó nhanh hơn các bộ chuyển đổi Python thông dụng từ 3 đến 21 lần trên những trang tôi đo. Nó còn phân giải được các liên kết tương đối dựa trên URL của trang, nên liên kết trong đầu ra vẫn dùng được khi đã tách khỏi trang gốc.</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>Mọi chuyện bắt đầu từ một pull request gửi cho rust-html2text. Người bảo trì (maintainer) đã đóng nó vì nó được viết bằng LLM, nên pyhtml2text chạy trên một bản fork. Bài này kể chuyện đó diễn ra thế nào, bản fork và gói Python có gì, và tôi đã kiểm tra thế nào để chắc rằng Markdown đầu ra là đúng.</p>

<h2 class="relative group">Vì sao tôi cần nó
    <div id="vì-sao-tôi-cần-nó" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-t%c3%b4i-c%e1%ba%a7n-n%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng chợ kiêm cộng đồng mà tôi đang làm trả về Markdown thay vì HTML khi nhận <code>Accept: text/markdown</code>, dành cho các AI agent và client LLM không muốn phải lội qua thanh điều hướng và chân trang. Phần xử lý ở tầng edge tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >bài về crawler và bộ nhớ đệm</a>. Những trang lấy từ dữ liệu của ứng dụng thì HTML chỉ là một vỏ JavaScript rỗng, nên Markdown được dựng thẳng từ dữ liệu. Phần còn lại là HTML tĩnh (trang giới thiệu, điều khoản pháp lý và blog), và phần đó thì phải chuyển đổi.</p>
<p>Vấn đề khi chuyển đổi nằm ở các liên kết. Một trang web tĩnh đầy những liên kết kiểu <code>/en/posts/...</code> và <code>../setup/</code>. Trong trình duyệt thì không sao, vì trình duyệt biết chúng đến từ trang nào. Một agent đọc Markdown thì không có ngữ cảnh đó, nên nó không thể đi theo <code>/login</code>. Nó cần <code>https://example.com/login</code>.</p>
<p>Thư viện <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> viết bằng Python thuần có tuỳ chọn <code>baseurl</code> cho việc này, và lúc đầu tôi dùng nó. Nhưng nó chậm, và Markdown của nó có vài vấn đề mà tôi sẽ nói ở phần dưới. rust-html2text thì nhanh và được kiểm thử kỹ, nhưng lại không có cách nào phân giải liên kết.</p>

<h2 class="relative group">Hỏi dự án gốc trước
    <div id="hỏi-dự-án-gốc-trước" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#h%e1%bb%8fi-d%e1%bb%b1-%c3%a1n-g%e1%bb%91c-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ngày 29 tháng 8, tôi mở <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a>, mô tả trường hợp sử dụng và hành vi tôi muốn: phân giải theo RFC 3986, giữ nguyên các URL tuyệt đối và các liên kết <code>mailto:</code>, <code>data:</code> và <code>#fragment</code>. Vài phút sau, tôi mở <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">pull request #288</a>. Nó thêm tuỳ chọn <code>Config::base_url(...)</code>, và trong issue tôi có nói rằng đây chỉ là điểm khởi đầu, và tôi sẽ hiểu nếu người bảo trì muốn làm theo cách khác.</p>
<p>Ngày 11 tháng 10, người bảo trì đóng 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>(Tạm dịch: &ldquo;Cảm ơn bạn đã đóng góp, nhưng tôi không thể nhận cái này vì có vẻ nó được tạo bằng LLM. (Tôi vừa thêm một chính sách ngắn vào README.md, trước đây chưa có.)&rdquo;)</p>
<p>và trả lời trong 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>(Tạm dịch: &ldquo;Chào bạn, đây là một yêu cầu tính năng hợp lý, cảm ơn! Tôi đã đóng PR vì có vẻ nó được viết bằng LLM, điều mà tôi không thể chấp nhận, nhưng cảm ơn bạn đã thử. Một mẫu HTML ngắn kèm đầu ra mong muốn chắc chắn sẽ giúp tính năng này được hiện thực!&rdquo;)</p>
<p>README giờ có chính sách này:</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>(Tạm dịch: &ldquo;Hiện tại, dự án này không thể nhận các đóng góp mã được tạo bằng AI tạo sinh/LLM, vì tình trạng bản quyền còn chưa rõ ràng.&rdquo;)</p>
<p>Nhận định đó là đúng: tôi viết đoạn mã ấy bằng một trợ lý lập trình AI. Tôi cũng không nghĩ chính sách đó vô lý. Tình trạng bản quyền của đầu ra từ LLM chưa ngã ngũ, và người gánh rủi ro khi gộp nó vào một crate MIT mà nhiều dự án khác phụ thuộc là người bảo trì. Đó là dự án của họ, và câu trả lời rất lịch sự. Yêu cầu tính năng đó đến giờ vẫn còn mở.</p>
<p>Dù vậy, tôi vẫn cần tính năng này, và tranh cãi với người bảo trì về chính sách đóng góp của chính dự án họ không phải là cách hay để có nó. Vì thế tôi giữ thay đổi trên một bản fork rồi xây gói Python dựa trên đó, và cả hai đều ghi rõ rằng chúng không phải là bản gốc (upstream).</p>

<h2 class="relative group">Bản fork có gì
    <div id="bản-fork-có-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a3n-fork-c%c3%b3-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản fork là <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer">nhánh <code>pyhtml2text-perf</code></a> trong bản fork rust-html2text của tôi. Tôi chỉ giữ ba commit nhỏ để nó luôn rebase gọn gàng lên dự án gốc (upstream). Commit chính là tùy chọn <code>Config::base_url</code> lấy từ PR: liên kết tương đối được phân giải theo <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>, và nếu không truyền base URL thì đầu ra giữ nguyên. Commit thứ hai khiến crate không còn dựng lại các quy tắc style mỗi lần được gọi, nhờ vậy một đoạn HTML ngắn giảm từ 7.3 µs xuống 5.6 µs. Với trang lớn thì chẳng đáng là bao, nhưng gọi vài nghìn lần thì cộng dồn lại cũng đáng kể. Commit thứ ba chỉ mở ra (export) những phần mà bộ chuyển Markdown ở dưới cần dùng từ crate.</p>

<h2 class="relative group">Gói Python
    <div id="gói-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="#g%c3%b3i-python" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a> được xây bằng PyO3 và maturin, và có hai hàm:</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> là bộ render văn bản thuần của rust-html2text, vốn được làm cho terminal và phần <code>text/plain</code> của email:</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>Vài điểm về phần binding có thể không hiển nhiên:</p>
<ul>
<li><strong>Cả hai hàm đều nhận <code>str</code> hoặc <code>bytes</code> UTF-8</strong>, nên bạn có thể truyền thẳng <code>response.content</code> mà không cần giải mã trước. Dữ liệu đầu vào được mượn (borrow), không bị sao chép.</li>
<li><strong>GIL được nhả ra trong lúc chuyển đổi.</strong> Từ đầu tới cuối đều là Rust thuần, nên các luồng (thread) chạy song song được trên nhiều nhân. Module cũng khai báo rằng nó không cần GIL, nên Python 3.14t bản free-threaded sẽ không bật GIL trở lại khi import module.</li>
<li><strong>Một panic sẽ trả về dưới dạng <code>ValueError</code>.</strong> Mặc định PyO3 ném <code>PanicException</code>, thứ mà <code>except Exception</code> không bắt được. Một web worker tưởng rằng mình đã xử lý hết lỗi chuyển đổi sẽ sập vì nó, nên phần binding bắt panic lại và ném ra dưới dạng <code>ValueError</code>.</li>
<li><strong>Có 30 wheel:</strong> wheel abi3 cho CPython 3.10 trở lên, cộng thêm wheel cho bản free-threaded 3.14t, trên Linux (glibc và musl), macOS và Windows, ở mọi kiến trúc mà CI của maturin build được. Vì vậy không ai cần cài bộ công cụ Rust để cài gói này.</li>
</ul>

<h2 class="relative group">Làm cho nó nhanh
    <div id="làm-cho-nó-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%c3%a0m-cho-n%c3%b3-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản 0.2.0 nhanh hơn 0.1.0 từ 1.5 đến 1.7 lần trên trang thật và nhanh gấp đôi với đoạn HTML ngắn, mà đầu ra vẫn giống hệt đến từng byte. Phần lớn là nhờ tối ưu hóa dựa trên profile (profile-guided optimisation, PGO): CI build một wheel có gắn đo đạc (instrumented), chạy nó qua một bộ dữ liệu huấn luyện gồm các trang tài liệu và RFC, rồi dùng profile thu được để build wheel thật. Kết quả là nhanh hơn khoảng 25% trên những trang không nằm trong bộ huấn luyện, và chỉ áp dụng trên các nền tảng mà CI chạy được bản build đo đạc một cách native. Phần còn lại đến từ việc chuyển sang bộ cấp phát bộ nhớ mimalloc, vốn xử lý hàng loạt lần cấp phát nhỏ tốt hơn hẳn, và từ thay đổi về quy tắc style trong bản fork.</p>
<p>Để chắc chắn việc tăng tốc không làm thay đổi gì, tôi băm (hash) đầu ra của một tập trang thật với mọi tổ hợp tùy chọn, kèm các trường hợp biên như đầu vào rỗng hay lồng nhau cực sâu. Mỗi thay đổi đều phải giữ nguyên toàn bộ giá trị băm.</p>
<p>Đây là thời gian trung vị trên một máy Mac Apple Silicon, khi chuyển sang Markdown:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Bài bách khoa toàn thư (1 MB)</th>
					<th>Trang chủ một báo điện tử (370 KB)</th>
					<th>Trang tài liệu 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 thuần)</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>Và khi chuyển sang văn bản thuần:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Bài bách khoa toàn thư (780 KB)</th>
					<th>Trang chủ một báo điện tử (370 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (Python thuần)</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 lồng nhau rất sâu, và một bản vá cho html5ever
    <div id="html-lồng-nhau-rất-sâu-và-một-bản-vá-cho-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="#html-l%e1%bb%93ng-nhau-r%e1%ba%a5t-s%c3%a2u-v%c3%a0-m%e1%bb%99t-b%e1%ba%a3n-v%c3%a1-cho-html5ever" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chẳng ai tự tay viết 40,000 danh sách lồng vào nhau. Nhưng nếu bạn chuyển đổi những trang mà mình không viết ra, sớm muộn gì cũng sẽ có thứ độc hại hoặc bị hỏng gửi đến bạn một trang như thế. Với parser nguyên bản, 40,000 cặp <code>&lt;ul&gt;&lt;li&gt;</code> lồng nhau mất <strong>6 giây</strong> để phân tích, tức là một worker bị kẹt cứng chỉ vì một yêu cầu.</p>
<p>Nguyên nhân nằm ở <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>, bộ phân tích HTML5 mà rust-html2text dùng. Phần lớn thẻ mở cấp khối (block-level) phải kiểm tra xem có phần tử <code>&lt;p&gt;</code> nào đang mở &ldquo;trong button scope&rdquo; hay không, và html5ever trả lời câu đó bằng cách duyệt toàn bộ ngăn xếp các phần tử đang mở. Lồng càng sâu thì mỗi thẻ càng tốn thời gian, nên tổng thời gian tăng theo bình phương độ sâu.</p>
<p>Gói này đi kèm html5ever 0.39.0 với đúng một bản vá: câu trả lời được cập nhật sẵn mỗi khi một phần tử được mở hoặc đóng, nên việc kiểm tra không còn phụ thuộc vào độ sâu nữa. Cùng tài liệu đó giờ chỉ mất <strong>0.14 giây</strong> để phân tích. Để chắc bản vá không làm thay đổi gì khác, tôi so sánh DOM do bộ phân tích gốc và bản đã vá tạo ra trên toàn bộ bài kiểm thử dựng cây (tree-construction) của html5lib, khoảng 20 trang thật và 300,000 tài liệu tag soup ngẫu nhiên, và tất cả đều giống hệt nhau.</p>
<p>Vì cùng lý do đó, bộ chuyển Markdown cũng có giới hạn riêng. Không đầu vào nào có thể làm tràn ngăn xếp của luồng; blockquote và danh sách sẽ ngừng thụt lề khi vượt quá 32 cấp, để đầu ra không phình theo bình phương; và một bảng mà lưới sau khi trải ra vượt quá 100,000 ô sẽ chỉ được ghi ra nội dung các ô.</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="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phiên bản 0.3.0 thêm <code>html2markdown</code>, và phần lớn công sức trong gói này dồn vào nó. Bộ render của rust-html2text tạo ra văn bản trông giống Markdown, nhưng nó được ngắt dòng cho terminal và không được làm ra để phân tích lại. Vì vậy <code>html2markdown</code> chỉ mượn phần phân tích cú pháp, còn đầu ra thì tự ghi theo <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>, cộng thêm bảng pipe và <code>~~strikethrough~~</code> theo kiểu GitHub:</p>
<ul>
<li>Tiêu đề <code>#</code>, gạch đầu dòng <code>-</code>, <code>_emphasis_</code> và <code>**strong**</code>.</li>
<li>Liên kết và hình ảnh nội dòng (inline), được chuyển thành địa chỉ tuyệt đối khi có <code>base_url</code>. Một liên kết chỉ chứa mỗi biểu tượng (icon) sẽ dùng <code>aria-label</code> hoặc <code>title</code> của nó làm văn bản.</li>
<li>Khối mã có rào (fenced code block), gắn tên ngôn ngữ khi trang có ghi (<code>class=&quot;language-python&quot;</code>).</li>
<li>Bảng pipe. Ô trải nhiều hàng hoặc nhiều cột trở thành các ô trống, bảng không có hàng tiêu đề sẽ được thêm một hàng tiêu đề trống, và bảng dùng để dàn trang được ghi ra thành các đoạn văn.</li>
<li>Không ngắt dòng. Nội dung ẩn (<code>hidden</code>, <code>aria-hidden</code>, <code>display: none</code>), script, style và các điều khiển biểu mẫu (form control) bị bỏ qua.</li>
</ul>

<h3 class="relative group">Chỉ escape những gì cần thiết
    <div id="chỉ-escape-những-gì-cần-thiết" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%89-escape-nh%e1%bb%afng-g%c3%ac-c%e1%ba%a7n-thi%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Một số bộ chuyển HTML sang Markdown escape mọi ký tự có thể là cú pháp Markdown. Ví dụ, markdownify mặc định escape mọi <code>_</code> và <code>*</code>. Đầu ra vẫn đúng, nhưng đầy những <code>snake\_case</code> và <code>5 \* 3</code>, khó đọc hơn với con người và tốn token vô ích với một mô hình. pyhtml2text chỉ escape một ký tự ở những chỗ mà nếu không escape, một parser CommonMark sẽ đọc nó thành cú pháp:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">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">In đậm trước một tiểu từ tiếng Hàn
    <div id="in-đậm-trước-một-tiểu-từ-tiếng-hàn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#in-%c4%91%e1%ba%adm-tr%c6%b0%e1%bb%9bc-m%e1%bb%99t-ti%e1%bb%83u-t%e1%bb%ab-ti%e1%ba%bfng-h%c3%a0n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Vấn đề này đến từ chính các trang trong ứng dụng của tôi. Trong tiếng Hàn, tiểu từ ngữ pháp (particle, chẳng hạn <code>의</code> có nghĩa &ldquo;của&rdquo;) được viết dính liền ngay sau danh từ, không có dấu cách. Vì vậy một thuật ngữ in đậm có chứa dấu ngoặc trông như thế này:</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>Cách viết Markdown hiển nhiên nhất là <code>**타이타닉(Titanic)**의</code>, cũng chính là thứ markdownify tạo ra, và nó không hoạt động. Quy tắc flanking của CommonMark không cho <code>**</code> đóng lại ngay sau một dấu câu, trừ khi theo sau nó là dấu cách hoặc thêm một dấu câu nữa. Mà <code>의</code> là một chữ cái, nên các dấu sao bị giữ nguyên dưới dạng ký tự thường. html2text bản Python thuần né chuyện này bằng cách chèn thêm một dấu cách:</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>Cách đó hiển thị được chữ in đậm, nhưng làm thay đổi tiếng Hàn. Tiểu từ giờ bị tách ra thành một từ riêng, mà trong tiếng Hàn thì đó là lỗi chính tả, giống như viết &ldquo;của&rdquo; tách khỏi một từ mà lẽ ra nó phải dính vào. pyhtml2text thay vào đó đưa dấu câu ra ngoài cặp dấu in đậm:</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>Dấu ngoặc đóng không còn được in đậm nữa, nhưng phần nhấn mạnh vẫn được phân tích đúng và mọi ký tự của bản gốc đều được giữ lại. Điều tương tự cũng áp dụng cho tiếng Nhật và tiếng Trung, và cho bất kỳ ngôn ngữ nào không đặt dấu cách trước phần đi theo sau.</p>

<h3 class="relative group">Kiểm tra đầu ra bằng cách đọc ngược lại
    <div id="kiểm-tra-đầu-ra-bằng-cách-đọc-ngược-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ki%e1%bb%83m-tra-%c4%91%e1%ba%a7u-ra-b%e1%ba%b1ng-c%c3%a1ch-%c4%91%e1%bb%8dc-ng%c6%b0%e1%bb%a3c-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi không tin một bộ chuyển đổi chỉ vì nó qua hết bài kiểm thử đơn vị (unit test), bởi lỗi trong đầu ra Markdown thường nằm ở những tổ hợp mà chẳng ai nghĩ tới để viết kiểm thử. Vì vậy đầu ra được kiểm tra bằng cách đọc ngược lại.</p>
<p>Trong lúc chuyển đổi, mỗi đoạn văn được phân tích lại theo CommonMark để xác nhận phần nhấn mạnh được đọc đúng như ý. Nếu không đúng, đoạn đó được viết lại bằng kiểu nhấn mạnh còn lại (<code>*</code> thay cho <code>_</code>, hoặc ngược lại); nếu cả hai đều không được thì giữ lại chữ, liên kết và mã, chỉ bỏ định dạng, để không mất chữ nào. Bộ kiểm thử thì chạy một vòng khứ hồi (round trip) qua một bộ phân tích Markdown khác, rồi so từng chữ hiển thị, đích liên kết và nguồn ảnh với HTML gốc. Chữ bị cú pháp chưa escape &ldquo;nuốt&rdquo; mất, liên kết không phân tích được hay một dấu <code>*</code> lạc chỗ đều hiện ra thành chỗ khác biệt. Nó chạy trên các trang thật và hàng chục nghìn tài liệu ngẫu nhiên, và khi có lỗi thì thu nhỏ tài liệu đó về một trường hợp tối giản.</p>

<h2 class="relative group">Chạy thật
    <div id="chạy-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a1y-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần thương lượng markdown (markdown negotiation) trong ứng dụng của tôi chuyển từ html2text bản Python thuần sang pyhtml2text ngay trong ngày 0.3.0 ra mắt. Trên một trang hướng dẫn, thời gian chuyển đổi giảm từ 4.2 ms xuống 0.36 ms, nhanh hơn khoảng 12 lần. Liên kết đều là tuyệt đối, bảng là bảng pipe thật sự, còn chữ in đậm tiếng Hàn vẫn giữ tiểu từ dính liền. Các wheel abi3 vốn đã hỗ trợ Python 3.14 trên x86_64 và arm64, nên container image không cần cài thêm gì để dùng nó.</p>

<h2 class="relative group">Ghi công, và nơi báo lỗi
    <div id="ghi-công-và-nơi-báo-lỗi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ghi-c%c3%b4ng-v%c3%a0-n%c6%a1i-b%c3%a1o-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần render văn bản thuần là rust-html2text của Chris Emerson, theo giấy phép MIT. pyhtml2text bổ sung một bản fork của nó, một bản html5ever đã vá (MIT/Apache-2.0) và bộ chuyển đổi Markdown. Gói này không liên kết với cũng như không được bảo trợ bởi dự án nào trong hai dự án đó, nên <strong>vui lòng báo lỗi tại <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">trang issue của pyhtml2text</a></strong>, đừng báo lên dự án gốc. Lỗi nào trong mã này thì tôi là người phải sửa, và những người bảo trì dự án gốc không nên phải nhận báo cáo về đoạn mã mà họ đã từ chối nhận.</p>
<p>Nếu bạn đang chuyển đổi HTML bằng Python, nhất là để cho một mô hình đọc, hãy thử nó:</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>Mã nguồn có trên <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>, và changelog liệt kê mọi thay đổi từ 0.1.0 đến 0.3.0.</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>Django trên Fly.io: kiểm thử tải và tự động co giãn với tài nguyên tối thiểu</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Tôi chạy hai ứng dụng Django trên máy shared-cpu-1x của Fly.io: tối ưu một máy cho nhanh, kiểm thử tải để tìm điểm độ trễ tăng vọt, rồi đặt ngưỡng tự động co giãn ngay trước đó.</description>
      <content:encoded><![CDATA[<p>Để chạy Django trên Fly.io với chi phí thấp, tôi tối ưu một máy shared-cpu-1x nhỏ cho thật nhanh, kiểm thử tải (load testing) đến khi độ trễ tăng vọt, rồi cấu hình bộ tự động co giãn (autoscaling) của Fly khởi động máy thứ hai ngay trước điểm đó. Phần kiểm thử tải Django tôi dùng Locust với tải tăng theo từng bậc (còn ứng dụng kia dùng một script Python nhỏ quét qua các mức đồng thời), và theo dõi độ trễ p95 cùng tỉ lệ lỗi. Khi lưu lượng giảm, Fly tự dừng các máy rảnh, nên chi phí nền chỉ là một VM nhỏ cho mỗi ứng dụng.</p>
<p>Hiện tại, với vai trò là một lập trình viên độc lập (solo developer), tôi đang vận hành đồng thời hai sản phẩm:</p>
<ul>
<li>một <strong>ứng dụng ước tính và báo giá tại hiện trường (field estimation)</strong> dành cho các kỹ thuật viên/thợ dịch vụ, giúp họ tính chi phí và báo giá công việc ngay tại chỗ;</li>
<li>và một <strong>ứng dụng chợ mua bán kiêm cộng đồng (marketplace)</strong>, với các tính năng như đăng tin và nhắn tin thời gian thực.</li>
</ul>
<p>Hai sản phẩm khác nhau, nhóm người dùng cũng khác nhau, nhưng bộ công nghệ (tech stack) gần như giống hệt nhau — và cả hai đều đang chạy hoàn toàn trên các <strong>máy shared-cpu-1x</strong>, gói rẻ nhất mà Fly.io cung cấp.</p>
<p>Đây là một giới hạn được tôi chủ động lựa chọn.</p>
<p>Thay vì chọn cấu hình máy chủ theo mức tải đỉnh (peak load), tôi cố gắng khai thác tối đa hiệu năng của một máy nhỏ, đo chính xác ngưỡng mà hệ thống bắt đầu gặp điểm nghẽn (bottleneck) hoặc sụp đổ khi tải cao, rồi để bộ tự động co giãn (autoscaler) của Fly.io tự mở rộng hoặc thu hẹp theo chiều ngang (scale out / scale in), thêm hoặc bớt máy dựa trên chính ngưỡng đã đo đó.</p>
<p>Bài viết này sẽ đi vào các kỹ thuật tối ưu hóa giúp mô hình đó khả thi, các số liệu kiểm thử tải (load testing) dùng để xác định ngưỡng mở rộng (scaling threshold), và cách việc tăng, giảm số máy thực tế diễn ra trên Fly.io.</p>

<h2 class="relative group">Triết lý: đo một máy, rồi nhân lên
    <div id="triết-lý-đo-một-máy-rồi-nhân-lên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tri%e1%ba%bft-l%c3%bd-%c4%91o-m%e1%bb%99t-m%c3%a1y-r%e1%bb%93i-nh%c3%a2n-l%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chiến lược mở rộng quy mô của cả hai ứng dụng đều gồm ba bước giống nhau:</p>
<ol>
<li>Tối ưu một máy nhỏ cho nhanh nhất trong khả năng hợp lý.</li>
<li>Kiểm thử tải cho đến khi máy gãy, rồi ghi lại số yêu cầu đồng thời (concurrency) mà tại đó độ trễ (latency) bắt đầu vọt lên.</li>
<li>Cấu hình bộ tự động co giãn của Fly để bật máy thứ hai ngay trước điểm đó, và dừng các máy đang rảnh khi lưu lượng truy cập (traffic) giảm.</li>
</ol>
<p>Khi đó, năng lực xử lý tăng gần như tuyến tính theo số máy, còn chi phí nền chỉ là một máy ảo (VM) nhỏ cho mỗi ứng dụng. Mọi thứ trong phần còn lại của bài đều phục vụ một trong ba bước này.</p>

<h2 class="relative group">Tối ưu cho một máy nhỏ chạy nhanh
    <div id="tối-ưu-cho-một-máy-nhỏ-chạy-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%91i-%c6%b0u-cho-m%e1%bb%99t-m%c3%a1y-nh%e1%bb%8f-ch%e1%ba%a1y-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian: WSGI cho API, ASGI với uvloop cho WebSocket
    <div id="granian-wsgi-cho-api-asgi-với-uvloop-cho-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="#granian-wsgi-cho-api-asgi-v%e1%bb%9bi-uvloop-cho-websocket" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phía máy chủ (backend) của cả hai sản phẩm đều là Django 6 + Django REST Framework. API chạy trên <strong>Granian</strong>, một máy chủ viết bằng Rust, nhưng dùng <strong>WSGI với nhóm luồng chặn (blocking thread pool)</strong> chứ không phải ASGI:</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>Lúc đầu tôi dùng <code>--interface asgi --loop uvloop</code>, và đó là một quyết định sai. Mọi view HTTP trong hai ứng dụng này đều là đồng bộ (sync), mà trình xử lý ASGI của Django lại chạy view đồng bộ ở chế độ <code>thread_sensitive</code>, tức mỗi tiến trình worker chỉ xử lý được một view tại một thời điểm. Kết quả là với ASGI, mỗi worker chỉ xử lý được khoảng một yêu cầu đồng thời, <em>lại còn</em> phải nạp nguyên bộ asyncio/uvloop/Channels vào RAM mà chẳng được lợi gì. Còn WSGI với <code>--blocking-threads</code> thì chạy chính những view đồng bộ đó trong một nhóm luồng thực thụ: một worker × 16 luồng = 16 yêu cầu nặng về I/O chạy đồng thời (đọc cơ sở dữ liệu, tạo URL ký trước (presigned URL) cho S3), mà bộ nhớ thường trú lại còn thấp hơn bản bất đồng bộ.</p>
<p>Tại sao chỉ dùng một worker? Vì máy ảo là <code>shared-cpu-1x</code>, tức chỉ có một nhân. Thêm worker thứ hai cũng không chạy Python song song được trên một nhân (GIL cộng với chỉ một CPU), nhưng lại tốn thêm nguyên một tiến trình Django thường trú trong bộ nhớ; chính Granian cũng cảnh báo đúng chuyện này. Với các tác vụ nặng về I/O, mười sáu luồng trên một worker là mức trần thực tế của một máy 1 vCPU. Tham số <code>--workers-max-rss 1400</code> sẽ khởi động lại worker nếu nó phình quá giới hạn bộ nhớ, nên nếu một tác vụ tạo PDF hay xử lý ảnh chạy mất kiểm soát thì worker sẽ được thay mới, thay vì kéo cả máy đến chỗ cạn bộ nhớ (OOM).</p>
<p>WebSocket được tách ra chạy trong một tiến trình Granian riêng: một ứng dụng <strong>ASGI</strong> dùng uvloop và <code>--ws</code>, với một worker chuyên giữ các kết nối dài hạn. Phần API đồng bộ hoàn toàn không cần tới ASGI: khi một view cần đẩy dữ liệu xuống socket, nó gọi <code>async_to_sync(channel_layer.group_send)</code> qua Redis, và thông điệp vẫn đến được tiến trình WebSocket. Hai máy chủ, mỗi cái chạy đúng giao diện (interface) mà nó làm tốt nhất. Docker trên máy cục bộ cũng chạy cùng nhóm luồng WSGI đó, nên khi kiểm thử, laptop của tôi mô phỏng khá sát một máy trong môi trường production.</p>

<h3 class="relative group">Dùng Rust ở các đường xử lý nóng
    <div id="dùng-rust-ở-các-đường-xử-lý-nóng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#d%c3%b9ng-rust-%e1%bb%9f-c%c3%a1c-%c4%91%c6%b0%e1%bb%9dng-x%e1%bb%ad-l%c3%bd-n%c3%b3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Ở đường xử lý nóng (hot path) nào của Python mà có sẵn một thư viện viết bằng Rust thay thế được ngay, tôi đều dùng:</p>
<ul>
<li><strong>orjson</strong> qua <code>drf-orjson-renderer</code>: mọi phản hồi của API đều được tuần tự hóa bằng Rust thay vì bộ mã hóa JSON của thư viện chuẩn.</li>
<li><strong>blurhash-rs</strong>: tạo ảnh giữ chỗ (placeholder) ngay lúc ảnh được tải lên.</li>
<li><strong>uuid-utils</strong>: sinh UUID bằng Rust.</li>
<li><strong>psycopg 3 (binary)</strong>: trình điều khiển (driver) Postgres được tăng tốc bằng C.</li>
</ul>
<p>Không thư viện nào trong số này làm thay đổi kiến trúc; chúng chỉ nâng mức trần của một vCPU lên, để bạn chưa phải thêm máy thứ hai sớm.</p>

<h3 class="relative group">Loại bỏ những độ trễ ẩn
    <div id="loại-bỏ-những-độ-trễ-ẩn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lo%e1%ba%a1i-b%e1%bb%8f-nh%e1%bb%afng-%c4%91%e1%bb%99-tr%e1%bb%85-%e1%ba%a9n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cải thiện lớn nhất trong mã nguồn của cả hai ứng dụng không đến từ thuật toán nào cả, mà từ việc tái sử dụng kết nối. Trong nhóm luồng của Granian, mỗi luồng mới lại tạo một đối tượng client S3 của boto3 mới toanh, và endpoint nào trả về ảnh hay PDF cũng phải gánh chi phí đó. Tôi viết một lớp lưu trữ (storage) nhỏ tùy chỉnh để lưu đệm (cache) client theo từng tiến trình:</p>
<blockquote><p>Thời gian tạo URL có chữ ký trên các luồng mới giảm từ <strong>~150ms xuống ~1ms</strong>.</p>
</blockquote><p>Với cơ sở dữ liệu cũng vậy: Postgres trên Fly nằm sau PgBouncer, nên ứng dụng chạy với <code>conn_max_age=0</code> và để bộ gộp kết nối (pooler) lo phần tái sử dụng kết nối, kèm <code>connect_timeout</code> 5 giây để khi gặp kết nối SSL đã chết thì báo lỗi ngay, chứ không làm treo cả worker.</p>

<h3 class="relative group">Đặt bộ nhớ đệm đúng chỗ đang thực sự nghẽn
    <div id="đặt-bộ-nhớ-đệm-đúng-chỗ-đang-thực-sự-nghẽn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%ba%b7t-b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-%c4%91%c3%bang-ch%e1%bb%97-%c4%91ang-th%e1%bb%b1c-s%e1%bb%b1-ngh%e1%ba%bdn" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đợt kiểm thử tải phía chợ mua bán cho tôi một bài học đáng nhớ: <strong>mức trần nằm ở số kết nối Postgres, không phải CPU</strong>. Phần lớn lưu lượng duyệt tin đến từ người dùng ẩn danh, và nội dung họ xem giống hệt nhau. Khi tải lên cao, những truy vấn danh sách giống hệt nhau đó làm đầy nhóm kết nối, trong khi CPU vẫn ngồi chơi.</p>
<p>Cách sửa là thêm một lớp bộ nhớ đệm Redis cho các trang danh sách mà người dùng ẩn danh xem, và vô hiệu hóa bộ nhớ đệm bằng bộ đếm phiên bản theo từng không gian tên (namespace): mỗi thao tác ghi chỉ cần tăng một bộ đếm (O(1), không phải theo dõi từng khóa), thế là mọi trang đã lưu đệm của tài nguyên đó lập tức hết hiệu lực. Một thời gian sống (TTL) ngắn làm lưới an toàn cho những trường hợp bị sót. Trả trang duyệt tin thẳng từ Redis gỡ được đúng điểm nghẽn thật, và để cái máy nhỏ dành kết nối cơ sở dữ liệu cho người dùng đã đăng nhập.</p>

<h3 class="relative group">Chạy tác vụ nền mà không cần trình trung gian
    <div id="chạy-tác-vụ-nền-mà-không-cần-trình-trung-gian" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a1y-t%c3%a1c-v%e1%bb%a5-n%e1%bb%81n-m%c3%a0-kh%c3%b4ng-c%e1%ba%a7n-tr%c3%acnh-trung-gian" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cả hai ứng dụng đều dùng <strong>django-tasks-db</strong>, framework tác vụ của Django lưu hàng đợi trong cơ sở dữ liệu, thay cho Celery. Một tiến trình <code>db_worker</code> riêng liên tục thăm dò cơ sở dữ liệu để lấy việc: xử lý ảnh, tạo PDF, dịch máy, gửi nhắc nhở. Không cần trình trung gian thông điệp (broker), không phải dựng và giám sát thêm dịch vụ nào, và tiến trình API không bao giờ bị những việc nặng chặn lại. Worker còn kết nối thẳng tới Postgres, bỏ qua PgBouncer, vì các tác vụ chạy lâu không hợp với chế độ gộp kết nối theo giao dịch (transaction pooling).</p>

<h3 class="relative group">Bộ nhớ: jemalloc và bộ nhớ hoán đổi để dự phòng
    <div id="bộ-nhớ-jemalloc-và-bộ-nhớ-hoán-đổi-để-dự-phòng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%bb%99-nh%e1%bb%9b-jemalloc-v%c3%a0-b%e1%bb%99-nh%e1%bb%9b-ho%c3%a1n-%c4%91%e1%bb%95i-%c4%91%e1%bb%83-d%e1%bb%b1-ph%c3%b2ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Xử lý ảnh và PDF làm bộ nhớ tăng vọt theo những cách mà khi tính toán cấu hình cho trạng thái ổn định bạn không lường trước được. Tôi dùng hai cách giảm thiểu rẻ tiền: nạp sẵn <strong>jemalloc</strong> trong Docker image để giảm phân mảnh bộ nhớ, và bật <strong>bộ nhớ hoán đổi (swap) trên mọi máy</strong> (2GB trên máy API, 1GB trên máy WebSocket), để khi bộ nhớ tăng vọt thì dữ liệu được đẩy xuống đĩa chứ tiến trình không bị hệ thống buộc dừng vì cạn bộ nhớ. Bộ nhớ hoán đổi không phải tính năng giúp tăng hiệu năng; nó là một khoản bảo hiểm, cho phép tôi giữ mức cấp phát bộ nhớ ở mức nhỏ.</p>

<h2 class="relative group">Kiểm thử tải Django: tìm điểm gãy
    <div id="kiểm-thử-tải-django-tìm-điểm-gãy" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ki%e1%bb%83m-th%e1%bb%ad-t%e1%ba%a3i-django-t%c3%acm-%c4%91i%e1%bb%83m-g%c3%a3y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một ngưỡng chỉ đáng tin khi nó đã được đo thực tế. Mỗi dự án có bộ công cụ kiểm thử riêng:</p>
<ul>
<li>Ứng dụng báo giá tại hiện trường có một script kiểm thử sức chịu tải nhỏ viết bằng Python, chạy lần lượt qua các mức yêu cầu đồng thời và báo cáo số yêu cầu mỗi giây (RPS), p50/p95/p99 cùng tỉ lệ lỗi. Script chạy trên vài trăm bản ghi mẫu được sinh sẵn, để các endpoint danh sách được truy vấn trên khối lượng dữ liệu sát thực tế.</li>
<li>Chợ mua bán dùng <strong>Locust</strong> với kiểu tải bậc thang, tăng từ 50 → 500 người dùng theo từng bậc 30 giây để tìm điểm bão hòa, với trọng số các endpoint khớp với lưu lượng thực tế (phần lớn là duyệt tin). Ngoài ra còn một bộ kiểm thử nhỏ hơn, tăng từ 5 → 50 người dùng ảo, chạy thẳng vào môi trường production với ngưỡng p95 &lt; 2s.</li>
</ul>
<p>Vài con số tiêu biểu từ lượt đo của ứng dụng báo giá tại hiện trường (một worker Granian × 16 luồng chặn, chạy Docker trên máy phát triển của tôi):</p>
<table>
	<thead>
			<tr>
					<th>Endpoint</th>
					<th>Số yêu cầu đồng thời</th>
					<th>RPS</th>
					<th>p95</th>
					<th>Lỗi</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>danh sách nặng</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>danh sách nhẹ</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>danh sách nặng</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>Còn con số quyết định mọi thứ khác đến từ đợt kiểm thử tải chợ mua bán trên <strong>một máy shared-cpu-1x duy nhất</strong>. Với bản ASGI cũ, đây là một bức tường cứng: các view đồng bộ bị xếp hàng tuần tự, chỉ còn khoảng <strong>ba suất xử lý (slot) dùng được</strong>, nên p95 giữ dưới 300ms cho đến khoảng 5 yêu cầu đồng thời, rồi sụp hẳn khi vượt <strong>~8–10</strong>, vì mọi yêu cầu đều phải xếp hàng sau cái khóa thread-sensitive. Chính việc chuyển API sang nhóm luồng WSGI đã đẩy điểm gãy đó ra xa: <strong>16 suất đồng thời thực sự</strong> trên mỗi máy thay vì ~3, và mức trần mới này là nền tảng cho toàn bộ cấu hình tự động co giãn.</p>

<h2 class="relative group">Cơ chế co giãn trên Fly.io
    <div id="cơ-chế-co-giãn-trên-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="#c%c6%a1-ch%e1%ba%bf-co-gi%c3%a3n-tr%c3%aan-flyio" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mô hình của Fly khá đơn giản: ứng dụng của bạn là một nhóm máy giống hệt nhau đứng sau một proxy, và proxy đếm tải trên từng máy. Bạn chỉ cần khai báo hai con số:</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"># past this, start another machine</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"># past this, shed load instead of queueing</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>Khi tải tăng:</strong> khi số yêu cầu đang xử lý trên một máy vượt giới hạn mềm (soft limit), proxy sẽ bật một máy đang dừng và chuyển lưu lượng mới sang đó. Nhóm luồng WSGI cho mỗi máy 16 suất đồng thời thực sự, nên tôi đặt giới hạn mềm ở 12, tức khoảng 75% nhóm luồng, để máy số 2 khởi động khi các luồng vẫn còn dư sức, thay vì đợi đến lúc người dùng phải chờ tới vài giây. Giới hạn cứng (hard limit) đặt ở 24, gấp 1.5 lần nhóm luồng, đóng vai trò cầu dao: những đợt tăng vọt ngắn thì xếp hàng chờ luồng, còn vượt quá mức đó thì proxy sẽ từ chối bớt tải, thay vì để một máy bị quá tải tới mức sập. (Bản ASGI cũ chạy 8/20, tính theo ~3 suất thực của nó; chính việc nới rộng nhóm luồng mới cho phép nâng cả hai con số lên.)</p>
<p><strong>Khi tải giảm:</strong> khi lưu lượng giảm, Fly tự động dừng các máy đang rảnh, xuống tới <code>min_machines_running = 1</code>. Máy đã dừng không tốn đồng tiền tính toán nào, chỉ tốn phần lưu trữ rootfs, nên chi phí nền của mỗi ứng dụng đúng nghĩa đen là một máy ảo nhỏ, mà lúc nào cũng có sẵn năng lực dự phòng cho những đợt tăng tải.</p>
<p>Ban đầu, ở đoạn này tôi có viết rằng một máy đã dừng &ldquo;khởi động nguội (cold start) mất chưa tới một giây&rdquo;. Câu đó sai, và cái sai ấy khá đắt. Đến khi chịu đo thực tế thay vì đoán, tôi mới thấy một lần khởi động nguội mất <strong>25 đến 33 giây</strong>, và yêu cầu nào rơi vào một máy còn đang khởi động thì phải chờ trọn khoảng thời gian đó. Lúc đầu tôi đổ cho chính những lần khởi động nguội này tỉ lệ lỗi 22% trên bảng điều khiển của CDN, trong khi mọi lần kiểm tra tình trạng (health check) vẫn báo xanh. Hóa ra con số đó là các yêu cầu thăm dò Early Hints của chính CDN bị lỗi ở máy chủ gốc (origin); không có client thật nào nhận lỗi 504 cả. Dù vậy, chuyện khởi động nguội thì vẫn là thật.</p>
<p>Cách sửa không nằm ở mấy con số co giãn phía trên; chúng vẫn ổn. Vấn đề nằm ở những gì máy làm trong lúc khởi động, và ở việc yêu cầu Fly tạm ngưng (suspend) máy thay vì dừng hẳn (stop), để mỗi lần đánh thức máy chỉ còn là một lần khôi phục bộ nhớ mất khoảng 3 giây. Tôi đã viết riêng một bài về chuyện này: <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >Thu về 0, và mỗi lần khởi động lại là một lần sập 30 giây</a>, nói về cách tôi chẩn đoán và những gì cần thay đổi. Nếu bạn đang chạy bất cứ thứ gì trên Fly với <code>auto_stop_machines</code>, hãy đọc bài đó trước khi tin vào đoạn này.</p>
<p>Có một điểm tinh tế: kiểu đếm tải là <code>requests</code>, không phải <code>connections</code>. Máy khách hiện đại giữ kết nối duy trì (keep-alive) luôn mở, nên nếu đếm theo kết nối thì con số gần như đứng yên dù tải thực tế tăng, và bộ tự động co giãn sẽ không bao giờ được kích hoạt. Đếm số yêu cầu đang xử lý mới phản ánh đúng khối lượng công việc thực.</p>

<h3 class="relative group">WebSocket co giãn theo một trục khác
    <div id="websocket-co-giãn-theo-một-trục-khác" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-co-gi%c3%a3n-theo-m%e1%bb%99t-tr%e1%bb%a5c-kh%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chợ mua bán tách máy chủ WebSocket ra thành một <strong>ứng dụng Fly thứ hai</strong>, dùng chung Docker image. Ứng dụng API chạy một worker × 16 luồng chặn dưới WSGI và đếm theo <em>yêu cầu</em> (giới hạn mềm 12). Ứng dụng WebSocket thì chạy 1 worker dưới ASGI với uvloop, trên một máy <strong>512MB</strong>, và đếm theo <em>kết nối</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>Giữ một socket sống lâu gần như không tốn gì, nhưng nó chiếm một suất hàng giờ liền, trong khi một yêu cầu HTTP chỉ chiếm suất vài mili giây, nên không thể dùng chung một tín hiệu co giãn cho cả hai. Tách riêng ra thì mỗi nhóm máy co giãn theo đúng thước đo phản ánh tải thật của nó, và năng lực WebSocket lúc rảnh chỉ tốn 512MB chứ không phải 2GB. Về phía Channels, lớp Redis chạy với <code>capacity: 1500</code>, thông điệp hết hạn sau 60 giây, cộng thêm cơ chế duy trì kết nối cho socket mỗi 30 giây, vì proxy Redis của Fly sẽ tự đóng các socket BRPOP sống lâu mà Channels cần tới khi chúng không hoạt động.</p>

<h2 class="relative group">Đẩy việc tính toán ra tầng biên và sang phía máy khách
    <div id="đẩy-việc-tính-toán-ra-tầng-biên-và-sang-phía-máy-khách" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%ba%a9y-vi%e1%bb%87c-t%c3%adnh-to%c3%a1n-ra-t%e1%ba%a7ng-bi%c3%aan-v%c3%a0-sang-ph%c3%ada-m%c3%a1y-kh%c3%a1ch" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Việc rẻ nhất cho máy chủ là việc máy chủ không bao giờ phải làm:</p>
<ul>
<li><strong>Tệp tĩnh và tệp đa phương tiện (media)</strong> nằm trên dịch vụ lưu trữ đối tượng tương thích S3 của Fly, được phân phối qua CDN, với URL có chữ ký cho tệp đa phương tiện riêng tư. Không một byte ảnh nào phải đi qua các máy Django.</li>
<li><strong>Suy luận AI chạy trên điện thoại của người dùng.</strong> Cả hai ứng dụng đều nhúng một LLM nhỏ chạy cục bộ ngay trên thiết bị (mô hình 4B đã lượng tử hóa, khoảng 2.7GB). Phía máy chủ không tốn đồng nào cho suy luận, và tính năng này chạy được cả khi ngoại tuyến.</li>
<li><strong>Ứng dụng di động ưu tiên ngoại tuyến (offline-first)</strong> (SQLite trên thiết bị và một hàng đợi đồng bộ có cơ chế thử lại) giúp ứng dụng gom các thao tác ghi lại thành từng đợt và chịu được những vùng mất sóng, thay vì dội vào API hàng loạt yêu cầu lắt nhắt theo từng lần gõ phím.</li>
</ul>

<h2 class="relative group">Phần còn lại của bộ công nghệ, điểm nhanh
    <div id="phần-còn-lại-của-bộ-công-nghệ-điểm-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%e1%ba%a7n-c%c3%b2n-l%e1%ba%a1i-c%e1%bb%a7a-b%e1%bb%99-c%c3%b4ng-ngh%e1%bb%87-%c4%91i%e1%bb%83m-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Xác thực dùng một dịch vụ định danh (identity provider) có sẵn cộng với khóa truy cập (passkey) WebAuthn, và kết nối WebSocket được xác thực trước khi nâng cấp giao thức. Postgres cho môi trường production, SQLite khi phát triển, cùng vài gói Django có sẵn cho trang quản trị và lịch sử kiểm toán (audit trail). Phía di động là React Native và TypeScript chạy trên Expo, với Expo Router và Tailwind, cập nhật OTA qua một máy chủ Expo Updates tự vận hành, còn gói thuê bao trong ứng dụng thì giao cho một dịch vụ quản lý thuê bao. Khâu kiểm soát chất lượng: pytest và mypy ở phía máy chủ, kiểm thử đơn vị và kiểm thử đầu-cuối (end-to-end) ở phía máy khách.</p>

<h2 class="relative group">Những gì bạn nên &ldquo;học lỏm&rdquo;
    <div id="những-gì-bạn-nên-học-lỏm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-g%c3%ac-b%e1%ba%a1n-n%c3%aan-h%e1%bb%8dc-l%e1%bb%8fm" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Điều đáng mang về không nằm ở một thư viện cụ thể nào, mà ở cả vòng lặp. Tối ưu cho một máy rẻ tiền chạy thật nhanh (dùng Rust ở đường xử lý nóng, tái sử dụng kết nối, đặt bộ nhớ đệm chắn trước điểm nghẽn thật, đẩy được gì sang CDN và máy khách thì đẩy). Kiểm thử tải cho đến khi tìm ra chính xác mức yêu cầu đồng thời mà máy gãy. Đặt giới hạn mềm của bộ tự động co giãn ngay tại điểm gãy đó, để các máy rảnh tự dừng, và giữ tối thiểu một máy luôn chạy. Hai ứng dụng đang chạy production, có tính năng thời gian thực, có trợ lý AI, mà hóa đơn hằng tháng lúc bình thường chỉ là vài máy ảo rẻ nhất trên nền tảng.</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>Điều gì quyết định giá trị của một bức tranh? Xây thuật toán dự đoán giá tranh tại các phiên đấu giá</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-04-01-art-evaluation-algorithm/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Điều gì quyết định giá tranh khi đấu giá? Tái hiện một nghiên cứu năm 2024 dùng XGBoost trên 34,200 giao dịch cho thấy thị trường định giá người nghệ sĩ chứ không định giá tác phẩm. Bài viết đi qua các đặc trưng, kết quả, và nền tảng định giá tranh mà tôi xây dựng xoay quanh chúng.</description>
      <content:encoded><![CDATA[<p>Điều gì quyết định giá trị của một bức tranh khi đem ra đấu giá? Chủ yếu là người nghệ sĩ, chứ không phải bản thân bức tranh. Trong một nghiên cứu năm 2024 trên 34,200 giao dịch đấu giá nghệ thuật đương đại, mô hình chỉ nhìn vào tác phẩm giải thích được khoảng 5% sự biến thiên về giá. Mô hình dựa trên sự nghiệp, lịch sử triển lãm và các lần bán trước đây của nghệ sĩ giải thích được khoảng 73%, và khi thêm mức ước tính trước phiên của nhà đấu giá vào thì con số này lên tới 92%. Dưới đây là cách tôi tái hiện kết quả đó, và ý nghĩa của nó đối với việc định giá tranh bằng thuật toán.</p>
<p>Mấy tuần vừa rồi tôi dồn sức cho hai dự án có liên quan đến nhau. Dự án đầu tiên là tái hiện lại một bài báo năm 2024 đăng trên <em>Scientific Reports</em> của Lee và cộng sự, tựa đề “Social signals predict contemporary art prices better than visual features, particularly in emerging markets” (tạm dịch: tín hiệu xã hội dự đoán giá nghệ thuật đương đại tốt hơn đặc điểm thị giác, nhất là ở các thị trường mới nổi). Nhóm tác giả huấn luyện một mô hình XGBoost trên 34,200 bản ghi đấu giá của 590 nghệ sĩ còn sống. Dự án thứ hai là một nền tảng nghệ thuật có đồ thị tri thức (knowledge graph) nối nghệ sĩ, chủ sở hữu và các bản ghi đấu giá với nhau. Nói nôm na, nếu muốn đem một mô hình như của bài báo ra làm việc gì có ích thì bạn sẽ cần đến phần hạ tầng dữ liệu bao quanh nó, và nền tảng này chính là phần đó.</p>
<p>Làm cả hai, tôi cứ bị kéo về cùng một câu hỏi: liệu có đoán trước được một tác phẩm sẽ bán với giá bao nhiêu không? Câu trả lời là có, nhưng không phải nhờ nhìn vào tác phẩm. Những đặc trưng (feature) thực sự có tác dụng gần như đều mang tính xã hội.</p>

<h2 class="relative group">Vì sao định giá nghệ thuật lại khó
    <div id="vì-sao-định-giá-nghệ-thuật-lại-khó" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-%c4%91%e1%bb%8bnh-gi%c3%a1-ngh%e1%bb%87-thu%e1%ba%adt-l%e1%ba%a1i-kh%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trước khi viết dòng mã nào, tôi bỏ vài ngày đọc xem giám định viên và nhà đấu giá định giá tác phẩm như thế nào. Danh sách yếu tố dài dằng dặc, mà phần lớn lại chẳng quy ra số liệu gọn gàng được. Đầu tiên là provenance, tức lịch sử nguồn gốc: ai từng sở hữu, từng trưng bày ở đâu, được nhắc tới trong những danh mục tác phẩm (catalogue) nào. Rồi đến chuyện xác nhận tác giả: một tác phẩm được quỹ của nghệ sĩ xác nhận có thể đắt gấp hàng chục, hàng trăm lần so với tác phẩm chỉ được ghi là “attributed to”, tức “được cho là của” người đó. Danh tiếng nghệ sĩ cũng quan trọng, và danh tiếng ấy được gây dựng từ các triển lãm cá nhân, những lần góp mặt ở biennale, tác phẩm được bảo tàng mua lại và cách giới phê bình đón nhận. Kế đến là tình trạng tác phẩm và lịch sử phục chế, độ hiếm của nó trong toàn bộ sự nghiệp nghệ sĩ, và chất liệu: sơn dầu trên toan thường bán được giá hơn tác phẩm trên giấy, còn tác phẩm trên giấy lại hơn tranh in. Tác phẩm từng nằm trong bộ sưu tập của một bảo tàng lớn thì như được đóng một con dấu mà thị trường thứ cấp tin tưởng. Thời điểm cũng có ảnh hưởng: dữ liệu so sánh từ năm năm trước là đã lỗi thời.</p>
<p>Chuyên gia của các nhà đấu giá còn hay nhắc tới “giá trị cảm xúc”: nhà sưu tập khao khát đến mức nào, các màn đua giá nóng lên ra sao, tác phẩm chạm tới cảm xúc riêng của từng người đến đâu. Chính yếu tố này có thể đẩy giá búa (hammer price) vượt xa mọi mức ước tính hợp lý. Bạn không mô hình hóa trực tiếp được thứ đó, nhưng vẫn nắm được đại khái hình dạng của nó một cách gián tiếp, thông qua mức ước tính của nhà đấu giá.</p>
<p>Trong thực tế, cách định giá phổ biến nhất là so sánh với các giao dịch tương đương (comparable sales): tìm kết quả đấu giá gần đây của những tác phẩm tương tự của cùng nghệ sĩ, rồi điều chỉnh theo những điểm khác nhau. Mô hình XGBoost cũng làm đúng việc ấy, chỉ khác là ở quy mô lớn hơn và với nhiều đặc trưng hơn hẳn số yếu tố mà một giám định viên có thể tự theo dõi bằng tay.</p>

<h2 class="relative group">Luận điểm chính của bài báo
    <div id="luận-điểm-chính-của-bài-báo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lu%e1%ba%adn-%c4%91i%e1%bb%83m-ch%c3%adnh-c%e1%bb%a7a-b%c3%a0i-b%c3%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Lee và cộng sự đưa ra một lập luận khá ngược đời: nội dung thị giác của tác phẩm chỉ giải thích được rất ít về giá của nó. Mô hình chỉ dùng đặc trưng thị giác của họ, gồm màu sắc, bố cục, mật độ cạnh và vector nhúng (embedding) từ ResNet18, đạt R² khoảng 0.055. Tức là chỉ nhỉnh hơn một chút so với việc lấy giá trung bình ra đoán bừa.</p>
<p>Trong khi đó, một mô hình chỉ dùng các đặc trưng “xã hội” ở cấp nghệ sĩ (giai đoạn sự nghiệp, lịch sử triển lãm, giá đấu giá trước đây, thứ hạng trên ArtFacts) lại đạt R² ≈ 0.73. Thêm mức ước tính trước phiên của nhà đấu giá vào làm đặc trưng thì con số này leo lên 0.92.</p>
<p>Nói cách khác, bản thân tác phẩm gần như không quan trọng. Điều quan trọng là ai làm ra nó, và thị trường đã đánh giá người đó thế nào.</p>
<p>Luận điểm phụ của bài báo là khoảng cách này còn lớn hơn nữa ở các thị trường mới nổi, tức là mọi nơi nằm ngoài nhóm thị trường lâu đời gồm Mỹ, Anh, Pháp và Đức. Ở những thị trường đó, tín hiệu xã hội càng có trọng lượng, còn ước tính của chuyên gia lại kém chính xác hơn, nên dự đoán bằng thuật toán có nhiều đất dụng võ hơn.</p>

<h2 class="relative group">Tái hiện bài báo
    <div id="tái-hiện-bài-báo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%a1i-hi%e1%bb%87n-b%c3%a0i-b%c3%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Yêu cầu đặt ra khá rõ ràng: tái hiện bài báo dưới dạng một MVP do một lập trình viên làm. Không triển khai lên môi trường production, không có luồng xử lý dữ liệu (pipeline) chạy theo thời gian thực. Chỉ cần làm lại cho sát với bài gốc, mã ở mức bản mẫu (prototype), kèm một bản báo cáo ghi lại những gì tìm được.</p>

<h3 class="relative group">Dữ liệu
    <div id="dữ-liệu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#d%e1%bb%af-li%e1%bb%87u" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Nhóm tác giả đã đính kèm sẵn các tệp CSV đã làm sạch trong phần tài liệu bổ sung:</p>
<table>
	<thead>
			<tr>
					<th>Tệp</th>
					<th>Số dòng</th>
					<th>Nội dung</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Df_mloutfull.csv</code></td>
					<td>86,221</td>
					<td>Toàn bộ tập dữ liệu (dataset) thô, hơn 500 cột</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_up_to_2012.csv</code></td>
					<td>34,200</td>
					<td>Tập dữ liệu chính đã làm sạch, 1996–2012</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_old_market.csv</code></td>
					<td>29,853</td>
					<td>Chỉ gồm thị trường lâu đời</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_new_market.csv</code></td>
					<td>4,346</td>
					<td>Chỉ gồm thị trường mới nổi</td>
			</tr>
			<tr>
					<td><code>transactions.csv</code></td>
					<td>114,283</td>
					<td>Bản ghi đấu giá thô kèm đường dẫn ảnh</td>
			</tr>
	</tbody>
</table>
<p>Tổng cộng khoảng 5 GB, nạp vào RAM thoải mái. Huấn luyện XGBoost trên tập dữ liệu cỡ này chưa tới năm phút bằng CPU của laptop, và mô hình chính cũng không cần GPU. Với dữ liệu dạng bảng ở quy mô này, huấn luyện cây tăng cường gradient (gradient-boosted tree) gần như chẳng tốn gì. Chuyện này rất dễ quên nếu mấy năm qua bạn chỉ quanh quẩn với mạng nơ-ron (neural network).</p>

<h3 class="relative group">Đặc trưng
    <div id="đặc-trưng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%ba%b7c-tr%c6%b0ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mô hình dùng 38 đặc trưng, chia làm ba nhóm.</p>
<p><strong>Về nghệ sĩ (30 đặc trưng):</strong></p>
<ul>
<li>Nhân khẩu học: tuổi, giới tính, trình độ học vấn, có học trường top hay không.</li>
<li>Sự nghiệp: thứ hạng ArtFacts, số triển lãm cá nhân, số triển lãm nhóm, số lần góp mặt ở biennale, giải thưởng.</li>
<li>Bộ sưu tập: số bộ sưu tập tư nhân và công cộng đang giữ tác phẩm.</li>
<li>Lịch sử giá: giá trung bình, trung vị, cao nhất và thấp nhất trong 5 và 10 lần bán gần nhất của nghệ sĩ.</li>
<li>Lịch sử giá đã quy đổi theo kích thước (giá trên mỗi inch vuông).</li>
<li>Địa lý: nơi nghệ sĩ làm việc và sinh sống, mã hóa thành các cờ theo quốc gia.</li>
<li>Cờ khớp: thể loại của tác phẩm có đúng với thể loại quen thuộc của nghệ sĩ không? Quốc gia nơi diễn ra phiên bán có khớp không?</li>
</ul>
<p><strong>Về thị trường (8 đặc trưng):</strong></p>
<ul>
<li>Hạng của nhà đấu giá (1–4).</li>
<li>Mặt bằng giá theo quốc gia và khu vực trong năm đó (thấp nhất, trung bình, trung vị, cao nhất).</li>
</ul>
<p><strong>Về bản thân tác phẩm (không liên quan đến hình ảnh):</strong></p>
<ul>
<li>Chiều rộng, chiều cao, diện tích tính bằng inch vuông.</li>
<li>Nhóm chất liệu (tranh vẽ, tranh in, ảnh, điêu khắc, khác).</li>
</ul>
<p>Để ý thứ bị thiếu: không có bất cứ thông tin nào về nội dung hình ảnh thật sự của tác phẩm. Phần đó chỉ xuất hiện trong một thí nghiệm cắt bỏ (ablation) riêng.</p>

<h3 class="relative group">Chia tập huấn luyện và tập kiểm thử
    <div id="chia-tập-huấn-luyện-và-tập-kiểm-thử" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#chia-t%e1%ba%adp-hu%e1%ba%a5n-luy%e1%bb%87n-v%c3%a0-t%e1%ba%adp-ki%e1%bb%83m-th%e1%bb%ad" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Dữ liệu được chia theo thời gian chứ không chia ngẫu nhiên. Mọi giao dịch trước tháng 5 năm 2011 vào tập huấn luyện (~80%, ~27,360 bản ghi), mọi giao dịch sau đó vào tập kiểm thử (~20%, ~6,840 bản ghi). Nếu chia ngẫu nhiên, thông tin từ tương lai sẽ rò rỉ vào qua các đặc trưng lịch sử giá của nghệ sĩ. Khi các bản ghi của cùng một nghệ sĩ bị rải lẫn vào cả hai tập, những dòng kiểm thử đã “nhìn thấy” giá của nhau qua các cửa sổ trượt (rolling window), và R² của bạn sẽ đẹp hơn thực tế.</p>
<p>Kiểu lỗi này nhìn ra rồi thì thấy hiển nhiên, nhưng lần đầu làm thì rất dễ bỏ sót.</p>

<h3 class="relative group">Mô hình
    <div id="mô-hình" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%c3%b4-h%c3%acnh" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi dùng mô hình hồi quy XGBoost (XGBoost regressor) để dự đoán <code>log10(price_usd)</code>, dò siêu tham số (hyperparameter) <code>max_depth</code> và <code>learning_rate</code>, rồi đánh giá trên một tập kiểm định (validation set) để riêng. Có hai biến thể:</p>
<ol>
<li><strong>Không dùng ước tính của chuyên gia.</strong> Mục tiêu R² ≈ 0.73.</li>
<li><strong>Có dùng ước tính của chuyên gia.</strong> Mục tiêu R² ≈ 0.92.</li>
</ol>
<p>Biến thể “có ước tính” lấy thêm mức ước tính thấp và cao trước phiên của nhà đấu giá làm đặc trưng. Chỉ riêng hai con số ước tính này đã đạt R² ≈ 0.90. Cộng thêm các đặc trưng xã hội vào thì được thêm 0.02 nữa. Con số tuyệt đối thì nhỏ, nhưng 0.02 đó chính là phần mô hình thực sự dự đoán được vượt ra ngoài những gì chuyên gia đã biết.</p>

<h3 class="relative group">Mục tiêu của tôi
    <div id="mục-tiêu-của-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%a5c-ti%c3%aau-c%e1%bb%a7a-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi không định vượt mặt bài báo. Mục tiêu chỉ là đạt R² trong khoảng ±0.03 so với kết quả của họ ở mọi điều kiện. Với các con số chính, ~0.73 khi chỉ dùng siêu dữ liệu (metadata) và ~0.92 khi có ước tính, việc tái hiện khá thuận lợi: XGBoost cho kết quả tất định khi cố định seed ngẫu nhiên, còn các đặc trưng được mô tả rõ ràng. Tôi đoán trước sẽ bị lệch ở hai chỗ. Một là thí nghiệm cắt bỏ với đặc trưng thị giác, nơi chỉ cần khác một chút về số chiều PCA hay khâu tiền xử lý ảnh là kết quả đã xê dịch. Hai là phần thị trường mới nổi, vốn chỉ có 4,346 bản ghi nên mức nhiễu nền cao hơn.</p>

<h2 class="relative group">Vì sao đặc trưng thị giác gần như vô dụng
    <div id="vì-sao-đặc-trưng-thị-giác-gần-như-vô-dụng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-%c4%91%e1%ba%b7c-tr%c6%b0ng-th%e1%bb%8b-gi%c3%a1c-g%e1%ba%a7n-nh%c6%b0-v%c3%b4-d%e1%bb%a5ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với tôi, đây là kết quả thú vị nhất. Luồng xử lý ảnh của bài báo trích ra 8,971 con số cho mỗi bức ảnh:</p>
<ul>
<li><strong>GIST descriptor</strong> (960 chiều): bố cục không gian tổng thể.</li>
<li><strong>Histogram of Oriented Gradients</strong> (2,915 chiều): cấu trúc các đường cạnh.</li>
<li><strong>Color histogram</strong> (4,096 chiều): phân bố bảng màu.</li>
<li><strong>ResNet18 features</strong> (1,000 chiều): vector nhúng cấp cao từ một mô hình đã huấn luyện sẵn (pretrained).</li>
<li><strong>Colorfulness</strong> (1 chiều): một con số đo độ rực màu.</li>
<li><strong>Complexity</strong> (1 chiều): một con số đo mật độ cạnh.</li>
</ul>
<p>Nén hết chỗ đó bằng PCA rồi đưa vào XGBoost, bạn được R² ≈ 0.055. Riêng màu sắc đạt 0.056. Cấu trúc cạnh đạt 0.029. Còn vector nhúng của mạng nơ-ron chỉ được 0.009, coi như bằng không.</p>
<p>Cách giải thích gọn nhất là giá đấu giá được quyết định ở cấp nghệ sĩ chứ không phải ở từng tác phẩm. Hai bức tranh của cùng một nghệ sĩ sẽ bán được số tiền na ná nhau, bất kể trên toan vẽ gì, vì thứ người mua thật sự trả tiền là chữ ký.</p>
<p>Ai từng đọc phần ghi chú trong danh mục đấu giá sẽ hiểu vì sao lại như vậy. Gần như toàn bộ câu chữ dành cho lịch sử nguồn gốc, lịch sử triển lãm và các giao dịch tương đương. Bản thân tác phẩm thì hầu như chẳng được mô tả gì.</p>
<p>Nếu nghiêm túc nhìn vào con số 5% đó, thì nội dung thẩm mỹ của một tác phẩm gần như chẳng dính dáng gì tới giá trị thị trường của nó. Thị trường trả tiền cho thứ gì thì trả, nhưng không phải cho những gì nằm trên toan. Một mô hình hiểu nghệ thuật đến từng pixel sẽ đoán giá kém hơn một mô hình chỉ biết mỗi CV của nghệ sĩ.</p>

<h3 class="relative group">Giai đoạn 3 chỉ làm khi đủ điều kiện
    <div id="giai-đoạn-3-chỉ-làm-khi-đủ-điều-kiện" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#giai-%c4%91o%e1%ba%a1n-3-ch%e1%bb%89-l%c3%a0m-khi-%c4%91%e1%bb%a7-%c4%91i%e1%bb%81u-ki%e1%bb%87n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cột đường dẫn ảnh trong dữ liệu của bài báo trỏ tới các URL còn hoạt động vào năm 2012. Đến năm 2026 thì phần lớn đã chết. Trong kế hoạch, tôi đã ghi chú rủi ro này ngay từ ngày thứ 8: lấy ngẫu nhiên một trăm URL xem còn bao nhiêu cái mở được. Nếu hơn một nửa đã mất thì bỏ qua hẳn giai đoạn thị giác và ghi lại lý do.</p>
<p>Dù sao thì đặc trưng thị giác cũng chỉ giải thích khoảng 5% độ biến thiên của giá. Mất hai tuần vật lộn với đống đường dẫn hỏng chỉ để xác nhận một con số nhỏ cỡ đó thì không đáng. Tốt hơn là ghi chuyện đường dẫn chết vào báo cáo rồi làm tiếp phần khác.</p>

<h2 class="relative group">Thị trường lâu đời và thị trường mới nổi
    <div id="thị-trường-lâu-đời-và-thị-trường-mới-nổi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#th%e1%bb%8b-tr%c6%b0%e1%bb%9dng-l%c3%a2u-%c4%91%e1%bb%9di-v%c3%a0-th%e1%bb%8b-tr%c6%b0%e1%bb%9dng-m%e1%bb%9bi-n%e1%bb%95i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bài báo huấn luyện các mô hình riêng cho thị trường lâu đời (Mỹ, Anh, Pháp, Đức) và thị trường mới nổi (19 quốc gia còn lại). Kết quả chính như sau:</p>
<table>
	<thead>
			<tr>
					<th>Nhóm đặc trưng</th>
					<th>R² thị trường lâu đời</th>
					<th>R² thị trường mới nổi</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Chỉ thị giác</td>
					<td>0.053</td>
					<td>0.056</td>
			</tr>
			<tr>
					<td>Siêu dữ liệu (tín hiệu xã hội)</td>
					<td>0.667</td>
					<td>0.750</td>
			</tr>
			<tr>
					<td>Siêu dữ liệu + ước tính</td>
					<td>0.916</td>
					<td>0.859</td>
			</tr>
	</tbody>
</table>
<p>Có hai điểm đáng chú ý. Thứ nhất, tín hiệu xã hội ở thị trường mới nổi lại quan trọng hơn chứ không kém đi. Tôi cứ tưởng thị trường lâu đời có dữ liệu phong phú hơn thì đặc trưng xã hội sẽ phát huy tốt hơn, nhưng hóa ra ngược lại.</p>
<p>Thứ hai, ước tính của chuyên gia ở thị trường mới nổi kém chính xác hơn. Đó chính là khoảng cách giữa 0.916 và 0.859 sau khi thêm ước tính vào. Ở thị trường lâu đời, một dự đoán bằng thuật toán phải cạnh tranh với chuyên gia của một nhà đấu giá lớn, người có cả chục năm dữ liệu giao dịch tương đương chống lưng. Còn ở thị trường mới nổi, chuyên gia có ít dữ liệu hơn, nên thuật toán có nhiều chỗ để chứng tỏ giá trị hơn.</p>
<p>Nếu một ngày nào đó bạn muốn thương mại hóa một mô hình như thế này, thì thị trường mới nổi là nơi nó thật sự đáng đồng tiền bát gạo.</p>

<h2 class="relative group">Những giới hạn cần nói rõ
    <div id="những-giới-hạn-cần-nói-rõ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-gi%e1%bb%9bi-h%e1%ba%a1n-c%e1%ba%a7n-n%c3%b3i-r%c3%b5" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là bản tái hiện một bài báo, không phải hệ thống chạy trên môi trường production. Không có luồng xử lý dữ liệu thời gian thực: mô hình được huấn luyện trên dữ liệu đấu giá 1996–2012 và không bao giờ được cập nhật. Cũng không triển khai gì cả: đầu ra là notebook và một bản báo cáo, chứ không phải API dự đoán. Không có cơ chế phát hiện trôi dạt dữ liệu (data drift), nên huấn luyện xong thì mô hình hoàn toàn không biết thị trường đã thay đổi từ lúc nào. Tập dữ liệu cũng bị đóng băng, nên crypto art, NFT hay những biến động thị trường sau COVID đơn giản là không có trong đó. Nếu muốn dự đoán giá ở các nhà đấu giá Hàn Quốc, bạn sẽ cần đặc trưng riêng cho từng nhà đấu giá, và có khi cả một mô hình riêng cho thị trường Hàn Quốc. Ở đây chưa có gì chuyên biệt đến mức ấy.</p>
<p>Mô hình cũng chỉ phản ánh các quy luật trong quá khứ. Có ý kiến cho rằng thị trường đương đại đã thay đổi về cấu trúc vào khoảng năm 2015, khi các hội chợ nghệ thuật trở thành kênh bán hàng chính của thị trường sơ cấp và một thế hệ nhà sưu tập mới bước vào cuộc chơi. Nếu đúng vậy, các quy luật của giai đoạn 1996–2012 có thể không còn áp dụng được nữa.</p>

<h2 class="relative group">Từ bài báo đến nền tảng
    <div id="từ-bài-báo-đến-nền-tảng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%ab-b%c3%a0i-b%c3%a1o-%c4%91%e1%ba%bfn-n%e1%bb%81n-t%e1%ba%a3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đọc xong bài báo, tôi nhìn dự án thứ hai theo một cách khác hẳn. Ban đầu nền tảng này được định hình là một sàn gọi vốn cộng đồng (crowdfunding) cho các triển lãm nghệ thuật. Nghệ sĩ gọi vốn cho các triển lãm sắp tới, còn nhà đầu tư được tiếp cận nghệ sĩ mới nổi sớm hơn so với khi chờ họ lên sàn đấu giá.</p>
<p>Phần sản phẩm người dùng nhìn thấy được cố tình giữ đơn giản. Nghệ sĩ đăng các triển lãm sắp tới đang cần vốn (địa điểm, ngày tổ chức, các tác phẩm dự kiến). Nhà đầu tư rót tiền cho từng triển lãm cụ thể theo các mức ($100, $500, $1k, $5k), và được chia phần lời khi tác phẩm bán được. Tác tử AI (AI agent) lo phần lớn việc trò chuyện giữa nghệ sĩ và nhà đầu tư, nên hai bên không cần cùng trực tuyến mà câu chuyện vẫn tiếp tục. Điều này đặc biệt có ích khi một phòng tranh (gallery) ở Hàn Quốc làm việc với một nhà sưu tập ở Mỹ, hai bên lệch nhau tới 14 tiếng.</p>
<p>Một khi đã thấm rằng tài sản thật sự là người nghệ sĩ chứ không phải tác phẩm, thì mô hình dữ liệu (data model) cũng phải đổi theo. Thứ bạn cần ghi lại là ai từng sở hữu từng tác phẩm, tác phẩm đã được trưng bày ở đâu, các tác phẩm tương đương bán được bao nhiêu, và còn ai khác đang giữ tác phẩm của cùng nghệ sĩ. Chuỗi sở hữu, sự công nhận từ các tổ chức, dữ liệu đấu giá để so sánh, mạng lưới nhà sưu tập: đồ thị tri thức sinh ra chính là để lưu những thứ như vậy.</p>
<p>Mô hình dữ liệu được tôi cố tình giữ nhỏ gọn: nghệ sĩ, tác phẩm, các lần bán đấu giá, các giai đoạn sở hữu, và một nhật ký ghi lại nguồn gốc của chính dữ liệu, để bản ghi nào cũng truy ngược được về nơi nó được thu thập (scrape). Phần cuối nghe như việc sổ sách, nhưng khi dữ liệu là đi thu thập về thì phải có nó mới tin được đồ thị.</p>
<p>Ứng dụng di động hiển thị toàn bộ dữ liệu này thành một đồ thị bố trí theo lực (force-directed). Có một thanh trượt thời gian để bạn kéo qua từng năm và xem mạng lưới thay đổi ra sao: nhà sưu tập nào tham gia vào lúc nào, nghệ sĩ nào được bảo tàng nào để mắt tới, tác phẩm nào đổi chủ trong đợt suy thoái năm 2008.</p>
<p>Mỗi loại thực thể có hình dạng và màu sắc riêng. Nghệ sĩ là hình thoi, tác phẩm là hình chữ nhật bo góc, nhà đấu giá là hình lục giác, còn các bên sở hữu như nhà sưu tập, người buôn tranh, bảo tàng hay di sản nghệ sĩ (estate) là những hình tròn khác màu.</p>
<p>Mấy hình dạng này không phải để trang trí. Khi thu nhỏ để xem vài trăm nút cùng lúc, chỉ cần nhìn đường viền là biết đó là loại thực thể nào, khỏi phải chờ nhãn hiện lên. Một hình thoi có nhiều hình tròn vây quanh là một nghệ sĩ cùng mạng lưới nhà sưu tập của họ. Một hình lục giác nối với nhiều hình thoi là một nhà đấu giá đang phụ trách cả một danh sách nghệ sĩ. Chỉ cần liếc qua là đọc được cấu trúc của cả một thị trường.</p>
<p>Thẻ (tab) thứ hai hiển thị cùng dữ liệu đó trên một trục thời gian: các giai đoạn sở hữu là những thanh ngang, còn mỗi lần bán là một điểm sự kiện. Vẫn là đồ thị ấy, chỉ nhìn qua một lăng kính khác. Cách này hữu ích khi bạn muốn xem ai nắm giữ tác phẩm nào theo trình tự thời gian, thay vì hình dạng của mạng lưới ở một thời điểm.</p>
<p>Bản thân nền tảng này không phải công cụ dự đoán giá. Nó là tầng dữ liệu nằm bên dưới một công cụ như thế. Nếu muốn nuôi một mô hình kiểu của Lee và cộng sự bằng dữ liệu thời gian thực, luôn được cập nhật, thay vì mấy tệp CSV đóng băng từ năm 2012, thì đây chính là cấu trúc bạn cần.</p>

<h2 class="relative group">Định giá bằng đám đông, dưới dạng một trò chơi
    <div id="định-giá-bằng-đám-đông-dưới-dạng-một-trò-chơi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%8bnh-gi%c3%a1-b%e1%ba%b1ng-%c4%91%c3%a1m-%c4%91%c3%b4ng-d%c6%b0%e1%bb%9bi-d%e1%ba%a1ng-m%e1%bb%99t-tr%c3%b2-ch%c6%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần tôi chưa làm, nhưng lại là phần tôi hứng thú nhất, là biến việc định giá nghệ thuật thành một trò chơi.</p>
<p>Bài báo của Lee và cộng sự lấy mức ước tính trước phiên của nhà đấu giá làm đặc trưng, và nhờ nó mà R² tăng vọt. Những ước tính này có giá trị vì chúng là phán đoán tổng hợp của các chuyên gia được đào tạo bài bản, đã từng xem hàng nghìn tác phẩm tương đương, và bản thân họ cũng có quyền lợi gắn với kết quả. Nhưng chuyên gia của nhà đấu giá là nguồn lực vừa hiếm vừa đắt. Cả thế giới chỉ có vài trăm người, họ chỉ làm việc với những lô tác phẩm được ký gửi cụ thể, và họ không đụng tới phần đuôi dài (long tail): những nghệ sĩ mà chưa ai mang tác phẩm đi ký gửi.</p>
<p>Tôi cứ quay đi quay lại một câu hỏi: liệu có tổng hợp được một tín hiệu tương đương từ phán đoán của một nhóm người đông hơn và rẻ hơn nhiều hay không?</p>
<p>Thiết kế sơ bộ như sau:</p>
<ul>
<li>Cho người dùng xem một tác phẩm: ảnh, kích thước, năm sáng tác, tên nghệ sĩ, CV tóm tắt.</li>
<li>Hỏi họ tác phẩm đó sẽ bán được bao nhiêu ở phiên đấu giá.</li>
<li>Cho họ chọn một khoảng giá, hoặc đoán thẳng con số giá búa.</li>
<li>Sau khi họ chốt câu trả lời thì mới cho xem giá bán thật.</li>
<li>Chấm độ chính xác của họ bằng điểm Brier (Brier score), tích lũy theo thời gian.</li>
<li>Hiển thị bảng xếp hạng, chuỗi ngày chơi liên tục (streak) và huy hiệu cho những chuỗi đoán trúng liên tiếp.</li>
<li>Đánh trọng số cho các dự đoán sau này của từng người dựa trên độ chính xác trước đó của họ.</li>
</ul>
<p>Về bản chất, đây là một thị trường dự đoán (prediction market) cho giá tranh, chỉ khoác lên lớp áo của một trò đoán giá. Vòng lặp y hệt các trò đoán địa điểm trên bản đồ hay điểm giải cờ thế (puzzle) trên các trang cờ vua trực tuyến: chơi lại được nhiều lần, chấm điểm được, và có phản hồi để bạn luyện kỹ năng dần lên. Ai đoán giỏi sẽ leo lên bảng xếp hạng, và những lần đoán sau của họ sẽ có trọng lượng lớn hơn trong tín hiệu tổng hợp.</p>
<p>Cái hay nằm ở phía mô hình. Kết quả tổng hợp có trọng số của các lượt đoán trở thành một đặc trưng để đưa vào XGBoost, bên cạnh các tín hiệu xã hội. Nếu trung vị các lượt đoán của đám đông, sau khi đánh trọng số theo độ chính xác, bám sát giá búa, thì bạn đã có trong tay thứ gần giống một bản ước tính “nhân tạo” của nhà đấu giá cho những tác phẩm chưa có ước tính thật. Đó đúng là lỗ hổng ở phần đuôi dài các nghệ sĩ, chỗ mà mô hình của Lee và cộng sự cần được hỗ trợ nhất.</p>
<p>Còn một khía cạnh nữa là bài toán khởi động nguội (cold start). Mô hình học tốt với những nghệ sĩ có lịch sử giá dày dặn, nhưng học rất tệ với nghệ sĩ mới chỉ có ba lần bán. Ba lần bán cộng thêm một nghìn lượt đoán của đám đông thì ít ra cũng có cái để làm việc. Định giá bằng đám đông là một cách tạo ra tín hiệu huấn luyện cho những trường hợp mà dữ liệu gốc không bao phủ tới.</p>
<p>Trò chơi hóa (gamification) quan trọng ở đây, vì cách duy nhất để đám đông trở nên đáng tin là làm cho việc tham gia đủ vui để người ta chơi thật nhiều. Một biểu mẫu hỏi đúng một câu chỉ cho bạn dữ liệu nhiễu, từ những người bấm cho xong một lần rồi thôi. Còn một chuỗi ngày chơi liên tục, có bảng xếp hạng và huy hiệu cho người đoán sát thì giữ chân được cùng một người, để họ đưa ra 500 dự đoán cân nhắc kỹ lưỡng trong một năm. Số lượng lớn, cộng với việc người chơi tự sàng lọc (người đoán giỏi ở lại, người đoán dở chán rồi bỏ đi), mới là thứ cho bạn tín hiệu thay vì nhiễu.</p>
<p>Trước khi xây bất cứ thứ gì, tôi còn phải trả lời vài câu hỏi thiết kế. Nên cho người dùng xem bao nhiêu thông tin: chỉ ảnh và CV, hay cả các giao dịch tương đương? Càng nhiều ngữ cảnh thì đoán càng có cơ sở, nhưng quá một ngưỡng nào đó thì người dùng chủ yếu chỉ nhắc lại những gì bạn vừa cho họ xem. Phản hồi về giá nên chi tiết đến đâu: tiết lộ chính xác giá búa, chỉ đưa một khoảng, hay chỉ báo họ có nằm trong ±20% hay không? Chi tiết quá thì người chơi quen neo vào những con số cụ thể, còn mơ hồ quá thì họ không tự điều chỉnh được. Rồi còn chuyện lách bảng xếp hạng: người chơi sẽ tìm đến những nghệ sĩ họ đã biết, mà đoán giá một bức Banksy thì chủ yếu là kiến thức đố vui chứ đâu phải kỹ năng. Có lẽ cách xử lý là bắt buộc chọn tác phẩm ngẫu nhiên, giống cách các ứng dụng học ngoại ngữ chọn bài học, cộng thêm một bảng xếp hạng riêng cho các vòng “chơi mù” giấu tên nghệ sĩ. Cuối cùng, thiên kiến nhận thức muộn (hindsight bias) là không tránh được: một khi người dùng đã biết một bức Basquiat bán được $110M, họ không thể giả vờ là chưa biết. Thứ giữ cho vòng lặp này trung thực là nguồn tác phẩm mới, chưa ai xem, mà nguồn đó thì mỗi tuần chỉ có hạn.</p>
<p>Cách tiếp cận trò chơi hóa cũng giúp gắn kết hai nửa của nền tảng. Phía gọi vốn cộng đồng thưởng cho nhà đầu tư khi triển lãm làm ăn có lãi. Phía trò đoán giá thì thưởng cho bất kỳ ai đoán giá giỏi, dù có bỏ tiền đầu tư hay không. Cả hai đều xuất phát từ cùng một ý tưởng: biến phán đoán tập thể của đám đông thành một tín hiệu có thể giao dịch. Một bên giao dịch bằng vốn, bên kia giao dịch bằng sự chú ý. Người chơi nào thật sự có nghề thì về sau có thể “lên lớp” sang phía nhà đầu tư, mang theo một thành tích đoán giá kiểm chứng được. Đó là một bộ lọc tốt hơn nhiều so với kiểu “quen đúng ông chủ phòng tranh”.</p>

<h2 class="relative group">Những gì tôi rút ra
    <div id="những-gì-tôi-rút-ra" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-g%c3%ac-t%c3%b4i-r%c3%bat-ra" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ước tính trước phiên thật sự rất tốt. Nhà đấu giá hay bị chê là định giá thiếu minh bạch, nhưng chỉ riêng các ước tính của họ đã giải thích được gần 90% độ biến thiên của giá. Chuyên gia làm cách nào thì làm, họ làm tốt. Vậy mà xét về mặt thống kê, bản thân tác phẩm lại gần như không quan trọng, một sự thật khá khó chịu nếu bạn yêu nghệ thuật vì cái đẹp. Thị trường không định giá thứ bạn nhìn thấy. Nó định giá dựa trên số tiền người khác đã trả cho những tác phẩm na ná.</p>
<p>Về phía mô hình hóa, có hai thói quen tỏ ra rất đáng giá. Chia dữ liệu theo thời gian là cách trung thực duy nhất để đánh giá mô hình dự đoán giá. Chia ngẫu nhiên thì cho bạn những con số R² đẹp như mơ, nhưng đưa vào môi trường production là vỡ. Và cây tăng cường gradient vẫn là công cụ phù hợp cho bài toán này: XGBoost đánh bại mọi mô hình cơ sở (baseline) dùng mạng nơ-ron trong bài báo, và với dữ liệu dạng bảng ở quy mô này, lại có sẵn chừng ấy đặc trưng được thiết kế công phu, thì chẳng có lý do gì phải tìm đến transformer.</p>
<p>Về mặt kinh doanh, cơ hội thú vị nằm ở các thị trường mới nổi, nơi định giá của chuyên gia còn mỏng nhất và dự đoán bằng thuật toán có nhiều khoảng trống nhất để lấp. Đám đông cũng là một nguồn dữ liệu đang bị bỏ phí. Đặc trưng mạnh nhất trong mô hình của Lee và cộng sự là mức ước tính trước phiên của nhà đấu giá. Nếu một đám đông đã được hiệu chỉnh tạo ra được tín hiệu tương đương cho phần đuôi dài các nghệ sĩ, thì đó là một cơ hội thật, vừa làm đầu vào cho mô hình, vừa có thể đứng riêng thành một sản phẩm.</p>
<p>Rốt cuộc, hai dự án bổ trợ cho nhau nhiều hơn tôi dự tính. Bản tái hiện bài báo cho tôi biết điều gì dự đoán được giá. Phần làm nền tảng cho tôi biết cần cấu trúc dữ liệu thế nào để đem hiểu biết đó ra dùng. Ý tưởng trò chơi hóa nối hai thứ lại: đồ thị tri thức cung cấp tác phẩm và lịch sử giao dịch, trò đoán giá tạo ra một đặc trưng ước tính từ đám đông, rồi mô hình dùng đặc trưng đó cùng các tín hiệu xã hội để dự đoán giá cho những nghệ sĩ mà nếu thiếu nó thì mô hình hoàn toàn không nhìn thấy.</p>
<p>Chưa phần nào xong cả. Bản tái hiện bài báo mới là một bản mẫu chạy được kèm bản báo cáo, nền tảng là một bản chạy thử với dữ liệu mẫu, còn trò đoán giá thì mới chỉ nằm trên mấy ghi chú thiết kế bạn vừa đọc. Nhưng ý tưởng xuyên suốt đủ nhất quán để tôi tin rằng hướng đi đúng đã khá rõ: định giá nghệ sĩ thay vì tác phẩm, tổng hợp ý kiến đám đông thay vì chỉ trông vào chuyên gia, và coi giá trị của tác phẩm là thứ cả thị trường cùng tạo ra, chứ không phải thứ do chuyên gia định sẵn rồi ban xuống.</p>
<p>Nếu có thời gian để xây, đó sẽ là phiên bản tôi làm tiếp theo.</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>
