Chọn image nền là quyết định đầu tiên trong mọi Dockerfile, và nó thường được quyết bằng một câu: "dùng alpine cho nhẹ". Tôi đo năm lựa chọn phổ biến trên cùng một ứng dụng Go để xem "nhẹ" thật sự nghĩa là gì.

Dung lượng image nền

Image nền Dung lượng Số layer
scratch 0 MB 0
gcr.io/distroless/static-debian12 0,7 MB 12
alpine:3.20 3,9 MB 1
gcr.io/distroless/base-debian12 7,7 MB 14
debian:12-slim 26,8 MB 1
ubuntu:24.04 27,6 MB 1

Cùng một ứng dụng, năm image cuối

Quan trọng hơn là dung lượng sau khi bỏ ứng dụng vào. Cùng một tệp nhị phân Go, chỉ đổi dòng FROM của tầng chạy:

Image nền Image cuối Chạy được
scratch 1,89 MB
distroless/static 2,58 MB
alpine:3.20 5,80 MB
debian:12-slim 28,71 MB
ubuntu:24.04 29,44 MB

Cả năm đều chạy. Khoảng cách từ nhỏ nhất tới lớn nhất là 15,6 lần, nhưng cả năm đều dưới 30 MB — nên nếu ứng dụng của bạn là một tệp nhị phân tĩnh, đây không phải quyết định đáng tranh cãi nhiều.

Với ứng dụng có runtime — Java, Node, Python — thì bức tranh khác hẳn, vì bản thân runtime nặng hơn image nền rất nhiều. Ở sê-ri Vert.x tôi đo được một ứng dụng Java đóng gói với JRE là 299 MB, và phần lớn con số đó không phải image nền.

Nhỏ hơn không có nghĩa là bề mặt tấn công nhỏ hơn

Đây là chỗ phép đo cãi lại lời khuyên quen thuộc.

Số gói cài sẵn Số lệnh gọi được
alpine:3.20 14 313
debian:12-slim 88 390
ubuntu:24.04 92 411
distroless/static — (không có trình quản lý gói) 0
scratch 0 0

Về số gói, alpine nhỏ hơn Debian sáu lần — đúng như danh tiếng của nó.

Về số lệnh mà một kẻ tấn công có được sau khi vào container, alpine có 313 so với 411 của Ubuntu. Chỉ ít hơn 24%. Vì busybox gói hàng trăm tiện ích vào một tệp nhị phân duy nhất rồi tạo symlink cho từng cái: wget, nc, chmod, find, sed, mount — có đủ.

Tôi suýt viết sai chỗ này. Lần đếm đầu tiên tôi dùng find -type f và ra 9 lệnh cho alpine, một con số nghe rất ấn tượng và hoàn toàn sai — -type f bỏ qua symlink, mà mọi applet của busybox đều là symlink. Đếm lại cho ra 313.

Nên "alpine nhẹ nên an toàn hơn" chỉ đúng ở vế ít gói phải vá lỗ hổng hơn. Vế "kẻ tấn công có ít công cụ hơn" thì gần như không đúng. Muốn vế thứ hai thì phải đi tới distroless hoặc scratch, nơi con số là 0 — không có shell, như phần 9 đã đo thì docker run --entrypoint /bin/sh trả về failed to create task.

Về số lỗ hổng

Tôi định đo cả số CVE của từng image nền, nhưng docker scout yêu cầu đăng nhập tài khoản Docker và đây không phải máy của tôi, nên tôi không có số liệu đó và sẽ không đoán.

Điều nói được mà không cần quét: số CVE tỉ lệ khá chặt với số gói, vì mỗi gói là một nguồn lỗ hổng tiềm năng. 14 gói của alpine so với 92 gói của Ubuntu là chênh lệch có ý nghĩa về khối lượng phải theo dõi và vá. Với distroless và scratch thì phần lớn CVE còn lại nằm ở chính ứng dụng của bạn và thư viện nó mang theo — không phải ở image nền.

Nếu bạn cần con số thật, trivygrype là hai công cụ chạy được ngay, không cần tài khoản:

trivy image alpine:3.20
grype alpine:3.20

Chọn cái nào

Chọn Khi
scratch Tệp nhị phân tĩnh, không cần shell, chấp nhận không chẩn đoán được từ bên trong
distroless/static Như trên nhưng muốn có sẵn CA, múi giờ, /etc/passwd
alpine Cần shell và trình quản lý gói, chấp nhận musl (xem phần sau)
*-slim (Debian) Cần glibc, cần hệ sinh thái gói đầy đủ
ubuntu Khi có yêu cầu cụ thể về Ubuntu; ngoài ra debian:slim gần như luôn tốt hơn

Ba điều đáng cân nhắc mà bảng trên không nói:

distroless/static có 12 layer trong khi alpine chỉ có 1. Nghe có vẻ tệ hơn nhưng thực tế ngược lại: nhiều layer nhỏ được chia sẻ tốt hơn giữa các image, và phần 2 đã đo được rằng chia sẻ layer tiết kiệm 32% dung lượng đĩa thật.

Ảnh nền càng lạ thì càng ít người gặp lỗi giống bạn. Alpine phổ biến nên vấn đề của nó đã có người trả lời; một image nền hiếm nghĩa là bạn tự gỡ lỗi một mình.

Đừng ghim tag di động cho image nền. FROM alpine:3.20 hôm nay và sáu tháng sau là hai nội dung khác nhau — phần 4 đã cho thấy tag có thể bị đẩy đè. Ghim digest nếu bạn cần build lặp lại được.

Thử ba mươi giây

Xem image của bạn cho kẻ tấn công bao nhiêu công cụ:

docker run --rm <anh-cua-ban> sh -c 'ls /bin /sbin /usr/bin /usr/sbin 2>/dev/null | sort -u | wc -l'

Và những công cụ nguy hiểm nhất có mặt không:

docker run --rm <anh-cua-ban> sh -c 'which wget curl nc sh bash python perl 2>/dev/null'

Nếu lệnh đầu báo lỗi vì không có shell, bạn đang ở distroless hoặc scratch — và đó là câu trả lời tốt nhất có thể cho câu hỏi này.

Phần sau đo cái bẫy hiệu năng của alpine: musl so với glibc trên cùng một ứng dụng.