Docker Multi-stage Build và Distroless Images: Thu Gọn Container Xuống Chỉ Vài MB

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

Image Docker production đầu tiên của tôi nặng 1,2 GB. Ứng dụng bên trong? Một Node.js API trả về JSON — khoảng 50 KB code thực sự. Phần còn lại là build tools, package managers, các tiện ích shell, và đủ thứ OS không cần thiết chẳng có lý do gì tồn tại trong container production.

Sau khi chuyển sang multi-stage build với distroless base image, app đó giảm xuống còn 68 MB. Công cụ quét bảo mật không còn réo tên tôi nữa. Deploy nhanh hơn hẳn. Đây không phải tối ưu hóa cao siêu gì — đây là yêu cầu tối thiểu khi chạy container trên production.

Đây là cách làm.

Bắt Đầu Nhanh — Có Image Gọn Trong 5 Phút

Lý thuyết để sau. Trước tiên, xem kết quả. Đây là Dockerfile multi-stage cho ứng dụng Go tạo ra image cuối dưới 10 MB:

# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# Stage 2: Image cuối
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Build và kiểm tra kích thước:

docker build -t myapp:optimized .
docker image ls myapp:optimized
# REPOSITORY   TAG         IMAGE ID       KÍCH THƯỚC
# myapp        optimized   a3b1c2d4e5f6   8.4MB

Một build single-stage thông thường dùng golang:1.22 làm base sẽ nặng hơn 800 MB. Cùng một binary. Kết quả hoàn toàn khác.

Tìm Hiểu Sâu Hơn — Tại Sao Cách Này Hoạt Động

Vấn Đề Với Dockerfile Truyền Thống

Dockerfile single-stage làm tất cả trong một image: cài build tools, biên dịch code, chạy app. Mọi package bạn kéo vào — compiler, package manager, debug utilities — đều bị đóng gói vĩnh viễn vào image cuối.

Mỗi binary thừa là một CVE tiềm ẩn. Go compiler của bạn không giúp app phục vụ request nhanh hơn. Nó chỉ cho kẻ tấn công thêm thứ để khai thác. Một cái shell cho chúng chỗ đứng chân nếu tìm được lỗ hổng.

Multi-stage Build Hoạt Động Như Thế Nào

Multi-stage build cho phép dùng nhiều lệnh FROM trong một Dockerfile. Mỗi FROM tạo ra một stage mới với filesystem riêng. Giữa các stage, bạn chọn lọc file cần thiết bằng COPY --from=<stage>.

Chỉ có stage cuối cùng được đóng gói và ship. Mọi compiler, build tool, và file trung gian đều bị loại bỏ sau khi build xong — chúng không bao giờ chạm đến image cuối.

# Stage 1: Môi trường build (bị loại bỏ sau khi build xong)
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci                          # cài tất cả deps kể cả devDependencies
COPY . .
RUN npm run build                   # biên dịch TypeScript, bundle, v.v.

# Stage 2: Môi trường production (đây là thứ được ship)
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev               # chỉ cài deps production
COPY --from=build /app/dist ./dist  # chỉ lấy output đã biên dịch
USER node
CMD ["node", "dist/index.js"]

Distroless Images Là Gì?

Distroless images, được Google duy trì, đẩy sự tối giản đi xa hơn Alpine. Chúng chỉ chứa ứng dụng của bạn và các runtime dependency cần thiết. Không có shell. Không có package manager. Không có bất kỳ binary tiện ích nào.

Không có shell đồng nghĩa kẻ tấn công không thể exec vào container và chạy lệnh tùy ý dù có tìm được lỗ hổng. Không có apt, không có curl, không có wget — không có gì để leo thang tấn công.

Các biến thể có sẵn trên gcr.io/distroless/:

  • static-debian12 — cho binary biên dịch tĩnh (Go, Rust)
  • base-debian12 — thêm glibc, cho binary C liên kết động
  • cc-debian12 — thêm libstdc++, cho ứng dụng C++
  • java21-debian12 — chỉ JRE, không có JDK
  • nodejs22-debian12 — Node.js runtime, không có npm hay shell
  • python3-debian12 — chỉ Python runtime

Nâng Cao — Ví Dụ Thực Tế Cho Từng Stack

Ứng Dụng Python FastAPI

# Stage 1: Cài đặt dependencies
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Stage 2: Distroless Python runtime
FROM gcr.io/distroless/python3-debian12
WORKDIR /app
# Sao chép các package đã cài từ builder
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Ứng Dụng Java Spring Boot

Image Java nổi tiếng là nặng. Một app Spring Boot mặc định với OpenJDK dễ dàng đạt 500 MB. Cách tiếp cận ba stage dưới đây giúp giảm xuống dưới 150 MB — và với layered jar, các lần build tiếp theo sẽ nhanh hơn đáng kể:

# Stage 1: Build với Maven
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q  # cache dependencies riêng biệt
COPY src ./src
RUN mvn package -DskipTests -q

# Stage 2: Tách layers (Spring Boot layered jars)
FROM eclipse-temurin:21-jre AS extractor
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 3: Distroless JRE
FROM gcr.io/distroless/java21-debian12
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]

Điểm mấu chốt là tách layer theo tần suất thay đổi. Dependencies chỉ rebuild khi pom.xml thay đổi — điều này hiếm khi xảy ra. Layer code ứng dụng, thứ thay đổi liên tục, vẫn nhỏ gọn và rebuild nhanh.

Multi-stage Cho Frontend + Backend

# Stage 1: Build React frontend
FROM node:20-alpine AS frontend-build
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build

# Stage 2: Build Go backend
FROM golang:1.22-alpine AS backend-build
WORKDIR /backend
COPY backend/go.* ./
RUN go mod download
COPY backend/ .
COPY --from=frontend-build /frontend/dist ./static  # nhúng frontend vào
RUN CGO_ENABLED=0 go build -o app .

# Stage 3: Image cuối distroless
FROM gcr.io/distroless/static-debian12
COPY --from=backend-build /backend/app /app
ENTRYPOINT ["/app"]

Mẹo Thực Tế — Những Gì Tôi Học Được Theo Cách Khó

Mẹo 1: Chạy Với User Không Phải Root Dù Dùng Distroless

Distroless đi kèm một user non-root chuyên dụng. Hãy dùng nó:

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]

Tag :nonroot chạy với UID 65532. Nếu ai đó thoát khỏi container, họ sẽ chỉ là user không có quyền trên host — không phải root. Đây là bảo vệ theo chiều sâu, không phải phép màu, nhưng nó quan trọng.

Mẹo 2: Quét Trước và Sau Để Thấy Sự Khác Biệt

# Cài trivy
brew install aquasecurity/trivy/trivy  # macOS
# hoặc
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh

# Quét trước và sau khi tối ưu
trivy image myapp:before
trivy image myapp:optimized

# Thường thấy 200+ CVEs → còn 0-5 CVEs

Chạy một lần. Thấy 247 CVEs sụp xuống còn 3 thuyết phục hơn bất kỳ benchmark nào.

Mẹo 3: Debug Container Distroless Không Cần Shell

Không có shell khiến việc debug cảm giác khác lúc đầu. Hai cách thực sự hoạt động:

# Distroless cung cấp tag :debug với busybox shell để debug local
FROM gcr.io/distroless/static-debian12:debug
# Chỉ dùng local — không bao giờ ship :debug lên production

# Trong Kubernetes, dùng ephemeral containers:
kubectl debug -it mypod --image=busybox --target=mycontainer

Mẹo 4: Ghim Digest Image Trên Production

Các tag như :latest hay :nonroot có thể bị cập nhật âm thầm. Ghim digest để có build tái tạo được:

# Lấy digest hiện tại
docker inspect --format='{{index .RepoDigests 0}}' gcr.io/distroless/static-debian12:nonroot
# gcr.io/distroless/static-debian12@sha256:abcd1234...

# Dùng digest trong Dockerfile
FROM gcr.io/distroless/static-debian12@sha256:abcd1234...

Mẹo 5: Đừng Quên .dockerignore

Multi-stage build không bảo vệ bạn khỏi build context phình to. Một lệnh COPY . . đơn thuần sẽ vui vẻ gửi cả thư mục node_modules — đôi khi hàng gigabyte — lên Docker daemon trước khi build bất kỳ layer nào.

node_modules/
.git/
*.log
dist/
.env
.env.*
tests/
docs/
*.md
.DS_Store

Bảng So Sánh Kích Thước Nhanh

Số liệu từ một Go REST API điển hình, cùng binary, base image khác nhau:

  • golang:1.22 (single stage): ~850 MB, 200+ CVEs
  • golang:1.22-alpine (single stage): ~300 MB, 15–30 CVEs
  • multi-stage + alpine final: ~12 MB, 5–10 CVEs
  • multi-stage + distroless/static: ~6–10 MB, 0–2 CVEs

Multi-stage build kết hợp distroless không phải tối ưu hóa nhỏ nhặt. Đây là tiêu chuẩn cơ bản. Image nhỏ hơn pull nhanh hơn trong Kubernetes, tốn ít chi phí hơn trong container registry, và để lại gần như không có gì cho kẻ tấn công khai thác.

Bắt đầu với ví dụ Go ở trên. Điều chỉnh cho stack của bạn. Khi bạn thấy lần quét đầu tiên trả về kết quả sạch, những image phình to sẽ không còn được chấp nhận nữa.

Share: