Mình từng có một pipeline CI mà mỗi lần push code là phải đi pha một bình trà. Sáu phút rưỡi — trong đó gần bốn phút chỉ để pnpm install tải lại đúng những package mà lần trước đã tải. Sửa một dấu phẩy trong README, chờ sáu phút. Sửa thêm một comment theo review, chờ sáu phút nữa. Tới lúc hết chịu nổi, mình ngồi đo lại mới thấy: phần lớn thời gian CI không nằm ở test hay build, mà ở việc cài lại những thứ y hệt lần trước.
Sau khi thêm cache, pipeline đó chạy 90 giây. Bài này mình kể lại cách làm, kèm mấy cái bẫy mình từng dính.
Trước tiên, đo cái đã
Đừng vội thêm cache. Mở một run gần nhất, xem thời gian từng step. Với dự án JS/TS điển hình, thứ tự chậm thường là: cài dependencies, rồi tới build (tsc, vite build), rồi mới tới test. Nếu step cài dependencies chiếm hơn một nửa tổng thời gian thì cache là thứ đáng đụng vào nhất. Còn nếu test của bạn chạy 5 phút vì chờ setTimeout thì cache không cứu được — sửa test trước.
Cache có sẵn: setup-node lo phần pnpm store
Thứ đơn giản nhất mà nhiều người bỏ qua: actions/setup-node tự cache được package manager store, chỉ cần khai báo một dòng.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
Nó băm pnpm-lock.yaml thành cache key và lưu pnpm store vào cache của GitHub. Lần sau, pnpm install chỉ việc link từ store ra node_modules thay vì tải từ registry.
Một lưu ý về --frozen-lockfile: trong CI luôn bật cờ này. Nó chặn trường hợp lockfile lệch với package.json — nếu lệch thì fail ngay, thay vì âm thầm cài một cây dependency khác với máy dev.
Cache thứ khác: dùng actions/cache thủ công
pnpm store chỉ là một phần. Với monorepo hoặc dự án Next.js, còn mấy thứ ăn thời gian nữa: cache của Turborepo, thư mục .next/cache, và file .tsbuildinfo của tsc. Mấy cái này setup-node không biết, phải tự cache.
Nhưng đừng đặt cache key cố định kiểu key: build-cache. Key đứng yên nghĩa là cache cũ sẽ được restore mãi, và bạn build bằng output của commit từ tuần trước — kiểu bug rất khó truy. Cách mình làm là sinh key từ chính nội dung quyết định kết quả build:
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const files = ["pnpm-lock.yaml", "tsconfig.json", "package.json"];
const hash = createHash("sha256");
for (const f of files) hash.update(readFileSync(f));
console.log(hash.digest("hex").slice(0, 12));
Đẩy output đó ra $GITHUB_OUTPUT, rồi dùng trong workflow:
- id: cachekey
shell: bash
run: echo "key=$(npx tsx scripts/cache-key.ts)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: |
.next/cache
**/*.tsbuildinfo
key: ${{ runner.os }}-build-${{ steps.cachekey.outputs.key }}
restore-keys: |
${{ runner.os }}-build-
restore-keys là chỗ nhiều người quên. Key chính xác chỉ khớp khi lockfile và tsconfig y nguyên. Khi bạn thêm một dependency, key đổi, cache miss — nhưng restore-keys sẽ lấy bản cache gần nhất cùng tiền tố, rồi step sau ghi đè dần. Nghĩa là bạn vẫn được lợi một phần thay vì mất trắng.
Bốn cái bẫy mình từng dính
Cache node_modules có native binary. Nghe hấp dẫn vì tiết kiệm được cả bước link, nhưng chỉ an toàn khi OS và Node version cố định trong runner. Đổi từ ubuntu-latest sang image khác là binary cũ nằm lại, lỗi lúc runtime chứ không lỗi lúc cài. Mình chỉ cache store, không cache node_modules.
Cache giới hạn 10GB mỗi repo. Vượt là GitHub evict bản cũ nhất. Nếu bạn cache cả .next/cache của một site lớn, chỉ vài ngày là đầy. next build có cacheMaxMemorySize để chặn phần in-memory, còn trên CI thì nên giới hạn bằng actions/cache với path cụ thể thay vì cache cả thư mục cha.
Cache che mất lỗi cài đặt. Có lần cache làm CI xanh trong khi máy dev cài lỗi. Lý do: một dependency khai thiếu trong package.json nhưng có sẵn trong store nhờ build trước. Mỗi tháng mình chạy một job không cache (cache: false) để chắc chắn lockfile vẫn cài sạch.
Kết quả
Sau khi gộp pnpm store cache và cache .tsbuildinfo của tsc: cài dependencies từ 3m50s xuống 25s, build từ 1m40s xuống 55s, tổng pipeline 6m30s còn khoảng 1m30s. Quan trọng hơn con số: vòng lặp sửa–push–xem CI ngắn lại, nên mình ít gộp nhiều thay đổi vào một lần push hơn hẳn.
Bắt đầu từ đâu
Đừng thêm cả bốn cache một lượt. Làm theo thứ tự: bật cache: pnpm trong setup-node trước, đo lại. Thấy còn chậm thì mới cache thêm build output, và luôn nhớ một job không cache để kiểm tra lockfile. Cache là để nhanh hơn, không phải để che lỗi.
Nếu pipeline của bạn đang chậm, thử mở time từng step xem nghẽn nằm ở đâu — biết đâu cái cần sửa không phải cache mà là một test đang chờ mạng.
📋 Phụ lục thuật ngữ
- pnpm store: kho chứa package dùng chung trên máy,
node_moduleschỉ là hard link trỏ vào store. Cache store là cache đúng chỗ. --frozen-lockfile: bắtpnpm installdùng đúng dependency trong lockfile, lệch thì fail thay vì tự cập nhật.- Cache key: chuỗi định danh một bản cache. Key đổi thì cache miss, hệ thống đi tìm theo
restore-keys. restore-keys: danh sách tiền tố dự phòng, cho phép lấy bản cache gần nhất khi key chính xác không khớp..tsbuildinfo: filetsclưu thông tin build tăng dần, giúp lần build sau bỏ qua file không đổi.- Cache eviction: GitHub xoá cache không dùng sau 7 ngày, hoặc khi tổng cache vượt 10GB mỗi repo.