<?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>Cloudflare · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/cloudflare/</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>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/cloudflare/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Self host ngay trên laptop: public localhost miễn phí bằng Cloudflare Tunnel</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>Cloudflare Tunnel là gì và có miễn phí không? Cách public localhost ra Internet với tên miền HTTPS thật, không mở cổng nào, dùng allowlist trong ingress và Docker Compose.</description>
      <content:encoded><![CDATA[<p>Ý tưởng nào của tôi cũng bắt đầu giống nhau: nó chạy trên laptop, nó chạy được, và tôi muốn người khác xem. Một thời gian dài, bước tiếp theo luôn là một hóa đơn cloud. Một máy chủ, một cơ sở dữ liệu, một bucket, một tên miền trỏ vào đó, và một khoản phí hằng tháng cho thứ mà chỉ ba người ngó tới. Với một dự án phụ, hay tháng đầu tiên của một dự án thật, đó là sai thứ tự: bạn trả tiền cho production trước khi biết liệu có production hay không.</p>
<p>Bài này đi theo thứ tự ngược lại. Dịch vụ vẫn chạy trên chính cái máy đã dựng nên nó, và một Cloudflare Tunnel cho nó một tên miền HTTPS công khai. Không có gì trên laptop lắng nghe Internet: không chuyển tiếp cổng (port forwarding), không chỉnh router, không lộ địa chỉ IP. Máy chủ động gọi ra Cloudflare, và Cloudflare gửi request ngược xuống qua chính kết nối đó. Tunnel thì miễn phí, còn chứng chỉ TLS, chống DDoS và cache đi kèm với zone Cloudflare mà có lẽ bạn đã có sẵn.</p>
<p>Ví dụ ở đây là thật. Một <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/" >sản phẩm của tôi</a> chạy trên Fly.io, và ứng dụng di động của nó nhận bản cập nhật JavaScript qua mạng (OTA) từ backend (Expo Updates tự host). Tôi muốn có một bản sao thứ hai của máy chủ cập nhật đó chạy trên Mac cho các bản build phát triển, còn production thì giữ nguyên. Các kết quả bên dưới lấy từ buổi chiều hôm đó, kể cả đoạn nó không chạy.</p>

<h2 class="relative group">Cloudflare Tunnel thực ra là gì
    <div id="cloudflare-tunnel-thực-ra-là-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="#cloudflare-tunnel-th%e1%bb%b1c-ra-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một máy chủ bình thường ngồi chờ kết nối. Muốn chạm tới laptop từ Internet, bạn phải mở một cổng trên router, chuyển tiếp nó về laptop, rồi cầu mong nhà mạng cấp cho bạn một địa chỉ IPv4 công khai (ở nhiều nơi, kể cả chỗ tôi, thì không). Sau đó mọi thứ lắng nghe trên cổng ấy đều bị mọi máy quét trên Internet chạm tới, cả ngày.</p>
<p>Tunnel đảo ngược chuyện đó. <code>cloudflared</code> chạy cạnh ứng dụng của bạn và mở các kết nối đi ra tới edge của Cloudflare, bốn kết nối tới hai trung tâm dữ liệu gần nhất. Một bản ghi DNS trong zone của bạn trỏ một tên miền vào tunnel. Khi có request tới tên miền đó, Cloudflare kết thúc TLS rồi chuyển request xuống một trong các kết nối ấy. <code>cloudflared</code> sau đó giao nó cho dịch vụ cục bộ mà cấu hình của bạn chỉ định.</p>
<figure class="diagram">
  <div role="img" aria-label="Một request đi qua HTTPS tới Cloudflare, qua các quy tắc của nó, tới điểm cuối của tunnel, rồi xuống một kết nối đi ra mà cloudflared đã mở từ máy bạn; cloudflared gửi nó tới API, edge, hoặc trả 404 tùy theo tên miền và đường dẫn"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">Internet</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">Máy của bạn</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="19">Điện thoại / trình duyệt</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="19">TLS, quy tắc redirect, WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">Điểm cuối tunnel</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">QUIC đi ra</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">(không mở cổng)</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">quy tắc ingress: allowlist tên miền + đường dẫn</text>
</svg>
</div><figcaption>Máy chỉ kết nối đi ra. Các quy tắc của zone Cloudflare chạy trước tunnel; các quy tắc ingress quyết định thứ gì chạm tới được.</figcaption>
</figure>

<p>Về bảo mật, điểm quan trọng là router của bạn không có quy tắc chiều vào nào và địa chỉ IP của bạn không bao giờ xuất hiện trong DNS. Ai quét đường mạng nhà bạn cũng chẳng thấy gì, vì không có gì trên đó đang lắng nghe.</p>

<h2 class="relative group">Thiết lập, từ đầu đến cuối
    <div id="thiết-lập-từ-đầu-đến-cuố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="#thi%e1%ba%bft-l%e1%ba%adp-t%e1%bb%ab-%c4%91%e1%ba%a7u-%c4%91%e1%ba%bfn-cu%e1%bb%91i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bạn cần một tên miền trên Cloudflare (gói miễn phí là đủ) và cài <code>cloudflared</code> (<code>brew install cloudflared</code> trên Mac).</p>
<p><strong>1. Đăng nhập một lần.</strong> Lệnh này mở trình duyệt, bạn chọn zone, và nó lưu một chứng chỉ cho phép máy này tạo tunnel và bản ghi DNS trong zone đó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. Tạo tunnel.</strong> Tôi để mọi thứ của dự án này trong một thư mục riêng, vì tệp credentials chính là danh tính của tunnel: ai có nó đều phục vụ được lưu lượng cho các tên miền của bạ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">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. Trỏ tên miền vào nó.</strong> Mỗi tên miền một lệnh, tạo một CNAME có proxy tới <code>&lt;tunnel-id&gt;.cfargotunnel.com</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. Viết các quy tắc ingress.</strong> Đây là phần quyết định về bảo mật, nên nó có hẳn một mục riêng bên dưới.</p>
<p><strong>5. Chạy nó.</strong> Ứng dụng của tôi vốn đã chạy bằng Docker Compose, nên <code>cloudflared</code> chỉ là thêm một container trên cùng mạng. Nó gọi ứng dụng bằng tên service, và ứng dụng hoàn toàn không cần publish cổng nào ra máy host:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>Bốn kết nối tới Seoul, và laptop đã có mặt trên Internet, nhìn từ ngoài vào. <code>restart: unless-stopped</code> đưa nó trở lại sau khi khởi động lại máy, ngay khi Docker chạy.</p>

<h2 class="relative group">Mở đường dẫn, đừng mở cả máy chủ
    <div id="mở-đường-dẫn-đừng-mở-cả-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%9f-%c4%91%c6%b0%e1%bb%9dng-d%e1%ba%abn-%c4%91%e1%bb%abng-m%e1%bb%9f-c%e1%ba%a3-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là lỗi tôi suýt mắc, và tôi đoán là lỗi phổ biến nhất. Cấu hình dễ nhất chỉ có một dòng: <code>hostname → http://localhost:8000</code>. Như thế là công khai <strong>toàn bộ máy chủ phát triển</strong>.</p>
<p>Máy chủ phát triển không được làm ra cho việc đó. Máy của tôi chạy với <code>DEBUG=True</code>, hiện đầy đủ traceback và settings mỗi khi có lỗi. Nó còn chạy với xác thực bị tắt (<code>AUTH_MODE=disabled</code>), nên bearer token nào cũng đăng nhập bạn thành một user test. Trên <code>localhost</code> thì vậy là đúng ý, còn trên một tên miền công khai thì là thảm họa. Máy của bạn chắc cũng có thứ tương tự: một trang admin với mật khẩu mặc định, một endpoint nạp dữ liệu mẫu, một debug toolbar, hay một API tin người gọi vì xưa nay chỉ có bạn gọi nó.</p>
<p>Vì vậy cấu hình tunnel là một danh sách cho phép (allowlist). <code>cloudflared</code> so mỗi request với các quy tắc ingress theo thứ tự, theo tên miền và theo một biểu thức chính quy trên đường dẫn. Quy tắc khớp đầu tiên thắng, và quy tắc cuối cùng hứng mọi thứ còn lạ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">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>Endpoint phát hành nằm trên cùng máy chủ, dưới cùng tiền tố (<code>/api/expo-updates/import-update/</code>), và nó không có trong danh sách. Trang admin hay phần còn lại của API cũng không. Những request đó không bao giờ ra khỏi Cloudflare.</p>
<p>Bạn không cần phải tin regex của mình; <code>cloudflared</code> sẽ cho bạn biết một URL khớp quy tắc nào:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>Tôi sẽ đưa bước kiểm tra này vào một script và chạy mỗi lần cấu hình thay đổi. Nó mất vài giây, còn một regex khớp rộng hơn ý bạn là kiểu lỗi mà bạn chỉ biết được qua người khác.</p>
<p>Thêm hai nguyên tắc nữa:</p>
<ul>
<li><strong>Thứ bạn mở ra phải tự xác thực.</strong> Các endpoint OTA phục vụ dữ liệu công khai, chỉ đọc. Check-in của thiết bị được ký bằng khóa riêng của từng thiết bị. Không cái nào dựa vào mạng để giữ an toàn. Nếu muốn cho ai đó xem một trang không có đăng nhập, hãy đặt <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a> trước tên miền đó (miễn phí tới 50 người dùng) thay vì tự dựng trang đăng nhập.</li>
<li><strong>Đừng để tunnel chạy ngầm như một phần quen thuộc của quy trình dev.</strong> Chạy nó có chủ đích, cho đúng tên miền bạn cần, và tắt khi xong việc. <code>docker compose stop cloudflared</code> gỡ mọi thứ khỏi Internet chỉ bằng một lệnh.</li>
</ul>

<h2 class="relative group">Cú redirect nuốt mất tên miền của tôi
    <div id="cú-redirect-nuốt-mất-tên-miền-của-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%ba-redirect-nu%e1%bb%91t-m%e1%ba%a5t-t%c3%aan-mi%e1%bb%81n-c%e1%bb%a7a-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tunnel đã kết nối, các quy tắc đã kiểm tra, tôi thử từ bên ngoài:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>Đường dẫn nào cũng trả về cùng một mã 301, kể cả những đường dẫn mà tunnel lẽ ra phải trả 404. Nghĩa là request chưa bao giờ tới được <code>cloudflared</code>. Header <code>Location</code> ghi <code>https://www.example.com/</code>, và header <code>server: cloudflare</code> chốt lại: chính Cloudflare đang tự trả lời.</p>
<p>Tôi tự gây ra chuyện này. Mấy tháng trước tôi thêm một redirect rule ở cấp zone, &ldquo;mọi subdomain khác → www&rdquo;, làm <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >hố chặn cho các máy quét dò những subdomain bịa ra</a>. Redirect rule chạy trong pipeline xử lý request của Cloudflare trước khi request được gửi tới origin, mà tunnel thì cũng chỉ là một origin. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Chính quy tắc đó đã tóm các môi trường preview của tôi</a> trước khi Workers kịp thấy chúng. Cách sửa vẫn như lần trước: thêm một ngoại lệ vào biểu thức của quy 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">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>Nếu tên miền tunnel của bạn trả về một redirect hay một trang lỗi của Cloudflare thay vì ứng dụng của bạn, hãy kiểm tra <strong>Rules → Redirect Rules</strong>, Page Rules và mọi WAF custom rule của zone trước khi đi debug tunnel. <code>cloudflared</code> ghi log mọi request nó chuyển tiếp, nên log trống nghĩa là request chưa đi được tới đó.</p>

<h2 class="relative group">Những gì nó không làm được
    <div id="những-gì-nó-không-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="#nh%e1%bb%afng-g%c3%ac-n%c3%b3-kh%c3%b4ng-l%c3%a0m-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li><strong>UDP từ Internet công khai.</strong> Tên miền tunnel công khai chỉ mang HTTP(S) (cùng WebSocket và gRPC). Thiết bị của sản phẩm tôi bình thường check-in bằng CoAP trên UDP, và thứ đó không đi qua tunnel được; các thiết bị trên bản sao cục bộ chuyển sang check-in qua HTTPS, mỗi lần tốn thêm vài giây. Nếu ý tưởng của bạn là game server hay VoIP, tunnel không phải công cụ phù hợp.</li>
<li><strong>Uptime.</strong> Đây là laptop của bạn. Gập máy là site sập; Cloudflare hiện trang lỗi 530. Với một bản demo, một bản beta mười người, hay một bản staging thì không sao. Với thứ gì có người phụ thuộc vào thì không ổn.</li>
<li><strong>Những thứ đã đóng cứng vào bản build.</strong> URL cập nhật của ứng dụng di động là cấu hình native, nên chỉ những bản build tạo với URL của tunnel mới dùng nó. Các bản build trên store của tôi vẫn nói chuyện với production, và đó là lý do thử cách này là an toàn.</li>
</ul>

<h2 class="relative group">Chi phí: Cloudflare Tunnel có miễn phí không?
    <div id="chi-phí-cloudflare-tunnel-có-miễn-phí-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="#chi-ph%c3%ad-cloudflare-tunnel-c%c3%b3-mi%e1%bb%85n-ph%c3%ad-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một zone Cloudflare gói miễn phí, tunnel, chứng chỉ của nó và các bản ghi DNS: 0 đô. Máy thì vốn đã chạy sẵn. Chi phí thật duy nhất là giờ bạn chính là đội lo uptime.</p>
<p>Thứ nó tiết kiệm cho bạn là giai đoạn giữa &ldquo;chạy được trên máy tôi&rdquo; và &ldquo;đáng bỏ tiền ra host&rdquo;. Ngay chiều hôm đó bạn đã có thể gửi link cho một tester, một designer hay khách hàng đầu tiên. Nếu không ai quan tâm, bạn chẳng mất gì. Nếu có, chuyển sang một host thật chỉ là một thay đổi: trỏ CNAME sang chỗ khác.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Lỗi 504 Cloudflare không phải sự cố thật: Early Hints và HTTP 103</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/</guid>
      <pubDate>Wed, 04 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Early Hints</category>
      <category>HTTP 103</category>
      <category>504 Gateway Timeout</category>
      <category>Hiệu năng web</category>
      <category>Preload</category>
      <category>Khả năng quan sát</category>
      <category>Gỡ lỗi</category>
      <description>Hàng nghìn lỗi 504 trên Cloudflare, nhưng không lỗi nào đến tay người dùng thật: tất cả đều là cache miss của Early Hints. Bài viết giải thích HTTP 103 hoạt động thế nào, cách phân biệt 504 giả với 504 thật, và header đã giải quyết vấn đề.</description>
      <content:encoded><![CDATA[<p>Bảng điều khiển của Cloudflare báo một phần tư số request của tôi là 504. Không một lỗi nào trong số đó đến tay người dùng: chúng là cache miss của tính năng Early Hints trên Cloudflare, được ghi thành 504 với <code>ClientRequestSource = earlyHintsCache</code> và <code>OriginResponseStatus = 0</code>. Bài này nói cách phân biệt chúng với 504 thật, và một header duy nhất biến những lần miss đó thành hit.</p>
<p>Tôi gặp chúng lần đầu vào tháng 9, khi <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >bảng điều khiển Cloudflare báo 22% request bị lỗi</a> trong khi mọi health check vẫn xanh; tôi tắt Early Hints và chuyển sang việc khác. Đến tháng 11, cũng zone đó lại có thêm 2.600 lỗi 504 trong một ngày, Early Hints lại đang bật, và lần này tôi muốn biết chính xác chúng là gì, cũng như cách đúng là tắt tính năng thêm lần nữa hay làm cho nó thực sự làm đúng việc. Zone này đứng trước một origin trên Fly.io, phục vụ một Django API, một web app viết bằng Expo và một trang marketing dựng bằng Hugo.</p>

<h2 class="relative group">Câu trả lời ngắn
    <div id="câu-trả-lời-ngắ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="#c%c3%a2u-tr%e1%ba%a3-l%e1%bb%9di-ng%e1%ba%afn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li>Những lỗi 504 này được ghi bởi bộ cache Early Hints của Cloudflare, không phải bởi khách truy cập. Trong log của Cloudflare, chúng có <code>ClientRequestSource = earlyHintsCache</code>.</li>
<li>Ở đây, 504 nghĩa là cache miss: Cloudflare không có gợi ý nào được lưu cho URL đó. Một lần hit được ghi là 2xx.</li>
<li>Chúng không bao giờ chạm tới origin của bạn. <code>OriginResponseStatus</code> là <code>0</code>.</li>
<li>Early Hints chạy bằng response header HTTP <code>Link</code>. Các thẻ <code>&lt;link rel=&quot;preload&quot;&gt;</code> trong HTML không cung cấp dữ liệu cho nó.</li>
<li>Gửi header đó đi, và trên những trang có thứ cần preload, các lần miss sẽ chuyển thành hit và bắt đầu có phản hồi <code>103 Early Hints</code> thật.</li>
</ul>
<p>Có thêm ba điều đáng biết ngay từ đầu. Chỉ gợi ý CSS, script và font quan trọng cho việc render, và giữ <code>crossorigin</code> cho font. Kiểm tra bằng <code>curl -sv --http2</code>: bạn sẽ thấy <code>103</code> trước <code>200</code>. Và dù quyết định thế nào, hãy để <code>earlyHintsCache</code> ra ngoài hệ thống cảnh báo; nếu bạn sẽ không bao giờ gửi header, hãy tắt Early Hints thay vì trả giá bằng tiếng ồn trong log.</p>

<h2 class="relative group">Cách phân biệt 504 Cloudflare giả với 504 thật
    <div id="cách-phân-biệt-504-cloudflare-giả-với-504-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1ch-ph%c3%a2n-bi%e1%bb%87t-504-cloudflare-gi%e1%ba%a3-v%e1%bb%9bi-504-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn dùng Logpush hoặc Instant Logs, hãy xem hai trường trước tiên. <code>ClientRequestSource</code> là <code>eyeball</code> với request của khách truy cập thật, và là <code>earlyHintsCache</code> với các lượt tra cứu Early Hints. <code>OriginResponseStatus</code> bằng <code>0</code> nghĩa là origin chưa từng được liên hệ.</p>
<p>Trong GraphQL Analytics API, sự phân tách đó chính là dimension <code>requestSource</code>. Đây là truy vấn tôi luôn chạy trước khi tin vào bất kỳ đợt tăng vọt 5xx nào:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-graphql" data-lang="graphql"><span class="line"><span class="cl"><span class="kd">query</span><span class="w"> </span><span class="p">(</span><span class="nv">$zone</span><span class="p">:</span><span class="w"> </span><span class="nc">String</span><span class="p">!,</span><span class="w"> </span><span class="nv">$since</span><span class="p">:</span><span class="w"> </span><span class="nc">Time</span><span class="p">!)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="py">viewer</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="py">zones</span><span class="p">(</span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">zoneTag</span><span class="p">:</span><span class="w"> </span><span class="nv">$zone</span><span class="w"> </span><span class="p">})</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="py">httpRequestsAdaptiveGroups</span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">limit</span><span class="p">:</span><span class="w"> </span><span class="nc">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">filter</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">datetime_geq</span><span class="p">:</span><span class="w"> </span><span class="nv">$since</span><span class="p">,</span><span class="w"> </span><span class="nc">edgeResponseStatus</span><span class="p">:</span><span class="w"> </span><span class="nc">504</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">orderBy</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="py">count_DESC</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">count</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="py">dimensions</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="py">requestSource</span><span class="w"> </span><span class="py">userAgent</span><span class="w"> </span><span class="py">clientRequestPath</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Nhóm theo User-Agent cũng cho ra cùng một câu chuyện. Vào tháng 9, các lượt tra cứu Early Hints hiện lên dưới dạng <code>bastion early hints</code> và <code>nginx-ssl early hints</code>. Đến tháng 11 thì là <code>fl2 early hints</code> và <code>nginx-ssl early hints</code> (FL2 là thế hệ proxy mới của Cloudflare). Trong 24 giờ, toàn bộ 2.631 lỗi 504 của zone đều đến từ những User-Agent này. Không có lỗi nào đến từ trình duyệt, ứng dụng, curl hay crawler của công cụ tìm kiếm.</p>
<p>Một lỗi 504 thật mà khách truy cập nhìn thấy thì hầu như luôn đến từ phía bạn, sau Cloudflare: một proxy hay load balancer đứng trước ứng dụng đã chờ quá lâu và bỏ cuộc. Đó có thể là nginx, một load balancer trên cloud, hoặc proxy của nền tảng đang chờ một máy đang ngủ khởi động. Các timeout của chính Cloudflare dùng mã khác: <strong>522</strong> nghĩa là không kết nối được tới origin, còn <strong>524</strong> nghĩa là đã kết nối nhưng không nhận được phản hồi trong vòng 125 giây (mặc định trên các gói dưới Enterprise).</p>
<p>Vậy nếu bạn tìm đến đây vì muốn tăng timeout của Cloudflare do lỗi 504: timeout đứng sau một lỗi 504 gần như không bao giờ là của Cloudflare. Hãy kiểm tra <code>proxy_read_timeout</code> trong nginx, idle timeout của load balancer, hoặc thời gian nền tảng của bạn cần để đánh thức một máy. Một manh mối hữu ích: nếu request thất bại ở một con số tròn thấp hơn hẳn 125 giây, như 30 hay 60, thì đó là giới hạn của proxy của bạn, không phải của Cloudflare. (Chỉ gói Enterprise mới nâng được giới hạn 125 giây của Cloudflare, tối đa 6.000 giây, và chính Cloudflare cũng coi đó là giải pháp tạm thời.)</p>

<h2 class="relative group">Bản chất thật của những lỗi 504 này
    <div id="bản-chất-thật-của-những-lỗi-504-nà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="#b%e1%ba%a3n-ch%e1%ba%a5t-th%e1%ba%adt-c%e1%bb%a7a-nh%e1%bb%afng-l%e1%bb%97i-504-n%c3%a0y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên một zone đã bật Early Hints, mỗi request tới một trang HTML hoặc một URL không có phần mở rộng (trên một zone nặng về API thì phần lớn lời gọi API đều thuộc diện này) đều kèm thêm một lượt tra cứu nội bộ: Cloudflare kiểm tra cache của chính nó xem có gợi ý nào để gửi không. Lượt tra cứu đó được ghi thành một dòng riêng, với <code>ClientRequestSource = earlyHintsCache</code>. Nó không phải request của người dùng cuối, và không bao giờ được gửi tới origin của bạn.</p>
<p>Mã trạng thái trên dòng đó là kết quả tra cứu cache, không phải lỗi:</p>
<ul>
<li>2xx là hit. Cloudflare có lưu header <code>Link</code> cho URL đó và đã gửi chúng tới trình duyệt dưới dạng <code>103</code>. Trên zone của tôi, hit xuất hiện dưới cả hai mã <code>200</code> và <code>204</code>.</li>
<li>504 là miss. Không có gì được lưu, nên không có gì để gửi sớm.</li>
</ul>
<p>Nếu origin không bao giờ gửi header mà Early Hints dựa vào, mọi lượt tra cứu đều miss và mọi request đều ghi một dòng 504. Trong ba giờ trước khi tôi thay đổi bất cứ điều gì, zone ghi nhận 173 lượt tra cứu Early Hints và cả 173 đều là miss. Kể từ khi được bật, tính năng này chưa từng gửi đi một gợi ý nào.</p>
<p>Một điều chỉnh cho bài viết tháng 9 của tôi: khi đó tôi mô tả chúng là các probe &ldquo;không nhận được phản hồi từ origin trên Fly&rdquo;. Điều đó không hoàn toàn đúng. Chúng hoàn toàn không đi tới origin. Mã 504 ghi lại một lần cache miss, không phải một lần timeout.</p>
<p>Cloudflare có nói ngắn gọn về chuyện này trong <a href="https://developers.cloudflare.com/cache/advanced-configuration/early-hints/"  target="_blank" rel="noreferrer">tài liệu Early Hints</a>: một lượt tra cứu Early Hints được ghi với <code>ClientRequestSource: earlyHintsCache</code> là &ldquo;expected and benign&rdquo; (nằm trong dự kiến và vô hại), 200 là cache hit còn 504 là cache miss, và trong Log Explorer bạn lọc chúng ra bằng <code>ClientRequestSource != &quot;earlyHintsCache&quot;</code>. Cache Analytics vốn đã bỏ chúng ra ngoài. Còn biểu đồ lưu lượng HTTP làm tôi hoảng thì không.</p>

<h2 class="relative group">Lỗi 504 giả đã khiến tôi nâng cấp máy chủ
    <div id="lỗi-504-giả-đã-khiến-tôi-nâng-cấp-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%97i-504-gi%e1%ba%a3-%c4%91%c3%a3-khi%e1%ba%bfn-t%c3%b4i-n%c3%a2ng-c%e1%ba%a5p-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là lý do việc tách bạch quan trọng. Ngày 3 tháng 11, một ngày trước khi tôi bắt tay vào bài này, Cloudflare ghi nhận 3.292 lỗi 504 trên khoảng 13.300 request của khách truy cập: tỷ lệ lỗi bề ngoài là 25%. API chạy trên máy <code>shared-cpu-1x</code> của Fly.io, loại máy hoạt động theo burst credit: nó chỉ được bảo đảm một phần nhỏ của CPU và phải tiêu credit để chạy nhanh hơn. Một bài kiểm thử tải hồi tháng 8 cho thấy khi tải kéo dài, máy bị bóp xuống khoảng 20 đến 30 request mỗi giây. &ldquo;Một phần tư số request bị timeout vào giờ cao điểm&rdquo; trông đúng y như kiểu bóp CPU đó, nên tôi chuyển API sang <code>shared-cpu-2x</code>.</p>
<p>Nhưng tất cả những lỗi 504 đó đều là các lần miss của cache Early Hints. Khi tách theo <code>requestSource</code> và xem lại log của chính origin:</p>
<ul>
<li>Mọi lỗi 5xx của Cloudflare từ 28/10 đến 4/11 đều là lượt tra cứu <code>earlyHintsCache</code>, trừ khoảng một lỗi 525 mỗi ngày (lỗi 525 tôi lần ra đến từ một trình quét bảo mật).</li>
<li>Log truy cập của origin từ 24/10 đến 4/11 không có lỗi 5xx nào, và chỉ có một request chậm hơn 10 giây.</li>
<li>Số liệu của Fly cho thấy lúc cao nhất cũng chỉ khoảng 7 request mỗi giây, và máy chưa bao giờ dùng quá một phần năm của một lõi CPU. Nó chưa bao giờ tiến gần mức trần bị bóp.</li>
</ul>
<p>Máy lớn hơn chẳng sửa được gì, vì vốn không có gì hỏng. API đã quay lại <code>shared-cpu-1x</code>.</p>
<p>Đừng ra quyết định về năng lực xử lý dựa trên một biểu đồ 5xx chưa được tách theo request source. Hãy đối chiếu cả với log của chính origin: nếu Cloudflare báo một phần tư số request thất bại mà máy chủ của bạn không ghi nhận lỗi nào, hãy tìm hiểu Cloudflare đang đếm cái gì trước khi động vào máy chủ.</p>
<p><em>Cập nhật ngày 5/11/2026: phiên bản đầu của phần này viết rằng tuần đó API thực sự đã sập trên <code>shared-cpu-1x</code>. Thực ra không phải vậy. Các số liệu ở trên là những gì tôi tìm thấy khi kiểm tra lại.</em></p>

<h2 class="relative group">HTTP 103 có phải là lỗi không?
    <div id="http-103-có-phải-là-lỗi-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="#http-103-c%c3%b3-ph%e1%ba%a3i-l%c3%a0-l%e1%bb%97i-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Không. Các mã trạng thái trong dải 1xx là phản hồi thông tin: những phản hồi tạm thời mà máy chủ có thể gửi trước phản hồi thật. <code>103 Early Hints</code>, được định nghĩa trong RFC 8297, mang theo header <code>Link</code> để trình duyệt có thể bắt đầu tải CSS, JavaScript và font quan trọng, hoặc mở sẵn kết nối tới các origin khác, trong khi máy chủ vẫn đang dựng trang. Mã <code>200</code> cuối cùng sẽ đến sau trên cùng một request.</p>
<p>Mức hỗ trợ không đồng đều nhưng an toàn. Các trình duyệt dựa trên Chromium (Chrome từ phiên bản 103, và Edge) cùng Firefox bản mới xử lý cả gợi ý <code>preload</code> lẫn <code>preconnect</code>. Safari (từ phiên bản 17) chỉ dùng <code>preconnect</code>. Trình duyệt chỉ xử lý <code>103</code> qua HTTP/2 và HTTP/3. Trình duyệt nào không hiểu nó thì đơn giản là chờ <code>200</code> như mọi khi, nên <code>103</code> là một lợi thế xuất phát sớm cho phần lớn khách truy cập và không ảnh hưởng gì tới số còn lại.</p>

<h2 class="relative group">Vì sao của tôi trống trơn: header <code>Link</code> so với <code>&lt;link rel=preload&gt;</code>
    <div id="vì-sao-của-tôi-trống-trơn-header-link-so-với-link-relpreload" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-c%e1%bb%a7a-t%c3%b4i-tr%e1%bb%91ng-tr%c6%a1n-header-link-so-v%e1%bb%9bi-link-relpreload" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là chỗ tôi mất nhiều thời gian nhất, vì trông nó như thể lẽ ra đã hoạt động rồi.</p>
<p>Early Hints chỉ cache đúng một thứ: <strong>response header HTTP <code>Link</code></strong> có quan hệ <code>rel=preload</code> hoặc <code>rel=preconnect</code>. Cloudflare đọc nó từ các phản hồi thông thường của bạn (mã <code>200</code>, <code>301</code> hoặc <code>302</code>, trên URL HTML hoặc không có phần mở rộng), lưu lại, rồi phát lại cho khách truy cập tiếp theo dưới dạng <code>103</code> trước khi phản hồi đầy đủ sẵn sàng.</p>
<p>Các trang của tôi vốn đã có preload, nhưng ở dạng thẻ trong <code>&lt;head&gt;</code> của HTML:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/css/global-7d77bd.css&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;style&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/_expo/static/js/web/entry-fd7fa4.js&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;script&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">link</span> <span class="na">rel</span><span class="o">=</span><span class="s">&#34;preload&#34;</span> <span class="na">href</span><span class="o">=</span><span class="s">&#34;/fonts/manrope-latin.woff2&#34;</span> <span class="na">as</span><span class="o">=</span><span class="s">&#34;font&#34;</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;font/woff2&#34;</span> <span class="na">crossorigin</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>Những thẻ này vẫn có tác dụng: trình duyệt hành động theo chúng. Nhưng trình duyệt chỉ thấy chúng sau khi đã nhận HTML và bắt đầu phân tích, mà đó lại chính là khoảnh khắc Early Hints muốn đi trước. Bản export của Expo và theme Hugo tôi dùng đều tự viết các thẻ này, nhưng không cái nào đặt header. Vậy là tính năng đã bật, preload cũng đã khai báo, nhưng hai thứ chưa bao giờ gặp nhau.</p>

<h2 class="relative group">Cách sửa: gửi header từ dữ liệu bạn vốn đã có
    <div id="cách-sửa-gửi-header-từ-dữ-liệu-bạn-vốn-đã-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="#c%c3%a1ch-s%e1%bb%ada-g%e1%bb%adi-header-t%e1%bb%ab-d%e1%bb%af-li%e1%bb%87u-b%e1%ba%a1n-v%e1%bb%91n-%c4%91%c3%a3-c%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách sạch nhất không phải là tự tay duy trì một danh sách tài nguyên quan trọng, thứ sẽ lỗi thời mỗi khi hash của bundle thay đổi. Mỗi trang đã tự khai báo những gì nó muốn preload, trong chính các thẻ đó. Vậy chỉ cần chép chúng sang header <code>Link</code> khi phục vụ trang. <code>html.parser</code> trong thư viện chuẩn xử lý được cả thuộc tính có và không có dấu ngoặc kép (minifier của Hugo bỏ dấu ngoặc đi), nên không có regex nào để viết sai:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">html.parser</span> <span class="kn">import</span> <span class="n">HTMLParser</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">PreloadLinks</span><span class="p">(</span><span class="n">HTMLParser</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="fm">__init__</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">handle_starttag</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tag</span><span class="p">,</span> <span class="n">attrs</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">a</span> <span class="o">=</span> <span class="nb">dict</span><span class="p">(</span><span class="n">attrs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">tag</span> <span class="o">!=</span> <span class="s2">&#34;link&#34;</span> <span class="ow">or</span> <span class="s2">&#34;preload&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;rel&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">split</span><span class="p">()</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;href&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="cl">        <span class="n">hint</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;&lt;</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;href&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&gt;; rel=preload&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="sa">f</span><span class="s2">&#34;; as=</span><span class="si">{</span><span class="n">a</span><span class="p">[</span><span class="s1">&#39;as&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">a</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;as&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s2">&#34;font&#34;</span> <span class="ow">or</span> <span class="s2">&#34;crossorigin&#34;</span> <span class="ow">in</span> <span class="n">a</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">hint</span> <span class="o">+=</span> <span class="s2">&#34;; crossorigin&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">hints</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">hint</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">limit</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">12</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span> <span class="o">|</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span> <span class="o">=</span> <span class="n">PreloadLinks</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">parser</span><span class="o">.</span><span class="n">feed</span><span class="p">(</span><span class="n">html</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="s2">&#34;, &#34;</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="nb">list</span><span class="p">(</span><span class="nb">dict</span><span class="o">.</span><span class="n">fromkeys</span><span class="p">(</span><span class="n">parser</span><span class="o">.</span><span class="n">hints</span><span class="p">))[:</span><span class="n">limit</span><span class="p">])</span> <span class="ow">or</span> <span class="kc">None</span></span></span></code></pre></div></div>
<p>Và gắn nó vào phản hồi HTML trong Django:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">response</span> <span class="o">=</span> <span class="n">HttpResponse</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">content_type</span><span class="o">=</span><span class="s2">&#34;text/html&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">link</span> <span class="o">:=</span> <span class="n">link_header</span><span class="p">(</span><span class="n">html</span><span class="p">))</span> <span class="ow">and</span> <span class="s2">&#34;Link&#34;</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">response</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Link&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">link</span></span></span></code></pre></div></div>
<p>Có một chi tiết đã làm tôi vấp, và đáng để nhớ: giữ <code>crossorigin</code> cho font. Một preload font thiếu nó sẽ bị coi là một request khác, font bị tải về hai lần, và trang chậm đi chứ không nhanh hơn.</p>
<p>Bạn không cần tự viết <code>103</code>. Không có đoạn mã phản hồi tạm thời nào phải thêm vào ASGI, Granian hay uvicorn. Cloudflare dựng <code>103</code> từ header của bạn; việc duy nhất của bạn là gửi header đó.</p>

<h2 class="relative group">Trên các stack khác
    <div id="trên-các-stack-khác" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%c3%aan-c%c3%a1c-stack-kh%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Quy tắc ở đâu cũng vậy: đặt header <code>Link</code> lên phản hồi HTML và để Cloudflare lo phần còn lại.</p>
<ul>
<li>
<p>nginx, trong location phục vụ HTML:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="cl"><span class="k">location</span> <span class="p">=</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/css/app.css&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=style&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kn">add_header</span> <span class="s">Link</span> <span class="s">&#34;&lt;/fonts/inter.woff2&gt;</span><span class="p">;</span> <span class="kn">rel=preload</span><span class="p">;</span> <span class="kn">as=font</span><span class="p">;</span> <span class="kn">crossorigin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
</li>
<li>
<p>Laravel dùng Vite đã có sẵn middleware cho việc này, <code>AddLinkHeadersForPreloadedAssets</code>, chuyển các preload của Vite thành header <code>Link</code>. Hai vấn đề từng được báo cáo với nó cũng chính là những cái bẫy ở trên: các gợi ý <code>modulepreload</code> của nó từng được gửi đi mà không có <code>crossorigin</code>, khiến trình duyệt có thể bỏ qua chúng và tải script hai lần, và trên các trang lớn, header từng vượt quá giới hạn header khoảng 8 KB của Apache. Hãy kiểm tra cả hai trên phiên bản bạn đang dùng.</p>
</li>
<li>
<p>Mọi môi trường khác: dùng một header <code>Link</code> phân tách bằng dấu phẩy, hoặc gửi nhiều header <code>Link</code>. Cách nào cũng được.</p>
</li>
</ul>
<p>Chỗ mọi người hay làm quá tay là chọn gợi ý những gì. Chỉ gợi ý CSS quan trọng cho việc render, script đầu vào và một hai font dùng ở màn hình đầu tiên, đừng nhét mọi chunk vào. Danh sách dài khiến trình duyệt đi tải những thứ chưa cần trước cả những thứ đang cần. Tôi giới hạn ở mười hai mục, và đó là vì bản export của Expo đã chọn lọc sẵn danh sách preload.</p>
<p>Nếu bạn không đứng sau Cloudflare, hoặc CDN của bạn không hỗ trợ Early Hints, header <code>Link</code> vẫn giúp được một chút: trình duyệt có thể bắt đầu tải các tài nguyên đó ngay khi header phản hồi tới nơi, trước khi phân tích phần thân. Nhưng sẽ không có <code>103</code> nào trừ khi máy chủ hoặc CDN của bạn tự gửi.</p>

<h2 class="relative group">Cách kiểm tra xem nó đã hoạt động chưa
    <div id="cách-kiểm-tra-xem-nó-đã-hoạt-động-chưa" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-ki%e1%bb%83m-tra-xem-n%c3%b3-%c4%91%c3%a3-ho%e1%ba%a1t-%c4%91%e1%bb%99ng-ch%c6%b0a" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><code>curl</code> cho bạn thấy <code>103</code> trực tiếp. Gửi request bằng HTTP/2 và chỉ giữ lại các dòng trạng thái cùng header <code>Link</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ curl -sv --http2 -o /dev/null https://example.com/register 2&gt;&amp;1 | grep -E &#39;^&lt; (HTTP|link)&#39;
</span></span><span class="line"><span class="cl">&lt; HTTP/2 103
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; as=style; rel=preload, …
</span></span><span class="line"><span class="cl">&lt; HTTP/2 200
</span></span><span class="line"><span class="cl">&lt; link: &lt;/_expo/static/css/reset-f5d396.css&gt;; rel=preload; as=style, …</span></span></code></pre></div></div>
<p><code>103</code> đến trước, rồi mới tới <code>200</code>. Cloudflare sắp xếp lại thứ tự các thuộc tính trong gợi ý, điều này vô hại. Đừng kỳ vọng mọi request đều nhận được <code>103</code>. Trong ba lần đầu của tôi, chỉ có hai lần nhận được. Lần truy cập đầu tiên sau khi thay đổi chỉ lưu header lại, và Cloudflare bỏ qua <code>103</code> khi header của phản hồi cuối cùng đã sẵn sàng.</p>
<p>Sau đó quay lại truy vấn phân tích ở trên, lọc theo <code>requestSource: &quot;earlyHintsCache&quot;</code>, nhóm theo mã trạng thái và đường dẫn. Trên những trang bạn đã sửa, hit sẽ dần thay thế miss.</p>

<h2 class="relative group">Điều gì đã thay đổi, và điều gì còn lại
    <div id="điều-gì-đã-thay-đổi-và-điều-gì-còn-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%81u-g%c3%ac-%c4%91%c3%a3-thay-%c4%91%e1%bb%95i-v%c3%a0-%c4%91i%e1%bb%81u-g%c3%ac-c%c3%b2n-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Vài phút sau khi header được đưa lên production, zone ghi nhận những lần hit Early Hints đầu tiên trong lịch sử, và <code>curl</code> cho thấy phản hồi <code>103</code> thật trên các trang web. Một header giải quyết cùng lúc hai vấn đề: tính năng bắt đầu hoạt động, và một phần các &ldquo;lỗi 504&rdquo; biến mất.</p>
<p>Điều còn lại là một JSON API chẳng có gì để preload. Các endpoint API tiếp tục miss và tiếp tục ghi các dòng <code>504</code>, và trên một zone nặng về API thì phần này vẫn chiếm đa số. Bạn có ba lựa chọn hợp lý. Gửi header trên những trang có lợi và chấp nhận các đường dẫn API tiếp tục ghi miss; tính năng hoạt động, các biểu đồ yên tĩnh hơn nhưng không hoàn toàn im lặng. Loại <code>earlyHintsCache</code> khỏi cảnh báo 5xx và dashboard, dù bạn quyết định gì đi nữa, vì một lượt tra cứu cache nội bộ không bao giờ nên đánh thức ai dậy. Hoặc tắt Early Hints (Speed → Settings → Content Optimization trong dashboard, hoặc thiết lập <code>early_hints</code> của zone qua API) nếu bạn sẽ không bao giờ gửi header <code>Link</code>; một ứng dụng một trang có HTML rất nhỏ có thể không được lợi gì nhiều, và khi tắt thì mọi lượt tra cứu dừng lại, tiếng ồn cũng biến mất theo.</p>
<p>Tôi giữ nó bật. Các trang web giờ đã được xuất phát sớm hơn, các lần miss của API đã bị lọc khỏi mọi thứ có thể kích hoạt cảnh báo, và đó mới là điều lẽ ra tôi phải làm vào tháng 9 thay vì tắt tính năng đi.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/cloudflare-504-early-hints/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>Giám sát Django trên Fly.io: những công cụ DevOps tôi giữ lại và đã bỏ</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-monitoring-tools-two-products/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-monitoring-tools-two-products/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>DevOps</category>
      <category>Monitoring</category>
      <category>Observability</category>
      <category>Site Reliability</category>
      <category>Fly.io</category>
      <category>Django</category>
      <category>Cloudflare</category>
      <category>Sentry</category>
      <category>Crash Reporting</category>
      <category>Small Teams</category>
      <category>Tech Stack</category>
      <description>Cách tôi giám sát hai ứng dụng Django nhỏ trên Fly.io với khoảng 2 đô la mỗi tháng: lỗi gửi vào chat, nhật ký lưu trên S3 truy vấn bằng DuckDB, chỉ số của Fly, báo cáo crash, và những gì tôi đã bỏ.</description>
      <content:encoded><![CDATA[<p>Tôi có hai sản phẩm nhỏ: một ứng dụng sàn giao dịch kiêm cộng đồng mà tôi đang làm, và một ứng dụng báo giá cho thợ làm nghề phải đo đạc, báo giá ngay tại nhà khách. Cả hai đều chạy trên loại máy rẻ nhất của Fly.io. Hệ thống giám sát DevOps của tôi rốt cuộc gói gọn trong năm thứ: một kênh chat nhận mọi nhật ký (log) mức ERROR, toàn bộ nhật ký được lưu vào object storage và truy vấn bằng DuckDB, bộ chỉ số (metric) Prometheus có sẵn của Fly, báo cáo sự cố ứng dụng trên điện thoại gửi qua SDK mã nguồn mở của Sentry về API của chính tôi, và một bản tóm tắt mỗi sáng.</p>
<p>Không có APM thuê ngoài, không có truy vết (tracing) trên production, cũng không có bảng điều khiển (dashboard) nào mà tôi phải nhớ để mở ra xem. Thứ duy nhất tốn thêm tiền là kho nhật ký, khoảng 2 đô la Mỹ mỗi tháng. Dưới đây là danh sách đầy đủ, cùng những thứ tôi đã bỏ hoặc thôi tin tưởng, trong đó có một thứ khiến tôi mất mấy ngày đuổi theo một sự cố mà chưa từng có người dùng nào gặp phải.</p>

<h2 class="relative group">Giám sát ở đây nghĩa là gì, và quy mô nhỏ đến mức nào
    <div id="giám-sát-ở-đây-nghĩa-là-gì-và-quy-mô-nhỏ-đến-mức-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="#gi%c3%a1m-s%c3%a1t-%e1%bb%9f-%c4%91%c3%a2y-ngh%c4%a9a-l%c3%a0-g%c3%ac-v%c3%a0-quy-m%c3%b4-nh%e1%bb%8f-%c4%91%e1%ba%bfn-m%e1%bb%a9c-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi nói giám sát DevOps, ý tôi là hai việc: biết có gì đó hỏng trước khi có người báo cho mình, và tìm ra nguyên nhân mà không phải đoán mò. Mọi công cụ dưới đây đều được đánh giá theo đúng hai tiêu chí đó.</p>
<p>Năm ngoái tôi có viết bài <a href="https://jared.lynskey.co.nz/vi/posts/2025/2025-10-13-devops-monitoring/" >Dựng hệ thống giám sát mà lập trình viên thật sự dùng tới</a>, chủ yếu nghĩ cho các đội: đội nhỏ thì dùng công cụ giám sát có sẵn của nhà cung cấp đám mây, khi đã có hàng trăm dịch vụ thì dùng một APM thuê ngoài. Bài này là đầu nhỏ nhất của cùng một cách nghĩ. Cả hai sản phẩm đều là Django chạy trên máy shared-cpu-1x của Fly, như tôi đã kể trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/" >Bộ công nghệ hiện tại của tôi</a>, và cả hai đều chỉ có một lập trình viên là tôi. Nếu coi giám sát là một sản phẩm và lập trình viên là người dùng của nó, thì hệ thống của tôi chỉ có đúng một người dùng: người đọc cảnh báo cũng chính là người sửa lỗi. Khi tôi lấy số liệu nền từ chỉ số của Fly vào tháng Chín, API của sàn giao dịch dùng trung bình 1% CPU. Đây không phải bộ công nghệ cho doanh nghiệp lớn, và tôi cũng không định tô vẽ nó thành như vậy.</p>
<p>Trong ba trụ cột quen thuộc là chỉ số, nhật ký và truy vết, tôi có hai. Cả hai sản phẩm đều không có truy vết phân tán (distributed tracing), vì mỗi sản phẩm chỉ là một codebase Django và một cơ sở dữ liệu, không có ranh giới dịch vụ nào để một trace phải đi qua. Dữ liệu truy vết duy nhất tôi xem là của reverse proxy trên <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >máy trạm của tôi</a>.</p>

<h2 class="relative group">Danh sách
    <div id="danh-sách" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#danh-s%c3%a1ch" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Công cụ</th>
					<th>Theo dõi cái gì</th>
					<th>Giữ hay bỏ, và vì sao</th>
					<th>Chi phí</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Kênh chat của đội, nhận dữ liệu từ một logging handler của Django</td>
					<td>Mọi lỗi ERROR từ API, máy chủ WebSocket và worker; các lần triển khai; nhập sai mã đăng nhập dồn dập</td>
					<td>Giữ. Đây là nơi duy nhất tôi tự xem mà không cần ai nhắc</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td>Log shipper của Fly đẩy sang một bucket tương thích S3, truy vấn bằng DuckDB</td>
					<td>Mọi dòng nhật ký của các ứng dụng thuộc sàn giao dịch</td>
					<td>Giữ. Bộ đệm của Fly chỉ giữ vài phút; chỗ này giữ tất cả, lại truy vấn được bằng SQL</td>
					<td>Khoảng 2 đô la/tháng, cộng phí lưu trữ</td>
			</tr>
			<tr>
					<td>Chỉ số có sẵn của Fly (Prometheus API)</td>
					<td>CPU, bộ nhớ, hạn mức burst, số phản hồi; exporter của Postgres được quản lý</td>
					<td>Giữ. Nó cho thấy các lần cơ sở dữ liệu bị treo không phải do code của tôi</td>
					<td>Có sẵn trong Fly</td>
			</tr>
			<tr>
					<td>Kiểm tra tình trạng (health check) của Fly trên <code>/api/health/</code> (cả hai ứng dụng)</td>
					<td>Một máy có nên nhận lưu lượng hay không</td>
					<td>Giữ để định tuyến. Bỏ, nếu coi nó là bằng chứng người dùng vẫn ổn</td>
					<td>Có sẵn trong Fly</td>
			</tr>
			<tr>
					<td>SDK của Sentry, với DSN trỏ về API của tôi</td>
					<td>Ứng dụng bị crash trên điện thoại</td>
					<td>Giữ SDK. Chưa bao giờ đăng ký sentry.io</td>
					<td>Không tốn thêm gì ngoài API</td>
			</tr>
			<tr>
					<td>Bản tóm tắt buổi sáng trong kênh chat của đội</td>
					<td>Lỗi, mức sử dụng, tìm kiếm, tin nhắn và bảng công việc của hôm trước</td>
					<td>Giữ. Chuyển từ một tác vụ cron trên CI sang máy Mac của tôi</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td>SDK phân tích ứng dụng (cả hai ứng dụng), và công cụ quản trị trang web của công cụ tìm kiếm</td>
					<td>Mức sử dụng trên web, iOS và Android; công cụ tìm kiếm nhìn trang web ra sao</td>
					<td>Giữ, đọc qua API</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td>Phân tích GraphQL của Cloudflare</td>
					<td>Ai đứng sau một mã trạng thái hay một đường dẫn</td>
					<td>Giữ, nhưng gom nhóm theo user agent trước khi tin</td>
					<td>Gói miễn phí, mỗi truy vấn một ngày</td>
			</tr>
			<tr>
					<td>Dashboard của một dịch vụ cập nhật OTA thuê ngoài</td>
					<td>Điện thoại đang chạy bản phát hành OTA nào; các lần tự quay lui khi crash</td>
					<td>Chuyển sang đây từ một danh sách trong trang quản trị chẳng cho biết gì</td>
					<td>Nằm trong dịch vụ cập nhật</td>
			</tr>
			<tr>
					<td>Một script thăm dò nhỏ</td>
					<td>Các yêu cầu bị lỗi trong lúc triển khai hoặc khởi động lại</td>
					<td>Giữ. Nó gọi thẳng vào origin nên bộ nhớ đệm không che được khoảng hở</td>
					<td>Miễn phí</td>
			</tr>
			<tr>
					<td><code>flyctl logs</code></td>
					<td>Vài phút đầu ra gần nhất của một ứng dụng</td>
					<td>Giữ để xem trực tiếp. Bỏ, nếu dùng làm nơi lưu trữ</td>
					<td>Có sẵn trong Fly</td>
			</tr>
			<tr>
					<td>Cloudflare Early Hints</td>
					<td>Không theo dõi gì cả; đây là tính năng tăng tốc</td>
					<td>Giữ lại, sau khi origin gửi header <code>Link</code>. Các lần cache miss của nó chính là toàn bộ lỗi 504 của zone</td>
					<td>Miễn phí</td>
			</tr>
	</tbody>
</table>
<p>Những dòng không ghi &ldquo;cả hai ứng dụng&rdquo; đều là của sàn giao dịch. Ứng dụng báo giá có ít hơn: health check, nhật ký và chỉ số của Fly, phân tích ứng dụng trên iOS, và một tác vụ xuất cơ sở dữ liệu ra tệp Parquet mỗi ngày để tôi truy vấn bằng DuckDB. Nó chưa có báo cáo crash, cũng chưa có kênh cảnh báo, và đó là chỗ hổng lớn nhất trong bài này. (Xin nói rõ về dòng dịch vụ cập nhật: công ty làm ra dịch vụ đó trả tiền cho tôi để viết bài trên blog của họ.)</p>

<h2 class="relative group">Lỗi: một logging handler gửi vào kênh chat
    <div id="lỗi-một-logging-handler-gửi-vào-kênh-chat" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-m%e1%bb%99t-logging-handler-g%e1%bb%adi-v%c3%a0o-k%c3%aanh-chat" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trước tháng Chín, một lỗi 500 chưa được xử lý, một thông báo đẩy (push notification) gửi thất bại hay một frame WebSocket bị hỏng chỉ xuất hiện ở đúng một chỗ là <code>flyctl logs</code>, mà luồng đó thì chẳng ai tự giác ngồi đọc. Giờ đây một logging handler gửi mọi bản ghi từ mức ERROR trở lên vào một kênh chat, dù nó đến từ API, máy chủ WebSocket hay worker chạy nền. Cảnh báo mức WARNING thì tôi cố ý không gửi.</p>
<p>Handler gửi tin từ một luồng nền, nên không bao giờ làm chậm một yêu cầu. Bên trong nó không ghi nhật ký gì, nên không thể tự gọi đệ quy. Và cùng một lỗi chỉ được gửi tối đa mười phút một lần, giới hạn 30 cảnh báo mỗi giờ, nên khi có sự cố bạn chỉ đọc một tin nhắn chứ không phải một nghìn.</p>
<p>Kênh này cũng chứa những thứ tôi muốn đặt cạnh lỗi để đối chiếu: các lần triển khai đều báo lúc bắt đầu, lúc chạy xong và chỗ bị hỏng, kèm commit và người chạy; các bản cập nhật OTA và <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/" >các lần phát hành giao diện web</a> cũng vậy. Khi một tài khoản bị nhập sai mã đăng nhập dồn dập, hệ thống cũng ghi ở mức ERROR, nên ai đó đang dò mã sẽ hiện ra ngay cạnh các sự cố.</p>
<p>Hồi tháng Mười có một cảnh báo đến mà không khớp với dòng nào trong bất kỳ nhật ký nào. Từ đó, mỗi cảnh báo mang theo một id ngẫu nhiên ngắn, tên máy và lệnh khởi động tiến trình, đồng thời ghi đúng dòng đó vào nhật ký của tiến trình. Nếu tôi không tìm thấy id ở đâu cả, thì cảnh báo đến từ một tiến trình không ghi nhật ký vào đó, chẳng hạn một phiên SSH. Mọi thứ vào kênh cũng được đẩy vào một luồng dữ liệu mà agent lập trình tôi làm việc cùng đọc được, nên agent đang làm việc với code cũng thấy lỗi cùng lúc với tôi.</p>

<h2 class="relative group">Nhật ký: log shipper của Fly đẩy lên S3, truy vấn bằng DuckDB
    <div id="nhật-ký-log-shipper-của-fly-đẩy-lên-s3-truy-vấn-bằng-duckdb" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%ba%adt-k%c3%bd-log-shipper-c%e1%bb%a7a-fly-%c4%91%e1%ba%a9y-l%c3%aan-s3-truy-v%e1%ba%a5n-b%e1%ba%b1ng-duckdb" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bộ đệm nhật ký của Fly chỉ giữ vài phút đầu ra, khoảng một trăm dòng gần nhất, đủ để theo dõi một lần triển khai nhưng vô dụng với chuyện của ngày hôm qua. Vì vậy một ứng dụng Fly thứ ba chạy log shipper của Fly, tức là Vector đăng ký nghe luồng nhật ký của cả tổ chức, và tôi chỉ đổi một chỗ ở sink S3 của nó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">sinks</span><span class="p">.</span><span class="nx">aws_s3</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s2">&#34;aws_s3&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">bucket</span> <span class="p">=</span> <span class="s2">&#34;myapp-logs&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">compression</span> <span class="p">=</span> <span class="s2">&#34;gzip&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key_prefix</span> <span class="p">=</span> <span class="s2">&#34;logs/{{fly.app.name}}/%F/&#34;</span>   <span class="c"># one folder per app per day</span>
</span></span><span class="line"><span class="cl">  <span class="nx">batch</span><span class="p">.</span><span class="nx">timeout_secs</span> <span class="p">=</span> <span class="mi">300</span>                  <span class="c"># a quiet app still lands its lines</span></span></span></code></pre></div></div>
<p>Mọi dòng nhật ký của các ứng dụng thuộc sàn giao dịch đều được ghi vào một bucket tương thích S3 dưới dạng JSON nén gzip. Shipper chạy trên một máy shared-cpu-1x 256 MB, khoảng 2 đô la mỗi tháng, phí lưu trữ chỉ vài xu. DuckDB đọc thẳng các tệp tại chỗ thông qua một macro, và dữ liệu của một ngày trả về trong khoảng một giây:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="n">MACRO</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="n">app</span><span class="p">,</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;*&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="k">TABLE</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">read_json_auto</span><span class="p">(</span><span class="s1">&#39;s3://myapp-logs/logs/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/&#39;</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="k">day</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="s1">&#39;/*.log.gz&#39;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                               </span><span class="n">union_by_name</span><span class="w"> </span><span class="p">:</span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="p">(</span><span class="s1">&#39;myapp-api&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;2026-09-06&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w"> </span><span class="s1">&#39;% 500 %&#39;</span><span class="p">;</span></span></span></code></pre></div></div>
<p>Tôi đã dùng chính macro này để đo thời gian khởi động nguội (cold start) trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >bài về scale to zero</a>: script khởi động báo từng giai đoạn, dòng nào cũng mang id của máy, phần còn lại chỉ là một câu <code>GROUP BY</code>.</p>
<p>Nói gọn lại, đó là ba tầng của cùng một luồng, chia theo người đọc: kho lưu trữ giữ mọi thứ từ INFO trở lên (cũng là lý do các ứng dụng ghi nhật ký ở mức INFO trên production), một script xem trực tiếp chỉ hiện cảnh báo và lỗi, còn kênh chat nhận mức ERROR. Có điều, cả ba đều không biết ai là người gọi. Nhật ký truy cập không có IP hay user agent của client, nên muốn biết điều đó tôi phải sang Cloudflare.</p>

<h2 class="relative group">Chỉ số: Prometheus có sẵn của Fly
    <div id="chỉ-số-prometheus-có-sẵn-của-fly" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%89-s%e1%bb%91-prometheus-c%c3%b3-s%e1%ba%b5n-c%e1%bb%a7a-fly" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Fly chạy sẵn một Prometheus được quản lý cho mỗi tổ chức, nên tôi không phải vận hành hệ thống chỉ số riêng. Khi có câu hỏi, tôi gọi thẳng API của nó: bộ nhớ, CPU và hạn mức burst theo từng máy (trên CPU dùng chung thì <code>fly_instance_cpu_balance</code> rất quan trọng), số phản hồi và số yêu cầu đồng thời theo từng ứng dụng. Có hai điều tôi ước mình biết sớm hơn. Token tạo bằng <code>fly tokens create</code> phải gửi dưới dạng <code>Authorization: FlyV1 &lt;token&gt;</code>; <code>Bearer</code> chỉ dùng được với token cá nhân từ <code>flyctl auth token</code>, còn token chỉ đọc sẽ nhận về &ldquo;something went wrong resolving organization&rdquo;. Và dữ liệu chỉ được giữ khoảng hai tuần (Fly nói là khoảng 15 ngày).</p>
<p>Nó chứng minh giá trị của mình vào tháng Chín, khi nhật ký xuất hiện &ldquo;Could not open a database connection&rdquo; vài lần mỗi ngày, trông y hệt một lỗi của ứng dụng. Cụm Postgres được quản lý cũng nằm trong cùng Prometheus đó, và ở mỗi phút có lỗi, thời gian scrape của máy chủ chính (primary) nhảy từ khoảng 80 mili giây lên một đến mười giây, trong khi bản sao (replica) vẫn bình thường và thời điểm khởi động của Postgres không hề đổi. Tức là Postgres không khởi động lại; chính máy chủ vật lý bên dưới bị treo. Tôi gửi các mốc thời gian cho bộ phận hỗ trợ của Fly, và sau một lần khởi động lại để bảo trì vào đầu tháng Mười đưa primary sang máy khác, tình trạng treo hằng ngày đã chấm dứt.</p>

<h2 class="relative group">Báo cáo crash: SDK của Sentry, trỏ về API của chính tôi
    <div id="báo-cáo-crash-sdk-của-sentry-trỏ-về-api-của-chính-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c3%a1o-c%c3%a1o-crash-sdk-c%e1%bb%a7a-sentry-tr%e1%bb%8f-v%e1%bb%81-api-c%e1%bb%a7a-ch%c3%adnh-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trước đây, khi ứng dụng crash trên điện thoại thì tôi không thấy dấu vết gì ở bất cứ đâu. Câu trả lời hiển nhiên là Sentry, và tôi lấy một nửa của nó: ứng dụng dùng SDK mã nguồn mở của Sentry, nhưng DSN trỏ vào một endpoint trong chính API Django của tôi. Endpoint này nhận định dạng envelope của Sentry, gom nhóm các lần crash và cảnh báo qua đúng handler gửi vào kênh chat ở trên. Truy vết, phiên (session) và replay đều tắt; chỉ nhận lỗi.</p>
<p>Tôi không đăng ký sentry.io vì với vài báo cáo mỗi ngày, làm vậy nghĩa là thêm một nhà cung cấp nắm dữ liệu người dùng, thêm một dòng trong chính sách quyền riêng tư và thêm một hóa đơn. Cái giá phải trả là symbolication (giải mã stack trace về mã nguồn), thứ tôi vẫn chưa thay thế được. Stack trace hoặc là JavaScript đã bị rút gọn (minify), hoặc là địa chỉ native thô, nên một issue cho tôi biết cái gì crash, trên bản phát hành và thiết bị nào, người dùng vừa làm gì ngay trước đó, nhưng không cho biết lỗi nằm ở đâu trong mã nguồn.</p>

<h2 class="relative group">Bảng điều khiển duy nhất là một tin nhắn
    <div id="bảng-điều-khiển-duy-nhất-là-một-tin-nhắ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="#b%e1%ba%a3ng-%c4%91i%e1%bb%81u-khi%e1%bb%83n-duy-nh%e1%ba%a5t-l%c3%a0-m%e1%bb%99t-tin-nh%e1%ba%afn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi không dựng bảng điều khiển cho ứng dụng nào cả. Bài năm ngoái có một phép thử cho dashboard: nếu không ai tự mở nó ra, nó chưa chứng minh được giá trị. Một tin nhắn tự gửi đến thì không cần ai mở, nên thứ gần với dashboard nhất mà tôi có là một bài đăng trong kênh chat của đội mỗi sáng.</p>
<p>Phần đầu là các lỗi production trong 24 giờ qua, đọc từ kho nhật ký chứ không phải từ kênh chat, vì cảnh báo đã bị giới hạn tần suất nên không thể cho biết một chuyện xảy ra bao nhiêu lần. Số, id và địa chỉ được che đi trước khi gom nhóm, nên một trăm lần cùng một lỗi chỉ là một dòng kèm số lần, và không địa chỉ email nào lọt vào kênh.</p>
<p>Tiếp theo là các lần health check thất bại, mức sử dụng hôm qua so với cùng thứ đó của tuần trước (Chủ nhật thì không bao giờ giống thứ Hai), lượt truy cập từ tìm kiếm của tuần gần nhất, các cuộc trò chuyện đang chờ trả lời, bảng công việc và khoản chi tiêu. Phần nào không tải được thì ghi rõ như vậy, còn bài đăng vẫn được gửi đi.</p>
<p>Đọc số liệu phân tích ứng dụng qua API dạy bạn nghi ngờ con số một chút. Tháng Tám có 121 lượt mở ứng dụng Android lần đầu từ Azerbaijan, kéo theo 119 lượt gỡ cài đặt, và tôi đọc tổng số Android của tháng đó sau khi đã trừ chúng ra.</p>
<p>Các bản phát hành có màn hình riêng. Hồi bản cập nhật OTA còn được phát từ máy chủ của tôi, cách duy nhất để xem một bản phát hành là một danh sách trong trang quản trị có ô &ldquo;is active&rdquo;, và tôi hoàn toàn không biết có bao nhiêu điện thoại đã nhận bản cập nhật. Dashboard của dịch vụ mới cho thấy các thiết bị thật sự đang chạy bản nào, việc cài đặt diễn ra ra sao, và cả lần quay lui khi một bundle lỗi bị crash và SDK đưa bản trước đó quay lại. Tôi đã viết riêng về <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >việc chuyển sang một dịch vụ cập nhật thuê ngoài</a>.</p>

<h2 class="relative group">Những thứ tôi đã bỏ, hoặc thôi tin
    <div id="những-thứ-tôi-đã-bỏ-hoặc-thôi-tin" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-th%e1%bb%a9-t%c3%b4i-%c4%91%c3%a3-b%e1%bb%8f-ho%e1%ba%b7c-th%c3%b4i-tin" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Không có thứ nào trong số này là công cụ trả phí. Phần lớn là những thứ tôi vẫn coi là tín hiệu nhưng hóa ra không phải, và một tác vụ chạy sai chỗ.</p>

<h3 class="relative group">Tỷ lệ 504 của Cloudflare, cho tới khi tôi gom nhóm theo user agent
    <div id="tỷ-lệ-504-của-cloudflare-cho-tới-khi-tôi-gom-nhóm-theo-user-agent" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b7-l%e1%bb%87-504-c%e1%bb%a7a-cloudflare-cho-t%e1%bb%9bi-khi-t%c3%b4i-gom-nh%c3%b3m-theo-user-agent" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tháng Chín, dashboard của Cloudflare cho thấy có vẻ như 22% số yêu cầu trong một ngày thất bại với lỗi 504, dồn vào các endpoint mà ứng dụng thăm dò (poll) mỗi 30 giây, trong khi health check vẫn xanh hết và nhật ký của chính ứng dụng cho thấy các yêu cầu đó được xử lý rất nhanh. Tôi mất mấy ngày với nó, phát hiện máy cần 25 đến 33 giây để khởi động nguội và phải làm thế 92 lần mỗi ngày, sửa xong, rồi viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >bài về scale to zero</a> rằng đó là nguyên nhân.</p>
<p>Sáng hôm sau tôi gom nhóm chính những lỗi 504 đó theo user agent. Toàn bộ 7,344 lỗi từ ngày 5 đến ngày 9 tháng Chín đều đến từ các lượt tra cứu cache Early Hints của chính Cloudflare (user agent là <code>bastion early hints</code> và <code>nginx-ssl early hints</code>); chúng không bao giờ đi tới origin, và mã 504 trên những dòng đó ghi lại một lần cache miss chứ không phải timeout. Chúng bắt chước đường dẫn của yêu cầu thật, nên rơi đúng vào những endpoint bận nhất và trông y như người dùng đang gặp lỗi. Ứng dụng, trình duyệt, curl và trình thu thập dữ liệu của công cụ tìm kiếm không có lấy một lỗi nào. Origin khi đó không gửi header <code>Link</code>, nên Early Hints thực ra chẳng làm được gì, và tôi đã tắt nó; về sau tôi <a href="https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/" >làm cho nó hoạt động thật sự</a>.</p>
<p>Công sức tối ưu khởi động nguội vẫn có ích, vì triển khai và đánh thức máy đều nhanh hơn, nhưng sự cố mà tôi tưởng mình đang sửa thì chưa từng chạm tới người dùng nào.</p>
<p>Vì thế, quy tắc tôi muốn chia sẻ nhất là: trước khi tin vào tỷ lệ lỗi của CDN, hãy gom nhóm theo user agent và kiểm tra xem đó có phải do chính cơ chế của CDN sinh ra không. Trên gói miễn phí của Cloudflare, đó là một truy vấn GraphQL vào <code>httpRequestsAdaptiveGroups</code>, mỗi lần một ngày. Lần hoảng tiếp theo cũng được giải quyết bằng đúng truy vấn đó, khi một endpoint thăm dò tăng từ 70–260 yêu cầu mỗi ngày lên 2,880. Không phải công cụ cào dữ liệu (scraper) nào cả. Đó là chính máy Mac của tôi, với ứng dụng vẫn đang chạy trong trình giả lập iOS sau khi kiểm thử.</p>

<h3 class="relative group">Một health check báo xanh
    <div id="một-health-check-báo-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="#m%e1%bb%99t-health-check-b%c3%a1o-xanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Fly kiểm tra <code>/api/health/</code> của cả hai ứng dụng mỗi 30 giây và dùng kết quả để quyết định máy nào được nhận lưu lượng, và nó làm việc này tốt. Nhưng health check chạy trên một máy đã khởi động xong, nên nó không thấy những gì xảy ra trước đó: một lần khởi động nguội, một hàng đợi ở proxy, một yêu cầu không bao giờ tới nơi. Giờ tôi chỉ nhờ nó định tuyến.</p>
<p>Khi triển khai, tôi dùng script thăm dò thay thế: nó gọi vào origin suốt quá trình và thoát với mã 1 nếu có bất kỳ yêu cầu nào lỗi. Script này đã bắt được một lần <code>flyctl deploy</code> thông thường để lại khoảng hở 7.3 giây, rồi ghi nhận không lỗi nào trong 2,514 yêu cầu khi chạy qua script triển khai, vốn đánh thức một máy dự phòng trước.</p>
<p>Bản thân health check cũng từng gây ra hai sự cố. Tháng Bảy, thiết lập <code>SECURE_SSL_REDIRECT</code> của Django trả lời yêu cầu thăm dò HTTP thường của Fly bằng mã 301, mọi máy đều trượt kiểm tra, và ai cũng nhận về lỗi 503. Tháng Tám, việc thu hẹp một <code>except Exception</code> trong view health check khiến lỗi &ldquo;connection already closed&rdquo; lọt ra thành 500, và Fly gỡ máy ra đúng trong trường hợp mà cơ chế thử lại của view được viết ra để xử lý. Giờ health check chỉ hỏi một câu: tiến trình này có phục vụ được yêu cầu không, và khi cơ sở dữ liệu bị treo thì cả hai ứng dụng không còn bị gỡ khỏi định tuyến nữa.</p>
<p>Bản tóm tắt buổi sáng cũng phải học bài học tương tự về cách đếm. Sàn giao dịch tạm ngưng (suspend) các máy đang rảnh, nên một máy bị đánh thức bởi yêu cầu sẽ trượt lần kiểm tra đầu tiên vài trăm mili giây sau khi khởi động và qua ở lần kế tiếp. Đếm cả những lần đó khiến sáng nào đọc cũng như có sự cố, nên giờ lỗi nào qua lại được trong vòng một phút sẽ được liệt kê riêng, ghi là máy thức dậy và các lần triển khai.</p>

<h3 class="relative group">Một tác vụ định kỳ trên CI
    <div id="một-tác-vụ-định-kỳ-trên-ci" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%99t-t%c3%a1c-v%e1%bb%a5-%c4%91%e1%bb%8bnh-k%e1%bb%b3-tr%c3%aan-ci" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Bản tóm tắt buổi sáng ban đầu là một workflow CI chạy theo lịch cron, và chỉ trong vòng một ngày đã phải chuyển đi. Khi nó cần đọc số liệu phân tích và tìm kiếm, khóa cần dùng là khóa tài khoản dịch vụ (service account) của dự án phân tích, thứ làm được mọi việc trong dự án đó, và tôi không muốn để nó trong secrets của CI. Giờ một trình lập lịch trên máy Mac của tôi chạy nó mỗi 20 phút, và nó chỉ đăng bài khi đã qua tám giờ sáng theo giờ Auckland mà bài của hôm nay chưa được gửi, nên một chiếc Mac đang ngủ lúc tám giờ sẽ đăng khi nó thức dậy. Workflow vẫn được giữ làm phương án dự phòng chạy tay, chỉ thiếu hai phần phân tích và tìm kiếm.</p>

<h2 class="relative group">Nếu được bắt đầu lại
    <div id="nếu-được-bắt-đầu-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-%c4%91%c6%b0%e1%bb%a3c-b%e1%ba%aft-%c4%91%e1%ba%a7u-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi sẽ dựng ba thứ trước khi có người dùng đầu tiên, chứ không đợi tới sau sự cố đầu tiên: gửi lỗi vào một kênh chat mà đội vốn đã đọc, có giới hạn tần suất; một kho nhật ký trên object storage truy vấn được bằng SQL; và những chỉ số mà nền tảng vốn đã thu thập sẵn. Tất cả chỉ tốn vài đô la mỗi tháng. Báo cáo crash thì thêm vào đúng tuần ứng dụng lên điện thoại, vì nếu không, một lần crash trên điện thoại chẳng báo cho ai biết điều gì. Bản tóm tắt buổi sáng thì thêm vào khi số chỗ phải kiểm tra đã nhiều tới mức tôi sắp chẳng buồn kiểm tra chỗ nào nữa.</p>
<p>Truy vết thì tôi để dành cho tới khi có dịch vụ thứ hai để một yêu cầu phải đi qua, còn APM dạng dịch vụ thuê ngoài thì đợi tới khi có một đội cần dashboard dùng chung, lịch trực (on-call) và ngân sách để trả hóa đơn cho người khác. Đó là lúc những công cụ trong bài năm ngoái bắt đầu đáng đồng tiền. Trước lúc đó, thói quen hữu ích nhất lại miễn phí: khi một con số trông đáng sợ, hãy tìm xem cái gì tạo ra nó trước khi kết luận cái gì bị hỏng. Không ít lần hoảng hốt trong bài này đến từ chính hệ thống giám sát, chứ không phải từ sản phẩm.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-monitoring-tools-two-products/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Dùng Cloudflare WARP làm mạng riêng: kết nối laptop, điện thoại và máy trạm của tôi</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-01-cloudflare-warp-dev-network/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-01-cloudflare-warp-dev-network/</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>WARP</category>
      <category>Zero Trust</category>
      <category>Cloudflare Tunnel</category>
      <category>Networking</category>
      <category>SSH</category>
      <category>DevOps</category>
      <category>AI Agents</category>
      <category>Ollama</category>
      <category>Mobile Development</category>
      <category>IPv6</category>
      <description>Cách tôi dùng Cloudflare WARP và hai Cloudflare Tunnel (Zero Trust) để đưa một laptop, một điện thoại chạy 5G và một máy trạm có GPU ở văn phòng vào chung một mạng riêng: IP riêng cố định, không mở cổng nào, và các dev server nằm sau lớp đăng nhập Access.</description>
      <content:encoded><![CDATA[<p>Cloudflare WARP có thể đưa các máy của chính bạn vào chung một mạng riêng mà không cần mở một cổng nào. Trên mỗi máy bạn muốn truy cập tới, hãy chạy một Cloudflare Tunnel (<code>cloudflared</code>) và cho đường hầm đó quảng bá một dải IP riêng; sau đó đăng ký laptop và điện thoại vào một tổ chức Zero Trust, với client WARP đặt ở include mode. Từ đó trở đi, một chiếc điện thoại đang dùng 5G có thể mở một kết nối bình thường tới <code>198.18.42.2</code> và tới thẳng máy trạm ở văn phòng. Dưới đây là cách tôi thiết lập hệ thống của mình, cùng bốn chỗ khiến tôi vấp trên đường đi.</p>
<p>Bộ đồ nghề làm việc của tôi gồm ba cỗ máy hiếm khi ở cùng một chỗ. Thứ nhất là chiếc MacBook Air, tôi đi đâu nó theo đó. Thứ hai là một máy trạm ở văn phòng tên devbox, có GPU thật, chạy Ollama, mấy công cụ fine-tuning, một cụm Kubernetes nhỏ kèm container registry riêng, và bất kỳ agent nào tôi để lại đang cày một tác vụ dài. Thứ ba là điện thoại, nơi các ứng dụng tôi làm ra thật sự được dùng, và phần lớn thời gian trong ngày nó chạy bằng dữ liệu di động chứ không phải wifi.</p>
<p>Suốt một thời gian dài, ba thiết bị này gần như không nói chuyện được với nhau. Laptop chỉ kết nối được tới devbox khi đang ở trong mạng LAN văn phòng, ra ngoài là chịu. Điện thoại truy cập được dev server trên laptop khi cả hai cùng một mạng wifi, mà cũng chỉ khi tôi nhớ bind vào <code>0.0.0.0</code> và gõ đúng địa chỉ mà DHCP cấp cho chiếc Mac sáng hôm đó. Mọi thứ hay ho trên máy trạm, từ API của Ollama, các dashboard của cụm, dữ liệu trace, đến registry, đều được bind vào localhost hoặc mạng nội bộ của cụm, và tôi cố ý làm vậy, vì không thứ nào trong số đó nên nằm trên internet.</p>
<p>Bài này kể cách tôi nối chúng lại bằng Cloudflare WARP và một cặp Cloudflare Tunnel, điều đó đã thay đổi cách tôi làm tính năng AI và ứng dụng di động ra sao, và vài thứ đã hỏng mà chẳng hề báo cho tôi biết.</p>

<h2 class="relative group">Điều tôi thật sự muốn
    <div id="điều-tôi-thật-sự-muốn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%81u-t%c3%b4i-th%e1%ba%adt-s%e1%bb%b1-mu%e1%bb%91n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu viết thành yêu cầu thì danh sách khá ngắn:</p>
<ol>
<li>Laptop chỉ nên là máy khách mỏng (thin client). Việc gì cần GPU, cần nhiều RAM hay phải chạy hàng giờ thì để devbox lo, và laptop phải kết nối tới nó theo cùng một cách, dù đang ở văn phòng, ở nhà hay ở quán cà phê.</li>
<li>Điện thoại phải truy cập được dev server và shell của tôi từ bất cứ đâu, qua dữ liệu di động, mà không cần một cái VPN tôi phải nhớ bật tắt.</li>
<li>Những công cụ tôi dùng để xem hệ thống đang chạy thế nào, như dashboard, trace, <code>docker info</code>, danh sách mô hình, chỉ mình tôi truy cập được, và không bao giờ có URL công khai.</li>
<li>Không mở cổng vào (inbound port) nào. Không trên router văn phòng, không trên router ở nhà, không ở đâu cả.</li>
</ol>
<p>Yêu cầu thứ tư loại bỏ luôn lời giải cũ, tức là chuyển tiếp cổng (port forward) cộng với một tên miền DNS động. Nó cũng loại bỏ khá nhiều mẹo khôn lỏi khác: cả devbox lẫn laptop đều nằm sau NAT mà tôi không kiểm soát, và cứ vài tiếng laptop lại nằm sau một NAT khác.</p>

<h2 class="relative group">Trước đây: một repo toàn đường hầm SSH
    <div id="trước-đây-một-repo-toàn-đường-hầm-ssh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%c6%b0%e1%bb%9bc-%c4%91%c3%a2y-m%e1%bb%99t-repo-to%c3%a0n-%c4%91%c6%b0%e1%bb%9dng-h%e1%ba%a7m-ssh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phiên bản đầu tiên là một repo nhỏ tôi tự viết, và đến giờ nó vẫn là lớp nền. Nó giải quyết đúng một bài toán trên laptop: đặt tên gọn gàng cho những thứ tôi truy cập qua SSH, không hơn. dnsmasq trả lời <code>*.test</code> bằng <code>127.0.0.1</code>, Caddy định tuyến theo hostname tới một cổng cục bộ với <code>tls internal</code> để trình duyệt không kêu ca, còn một loạt launch agent chạy autossh giữ cho các đường hầm chuyển tiếp SSH (SSH forward) luôn mở và tự kết nối lại khi bị rớt. Chỉ cần nhìn hostname là biết thứ đó nằm ở đâu:</p>
<table>
	<thead>
			<tr>
					<th>Hậu tố</th>
					<th>Nghĩa là</th>
					<th>Đi qua</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>*.local.test</code></td>
					<td>chính chiếc Mac này</td>
					<td>không qua gì cả, đây là dev server</td>
			</tr>
			<tr>
					<td><code>*.ssh.test</code></td>
					<td>một máy chủ</td>
					<td>một đường chuyển tiếp autossh</td>
			</tr>
			<tr>
					<td><code>*.cloud.test</code></td>
					<td>một ứng dụng đang host trên cloud</td>
					<td>proxy riêng của nền tảng hosting</td>
			</tr>
	</tbody>
</table>
<p>Vậy nên <code>https://devbox-ollama.ssh.test/api/tags</code> liệt kê các mô hình trên máy trạm, còn <code>https://api.local.test</code> là API của ứng dụng tôi đang làm, chạy ngay trên máy tôi.</p>
<p>Tôi dùng tên miền con của <code>.test</code>, chứ không tạo tên miền cấp cao mới như <code>.ssh</code> hay <code>.cloud</code>, và cũng không dùng <code>.local</code>. RFC 6761 dành riêng <code>.test</code> cho đúng mục đích này, còn <code>.local</code> thuộc về mDNS; chiếm nó là tính năng dò tìm Bonjour trên Mac ngừng hoạt động. dnsmasq vốn đã bắt wildcard toàn bộ <code>*.test</code>, nên thêm một nhãn nữa chẳng tốn gì: chỉ một tệp <code>/etc/resolver/test</code> trỏ vào <code>127.0.0.1</code>, và từ đó về sau không phải chỉnh DNS thêm lần nào.</p>

<h3 class="relative group">HTTPS trên những cái tên không hề tồn tại
    <div id="https-trên-những-cái-tên-không-hề-tồn-tại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#https-tr%c3%aan-nh%e1%bb%afng-c%c3%a1i-t%c3%aan-kh%c3%b4ng-h%e1%bb%81-t%e1%bb%93n-t%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tất cả các tên đó đều chạy HTTPS với chứng chỉ mà trình duyệt tin tưởng, nghe thì có vẻ bất khả thi với một hostname chỉ tồn tại trên laptop của tôi. Caddy làm được nhờ tổ chức cấp chứng chỉ (CA) nội bộ của riêng nó. Khai báo <code>tls internal</code> cho một site nghĩa là bảo Caddy cấp chứng chỉ cho site đó từ một CA cục bộ thay vì xin Let&rsquo;s Encrypt, còn <code>caddy trust</code>, được trình cài đặt chạy một lần, đưa chứng chỉ gốc của CA vào System keychain của macOS (Chrome và Safari đọc từ đây) và vào kho chứng chỉ riêng của Firefox. Từ lúc đó, <code>https://anything.test</code> mở ra mà không có cảnh báo chứng chỉ nào, <code>http://</code> thường thì tự chuyển hướng sang, và không có gì rời khỏi máy.</p>
<p>Đây không chỉ là chuyện cho đẹp. Ngày càng nhiều tính năng của nền tảng web từ chối chạy trên HTTP thường: secure cookie, service worker, clipboard API, bất cứ thứ gì có <code>SameSite=None</code>. Kiểm thử một bản web build trên <code>http://localhost</code> thường sẽ che mất những lỗi chỉ lộ ra trên tên miền HTTPS thật. Kiểm thử trên <code>https://app.local.test</code> thì các lỗi ấy hiện ra ngay trên laptop của tôi.</p>
<p>Một khối cấu hình site, dịch vụ nào cũng giống hệt, chỉ khác số cổng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddyfile" data-lang="caddyfile"><span class="line"><span class="cl"><span class="gh">devbox-ollama.ssh.test</span>, <span class="gh">devbox-ollama.test</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">	<span class="k">tls</span> <span class="no">internal</span>
</span></span><span class="line"><span class="cl">	<span class="k">import</span> cors
</span></span><span class="line"><span class="cl">	<span class="k">reverse_proxy</span> <span class="n">127.0.0.1</span><span class="p">:</span><span class="mi">11435</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">		<span class="k">import</span> proxyhdr
</span></span><span class="line"><span class="cl">		<span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">			<span class="k">keepalive</span> <span class="no">off</span>
</span></span><span class="line"><span class="cl">			<span class="k">dial_timeout</span> <span class="mi">5s</span>
</span></span><span class="line"><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Hai snippet kia gánh những phần lắt nhắt. <code>cors</code> phản chiếu lại đúng <code>Origin</code> của bên gọi thay vì gửi <code>*</code>, vì <code>*</code> không được phép khi có dính tới thông tin xác thực (credentials), và nó tự trả lời các yêu cầu preflight <code>OPTIONS</code> bằng mã 204 để phía upstream không bao giờ phải lo. <code>proxyhdr</code> ghi đè header <code>Host</code> thành địa chỉ đích. Một số backend khó tính, trong đó có trang quản trị router và endpoint remote-debugging của Chrome, kiểm tra <code>Host</code>, và nếu không ghi đè thì chúng sẽ từ chối <code>devbox-ollama.ssh.test</code> như một cái tên chưa từng nghe tới. Cơ chế kiểm tra origin của chính Ollama chấp nhận loopback ở mọi cổng, nên ghi đè <code>Host</code> thành <code>127.0.0.1:11435</code> là lọt qua.</p>
<p><code>keepalive off</code> là dòng mà nếu tự làm thì tôi đã sai. Phía upstream là các đường chuyển tiếp SSH mà autossh phá đi dựng lại mỗi khi mạng thay đổi. Nếu bật keep-alive, Caddy sẽ giữ một kết nối trong pool tới một đường hầm đã chết từ lâu, và cứ trả về những lỗi 404 khó hiểu cho tới khi được reload. Tắt đi thì mỗi yêu cầu đều kết nối mới, mà trên loopback thì chi phí đó không đo được.</p>
<p>Mỗi dịch vụ cũng trả lời ở tên phẳng cũ của nó (<code>devbox-ollama.test</code>), để các bookmark từ trước khi có hệ hậu tố vẫn dùng được.</p>

<h3 class="relative group">Thanh menu
    <div id="thanh-menu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#thanh-menu" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Khi đã có cả chục đường chuyển tiếp do launchd giữ mở, câu tôi cứ hỏi mãi là &ldquo;nó có đang chạy không?&rdquo;, và câu trả lời thì nằm ở ba chỗ: <code>launchctl list</code>, <code>lsof</code> và một lệnh <code>curl</code>. Thế nên tôi viết một plugin nhỏ cho thanh menu để gom về một chỗ, mỗi hostname có một chấm màu bên cạnh:</p>
<ul>
<li>🟢 dịch vụ đã trả lời một yêu cầu đi qua đường hầm</li>
<li>🟡 đường hầm đang mở nhưng dịch vụ phía sau im lặng</li>
<li>🔴 không có gì lắng nghe ở cổng cục bộ, tức là chính đường hầm đã sập</li>
</ul>
<p>Trạng thái vàng mới là cái hữu ích. Một phép kiểm tra chỉ có xanh/đỏ không phân biệt được &ldquo;đường chuyển tiếp SSH đã chết&rdquo; với &ldquo;Ollama bị crash ở đầu bên kia&rdquo;, mà hai chuyện đó có cách sửa hoàn toàn khác nhau. Plugin không giữ danh sách dịch vụ riêng mà đọc chính cấu hình proxy và cấu hình SSH mà mọi thứ khác đang dùng, nên thêm một dịch vụ ở đó là nó tự xuất hiện trên menu.</p>
<p>Phiên bản đầu tiên hơi tham. Nó làm mới mỗi mười giây và gọi vài tiến trình cho mỗi dịch vụ, khoảng bảy mươi tiến trình mỗi lượt, và macOS lập tức liệt nó vào danh sách &ldquo;apps using significant energy&rdquo;. Chỉ chụp trạng thái một lần mỗi lượt và làm mới cách vài phút là đủ kéo nó xuống còn khoảng 0.1% một nhân. Một đèn trạng thái mà ngốn pin thì chẳng giúp được ai, nên đáng tính chi phí của nó ngay từ đầu.</p>
<p>Nó có đúng một lỗ hổng, và lại là lỗ hổng quan trọng nhất. devbox không có tên công khai. Đường chuyển tiếp SSH tới nó kết nối vào một địa chỉ <code>192.168.x</code>, nên nó chỉ chạy được khi laptop đang ở trong LAN văn phòng. Ra khỏi văn phòng là thanh menu chuyển đỏ và đỏ luôn, và mọi thứ tôi đã dời sang máy trạm để đỡ tải cho laptop đều nằm ngoài tầm với, đúng vào những lúc tôi không ngồi cạnh nó.</p>

<h2 class="relative group">Hai đường hầm, quay đúng hướng
    <div id="hai-đường-hầm-quay-đúng-hướ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="#hai-%c4%91%c6%b0%e1%bb%9dng-h%e1%ba%a7m-quay-%c4%91%c3%bang-h%c6%b0%e1%bb%9bng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách sửa gồm ba phần, và hai phần đầu là Cloudflare Tunnel.</p>
<p>Một Cloudflare Tunnel là <code>cloudflared</code> giữ một kết nối đi ra (outbound) tới edge của Cloudflare. Cloudflare đẩy các yêu cầu ngược xuống theo chính kết nối đó, nên một máy nằm sau bao nhiêu lớp NAT cũng truy cập được mà không cần thứ gì lắng nghe trên router. Laptop mang ra wifi khách sạn nó vẫn theo, và nó sống sót được cả CGNAT.</p>
<p>Tôi chạy hai đường hầm, mỗi máy một cái, và lý do đáng để nói một câu. Lối tắt dễ thấy là chỉ một đường hầm trên laptop, thêm một quy tắc ingress trỏ tới cổng SSH của devbox trong LAN văn phòng. Cách đó không cần cài gì lên devbox cả. Nhưng nó cũng đi vòng qua laptop, mà laptop lại chính là cái máy rời khỏi văn phòng. Nó sẽ hỏng đúng trong trường hợp mà nó được tạo ra để xử lý. devbox thì đứng yên một chỗ, nên devbox tự giữ kết nối riêng của mình tới edge.</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"># config.yml on devbox (runs as a systemd service)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">protocol</span><span class="p">:</span><span class="w"> </span><span class="l">auto       </span><span class="w"> </span><span class="c"># prefer QUIC, fall back to HTTP/2 over TCP 443</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ha-connections</span><span class="p">:</span><span class="w"> </span><span class="m">4</span><span class="w">     </span><span class="c"># mains-powered, its job is to stay reachable</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">warp-routing</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">connectTimeout</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></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ssh-devbox.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">tcp://127.0.0.1:22</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>Dùng <code>tcp://</code> thay vì <code>ssh://</code> là chỗ rất dễ làm sai. <code>ssh://</code> chọn chế độ terminal hiển thị trên trình duyệt của Cloudflare, vốn bọc luồng dữ liệu lại cho một trang web; một client <code>ssh</code> bình thường đi qua đó sẽ nhận được banner rồi chết ngay ở bước trao đổi khóa. <code>tcp://</code> mới là đường ống byte thuần mà <code>cloudflared access ssh</code> cần, và được thêm cái lợi là nó mang được cả <code>scp</code>, <code>rsync</code>, git qua SSH lẫn port forward.</p>
<p>Chính phần sau cùng ấy đã vá lỗ hổng ở lớp SSH. Cấu hình SSH giờ chọn đường đi cho từng kết nối:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-sshconfig" data-lang="sshconfig">Match originalhost devbox !exec &#34;nc -z -G 1 &lt;office-lan-ip&gt; 22 &gt;/dev/null 2&gt;&amp;1&#34;
    ProxyCommand cloudflared access ssh --hostname ssh-devbox.example.com ...</code></pre></div>
<p>Khi ở trong LAN văn phòng, phép thăm dò thành công và SSH đi thẳng, một bước nhảy. Ở bất kỳ đâu khác, phép thăm dò thất bại và SSH đi qua Cloudflare, xác thực bằng một service token của Access nên không có bước đăng nhập trên trình duyệt nào chắn đường. Phải là <code>Match originalhost</code>, không phải <code>Match host</code>: <code>host</code> so khớp sau khi <code>HostName</code> đã được thay vào, nên lúc đó tên đã thành IP và khối cấu hình không bao giờ kích hoạt. Kết quả là <code>devbox-ollama.ssh.test</code> chạy được ở văn phòng, ở nhà và trên tàu, và chấm trên thanh menu cứ xanh mãi.</p>
<p>Đường hầm của laptop thì đi theo chiều ngược lại. Nó cấp cho các dev server trên Mac những hostname HTTPS công khai thật: với mỗi ứng dụng tôi đang làm, đó là API, WebSocket server và bản web build của nó. Tất cả đều nằm sau Cloudflare Access, phần này tôi sẽ quay lại sau, vì chính nó biến cách làm này từ liều lĩnh thành an toàn.</p>
<p>Thanh menu cũng phải học cách nhận biết cả hai đường hầm, và nó không làm được bằng cách thăm dò. Một đường hầm chiều vào không có cổng cục bộ nào để kiểm tra, một trong hai connector thậm chí không chạy trên laptop, còn phép thử dễ thấy nhất, curl tới <code>ssh-devbox.example.com</code>, lại luôn nhận được câu trả lời từ edge của Cloudflare dù có connector nào gắn vào hay không, nên nó sẽ báo xanh cho một đường hầm đã chết. launchd cũng chẳng khá hơn: <code>cloudflared</code> cứ nằm đó ở trạng thái &ldquo;loaded, exit 0&rdquo; trong khi mọi kết nối tới edge đã rớt hết. Vậy nên với các đường hầm, menu hỏi Cloudflare API xem mỗi đường hầm có connector nào khỏe mạnh không, vì chính điều đó quyết định một lệnh <code>ssh</code> có tới nơi hay không. Nếu không liên lạc được với API, cả phần đó bị bỏ qua luôn thay vì vẽ màu đỏ. Thanh menu là thứ nhìn lướt qua, và một báo động giả ở đó tốn kém hơn một dòng bị thiếu.</p>

<h2 class="relative group">Phần thứ ba: WARP biến nó thành một mạng
    <div id="phần-thứ-ba-warp-biến-nó-thành-một-mạ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="#ph%e1%ba%a7n-th%e1%bb%a9-ba-warp-bi%e1%ba%bfn-n%c3%b3-th%c3%a0nh-m%e1%bb%99t-m%e1%ba%a1ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đường hầm có hostname rất tuyệt cho mọi thứ nói HTTPS, và cho SSH từ một máy chạy được <code>cloudflared</code>. Điện thoại thì không chạy được. Hơn nữa, hostname của đường hầm không phải là một endpoint TCP: edge của Cloudflare chỉ kết thúc (terminate) HTTPS và WebSocket trên đó, nên cổng 22 tại <code>ssh.example.com</code> đóng, và loay hoay với Access đến mấy cũng không mở được.</p>
<p>Đó là việc của WARP. Cloudflare One Agent (tức client WARP bản doanh nghiệp, không phải ứng dụng &ldquo;1.1.1.1&rdquo; cho người dùng phổ thông, vốn không tham gia tổ chức được) được đăng ký trên laptop và điện thoại vào tổ chức Zero Trust của tôi. Mỗi đường hầm quảng bá các tuyến riêng (private route), và một thiết bị đang chạy WARP có thể mở một socket bình thường tới những địa chỉ đó và tới đúng máy. Nó không còn là &ldquo;truy cập dịch vụ này qua tên&rdquo; nữa, mà thành &ldquo;những cỗ máy này đang ở chung một mạng với tôi&rdquo;:</p>
<table>
	<thead>
			<tr>
					<th>Địa chỉ</th>
					<th>Máy</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>198.18.42.1</code> / <code>fd12:3456:789a::1</code></td>
					<td>chiếc MacBook Air</td>
			</tr>
			<tr>
					<td><code>198.18.42.2</code> / <code>fd12:3456:789a::2</code></td>
					<td>devbox</td>
			</tr>
			<tr>
					<td><code>198.18.42.3</code> / <code>fd12:3456:789a::3</code></td>
					<td>một chiếc Mac thứ hai</td>
			</tr>
	</tbody>
</table>
<p>Những địa chỉ này là alias loopback trên từng máy, được tạo lúc khởi động, nên chúng không phụ thuộc vào DHCP hay router nào tôi đang đứng sau. <code>198.18.42.2</code> là devbox, dù đang ở văn phòng, ở nhà hay từ điện thoại dùng 5G. Địa chỉ LAN thật của chiếc Mac đổi mỗi lần tôi di chuyển; địa chỉ này thì không bao giờ đổi.</p>
<p>Chế độ chia tách đường hầm (split tunnel) đặt ở <strong>include mode</strong>, chỉ chứa những tuyến đó (cộng các dải của Cloudflare mà bản thân WARP cần). Chính điều này khiến client dễ sống cùng. Mặc định là exclude mode, khi đó WARP mang mọi thứ trừ một danh sách các dải riêng, nên từng byte điện thoại gửi đi đều đi vòng qua Cloudflare. Cái giá về pin và độ trễ là có thật, đủ để rốt cuộc tôi sẽ chỉ bật nó khi cần, mà như thế thì mất hết ý nghĩa. Ở include mode, lưu lượng tới ba máy của tôi đi qua WARP, còn mọi thứ khác đi theo đường bình thường, không bị động tới. Thế là client cứ bật suốt. <code>warp-cli status</code> trên laptop báo <code>Connected</code> cả ngày và tôi chẳng phải nghĩ đến nó.</p>
<p>Về giao thức truyền tải, tôi dùng MASQUE, tức HTTP/3 trên UDP 443 với HTTP/2 làm phương án dự phòng, thay vì WireGuard trên UDP 2408. Một số nhà mạng và mạng dành cho khách chặn 2408; gần như chẳng ai chặn 443. Chính sách của tổ chức tôi còn bật sẵn thỏa thuận khóa hậu lượng tử (post-quantum key agreement) cho MASQUE, thứ tôi không đi tìm nhưng cũng chẳng dại gì từ chối.</p>

<h2 class="relative group">Vì sao là 198.18, và vì sao lại có địa chỉ IPv6
    <div id="vì-sao-là-19818-và-vì-sao-lại-có-địa-chỉ-ipv6" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-l%c3%a0-19818-v%c3%a0-v%c3%ac-sao-l%e1%ba%a1i-c%c3%b3-%c4%91%e1%bb%8ba-ch%e1%bb%89-ipv6" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các địa chỉ này trông hơi lạ, và những địa chỉ đầu tiên tôi chọn đã sai theo một cách khá bổ ích.</p>
<p>Lần đầu tôi thử <code>10.10.0.1</code>, vì đó là thứ ai cũng nghĩ tới đầu tiên. Nó thất bại ở cả hai chế độ split tunnel. Ở exclude mode, danh sách mặc định của WARP loại trừ mọi dải RFC 1918 cộng <code>100.64.0.0/10</code>, nên client sẽ bỏ lưu lượng tới địa chỉ đó trước khi nó kịp rời thiết bị. Ở include mode, nó sẽ đụng với bất kỳ mạng <code>10.x</code> thật nào tôi tình cờ tham gia; quán cà phê, không gian làm việc chung và rất nhiều văn phòng dùng đúng dải đó. <code>198.18.0.0/15</code> là dải dành cho đo kiểm hiệu năng (benchmarking) theo RFC 2544. Nó được dành riêng, không bao giờ được định tuyến trên internet công cộng, không nằm trong danh sách loại trừ của WARP, và không mạng thông thường nào dùng tới nó. Các địa chỉ IPv6 là ULA (<code>fd00::/8</code>) cũng vì lý do đó.</p>
<p>Còn vì sao lại có IPv6 thì mới là câu chuyện thú vị hơn. Khi địa chỉ v4 đã chạy, tôi thử từ điện thoại qua wifi, mọi thứ ổn, và tôi coi như xong. Dùng 5G thì không chạy. Agent báo <em>connected</em>, đường hầm khỏe mạnh, Access cũng ổn; phép thử qua wifi đã chứng minh tất cả những điều đó. Vấn đề là mạng di động bây giờ phần lớn chỉ có IPv6. Điện thoại hoàn toàn không có IPv4 gốc, và nó truy cập địa chỉ v4 kia qua bộ chuyển đổi 464XLAT của nhà mạng, thứ nằm <em>bên ngoài</em> đường hầm khi danh sách include chỉ chứa một tiền tố v4. Lưu lượng đi ra internet công cộng, nơi <code>198.18.0.0/15</code> không được định tuyến, và bị bỏ đi không một tiếng động. Không có lỗi nào trên điện thoại, không có gì trong log của connector.</p>
<p>Cách sửa là cấp cho mỗi máy một địa chỉ thứ hai mà điện thoại truy cập được trực tiếp qua IPv6, rồi thêm nó vào các tuyến và vào danh sách include. Giờ các địa chỉ <code>fd12:3456:789a::</code> chính là thứ mà client SSH trên điện thoại thật sự dùng khi chạy dữ liệu di động.</p>

<h2 class="relative group">Giảm tải: laptop chỉ còn là một cái terminal
    <div id="giảm-tải-laptop-chỉ-còn-là-một-cái-terminal" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%e1%ba%a3m-t%e1%ba%a3i-laptop-ch%e1%bb%89-c%c3%b2n-l%c3%a0-m%e1%bb%99t-c%c3%a1i-terminal" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây mới là phần mà cả hệ thống này tồn tại để phục vụ.</p>
<p><strong>Mô hình và fine-tuning.</strong> Ollama chạy trên devbox và bind vào <code>127.0.0.1:11434</code>. Phần còn lại của LAN văn phòng không nhìn thấy nó, huống gì internet. Trên laptop, nó xuất hiện ở <code>https://devbox-ollama.ssh.test</code>, hoặc dưới dạng một cổng trần cho những công cụ cần thế:</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">OLLAMA_HOST</span><span class="o">=</span>127.0.0.1:11435 ollama list</span></span></code></pre></div></div>
<p>Là 11435 chứ không phải 11434, và tôi cố ý: 11434 là cổng mặc định của chính Ollama, nên nếu có ngày laptop cũng chạy một Ollama cục bộ, hai bên sẽ tranh nhau cổng đó, và tùy cái nào khởi động trước mà tôi có thể âm thầm nhận câu trả lời của devbox từ cái Ollama &ldquo;cục bộ&rdquo;.</p>
<p>Hiệu quả thực tế là khi tôi làm một tính năng AI, mô hình mà nó gọi chạy trên GPU của máy trạm chứ không phải của laptop, dù tôi đang ở đâu. Các lượt đánh giá (evaluation), việc tạo embedding cho cả đống tài liệu, một lần fine-tune: tất cả chạy trên devbox, và quạt laptop thì cứ im. Khi đang làm prototype một agent, tôi có thể trỏ nó tới một mô hình cục bộ trên máy trạm và cho nó chạy hết vòng gọi công cụ này đến vòng khác (tool calling) mà không tốn hóa đơn API nào, rồi chuyển sang mô hình được host khi vòng lặp đã đúng.</p>
<p><strong>Agent chạy dài.</strong> Một phiên agent chạy cả tiếng đồng hồ không nên phụ thuộc vào việc nắp laptop có mở hay không. Chạy nó trên devbox thì laptop chỉ còn là một ô cửa sổ nhìn vào: gập máy lại, lát sau <code>ssh devbox</code> từ bất cứ đâu tôi đang ở, hay từ điện thoại, và nó vẫn còn đó. Trước khi có đường hầm, điều này chỉ làm được ở văn phòng, mà đó lại chính là nơi tôi không cần đến nó.</p>
<p><strong>Build và cụm máy.</strong> Cụm trên devbox có registry riêng, nên image có thể được build và push ngay trên máy trạm, cạnh nơi chúng sẽ chạy, thay vì upload từ laptop qua wifi khách sạn. Dashboard của cụm, dashboard của ingress và giao diện của registry đều là các hostname <code>*.ssh.test</code> trên laptop.</p>
<p><strong>Vòng lặp phát triển di động.</strong> Điện thoại ở cùng mạng riêng với laptop, và các dev server trên laptop cũng có hostname công khai nằm sau Access. Vậy nên tôi có thể mang một bản build native của ứng dụng ra khỏi tòa nhà, dùng 5G, và nó vẫn nói chuyện với API đang chạy trên bàn làm việc của tôi. Hoặc với API trên devbox, nếu tôi để nó chạy ở đó. Trước đây, vòng phản hồi trên thiết bị thật qua mạng di động thật đồng nghĩa với việc triển khai lên một staging server dùng chung. Giờ nó chỉ còn là lưu một tệp.</p>
<p>Có một cái bẫy ở đây, nên biết trước khi bạn đi debug nó như lỗi của ứng dụng. Access cũng bảo vệ cả hostname WebSocket, mà client WebSocket native thì không phải trình duyệt. Nó không mang cookie <code>CF_Authorization</code>, nên kết nối từ bản build native tới WebSocket server bị chặn ngay ở edge với mã 403, trông chẳng giống lỗi do server của bạn trả về chút nào. Các tab trình duyệt thì không sao vì chúng đã giữ cookie từ lúc đăng nhập. Với mọi thứ không phải trình duyệt (ứng dụng native, một script, một agent gọi API dev), lời giải là một <strong>service token</strong> của Access, gửi dưới dạng hai header:</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">CF-Access-Client-Id:     &lt;client_id&gt;
</span></span><span class="line"><span class="cl">CF-Access-Client-Secret: &lt;client_secret&gt;</span></span></code></pre></div></div>
<p>Bản thân token không cấp quyền gì. Access chỉ chấp nhận nó ở những ứng dụng có policy nêu tên nó, nên phạm vi của nó đúng bằng danh sách hostname dev, không rộng hơn. Tôi xếp policy của nó vào quyết định <code>non_identity</code> thay vì <code>allow</code>, để trong audit log nó vẫn hiện ra là một bí mật dạng bearer, chứ không phải một người đã đăng nhập.</p>

<h2 class="relative group">Những chỉ số internet không bao giờ thấy
    <div id="những-chỉ-số-internet-không-bao-giờ-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-ch%e1%bb%89-s%e1%bb%91-internet-kh%c3%b4ng-bao-gi%e1%bb%9d-th%e1%ba%a5y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nửa còn lại của điều tôi muốn là khả năng quan sát. Phần lớn những thứ cho bạn biết một hệ thống đang chạy thế nào, một cách đúng đắn, đều không công khai. Trên devbox, đó là:</p>
<ul>
<li>dashboard của ingress proxy, và giao diện tracing chứa trace của nó</li>
<li>một dashboard cho cụm Kubernetes</li>
<li>giao diện của container registry (chỉ đọc)</li>
<li>danh sách mô hình của Ollama và những gì đang được nạp lúc này</li>
<li><code>docker info</code> và số liệu thống kê của container</li>
</ul>
<p>Không thứ nào trong số đó có hostname công khai, và sẽ không bao giờ có. Chúng được bind vào loopback hoặc IP dịch vụ của cụm, truy cập qua một đường chuyển tiếp SSH mà bản thân nó lại chạy trên đường hầm. Một ứng dụng giám sát trên điện thoại đọc <code>docker info --format '{{json .}}'</code> qua SSH xuyên WARP, nên từ đâu tôi cũng xem được các container của mình có đang chạy không, hay có thứ gì đã ngốn hết bộ nhớ.</p>
<p>Chính ứng dụng đó giúp tôi tìm ra một lỗi nhỏ đáng kể lại. sshd dành cho đường hầm trên Mac vẫn đang để <code>LogLevel DEBUG2</code> từ hồi tôi debug nó. Với <code>-e</code>, sshd ghi log ra stderr, và với một phiên SSH thì stderr đó <em>chính là</em> stderr của kênh SSH. Output của mọi lệnh đều trả về kèm thêm dòng <code>debug2: do_setup_env: set TMPDIR</code> ở cuối, và ứng dụng trên điện thoại, vốn gộp hai luồng lại, nhận được JSON hợp lệ theo sau là một dòng log, parse thất bại, rồi hiển thị toàn bộ như một lỗi. Đổi sang <code>LogLevel INFO</code> là xong. Debug xong nhớ trả log level về như cũ.</p>
<p>Cũng có chủ đích không kém là những thứ tôi <em>không</em> đưa lên mạng này. Các đường hầm autossh cũ còn truy cập tới vài router trên mạng của người khác mà tôi giúp trông coi. Những router đó không có hostname công khai và không có tuyến WARP. Một trang quản trị router nằm trên internet là một rủi ro, dù có đăng nhập chắn phía trước, và đó cũng không phải mạng của tôi để mà mở rộng.</p>

<h2 class="relative group">Access là bắt buộc
    <div id="access-là-bắt-buộ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="#access-l%c3%a0-b%e1%ba%aft-bu%e1%bb%99c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hostname của một Cloudflare Tunnel nằm trên internet công cộng ngay khi bản ghi DNS của nó tồn tại. Không có tường lửa nào đứng trước nó; không mở cổng chính là mục đích của cả việc này. Lớp bảo vệ phải đến từ edge của Cloudflare, và đó là việc Access làm. Nó từ chối chuyển tiếp bất kỳ yêu cầu nào cho tới khi người truy cập chứng minh được mình là ai, nên một công cụ quét chưa xác thực không bao giờ chạm tới được laptop. Điều đó quan trọng, vì đây là các dev server: một traceback ở chế độ debug kèm cả settings, một JavaScript bundler mở toang. Không thứ gì đằng sau các hostname này được làm ra để đối mặt với internet.</p>
<p>Policy chỉ cho phép chính các địa chỉ email của tôi, không gì khác, với hai cách chứng minh là mã PIN dùng một lần gửi qua email và đăng nhập một lần (SSO). Phiên đăng nhập kéo dài tám tiếng, vì mối đe dọa thực tế là một chiếc laptop bỏ mở ở quán cà phê với cookie có hạn cả ngày, chứ không phải ai đó dò mã PIN bằng brute-force.</p>
<p>SSH được xác thực hai lần, bởi hai hệ thống không hề biết đến nhau. Access quyết định một luồng TCP có được phép tới sshd hay không; sshd thì vẫn đòi khóa của tôi. Mất một trong hai cũng không mở được máy. sshd dành cho đường hầm trên Mac còn tin tưởng SSH CA của Cloudflare, nên Access có thể đăng nhập cho tôi bằng một chứng chỉ ngắn hạn được cấp từ phiên SSO, trong khi <code>authorized_keys</code> vẫn chạy song song, để một cấu hình Access bị hỏng không thể khóa tôi ra khỏi chính laptop của mình.</p>
<p>Phần tôi hài lòng nhất lại là một chi tiết nhỏ. Việc lộ ra ngoài xảy ra khi ba thứ cùng khớp: một bản ghi DNS, một connector đang chạy và một zone đang hoạt động. Chúng trở thành đúng vào những thời điểm khác nhau, theo thứ tự tôi không kiểm soát được; zone tự chuyển sang active, vào một lúc nào đó sau khi nameserver được đổi, rất có thể trong lúc chiếc Mac đang ngủ. Vậy nên launch agent không chạy <code>cloudflared</code> trực tiếp. Nó chạy một bước kiểm tra trước (preflight): đọc các hostname từ chính cấu hình của đường hầm, hỏi Access API xem hostname nào đã được bảo vệ, và từ chối khởi động nếu còn cái nào chưa:</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">HOSTNAMES</span><span class="o">=</span><span class="k">$(</span>grep -E <span class="s1">&#39;^\s*-\s*hostname:&#39;</span> <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> <span class="p">|</span> sed -E <span class="s1">&#39;s/.*hostname:\s*//&#39;</span> <span class="p">|</span> sort -u<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">PROTECTED</span><span class="o">=</span><span class="k">$(</span>curl -fsS -H <span class="s2">&#34;Authorization: Bearer </span><span class="nv">$CLOUDFLARE_API_TOKEN</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="s2">&#34;https://api.cloudflare.com/client/v4/accounts/</span><span class="nv">$CLOUDFLARE_ACCOUNT_ID</span><span class="s2">/access/apps?per_page=100&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="p">|</span> python3 -c <span class="s1">&#39;import sys,json; print(&#34;\n&#34;.join(a.get(&#34;domain&#34;,&#34;&#34;) for a in json.load(sys.stdin)[&#34;result&#34;]))&#39;</span><span class="k">)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;could not reach the Access API. refusing (fail closed).&#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">
</span></span><span class="line"><span class="cl"><span class="k">for</span> h in <span class="nv">$HOSTNAMES</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    grep -qxF <span class="s2">&#34;</span><span class="nv">$h</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$PROTECTED</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;NOT starting: </span><span class="nv">$h</span><span class="s2"> has no Access app&#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"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> cloudflared tunnel --config <span class="s2">&#34;</span><span class="nv">$CONFIG</span><span class="s2">&#34;</span> run</span></span></code></pre></div></div>
<p>Launch agent có <code>KeepAlive</code>, nên một lần từ chối sẽ tự thử lại sau mỗi 30 giây, và đường hầm tự lên khi các Access app đã tồn tại. Thất bại theo hướng đóng (fail closed) thì cái giá là một lần gián đoạn. Thất bại theo hướng mở (fail open) thì cái giá là bị lộ. Tôi biết mình thà phải giải thích chuyện nào hơn.</p>

<h2 class="relative group">Bốn chỗ khiến tôi vấp
    <div id="bốn-chỗ-khiến-tôi-vấ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="#b%e1%bb%91n-ch%e1%bb%97-khi%e1%ba%bfn-t%c3%b4i-v%e1%ba%a5p" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ ở trên giờ đều chạy. Nhưng trên đường tới đây, ba trong bốn chỗ có cùng một kiểu: thứ bị hỏng không sinh ra lỗi ở bất cứ đâu, còn những phần tôi nhìn thấy được thì đều báo khỏe mạnh.</p>
<p><strong>Tuyến và danh sách include là hai danh sách khác nhau.</strong> Một tuyến của đường hầm khiến Cloudflare <em>sẵn lòng</em> mang một địa chỉ tới đúng connector. Danh sách include của WARP mới là thứ khiến client <em>gửi</em> nó đi ngay từ đầu. devbox không truy cập được từ điện thoại vì các tuyến của nó đã có, nhưng danh sách include vẫn chỉ chứa địa chỉ của chiếc Mac. Điện thoại bỏ gói tin ngay tại máy, connector không hề thấy luồng nào, và dashboard nào cũng xanh. Mỗi máy mới đồng nghĩa với việc thêm địa chỉ của nó vào cả hai nơi.</p>
<p><strong>Địa chỉ IPv4 viết cứng trên mạng di động chỉ có IPv6.</strong> Đã kể ở trên: chạy trên wifi, hỏng trên 5G, không có lỗi. Nếu một tuyến WARP chạy trên wifi mà không chạy trên dữ liệu di động, trong khi Agent vẫn báo connected, thì vấn đề nằm ở định tuyến, không phải ở đường hầm.</p>
<p><strong>sshd của macOS đằng sau cloudflared.</strong> macOS khởi động sshd bằng cơ chế kích hoạt qua socket của launchd, mỗi kết nối một tiến trình <code>sshd -i</code> sống ngắn. Đằng sau cloudflared, banner tới được client rồi luồng bị cắt trước khi chuỗi nhận dạng của client kịp gửi ngược lại, nên mọi lần đăng nhập đều chết ở <code>kex_exchange_identification</code>. Tôi đã xác nhận từ cả hai đầu và tái hiện được bằng một bộ chuyển tiếp TCP thuần, qua đó loại trừ Cloudflare. Cách sửa là chạy thêm một <code>sshd -D</code> độc lập thứ hai trên <code>127.0.0.1:2222</code> và các địa chỉ loopback của WARP, chạy dưới user của tôi, chỉ để làm origin cho đường hầm. Remote Login trong System Settings không bị đụng tới. devbox chạy Linux với một sshd sống lâu bình thường, nên chưa bao giờ gặp vấn đề này.</p>
<p><strong>Những trường cấu hình không còn tồn tại.</strong> Các ví dụ cũ vẫn ghi <code>warp-routing: enabled: true</code> trong cấu hình đường hầm. cloudflared 2026.8 từ chối thẳng trường đó. WARP routing được bật hễ khối cấu hình tồn tại và có một tuyến trỏ tới đường hầm. Ít ra cái này còn báo lỗi, mà sau ba vụ kia thì như thế đã là hào phóng lắm rồi.</p>

<h2 class="relative group">Những gì đã thay đổi
    <div id="những-gì-đã-thay-đổ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="#nh%e1%bb%afng-g%c3%ac-%c4%91%c3%a3-thay-%c4%91%e1%bb%95i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tóm lại một cách thành thật: tôi không còn chọn chỗ làm việc dựa trên chỗ đặt phần cứng nữa. Laptop là một bàn phím tốt, một màn hình tốt với thời lượng pin dài, và nó không cần phải hơn thế. Máy trạm gánh phần việc nặng dù tôi đang ở đâu. Điện thoại là một thiết bị kiểm thử thật trên một mạng thật, chứ không phải thứ bị buộc vào wifi văn phòng. Và những phần của hệ thống mà tôi dùng để xem nó đang làm gì thì chỉ cách tôi một cú bấm, còn với người khác thì chúng không hề tồn tại.</p>
<p>Ở quy mô này, mọi thứ nằm gọn trong gói Zero Trust miễn phí của Cloudflare. Cái giá tôi phải trả là thời gian, và một tệp README mà tôi mừng vì đã viết, vì nếu không thì mỗi lỗi ở trên đều sẽ lại là một bí ẩn mới ở lần gặp thứ hai.</p>
<p>Nếu bạn đang tính làm điều gì đó tương tự, đây là những câu tôi sẽ hỏi về hệ thống của bạn:</p>
<ol>
<li>Thứ gì đang chạy trên laptop của bạn chỉ vì laptop là cái máy duy nhất bạn luôn truy cập được?</li>
<li>Điện thoại của bạn có truy cập được dev server qua dữ liệu di động không, chứ không chỉ qua wifi văn phòng? Hãy thử trên 5G, đừng thử trên wifi.</li>
<li>Dashboard nào của bạn đang công khai chỉ vì đó là cách dễ nhất để xem nó từ bên ngoài? Mỗi cái như thế đều là ứng viên để chuyển sang một tuyến riêng.</li>
<li>Nếu ngày mai một hostname của đường hầm được bật lên mà chưa có Access app, thứ gì sẽ chặn nó lại? Nếu câu trả lời là &ldquo;tôi sẽ nhớ&rdquo;, hãy viết bước preflight.</li>
<li>Các địa chỉ riêng của bạn có nằm trong một dải mà những mạng bạn ghé qua sẽ không bao giờ dùng không?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/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>Làm sao để được ChatGPT trích dẫn: những gì log của một blog nhỏ cho thấy</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/</guid>
      <pubDate>Wed, 16 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>SEO</category>
      <category>Crawlers</category>
      <category>LLM</category>
      <category>Hugo</category>
      <category>Cloudflare</category>
      <category>content strategy</category>
      <category>Writing</category>
      <description>Muốn được ChatGPT và các trợ lý AI trích dẫn (GEO): nên cho bot nào vào, HTML không cần trình duyệt, tên tác giả trong dữ liệu có cấu trúc, và llms.txt mà chẳng ai đọc.</description>
      <content:encoded><![CDATA[<p>Muốn được ChatGPT và các trợ lý AI khác trích dẫn, việc đầu tiên là cho bot tìm kiếm của chúng, loại bot tải trang ngay lúc trả lời, vào đọc trang của bạn. Chúng khác với các crawler huấn luyện, và chặn loại này chẳng ảnh hưởng gì đến loại kia. Sau đó là trả về HTML đọc được mà không cần chạy JavaScript, kèm dữ liệu có cấu trúc (structured data) ghi rõ tên tác giả. Còn llms.txt thì theo log của tôi chẳng ai đến lấy nên không có tác dụng gì; điều quan trọng nhất là viết sao cho mỗi phần nêu luận điểm ngay câu đầu, để một đoạn có thể được trích dẫn riêng.</p>
<p>Lượng truy cập từ công cụ tìm kiếm vào blog này đã đứng yên vài năm nay, và tôi cũng thôi mong nó nhúc nhích. Nên thay vào đó tôi đi xem khách đến từ đâu, và trong số đó có các trợ lý AI (AI assistant) hay không. Có, nhưng chỉ vừa đủ để gọi là có. Trong mười hai tháng tính đến ngày 16 tháng 9 năm 2026, công cụ phân tích truy cập của tôi đếm được 5,372 phiên truy cập (session) ở đây. Hai mươi bảy phiên trong số đó đến từ một trợ lý AI: mười hai từ chatgpt.com, chín từ gemini.google.com, sáu từ Perplexity, và không có phiên nào từ claude.ai. Nửa phần trăm. Nhưng khách đến từ ChatGPT lại là loại khách tốt. Họ ở lại trung bình năm phút, so với 27 giây của lượt truy cập trực tiếp và ba phút của khách từ Google, vì họ đã đọc xong câu trả lời rồi và muốn xem chi tiết.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Số phiên theo nguồn giới thiệu trong mười hai tháng, trợ lý AI được tô đậm"
    width="2400"
    height="1164"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_590f408ed875db1f.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_d8da7a3f1d1c9190.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-referrers_hu_b3dda7d23abf271d.webp"></span><figcaption>Số phiên theo nguồn trong mười hai tháng. Các cột xanh là trợ lý AI: 27 trên tổng 5,372 phiên.</figcaption></figure>
</p>
<p>Nên tôi đã dành thời gian tìm hiểu xem một blog nhỏ làm thế nào để được chọn làm nguồn cho những câu trả lời đó. Bài này là những gì tôi đã làm trên trang này, cùng với những gì log nói về chuyện đó. Chỗ nào có số thì tôi đưa số, chỗ nào đang đoán thì tôi nói rõ là đoán. Có ba câu trong bản nháp đầu hóa ra sai khi tôi thực sự nhìn vào số liệu, và tôi để nguyên các chỗ sửa trong bài.</p>

<h2 class="relative group">Hai loại bot, và chỉ một loại trích dẫn bạn
    <div id="hai-loại-bot-và-chỉ-một-loại-trích-dẫn-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="#hai-lo%e1%ba%a1i-bot-v%c3%a0-ch%e1%bb%89-m%e1%bb%99t-lo%e1%ba%a1i-tr%c3%adch-d%e1%ba%abn-b%e1%ba%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Điều đầu tiên cần phân định rõ là &ldquo;bot AI&rdquo; thực ra là hai công việc khác nhau với hai bộ user agent khác nhau, và chặn loại này chẳng ảnh hưởng gì đến loại kia.</p>
<p>Trình thu thập dữ liệu (crawler) huấn luyện kéo trang về hàng loạt để xây mô hình tiếp theo. Của OpenAI là GPTBot, của Anthropic là ClaudeBot, của Google được điều khiển qua token Google-Extended, của Apple qua Applebot-Extended, còn CCBot của Common Crawl thì cấp dữ liệu cho phần lớn các bộ dữ liệu mở. Những gì chúng lấy đi cuối cùng trở thành trọng số (weights) của mô hình, không có đường dẫn nào trỏ ngược về bạn. Chặn những bot này là có lý, và tôi không cho đó là chuyện vớ vẩn.</p>
<p>Bot truy xuất (retrieval bot) là loại còn lại. Khi ai đó hỏi ChatGPT, Claude hay Perplexity một câu, trợ lý sẽ chạy một lượt tìm kiếm, tải về ngay lúc đó một nhúm trang, đọc chúng, rồi viết câu trả lời kèm trích dẫn (citation). Những lượt tải đó đến dưới tên OAI-SearchBot và ChatGPT-User, Claude-SearchBot và Claude-User, PerplexityBot và Perplexity-User. AI Overviews của Google thì dùng Googlebot thường, nên chẳng có công tắc riêng nào cho chúng cả. Đây mới là những bot đưa người đọc quay lại với bạn. Nếu mục tiêu là được trích dẫn thì đây là những bot bạn đang tối ưu cho, và cũng nên biết rằng chặn GPTBot không ảnh hưởng gì đến kết quả tìm kiếm của ChatGPT, theo cả hai chiều.</p>
<p>robots.txt của trang này là bản mặc định của Hugo, tức là cho phép 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">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /
</span></span><span class="line"><span class="cl">Sitemap: https://jared.lynskey.co.nz/sitemap.xml</span></span></code></pre></div></div>
<p>Tôi đã cân nhắc tách nó ra, cho bot truy xuất vào và giữ bot huấn luyện ở ngoài, rồi quyết định là không đáng làm với một blog mà mục đích duy nhất là để được đọc. Nếu tôi muốn vậy thì nó sẽ trông như khối bên dưới. Lý do đưa nó ra đây cũng là bài học trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >Cloudflare trả về cho Googlebot một trang trống</a>: danh sách phải gọi tên bot, không phải tên hãng. Không có dòng nào bạn viết ra được để nói &ldquo;OpenAI&rdquo;.</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">User-agent: GPTBot
</span></span><span class="line"><span class="cl">User-agent: ClaudeBot
</span></span><span class="line"><span class="cl">User-agent: Google-Extended
</span></span><span class="line"><span class="cl">User-agent: Applebot-Extended
</span></span><span class="line"><span class="cl">User-agent: CCBot
</span></span><span class="line"><span class="cl">Disallow: /
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">User-agent: *
</span></span><span class="line"><span class="cl">Allow: /</span></span></code></pre></div></div>
<p>Một điều cần kiểm tra nếu trang nằm sau Cloudflare. Từ giữa năm 2025, các zone mới ở đó chặn trình thu thập dữ liệu AI theo mặc định, và có một nút bật một chạm trong phần cài đặt bot làm điều tương tự với các zone cũ. Nó chặn cả hai loại. Blog này vừa chuyển sang Cloudflare Pages tuần trước, nên việc đó có trong danh sách của tôi. Nếu bạn bật nó lên vì &ldquo;bot AI&rdquo; nghe như thứ nên chặn ở ngoài, thì bạn cũng vừa tắt luôn việc được trích dẫn. Cloudflare sau đó đã bắt đầu ghi một dòng <code>Content-Signal</code> vào các tệp robots.txt mà họ quản lý, với ba cờ riêng <code>search</code>, <code>ai-input</code> và <code>ai-train</code>. Không ai bị bắt buộc phải tuân theo, nhưng đó là đúng bộ từ vựng: cùng cách phân chia như trên, diễn đạt như một nguyện vọng thay vì một bức tường.</p>

<h2 class="relative group">Thực sự thì ai đang gõ cửa
    <div id="thực-sự-thì-ai-đang-gõ-cửa" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#th%e1%bb%b1c-s%e1%bb%b1-th%c3%ac-ai-%c4%91ang-g%c3%b5-c%e1%bb%ada" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Việc chuyển sang Cloudflare có một tác dụng phụ: lần đầu tiên tôi nhìn thấy được từng request theo user agent. Dưới đây là những gì đã ghé vào trong bốn ngày từ ngày 13 đến ngày 16 tháng 9 năm 2026, sau khi đã loại bỏ các bot giả mạo (spoof), phần này tôi sẽ nói ngay sau đây.</p>
<table>
	<thead>
			<tr>
					<th>Bot</th>
					<th>Số request</th>
					<th>Nó đã làm gì</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>ClaudeBot (huấn luyện)</td>
					<td>1,777</td>
					<td>1,380 trang khác nhau trong hai ngày, đủ cả năm ngôn ngữ, cộng thêm 37 lần đọc robots.txt</td>
			</tr>
			<tr>
					<td>meta-externalagent (huấn luyện)</td>
					<td>1,748</td>
					<td>Y hệt như trên, chậm hơn một ngày</td>
			</tr>
			<tr>
					<td>OAI-SearchBot (chỉ mục tìm kiếm của ChatGPT)</td>
					<td>59</td>
					<td>Các trang ở cả năm ngôn ngữ, và robots.txt mười sáu lần</td>
			</tr>
			<tr>
					<td>ChatGPT-User (tải về lúc đang trả lời)</td>
					<td>20</td>
					<td>Bài tiếng Nhật về khởi động nguội (cold start), bài về máy chủ cập nhật tự dựng, trang chủ</td>
			</tr>
			<tr>
					<td>Claude-User (tải về lúc đang trả lời)</td>
					<td>8</td>
					<td>Bài về máy chủ cập nhật, trang liên hệ, trang chủ</td>
			</tr>
			<tr>
					<td>PerplexityBot</td>
					<td>2</td>
					<td>Bài về gỡ lỗi (debug) iOS, bản tiếng Anh và bản tiếng Việt</td>
			</tr>
			<tr>
					<td>Claude-SearchBot, Perplexity-User</td>
					<td>0</td>
					<td></td>
			</tr>
			<tr>
					<td>Googlebot / Bingbot / Applebot / CCBot</td>
					<td>168 / 71 / 45 / 28</td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>Có hai điều nổi bật. Các bot huấn luyện (training crawler) đọc hết mọi thứ, tới hai lần, trong khoảng thời gian mà các bot truy xuất chỉ đọc vài chục trang. Và bot truy xuất không tải về một cách ngẫu nhiên: cả ChatGPT-User lẫn Claude-User đều nhắm vào bài về máy chủ cập nhật, là bài cụ thể nhất trên trang này, còn bài về khởi động nguội bản tiếng Nhật thì được tải về đúng bằng tiếng Nhật.</p>
<p>Giờ đến phần giả mạo. Trong 20 request tự xưng là GPTBot, có 18 đến từ những địa chỉ nằm ngoài dải địa chỉ IP (IP range) mà OpenAI công bố, và chúng đòi <code>/.env</code>, <code>/.git/HEAD</code> và <code>/firebase-adminsdk.json</code>. Mười lăm trong 36 request &ldquo;ChatGPT-User&rdquo; cũng là kiểu đó. Phần lớn lưu lượng &ldquo;PerplexityBot&rdquo; thì muốn lấy <code>/id_ed25519</code>. User agent chỉ là một chuỗi ký tự mà ai cũng gửi được, và các công cụ quét lỗ hổng đã nhận ra rằng cứ mang tên bot AI là được cho qua. OpenAI, Anthropic và Perplexity đều công bố dải địa chỉ IP của mình. Nếu bạn định đối xử đặc biệt với một bot, dù là cho vào hay chặn lại, hãy kiểm tra địa chỉ chứ đừng tin cái tên. Các con số 20, 59 và 8 ở trên là những request đến từ đúng địa chỉ.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Các yêu cầu tự nhận là user agent của OpenAI, tách theo việc có đến từ dải IP OpenAI công bố hay không"
    width="2400"
    height="722"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_e7035266dc7ce0b1.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_638e8c6286f5c91f.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-spoofed_hu_11d44262308d7c6c.webp"></span><figcaption>Cùng một chuỗi user agent, tách theo địa chỉ nguồn. Phần lớn lưu lượng tự xưng &lsquo;GPTBot&rsquo; không hề đến từ OpenAI.</figcaption></figure>
</p>

<h2 class="relative group">Trang phải đọc được mà không cần trình duyệt
    <div id="trang-phải-đọc-được-mà-không-cần-trình-duyệ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="#trang-ph%e1%ba%a3i-%c4%91%e1%bb%8dc-%c4%91%c6%b0%e1%bb%a3c-m%c3%a0-kh%c3%b4ng-c%e1%ba%a7n-tr%c3%acnh-duy%e1%bb%87t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bot truy xuất tải trang ngay lúc trả lời, có giới hạn thời gian chờ (timeout), và phần lớn không chạy JavaScript. Một trang chỉ là cái div rỗng cho đến khi bundle tải xong là một trang chúng không đọc. Hugo xuất ra HTML hoàn chỉnh, nên blog này có sẵn điều đó mà không phải làm gì, và đó là lý do chính tôi chưa bao giờ bị cám dỗ chuyển nó sang thứ gì đó thông minh hơn. Sàn mua bán tôi đang vận hành là trường hợp ngược lại, một ứng dụng web React Native, và để trình thu thập dữ liệu nhận được một tài liệu thực sự ở đó, tôi đã phải làm một nhánh dựng trang động (dynamic rendering), rồi dính một lỗi bộ nhớ đệm (cache) lặng lẽ phá hỏng nó. Bài Cloudflare ở trên chính là câu chuyện đó.</p>
<p>Tốc độ cũng được tính theo cách tương tự, và ở chỗ này log đã sửa lưng tôi. Tôi vẫn đinh ninh một trang tĩnh nằm sau Cloudflare thì được phục vụ từ máy chủ biên (edge) gần nhất. Không phải vậy. Blog này chạy trên Cloudflare Pages, HTML được trả ra mà không qua bộ nhớ đệm, và từ Seoul thì phản hồi quay về từ Los Angeles mất khoảng nửa giây. Với một bot có thời gian chờ vài giây thì vẫn ổn, nhưng nó không phải vài chục mili giây như tôi suýt viết ra, và tôi biết được chỉ vì đã đo. Một trợ lý đang ghép câu trả lời từ sáu trang sẽ bỏ cái trang mất ba giây, và nó không nói cho bạn biết là nó đã bỏ.</p>

<h2 class="relative group">Viết sao cho nhấc nguyên một đoạn ra được
    <div id="viết-sao-cho-nhấc-nguyên-một-đoạn-ra-đượ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="#vi%e1%ba%bft-sao-cho-nh%e1%ba%a5c-nguy%c3%aan-m%e1%bb%99t-%c4%91o%e1%ba%a1n-ra-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách các hệ thống này dùng một trang không giống cách một người đọc dùng. Chúng cắt trang thành từng khúc (chunk), chấm điểm từng khúc so với câu hỏi, rồi trích những khúc điểm cao. Một bài dẫn dắt suốt tám trăm chữ mới đến ý chính thì khúc nào điểm cũng thấp. Một bài mà mỗi phần nêu luận điểm ngay câu đầu rồi mới chứng minh thì phần khớp với câu hỏi sẽ ghi điểm.</p>
<p>Điều đó thay đổi cách tôi viết ở đây nhiều hơn bất cứ thứ gì thuộc về kỹ thuật. Phần description trong front matter giờ là tóm tắt bài nói gì, không phải câu mồi để bạn bấm vào đọc, vì description thường là phần duy nhất trợ lý hiển thị. Tiêu đề mục nói thẳng mục đó kết luận gì. Con số đi kèm đơn vị ngay trong câu thay vì nằm trong biểu đồ. Ngày tháng được viết ra đầy đủ, vì &ldquo;tuần trước&rdquo; chẳng có nghĩa gì với một mô hình đọc trang này một năm sau. Và một bài chỉ nói về một chuyện.</p>
<p>Nửa còn lại là phải có thứ đáng để trích, và ở chỗ này số liệu lại không thuận một chiều. Trong log, những trang mà bot tải về lúc trả lời là những bài cụ thể: khởi động nguội, máy chủ cập nhật, gỡ lỗi iOS. Trình thu thập dữ liệu của Naver tên là Yeti và của Daum là Daumoa, một lần khởi động lại máy ở nhà cung cấp hosting của tôi là một khoảng ba mươi giây trả lỗi 504, Cloudflare nén Brotli tại chỗ đo trên bundle của tôi còn tệ hơn gzip. Những dữ kiện đó không được ghi lại ở nhiều nơi, nên câu hỏi nào cần đến một trong số chúng sẽ rơi vào đây. Nhưng những người bấm từ trợ lý sang thì phần lớn lại rơi vào các bài về quản lý từ năm 2023 và 2024, mỗi bài một hai phiên. Bài cụ thể thì máy đọc. Bài chung chung thì người bấm. Tôi không có lời giải thích gọn gàng nào cho chuyện đó, và tôi thà kể lại đúng như vậy còn hơn bịa ra một lý do. Đòn bẩy, nếu có, vẫn là viết ra cái điều cụ thể bạn vừa phát hiện tuần này mà lúc tìm kiếm bạn không thấy đâu, vì đó rõ ràng là thứ máy đang đọc.</p>

<h2 class="relative group">Nói rõ ai viết, và lần nào cũng nói như nhau
    <div id="nói-rõ-ai-viết-và-lần-nào-cũng-nói-như-nhau" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b3i-r%c3%b5-ai-vi%e1%ba%bft-v%c3%a0-l%e1%ba%a7n-n%c3%a0o-c%c5%a9ng-n%c3%b3i-nh%c6%b0-nhau" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một trợ lý khi cân nhắc có trích dẫn một trang hay không đang làm gần giống một người đọc cẩn thận: ai viết cái này, viết khi nào, và nó có khớp với mọi thứ khác tôi đã tìm thấy không? Phần cơ chế của việc đó trên trang này chủ yếu là do theme làm. Mỗi bài đều mang dữ liệu có cấu trúc (structured data) kiểu Article với tác giả, ngày đăng và ngày sửa, và tác giả là một Person với liên kết đến cùng các hồ sơ công khai. Đó là vài dòng trong template, và là khác biệt giữa &ldquo;một trang&rdquo; với &ldquo;một trang của một người có tên tuổi và có quá trình&rdquo;.</p>
<p>Phần không phải theme làm là sự nhất quán. Trang giới thiệu, llms.txt, các hồ sơ công khai của tôi và dòng ở đầu mỗi bài đều mô tả cùng một người với cùng một quá trình làm việc. Khi có gì thay đổi thì tất cả cùng đổi, nếu không, cái tôi quên sẽ thành cái sai. Nghe có vẻ tỉ mẩn, nhưng một trang nói tôi là quản lý kỹ thuật (engineering manager) còn trang khác nói tôi làm tự do chính là kiểu mâu thuẫn khiến mô hình trả lời nước đôi, hoặc chọn người khác.</p>
<p>Ngày sửa cũng phải thành thật. Nó đổi khi nội dung đổi, ngoài ra tôi không đụng vào. Đẩy ngày lên cho trông có vẻ mới là kiểu chiêu dùng được một lần, rồi cả tên miền của bạn bị hạ tín nhiệm.</p>

<h2 class="relative group">llms.txt là gì, và sự thật là chẳng ai đọc nó
    <div id="llmstxt-là-gì-và-sự-thật-là-chẳng-ai-đọc-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="#llmstxt-l%c3%a0-g%c3%ac-v%c3%a0-s%e1%bb%b1-th%e1%ba%adt-l%c3%a0-ch%e1%ba%b3ng-ai-%c4%91%e1%bb%8dc-n%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>llms.txt là một đề xuất từ tháng Chín năm 2024 về một tệp Markdown đặt ở gốc trang, cung cấp cho mô hình ngôn ngữ một bản tóm tắt về trang cùng một danh sách liên kết chọn lọc. Tôi thêm một tệp như vậy vào tháng Hai năm nay. Nó gồm một đoạn về tôi là ai, rồi các mục cho quản lý, hạ tầng, di động và cộng đồng, mỗi bài một dòng: tiêu đề, URL, và một câu nói bài đó kết luận gì. Đọc nó giống như một mục lục viết cho người đang vội.</p>
<p>Điều đáng nói là chẳng ai đọc nó. Trong bốn ngày log tôi có, những request duy nhất tới <code>/llms.txt</code> là của chính tôi. Common Crawl đã lấy 76 trang của site này trong đợt thu thập tháng 8 năm 2026 mà chưa lần nào đụng đến tệp đó. Chưa có trợ lý lớn nào nói rằng họ đọc nó. Nên nó không phải lý do bất cứ thứ gì ở đây được trích dẫn, và tôi sẽ không khuyên ai làm nó vì lý do đó. Tôi giữ nó vì hai lý do. Nó tốn một tiếng, và việc viết cho mỗi bài một câu nói bài đó thực sự kết luận gì còn đáng giá hơn bản thân cái tệp, vì nó cho tôi thấy bài nào chẳng kết luận được gì. Nó cũng là trang duy nhất trên site nói cho máy biết trang này để làm gì, và tôi thà có mà không cần đến. Từ đó nó đã lệch đi, vì tôi viết tay và hay quên, và đó là lý do nên sinh nó từ front matter như một định dạng đầu ra (output format) của Hugo thay vì viết tay. Việc đó đang nằm trong danh sách.</p>

<h2 class="relative group">Feed, sitemap và năm ngôn ngữ
    <div id="feed-sitemap-và-năm-ngôn-ngữ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#feed-sitemap-v%c3%a0-n%c4%83m-ng%c3%b4n-ng%e1%bb%af" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần đường ống nhàm chán vẫn có giá trị. Sitemap là cách trình thu thập dữ liệu tìm thấy bài mới ngay ngày nó lên. RSS là cách nó biết một bài đã thay đổi. Cả hai đều là mặc định của Hugo và tôi chưa bao giờ phải nghĩ đến. ClaudeBot đọc sitemap 36 lần trong hai ngày, và đó là cách nó tìm ra 1,380 trang nhanh đến vậy.</p>
<p>Ngôn ngữ là phần tôi không ngờ lại quan trọng. Mọi thứ ở đây có bản tiếng Anh, Nhật, Hàn, Trung giản thể và Việt, với các liên kết thay thế (alternate link) để trình thu thập dữ liệu biết chúng là cùng một bài. Các trình thu thập dữ liệu coi chúng như năm site riêng: lượt thu thập của ClaudeBot chia gần như đều cho cả năm, và trong 73 trang bài viết mà Common Crawl lấy hồi tháng 8, chỉ có bốn trang là bản tiếng Anh. Trợ lý trả lời bằng ngôn ngữ được hỏi và nghiêng về nguồn bằng chính ngôn ngữ đó, nên một câu hỏi tiếng Hàn về khởi động nguội có thể được trả lời từ trang tiếng Hàn thay vì từ một bản dịch tại chỗ của trang tiếng Anh, và trang tiếng Nhật chính là trang mà ChatGPT thực sự đã tải về. Điều kiện là bản dịch phải đọc như thể được viết bằng chính ngôn ngữ đó. Lượt rà soát tôi làm đầu năm nay, có người bản ngữ kiểm tra, là dành cho người đọc, nhưng nó cũng là thứ khiến trang tiếng Việt trở thành một nguồn thay vì một món lạ.</p>

<h2 class="relative group">Những gì tôi đo được
    <div id="những-gì-tôi-đo-đượ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="#nh%e1%bb%afng-g%c3%ac-t%c3%b4i-%c4%91o-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nhiều hơn tôi tưởng, một khi tôi thôi đoán mò. Gói miễn phí của Cloudflare cho xem log request theo user agent và đường dẫn, mỗi lần truy vấn được một ngày, và bảng bot ở trên là từ đó mà ra; còn muốn xem referrer thì phải lên gói trả phí. Công cụ phân tích truy cập thì có referrer, và kéo chúng ra qua API báo cáo của nó thay vì căng mắt nhìn dashboard chính là cách tôi có được các con số ở đầu bài. Ghép hai nguồn lại thì thấy được hình dạng của câu chuyện. Trong bốn ngày, các bot truy xuất tải về khoảng chín mươi trang từ các địa chỉ của OpenAI và Anthropic. Trong tháng vừa rồi, có hai người đến từ ChatGPT. Đọc nhiều, thỉnh thoảng trích dẫn, hiếm khi bấm vào. Vẫn chưa có cách nào tôi biết để xem những câu trả lời đã dùng một trang mà không được bấm vào. Cách kiểm tra hữu ích nhất vẫn là làm bằng tay: hỏi trợ lý đúng câu hỏi mà bài của bạn trả lời, rồi xem nó trích trang của ai.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Số phiên do trợ lý AI giới thiệu theo tháng trong mười ba tháng, không có xu hướng"
    width="2400"
    height="968"
    src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_cd5dc99be8b0cfc3.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_7d401a84f177509c.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/chart-monthly_hu_5c308d89ad110ef7.webp"></span><figcaption>Lượt truy cập từ trợ lý AI theo tháng. Không có xu hướng gì đáng nói.</figcaption></figure>
</p>
<p>Nếu phải giữ lại một thứ từ tất cả những điều này thì đó là quy tắc viết. robots.txt, dữ liệu có cấu trúc và llms.txt là công việc của một buổi chiều, xong là xong. Viết sao cho mỗi phần nêu luận điểm trước, và viết về điều cụ thể thay vì điều chung chung, là một thói quen, và chính thói quen đó quyết định một trang có đáng trích dẫn hay không. Điều thứ hai là hãy đo trước rồi hãy khẳng định. Hóa ra được máy tin cậy trông rất giống được người tin cậy: nói bạn là ai, nói bạn biết gì, và lần nào cũng nói theo cùng một cách.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-16-blog-as-a-source-for-ai-assistants/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Triển khai frontend và backend riêng biệt, không cần triển khai lại API</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/</guid>
      <pubDate>Mon, 14 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>Deployment</category>
      <category>Object Storage</category>
      <description>Cách tôi triển khai frontend và backend riêng: tải bản build lên object storage dưới tiền tố có phiên bản, đổi con trỏ mà Django đọc, rồi xóa bộ nhớ đệm Cloudflare qua API.</description>
      <content:encoded><![CDATA[<p>Để triển khai frontend và backend riêng biệt (deploy frontend and backend separately), tôi thôi sao chép bản build web vào Docker image của API. Giờ một script build giao diện, tải lên object storage dưới một tiền tố có phiên bản, rồi đổi một đối tượng con trỏ; Django đọc con trỏ đó ở mỗi yêu cầu và phục vụ đúng phiên bản được ghi trong đó. Sau cùng, một lệnh gọi API xóa bộ nhớ đệm (purge cache) của Cloudflare làm mới phần HTML. Một bản sửa CSS giờ chỉ mất khoảng một phút và không khởi động lại máy nào.</p>
<p>Cho tới gần đây, mọi thay đổi ở phần web của ứng dụng chợ mà tôi đang làm đều được đưa lên theo đúng cách của một thay đổi trên API Django: build Docker image, đẩy lên, rồi triển khai lần lượt (rolling) lên từng máy trên Fly.io. Giao diện web (bản xuất web của ứng dụng di động, cộng với các trang giới thiệu do một trình tạo trang tĩnh (static site generator) build ra) được sao chép vào image ngay cạnh API, nên không có cách nào khác để đưa nó ra. Sửa một dòng CSS mất khoảng bảy phút và khởi động lại 11 máy. Có một ngày trong tháng Tám tôi làm việc đó sáu lần.</p>
<p>Bài này đi lần lượt qua thứ tôi đã thay vào đó: một script triển khai build cả hai cây giao diện, tải chúng lên object storage dưới một tiền tố có phiên bản, đổi một đối tượng con trỏ, đợi đến khi mọi máy đều đang phục vụ phiên bản mới, rồi xóa bộ nhớ đệm của Cloudflare qua API. Ở phía bên kia, Django đọc con trỏ ở mỗi yêu cầu và phục vụ đúng phiên bản được ghi trong đó. API không khởi động lại chút nào.</p>

<h2 class="relative group">Vì sao cách cũ có vấn đề
    <div id="vì-sao-cách-cũ-có-vấ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-c%c3%a1ch-c%c5%a9-c%c3%b3-v%e1%ba%a5n-%c4%91%e1%bb%81" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chi phí lớn nhất là việc khởi động lại. Mười một máy triển khai lần lượt nghĩa là mười một lần khởi động nguội (cold start), và trên CPU dùng chung thì Django mất khoảng mười ba giây để lên, trong đó bảy giây chỉ để import Django. Tôi có thêm một bước làm nóng ở cuối script triển khai, gọi vào từng máy một để người dùng thật không phải là người trả cái giá đó, và hệ quả là mỗi lần phát hành web đều kết thúc bằng việc ngồi đợi máy chậm nhất. Những lần khởi động nguội đó tốn kém thế nào trên Fly.io, tôi đã viết trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >Thu về 0 (scale to zero), và mỗi lần khởi động lại là một lần sập 30 giây</a>. Nói ngắn gọn, mỗi lần khởi động lại là một khoảng thời gian người dùng có thể nhận lỗi 504, nên một đường triển khai không khởi động lại gì cả sẽ đóng hẳn khoảng đó cho các lần phát hành web.</p>
<p>Vấn đề thứ hai là đầu ra của trang tĩnh bị tách đôi. HTML thì vào image, nhưng RSS feed và chỉ mục tìm kiếm thì lên thẳng bucket. Chúng được build từ cùng một nguồn, chỉ là không cùng thời điểm, nên có thể lệch nhau mà chẳng ai nhận ra cho đến khi lệch thật.</p>
<p>Vấn đề thứ ba thiên về thói quen. Khi một lần phát hành tốn bảy phút cộng một lần khởi động lại, bạn sẽ thôi làm những lần phát hành nhỏ. Tôi để ba bốn bản sửa chất đống thay vì đưa lên từng cái, và đó chính xác là cách một thay đổi một dòng bị đưa lên cùng với thứ gì đó đang hỏng.</p>

<h2 class="relative group">Bố cục trong bucket
    <div id="bố-cục-trong-bucket" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%91-c%e1%bb%a5c-trong-bucket" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ nằm trong một bucket tương thích S3, dưới tiền tố <code>web/</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">web/current                              ← a text file containing one version string
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/manifest.json ← every file in this version
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/app/...       ← the app&#39;s web export
</span></span><span class="line"><span class="cl">web/a1b2c3d-20260914021500/site/...      ← the static site&#39;s output
</span></span><span class="line"><span class="cl">web/9f8e7d6-20260912093012/...           ← the previous version, still there</span></span></code></pre></div></div>
<p>Phiên bản là mã git SHA ngắn cộng với dấu thời gian UTC, nên nhìn vào là biết commit nào và đưa lên lúc nào. <code>web/current</code> là thứ duy nhất thay đổi khi một bản phát hành lên sóng.</p>

<h2 class="relative group">Script triển khai làm gì
    <div id="script-triển-khai-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="#script-tri%e1%bb%83n-khai-l%c3%a0m-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ là một script, chạy từ thư mục gốc của repo. Theo thứ tự:</p>
<p><strong>1. Từ chối cây git chưa sạch.</strong> Việc đầu tiên nó làm là <code>git status --porcelain</code>, và nếu có bất kỳ dòng nào trả về thì thoát. Cái gì nằm trên đĩa là cái được tải lên, và script triển khai cũ của trang giới thiệu từng đưa lên nội dung chưa commit đúng theo cách đó. Có một cờ để bỏ qua bước này cho lúc bạn biết rõ mình đang làm gì, nhưng tôi chưa dùng. Sau đó nó tính chuỗi phiên bản từ <code>git rev-parse --short HEAD</code> và thời điểm hiện tại.</p>
<p><strong>2. Build cả hai cây.</strong> Với ứng dụng, nó chạy bản xuất web với địa chỉ API và WebSocket của production được nhúng sẵn, tiếp theo là một lượt nén trước bằng Brotli. Việc nén trước đáng để giải thích: Cloudflare có nén Brotli tại chỗ, nhưng ở mức chất lượng thấp, và trên bundle này đo ra còn tệ hơn gzip (2,04MB so với 1,66MB). Nén một lần lúc build với chất lượng 11 đưa nó xuống 1,28MB, nên script ghi một tệp <code>.br</code> song sinh cạnh mỗi tệp JS, CSS và JSON trên 1KB. Với trang giới thiệu, nó chạy trình tạo trang tĩnh với chế độ rút gọn (minify) bật.</p>
<p><strong>3. Tải lên.</strong> Nó duyệt cả hai thư mục build, rồi tải mọi tệp lên <code>web/&lt;version&gt;/app/...</code> và <code>web/&lt;version&gt;/site/...</code> bằng mười sáu luồng. Mỗi đối tượng được gán <code>Content-Type</code> đoán từ tên tệp (bỏ <code>.br</code> trước), và các tệp Brotli song sinh có thêm <code>Content-Encoding: br</code>. Chỉ khi mọi tệp đã lên hết, nó mới ghi <code>manifest.json</code>, một đối tượng JSON với hai danh sách đường dẫn tương đối. Thứ tự này quan trọng: phía Django coi một phiên bản không có tệp kê khai là không tồn tại, nên một phiên bản tải dở dang không bao giờ có thể được đặt làm hiện tại. Hiện giờ là vài trăm tệp và tải lên mất khoảng sáu giây.</p>
<p><strong>4. Đổi con trỏ.</strong> Một lệnh <code>put_object</code> ghi chuỗi phiên bản vào <code>web/current</code>. Đó là phát hành.</p>
<p><strong>5. Đợi các máy.</strong> Mỗi máy API đệm con trỏ năm giây, nên trong chốc lát sau khi đổi, vài máy vẫn còn ở phiên bản cũ. Script thăm dò một endpoint báo phiên bản trên API mỗi nửa giây và đếm số câu trả lời liên tiếp mang phiên bản mới. Fly cân bằng tải qua tất cả các máy, nên một chuỗi mười sáu câu trả lời khớp liên tiếp (nhiều hơn số máy) là cách nó biết mọi máy đều đã đọc lại con trỏ. Một câu trả lời cũ là đếm lại từ đầu. Sau 90 giây nó bỏ cuộc và bảo bạn kiểm tra bằng tay; lần đổi thật đầu tiên mất hơn 30 giây một chút, nên giới hạn không đặt thấp hơn.</p>
<p><strong>6. Xóa bộ nhớ đệm Cloudflare.</strong> Một lời gọi API, nói ở dưới. Nếu thất bại, script in cảnh báo rồi đi tiếp.</p>
<p><strong>7. Báo lên kênh chat của đội.</strong> Cùng webhook mà cảnh báo lỗi lúc chạy dùng nhận một dòng với phiên bản và người chạy, để một loạt lỗi có thể được đọc đối chiếu với thứ vừa thay đổi. Quay lui đăng bằng màu hổ phách.</p>
<p>Từ đầu đến cuối, một lần phát hành mất hơn một phút một chút, phần lớn là thời gian xuất bản web.</p>

<h2 class="relative group">Phía Django
    <div id="phía-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="#ph%c3%ada-django" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Django vốn đã là thứ phục vụ giao diện. Mọi URL không phải API đều được trả lời bằng trang đã dựng sẵn (prerender) trong bản xuất web cho route đó, còn các trang giới thiệu, chính sách bảo mật và hỗ trợ lấy từ đầu ra của trang tĩnh. Trước đây những view này mở tệp trên đĩa. Giờ chúng đọc qua một lớp nhỏ biết cách lấy dữ liệu từ bucket.</p>
<p>Với mỗi yêu cầu, nó xác định ba thứ: phiên bản hiện tại (đọc từ <code>web/current</code>, đệm năm giây), tệp kê khai của phiên bản đó (đệm chừng nào phiên bản đó còn là hiện tại), và bản thân tệp (một LRU nhỏ trong bộ nhớ chứa nội dung tệp, nên HTML của trang và bundle chính luôn nằm sẵn trong bộ nhớ). Tệp kê khai là thứ khiến 404 rẻ: đường dẫn không có trong danh sách thì trả lời không luôn, không cần hỏi bucket. Nếu client chấp nhận Brotli, nó đọc tệp <code>.br</code> song sinh thay thế và đặt header mã hóa tương ứng. Tài nguyên tĩnh đi ra với header cache immutable một năm, vì tên tệp đã gắn hash theo nội dung. Những gì suy ra từ danh sách tệp, chẳng hạn bảng ánh xạ route sang trang dựng sẵn, cũng được đệm theo phiên bản, nên một bản triển khai mới sẽ dựng lại chúng ở yêu cầu đầu tiên chứ không sớm hơn.</p>
<p>Rồi đến endpoint mà script triển khai thăm dò:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">web_version</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># bundle: the layer that reads web/current and the manifest</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;source&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">source</span><span class="p">(),</span> <span class="s2">&#34;version&#34;</span><span class="p">:</span> <span class="n">bundle</span><span class="o">.</span><span class="n">version</span><span class="p">()})</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="p">[</span><span class="s2">&#34;Cache-Control&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;no-store&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">response</span></span></span></code></pre></div></div>
<p>Nó trả về <code>bucket:&lt;version&gt;</code> trên production, hoặc <code>filesystem</code> khi đang phục vụ từ image. Mỗi máy Fly chỉ trả lời cho chính nó, nên gọi chừng chục lần là biết cả cụm đã đồng bộ chưa. Đây cũng là cách nhanh nhất để xem một máy đang nghĩ cái gì là bản chạy khi có gì đó trông không ổn.</p>

<h2 class="relative group">Xóa bộ nhớ đệm Cloudflare qua API
    <div id="xóa-bộ-nhớ-đệm-cloudflare-qua-api" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#x%c3%b3a-b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-cloudflare-qua-api" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>HTML trên trang được đệm ở biên của Cloudflare trong năm phút. Nếu không xóa, bản triển khai đã chạy trên máy nhưng hầu hết người dùng vẫn nhận trang cũ tới tận năm phút, thế thì mất hết ý nghĩa.</p>
<p>Việc xóa chỉ là một lời gọi API:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">urllib</span><span class="o">.</span><span class="n">request</span><span class="o">.</span><span class="n">Request</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="sa">f</span><span class="s2">&#34;https://api.cloudflare.com/client/v4/zones/</span><span class="si">{</span><span class="n">zone</span><span class="si">}</span><span class="s2">/purge_cache&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span><span class="o">=</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">({</span><span class="s2">&#34;hosts&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;example.com&#34;</span><span class="p">,</span> <span class="s2">&#34;www.example.com&#34;</span><span class="p">]})</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">    <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Authorization&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Bearer </span><span class="si">{</span><span class="n">token</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span> <span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;application/json&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>
<p>Tôi xóa theo host thay vì theo URL để khỏi phải liệt kê từng trang. Các tài nguyên tĩnh (asset) thì không cần xóa: tên tệp đã gắn hash theo nội dung, nên bản build mới trỏ tới tên tệp mới và các tệp cũ đơn giản là không còn được tham chiếu. Chỉ phần HTML tham chiếu tới chúng là cần làm mới.</p>
<p>Script đợi các máy <em>trước khi</em> xóa đệm là có chủ ý. Nếu xóa trước, Cloudflare có thể nạp lại bộ nhớ đệm từ một máy vẫn còn ở phiên bản cũ, và trang đó sẽ nằm ở biên thêm năm phút nữa.</p>
<p>Nếu xóa đệm thất bại, script in một cảnh báo rồi vẫn chạy cho xong. Bộ nhớ đệm tự hết hạn trong vòng năm phút, nên trường hợp xấu nhất là một lần phát hành chậm chứ không phải một lần phát hành hỏng. Tôi thà như vậy còn hơn để việc triển khai bị hủy ở bước cuối.</p>

<h2 class="relative group">Kiểm tra một bản build trước khi lên sóng
    <div id="kiểm-tra-một-bản-build-trước-khi-lên-só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="#ki%e1%bb%83m-tra-m%e1%bb%99t-b%e1%ba%a3n-build-tr%c6%b0%e1%bb%9bc-khi-l%c3%aan-s%c3%b3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chế độ không đổi con trỏ (no-flip) làm đến hết bước 3 rồi dừng. Phiên bản đã nằm trong bucket nhưng chưa có gì phục vụ nó. Để xem, tôi đặt một biến môi trường trên một máy để ghim máy đó vào bản build ấy, bất kể <code>web/current</code> nói gì. Khi thấy ổn, lệnh rollback sẽ đặt nó làm hiện tại. Lệnh tên là rollback nhưng thực chất là &ldquo;trỏ <code>current</code> vào phiên bản này&rdquo;, và tiến lên hay lùi lại đều là cùng một thao tác.</p>

<h2 class="relative group">Quay lui
    <div id="quay-lui" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#quay-lui" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các phiên bản cũ vẫn nằm trong bucket, nên quay lui (rollback) là chạy đúng lệnh đó với một phiên bản trước, và một lệnh liệt kê cho thấy có những gì, mới nhất trước. Nó cũng đợi máy và xóa đệm y như một lần triển khai tiến lên. Tôi chưa cần dùng tới. Hiện chưa có gì dọn các phiên bản cũ; chúng nhỏ và tôi thà giữ còn hơn không.</p>

<h2 class="relative group">Những gì vẫn như cũ
    <div id="những-gì-vẫn-như-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="#nh%e1%bb%afng-g%c3%ac-v%e1%ba%abn-nh%c6%b0-c%c5%a9" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>API vẫn triển khai theo cách cũ, qua script triển khai riêng của nó, và nên như vậy: nó có trạng thái và là nơi migration chạy. Dockerfile cũng vẫn sao chép cả hai bản build giao diện vào image. Đó là chủ ý. Khi không cấu hình bucket (phát triển cục bộ, bộ kiểm thử) hoặc không đọc được bucket, lớp phục vụ tệp lùi về những thư mục đó trên đĩa, nên sự cố lưu trữ nghĩa là người dùng nhận được giao diện đi kèm image gần nhất thay vì một trang lỗi. Chỉ một biến môi trường là ép được hành vi đó trên production, nếu có lúc cần tạm gạt bucket ra khỏi luồng.</p>
<p>Giao diện thay đổi thường xuyên hơn API nhiều, và giờ mỗi lần phát hành mất khoảng một phút thay vì bảy. Tôi đã thôi dồn lại mà chẳng cần cố.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>SEO cho React Native Web: khi Cloudflare trả về cho Googlebot một trang trống</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>Cách tôi làm cho ứng dụng React Native Web hiện ra với Google và trình thu thập dữ liệu của Naver bằng kết xuất động, và quy tắc bộ nhớ đệm Cloudflare đã trả cho Googlebot khung SPA rỗng.</description>
      <content:encoded><![CDATA[<p>Nếu trang web React hay React Native Web của bạn là một ứng dụng trang đơn, trình thu thập dữ liệu (crawler) của Naver (Yeti) và Bing sẽ không chạy JavaScript của nó, còn Google thì chỉ thỉnh thoảng mới chạy. Cách sửa là kết xuất động (dynamic rendering): nhận diện crawler qua user-agent rồi trả cho nó HTML kết xuất sẵn ở máy chủ, với đúng nội dung mà ứng dụng sẽ vẽ ra. Sai lầm cần tránh trên Cloudflare là lưu đệm URL đó cho tất cả mọi người. Cloudflare bỏ qua <code>Vary</code> với HTML, nên nội dung nào đến trước sẽ được trả cho mọi lượt truy cập sau, và Googlebot có thể nhận về cái khung rỗng; chỉ lưu đệm request của crawler, và xóa bộ nhớ đệm (purge) sau khi thu hẹp quy tắc.</p>
<p>Ứng dụng chợ (marketplace) mà tôi đang làm là một ứng dụng trang đơn (single-page app) viết bằng React Native Web. Trước khi JavaScript chạy, trang tin đăng (listing) nào cũng chỉ có đúng một thẻ script và một thẻ div rỗng. Với người dùng thì chẳng sao, nhưng với công cụ tìm kiếm thì coi như xong: Google thỉnh thoảng chạy được JS, Bing thì không, còn trình thu thập dữ liệu (crawler) của Naver — thứ quan trọng nhất với một trang web mà gần như toàn bộ tin đăng đều bằng tiếng Hàn — thì chắc chắn là không.</p>
<p>Bài này kể lại quá trình tôi làm cho các trang đó hiện ra với công cụ tìm kiếm, rồi chuyện một quy tắc lưu đệm (cache rule) phá hỏng tất cả theo cách tôi không hề lường trước.</p>

<h2 class="relative group">Kết xuất động, chứ không phải che giấu nội dung
    <div id="kết-xuất-động-chứ-không-phải-che-giấu-nội-dung" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#k%e1%ba%bft-xu%e1%ba%a5t-%c4%91%e1%bb%99ng-ch%e1%bb%a9-kh%c3%b4ng-ph%e1%ba%a3i-che-gi%e1%ba%a5u-n%e1%bb%99i-dung" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách sửa thì chẳng có gì mới, và được chính Google chấp nhận dưới cái tên kết xuất động (dynamic rendering), khác hẳn với che giấu nội dung (cloaking): nhận diện trình thu thập dữ liệu, rồi trả cho nó HTML đã kết xuất (render) sẵn ở máy chủ, chứa đúng nội dung mà SPA lẽ ra sẽ vẽ lên màn hình. Không phải nội dung khác. Vẫn nội dung ấy, chỉ là có sớm hơn.</p>
<p>Vì vậy, một URL tin đăng như <code>/listings/view?id=...</code> được tách làm hai nhánh. Trình duyệt nhận phần khung (shell) của SPA. Trình thu thập dữ liệu nhận một tài liệu HTML thật sự: tiêu đề, các thông tin chính của tin đăng, mỗi ảnh một thẻ ảnh kèm văn bản thay thế (alt text), một đoạn trích nội dung, và liên kết sang vài tin đăng liên quan để trang không bị mồ côi.</p>
<p>Phần cuối này hoá ra quan trọng hơn tôi nghĩ. Trước khi có mấy liên kết nội bộ đó, khoảng một nghìn trang tin đăng nằm trơ trọi, không có liên kết nào trỏ tới, và Search Console cho thấy lượng thu thập chững lại ở mức bảy đến mười một yêu cầu mỗi ngày. Trình thu thập dữ liệu mà không tìm thấy trang của bạn thì trang có hay đến mấy cũng vô nghĩa.</p>

<h2 class="relative group">Cái bẫy: tên công ty không phải là tên bot
    <div id="cái-bẫy-tên-công-ty-không-phải-là-tên-bot" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1i-b%e1%ba%aby-t%c3%aan-c%c3%b4ng-ty-kh%c3%b4ng-ph%e1%ba%a3i-l%c3%a0-t%c3%aan-bot" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nhận diện trình thu thập dữ liệu thực chất là so khớp chuỗi user-agent, và tôi dính đòn đúng ở bước này.</p>
<p>Các nền tảng Hàn Quốc cho trình thu thập dữ liệu chạy dưới những cái tên chẳng liên quan gì tới tên công ty. Trình thu thập của Naver tên là <strong>Yeti</strong>. Của Daum là <strong>Daumoa</strong>. Còn công cụ cào dữ liệu (scraper) lấy bản xem trước liên kết của Kakao là <strong>kakaotalk-scrap</strong>. Không cái nào mang đúng tên công ty.</p>
<p>Trong khi đó, cũng chính mấy công ty này làm ứng dụng có trình duyệt nhúng bên trong, và trình duyệt đó gửi user-agent y như trình duyệt thật, chỉ gắn thêm tên ứng dụng ở cuối. Trình duyệt trong ứng dụng KakaoTalk ghi <code>KAKAOTALK/10.5.1</code>. Công cụ cào dữ liệu của Kakao thì ghi <code>kakaotalk-scrap/1.0</code>.</p>
<p>Cứ so khớp theo <code>kakao</code> là bạn tóm luôn cả hai. Tôi đã làm đúng như vậy, thế là người dùng KakaoTalk, ứng dụng Naver hay ứng dụng Daum nào mở liên kết cũng nhận về trang dành cho trình thu thập: không JavaScript, không điều hướng, không đăng nhập. Một tài liệu hoàn toàn chuẩn chỉnh, nhưng với con người thì vô dụng.</p>
<p>Cách sửa là một quy tắc về thứ tự kiểm tra, và đáng để viết hẳn ra thành quy tắc:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In-app browsers append the app&#39;s name to an ordinary browser UA.</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Name the bot, never the vendor: Naver crawls as Yeti, Daum as Daumoa,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao&#39;s link-preview scraper as kakaotalk-scrap.</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</span> <span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># An in-app browser is never a crawler, so that check comes first.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>Trình duyệt trong ứng dụng được kiểm tra trước, và luôn thắng. Còn token nhận diện trình thu thập nào cũng phải là tên của một <em>bot</em>, tuyệt đối không phải tên công ty.</p>

<h2 class="relative group">Rồi tôi đặt Cloudflare đứng trước
    <div id="rồi-tôi-đặt-cloudflare-đứng-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="#r%e1%bb%93i-t%c3%b4i-%c4%91%e1%ba%b7t-cloudflare-%c4%91%e1%bb%a9ng-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Máy chủ gốc (origin) chỉ là một máy shared-cpu duy nhất đặt ở Sydney, chịu tải ổn định được khoảng hai mươi đến ba mươi yêu cầu mỗi giây. Googlebot có chừng 995 URL cần đi qua. URL nào cũng chạy thẳng về Sydney, vì <code>cf-cache-status</code> ở tất cả đều là <code>DYNAMIC</code>: <strong>Cloudflare không lưu đệm HTML trừ khi có quy tắc bảo nó làm vậy.</strong> Cả vùng (zone) chỉ khoảng 16% được lưu đệm, và phần quan trọng thì không nằm trong số đó.</p>
<p>Vậy thì thêm một quy tắc lưu đệm cho các trang dành cho trình thu thập. Đơn giản. Có điều, một URL tin đăng không chỉ có một phần thân (body). Nó có tới ba.</p>
<ul>
<li>Trình thu thập dữ liệu xin HTML thì nhận trang kết xuất ở máy chủ.</li>
<li>Người dùng thật thì nhận phần khung của SPA.</li>
<li>Tác tử AI (AI agent) gửi <code>Accept: text/markdown</code> thì nhận markdown dựng từ cơ sở dữ liệu, vì đem một khung HTML rỗng đi chuyển đổi thì cũng chỉ ra một tài liệu rỗng.</li>
</ul>
<p>Máy chủ gốc có trả <code>Vary: Accept</code>, còn <code>Vary</code> theo user-agent thì đằng nào cũng vô vọng. Và đây là điều tôi không hề biết:</p>
<blockquote><p><strong>Cloudflare bỏ qua <code>Vary</code> với HTML.</strong> Cả ba phần thân dùng chung một khoá lưu đệm (cache key).</p>
</blockquote><p>Phần thân nào tới trước sẽ chiếm luôn URL đó cho mọi lượt truy cập sau ở điểm biên (edge location) ấy, trong suốt một tiếng mà <code>s-maxage</code> của máy chủ gốc cho phép, cộng thêm một ngày <code>stale-while-revalidate</code>.</p>
<p>Tôi được tận mắt thấy chuyện này trên môi trường production, theo cả hai chiều:</p>
<ul>
<li>Một người dùng ứng dụng Naver mở một URL tin đăng trước, bộ nhớ đệm giữ lại trang dành cho trình thu thập, rồi sau đó Safari bình thường trên iPhone nhận đúng trang ấy dưới dạng HIT. Một con người ngồi nhìn một tài liệu không có tí JavaScript nào.</li>
<li>Một người dùng thật mở một URL trước, bộ nhớ đệm giữ lại phần khung của SPA, và Googlebot nhận về cái khung rỗng. Đúng cái lỗi mà cả nỗ lực SEO sinh ra để ngăn chặn, giờ lại còn được phục vụ nhanh hơn, ngay từ máy chủ biên.</li>
</ul>

<h2 class="relative group">Cái quy tắc sửa lỗi bằng cách thu hẹp phạm vi
    <div id="cái-quy-tắc-sửa-lỗi-bằng-cách-thu-hẹp-phạm-vi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1i-quy-t%e1%ba%afc-s%e1%bb%ada-l%e1%bb%97i-b%e1%ba%b1ng-c%c3%a1ch-thu-h%e1%ba%b9p-ph%e1%ba%a1m-vi" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bạn không thể bắt Cloudflare tôn trọng <code>Vary</code> với HTML. Cái bạn làm được là đảm bảo trong ba phần thân đó, chỉ đúng một cái có cửa vào bộ nhớ đệm:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>Chỉ khớp user-agent của trình thu thập, và loại trừ yêu cầu xin markdown. Giờ yêu cầu của người dùng thật không khớp quy tắc nào cả, nên nó là <code>DYNAMIC</code> và đi về máy chủ gốc như trước. Chỉ yêu cầu của trình thu thập mới được đọc hoặc ghi bộ nhớ đệm, nên phần thân duy nhất có thể nằm trong đó là phần thân dành cho trình thu thập.</p>
<p>Edge TTL để là <code>respect_origin</code>, nên thời gian sống của dữ liệu đệm vẫn do chính phần mã kết xuất trang quyết định, chứ không bị đóng cứng trong một bảng điều khiển nào đó.</p>
<p>Có một chi tiết vận hành rất dễ bỏ sót: <strong>thu hẹp quy tắc không xoá những gì đã nằm sẵn trong bộ nhớ đệm.</strong> Bạn phải xoá bộ nhớ đệm (purge), không thì vẫn tiếp tục trả ra những mục nhiễm độc mà mình vừa ngừng tạo ra.</p>

<h2 class="relative group">Hai danh sách, và vì sao sai lệch giữa chúng không đối xứng
    <div id="hai-danh-sách-và-vì-sao-sai-lệch-giữa-chúng-không-đối-xứ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="#hai-danh-s%c3%a1ch-v%c3%a0-v%c3%ac-sao-sai-l%e1%bb%87ch-gi%e1%bb%afa-ch%c3%bang-kh%c3%b4ng-%c4%91%e1%bb%91i-x%e1%bb%a9ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Quy tắc này cần một danh sách user-agent của trình thu thập, mà Django thì đã có sẵn một danh sách như vậy. Cùng một kiến thức mà nằm hai bản ở hai hệ thống thường là dấu hiệu có vấn đề. Nhưng ở đây, điều thú vị là chuyện gì xảy ra khi hai bản lệch nhau, vì hậu quả không đối xứng:</p>
<ul>
<li><strong>Danh sách bot trong Cloudflare phải luôn là tập con</strong> của danh sách trong Django. Nếu có token mà Cloudflare coi là bot còn Django thì không, phần khung SPA sẽ bị lưu đệm dưới khoá chỉ dành cho trình thu thập, rồi bị trả cho một trình thu thập thật.</li>
<li><strong>Danh sách loại trừ trình duyệt trong ứng dụng phải luôn là tập cha</strong> của danh sách trong Django. Loại trừ thừa thì cùng lắm bạn mất một lần trúng bộ nhớ đệm (cache hit).</li>
</ul>
<p>Sai theo chiều an toàn, dù ở danh sách nào, thì bạn chỉ mất một ít dữ liệu đệm. Nhưng sai danh sách đầu theo chiều ngược lại là bạn quay về cảnh trả cho Googlebot một trang trống. Nhân tiện, cũng vì sự bất đối xứng đó mà hai danh sách dùng <code>contains</code> chứ không dùng biểu thức chính quy (regex): <code>matches</code> đòi gói Business, còn <code>contains</code> là đủ cho một danh sách được phép xấp xỉ, miễn là lệch về phía đã biết là an toàn.</p>

<h2 class="relative group">Một bất biến làm hỏng bản dựng thay vì làm hỏng trang
    <div id="một-bất-biến-làm-hỏng-bản-dựng-thay-vì-làm-hỏng-trang" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%99t-b%e1%ba%a5t-bi%e1%ba%bfn-l%c3%a0m-h%e1%bb%8fng-b%e1%ba%a3n-d%e1%bb%b1ng-thay-v%c3%ac-l%c3%a0m-h%e1%bb%8fng-trang" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các trang dành cho trình thu thập nhúng URL ảnh dạng ký sẵn (presigned), có hiệu lực bảy ngày. Còn bản thân các trang thì được lưu đệm ở máy chủ biên một tiếng, cộng thêm một ngày stale-while-revalidate.</p>
<p>Hai con số này dính dáng tới nhau. Nếu có ngày thời gian lưu đệm vượt quá thời hạn của ảnh, máy chủ biên sẽ bắt đầu trả ra những trang mà ảnh nào cũng dính lỗi 403. Sẽ chẳng có cảnh báo nào kêu cả, vì HTML vẫn hoàn toàn bình thường.</p>
<p>Nên đoạn mã định nghĩa mấy con số đó sẽ từ chối nạp (import) nếu quan hệ này bị phá vỡ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># presigned image URLs</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Fail at import time if the edge cache could outlive the image URLs.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Một ràng buộc giữa hai hằng số nằm ở hai tệp khác nhau thì gần như vô hình khi rà soát mã, nhưng lộ ra ngay khi tiến trình khởi động. Vậy thì cứ đặt phép kiểm tra ở chỗ tiến trình khởi động.</p>

<h2 class="relative group">Hai thứ Cloudflare làm mà tôi phải gỡ lại
    <div id="hai-thứ-cloudflare-làm-mà-tôi-phải-gỡ-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hai-th%e1%bb%a9-cloudflare-l%c3%a0m-m%c3%a0-t%c3%b4i-ph%e1%ba%a3i-g%e1%bb%a1-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> khiến gói JavaScript không vào được bộ nhớ đệm.</strong> <code>django-cors-headers</code> đóng dấu <code>Vary: Origin</code> lên mọi phản hồi mà nó đi qua, còn Cloudflare thì không lưu đệm bất cứ thứ gì vary theo nhiều hơn <code>Accept-Encoding</code>. Thế là gói JavaScript (bundle) 1.6 MB — thứ nặng nhất trên đường tải quan trọng (critical path) — lặng lẽ bị loại hẳn khỏi máy chủ biên. Cách sửa là một middleware đặt đầu tiên trong danh sách, để nó chạy sau cùng lúc phản hồi đi ra, và viết lại <code>Vary</code> cho các đường dẫn tài nguyên tĩnh.</p>
<p><strong>Nén ở máy chủ biên còn tệ hơn nén ở máy chủ gốc.</strong> Để Cloudflare tự nén Brotli tức thời thì ra 2,041,742 byte, trong khi gzip của chính máy chủ gốc chỉ ra 1,658,115. To hơn hai mươi ba phần trăm, chỉ để đổi lấy cái sướng là không phải nghĩ ngợi gì. Nén tức thời tối ưu cho CPU chứ không tối ưu cho tỉ lệ nén; tài nguyên nén sẵn từ máy chủ gốc vẫn thắng.</p>

<h2 class="relative group">Báo cho công cụ tìm kiếm mà không cần chờ được thu thập
    <div id="báo-cho-công-cụ-tìm-kiếm-mà-không-cần-chờ-được-thu-thậ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="#b%c3%a1o-cho-c%c3%b4ng-c%e1%bb%a5-t%c3%acm-ki%e1%ba%bfm-m%c3%a0-kh%c3%b4ng-c%e1%ba%a7n-ch%e1%bb%9d-%c4%91%c6%b0%e1%bb%a3c-thu-th%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Có hai giao thức, nhưng gộp cả hai lại thì phạm vi phủ vẫn ít đến đáng thất vọng.</p>
<p><strong>IndexNow</strong> chỉ cần một cú ping là phủ được Bing, Yandex và Seznam. Google và Naver thì không tham gia. Mỗi khi một tin đăng được lưu, tôi gửi ping ở chế độ nền với thời gian chờ ngắn, và bỏ qua tin đăng đã bán hoặc đã đóng. Gặp lỗi mạng thì nó ghi một dòng cảnh báo vào nhật ký và không bao giờ làm hỏng lần lưu đã kích hoạt nó. Với một kênh phụ kiểu làm được đến đâu hay đến đó (best-effort), đó là cách hành xử duy nhất chấp nhận được.</p>
<p><strong>Indexing API của Google</strong> chỉ nhận đúng hai loại schema, và một trong hai là <code>JobPosting</code>, thứ mà tôi lại có. Tin tuyển dụng còn mở thì gửi <code>URL_UPDATED</code>, tin đã đóng thì gửi <code>URL_DELETED</code>. Lệnh xoá đó chỉ trung thực vì tin đăng đã đóng cũng trả <code>noindex</code>, và mã nguồn có ghi chú rõ chuyện này, để lỡ ai gỡ noindex đi thì cũng biết mình còn làm hỏng thêm thứ gì.</p>
<p>Có hai chỗ làm tôi mất thời gian. Thứ nhất, hạn mức (quota) là 200 URL mỗi ngày, mà một lần nhập hàng loạt tin đăng sẽ gọi nó hàng trăm lần — nên tin đăng nhập vào phải được bỏ qua, không thì một lần nhập đốt sạch hạn mức của cả ngày. Thứ hai, endpoint là <code>urlNotifications:publish</code>, dùng dấu hai chấm. Dùng dấu gạch chéo là Google trả về một trang HTML 404 trơn, trông y hệt như API đang bị tắt.</p>

<h2 class="relative group">Dữ liệu có cấu trúc dạy tôi những gì
    <div id="dữ-liệu-có-cấu-trúc-dạy-tôi-những-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="#d%e1%bb%af-li%e1%bb%87u-c%c3%b3-c%e1%ba%a5u-tr%c3%bac-d%e1%ba%a1y-t%c3%b4i-nh%e1%bb%afng-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ba quy tắc về dữ liệu có cấu trúc (structured data), cái nào cũng học được sau khi bị Search Console phàn nàn:</p>
<ol>
<li><strong>Không phân tích được thì bỏ ra.</strong> Mức lương gửi đến dưới dạng văn bản tự do. Trước đây tôi nhét nguyên chuỗi đó vào <code>baseSalary.value</code>, vốn không hợp lệ, thế là tin tuyển dụng nào cũng bị gắn cờ thiếu đơn vị. Giờ lương nào không phân tích (parse) được thì đơn giản là không có.</li>
<li><strong>Đừng bao giờ bịa ra một trường chỉ để chiều schema.</strong> Tin tuyển dụng hiếm khi ghi địa chỉ cụ thể đến tên đường. Rất dễ bị cám dỗ bịa ra một địa chỉ nghe hợp lý cho <code>PostalAddress</code>. Nhưng một địa chỉ bịa trên tin tuyển dụng còn tệ hơn một địa chỉ thiếu, nên tôi chỉ đưa vào khu vực (locality) và quốc gia.</li>
<li><strong>Ngày hết hạn không phải là tuỳ chọn.</strong> Google bỏ qua <code>JobPosting</code> nào không có <code>validThrough</code>, nên tôi tự suy ra một giá trị, kèm mức sàn để một tin đăng cũ không tự nhận là đã hết hạn từ hôm qua.</li>
</ol>
<p>Còn quy tắc thứ tư, về sơ đồ trang web (sitemap): <strong>đừng khai <code>changefreq: daily</code> cho mọi thứ.</strong> Bảo Googlebot thu thập lại một nghìn tin đăng mỗi ngày thì trong mắt nó, máy chủ của bạn đang quá tải, và nó sẽ đáp lại bằng cách giảm tốc độ thu thập cho cả trang web. Hãy suy ra giá trị này từ lần gần nhất nội dung thực sự thay đổi.</p>
<p>Sơ đồ trang web cũng có một mức sàn về chất lượng: tiêu đề và nội dung cộng lại phải đủ bốn mươi ký tự. Search Console từng báo khoảng một nghìn tin đăng ở trạng thái “Crawled – currently not indexed”, và có một trang đồ cũ mà toàn bộ nội dung riêng của nó chỉ gồm một tiêu đề đúng một chữ và một số điện thoại. Bốn mươi ký tự là tôi cố tình đặt thấp, vì 471 trên 484 tin đăng viết bằng tiếng Hàn, mà tiếng Hàn chứa được nhiều ý hơn trên mỗi ký tự; một ngưỡng canh theo tiếng Anh sẽ loại mất một nửa sàn mua bán. Tin đăng nào nội dung mỏng thì vẫn được thu thập qua các trang danh mục, và sẽ tự động quay lại sơ đồ trang web khi có người sửa chúng.</p>

<h2 class="relative group">Tôi rút ra được gì
    <div id="tôi-rút-ra-đượ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="#t%c3%b4i-r%c3%bat-ra-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li><strong>Trả nhiều phần thân khác nhau ở cùng một URL trước hết là bài toán khoá lưu đệm, rồi mới đến chuyện khác.</strong> Hãy tìm hiểu xem CDN của bạn lấy khoá theo những gì, và cứ giả định nó bỏ qua <code>Vary</code> cho đến khi bạn chứng minh được điều ngược lại với đúng kiểu nội dung (content type) đó.</li>
<li><strong>Khi hai hệ thống phải thống nhất về một danh sách, hãy xác định chiều sai nào là an toàn</strong>, rồi ghi điều đó ngay cạnh cả hai bản. Nói thẳng “hai bản này có thể lệch nhau, và đây là cách duy nhất chúng được phép lệch” vẫn hơn là giả vờ chúng không bao giờ lệch.</li>
<li><strong>So khớp bot theo tên bot, đừng bao giờ theo tên công ty.</strong> Nhất là ở ngoài thế giới nói tiếng Anh, nơi chính công ty làm bot cũng làm luôn cái trình duyệt mà người dùng của bạn đang cầm trên tay.</li>
<li><strong>Bất biến giữa những hằng số nằm xa nhau thì hãy đặt ở chỗ nào hỏng thật to.</strong> Kiểm tra lúc nạp mô-đun chẳng tốn gì, và không ai lách qua được.</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Khởi động nguội (cold start) trên Fly.io với scale to zero: từ 33 giây xuống 3 giây</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>Performance</category>
      <category>Cold Starts</category>
      <category>Redis</category>
      <category>Debugging</category>
      <description>Vì sao máy Fly.io của tôi mất 25-33 giây khởi động nguội (cold start) khi bật auto_stop_machines, và cách tôi rút thời gian đánh thức máy xuống còn khoảng 3 giây.</description>
      <content:encoded><![CDATA[<p><strong>Cập nhật ngày 22/10/2026:</strong> con số chính của bài này là sai. Ngay hôm đăng bài, tôi nhóm các lỗi 504 của zone theo user agent và thấy toàn bộ 7,344 lỗi từ ngày 5 đến 9/9 đều đến từ các yêu cầu thăm dò Early Hints của chính Cloudflare (<code>bastion early hints</code> và <code>nginx-ssl early hints</code>). Các yêu cầu này không nhận được phản hồi từ máy chủ gốc trên Fly, bị ghi lại là 504, lại đi theo đúng đường dẫn của yêu cầu thật, nên trông y hệt lỗi phía người dùng. Trong khoảng thời gian đó, không có client thật nào (ứng dụng, trình duyệt, curl, trình thu thập dữ liệu của các công cụ tìm kiếm) nhận lỗi 504. Tôi tắt Early Hints ngay trong ngày, vì máy chủ gốc không gửi header preload nào nên tính năng này chẳng làm được gì có ích. Chuyện khởi động nguội bên dưới là thật và các cách khắc phục vẫn đúng, nhưng con số 22% chưa bao giờ là thứ người dùng của tôi gặp phải. Trước khi coi tỉ lệ lỗi 5xx trên Cloudflare là sự cố, hãy kiểm tra xem những user agent nào đang nhận các lỗi đó.</p>
<p><strong>Cập nhật ngày 4/11/2026:</strong> thêm một điều chỉnh nữa. Những lỗi 504 này không phải là các yêu cầu thăm dò mà máy chủ gốc không trả lời được. Chúng là các lượt tra cứu cache Early Hints của chính Cloudflare, hoàn toàn không đi tới máy chủ gốc, và mã 504 ở đó chỉ có nghĩa là chưa có gì được cache cho URL ấy. Tắt Early Hints cũng không phải cách sửa tốt nhất: khi gửi response header <code>Link</code>, tính năng bắt đầu hoạt động và các lần miss chuyển thành hit. Toàn bộ câu chuyện có trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/" >Lỗi 504 Cloudflare không phải sự cố thật: Early Hints và HTTP 103</a>.</p>
<p>Nói ngắn gọn: khi bật <code>auto_stop_machines</code> để thu về 0 (scale to zero), yêu cầu nào rơi vào một máy Fly.io đang dừng đều phải chờ máy khởi động xong. Máy của tôi mất 25 đến 33 giây cho mỗi lần khởi động nguội (cold start), vì mỗi lần máy bật lên, entrypoint lại chạy migration của Django và <code>collectstatic</code>, còn bytecode thì chưa từng được lưu đệm. Khi chỉ chạy những việc đó một lần cho mỗi image (bằng một khóa Redis đặt theo <code>FLY_IMAGE_REF</code>) và thêm <code>compileall</code> vào bước build, khởi động nguội giảm còn khoảng 9 giây. Đổi <code>auto_stop_machines</code> từ <code>'stop'</code> sang <code>'suspend'</code> (muốn vậy phải bỏ swap, vì Fly không cho tạm ngưng máy có swap) thì đánh thức máy chỉ còn khoảng 3 giây.</p>
<p>Tôi mở bảng điều khiển (dashboard) của Cloudflare để tìm hiểu một đợt lưu lượng truy cập (traffic) tăng đột biến, và phát hiện bên dưới nó là một chuyện tệ hơn nhiều: trong một ngày, 22% tổng số yêu cầu (request) gửi tới ứng dụng của tôi đã bị lỗi. Không có cảnh báo nào bắn ra. Lượt kiểm tra tình trạng (health check) nào cũng báo xanh. Nhật ký (log) trông hoàn toàn bình thường, vì những yêu cầu bị lỗi chưa bao giờ tới được ứng dụng.</p>
<p>Hóa ra thủ phạm lại là chính script khởi động container của tôi: nó làm những việc hoàn toàn hợp lý, chỉ có điều là vào đúng thời điểm tệ nhất.</p>

<h2 class="relative group">Đợt lưu lượng tăng vọt chỉ là cú đánh lạc hướng
    <div id="đợt-lưu-lượng-tăng-vọt-chỉ-là-cú-đánh-lạc-hướng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%a3t-l%c6%b0u-l%c6%b0%e1%bb%a3ng-t%c4%83ng-v%e1%bb%8dt-ch%e1%bb%89-l%c3%a0-c%c3%ba-%c4%91%c3%a1nh-l%e1%ba%a1c-h%c6%b0%e1%bb%9bng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ban đầu tôi chỉ định tìm hiểu một khung giờ nhận khoảng 4,900 yêu cầu, trong khi trung vị chỉ là 121. Có bốn mươi hai khách truy cập khác nhau, tức là 110 yêu cầu cho mỗi khách. Người thật thì không lướt web kiểu đó.</p>
<p>Đó là một công cụ quét (scanner): một con bot duy nhất lần lượt dò qua các tên miền phụ (subdomain) do nó tự bịa ra, và hỏi từng cái xem có để lộ thông tin bí mật (secrets) nào không:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">api-stage-asia.example.com/.env.local
</span></span><span class="line"><span class="cl">api-us-east-1-demo.example.com/.env
</span></span><span class="line"><span class="cl">api-eu-uat.example.com/wp-config.php~
</span></span><span class="line"><span class="cl">app-test-us-east-1.example.com/actuator/configprops
</span></span><span class="line"><span class="cl">prod-api-use2.example.com/.env.local
</span></span><span class="line"><span class="cl">relay.example.com/.env.production</span></span></code></pre></div></div>
<p>Khoảng ba mươi tên máy chủ (hostname), đủ mọi biến thể <code>.env</code> bạn nghĩ ra được, thêm cả các endpoint actuator của một framework Java trên một ứng dụng Django. Tất cả đều nhận mã 301 chuyển hướng về tên miền chính, và không có gì bị lộ ra ngoài. Nhàm chán, và thật sự vô hại.</p>
<p>Nhưng nhân tiện đang mở trang phân tích (analytics), tôi thử nhóm số liệu của cùng ngày đó theo mã trạng thái (status code). Vấn đề thật nằm ở đây.</p>
<table>
	<thead>
			<tr>
					<th>Mã trạng thái</th>
					<th>Số yêu cầu trong 24h</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Tổng</td>
					<td>11,350</td>
			</tr>
			<tr>
					<td>504 Gateway Timeout</td>
					<td>2,474</td>
			</tr>
	</tbody>
</table>
<p>Hai mươi hai phần trăm. Và số lỗi này không rải đều khắp trang, mà dồn vào đúng những chỗ đau nhất:</p>
<table>
	<thead>
			<tr>
					<th>Endpoint</th>
					<th>Số lỗi 504</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>số thông báo chưa đọc</td>
					<td>621</td>
			</tr>
			<tr>
					<td>danh sách thông báo</td>
					<td>614</td>
			</tr>
			<tr>
					<td>số tin nhắn chưa đọc</td>
					<td>469</td>
			</tr>
			<tr>
					<td>feed cursor</td>
					<td>210</td>
			</tr>
	</tbody>
</table>
<p>Đây đều là những endpoint mà ứng dụng di động của tôi gọi định kỳ (poll) mỗi 30 giây. Nên đây không phải một con số tỉ lệ lỗi trừu tượng. Đó là điện thoại của từng người dùng, âm thầm không tải được số tin chưa đọc hiện trên biểu tượng, suốt cả ngày, và nhiều khả năng là đã như vậy mấy tuần liền.</p>

<h2 class="relative group">Vì sao log vẫn trông bình thường
    <div id="vì-sao-log-vẫn-trông-bình-thường" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-log-v%e1%ba%abn-tr%c3%b4ng-b%c3%acnh-th%c6%b0%e1%bb%9dng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phản xạ đầu tiên của tôi là ứng dụng đã bị chậm đi. Nhưng không phải. Tôi lấy từ kho nhật ký ra các dòng nhật ký yêu cầu của chính ứng dụng trong một khung giờ tệ nhất:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">[2026-09-08 01:00:29] &#34;GET /api/notifications/unread/&#34; 304 61.615
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:29] &#34;GET /api/notifications/&#34;        304 63.568
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/unread/&#34; 304 58.742
</span></span><span class="line"><span class="cl">[2026-09-08 01:00:59] &#34;GET /api/notifications/&#34;        304 105.438</span></span></code></pre></div></div>
<p>Từ bốn mươi bốn đến một trăm mili giây, toàn bộ đều là 304. Máy chủ gốc (origin) chạy rất nhanh.</p>
<p>Đó là manh mối quan trọng, và đáng để đúc kết thành một quy tắc: <strong>nếu tầng biên (edge) báo lỗi mà máy chủ gốc chưa từng nghe thấy, thì yêu cầu đã chết trước khi tới nơi.</strong> Lúc đó, hãy thôi đọc nhật ký ứng dụng và bắt đầu nhìn vào những gì đứng trước nó.</p>
<p>Trong trường hợp của tôi, thứ đứng trước là proxy của Fly.io, và câu trả lời nằm ở cách các máy (machine) của tôi được lên lịch chạy.</p>

<h2 class="relative group">Thu về 0 (scale to zero) là lời hứa về tiền, không phải về độ trễ
    <div id="thu-về-0-scale-to-zero-là-lời-hứa-về-tiền-không-phải-về-độ-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="#thu-v%e1%bb%81-0-scale-to-zero-l%c3%a0-l%e1%bb%9di-h%e1%bb%a9a-v%e1%bb%81-ti%e1%bb%81n-kh%c3%b4ng-ph%e1%ba%a3i-v%e1%bb%81-%c4%91%e1%bb%99-tr%e1%bb%85" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cấu hình của tôi trông như thế này, và thoạt nhìn thì khá hợp lý:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>Máy nào rảnh thì tự dừng. Có lưu lượng thì tự bật lại. Dùng bao nhiêu trả bấy nhiêu. Với một ứng dụng nhỏ có lưu lượng lúc lên lúc xuống thất thường, đây đúng là thứ bạn cần, và đó cũng là lý do tôi khai báo mười máy nhưng gần như lúc nào cũng chỉ có một cái đang chạy.</p>
<p>Cái giá bị giấu đi ở đây là độ trễ (latency). Khi một yêu cầu tới một máy đang dừng, sẽ có người phải ngồi chờ máy đó khởi động. Nếu thời gian khởi động lâu hơn sức chờ của proxy, người đó sẽ nhận về lỗi 504.</p>
<p>Vậy máy của tôi mất bao lâu để khởi động? Tôi chưa từng đo. Nhưng kho nhật ký thì biết, vì script khởi động có in ra từng giai đoạn, và dòng nhật ký nào cũng kèm ID của máy:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">WITH</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="p">(</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">SELECT</span><span class="w"> </span><span class="n">instance</span><span class="p">,</span><span class="w"> </span><span class="k">timestamp</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">         </span><span class="k">CASE</span><span class="w"> </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Migrations complete%&#39;</span><span class="w"> </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="k">WHEN</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">LIKE</span><span class="w">  </span><span class="s1">&#39;%&#34;GET /healthz%&#39;</span><span class="w">       </span><span class="k">THEN</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="w"> </span><span class="k">END</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">phase</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">WHERE</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="n">instance</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;begin&#39;</span><span class="p">)</span><span class="w">    </span><span class="k">AS</span><span class="w"> </span><span class="n">started</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;migrated&#39;</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">migrated</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="k">min</span><span class="p">(</span><span class="k">timestamp</span><span class="p">)</span><span class="w"> </span><span class="n">FILTER</span><span class="w"> </span><span class="p">(</span><span class="k">WHERE</span><span class="w"> </span><span class="n">phase</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;serving&#39;</span><span class="p">)</span><span class="w">  </span><span class="k">AS</span><span class="w"> </span><span class="n">first_serve</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">s</span><span class="w"> </span><span class="k">GROUP</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">instance</span></span></span></code></pre></div></div>
<p>Kết quả rất nhất quán trên mọi máy:</p>
<table>
	<thead>
			<tr>
					<th>Giai đoạn</th>
					<th>Thời gian</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Từ lúc khởi động đến khi chạy xong chuyển đổi lược đồ (migration)</td>
					<td>10 đến 13 giây</td>
			</tr>
			<tr>
					<td>Từ lúc xong chuyển đổi lược đồ đến khi phục vụ yêu cầu đầu tiên</td>
					<td>8 đến 19 giây</td>
			</tr>
			<tr>
					<td><strong>Tổng</strong></td>
					<td><strong>25 đến 33 giây</strong></td>
			</tr>
	</tbody>
</table>
<p>Sau đó tôi đếm xem chuyện này xảy ra bao nhiêu lần:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">starts</span><span class="p">,</span><span class="w"> </span><span class="k">count</span><span class="p">(</span><span class="k">DISTINCT</span><span class="w"> </span><span class="n">instance</span><span class="p">)</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="n">machines</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="n">logs</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">WHERE</span><span class="w"> </span><span class="n">app</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;myapp&#39;</span><span class="w"> </span><span class="k">AND</span><span class="w"> </span><span class="k">timestamp</span><span class="p">::</span><span class="nb">date</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;2026-09-08&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="k">AND</span><span class="w"> </span><span class="n">message</span><span class="w"> </span><span class="k">ILIKE</span><span class="w"> </span><span class="s1">&#39;%Starting app server%&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- starts: 92, machines: 10</span></span></span></code></pre></div></div>
<p>Chín mươi hai lần khởi động nguội (cold start) chỉ trong một ngày. Mỗi lần là một khoảng nửa phút, trong đó yêu cầu nào được định tuyến tới máy đó cũng đều hết thời gian chờ (timeout). Đến đây thì con số hai mươi hai phần trăm hết còn bí ẩn.</p>

<h2 class="relative group">Thủ phạm nằm ngay trong script khởi chạy (entrypoint) của tôi
    <div id="thủ-phạm-nằm-ngay-trong-script-khởi-chạy-entrypoint-của-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#th%e1%bb%a7-ph%e1%ba%a1m-n%e1%ba%b1m-ngay-trong-script-kh%e1%bb%9fi-ch%e1%ba%a1y-entrypoint-c%e1%bb%a7a-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là những gì mọi máy đều phải chạy trước khi chịu trả lời yêu cầu:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="c1"># entrypoint.sh, run on every machine start</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running migrations...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Running collectstatic in background...&#34;</span>
</span></span><span class="line"><span class="cl">python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span>    <span class="c1"># then start the app server</span></span></span></code></pre></div></div>
<p>Hãy đọc lại đoạn này, lần này nhớ trong đầu con số &ldquo;92 lần mỗi ngày&rdquo;.</p>
<p><code>migrate</code> là một lần khởi động Django trọn vẹn: nạp mọi mô hình, kết nối tới Postgres, truy vấn bảng migrations, rồi kết luận là chẳng có gì phải làm. Trên CPU dùng chung (shared CPU), mất mười giây chỉ để không làm gì cả. <code>collectstatic</code> là lần khởi động Django trọn vẹn thứ hai, sau đó còn tải các tệp tĩnh (static file) lên kho lưu trữ đối tượng (object storage), tranh giành đúng một nhân CPU duy nhất mà máy chủ web đang cố khởi động trên đó. Máy chủ ứng dụng (app server) là lần khởi động thứ ba.</p>
<p>Ba trình thông dịch Python, hai trong số đó làm lại những việc đã xong từ trước, và chuyện này lặp lại mỗi khi một máy thức dậy.</p>
<p>Tôi viết như vậy là có lý do chính đáng, và có lẽ script khởi chạy của bạn trông na ná cũng vì lý do đó: chuyển đổi lược đồ thì phải chạy ở đâu đó, <code>release_command</code> của Fly từng bị treo với tôi, và script khởi chạy là nơi duy nhất chắc chắn được chạy mỗi lần triển khai. Đặt việc của khâu triển khai vào đó là đúng. Có điều, đó không phải chỗ đúng để chạy việc đó <em>mỗi lần máy khởi động</em>, và một khi máy tự dừng rồi tự bật lại, hai sự kiện này không còn là một nữa.</p>
<p>Toàn bộ lỗi chỉ có vậy, và tôi nghĩ nó khá phổ biến:</p>
<blockquote><p>Những việc thuộc về một lần <strong>phát hành</strong> lại bị chạy lại mỗi lần <strong>máy khởi động</strong>. Cơ chế thu về 0 biến một sự kiện thành chín mươi hai sự kiện.</p>
</blockquote>
<h2 class="relative group">Cách sửa: mỗi bản phát hành chỉ chuẩn bị đúng một lần
    <div id="cách-sửa-mỗi-bản-phát-hành-chỉ-chuẩn-bị-đúng-một-lần" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1ch-s%e1%bb%ada-m%e1%bb%97i-b%e1%ba%a3n-ph%c3%a1t-h%c3%a0nh-ch%e1%bb%89-chu%e1%ba%a9n-b%e1%bb%8b-%c4%91%c3%bang-m%e1%bb%99t-l%e1%ba%a7n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bước chuyển đổi lược đồ không cần chạy lại trên chiếc máy thứ tám khởi động từ cùng một image. Nó chỉ cần chạy một lần cho image đó, và mọi máy khác cần biết là việc này đã xong.</p>
<p>Nói cách khác, ta cần một khóa (lock) có tên thay đổi theo từng lần triển khai. Fly cung cấp sẵn cái tên đó trong <code>FLY_IMAGE_REF</code>, còn Redis thì tôi đã có sẵn. Tất cả chỉ là một script nhỏ chạy trước khi Django được nạp, nên trong trường hợp thường gặp, nó chỉ tốn vài mili giây thay vì một lần khởi động cả framework:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># release_gate.py: runs before Django exists. Exit code 0 means &#34;prepare this release&#34;.</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span><span class="o">,</span> <span class="nn">time</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">PREPARE</span><span class="p">,</span> <span class="n">SKIP</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl"><span class="n">LOCK_TTL</span><span class="p">,</span> <span class="n">WAIT</span><span class="p">,</span> <span class="n">POLL</span> <span class="o">=</span> <span class="mi">300</span><span class="p">,</span> <span class="mi">240</span><span class="p">,</span> <span class="mi">2</span>
</span></span><span class="line"><span class="cl"><span class="n">_sleep</span><span class="p">,</span> <span class="n">_monotonic</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">,</span> <span class="n">time</span><span class="o">.</span><span class="n">monotonic</span>    <span class="c1"># aliases, so tests can replace them</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">release_id</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Something that changes exactly when the deployed image does.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_IMAGE_REF&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_VERSION&#34;</span><span class="p">)</span> <span class="ow">or</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">claim</span><span class="p">(</span><span class="n">client</span><span class="p">,</span> <span class="n">release</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">int</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span> <span class="ow">is</span> <span class="kc">None</span> <span class="ow">or</span> <span class="ow">not</span> <span class="n">release</span><span class="p">:</span>   <span class="c1"># no Redis, or no idea which release: behave as before</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>
</span></span><span class="line"><span class="cl">    <span class="n">done_key</span><span class="p">,</span> <span class="n">lock_key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:done&#34;</span><span class="p">,</span> <span class="sa">f</span><span class="s2">&#34;release:</span><span class="si">{</span><span class="n">release</span><span class="si">}</span><span class="s2">:lock&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>            <span class="c1"># somebody already did it for this image</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">owner</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;FLY_MACHINE_ID&#34;</span><span class="p">,</span> <span class="s2">&#34;unknown&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">PREPARE</span>                  <span class="c1"># we won the race; we do the work</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Someone else is preparing. Wait for them, because serving requests</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># against a half-migrated schema is worse than starting slowly.</span>
</span></span><span class="line"><span class="cl">    <span class="n">deadline</span> <span class="o">=</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">+</span> <span class="n">WAIT</span>
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">_monotonic</span><span class="p">()</span> <span class="o">&lt;</span> <span class="n">deadline</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">_sleep</span><span class="p">(</span><span class="n">POLL</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">done_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">SKIP</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">client</span><span class="o">.</span><span class="n">set</span><span class="p">(</span><span class="n">lock_key</span><span class="p">,</span> <span class="n">owner</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="n">LOCK_TTL</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="n">PREPARE</span>              <span class="c1"># the owner died and its lock expired; take over</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">PREPARE</span>                      <span class="c1"># waited long enough; do it ourselves</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># ... plus `done` (sets done_key) and a main() that treats any Redis error as PREPARE</span></span></span></code></pre></div></div>
<p>Script khởi chạy giờ chỉ còn một câu <code>if</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl"><span class="k">if</span> python3 release_gate.py claim<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    python manage.py migrate --noinput
</span></span><span class="line"><span class="cl">    python manage.py collectstatic --noinput <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">    python3 release_gate.py <span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">exec</span> <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span></span></span></code></pre></div></div>

<h2 class="relative group">Mọi nhánh lỗi đều phải dẫn tới &ldquo;cứ chuẩn bị đi&rdquo;
    <div id="mọi-nhánh-lỗi-đều-phải-dẫn-tới-cứ-chuẩn-bị-đi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%8di-nh%c3%a1nh-l%e1%bb%97i-%c4%91%e1%bb%81u-ph%e1%ba%a3i-d%e1%ba%abn-t%e1%bb%9bi-c%e1%bb%a9-chu%e1%ba%a9n-b%e1%bb%8b-%c4%91i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần đáng học theo nằm ở đây, hơn là ở đoạn mã.</p>
<p>Một cổng kiểm soát (gate) như thế này đứng giữa người dùng và một lần chuyển đổi lược đồ. Nếu nó sai theo hướng <em>bỏ qua</em>, bạn sẽ phục vụ yêu cầu trên một cơ sở dữ liệu chưa được chuyển đổi, và đó là một sự cố thật sự nghiêm trọng. Còn nếu nó sai theo hướng <em>chuẩn bị</em>, bạn chỉ chạy lại một bước chuyển đổi lũy đẳng (idempotent) vốn chẳng làm gì, và mất thêm mười giây.</p>
<p>Hai kết cục này không ngang nhau, nên mã nguồn không được xử lý chúng như nhau. Mọi nhánh không chắc chắn đều trả về <code>PREPARE</code>:</p>
<ul>
<li><strong>Không có Redis, hoặc không kết nối được tới Redis.</strong> Chuẩn bị. Đây chính là hành vi cũ, nên nếu bộ nhớ đệm (cache) có sập thì hệ thống chỉ &ldquo;chậm&rdquo; chứ không &ldquo;hỏng&rdquo;.</li>
<li><strong>Biến môi trường không có tham chiếu tới image.</strong> Chuẩn bị. Không biết đây là bản phát hành nào thì cũng không thể biết nó đã sẵn sàng hay chưa.</li>
<li><strong>Máy đang giữ khóa chết giữa chừng lúc chạy chuyển đổi lược đồ.</strong> Khóa của nó sẽ hết hạn, và máy tiếp theo đang chờ sẽ giành lấy khóa rồi chuẩn bị.</li>
<li><strong>Chờ quá thời gian cho phép.</strong> Cứ chuẩn bị. Một máy không bao giờ phục vụ được còn tệ hơn một lần chuyển đổi lược đồ bị chạy trùng.</li>
</ul>
<p>Có một điểm bất đối xứng tôi cố ý đặt theo chiều ngược lại: máy nào thấy một máy khác đang giữ khóa thì sẽ <em>chờ</em> chứ không phục vụ yêu cầu. Khởi động chậm một chút trong lúc triển khai thì không sao. Nhưng trả lời truy vấn trên một lược đồ mới áp dụng được một nửa thì có sao đấy.</p>
<p>Nếu bạn dựng cơ chế này trên một lớp bọc (wrapper) bộ nhớ đệm nuốt luôn lỗi kết nối, như tôi đã làm lúc đầu, hãy cẩn thận với một cái bẫy. Khi không kết nối được Redis, lớp bọc kiểu đó trả về <code>False</code> từ <code>add()</code>, trông y hệt kết quả &ldquo;đã có máy khác giữ khóa&rdquo;, và nếu không để ý thì đã khiến mọi máy bỏ qua bước chuyển đổi lược đồ. Đó chính xác là hướng nguy hiểm. Hãy phân biệt hai trường hợp bằng cách đọc lại khóa: nếu không ai giữ khóa, nghĩa là bộ nhớ đệm đang hỏng, chứ không phải bạn thua cuộc đua.</p>

<h2 class="relative group">Kiểm thử mà không cần Redis
    <div id="kiểm-thử-mà-không-cần-redis" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ki%e1%bb%83m-th%e1%bb%ad-m%c3%a0-kh%c3%b4ng-c%e1%ba%a7n-redis" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Logic này đáng để viết kiểm thử đơn vị (unit test), vì những nhánh đáng chú ý nhất lại chính là những nhánh bạn không thể tái hiện bằng tay. Chỉ cần một bản giả lập (fake) có hai phương thức là đủ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">FakeRedis</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;&#34;&#34;Enough of a Redis client for the gate: get, and set with nx/ex.&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span> <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">,</span> <span class="n">nx</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">ex</span><span class="o">=</span><span class="kc">None</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">nx</span> <span class="ow">and</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">store</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">value</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">True</span></span></span></code></pre></div></div>
<p>Sau đó, dựng các trường hợp khó bằng cách tự điều khiển thời gian trôi. Đây chính là lý do cổng kiểm soát gọi các bí danh <code>_sleep</code> và <code>_monotonic</code> ở cấp mô-đun chứ không gọi thẳng <code>time.sleep</code>, nên bài kiểm thử có thể thay thế chúng mà không phải vá (patch) mô-đun <code>time</code> cho mọi luồng (thread) khác trong tiến trình:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">test_a_waiter_takes_over_when_the_owner_disappears</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">redis</span> <span class="o">=</span> <span class="n">FakeRedis</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span>   <span class="c1"># the owner takes the lock</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">owner_dies</span><span class="p">(</span><span class="n">_seconds</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="n">redis</span><span class="o">.</span><span class="n">store</span><span class="o">.</span><span class="n">pop</span><span class="p">(</span><span class="s2">&#34;release:img-1:lock&#34;</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>                              <span class="c1"># its lock expired</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="o">.</span><span class="n">object</span><span class="p">(</span><span class="n">release_gate</span><span class="p">,</span> <span class="s2">&#34;_sleep&#34;</span><span class="p">,</span> <span class="n">owner_dies</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">release_gate</span><span class="o">.</span><span class="n">claim</span><span class="p">(</span><span class="n">redis</span><span class="p">,</span> <span class="s2">&#34;img-1&#34;</span><span class="p">),</span> <span class="n">release_gate</span><span class="o">.</span><span class="n">PREPARE</span><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">Có hiệu quả không? Được một phần
    <div id="có-hiệu-quả-không-được-một-phầ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="#c%c3%b3-hi%e1%bb%87u-qu%e1%ba%a3-kh%c3%b4ng-%c4%91%c6%b0%e1%bb%a3c-m%e1%bb%99t-ph%e1%ba%a7n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Triển khai, dừng một máy, bật lại, rồi canh đồng hồ. Giờ đây lúc khởi động, script báo ra quyết định của nó rồi nhường đường luôn:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">15:23:34  Starting app server...
</span></span><span class="line"><span class="cl">15:23:36  release gate: this release is already prepared, starting straight away
</span></span><span class="line"><span class="cl">15:23:44  &#34;GET /healthz HTTP/1.1&#34; 200</span></span></code></pre></div></div>
<p>Mười giây, và một máy thứ hai đo được mười một giây. So với 25 đến 33 giây trước đó, hai phần ba khoảng thời gian chết đã biến mất, và bản thân cổng kiểm soát chỉ tốn hai giây vì nó không bao giờ nạp Django.</p>
<p>Nhưng mười giây vẫn là mười giây. Yêu cầu nào tới trong những giây đầu tiên đó vẫn bị lỗi. Nên tôi đi tìm tiếp phần còn lại.</p>

<h2 class="relative group">Bytecode chẳng ai lưu đệm
    <div id="bytecode-chẳng-ai-lưu-đệ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="#bytecode-ch%e1%ba%b3ng-ai-l%c6%b0u-%c4%91%e1%bb%87m" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Image có một dòng mà gần như Dockerfile Python nào cũng có:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">ENV</span> <span class="nv">PYTHONDONTWRITEBYTECODE</span><span class="o">=</span><span class="m">1</span></span></span></code></pre></div></div>
<p>Đây là lời khuyên đúng. Container không nên tự ghi các tệp <code>.pyc</code> vào lớp (layer) của image lúc đang chạy. Nhưng tôi chưa bao giờ nghĩ kỹ về nửa còn lại của vấn đề: nếu lúc chạy không ghi bytecode, lúc dựng image cũng không ghi, thì chẳng có gì được lưu đệm cả, và mỗi lần tiến trình khởi động là biên dịch lại toàn bộ cây thư viện phụ thuộc từ mã nguồn.</p>
<p>Tôi thử đếm:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">site-packages .py  files : 5,707
</span></span><span class="line"><span class="cl">site-packages .pyc files : 0</span></span></code></pre></div></div>
<p>Django, framework REST, mọi thư viện đều bị biên dịch lại trong từng lần khởi động, 92 lần mỗi ngày. Đo trên một máy trong môi trường production:</p>
<table>
	<thead>
			<tr>
					<th><code>django.setup()</code></th>
					<th>Thời gian</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Bản đang chạy hiện tại</td>
					<td>5.15s</td>
			</tr>
			<tr>
					<td>Sau khi chạy <code>compileall</code></td>
					<td>3.17s</td>
			</tr>
	</tbody>
</table>
<p>Cách sửa chỉ có một dòng, và vì <code>compileall</code> luôn ghi bytecode ra tệp, nên nó vẫn hoạt động bất kể biến môi trường kia:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="cl"><span class="k">RUN</span> python -m compileall -q /app/.venv/lib /app/myapp <span class="o">||</span> true</span></span></code></pre></div></div>
<p>Tốn thêm mười sáu giây lúc dựng image, chỉ một lần, trong một lớp đã được lưu đệm.</p>
<p>Nhân tiện, tôi còn phát hiện thêm một vấn đề âm thầm hơn. Tệp <code>.dockerignore</code> 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">__pycache__/
</span></span><span class="line"><span class="cl">*.pyc</span></span></code></pre></div></div>
<p>Docker so khớp các mẫu (pattern) này với toàn bộ đường dẫn tương đối, chứ không phải với từng thành phần của đường dẫn, nên một mẫu không có neo sẽ chỉ khớp ở thư mục gốc của ngữ cảnh dựng (build context). Kết quả là mọi thư mục <code>__pycache__</code> lồng bên trong đều bị đóng gói theo. Image chứa 218 tệp <code>.pyc</code> do Python 3.15 biên dịch trên laptop, đi nhờ trong một image có trình thông dịch 3.14, và tất cả đều bị lặng lẽ bỏ qua. Mẫu đúng phải là <code>**/__pycache__/</code> và <code>**/*.pyc</code>.</p>
<p>Nói thật thì kết quả không như kỳ vọng: tính từ đầu đến cuối, cách này chỉ bớt được khoảng một giây chứ không phải hai. Mười giây còn chín. Phép đo hiệu năng riêng lẻ (benchmark) đã phóng đại, như các phép đo riêng lẻ vẫn thường vậy.</p>

<h2 class="relative group">Tạm ngưng (suspend), và dòng cấu hình đang chặn nó
    <div id="tạm-ngưng-suspend-và-dòng-cấu-hình-đang-chặn-nó" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%ba%a1m-ng%c6%b0ng-suspend-v%c3%a0-d%c3%b2ng-c%e1%ba%a5u-h%c3%acnh-%c4%91ang-ch%e1%ba%b7n-n%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đến mức chín giây thì tôi hết thứ để cắt bỏ. Phần còn lại là một lần khởi động không thể tránh: Python khởi động, Django nạp các mô-đun, rồi ứng dụng WSGI chạy lên trên một nhân CPU dùng chung.</p>
<p>Vậy thì đừng khởi động lại từ đầu nữa. Thay vì tắt hẳn máy, Fly có thể chụp lại toàn bộ bộ nhớ (snapshot) của nó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>   <span class="c"># was &#39;stop&#39;</span></span></span></code></pre></div></div>
<p>Tôi thử bật lên thì Fly từ chối, kèm theo thông báo lỗi hữu ích nhất trong cả quá trình 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">failed to suspend VM: failed_precondition:
</span></span><span class="line"><span class="cl">Machines with swap cannot be suspended</span></span></code></pre></div></div>
<p>Gần đầu tệp cấu hình của tôi có đoạn này, kèm một dòng chú thích (comment) do chính tôi viết và tin sái cổ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># Swap cushion so a memory spike swaps to disk instead of getting OOM-killed.</span>
</span></span><span class="line"><span class="cl"><span class="nx">swap_size_mb</span> <span class="p">=</span> <span class="mi">2048</span></span></span></code></pre></div></div>
<p>Một biện pháp phòng ngừa hợp lý. Nhưng nó có thực sự làm gì không? Máy nào cũng 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">MemTotal:   985220 kB
</span></span><span class="line"><span class="cl">SwapTotal: 2097148 kB
</span></span><span class="line"><span class="cl">SwapFree:  2097148 kB</span></span></code></pre></div></div>
<p>Chưa từng có một trang bộ nhớ nào bị đẩy ra vùng hoán đổi (swap). Nhật ký trong ngày cũng không có vụ tiến trình nào bị diệt vì hết bộ nhớ (OOM kill) — mười một dòng khớp với chữ &ldquo;oom&rdquo; hóa ra là yêu cầu tới một gói JavaScript mà mã băm nội dung tình cờ chứa ba chữ cái đó. Trong khi đó, cơ chế bảo vệ bộ nhớ thật sự lại nằm ở một chỗ hoàn toàn khác, ngay trong cấu hình của chính máy chủ ứng dụng: worker nào vượt quá 800MB sẽ bị khởi động lại từ lâu trước khi kịp chạm tới trần 1GB.</p>
<p>Nói cách khác, vùng hoán đổi dự phòng là bảo hiểm cho một sự cố chưa từng xảy ra, vốn đã có cơ chế khác lo, và phí bảo hiểm phải trả là mất khả năng tạm ngưng. Tôi bỏ nó đi.</p>
<table>
	<thead>
			<tr>
					<th>Cách đánh thức</th>
					<th>Thời gian đến khi phục vụ được</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Khởi động nguội, sáng nay</td>
					<td>25 đến 33s</td>
			</tr>
			<tr>
					<td>Khởi động nguội, sau khi thêm cổng kiểm soát và lưu đệm bytecode</td>
					<td>9 đến 10s</td>
			</tr>
			<tr>
					<td><strong>Đánh thức từ trạng thái tạm ngưng</strong></td>
					<td><strong>2.5 đến 3.1s</strong></td>
			</tr>
	</tbody>
</table>
<p>Ba lần thử, các kết quả chênh nhau không quá nửa giây. Ngay sau khi thức dậy, endpoint nào cũng trả về 200 trong 250 đến 435 mili giây.</p>

<h2 class="relative group">Socket sẽ ra sao khi máy đang ngủ
    <div id="socket-sẽ-ra-sao-khi-máy-đang-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="#socket-s%e1%ba%bd-ra-sao-khi-m%c3%a1y-%c4%91ang-ng%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chẳng có gì tốt đẹp cả, mà cũng không có hook nào để can thiệp. Fly đóng băng máy ảo (VM); tiến trình không nhận được tín hiệu (signal) nào và không kịp đóng thứ gì trước khi ngủ. Những kết nối nó đang giữ vẫn nằm nguyên trong bộ nhớ lúc thức dậy, trỏ tới những socket mà đầu bên kia đã bỏ đi từ mấy phút trước.</p>
<p>Bạn không xử lý chuyện này lúc máy đi ngủ, mà lúc nó thức dậy, và phần lớn là nhờ những quyết định đúng đã có từ trước:</p>
<ul>
<li><strong>Kết nối cơ sở dữ liệu được mở theo từng yêu cầu</strong> (<code>conn_max_age = 0</code>), nên không có kết nối sống lâu nào để bị cũ.</li>
<li><strong>Bộ nhớ đệm Redis chỉ xuống cấp chứ không ném ngoại lệ (exception).</strong> Một lớp bọc bắt lỗi kết nối và coi như không trúng bộ nhớ đệm (cache miss).</li>
</ul>
<p>Tôi đã được tận mắt thấy cái thứ hai phát huy tác dụng. Một lần đánh thức ghi ra đúng cái lỗi bạn đoá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">redis.exceptions.ConnectionError: Error while reading from fly-...</span></span></code></pre></div></div>
<p>và yêu cầu gặp lỗi đó vẫn trả về 200 sau 3.6 giây, vì bộ nhớ đệm chết thì chỉ khiến trang chậm đi chứ không thành trang lỗi. Những lần đánh thức sau đó thì nhật ký sạch trơn.</p>
<p>Nếu thư viện kết nối bộ nhớ đệm của bạn ném ngoại lệ khi gặp kết nối chết, chế độ tạm ngưng sẽ biến mỗi lần thức dậy thành một tràng lỗi 500. Hãy kiểm tra chuyện đó trước khi bật cấu hình này, đừng đợi đến sau.</p>

<h2 class="relative group">Lần kiểm thử đã đánh lừa tôi
    <div id="lần-kiểm-thử-đã-đánh-lừa-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%ba%a7n-ki%e1%bb%83m-th%e1%bb%ad-%c4%91%c3%a3-%c4%91%c3%a1nh-l%e1%bb%aba-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Lần đo chế độ tạm ngưng đầu tiên cho thấy tính năng này là một thảm họa: 502 sau 30 giây, hai lần liên tiếp.</p>
<p>Lúc đó tôi đã ghim yêu cầu vào một máy cụ thể bằng header <code>fly-force-instance-id</code>. Header này rất tiện khi kiểm thử tải (load test) một máy, nhưng lại cực kỳ tệ cho trường hợp này. Ép vào một máy cụ thể sẽ bỏ qua phần logic của proxy vốn có nhiệm vụ đánh thức máy. Thật ra Fly đã nói rõ điều đó, nếu tôi chịu đọc nó như một câu trả lời thay vì một thông báo lỗi:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">machine was recently stopped and is unavailable to service request</span></span></code></pre></div></div>
<p>Con số đáng quan tâm phải đến từ đường đi mà lưu lượng thật đi qua. Tôi bắn bốn mươi yêu cầu đồng thời qua tầng biên, vượt xa giới hạn mềm (soft limit) là 12, để proxy buộc phải mở rộng theo chiều ngang (scale out) sang các máy đang tạm ngưng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">status codes:  40 × 200
</span></span><span class="line"><span class="cl">slowest:       2.1s</span></span></code></pre></div></div>
<p>Có hai bài học, và bài thứ hai là bài đắt giá hơn. Hãy đo trên đường đi mà người dùng thật sự đi, chứ không phải đường đi tiện cho việc gắn công cụ đo. Và khi công cụ từ chối làm gì đó, hãy đọc kỹ lời từ chối: chính câu &ldquo;Machines with swap cannot be suspended&rdquo; đã gỡ nút thắt cho cả buổi chiều hôm đó.</p>

<h2 class="relative group">Nếu là ứng dụng của bạn, tôi sẽ kiểm tra những gì
    <div id="nếu-là-ứng-dụng-của-bạn-tôi-sẽ-kiểm-tra-những-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%e1%ba%bfu-l%c3%a0-%e1%bb%a9ng-d%e1%bb%a5ng-c%e1%bb%a7a-b%e1%ba%a1n-t%c3%b4i-s%e1%ba%bd-ki%e1%bb%83m-tra-nh%e1%bb%afng-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn đang chạy bất cứ thứ gì trên một nền tảng tự dừng máy khi rảnh, thì những câu hỏi dưới đây chỉ tốn của tôi một buổi chiều, nhưng lẽ ra đã giúp tôi tránh được mấy tuần lỗi âm thầm:</p>
<ol>
<li><strong>Một lần khởi động nguội mất bao lâu?</strong> Không phải container mất bao lâu để chạy, mà là mất bao lâu đến khi nó trả lời được một yêu cầu thật. Nếu bạn không trả lời được câu này bằng một con số tính theo giây, hãy đo ngay, trước khi thật sự cần tới nó.</li>
<li><strong>Script khởi chạy của bạn đang làm những việc gì mỗi lần khởi động mà lẽ ra thuộc về khâu triển khai?</strong> Chuyển đổi lược đồ, gom tệp tĩnh, làm nóng bộ nhớ đệm, dựng chỉ mục. Làm một lần thì không sao. Làm chín mươi hai lần thì rất tốn kém.</li>
<li><strong>Có thứ gì đang lưu đệm bytecode cho bạn không?</strong> Nếu có <code>PYTHONDONTWRITEBYTECODE</code> mà lúc dựng image không chạy <code>compileall</code>, thì câu trả lời là không.</li>
<li><strong>Bạn có thể cho máy tạm ngưng thay vì tắt hẳn không?</strong> Nếu không, thì cái gì đang cản bạn? Với tôi, đó là một tệp hoán đổi chưa từng được dùng tới.</li>
<li><strong>Tầng biên có thấy những lỗi mà máy chủ gốc không thấy không?</strong> Hãy so sánh hai bên. Khoảng chênh giữa chúng không phải do báo cáo bị lệch, mà là yêu cầu đang chết ở khoảng giữa, và nhật ký ứng dụng sẽ không bao giờ cho bạn thấy điều đó.</li>
</ol>
<p>À, nhân tiện, các lượt kiểm tra tình trạng vẫn xanh suốt từ đầu đến cuối. Lúc nào chúng cũng xanh cả: kiểm tra tình trạng chỉ chạy trên một máy đã khởi động xong, nên không thể báo cáo gì về ba mươi giây trước khi máy đó tồn tại.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
