blog

Bỏ commit thư mục out/: dựng CI/CD build static ngay trên server

Repo này có 29 trên 51 commit tồn tại chỉ để chở build output. Cách bỏ hẳn nó: self-hosted runner build ngay trên server, publish bằng một thao tác đổi symlink, purge Cloudflare, và ba cái bẫy bash chỉ lộ ra khi đem ra test.

CI/CDGitHub ActionsNext.js 16NginxCloudflare
Con trỏ current đang đổi từ release cũ sang release vừa build, ba khối release nằm cạnh nhau

Site nguyentrungkhuong.com là static export, trước đây nginx trỏ thẳng vào thư mục out/. Suốt một thời gian dài, deploy nghĩa là build ở máy local, commit cả out/ vào git, rồi ssh lên server git pull. Đếm lại lịch sử: 29 trên 51 commit của repo có đụng out/.

Tuần này tôi bỏ hẳn cách đó. Đổi sang self-hosted runner build ngay trên server, publish bằng một thao tác đổi symlink. Từ lúc git push tới lúc bản mới lên production: 120 giây.

Kiểu cũ hỏng ở đâu: 29 trên 51 commit là build output

Con số của repo này, đếm bằng git log --numstat:

Chỉ sốGiá trị
Commit có đụng out/29 / 51
Commit đụng out/ mà không đụng source12
Dòng thêm/xoá trong out/ toàn lịch sử9.966 / 9.966
Kích thước out/ hiện tại228 file, 8,2 MB

12 commit trong đó không sửa một dòng code hay content nào. Chúng tồn tại chỉ vì HTML minify đổi sau khi build lại. git log của repo trở thành bản ghi của trình đóng gói chứ không phải của người viết.

Nhưng phiền nhất không phải rác lịch sử, mà là ba thứ này:

  • Build phụ thuộc máy local. Node phiên bản nào, .env.local có đủ key không, node_modules có lệch lockfile không — không ai kiểm. Bản lên production là bản máy tôi tình cờ đang có.
  • out/ trong git có thể lệch với thứ đang chạy. Quên commit sau khi sửa content thì server pull về bản cũ, mà git status vẫn sạch.
  • Rebase là địa ngục. Hai nhánh cùng đổi content, out/ conflict ở 228 file minify một dòng.
So sánh hai quy trình deploy: hàng trên là build local rồi commit out/ và ssh pull, hàng dưới là push rồi runner tự build, verify, đổi symlink và purge
So sánh hai quy trình deploy: hàng trên là build local rồi commit out/ và ssh pull, hàng dưới là push rồi runner tự build, verify, đổi symlink và purge

Self-hosted runner: build đúng chỗ file sẽ nằm

Lựa chọn đầu tiên phải chốt: build trên GitHub-hosted runner rồi đẩy file sang server, hay cài runner ngay trên server.

Tôi chọn cài trên server. Build trên GitHub-hosted nghĩa là phải sinh deploy key, thêm private key vào secret, mở SSH cho dải IP của GitHub, rồi đẩy 8,2 MB qua Internet mỗi lần. Runner nằm sẵn trên server thì build xong file đã ở đúng thư mục, chỉ còn rsync nội bộ.

yaml
jobs:
  deploy:
    runs-on: [self-hosted, khuong]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build:static

2 chi tiết dễ mất thời gian:

khuong là label tự đặt lúc chạy config.sh. Khai sai thì job không báo lỗi — nó nằm im ở trạng thái Waiting for a runner cho tới khi hết hạn.

actions/checkout@v4 chạy trên Node 20 runtime mà GitHub đã deprecate, log sẽ nhắc mỗi lần chạy. @v7 dùng Node 24. Lưu ý node-version: 22 trong setup-node là Node để build site, không liên quan tới runtime chạy bản thân action — hai thứ khác nhau, đừng đổi nhầm.

Publish là thao tác rename, không phải rsync đè lên thư mục đang phục vụ

Cách ngây thơ là rsync --delete thẳng vào thư mục nginx đang đọc. Trong lúc rsync chạy, thư mục đó ở trạng thái nửa cũ nửa mới: HTML mới trỏ tới chunk JS chưa kịp ghi. Vài trăm ms, nhưng người xui thì trắng trang.

Cách đúng là mỗi lần build ghi vào một thư mục riêng, rồi đổi con trỏ:

text
/home/khuong/
├── releases/
│   ├── a734f3b/
│   └── df6f707/
└── current -> releases/df6f707      <- nginx root trỏ vào đây
bash
dest="$DEPLOY_ROOT/releases/$GITHUB_SHA"
rsync -a --delete --chmod=D755,F644 out/ "$dest/"
touch "$dest"

ln -sfn "$dest" "$DEPLOY_ROOT/current.tmp"
mv -T "$DEPLOY_ROOT/current.tmp" "$DEPLOY_ROOT/current"

mv -T là một lệnh rename() ở tầng syscall. Request đang bay không bao giờ thấy trạng thái trung gian: hoặc nó đọc release cũ, hoặc đọc release mới, không có ở giữa.

Bên nginx chỉ đổi đúng một dòng:

nginx
root /home/khuong/current;

nginx đi xuyên symlink mặc định (disable_symlinks off), không cần cấu hình thêm.

Ba cái bẫy bash chỉ lộ ra khi đem ra test

Trước khi để workflow chạm production, tôi viết một script dựng lại toàn bộ cây thư mục trong sandbox rồi chạy sáu kịch bản: deploy lần đầu, deploy lần hai, prune khi ít release, prune khi nhiều release, rollback, rollback tới SHA không tồn tại. Ba lỗi rơi ra, không lỗi nào phát hiện được bằng cách đọc lại code.

mv thiếu -T thì symlink chui vào bên trong release cũ. Lần deploy đầu current chưa tồn tại nên mv tạo mới, chạy đúng. Lần thứ hai current đã là symlink trỏ tới một thư mục, mv mặc định đi theo symlink và đặt file vào trong thư mục đó — sinh ra releases/a734f3b/current, còn current ở ngoài vẫn trỏ bản cũ. Site đứng im ở bản cũ mà job vẫn xanh.

rsync -a chép luôn mtime của nguồn. Bước prune giữ 5 release mới nhất bằng ls -t, mà -a bao gồm -t, nên mtime của releases/<sha> là mtime của out/ chứ không phải thời điểm publish. Trong sandbox, bản publish sau lại xếp sau bản publish trước. Prune sắp theo thứ tự sai thì nó xoá nhầm bản mới. Sửa bằng đúng một dòng touch "$dest" sau rsync.

grep trả 1 khi không khớp gì, và pipefail coi đó là lỗi. Câu prune lọc bản đang live ra khỏi danh sách cần xoá:

bash
ls -1dt ./*/ | tail -n "+$((KEEP_RELEASES + 1))" | sed 's:^\./::;s:/$::' \
  | { grep -vx "$live" || true; } | xargs -r rm -rf

Khi repo mới có dưới 5 release — tức là mọi lần deploy trong tuần đầu — tail không ra dòng nào, grep trả exit 1, set -euo pipefail giết cả step. Deploy fail ở bước dọn rác, sau khi đã publish thành công. || true trong ngoặc nhọn xử lý đúng chỗ đó.

Bẫy thứ tư không phải bug mà là thiếu sót: prune phải chừa bản current đang trỏ tới. Bình thường bản live luôn là bản mới nhất nên không sao, nhưng ngay sau một lần rollback thì bản live nằm ngoài top 5. Không chừa thì lần deploy kế tiếp xoá mất thư mục mà nginx đang đọc.

2 công cụ đáng cài trước khi viết dòng YAML đầu tiên: actionlint (nó gọi luôn shellcheck cho từng khối run:) và một file .github/actionlint.yaml khai label self-hosted để nó khỏi báo nhầm. Nhưng cả hai chỉ bắt được lỗi cú pháp — ba lỗi ở trên đều là code hợp lệ, chạy sai. Chỉ test mới ra.

Đổi symlink là điểm không quay lại. Trước đó phải có một cửa fail-closed:

bash
for f in out/index.html out/sitemap.xml out/robots.txt; do
  test -s "$f" || { echo "::error::missing or empty $f"; exit 1; }
done
count=$(find out -type f | wc -l)
test "$count" -ge 50 || { echo "::error::out/ has only $count files"; exit 1; }

next build thất bại thì exit code khác 0 và job dừng sẵn. Cửa này bắt trường hợp khó chịu hơn: build "thành công" nhưng ra thiếu file — plugin sitemap câm, script prebuild lỗi mà không throw, đĩa đầy giữa chừng. Thà job đỏ và site giữ bản cũ, còn hơn symlink trỏ sang một thư mục rỗng.

Purge Cloudflare, và cái redirect chết sống dai vì HTML cache một năm

Bước cuối là xoá cache edge:

bash
curl -sS -X POST \
  "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"purge_everything":true}'

Token tạo bằng Create Custom Token, đúng một quyền Zone → Cache Purge → Purge, giới hạn ở một zone. Đừng dùng Global API Key — nó mở toàn bộ tài khoản cho một việc xoá cache.

purge_everything nghe phí nhưng không phải: mọi asset trong /_next/static/ đều có hash trong tên, purge xong request kế tiếp chỉ kéo lại HTML. Site 8,2 MB, không đáng tối ưu bằng cách diff URL giữa hai build rồi purge từng cái — sót một URL là người dùng thấy bản cũ.

Phần đắt giá nằm ở chỗ này. Lúc dò lại production sau khi CI/CD chạy, /blog trả về chuỗi 301 sang /blog/ rồi 403. Trang danh sách blog nằm trong menu chính và trong sitemap.xml. Tôi đã kết luận đó là lỗi try_files của nginx.

Sai. Header có sẵn câu trả lời mà tôi đọc lướt qua:

text
cache-control: public, max-age=31536000, s-maxage=300, stale-while-revalidate=60
cf-cache-status: HIT
age: 584

HIT nghĩa là phản hồi đó tới từ edge Cloudflare, không phải từ nginx. Cái 301 đó là tàn dư của một cấu hình nginx từ lâu rồi, được edge giữ lại và phục vụ tiếp cho tất cả mọi người. Lần deploy sau, purge_everything dọn nó đi, /blog trả 200 ngay.

Hai bài học rút ra, cái thứ hai đắt hơn cái thứ nhất:

  • Debug production phải phân biệt edge với origin. Có CDN đứng trước thì curl từ máy mình không nói được nginx đang trả gì. Hỏi thẳng origin: curl -sI http://127.0.0.1/ -H 'Host: example.com' chạy trên server.
  • Cache-Control dài cho HTML giữ luôn cả lỗi. max-age=31536000 cho .html biến một redirect chết thành vĩnh viễn với trình duyệt đã ghé qua, và purge Cloudflare không với tới họ vì cache nằm ở máy khách. HTML phải là no-cache cộng s-maxage ngắn; chỉ tài nguyên có hash trong tên mới được immutable.
nginx
# HTML: luôn hỏi lại origin, nhưng vẫn cho edge cache 5 phút
location ~* \.html$ {
    add_header Cache-Control "public, no-cache, s-maxage=300, stale-while-revalidate=60" always;
}
# tài nguyên có hash trong tên: bất biến
location /_next/static/ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

Secret thiếu thì build vẫn xanh, form chết im lặng

Site tĩnh không có backend, form liên hệ và newsletter gọi thẳng dịch vụ ngoài bằng key NEXT_PUBLIC_*. Next inline mấy biến này vào bundle lúc build, đọc từ process.env — nên không cần ghi .env ra đĩa, chỉ cần khai ở step:

yaml
- name: Build static site
  env:
    NEXT_PUBLIC_W3F_ACCESS_KEY: ${{ secrets.NEXT_PUBLIC_W3F_ACCESS_KEY }}
    NEXT_PUBLIC_MC_DC: ${{ secrets.NEXT_PUBLIC_MC_DC }}
  run: npm run build:static

Chỗ cần lưu ý: thiếu secret thì build vẫn xanh. Biến bị inline thành undefined, form gửi lên và dịch vụ từ chối. Không log server, không ai biết, cho tới khi khách phàn nàn không liên hệ được.

Sau lần deploy tự động đầu tiên, kiểm tay:

  • Gửi thử form liên hệ, xem có nhận được mail không
  • Đăng ký thử newsletter
  • Mở HTML đã build, tìm chuỗi undefined cạnh tên biến

Release cũ vẫn nằm trên đĩa, nên quay lui không cần chạy lại gì:

bash
ln -sfn "$DEPLOY_ROOT/releases/$SHA" "$DEPLOY_ROOT/current.tmp"
mv -T "$DEPLOY_ROOT/current.tmp" "$DEPLOY_ROOT/current"

Tôi để nó thành workflow riêng chạy bằng workflow_dispatch, ô nhập SHA bỏ trống thì job chỉ liệt kê các release đang có rồi dừng. Kèm một guard test -s "$target/index.html" — gõ nhầm SHA thì job đỏ, không phải site 404.

Cả hai workflow dùng chung concurrency.group với cancel-in-progress: false. Hai lần deploy chồng nhau, hoặc rollback chen vào giữa lúc deploy đang rsync, là cách chắc chắn nhất để có một release dở dang.

Đánh đổi khi để production server tự build

Cái được thì rõ: một thao tác tay thay vì ba, out/ biến khỏi git, mỗi commit chỉ còn code và content, rollback 5 giây. Cái mất thì phải nói thẳng:

  • Build ăn RAM của chính máy đang phục vụ web. next build không nhẹ. VPS nhỏ mà không có swap thì bị OOM kill giữa chừng, và nó kill trong lúc site đang chạy.
  • Runner có quyền ghi vào web root. Ai push được lên master là deploy được. Repo cá nhân thì chấp nhận; nhiều người thì phải thêm branch protection và environment approval.
  • Mất bản dự phòng trong git. Trước đây out/ nằm trong lịch sử, hỏng gì cũng checkout lại được. Giờ chỗ dựa là 5 thư mục release trên đĩa. Đây là lý do prune phải chừa bản đang live.
  • Không có staging. master lên thẳng production. Bước verify chỉ chặn được build hỏng, không chặn được nội dung sai.
  • actions/checkout xoá sạch workspace mỗi lần, kể cả node_modules, nên npm ci chạy lại từ đầu — chỉ có npm cache là được giữ. Đó là phần lớn trong 120 giây.

Còn một cái bẫy nữa cho ai làm theo, nằm ở đúng lúc chuyển giao. Commit bỏ track out/ sẽ xoá 228 file khỏi git. Nếu bản clone cũ trên server còn đó và ai đó git pull, git xoá sạch thư mục out/ mà nginx đang phục vụ — site chết ngay lập tức. Thứ tự bắt buộc là: cài runner, khai secret, chạy deploy lần đầu, kiểm current có nội dung thật, đổi root nginx, rồi mới đụng tới bản clone cũ.

Bước tiếp theo tôi định làm là preview build cho pull request: mỗi PR ra một release riêng dưới preview/<số PR>, nginx map bằng subdomain. Cùng bộ máy này, chỉ khác chỗ symlink trỏ tới.

Nếu bạn muốn xem phía dưới cái pipeline này đang build gì, tôi có viết riêng về kiến trúc static export và catch-all route của site, cùng những chỗ vấp khi nâng lên Next.js 16.

Bài viết liên quan

tất cả bài viết