Hãy coi việc chọn image nền như bày biện một căn phòng mà kẻ trộm có thể lọt vào. Ai cũng theo phản xạ hỏi "đồ đạc nặng bao nhiêu" (dung lượng image), nhưng câu hỏi thật sự là: bạn để lại bao nhiêu dụng cụ nằm sẵn cho kẻ vào được. Một căn hộ nhỏ gọn (alpine) mà trên bàn có một con dao đa năng (busybox gấp hàng trăm applet vào một tệp) thì vẫn trao cho kẻ trộm nguyên một bộ đồ nghề; còn một căn phòng trống trơn (scratch/distroless) thì chẳng đưa gì cả — không cả một cái shell để đứng lên. 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, trivy và grype 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.
Muốn biết ngay image của mình trao cho kẻ tấn công bao nhiêu công cụ, đếm số lệnh và soi mấy cái nguy hiểm nhất:
docker run --rm <anh-cua-ban> sh -c 'ls /bin /sbin /usr/bin /usr/sbin 2>/dev/null | sort -u | wc -l'
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.
Mẫu số chung
Bài học đầu tiên, đọc thẳng từ "sáu lần về gói nhưng chỉ 24% về số lệnh": một cái nhãn tiện miệng như "nhẹ" là thước đo thay thế, và nó có thể đúng ở trục này nhưng gần như vô can ở trục bạn thật sự quan tâm. "Nhẹ" (dung lượng) tương quan khá với "ít gói phải vá" nhưng gần như không tương quan với "kẻ tấn công có ít công cụ hơn" — vì busybox gấp hàng trăm applet vào một tệp rồi tạo symlink, nên nhỏ mà vẫn đủ đồ nghề. Cùng cái bẫy "một chữ gánh nhiều tính chất" ở khắp nơi: "nhanh" trộn lẫn độ trễ với thông lượng, "gọn" trộn kích thước tải về với chi phí phân tích, "đơn giản" trộn ít-hàm với ít-khái-niệm. Nguyên tắc: gọi tên đúng cái tính chất mình cần rồi đo thẳng vào nó — đừng để một proxy quen miệng quyết hộ, và luôn biết proxy ấy bám sát trục nào, buông trục nào.
Điều thứ hai, về an ninh nói riêng: giảm một bề mặt tấn công cho lợi ích giảm dần, còn xoá hẳn nó là một thắng lợi khác loại. Từ 411 lệnh xuống 313 chỉ là cắt 24% — vẫn đủ shell, wget, nc để kẻ trộm xoay xở; nhưng từ 313 xuống 0 (không shell, không trình thông dịch, không trình quản lý gói) là bước sang một phạm trù khác: không còn gì để đứng lên mà thao tác. Cùng ý "xoá thắng giảm" ở khắp nơi: read_only toàn bộ mạnh hơn bớt vài đường ghi, không-có-phụ-thuộc an toàn hơn phụ thuộc đã vá, bỏ hẳn một tính năng chắc hơn gia cố nó. Nguyên tắc: chỗ nào có thể loại bỏ năng lực thay vì thu nhỏ nó thì hãy loại bỏ — nhưng biết rõ cái giá đi kèm: phòng trống cũng là phòng không thể đứng trong đó mà chẩn đoán (không exec sh được), tức bạn mua sự an toàn bằng khả năng gỡ lỗi từ bên trong, và đó là một đánh đổi cần chọn có chủ đích.
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.