<?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>Fly.io · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/fly.io/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/fly.io/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Upload thẳng lên S3 mà vẫn nén ảnh phía server: Tigris Object Notifications với Django</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/tigris-object-notifications/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/tigris-object-notifications/</guid>
      <pubDate>Sat, 07 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Tigris</category>
      <category>S3</category>
      <category>S3 Event Notifications</category>
      <category>Presigned URLs</category>
      <category>Image Uploads</category>
      <category>Image Compression</category>
      <category>Object Storage</category>
      <category>Webhooks</category>
      <category>Django</category>
      <category>Fly.io</category>
      <category>Discord</category>
      <description>Upload thẳng lên S3 bằng presigned URL thì nhanh, nhưng backend của bạn không bao giờ được báo là file đã tới nơi, nên chuyện nén ảnh và tạo thumbnail thành ra lúng túng. Tigris object notifications giải quyết đúng chỗ đó: bucket POST tới một webhook Django, và ảnh được xử lý trong lúc người dùng còn đang gõ. Cùng một tín hiệu ấy biến file log thành cảnh báo trên Discord.</description>
      <content:encoded><![CDATA[<p>Tính năng upload ảnh nào tôi từng xây cũng có chung một mâu thuẫn. Người upload thì muốn ảnh lên thật nhanh. Còn tôi thì muốn server nén ảnh, tạo thumbnail, rút ra một blurhash cùng vài màu chủ đạo, bỏ đi những metadata không nên nằm ở đó, rồi lưu đúng theo cách mà phần còn lại của ứng dụng mong đợi. Cách dễ nhất để tôi có được mọi thứ trong danh sách của mình là cho file đi qua API, và đó lại chính là thứ làm chậm điều duy nhất mà họ muốn.</p>
<p>Cách nhanh là dùng presigned URL (URL ký sẵn). API ký một URL, client PUT file thẳng lên bucket, và dữ liệu file không hề chạm tới API. Đây là câu trả lời chuẩn, và nó thành chuẩn là có lý do. Nhưng ngay khoảnh khắc bạn làm vậy, backend đã bị gạt ra khỏi cuộc chơi. Nó phát ra một URL rồi không nghe thấy gì nữa. File đã tới chưa? Kích thước có đúng như mong đợi không? Nó có thật sự là JPEG không? Phải có thứ gì đó báo cho backend biết, mà các lựa chọn thường thấy thì đều hơi chán: tin rằng client sẽ gọi lại báo &ldquo;xong rồi&rdquo; (client thì nói dối, crash, và mất sóng), định kỳ hỏi thăm bucket theo lịch (polling, vừa chậm vừa lãng phí), hoặc xử lý lười (lazy) vào lần đọc đầu tiên (người xem đầu tiên phải trả giá, và bạn vẫn không biết khi nào file đủ an toàn để tham chiếu tới). Chính điều này khiến upload trực tiếp lên S3 trông khó hơn thực tế. Phần upload thì dễ. Thứ còn thiếu là tín hiệu báo về.</p>
<p>Object notification (thông báo object) chính là tín hiệu đó. Tigris, dịch vụ lưu trữ tương thích S3 mà tôi dùng trên Fly.io, sẽ POST một body JSON nhỏ tới ứng dụng của bạn mỗi khi có object được tạo hoặc bị xóa trong bucket. Trên AWS, thứ tương đương là S3 event notification, mà thường nghĩa là phải nối thêm một Lambda, một hàng đợi SQS hoặc EventBridge trước khi bất cứ thứ gì chạm được tới code của bạn (SNS thì có thể POST tới một endpoint HTTPS, nhưng khi đó bạn lại phải xác nhận subscription và xác minh chữ ký trước đã). Tigris giữ mọi thứ ở mức một request HTTP tới một URL do bạn sở hữu, gần như là mức đơn giản nhất mà việc báo tín hiệu từ storage sang backend có thể đạt tới. Tính năng này tồn tại để bucket nói chuyện được với backend của bạn mà bạn không phải tự xây một cây cầu nối cho nó.</p>
<p>Bài này đi trọn cả luồng: phần cài đặt, một endpoint Django nhận sự kiện, một luồng upload bằng presigned URL mà vẫn giữ nguyên toàn bộ phần xử lý phía server, và những cái bẫy hay gặp (CORS, giới hạn kích thước, thời hạn, nhiều file). Cuối bài là cùng tín hiệu ấy làm việc hậu trường (back office): các file log rơi vào bucket được biến thành cảnh báo trên Discord, và nhờ đó tôi xóa được một cron job đã phải chăm bẵm suốt mấy tháng.</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>
<p>Trong dashboard của Tigris, mở bucket, vào Settings, rồi Object notifications. Đưa cho nó một URL HTTPS trỏ tới ứng dụng của bạn và chọn basic auth (một cặp username và password mà Tigris sẽ gửi trong header <code>Authorization</code>; lựa chọn còn lại là bearer token). Thêm một bộ lọc để chỉ nhận thông báo về những key bạn quan tâm. Bộ lọc là một biểu thức kiểu SQL, nên &ldquo;chỉ ba prefix mà bài này xử lý&rdquo; sẽ là:</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">WHERE</span><span class="w"> </span><span class="o">`</span><span class="k">key</span><span class="o">`</span><span class="w"> </span><span class="n">REGEXP</span><span class="w"> </span><span class="s2">&#34;^(uploads/raw|logs/api|media)/&#34;</span></span></span></code></pre></div></div>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Cài đặt bucket trên Tigris, tab Notifications: object notifications đã bật, một URL webhook, basic authentication với username và password, và một bộ lọc key kiểu SQL"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_7e9da7c6ca1125db.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-object-notifications-settings_hu_4ddc968851d1f052.webp"></span><figcaption>Toàn bộ phần cài đặt: một URL HTTPS trên ứng dụng của bạn, basic auth, và một bộ lọc trên key. Tên bucket và URL ở đây đã được thay bằng ví dụ.</figcaption></figure>
</p>
<p>Sau đó, mỗi khi có thay đổi, Tigris gửi một lô (batch) sự kiện như thế này:</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;events&#34;</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="nt">&#34;eventName&#34;</span><span class="p">:</span> <span class="s2">&#34;OBJECT_CREATED_PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;eventTime&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-11-07T09:12:44Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;bucket&#34;</span><span class="p">:</span> <span class="s2">&#34;example-bucket&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;object&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;uploads/raw/1f3c.jpg&#34;</span><span class="p">,</span> <span class="nt">&#34;size&#34;</span><span class="p">:</span> <span class="mi">48213</span><span class="p">,</span> <span class="nt">&#34;eTag&#34;</span><span class="p">:</span> <span class="s2">&#34;9b2c…&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Những tên sự kiện bạn sẽ gặp là <code>OBJECT_CREATED_PUT</code>, <code>OBJECT_CREATED_MULTIPART</code>, <code>OBJECT_CREATED_COPY</code> và <code>OBJECT_DELETED</code>, cộng thêm các sự kiện đổi tên và chuyển tầng lưu trữ (storage tier). Endpoint của bạn kiểm tra auth, định tuyến từng sự kiện theo prefix của key, và trả về mã 2xx trong vòng mười giây. Có ba điều cần tính trước ngay từ đầu: việc gửi theo kiểu at least once (ít nhất một lần), nên bạn sẽ gặp sự kiện bị lặp; sự kiện được gom theo lô, nên một request có thể chứa nhiều sự kiện cùng lúc; và chúng có thể đến không theo thứ tự, nên đừng suy ra cái nào &ldquo;mới nhất&rdquo; từ thứ tự chúng tới.</p>

<h2 class="relative group">Một webhook Django cho sự kiện của bucket
    <div id="một-webhook-django-cho-sự-kiện-của-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="#m%e1%bb%99t-webhook-django-cho-s%e1%bb%b1-ki%e1%bb%87n-c%e1%bb%a7a-bucket" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>View này được viết nhàm chán một cách có chủ đích. Nó kiểm tra auth, parse body, rồi giao từng sự kiện cho một bộ định tuyến (router):</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">base64</span><span class="o">,</span> <span class="nn">hmac</span><span class="o">,</span> <span class="nn">json</span><span class="o">,</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.http</span> <span class="kn">import</span> <span class="n">JsonResponse</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.csrf</span> <span class="kn">import</span> <span class="n">csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.views.decorators.http</span> <span class="kn">import</span> <span class="n">require_POST</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">.bucket_events</span> <span class="kn">import</span> <span class="n">handle</span><span class="p">,</span> <span class="n">parse</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</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">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</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="n">header</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">headers</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;Authorization&#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="k">if</span> <span class="ow">not</span> <span class="n">header</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;Basic &#34;</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">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">password</span> <span class="o">=</span> <span class="n">base64</span><span class="o">.</span><span class="n">b64decode</span><span class="p">(</span><span class="n">header</span><span class="p">[</span><span class="mi">6</span><span class="p">:])</span><span class="o">.</span><span class="n">decode</span><span class="p">()</span><span class="o">.</span><span class="n">partition</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="k">except</span> <span class="p">(</span><span class="ne">ValueError</span><span class="p">,</span> <span class="ne">UnicodeDecodeError</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="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">user</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="sa">b</span><span class="s2">&#34;tigris&#34;</span><span class="p">)</span> <span class="ow">and</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">password</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">secret</span><span class="o">.</span><span class="n">encode</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></span><span class="line"><span class="cl"><span class="nd">@csrf_exempt</span>
</span></span><span class="line"><span class="cl"><span class="nd">@require_POST</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">bucket_webhook</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">secret</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">BUCKET_WEBHOOK_SECRET</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">secret</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">_authorised</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">secret</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;unauthorised&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">body</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">body</span> <span class="ow">or</span> <span class="sa">b</span><span class="s2">&#34;</span><span class="si">{}</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">except</span> <span class="ne">ValueError</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;invalid json&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">400</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">event</span> <span class="ow">in</span> <span class="n">parse</span><span class="p">(</span><span class="n">body</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">handle</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;bucket event failed: </span><span class="si">%s</span><span class="s2">&#34;</span><span class="p">,</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">JsonResponse</span><span class="p">({</span><span class="s2">&#34;ok&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">})</span></span></span></code></pre></div></div>
<p><code>compare_digest</code> ở đó để phép kiểm tra luôn mất cùng một khoảng thời gian, bất kể bạn gửi gì lên (so sánh trên bytes, vì với chuỗi thì nó ném lỗi khi gặp ký tự ngoài ASCII, và một header cố tình chế ra sẽ nhận 500 thay vì 401). Nếu secret chưa được đặt, chẳng hạn trên một máy staging mới dựng, endpoint sẽ từ chối tất cả chứ không phải chấp nhận tất cả. Còn một handler ném ra ngoại lệ thì được ghi log và vẫn được trả lời 200: Tigris gửi lại bất cứ thứ gì không phải 2xx, lần gửi lại sẽ vấp đúng bug đó, và đến lúc ấy sự kiện đã được claim rồi (đánh dấu là đã nhận, xem hai mục bên dưới) nên lần gửi lại cũng chẳng làm được gì. Chính dòng log mới là thứ giúp bug được sửa. Cách làm này giả định rằng lỗi là tất định (deterministic), tức là lần nào chạy cũng hỏng y như nhau. Một lần timeout tạm thời phía storage sẽ tiêu thụ mất sự kiện đó vĩnh viễn: với ảnh thì không sao, vì bước lưu có thể tự xử lý, nhưng cảnh báo từ một file log thì sẽ mất luôn. Nếu điều đó quan trọng với bạn, hãy giải phóng claim trong khối <code>except</code> và trả về 500 để Tigris gửi lại, đồng thời chấp nhận rằng một bug thật khi ấy sẽ bị gửi lại cho tới khi được sửa.</p>

<h2 class="relative group">Định tuyến theo prefix
    <div id="định-tuyến-theo-prefix" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%8bnh-tuy%e1%ba%bfn-theo-prefix" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ trong bucket vốn đã được tổ chức theo prefix, nên định tuyến theo prefix là cách tự nhiên nhất. Mỗi mục đích sử dụng là một hàm và một dòng trong bảng:</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">HANDLERS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;uploads/raw/&#34;</span><span class="p">,</span>   <span class="n">on_raw_photo</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;logs/api/&#34;</span><span class="p">,</span>      <span class="n">on_api_log_file</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="s2">&#34;media/&#34;</span><span class="p">,</span>         <span class="n">on_media_change</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></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">handle</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">prefix</span><span class="p">,</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">HANDLERS</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">prefix</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">claim</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>  <span class="c1"># xem bên dưới</span>
</span></span><span class="line"><span class="cl">                <span class="n">fn</span><span class="p">(</span><span class="n">event</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span></span></span></code></pre></div></div>
<p>Sau này muốn thêm việc thứ tư thì chỉ cần một hàm mới và một dòng mới. Tôi thích cách này hơn mình tưởng.</p>

<h2 class="relative group">&ldquo;Ít nhất một lần&rdquo; nghĩa là &ldquo;có thể hai lần&rdquo;
    <div id="ít-nhất-một-lần-nghĩa-là-có-thể-hai-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="#%c3%adt-nh%e1%ba%a5t-m%e1%bb%99t-l%e1%ba%a7n-ngh%c4%a9a-l%c3%a0-c%c3%b3-th%e1%bb%83-hai-l%e1%ba%a7n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tigris sẽ gửi lại nếu endpoint của bạn phản hồi chậm hoặc trả lỗi, và giống hầu hết các bên gửi webhook khác, nó không hứa rằng bạn chỉ thấy mỗi sự kiện đúng một lần. Vì vậy, trước khi handler chạy, sự kiện được claim theo bộ ba <code>(key, eTag, eventName)</code>. eTag ở đây quan trọng: nếu một file bị ghi đè, đó là phiên bản mới với eTag mới và cần được xử lý lại. Còn nếu chỉ là cùng phiên bản đó được gửi lặp lại thì không cần xử lý nữa.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.core.cache</span> <span class="kn">import</span> <span class="n">cache</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">claim</span><span class="p">(</span><span class="n">event</span><span class="p">,</span> <span class="n">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="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="n">k</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;bucket-event:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">name</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="si">}</span><span class="s2">:</span><span class="si">{</span><span class="n">event</span><span class="o">.</span><span class="n">etag</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">cache</span><span class="o">.</span><span class="n">add</span><span class="p">(</span><span class="n">k</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="n">ttl</span><span class="p">)</span>  <span class="c1"># False nếu đã có bên khác claim trước</span></span></span></code></pre></div></div>
<p><code>cache.add</code> chỉ ghi key khi key đó chưa tồn tại, và trên Redis thao tác này là nguyên tử (atomic), nên hai máy cùng nhận một lần gửi lại không thể cùng thắng. Trong hệ thống thật, tôi dùng một cơ chế lease lưu trong database cho việc này, vì muốn nó sống sót qua cả một lần flush Redis, nhưng ý tưởng thì y hệt.</p>

<h2 class="relative group">Upload ảnh thẳng lên S3 mà pipeline vẫn nguyên vẹn
    <div id="upload-ảnh-thẳng-lên-s3-mà-pipeline-vẫn-nguyên-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="#upload-%e1%ba%a3nh-th%e1%ba%b3ng-l%c3%aan-s3-m%c3%a0-pipeline-v%e1%ba%abn-nguy%c3%aan-v%e1%ba%b9n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là phần khiến toàn bộ công sức làm đường ống (plumbing) ở trên trở nên xứng đáng.</p>
<p>Trước đây, chọn ảnh cho một tin đăng nghĩa là ứng dụng giữ ảnh lại trên máy, và khi bạn bấm Đăng thì nó gửi tất cả lên API dưới dạng base64. API resize ảnh, tạo thumbnail, tính màu chủ đạo và blurhash, xong xuôi mới phản hồi. Mười tấm ảnh trên một đường mạng chậm là một vòng xoay loading rất dài. Và một request chứa mười tấm ảnh base64 là một body to và chậm mà API server phải giữ trong bộ nhớ, đó cũng là lý do thầm lặng khiến tôi cứ phải giữ ảnh ở kích thước nhỏ.</p>
<p>Giờ luồng này gồm bốn bước, và backend được báo về từng bước một.</p>

<h3 class="relative group">1. Client thu nhỏ ảnh và xin một suất upload
    <div id="1-client-thu-nhỏ-ảnh-và-xin-một-suất-upload" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#1-client-thu-nh%e1%bb%8f-%e1%ba%a3nh-v%c3%a0-xin-m%e1%bb%99t-su%e1%ba%a5t-upload" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Ứng dụng vẫn resize ngay trên thiết bị, về đúng cạnh dài nhất mà server sẽ giữ (1080px), dưới dạng JPEG với chất lượng 0.7. Lần resize đó chỉ để tiết kiệm băng thông cho người dùng, không vì gì khác. Rồi nó xin API một suất upload (upload slot):</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">uuid</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">s3</span> <span class="o">=</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span><span class="s2">&#34;s3&#34;</span><span class="p">,</span> <span class="n">endpoint_url</span><span class="o">=</span><span class="s2">&#34;https://fly.storage.tigris.dev&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET</span> <span class="o">=</span> <span class="s2">&#34;example-bucket&#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="nf">create_upload_slot</span><span class="p">(</span><span class="n">user</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">public_id</span> <span class="o">=</span> <span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;uploads/raw/</span><span class="si">{</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.jpg&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">public_id</span><span class="o">=</span><span class="n">public_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">raw_key</span><span class="o">=</span><span class="n">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">expires_at</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">+</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">2</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="n">url</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;put_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">BUCKET</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">key</span><span class="p">,</span> <span class="s2">&#34;ContentType&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;upload_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;url&#34;</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;method&#34;</span><span class="p">:</span> <span class="s2">&#34;PUT&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;headers&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;Content-Type&#34;</span><span class="p">:</span> <span class="s2">&#34;image/jpeg&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;expires_in&#34;</span><span class="p">:</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">60</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span></span></span></code></pre></div></div>
<p>Suất upload là một dòng trong database trước khi trở thành một file. Chính điều đó cho server biết ai đã xin nó, khi nào nó hết hạn, và sau này là nó có từng được dùng hay không.</p>

<h3 class="relative group">2. Client PUT thẳng lên bucket
    <div id="2-client-put-thẳng-lên-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="#2-client-put-th%e1%ba%b3ng-l%c3%aan-bucket" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">slot</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">api</span><span class="p">.</span><span class="nx">post</span><span class="p">(</span><span class="s2">&#34;/photo-uploads/&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">slot</span><span class="p">.</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">method</span>: <span class="kt">slot.method</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">headers</span>: <span class="kt">slot.headers</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">body</span>: <span class="kt">blob</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">res</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="s2">&#34;upload refused&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="c1">// giữ lại slot.upload_id cho bước lưu
</span></span></span></code></pre></div></div>
<p>API không bao giờ nhìn thấy dữ liệu file. Từ trình duyệt, từ React Native hay từ app native đều y như nhau; khác biệt duy nhất là trình duyệt cần một rule CORS trên bucket, sẽ nói ở phần các cái bẫy bên dưới.</p>

<h3 class="relative group">3. Tigris báo cho backend, và pipeline chạy
    <div id="3-tigris-báo-cho-backend-và-pipeline-chạ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="#3-tigris-b%c3%a1o-cho-backend-v%c3%a0-pipeline-ch%e1%ba%a1y" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trong vòng vài giây, Tigris POST một sự kiện <code>OBJECT_CREATED_PUT</code> cho <code>uploads/raw/…</code>, và handler chạy đúng pipeline xử lý ảnh mà đường base64 trước giờ vẫn dùng, trong lúc người dùng còn đang gõ tiêu đề:</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">io</span> <span class="kn">import</span> <span class="n">BytesIO</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">PIL</span> <span class="kn">import</span> <span class="n">Image</span><span class="p">,</span> <span class="n">ImageOps</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.core.files.base</span> <span class="kn">import</span> <span class="n">ContentFile</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">blurhash</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">MAX_BYTES</span> <span class="o">=</span> <span class="mi">15</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</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">on_raw_photo</span><span class="p">(</span><span class="n">event</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">event</span><span class="o">.</span><span class="n">name</span><span class="o">.</span><span class="n">startswith</span><span class="p">(</span><span class="s2">&#34;OBJECT_CREATED&#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="k">if</span> <span class="n">event</span><span class="o">.</span><span class="n">size</span> <span class="o">&gt;</span> <span class="n">MAX_BYTES</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</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">upload</span> <span class="o">=</span> <span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">raw_key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">,</span> <span class="n">processed_at</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">upload</span> <span class="ow">is</span> <span class="kc">None</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">data</span> <span class="o">=</span> <span class="n">s3</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">bucket</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">event</span><span class="o">.</span><span class="n">key</span><span class="p">)[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</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">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="n">longest</span><span class="p">,</span> <span class="n">quality</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">copy</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="n">longest</span><span class="p">,</span> <span class="n">longest</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="n">buf</span> <span class="o">=</span> <span class="n">BytesIO</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">im</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="n">buf</span><span class="p">,</span> <span class="s2">&#34;WEBP&#34;</span><span class="p">,</span> <span class="n">quality</span><span class="o">=</span><span class="n">quality</span><span class="p">,</span> <span class="n">icc_profile</span><span class="o">=</span><span class="n">im</span><span class="o">.</span><span class="n">info</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;icc_profile&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">ContentFile</span><span class="p">(</span><span class="n">buf</span><span class="o">.</span><span class="n">getvalue</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">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">Image</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">BytesIO</span><span class="p">(</span><span class="n">data</span><span class="p">))</span> <span class="k">as</span> <span class="n">im</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span> <span class="o">=</span> <span class="n">ImageOps</span><span class="o">.</span><span class="n">exif_transpose</span><span class="p">(</span><span class="n">im</span><span class="p">)</span><span class="o">.</span><span class="n">convert</span><span class="p">(</span><span class="s2">&#34;RGB&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">im</span><span class="o">.</span><span class="n">thumbnail</span><span class="p">((</span><span class="mi">1080</span><span class="p">,</span> <span class="mi">1080</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">width</span><span class="p">,</span> <span class="n">upload</span><span class="o">.</span><span class="n">height</span> <span class="o">=</span> <span class="n">im</span><span class="o">.</span><span class="n">size</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">dominant_color</span> <span class="o">=</span> <span class="s2">&#34;#</span><span class="si">%02x%02x%02x</span><span class="s2">&#34;</span> <span class="o">%</span> <span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">1</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span><span class="o">.</span><span class="n">getpixel</span><span class="p">((</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">image</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">1080</span><span class="p">,</span> <span class="mi">80</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">thumbnail</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="si">}</span><span class="s2">_t.webp&#34;</span><span class="p">,</span> <span class="n">to_webp</span><span class="p">(</span><span class="n">im</span><span class="p">,</span> <span class="mi">320</span><span class="p">,</span> <span class="mi">70</span><span class="p">),</span> <span class="n">save</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">upload</span><span class="o">.</span><span class="n">blurhash</span> <span class="o">=</span> <span class="n">blurhash</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="n">im</span><span class="o">.</span><span class="n">resize</span><span class="p">((</span><span class="mi">32</span><span class="p">,</span> <span class="mi">32</span><span class="p">)),</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">upload</span><span class="o">.</span><span class="n">save</span><span class="p">()</span></span></span></code></pre></div></div>
<p>Dòng đầu tiên của <code>on_raw_photo</code> quan trọng hơn vẻ ngoài của nó. Router gửi mọi sự kiện dưới <code>uploads/raw/</code> tới đây, kể cả các sự kiện xóa do job dọn upload mồ côi và do chính phép kiểm tra kích thước ngay bên dưới tạo ra, mà <code>get_object</code> trên một key đã bị xóa thì chỉ ném lỗi chứ chẳng làm gì khác.</p>
<p>Vậy nên việc client thu nhỏ ảnh chỉ là để nhẹ đường truyền, không hơn. Server vẫn là bên quyết định định dạng (WebP), các kích thước, thumbnail, ảnh placeholder, và chiều rộng chiều cao được lưu lại. Việc encode lại qua Pillow cũng đồng thời bỏ đi khối EXIF, nơi camera điện thoại cất vị trí GPS, nên phần tước bỏ thông tin riêng tư có được miễn phí. Profile ICC lẽ ra cũng sẽ mất theo cùng cách đó, khiến ảnh chụp từ điện thoại có dải màu rộng (wide-gamut) bị lệch màu đôi chút, nên <code>to_webp</code> giữ nó lại và truyền tiếp. Nếu bạn muốn giữ ảnh gốc nguyên từng pixel thì đừng thu nhỏ ở client; luồng vẫn y nguyên, chỉ là các lệnh PUT nặng hơn, và PUT nặng hơn chính xác là thứ mà thiết kế này làm cho rẻ. Một điểm cần lưu ý: ảnh gốc từ iPhone là HEIC, mà Pillow chỉ mở được khi đã cài <code>pillow-heif</code>.</p>

<h3 class="relative group">4. Bước lưu gắn ảnh vào và không bao giờ chờ webhook
    <div id="4-bước-lưu-gắn-ảnh-vào-và-không-bao-giờ-chờ-webhook" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#4-b%c6%b0%e1%bb%9bc-l%c6%b0u-g%e1%ba%afn-%e1%ba%a3nh-v%c3%a0o-v%c3%a0-kh%c3%b4ng-bao-gi%e1%bb%9d-ch%e1%bb%9d-webhook" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Khi người dùng bấm Đăng, request mang theo <code>upload_ids</code> thay vì dữ liệu ảnh. Server tra từng id một, với ba kết quả có thể xảy ra:</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">take</span><span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">upload_ids</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">uploads</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">PhotoUpload</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">user</span><span class="o">=</span><span class="n">user</span><span class="p">,</span> <span class="n">public_id__in</span><span class="o">=</span><span class="n">upload_ids</span><span class="p">,</span> <span class="n">expires_at__gt</span><span class="o">=</span><span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">uploads</span><span class="p">)</span> <span class="o">!=</span> <span class="nb">len</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)):</span>     <span class="c1"># đã hết hạn, hoặc không phải của người dùng này</span>
</span></span><span class="line"><span class="cl">        <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload_ids</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">upload</span> <span class="ow">in</span> <span class="n">uploads</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="n">upload</span><span class="o">.</span><span class="n">processed_at</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>          <span class="c1"># webhook chưa tới</span>
</span></span><span class="line"><span class="cl">            <span class="n">data</span> <span class="o">=</span> <span class="n">read_raw</span><span class="p">(</span><span class="n">upload</span><span class="p">)</span>              <span class="c1"># None nếu lệnh PUT chưa từng tới nơi</span>
</span></span><span class="line"><span class="cl">            <span class="k">if</span> <span class="n">data</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">                <span class="k">raise</span> <span class="n">UploadNotReadyError</span><span class="p">(</span><span class="n">upload</span><span class="o">.</span><span class="n">public_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="n">process</span><span class="p">(</span><span class="n">upload</span><span class="p">,</span> <span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">uploads</span></span></span></code></pre></div></div>
<p>Nếu thông báo đã chạy rồi, các file đã xử lý được gắn vào nguyên như vậy. Nếu file đã nằm trong bucket nhưng thông báo chưa tới, bước lưu xử lý ngay tại chỗ, nên một tin đăng không bao giờ phải chờ webhook. Thông báo là một lợi thế xuất phát sớm, không phải một phụ thuộc bắt buộc. Còn nếu file hoàn toàn chưa có, hoặc một id đã hết hạn hay thuộc về người khác, bước lưu trả về lỗi validation và ứng dụng sẽ thử lại sau một chút; ứng dụng vốn chờ các lệnh PUT của chính nó xong rồi mới gửi, nên trên thực tế chuyện này hiếm.</p>
<p>Vài chi tiết giúp cách này đứng vững. Suất upload thuộc về một người dùng, nên server chỉ chấp nhận những upload id do chính người đó tạo; nếu không, bất kỳ ai nhìn thấy một id đều có thể gắn ảnh của bạn vào tin đăng của họ. Mọi thứ đều có đường lui: nếu xin suất upload thất bại, lệnh PUT bị từ chối, hoặc API đang ở phiên bản cũ, ứng dụng gửi base64 y như trước. Nó chỉ đi đường upload khi mọi ảnh mới đều có suất, nên ảnh của một tin đăng hoặc toàn bộ là upload hoặc toàn bộ là base64, và thứ tự của chúng vẫn là một danh sách duy nhất. Và upload mồ côi sẽ tự hết hạn: có người chọn ảnh xong rồi bỏ ngang, nên một upload không bao giờ được gắn vào đâu sẽ hết hạn sau hai ngày, và job dọn media mồ côi sẵn có sẽ xóa các file của nó.</p>

<h2 class="relative group">Những cái bẫy khi upload bằng presigned URL: CORS, giới hạn kích thước, thời hạn, nhiều file
    <div id="những-cái-bẫy-khi-upload-bằng-presigned-url-cors-giới-hạn-kích-thước-thời-hạn-nhiều-file" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1i-b%e1%ba%aby-khi-upload-b%e1%ba%b1ng-presigned-url-cors-gi%e1%bb%9bi-h%e1%ba%a1n-k%c3%adch-th%c6%b0%e1%bb%9bc-th%e1%bb%9di-h%e1%ba%a1n-nhi%e1%bb%81u-file" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần lớn những gì trục trặc với upload bằng presigned URL đều trục trặc trước khi thông báo kịp bắn ra. Đây là những thứ tôi muốn gom lại trên một trang.</p>

<h3 class="relative group">CORS, chỉ với trình duyệt
    <div id="cors-chỉ-với-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="#cors-ch%e1%bb%89-v%e1%bb%9bi-tr%c3%acnh-duy%e1%bb%87t" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>App native không quan tâm chuyện này. Trình duyệt thì gửi một request preflight <code>OPTIONS</code> tới bucket trước khi PUT, và upload sẽ thất bại với lỗi CORS trừ khi các rule CORS của bucket cho phép <code>PUT</code> từ origin của bạn kèm header <code>Content-Type</code>. Tigris nhận cấu hình CORS chuẩn của S3, nên rule trông giống hệt trên AWS. Đó là thứ đầu tiên cần kiểm tra khi một lệnh PUT bằng presigned URL chạy được từ <code>curl</code> mà không chạy được từ trang web.</p>
<p><figure><span class="mc-img"><img
    class="my-0 rounded-md"
    loading="lazy"
    decoding="async"
    fetchpriority="auto"
    alt="Cài đặt bucket trên Tigris, tab CORS and headers: một rule cho phép PUT từ hai origin kèm header Content-Type"
    width="1204"
    height="671"
    src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp"
    srcset="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_7f61261034a8a56.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp 1280w"
    sizes="(min-width: 768px) 50vw, 65vw"
    data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/tigris-bucket-cors_hu_3aa47360232f6475.webp"></span><figcaption>Một rule CORS là tất cả những gì một lượt upload từ trình duyệt cần: các origin của bạn, PUT, và header Content-Type mà chữ ký mong đợi.</figcaption></figure>
</p>

<h3 class="relative group">Content type là một phần của chữ ký
    <div id="content-type-là-một-phần-của-chữ-ký" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#content-type-l%c3%a0-m%e1%bb%99t-ph%e1%ba%a7n-c%e1%bb%a7a-ch%e1%bb%af-k%c3%bd" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Ký kèm <code>ContentType</code> nghĩa là client phải gửi đúng header đó, nếu không bucket trả về 403 <code>SignatureDoesNotMatch</code>. Đó là lý do suất upload trả về cả headers cùng với URL. Hãy ký như vậy một cách có chủ đích, nhưng cần biết rõ nó đem lại gì và không đem lại gì: header thì bị ép buộc, còn nội dung thì không, nên một file PDF gắn nhãn <code>image/jpeg</code> vẫn lọt qua. Pipeline mới là phép kiểm tra thật; Pillow từ chối mở thứ gì không phải ảnh, và dòng đó không bao giờ được đánh dấu là đã xử lý.</p>

<h3 class="relative group">Giới hạn kích thước
    <div id="giới-hạn-kích-thướ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="#gi%e1%bb%9bi-h%e1%ba%a1n-k%c3%adch-th%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Một presigned PUT URL không mang theo giới hạn kích thước nào. Nếu bạn cần một chốt chặn cứng trước khi dữ liệu kịp tới nơi, presigned POST với điều kiện policy <code>content-length-range</code> làm được việc đó. Tôi không bận tâm, vì thông báo đã mang theo <code>size</code> của object: handler xóa bất cứ thứ gì vượt giới hạn trước khi đọc nó, còn đường xử lý lúc lưu thì chỉ đọc tối đa bằng giới hạn cộng thêm một byte, đủ để nhận ra file quá lớn. Ứng dụng gửi vài trăm kilobyte; thứ gì tiến gần tới mười lăm megabyte thì chắc chắn không phải do ứng dụng gửi lên.</p>

<h3 class="relative group">Thời hạn hiệu lực
    <div id="thời-hạn-hiệu-lự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="#th%e1%bb%9di-h%e1%ba%a1n-hi%e1%bb%87u-l%e1%bb%b1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Có hai mốc hết hạn khác nhau. Presigned URL có hiệu lực mười lăm phút, thừa đủ cho một lệnh PUT và đủ ngắn để một URL bị lộ sớm trở nên vô dụng. Dòng suất upload có hiệu lực hai ngày, đủ cho trường hợp ai đó chọn ảnh, đặt điện thoại xuống, rồi hôm sau mới hoàn thành tin đăng. Client coi một upload đang dang dở là cũ sau một ngày và bắt đầu một upload mới thay vì ngồi hy vọng.</p>

<h3 class="relative group">Nhiều file, và file lớn hơn
    <div id="nhiều-file-và-file-lớn-hơn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nhi%e1%bb%81u-file-v%c3%a0-file-l%e1%bb%9bn-h%c6%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mỗi file một suất upload. Mười tấm ảnh là mười suất và mười lệnh PUT song song, mỗi lệnh có thanh tiến trình (progress) riêng (<code>XMLHttpRequest</code> cho bạn <code>upload.onprogress</code>; <code>fetch</code> thì không), điều bạn không thể có với một lệnh POST base64 khổng lồ. Quá vài megabyte thì dùng multipart (mỗi phần tối thiểu 5 MiB, nên cách này dành cho những file lớn hơn mức đó khá nhiều): tạo multipart upload trên server, ký sẵn từng <code>upload_part</code>, rồi hoàn tất khi các phần đã tới đủ. Tigris gửi <code>OBJECT_CREATED_MULTIPART</code> khi upload được hoàn tất (complete), nên cùng handler đó được kích hoạt và không có gì khác phải thay đổi.</p>

<h3 class="relative group">Không theo thứ tự
    <div id="không-theo-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="#kh%c3%b4ng-theo-th%e1%bb%a9-t%e1%bb%b1" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Sự kiện có thể đến không theo thứ tự, vì object có thể đã được ghi ở nhiều hơn một region. Với upload thì không sao: một key, một sự kiện. Với một key bị ghi đè, hãy tin vào eTag và <code>Last-Modified</code> của object, chứ không phải thứ tự các webhook tới chỗ bạn.</p>

<h2 class="relative group">Cùng tín hiệu đó cho log: cảnh báo lên Discord
    <div id="cùng-tín-hiệu-đó-cho-log-cảnh-báo-lên-discord" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b9ng-t%c3%adn-hi%e1%bb%87u-%c4%91%c3%b3-cho-log-c%e1%ba%a3nh-b%c3%a1o-l%c3%aan-discord" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một khi bucket đã nói chuyện được với backend, bất cứ thứ gì rơi vào bucket đều có thể được phản ứng theo cùng một cách, và thứ thứ hai tôi trỏ nó vào là log.</p>
<p>Log của ứng dụng được đẩy vào bucket dưới dạng các file gzip nhỏ, mỗi ứng dụng khoảng năm phút một file. Suốt gần cả năm, một GitHub Action chạy cron 15 phút một lần dùng DuckDB đọc 20 phút gần nhất trong kho lưu trữ đó, lọc ra các dòng request chậm, rồi gửi bản tổng hợp lên Discord. Trên giấy thì ổn. Thực tế, các workflow chạy theo lịch của GitHub không chạy đúng giờ khi nền tảng đang bận, nên &ldquo;15 phút một lần&rdquo; thành ra &ldquo;vài tiếng một lần&rdquo;, và đến khi chạy được thì khoảng thời gian cần xử lý đã dồn lại lớn hơn nhiều, thỉnh thoảng còn hết bộ nhớ. Phần cửa sổ chồng lấn mà tôi thêm vào để không bỏ sót gì giữa hai lần chạy lại khiến cùng một request chậm đôi khi hiện lên hai lần. Cả một bộ máy cồng kềnh chỉ để làm một việc: &ldquo;báo tôi biết khi có request chậm&rdquo;.</p>
<p>Giờ mỗi file log được đọc đúng một lần, ngay khi nó vừa tới. Mỗi file chỉ vài kilobyte sau khi gzip. Handler lọc ra các dòng request chậm, và nếu có đủ nhiều, hoặc có một request chậm hẳn, nó gửi bản tổng hợp vào một kênh Discord thông qua webhook. Tương tự với một loạt phản hồi 5xx dồn dập trong cùng một file. Gọi Discord webhook từ Python chỉ là một lệ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="kn">import</span> <span class="nn">requests</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.conf</span> <span class="kn">import</span> <span class="n">settings</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">discord_alert</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</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="n">requests</span><span class="o">.</span><span class="n">post</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">settings</span><span class="o">.</span><span class="n">DISCORD_WEBHOOK_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">json</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">[:</span><span class="mi">1900</span><span class="p">]},</span>  <span class="c1"># Discord giới hạn một tin nhắn ở 2000 ký tự</span>
</span></span><span class="line"><span class="cl">        <span class="n">timeout</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>Không còn cron, không còn cửa sổ chồng lấn, không còn báo trùng, và cảnh báo đến trong vòng vài phút sau request thay vì đợi đến khi GitHub rảnh tay. Cái Action đó đã bị xóa.</p>
<p>Nhân tiện đã mở từng file ra, tôi đếm luôn những gì có trong đó (lỗi, cảnh báo, health check) rồi cộng vào một bảng thống kê theo giờ. Trước đây, bản tóm tắt hằng ngày phải quét cả một ngày log trong kho lưu trữ. Giờ nó chỉ đọc vài trăm dòng, và chỉ quay lại quét kho lưu trữ khi bảng thống kê bị hở ở đâu đó.</p>

<h2 class="relative group">Một chốt chặn cho lệnh xóa
    <div id="một-chốt-chặn-cho-lệnh-xó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="#m%e1%bb%99t-ch%e1%bb%91t-ch%e1%ba%b7n-cho-l%e1%bb%87nh-x%c3%b3a" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cái này tôi không hề lên kế hoạch. Nó tự nảy ra khi đã có sự kiện <code>OBJECT_DELETED</code>. Khi một file media bị xóa, handler kiểm tra xem còn dòng nào trong database trỏ tới file đó không. Nếu còn, nghĩa là có bug ở đâu đó (hoặc có ai đó lỡ tay trong console của bucket), nên handler xóa giá trị ở cột đó (ứng dụng sẽ hiển thị ảnh placeholder thay vì ảnh lỗi) rồi báo cáo lại.</p>
<p>Nó cũng đếm số lượt xóa theo giờ. Dọn dẹp bình thường chỉ xóa vài file. Nếu có lúc nó thấy vài trăm lượt xóa trong một giờ, tôi muốn được biết ngay khi chuyện đang xảy ra, chứ không phải đợi đến lúc người dùng bắt đầu báo ảnh trắng trơn.</p>

<h2 class="relative group">Vài điều nên biết
    <div id="vài-điều-nên-biế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="#v%c3%a0i-%c4%91i%e1%bb%81u-n%c3%aan-bi%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phản hồi thật nhanh. Tigris cho mỗi lần gửi mười giây. Đọc một file log 5 KB hay xử lý một tấm ảnh điện thoại thì vừa; thứ gì có thể lâu hơn thì đẩy vào hàng đợi (queue) và để view trả 200 ngay, nếu không Tigris sẽ timeout rồi gửi lại, và bạn lại quay về chuyện khử trùng lặp.</p>
<p>Lọc ngay tại bucket. Bộ đẩy log của tôi ghi một file vài phút một lần cho mỗi ứng dụng, ngày này qua ngày khác. Nếu bạn chỉ quan tâm một prefix, hãy nói rõ trong bộ lọc của thông báo để ứng dụng của bạn không bị đánh thức vì mọi thứ khác.</p>
<p>Kiểm thử bằng body giả. Payload nhỏ và ổn định, nên các bài kiểm thử chỉ cần POST JSON vào view kèm header <code>Authorization</code> đúng, rồi assert xem handler đã làm gì. Không cần đụng đến storage thật.</p>
<p>Đừng để nó gánh trọng trách ngay từ ngày đầu. Mỗi cách dùng ở đây đều có đường lui: bước lưu có thể tự xử lý ảnh, bản tóm tắt hằng ngày vẫn có thể quét kho lưu trữ, ứng dụng vẫn có thể gửi base64. Thông báo giúp từng việc nhanh hơn và rẻ hơn, nhưng không việc nào sụp đổ nếu lỡ mất một webhook.</p>
<p>Khoảnh khắc đã nhất trong tuần là lúc xóa file workflow đó. Bốn mươi dòng YAML, một script Python, hai repository secret và câu hỏi lặp đi lặp lại &ldquo;sao cảnh báo không bắn ra&rdquo; đều biến mất, thay vào đó là một handler đọc một file nhỏ ngay khi nó vừa tới. Nhưng chuyện upload mới là mục đích chính. Ảnh đi lên nhanh hết mức đường mạng cho phép, server vẫn là bên quyết định cuối cùng với từng tấm một, và không ai phải xây cầu nối giữa bucket và backend, vì giờ bucket tự kể lại chuyện gì vừa xảy ra.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/tigris-object-notifications/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>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>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>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>
    <item>
      <title>Django trên Fly.io: kiểm thử tải và tự động co giãn với tài nguyên tối thiểu</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>Tôi chạy hai ứng dụng Django trên máy shared-cpu-1x của Fly.io: tối ưu một máy cho nhanh, kiểm thử tải để tìm điểm độ trễ tăng vọt, rồi đặt ngưỡng tự động co giãn ngay trước đó.</description>
      <content:encoded><![CDATA[<p>Để chạy Django trên Fly.io với chi phí thấp, tôi tối ưu một máy shared-cpu-1x nhỏ cho thật nhanh, kiểm thử tải (load testing) đến khi độ trễ tăng vọt, rồi cấu hình bộ tự động co giãn (autoscaling) của Fly khởi động máy thứ hai ngay trước điểm đó. Phần kiểm thử tải Django tôi dùng Locust với tải tăng theo từng bậc (còn ứng dụng kia dùng một script Python nhỏ quét qua các mức đồng thời), và theo dõi độ trễ p95 cùng tỉ lệ lỗi. Khi lưu lượng giảm, Fly tự dừng các máy rảnh, nên chi phí nền chỉ là một VM nhỏ cho mỗi ứng dụng.</p>
<p>Hiện tại, với vai trò là một lập trình viên độc lập (solo developer), tôi đang vận hành đồng thời hai sản phẩm:</p>
<ul>
<li>một <strong>ứng dụng ước tính và báo giá tại hiện trường (field estimation)</strong> dành cho các kỹ thuật viên/thợ dịch vụ, giúp họ tính chi phí và báo giá công việc ngay tại chỗ;</li>
<li>và một <strong>ứng dụng chợ mua bán kiêm cộng đồng (marketplace)</strong>, với các tính năng như đăng tin và nhắn tin thời gian thực.</li>
</ul>
<p>Hai sản phẩm khác nhau, nhóm người dùng cũng khác nhau, nhưng bộ công nghệ (tech stack) gần như giống hệt nhau — và cả hai đều đang chạy hoàn toàn trên các <strong>máy shared-cpu-1x</strong>, gói rẻ nhất mà Fly.io cung cấp.</p>
<p>Đây là một giới hạn được tôi chủ động lựa chọn.</p>
<p>Thay vì chọn cấu hình máy chủ theo mức tải đỉnh (peak load), tôi cố gắng khai thác tối đa hiệu năng của một máy nhỏ, đo chính xác ngưỡng mà hệ thống bắt đầu gặp điểm nghẽn (bottleneck) hoặc sụp đổ khi tải cao, rồi để bộ tự động co giãn (autoscaler) của Fly.io tự mở rộng hoặc thu hẹp theo chiều ngang (scale out / scale in), thêm hoặc bớt máy dựa trên chính ngưỡng đã đo đó.</p>
<p>Bài viết này sẽ đi vào các kỹ thuật tối ưu hóa giúp mô hình đó khả thi, các số liệu kiểm thử tải (load testing) dùng để xác định ngưỡng mở rộng (scaling threshold), và cách việc tăng, giảm số máy thực tế diễn ra trên Fly.io.</p>

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

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

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

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

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

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

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

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

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

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

<h3 class="relative group">WebSocket co giãn theo một trục khác
    <div id="websocket-co-giãn-theo-một-trục-khác" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#websocket-co-gi%c3%a3n-theo-m%e1%bb%99t-tr%e1%bb%a5c-kh%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chợ mua bán tách máy chủ WebSocket ra thành một <strong>ứng dụng Fly thứ hai</strong>, dùng chung Docker image. Ứng dụng API chạy một worker × 16 luồng chặn dưới WSGI và đếm theo <em>yêu cầu</em> (giới hạn mềm 12). Ứng dụng WebSocket thì chạy 1 worker dưới ASGI với uvloop, trên một máy <strong>512MB</strong>, và đếm theo <em>kết nối</em>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>Giữ một socket sống lâu gần như không tốn gì, nhưng nó chiếm một suất hàng giờ liền, trong khi một yêu cầu HTTP chỉ chiếm suất vài mili giây, nên không thể dùng chung một tín hiệu co giãn cho cả hai. Tách riêng ra thì mỗi nhóm máy co giãn theo đúng thước đo phản ánh tải thật của nó, và năng lực WebSocket lúc rảnh chỉ tốn 512MB chứ không phải 2GB. Về phía Channels, lớp Redis chạy với <code>capacity: 1500</code>, thông điệp hết hạn sau 60 giây, cộng thêm cơ chế duy trì kết nối cho socket mỗi 30 giây, vì proxy Redis của Fly sẽ tự đóng các socket BRPOP sống lâu mà Channels cần tới khi chúng không hoạt động.</p>

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

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

<h2 class="relative group">Những gì bạn nên &ldquo;học lỏm&rdquo;
    <div id="những-gì-bạn-nên-học-lỏm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-g%c3%ac-b%e1%ba%a1n-n%c3%aan-h%e1%bb%8dc-l%e1%bb%8fm" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Điều đáng mang về không nằm ở một thư viện cụ thể nào, mà ở cả vòng lặp. Tối ưu cho một máy rẻ tiền chạy thật nhanh (dùng Rust ở đường xử lý nóng, tái sử dụng kết nối, đặt bộ nhớ đệm chắn trước điểm nghẽn thật, đẩy được gì sang CDN và máy khách thì đẩy). Kiểm thử tải cho đến khi tìm ra chính xác mức yêu cầu đồng thời mà máy gãy. Đặt giới hạn mềm của bộ tự động co giãn ngay tại điểm gãy đó, để các máy rảnh tự dừng, và giữ tối thiểu một máy luôn chạy. Hai ứng dụng đang chạy production, có tính năng thời gian thực, có trợ lý AI, mà hóa đơn hằng tháng lúc bình thường chỉ là vài máy ảo rẻ nhất trên nền tảng.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
