<?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>S3 · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/s3/</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/s3/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>Tự host cập nhật OTA cho Expo, không cần EAS: dựng máy chủ bằng Django và Tigris</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Django</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <category>Self-Hosted</category>
      <category>Tigris</category>
      <category>S3</category>
      <description>Cập nhật OTA của Expo hoạt động ra sao, và cách tôi tự host nó mà không cần EAS Update: một máy chủ Django REST nói đúng giao thức Expo Updates, gói mã nằm trên Tigris S3, chi phí vận hành chỉ khoảng một đô mỗi tháng.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>Cập nhật, ngày 13 tháng 9 năm 2026.</strong> Sau khi bài này lên, Codemagic đã ra mắt <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>, một máy chủ OTA <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">mã nguồn mở</a>, tự vận hành được (self-hosted), theo mô hình CodePush. Nếu bạn tìm tới đây vì không muốn tự viết máy chủ, thì giờ đã có một lựa chọn thật sự: tôi đã <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >chuyển một ứng dụng từ hệ thống bên dưới sang Patch</a> và viết lại quá trình đó, ngoài ra còn có một ghi chú ngắn ở phần <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/#t%e1%bb%95ng-k%e1%ba%bft" >Tổng kết</a>. Minh bạch: Codemagic đã trả tiền để tôi thực hiện đợt di chuyển đó và viết về nó, và họ đề nghị nhắc tới ở đây. Phần mã trong bài sau đó đã được rút gọn thành các ví dụ ngắn.</p>
</blockquote><p>Với ứng dụng báo giá mà tôi đang làm, các bản sửa lỗi được đẩy thẳng qua mạng (over the air): phần mã JavaScript mới về tận điện thoại người dùng mà không phải chờ App Store duyệt. Thật ra EAS Update, dịch vụ do chính Expo vận hành, sẵn sàng lo việc này giúp bạn. Nhưng tôi vẫn tự làm một bản riêng bằng Django REST Framework và Tigris S3, và từ đó tới giờ, mọi bản cập nhật cho cả iOS lẫn Android đều đi qua hệ thống này.</p>
<p>Cơ chế cập nhật OTA của Expo như sau: thư viện <code>expo-updates</code> trong ứng dụng gửi yêu cầu lấy manifest tới một máy chủ cập nhật, kèm theo nền tảng (platform) và phiên bản runtime (runtime version) trong header. Máy chủ trả về bản cập nhật mới nhất còn tương thích, cùng URL của gói mã (bundle) JavaScript và các tài nguyên đi kèm; ứng dụng tải chúng về và chạy gói mã mới ở lần khởi động kế tiếp. EAS Update là máy chủ do Expo vận hành cho giao thức đó, nhưng thực ra máy chủ nào nói đúng giao thức cũng dùng được, và tôi đã tự làm một cái như thế.</p>
<p>Trong bài này, tôi sẽ đi qua toàn bộ hệ thống: các mô hình dữ liệu (model), endpoint trả về manifest, quy trình phát hành (publish), và cả những cái bẫy mà phải tự vận hành thì bạn mới gặp.</p>

<h2 class="relative group">Vì sao lại tự vận hành?
    <div id="vì-sao-lại-tự-vận-hành" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-l%e1%ba%a1i-t%e1%bb%b1-v%e1%ba%adn-h%c3%a0nh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với tôi thì lý do chính là tiền. EAS Update tính phí theo mức sử dụng, nên khi bạn đẩy bản cập nhật thường xuyên cho một lượng người dùng ngày càng đông, chi phí sẽ cộng dồn lên. Trong khi đó, dịch vụ lưu trữ tương thích S3 như Tigris thì gần như miễn phí. Cũng có vài lý do khác, biết đâu còn quan trọng hơn với bạn. Một số ngành bắt buộc mọi tài nguyên (asset) của ứng dụng phải nằm trong hạ tầng của chính công ty. Tự nắm máy chủ cũng có nghĩa là tự nắm luồng cập nhật: nếu sản phẩm cần, bạn có thể phát hành dần cho từng nhóm người dùng, hay thử nghiệm A/B với các gói mã (bundle) khác nhau. Và quy trình cập nhật của bạn không còn phụ thuộc vào chuyện dịch vụ của Expo có ổn định hay không, hay lần tới họ sẽ đổi bảng giá ra sao.</p>

<h2 class="relative group">Các thành phần kết nối với nhau thế nào
    <div id="các-thành-phần-kết-nối-với-nhau-thế-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-th%c3%a0nh-ph%e1%ba%a7n-k%e1%ba%bft-n%e1%bb%91i-v%e1%bb%9bi-nhau-th%e1%ba%bf-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hệ thống gồm bốn thành phần: một máy chủ Django trả về manifest của bản cập nhật và lưu siêu dữ liệu (metadata); Tigris chứa các tệp gói mã và tài nguyên thật; một script phát hành lo phần kết xuất (export), tải lên và đăng ký bản cập nhật; và cuối cùng là chính ứng dụng di động, được cấu hình trỏ về máy chủ của tôi thay vì máy chủ của Expo.</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">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Mobile App    │
</span></span><span class="line"><span class="cl">│  (expo-updates) │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 1. Request manifest
</span></span><span class="line"><span class="cl">         │    (with headers: platform, runtime-version)
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│  Django Server  │
</span></span><span class="line"><span class="cl">│  /api/expo-     │◄─── 2. Query DB for latest update
</span></span><span class="line"><span class="cl">│   updates/      │
</span></span><span class="line"><span class="cl">│   manifest/     │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 3. Generate presigned URLs
</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></span><span class="line"><span class="cl">│   Tigris S3     │
</span></span><span class="line"><span class="cl">│  (Asset Files)  │◄─── 4. App downloads bundles directly
</span></span><span class="line"><span class="cl">└─────────────────┘</span></span></code></pre></div></div>

<h2 class="relative group">Bắt tay vào làm
    <div id="bắt-tay-vào-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="#b%e1%ba%aft-tay-v%c3%a0o-l%c3%a0m" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hai mô hình dữ liệu
    <div id="hai-mô-hình-dữ-liệu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hai-m%c3%b4-h%c3%acnh-d%e1%bb%af-li%e1%bb%87u" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mọi thứ đều xoay quanh hai mô hình <code>ExpoUpdate</code> và <code>ExpoUpdateAsset</code>. Mô hình thứ nhất lưu siêu dữ liệu của từng bản cập nhật:</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">ExpoUpdate</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</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">runtime_version</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">choices</span><span class="o">=</span><span class="p">[(</span><span class="s2">&#34;ios&#34;</span><span class="p">,</span> <span class="s2">&#34;iOS&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;android&#34;</span><span class="p">,</span> <span class="s2">&#34;Android&#34;</span><span class="p">)],</span>
</span></span><span class="line"><span class="cl">        <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">is_active</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">JSONField</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">created_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>Trong mô hình này có vài chỗ tôi cố ý thiết kế như vậy. <code>runtime_version</code> tương ứng với <code>runtimeVersion</code> trong <code>app.json</code>, và trường này gánh khá nhiều việc: máy khách chỉ tải những bản cập nhật có cùng phiên bản runtime (runtime version) với mình. iOS và Android được lưu thành hai bản ghi riêng, vì gói mã của hai nền tảng khác nhau. <code>is_active</code> chính là cơ chế khôi phục về phiên bản trước (rollback): chỉ cần tắt bản cập nhật bị lỗi là máy khách quay về bản trước đó. Còn <code>manifest_data</code> lưu nguyên manifest theo giao thức Expo Updates v1 dưới dạng JSON, nên về sau muốn trả manifest ra thì chỉ cần tra một lần là xong.</p>
<p><code>ExpoUpdateAsset</code> thì theo dõi từng tệp riêng 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">ExpoUpdateAsset</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</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">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="s2">&#34;assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">hash</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">content_type</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_extension</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_path</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_size</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Tài nguyên được định danh bằng mã băm (hash) SHA-256, nên nội dung của chúng không bao giờ đổi và có thể lưu đệm (cache) thoải mái. Một tài nguyên dùng chung cho bao nhiêu bản cập nhật cũng được.</p>

<h3 class="relative group">Endpoint manifest
    <div id="endpoint-manifest" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#endpoint-manifest" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p><code>/api/expo-updates/manifest/</code> là nơi ứng dụng và máy chủ thật sự trao đổi với nhau. Endpoint này được xây dựng theo <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">giao thức Expo Updates v1</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;get&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;manifest&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manifest</span><span class="p">(</span><span class="bp">self</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"># Extract required headers</span>
</span></span><span class="line"><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PROTOCOL_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PLATFORM&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_RUNTIME_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Validate protocol version</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">protocol_version</span> <span class="o">!=</span> <span class="s2">&#34;1&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Unsupported protocol version: </span><span class="si">{</span><span class="n">protocol_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="n">status</span><span class="o">=</span><span class="mi">400</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="c1"># Find latest active update for this runtime + platform</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</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">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">order_by</span><span class="p">(</span><span class="s2">&#34;-created_at&#34;</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></span><span class="line"><span class="cl">    <span class="c1"># No update available - client uses embedded bundle</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">update</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</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;expo-protocol-version&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate presigned URLs for all assets</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">build_manifest</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># fills in presigned asset URLs</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">manifest_data</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Ở đây có ba chi tiết cần để ý. Thứ nhất, phản hồi (response) 204 nghĩa là “không có bản cập nhật nào”, và ứng dụng cứ thế chạy tiếp với gói mã nhúng sẵn. Thứ hai, URL của tài nguyên trong manifest là URL ký sẵn (presigned URL), nên ứng dụng tải thẳng từ CDN của Tigris chứ không phải truyền mọi thứ qua Django. Thứ ba, header <code>expo-protocol-version</code> trong phản hồi là bắt buộc, vì máy khách sẽ kiểm tra giá trị này.</p>

<h3 class="relative group">Phát hành bản cập nhật
    <div id="phát-hành-bản-cập-nhậ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="#ph%c3%a1t-h%c3%a0nh-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần phát hành là một lệnh quản trị (management command) của Django, bên ngoài bọc thêm một shell script. Lệnh này đọc đầu ra của <code>expo export</code>, tính mã băm SHA-256 cho từng tài nguyên, tải song song gói mã và tài nguyên lên Tigris, ghi bản ghi vào cơ sở dữ liệu, và nếu cần thì đặt thêm vào bucket một tệp JSON dùng để nhập (import) dữ liệu, phục vụ việc đồng bộ lên môi trường production. Rút gọn lại thì phần quan trọng trông như sau:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</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">django.db</span> <span class="kn">import</span> <span class="n">transaction</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">publish</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">runtime_version</span><span class="p">,</span> <span class="n">manifest</span><span class="p">,</span> <span class="n">files</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload the bundle and assets in parallel (keys are content hashes)</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">list</span><span class="p">(</span><span class="n">executor</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">files</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Swap the active update in one transaction</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpoUpdate</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">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ExpoUpdate</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">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">manifest_data</span><span class="o">=</span><span class="n">manifest</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span></span></span></code></pre></div></div>
<p>Còn thứ tôi thật sự gõ hằng ngày là script bọc ngoài:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Publish to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Dry run to validate</span>
</span></span><span class="line"><span class="cl">./publish.sh --dry-run</span></span></code></pre></div></div>
<p>Script này nạp các biến môi trường production trước khi kết xuất, chạy được cho một hoặc cả hai nền tảng, và lo luôn phần đồng bộ lên production mà tôi sẽ nói ngay bên dưới.</p>

<h3 class="relative group">Đồng bộ lên production
    <div id="đồng-bộ-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%93ng-b%e1%bb%99-l%c3%aan-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi không muốn để sẵn thông tin đăng nhập (credential) của production trên laptop, nên việc triển khai lên production được chia làm hai bước. Đầu tiên là phát hành từ môi trường cục bộ (local): tài nguyên được đẩy lên Tigris, kèm một bản chụp siêu dữ liệu dạng JSON. Sau đó gọi một endpoint API trên production, kèm theo đường dẫn S3, để máy chủ tự nhập siêu dữ liệu về:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;post&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;import-update&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">import_update</span><span class="p">(</span><span class="bp">self</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"># Authenticate via Bearer token</span>
</span></span><span class="line"><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_AUTHORIZATION&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;Bearer &#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">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">settings</span><span class="o">.</span><span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#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></span><span class="line"><span class="cl">    <span class="c1"># Download the import JSON from the bucket</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_key</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;s3_key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">=</span> <span class="n">s3_client</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">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</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">loads</span><span class="p">(</span><span class="n">obj</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></span><span class="line"><span class="cl">    <span class="c1"># Import into the production database</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">update</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;id&#34;</span><span class="p">],</span> <span class="n">defaults</span><span class="o">=</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">asset_data</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s2">&#34;assets&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ExpoUpdateAsset</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</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="c1"># Clean up the import JSON</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_client</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">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;update_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">update</span><span class="o">.</span><span class="n">id</span><span class="p">)})</span></span></span></code></pre></div></div>

<h3 class="relative group">Trỏ ứng dụng về máy chủ của bạn
    <div id="trỏ-ứng-dụng-về-máy-chủ-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="#tr%e1%bb%8f-%e1%bb%a9ng-d%e1%bb%a5ng-v%e1%bb%81-m%c3%a1y-ch%e1%bb%a7-c%e1%bb%a7a-b%e1%ba%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trong <code>app.json</code>, bạn khai báo URL cập nhật và phiên bản runtime:</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;expo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;runtimeVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;1.0.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updates&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://api.example.com/api/expo-updates/manifest/&#34;</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>Chỗ này phải thật chặt chẽ: <code>runtimeVersion</code> ở ứng dụng và ở máy chủ lúc nào cũng phải khớp nhau. Mỗi khi thay đổi phần mã native hoặc nâng cấp Expo SDK, hãy tăng phiên bản runtime rồi phát hành bản cập nhật mới cho phiên bản đó.</p>

<h2 class="relative group">Lưu trữ trên Tigris
    <div id="lưu-trữ-trên-tigris" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%c6%b0u-tr%e1%bb%af-tr%c3%aan-tigris" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tigris là dịch vụ lưu trữ đối tượng (object storage) tương thích S3, rẻ hơn AWS S3 khá nhiều, lại có sẵn bộ nhớ đệm tại biên (edge cache) trên toàn cầu. Điểm này rất đáng giá khi người dùng phải tải về những gói mã nặng vài MB. Cấu hình phía Django như sau:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># settings.py</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET_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;BUCKET_NAME&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ENDPOINT_URL_S3</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;AWS_ENDPOINT_URL_S3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ACCESS_KEY_ID</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;AWS_ACCESS_KEY_ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_SECRET_ACCESS_KEY</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;AWS_SECRET_ACCESS_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_REGION</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;AWS_REGION&#34;</span><span class="p">,</span> <span class="s2">&#34;auto&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Còn phần khởi tạo client S3 thì chỉ là boto3 như bình thườ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="kn">import</span> <span class="nn">boto3</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_s3_client</span><span class="p">(</span><span class="n">endpoint_url</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="n">access_key</span><span class="p">,</span> <span class="n">secret_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;s3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">endpoint_url</span><span class="o">=</span><span class="n">endpoint_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">region_name</span><span class="o">=</span><span class="n">region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_access_key_id</span><span class="o">=</span><span class="n">access_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_secret_access_key</span><span class="o">=</span><span class="n">secret_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>Mọi lượt tải đều đi qua URL ký sẵn, nên ứng dụng lấy tệp thẳng từ CDN:</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">presigned_url</span> <span class="o">=</span> <span class="n">s3_client</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;get_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_name</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">asset</span><span class="o">.</span><span class="n">file_path</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">3600</span><span class="p">,</span>  <span class="c1"># 1 hour</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">Bảo mật
    <div id="bảo-mậ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%e1%ba%a3o-m%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Endpoint manifest được cố ý để không cần xác thực, vì ứng dụng phải nhận được bản cập nhật ngay cả khi chưa có ai đăng nhập. Endpoint nhập dữ liệu thì khác hẳn. Endpoint này ghi được vào cơ sở dữ liệu production, nên bắt buộc phải có một khóa bí mật dùng chung (shared secret), và phép so sánh khóa phải chạy trong thời gian không đổi (constant-time) để chặn tấn công định thời (timing attack):</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">IMPORT_TOKEN</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;IMPORT_TOKEN&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Constant-time comparison prevents timing attacks</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#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></code></pre></div></div>
<p>Tính toàn vẹn của tài nguyên thì thiết kế đã lo sẵn: tệp nào cũng được đối chiếu với mã băm SHA-256 của nó, nên tài nguyên nào bị chỉnh sửa sẽ bị loại ngay. Ngoài ra, URL ký sẵn hết hạn sau một giờ, nên không ai dùng liên kết trực tiếp (hotlink) tới gói mã của bạn mãi được.</p>

<h2 class="relative group">Giữ cho hệ thống chạy nhanh
    <div id="giữ-cho-hệ-thống-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="#gi%e1%bb%af-cho-h%e1%bb%87-th%e1%bb%91ng-ch%e1%ba%a1y-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nhờ chỉ mục kết hợp (composite index) trên <code>(runtime_version, platform, is_active, -created_at)</code>, truy vấn lấy manifest vẫn nhanh dù bản cập nhật có tích lại nhiều đến đâ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="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>Script phát hành tải tài nguyên lên song song:</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">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">asset</span><span class="p">):</span> <span class="n">asset</span> <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">assets</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="n">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Track progress</span></span></span></code></pre></div></div>
<p>Với một bản cập nhật điển hình có khoảng 50 tài nguyên, thời gian phát hành giảm từ chừng 2 phút xuống còn 15 giây. Khâu phân phối thì không phải làm gì cả: Tigris tự lưu đệm tài nguyên ở các điểm biên (edge location) gần người dùng của bạn.</p>

<h2 class="relative group">Quy trình làm việc hằng ngày
    <div id="quy-trình-làm-việc-hằng-ngà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="#quy-tr%c3%acnh-l%c3%a0m-vi%e1%bb%87c-h%e1%ba%b1ng-ng%c3%a0y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Trong lúc phát triển
    <div id="trong-lúc-phát-triể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="#trong-l%c3%bac-ph%c3%a1t-tri%e1%bb%83n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<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="c1"># 1. Make code changes in the app</span>
</span></span><span class="line"><span class="cl">git commit -am <span class="s2">&#34;Fix bug&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Publish OTA update to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Test on device</span>
</span></span><span class="line"><span class="cl"><span class="c1"># App automatically downloads and applies update</span></span></span></code></pre></div></div>

<h3 class="relative group">Đưa lên production
    <div id="đưa-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%c6%b0a-l%c3%aan-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<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="c1"># 1. Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Monitor</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Check Django admin for update records</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Verify assets in Tigris dashboard</span></span></span></code></pre></div></div>

<h3 class="relative group">Khôi phục về phiên bản trước
    <div id="khôi-phục-về-phiên-bản-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="#kh%c3%b4i-ph%e1%bb%a5c-v%e1%bb%81-phi%c3%aan-b%e1%ba%a3n-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<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="c1"># Mark problematic update as inactive in Django admin</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or via management shell:</span>
</span></span><span class="line"><span class="cl">python manage.py shell
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; from updates.models import ExpoUpdate
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; <span class="nv">bad_update</span> <span class="o">=</span> ExpoUpdate.objects.get<span class="o">(</span><span class="nv">id</span><span class="o">=</span><span class="s2">&#34;uuid-here&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.is_active <span class="o">=</span> False
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.save<span class="o">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clients will now receive the previous active update</span></span></span></code></pre></div></div>

<h2 class="relative group">Chi phí thực tế
    <div id="chi-phí-thực-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="#chi-ph%c3%ad-th%e1%bb%b1c-t%e1%ba%bf" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng này hiện có khoảng 500 người dùng hoạt động. Trên Tigris, ~200 MB các bản cập nhật tích lũy từ trước tới giờ tốn chừng $0.02/tháng, còn ~50 GB lưu lượng tải ra (egress) mỗi tháng từ các lượt tải bản cập nhật thì khoảng $1.00. Phần Django chạy ké trên chính máy chủ đang chạy API của tôi, nên không phát sinh thêm đồng nào. Tính tròn thì tổng cộng là một đô mỗi tháng. Với mức sử dụng tương tự, EAS Update sẽ rơi vào khoảng $300–500/năm, nên có thể nói hệ thống này hoàn vốn gần như ngay lập tức.</p>

<h2 class="relative group">Giám sát và gỡ lỗi
    <div id="giám-sát-và-gỡ-lỗi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%c3%a1m-s%c3%a1t-v%c3%a0-g%e1%bb%a1-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần này chẳng có gì cầu kỳ. Viewset ghi nhật ký (log) mọi yêu cầu lấy manifest:</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">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Manifest request: platform=</span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2">, runtime=</span><span class="si">{</span><span class="n">runtime_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Trên thực tế, Django admin chính là bảng điều khiển. Chỉ cần đăng ký các mô hình là bạn xem và lọc được mọi thứ:</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="nd">@admin.register</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAdmin</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">ModelAdmin</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_display</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;created_at&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_filter</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">search_fields</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;description&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>Còn ở phía máy khách, <code>expo-updates</code> sẽ cho bạn biết nó đang thấy gì:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="o">*</span> <span class="kr">as</span> <span class="nx">Updates</span> <span class="kr">from</span> <span class="s1">&#39;expo-updates&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Updates</span><span class="p">.</span><span class="nx">checkForUpdateAsync</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">update</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Update available:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">isAvailable</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Manifest:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">manifest</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>

<h2 class="relative group">Những cái bẫy
    <div id="những-cái-bẫy" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-c%c3%a1i-b%e1%ba%aby" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Lệch phiên bản runtime
    <div id="lệch-phiên-bản-runtime" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%87ch-phi%c3%aan-b%e1%ba%a3n-runtime" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Lỗi hay gặp nhất cũng là lỗi khó phát hiện nhất: máy khách chỉ tải những bản cập nhật khớp với phiên bản runtime của mình. Nếu ứng dụng đã cài đang ở runtime <code>1.0.0</code> mà bạn lại phát hành cho <code>1.0.1</code>, thì sẽ chẳng có bản cập nhật nào về máy, mà cũng chẳng có lỗi nào báo ra. Hãy giữ phiên bản runtime đồng bộ với các bản dựng (build), và chỉ tăng nó khi phần mã native thay đổi.</p>

<h3 class="relative group">Dấu thời gian createdAt
    <div id="dấu-thời-gian-createdat" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%ba%a5u-th%e1%bb%9di-gian-createdat" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thư viện <code>expo-updates</code> phía máy khách so sánh <code>createdAt</code> trong manifest với <code>commitTime</code> của gói mã nhúng sẵn, và chỉ áp dụng bản cập nhật nào có <code>createdAt</code> mới hơn. Trong viewset, tôi ghi đè giá trị này bằng dấu thời gian (timestamp) lấy từ cơ sở dữ liệ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="n">manifest_data</span><span class="p">[</span><span class="s2">&#34;createdAt&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">update</span><span class="o">.</span><span class="n">created_at</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&#34;%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%S.</span><span class="si">%f</span><span class="s2">Z&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Khi phát triển trên máy cục bộ, nếu bạn dựng lại bản nhị phân của ứng dụng <em>sau khi</em> đã phát hành OTA, hãy phát hành lại bản OTA đó để dấu thời gian của nó mới hơn.</p>

<h3 class="relative group">Giá trị <code>key</code> của tài nguyên trong manifest
    <div id="giá-trị-key-của-tài-nguyên-trong-manifest" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1-tr%e1%bb%8b-key-c%e1%bb%a7a-t%c3%a0i-nguy%c3%aan-trong-manifest" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trường <code>key</code> trên mỗi tài nguyên trong manifest là thứ <code>expo-updates</code> dùng để lưu đệm, và giá trị này phải là một mã băm tất định (MD5 của tên tệp là được), chứ không phải UUID ngẫu nhiên hay một chuỗi tùy ý. Làm sai chỗ này thì máy khách có thể không lưu đệm hoặc không lấy lại được tài nguyên đúng cách, và bản cập nhật sẽ âm thầm hỏng sau lần tải thành công đầu tiên. Cảm ơn bạn đọc Raphael Mutschler đã chỉ ra lỗi này: bản cập nhật bên anh ấy chỉ chạy được đúng một lần, rồi anh mới tìm ra nguyên nhân.</p>

<h3 class="relative group">Cấu hình ứng dụng trong <code>expoClient</code>
    <div id="cấu-hình-ứng-dụng-trong-expoclient" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%e1%ba%a5u-h%c3%acnh-%e1%bb%a9ng-d%e1%bb%a5ng-trong-expoclient" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Nếu ứng dụng của bạn dùng <code>Linking</code>, <code>Constants</code>, hay bất cứ thứ gì đọc cấu hình ứng dụng (app config) lúc chạy, thì trường <code>extra.expoClient</code> trong manifest phải chứa cấu hình đó. Thiếu trường này, lúc đầu ứng dụng có thể vẫn mở lên bình thường, nhưng sau khi bị đóng thì bị sập (crash) hoặc không chịu mở lại nữa. Lý do là <code>expo-updates</code> thay manifest nhúng sẵn bằng manifest OTA, và nếu thiếu <code>expoClient</code> thì các API kia mất luôn phần cấu hình mà chúng dựa vào. Lỗi này cũng do Raphael Mutschler phát hiện.</p>

<h3 class="relative group">Dọn dẹp tài nguyên
    <div id="dọn-dẹp-tài-nguyê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="#d%e1%bb%8dn-d%e1%ba%b9p-t%c3%a0i-nguy%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Các bản cập nhật cũ cứ thế chất đống trên Tigris. Hiện tại tôi vẫn dọn bằng tay:</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"># Delete updates older than 30 days</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">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">cutoff</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">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">old_updates</span> <span class="o">=</span> <span class="n">ExpoUpdate</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">created_at__lt</span><span class="o">=</span><span class="n">cutoff</span><span class="p">,</span> <span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">old_updates</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete assets from S3</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">update</span><span class="o">.</span><span class="n">assets</span><span class="o">.</span><span class="n">all</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3_client</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">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete DB records</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span><span class="o">.</span><span class="n">delete</span><span class="p">()</span></span></span></code></pre></div></div>
<p>Rõ ràng đoạn này nên được đưa vào một tác vụ chạy định kỳ (scheduled task). Chỉ là tôi chưa có thời gian làm.</p>

<h2 class="relative group">Những thứ tôi muốn làm tiếp
    <div id="những-thứ-tôi-muốn-làm-tiế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="#nh%e1%bb%afng-th%e1%bb%a9-t%c3%b4i-mu%e1%bb%91n-l%c3%a0m-ti%e1%ba%bfp" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><em>(Tháng 9 năm 2026: Patch giờ đã có sẵn cả ba thứ này và hơn thế nữa; xem <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >bài viết về đợt di chuyển</a>.)</em></p>

<h3 class="relative group">Phát hành theo từng đợt
    <div id="phát-hành-theo-từng-đợ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="#ph%c3%a1t-h%c3%a0nh-theo-t%e1%bb%abng-%c4%91%e1%bb%a3t" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chỉ cần thêm một trường <code>rollout_percentage</code> là có thể phát hành bản cập nhật cho một phần người dùng trước:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">rollout_percentage</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the manifest view:</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Hash user ID and check if they&#39;re in rollout group</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_hash</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(),</span> <span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">user_hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>  <span class="c1"># No update</span></span></span></code></pre></div></div>

<h3 class="relative group">Tách riêng bản cập nhật cho staging
    <div id="tách-riêng-bản-cập-nhật-cho-staging" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1ch-ri%c3%aang-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt-cho-staging" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thêm một trường <code>environment</code> thì các bản dựng cho môi trường staging có thể nhận bản cập nhật khác với production:</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">environment</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Client sends environment in custom header</span>
</span></span><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_X_UPDATE_ENVIRONMENT&#34;</span><span class="p">,</span> <span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</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">environment</span><span class="o">=</span><span class="n">environment</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span></span></span></code></pre></div></div>

<h3 class="relative group">Thống kê lượt tải
    <div id="thống-kê-lượt-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%91ng-k%c3%aa-l%c6%b0%e1%bb%a3t-t%e1%ba%a3i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Và một mô hình nhỏ nữa sẽ trả lời đàng hoàng câu hỏi “rốt cuộc đã có ai nhận được bản này chư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="k">class</span> <span class="nc">ExpoUpdateDownload</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">null</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloaded_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">Tổng kết
    <div id="tổng-kế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="#t%e1%bb%95ng-k%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ hệ thống chỉ gồm hai mô hình và vài view của Django, một script phát hành, và một đô mỗi tháng tiền hạ tầng. Ít hơn nhiều so với tôi nghĩ lúc bắt đầu, và từ đó tới nay nó vẫn lặng lẽ làm tốt việc của mình cho ứng dụng trên cả hai nền tảng.</p>
<p>Phần mã ở trên là các ví dụ đã được rút gọn từ chính ứng dụng đang chạy production đó, nên bạn cứ điều chỉnh cho hợp với dự án của mình. Còn nếu muốn bắt đầu từ một thứ đã chạy được sẵn, bạn đọc Raphael Mutschler cũng đã công bố một phiên bản độc lập của riêng anh ấy tại <a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>.</p>

<h3 class="relative group">Nếu bạn không muốn tự viết máy chủ
    <div id="nếu-bạn-không-muốn-tự-viết-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-b%e1%ba%a1n-kh%c3%b4ng-mu%e1%bb%91n-t%e1%bb%b1-vi%e1%ba%bft-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Từ lúc tôi viết bài này, đã có hai thứ xuất hiện làm được cùng việc mà không cần tới Django. Thứ nhất là <code>expo-ota-server</code> của Raphael ở trên. Thứ hai là <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Codemagic Patch</a>: <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">mã nguồn mở</a>, chạy trên máy của chính bạn qua một bộ cài Docker Compose, và nói giao thức CodePush thay vì Expo Updates, nên bạn thay <code>expo-updates</code> bằng SDK của nó và thay <code>eas update</code> bằng <code>cmpatch release-react</code>. Nó dùng được kho lưu trữ tương thích S3, nên một bucket Tigris như trong bài này cũng chạy được.</p>
<p>Ngay từ đầu, nó đã có sẵn những thứ tôi liệt kê ở mục &ldquo;Những thứ tôi muốn làm tiếp&rdquo; mà chưa bao giờ xây: môi trường triển khai staging và production riêng, phát hành theo tỷ lệ phần trăm, số lượt tải và cài của từng bản, cập nhật bắt buộc, bản vá nhị phân, ký mã (code signing), và khôi phục (rollback) ngay trên thiết bị khi gói mã mới bị sập trước khi ứng dụng kịp báo sẵn sàng. Cái giá phải trả là một bản nhị phân native mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng, và một máy chủ phải vận hành thay cho một lệnh quản trị.</p>
<p>Tôi đã chuyển một ứng dụng của chính mình, với dự án native đã commit và quy trình phát hành thật, từ hệ thống trong bài này sang Patch. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >Bài viết đó</a> kể lại những gì chạy tốt, những gì không, và thật sự thì khó tới mức nào. Codemagic tài trợ cho phần việc đó và có một lượt đọc duyệt về tính chính xác của sự kiện, chứ không phải duyệt biên tập.</p>
<hr>
<p><em>Muốn đọc toàn bộ đặc tả, bạn tham khảo <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">tài liệu đặc tả giao thức Expo Updates</a>.</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-22-self-hosted-ota-updates/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
