Image build ra thường chứa những thứ chỉ cần lúc build: trình biên dịch, thư viện phát triển, cache của trình quản lý gói. Chúng theo image đi khắp nơi — lên registry, về từng máy chủ — và không bao giờ được dùng.

Multi-stage build giải quyết chuyện đó. Tôi đo trên một máy chủ HTTP viết bằng Go.

Ba cách đóng gói cùng một chương trình

Một tầng — cách viết tự nhiên nhất:

FROM golang:1.22-alpine
WORKDIR /src
COPY go.mod main.go ./
RUN go build -o /may-chu .
CMD ["/may-chu"]

Nhiều tầng — build ở một tầng, chỉ chép tệp nhị phân sang tầng chạy:

FROM golang:1.22-alpine AS xay
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /may-chu .

FROM alpine:3.20
COPY --from=xay /may-chu /may-chu
CMD ["/may-chu"]

Scratch — tầng chạy không có hệ điều hành nào cả:

FROM scratch
COPY --from=xay /may-chu /may-chu
CMD ["/may-chu"]

Kết quả:

Dung lượng Số layer Thời gian build
Một tầng 85,9 MB 9 13,5 s
Nhiều tầng 5,8 MB 2 3,8 s
Scratch 1,9 MB 1 1,0 s

Nhỏ hơn 14,8 lần khi tách tầng, và 45 lần với scratch. Cả ba đều chạy và trả về cùng một kết quả — tôi kiểm tra trước khi đo:

mot-tang    : {"duong":"/thu","ok":true}
nhieu-tang  : {"duong":"/thu","ok":true}
scratch     : {"duong":"/thu","ok":true}

Về thời gian build, con số ở bảng trên có yếu tố cache dùng lại giữa các lần chạy nên đừng đọc quá kỹ. Cái đáng đọc là dung lượng, và nó không mơ hồ: 80 MB kia là Go toolchain, và nó không cần thiết để chạy một tệp nhị phân đã biên dịch xong.

Hai cờ trong lệnh build cũng đáng nhắc. CGO_ENABLED=0 bắt Go liên kết tĩnh, nếu không tệp nhị phân sẽ cần thư viện C của hệ thống và scratch không có. -ldflags="-s -w" bỏ bảng ký hiệu và thông tin gỡ lỗi.

Cái giá của scratch

scratch là image rỗng thật sự. Không có /bin/sh:

docker run --entrypoint /bin/sh ms:scratch -c 'echo CO'
-> Error: failed to create task for container

Nghĩa là không docker exec vào xem được, không chạy được script chuẩn bị, không có công cụ chẩn đoán nào. Với sự cố lúc nửa đêm thì đó là một cái giá thật.

Và có một thứ vắng mặt gây hậu quả tinh vi hơn nhiều: chứng chỉ CA. Tôi viết một chương trình Go gọi https://example.com:

scratch khong-ca : LOI: tls: failed to verify certificate: x509: certificate signed by unknown authority
scratch co-ca    : OK, ma: 200

Ứng dụng chạy hoàn hảo trên máy bạn, chạy hoàn hảo trong alpine, rồi hỏng khi đóng gói vào scratch — chỉ với những lời gọi HTTPS ra ngoài. Cách vá là chép bó chứng chỉ từ tầng build sang:

FROM scratch
COPY --from=xay /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=xay /goi /goi

Tốn thêm 0,12 MB (3,67 → 3,79 MB). Ngoài CA, hai thứ khác cũng hay thiếu trên scratch: dữ liệu múi giờ (/usr/share/zoneinfo) và tệp /etc/passwd nếu bạn muốn chạy bằng người dùng không phải root.

Vậy dùng scratch hay alpine? Chênh lệch trong phép đo này là 5,8 so với 1,9 MB — chưa tới 4 MB. Đổi lấy 4 MB đó, alpine cho bạn một shell để chẩn đoán, CA có sẵn, múi giờ có sẵn. Với đa số dịch vụ thì đó là món hời. scratch xứng đáng khi bạn chạy hàng nghìn bản sao, hoặc khi bề mặt tấn công nhỏ nhất là yêu cầu thật.

Tầng không ai dùng tới thì không chạy

Đây là hành vi khiến nhiều người viết một Dockerfile hoàn toàn vô nghĩa mà không biết. Tôi viết bốn tầng, trong đó hai tầng chẳng ai tham chiếu tới:

FROM golang:1.22-alpine AS xay        # tang chay dung
...
FROM golang:1.22-alpine AS kiem-thu   # khong ai dung
RUN go vet ./... && echo "kiem tra xong" > /kq

FROM alpine:3.20 AS tai-lieu          # khong ai dung
RUN sleep 3 && echo "tai lieu" > /td

FROM alpine:3.20 AS chay
COPY --from=xay /may-chu /may-chu

Log build:

#5  [chay 1/2]  FROM alpine:3.20@sha256:d9e853e8...
#7  [xay 1/4]   FROM golang:1.22-alpine@sha256:1699c100...
#8  [xay 2/4]   WORKDIR /src
#9  [xay 3/4]   COPY go.mod main.go ./
#10 [xay 4/4]   RUN CGO_ENABLED=0 go build ...
#11 [chay 2/2]  COPY --from=xay /may-chu /may-chu

Không có một dòng nào của kiem-thu hay tai-lieu. BuildKit dựng đồ thị phụ thuộc từ tầng cuối ngược lên và bỏ qua hoàn toàn những tầng không dẫn tới nó — kể cả tầng tai-lieusleep 3, tức là nó thật sự không tốn ba giây đó.

Hệ quả thực tế: thêm một tầng kiem-thu chạy go vet hay npm test vào Dockerfile không làm test chạy. Rất nhiều người viết như vậy rồi tin rằng CI đang chạy kiểm thử. Muốn nó chạy, phải gọi tường minh:

docker build --target kiem-thu .

Hoặc làm tầng chạy phụ thuộc vào nó, ví dụ COPY --from=kiem-thu /kq /kq.

--target cũng hữu ích cho việc khác: dựng một image chỉ có môi trường phát triển:

--target xay  : 6,2 s   (co ca Go toolchain)
--target chay : 4,7 s   (chi tep nhi phan)

COPY --from nhận cả tên image

Ít người biết là --from không chỉ trỏ tới tầng trong cùng Dockerfile:

FROM alpine:3.20
COPY --from=nginx:alpine /etc/nginx/nginx.conf /nginx.conf

Chạy được, và in ra đúng nội dung tệp cấu hình của nginx. Cách này tiện để lấy một tệp nhị phân hay một tệp cấu hình từ image có sẵn mà không phải cài cả gói — ví dụ lấy mc từ image MinIO, hoặc lấy chứng chỉ CA từ alpine như ở trên.

Mẫu áp dụng cho các hệ sinh thái khác

Nguyên tắc giống nhau, chỉ khác thứ được chép sang:

Tầng build Chép gì sang
Go, Rust toolchain đầy đủ một tệp nhị phân
Java JDK + Maven/Gradle tệp .jar + JRE (hoặc runtime jlink)
Node node_modules đầy đủ gồm devDependencies dist/ + node_modules bản production
Python build tools cho gói có phần C site-packages đã cài

Với Java thì phần này nối thẳng với những gì tôi đã đo ở sê-ri Vert.x: runtime dựng bằng jlink chỉ 32 MB so với JDK đầy đủ 336 MB, và ảnh Docker giảm từ 299 MB xuống 92,4 MB.

Thử ba mươi giây

Xem image của bạn đang mang theo những gì không cần:

docker run --rm <anh-cua-ban> sh -c 'which gcc make git curl npm mvn go 2>/dev/null'
docker image inspect <anh-cua-ban> --format '{{len .RootFS.Layers}} layer'

Bất kỳ trình biên dịch hay công cụ build nào hiện ra ở dòng đầu đều là thứ đang đi cùng image tới mọi máy chủ. Đó là danh sách những thứ multi-stage sẽ bỏ lại.

Phần sau so bốn image nền — distroless, alpine, slim, scratch — về dung lượng, số gói và số lỗ hổng.