Tối ưu build cache: những gì thực sự có tác dụng năm 2025
CI chậm vì sai cache layer, mode hoặc key — không phải do phần cứng. Cách sửa hệ thống: Docker BuildKit, GitHub Actions cache và Turborepo remote cache.
Bởi Ethan
3.214 từ · 17 phút đọc
Phần lớn các team vẫn đang để lãng phí một lượng đáng kể thời gian CI. Build xong, test pass, pipeline xanh — nhưng một phần lớn những gì đã chạy thực ra không cần chạy. Cache đã được cấu hình, nhưng đang cache sai granularity, sai mode, hoặc dùng key bị invalidate quá nhanh.
Bài viết này phân tích ba tầng cache thường xuyên bị cấu hình sai nhất — Docker/BuildKit, GitHub Actions cache, và Turborepo remote cache — và cách chẩn đoán từng tầng trước khi nghĩ đến chuyện nâng phần cứng.
Dành cho ai
Developer senior và kỹ sư DevOps đã có cache sẵn và đang gặp phải các edge case: build đáng lẽ phải hit cache nhưng không hit, lo ngại cache poisoning, hoặc CI chậm hơn sau khi nâng cấp infrastructure. Nếu bạn chưa thiết lập cache, tài liệu chính thức của từng công cụ là điểm khởi đầu tốt hơn bài này.
”Cache hit” có nghĩa gì với từng công cụ
“Cache” thực ra chỉ ba thứ có kiến trúc khác nhau hoàn toàn. Nhầm lẫn giữa chúng là nguồn gốc của hầu hết các lỗi khó debug.
Docker layer cache: được key bằng nội dung instruction và hash của layer cha. Một instruction RUN ở layer 7 sẽ invalidate tất cả các layer sau đó. Cache được lưu trên disk hoặc trong registry, tùy backend bạn cấu hình.
GitHub Actions cache action: được key bằng một chuỗi bạn tự xây dựng, tra cứu trong một store 10 GB mỗi repository. Khi miss, action không làm gì — build chạy từ đầu. Khi hit, nó restore một archive .tar.gz vào path bạn chỉ định, rồi lưu path đó vào cuối job nếu primary key là mới.
Turborepo task hash: được key bằng content hash của tất cả inputs của một task — source file, env var, turbo config. Hash match sẽ phát lại output đã cache và bỏ qua task. Cache có thể nằm trên local disk hoặc trên remote store (Vercel Remote Cache hoặc bất kỳ S3-compatible endpoint nào).
Ba loại cache này không tương tác với nhau. Sửa thứ tự Docker layer không giúp ích gì cho Turborepo. Turborepo cache hit hoàn hảo vẫn có thể khiến Docker re-run nếu một Docker cache key không liên quan thay đổi. Hãy giữ chúng tách biệt trong mô hình tư duy của bạn.
Docker/BuildKit: các mode cần quan tâm
BuildKit có sáu cache backend: inline, registry, local, gha, s3, và azblob. Backend s3 và azblob chưa được release chính thức. Backend gha đang ở beta. Với phần lớn pipeline, lựa chọn thực tế là inline, registry, hoặc gha.
min vs max mode — cài đặt bị hiểu nhầm nhiều nhất
Mọi external cache backend đều hỗ trợ tham số mode: min hoặc max.
min(mặc định): chỉ export các layer của final image. Intermediate stage trong multi-stage build không được cache.max: export tất cả các layer, bao gồm mọi intermediate stage.
inline về mặt cấu trúc chỉ hỗ trợ min mode. Inline cache lưu metadata cache trong image manifest. Như tài liệu chính thức ghi: “It doesn’t scale with multi-stage builds as well as the other drivers do.”
Nếu Dockerfile của bạn có nhiều hơn một FROM stage và bạn đang dùng inline cache, bạn không nhận được lợi ích cache nào trên intermediate stage. Đó là lý do chuyển từ inline sang registry cache thường mang lại mức tăng tốc CI lớn nhất trong một thay đổi duy nhất.
Inline vs registry vs gha
| Backend | Hỗ trợ mode | Vị trí cache | Phù hợp nhất với |
|---|---|---|---|
| inline | chỉ min | Image manifest | Single-stage image, team nhỏ |
| registry | min + max | OCI artifact riêng biệt | Multi-stage build, production |
| gha | min + max | GitHub Actions cache | Pipeline chỉ dùng GitHub |
Chuyển sang registry mode với max:
- name: Build
uses: docker/build-push-action@v6
with:
cache-from: type=registry,ref=ghcr.io/myorg/myimage:cache
cache-to: type=registry,ref=ghcr.io/myorg/myimage:cache,mode=max
Lưu ý cả registry và gha backend đều yêu cầu BuildKit driver không phải mặc định. Để dùng những backend này, bạn cần tạo một builder mới với driver khác (ví dụ docker-container hoặc kubernetes). Driver docker mặc định không hỗ trợ chúng.
Thứ tự COPY/RUN layer
Thứ tự layer là tối ưu hóa Docker được thảo luận nhiều nhất và vẫn bị làm sai phổ biến nhất trên thực tế.
Mỗi layer thay đổi sẽ invalidate tất cả các layer tiếp theo. Quy tắc sắp xếp:
- File ít thay đổi (package hệ thống, cấu hình language runtime)
- Dependency manifest (
package.json,requirements.txt,go.mod) - Cài đặt dependency (
RUN npm ci,RUN pip install -r requirements.txt) - Source code ứng dụng
- Bước build
FROM node:22-slim AS builder
WORKDIR /app
# Layer 2-3: dependency manifests + install (ít thay đổi)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Layer 4-5: source + build (thay đổi theo từng commit)
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY . . trước npm ci có nghĩa là mọi thay đổi source — kể cả một comment trong file test — sẽ bust install layer. Đây là thay đổi Docker có ROI cao nhất trên phần lớn dự án.
Breaking change tháng 2–3/2025 trong gha cache
GitHub Actions Cache API v1 bị khai tử theo từng giai đoạn: dịch vụ legacy kết thúc vào ngày 1 tháng 2 năm 2025, với deadline nâng cấp bắt buộc là ngày 1 tháng 3 năm 2025. Backend gha hiện yêu cầu Cache API v2, và điều đó đòi hỏi:
- Buildx ≥ v0.21.0
- BuildKit ≥ v0.20.0
Nếu bạn đã pin một phiên bản Buildx trước thời điểm đó và đang dùng backend gha, cache của bạn đã âm thầm fallback về cold build sau ngày 1 tháng 3 năm 2025. Điều đáng ngại là failure mode rất tinh vi: build vẫn thành công, chỉ không có cache. Kiểm tra phiên bản Buildx của bạn:
docker buildx version
Backend gha cũng áp dụng branch scoping: cache entry chỉ có thể truy cập từ branch hiện tại, PR base branch, và default branch. Cross-branch cache pollution là không thể, nhưng cross-team cache warming trên long-lived feature branch cũng vậy.
GitHub Actions cache: thiết kế key
Primary key của action actions/cache là chuỗi duy nhất tạo ra exact match và lưu cache entry mới. Nếu nó thay đổi theo mỗi lần chạy, bạn không bao giờ hit cache. Nếu nó không bao giờ thay đổi, bạn nhận được dữ liệu cũ.
Key đúng cho Node.js dependencies:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Expression hashFiles('**/package-lock.json') tạo ra key ổn định giữa các lần chạy khi lockfile không đổi. Khi lockfile thay đổi, key thay đổi và một cache entry mới được lưu. Fallback restore-keys bắt partial match — cache từ lockfile hôm qua vẫn tốt hơn không có cache gì cả.
Eviction policy
GitHub Actions cache store có dung lượng 10 GB mỗi repository. Khi vượt quá giới hạn đó, GitHub evict theo ngày truy cập cuối, cũ nhất bị xóa trước. Entry cũng hết hạn sau 7 ngày không truy cập.
Hai hệ quả thực tế:
- Feature branch chạy hàng tuần bị evict thường xuyên. Chỉ thêm
${{ github.ref }}vào key nếu bạn chấp nhận cold start trên branch chạy ít hơn một lần mỗi ngày. - Giữ một fallback
restore-keysvề prefix ngắn hơn. Fallback${{ runner.os }}-node-cho bạn cache ấm từ lần chạy gần đây bất kỳ, kể cả khi lockfile hash chính xác khác.
Invalidate cache có chủ đích
Khi bạn nâng phiên bản major dependency, bạn muốn cache mới hoàn toàn, không phải partial restore âm thầm giữ lại package cũ. Pattern là đánh version vào cache key:
key: ${{ runner.os }}-node-v2-${{ hashFiles('**/package-lock.json') }}
Tăng v2 lên v3 khi bạn muốn force cold start. Một ngày cụ thể như 2025-04-01 cũng hoạt động tốt và tự ghi lại khi nào invalidation xảy ra.
Nếu bạn đang cân nhắc có nên dùng GitHub Actions không, GitHub Actions vs CircleCI — CI nào thắng năm 2026? so sánh toàn bộ trade-off về chi phí và tính năng.
Turborepo remote cache
Task hash của Turborepo được tính từ nội dung inputs của bạn — theo mặc định, tất cả file trong package không bị loại trừ bởi .gitignore, cộng với global dependency và env var. Hash match sẽ phát lại output đã cache và bỏ qua task.
Cấu hình inputs tường minh
Bộ input mặc định khá thận trọng: mọi thay đổi file trong package đều bust task cache của nó. Với hầu hết package, nhiều file trong số đó không ảnh hưởng đến output build — test fixture, cập nhật README, comment nội bộ. Hãy thu hẹp inputs:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**/*.ts", "src/**/*.tsx", "package.json", "tsconfig.json"],
"outputs": ["dist/**"]
}
}
}
Với inputs tường minh, thay đổi README.md hay snapshot file không bust build cache. Đây là tinh chỉnh Turborepo có hiệu quả cao thứ hai, sau việc cấu hình đúng outputs glob.
Env var không nên là input
Theo mặc định, Turborepo đưa các env var được liệt kê trong globalEnv vào global cache key. Hãy cân nhắc kỹ những gì bạn đưa vào:
{
"globalEnv": ["CI", "NODE_ENV"],
"globalPassThroughEnv": ["SENTRY_AUTH_TOKEN", "GITHUB_TOKEN"]
}
globalPassThroughEnv truyền biến vào task mà không đưa nó vào cache key. Token luân phiên — GITHUB_TOKEN của GitHub Actions thay đổi mỗi lần chạy — nên ở trong passThrough, không phải env. Nếu nó nằm trong env, mỗi lần chạy sẽ có cache key duy nhất và bạn không bao giờ hit remote cache.
Breaking change trong Turborepo 2.0 (tháng 6/2024)
Ba thay đổi trong Turborepo 2.0 âm thầm làm giảm cache hit rate khi nâng cấp:
-
Workspace root giờ là implicit dependency của mọi package. Thay đổi bất kỳ file nào trong repo root sẽ bust task cache của tất cả package. Đây là cố ý — thay đổi tooling ở root có thể ảnh hưởng tất cả package — nhưng có nghĩa là CI của bạn chạy cold sau khi bạn cập nhật
.eslintrcở root mà chẳng ai đụng đến. -
Trường
enginestrong rootpackage.jsonđược đưa vào global cache key. Nếu bạn có"engines": { "node": ">=20" }trong rootpackage.json, một lần bump version ở đó sẽ invalidate tất cả cache. Kiểm tra rootpackage.jsoncủa bạn trước khi cho rằng cache regression là vấn đề của BuildKit. -
outputModeđổi tên thànhoutputLogs. Nếu config của bạn vẫn dùngoutputMode, Turborepo âm thầm bỏ qua nó. Chạy codemod được cung cấp:
npx @turbo/codemod@latest migrate
Scope với --filter
Trên monorepo lớn, phần lớn PR chỉ đụng đến một hoặc hai package. Dùng --filter để bỏ qua hoàn toàn các package không đổi:
turbo build --filter='[HEAD^1]'
[HEAD^1] chỉ build những package có thay đổi kể từ commit trước, cùng với các dependent của chúng. Trên monorepo 40 package mà một PR chỉ đụng đến 3 package, điều này thu nhỏ dependency-resolution graph từ 40 node xuống còn 5–8.
Để tìm hiểu các pitfall production xuất hiện sau 3–12 tháng vận hành monorepo Turborepo — gồm cả edge case về cache ngoài phạm vi bài này — xem Các pitfall monorepo Turborepo học được qua thực chiến.
Nx Cloud: khi nào đáng dùng
Nx Cloud về bản chất là Turborepo remote cache với distributed task execution (DTE) chạy phía trên. Với DTE, các task trong một lần build được phân tán sang nhiều CI agent thay vì chạy tuần tự trên một máy.
Free tier cho 50.000 credit mỗi tháng (mỗi task-second tiêu thụ credit) và tối đa 5 contributor. Team tier bắt đầu từ $19 mỗi contributor cộng $5.50 mỗi 10.000 credit. Với phần lớn team, câu hỏi không phải là free tier — mà là liệu DTE có đáng chi phí Team tier không.
DTE có lợi khi bạn có các task có thể chạy song song nhưng đang bị bottleneck trên một runner duy nhất. Test run 20 phút với 4 test suite độc lập sẽ chạy xong trong ~5 phút với 4 DTE agent. Phép tính khá rõ ràng.
Để so sánh đầy đủ Turborepo và Nx ở cấp độ kiến trúc — bao gồm cả khi giá DTE của Nx có lý — xem Công cụ monorepo tốt nhất năm 2026 — pnpm + Turborepo hay Nx?.
Nhược điểm: overhead cấu hình của Nx là có thật. Nếu bạn đã chạy Turborepo với remote cache và bottleneck không phải là agent parallelism, DTE thêm độ phức tạp mà không mang lại tốc độ. Self-hosted Turborepo remote cache (bất kỳ S3-compatible bucket nào) xử lý được use case “chia sẻ cache giữa các CI machine” mà không phát sinh chi phí per-contributor của Nx Cloud.
Kiểm tra xem cache có hoạt động không
Docker cache hit rate
Thêm --progress=plain vào lệnh BuildKit của bạn:
docker build --progress=plain .
Tìm CACHED ở mỗi bước. Bước nào không có CACHED là cache miss. Miss đầu tiên trong chuỗi layer là điểm invalidation của bạn.
Turborepo cache analytics
Truyền --summarize để nhận breakdown theo từng task:
turbo build --summarize
Turborepo ghi vào .turbo/runs/<run-id>.json. Mỗi task entry có cacheState — HIT, MISS, hoặc SKIP. Lần chạy mà 90% task là HIT nhưng CI vẫn chậm thường có nghĩa là 10% task cold đang nằm trên critical path.
Để đo lường liên tục, Vercel Remote Cache dashboard hiển thị hit rate theo thời gian. Với self-hosted cache, thêm một CI step phân tích summary JSON và đăng hit rate dưới dạng PR comment hoặc metric.
GitHub Actions cache metrics
Cache action ghi Cache restored from key khi hit và Cache not found for input keys khi miss. Tìm những chuỗi này trong job log của bạn. GitHub cũng hiển thị cache usage tại Settings → Actions → Caches — sắp xếp theo last accessed để tìm các entry chưa được hit trong nhiều ngày.
Bộ sưu tập lỗi phổ biến
1. Timestamp không xác định trong Docker layer
Một bước RUN nhúng timestamp hiện tại vào bất kỳ output file nào sẽ tạo ra layer duy nhất trên mỗi lần build, bất kể source file có thay đổi hay không. Các thủ phạm thường gặp: date trong build script, Date.now() được đưa vào bundle output, hoặc C compilation dùng __DATE__.
Cách xử lý: SOURCE_DATE_EPOCH. Đặt nó thành một giá trị cố định (timestamp của git commit rất phù hợp) và cấu hình build tool để tôn trọng nó:
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
docker build --build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH .
Sau đó trong Dockerfile:
ARG SOURCE_DATE_EPOCH
RUN SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH npm run build
2. Secret qua build arg
Docker build arg được ghi vào image history. Một --build-arg API_KEY=... sẽ lộ key cho bất kỳ ai có quyền docker history trên image. Đây là vấn đề riêng so với cache, nhưng cả hai thường xuất hiện cùng nhau — các team truyền secret qua build arg để có sẵn tại build time mà không nhận ra nguy cơ.
Cách xử lý: dùng BuildKit secrets mount. Chúng có sẵn tại build time nhưng không được đưa vào image hay history của nó:
RUN --mount=type=secret,id=api_key \
API_KEY=$(cat /run/secrets/api_key) npm run build
docker build --secret id=api_key,env=API_KEY .
3. Lockfile không có trong cache key
package-lock.json không được đưa vào cache key có nghĩa là dependency install được cache ngay cả khi package thay đổi. Lỗi kinh điển:
# Sai: key không bao giờ thay đổi khi dependency cập nhật
key: ${{ runner.os }}-node
Cách xử lý: luôn hash lockfile:
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
4. Cache write race với parallel runner
Khi hai CI job chia sẻ một cache key và cả hai đều miss, cả hai sẽ cố gắng lưu vào key đó khi hoàn thành. Lần ghi thứ hai thắng và ghi đè lên lần đầu. Nếu hai job có output khác nhau (phiên bản package khác nhau, test fixture khác nhau), bạn sẽ có cache state không xác định.
Cách xử lý: scope cache key theo job hoặc thêm thành phần unique theo job khi các parallel job tạo ra output khác nhau. Nếu parallel job tạo ra output giống nhau (cùng lockfile, cùng input), race condition là vô hại — cả hai lần ghi đều tương đương nhau.
5. Cache poisoning qua branch access
Bất kỳ workflow nào có thể ghi vào Actions cache trên default branch đều có thể đầu độc cache được dùng bởi các branch khác, vì chính sách access của GitHub cho phép đọc từ base branch. Một PR độc hại ghi node_modules bị xâm phạm vào cache có thể ảnh hưởng đến build trên main.
Biện pháp giảm thiểu thực tế: đánh dấu cache-write step với if: github.ref == 'refs/heads/main' trên các key nhạy cảm, hoặc dùng namespace cache key riêng cho CI không chia sẻ write path với PR workflow. Toàn bộ attack surface được ghi lại ở đây.
Kết luận
Docker cache: chuyển từ inline sang registry mode với mode=max nếu bạn có nhiều hơn một FROM stage. Kiểm tra lại thứ tự COPY — đây là thay đổi đơn lẻ có đòn bẩy cao nhất. Nếu bạn đang dùng backend gha và Buildx cũ hơn v0.21.0, nâng cấp trước khi làm bất kỳ tối ưu nào khác.
GitHub Actions cache: hash lockfile trong key của bạn, thêm version prefix để invalidate có chủ đích, và giữ một fallback restore-keys. Giới hạn 10 GB mỗi repo hiếm khi là vấn đề nếu bạn evict theo key prefix khi lockfile thay đổi.
Turborepo: định nghĩa inputs tường minh cho mỗi task, đưa token luân phiên vào globalPassThroughEnv, và chạy codemod 2.0 nếu bạn đã nâng cấp mà chưa làm. Kiểm tra hit rate với --summarize trước khi cho rằng nâng cấp phần cứng sẽ giải quyết được.
Nx Cloud DTE: đáng cân nhắc nếu bạn có các task có thể song song hóa độc lập và dự án ở Team tier. Không đáng nếu bottleneck là sequential dependency chain hoặc bạn chưa đạt trần cache hit rate của Turborepo.
Hãy bắt đầu bằng việc đo lường. CI job ghi Cache not found mỗi lần chạy là vấn đề cấu hình, không phải vấn đề phần cứng.
Tham khảo
- BuildKit cache backends
- BuildKit inline cache — min mode limitation
- BuildKit cache invalidation
- GitHub Actions gha cache BREAKING CHANGE — moby/buildkit #5683
- GitHub Actions cache — eviction policy and key behavior
- Turborepo 2.0 release notes
- Turborepo
inputsconfiguration - Turborepo global env vs passThrough
- Nx Cloud DTE
- GitHub Actions cache poisoning research
- Docker build-arg secrets risk
- Parallel runner bake-action cache race