<?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>GitHub Actions · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/github-actions/</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/github-actions/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ôi trường xem trước cho từng nhánh: hết tranh giành staging trên Fly.io và Cloudflare</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>Cách tôi cho mỗi PR một môi trường xem trước (preview environment) trên Fly.io và Cloudflare: một nhãn, mã băm từ tên nhánh chia Postgres, Redis, bucket dùng chung, đóng PR là dỡ bỏ.</description>
      <content:encoded><![CDATA[<p>Để mỗi nhánh có một môi trường xem trước (preview environment) riêng trên Fly.io, tôi không nhân bản cả hạ tầng mà chia nhỏ phần hạ tầng dùng chung. Gắn nhãn <code>env</code> lên PR là một workflow GitHub Actions chạy một script; script rút ra một cái tên từ tên nhánh (một slug cộng một mã băm ngắn) rồi đóng dấu nó lên một ứng dụng Fly, một cơ sở dữ liệu Postgres nhân bản từ mẫu, một tiền tố khóa Redis, một tiền tố trong bucket, một bộ bí mật được sinh mới và một tên miền dưới wildcard của Cloudflare. Mỗi lần push là môi trường được cập nhật, đóng hoặc merge PR là nó bị dỡ bỏ, còn thứ gì sót lại thì một tác vụ dọn dẹp chạy hằng đêm sẽ lo. Thế là không ai phải hỏi &ldquo;có ai đang dùng staging không?&rdquo; nữa.</p>
<p>Hãy hình dung một đội nhỏ làm chung một ứng dụng. Hai lập trình viên, hay năm, cũng được. Không phải các chuyên gia mỗi người một mảng, mà là những người mỗi người trên nhánh riêng của mình, ôm trọn một tính năng từ migration cơ sở dữ liệu, qua API, đến màn hình trên ứng dụng di động. Đó là cách vận hành hay cho một đội, và kể từ khi có lập trình viên thứ hai, nó có một lỗi lặp đi lặp lại: tin nhắn &ldquo;Có ai đang dùng staging không?&rdquo;</p>
<p>Đằng sau tin nhắn ấy là một lập trình viên đã làm xong tính năng, muốn tester hoặc designer xem, mà không được, vì staging đang chạy nhánh của người thứ hai, còn dev đang chạy nhánh của người thứ ba, cái nhánh đã làm hỏng đăng nhập hai ngày trước và từ đó chưa được triển khai lại. Vài người, hai môi trường dùng chung, và một luồng chat làm việc xếp lịch.</p>
<p>Đó không phải vấn đề con người. Đó là vấn đề topology. Mỗi lập trình viên đều coi môi trường là nơi nhánh của mình trở thành thật, nên môi trường đang làm thay việc mà merge đáng lẽ phải làm, mỗi lần một nhánh, và những người còn lại xếp hàng chờ.</p>
<p>Tôi đã mô tả hình dạng production của stack mình trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a>: Django chạy trên những máy rẻ nhất của Fly.io, Postgres đứng sau một bộ gộp kết nối (connection pooler), Redis cho cache và tầng Channels, một ứng dụng WebSocket riêng, một worker chạy việc nền, một bucket tương thích S3 cho tệp, một dịch vụ xác thực bên ngoài, Cloudflare đứng trước, và một ứng dụng di động nói chuyện với tất cả. Bài này kể tôi đã biến stack ấy thành thứ mà với bất kỳ nhánh nào, lập trình viên chỉ cần gắn một nhãn lên PR là vài phút sau có ngay bản sao công khai, có HTTPS, của toàn bộ hệ thống, không phải nhờ ai khác và không ai phải cấp phát (provision) cho, mà cũng không cần cụm Postgres thứ hai, Redis thứ hai hay bucket thứ hai. Hạ tầng vẫn dùng chung. Điều thay đổi là mọi tài nguyên dùng chung học cách bị cắt lát theo một cái tên, và cái tên ấy đến từ nhánh. Không có gì trong đó giả định quy mô đội: thiết kế này giống hệt nhau cho hai lập trình viên hay hai mươi, thứ duy nhất tăng lên là danh sách ứng dụng, và việc đó đã có bộ dọn dẹp (sweeper) lo. Tôi dựng nó, chạy ba nhánh cùng lúc, làm hỏng ở hai chỗ không lường trước, rồi dỡ đi, tất cả trong một buổi chiều; các kết quả bên dưới lấy từ lần chạy đó.</p>

<h2 class="relative group">Vì sao một môi trường dùng chung không chịu nổi quá một lập trình viên
    <div id="vì-sao-một-môi-trường-dùng-chung-không-chịu-nổi-quá-một-lập-trình-viê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-m%e1%bb%99t-m%c3%b4i-tr%c6%b0%e1%bb%9dng-d%c3%b9ng-chung-kh%c3%b4ng-ch%e1%bb%8bu-n%e1%bb%95i-qu%c3%a1-m%e1%bb%99t-l%e1%ba%adp-tr%c3%acnh-vi%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một môi trường dev dùng chung có sẵn một giả định: chỉ có một &ldquo;phiên bản code hiện tại&rdquo; mà ai cũng muốn xem. Điều đó đúng với một lập trình viên, hoặc với một nhóm cùng bước trên một dòng thay đổi duy nhất. Nó hết đúng ngay khi người thứ hai bắt đầu một tính năng theo lịch khác, và với kiểu sở hữu trọn gói thì tính năng nào cũng thế. Từ đó, mọi thứ môi trường nắm giữ đều thành thứ bị tranh:</p>
<ul>
<li><strong>Code đã triển khai.</strong> Ai triển khai sau cùng thì thắng. Công việc của những người còn lại vô hình cho đến khi họ triển khai lại, và việc đó xóa của người trước.</li>
<li><strong>Schema cơ sở dữ liệu.</strong> Đây là chỗ cắn đau nhất khi mỗi người sở hữu mô hình dữ liệu của tính năng mình. Nhánh thứ nhất thêm một cột. Nhánh thứ hai, triển khai một giờ sau, không biết về cột đó và ORM bắt đầu lỗi khi insert. Hoặc cả hai nhánh cùng có migration <code>0042_*</code>, cơ sở dữ liệu dùng chung rơi vào trạng thái không khớp lịch sử migration của ai, rồi ai đó reset nó, xóa luôn dữ liệu test mà người thứ ba mất cả buổi chiều chuẩn bị.</li>
<li><strong>Cache.</strong> Thay đổi serializer của một nhánh bị cache dưới đúng những khóa Redis mà code nhánh khác đọc. Các báo cáo lỗi sinh ra từ đó khó hiểu và ngốn mỗi cái một buổi sáng.</li>
<li><strong>Tích hợp bên thứ ba.</strong> Một OAuth callback, một URL webhook, một chứng chỉ push. Nhánh của ai đang được triển khai thì nhánh ấy nhận sự kiện.</li>
<li><strong>Tester.</strong> Họ chỉ test được thứ đã triển khai, nên họ test từng tính năng theo thứ tự môi trường tình cờ bị chiếm, chứ không theo thứ tự tính năng sẵn sàng.</li>
</ul>
<p>Nhóm ứng phó bằng hệ thống đặt chỗ: một luồng chat, một tin nhắn ghim, một bot <code>/claim staging</code>. Đó là một cái khóa, và khóa đặt lên nơi duy nhất công việc của bạn có thể được nhìn thấy là khóa đặt lên tốc độ giao hàng. Với hai người thì chỉ hơi khó chịu. Với ba người thì tuần thuận lợi còn chịu được. Thêm người thứ tư, hoặc có một tính năng giữ staging ba ngày để qua lại với designer, là hết chịu nổi.</p>
<p>Cách sửa đầu tiên người ta với tới là môi trường dùng chung thứ ba, &ldquo;dev2&rdquo; hay &ldquo;uat&rdquo;. Nó mua được vài tuần. Hàng chờ chỉ có thêm một làn.</p>
<p>Cách sửa thật sự là nhận ra dev và staging chưa bao giờ là môi trường. Chúng là <em>trạng thái hiện tại của nhánh ai đó, kèm một URL</em>. Nếu chúng là thế, thì số lượng đúng là số nhánh mà cả đội đang quan tâm lúc này, và vòng đời đúng là vòng đời của nhánh.</p>

<h2 class="relative group">Mô hình: nhánh chính là môi trường
    <div id="mô-hình-nhánh-chính-là-môi-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="#m%c3%b4-h%c3%acnh-nh%c3%a1nh-ch%c3%adnh-l%c3%a0-m%c3%b4i-tr%c6%b0%e1%bb%9dng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là trạng thái đích.</p>
<table>
	<thead>
			<tr>
					<th>Môi trường</th>
					<th>Bao nhiêu</th>
					<th>Ai tạo</th>
					<th>Ai hủy</th>
					<th>Sống bao lâu</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Môi trường nhánh (trước là &ldquo;dev&rdquo;)</td>
					<td>Mỗi nhánh đang hoạt động một cái</td>
					<td>Nhãn <code>env</code> trên PR của nhánh, rồi mỗi lần push sau đó; với một spike chưa có PR thì tự chạy cùng script <code>./scripts/env up</code> ấy bằng tay</td>
					<td>Xóa nhánh, cộng với trình quét chạy hằng đêm</td>
					<td>Vài ngày</td>
			</tr>
			<tr>
					<td>Staging</td>
					<td>Mỗi ứng viên phát hành một cái</td>
					<td>CI khi cắt tag từ <code>main</code></td>
					<td>CI khi cùng image đó được đưa lên prod</td>
					<td>Vài giờ</td>
			</tr>
			<tr>
					<td>Prod</td>
					<td>Một</td>
					<td>Bạn, một lần</td>
					<td>Không ai</td>
					<td>Mãi mãi</td>
			</tr>
	</tbody>
</table>
<p>Hãy nhìn lại trong bảng xem ai tạo và ai hủy: mục nào cũng là một sự kiện git hoặc một cái đồng hồ. Git chính là mặt phẳng điều khiển (control plane). Tập hợp môi trường cần tồn tại được suy thẳng từ tập hợp nhánh đang tồn tại, và việc duy nhất của tự động hóa là giữ cho thực tế khớp với nó: nhãn trên PR thì tạo, mỗi lần push sau đó thì cập nhật, đóng hay merge PR thì hủy, và một job chạy hằng đêm dọn nốt những gì ba bước trước bỏ sót. Cũng không phải nhánh nào cũng có môi trường: nhãn chính là cái cổng, một cú bấm của lập trình viên, nên một lần push để sao lưu hay bản cập nhật thư viện của bot không tốn gì. Không ai cấp phát môi trường cả. Không phải kỹ sư DevOps nhận ticket, không phải bot bạn gọi trong kênh chat, không phải AI agent bạn phải nhắc (prompt) mỗi lần. Nhánh đầu tiên của lập trình viên thứ mười có môi trường theo đúng cách nhánh của người thứ nhất đã có: vì PR của nó được gắn nhãn. Nếu vẫn phải nhờ đến một người, thì hệ thống chưa mở rộng được; hàng đợi chỉ đổi chỗ thôi.</p>
<p>Và quy tắc khiến nó vận hành: <strong>mọi tài nguyên mà một môi trường chạm vào đều được đặt tên theo nhánh, một cách tất định.</strong> Không theo lập trình viên, không theo ticket, không theo số PR vốn không tồn tại cho đến khi ai đó mở. Theo nhánh, vì nhánh là thứ lập trình viên thật sự làm việc cùng, và vì tên tất định nghĩa là lần chạy <code>./scripts/env up</code> thứ hai trên cùng nhánh sẽ tìm thấy môi trường đã tạo thay vì tạo thêm cái mới.</p>
<p>Tên gồm một slug cho người đọc và một mã băm ngắn để đảm bảo duy nhất:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code> là môi trường. Chuỗi đó trở thành tên ứng dụng Fly, tên cơ sở dữ liệu Postgres (gạch ngang đổi thành gạch dưới), tiền tố khóa Redis, tiền tố đối tượng trong bucket, environment trên công cụ theo dõi lỗi, và nhãn ngoài cùng bên trái của tên miền. Ai biết tên nhánh đều tính ra được. Không phải tra cứu ở đâu.</p>
<p>Câu cuối ấy chính là thiết kế. Không có sổ đăng ký môi trường và không có người điều phối. <code>env up</code>, <code>env down</code>, Cloudflare Worker định tuyến tên miền, và trình quét hằng đêm, mỗi thứ tự tính lại tên từ nhánh một cách độc lập và khớp nhau nhờ cấu trúc. Riêng Cloudflare không điều phối gì cả: Worker là một hàm thuần từ tên miền sang ứng dụng Fly, và nó không bao giờ biết một môi trường vừa được tạo hay hủy. Ứng dụng tồn tại thì yêu cầu đến nơi; không thì Fly trả lời bằng lỗi của chính nó.</p>
<p>Ba điều rút ra từ đây.</p>
<p>Thứ nhất, &ldquo;ai đang dùng staging&rdquo; không còn là câu hỏi. Có <code>feature-payments-retr-3f9a1c-dev.example.com</code> và có <code>fix-login-redirect-8b21e0-dev.example.com</code>, mỗi cái thuộc về người đang ở nhánh đó.</p>
<p>Thứ hai, staging trở thành buổi tổng duyệt trung thực của prod thay vì bãi rác. Nó được dựng từ chính image sẽ lên prod, trên cơ sở dữ liệu nhân bản vài phút trước từ snapshot có hình dạng prod. Chạy được ở đó thì biến số duy nhất còn lại trên prod là dữ liệu của prod.</p>
<p>Thứ ba, mỗi lập trình viên có quyền lực mà không cần root trên bất cứ thứ gì dùng chung. Bán kính nổ của một môi trường nhánh là chính môi trường nhánh đó. Người thứ tư vào ngày đầu có thể triển khai gì tùy thích lên nhánh mình, và kết cục tệ nhất là nhánh của họ hỏng.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Bảng điều khiển Fly.io của tổ chức: ba ứng dụng của các nhánh tính năng (hai cái đã suspended) bên cạnh các ứng dụng production và Postgres, Redis preview dùng chung"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>Danh sách ứng dụng của tổ chức khi ba nhánh đang chạy. Hai trong ba đã suspended chỉ một phút sau khi triển khai; Postgres và Redis preview dùng chung nằm bên cạnh. Các ứng dụng production là những dòng bên dưới.</figcaption>
</figure>


<h2 class="relative group">Dùng chung về vật lý, tách biệt về logic
    <div id="dùng-chung-về-vật-lý-tách-biệt-về-logic" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-chung-v%e1%bb%81-v%e1%ba%adt-l%c3%bd-t%c3%a1ch-bi%e1%bb%87t-v%e1%bb%81-logic" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phiên bản cám dỗ của &ldquo;mỗi nhánh một môi trường&rdquo; là sao chép toàn bộ: mỗi cái một cụm Postgres, một Redis, một bucket. Sạch sẽ, và là một cuộc đổi chác sai. Một cụm Fly Postgres là một máy cộng một volume cộng một lần khôi phục; một Redis là thêm một máy nữa; bạn sẽ chờ vài phút cho mỗi môi trường và trả tiền cho hàng chục cơ sở dữ liệu nằm không. Trình quét sẽ phải dọn bốn loại thứ thay vì một.</p>
<p>Thay vào đó, mỗi dịch vụ dùng chung được cấp phát một lần cho cả tổ chức preview, cỡ như một prod nhỏ, và mỗi môi trường nhận một lát cắt khóa theo <code>$ENV</code>. Đây là bảng tôi muốn dán lên tường:</p>
<table>
	<thead>
			<tr>
					<th>Tài nguyên</th>
					<th>Thứ dùng chung</th>
					<th>Thứ riêng từng môi trường</th>
					<th>Được đảm bảo bởi</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Tính toán</td>
					<td>Tổ chức Fly <code>myapp-preview</code></td>
					<td>Một ứng dụng Fly <code>myapp-$ENV</code>, một máy</td>
					<td>Fly: ứng dụng là đơn vị cô lập</td>
			</tr>
			<tr>
					<td>Bí mật</td>
					<td>Kho bí mật theo ứng dụng của Fly</td>
					<td>Bí mật của riêng ứng dụng; <code>SECRET_KEY</code>, <code>ENCRYPTION_KEY</code> và <code>ENV_TOKEN</code> được <em>sinh mới</em> cho từng môi trường, không sao chép</td>
					<td>Fly: bí mật thuộc về ứng dụng, máy chỉ đọc được của mình</td>
			</tr>
			<tr>
					<td>Postgres</td>
					<td>Một cụm Fly Postgres <code>myapp-preview-pg</code></td>
					<td>Một cơ sở dữ liệu <code>env_feature_payments_retr_3f9a1c</code>, nhân bản từ <code>seed_template</code></td>
					<td>Postgres: cơ sở dữ liệu là ranh giới cứng, không truy vấn chéo được</td>
			</tr>
			<tr>
					<td>Redis</td>
					<td>Một Redis dùng chung</td>
					<td>Tiền tố khóa <code>feature-payments-retr-3f9a1c:</code> trên cache và tầng Channels</td>
					<td><code>KEY_PREFIX</code> của cache Django, <code>prefix</code> của tầng channel</td>
			</tr>
			<tr>
					<td>Tệp</td>
					<td>Một bucket tương thích S3 <code>myapp-preview</code></td>
					<td>Tiền tố đối tượng <code>feature-payments-retr-3f9a1c/</code></td>
					<td><code>location</code> của backend lưu trữ; URL ký sẵn vốn đã giới hạn theo khóa</td>
			</tr>
			<tr>
					<td>Tên miền</td>
					<td>Bản ghi <code>*.example.com</code> đã proxy sẵn có của zone</td>
					<td>Nhãn ngoài cùng bên trái <code>&lt;env&gt;-dev</code></td>
					<td>Một route Cloudflare Worker trên <code>*-dev.example.com/*</code></td>
			</tr>
			<tr>
					<td>Xác thực</td>
					<td>Một dự án dev trên dịch vụ xác thực</td>
					<td>Không có gì; danh tính người dùng cố ý dùng chung (xem dưới)</td>
					<td>Tên miền được ủy quyền <code>example.com</code></td>
			</tr>
			<tr>
					<td>Lỗi và log</td>
					<td>Công cụ theo dõi lỗi, log của Fly</td>
					<td>Thẻ <code>environment=$ENV</code> trên mọi thứ</td>
					<td>Cấu hình</td>
			</tr>
	</tbody>
</table>
<p>Hai dòng trong đó đáng nói thêm một câu.</p>
<p><strong>Postgres: cơ sở dữ liệu, không phải schema.</strong> Cách khác để cắt lát một cụm là mỗi môi trường một schema, đặt <code>search_path</code> theo từng kết nối. Django làm được nếu bạn nhét <code>-c search_path=...</code> qua <code>options</code> của kết nối, nhưng bộ gộp kết nối ở chế độ transaction bỏ qua tham số khởi động trừ khi bạn bảo nó đừng, và dù có thế thì mọi kết nối trong pool rốt cuộc cũng phải thuộc về một môi trường. Đó là nguồn của những lỗi kiểu &ldquo;sao truy vấn của tôi lại chạm bảng khác&rdquo;, thứ mà mỗi môi trường một cơ sở dữ liệu đơn giản là không có. Cơ sở dữ liệu là ranh giới cứng, <code>CREATE DATABASE ... TEMPLATE</code> tạo một cái trong vài giây, và <code>DROP DATABASE</code> xóa nó không để lại gì. Các môi trường nhánh kết nối thẳng vào Postgres qua cổng 5433 thay vì qua bộ gộp kết nối, giống như worker chạy nền làm trên prod; pooler chỉ đáng giá ở mức đồng thời của production, thứ mà một môi trường nhánh không bao giờ thấy.</p>
<p><strong>Bí mật: sinh mới, không dùng chung.</strong> Sẽ dễ nếu có một bộ bí mật dev rồi chép vào mọi môi trường. Đừng. Script <code>env up</code> sinh <code>SECRET_KEY</code> (ký phiên), <code>ENCRYPTION_KEY</code> (mọi thứ mã hóa khi lưu) và <code>ENV_TOKEN</code> (bearer token mà ứng dụng di động xuất trình, nói thêm bên dưới) mới cho mỗi môi trường và chỉ lưu trong kho bí mật của ứng dụng Fly đó. Nghĩa là cookie phiên của nhánh này vô giá trị trên nhánh khác, một token preview bị lộ chỉ mở được một preview, và lập trình viên không bao giờ thấy giá trị trừ khi cố tình tìm. Nếu bạn giữ bí mật trong một trình quản lý bí mật (secrets manager) riêng thay vì chỉ trong Fly, quy tắc vẫn vậy: đường dẫn là <code>myapp/preview/$ENV/</code>, và trình quét xóa đường dẫn cùng với môi trường. Những bí mật <em>thật sự</em> dùng chung giữa các môi trường chỉ là thông tin đăng nhập của chính hạ tầng dùng chung (URL quản trị Postgres, URL Redis, khóa truy cập bucket), và chúng nằm trong CI, không nằm trong môi trường nào.</p>
<p>Dòng Redis là dòng dễ nghi ngờ nhất, nên đây là bài kiểm tra. Đặt một khóa cache từ bên trong một môi trường, đọc từ môi trường kia, rồi liệt kê mọi khóa trong Redis dùng chung:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-tagl-fa8832 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; cache.set(&#39;who-am-i&#39;, &#39;tagline-branch&#39;, 600)\&#34;&#34;
</span></span><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-webs-dcb2e7 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; print(cache.get(&#39;who-am-i&#39;))\&#34;&#34;
</span></span><span class="line"><span class="cl">None
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ # every key in the shared Redis
</span></span><span class="line"><span class="cl">  feature-profile-tagl-fa8832:1:who-am-i
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
<p>Cùng một Redis, cùng tên khóa, môi trường kia không thấy gì.</p>
<p>Phần còn lại của bài là script đóng dấu <code>$ENV</code> lên tám dòng ấy, và các quy tắc giữ cho nó trung thực.</p>

<h2 class="relative group"><code>./scripts/env up</code>
    <div id="scriptsenv-up" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scriptsenv-up" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là toàn bộ ý tưởng, dưới dạng một script lập trình viên chạy từ nhánh của mình. CI chạy cùng script; không có đường riêng cho CI để lệch đi. Trên laptop chỉ cần <code>fly</code> và <code>git</code>, không gì khác: SQL đi tới ứng dụng Postgres qua <code>fly ssh</code>, còn khóa Redis và đối tượng trong bucket của môi trường được chính máy của môi trường xóa trước khi nó bị hủy, nên laptop không giữ thông tin đăng nhập Redis hay bucket dùng chung ngoài một tệp env đã gitignore. Dưới đây là bản rút gọn, chỉ giữ những dòng quan trọng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/env up|down|name|url: one environment per branch on shared preview infrastructure</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">PG_APP</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_PG_APP</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview-pg</span><span class="si">}</span><span class="p">;</span> <span class="nv">DOMAIN</span><span class="o">=</span><span class="si">${</span><span class="nv">BASE_DOMAIN</span><span class="k">:-</span><span class="nv">example</span><span class="p">.com</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="si">${</span><span class="nv">BRANCH</span><span class="k">:-$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;main has staging, not a branch environment&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span><span class="p">;</span> <span class="nv">slug</span><span class="o">=</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">ENV_OVERRIDE</span><span class="k">:-</span><span class="si">${</span><span class="nv">slug</span><span class="si">}</span><span class="p">-</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> shasum -a <span class="m">256</span> <span class="p">|</span> cut -c1-6<span class="k">)</span><span class="si">}</span>   <span class="c1"># the sweeper passes the name</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP</span><span class="o">=</span><span class="s2">&#34;myapp-</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">DB</span><span class="o">=</span><span class="s2">&#34;env_</span><span class="si">${</span><span class="nv">ENV</span><span class="p">//-/_</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">-dev.</span><span class="si">${</span><span class="nv">DOMAIN</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pg<span class="o">()</span> <span class="o">{</span> fly ssh console -a <span class="s2">&#34;</span><span class="nv">$PG_APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;psql postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@localhost:5433/postgres -At -c \&#34;</span><span class="nv">$1</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">exists<span class="o">()</span> <span class="o">{</span> fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -q <span class="s2">&#34;\&#34;Name\&#34;: *\&#34;</span><span class="nv">$APP</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">up<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;== </span><span class="nv">$branch</span><span class="s2"> -&gt; </span><span class="nv">$ENV</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  exists <span class="o">||</span> fly apps create <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span>                         <span class="c1"># idempotent</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>pg <span class="s2">&#34;SELECT 1 FROM pg_database WHERE datname=&#39;</span><span class="nv">$DB</span><span class="s2">&#39;&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">||</span> pg <span class="s2">&#34;CREATE DATABASE </span><span class="nv">$DB</span><span class="s2"> TEMPLATE seed_template&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">have</span><span class="o">=</span><span class="k">$(</span>fly secrets list -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4 <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  gen<span class="o">()</span> <span class="o">{</span> grep -qx <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$have</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">=</span><span class="k">$(</span>openssl rand -hex 32<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>   <span class="c1"># generated once, never rotated by a later `up`</span>
</span></span><span class="line"><span class="cl">  <span class="o">{</span> gen SECRET_KEY<span class="p">;</span> gen ENCRYPTION_KEY<span class="p">;</span> gen ENV_TOKEN
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;ENV_NAME=</span><span class="nv">$ENV</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;ALLOWED_HOSTS=</span><span class="nv">$HOST</span><span class="s2">,</span><span class="nv">$APP</span><span class="s2">.fly.dev&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;DATABASE_URL=postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@</span><span class="si">${</span><span class="nv">PG_APP</span><span class="si">}</span><span class="s2">.flycast:5432/</span><span class="si">${</span><span class="nv">DB</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... plus the shared Redis URL and bucket credentials</span>
</span></span><span class="line"><span class="cl">  <span class="o">}</span> <span class="p">|</span> fly secrets import -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --stage
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  fly deploy -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --config fly.preview.toml --remote-only --ha<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --build-arg <span class="nv">GIT_SHA</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">&#34;</span> --build-arg <span class="nv">GIT_BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">down<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^myapp-<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;refusing to destroy </span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> exists<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    fly machine start -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>                 <span class="c1"># it may be suspended</span>
</span></span><span class="line"><span class="cl">    fly ssh console -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;python manage.py teardown_env&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;warning: cleanup failed; the sweeper will catch it&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    fly apps destroy <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --yes
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  pg <span class="s2">&#34;DROP DATABASE IF EXISTS </span><span class="nv">$DB</span><span class="s2"> WITH (FORCE)&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in up<span class="o">)</span> up <span class="p">;;</span> down<span class="o">)</span> down <span class="p">;;</span> name<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ENV</span><span class="s2">&#34;</span> <span class="p">;;</span> url<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span></span></span></code></pre></div></div>
<p>Trong bản demo tôi chạy nó từ laptop thay vì push, vì ba nhánh này là đồ dùng một lần tôi không muốn để lại trong lịch sử kho, và tôi muốn nhìn output chạy ra từng dòng. Khi cả đội dùng thì không ai gõ lệnh này cả: workflow gắn nhãn ở phần dưới chạy đúng script này, và những gì bạn thấy tiếp theo là thứ sẽ nằm trong log của Actions. Đây là những gì nó in ra lần đầu, trên chính nhánh chứa bộ công 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">$ ./scripts/env up
</span></span><span class="line"><span class="cl">== feature/branch-environments -&gt; feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">New app created: myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">CREATE DATABASE
</span></span><span class="line"><span class="cl">Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
</span></span><span class="line"><span class="cl">==&gt; Building image
</span></span><span class="line"><span class="cl">image size: 139 MB
</span></span><span class="line"><span class="cl">✔ Machine 1a2b3c4d5e6f78 [app] update finished: success
</span></span><span class="line"><span class="cl">https://feature-branch-envir-c65cb4-dev.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s https://feature-branch-envir-c65cb4-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">{&#34;environment&#34;: &#34;feature-branch-envir-c65cb4&#34;, &#34;branch&#34;: &#34;feature/branch-environments&#34;, &#34;commit&#34;: &#34;a1b2c3d4&#34;, &#34;region&#34;: &#34;syd&#34;}</span></span></code></pre></div></div>
<p>Khoảng hai phút rưỡi từ lệnh đến URL, phần lớn là build image. Lần <code>up</code> thứ hai trên cùng nhánh, sau một commit nữa, không in &ldquo;New app created&rdquo; hay &ldquo;CREATE DATABASE&rdquo;, stage đúng các tên bí mật cũ mà không sinh lại những cái được sinh, rồi build lại: cùng URL, commit mới.</p>
<p>Vài lựa chọn trong đó xứng đáng mỗi cái một câu.</p>
<p><strong><code>fly.preview.toml</code> không phải <code>fly.toml</code>.</strong> Prod chạy API, máy chủ WebSocket và worker chạy nền như các ứng dụng Fly và nhóm tiến trình riêng để mỗi thứ mở rộng theo chỉ số của mình, đó là toàn bộ lập luận của bài về stack. Một môi trường nhánh không cần mở rộng; nó cần là một máy. Nên cấu hình preview chỉ có một tiến trình: script khởi chạy (entrypoint) của container có thêm một chế độ preview, chạy migration rồi khởi động <em>một</em> máy chủ ASGI có bật WebSocket. Channels vốn đã định tuyến cả lưu lượng HTTP lẫn WebSocket trong cùng một ứng dụng ASGI, nên một tiến trình phục vụ cả ứng dụng. Ở chế độ đó các view đồng bộ chạy kiểu thread-sensitive, gần như mỗi lần một yêu cầu, chính là cuộc đổi chác tôi đã rời bỏ trên prod và lại đúng ở đây, vì không ai test một nhánh mà nhận ra.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># fly.preview.toml (the app name comes from `fly deploy -a`)</span>
</span></span><span class="line"><span class="cl"><span class="nx">app</span> <span class="p">=</span> <span class="s1">&#39;placeholder&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">primary_region</span> <span class="p">=</span> <span class="s1">&#39;syd&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">processes</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">app</span> <span class="p">=</span> <span class="s2">&#34;/app/entrypoint.sh preview&#34;</span>   <span class="c"># migrate, then one ASGI server for HTTP and WebSockets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">env</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DJANGO_SETTINGS_MODULE</span> <span class="p">=</span> <span class="s1">&#39;myapp.settings&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DEBUG</span> <span class="p">=</span> <span class="s1">&#39;False&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">internal_port</span> <span class="p">=</span> <span class="mi">8000</span>
</span></span><span class="line"><span class="cl">  <span class="nx">force_https</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">[[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;/healthz&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">headers</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="nx">Host</span> <span class="p">=</span> <span class="s1">&#39;127.0.0.1&#39;</span>               <span class="c"># the app&#39;s own hostname isn&#39;t known to a static file</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">size</span> <span class="p">=</span> <span class="s1">&#39;shared-cpu-1x&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;1gb&#39;</span></span></span></code></pre></div></div>
<p><strong><code>auto_stop_machines = &quot;suspend&quot;</code>, <code>min_machines_running = 0</code>.</strong> Một môi trường nhánh không ai mở từ sau bữa trưa nên tốn số không. Suspend thay vì stop cho ra khoảng 3 giây khôi phục mà tôi đạt được <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >sau khi đuổi theo lỗi 504</a> thay vì 30 giây khởi động lạnh, và ở đây ngay cả 30 giây cũng chấp nhận được. Hai trong ba môi trường demo đã <code>suspended</code> trong <code>fly apps list</code> chỉ một phút sau khi triển khai xong.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/apps/myapp-feature-profile-tagl-fa8832</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d2f15e465075b4eb.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Tổng quan Fly.io của một ứng dụng nhánh: một máy shared-cpu-1x ở SYD, một nhóm tiến trình, tên miền *.fly.dev riêng"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_381dc63e1f9d24c0.webp"></span></div>
  <figcaption>Một môi trường nhánh dưới mắt Fly: một máy, một nhóm tiến trình, một vùng. Không có gì ở đây đáng để mở rộng.</figcaption>
</figure>

<p><strong><code>DEBUG = False</code>, cùng module settings với prod.</strong> Môi trường nhánh là một URL công khai. Nó nên hành xử như prod ở mọi mặt quan trọng cho việc test (HTTPS thật, cookie thật, cùng health check) và khác prod ở mọi mặt có thể làm ai đó bị hại. Thay vì một tệp settings riêng cho preview, sự khác biệt được mang bởi các bí mật mà <code>env up</code> đặt: khóa thanh toán sandbox, không có thông tin quản trị của dịch vụ xác thực, và <code>ENV_NAME</code> bật mọi tiền tố bên dưới.</p>
<p><strong>Một cấu hình cắt lát mọi thứ.</strong> <code>ENV_NAME</code> là núm vặn mới duy nhất trong settings, và được áp ở bốn chỗ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ENV_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>Bỏ sót chỗ channels thì máy chủ WebSocket của hai nhánh dùng chung tên nhóm, và tin nhắn gửi ở môi trường này hiện ra ở môi trường kia. Đó là loại lỗi tốn cả ngày.</p>
<p>Còn chỗ thứ năm tôi chỉ tìm thấy khi đọc Redis dùng chung sau đó. Cái khóa lúc khởi động từ bài về 504, thứ cho phép mỗi image chỉ một máy chạy migration trong khi các máy khác chờ, vốn đặt tên khóa theo tham chiếu image. Hai môi trường nhánh dựng từ cùng một commit có cùng image. Cái thứ hai sẽ thấy dấu &ldquo;done&rdquo; của cái thứ nhất và bỏ qua migration của chính nó trên cơ sở dữ liệu chưa migration của chính nó. Giờ tên môi trường là một phần của tên khóa, và khóa nằm dưới tiền tố. Đó là loại chuyện mà thiết kế hạ tầng dùng chung cứ sinh ra mãi: mọi thứ ngầm là &ldquo;theo lần triển khai&rdquo; phải được làm rõ thành theo môi trường, và bạn không biết cái gì là ngầm cho đến khi nhìn vào.</p>
<p><strong>Cơ sở dữ liệu được nhân bản, không migration từ rỗng.</strong> Đó là mục tiếp theo.</p>

<h2 class="relative group">Cơ sở dữ liệu: một mẫu, nhân bản trong vài giây
    <div id="cơ-sở-dữ-liệu-một-mẫu-nhân-bản-trong-vài-giâ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="#c%c6%a1-s%e1%bb%9f-d%e1%bb%af-li%e1%bb%87u-m%e1%bb%99t-m%e1%ba%abu-nh%c3%a2n-b%e1%ba%a3n-trong-v%c3%a0i-gi%c3%a2y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cơ sở dữ liệu của một môi trường nhánh có thể là một trong ba thứ. Dùng chung với mọi người, thứ đưa chúng ta đến đây. Rỗng, migration từ số không rồi đổ fixture: tất định, nhưng fixture chưa bao giờ tốt bằng dữ liệu có hình dạng thật và luôn cũ mười tám tháng. Hoặc một bản nhân bản từ snapshot có hình dạng prod: số dòng thực tế, những giá trị null thực tế trong các cột mà fixture cứ tưởng luôn có dữ liệu, cái migration tức thì trên bảng rỗng nhưng mất bốn mươi phút trên bảng thật sẽ lộ mặt.</p>
<p>Chọn cách thứ ba, giữ cách thứ hai làm một kiểm tra chỉ chạy trên CI. Và thứ khiến cách thứ ba đủ nhanh để làm cho từng nhánh là một tính năng Postgres mà hầu hết mọi người quên là có tồn tại:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code> là sao chép ở mức tệp trong cụm. Vài gigabyte là vài giây, không phải vài phút như <code>pg_restore</code>. Mẫu không được có kết nối mở trong khi đang nhân bản, đó là điều <code>datallowconn = false</code> đảm bảo, và cũng là lý do tác vụ đêm dựng vào <code>seed_raw</code> rồi đổi tên ở cuối thay vì khôi phục đè lên mẫu đang sống.</p>
<p>Trong demo, mẫu được dựng bằng tay thay vì từ dump prod: một đường hầm tới cụm preview bằng <code>fly proxy</code>, <code>manage.py migrate</code>, rồi các lệnh seed sẵn có của repo để tạo một bộ dữ liệu nhỏ nhưng sát với thực tế. Mười chín megabyte. Ba bản nhân bản của nó xuất hiện trong cụm khi ba nhánh khởi lên, mỗi cái 19 MB, mỗi cái chưa tới một giâ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">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>Cụm preview là một node Fly Postgres không quản lý, <code>shared-cpu-1x</code> với volume 1 GB, khoảng hai đô la một tháng. Các môi trường nhánh kết nối tới nó qua <code>.flycast</code> trên mạng riêng; không có gì công khai.</p>
<p>Bộ ẩn danh hóa không phải tùy chọn. Khoảnh khắc dữ liệu prod có thể truy cập trên một tên miền <code>-dev</code>, nó phải không còn là dữ liệu prod: tên, email, số điện thoại, địa chỉ, văn bản tự do, tham chiếu thanh toán, token, tất cả bị ghi đè trước khi đổi tên. Nếu bộ ẩn danh hóa thất bại, mẫu của đêm trước được giữ lại và ai đó nhận tin nhắn. Đó là một việc nhỏ và là thứ khiến toàn bộ cách tiếp cận này chấp nhận được với người trả lời bảng câu hỏi về quyền riêng tư.</p>
<p>Script khởi chạy chạy <code>migrate</code> ở lần khởi động đầu của mỗi image, nên migration của chính nhánh được áp lên trên mẫu. Đó là bài kiểm tra trung thực đầu tiên cho câu &ldquo;migration của tôi có chạy trên dữ liệu có hình dạng prod không&rdquo;, và nó xảy ra trước khi ai đó mở URL.</p>

<h2 class="relative group">Tên miền: một wildcard, một Worker, và hai điều tài liệu không nói
    <div id="tên-miền-một-wildcard-một-worker-và-hai-điều-tài-liệu-không-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="#t%c3%aan-mi%e1%bb%81n-m%e1%bb%99t-wildcard-m%e1%bb%99t-worker-v%c3%a0-hai-%c4%91i%e1%bb%81u-t%c3%a0i-li%e1%bb%87u-kh%c3%b4ng-n%c3%b3i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mỗi môi trường cần một tên miền HTTPS công khai, và &ldquo;công khai&rdquo; chính là điểm mấu chốt: tester cầm điện thoại, designer ở mạng khác, product manager không chịu cài VPN. Fly cho mỗi ứng dụng <code>myapp-$ENV.fly.dev</code> miễn phí, và chỉ thế cũng chạy được. Nhưng tên miền <code>*.fly.dev</code> có ba vấn đề: gửi đi trông xấu, Cloudflare không đứng trước nên không có lớp bảo vệ nào của prod được áp dụng, và nếu muốn đặt chúng dưới tên miền của mình thì phải tạo mỗi môi trường một bản ghi DNS và một chứng chỉ, thêm một thứ nữa cho trình quét.</p>
<p>Phiên bản loại bỏ tất cả những thứ ấy là một wildcard và một Cloudflare Worker. Tôi đã định dùng <code>*.dev.example.com</code>. Hai điều đã chặn lại, và cả hai đều đáng biết trước khi bạn lên kế hoạch tương tự.</p>
<p><strong>Universal SSL chỉ phủ một tầng wildcard.</strong> Chứng chỉ miễn phí của Cloudflare được cấp cho <code>example.com</code> và <code>*.example.com</code>. Nó không phủ <code>*.dev.example.com</code>; wildcard tầng hai cần Advanced Certificate Manager giá 10 đô la một tháng. Yêu cầu đầu tiên tới <code>feature-branch-envir-c65cb4.dev.example.com</code> chết vì lỗi bắt tay SSL trước khi bất cứ thứ gì của tôi kịp chạy. Cách sửa là giữ môi trường ở nhãn đầu tiên: <code>feature-branch-envir-c65cb4-dev.example.com</code>. Nó được chứng chỉ sẵn có phủ, và được bản ghi <code>*.example.com</code> đã proxy sẵn có phủ, chính bản ghi tôi dựng làm hố đen cho <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >các trình quét lần mò những tên miền con bịa đặt</a>. Vậy nên một môi trường hoàn toàn không cần DNS.</p>
<p><strong>Redirect Rules chạy trước Workers.</strong> Với route đã đặt, mọi tên miền <code>-dev</code> đều trả 301 về trang chủ www mà Worker không hề chạy. Hố đen là một quy tắc chuyển hướng cấp zone, &ldquo;mọi tên miền con khác → www&rdquo;, và pha chuyển hướng của Cloudflare thực thi trước pha Workers. Quy tắc cần thêm một mệnh đề: <code>and not ends_with(http.host, &quot;-dev.example.com&quot;)</code>. Script triển khai thêm nó khi <code>up</code> và gỡ khi <code>down</code>, vì một quy tắc ai đó sửa tay chính là thứ bị quên.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/rules/redirect-rules/…/edit</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_38e5e373828b8373.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Quy tắc chuyển hướng trong bảng điều khiển Cloudflare, biểu thức kết thúc bằng ngoại lệ -dev"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_71d546da5b5668b6.webp"></span></div>
  <figcaption>Chuyển hướng hố đen sau khi script đã can thiệp. Mọi thứ dưới zone vẫn bật về www, trừ các tên miền kết thúc bằng <code>-dev.example.com</code>, chúng lọt xuống Worker.</figcaption>
</figure>

<p>Phía DNS là bản ghi vốn đã có sẵn:</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/dns/records</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_2c615b9e52444abd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Bản ghi DNS của zone lọc theo wildcard: một bản ghi A đã proxy cho wildcard của zone, trỏ tới 192.0.2.1"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_952422604a6c8304.webp"></span></div>
  <figcaption>Bản ghi DNS duy nhất liên quan, và nó có trước bài viết này. Một wildcard đã proxy trỏ tới địa chỉ giữ chỗ: Cloudflare trả lời, còn chuyện tiếp theo tùy quy tắc và Workers.</figcaption>
</figure>

<p>Gỡ xong hai thứ đó, việc định tuyến là một Worker duy nhất trên <code>*-dev.example.com/*</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// dev-router.js: &lt;env&gt;-dev.example.com -&gt; myapp-&lt;env&gt;.fly.dev
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DEV_SUFFIX</span> <span class="o">=</span> <span class="s2">&#34;-dev.example.com&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ENV_NAME</span> <span class="o">=</span> <span class="sr">/^[a-z0-9-]+-[0-9a-f]{6}$/</span><span class="p">;</span>   <span class="c1">// slug-hash, as scripts/env names them
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">endsWith</span><span class="p">(</span><span class="nx">DEV_SUFFIX</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">&#34;not a dev hostname&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">env</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nx">DEV_SUFFIX</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ENV_NAME</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">env</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="sb">`no such environment: </span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">=</span> <span class="sb">`myapp-</span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">.fly.dev`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">upstream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">upstream</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Forwarded-Host&#34;</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="s2">&#34;&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">upstream</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="nx">res</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Robots-Tag&#34;</span><span class="p">,</span> <span class="s2">&#34;noindex, nofollow&#34;</span><span class="p">);</span>   <span class="c1">// a half-finished branch is not for search engines
</span></span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Dev-Environment&#34;</span><span class="p">,</span> <span class="nx">env</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">out</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span></span></span></code></pre></div></div>
<p>Cloudflare kết thúc TLS trên wildcard, Worker tính tên miền Fly từ nhãn, và chứng chỉ <code>*.fly.dev</code> của chính Fly phủ chặng tới máy. Nâng cấp WebSocket đi qua <code>fetch</code> không đổi. Và vì route nằm trên zone, mọi thứ Cloudflare làm cho prod đều dùng được: quy tắc WAF, giới hạn tốc độ, chế độ chống bot, và Access.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/workers</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_9996f094fe4379cd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Trang Workers Routes của zone trên Cloudflare: một route *-dev.example.com/* gắn với Worker dev-router"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_78420fc686a1e6e3.webp"></span></div>
  <figcaption>Toàn bộ phía Cloudflare của một môi trường: một mẫu route, một Worker. Không có tên môi trường nào xuất hiện ở bất kỳ đâu trong bảng điều khiển này.</figcaption>
</figure>

<p>Ba nhánh cùng chạy, một Worker, và câu trả lời của từng tên miền:</p>
<table>
	<thead>
			<tr>
					<th>Tên miền</th>
					<th>HTTP</th>
					<th>Định tuyến tới</th>
					<th><code>branch</code></th>
					<th><code>commit</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>feature-branch-envir-c65cb4-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-branch-envir-c65cb4.fly.dev</code></td>
					<td><code>feature/branch-environments</code></td>
					<td><code>a1b2c3d4</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-tagl-fa8832-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-tagl-fa8832.fly.dev</code></td>
					<td><code>feature/profile-tagline</code></td>
					<td><code>e5f6a7b8</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-webs-dcb2e7-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-webs-dcb2e7.fly.dev</code></td>
					<td><code>feature/profile-website</code></td>
					<td><code>c9d0e1f2</code></td>
			</tr>
			<tr>
					<td><code>not-an-env-dev.example.com</code></td>
					<td>404</td>
					<td>không đâu cả; Worker từ chối nhãn</td>
					<td></td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>Mọi phản hồi 200 đều kèm <code>X-Robots-Tag: noindex, nofollow</code> và <code>X-Dev-Environment: &lt;env&gt;</code> do Worker thêm vào. Cái 404 là của chính Worker, trước cả khi hỏi Fly.</p>
<p>Cloudflare Access là thứ biến &ldquo;công khai&rdquo; thành &ldquo;đến được với những người nên đến&rdquo;. Một ứng dụng trên <code>*-dev.example.com</code> với chính sách &ldquo;bất kỳ ai có email @example.com&rdquo; và người dùng trình duyệt thấy trang đăng nhập một lần rồi không bao giờ phải nghĩ tới nữa. Designer, PM và tester dùng laptop đều được phủ. Tôi không thêm nó cho demo; các môi trường chạy một giờ với <code>noindex</code> và không có gì ngoài dữ liệu seed.</p>
<p>Nó không phủ được ứng dụng di động, vì ứng dụng native không hoàn tất được chuyển hướng đăng nhập của Access. Hai lối đi. Access hỗ trợ service token (cặp header <code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code>) bỏ qua đăng nhập, và menu dev của ứng dụng di động có thể mang một cặp cho các tên miền dev. Hoặc đơn giản hơn, thêm một bypass Access cho <code>/api/</code> và để Django từ chối mọi yêu cầu không có người dùng đã đăng nhập hoặc <code>ENV_TOKEN</code> của môi trường, chính cái token <code>env up</code> đã sinh cho đúng môi trường này. Dù cách nào, phía ứng dụng di động là một màn hình menu dev với hai trường, URL gốc và token, và ứng dụng tester đã cài sẵn giờ nói chuyện với nhánh đó. Không cần build mới. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/" >Kênh cập nhật qua mạng (OTA)</a> vẫn trỏ về thứ ứng dụng được build cùng; URL gốc của API là cấu hình lúc chạy, không phải lúc build, và vốn nên như thế từ đầu.</p>
<p>Xác thực là dịch vụ dùng chung duy nhất được cố ý dùng chung. Danh tính không thuộc về môi trường nào; tester muốn đăng nhập bằng cùng tài khoản trên mọi nhánh. Một dự án dev trên dịch vụ xác thực, với <code>example.com</code> trong danh sách tên miền được ủy quyền phủ mọi tên miền <code>-dev</code>, và mọi môi trường xác minh ID token với nó. Bảng người dùng trong cơ sở dữ liệu mỗi môi trường là bản sao người dùng của mẫu, nên tài khoản của tester tồn tại ở mọi nơi mẫu có.</p>

<h2 class="relative group">Tự động hóa môi trường xem trước bằng GitHub Actions: sự kiện vào, môi trường ra
    <div id="tự-động-hóa-môi-trường-xem-trước-bằng-github-actions-sự-kiện-vào-môi-trường-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="#t%e1%bb%b1-%c4%91%e1%bb%99ng-h%c3%b3a-m%c3%b4i-tr%c6%b0%e1%bb%9dng-xem-tr%c6%b0%e1%bb%9bc-b%e1%ba%b1ng-github-actions-s%e1%bb%b1-ki%e1%bb%87n-v%c3%a0o-m%c3%b4i-tr%c6%b0%e1%bb%9dng-ra" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ ở trên làm bằng tay thì ổn khi chỉ có bạn làm. Từ khi có lập trình viên thứ hai, nó phải xảy ra vì có gì đó xảy ra trong git, chứ không phải vì ai đó nhớ. Toàn bộ tự động hóa là bốn workflow, một cài đặt kho, một ruleset và một GitHub environment, và mỗi thứ ánh xạ một sự kiện git thành một lời gọi tới chính <code>scripts/env</code> mà lập trình viên chạy bằng tay. Không có đường riêng cho CI để lệch đi.</p>
<table>
	<thead>
			<tr>
					<th>Sự kiện git</th>
					<th>Chạy gì</th>
					<th>Làm gì</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PR được gắn nhãn <code>env</code></td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>tạo môi trường của nhánh đó, đăng URL lên PR</td>
			</tr>
			<tr>
					<td>push lên nhánh của một PR đã gắn nhãn</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>cập nhật tại chỗ</td>
			</tr>
			<tr>
					<td>gỡ nhãn, PR bị đóng hoặc merge</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>môi trường tự dọn lát cắt của mình rồi bị hủy</td>
			</tr>
			<tr>
					<td>xóa nhánh</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>lưới an toàn cho nhánh dựng bằng tay mà chưa từng có PR; cài đặt kho <em>delete branch on merge</em> làm merge cũng kích hoạt nó</td>
			</tr>
			<tr>
					<td>push lên bất kỳ nhánh nào khác</td>
					<td>không gì cả</td>
					<td>một lần push để sao lưu hay một spike không tốn gì cho đến khi có người muốn xem</td>
			</tr>
			<tr>
					<td>pull request vào <code>main</code></td>
					<td><code>migrations-check.yml</code></td>
					<td>một lá duy nhất trong đồ thị migration sau khi merge <code>main</code>; chuỗi đầy đủ áp được từ rỗng</td>
			</tr>
			<tr>
					<td>push tag <code>v*</code></td>
					<td><code>release.yml</code></td>
					<td>môi trường staging từ tag; người duyệt phê chuẩn thì cùng image được đưa lên prod và staging bị hủy</td>
			</tr>
			<tr>
					<td>02:00 hằng đêm</td>
					<td><code>sweep-envs.yml</code> → <code>scripts/sweep-envs</code></td>
					<td>hủy mọi thứ có nhánh đã mất hoặc nằm không một tuần</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">Workflow nhãn, push và delete
    <div id="workflow-nhãn-push-và-delete" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-nh%c3%a3n-push-v%c3%a0-delete" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>Cái cổng là nhãn <code>env</code>, và đây là phần tôi sẽ bảo vệ mạnh nhất. Không có cổng, mỗi nhánh đều tốn một ứng dụng và một bản nhân bản cơ sở dữ liệu: nhánh ai đó push lên chỉ để sao lưu laptop, spike ngày mai sẽ bị squash, bản cập nhật thư viện mà bot mở lúc 3 giờ sáng. Nên chỉ push thì không có gì xảy ra, và chỉ mở PR cũng không có gì xảy ra. Gắn nhãn <code>env</code> lên PR thì môi trường được dựng lên, mỗi lần push khi nhãn còn đó thì nó được cập nhật, còn gỡ nhãn, đóng PR hay merge thì nó bị hạ xuống. Việc chọn tham gia vẫn là một sự kiện trên GitHub chứ không phải lời nhờ vả một ai, và đó là một cú bấm của đúng người biết có ai sẽ xem hay không. Bình luận ghim là giao diện người dùng: gắn nhãn cho PR, một bình luận hiện ra với URL có thể gửi cho bất kỳ ai, và nó cập nhật tại chỗ sau mỗi lần push thay vì chồng lên nhau. <code>workflow_dispatch</code> lo cho nhánh bạn muốn dựng trước khi có PR, còn <code>./scripts/env up</code> từ laptop lo phần còn lại.</p>
<p>Có hai kiểu cổng khác đáng biết. Bộ lọc theo tên nhánh (<code>branches: ['feature/**', 'fix/**']</code>) không cần PR nhưng khiến mọi nhánh tính năng đều phải trả tiền. &ldquo;Mọi PR không phải bản nháp&rdquo; là kiểu tự động nhất, và là lựa chọn đúng nếu đội bạn mở PR muộn, chỉ khi đã sẵn sàng. Tôi chọn nhãn vì nó đặt quyết định chi phí vào tay người có ngữ cảnh, và vì có thể rút lại mà không phải đóng gì cả. Một tác dụng phụ tôi thích: các lần chạy <code>pull_request</code> từ fork không nhận được secret, nên fork không thể dựng môi trường.</p>
<p><code>concurrency</code> quan trọng hơn vẻ ngoài. Một loạt push nhanh vào một nhánh sẽ hủy các lần triển khai trước thay vì để chúng chạy đua; hai <code>fly deploy</code> cùng lúc vào cùng ứng dụng kết thúc bằng việc một cái thua.</p>
<p><code>closed</code> lo việc dỡ bỏ cho mọi thứ từng có PR, merge hay không. Sự kiện <code>delete</code> là lưới an toàn cho nhánh dựng bằng <code>workflow_dispatch</code> mà rốt cuộc không có PR, và nó chỉ kích hoạt nếu nhánh thật sự bị xóa. Đó là một cài đặt kho, <em>Automatically delete head branches</em>, mặc định tắt. Bật lên, bấm Merge sẽ đóng PR và xóa nhánh, và chỉ một trong hai sự kiện ấy cũng đủ để chạy <code>env down</code>. Nút merge chính là việc dỡ bỏ.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code> là dòng nguy hiểm trong tệp. Nó tạo và hủy được ứng dụng, và nếu <code>$APP</code> có lúc nào là thứ gì ngoài môi trường nhánh, nó có thể hủy prod. Hai lớp bảo vệ, và tôi giữ cả hai: token giới hạn trong một tổ chức Fly riêng để nó theo đúng nghĩa đen không nhìn thấy tổ chức production (trong demo tôi dùng tổ chức production để khỏi phải gắn thanh toán cho tổ chức mới, và chỉ dựa vào lớp thứ hai); và <code>down()</code> từ chối mọi tên ứng dụng không khớp mẫu <code>slug-hash</code>.</p>

<h3 class="relative group">Kiểm tra mà môi trường nhánh không tự làm được
    <div id="kiểm-tra-mà-môi-trường-nhánh-không-tự-làm-đượ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="#ki%e1%bb%83m-tra-m%c3%a0-m%c3%b4i-tr%c6%b0%e1%bb%9dng-nh%c3%a1nh-kh%c3%b4ng-t%e1%bb%b1-l%c3%a0m-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Một nhánh chỉ thấy migration của chính nó, nên kiểm tra quan trọng chạy trên pull request đối với trạng thái đã merge <code>main</code>, và đó là kiểm tra trạng thái duy nhất <code>main</code> yêu cầu:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>Ba mươi giây. Chính nó biến va chạm <code>0042</code> / <code>0042</code> từ bất ngờ ngày phát hành thành một dấu đỏ trên PR của lập trình viên thứ hai; mục migration bên dưới cho thấy nó nổ thật.</p>

<h3 class="relative group">Quy tắc trên <code>main</code>
    <div id="quy-tắc-trên-main" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#quy-t%e1%ba%afc-tr%c3%aan-main" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Không gì trong đây trụ được nếu nhánh sống một tháng hay ai đó push thẳng lên <code>main</code>. Các quy tắc thiên về giữ môi trường nhỏ và migration mới hơn là bảo vệ:</p>
<ul>
<li><strong><code>main</code> được bảo vệ và luôn triển khai được.</strong> Chỉ merge qua PR với kiểm tra <code>migrations</code> xanh <em>đối với <code>main</code> hiện tại</em> (kiểm tra trạng thái nghiêm ngặt, nên PR xanh tuần trước phải chạy lại khi <code>main</code> tiến lên). Lịch sử tuyến tính, không force-push, không xóa.</li>
<li><strong>Nhánh tính năng phải ngắn.</strong> Nhánh mở hai tuần có môi trường tụt sau <code>main</code> hai tuần và migration có thêm hai tuần khả năng va với của người khác. Tính năng kéo dài một tháng thì merge vào <code>main</code> từng phần sau feature flag. Thứ sống lâu là flag, không phải nhánh.</li>
<li><strong>Một nhánh, một môi trường, một cơ sở dữ liệu.</strong> Cách đặt tên đã đảm bảo.</li>
<li><strong>Chỉ tag phát hành mới lên prod.</strong> Image được build một lần từ tag; staging và prod chạy image đó, không build lại.</li>
<li><strong>PR chạm vào <code>migrations/</code> được một trong hai người còn lại review.</strong> Bên dưới.</li>
</ul>
<p>Dưới dạng ruleset, áp một lần bằng <code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.json</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Một nếp gấp thành thật. Kho này là kho riêng tư trên gói GitHub Free, và trên Free, bảo vệ nhánh và ruleset chỉ có cho kho công khai; API trả lời <code>403 Upgrade to GitHub Pro or make this repository public</code>. Cài đặt <code>delete_branch_on_merge</code>, các environment và workflow đều chạy trên Free. Vậy nên với một đội nhỏ trên kho riêng tư Free, ruleset ở trên là một quy ước: workflow <code>migrations</code> vẫn chạy và vẫn đỏ, nhưng không gì làm xám nút merge. Đó là quyết định 4 đô la một tháng, và tôi sẽ quyết ngay khoảnh khắc có người thứ hai được phép merge.</p>
<p>Không có nhánh <code>develop</code> và không có nhánh <code>release/*</code>. Git-flow được thiết kế quanh giả định rằng tích hợp đắt đỏ và phải gom theo đợt. Môi trường nhánh làm tích hợp rẻ đi, nên việc gom đợt biến mất.</p>

<h3 class="relative group">Staging là môi trường của một tag, và prod là một lần thăng cấp
    <div id="staging-là-môi-trường-của-một-tag-và-prod-là-một-lần-thăng-cấp" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#staging-l%c3%a0-m%c3%b4i-tr%c6%b0%e1%bb%9dng-c%e1%bb%a7a-m%e1%bb%99t-tag-v%c3%a0-prod-l%c3%a0-m%e1%bb%99t-l%e1%ba%a7n-th%c4%83ng-c%e1%ba%a5p" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Staging tái sử dụng toàn bộ bộ máy. Một tag <code>v*</code> trên <code>main</code> chạy <code>scripts/env up</code> với tên nhánh <code>staging/v1.42.0</code>, cách đặt tên biến nó thành <code>staging-v1-42-0-&lt;hash&gt;</code>: cùng cơ sở dữ liệu nhân bản từ mẫu, cùng máy suspend khi rảnh, cùng sơ đồ tên miền. Khác biệt là chuyện xảy ra sau đó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>Job <code>production</code> chờ một GitHub environment có người duyệt bắt buộc. Ai đó xem staging, bấm Approve, và prod nhận <em>cùng image</em> staging đã chạy, theo tham chiếu, không build lại. Bước cuối hủy staging vì nó đã xong việc. Nếu không ai duyệt, ứng viên phát hành bị từ chối, và trình quét gỡ staging khi &ldquo;nhánh&rdquo; của nó (<code>staging/v1.42.0</code> chưa bao giờ tồn tại trên origin) rớt kiểm tra còn sống. Không thứ gì được tồn tại mà không có lý do trình quét kiểm chứng được.</p>

<h3 class="relative group">Trình quét hằng đêm
    <div id="trình-quét-hằng-đê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="#tr%c3%acnh-qu%c3%a9t-h%e1%ba%b1ng-%c4%91%c3%aam" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code> vì trình quét cần mọi nhánh từ xa và ngày commit cuối của chúng, chứ không phải một commit Actions checkout mặc định. <code>workflow_dispatch</code> để có thể chạy tay sau sự cố.</p>

<h3 class="relative group">Lập trình viên thật sự làm gì
    <div id="lập-trình-viên-thật-sự-làm-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="#l%e1%ba%adp-tr%c3%acnh-vi%c3%aan-th%e1%ba%adt-s%e1%bb%b1-l%c3%a0m-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Push một nhánh, mở PR, và gắn nhãn <code>env</code> khi muốn ai đó xem. Danh sách chỉ có thế. Một URL xuất hiện trên PR trong vòng ba phút, cập nhật sau mỗi lần push, và biến mất khi PR được merge hoặc đóng. Kiểm tra migration báo cho họ trước bất kỳ ai nếu họ va với đồng đội. Không ai giành staging vì không có staging để giành, và không ai dỡ gì cả vì dỡ bỏ chính là merge.</p>
<p>Còn người đã dựng hệ thống này, sau đó phải làm gì cho mỗi nhánh? Không gì cả. Postgres, Redis và bucket dùng chung được cấp phát một lần, các bí mật được đưa vào Actions một lần, và từ đó việc lặp lại duy nhất là đọc log của trình quét vào sáng thứ Hai. Không hàng đợi ticket, không bot để gọi, không ai để nhắc.</p>

<h2 class="relative group">Dỡ bỏ không tin vào việc dỡ bỏ
    <div id="dỡ-bỏ-không-tin-vào-việc-dỡ-bỏ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1-b%e1%bb%8f-kh%c3%b4ng-tin-v%c3%a0o-vi%e1%bb%87c-d%e1%bb%a1-b%e1%bb%8f" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><code>env down</code> là trường hợp thông thường, và phần thú vị là <em>ai</em> dọn. Khóa Redis và đối tượng trong bucket thuộc về các dịch vụ dùng chung, và laptop chạy <code>down</code> không cần thông tin đăng nhập của cả hai, vì chính máy của môi trường đã có chúng và đã biết tiền tố của mình. Nên <code>down</code> khởi động máy nếu nó đang suspend, chạy một lệnh dọn dẹp nhỏ trên đó, rồi mới hủy ứng dụng và xóa cơ sở dữ liệu. Môi trường tự dọn dẹp sau mình:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>Cái 530 lại là điểm về điều phối: Worker vẫn định tuyến tên miền, Fly không có gì ở đó, và không ai phải báo cho Cloudflare.</p>
<p>Lệnh dọn dẹp từ chối chạy khi không có <code>ENV_NAME</code>, vì với tiền tố rỗng nó sẽ xóa sạch cả bucket dùng chung. Lớp bảo vệ ấy là toàn bộ lý do nó tồn tại như một lệnh bên trong ứng dụng thay vì ba dòng trong shell script.</p>
<p>Môi trường vẫn rò rỉ. Một nhánh bị xóa khi GitHub Actions đang gặp sự cố. Một lập trình viên chạy <code>env up</code> từ laptop cho một thử nghiệm và không bao giờ push. Một <code>fly apps destroy</code> thất bại thoáng qua và <code>|| true</code> nuốt mất. Ba tháng sau có bốn mươi ứng dụng và một hóa đơn khiến ai đó hỏi chuyện gì đã xảy ra.</p>
<p>Nên có cơ chế thứ hai không tin cơ chế thứ nhất: trình quét hằng đêm trong bảng ở trên. Nó liệt kê mọi ứng dụng <code>myapp-*</code>, tìm ra mỗi cái thuộc nhánh nào, và hủy mọi cái có nhánh đã mất hoặc không được push trong bảy ngày. Một nhánh nằm không một tuần không cần URL sống; lần push tiếp theo dựng lại nó trong hai phút. Mã băm không đảo ngược được, nên trình quét tính tên môi trường cho mọi nhánh còn sống và hủy mọi ứng dụng không nằm trong tập đó, rồi xóa mọi cơ sở dữ liệu <code>env_*</code> không có ứng dụng đứng sau:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>Không nhánh demo nào từng được push (lần chạy từ laptop ở trên), nên dưới mắt trình quét tất cả đều đã mất; đó cũng chính là trạng thái mà một spike ai đó dựng từ laptop rồi không bao giờ push sẽ rơi vào sau một tuần. Nó tháo dỡ hai cái còn lại, gồm cả các khóa Redis mà môi trường tagline đã ghi:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>(Một thứ nhỏ đã cắn: macOS đi kèm bash 3, không có mảng kết hợp. Phiên bản đầu của trình quét dùng nó và chết ở <code>declare -A</code> trên laptop của tôi. Phiên bản trên dùng danh sách phân cách bằng xuống dòng và chạy được ở mọi nơi.)</p>
<p>Chi phí là phần dễ chịu. Một máy suspend chỉ tốn rootfs, vài xu một tháng. Một cơ sở dữ liệu là 19 MB trên volume đã trả tiền. Redis chạy trên một gói trả theo mức dùng, hai mươi xu cho một trăm nghìn lệnh, ở mức test nhánh thì làm tròn thành không. Một tiền tố trong bucket tốn đúng phần nó lưu. Node Postgres dùng chung là khoản cố định duy nhất, khoảng hai đô la một tháng. Hai mươi nhánh đang hoạt động tốn cỡ một <code>shared-cpu-1x</code> chạy liên tục, và trình quét giữ ở hai mươi thay vì hai trăm. Cả buổi demo, ba môi trường trong một giờ cộng các phần dùng chung, rẻ hơn ly cà phê tôi uống trong lúc đó.</p>

<h2 class="relative group">Migration qua nhiều nhánh, không có cơ sở dữ liệu dùng chung để cảnh báo
    <div id="migration-qua-nhiều-nhánh-không-có-cơ-sở-dữ-liệu-dùng-chung-để-cảnh-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="#migration-qua-nhi%e1%bb%81u-nh%c3%a1nh-kh%c3%b4ng-c%c3%b3-c%c6%a1-s%e1%bb%9f-d%e1%bb%af-li%e1%bb%87u-d%c3%b9ng-chung-%c4%91%e1%bb%83-c%e1%ba%a3nh-b%c3%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi mỗi lập trình viên sở hữu trọn một tính năng, mỗi nhánh đều có một migration. Điều đó khiến mục này là mục quan trọng.</p>
<p>Đây là điều người ta bỏ lỡ khi rời thế giới môi trường dùng chung. Khi cả đội cùng triển khai vào một cơ sở dữ liệu dev, xung đột migration nổi lên ngay lập tức và đau đớn, nhưng chúng <em>nổi lên</em>. Một migration chạy, lần triển khai tiếp theo thất bại, ai đó sửa ngay chiều đó. Với mỗi nhánh một cơ sở dữ liệu, mỗi migration chạy hoàn hảo trong môi trường của mình, và hai cái gặp nhau lần đầu khi cái thứ hai merge vào <code>main</code>. Làm cẩu thả, việc này chuyển xung đột từ dev, nơi nó rẻ, sang phát hành, nơi nó không rẻ.</p>
<p>Bốn quy tắc, tất cả CI ép được nên không ai phải nhớ.</p>
<p><strong>1. CI thất bại khi đồ thị migration có hai lá, đối với trạng thái đã merge <code>main</code>.</strong> <code>makemigrations --check</code> bắt được model lệch với migration, nhưng xung đột đáng kể là hai nhánh cùng thêm <code>0042_*</code>. Kiểm tra trên nhánh đã merge vào <code>main</code>, không phải nhánh đơn lẻ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>Cái này tôi cố ý gây ra. Hai trong ba nhánh demo, <code>feature/profile-tagline</code> và <code>feature/profile-website</code>, mỗi cái thêm một trường vào model hồ sơ người dùng bằng một migration, và mỗi môi trường áp migration của mình khi khởi lên. Các cột mới chỉ ở đúng chỗ nên 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">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>Cả hai migration đều là <code>0042_*</code>. Nhánh nào cũng không thấy của nhánh kia, nên môi trường nào cũng không báo được có vấn đề. Merge cái này vào cái kia, kiểm tra sẽ báo:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>Tác giả thứ hai chạy <code>--merge</code> đó trên nhánh mình, trong môi trường mình, không làm phiền ai, và lần <code>env up</code> tiếp theo áp <code>0043</code> lên bản nhân bản của họ. (Trong trường hợp này git còn báo xung đột văn bản trước, vì cả hai trường được thêm vào cùng một dòng của tệp model. Thực tế, và đáng biết rằng xung đột văn bản che mất xung đột migration cho đến khi được giải quyết.)</p>
<p><strong>2. Migration tương thích ngược với code của bản phát hành trước.</strong> Trong lúc triển khai có một khoảng thời gian schema mới và code cũ cùng tồn tại, hoặc ngược lại nếu một máy rollback. Thêm cột nullable hoặc có giá trị mặc định, không bao giờ đổi tên trong một bước, không bao giờ xóa thứ bản phát hành hiện tại vẫn đọc: thêm, triển khai, backfill, đổi code, xóa ở bản phát hành sau. Expand and contract. Lý do nó quan trọng <em>hơn</em> ở đây là môi trường dùng một lần khiến triển khai thường xuyên hơn, nên bạn ở trong khoảng đó nhiều hơn. Staging theo tag là nơi tổng duyệt điều này.</p>
<p><strong>3. CI cũng migration từ số không vào cơ sở dữ liệu rỗng.</strong> Nhân bản mẫu kiểm tra &ldquo;migration của tôi có áp được lên dữ liệu thực tế không&rdquo;. Chạy rỗng kiểm tra &ldquo;cả chuỗi còn chạy từ số không không&rdquo;, bắt được migration giả định một data migration trước đó đã điền gì đó. Ba mươi giây, và nó bắt được một lớp lỗi mà nếu không sẽ chờ đến chiếc laptop mới tiếp theo.</p>
<p><strong>4. Thay đổi schema có cặp mắt thứ hai.</strong> Trong một đội nhỏ không cần tệp <code>CODEOWNERS</code>, nhưng cần một quy tắc: PR chạm vào <code>migrations/</code> được một đồng đội review trước khi merge, và việc của người review không phải kiểm tra SQL. Là biết rằng họ, hoặc ai đó khác trong đội, đang chạm vào cùng bảng trong hai tuần này và nói ra. Quá mười mấy lập trình viên thì kiến thức ấy không còn nằm gọn trong đầu một người nữa, và lúc đó một dòng <code>CODEOWNERS</code> cho <code>migrations/</code> mới đáng có. Không công cụ nào thay được. Với công cụ bắt hết phần cơ học, đó là việc hai phút, và là phần duy nhất của thế giới môi trường dùng chung cũ đáng giữ lại: khoảnh khắc hai người phát hiện họ sắp va nhau.</p>

<h2 class="relative group">Phiên bản, để một URL nói nó đang chạy gì
    <div id="phiên-bản-để-một-url-nói-nó-đang-chạy-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="#phi%c3%aan-b%e1%ba%a3n-%c4%91%e1%bb%83-m%e1%bb%99t-url-n%c3%b3i-n%c3%b3-%c4%91ang-ch%e1%ba%a1y-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với một môi trường dùng chung, &ldquo;staging đang có gì&rdquo; là câu hỏi trong kênh chat. Với nhiều môi trường, nó phải là thuộc tính của môi trường, nếu không mọi báo cáo lỗi đều bắt đầu bằng khảo cổ.</p>
<ul>
<li><strong>Mỗi image mang SHA commit và ứng dụng lộ nó ra.</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code> đóng chúng vào image dưới dạng biến môi trường, và <code>/api/version/</code> trả chúng về kèm tên môi trường và vùng (xem output ở trên). Việc đầu tiên ai cũng làm với &ldquo;nó hỏng trên feature-profile-tagl-fa8832&rdquo; là kiểm tra nó đang chạy commit mà họ nghĩ. Môi trường demo đầu tiên được triển khai từ working tree một commit trước khi bộ công cụ được commit, và endpoint nói đúng thế: nó báo commit trước đó, không phải SHA tôi mong đợi.</li>
<li><strong><code>ENVIRONMENT=$ENV</code> có trong mọi dòng log và thẻ environment trên công cụ theo dõi lỗi.</strong> Lỗi của nhánh không làm bẩn theo dõi lỗi của prod và lọc được bằng một cú bấm.</li>
<li><strong>Nhánh có SHA, bản phát hành có tag.</strong> <code>feature-payments-retr-3f9a1c</code> không bao giờ có số phiên bản; nó có commit. Staging và prod có <code>v1.42.0</code>.</li>
<li><strong>Ứng dụng di động mang phiên bản API tối thiểu, không phải URL.</strong> Trường URL trong menu dev là cách tester trỏ điện thoại thật vào một nhánh, và endpoint phiên bản mang phiên bản schema API là cách ứng dụng biết nó đang nói chuyện với thứ nó hiểu được.</li>
</ul>

<h2 class="relative group">Điều này không giải quyết được gì
    <div id="điều-này-không-giải-quyết-đượ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="#%c4%91i%e1%bb%81u-n%c3%a0y-kh%c3%b4ng-gi%e1%ba%a3i-quy%e1%ba%bft-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nó không giải quyết hai tính năng chỉ có nghĩa khi đi cùng nhau. Một môi trường nhánh hiện một nhánh. Câu trả lời là merge cả hai sau feature flag rồi nhìn vào <code>main</code>, làm được bằng một môi trường thường trực bám theo <code>main</code> (<code>main-dev.example.com</code>, triển khai lại sau mỗi lần merge). Đó là môi trường phi production sống lâu duy nhất tôi giữ, và không ai triển khai <em>lên</em> nó; nó để xem bản phát hành tiếp theo sẽ là gì.</p>
<p>Nó không giải quyết các nhà cung cấp đòi callback đã đăng ký và không chấp nhận wildcard. Dịch vụ xác thực tôi dùng chấp nhận tên miền cha, nên <code>example.com</code> phủ mọi tên miền nhánh <code>-dev</code>. Một số nhà cung cấp OAuth và thanh toán thì không, và với họ bạn giữ một tên miền dùng chung mà Worker định tuyến tới môi trường nào vừa giành nó gần nhất. Đó là cái khóa nhỏ lên một thứ nhỏ, và tốt hơn nhiều so với cái khóa lên mọi thứ.</p>
<p>Nó không giải quyết phía con người của migration. Công cụ bắt xung đột cơ học. Nó không bắt được hai người trong các bạn mô hình hóa cùng một khái niệm theo cách không tương thích trong cùng hai tuần, và cách sửa duy nhất là buổi review migration mà một người trong hai nhận ra.</p>

<h2 class="relative group">Tôi sẽ kiểm tra gì trong hệ thống của bạn
    <div id="tôi-sẽ-kiểm-tra-gì-trong-hệ-thống-của-bạ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="#t%c3%b4i-s%e1%ba%bd-ki%e1%bb%83m-tra-g%c3%ac-trong-h%e1%bb%87-th%e1%bb%91ng-c%e1%bb%a7a-b%e1%ba%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn có dev hoặc staging dùng chung và một luồng chat để đặt chỗ:</p>
<ol>
<li>Một nhánh đã xong phải chờ bao lâu để ai đó ngoài tác giả thấy nó chạy? Quá một giờ là bạn đang trả giá cho cái khóa bằng thời gian giao hàng, và với một đội nhỏ thì đó là một phần lớn của đội đang chờ.</li>
<li>Cơ sở dữ liệu dùng chung bị reset bao lâu một lần, và khi đó ai mất việc đã làm?</li>
<li>Một lập trình viên mới, ngày đầu, có thể có URL công khai chạy nhánh của mình mà không phải xin ai bất cứ thứ gì không? Thứ họ phải xin chính là thứ cần tự động hóa trước tiên.</li>
<li>Môi trường mới có bắt đầu từ dữ liệu thực tế không? Nếu bắt đầu rỗng, lỗi đầu tiên nó không bắt được là migration ổn trên bảng rỗng nhưng mất bốn mươi phút trên bảng thật.</li>
<li>Trong hạ tầng phi production của bạn có thứ gì vẫn tồn tại một tháng sau khi mọi người quên nó không? Nếu có, hãy viết trình quét trước mọi thứ khác. Rò rỉ bắt đầu từ ngày đầu tiên.</li>
</ol>
<p>Câu hỏi trong kênh chat biến mất không phải vì người ta thôi hỏi, mà vì câu trả lời luôn là &ldquo;có, đang dùng cái của tôi.&rdquo;</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/</guid>
      <pubDate>Sun, 02 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>DevOps</category>
      <description>Đưa khâu dựng (build) và kiểm thử vào tệp Docker Compose thay vì viết trong YAML của GitHub Actions: dễ chuyển sang nền tảng khác, chạy thử được ngay trên máy mình, bảo trì cũng nhẹ đầu hơn.</description>
      <content:encoded><![CDATA[<p><em>Cập nhật tháng 10/2026: hai năm sau, tôi đã rà lại mọi kho mã mà tôi đang dùng để triển khai sản phẩm, và viết lại những gì còn đứng vững trong lời khuyên này ở bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >Dựng quy trình CI/CD trên GitHub Actions</a>. Nguyên tắc thì vẫn đúng; còn cách chạy Docker Compose bên trong CI thì phần lớn đã không trụ được.</em></p>
<p>Đa số các đội đều viết thẳng các bước dựng (build) và kiểm thử vào tệp YAML của GitHub Actions. Cách này vẫn chạy tốt, cho đến khi bạn muốn chạy lại đúng bản dựng đó trên laptop của mình, hoặc có người đề xuất chuyển sang GitLab, và lúc đó bạn mới nhận ra cả quy trình CI/CD (pipeline) đã dính chặt vào định dạng cấu hình của một nhà cung cấp duy nhất. Cách tôi chọn thì khá nhàm: toàn bộ logic thật sự nằm trong Docker Compose, còn công cụ CI chỉ có mỗi việc gọi nó, ngoài ra không làm gì thêm.</p>

<h4 class="relative group">Vì sao tôi không để các bước dựng trong YAML của Actions
    <div id="vì-sao-tôi-không-để-các-bước-dựng-trong-yaml-của-actions" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-t%c3%b4i-kh%c3%b4ng-%c4%91%e1%bb%83-c%c3%a1c-b%c6%b0%e1%bb%9bc-d%e1%bb%b1ng-trong-yaml-c%e1%bb%a7a-actions" aria-label="Cọc dấu">#</a>
    </span>
    
</h4>
<p>Vấn đề dễ thấy nhất là bị phụ thuộc vào nhà cung cấp (vendor lock-in). GitLab, Bitbucket hay Jenkins đều không hiểu workflow của Actions, nên nếu các bước dựng nằm trong tệp workflow thì đổi nhà cung cấp cũng có nghĩa là viết lại từ đầu. Mà có ai lên kế hoạch chừa thời gian cho việc đó bao giờ đâu.</p>
<p>Vấn đề thứ hai ít ai để ý hơn: bạn không chạy được quy trình CI/CD trên máy mình. Nếu muốn thử một thay đổi trong bước dựng mà cách duy nhất là đẩy (push) commit lên rồi ngồi canh tab Actions, thì lịch sử git của bạn sẽ toàn những commit kiểu &ldquo;fix ci&rdquo;, &ldquo;fix ci again&rdquo;, &ldquo;please&rdquo;. Còn tệp Compose thì chạy trên laptop của tôi giống hệt như trên máy chạy CI (runner), nhờ vậy phần lớn những màn cãi nhau &ldquo;máy tôi chạy được mà&rdquo; bị dập tắt ngay từ đầu.</p>
<p>Chưa hết, tệp YAML còn cứ thế phình ra. Hai mươi dòng gọn gàng ban đầu sẽ thành ba trăm dòng khi các mẹo lưu đệm (caching) và điều kiện rẽ nhánh lần lượt được thêm vào, và giờ lập trình viên nào cũng phải học thêm một &ldquo;phương ngữ&rdquo; CI nữa, bên cạnh Git và đủ thứ khác. Một script chỉ nên làm một việc; còn một tệp workflow cồng kềnh thì rốt cuộc sẽ ôm đồm mọi việc cùng lúc.</p>
<p>Với Compose, trách nhiệm được chia rất rạch ròi: tệp Compose lo phần dựng và kiểm thử, còn nền tảng CI chỉ đơn giản là nơi chạy nó mỗi lần có commit mới được đẩy lên.</p>

<h4 class="relative group">Cách thiết lập
    <div id="cách-thiết-lập" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-thi%e1%ba%bft-l%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h4>
<ol>
<li>Bắt đầu với một <code>Dockerfile</code>, để khâu dựng image chỉ nằm ở đúng một chỗ:</li>
</ol>
<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"># Use an official Node.js runtime as the base image</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">FROM node:14</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="c"># Set the working directory in the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">WORKDIR /app</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="c"># Copy the package.json and package-lock.json files to the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY package*.json ./</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Install the dependencies</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">RUN npm install</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="c"># Copy the rest of the application code to the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY . .</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="c"># Expose the port the app runs on</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">EXPOSE 3000</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="c"># Define the command to run the application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">CMD [&#34;npm&#34;, &#34;start&#34;]</span></span></span></code></pre></div></div>
<ol start="2">
<li>
<p>Sau đó tạo tệp <code>docker-compose.build.yaml</code>, khai báo các dịch vụ (service) cần cho việc dựng và kiểm thử:</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">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">image</span><span class="p">:</span><span class="w"> </span><span class="l">myprivaterepo.com/image:latest</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">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">command</span><span class="p">:</span><span class="w"> </span><span class="l">sh -c &#34;npm install &amp;&amp; npm test&#34;</span></span></span></code></pre></div></div>
</li>
<li>
<p>Chạy thử trên máy mình trước. Cả bài này thật ra chỉ xoay quanh bước này:</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">docker compose -f docker-compose.build.yaml build</span></span></code></pre></div></div>
</li>
<li>
<p>Khi bước trên đã chạy ổn, workflow của GitHub Actions (hay thứ tương đương trên bất kỳ nền tảng nào khác) chỉ còn đóng vai trò điều phối:</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">CI/CD Pipeline</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</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">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@v2</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">Set up Docker Buildx</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">docker/setup-buildx-action@v1</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 and Test with Docker Compose</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">docker compose -f docker-compose.build.yaml build</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">Push the image referenced in the docker compose file</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">docker compose -f docker-compose.build.yaml push</span></span></span></code></pre></div></div>
</li>
</ol>
<p>Như vậy công cụ CI giờ chỉ làm hai việc là dựng và đẩy image lên. Mọi thứ đáng để hiểu đều nằm trong các tệp mà nền tảng nào cũng chạy trực tiếp được, và thành viên mới vào đội cũng vậy.</p>

<h4 class="relative group">Đẩy image lên kho lưu trữ
    <div id="đẩy-image-lên-kho-lưu-trữ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-image-l%c3%aan-kho-l%c6%b0u-tr%e1%bb%af" aria-label="Cọc dấu">#</a>
    </span>
    
</h4>
<p>Một cái lợi đi kèm của tệp Compose là việc đẩy image trở nên cực kỳ đơn giản. Docker tự biết image sẽ được đẩy lên đâu dựa vào tên của nó: <code>image: myprivaterepo.com/image:latest</code> nghĩa là image sẽ được đẩy lên <code>myprivaterepo.com</code>. Nếu dùng kho lưu trữ image riêng (private registry) thì bạn sẽ cần token truy cập, phần này bạn xem thêm tài liệu của kho lưu trữ mình đang dùng nhé.</p>
<p>Nếu bạn đang dùng GitHub Actions và thấy ổn thì cũng chẳng có gì phải vội. Nhưng đến ngày cần đổi nhà cung cấp, việc chuyển đổi chỉ là chép lại hai câu lệnh, chứ không phải chuyển ba trăm dòng YAML sang định dạng mới. Còn từ giờ đến lúc đó, ít ra bạn cũng được gỡ lỗi bản dựng ngay trên máy mình, đàng hoàng như một người văn minh.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
