Hình dung một phòng bệnh lớn, chung một sàn, chung hệ thống điện, oxy và điều hoà. Người ta không xây cho mỗi bệnh nhân một căn phòng riêng có móng, ống nước, máy lạnh riêng — họ chỉ kéo một tấm rèm quanh mỗi giường. Sau tấm rèm, bệnh nhân ngỡ mình ở phòng riêng: không thấy ai khác, đánh số giường của mình là số 1. Còn y tá đứng ngoài kéo hết rèm ra thì thấy rõ: chỉ là một phòng lớn, nhiều giường, dùng chung một hạ tầng. Kéo thêm một tấm rèm gần như không tốn gì; xây thêm một căn phòng kín thì đắt. Container với máy ảo đúng là rèm với phòng riêng — và bài này đo xem tấm rèm ấy giá bao nhiêu.
Câu giải thích quen thuộc là "container nhẹ hơn máy ảo vì dùng chung nhân". Đúng, nhưng nó không cho bạn con số nào để ra quyết định. Bài này đo cái giá thật của một container: bao nhiêu mili giây, bao nhiêu megabyte, và bao nhiêu cái thì máy chịu được.
Môi trường đo, ghi lại để bạn đối chiếu:
Docker 29.3.1 | storage-driver overlayfs | cgroup v2
16 nhân | 15,6 GB RAM cấp cho máy ảo Linux
nhân trong container: 6.12.76-linuxkit
Khởi động một container mất bao lâu
Hai mươi lần, lấy trung vị:
| Thời gian | |
|---|---|
docker run --rm alpine true |
260,1 ms (thấp nhất 231,7 — cao nhất 366,1) |
docker create |
74,4 ms |
docker start trên container đã tạo |
193,1 ms |
docker exec vào container có sẵn |
54,1 ms |
docker version (chỉ đi về daemon) |
19,6 ms |
Đọc bảng này theo chiều từ dưới lên thì thấy tiền đi đâu: 19,6 ms là chi phí CLI gọi sang daemon và về, tức là cái sàn bạn phải trả cho mọi lệnh docker. 54,1 ms là chạy một tiến trình trong không gian tên đã có sẵn. Và 260,1 ms là dựng mới toàn bộ: tạo lớp ghi, dựng không gian tên, gắn mạng, khởi động tiến trình, rồi dọn.
Nhưng con số 260 ms đó gây hiểu nhầm nếu bạn suy ra "máy này dựng được 4 container mỗi giây". Tôi dựng 100 container song song:
100 container trong 4,3 giay = 43 ms moi cai
Nhanh gấp sáu lần so với chạy tuần tự. Nghĩa là phần lớn trong 260 ms kia là thời gian chờ — chờ daemon trả lời, chờ tiến trình khởi động — chứ không phải công sức. Máy làm được nhiều container cùng lúc mà không tốn thêm gì đáng kể.
Đây là khuôn hình sẽ lặp lại suốt sê-ri này, nên đáng nhớ ngay từ bài đầu: một phép đo tuần tự đo độ trễ, không đo năng lực. Muốn biết máy chịu được bao nhiêu thì phải đo song song.
Một container tốn bao nhiêu bộ nhớ
Câu hỏi này khó đo hơn tôi tưởng. Lần đầu tôi đọc MemAvailable của máy ảo Linux trước và sau khi dựng container, rồi lấy hiệu. Kết quả:
20 container : -22,8 MB
=> gia cach ly: -5,5 MB
Bộ nhớ âm. Máy này còn 35 container khác đang chạy, và dao động của chúng lớn hơn hẳn thứ tôi đang cố đo. Một con số âm cho thứ chỉ có thể dương là dấu hiệu phép đo hỏng, không phải phát hiện.
Cách đúng là đọc cgroup của từng container, thứ không bị hoạt động của máy làm nhiễu:
docker stats --no-stream --format '{{.MemUsage}}' <ten-container>
| Tổng | Mỗi tiến trình | |
|---|---|---|
20 container, mỗi cái 1 tiến trình sleep |
10,57 MB | 0,53 MB |
1 container chạy 20 tiến trình sleep |
2,93 MB | 0,15 MB |
Chênh lệch 7,65 MB cho 20 container, tức mỗi container tốn thêm khoảng 0,38 MB so với việc chỉ chạy tiến trình đó trần trụi. Đó là toàn bộ cái giá của cách ly: một bộ không gian tên, một cgroup, một lớp filesystem ghi được, một giao diện mạng ảo. Đúng là giá của một tấm rèm, không phải giá của một căn phòng.
Để có cảm giác về tỉ lệ: 100 container chạy đồng thời chiếm 52,8 MB. Một máy ảo Linux tối giản, chưa chạy gì, thường cần vài trăm megabyte. Đó là khác biệt thật giữa hai công nghệ, và giờ nó có con số.
Vì sao rẻ đến vậy: cùng một nhân
Bằng chứng trực tiếp, không cần tin ai:
$ docker run --rm alpine:3.20 uname -r
6.12.76-linuxkit
$ docker run --rm --privileged --pid=host alpine:3.20 uname -r
6.12.76-linuxkit
Cùng một chuỗi. Container không có nhân riêng — nó chỉ là tiến trình chạy trên nhân của máy chủ, được nhân đó giấu đi những thứ không cho thấy.
Cái "giấu đi" ấy cũng đo được. Cho một container chạy sleep, rồi nhìn từ hai chỗ:
# tu ben trong chinh no
$ docker exec nhin-thay ps -o pid,comm
PID COMMAND
1 sleep
7 ps
# tu mot container khac, nhung xin nhin bang khong gian ten cua may chu
$ docker run --rm --pid=host alpine:3.20 ps -o pid,comm | grep -c sleep
1
Bên trong, tiến trình sleep là PID 1 và không thấy gì khác. Từ bên ngoài với --pid=host, nó chỉ là một tiến trình bình thường trong danh sách. Cùng một tiến trình, hai cách nhìn — đúng như bệnh nhân sau tấm rèm với y tá kéo rèm ra. Đó chính là toàn bộ ý tưởng của không gian tên, và là lý do container rẻ hơn máy ảo hai bậc độ lớn.
Nhưng trên macOS thì bạn vẫn đang chạy máy ảo
Đây là chi tiết mà rất nhiều hướng dẫn bỏ qua, và nó giải thích khá nhiều chuyện khó hiểu về sau.
Nhìn lại kết quả uname ở trên: container báo Linux 6.12.76-linuxkit. Còn máy của tôi:
$ uname -a
Darwin ... 25.5.0 ... arm64
Darwin không phải Linux. Container không thể chạy trực tiếp trên nhân macOS, nên Docker Desktop dựng sẵn một máy ảo Linux và mọi container chạy bên trong đó:
RAM cap cho may ao Linux : 15,6 GB
com.docker.backend chiem : 819 MB tren macOS
Nghĩa là trên macOS bạn trả tiền máy ảo một lần — cho cả hệ thống, không phải cho mỗi container. Điều đó vẫn giữ nguyên lợi thế về mật độ, nhưng nó có ba hệ quả thực tế mà sê-ri này sẽ gặp lại:
localhosttrong container không phải máy Mac của bạn — nó là máy ảo Linux.- Bind mount phải đi qua ranh giới máy ảo, và đó là lý do I/O trên macOS chậm hơn Linux rất nhiều. Chúng ta sẽ đo con số này ở phần về lưu trữ.
- Mọi phép đo hiệu năng trên macOS đều mang thêm một tầng, nên đừng đem con số của máy Mac đi so với máy chủ Linux.
Trên Linux thì không có tầng này: Docker chạy thẳng trên nhân của máy, và các con số ở trên còn đẹp hơn nữa.
Vậy Docker giải quyết vấn đề gì
Sau khi có số, câu trả lời gọn hơn nhiều so với lời quảng cáo thường nghe. Docker không làm ứng dụng của bạn chạy nhanh hơn — sê-ri này sẽ đo và cho thấy nó gần như không đổi. Nó cho bạn ba thứ:
- Đóng gói kèm phụ thuộc. Ứng dụng cộng thư viện cộng cấu hình thành một thứ chuyển đi được.
- Cách ly với giá 0,38 MB. Đủ rẻ để mỗi dịch vụ một container thay vì nhồi chung một máy.
- Cùng một thứ chạy ở mọi nơi — với dấu sao ở phần macOS phía trên.
Và đổi lại, bạn nhận thêm một tầng nữa để hiểu, để gỡ lỗi, và để đo. Toàn bộ sê-ri này là về tầng đó.
Tự đo trên máy bạn
Đo cái giá của một container trên chính máy bạn:
# thoi gian dung mot container
time docker run --rm alpine:3.20 true
# bo nho that su cua mot container dang chay
docker run -d --name thu alpine:3.20 sleep 60
docker stats --no-stream --format '{{.Name}}: {{.MemUsage}}' thu
docker rm -f thu
Nếu con số thời gian của bạn lớn hơn 260 ms nhiều, hãy chạy lại — lần đầu luôn gồm cả thời gian tải image về.
Mẫu số chung
Bài học đầu tiên, chính là chỗ 260 ms rơi xuống 43 ms khi chạy song song: đo tuần tự cho bạn biết độ trễ của một cái, không cho biết năng lực của cả máy — và hai con số đó không nhân ra được nhau, vì phần lớn "thời gian một cái" là chờ, không phải làm. 100 container không mất 100 × 260 ms, vì lúc cái này đang chờ thì cái kia đang chạy. Cùng nhầm lẫn "độ trễ ≠ thông lượng" ở khắp nơi: đo một truy vấn CSDL bằng một kết nối rồi suy ra số truy vấn mỗi giây là sai — phải mở nhiều kết nối song song mới thấy trần thật; trong Java, thời gian một lời gọi hàm không cho biết thông lượng của một thread pool; trong Go, phóng 100 goroutine cùng lúc mới đo được năng lực chứ không phải gọi tuần tự; một API "trả lời trong 50 ms" chẳng nói gì về việc nó chịu được bao nhiêu yêu cầu đồng thời. Nguyên tắc: khi hỏi "máy/dịch vụ này tải được bao nhiêu", đừng bao giờ trả lời bằng phép đo một-lần-một — hãy chạy song song đúng mức tải thật, vì độ trễ và thông lượng là hai câu hỏi khác nhau và cái sau mới quyết định hệ thống có đứng vững không.
Điều thứ hai, đọc từ "cùng một tiến trình, hai cách nhìn" và cái máy ảo ẩn trên macOS: mọi lớp trừu tượng cho bạn cảm giác "có một cái X riêng" thực ra đều dùng chung nền bên dưới — nó rẻ vì cái nền đó gánh hộ, nhưng cái giá nhỏ và chỗ rò rỉ của trừu tượng luôn lộ ra đúng tại ranh giới ấy. Container ngỡ có nhân riêng nhưng chung nhân máy chủ; trên macOS còn thêm một máy ảo giấu đi khiến localhost không phải máy bạn và bind mount thì chậm. Cùng chuyện "trừu tượng dùng chung nền, và nó rò" ở khắp nơi: JVM cho bạn ảo giác thoát ly hệ điều hành nhưng cú dừng GC vẫn xuyên qua; một giao dịch CSDL cho bạn ảo giác chạy một mình nhưng tranh khoá vẫn lộ ra dưới tải; một ORM giấu SQL đi nhưng lỗi N+1 rò lên tận trang; bộ nhớ ảo giấu bộ nhớ vật lý nhưng page fault vẫn hiện thành độ trễ. Nguyên tắc: với mỗi lớp trừu tượng "cho bạn một cái riêng", hãy hỏi ngay thật ra đang dùng chung cái gì bên dưới — vì cái giá phải trả và chỗ trừu tượng vỡ ra luôn nằm đúng ở phần dùng chung đó, và biết trước nó giúp bạn không so nhầm số (như đem máy Mac đi so máy chủ Linux) lẫn không tin nhầm vào một sự cách ly chỉ dày bằng tấm rèm.
Phần sau mổ xẻ image: layer là gì, chồng lên nhau ra sao, và vì sao mười image có thể chỉ tốn dung lượng của một.