Bạn viết một chương trình Go 10 dòng, đóng vào Docker, và image ra... 400MB. Cái binary chỉ có 2MB, sao image lại to gấp 200 lần? Câu trả lời hé lộ một nguyên tắc quan trọng: thứ cần để BUILD khác thứ cần để CHẠY. Biên dịch cần cả một bộ công cụ nặng (trình biên dịch, thư viện dev); nhưng chạy thì chỉ cần cái binary đã biên dịch. Nếu bạn build và chạy trong cùng một image, image cuối phải mang theo toàn bộ toolchain vô dụng. Multi-stage build giải quyết đúng vấn đề này. Bài này (phần 9 loạt Docker) build một chương trình Go thật hai cách và đo kích thước image.

Vấn đề: image mang theo cả toolchain

Một Dockerfile "một stage" cho Go trông hợp lý:

FROM golang:1.23-alpine   # image có trình biên dịch Go — 365MB
COPY . .
RUN go build -o /hello .
CMD ["/hello"]

Nhưng image cuối là chính image golang:1.23-alpine (365MB) cộng thêm module cache và binary. Nó chứa trình biên dịch Go, thư viện chuẩn dạng nguồn, công cụ build — toàn bộ thứ chỉ cần lúc build, hoàn toàn thừa lúc chạy. Bạn đóng gói và phân phối một bộ compiler cho mọi máy chỉ để chạy một binary tĩnh.

Multi-stage: build ở stage này, chạy ở stage khác

Multi-stage cho phép nhiều FROM trong một Dockerfile, mỗi cái là một "stage". Bạn build ở stage nặng, rồi chỉ copy kết quả (binary) sang một stage runtime gọn:

FROM golang:1.23-alpine AS builder   # stage BUILD (nặng, đủ công cụ)
COPY . .
RUN CGO_ENABLED=0 go build -o /hello .

FROM scratch                          # stage RUNTIME (rỗng tuyệt đối, 0B)
COPY --from=builder /hello /hello     # chỉ lấy binary từ stage trước
CMD ["/hello"]

Điểm mấu chốt: stage builder bị bỏ đi, không nằm trong image cuối. Chỉ những gì bạn COPY --from=builder mới được giữ. Image cuối = scratch (rỗng) + binary tĩnh vài MB.

Ảnh chụp đoạn mã nền tối minh hoạ multi-stage build image chỉ chứa thứ cần để chạy bỏ thứ chỉ cần để build, vấn đề image build nặng vì chứa cả toolchain để build cần trình biên dịch thư viện dev công cụ hàng trăm MB để chạy chỉ cần binary vài MB nhưng nếu build và chạy cùng một image golang image cuối mang theo cả toolchain phình to, một-stage kéo cả golang image vào runtime FROM golang 1.23-alpine 365MB toolchain COPY chấm chấm RUN go build CMD hello image cuối 400MB thừa toolchain, multi-stage build ở stage 1 chỉ COPY binary sang stage 2 gọn FROM golang 1.23-alpine AS builder stage build nặng COPY chấm chấm RUN CGO_ENABLED 0 go build FROM scratch stage runtime rỗng 0B COPY --from builder hello hello chỉ lấy binary CMD hello image cuối bằng chỉ binary tĩnh vài MB stage builder bị bỏ, chọn base cho stage runtime scratch rỗng tuyệt đối cho binary tĩnh Go CGO_ENABLED 0 alpine 7MB khi cần shell libc cert distroless của Google có cert tzdata không shell bảo mật

Hình 1: Build cần toolchain nặng, chạy chỉ cần binary; một-stage kéo cả golang image (365MB) vào runtime; multi-stage build ở stage builder rồi COPY --from binary sang scratch (rỗng) — stage builder bị bỏ.

Đo thật: 407MB xuống 3.4MB

Build cùng một chương trình Go (in "Xin chao tu container Go!") hai cách:

Ảnh chụp bảng kết quả chạy thật build chương trình Go output thật, một kích thước image docker images hello-single golang image làm runtime 407MB hello-multi binary trên scratch 3.4MB giảm 120 lần cho cùng một chương trình, hai cả hai chạy y hệt cùng binary docker run hello-single ra Xin chao tu container Go docker run hello-multi ra Xin chao tu container Go, ba vì sao chênh lớn thế golang 1.23-alpine base build bằng 365MB trình biên dịch cache binary hello thật bằng 2.1MB thứ duy nhất cần để chạy scratch bằng 0B không có gì single mang cả 365MB toolchain multi chỉ giữ 2.1MB binary, kết tách build nặng đủ công cụ khỏi runtime chỉ binary image nhỏ bằng tải nhanh khởi động nhanh bề mặt tấn công ít hơn

Hình 2: Chạy thật — hello-single (dùng golang image làm runtime) = 407MB; hello-multi (binary trên scratch) = 3.4MB (giảm ~120×); cả hai in cùng kết quả; golang base 365MB vs binary thật 2.1MB vs scratch 0B.

  • Một-stage: 407MB — mang theo cả golang toolchain 365MB.
  • Multi-stage: 3.4MB — chỉ binary tĩnh 2.1MB trên scratch (0B). Giảm khoảng 120 lần.
  • Cùng chạy y hệt: cả hai docker run đều in "Xin chao tu container Go!" — vì binary bên trong là chính xác cùng một file. Chỉ khác image bọc quanh nó.

Với image nhỏ hơn 120 lần: tải từ registry nhanh hơn 120 lần, tốn ít đĩa hơn, khởi động nhanh hơn, và bề mặt tấn công nhỏ hơn (không có shell, không có compiler, không có gói thừa để khai thác).

Chọn base cho stage runtime

Stage cuối copy binary vào đâu tùy nhu cầu:

  • scratch: rỗng tuyệt đối — cho binary tĩnh (Go với CGO_ENABLED=0, Rust static). Nhỏ nhất, an toàn nhất, nhưng không có shell/cert/tzdata.
  • alpine (~7MB): khi cần một chút — shell để debug, libc, chứng chỉ CA (ca-certificates để gọi HTTPS).
  • distroless (Google): có cert, tzdata, nhưng không shell — cân bằng giữa tiện dụng và bảo mật, phổ biến cho production.

Đánh đổi cần cân nhắc

scratch cần binary hoàn toàn tĩnh và không có sẵn cert/tz. Nếu app gọi HTTPS ra ngoài, scratch thiếu chứng chỉ CA → lỗi xác thực TLS; thiếu tzdata → giờ địa phương sai. Copy thêm ca-certificates và zoneinfo từ stage builder, hoặc dùng distroless/alpine. Và với ngôn ngữ không tạo binary tĩnh (Python, Node, Java), scratch không dùng được — stage runtime phải có runtime của ngôn ngữ đó.

Multi-stage giúp mọi ngôn ngữ, không chỉ Go. Node: stage build chạy npm install + npm run build, stage runtime chỉ copy dist/ + node_modules production. Java: stage build chạy Maven, stage runtime chỉ copy file .jar vào một JRE gọn. Nguyên tắc giống nhau: tách công cụ build khỏi thứ cần để chạy.

Đừng hy sinh khả năng debug quá mức. Image scratch không có shell nên docker exec vào để xem là không thể — khó chẩn đoán sự cố production. Nhiều đội dùng distroless (có đủ để chạy, không shell) làm điểm cân bằng, hoặc có một biến thể image "debug" kèm shell khi cần. Nhỏ nhất không phải lúc nào cũng là tốt nhất; cân giữa kích thước, bảo mật và vận hành.

Ba ý mang về

  1. Thứ cần để build khác thứ cần để chạy: đo thật, image một-stage 407MB mang cả golang toolchain (365MB), trong khi binary thật chỉ 2.1MB — phần lớn image là công cụ thừa lúc chạy.
  2. Multi-stage chỉ giữ kết quả build: đo thật, COPY --from=builder binary sang scratch cho image 3.4MB (giảm ~120×), chạy y hệt vì binary là cùng một file — stage builder bị bỏ hoàn toàn.
  3. Chọn base runtime theo nhu cầu: scratch cho binary tĩnh (nhỏ/an toàn nhất nhưng không cert/shell), alpine/distroless khi cần cert/debug — và multi-stage áp dụng cho mọi ngôn ngữ, không chỉ Go.

Nguồn

Phần sau ta trở lại mạng ở mức thực hành: publish cổng với -p, mạng bridge tuỳ chỉnh, và DNS nội bộ cho phép container gọi nhau bằng tên thay vì IP.