Alpine nhỏ vì nó dùng musl thay cho glibc — thư viện C mà gần như mọi bản Linux khác dùng. Đổi thư viện C không phải chuyện nhỏ như đổi tên gói: nó đổi cách chương trình của bạn cấp phát bộ nhớ, phân giải tên miền, và khởi tạo luồng.

Bài này đo ba khác biệt, và một trong ba đủ lớn để đảo ngược quyết định chọn image nền.

Cấp phát bộ nhớ đa luồng

Chương trình C: mỗi luồng cấp phát và giải phóng 400 000 lần, kích thước thay đổi từ 32 đến 544 byte, có ghi và đọc lại để trình biên dịch không vứt vòng lặp đi.

Số luồng glibc musl musl chậm hơn
1 45,8 triệu lần/giây 6,26 triệu 7,3×
2 93,5 triệu 2,30 triệu 40,7×
4 185,4 triệu 1,90 triệu 97,5×
8 342,4 triệu 1,64 triệu 209×

Đọc theo cột thì thấy hai hành vi ngược nhau. glibc tăng gần tuyến tính — 45,8 lên 342,4 triệu khi từ 1 lên 8 luồng, tức gấp 7,5 lần. musl đi lùi — 6,26 xuống 1,64 triệu, tức chậm đi 3,8 lần khi có thêm luồng.

Nguyên nhân là thiết kế: glibc cấp cho mỗi luồng một vùng cấp phát riêng nên chúng không tranh nhau. musl dùng một khoá chung, nên càng nhiều luồng thì càng nhiều thời gian chờ khoá.

Con số 209 lần chỉ đúng cho tải cấp phát dày đặc như phép đo này. Ứng dụng thật ít khi làm mỗi việc cấp phát, nên đừng chờ đợi dịch vụ của bạn chậm 209 lần. Nhưng những thứ hay cấp phát và hay chạy đa luồng thì nằm đúng vào vùng nguy hiểm: JVM, Node với nhiều worker, Python đa tiến trình, và bất cứ máy chủ nào tạo đối tượng cho mỗi request.

Đây cũng là lý do bạn hay nghe "ứng dụng của tôi chạy chậm hẳn khi chuyển sang Alpine" mà không ai chỉ ra được dòng mã nào chậm — vì không có dòng nào cả, chậm nằm ở tầng dưới.

Một lưu ý về cách đo: lần chạy đầu tiên tôi được 0,00 giây và 11 tỉ lần cấp phát mỗi giây. Đó là trình biên dịch vứt cả vòng lặp đi vì kết quả không ai dùng. Phải thêm biến volatile hút giá trị đọc lại thì con số mới thật — cùng một cái bẫy đã gặp khi đo JIT của Java.

Wheel Python: lời khuyên cũ đã lỗi thời

Lời khuyên quen thuộc: đừng dùng Alpine cho Python, vì không có wheel dựng sẵn nên pip phải biên dịch từ mã nguồn. Tôi đo pandas:

Thời gian Dung lượng
python:3.12-slim 37,9 s 87,4 MB dùng wheel có sẵn
python:3.12-alpine 31,5 s 66,1 MB dùng wheel có sẵn

Alpine nhanh hơn và nhỏ hơn. Từ khi có chuẩn musllinux (PEP 656), các gói phổ biến đã phát hành wheel cho musl, và pandas là một trong số đó.

Nhưng không phải gói nào cũng vậy. Thử grpcio:

slim  :  5,6 s | wheel co san
alpine: THAT BAI sau 11,2 s

Lỗi đầy đủ:

FileNotFoundError: [Errno 2] No such file or directory: 'c++'

Không có wheel cho musl, pip chuyển sang biên dịch từ mã nguồn, và image Alpine không có trình biên dịch. Cách vá hiển nhiên là cài công cụ build — và đây là chỗ mọi thứ đảo ngược:

Dung lượng
python:3.12-alpine trần 17,7 MB
+ build-base linux-headers 104,7 MB
python:3.12-slim trần 41,5 MB

Alpine cộng công cụ biên dịch lớn gấp 2,5 lần slim trần. Bạn chọn Alpine để image nhỏ, rồi phải thêm 87 MB công cụ để cài được gói, và kết thúc với image to hơn lựa chọn bạn đã bỏ qua.

Multi-stage build gỡ được phần dung lượng — như phần 9 đã đo, tầng build không đi vào image cuối. Nhưng thời gian biên dịch thì vẫn phải trả, mỗi lần cache hỏng.

Kết luận thực dụng: kiểm tra chính danh sách gói của bạn, đừng theo lời khuyên chung. Một dự án toàn gói có wheel musl thì Alpine tốt hơn thật; một gói duy nhất thiếu wheel là đủ đảo ngược.

Ngăn xếp luồng: 128 KB so với 8 MB

musl  (alpine): ngan xep mac dinh moi luong: 131072 byte (128 KB)
glibc (debian): ngan xep mac dinh moi luong: 8388608 byte (8192 KB)

Khác 64 lần. Đây là nguồn của một lớp lỗi rất khó chẩn đoán: chương trình đệ quy sâu, phân tích cú pháp lồng nhau, hoặc thư viện dùng mảng lớn trên ngăn xếp sẽ tràn ngăn xếp trên Alpine trong khi chạy tốt ở nơi khác. Và triệu chứng thường là một Segmentation fault trần trụi, không kèm thông tin gì.

Chữa được bằng cách khai tường minh khi tạo luồng, hoặc đặt ulimit -s, nhưng phải biết mình đang tìm gì.

Còn gì nữa

Hai khác biệt tôi không đo trong bài này nhưng đáng biết để đi tìm khi có sự cố:

  • Phân giải tên miền. musl xử lý /etc/resolv.conf khác glibc, đặc biệt với search domain và với việc thử tuần tự nhiều máy chủ DNS. Trong Kubernetes, nơi tên dịch vụ dựa nhiều vào search, đây là nguồn lỗi có thật.
  • Múi giờ và locale. Alpine không cài sẵn tzdata, nên TZ=Asia/Ho_Chi_Minh im lặng không có tác dụng cho tới khi bạn apk add tzdata.

Vậy có nên dùng Alpine không

Số đo cho một câu trả lời có điều kiện, không phải một câu khẩu hiệu:

Nên khi ứng dụng là tệp nhị phân tĩnh không dùng libc của hệ thống — Go với CGO_ENABLED=0, Rust liên kết tĩnh. Ở đó musl không tham gia lúc chạy, và bạn chỉ hưởng phần dung lượng nhỏ.

Nên khi ứng dụng ít cấp phát và ít luồng, và mọi phụ thuộc đều có wheel musl.

Cân nhắc lại khi ứng dụng đa luồng và cấp phát nhiều — bảng đầu bài là lý do. JVM là ví dụ điển hình, và các bản phân phối Java chính thức cho Alpine đều là bản riêng vì lý do này.

Đừng chọn Alpine chỉ vì con số dung lượng trên docker images. Chênh lệch giữa alpinedebian:12-slim là khoảng 23 MB — nhỏ hơn nhiều so với 87 MB công cụ biên dịch bạn có thể phải thêm vào, và nhỏ hơn rất nhiều so với cái giá của việc gỡ một lỗi tràn ngăn xếp lúc nửa đêm.

Thử ba mươi giây

Xem ứng dụng của bạn có thật sự dùng libc của hệ thống không:

docker run --rm <anh-cua-ban> sh -c 'ldd /duong/dan/toi/chuong-trinh 2>&1 | head -5'

Kết quả not a dynamic executable hoặc statically linked nghĩa là musl không tham gia lúc chạy, và mọi thứ trong bài này không ảnh hưởng tới bạn.

Còn nếu bạn đang cân nhắc chuyển sang Alpine, phép thử rẻ nhất là dựng cả hai và bắn tải:

docker build -t thu:alpine -f Dockerfile.alpine .
docker build -t thu:slim   -f Dockerfile.slim   .
# roi do cung mot kich ban tai len ca hai

Phần sau đo chuyện chạy container bằng người dùng không phải root: cái gì hỏng, và vá thế nào cho volume.