<?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>Debugging · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/debugging/</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/debugging/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Lỗi &#34;Test timeout of 30000ms exceeded.&#34; trong Playwright: tìm bước chờ không bao giờ kết thúc</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-playwright-timeout-30000ms/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-playwright-timeout-30000ms/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Playwright</category>
      <category>UI Testing</category>
      <category>Testing Best Practices</category>
      <category>Debugging</category>
      <description>Thường là một bước chờ mãi một phần tử hay URL không bao giờ tới, vì thao tác Playwright không có timeout riêng. Cách tìm bước đó, và cách đặt actionTimeout.</description>
      <content:encoded><![CDATA[<p><code>Test timeout of 30000ms exceeded.</code> nghĩa là một bài kiểm thử Playwright đã chạy quá giới hạn 30 giây dành cho nó. Ba mươi giây là giá trị mặc định của Playwright Test, và khoảng thời gian này tính gộp cả phần thân bài kiểm thử, các hook <code>beforeEach</code> lẫn bước chuẩn bị fixture. Thông báo chỉ nêu tên bài kiểm thử chứ không nêu bước nào bị kẹt, vì theo mặc định, các thao tác như bấm, nhập liệu hay chuyển trang không có giới hạn thời gian (timeout) riêng. Chúng cứ chờ cho tới khi xong thì thôi, và timeout của bài kiểm thử đơn giản là chiếc đồng hồ hết giờ đầu tiên.</p>
<p>Vì vậy, lỗi này hiếm khi thật sự là chuyện chậm. Thường thì có một bước đang chờ một điều sẽ không bao giờ xảy ra. Dưới đây là các nguyên nhân theo thứ tự tôi hay kiểm tra, kèm cách sửa cho từng cái:</p>
<ol>
<li>Locator (bộ chọn phần tử) không khớp với phần tử nào, nên lệnh <code>click()</code> hay <code>fill()</code> cứ chờ cho tới khi bài kiểm thử hết giờ. Lý do quen thuộc là test ID bị đổi tên, chữ hiển thị bị đổi, hoặc có hộp thoại che phía trước. Cách sửa: sửa lại locator.</li>
<li>Một lần chuyển trang hay lệnh <code>waitForURL()</code> đang chờ một trang không bao giờ tới, vì một lần chuyển hướng (redirect), màn hình đăng nhập hoặc màn hình chạy lần đầu đã đưa ứng dụng sang chỗ khác. Cách sửa: sửa lại điều kiện bạn đang chờ.</li>
<li>Bước chuẩn bị chậm thật: máy chủ phát triển (dev server) chỉ bắt đầu biên dịch khi có yêu cầu đầu tiên, backend phải khởi động nguội (cold start) sau khi bị thu về 0 (scale to zero), hoặc đăng nhập qua giao diện trong mọi <code>beforeEach</code>. Cách sửa: sửa môi trường, hoặc chỉ chuẩn bị một lần.</li>
<li>Bài kiểm thử vốn dĩ dài. Khi đó, và chỉ khi đó, mới cho riêng bài ấy thêm thời gian bằng <code>test.slow()</code> hoặc <code>test.setTimeout()</code>.</li>
</ol>
<p>Dù là trường hợp nào, việc đầu tiên vẫn là tìm ra bước còn đang chạy vào lúc hết giờ. Việc thứ hai là đặt <code>actionTimeout</code>, để ở lần thất bại sau, chính thông báo lỗi sẽ chỉ ra bước đó cho bạn.</p>
<p>Chỉ cần thêm hai dòng vào <code>use</code>:</p>
<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">import</span> <span class="p">{</span> <span class="nx">defineConfig</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;@playwright/test&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="nx">defineConfig</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">timeout</span>: <span class="kt">30_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expect</span><span class="o">:</span> <span class="p">{</span> <span class="nx">timeout</span>: <span class="kt">5_000</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">use</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">actionTimeout</span>: <span class="kt">10_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">navigationTimeout</span>: <span class="kt">15_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">trace</span><span class="o">:</span> <span class="s2">&#34;retain-on-failure&#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></code></pre></div></div>

<h2 class="relative group">30 giây ấy bao gồm những gì
    <div id="30-giây-ấy-bao-gồm-những-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#30-gi%c3%a2y-%e1%ba%a5y-bao-g%e1%bb%93m-nh%e1%bb%afng-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Timeout của bài kiểm thử là một quỹ thời gian duy nhất, dùng chung cho bước chuẩn bị fixture, các hook <code>beforeEach</code> và hàm kiểm thử. Khi bài kiểm thử kết thúc, các hook <code>afterEach</code> và bước dọn dẹp fixture (teardown) được cấp thêm một quỹ thời gian riêng có cùng độ dài. <code>beforeAll</code> và <code>afterAll</code> cũng nhận cùng giá trị đó, nhưng không dùng chung với bài kiểm thử nào, và khi hết giờ thì báo lỗi bằng thông điệp riêng.</p>
<p>Trong quỹ thời gian đó, chỉ có các câu lệnh kiểm tra (assertion) là có đồng hồ riêng. <code>await expect(locator).toBeVisible()</code> mặc định bỏ cuộc sau 5 giây và cho bạn biết chính xác nó đang tìm gì. Các thao tác và lệnh chuyển trang thì không: <code>actionTimeout</code> và <code>navigationTimeout</code> mặc định là không giới hạn. Đó là toàn bộ lý do khiến lỗi này mơ hồ đến vậy. Một lệnh bấm vào phần tử sẽ không bao giờ xuất hiện thì không hề thất bại. Nó chỉ ngoan ngoãn chờ, cho tới khi có thứ khác kết thúc bài kiểm thử.</p>
<p>Bảng dưới đây liệt kê mọi loại timeout có thể kết thúc một lần chạy, cùng thông báo mà mỗi loại in ra. Các thông báo được chép nguyên văn từ Playwright 1.58 và 1.63, phiên bản cũ nhất và mới nhất trong các dự án của tôi.</p>
<table>
	<thead>
			<tr>
					<th>Loại timeout</th>
					<th>Mặc định</th>
					<th>Đặt ở đâu</th>
					<th>Thông báo in ra</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Bài kiểm thử (thân bài, <code>beforeEach</code>, chuẩn bị fixture)</td>
					<td>30,000 ms</td>
					<td><code>timeout</code> trong tệp cấu hình, <code>--timeout</code>, <code>test.setTimeout()</code>, <code>test.slow()</code></td>
					<td><code>Test timeout of 30000ms exceeded.</code></td>
			</tr>
			<tr>
					<td><code>beforeAll</code> / <code>afterAll</code></td>
					<td>bằng timeout của bài kiểm thử</td>
					<td><code>test.setTimeout()</code> bên trong hook</td>
					<td><code>&quot;beforeAll&quot; hook timeout of 30000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Assertion (<code>expect</code>)</td>
					<td>5,000 ms</td>
					<td><code>expect: { timeout }</code> trong tệp cấu hình, <code>{ timeout }</code> trên từng assertion</td>
					<td><code>expect(locator).toBeVisible() failed</code> … <code>Timeout: 5000ms</code></td>
			</tr>
			<tr>
					<td>Thao tác (<code>click</code>, <code>fill</code>)</td>
					<td>không có</td>
					<td><code>use: { actionTimeout }</code>, <code>{ timeout }</code> trên từng thao tác</td>
					<td><code>TimeoutError: locator.click: Timeout 10000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Chuyển trang (<code>goto</code>, <code>waitForURL</code>)</td>
					<td>không có</td>
					<td><code>use: { navigationTimeout }</code>, <code>{ timeout }</code> trên từng lệnh</td>
					<td><code>TimeoutError: page.waitForURL: Timeout 15000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Toàn bộ lần chạy</td>
					<td>không có</td>
					<td><code>globalTimeout</code> trong tệp cấu hình</td>
					<td><code>Timed out waiting 3600s for the test suite to run</code></td>
			</tr>
			<tr>
					<td>Khởi động <code>webServer</code></td>
					<td>60,000 ms</td>
					<td><code>webServer: { timeout }</code></td>
					<td><code>Timed out waiting 60000ms from config.webServer.</code></td>
			</tr>
	</tbody>
</table>
<p>Ba hàng thao tác, chuyển trang và toàn bộ lần chạy không có giá trị mặc định để in ra, nên con số ở đó là giá trị do bạn tự đặt.</p>

<h2 class="relative group">Năm bài kiểm thử và một test ID bị đổi tên
    <div id="năm-bài-kiểm-thử-và-một-test-id-bị-đổi-tê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="#n%c4%83m-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad-v%c3%a0-m%e1%bb%99t-test-id-b%e1%bb%8b-%c4%91%e1%bb%95i-t%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hồi tháng 2, trong một lần chạy, năm bài kiểm thử trong bộ kiểm thử của ứng dụng báo giá mà tôi đang làm đã thất bại theo cùng một kiểu. Bộ kiểm thử này dùng Playwright để điều khiển bản web của ứng dụng (do máy chủ phát triển của ứng dụng phục vụ), chạy với một worker, gọi tới một backend cục bộ vừa được nạp lại dữ liệu mẫu (seed). Đây là lỗi đầu tiên trong năm lỗi, đã lược bớt:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">  1) [default] › tests/example.spec.ts:50:7 › Templates › should edit a template ───
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    Test timeout of 30000ms exceeded.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    Error: locator.click: Target page, context or browser has been closed
</span></span><span class="line"><span class="cl">    Call log:
</span></span><span class="line"><span class="cl">      - waiting for locator(&#39;[data-testid=&#34;template-item&#34;]&#39;).filter({ hasText: &#39;E2E Edit Template&#39; }).locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      57 |       .locator(&#39;[data-testid=&#34;template-item&#34;]&#39;)
</span></span><span class="line"><span class="cl">      58 |       .filter({ hasText: &#34;E2E Edit Template&#34; });
</span></span><span class="line"><span class="cl">    &gt; 59 |     await item.locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;).click();
</span></span><span class="line"><span class="cl">         |                                                         ^
</span></span><span class="line"><span class="cl">      60 |     await page.waitForTimeout(500);</span></span></code></pre></div></div>
<p>Chỉ nhìn dòng <code>Target page, context or browser has been closed</code> thì tưởng trình duyệt bị sập. Không phải vậy. Đó là thông báo mà một thao tác đưa ra khi trang bị đóng ngay dưới chân nó: 30 giây đã hết, Playwright dọn dẹp bài kiểm thử, và lệnh bấm vẫn đang chờ kia báo lại rằng trang của nó đã mất. Nếu dùng fixture <code>page</code> có sẵn, dòng đó sẽ nhắc lại chuyện hết giờ, thành <code>Error: locator.click: Test timeout of 30000ms exceeded.</code> Các bài kiểm thử này tự mở ngữ cảnh trình duyệt (browser context) trong <code>beforeAll</code>, nên lệnh bấm chỉ nhận được thông báo chung chung.</p>
<p>Dòng có ích là nhật ký lệnh gọi (call log). Lệnh bấm đang chờ <code>[data-testid=&quot;template-item&quot;]</code>. Mười tiếng trước đó, tôi đã đổi test ID của component dòng danh sách từ <code>template-item</code> thành <code>template-item-${name}</code>, để các bài kiểm thử đầu cuối (end-to-end) trên bản native của ứng dụng có thể chọn đúng một dòng theo tên. Bộ chọn (selector) thuộc tính dùng <code>=</code> đòi hỏi khớp toàn bộ giá trị, nên lúc này các bài kiểm thử web đang tìm một phần tử không hề tồn tại ở đâu trên trang. Lại không có timeout cho thao tác, nên lệnh bấm đó chắc chắn sẽ chờ cho tới khi bài kiểm thử hết giờ. Năm bài kiểm thử, mỗi bài 30 giây, là hai phút rưỡi, trong một lần chạy tổng cộng mất 3.8 phút.</p>
<p>Thứ tôi thêm vào đầu tiên là chạy lại (retry). Chưa tới nửa tiếng sau, tệp cấu hình đã có <code>retries: process.env.CI ? 2 : 1</code>, kèm một dòng chú thích mà tới giờ vẫn ghi &ldquo;for flaky browser crashes&rdquo;, tức là để phòng những lần trình duyệt thỉnh thoảng bị sập. Ở lần chạy kế tiếp, bốn trong năm bài kiểm thử cũ lại thất bại, kể cả ở lần chạy lại, và lần chạy kéo dài 6.4 phút thay vì 3.8. Các khối describe này chạy ở chế độ tuần tự (serial): một bài thất bại thì các bài còn lại trong nhóm bị bỏ qua, còn khi chạy lại thì cả nhóm chạy lại từ đầu. Vì thế mỗi lệnh bấm hỏng đã chờ trọn 30 giây tới hai lần. Một bài kiểm thử lần nào cũng thất bại theo cùng một cách thì chạy lại không cứu được. Nó chỉ bắt bạn trả giá hai lần.</p>
<p>Cách sửa là khớp theo tiền tố:</p>
<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">item</span> <span class="o">=</span> <span class="nx">page</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid^=&#34;template-item-&#34;]&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">filter</span><span class="p">({</span> <span class="nx">hasText</span><span class="o">:</span> <span class="s2">&#34;E2E Edit Template&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">item</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;edit-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span></span></span></code></pre></div></div>
<p>Bài học rộng hơn là test ID cũng là một dạng hợp đồng, và tôi có tới hai bộ kiểm thử cùng dựa vào ID này mà không bên nào biết bên kia tồn tại. Hướng dẫn thực hành tốt (best practices) của Playwright khuyên ưu tiên các locator dựa trên thuộc tính người dùng nhìn thấy được, như <code>getByRole()</code>, thay vì bộ chọn CSS như của tôi, để bài kiểm thử không vỡ vì những thay đổi DOM mà người dùng chẳng bao giờ nhận ra. Còn nếu bạn mới bắt đầu thiết lập Playwright, lý do tôi khuyên nên bắt đầu từ so sánh ảnh chụp màn hình nằm trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2024/2024-06-09-playwright-tests/" >Kiểm thử hồi quy giao diện (visual regression testing) với Playwright cho đội nhỏ</a>.</p>

<h2 class="relative group">Tìm bước bị treo
    <div id="tìm-bước-bị-treo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%acm-b%c6%b0%e1%bb%9bc-b%e1%bb%8b-treo" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hãy đọc call log trước tiên. Nó thuộc về bước vẫn còn đang chạy lúc hết giờ, và nếu sau dòng <code>waiting for locator(...)</code> không có gì nữa, nghĩa là locator chưa bao giờ khớp được với phần tử nào.</p>
<p>Nếu vẫn chưa đủ, hãy mở bản ghi thực thi (trace). Hai bộ kiểm thử ứng dụng của tôi, một cho ứng dụng báo giá và một cho ứng dụng chợ kiêm cộng đồng mà tôi đang làm, đều dùng <code>trace: &quot;on-first-retry&quot;</code>. Tùy chọn này chỉ ghi lại ở lần chạy lại đầu tiên, nên cần đặt <code>retries</code> ít nhất là 1. Nếu không dùng chạy lại thì dùng <code>&quot;retain-on-failure&quot;</code>; còn muốn chạy thử một lần thì thêm <code>--trace on</code>. Sau đó mở trace trực tiếp, hoặc mở từ báo cáo HTML:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">npx playwright show-trace test-results/&lt;test-folder&gt;/trace.zip
</span></span><span class="line"><span class="cl">npx playwright show-report</span></span></code></pre></div></div>
<p>Thao tác chưa bao giờ hoàn tất là mục mang thông báo lỗi, nằm gần cuối danh sách. Chọn nó rồi xem ảnh chụp nhanh (snapshot): đó là trang đúng như lúc Playwright đang chờ, và bạn có thể soi trong đó để tìm phần tử mình mong đợi. Với trường hợp như của tôi, dòng danh sách vẫn nằm ngay đó, chỉ là mang một ID mà bài kiểm thử không hề tìm.</p>
<p>Các phiên bản gần đây còn ghi thêm một tệp <code>error-context.md</code> bên cạnh ảnh chụp màn hình lúc thất bại, chứa ảnh chụp nhanh dạng văn bản của trang vào đúng lúc lỗi xảy ra. Với trường hợp thiếu phần tử, chỉ riêng tệp này nhiều khi là đủ.</p>
<p>Có một cái bẫy: <code>--debug</code> vừa mở Inspector vừa đặt <code>--timeout=0</code>, cùng với <code>--max-failures=1</code>, <code>--headed</code> và <code>--workers=1</code>. Một bài kiểm thử chạy qua được dưới <code>--debug</code> chẳng nói lên điều gì về 30 giây kia.</p>

<h2 class="relative group">Đặt trần thời gian cho mọi thao tác
    <div id="đặt-trần-thời-gian-cho-mọi-thao-tá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="#%c4%91%e1%ba%b7t-tr%e1%ba%a7n-th%e1%bb%9di-gian-cho-m%e1%bb%8di-thao-t%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Không tệp cấu hình nào của tôi đặt timeout cho thao tác, và đó là lý do một phần tử không tồn tại có thể tiêu tốn 30 giây, tới năm lần. Với đoạn cấu hình ở đầu bài, cùng cái locator hỏng ấy sẽ thất bại như thế này, trong lúc bài kiểm thử vẫn còn thời gian:</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">    TimeoutError: locator.click: Timeout 10000ms exceeded.
</span></span><span class="line"><span class="cl">    Call log:
</span></span><span class="line"><span class="cl">      - waiting for locator(&#39;[data-testid=&#34;template-item&#34;]&#39;).filter({ hasText: &#39;E2E Edit Template&#39; }).locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;)</span></span></code></pre></div></div>
<p>Thông báo nêu rõ bước nào, thao tác nào và đã chờ bao lâu. Tôi nghĩ nên để cả hai giá trị thấp hơn hẳn timeout của bài kiểm thử, để một bước bị kẹt không thể ngốn hết quỹ thời gian rồi nấp sau thông báo mơ hồ kia. Hồi tháng 2, vấn đề chưa bao giờ nằm ở con số 30 giây. Đó chỉ là chiếc đồng hồ duy nhất gắn với lệnh bấm ấy, nên nó là cái reo lên.</p>

<h2 class="relative group">Chỉ tăng timeout cho bài kiểm thử dài, đừng tăng cho bài bị kẹt
    <div id="chỉ-tăng-timeout-cho-bài-kiểm-thử-dài-đừng-tăng-cho-bài-bị-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="#ch%e1%bb%89-t%c4%83ng-timeout-cho-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad-d%c3%a0i-%c4%91%e1%bb%abng-t%c4%83ng-cho-b%c3%a0i-b%e1%bb%8b-k%e1%ba%b9t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tăng timeout là cách sửa đúng khi bài kiểm thử đang làm việc thật và việc đó tốn thời gian. Nó là cách sửa sai khi có một bước đang chờ điều sẽ không xảy ra, vì con số lớn hơn chỉ khiến thất bại đến chậm hơn. Bản ghi trace thường cho biết bạn đang gặp trường hợp nào: một chuỗi thao tác đều đặn, cái nào cũng hoàn tất, chỉ là có nhiều, thì đó là bài kiểm thử dài; còn một thao tác nằm ở trạng thái <code>waiting for</code> cho tới tận cuối thì đó là bài bị kẹt.</p>
<p>Với bài kiểm thử dài, hãy thêm thời gian ở đúng chỗ dài chứ đừng nới cho cả bộ kiểm thử. Bộ kiểm thử của ứng dụng chợ có một bài kiểm thử khói (smoke test) đi qua màn hình chat và chụp ảnh màn hình ở từng bước, và bài này được cho ba phút:</p>
<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="nx">test</span><span class="p">(</span><span class="s2">&#34;chat: open, send a message, go back&#34;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">test</span><span class="p">.</span><span class="nx">setTimeout</span><span class="p">(</span><span class="mi">180</span><span class="nx">_000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>Gọi <code>test.setTimeout()</code> bên trong <code>describe</code> thì nó áp dụng cho mọi bài kiểm thử trong nhóm (dạng được tài liệu hướng dẫn hiện nay là <code>test.describe.configure({ timeout: 60_000 })</code>); bài kiểm thử tính chi phí công việc (job-cost) trong bộ kiểm thử của ứng dụng báo giá được một phút theo cách đó. <code>test.slow()</code> nhân ba timeout hiện tại, bất kể nó đang là bao nhiêu, và có thể nhận một điều kiện. Trong <code>beforeEach</code>, <code>testInfo.setTimeout()</code> kéo dài thời gian cho mọi bài kiểm thử đi qua hook đó:</p>
<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="nx">test</span><span class="p">(</span><span class="s2">&#34;slow in WebKit&#34;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span><span class="p">,</span> <span class="nx">browserName</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">test</span><span class="p">.</span><span class="nx">slow</span><span class="p">(</span><span class="nx">browserName</span> <span class="o">===</span> <span class="s2">&#34;webkit&#34;</span><span class="p">,</span> <span class="s2">&#34;This page is slow in WebKit&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...
</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="nx">test</span><span class="p">.</span><span class="nx">beforeEach</span><span class="p">(</span><span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">},</span> <span class="nx">testInfo</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">testInfo</span><span class="p">.</span><span class="nx">setTimeout</span><span class="p">(</span><span class="nx">testInfo</span><span class="p">.</span><span class="nx">timeout</span> <span class="o">+</span> <span class="mi">30</span><span class="nx">_000</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 một thứ nữa lặng lẽ ăn vào quỹ thời gian ở mọi lần chạy: những lần ngủ cố định. Bộ kiểm thử của ứng dụng báo giá có hơn một trăm lệnh gọi <code>page.waitForTimeout()</code>, rải gần như khắp các tệp spec. Bài kiểm thử chỉnh sửa ở trên ngủ ba giây trước cả khi tới được lệnh bấm, và ngủ bao lâu cũng không cứu được nó. Tài liệu API của Playwright nói rất thẳng về chuyện này: &ldquo;Never wait for timeout in production. Tests that wait for time are inherently flaky.&rdquo; Nghĩa là: đừng bao giờ chờ theo thời gian khi chạy thật, vì bài kiểm thử chờ theo thời gian vốn dĩ đã chập chờn. Một assertion kiểu web-first (web-first assertion) vốn đã chờ đúng bao lâu cần thiết, không hơn:</p>
<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="c1">// before
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;save-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">1500</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByText</span><span class="p">(</span><span class="s2">&#34;E2E Edited Template&#34;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// after
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;save-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByText</span><span class="p">(</span><span class="s2">&#34;E2E Edited Template&#34;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span></span></span></code></pre></div></div>
<p>Timeout của assertion cũng cần cẩn thận như vậy. Cả hai bộ kiểm thử ứng dụng của tôi đều nâng <code>expect.timeout</code> lên 15 giây, và cả hai đều chạy với máy chủ phát triển của ứng dụng chứ không phải bản build production. Như vậy là có thêm khoảng đệm, nhưng hai assertion chậm liền nhau có thể tiêu hết 30 giây, và khi đó timeout của bài kiểm thử sẽ nổ trước, bạn lại quay về với thông báo mơ hồ.</p>

<h2 class="relative group">Khi đồng hồ không phải của bài kiểm thử
    <div id="khi-đồng-hồ-không-phải-của-bài-kiểm-thử" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#khi-%c4%91%e1%bb%93ng-h%e1%bb%93-kh%c3%b4ng-ph%e1%ba%a3i-c%e1%bb%a7a-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu thông báo có nhắc tới <code>config.webServer</code>, nghĩa là chưa bài kiểm thử nào kịp chạy. Playwright khởi động lệnh <code>webServer</code> của bạn và chờ cho tới khi <code>url</code> của nó phản hồi rồi mới chạy kiểm thử, với một timeout riêng, mặc định là 60 giây. Cả hai bộ kiểm thử ứng dụng của tôi đều cho máy chủ phát triển hai phút:</p>
<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="nx">webServer</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">command</span><span class="o">:</span> <span class="s2">&#34;npm run web -- --port 8081&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">url</span><span class="o">:</span> <span class="s2">&#34;http://localhost:8081&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">reuseExistingServer</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">timeout</span>: <span class="kt">120_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">},</span></span></span></code></pre></div></div>
<p>Nếu bài kiểm thử của bạn chạy với một môi trường đã triển khai thay vì máy chủ cục bộ, hãy tìm hiểu xem yêu cầu đầu tiên tới đó mất bao lâu. Khi cuối cùng cũng đo môi trường của mình trên nền tảng hosting đang dùng, tôi thấy một máy khởi động nguội từ trạng thái thu về 0 mất từ 25 tới 33 giây mới phục vụ được bất cứ thứ gì. Tức là phần lớn, thậm chí toàn bộ quỹ thời gian của một bài kiểm thử, trước cả lần bấm đầu tiên. Câu chuyện đó nằm trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >Khởi động nguội (cold start) trên Fly.io với scale to zero: từ 33 giây xuống 3 giây</a>.</p>

<h2 class="relative group">Nếu thông báo là &ldquo;No tests found&rdquo;
    <div id="nếu-thông-báo-là-no-tests-found" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-th%c3%b4ng-b%c3%a1o-l%c3%a0-no-tests-found" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><code>No tests found</code> nghe có vẻ liên quan, nhưng nó không phải timeout: Playwright đã đi tìm mà không thấy gì để chạy, thường là vì tệp spec nằm ngoài <code>testDir</code> hoặc đối số dòng lệnh (một biểu thức chính quy so với đường dẫn tệp) không khớp với nó. <code>npx playwright test --list</code> liệt kê những gì nó gom được mà không chạy gì cả; nếu spec của bạn không có trong đó thì nó vốn chẳng bao giờ có cơ hội bị timeout.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-playwright-timeout-30000ms/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Khởi động nguội (cold start) trên Fly.io với scale to zero: từ 33 giây xuống 3 giây</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/</guid>
      <pubDate>Wed, 09 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Django</category>
      <category>DevOps</category>
      <category>Cloudflare</category>
      <category>Performance</category>
      <category>Cold Starts</category>
      <category>Redis</category>
      <category>Debugging</category>
      <description>Vì sao máy Fly.io của tôi mất 25-33 giây khởi động nguội (cold start) khi bật auto_stop_machines, và cách tôi rút thời gian đánh thức máy xuống còn khoảng 3 giây.</description>
      <content:encoded><![CDATA[<p><strong>Cập nhật ngày 22/10/2026:</strong> con số chính của bài này là sai. Ngay hôm đăng bài, tôi nhóm các lỗi 504 của zone theo user agent và thấy toàn bộ 7,344 lỗi từ ngày 5 đến 9/9 đều đến từ các yêu cầu thăm dò Early Hints của chính Cloudflare (<code>bastion early hints</code> và <code>nginx-ssl early hints</code>). Các yêu cầu này không nhận được phản hồi từ máy chủ gốc trên Fly, bị ghi lại là 504, lại đi theo đúng đường dẫn của yêu cầu thật, nên trông y hệt lỗi phía người dùng. Trong khoảng thời gian đó, không có client thật nào (ứng dụng, trình duyệt, curl, trình thu thập dữ liệu của các công cụ tìm kiếm) nhận lỗi 504. Tôi tắt Early Hints ngay trong ngày, vì máy chủ gốc không gửi header preload nào nên tính năng này chẳng làm được gì có ích. Chuyện khởi động nguội bên dưới là thật và các cách khắc phục vẫn đúng, nhưng con số 22% chưa bao giờ là thứ người dùng của tôi gặp phải. Trước khi coi tỉ lệ lỗi 5xx trên Cloudflare là sự cố, hãy kiểm tra xem những user agent nào đang nhận các lỗi đó.</p>
<p><strong>Cập nhật ngày 4/11/2026:</strong> thêm một điều chỉnh nữa. Những lỗi 504 này không phải là các yêu cầu thăm dò mà máy chủ gốc không trả lời được. Chúng là các lượt tra cứu cache Early Hints của chính Cloudflare, hoàn toàn không đi tới máy chủ gốc, và mã 504 ở đó chỉ có nghĩa là chưa có gì được cache cho URL ấy. Tắt Early Hints cũng không phải cách sửa tốt nhất: khi gửi response header <code>Link</code>, tính năng bắt đầu hoạt động và các lần miss chuyển thành hit. Toàn bộ câu chuyện có trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/cloudflare-504-early-hints/" >Lỗi 504 Cloudflare không phải sự cố thật: Early Hints và HTTP 103</a>.</p>
<p>Nói ngắn gọn: khi bật <code>auto_stop_machines</code> để thu về 0 (scale to zero), yêu cầu nào rơi vào một máy Fly.io đang dừng đều phải chờ máy khởi động xong. Máy của tôi mất 25 đến 33 giây cho mỗi lần khởi động nguội (cold start), vì mỗi lần máy bật lên, entrypoint lại chạy migration của Django và <code>collectstatic</code>, còn bytecode thì chưa từng được lưu đệm. Khi chỉ chạy những việc đó một lần cho mỗi image (bằng một khóa Redis đặt theo <code>FLY_IMAGE_REF</code>) và thêm <code>compileall</code> vào bước build, khởi động nguội giảm còn khoảng 9 giây. Đổi <code>auto_stop_machines</code> từ <code>'stop'</code> sang <code>'suspend'</code> (muốn vậy phải bỏ swap, vì Fly không cho tạm ngưng máy có swap) thì đánh thức máy chỉ còn khoảng 3 giây.</p>
<p>Tôi mở bảng điều khiển (dashboard) của Cloudflare để tìm hiểu một đợt lưu lượng truy cập (traffic) tăng đột biến, và phát hiện bên dưới nó là một chuyện tệ hơn nhiều: trong một ngày, 22% tổng số yêu cầu (request) gửi tới ứng dụng của tôi đã bị lỗi. Không có cảnh báo nào bắn ra. Lượt kiểm tra tình trạng (health check) nào cũng báo xanh. Nhật ký (log) trông hoàn toàn bình thường, vì những yêu cầu bị lỗi chưa bao giờ tới được ứng dụng.</p>
<p>Hóa ra thủ phạm lại là chính script khởi động container của tôi: nó làm những việc hoàn toàn hợp lý, chỉ có điều là vào đúng thời điểm tệ nhất.</p>

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

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

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

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

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

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

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

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

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

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

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

<h2 class="relative group">Nếu là ứng dụng của bạn, tôi sẽ kiểm tra những gì
    <div id="nếu-là-ứng-dụng-của-bạn-tôi-sẽ-kiểm-tra-những-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-l%c3%a0-%e1%bb%a9ng-d%e1%bb%a5ng-c%e1%bb%a7a-b%e1%ba%a1n-t%c3%b4i-s%e1%ba%bd-ki%e1%bb%83m-tra-nh%e1%bb%afng-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn đang chạy bất cứ thứ gì trên một nền tảng tự dừng máy khi rảnh, thì những câu hỏi dưới đây chỉ tốn của tôi một buổi chiều, nhưng lẽ ra đã giúp tôi tránh được mấy tuần lỗi âm thầm:</p>
<ol>
<li><strong>Một lần khởi động nguội mất bao lâu?</strong> Không phải container mất bao lâu để chạy, mà là mất bao lâu đến khi nó trả lời được một yêu cầu thật. Nếu bạn không trả lời được câu này bằng một con số tính theo giây, hãy đo ngay, trước khi thật sự cần tới nó.</li>
<li><strong>Script khởi chạy của bạn đang làm những việc gì mỗi lần khởi động mà lẽ ra thuộc về khâu triển khai?</strong> Chuyển đổi lược đồ, gom tệp tĩnh, làm nóng bộ nhớ đệm, dựng chỉ mục. Làm một lần thì không sao. Làm chín mươi hai lần thì rất tốn kém.</li>
<li><strong>Có thứ gì đang lưu đệm bytecode cho bạn không?</strong> Nếu có <code>PYTHONDONTWRITEBYTECODE</code> mà lúc dựng image không chạy <code>compileall</code>, thì câu trả lời là không.</li>
<li><strong>Bạn có thể cho máy tạm ngưng thay vì tắt hẳn không?</strong> Nếu không, thì cái gì đang cản bạn? Với tôi, đó là một tệp hoán đổi chưa từng được dùng tới.</li>
<li><strong>Tầng biên có thấy những lỗi mà máy chủ gốc không thấy không?</strong> Hãy so sánh hai bên. Khoảng chênh giữa chúng không phải do báo cáo bị lệch, mà là yêu cầu đang chết ở khoảng giữa, và nhật ký ứng dụng sẽ không bao giờ cho bạn thấy điều đó.</li>
</ol>
<p>À, nhân tiện, các lượt kiểm tra tình trạng vẫn xanh suốt từ đầu đến cuối. Lúc nào chúng cũng xanh cả: kiểm tra tình trạng chỉ chạy trên một máy đã khởi động xong, nên không thể báo cáo gì về ba mươi giây trước khi máy đó tồn tại.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Cách debug ứng dụng iOS trong Xcode: breakpoint, LLDB, Instruments và Simulator</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-02-26-debugging-ios-mobile-apps/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-02-26-debugging-ios-mobile-apps/</guid>
      <pubDate>Thu, 26 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>iOS</category>
      <category>Xcode</category>
      <category>Debugging</category>
      <category>Swift</category>
      <category>iOS Simulator</category>
      <category>Mobile Development</category>
      <description>Cách debug ứng dụng iOS trong Xcode: điểm dừng (breakpoint) có điều kiện và theo ký hiệu, những lệnh LLDB đáng nhớ, View Debugger và Memory Graph, Instruments, các Sanitizer, nhật ký sự cố (crash log), và những lỗi trình giả lập bỏ sót mà chỉ iPhone thật mới bắt được.</description>
      <content:encoded><![CDATA[<p>Để gỡ lỗi (debug) một ứng dụng iOS trong Xcode, trước hết hãy thêm điểm dừng ngoại lệ (exception breakpoint) để mỗi khi ứng dụng sập, Xcode dừng ngay ở dòng mã đã ném ra ngoại lệ. Sau đó tái hiện lỗi, rồi kiểm tra trạng thái trong console LLDB bằng lệnh <code>po</code>. Tiếp theo, chọn công cụ theo triệu chứng: View Debugger cho lỗi bố cục giao diện, Memory Graph và Instruments cho rò rỉ bộ nhớ và chạy chậm, còn Address Sanitizer và Thread Sanitizer cho những vụ sập lúc có lúc không. Cuối cùng, hãy chạy thử trên iPhone thật, vì trình giả lập che mất cả một nhóm lỗi chỉ lộ ra trên máy thật.</p>
<p>Thời gian tôi dành cho việc gỡ lỗi nhiều đến mức khó tin. Mà lập trình viên nào chẳng thế. Dạo này phần lớn thời gian đó tôi ngồi trong Xcode và trình giả lập (Simulator), lần theo lỗi trong chính ứng dụng của mình, và có một điều tôi cứ thấy lạ mãi: Xcode đi kèm cả một kho công cụ gỡ lỗi, vậy mà gần như chẳng ai đụng tới. Dưới đây là quy trình tôi đúc kết qua nhiều năm, chỉ gồm những phần thực sự giúp tiết kiệm thời gian mỗi khi có gì đó hỏng.</p>

<h2 class="relative group">Điểm dừng làm được nhiều hơn bạn nghĩ
    <div id="điểm-dừng-làm-được-nhiều-hơn-bạn-nghĩ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%83m-d%e1%bb%abng-l%c3%a0m-%c4%91%c6%b0%e1%bb%a3c-nhi%e1%bb%81u-h%c6%a1n-b%e1%ba%a1n-ngh%c4%a9" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đa số chúng ta chỉ nhấp vào lề trái để đặt điểm dừng (breakpoint) rồi thôi. Nhưng thử nhấp chuột phải vào một điểm dừng xem, bạn sẽ thấy cả một menu tùy chọn, và nó thay đổi hẳn cách bạn gỡ lỗi.</p>

<h3 class="relative group">Điểm dừng có điều kiện (Conditional Breakpoint)
    <div id="điểm-dừng-có-điều-kiện-conditional-breakpoint" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%83m-d%e1%bb%abng-c%c3%b3-%c4%91i%e1%bb%81u-ki%e1%bb%87n-conditional-breakpoint" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Giả sử bạn có một vòng lặp xử lý 1000 phần tử, và chỉ riêng phần tử thứ 347 là trục trặc. Nếu phải dừng ở điểm dừng 346 lần trước khi tới được nó thì coi như mất toi một buổi chiều.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mf">0.</span><span class="p">.&lt;</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">processUser</span><span class="p">(</span><span class="n">users</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>  <span class="c1">// Set breakpoint here with condition: i == 347</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Nhấp chuột phải vào điểm dừng, thêm điều kiện <code>i == 347</code>, thế là Xcode chỉ dừng khi điều kiện đó đúng. Mấy điều kiện kiểu <code>userId == &quot;abc123&quot;</code> hay <code>error != nil</code> cũng dùng tốt y như vậy.</p>

<h3 class="relative group">Điểm dừng theo ký hiệu (Symbolic Breakpoint)
    <div id="điểm-dừng-theo-ký-hiệu-symbolic-breakpoint" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%83m-d%e1%bb%abng-theo-k%c3%bd-hi%e1%bb%87u-symbolic-breakpoint" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Loại này dùng khi bạn không biết một phương thức đang được gọi từ đâu. Mở Breakpoint Navigator (Cmd+8), bấm nút +, rồi chọn &ldquo;Symbolic Breakpoint&rdquo;.</p>
<p>Muốn biết mỗi lần có view controller nào được nạp? Đặt ký hiệu là <code>viewDidLoad</code>, Xcode sẽ dừng lại ở từng cái một. Thấy nhiều quá? Thì thu hẹp lại: <code>MyViewController.viewDidLoad</code>.</p>
<p>Có mấy cái tôi để cố định trong mọi dự án:</p>
<ul>
<li><code>UIViewAlertForUnsatisfiableConstraints</code> bắt các xung đột Auto Layout ngay khi chúng xảy ra</li>
<li><code>objc_exception_throw</code> dừng ở mọi ngoại lệ (exception) Objective-C, trước khi ứng dụng kịp bị sập (crash)</li>
<li><code>malloc_error_break</code> giúp tìm ra các lỗi cấp phát bộ nhớ</li>
</ul>

<h3 class="relative group">Điểm dừng ngoại lệ (Exception Breakpoint)
    <div id="điểm-dừng-ngoại-lệ-exception-breakpoint" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%83m-d%e1%bb%abng-ngo%e1%ba%a1i-l%e1%bb%87-exception-breakpoint" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đây nên là thứ đầu tiên bạn thiết lập khi tạo dự án mới. Thêm một cái (Breakpoint Navigator, + → Exception Breakpoint) là Xcode sẽ dừng ngay lúc có ngoại lệ được ném ra, chứ không phải đợi đến khi ứng dụng đã chết hẳn.</p>
<p>Nhờ nó mà tôi bắt được không biết bao nhiêu lỗi mở gói (unwrap) giá trị nil, nhiều hơn mức tôi muốn thừa nhận. Không có nó thì mấy lỗi này chỉ hiện ra dưới dạng những vụ sập khó hiểu trong console.</p>

<h3 class="relative group">Hành động của điểm dừng (Breakpoint Action)
    <div id="hành-động-của-điểm-dừng-breakpoint-action" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#h%c3%a0nh-%c4%91%e1%bb%99ng-c%e1%bb%a7a-%c4%91i%e1%bb%83m-d%e1%bb%abng-breakpoint-action" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Điểm dừng không nhất thiết phải dừng chương trình. Sửa một điểm dừng, bấm &ldquo;Add Action&rdquo;, và bạn có thể cho nó:</p>
<ul>
<li>In giá trị biến ra mà không dừng: <code>&quot;User count: @(users.count)@&quot;</code></li>
<li>Tự động chạy lệnh LLDB</li>
<li>Chạy shell script</li>
<li>Phát âm thanh (tôi hay dùng cho những nhánh mã hiếm khi chạy tới, để biết ngay khi nó xảy ra)</li>
</ul>
<p>Đánh dấu thêm &ldquo;Automatically continue after evaluating actions&rdquo; là điểm dừng biến thành một công cụ ghi nhật ký (log), không bao giờ làm gián đoạn bạn.</p>

<h2 class="relative group">LLDB: những lệnh console bạn sẽ dùng thật
    <div id="lldb-những-lệnh-console-bạn-sẽ-dùng-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="#lldb-nh%e1%bb%afng-l%e1%bb%87nh-console-b%e1%ba%a1n-s%e1%ba%bd-d%c3%b9ng-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi chương trình dừng ở điểm dừng, cái console ở dưới cùng không chỉ để đọc nhật ký sự cố (crash log). Đó chính là LLDB, và nó hữu ích hơn bạn tưởng nhiều.</p>

<h3 class="relative group">Cơ bản
    <div id="cơ-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="#c%c6%a1-b%e1%ba%a3n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po user
▿ User
  - id: &#34;123&#34;
  - name: &#34;John Doe&#34;
  - email: &#34;john@example.com&#34;</code></pre></div>
<p><code>po</code> in đối tượng (object) ra ở dạng dễ đọc. Riêng lệnh này đã chiếm tới 90% số lần tôi dùng LLDB.</p>
<p>Cần chi tiết hơn thì dùng <code>p</code>:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) p user.name
(String) $R0 = &#34;John Doe&#34;</code></pre></div>
<p>Còn muốn xem toàn bộ biến cục bộ cùng lúc:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) frame variable</code></pre></div>

<h3 class="relative group">Thay đổi giá trị khi chương trình đang chạy
    <div id="thay-đổi-giá-trị-khi-chương-trình-đang-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="#thay-%c4%91%e1%bb%95i-gi%c3%a1-tr%e1%bb%8b-khi-ch%c6%b0%c6%a1ng-tr%c3%acnh-%c4%91ang-ch%e1%ba%a1y" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>LLDB thực sự đáng đồng tiền ở chỗ này: bạn sửa được giá trị biến mà không cần biên dịch lại.</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) expr user.name = &#34;Jane Doe&#34;
(lldb) expr index = 0</code></pre></div>
<p>Tìm ra lỗi rồi và muốn thử cách sửa mà không phải biên dịch lại? Cứ đổi giá trị biến rồi cho chương trình chạy tiếp. Tôi dùng cách này liên tục mỗi khi cần khoanh vùng các trường hợp biên (edge case).</p>
<p>Gọi phương thức cũng được luôn:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)</code></pre></div>

<h3 class="relative group">Di chuyển trong chương trình
    <div id="di-chuyển-trong-chương-trì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="#di-chuy%e1%bb%83n-trong-ch%c6%b0%c6%a1ng-tr%c3%acnh" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) bt              # Show the call stack
(lldb) frame select 3  # Jump to a different stack frame
(lldb) continue        # Keep running (or just &#39;c&#39;)
(lldb) n               # Step over (next line)
(lldb) s               # Step into function</code></pre></div>

<h3 class="relative group">Điểm theo dõi (Watchpoint)
    <div id="điểm-theo-dõi-watchpoint" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91i%e1%bb%83m-theo-d%c3%b5i-watchpoint" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Muốn biết khi nào một biến bị thay đổi? Đặt điểm theo dõi:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) watchpoint set variable user.isLoggedIn</code></pre></div>
<p>Từ giờ, cứ mỗi lần giá trị đó thay đổi là chương trình dừng lại, bất kể dòng mã nào gây ra. Khi phải truy lùng những lần trạng thái bị thay đổi ngoài ý muốn thì công cụ này quý như vàng.</p>

<h2 class="relative group">Console.app: công cụ gỡ lỗi ít người để ý
    <div id="consoleapp-công-cụ-gỡ-lỗi-ít-ngườ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="#consoleapp-c%c3%b4ng-c%e1%bb%a5-g%e1%bb%a1-l%e1%bb%97i-%c3%adt-ng%c6%b0%e1%bb%9di-%c4%91%e1%bb%83-%c3%bd" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Console của Xcode dùng tạm thì ổn, cho đến khi bạn cần xem mọi thứ: nhật ký hệ thống, báo cáo sự cố (crash report), đủ cả. Những lúc đó tôi mở Console.app.</p>
<p>Nếu bạn đang dùng OSLog (và bạn nên dùng), Console.app cho phép tìm kiếm và lọc các nhật ký đó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;networking&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Starting API request&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">debug</span><span class="p">(</span><span class="s">&#34;Request URL: </span><span class="si">\(</span><span class="n">url</span><span class="p">.</span><span class="n">absoluteString</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="s">&#34;Failed to decode: </span><span class="si">\(</span><span class="n">error</span><span class="p">.</span><span class="n">localizedDescription</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Sau đó, trong Console.app:</p>
<ol>
<li>Chọn trình giả lập hoặc thiết bị đang kết nối</li>
<li>Lọc theo phân hệ (subsystem) của bạn: <code>subsystem:com.example.app</code></li>
<li>Lọc theo mức độ: <code>subsystem:com.example.app AND level:error</code></li>
</ol>
<p>Với những kiểu gỡ lỗi hay làm, bạn có thể lưu sẵn biểu thức lọc (predicate). Tôi có một cái cho yêu cầu mạng, một cái cho các thao tác với cơ sở dữ liệu, và một cái chỉ hiện lỗi với cảnh báo.</p>

<h3 class="relative group">Sao lại dùng OSLog thay vì print()?
    <div id="sao-lại-dùng-oslog-thay-vì-print" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sao-l%e1%ba%a1i-d%c3%b9ng-oslog-thay-v%c3%ac-print" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>So với việc rải <code>print()</code> khắp nơi thì OSLog hơn hẳn. Nhật ký có cấu trúc nên lọc được theo mức độ và danh mục. Nó đủ nhanh để việc ghi nhật ký không làm ứng dụng chậm đi. Trên môi trường production, dữ liệu nhạy cảm được tự động ẩn đi, và nhật ký vẫn còn nguyên kể cả khi ứng dụng bị sập.</p>
<p><code>print()</code> vẫn ổn cho mấy lần kiểm tra nhanh rồi xóa. Còn thứ gì sau này bạn có thể cần xem lại thì nên ghi bằng OSLog.</p>

<h3 class="relative group">Lọc nâng cao trong Console.app
    <div id="lọc-nâng-cao-trong-consoleapp" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%8dc-n%c3%a2ng-cao-trong-consoleapp" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Console.app hỗ trợ đầy đủ truy vấn bằng biểu thức lọc. Đây là mấy câu tôi dùng thường xuyên:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># All errors and faults in your app
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND (level:error OR level:fault)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Network requests that failed
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND category:networking AND eventMessage CONTAINS &#34;failed&#34;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Everything from the last 5 minutes
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND timestamp &gt;= now(-5m)</span></span></code></pre></div></div>
<p>Bạn cũng có thể xuất nhật ký ra để đính kèm vào báo cáo lỗi, tiện hơn nhiều so với sao chép thủ công từ console của Xcode.</p>

<h2 class="relative group">Gỡ lỗi giao diện với View Debugger
    <div id="gỡ-lỗi-giao-diện-với-view-debugger" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#g%e1%bb%a1-l%e1%bb%97i-giao-di%e1%bb%87n-v%e1%bb%9bi-view-debugger" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi giao diện bị vỡ mà bạn không hiểu tại sao, View Debugger là cách nhanh nhất để tìm ra nguyên nhân.</p>
<p>Chạy ứng dụng, mở tới màn hình bị lỗi, rồi bấm nút &ldquo;Debug View Hierarchy&rdquo; trên thanh gỡ lỗi của Xcode (hoặc nhấn Cmd+Shift+D). Xcode sẽ hiện toàn bộ cây phân cấp view (view hierarchy) dưới dạng 3D, tách ra từng lớp.</p>
<p>Xoay qua xoay lại một chút là vấn đề lộ ra ngay:</p>
<ul>
<li>View bị vẽ nằm phía sau view khác</li>
<li>View nằm tít ngoài màn hình</li>
<li>View có kích thước bằng 0</li>
<li>Toàn bộ chuỗi ràng buộc (constraint) của view bạn đang chọn</li>
</ul>
<p>Tôi từng nhờ nó mà tìm ra những cái nút vô hình nuốt mất sự kiện chạm, những nhãn bị vẽ ở kích thước 0x0, và có lần là một tấm ảnh lỡ tay rộng tới 10,000 pixel.</p>

<h3 class="relative group">Gỡ lỗi Auto Layout
    <div id="gỡ-lỗi-auto-layout" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#g%e1%bb%a1-l%e1%bb%97i-auto-layout" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trong cây phân cấp view, biểu tượng cảnh báo màu tím nghĩa là đang có xung đột ràng buộc. Nhấp vào đó, Xcode sẽ chỉ rõ những ràng buộc nào đang &ldquo;đánh nhau&rdquo;.</p>
<p>Hãy đặt định danh (identifier) cho ràng buộc:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">heightConstraint</span><span class="p">.</span><span class="n">identifier</span> <span class="p">=</span> <span class="s">&#34;ProfileImageHeight&#34;</span></span></span></code></pre></div></div>
<p>Khi ràng buộc bị vỡ, thông báo lỗi sẽ hiện <code>&quot;ProfileImageHeight&quot;</code> thay vì một địa chỉ bộ nhớ, và lúc đó nhật ký mới thực sự đọc được.</p>
<p>Bạn cũng có thể in cây ràng buộc ra từ LLDB:</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()</code></pre></div>

<h2 class="relative group">Những tính năng của trình giả lập giúp tiết kiệm thời gian
    <div id="những-tính-năng-của-trình-giả-lập-giúp-tiết-kiệm-thời-gian" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-t%c3%adnh-n%c4%83ng-c%e1%bb%a7a-tr%c3%acnh-gi%e1%ba%a3-l%e1%ba%adp-gi%c3%bap-ti%e1%ba%bft-ki%e1%bb%87m-th%e1%bb%9di-gian" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Làm chậm hoạt ảnh (Slow Animations)
    <div id="làm-chậm-hoạt-ảnh-slow-animations" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%c3%a0m-ch%e1%ba%adm-ho%e1%ba%a1t-%e1%ba%a3nh-slow-animations" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Debug → Slow Animations làm mọi thứ chạy chậm lại còn 1/10. Rất hợp để xem trong một hiệu ứng chuyển cảnh thực sự đang xảy ra chuyện gì, hoặc tìm hiểu vì sao một hoạt ảnh trông sai sai.</p>

<h3 class="relative group">Giả lập cảnh báo bộ nhớ
    <div id="giả-lập-cảnh-báo-bộ-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="#gi%e1%ba%a3-l%e1%ba%adp-c%e1%ba%a3nh-b%c3%a1o-b%e1%bb%99-nh%e1%bb%9b" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Debug → Simulate Memory Warning giúp bạn kiểm tra xem ứng dụng xoay xở ra sao khi thiếu bộ nhớ. Tôi đã tìm ra khá nhiều lỗi liên quan đến bộ nhớ đệm (cache) ảnh theo cách này: mọi thứ vẫn chạy ổn, cho tới khi cảnh báo được phát ra, và tự dưng toàn bộ ảnh biến mất sạch.</p>

<h3 class="relative group">Network Link Conditioner
    <div id="network-link-conditioner" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#network-link-conditioner" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Xcode → Open Developer Tools → Network Link Conditioner</p>
<p>Công cụ này giả lập mạng 3G, LTE, mạng mất gói nhiều, hoặc mất kết nối hoàn toàn. Nếu bạn từng gặp một yêu cầu API chạy ngon lành trên WiFi nhưng lại hết thời gian chờ (timeout) khi dùng 4G, thì đây là cách để tái hiện lỗi đó.</p>
<p>Tôi luôn để sẵn một cấu hình tên &ldquo;Bad Network&rdquo;, với độ trễ 500ms và tỉ lệ mất gói 10%. Nhờ nó mà lộ ra những lỗi hết thời gian chờ và lỗi ở trạng thái đang tải, loại lỗi bạn sẽ không bao giờ thấy khi ngồi dùng WiFi tốc độ cao ở văn phòng.</p>

<h3 class="relative group">Ghi đè thanh trạng thái
    <div id="ghi-đè-thanh-trạng-thái" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ghi-%c4%91%c3%a8-thanh-tr%e1%ba%a1ng-th%c3%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Nhấp chuột phải vào thanh trạng thái (status bar) trong trình giả lập là bạn ghi đè được giờ, mức pin, cường độ sóng và tên nhà mạng. Rất tiện khi cần chụp ảnh màn hình đồng nhất, hoặc muốn kiểm tra giao diện trông thế nào ở các mức pin khác nhau.</p>

<h3 class="relative group">Giả lập vị trí
    <div id="giả-lập-vị-trí" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%e1%ba%a3-l%e1%ba%adp-v%e1%bb%8b-tr%c3%ad" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Debug → Location cho phép giả lập các vị trí khác nhau mà không cần rời khỏi bàn làm việc: tọa độ tùy chọn, đi bộ trong thành phố, hay lái xe trên cao tốc. Rất hữu ích khi làm các tính năng dựa trên vị trí, hoặc khi gặp những lỗi chỉ xuất hiện ở một số khu vực nhất định.</p>

<h2 class="relative group">Gỡ lỗi bộ nhớ
    <div id="gỡ-lỗi-bộ-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="#g%e1%bb%a1-l%e1%bb%97i-b%e1%bb%99-nh%e1%bb%9b" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Debug Memory Graph
    <div id="debug-memory-graph" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#debug-memory-graph" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Bấm nút Debug Memory Graph (Cmd+Shift+M), Xcode sẽ hiện ra mọi đối tượng đang nằm trong bộ nhớ, quan hệ giữa chúng, và phần bạn thực sự quan tâm: đối tượng nào đang bị rò rỉ bộ nhớ (memory leak).</p>
<p>Dấu chấm than màu tím nghĩa là có rò rỉ. Nhấp vào là bạn thấy ngay vòng tham chiếu (retain cycle).</p>
<p>Đây là kiểu rò rỉ tôi gặp nhiều nhất:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">class</span> <span class="nc">ViewController</span><span class="p">:</span> <span class="n">UIViewController</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">onComplete</span><span class="p">:</span> <span class="p">(()</span> <span class="p">-&gt;</span> <span class="nb">Void</span><span class="p">)?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">setupHandler</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="kc">self</span><span class="p">.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ❌ Captures self strongly</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>Cách sửa là dùng weak self:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span> <span class="p">[</span><span class="kr">weak</span> <span class="kc">self</span><span class="p">]</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">?.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ✓ No retain cycle</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Delegate cũng phải khai báo weak:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kr">weak</span> <span class="kd">var</span> <span class="nv">delegate</span><span class="p">:</span> <span class="n">ManagerDelegate</span><span class="p">?</span>  <span class="c1">// Not just &#39;var&#39;</span></span></span></code></pre></div></div>

<h3 class="relative group">Instruments
    <div id="instruments" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#instruments" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Khi cần điều tra bộ nhớ nghiêm túc, hãy dùng Instruments (Product → Profile, hoặc Cmd+I).</p>
<p>Công cụ Allocations trong Instruments cho bạn thấy từng lần cấp phát đối tượng, bộ nhớ tăng lên theo thời gian ra sao, lớp nào đang ngốn nhiều bộ nhớ nhất, kèm stack trace cho biết mỗi lần cấp phát xảy ra ở đâu.</p>
<p>Tôi thường tìm ba thứ: bộ nhớ tăng đều theo thời gian (nhiều khả năng là rò rỉ), những lần cấp phát lớn bất thường (mình vừa nạp cả 1000 tấm ảnh một lúc à?), và những đối tượng lẽ ra đã phải được giải phóng nhưng vẫn còn đó.</p>
<p>Bấm đánh dấu generation (nút hình lá cờ nhỏ) trước và sau một thao tác để xem thứ gì vẫn còn nằm lại trong bộ nhớ khi đáng lẽ phải được giải phóng.</p>

<h2 class="relative group">Đo hiệu năng
    <div id="đo-hiệu-năng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91o-hi%e1%bb%87u-n%c4%83ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Time Profiler
    <div id="time-profiler" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#time-profiler" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Product → Profile → Time Profiler. Bấm ghi, thực hiện đúng thao tác đang chậm trong ứng dụng, rồi đọc cây lời gọi hàm (call tree).</p>
<p>Sắp xếp theo &ldquo;Self Weight&rdquo; để tìm điểm nghẽn (bottleneck). Hàm nào có self weight tới 40% thì đó chính là chỗ thời gian của bạn đang bị ngốn.</p>
<p>Có lần tôi phát hiện một hàm phân tích JSON bị gọi tới 1000 lần mỗi giây. Chuyển nó sang hàng đợi chạy nền (background queue) xong là giao diện hết giật ngay.</p>

<h3 class="relative group">Main Thread Checker
    <div id="main-thread-checker" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#main-thread-checker" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Xcode tự động phát hiện những chỗ cập nhật giao diện từ luồng nền (background thread). Nếu bạn thấy dòng này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Main Thread Checker: UI API called on a background thread: -[UILabel setText:]</span></span></code></pre></div></div>
<p>thì nghĩa là bạn đã viết kiểu như thế này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ❌ Crash!</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Sửa lại như sau:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="n">DispatchQueue</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ✓</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>

<h2 class="relative group">Chẩn đoán khi chạy (Runtime Diagnostics)
    <div id="chẩn-đoán-khi-chạy-runtime-diagnostics" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a9n-%c4%91o%c3%a1n-khi-ch%e1%ba%a1y-runtime-diagnostics" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Vào Edit Scheme → Run → Diagnostics. Ở đây có một loạt ô đánh dấu, mỗi ô bắt được những lỗi mà tự tay bạn sẽ không bao giờ tìm ra.</p>

<h3 class="relative group">Address Sanitizer
    <div id="address-sanitizer" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#address-sanitizer" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Công cụ này tìm lỗi hỏng bộ nhớ: use-after-free, tràn bộ đệm (buffer overflow), rò rỉ bộ nhớ. Nó làm ứng dụng chậm đi 2-3 lần, nhưng bù lại bắt được những lỗi gần như không thể lần ra bằng cách nào khác. Tôi bật nó mỗi khi đi săn những vụ sập lúc có lúc không.</p>

<h3 class="relative group">Thread Sanitizer
    <div id="thread-sanitizer" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#thread-sanitizer" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thread Sanitizer bắt lỗi tranh chấp dữ liệu (data race) và các lỗi liên quan đến luồng (thread). Bạn chỉ cần bật lên, chạy ứng dụng và dùng như bình thường; nếu trong mã có tình trạng tranh chấp (race condition), nó sẽ tìm ra.</p>
<p>Không thể chạy Address Sanitizer và Thread Sanitizer cùng lúc, nên khi gặp một vụ sập có biểu hiện kỳ quặc, tôi bật luân phiên từng cái một.</p>

<h3 class="relative group">Zombie Objects
    <div id="zombie-objects" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#zombie-objects" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tùy chọn này bắt các thông điệp (message) được gửi tới đối tượng đã bị giải phóng. Khi bật lên, Xcode sẽ báo chính xác lúc nào bạn đang đụng vào vùng nhớ đã được giải phóng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000</span></span></code></pre></div></div>
<p>Đừng để nó bật thường trực. Zombie Objects ngăn đối tượng bị giải phóng, nên bộ nhớ cứ thế tăng mãi. Nhưng để khoanh vùng một lỗi use-after-free cụ thể thì nó rất tuyệt.</p>

<h2 class="relative group">Những tình huống hay gặp
    <div id="những-tình-huống-hay-gặ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-t%c3%acnh-hu%e1%bb%91ng-hay-g%e1%ba%b7p" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Ứng dụng bị sập ngay khi mở
    <div id="ứng-dụng-bị-sập-ngay-khi-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="#%e1%bb%a9ng-d%e1%bb%a5ng-b%e1%bb%8b-s%e1%ba%adp-ngay-khi-m%e1%bb%9f" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trước hết hãy thêm điểm dừng ngoại lệ; thường thì thế là bắt được luôn.</p>
<p>Nếu vẫn chưa ra, mấy &ldquo;nghi phạm quen mặt&rdquo; là: thiếu khóa (key) trong Info.plist, ép mở gói (force-unwrap) optional trong phần mã khởi tạo, hoặc cơ chế tiêm phụ thuộc (dependency injection) bị lỗi.</p>

<h3 class="relative group">Giao diện không cập nhật
    <div id="giao-diện-không-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="#giao-di%e1%bb%87n-kh%c3%b4ng-c%e1%ba%adp-nh%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Lần. Nào. Cũng. Vậy. Nguyên nhân chỉ nằm trong mấy trường hợp sau:</p>
<ol>
<li>Việc cập nhật không chạy trên luồng chính (main thread)</li>
<li>Outlet chưa được nối</li>
<li>View thực ra không hiển thị (kiểm tra bằng <code>po view.window</code> trong LLDB; nếu kết quả là nil thì view không nằm trong cây phân cấp)</li>
</ol>
<p>Còn nếu là table view hay collection view: bạn quên gọi <code>reloadData()</code> rồi.</p>

<h3 class="relative group">Ứng dụng bị sập vì cảnh báo bộ nhớ
    <div id="ứng-dụng-bị-sập-vì-cảnh-báo-bộ-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="#%e1%bb%a9ng-d%e1%bb%a5ng-b%e1%bb%8b-s%e1%ba%adp-v%c3%ac-c%e1%ba%a3nh-b%c3%a1o-b%e1%bb%99-nh%e1%bb%9b" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Dùng Allocations trong Instruments để xem thứ gì đang giữ bộ nhớ. Thường là ảnh nằm trong bộ nhớ đệm mà không bao giờ được nhả ra, view controller không chịu giải phóng (vòng tham chiếu), hoặc một mảng hay dictionary nào đó cứ phình to mãi.</p>
<p>Hãy giả lập cảnh báo bộ nhớ trong trình giả lập để kiểm tra phần mã dọn dẹp của bạn.</p>

<h3 class="relative group">Những vụ sập khó hiểu
    <div id="những-vụ-sập-khó-hiể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="#nh%e1%bb%afng-v%e1%bb%a5-s%e1%ba%adp-kh%c3%b3-hi%e1%bb%83u" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Bật Sanitizer lên. Sập lúc có lúc không thường là do luồng, nên hãy chạy với Thread Sanitizer. Còn sập bên trong framework của hệ thống thì thường là do hỏng bộ nhớ, nên chạy với Address Sanitizer.</p>

<h3 class="relative group">Những vụ sập không tái hiện được
    <div id="những-vụ-sập-không-tái-hiện-được" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-v%e1%bb%a5-s%e1%ba%adp-kh%c3%b4ng-t%c3%a1i-hi%e1%bb%87n-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Khi vụ sập xảy ra trên điện thoại của người khác, thường thì báo cáo sự cố vẫn còn lưu lại ở đâu đó. Với các bản build TestFlight và App Store, Organizer của Xcode (Window → Organizer → Crashes) thu thập báo cáo sự cố từ những người dùng đồng ý chia sẻ dữ liệu phân tích, và báo cáo đã được giải mã ký hiệu (symbolicate) sẵn nếu tệp dSYM được tải lên cùng bản build. Còn với máy của người kiểm thử, báo cáo nằm ngay trên thiết bị, trong Settings → Privacy &amp; Security → Analytics &amp; Improvements → Analytics Data, dưới dạng các tệp <code>.ips</code> có tên bắt đầu bằng tên ứng dụng, và họ có thể gửi cho bạn qua AirDrop. Hãy bắt đầu từ stack trace của luồng bị sập, rồi lần lượt đối chiếu với các tình huống ở trên.</p>

<h3 class="relative group">Gỡ lỗi bản cập nhật OTA trong trình giả lập
    <div id="gỡ-lỗi-bản-cập-nhật-ota-trong-trình-giả-lập" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#g%e1%bb%a1-l%e1%bb%97i-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt-ota-trong-tr%c3%acnh-gi%e1%ba%a3-l%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Ứng dụng của tôi có phát hành bản cập nhật qua mạng (OTA), và trình giả lập là nơi dễ nhất để gỡ lỗi toàn bộ luồng cập nhật đó.</p>
<p>Việc đầu tiên tôi làm là theo dõi lưu lượng mạng. Mở Console.app, lọc theo phân hệ của ứng dụng, bạn sẽ thấy các yêu cầu và phản hồi của tệp manifest theo thời gian thực: tiêu đề (header) nào được gửi đi, máy chủ trả về cái gì, rõ ràng từng thứ.</p>
<p>Tiếp theo, tôi cố tình làm cho mạng tệ đi. Network Link Conditioner cho bạn thấy luồng cập nhật xoay xở ra sao khi kết nối chậm và mất gói. Ứng dụng có bị treo không? Có hiện biểu tượng đang tải không? Có chuyển sang phương án dự phòng êm ái không?</p>
<p>Quanh bước kiểm tra cập nhật, tôi ghi nhật ký mọi thứ: lúc bắt đầu kiểm tra, lúc phát hiện có bản mới, lúc bắt đầu tải xuống, và lúc gói (bundle) mới được nạp.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;updates&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Checking for updates...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">update</span> <span class="p">=</span> <span class="k">try</span> <span class="n">await</span> <span class="n">Updates</span><span class="p">.</span><span class="n">checkForUpdateAsync</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Update available: </span><span class="si">\(</span><span class="n">update</span><span class="p">.</span><span class="n">isAvailable</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Cuối cùng, bạn nên kiểm tra riêng endpoint manifest. Dùng curl gọi nó với đúng những tiêu đề mà ứng dụng gửi đ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">curl -H <span class="s2">&#34;expo-protocol-version: 1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-platform: ios&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-runtime-version: 1.0.0&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     https://updates.example.com/manifest</span></span></code></pre></div></div>
<p>Làm vậy để chắc chắn máy chủ đang trả về đúng dữ liệu, trước khi bạn lao vào đào bới phía máy khách.</p>

<h2 class="relative group">Công cụ bên thứ ba
    <div id="công-cụ-bên-thứ-ba" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b4ng-c%e1%bb%a5-b%c3%aan-th%e1%bb%a9-ba" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Charles Proxy / Proxyman
    <div id="charles-proxy--proxyman" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#charles-proxy--proxyman" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Hai công cụ này cho bạn xem toàn bộ lưu lượng mạng, sửa yêu cầu và phản hồi, và thử các tình huống lỗi. Gỡ lỗi API mà thiếu chúng thì rất khổ.</p>
<p>Proxyman có giao diện đẹp hơn Charles và dùng trên macOS thấy tự nhiên hơn. Tôi chuyển sang từ năm ngoái và chưa một lần muốn quay lại.</p>

<h3 class="relative group">Reveal
    <div id="reveal" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#reveal" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Reveal giống View Debugger của Xcode nhưng mạnh hơn. Nó chạy được trên máy thật mà không cần kết nối với Xcode, nên rất hợp để truy lỗi bố cục ngay trên máy của người kiểm thử (tester).</p>

<h2 class="relative group">Trình giả lập hay máy thật
    <div id="trình-giả-lập-hay-máy-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%c3%acnh-gi%e1%ba%a3-l%e1%ba%adp-hay-m%c3%a1y-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần lớn việc gỡ lỗi tôi làm trên trình giả lập, vì nó nhanh: không cần cáp, không cần ký ứng dụng (code signing), không phải chờ cài đặt. Nhưng trình giả lập thực chất là một bản iOS chạy trên bộ xử lý của máy Mac, dùng bộ nhớ và kết nối mạng của máy Mac, nên có những lỗi chỉ tồn tại trên điện thoại thật.</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Trình giả lập</th>
					<th>iPhone thật</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Tốc độ</td>
					<td>CPU của máy Mac, thường nhanh hơn điện thoại</td>
					<td>Số liệu thật; nên đo hiệu năng ở đây</td>
			</tr>
			<tr>
					<td>Bộ nhớ</td>
					<td>Gần như không giới hạn, nên bạn phải tự giả lập cảnh báo bộ nhớ</td>
					<td>Hệ thống tắt ứng dụng nào dùng quá nhiều</td>
			</tr>
			<tr>
					<td>Mạng</td>
					<td>Kết nối của máy Mac</td>
					<td>Mạng di động, sóng yếu, chuyển qua lại giữa Wi-Fi và dữ liệu di động</td>
			</tr>
			<tr>
					<td>Camera, Bluetooth, NFC</td>
					<td>Không có</td>
					<td>Có</td>
			</tr>
			<tr>
					<td>Thông báo đẩy (push notification)</td>
					<td>Giả lập được bằng <code>xcrun simctl push</code></td>
					<td>Nhận thật, đúng như cách người dùng nhận</td>
			</tr>
			<tr>
					<td>Vòng sửa và chạy thử</td>
					<td>Vài giây</td>
					<td>Chậm hơn: ký ứng dụng, cài đặt, qua cáp hoặc Wi-Fi</td>
			</tr>
	</tbody>
</table>
<p>Vì vậy tôi viết và gỡ lỗi phần logic trên trình giả lập, còn trước mỗi lần phát hành thì chạy ứng dụng trên chiếc iPhone cũ nhất mà tôi vẫn còn hỗ trợ. Hiệu năng, tình trạng thiếu bộ nhớ và bất cứ thứ gì đụng tới phần cứng thì chưa chạy trên máy thật là coi như chưa thực sự được kiểm thử.</p>

<h3 class="relative group">Chạy ứng dụng trên máy thật
    <div id="chạy-ứng-dụng-trên-máy-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a1y-%e1%bb%a9ng-d%e1%bb%a5ng-tr%c3%aan-m%c3%a1y-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cắm iPhone vào máy Mac, chọn nó làm thiết bị chạy (run destination), rồi chọn team trong mục Signing &amp; Capabilities (nếu chỉ chạy trên điện thoại của chính bạn thì một Apple ID miễn phí là đủ). Từ iOS 16 trở đi, điện thoại còn phải bật Developer Mode trong Settings → Privacy &amp; Security, và lần đầu bật thì máy sẽ khởi động lại. Bật xong, ngay trong Settings → Developer của điện thoại cũng có Network Link Conditioner, với đúng những cấu hình mạng kém giống như trên máy Mac.</p>

<h3 class="relative group">Gỡ lỗi không dây
    <div id="gỡ-lỗi-không-dâ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="#g%e1%bb%a1-l%e1%bb%97i-kh%c3%b4ng-d%c3%a2y" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cắm máy vào Mac qua cáp USB một lần, vào Window → Devices and Simulators, đánh dấu &ldquo;Connect via network&rdquo;, rồi rút cáp ra. Chỉ cần máy và Mac dùng chung mạng WiFi là máy vẫn hiện trong Xcode.</p>

<h3 class="relative group">Device Console
    <div id="device-console" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#device-console" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Window → Devices and Simulators → Open Console. Ở đây hiện đủ mọi thứ: nhật ký hệ thống, nhật ký của ứng dụng, báo cáo sự cố. Chi tiết hơn console của Xcode rất nhiều.</p>
<p>Mỗi khi người kiểm thử báo &ldquo;ứng dụng bị sập&rdquo;, tôi luôn nhờ họ cắm máy vào để lấy nhật ký console. Rất hay gặp trường hợp có sẵn một lỗi ở cấp hệ thống nằm ngay đó, giải thích được toàn bộ câu chuyện.</p>

<h2 class="relative group">Tôi thực sự làm gì khi gỡ lỗi
    <div id="tôi-thực-sự-làm-gì-khi-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="#t%c3%b4i-th%e1%bb%b1c-s%e1%bb%b1-l%c3%a0m-g%c3%ac-khi-g%e1%bb%a1-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ol>
<li>Thêm điểm dừng ngoại lệ nếu chưa có</li>
<li>Tái hiện lỗi</li>
<li>Đặt điểm dừng gần chỗ tôi nghi là đang sai</li>
<li>Kiểm tra giá trị bằng <code>po</code> trong LLDB</li>
<li>Sửa giá trị biến để thử cách sửa mà không cần biên dịch lại</li>
<li>Xem cây phân cấp view nếu lỗi liên quan đến giao diện</li>
<li>Phân tích hiệu năng (profile) bằng Instruments nếu lỗi liên quan đến hiệu năng</li>
<li>Bật Sanitizer nếu lỗi liên quan đến bộ nhớ hoặc luồng</li>
</ol>
<p>Bí quyết là đi ngược từ triệu chứng. Sập? Điểm dừng ngoại lệ. Chậm? Time Profiler. Bộ nhớ? Instruments. Giao diện kỳ cục? View Debugger.</p>

<h2 class="relative group">Những mẹo tôi ước mình biết sớm hơn
    <div id="những-mẹo-tôi-ước-mình-biết-sớm-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="#nh%e1%bb%afng-m%e1%ba%b9o-t%c3%b4i-%c6%b0%e1%bb%9bc-m%c3%acnh-bi%e1%ba%bft-s%e1%bb%9bm-h%c6%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hãy dùng assertion. Chúng bắt được lỗi logic trước khi lỗi đó kịp gây hậu quả thật:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="bp">assert</span><span class="p">(</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="s">&#34;Users array should never be empty here&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Commit trước khi bắt đầu gỡ lỗi. Một khi đã sửa lung tung để thử các giả thuyết, bạn sẽ cần một điểm sạch sẽ để quay về.</p>
<p>Gỡ lỗi xong thì xóa mấy lệnh print đi, hoặc ít nhất bọc chúng trong <code>#if DEBUG</code>. Nhật ký gỡ lỗi cũ để lại chỉ làm cho lỗi tiếp theo khó thấy hơn.</p>
<p>Học LLDB cho tử tế. Nhanh hơn nhiều so với việc biên dịch lại ứng dụng hai mươi lần.</p>
<p>Và nhớ rằng trình giả lập không phải điện thoại thật. Lỗi chỉ xuất hiện trên máy thật thì gần như lúc nào cũng là do luồng hoặc bộ nhớ. Hoặc do hiệu năng, vì trình giả lập chạy bằng CPU của máy Mac chứ không phải chip iPhone thật.</p>

<h2 class="relative group">Những công cụ tôi luôn để mở
    <div id="những-công-cụ-tôi-luôn-để-mở" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-c%c3%b4ng-c%e1%bb%a5-t%c3%b4i-lu%c3%b4n-%c4%91%e1%bb%83-m%e1%bb%9f" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li>Xcode, tất nhiên rồi</li>
<li>Console.app, lọc sẵn theo phân hệ của ứng dụng</li>
<li>Proxyman để gỡ lỗi mạng</li>
<li>Instruments, khi mọi chuyện bắt đầu nghiêm trọng</li>
</ul>
<hr>
<p>Không có công cụ nào lo được hết mọi thứ. View Debugger không tìm ra rò rỉ bộ nhớ, Instruments cũng không gỡ được xung đột Auto Layout, nên phần lớn kỹ năng nằm ở chỗ chọn đúng công cụ cho đúng triệu chứng. Cái này thì chỉ có làm nhiều mới quen: dính vòng tham chiếu đủ nhiều lần rồi, chỉ cần liếc stack trace là bạn đã &ldquo;ngửi&rdquo; ra nó. Gỡ lỗi chẳng bao giờ thực sự vui, nhưng ít ra nó sẽ không còn là cực hình nữa.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-26-debugging-ios-mobile-apps/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
