Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang làm trong một team 8 developer tại một công ty fintech ở TP.HCM. Mỗi lần ai đó push code lên nhánh, pipeline CI khởi động và chạy npm ci để cài dependencies. Bước này mất 90 giây. Nghe có vẻ nhỏ, đúng không? Nhưng hãy nhân lên: team push trung bình 40 lần mỗi ngày, mỗi lần pipeline chạy vài job song song cùng cài dependencies. Chỉ riêng bước cài đặt thư viện đã ngốn của bạn khoảng 1–1.5 giờ máy chạy mỗi ngày, tức khoảng 30 giờ mỗi tháng — chưa kể developer phải ngồi chờ trước khi biết code của mình có pass hay không.
Bây giờ, nếu bạn bật cache đúng cách, npm ci từ 90 giây rớt xuống còn 10 giây. Con số này không phải phóng đại — nó đến từ việc thay vì tải lại toàn bộ package từ registry qua mạng rồi giải nén, runner chỉ cần khôi phục thư mục đã lưu sẵn từ lần chạy trước. Nhân với hàng chục lần chạy mỗi ngày, trên hàng chục repository, bạn tiết kiệm hàng giờ mỗi ngày — và quan trọng hơn, feedback loop của developer nhanh hơn hẳn.
Caching trong CI không phải là "tối ưu cho vui". Ở quy mô đội nhóm, nó ảnh hưởng trực tiếp đến ba thứ: tốc độ feedback (developer chờ ít hơn), chi phí (bạn trả tiền theo số phút runner chạy, đặc biệt trên GitHub Actions và GitLab SaaS), và trải nghiệm làm việc (không ai muốn chờ pipeline 12 phút chỉ để sửa một dòng chữ). Bài này sẽ dạy bạn tư duy đúng về caching: cache cái gì, cache như thế nào cho an toàn, và những cái bẫy khiến cache "trông như đang chạy" nhưng thực ra chẳng bao giờ hit.
Khái niệm cốt lõi
Cache là gì trong ngữ cảnh CI
Cache trong CI là một cơ chế lưu lại kết quả của một công việc tốn thời gian ở lần chạy trước, để lần chạy sau tái sử dụng thay vì làm lại từ đầu. Bản chất rất đơn giản: bạn nói với hệ thống CI "hãy lưu thư mục này lại dưới một cái tên (key), và lần sau nếu key trùng khớp thì khôi phục nó về".
Điểm mấu chốt cần khắc sâu: cache là để tăng tốc, không phải để lưu trữ vĩnh viễn và không được coi là nguồn dữ liệu tin cậy. Nếu cache bị mất, pipeline vẫn phải chạy đúng — chỉ là chậm hơn. Đây là ranh giới quan trọng phân biệt cache với artifact (thứ mà Bài 12 sẽ đào sâu): artifact là sản phẩm bạn cần giữ và chuyển giữa các stage; cache chỉ là bản sao tạm để đi nhanh hơn.
Cache key và restore key
Trái tim của mọi hệ thống cache là key. Key quyết định khi nào cache được coi là "còn hợp lệ" và có thể tái sử dụng.
Một pattern chuẩn mực trông như thế này:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
Hãy đọc key này từ trái sang phải:
runner.os— hệ điều hành (Linux/macOS/Windows). Cache của Linux không dùng được cho Windows.node— nhãn phân biệt loại cache (đây là cache cho node_modules).hashFiles('/package-lock.json')— đây là phần thông minh nhất. Nó băm (hash) nội dung file lock. Nếupackage-lock.jsonkhông đổi, hash không đổi, key trùng khớp → cache hit, khôi phục nguyên vẹn. Nếu có ai thêm/bớt/nâng cấp một package, file lock đổi, hash đổi, key mới → cache miss**, cài lại và lưu cache mới.
Bên cạnh key (khớp chính xác) còn có restore-keys (khớp một phần, dạng prefix). Ví dụ:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Khi key không khớp (vì lock file vừa đổi), CI sẽ tìm cache gần nhất có prefix Linux-node-. Nó khôi phục cache cũ đó, rồi npm ci chỉ cần cài phần chênh lệch — vẫn nhanh hơn cài từ số 0. Đây gọi là partial cache restore, một kỹ thuật cực kỳ đáng giá.
Cache đúng thư mục — package manager cache vs node_modules
Đây là chỗ nhiều người làm sai. Với hệ Node.js, có hai lựa chọn cache và chúng khác nhau về bản chất:
- Cache thư mục download của package manager (ví dụ
~/.npm,~/.yarn/cache,~/.pnpm-store). Đây là nơi package manager lưu các gói đã tải về. Kết hợp vớinpm ci, bạn tránh được việc tải lại qua mạng, nhưng vẫn chạy bước "link/install" cục bộ. - Cache thẳng
node_modules. Nhanh hơn nữa vì bỏ qua cả bước install, nhưng rủi ro hơn:node_modulescó thể chứa binary biên dịch theo OS/kiến trúc, dễ gây lỗi khi khôi phục nhầm môi trường.
npm ci, yarn install --frozen-lockfile, pnpm install --frozen-lockfile). Cách này an toàn hơn và vẫn nhanh. Chỉ cache node_modules trực tiếp khi bạn hiểu rõ rủi ro và khóa chặt key theo OS + version Node + lock file.Các loại cache thường gặp theo hệ sinh thái
Mỗi ngôn ngữ/công cụ có "điểm nghẽn tải về" riêng cần cache:
| Hệ sinh thái | Thư mục nên cache | Tín hiệu để tạo key |
|---|---|---|
| Node.js (npm) | ~/.npm | package-lock.json |
| Node.js (yarn) | ~/.yarn/cache hoặc .yarn/cache | yarn.lock |
| Node.js (pnpm) | store path của pnpm | pnpm-lock.yaml |
| Java (Maven) | ~/.m2/repository | pom.xml |
| Java (Gradle) | ~/.gradle/caches | .gradle, gradle-wrapper |
| Python (pip) | ~/.cache/pip | requirements.txt / poetry.lock |
| Go | build cache + module cache | go.sum |
| Docker | layer cache (BuildKit) | Dockerfile + context |
| Công cụ build (Nx/Turbo) | cache task nội bộ | hash input của task |
Layer cache cho Docker — một loại cache đặc biệt
Nếu pipeline của bạn build image Docker, đây thường là nút thắt lớn nhất. Docker cache theo layer: mỗi lệnh trong Dockerfile tạo một layer, và Docker chỉ build lại từ layer đầu tiên bị thay đổi trở đi. Nguyên tắc vàng: đặt những thứ ít đổi lên trên, thứ hay đổi xuống dưới. Copy package.json và cài dependencies TRƯỚC, rồi mới copy source code. Như vậy khi bạn chỉ sửa code, layer cài dependencies vẫn được cache lại. Với BuildKit, bạn còn dùng được --cache-from để chia sẻ layer cache giữa các lần chạy CI khác nhau — điều mà runner "sạch" mặc định không có.
Tình huống thực tế
Tình huống 1 — Startup e-commerce ở Hà Nội và bill GitHub Actions tăng vọt
Một startup thương mại điện tử khoảng 15 người, monorepo frontend React + backend Node. Sau vài tháng, trưởng nhóm DevOps phát hiện hóa đơn GitHub Actions đã vượt hạn mức miễn phí và họ đang trả thêm khoảng 180 USD/tháng cho runner phút. Điều tra ra: mỗi pipeline chạy 5 job (lint, unit test, integration test, build FE, build BE), và cả 5 job đều tự cài dependencies từ đầu, mỗi job tốn ~2 phút cho bước cài. Không có cache nào cả.
Họ áp dụng ba thay đổi: (1) thêm actions/cache với key dựa trên hashFiles('**/package-lock.json'); (2) dùng restore-keys để partial restore khi lock đổi nhẹ; (3) tách bước cài dependencies thành một job "setup" chạy trước rồi các job sau kế thừa. Kết quả sau hai tuần: thời gian trung bình mỗi pipeline giảm từ 11 phút xuống 6 phút, số runner phút giảm ~48%, bill xuống dưới hạn mức miễn phí trở lại.
Bài học: cache không phải chuyện "kỹ thuật cho đẹp" mà là chuyện tiền bạc rất cụ thể. Và điểm nghẽn thường nằm ở việc nhiều job lặp lại cùng một công việc.
Tình huống 2 — "Cache của tôi bật rồi mà sao vẫn chậm?"
Một bạn backend Java ở một công ty gia công phần mềm tại Đà Nẵng phàn nàn: đã thêm cache cho ~/.m2/repository mà build Maven vẫn mất gần 4 phút mỗi lần. Khi xem log kỹ, mentor phát hiện dòng Cache not found for input keys xuất hiện ở mọi lần chạy — nghĩa là cache miss 100%, chưa bao giờ hit.
Nguyên nhân: key của bạn ấy dùng ${{ github.sha }} (commit hash) làm một phần của cache key. Vì mỗi commit có SHA khác nhau, key luôn luôn mới → không bao giờ khớp cache cũ → lưu cache rồi vứt đi, chẳng bao giờ dùng lại. Đây là lỗi kinh điển: key quá "biến động" khiến cache vô dụng. Sửa lại key thành ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} cộng thêm restore-keys, tỉ lệ hit lập tức lên gần 95% và build xuống còn khoảng 70 giây.
Bài học: một cache "được cấu hình" chưa chắc là một cache "hoạt động". Luôn kiểm tra log xem tỉ lệ hit thực tế. Key phải ổn định khi input không đổi, và chỉ đổi khi input thật sự đổi.
Tình huống 3 — Cache "bẩn" gây bug khó chịu ở một team fintech Singapore
Một team fintech tại Singapore cache thẳng node_modules để đi thật nhanh. Mọi thứ ổn cho tới một ngày họ nâng Node runner từ 16 lên 18. Build pass trên máy local nhưng CI fail loạn xạ với lỗi native module. Lý do: cache node_modules cũ chứa binary được biên dịch cho Node 16, khi khôi phục vào môi trường Node 18 thì các package như bcrypt, sharp không tương thích.
Vấn đề gốc là key không bao gồm phiên bản Node. Họ sửa bằng cách thêm version Node vào key: ${{ runner.os }}-node18-${{ hashFiles('package-lock.json') }} và chuyển sang cache ~/.npm thay vì node_modules trực tiếp. Từ đó không còn lỗi "chạy trên máy tôi mà không chạy trên CI".
Bài học: cache phải phản ánh toàn bộ những yếu tố ảnh hưởng đến nội dung của nó — OS, kiến trúc CPU, phiên bản runtime, lock file. Bỏ sót một yếu tố là mời gọi bug kiểu "poisoned cache" (cache bị nhiễm bẩn).
Hướng dẫn từng bước
Dưới đây là quy trình thêm caching cho một pipeline Node.js trên GitHub Actions. Tư duy này áp dụng được cho mọi hệ CI khác.
Bước 1 — Xác định điểm nghẽn. Mở log của một lần chạy gần nhất, tìm bước nào tốn thời gian nhất. Thường là bước cài dependencies hoặc build. Đừng cache mù quáng; cache đúng chỗ đau.
Bước 2 — Xác định thư mục cần cache và tín hiệu invalidation. Với npm, thư mục là ~/.npm, tín hiệu là package-lock.json. Ghi nhớ nguyên tắc: cache thư mục download của package manager, không cache thẳng node_modules trừ khi có lý do rõ ràng.
Bước 3 — Cấu hình cache với key ổn định.
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node20-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node20-npm-
Bước 4 — Cân nhắc dùng cache tích hợp sẵn. Nhiều setup-* action đã có cache built-in gọn hơn:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
Một dòng cache: 'npm' này tự lo phần cache cho bạn. Nếu nhu cầu đơn giản, hãy ưu tiên nó thay vì tự viết actions/cache.
Bước 5 — Dùng lệnh cài "sạch". Luôn dùng npm ci (không phải npm install) trong CI. npm ci cài đúng theo lock file, xóa node_modules cũ trước, và phối hợp tốt với cache download.
Bước 6 — Chạy thử và kiểm chứng hit rate. Chạy pipeline hai lần liên tiếp. Lần đầu sẽ là cache miss (bình thường — chưa có gì để khôi phục). Lần hai PHẢI thấy dòng "Cache restored" và thời gian giảm rõ rệt. Nếu lần hai vẫn miss, key của bạn có vấn đề — quay lại Bước 3.
Bước 7 — Mở rộng cho các loại cache khác. Sau khi ổn với dependencies, xét đến build cache (Nx/Turbo), Docker layer cache (--cache-from), và cache của test runner. Áp dụng cùng một nguyên tắc key cho từng loại.
Lỗi thường gặp & mẹo
Lỗi 1 — Đưa giá trị luôn thay đổi vào key. Dùng github.sha, timestamp, hay số run vào key khiến cache không bao giờ hit. Key phải phản ánh input, không phải thời điểm.
Lỗi 2 — Quên restore-keys. Không có restore-keys, mỗi lần lock file đổi dù chỉ một dòng, bạn mất sạch cache và cài lại từ đầu. Với restore-keys, bạn khôi phục cache gần nhất rồi cài phần chênh — nhanh hơn nhiều.
Lỗi 3 — Cache node_modules mà quên OS/version runtime trong key. Đây là nguồn gốc của bug "poisoned cache" như tình huống 3. Nếu buộc phải cache node_modules, key BẮT BUỘC gồm OS + version runtime + lock file.
Lỗi 4 — Cache thứ nên là artifact. Đừng dùng cache để chuyển file build giữa các stage/job như một kênh tin cậy. Cache có thể bị evict bất cứ lúc nào. Chuyển sản phẩm build bằng artifact (Bài 12), không phải cache.
Lỗi 5 — Không dọn cache, để phình to. Nhiều hệ CI giới hạn tổng dung lượng cache (GitHub Actions ví dụ ~10GB mỗi repo) và tự evict cache ít dùng theo LRU. Nếu cache của bạn quá to hoặc quá nhiều biến thể key, cache hữu ích sẽ bị đẩy ra. Giữ key gọn, tránh sinh vô số biến thể.
Mẹo 1 — Luôn đọc log để đo hit rate thật. "Cache restored from key..." là bạn của bạn. Nếu không thấy dòng đó ở lần chạy thứ hai, cache đang không hoạt động.
Mẹo 2 — Cache theo tầng. Với monorepo lớn, hãy cache riêng cho từng loại (dependencies, build output, lint cache) thay vì gộp tất cả vào một cache khổng lồ. Cache nhỏ, tách bạch thì hit chính xác hơn.
Mẹo 3 — Cẩn trọng với cache trên nhánh. Trên GitHub Actions, cache tạo ở nhánh feature không phải lúc nào cũng dùng được cho nhánh khác (do giới hạn scope theo nhánh). Tạo cache "nền" trên nhánh chính (main) để các PR đều restore được từ đó.
Mẹo 4 — Đừng cache secret hay dữ liệu nhạy cảm. Cache có thể được khôi phục ở các workflow khác; không bao giờ để token, key riêng tư lọt vào thư mục được cache.
Bài tập thực hành
- Đo baseline. Lấy một repo Node.js bất kỳ (hoặc tạo mới với vài dependency), viết một workflow GitHub Actions chỉ có bước
npm ci. Chạy và ghi lại thời gian bước cài đặt khi CHƯA có cache.
- Thêm cache và đo lại. Thêm
actions/setup-nodevớicache: 'npm'(hoặcactions/cachethủ công). Chạy pipeline hai lần. Ghi lại thời gian lần hai và so sánh. Bạn kỳ vọng giảm ít nhất 50% ở bước cài đặt.
- Cố tình làm hỏng cache. Đổi key thành
${{ github.sha }}-node. Chạy lại vài lần và quan sát log. Xác nhận rằng cache luôn miss. Sau đó sửa lại key đúng chuẩn và xác nhận hit trở lại. Bài tập này giúp bạn "cảm" được sự khác biệt giữa key ổn định và key biến động.
- Thử partial restore. Thêm một package mới vào
package.json(lock file đổi). Chạy pipeline và kiểm tra trong log: cache có được khôi phục quarestore-keyskhông, và bước cài chỉ xử lý phần chênh lệch chứ không cài lại từ đầu.
- (Nâng cao) Docker layer cache. Viết một Dockerfile copy
package.jsonvà cài dependencies trước, copy source sau. Build hai lần, lần hai chỉ sửa một dòng source. Quan sát Docker báo "CACHED" ở layer dependencies. Rút ra thứ tự lệnh trong Dockerfile ảnh hưởng thế nào đến cache.
Tóm tắt
Caching là một trong những đòn bẩy hiệu quả nhất để tăng tốc CI và cắt giảm chi phí runner. Những điểm cần khắc sâu:
- Cache để đi nhanh, không phải để lưu trữ tin cậy. Pipeline vẫn phải đúng khi cache biến mất. Thứ cần giữ và chuyển giao là artifact, không phải cache.
- Key là tất cả. Key phải ổn định khi input không đổi, và tự đổi khi input thật sự đổi — thường bằng cách
hashFileslên file lock. Không bao giờ nhét giá trị biến động (SHA, timestamp) vào key. - restore-keys cứu bạn khi lock file đổi nhẹ, cho phép partial restore thay vì cài lại từ số 0.
- Cache đúng thư mục. Ưu tiên cache thư mục download của package manager (
~/.npm,~/.m2,~/.cache/pip) kèm lệnh cài sạch, thay vì cache thẳngnode_modules. - Đưa đủ yếu tố môi trường vào key (OS, kiến trúc, version runtime) để tránh bug "poisoned cache".
- Luôn kiểm chứng hit rate qua log. Một cache "được cấu hình" chưa chắc là cache "đang hoạt động".