Tối Ưu Image: Multi-Stage Build và Bảo Mật

K
Kai··5 min read

Image của bạn chạy được rồi, nhưng nó có thể đang to gấp nhiều lần mức cần thiết. Image to kéo theo: deploy chậm (tải nhiều dữ liệu), tốn đĩa và băng thông, và bề mặt tấn công lớn hơn (càng nhiều phần mềm trong image càng nhiều lỗ hổng tiềm tàng). Bài này làm image nhỏ và an toàn hơn.

Vì sao image hay bị to

Nguyên nhân phổ biến nhất: image chứa cả công cụ build mà lúc chạy không cần. Để biên dịch một ứng dụng, bạn cần compiler, thư viện dev, công cụ đóng gói... Nhưng khi chạy, bạn chỉ cần kết quả đã build. Nếu để toàn bộ toolchain trong image cuối, nó phình ra vô ích.

Xem một ví dụ Go. Cách "ngây thơ" — build và chạy trong cùng một image:

FROM golang:1.22-alpine
WORKDIR /src
COPY . .
RUN go build -o /app .
CMD ["/app"]

Image này nặng 301MB — vì nó mang theo cả bộ công cụ Go (golang:1.22-alpine) trong khi ứng dụng chỉ là một file binary vài MB.

Multi-stage build

Giải pháp là multi-stage build: dùng nhiều FROM trong một Dockerfile. Stage đầu có đủ công cụ để build; stage cuối là một image gọn, chỉ copy kết quả từ stage build sang. Mọi thứ ở stage build (compiler, file trung gian) bị bỏ lại, không vào image cuối.

# Stage 1: build (có toolchain Go)
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY . .
RUN go build -o /app .

# Stage 2: runtime (gọn, chỉ lấy binary)
FROM alpine:latest
COPY --from=build /app /app
CMD ["/app"]

COPY --from=build /app /app lấy đúng file binary từ stage build. Image cuối chỉ có Alpine + binary.

Kết quả thật khi build hai cách rồi so sánh:

docker images | grep demo
demo:multi    15.4MB
demo:single   301MB

Từ 301MB xuống 15.4MB — nhỏ gần 20 lần, cùng một ứng dụng. Ứng dụng vẫn chạy y hệt; ta chỉ vứt phần không cần lúc runtime.

   ┌─ Stage 1: build ──────────────┐
   │ FROM golang  (toolchain ~300MB)│
   │ RUN go build -o /app           │
   └───────────┬────────────────────┘
               │ COPY --from=build /app
               ▼
   ┌─ Stage 2: runtime ────────────┐
   │ FROM alpine  (~7MB)            │   ← image cuối
   │ chỉ chứa: alpine + binary       │   chỉ 15.4MB
   └────────────────────────────────┘
        (mọi thứ ở Stage 1 bị bỏ lại)

Multi-stage áp dụng được cho mọi ngôn ngữ: Node (stage build chạy npm install + bundle, stage runtime chỉ lấy code đã build + dependency production), Java (build bằng Maven, runtime chỉ lấy file .jar), v.v.

Chọn base image nhỏ

Phần lớn kích thước đến từ base image. Cùng một phần mềm, các base khác nhau cho size rất khác:

  • Bản đầy đủ (node:20, python:3.12): kèm nhiều công cụ, lớn nhất.
  • -slim: bản gọn, bỏ bớt thứ ít dùng.
  • -alpine: dựa trên Alpine Linux, rất nhỏ (vài MB). Lưu ý Alpine dùng musl libc thay vì glibc, đôi khi gây lỗi với phần mềm cần glibc — cần kiểm thử.
  • distroless (của Google): chỉ có runtime, không cả shell — an toàn hơn nhưng khó debug.
  • scratch: image rỗng tuyệt đối, dùng cho binary tĩnh (như Go) không cần gì khác.

Quy tắc: chọn base nhỏ nhất mà ứng dụng vẫn chạy ổn.

Giảm số layer và giữ chúng gọn

Nhớ Bài 2/5: mỗi RUN tạo một layer, và file đã thêm ở một layer thì xóa ở layer sau không làm image nhỏ đi (nó vẫn nằm trong layer cũ). Vì vậy nên dọn ngay trong cùng một RUN:

# Tốt: cài và dọn cache trong cùng một layer
RUN apk add --no-cache curl \
    && <làm gì đó> \
    && rm -rf /var/cache/...

Và đừng quên .dockerignore (Bài 5) để không nhét node_modules, .git, file build vào context.

Bảo mật image

Image nhỏ đã an toàn hơn (ít phần mềm = ít lỗ hổng). Thêm vài thói quen:

Chạy bằng user không phải root. Mặc định tiến trình trong container chạy bằng root (root trong container — nhớ user namespace ở Bài 2). Nên tạo và chuyển sang user thường:

RUN addgroup -S app && adduser -S app -G app
USER app
CMD ["/app"]

Nếu kẻ tấn công thoát được ra ngoài tiến trình, chạy non-root giảm thiệt hại.

Không nhúng secret vào image. Đừng COPY file .env, khóa private, mật khẩu vào image — image có thể bị đẩy lên registry và ai cũng xem được các layer (nhớ docker history ở Bài 4 lộ cả lệnh). Truyền secret lúc chạy bằng biến môi trường, hoặc dùng cơ chế secret (Bài 13 với Swarm).

Pin phiên bản. Dùng tag cụ thể (node:20.11-alpine) thay vì latest (Bài 4) để build tái lập được và biết chính xác mình chạy gì.

Quét lỗ hổng. Docker có sẵn công cụ quét:

docker scout quickview <image>
docker scout cves <image>

Nó liệt kê các CVE đã biết trong image, giúp bạn biết cần cập nhật base hay gói nào.

🧹 Dọn dẹp

docker rmi demo:single demo:multi 2>/dev/null
docker image prune -a       # xóa image không dùng
docker builder prune        # xóa build cache (multi-stage tạo nhiều cache)

Tổng kết

Image to phần lớn do mang theo công cụ build không cần lúc chạy. Multi-stage build tách build khỏi runtime, chỉ copy kết quả sang image cuối — giảm kích thước rõ rệt (ví dụ 301MB → 15.4MB). Cộng thêm: chọn base nhỏ, gộp/dọn layer, chạy non-root, không nhúng secret, pin phiên bản, và quét CVE. Image nhỏ và sạch deploy nhanh hơn và an toàn hơn.

Đến đây ta đã thành thạo Docker trên một máy: dựng, chạy, lưu trữ, nối mạng, gom bằng Compose, tối ưu image. Phần còn lại của series bước sang Docker Swarm — chạy container trên nhiều máy. Bài 10 mở đầu bằng kiến trúc cluster và cơ chế đồng thuận Raft.