Vấn đề build cache trong CI
Khi build Docker image trong CI, thách thức lớn nhất là build cache không tồn tại giữa các job. Mỗi CI job chạy trên một runner mới, không có layer cache từ lần build trước. Kết quả: npm ci tải lại toàn bộ package mỗi lần build dù package.json không thay đổi, thời gian build bị kéo dài không cần thiết.
Giải pháp: dùng --cache-from để pull cache từ registry, kết hợp BuildKit và tối ưu Dockerfile. Bài này đi qua từng bước: build cache trong CI, BuildKit features, multi-arch build với buildx, registry strategy, và cấu hình GitHub Actions workflow hoàn chỉnh.
Build cache trong CI
Vấn đề: ephemeral runner
CI runner (GitHub Actions, GitLab CI, CircleCI, v.v.) chạy job trong container hoặc VM tạm thời. Khi job kết thúc, toàn bộ filesystem bị xóa sạch. Điều này có nghĩa Docker layer cache – thứ giúp docker build lần thứ hai chỉ mất vài giây – không tồn tại giữa các job. Mỗi lần chạy pipeline là một lần build từ đầu.
Giải pháp 1: --cache-from và --cache-to
Thay vì lưu cache trên disk của runner, bạn lưu cache manifest lên chính container registry:
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
# Build với registry cache
docker buildx build \
--cache-from type=registry,ref=myregistry.azurecr.io/myapp:cache \
--cache-to type=registry,ref=myregistry.azurecr.io/myapp:cache,mode=max \
--tag myregistry.azurecr.io/myapp:$GIT_SHA \
--push .
Cách này hoạt động như sau:
--cache-from: trước khi build, Docker pull cache manifest từ registry. Các layer nào match với layer trong manifest sẽ được reuse.--cache-to: sau khi build, Docker push cache manifest mới lên registry.mode=maxexport cache cho tất cả stage của multi-stage build (mặc địnhmode=minchỉ export cache cho stage cuối cùng).- Cache manifest là blob metadata nhẹ, không phải image thật, nên push/pull nhanh.
--cache-to type=inline. Cache được embed trực tiếp vào image config, không cần tag cache riêng. Nhược điểm: chỉ export cache cho stage cuối, và cache blob nằm trong image nên weight nhẹ hơn nhưng không linh hoạt bằng registry cache. Phù hợp cho project nhỏ, ít layer.Giải pháp 2: BuildKit cache mount
Nếu CI runner của bạn có persistent volume hoặc bạn dùng self-hosted runner, cache mount là lựa chọn hiệu quả nhất:
# Dockerfile với cache mount
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
docker buildx build \
--cache-from type=local,src=/tmp/docker-cache \
--cache-to type=local,dest=/tmp/docker-cache,mode=max \
--tag myapp:latest .
--mount=type=cache mount một thư mục cache từ host vào trong container build. Thư mục /root/.npm được giữ lại giữa các lần build, nên npm ci chỉ tải package mới hoặc thay đổi. Kết hợp với local cache, thời gian npm ci từ 2 phút giảm xuống dưới 5 giây.
Cache mount vs volume mount: --mount=type=cache khác với --mount=type=bind ở chỗ cache directory được quản lý bởi BuildKit và có cơ chế garbage collection riêng. Dữ liệu trong cache không xuất hiện trong image cuối cùng, chỉ dùng trong quá trình build. Nếu cache directory không tồn tại, BuildKit tạo mới. Nếu tồn tại, nó reuse. Đây là “lazy persistent cache” – cực kỳ phù hợp cho CI.
Các cache directory phổ biến:
- Node.js:
/root/.npmhoặc~/.npm - Go:
/root/.cache/go-build+/go/pkg/mod - Python:
/root/.cache/pip - Rust:
/usr/local/cargo/registry+/target - Java/Maven:
/root/.m2
BuildKit
BuildKit là gì
BuildKit là builder engine thế hệ mới của Docker, thay thế builder cũ từ năm 2018. Từ Docker Engine v23 (2023), BuildKit được bật mặc định – bạn không cần DOCKER_BUILDKIT=1 nữa. BuildKit mang lại:
| Feature | Builder cũ | BuildKit |
|---|---|---|
| Build song song | Không (sequential) | Có (parallel stages) |
| Cache mount | Không | Có |
| Secret mount | Không | Có |
| SSH mount | Không | Có |
| Multi-platform | Không (cần buildx) | Có (qua buildx) |
| Output format | Chỉ image | Image, local, tar, oci |
Build song song (parallel build)
Builder cũ build từng stage tuần tự. BuildKit phân tích dependency graph giữa các stage và build các stage không phụ thuộc lẫn nhau song song:
FROM node:20 AS base
WORKDIR /app
COPY package*.json ./
FROM base AS deps
RUN npm ci
FROM base AS test
RUN npm ci
COPY . .
RUN npm test
FROM deps AS build
COPY . .
RUN npm run build
# base, deps, test chạy song song vì không phụ thuộc lẫn nhau
# build đợi deps hoàn thành
Trong CI pipeline, parallel build có thể giảm 30-40% thời gian build với multi-stage Dockerfile phức tạp.
Secret mount
Bạn cần NPM_TOKEN để cài private package, nhưng không muốn token này nằm trong image layer (vì COPY .npmrc . sẽ embed token vào layer):
# Dockerfile cũ -- NGUY HIỂM: token trong layer
COPY .npmrc .
RUN npm ci --only=production
RUN rm .npmrc # không giúp ích gì -- layer trước vẫn chứa token
# Dockerfile với BuildKit secret mount
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --only=production
# CI: truyền secret qua file hoặc env
docker buildx build \
--secret id=npmrc,src=$HOME/.npmrc \
--tag myapp:latest .
Secret chỉ tồn tại trong quá trình build, không xuất hiện trong bất kỳ layer nào của image. Trên GitHub Actions, bạn có thể truyền secret từ repository secrets.
SSH mount
Khi build cần clone private repo hoặc SSH vào server nội bộ:
RUN --mount=type=ssh \
git clone [email protected]:org/private-repo.git
docker buildx build --ssh default=$SSH_AUTH_SOCK --tag myapp .
DOCKER_BUILDKIT=1. Một dấu hiệu nhận biết: nếu bạn thấy lỗi the --mount option requires BuildKit, là bạn đang dùng builder cũ.docker buildx cho advanced build
docker buildx là CLI mở rộng cho Docker build, hỗ trợ multi-platform, advanced cache, và nhiều builder driver:
# Kiểm tra builder hiện tại
docker buildx ls
# Tạo builder mới với docker-container driver (hỗ trợ multi-arch)
docker buildx create --name mybuilder --use --driver docker-container
# Build và push trong một lệnh
docker buildx build \
--platform linux/amd64,linux/arm64 \
--cache-from type=registry,ref=myreg/myapp:cache \
--cache-to type=registry,ref=myreg/myapp:cache,mode=max \
--tag myreg/myapp:$GIT_SHA \
--push .
docker buildx dùng builder driver docker-container (thay vì docker driver mặc định) để có đầy đủ tính năng: multi-platform, cache export/import, và output linh hoạt.
Multi-arch build
Tại sao cần multi-arch
Nếu bạn chạy production trên AWS Graviton (ARM64) hoặc team dùng Mac M1/M2 (ARM64) cho development, bạn cần build image cho nhiều kiến trúc CPU. Build image AMD64 trên máy ARM sẽ cực chậm vì phải emulate, và ngược lại. Multi-arch build giải quyết vấn đề này: tạo một manifest list chứa nhiều image cho các platform khác nhau, Docker runtime tự chọn image phù hợp khi pull.
Cấu hình buildx cho multi-arch
# Bước 1: tạo builder với QEMU support
docker buildx create --name multiarch --use --driver docker-container
# Bước 2: cài QEMU binaries (một lần)
docker run --privileged --rm tonistiigi/binfmt --install all
# Bước 3: build cho cả AMD64 và ARM64
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag myreg/myapp:$GIT_SHA \
--push .
tonistiigi/binfmt cài đặt QEMU user-mode emulation, cho phép kernel Linux chạy binary của kiến trúc khác thông qua binfmt_misc. Sau bước này, bạn có thể build ARM64 binary trên AMD64 runner và ngược lại.
Verify multi-arch image
# Kiểm tra manifest list
docker buildx imagetools inspect myreg/myapp:$GIT_SHA
# Output:
# Name: myreg/myapp:abc123
# MediaType: application/vnd.docker.distribution.manifest.list.v2+json
# Digest: sha256:...
#
# Manifests:
# Name: myreg/myapp:abc123@sha256:...
# Platform: linux/amd64
# Platform: linux/arm64
# Test pull trên từng platform
docker pull --platform linux/arm64 myreg/myapp:$GIT_SHA
docker run --rm --platform linux/arm64 myreg/myapp:$GIT_SHA
Registry strategy
Tag đúng cách
Quy tắc vàng: không bao giờ dùng :latest trong CI/CD pipeline. :latest là mutable tag – không biết version nào đang chạy, không rollback được, và gây ra “works on my machine” kinh điển.
Thay vào đó, dùng immutable tags:
# CI pipeline
GIT_SHA=$(git rev-parse --short HEAD) # abc1234
BRANCH=$(git rev-parse --abbrev-ref HEAD) # main hoặc feat/xxx
TIMESTAMP=$(date +%s) # 1718400000
# Tags cho production
docker tag myapp myreg/myapp:$GIT_SHA # immutable
docker tag myapp myreg/myapp:$BRANCH-$TIMESTAMP # traceable
docker tag myapp myreg/myapp:$BRANCH # rolling (dùng cho staging)
# KHÔNG dùng
docker tag myapp myreg/myapp:latest # NEVER in CI
Chiến lược tag mình dùng:
- Production deploy: tag bằng git SHA. Immutable, traceable về commit.
- Staging: rolling tag theo branch (ví dụ
main,develop). Deploy staging tự động pull tag mới nhất. - Hotfix: git SHA +
-hotfixsuffix để dễ nhận diện.
Push lên multiple registries
Trường hợp cần push image lên nhiều registry (ví dụ Docker Hub cho team dev + Azure Container Registry cho production):
docker buildx build \
--tag docker.io/myorg/myapp:$GIT_SHA \
--tag myregistry.azurecr.io/myapp:$GIT_SHA \
--push .
Build một lần, push lên nhiều registry. Image digest giống hệt nhau trên tất cả registry (manifest giống nhau, chỉ khác registry prefix).
GitHub Actions workflow hoàn chỉnh
Dưới đây là workflow production-ready cho Node.js app:
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=,format=short
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
scan:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
Giải thích các điểm quan trọng:
docker/setup-buildx-action@v3: cấu hình BuildKit builder với driverdocker-container.docker/metadata-action@v5: tự động generate tag và label theo convention (git SHA, branch name). Không cần tự viết shell script để extract tag.cache-from: type=ghavàcache-to: type=gha: dùng GitHub Actions cache backend. BuildKit lưu cache vào GitHub Actions cache (không phải registry). Cache được share giữa các job và các branch. Free, nhanh, không tốn registry storage.platforms: linux/amd64,linux/arm64: build multi-arch.- Job
scanchạy saubuild-and-push, dùng Trivy để scan CVE.
type=gha) vs Registry cache (type=registry): GHA cache miễn phí, nhanh vì dùng Azure CDN nội bộ, và tự động hết hạn sau 7 ngày không truy cập. Registry cache thì dùng chính container registry của bạn để lưu cache manifest – phù hợp nếu bạn dùng self-hosted CI (GitLab Runner, Jenkins) hoặc muốn cache không bị giới hạn 10GB như GHA cache.Layer caching: đúng thứ tự COPY
Nguyên tắc cơ bản nhất nhưng hay bị bỏ qua: thứ tự lệnh trong Dockerfile quyết định cache hit/miss.
Dockerfile chưa tối ưu
FROM node:20-alpine
WORKDIR /app
COPY . . # cache miss mỗi khi code thay đổi
RUN npm ci # cache miss theo, dù package.json không đổi
RUN npm run build # cache miss
CMD ["node", "dist/index.js"]
Mỗi lần sửa một dòng code trong src/, COPY . . cache miss, kéo theo tất cả lệnh sau đó cache miss. npm ci phải chạy lại dù không package nào thay đổi.
Dockerfile tối ưu
FROM node:20-alpine
WORKDIR /app
# Layer 1: package.json thay đổi ít
COPY package*.json ./
RUN npm ci --only=production
# Layer 2: source code thay đổi nhiều
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
Bây giờ khi sửa code nhưng không thay đổi dependency:
COPY package*.json ./→ cache HITRUN npm ci→ cache HIT (vì layer trên không đổi)COPY . .→ cache MISS (code thay đổi)RUN npm run build→ chạy (nhưng nhanh vìnode_modulesđã có)- Tổng thời gian: ~30 giây thay vì 3 phút
Quy tắc: COPY những thứ ít thay đổi trước, những thứ thay đổi nhiều sau.
builder cache hit hoàn toàn, stage runtime vẫn có thể cache miss nếu COPY --from=builder thấy artifact thay đổi. Để tận dụng tối đa: dùng mode=max trong --cache-to để export cache cho tất cả stage. Một pattern hay gặp: stage deps riêng để cache node_modules, stage builder extend từ deps, và stage runtime chỉ copy artifact cần thiết.Image scanning trong CI
Build xong push lên registry là chưa đủ. Bạn cần scan image để phát hiện CVE (Common Vulnerabilities and Exposures) trước khi deploy lên production.
Trivy
Trivy của Aqua Security là scanner phổ biến nhất cho Docker image:
# Scan image
trivy image myreg/myapp:$GIT_SHA
# Scan với severity filter, chỉ fail nếu có CRITICAL
trivy image \
--severity CRITICAL,HIGH \
--exit-code 1 \
--format sarif \
--output trivy-results.sarif \
myreg/myapp:$GIT_SHA
# Scan Dockerfile (misconfiguration)
trivy config ./Dockerfile
Trong CI, bạn nên:
- Scan image sau khi build, trước khi deploy.
- Set
--exit-code 1nếu có CRITICAL CVE → pipeline fail, ngăn deploy. - Export kết quả SARIF để hiển thị trên GitHub Security tab.
Docker Scout
Docker Scout là công cụ chính thức từ Docker:
docker scout quickview myreg/myapp:$GIT_SHA
docker scout cves myreg/myapp:$GIT_SHA --exit-code --only-severity critical
Scout có ưu điểm tích hợp sẵn với Docker Desktop và Docker Hub, nhưng cần Docker subscription cho advanced features.
Grype
Grype của Anchore – nhẹ, nhanh, dễ tích hợp:
grype myreg/myapp:$GIT_SHA --fail-on critical
Sơ đồ CI pipeline
graph TB
A[Git Push] --> B{Changed?}
B -->|Dockerfile| C[Build with cache]
B -->|Dependencies| D[Cache MISS<br/>npm ci from scratch]
B -->|Source only| E[Cache HIT<br/>skip npm ci]
C --> F[Unit Test]
F --> G{Pass?}
G -->|Yes| H[Image Scan<br/>Trivy / Grype]
G -->|No| X[Fail Pipeline]
H --> I{CVE Critical?}
I -->|Yes| X
I -->|No| J[Push to Registry]
J --> K{Purpose?}
K -->|Staging| L[Deploy Staging<br/>rolling tag]
K -->|Production| M[Deploy Production<br/>git SHA tag]
L --> N[Smoke Test]
N --> O[Ready for Review]
M --> P[Health Check]
P --> Q[Live]
style D fill:#ff6b6b,color:#000
style E fill:#51cf66,color:#000
style X fill:#ff6b6b,color:#000
style Q fill:#51cf66,color:#000
Pipeline này có 3 nhánh tùy theo loại thay đổi: thay đổi Dockerfile hoặc dependency thì cache miss phải build lại từ đầu; chỉ thay đổi source code thì cache hit, skip npm ci; và luôn có bước scan trước khi push. Nếu scan phát hiện CRITICAL CVE, pipeline fail và không deploy.
Tổng kết
Build Docker image trong CI nhanh hay chậm không phải do CI runner mạnh hay yếu mà chủ yếu do bạn có tận dụng cache đúng cách không. Những điểm cần nhớ:
- Dùng registry cache hoặc GHA cache (
--cache-from/--cache-to) để build cache tồn tại giữa các CI job. - Dùng BuildKit cache mount (
--mount=type=cache) cho package manager cache directory. Đây là cách hiệu quả nhất để cachenode_modules,.m2,go/pkg/mod. - Dùng BuildKit secret mount (
--mount=type=secret) thay vìCOPY .npmrc .để không leak token vào image layer. - Build multi-arch với
docker buildx build --platform linux/amd64,linux/arm64. Dùng QEMU emulation nếu không có ARM64 runner. - Immutable tags cho production (git SHA), rolling tags cho staging (branch name). Không dùng
:latest. - Sắp xếp COPY đúng thứ tự: package manifest trước, source code sau, để tối đa cache hit.
- Scan CVE trước khi deploy, block nếu có CRITICAL. Dùng Trivy, Grype, hoặc Docker Scout.
Câu hỏi hay gặp
Q: GitHub Actions cache có giới hạn 10GB, bị evict sau 7 ngày. Làm sao xử lý khi cache bị evict?
Cache bị evict thì pipeline vẫn hoạt động bình thường, chỉ chậm hơn vì cache miss. BuildKit tự động fallback về build từ đầu. Nếu bạn build image nhiều lần mỗi ngày, cache sẽ được refresh liên tục và không bị evict. Nếu vẫn vượt 10GB, dùng registry cache (type=registry) – không giới hạn dung lượng, chỉ tính vào registry storage.
Q: Multi-arch build QEMU chậm quá, có cách nào nhanh hơn không?
Có 3 lựa chọn theo thứ tự tăng dần chi phí và tốc độ: (1) Build tuần tự từng platform trên QEMU – chậm nhưng free. (2) Dùng native ARM64 runner (GitHub Actions có ubuntu-24.04-arm cho team plan). (3) Cross-compile trong Dockerfile thay vì QEMU emulation – phức tạp hơn nhưng native speed. Với đa số project, QEMU build ARM64 trên AMD64 runner vẫn chấp nhận được (chậm hơn ~2-3x).
Q: Có cần build image trong CI cho mỗi pull request không?
Tùy vào workflow team bạn. Mình khuyến khích build image cho PR để verify Dockerfile không bị hỏng (build test), nhưng không push image cho PR branch để tiết kiệm registry storage. Chỉ push khi merge vào main. Dùng push: ${{ github.ref == 'refs/heads/main' }} trong GitHub Actions để conditional push.
Q: Scan ra CRITICAL CVE nhưng không có fix version, phải làm sao?
Đây là tình huống khó. Các bước xử lý: (1) Đọc CVE detail, kiểm tra xem có thực sự ảnh hưởng đến app của bạn không (exploitability analysis). (2) Nếu CVE nằm trong transitive dependency mà app không dùng, bạn có thể suppress tạm thời (có documented exception) và theo dõi. (3) Nếu ảnh hưởng thật, tìm workaround (disable feature liên quan, thêm network policy, v.v.) cho đến khi upstream có patch. (4) Cập nhật base image thường xuyên (ít nhất weekly) để giảm thiểu window of exposure.
Bài tiếp theo (Production): Phần 13: Production: resource limit & OOM, CPU/memory limits, OOM killer, init process, graceful shutdown.