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.

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:

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ớiCGO_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ề
- 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.
- Multi-stage chỉ giữ kết quả build: đo thật,
COPY --from=builderbinary sangscratchcho 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. - Chọn base runtime theo nhu cầu:
scratchcho binary tĩnh (nhỏ/an toàn nhất nhưng không cert/shell),alpine/distrolesskhi cần cert/debug — và multi-stage áp dụng cho mọi ngôn ngữ, không chỉ Go.
Nguồn
- Docker docs — Multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Docker docs — Base images / scratch: https://docs.docker.com/build/building/base-images/
- GoogleContainerTools — distroless: https://github.com/GoogleContainerTools/distroless
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.