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--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=max export cache cho tất cả stage của multi-stage build (mặc định mode=min chỉ 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.
Dùng inline cache nếu bạn muốn đơn giản: --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/.npm hoặ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:

FeatureBuilder cũBuildKit
Build song songKhông (sequential)Có (parallel stages)
Cache mountKhông
Secret mountKhông
SSH mountKhông
Multi-platformKhông (cần buildx)Có (qua buildx)
Output formatChỉ imageImage, 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 .
Secret mount chỉ hoạt động với BuildKit. Nếu CI của bạn đang dùng Docker Engine cũ (< v23), hãy upgrade hoặc set 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
Chiến lược multi-arch cho CI: Nếu budget CI runner hạn chế, build tuần tự từng platform thay vì song song. QEMU emulation build ARM64 trên AMD64 runner chậm hơn native ~2-3 lần, nhưng vẫn rẻ hơn nhiều so với mua ARM64 runner riêng. Với project nhỏ, build cả 2 platform tuần tự trên 1 AMD64 runner là đủ dùng.

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 + -hotfix suffix để 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:

  1. docker/setup-buildx-action@v3: cấu hình BuildKit builder với driver docker-container.
  2. 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.
  3. cache-from: type=ghacache-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.
  4. platforms: linux/amd64,linux/arm64: build multi-arch.
  5. Job scan chạy sau build-and-push, dùng Trivy để scan CVE.
GitHub Actions cache (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 HIT
  • RUN 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.

Multi-stage build và layer cache: Với multi-stage build, cache hoạt động ở từng stage riêng biệt. Nếu stage 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:

  1. Scan image sau khi build, trước khi deploy.
  2. Set --exit-code 1 nếu có CRITICAL CVE → pipeline fail, ngăn deploy.
  3. 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
Scan image là bước bắt buộc, không phải optional. Một production image Node.js:20 có thể chứa 50+ CVE, trong đó 2-3 CRITICAL từ OpenSSL hoặc libcurl. Nếu bạn không scan, bạn đang deploy lỗ hổng đã biết lên production. Hãy đặt ngưỡng: block deploy nếu có CRITICAL CVE, warn nếu HIGH, ignore MEDIUM/LOW (nhưng theo dõi).

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ớ:

  1. Dùng registry cache hoặc GHA cache (--cache-from / --cache-to) để build cache tồn tại giữa các CI job.
  2. Dùng BuildKit cache mount (--mount=type=cache) cho package manager cache directory. Đây là cách hiệu quả nhất để cache node_modules, .m2, go/pkg/mod.
  3. Dùng BuildKit secret mount (--mount=type=secret) thay vì COPY .npmrc . để không leak token vào image layer.
  4. Build multi-arch với docker buildx build --platform linux/amd64,linux/arm64. Dùng QEMU emulation nếu không có ARM64 runner.
  5. Immutable tags cho production (git SHA), rolling tags cho staging (branch name). Không dùng :latest.
  6. Sắp xếp COPY đúng thứ tự: package manifest trước, source code sau, để tối đa cache hit.
  7. 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.