<?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>Docker · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/docker/</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/docker/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>So sánh vLLM và Ollama: khi nào nên dùng cái nào để tự triển khai LLM</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-30-vllm-vs-ollama/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-30-vllm-vs-ollama/</guid>
      <pubDate>Fri, 30 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>vLLM</category>
      <category>Ollama</category>
      <category>LLM</category>
      <category>Self-Hosted</category>
      <category>MLOps</category>
      <category>DevOps</category>
      <category>Docker</category>
      <category>Kubernetes</category>
      <description>So sánh vLLM và Ollama sau khi tôi dùng cả hai: Ollama cho laptop hay công cụ cá nhân, vLLM cho máy chủ GPU phục vụ nhiều yêu cầu. Kèm cách cài đặt, Docker, Kubernetes và những chỗ tôi vấp.</description>
      <content:encoded><![CDATA[<p>Nếu chỉ có một người, hoặc một ứng dụng trên một máy, cần có mô hình chạy được trong vài phút thì hãy dùng Ollama. Còn nếu một máy chủ GPU phải trả lời rất nhiều yêu cầu cùng lúc và thông lượng (throughput) là điều quan trọng thì hãy dùng vLLM. Ollama là công cụ chạy mô hình: nó tải về một mô hình đã lượng tử hóa (quantization), nạp mô hình vào bộ nhớ khi bạn gọi tới và giải phóng khi bạn thôi dùng. vLLM là máy chủ suy luận (inference server): ngay lúc khởi động, nó chiếm gần hết GPU, rồi giữ cho GPU luôn bận bằng cách gộp mọi yêu cầu đang xử lý vào chung một lô (batching). Cả hai đều hỗ trợ API của OpenAI, nên đoạn code gọi tới chúng có thể giống hệt nhau.</p>
<p>Ngày nào tôi cũng dùng cả hai. Ollama chạy trên laptop của tôi và trên máy trạm có GPU ở văn phòng, phục vụ việc phát triển và mấy công cụ tôi tự làm cho mình. Còn vLLM thì phục vụ một mô hình chat cho một dự án freelance, trên một GPU 96 GB duy nhất, lúc đầu chạy bằng Docker Compose và bây giờ chạy trong một cụm Kubernetes nhỏ.</p>

<h2 class="relative group">So sánh nhanh vLLM và Ollama
    <div id="so-sánh-nhanh-vllm-và-ollama" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#so-s%c3%a1nh-nhanh-vllm-v%c3%a0-ollama" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Ollama</th>
					<th>vLLM</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Phù hợp nhất cho</td>
					<td>Một người dùng: laptop, máy dev, công cụ cá nhân</td>
					<td>Nhiều người dùng hoặc một ứng dụng có nhiều yêu cầu, trên máy chủ GPU</td>
			</tr>
			<tr>
					<td>Chạy trên</td>
					<td>macOS, Linux, Windows; Apple silicon, NVIDIA, AMD hoặc CPU</td>
					<td>Suôn sẻ nhất là Linux với GPU NVIDIA; phần cứng khác vẫn chạy được nhưng tốn công hơn</td>
			</tr>
			<tr>
					<td>Mô hình</td>
					<td>GGUF đã lượng tử hóa, lấy từ thư viện riêng của Ollama (<code>qwen3:8b</code>)</td>
					<td>Trọng số (weights) từ Hugging Face ở dạng BF16, FP8, AWQ hoặc GPTQ (<code>Qwen/Qwen3-8B</code>); GGUF cũng nạp được nhưng chỉ ở mức thử nghiệm</td>
			</tr>
			<tr>
					<td>Cài đặt</td>
					<td>Một trình cài đặt, sau đó chạy <code>ollama run</code></td>
					<td><code>pip install vllm</code>, hoặc Docker image <code>vllm/vllm-openai</code></td>
			</tr>
			<tr>
					<td>Bộ nhớ GPU</td>
					<td>Nạp mô hình ở lần dùng đầu tiên, gỡ ra sau 5 phút không hoạt động</td>
					<td>Giữ chỗ một phần cố định của card ngay lúc khởi động (mặc định trên 90%) và không trả lại</td>
			</tr>
			<tr>
					<td>Nhiều yêu cầu cùng lúc</td>
					<td>Mặc định mỗi lần một yêu cầu; <code>OLLAMA_NUM_PARALLEL</code> thêm khe (slot)</td>
					<td>Gộp lô liên tục (continuous batching); đây chính là việc nó được làm ra để làm</td>
			</tr>
			<tr>
					<td>Khởi động</td>
					<td>Vài giây với mô hình nhỏ</td>
					<td>Vài phút với mô hình lớn</td>
			</tr>
			<tr>
					<td>LoRA adapter</td>
					<td>Phải gắn sẵn vào một mô hình mới bằng Modelfile</td>
					<td>Nhiều adapter trên cùng một máy chủ, chọn theo từng yêu cầu</td>
			</tr>
			<tr>
					<td>API</td>
					<td>API riêng, cộng thêm API tương thích OpenAI ở <code>:11434/v1</code></td>
					<td>Tương thích OpenAI ở <code>:8000/v1</code></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">Ollama là gì
    <div id="ollama-là-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ollama-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trong những cách tôi biết, Ollama là cách dễ nhất để có một mô hình trả lời được ngay trên máy của bạn. Nó được xây trên nền llama.cpp và chạy các mô hình GGUF đã lượng tử hóa, vì vậy một mô hình 8B chỉ chiếm khoảng 5 GB và chạy thoải mái trên một chiếc MacBook. Bạn tải mô hình về theo tên, mô hình được nạp vào bộ nhớ ở lần gọi đầu tiên, và sau năm phút không có việc gì làm thì nó tự rời khỏi bộ nhớ. Nhờ vậy, Ollama chia sẻ máy một cách lịch sự với mọi thứ khác bạn đang chạy.</p>
<p>Nhưng chính sự lịch sự đó cũng là cái giá phải trả. Ollama được thiết kế xoay quanh việc phục vụ từng người một. Mặc định nó chỉ xử lý mỗi lần một yêu cầu; <code>OLLAMA_NUM_PARALLEL</code> cho thêm khe xử lý, nhưng mỗi khe tốn trọn một ngữ cảnh bộ nhớ, và ngay từ đầu nó không được làm ra để gộp hàng chục người dùng lên cùng một GPU.</p>

<h2 class="relative group">vLLM là gì
    <div id="vllm-là-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#vllm-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>vLLM là một engine phục vụ mô hình (serving engine), và nó được làm ra chính là để gộp lô như vậy. Phần lớn sức mạnh của nó đến từ hai ý tưởng. Thứ nhất là PagedAttention: nó lưu KV cache của mỗi yêu cầu (bộ nhớ đệm chứa trạng thái attention mà mô hình tích lũy dần trong lúc đọc và sinh token) thành những khối nhỏ có kích thước cố định, nhờ đó bộ nhớ GPU không bị hao phí vì phần đệm (padding) và phân mảnh. Thứ hai là gộp lô liên tục: yêu cầu mới được đưa vào lô giữa các bước sinh token, thay vì phải chờ lô hiện tại chạy xong. Kết hợp hai ý tưởng này, một GPU chạy vLLM phục vụ được số người dùng đồng thời nhiều hơn hẳn so với chính GPU đó khi xử lý lần lượt từng yêu cầu một. Tôi chưa tự đo hai bên với nhau, nên bài này không có con số thông lượng (throughput) của riêng mình. Phiên bản đo thực tế của nhận định này là <a href="https://developers.redhat.com/articles/2025/08/08/ollama-vs-vllm-deep-dive-performance-benchmarking"  target="_blank" rel="noreferrer">bài benchmark Ollama vs. vLLM</a> của Red Hat: một card A100 chạy Llama 3.1 8B với 1 đến 256 người dùng đồng thời, dù đã nâng <code>OLLAMA_NUM_PARALLEL</code> lên 32, vLLM vẫn đạt đỉnh khoảng 793 token đầu ra mỗi giây còn Ollama là 41.</p>
<p>Cái giá phải trả là vLLM cư xử như thể nó là chủ của cả card GPU. Vừa khởi động, nó đã chiếm phần bộ nhớ GPU dành cho mình, dùng toàn bộ phần mà trọng số mô hình không cần đến để làm KV cache, và giữ hết chỗ đó dù có ai gọi tới hay không. Nó cũng đòi trọng số đầy đủ từ Hugging Face, hoặc một kiểu lượng tử hóa thân thiện với GPU như FP8 hay AWQ. Nó nạp được tệp GGUF, nhưng tài liệu của chính vLLM gọi phần hỗ trợ đó là &ldquo;highly experimental and under-optimized&rdquo;, nên đừng tính chuyện dùng lại các tệp đã tải bằng Ollama.</p>

<h2 class="relative group">Cần bao nhiêu bộ nhớ GPU
    <div id="cần-bao-nhiêu-bộ-nhớ-gpu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%e1%ba%a7n-bao-nhi%c3%aau-b%e1%bb%99-nh%e1%bb%9b-gpu" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Kích thước ước tính, chỉ tính riêng phần trọng số:</p>
<table>
	<thead>
			<tr>
					<th>Kích thước mô hình</th>
					<th>16-bit (BF16)</th>
					<th>8-bit (FP8)</th>
					<th>GGUF 4-bit (Q4_K_M)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>8B</td>
					<td>~16 GB</td>
					<td>~8 GB</td>
					<td>~5 GB</td>
			</tr>
			<tr>
					<td>32B</td>
					<td>~64 GB</td>
					<td>~32 GB</td>
					<td>~20 GB</td>
			</tr>
			<tr>
					<td>70B</td>
					<td>~140 GB</td>
					<td>~70 GB</td>
					<td>~43 GB</td>
			</tr>
	</tbody>
</table>
<p>Tức là khoảng 2 byte cho mỗi tham số ở 16-bit, 1 byte ở 8-bit, và hơn nửa byte một chút ở Q4_K_M. Cộng thêm vào phần trọng số là KV cache, thứ tăng theo độ dài ngữ cảnh và theo số yêu cầu đang được xử lý cùng lúc. Chính yếu tố sau, tức số yêu cầu chạy cùng lúc, là lý do cùng một mô hình lại cần nhiều bộ nhớ hơn hẳn khi chạy trên vLLM phục vụ năm mươi người dùng, so với khi chạy trên Ollama chỉ phục vụ mình bạn.</p>

<h2 class="relative group">Một API, hai máy chủ
    <div id="một-api-hai-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%99t-api-hai-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Vì cả hai đều cung cấp API chat completions của OpenAI, code của tôi không cần biết nó đang nói chuyện với bên nào. URL và tên mô hình được lấy từ biến môi trường:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">openai</span> <span class="kn">import</span> <span class="n">OpenAI</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Laptop:      LLM_URL=http://localhost:11434/v1  LLM_MODEL=qwen3:8b</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Máy chủ GPU: LLM_URL=http://localhost:8000/v1   LLM_MODEL=qwen3-8b</span>
</span></span><span class="line"><span class="cl"><span class="n">client</span> <span class="o">=</span> <span class="n">OpenAI</span><span class="p">(</span><span class="n">base_url</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;LLM_URL&#34;</span><span class="p">],</span> <span class="n">api_key</span><span class="o">=</span><span class="s2">&#34;not-needed&#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">reply</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">completions</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;LLM_MODEL&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">messages</span><span class="o">=</span><span class="p">[{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;Say hello in Korean.&#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="nb">print</span><span class="p">(</span><span class="n">reply</span><span class="o">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">content</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Trên laptop, vốn không có GPU mạnh, ứng dụng trỏ tới Ollama. Trên máy chủ thì nó trỏ tới vLLM. Ngoài hai biến đó ra không có gì thay đổi, và tôi có thể phát triển ứng dụng mà không cần bật máy GPU lên chút nào. Ở phía vLLM, <code>--served-model-name</code> đặt cho mô hình một cái tên ngắn, để client không phải gửi nguyên đường dẫn Hugging Face đầy đủ.</p>
<p>Có một điều cần lưu ý, vì rất dễ quên: cùng một mô hình nhưng chạy trên hai bên sẽ không cho ra cùng câu trả lời. <code>qwen3:8b</code> của Ollama là bản lượng tử hóa 4-bit, trong khi trên vLLM bạn thường chạy trọng số 16-bit hoặc FP8. Một prompt đã được tinh chỉnh trên bên này có thể cư xử khác trên bên kia, nên bất cứ thứ gì bạn đang đo, dù là chất lượng câu trả lời, việc mô hình có từ chối trả lời hay không, hay JSON có parse được không, đều phải được kiểm tra trên đúng máy chủ mà môi trường production sẽ thật sự dùng.</p>

<h2 class="relative group">Cách cài đặt Ollama
    <div id="cách-cài-đặt-ollama" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-c%c3%a0i-%c4%91%e1%ba%b7t-ollama" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên Mac hoặc Windows, hãy tải ứng dụng về từ ollama.com. Trên Linux thì chạy:</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 -fsSL https://ollama.com/install.sh <span class="p">|</span> sh
</span></span><span class="line"><span class="cl">ollama run qwen3:8b</span></span></code></pre></div></div>
<p>Thế là xong. API giờ đã có ở <code>http://localhost:11434</code>. Nó chỉ lắng nghe trên localhost, trừ khi bạn đặt <code>OLLAMA_HOST</code>, và như thế là đúng điều bạn muốn. Ollama của tôi chạy trên máy trạm ở văn phòng, bind vào <code>127.0.0.1</code>, và tôi truy cập nó từ bất cứ đâu qua một đường hầm (<a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >cách tôi thiết lập việc đó bằng Cloudflare WARP</a>).</p>

<h2 class="relative group">Cách cài đặt vLLM
    <div id="cách-cài-đặt-vllm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-c%c3%a0i-%c4%91%e1%ba%b7t-vllm" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên Linux, với GPU NVIDIA và driver đời mớ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">python -m venv .venv <span class="o">&amp;&amp;</span> <span class="nb">source</span> .venv/bin/activate
</span></span><span class="line"><span class="cl">pip install vllm
</span></span><span class="line"><span class="cl">vllm serve Qwen/Qwen3-8B --served-model-name qwen3-8b --max-model-len <span class="m">16384</span></span></span></code></pre></div></div>
<p>Lần chạy đầu tiên sẽ tải trọng số mô hình về từ Hugging Face. Khi log hiện dòng <code>Application startup complete</code>, lệnh <code>curl localhost:8000/v1/models</code> sẽ liệt kê mô hình, và đoạn Python ở trên chạy được với nó.</p>

<h2 class="relative group">Chạy vLLM bằng Docker
    <div id="chạy-vllm-bằng-docker" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-vllm-b%e1%ba%b1ng-docker" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với bất cứ thứ gì phải chạy liên tục, tôi dùng image chính thức thay vì cài bằng pip, chủ yếu để CUDA, PyTorch và vLLM đi kèm nhau với phiên bản đã được ghim cố định. Dưới đây là một service Compose dạng chung chung:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">vllm</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.30.0  </span><span class="w"> </span><span class="c"># tag tôi đã chạy; hãy ghim bản mới nhất hiện có. Image nặng khoảng 30 GB</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ipc</span><span class="p">:</span><span class="w"> </span><span class="l">host                         </span><span class="w"> </span><span class="c"># PyTorch cần bộ nhớ dùng chung (shared memory)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      --model Qwen/Qwen3-8B
</span></span></span><span class="line"><span class="cl"><span class="sd">      --served-model-name qwen3-8b
</span></span></span><span class="line"><span class="cl"><span class="sd">      --gpu-memory-utilization 0.85
</span></span></span><span class="line"><span class="cl"><span class="sd">      --max-model-len 16384</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cache/huggingface:/root/.cache/huggingface</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8000:8000&#34;</span><span class="w">          </span><span class="c"># truy cập qua ssh -L, không qua mạng LAN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">reservations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">nvidia</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">count</span><span class="p">:</span><span class="w"> </span><span class="l">all</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">capabilities</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">gpu]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;python3&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;import urllib.request; urllib.request.urlopen(&#39;http://localhost:8000/health&#39;)&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">90</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">120s</span></span></span></code></pre></div></div>
<p>Hãy ghim tag. vLLM thay đổi rất nhanh, các cờ (flag) và giá trị mặc định thay đổi giữa các bản phát hành, và bạn muốn chính mình là người quyết định khi nào những thay đổi đó xảy ra. <code>ipc: host</code> (hoặc một <code>shm_size</code> thật rộng rãi) có sẵn trong hướng dẫn Docker của chính vLLM. Còn lý do <code>start_period</code> dài như vậy chính là điều đầu tiên trong phần tiếp theo.</p>

<h2 class="relative group">Những chỗ khiến tôi vấp khi chạy vLLM
    <div id="những-chỗ-khiến-tôi-vấp-khi-chạy-vllm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-ch%e1%bb%97-khi%e1%ba%bfn-t%c3%b4i-v%e1%ba%a5p-khi-ch%e1%ba%a1y-vllm" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Khởi động mất vài phút
    <div id="khởi-động-mất-vài-phút" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#kh%e1%bb%9fi-%c4%91%e1%bb%99ng-m%e1%ba%a5t-v%c3%a0i-ph%c3%bat" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Nạp trọng số, biên dịch và ghi lại (capture) các CUDA graph mất 190 giây trên máy chủ của tôi, và với một mô hình lớn thì ba đến năm phút là chuyện bình thường. Một bài kiểm tra sức khỏe (health check) viết theo kiểu dành cho ứng dụng web sẽ giết nó trước khi nó kịp sẵn sàng. Trong Compose, điều đó có nghĩa là cần một <code>start_period</code> dài; trong Kubernetes thì cần một startup probe. Nó cũng có nghĩa là mỗi lần khởi động lại là vài phút không có mô hình nào phục vụ, nên đừng khởi động lại một cách tùy tiện.</p>

<h3 class="relative group">Bạn cho bao nhiêu bộ nhớ GPU, nó lấy bấy nhiêu, ngay từ lúc khởi động
    <div id="bạn-cho-bao-nhiêu-bộ-nhớ-gpu-nó-lấy-bấy-nhiêu-ngay-từ-lúc-khởi-động" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a1n-cho-bao-nhi%c3%aau-b%e1%bb%99-nh%e1%bb%9b-gpu-n%c3%b3-l%e1%ba%a5y-b%e1%ba%a5y-nhi%c3%aau-ngay-t%e1%bb%ab-l%c3%bac-kh%e1%bb%9fi-%c4%91%e1%bb%99ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p><code>--gpu-memory-utilization</code> là tỉ lệ bộ nhớ trên card mà vLLM được phép dùng, ở các bản gần đây mặc định là 0.92, và toàn bộ phần mà trọng số không cần đến sẽ trở thành KV cache. Như vậy không sao nếu vLLM chạy một mình trên card. Card của tôi thì không như vậy, vì có mấy mô hình nhỏ hơn chạy bên cạnh, nên tôi đặt 0.70 trên card 96 GB. Mô hình của tôi, một mô hình hỗn hợp chuyên gia (mixture-of-experts) 35B ở định dạng FP8, chiếm 47.7 GiB khi nạp xong và để lại 15 GiB cho KV cache, quá đủ cho độ dài ngữ cảnh (context) 16k. Nếu vLLM từ chối khởi động vì độ dài chuỗi tối đa không vừa trong KV cache, hãy giảm <code>--max-model-len</code> trước khi đi tìm một GPU lớn hơn.</p>

<h3 class="relative group">Mỗi chỗ dành cho LoRA đều tốn bộ nhớ ngay từ đầu
    <div id="mỗi-chỗ-dành-cho-lora-đều-tốn-bộ-nhớ-ngay-từ-đầ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="#m%e1%bb%97i-ch%e1%bb%97-d%c3%a0nh-cho-lora-%c4%91%e1%bb%81u-t%e1%bb%91n-b%e1%bb%99-nh%e1%bb%9b-ngay-t%e1%bb%ab-%c4%91%e1%ba%a7u" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>vLLM có thể phục vụ một mô hình gốc (base model) kèm nhiều LoRA adapter và chuyển qua lại giữa chúng theo từng yêu cầu, dựa vào tên mô hình, điều mà Ollama gần như không làm được. Nhưng lúc khởi động, nó giữ sẵn chỗ cho số adapter bằng <code>--max-loras</code>, mỗi adapter ở rank bằng <code>--max-lora-rank</code>, và trên một mô hình mixture-of-experts thì phần giữ chỗ đó bao gồm cả các expert, nên con số cộng dồn rất nhanh. Bốn adapter ở rank 64 không vừa khi đặt 0.70. Hai adapter thì vừa.</p>

<h3 class="relative group">Giới hạn số chuỗi mặc định không hợp với mô hình lai
    <div id="giới-hạn-số-chuỗi-mặc-định-không-hợp-với-mô-hình-lai" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%e1%bb%9bi-h%e1%ba%a1n-s%e1%bb%91-chu%e1%bb%97i-m%e1%ba%b7c-%c4%91%e1%bb%8bnh-kh%c3%b4ng-h%e1%bb%a3p-v%e1%bb%9bi-m%c3%b4-h%c3%acnh-lai" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mô hình tôi đang phục vụ trộn các lớp kiểu Mamba vào giữa các lớp attention, và mỗi chuỗi (sequence) cần một khối bộ nhớ đệm riêng cho những lớp đó. Giá trị <code>--max-num-seqs</code> mặc định của vLLM là 1024 trên card từ 70 GB trở lên (256 với card nhỏ hơn), nên trên card đó nó không khởi động được; đặt <code>--max-num-seqs 256</code> là hết lỗi. Nếu một kiến trúc mô hình mới hơn không chịu khởi động, hãy kiểm tra cờ này trước khi nghĩ tới những nguyên nhân cao siêu hơn.</p>

<h3 class="relative group">Một Service Kubernetes tên <code>vllm</code> làm vLLM sập
    <div id="một-service-kubernetes-tên-vllm-làm-vllm-sậ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="#m%e1%bb%99t-service-kubernetes-t%c3%aan-vllm-l%c3%a0m-vllm-s%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đây là vụ tôi khoái nhất. Kubernetes tự chèn biến môi trường cho mọi Service trong cùng namespace, đặt tên theo tên Service, nên một Service tên <code>vllm</code> sẽ đưa cho mọi pod biến <code>VLLM_PORT=tcp://&lt;cluster-ip&gt;:8000</code>. vLLM đọc <code>VLLM_PORT</code> như một thiết lập của chính nó, không hiểu nổi một URL nằm ở đó, và chết. Dĩ nhiên là chết sau khi đã nạp xong toàn bộ mô hình, nên mỗi lần thử tốn ba phút. Tài liệu của vLLM có cảnh báo chuyện này, nhưng tôi chỉ đọc thấy sau khi đã dính. Cách sửa là đặt <code>enableServiceLinks: false</code> trên pod, hoặc đặt cho Service một cái tên khác.</p>
<p>Dưới đây là khung của một Deployment chạy được, đã lược bớt chỉ còn những phần quan trọng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">strategy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">Recreate              </span><span class="w"> </span><span class="c"># pod cũ phải giải phóng GPU trước</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">enableServiceLinks</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">  </span><span class="c"># không nhận VLLM_PORT=tcp://... từ Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.30.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">args</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;--model=Qwen/Qwen3-8B&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--served-model-name=qwen3-8b&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">nvidia.com/gpu</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">32Gi      </span><span class="w"> </span><span class="c"># lúc nạp cần nhiều RAM máy host hơn hẳn lúc phục vụ</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">volumeMounts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- {<span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="nt">shm, mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/shm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">startupProbe</span><span class="p">:</span><span class="w">          </span><span class="c"># đừng phán xét trước khi mô hình nạp xong</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w"> </span>{<span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="nt">/health, port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">initialDelaySeconds</span><span class="p">:</span><span class="w"> </span><span class="m">120</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">90</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">livenessProbe</span><span class="p">:</span><span class="w">         </span><span class="c"># khởi động lại là nạp lại từ đầu, nên hãy kiên nhẫn</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w"> </span>{<span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="nt">/health, port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">timeoutSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">shm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">emptyDir</span><span class="p">:</span><span class="w"> </span>{<span class="nt">medium</span><span class="p">:</span><span class="w"> </span><span class="nt">Memory, sizeLimit</span><span class="p">:</span><span class="w"> </span><span class="l">16Gi}</span></span></span></code></pre></div></div>
<p>Phần đáng sao chép nhất là các probe. Startup probe chờ hai phút, rồi cho mô hình thêm mười lăm phút nữa để khởi động xong, trước khi Kubernetes coi nó là đã chết. Liveness probe chỉ khởi động lại pod sau khoảng năm phút thất bại liên tục, vì <code>/health</code> có thể phản hồi chậm khi engine đang bận, và dù sao thì mỗi lần khởi động lại cũng tốn một lần nạp lại toàn bộ. <code>Recreate</code> rất quan trọng khi chỉ có một GPU: cập nhật cuốn chiếu (rolling update) sẽ khởi động pod mới trước, và pod đó cứ nằm chờ một GPU mà pod cũ vẫn đang giữ. Giới hạn bộ nhớ trông có vẻ hào phóng, vì nó đúng là hào phóng thật; khi trọng số đã nằm trên GPU, pod của tôi ổn định ở khoảng 7 GiB RAM của máy host, nhưng riêng quá trình nạp thì cần nhiều hơn thế rất nhiều.</p>

<h2 class="relative group">Những điều nên biết về Ollama
    <div id="những-điều-nên-biết-về-ollama" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-%c4%91i%e1%bb%81u-n%c3%aan-bi%e1%ba%bft-v%e1%bb%81-ollama" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với Ollama thì ít bất ngờ hơn, chủ yếu là vì nó làm ít việc hơn.</p>
<p>Yêu cầu đầu tiên sau một khoảng thời gian không ai dùng sẽ chậm, vì mô hình đã bị gỡ khỏi bộ nhớ và phải nạp lại. <code>OLLAMA_KEEP_ALIVE</code> quy định một mô hình được giữ trong bộ nhớ bao lâu sau yêu cầu cuối cùng; hãy tăng giá trị này lên nếu khoảng chờ đó làm bạn khó chịu, hoặc đặt thành <code>-1</code> trên một máy không làm việc gì khác.</p>
<p>Cửa sổ ngữ cảnh (context window) phụ thuộc vào lượng bộ nhớ GPU bạn có. Dưới 24 GiB, Ollama mặc định chỉ dùng 4k token, ít hơn nhiều so với mức mà phần lớn mô hình hỗ trợ, và một prompt không vừa sẽ bị cắt bớt một cách lặng lẽ chứ không bị từ chối. Nếu câu trả lời có vẻ phớt lờ phần đầu của một prompt dài, hãy đặt <code>OLLAMA_CONTEXT_LENGTH</code> (hoặc <code>num_ctx</code> cho từng yêu cầu) bằng đúng mức bạn thật sự cần.</p>
<p>Nếu bạn chuyển tiếp (forward) một Ollama ở máy từ xa về laptop, đừng đặt nó ở cổng 11434. Đó là cổng mặc định của Ollama, và nếu có lúc một Ollama cục bộ trên laptop khởi động, hai bên sẽ tranh nhau cổng đó, và bạn có thể nói chuyện nhầm với bên kia mà không hề hay biết. Tôi chuyển tiếp Ollama của mình về cổng 11435.</p>
<p>Và Ollama không phải là máy chủ cho nhiều người dùng. Dùng cho một công cụ nội bộ chỉ vài người sử dụng thì ổn; khi lượng truy cập đồng thời thật sự xuất hiện, đó là lúc nên chuyển sang vLLM.</p>

<h2 class="relative group">Nên dùng cái nào
    <div id="nên-dùng-cái-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%c3%aan-d%c3%b9ng-c%c3%a1i-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn chỉ muốn có một mô hình chạy trên máy của chính mình, hãy cài Ollama và dừng ở đó; trong vòng mười phút là bạn đã có thứ trả lời được. Nếu bạn có một máy chủ GPU, cùng với người dùng hoặc một ứng dụng sẽ gọi tới nó suốt cả ngày, hãy dùng vLLM, chấp nhận việc nó khởi động chậm và chiếm nhiều bộ nhớ, rồi cấu hình các probe cho đúng. Rất nhiều hệ thống cuối cùng dùng cả hai, hệ thống của tôi cũng vậy: Ollama ở nơi viết code, vLLM ở nơi các yêu cầu đổ về.</p>
<p>Ngoài hai cái này vẫn còn những lựa chọn khác. SGLang là phương án thay thế vLLM mà người ta hay nhắc tới, nhưng tôi chưa chạy nó trên production, nên tôi để nó ra ngoài bài này thay vì đoán mò. Còn nếu mô hình phải chạy trên điện thoại thì lại quay về llama.cpp, như tôi đã viết trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-01-30-on-device-ai/" >chạy LLM ngay trên thiết bị với React Native</a>.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-30-vllm-vs-ollama/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Docker Compose trong CI GitHub Actions: tôi đã thay nó bằng gì, và các pipeline tôi đang chạy</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>Các pipeline GitHub Actions tôi chạy năm 2026: service container thay cho Docker Compose trong CI, cache chọn theo thời gian từng bước, và triển khai bằng script.</description>
      <content:encoded><![CDATA[<p>Quy trình CI/CD (CI/CD pipeline) là con đường tự động mà một thay đổi đi từ lúc <code>git push</code> cho đến khi lên production: CI dựng (build) và kiểm thử mọi lần push trên một máy sạch, còn CD phát hành những gì đã qua bằng một bước duy nhất, lặp lại được. Trên GitHub Actions, đó là một tệp YAML trong <code>.github/workflows/</code>, và các quy trình của tôi được cố ý làm mỏng: phần kiểm tra chạy đúng những lệnh tôi vẫn gõ trên laptop, còn mọi khâu triển khai đều là script hoặc CLI, không có GitHub thì vẫn chạy y như vậy.</p>
<p>Nếu bạn vào đây để tìm cách chạy Docker Compose trong GitHub Actions: tôi đã làm thế suốt hai năm rồi bỏ. Giờ CI khai báo cùng các image Postgres và Valkey đó dưới dạng <code>services:</code> và chạy thẳng trên runner đúng các lệnh <code>uv run …</code> và <code>npm run …</code>; Compose quay về làm thứ lập trình viên chạy ở máy mình. Phần còn lại là lý do, kèm các tệp.</p>
<p>Năm 2024 tôi có viết bài <a href="https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/" >Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng</a>, với ý chính là để logic dựng trong Compose, còn công cụ CI chỉ việc gọi nó. Hai năm sau, tôi xem lại toàn bộ các kho mã mà tôi đang dùng để phát hành. Có bốn kho có workflow, kho nào cũng chỉ có một người commit là tôi, và trong số các workflow đó không còn cái nào gọi <code>docker compose</code> nữa. Nguyên tắc thì vẫn đứng vững, còn cách làm thì không. Dưới đây là những thứ đã thay thế nó: phiên bản ví dụ đã cắt gọn của các tệp, thời gian từng bước mà GitHub ghi lại, và cả những phần ít tự động hơn so với mong đợi của mấy sơ đồ CI/CD.</p>

<h2 class="relative group">Mọi quy trình tôi đang chạy, gói trong một bảng
    <div id="mọi-quy-trình-tôi-đang-chạy-gói-trong-một-bảng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%8di-quy-tr%c3%acnh-t%c3%b4i-%c4%91ang-ch%e1%ba%a1y-g%c3%b3i-trong-m%e1%bb%99t-b%e1%ba%a3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Quy trình</th>
					<th>Bắt đầu bởi</th>
					<th>CI kiểm tra gì</th>
					<th>Đi đến đâu</th>
					<th>Ai phát hành</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Blog này và trang Hugo của một khách hàng</td>
					<td>Mọi lần push, trên bất kỳ nhánh nào</td>
					<td><code>hugo --gc</code> dựng được hay không</td>
					<td>Cloudflare Pages: <code>main</code> là production, các nhánh khác có URL xem trước</td>
					<td>Không ai cả; push chính là phát hành</td>
			</tr>
			<tr>
					<td>API của ứng dụng chợ mà tôi đang làm</td>
					<td>Một script triển khai, chạy tay</td>
					<td>Lint, kiểm tra kiểu và bộ kiểm thử Django, mỗi lần push lên <code>main</code></td>
					<td>Fly.io, triển khai cuốn chiếu (rolling deploy)</td>
					<td>Tôi</td>
			</tr>
			<tr>
					<td>Giao diện của chính ứng dụng đó: web, cập nhật OTA, bản dựng cho store</td>
					<td>Các script phát hành và bản dựng store trên máy, chạy tay</td>
					<td>TypeScript và Jest, mỗi lần push lên <code>main</code></td>
					<td>Kho lưu trữ đối tượng (object storage), một dịch vụ cập nhật OTA và hai kho ứng dụng</td>
					<td>Tôi</td>
			</tr>
			<tr>
					<td>pyhtml2text, gói Python mã nguồn mở của tôi</td>
					<td>Mọi lần push, pull request và tag</td>
					<td>Wheel cho 15 nền tảng đích, kiểm thử trên phần lớn trong số đó</td>
					<td>PyPI, khi gắn tag</td>
					<td>Cái tag</td>
			</tr>
	</tbody>
</table>
<p>Nói theo sách vở thì hai trang Hugo là triển khai liên tục, còn ứng dụng kia (một ứng dụng chợ kiêm cộng đồng mà tôi đang làm) là tích hợp liên tục cộng với phát hành bằng đúng một lệnh, tức là chuyển giao liên tục: phần kiểm tra báo cho tôi biết khi có gì sai, nhưng không chặn tôi triển khai. Tôi cố ý chia như vậy. Một bản cập nhật OTA sẽ đến mọi điện thoại đang dùng phiên bản ứng dụng mà nó nhắm tới, nên việc phát hành phải là một quyết định, chứ không phải tác dụng phụ của một lần merge. Còn API là nơi chạy migration cơ sở dữ liệu. Hai ứng dụng Django còn lại của tôi thì hoàn toàn không có workflow nào.</p>

<h2 class="relative group">Phần CI: một workflow, hai job
    <div id="phần-ci-một-workflow-hai-job" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%e1%ba%a7n-ci-m%e1%bb%99t-workflow-hai-job" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ CI của ứng dụng nằm trong một tệp, <code>.github/workflows/checks.yml</code>. Trước ngày 11 tháng 10, ngay cả tệp này cũng chưa có. Đây là một ví dụ đã cắt gọn cho thấy hình dạng của nó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>Hai job chạy song song. Trong 35 lần chạy thành công tính đến giờ, trung vị của job ứng dụng là khoảng bảy phút, còn job Python là khoảng mười hai phút rưỡi.</p>
<p>Khối <code>services:</code> chính là chỗ tệp Compose đã chuyển tới, phần sau sẽ nói rõ. Môi trường chỉ có vài biến và một cờ. Mọi thứ còn lại đều rơi về giá trị mặc định cho môi trường phát triển mà lập trình viên có được khi chạy <code>docker compose up</code>, nên bộ kiểm thử không cần bí mật (secret) nào. Để chắc chắn, tôi đã chạy thử một phần bộ kiểm thử với đúng môi trường đó và không có gì khác.</p>
<p>CI chạy cùng trình thông dịch với laptop của tôi và với image production, tức là bản Python 3.15.0 do chính uv dựng, vì <code>uv sync</code> đọc <code>.python-version</code> và tải về đúng bản đó. (Tệp thật của tôi còn truyền cho setup-uv một tham số <code>python-version-file</code>, thật ra chẳng làm gì cả: setup-uv v6 không có tham số nào như vậy, và lần chạy nào cũng kèm một cảnh báo nói đúng điều đó. Viết bài này tôi mới để ý.)</p>
<p>Việc dùng chung trình thông dịch đã chứng minh giá trị chỉ tám ngày sau khi workflow ra đời. Ngày 19 tháng 10, tôi bật trình biên dịch JIT của 3.15, và ngay lần chạy đầu tiên có JIT, một bài kiểm thử đã thất bại: một câu <code>yield from</code> rất bình thường, nằm trên đoạn code mà lần lưu dữ liệu nào của ứng dụng cũng đi qua, lại ném ra <code>AttributeError: 'list' object has no attribute 'send'</code>. Cùng commit đó mà tắt JIT thì qua. Máy Mac của tôi là arm64 nên chưa bao giờ gặp lỗi này; còn máy chạy CI (runner) là x86_64, giống các máy production. JIT giờ đã được tắt lại, cả trong CI lẫn trong Dockerfile, và lỗi này chưa bao giờ lọt lên production. Đó là lỗi python/cpython#155823, đã được sửa ở upstream vào ngày 13 tháng 10, sau khi 3.15.0 phát hành.</p>
<p>Thật ra ngay lần chạy đầu tiên cũng đã thất bại, nhưng vì một lý do tẻ nhạt hơn. Expo tạo tệp <code>expo-env.d.ts</code> khi bạn chạy <code>expo start</code>, mà tệp này lại nằm trong gitignore. Vì vậy bản làm việc nào tôi từng dùng cũng có nó, còn một bản checkout mới tinh thì không. TypeScript báo năm lỗi chưa từng xuất hiện trên máy tôi. Bước tạo tệp đó chính là cách sửa.</p>
<p><code>concurrency</code> sẽ hủy lần chạy cũ khi có một lần push mới hơn lên cùng nhánh. Cả mười hai lần chạy bị hủy tính đến giờ đều kết thúc theo cách này. Một hook trước commit (pre-commit) chạy công cụ lint, và chạy thêm bộ kiểm tra kiểu khi có tệp Python hoặc TypeScript được stage, nên phần lớn lỗi không đi được tới lúc push.</p>

<h2 class="relative group">Tệp Docker Compose đã đi đâu
    <div id="tệp-docker-compose-đã-đi-đâu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%87p-docker-compose-%c4%91%c3%a3-%c4%91i-%c4%91%c3%a2u" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là phần trong tệp <code>docker-compose.yml</code> của ứng dụng mà CI quan tâm, viết lại dưới dạng ví dụ. Tệp thật còn nhiều dịch vụ hơn và nhiều biến môi trường hơn hẳn.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p>Đặt nó cạnh khối <code>services:</code> trong <code>checks.yml</code> thì thấy đó là cùng hai image: Postgres 17 (ở đây là bản Alpine, trong CI là bản Debian) và <code>valkey/valkey:8-alpine</code>. Quan hệ giữa hai bên bây giờ là vậy. Tệp Compose là thứ lập trình viên chạy; còn CI khai báo cùng các image đó dưới dạng service container, rồi chạy thẳng trên runner đúng những lệnh mà lập trình viên vẫn chạy, <code>uv run …</code> và <code>npm run …</code>.</p>
<p>So với bài năm 2024, phần tôi quan tâm nhất vẫn đứng vững: công cụ CI vẫn mỏng, và các bước kiểm tra lẫn triển khai đều là những lệnh tôi tự chạy được. Thứ không trụ lại được là để CI gọi Compose.</p>
<p>CI của ứng dụng hoàn toàn không dựng Docker image. Image được dựng đúng một lần, lúc triển khai, trên máy dựng (builder) của Fly, còn CI chỉ cần trả lời code có ổn không, và khi bộ công cụ đã có sẵn trong bộ nhớ đệm (cache) thì việc đó chỉ tốn sáu giây cài đặt. Ngoài ra, tệp Compose còn bind-mount bản làm việc và nạp dữ liệu mẫu, những việc một lần chạy kiểm thử không nên làm. Vậy nên CI sẽ cần một tệp Compose riêng, tức là một bản mô tả thứ hai cho cùng các dịch vụ, phải giữ cho khớp nhau bằng tay.</p>
<p>Thêm nữa, ví dụ năm 2024 có một lỗi mà lúc đó tôi không để ý. <code>docker compose build</code> chỉ dựng image, chứ không bao giờ chạy <code>command</code> của dịch vụ. Thế nên lệnh <code>npm test</code> trong tệp đó chưa từng chạy trong CI, và workflow vẫn xanh dù kiểm thử có qua hay không.</p>
<p>Rủi ro mà bài đó lo nhất, tức là đổi nhà cung cấp CI, rốt cuộc chưa bao giờ xảy ra. Cuộc chuyển dịch thật sự lại diễn ra ở đầu bên kia: blog này và trang của khách hàng kia đã đổi nơi lưu trữ sang Cloudflare Pages, và với blog thì chỉ cần viết mới một workflow dài 45 dòng, vì khâu dựng vốn đã chỉ là một lệnh. Còn cái workflow ba trăm dòng mà tôi từng cảnh báo? Tôi có đúng một cái, của pyhtml2text. Nó dài vì wheel phải được dựng trên runner Linux, macOS và Windows, mỗi loại cho nhiều kiến trúc, và cũng vì vậy mà đây là việc duy nhất mà YAML riêng của nền tảng là công cụ đúng.</p>

<h2 class="relative group">Bộ nhớ đệm: đọc thời gian từng bước trước đã
    <div id="bộ-nhớ-đệm-đọc-thời-gian-từng-bước-trước-đã" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-%c4%91%e1%bb%8dc-th%e1%bb%9di-gian-t%e1%bb%abng-b%c6%b0%e1%bb%9bc-tr%c6%b0%e1%bb%9bc-%c4%91%c3%a3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>GitHub ghi lại thời gian của từng bước, và <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> sẽ trả về cho bạn. Đây là số liệu từ lần chạy thành công gần nhất của ứng dụng, ngày 20 tháng 10:</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>Bước</th>
					<th>Giây</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Khởi động Postgres và Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, đã có cache</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Kiểm thử Django</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, đã có cache</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>Cả hai bộ nhớ đệm cho thư viện phụ thuộc đều đang bật, và cả hai đều là phần rẻ. Bộ nhớ đệm uv mà GitHub giữ cho kho này chỉ khoảng 200 KB, vì setup-uv dọn đi những gì uv chỉ cần tải lại là xong; còn của npm thì gần 400 MB. Thời gian nằm ở bộ kiểm thử, mà bộ kiểm thử thì đã chạy song song bốn luồng rồi. Muốn chạy nhanh hơn, tôi sẽ sửa bộ kiểm thử chứ không phải bộ nhớ đệm.</p>
<p>Blog thì ngược lại. Ảnh đã được Hugo xử lý nằm trong thư mục <code>resources/_gen</code> vốn bị gitignore, nên runner mới nào cũng phải mã hóa lại toàn bộ ảnh bìa, ảnh trong bài và ảnh giữ chỗ. Workflow ở phía dưới khôi phục chúng bằng <code>actions/cache</code>, với khóa là mã băm của chính các bức ảnh; nếu khóa không khớp thì dùng bản cache cũ mới nhất, vì Hugo chỉ xử lý phần còn thiếu. Trong tám lần chạy cuối cùng trước khi có cache, bước dựng mất từ 33 đến 76 giây; trong 26 lần kể từ đó là 6 đến 20 giây, chỉ một lần ngoại lệ là 94 giây, dù chính commit đó còn thêm ảnh giữ chỗ và một cỡ thẻ thứ hai.</p>
<p>Image của ứng dụng được dựng trên builder của Fly: các tệp khai báo thư viện phụ thuộc được chép vào trước code, còn bộ nhớ đệm tải về của uv nằm trong một cache mount của BuildKit (<code>RUN --mount=type=cache,target=/root/.cache/uv</code>). Nhưng cải thiện lớn nhất ở đây lại không phải là bộ nhớ đệm. Tệp <code>.dockerignore</code> cũ là danh sách chặn (blocklist), và nó bỏ sót một thư mục chứa các git worktree phụ, mỗi worktree lại có thư viện phụ thuộc và sản phẩm dựng của riêng nó. Kết quả là 7,7 GB với 277.000 tệp bị đẩy lên builder mỗi lần triển khai. Viết lại thành danh sách cho phép (allowlist), ngữ cảnh dựng giờ chỉ còn khoảng 40 MB. Một ví dụ cho cách làm này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<h2 class="relative group">Bí mật, đặt ở càng ít chỗ càng tốt
    <div id="bí-mật-đặt-ở-càng-ít-chỗ-càng-tốt" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c3%ad-m%e1%ba%adt-%c4%91%e1%ba%b7t-%e1%bb%9f-c%c3%a0ng-%c3%adt-ch%e1%bb%97-c%c3%a0ng-t%e1%bb%91t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bí mật nằm ở ba nơi. Bí mật của GitHub Actions dành cho những việc workflow làm, và hai kho Hugo mỗi kho chỉ có đúng một cái, <code>CLOUDFLARE_API_TOKEN</code>. Bí mật của Fly là cấu hình lúc chạy của ứng dụng, được đặt bằng <code>flyctl secrets set</code>, và không bao giờ đi qua Actions. Cuối cùng là tệp <code>.env</code> bị gitignore trên laptop của tôi, chứa token mà script triển khai dùng để xóa cache của Cloudflare.</p>
<p>PyPI thì hoàn toàn không cần bí mật. pyhtml2text dùng cơ chế trusted publishing: PyPI chấp nhận token OIDC ngắn hạn mà GitHub cấp cho workflow, nên job phát hành chỉ xin quyền <code>id-token: write</code>, và trang bí mật của kho thì trống trơn. Không có gì để rò rỉ, cũng không có gì phải xoay vòng.</p>
<p>Token chỉ được cấp đúng quyền cần thiết. Token Fly của ứng dụng trên GitHub phục vụ một bước kiểm tra định kỳ chỉ liệt kê máy, nên nó được tạo bằng <code>fly tokens create readonly</code>. Các lần triển khai lên Pages chỉ xin <code>contents: read</code> và <code>deployments: write</code>, không hơn.</p>
<p>Dữ liệu đầu vào luôn đi qua <code>env:</code>, không bao giờ nhét thẳng vào script. Workflow OTA nhận ghi chú phát hành dạng văn bản tự do. Nếu viết <code>${{ inputs.notes }}</code> ngay trong <code>run:</code>, đoạn văn bản sẽ được dán thẳng vào script trước khi bash đọc, nên một ghi chú có dấu nháy và dấu chấm phẩy sẽ biến thành code. Truyền qua biến môi trường thì nó vẫn chỉ là dữ liệu. Một ví dụ cho cách làm này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>Có những khóa tôi để hẳn bên ngoài GitHub. Một bản báo cáo định kỳ cần một khóa có quyền quản trị phần đăng nhập của ứng dụng, nên nó đã chuyển từ một workflow định kỳ sang một tác vụ định kỳ trên máy Mac của tôi. Workflow vẫn còn đó làm phương án dự phòng chạy tay, và bỏ qua những mục cần đến khóa đó.</p>

<h2 class="relative group">Triển khai: mỗi lần push, bằng script, và theo tag
    <div id="triển-khai-mỗi-lần-push-bằng-script-và-theo-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tri%e1%bb%83n-khai-m%e1%bb%97i-l%e1%ba%a7n-push-b%e1%ba%b1ng-script-v%c3%a0-theo-tag" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Các trang Hugo được triển khai mỗi lần push
    <div id="các-trang-hugo-được-triển-khai-mỗi-lần-push" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-trang-hugo-%c4%91%c6%b0%e1%bb%a3c-tri%e1%bb%83n-khai-m%e1%bb%97i-l%e1%ba%a7n-push" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đây là phiên bản ví dụ của workflow cho blog, đã lược bỏ bước tạo dự án Pages ở lần chạy đầu tiên:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> làm cho <code>main</code> thành production và cho mọi nhánh khác một URL xem trước. Workflow cho trang của khách hàng có cùng cấu trúc, chỉ thêm một dòng <code>rm -rf public</code> trước khi dựng, vì bên đó thư mục <code>public/</code> được commit, nếu không thì các trang đã xóa sẽ cứ nằm lại trong bản tải lên.</p>
<p>Phiên bản Hugo chỉ được ghim ở đây, không ở đâu khác. Tháng 10, một lần cập nhật của trình quản lý gói đã nâng Hugo trên laptop của tôi lên 0.168, cao hơn một bậc so với bản mới nhất mà theme hỗ trợ. Các lần triển khai vẫn ở 0.165 và chẳng hề hấn gì.</p>
<p>Và bước dựng chính là bài kiểm thử. Ngày 14 tháng 9, bản triển khai xem trước của một nhánh thất bại ba lần liên tiếp: một bài viết trên nhánh đó dùng <code>ref</code> của Hugo để liên kết tới một bài vẫn còn là bản nháp. CI dựng đúng như production dựng, tức là bỏ bản nháp ra, nên liên kết trỏ vào hư không và Hugo dừng lại với lỗi <code>REF_NOT_FOUND</code>. Trong lúc đó, <code>main</code> vẫn triển khai bình thường.</p>

<h3 class="relative group">API của ứng dụng được triển khai bằng script
    <div id="api-của-ứng-dụng-được-triển-khai-bằng-script" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#api-c%e1%bb%a7a-%e1%bb%a9ng-d%e1%bb%a5ng-%c4%91%c6%b0%e1%bb%a3c-tri%e1%bb%83n-khai-b%e1%ba%b1ng-script" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần cốt lõi của script triển khai, viết dưới dạng ví dụ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>Và các dòng tương ứng trong <code>fly.toml</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>Image được dựng đúng một lần, đẩy lên registry của Fly với một nhãn ghép từ commit và thời điểm, rồi được triển khai theo đúng nhãn đó, nên thứ được tung ra chính xác là thứ đã được dựng.</p>
<p>Thứ giúp triển khai không gián đoạn (zero-downtime) là việc đánh thức một máy dự phòng trước. <code>max_unavailable = 1</code> chỉ đếm những máy đang chạy, mà ứng dụng lại không giữ máy nào chạy khi rảnh (một máy đang tạm dừng chỉ mất khoảng một giây để tỉnh lại). Vì vậy, trước đây cái máy duy nhất đang thức bị thay cùng đợt với các máy đang ngủ, và đằng sau nó không còn máy nào sẵn sàng. Ngày 28 tháng 9, tôi cho một đầu dò gửi yêu cầu liên tục tới origin: khi chạy <code>flyctl deploy</code> trơn thì có 7 lần hết thời gian chờ và một khoảng trống 7,3 giây, còn với script triển khai, vốn dựng trước và có sẵn một máy đang thức trước khi bắt đầu tung ra, thì không có lỗi nào trong 2.514 yêu cầu.</p>
<p>Phần còn lại của script đánh dấu lần triển khai trên kênh chat của đội, nơi các cảnh báo lỗi gửi về, để khi lỗi dồn dập thì có thể đối chiếu với thứ vừa thay đổi, rồi xóa cache Cloudflare và làm nóng các máy. Giao diện web thì hoàn toàn không đi qua image; chuyện đó tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/" >Triển khai frontend và backend riêng biệt, không cần triển khai lại API</a>.</p>
<p>Migration không phải là một bước trong quy trình. <code>release_command</code> của Fly bị treo trong trường hợp của tôi, nên máy đầu tiên khởi động với image mới sẽ giữ khóa và chạy migration, trong khi các máy khác chờ. Vì thế, migration nào cũng phải tương thích với code vẫn đang chạy: thêm cột ở lần triển khai này, ngừng dùng nó ở lần sau, và xóa nó ở lần thứ ba.</p>
<p>Script này không có chỗ nào bắt buộc phải là laptop của tôi, nhưng có vài tệp chỉ nằm trên máy đang giữ nó ở đó: thông tin đăng nhập Cloudflare, và một bản ghi về thứ đã được triển khai lần gần nhất, thứ mà một runner mới tinh sẽ không có. Nó còn triển khai cả những thay đổi chưa commit, và có báo điều đó trên kênh chat, trong khi script phát hành web thì từ chối một cây làm việc chưa sạch.</p>
<p>Trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Môi trường xem trước cho từng nhánh</a>, tôi viết rằng làm những việc này bằng tay thì ổn khi chỉ có một mình bạn làm, nhưng từ lập trình viên thứ hai trở đi thì nó phải xảy ra vì có gì đó xảy ra trong git. Ứng dụng vẫn chỉ có một lập trình viên. Các workflow môi trường theo nhánh trong bài đó vẫn nằm trên chính nhánh mà tôi đã dựng chúng.</p>
<p>Workflow OTA chỉ chạy tay, vì việc phát hành phải là một quyết định, và vì dịch vụ cập nhật từ chối bản phát hành mới khi đang có một đợt tung ra từng phần (partial rollout) còn mở, nên nếu kích hoạt theo push thì đợt canary nào cũng sẽ vướng. Workflow này chưa từng chạy lần nào. Mọi bản cập nhật OTA cho đến nay đều được đẩy đi từ laptop của tôi, qua chính script mà workflow gọi. Lý do nên giữ workflow mỏng gói gọn trong một câu: phát hành bắt đầu bằng cách nào thì cũng là cùng một script.</p>

<h3 class="relative group">pyhtml2text phát hành theo tag
    <div id="pyhtml2text-phát-hành-theo-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pyhtml2text-ph%c3%a1t-h%c3%a0nh-theo-tag" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Job phát hành, với tên gói đã được đổi thành tên chung chung:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>Job phát hành chờ mọi job dựng xong, và chỉ khi có tag mới thật sự xuất bản; chạy tay thì chỉ dựng và tạo chứng thực (attestation) chứ không tải gì lên. <code>actions/attest</code> ghi lại workflow nào đã dựng từng wheel, còn <code>uv publish --trusted-publishing always</code> tải chúng lên bằng token OIDC thay vì mật khẩu. Bản thân gói thư viện này tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-18-pyhtml2text/" >Một pull request bị đóng, một bản fork và một gói PyPI</a>.</p>

<h2 class="relative group">Workflow định kỳ chỉ là &ldquo;cố gắng hết sức&rdquo;
    <div id="workflow-định-kỳ-chỉ-là-cố-gắng-hết-sức" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#workflow-%c4%91%e1%bb%8bnh-k%e1%bb%b3-ch%e1%bb%89-l%c3%a0-c%e1%bb%91-g%e1%ba%afng-h%e1%ba%bft-s%e1%bb%a9c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng có một workflow chạy định kỳ, kiểm tra xem Fly có bật nhiều máy hơn mức cơ bản hay không, chuyện mà nếu không kiểm tra thì sẽ chỉ lộ ra trên hóa đơn. Nó cảnh báo bằng cách cố ý thất bại, vì GitHub sẽ gửi email cho chủ kho khi một lần chạy định kỳ thất bại.</p>
<p>Lịch được đặt là <code>cron: '*/15 * * * *'</code>, tức 96 lần một ngày. Vậy mà trong hai tuần qua, mỗi ngày nó chỉ chạy từ ba đến tám lần, với khoảng cách trung vị giữa các lần chạy khoảng ba tiếng bốn mươi phút. Lời giải thích duy nhất trong tài liệu của GitHub là tải: lần chạy định kỳ có thể bị trễ khi Actions bận, và bị bỏ qua khi bận đến một mức nào đó. Với một đợt mở rộng quy mô kéo dài, vài tiếng là đủ. Còn với thứ gì cần báo trong vòng vài phút, cron của GitHub không phải là công cụ giám sát.</p>

<h2 class="relative group">Những câu tôi sẽ hỏi về một quy trình CI/CD
    <div id="những-câu-tôi-sẽ-hỏi-về-một-quy-trình-cicd" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-c%c3%a2u-t%c3%b4i-s%e1%ba%bd-h%e1%bb%8fi-v%e1%bb%81-m%e1%bb%99t-quy-tr%c3%acnh-cicd" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Dù bạn đang dựng một quy trình mới hay xem lại quy trình đang có:</p>
<ol>
<li>Nếu GitHub Actions ngừng hoạt động cả một ngày, bạn có còn phát hành được không? Nếu không, logic phát hành của bạn đang nằm trong YAML, và bài năm 2024 vẫn còn đúng.</li>
<li>CI có chạy cùng trình thông dịch, cùng kiến trúc CPU và cùng phiên bản chính của cơ sở dữ liệu với production không? CI của tôi bắt được một lỗi trình biên dịch chỉ vì runner và production đều là x86_64, còn laptop của tôi thì không.</li>
<li>Một bản checkout mới tinh có qua được không? Nếu không, thủ phạm thường là các tệp được sinh tự động và bị gitignore.</li>
<li>Thời gian của một lần chạy thật ra tiêu vào đâu? Hãy đọc thời gian từng bước trước khi thêm bộ nhớ đệm.</li>
<li>Mỗi bí mật đang nằm ở bao nhiêu chỗ, và nó có thật sự cần nằm trên GitHub không?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/</guid>
      <pubDate>Sun, 02 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>DevOps</category>
      <description>Đưa khâu dựng (build) và kiểm thử vào tệp Docker Compose thay vì viết trong YAML của GitHub Actions: dễ chuyển sang nền tảng khác, chạy thử được ngay trên máy mình, bảo trì cũng nhẹ đầu hơn.</description>
      <content:encoded><![CDATA[<p><em>Cập nhật tháng 10/2026: hai năm sau, tôi đã rà lại mọi kho mã mà tôi đang dùng để triển khai sản phẩm, và viết lại những gì còn đứng vững trong lời khuyên này ở bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >Dựng quy trình CI/CD trên GitHub Actions</a>. Nguyên tắc thì vẫn đúng; còn cách chạy Docker Compose bên trong CI thì phần lớn đã không trụ được.</em></p>
<p>Đa số các đội đều viết thẳng các bước dựng (build) và kiểm thử vào tệp YAML của GitHub Actions. Cách này vẫn chạy tốt, cho đến khi bạn muốn chạy lại đúng bản dựng đó trên laptop của mình, hoặc có người đề xuất chuyển sang GitLab, và lúc đó bạn mới nhận ra cả quy trình CI/CD (pipeline) đã dính chặt vào định dạng cấu hình của một nhà cung cấp duy nhất. Cách tôi chọn thì khá nhàm: toàn bộ logic thật sự nằm trong Docker Compose, còn công cụ CI chỉ có mỗi việc gọi nó, ngoài ra không làm gì thêm.</p>

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

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

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