Suốt bốn mươi bảy phần trước, mọi phép đo đều chạy trong container trên một máy ảo, và tôi liên tục ghi chú rằng con số tuyệt đối không mang đi được. Bài này đo xem cái ghi chú đó đáng bao nhiêu.
Sai lầm trước, kết quả sau
Lần đo đầu tiên tôi biên dịch chương trình bằng trình biên dịch mặc định ở mỗi nơi — clang trên macOS, gcc trong Debian:
CPU thuần: máy 0,326 s | container 0,544 s
Ảo hoá làm CPU chậm 1,67 lần. Một kết luận gọn gàng, khớp với trực giác, và hoàn toàn sai.
Đổi sang dùng clang ở cả hai nơi:
CPU thuần: máy 0,329 s | container 0,345 s
1,05 lần.
Toàn bộ "chi phí ảo hoá" kia là trình biên dịch. gcc -O1 và clang -O1 sinh mã khác nhau cho một vòng lặp có phép chia lấy dư, và khác biệt đó lớn hơn mọi thứ ảo hoá làm.
Bài học chung: khi so sánh hai môi trường, mọi thứ khác ngoài môi trường phải giống nhau. Trình biên dịch là thứ dễ quên nhất, và nó một mình giải thích được một kết luận sai gấp 1,6 lần.
Bảng đúng
Cùng chương trình, cùng clang -O1, chạy thẳng trên máy so với chạy trong container Linux:
| Phép đo | Chạy thẳng | Trong container | Chênh |
|---|---|---|---|
| CPU thuần (400M vòng) | 0,329 s | 0,345 s | 1,05× |
| Bộ nhớ (duyệt 64 MB × 6) | 0,027 s | 0,034 s | 1,26× |
Lời gọi hệ thống (1M close) |
81,0 ns | 125,4 ns | 1,55× |
Ghi 256 MB + fsync |
8.801,8 MB/s | 795,3 MB/s | 11,1× (*) |
Hình dạng rất rõ: càng gần CPU thì ảo hoá càng rẻ, càng gần ổ đĩa thì càng đắt.
CPU: 1,05 lần — coi như miễn phí. Lệnh tính toán chạy thẳng trên cùng con chip, không có tầng nào chen vào. Đây là lý do ảo hoá thắng thế: phần đắt nhất của một máy tính lại là phần rẻ nhất để ảo hoá.
Bộ nhớ: 1,26 lần. Truy cập bộ nhớ đi qua một tầng dịch địa chỉ nữa (bảng trang lồng nhau), và TLB phải giữ nhiều mục hơn.
Lời gọi hệ thống: 1,55 lần. Đây là ranh giới thật giữa ứng dụng và nhân, nên nó là chỗ đầu tiên ảo hoá phải trả tiền.
I/O: một bậc độ lớn. Mỗi thao tác đĩa đi qua hệ thống tệp của khách, tầng khối ảo, rồi mới tới hệ thống tệp của máy chủ.
Dấu sao ở dòng cuối
Con số 8.801,8 MB/s không so sánh được, và tôi không được để nó trong bảng mà không nói rõ.
fsync() trên macOS không ép ổ đĩa xả bộ đệm của chính nó. Nó chỉ đẩy dữ liệu xuống driver rồi trả về. Muốn bền vững thật phải gọi fcntl(fd, F_FULLFSYNC), và con số khi đó thấp hơn nhiều.
Nên 11,1 lần ở đây là chênh lệch giữa hai định nghĩa khác nhau của "đã ghi xong", không phải chênh lệch của ảo hoá. Phần 14 đã đo fsync thật trên Linux: 686 IOPS với rào chắn đầy đủ, 8.523 khi tắt rào chắn — cùng một loại khác biệt.
Điều đúng rút ra được từ dòng đó: I/O là chỗ ảo hoá đắt nhất, và cũng là chỗ dễ đo sai nhất.
seccomp chiếm 28% chi phí lời gọi hệ thống
Tách riêng phần bảo vệ của container ra khỏi phần ảo hoá:
| Cấu hình | Seccomp: |
ns mỗi close(-1) |
|---|---|---|
| Container mặc định | 2 | 133,0 |
--security-opt seccomp=unconfined |
0 | 103,9 |
29,1 ns, tức 28%.
Nghĩa là trong con số 125–133 ns của container, khoảng 29 ns là bộ lọc seccomp — thứ không phải chi phí ảo hoá mà là chi phí bảo mật, và nó tồn tại y hệt trên kim loại trần nếu bạn bật bộ lọc.
Tách đúng ba tầng:
| Tầng | Cộng thêm |
|---|---|
| Lời gọi hệ thống trên máy | 81 ns |
| + máy ảo | ~23 ns |
+ bộ lọc seccomp của container |
~29 ns |
Đừng tắt seccomp để lấy lại 29 ns. Nó là một trong những lớp bảo vệ có tỷ lệ lợi ích trên chi phí cao nhất của container.
Nghĩa là gì khi chọn nơi chạy
| Tải chủ yếu là | Ảo hoá tốn |
|---|---|
| Tính toán, xử lý dữ liệu trong bộ nhớ | ~5%, bỏ qua được |
| Duyệt cấu trúc dữ liệu lớn | ~25% |
| Nhiều lời gọi hệ thống nhỏ | ~55% |
| Đọc ghi đĩa dày đặc | hàng lần |
| Mạng thông lượng cao | xem phần 36 — qua cầu nối chậm 1,7 lần |
Ba việc làm được ngay nếu tải của bạn nằm ở hai dòng cuối:
- Ổ đĩa: dùng volume gắn thẳng, không dùng lớp ghi của container. Lớp overlay cộng thêm một tầng nữa.
- Mạng:
--network hostkhi không cần cách ly (phần 36). - Lời gọi hệ thống: gom lại. Phần 47 cho thấy chi phí là mỗi lời gọi, không phải mỗi byte — và trong container thì chi phí đó còn cao hơn 55%.
Giới hạn của phép đo này
"Chạy thẳng" ở đây là macOS trên Apple Silicon, còn "container" là Linux trong máy ảo của Docker Desktop. Đó là hai hệ điều hành khác nhau, không chỉ khác về ảo hoá.
Phép so sánh sạch hơn sẽ là Linux trên kim loại trần so với Linux trong máy ảo trên cùng máy đó — thứ tôi không có. Nên con số cụ thể (1,05 / 1,26 / 1,55) mang tính minh hoạ cho hình dạng chứ không phải hằng số để trích dẫn.
Hình dạng thì đáng tin, vì nó khớp với chỗ ranh giới ảo hoá nằm: CPU không đi qua ranh giới nào, lời gọi hệ thống đi qua một, I/O đi qua ba.
Và với KVM hoặc phần cứng có hỗ trợ ảo hoá đầy đủ, các con số này tốt hơn những gì tôi đo — Docker Desktop trên macOS là một trong những cấu hình nhiều tầng nhất có thể gặp.
Thử ba mươi giây
So chính máy bạn với container của nó:
cat > /tmp/b.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
#include <time.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
return t.tv_sec+t.tv_nsec/1e9;}
int main(void){
volatile long s=0; double t;
t=now(); for(long i=0;i<200000000L;i++) s+=i%7;
printf("CPU : %6.3f s\n",now()-t);
t=now(); for(long i=0;i<500000L;i++) s+=close(-1);
printf("loi goi HT: %6.1f ns (s=%ld)\n",(now()-t)/500000*1e9,s);
return 0;
}
EOF
cc -O1 -o /tmp/b /tmp/b.c && echo "[may]" && /tmp/b
docker run --rm -v /tmp/b.c:/b.c:ro debian:12-slim sh -c '
apt-get -qq update >/dev/null 2>&1
apt-get -qq install -y clang >/dev/null 2>&1
clang -O1 -o /b /b.c && echo "[container]" && /b'
Dùng cùng một trình biên dịch ở hai bên — đó là cái bẫy đã làm hỏng lần đo đầu tiên của tôi. Nếu dòng CPU chênh nhau nhiều hơn 10%, hãy nghi ngờ trình biên dịch trước khi nghi ngờ ảo hoá.
Phần sau: bẫy khi đo — những sai lầm tôi đã mắc trong bốn mươi tám phần trước.