Tăng Tốc CI/CD Pipeline với Remote Caching và Docker BuildKit

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Pipeline Của Bạn Đang Chậm — Và Nó Tốn Kém Hơn Bạn Nghĩ

Bạn push một thay đổi config nhỏ. Có thể chỉ là cập nhật một biến môi trường hay sửa lỗi đánh máy trong comment của Dockerfile. Rồi bạn ngồi chờ. Năm phút. Tám phút. Đôi khi mười phút. Mọi kỹ sư trong team đều gặp phải cái bức tường đó, nhiều lần trong ngày.

Tôi đã trải qua điều này. Ở một dự án tôi tham gia, CI pipeline đang rebuild toàn bộ Docker image từ đầu ở mỗi commit — tải lại tất cả dependencies npm, biên dịch lại các native module, mọi thứ. Không ai thiết lập caching vì “nó chạy được, thôi kệ.” Sau khi chúng tôi thêm Remote Caching và BuildKit, thời gian build trung bình giảm từ 9 phút xuống dưới 90 giây. Đó là loại thay đổi khiến developer thực sự thích push code trở lại.

Hướng dẫn này sẽ chỉ cho bạn cách làm điều đó — từ việc hiểu tại sao build chậm cho đến cách kết nối remote cache hoạt động trong pipeline của bạn.

Khái Niệm Cốt Lõi: Điều Gì Thực Sự Xảy Ra Khi Build Docker

Layer Caching và Tại Sao Nó Bị Vỡ Trong CI

Docker build hoạt động theo từng layer. Mỗi lệnh trong Dockerfile (RUN, COPY, ADD) tạo ra một layer mới. Docker cache các layer này trên đĩa — vì vậy nếu không có gì thay đổi ở layer đó hoặc bất kỳ layer nào trước nó, Docker bỏ qua việc chạy lại lệnh và dùng kết quả đã cache.

Vấn đề là: hầu hết các CI runner đều là ephemeral. Mỗi lần GitHub Actions hay GitLab CI khởi động một runner, nó bắt đầu với một slate sạch. Không có local cache. Mọi build đều là cold build, và Docker làm lại mọi thứ từ đầu.

Docker BuildKit Mang Lại Điều Gì

BuildKit là build engine thế hệ tiếp theo của Docker, có sẵn từ Docker 18.09. Nó có nhiều ưu điểm so với build engine cổ điển:

  • Thực thi song song các build stage độc lập
  • Quản lý cache tốt hơn và kiểm soát cache chi tiết hơn
  • Hỗ trợ export và import cache từ remote storage
  • Secrets và SSH forwarding mà không nhúng credentials vào layer

Tính năng chúng ta quan tâm ở đây là cache export/import — cụ thể là đẩy build cache lên registry và kéo lại ở lần chạy tiếp theo.

Remote Caching: Mảnh Ghép Còn Thiếu

Remote caching có nghĩa là lưu trữ Docker build cache ở một nơi bền vững — thường là container registry như Docker Hub, Amazon ECR, hoặc GitHub Container Registry (GHCR). Khi CI runner bắt đầu build mới, nó tải cache đó về trước khi build. Các layer không thay đổi được bỏ qua hoàn toàn.

BuildKit hỗ trợ nhiều cache backend:

  • registry — Lưu cache dưới dạng OCI image layer trong container registry (phổ biến nhất)
  • local — Lưu cache trên filesystem cục bộ (hữu ích cho self-hosted runner với persistent volume)
  • s3 — Lưu cache trong S3-compatible bucket (thông qua external cache driver của BuildKit)
  • gha — Cache native của GitHub Actions (không cần registry, tích hợp chặt chẽ)

Thực Hành: Kết Nối Tất Cả Lại

Bước 1 — Bật BuildKit

Đầu tiên, hãy chắc chắn BuildKit đã được bật. Docker 23.0+ bật mặc định. Với các phiên bản cũ hơn, đặt biến môi trường:

export DOCKER_BUILDKIT=1

Hoặc đặt vĩnh viễn trong Docker daemon config (/etc/docker/daemon.json):

{
  "features": {
    "buildkit": true
  }
}

Bước 2 — Viết Dockerfile Thân Thiện Với Cache

Trước khi đụng vào remote caching, hãy sắp xếp cấu trúc Dockerfile đúng cách. Quy tắc đơn giản là: đặt những thứ ít thay đổi hơn lên đầu file.

Đây là ứng dụng Node.js điển hình với thứ tự layer tệ:

# Tệ: copy source code trước khi cài dependencies
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]

Mỗi lần bạn thay đổi một file source, layer COPY . . bị invalidate. Điều đó có nghĩa là npm install chạy lại từ đầu — mỗi lần một.

Cách sửa: tách việc cài dependency ra khỏi phần copy source.

# Tốt: dependencies được cache riêng khỏi source code
FROM node:20-alpine
WORKDIR /app

# Copy package file trước
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Source code thay đổi ở đây, nhưng npm ci đã được cache rồi
COPY . .
CMD ["node", "index.js"]

Bây giờ npm ci chỉ chạy lại khi package.json hoặc package-lock.json thay đổi. Việc sửa file source không đụng đến dependency layer.

Bước 3 — Build với Cache Export

Dùng flag --cache-to--cache-from của BuildKit để đẩy và kéo cache từ registry. Thay your-registry/your-image bằng đường dẫn image thực tế của bạn:

# Build và export cache lên registry
docker buildx build \
  --cache-from type=registry,ref=your-registry/your-image:cache \
  --cache-to type=registry,ref=your-registry/your-image:cache,mode=max \
  -t your-registry/your-image:latest \
  --push \
  .

Hai flag cần hiểu:

  • --cache-from — kéo cache hiện có từ registry trước khi build
  • --cache-to mode=max — export tất cả các layer trung gian vào cache, không chỉ image cuối cùng. Lưu nhiều dữ liệu hơn, nhưng tái sử dụng tối đa ở lần build tiếp theo.

Bước 4 — Tích Hợp GitHub Actions (Ví Dụ Đầy Đủ)

Đây là workflow GitHub Actions hoàn chỉnh dùng GHCR làm cache backend:

name: Build and Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

Build đầu tiên luôn là cold — chưa có cache nào, nên nó chạy đủ thời gian. Từ build thứ hai trở đi, các layer không thay đổi được lấy thẳng từ registry. Bạn sẽ thấy sự khác biệt ngay lập tức.

Bước 5 — Thay Thế: GitHub Actions Cache Backend

Không muốn quản lý registry chỉ để lưu cache? GitHub Actions có sẵn cache backend tích hợp. Không cần thông tin xác thực registry cho tầng cache:

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

Backend gha dùng bộ nhớ cache tích hợp của GitHub — 10 GB mỗi repo. Thiết lập đơn giản hơn, nhưng có hai đánh đổi: cache bị xóa sau 7 ngày không hoạt động, và có thể chậm hơn caching dựa trên registry với image lớn.

Bước 6 — Multi-Stage Build để Kết Quả Còn Nhanh Hơn

Kết hợp remote caching với multi-stage build: image cuối nhỏ hơn, tái sử dụng cache tối đa.

# Stage 1: Cài dependencies (được cache tích cực)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# Stage 2: Build ứng dụng
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage 3: Production image (chỉ giữ artifact runtime)
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

Với mode=max, BuildKit cache cả ba stage riêng biệt. Bạn thay đổi source code nhưng không đổi package.json? Stage deps được kéo từ cache và npm ci không bao giờ chạy.

Kiểm Tra Cache Có Hoạt Động Không

Theo dõi output của build. Các layer đã được cache trông như thế này:

#5 CACHED
#6 CACHED
#7 [builder 3/4] RUN npm run build  0.3s  # Chỉ stage này mới chạy

Các bước được đánh dấu CACHED đã được kéo từ remote cache và bỏ qua hoàn toàn. Đó là lý do thời gian giảm từ 9 phút xuống còn 90 giây.

Con Số Thực Tế: Kỳ Vọng Gì

Kết quả thay đổi tùy theo kích thước image và tần suất thay đổi dependency. Nhưng qua một số dự án Node.js và Python tôi đã làm, mô hình luôn nhất quán:

  • Cold build (không có cache): 5–12 phút cho ứng dụng Node.js hay Python thông thường
  • Warm build (chỉ thay đổi source): 30–90 giây
  • Warm build (thay đổi dependency): 2–4 phút (chỉ stage dependency rebuild lại)

Lợi ích lớn nhất đến từ các dự án có quá trình cài dependency nặng — bất cứ thứ gì mà chỉ riêng npm install, pip install -r requirements.txt, hoặc go mod download đã mất hơn 60 giây. Đó chính là bước mà remote caching loại bỏ ở hầu hết các commit.

Triển Khai Thôi: Checklist Năm Bước

CI pipeline chậm không chỉ lãng phí thời gian — chúng phá vỡ sự tập trung, làm chậm phản hồi, và âm thầm xói mòn thói quen push commit nhỏ, thường xuyên. Remote caching với Docker BuildKit là một trong những thay đổi có ROI cao nhất bạn có thể làm cho pipeline, và cần ít code đến ngạc nhiên.

Đây là checklist:

  1. Cấu trúc lại Dockerfile để cài dependencies trước khi copy source code
  2. Bật Docker Buildx trong môi trường CI
  3. Thêm flag cache-fromcache-to trỏ đến registry hoặc GHA cache
  4. Dùng mode=max để cache tất cả các layer trung gian
  5. Cân nhắc multi-stage build để giữ image cuối nhỏ gọn

Một khi đã có tất cả, hầu hết các commit build xong trong dưới hai phút. Những cái lâu hơn là những lúc thứ gì đó nặng thực sự thay đổi — và đó chính xác là cách mọi thứ nên hoạt động.

Share: