Hình dung hai nhà hàng xóm có sân sau giáp nhau. Họ có thể chuyền đồ qua hàng rào sau — với tay là tới. Hoặc họ có thể lái xe ra đường lớn, vòng quanh khối nhà, rồi vào bằng cổng trước — cùng một đích, nhưng một đường đi vòng vô ích. Hai container trên cùng một mạng Docker gọi nhau theo tên là chuyền qua hàng rào; bắt chúng đi qua localhost:cổng-publish của máy chủ là lái xe vòng khối nhà. Cổng publish là cổng trước cho khách từ ngoài đường, không phải lối cho ông hàng xóm sát vách. Phần 33 đo thông lượng. Nhưng dịch vụ nội bộ hiếm khi bão hoà băng thông — cái chúng làm cả ngày là gửi một request nhỏ rồi chờ một response nhỏ. Bài này đo đúng chuyện đó.
Cách đo: một kết nối keep-alive, TCP_NODELAY bật, làm nóng 200 lần, rồi 20.000 lần hỏi-đáp. Server trả về vỏn vẹn 2 byte, nên gần như toàn bộ thời gian là chi phí đường đi.
| Đường đi | Trung vị | p95 | p99 |
|---|---|---|---|
Trong cùng container (127.0.0.1) |
43,2 µs | 62,8 | 114,6 |
Mạng host (127.0.0.1) |
45,2 µs | 57,5 | 93,2 |
| Bridge tự tạo, theo IP | 48,5 µs | 60,5 | 94,3 |
| Bridge tự tạo, theo tên | 48,5 µs | 66,0 | 109,2 |
Toàn bộ bảng nằm gọn trong 5 micro giây. Đi qua cầu Docker đắt hơn loopback trong cùng container đúng 5,3 µs, tức khoảng 12%.
Để so sánh: một truy vấn SELECT đơn giản trên PostgreSQL nội bộ mất khoảng 200–500 µs. Chi phí của bridge nằm dưới mức nhiễu của chính ứng dụng bạn.
Hai dòng cuối cũng đáng chú ý: gọi theo tên và gọi theo IP cho con số y hệt. Phân giải DNS xảy ra một lần lúc mở kết nối, không phải mỗi request — miễn là bạn dùng kết nối lâu dài, cái giá 0,073 ms của phần 34 không lặp lại.
Chỗ thật sự đắt: đi qua cổng publish
Cùng một server, hai đường tới:
| Đường đi | Trung vị | p95 | p99 |
|---|---|---|---|
| container → container, IP trực tiếp | 47,3 µs | 63,4 | 96,5 |
qua cổng publish 127.0.0.1:19000 |
84,4 µs | 111,6 | 196,8 |
Chậm hơn 78%, và p99 xấu gấp đôi.
Thủ phạm là docker-proxy — tiến trình không gian người dùng mà phần 35 đã đếm được hai cái cho mỗi cổng. Mỗi gói tin phải đi lên không gian người dùng rồi quay xuống nhân.
Chứng minh bằng cách tắt nó đi. Dựng lại daemon với --userland-proxy=false:
so tien trinh docker-proxy: 0
di thang IP container : 47,0 µs
qua cong publish 19000 : 47,8 µs
Toàn bộ chênh lệch biến mất. Không còn proxy thì việc chuyển tiếp do iptables lo hoàn toàn, và cổng publish nhanh ngang đi thẳng.
Nghĩa là một dòng trong /etc/docker/daemon.json vừa lấy lại 78% độ trễ vừa gỡ bỏ 93 MB tiến trình proxy:
{ "userland-proxy": false }
Cần biết trước khi bật: không có proxy thì việc publish cùng một cổng hai lần sẽ báo lỗi ngay thay vì im lặng, và trên vài cấu hình mạng cũ có bản ghi lỗi về hỗ trợ IPv6 hoặc localhost. Hãy thử ở môi trường staging trước, đừng đổi thẳng trên production.
Rút ra: chọn đường đi, đừng chọn loại mạng
Xếp lại từ nhanh tới chậm:
| So với nhanh nhất | |
|---|---|
| Cùng container | 1,00× |
| host | 1,05× |
| Bridge tự tạo | 1,12× |
| Qua cổng publish | 1,95× |
Bốn dòng đầu gần như bằng nhau; dòng cuối mới là thứ đáng để ý. Kết luận thực dụng: hai dịch vụ nói chuyện với nhau thì cho chúng chung một mạng Docker và gọi theo tên, đừng bắt chúng đi vòng qua localhost:cổng-publish của máy chủ. Cổng publish là để cho thế giới bên ngoài vào, không phải để container nói với nhau.
Đây là lỗi rất hay gặp khi chuyển từ chạy trực tiếp sang Docker: cấu hình cũ ghi DB_HOST=localhost:5432, người ta publish cổng 5432 rồi giữ nguyên cấu hình. Nó chạy — chỉ là mỗi truy vấn phải qua thêm một chặng không gian người dùng.
Một phát hiện đã biến mất khi chạy lại
Tôi thử tái hiện một cái bẫy kinh điển: thuật toán Nagle gặp delayed ACK, gây treo 40 ms. Điều kiện kích hoạt là gửi request bằng hai lần ghi riêng biệt khi TCP_NODELAY tắt.
Lần chạy đầu cho kết quả rất kêu:
NODELAY bat, ghi hai lan : 53,0 us
NODELAY TAT, ghi hai lan : 16,1 us <- nhanh hon 3,3 lan
Ngược hẳn lý thuyết, và đúng theo hướng "phát hiện thú vị". Tôi suýt viết một mục về việc Nagle thật ra có lợi trong container.
Chạy lại năm nghìn lần, ba lần mỗi cấu hình:
| Cấu hình | Ba lần chạy (trung vị, µs) |
|---|---|
| NODELAY bật, ghi một lần | 44,1 / 49,0 / 32,0 |
| NODELAY bật, ghi hai lần | 47,8 / 46,1 / 48,7 |
| NODELAY tắt, ghi một lần | 52,8 / 49,4 / 54,0 |
| NODELAY tắt, ghi hai lần | 50,4 / 48,9 / 47,7 |
Con số 16,1 không xuất hiện lại lần nào. Nó là nhiễu của đúng một lần chạy, và bốn cấu hình thật ra không khác nhau — tất cả nằm trong khoảng 32–54 µs.
Kết luận đúng, và vẫn hữu ích: cái bẫy Nagle 40 mili giây không xảy ra ở đây. Nó cần độ trễ vòng đủ lớn để hết thời gian delayed ACK; giữa hai container trên cùng một máy, thời gian vòng là ~50 micro giây, nhỏ hơn ngưỡng đó hàng trăm lần.
Nagle vẫn làm việc của nó, chỉ là không ai thấy. Đếm segment thật sự gửi đi:
| Cấu hình | Segment mỗi request |
|---|---|
| NODELAY bật, ghi một lần | 1,00 |
| NODELAY bật, ghi hai lần | 2,01 |
| NODELAY tắt, ghi hai lần | 1,51 |
Nagle gộp được khoảng một nửa số cặp ghi lại thành một gói. Nó có tác dụng đo được ở tầng gói tin, nhưng tác dụng đó không hiện lên trong độ trễ ở khoảng cách này.
Bài học về cách đo: một kết quả vừa ngược lý thuyết vừa quá đẹp thì gần như chắc chắn là nhiễu. Cách rẻ nhất để biết là chạy lại ba lần trước khi nghĩ tới việc giải thích nó.
Muốn tự đo đúng đường đi ứng dụng mình đang dùng, chạy curl hai lần — một lần theo tên dịch vụ trong mạng Docker, một lần qua localhost:<cổng-publish> của máy chủ:
docker run --rm --network <mang-cua-ban> curlimages/curl:8.10.1 \
-s -o /dev/null -w 'ket noi %{time_connect}s tong %{time_total}s\n' \
http://<ten-dich-vu>:<cong>/
Nếu đường thứ hai chậm hơn rõ rệt, bạn đang trả tiền cho một chặng không cần thiết — sửa bằng cách cho hai dịch vụ chung một mạng.
Mẫu số chung
Bài học đầu tiên, đọc thẳng từ 47 so với 84 µs: với một chi phí cố định, hãy biết cái gì thật sự trội — ở đây mọi loại mạng chênh nhau trong 5 µs (nhiễu), còn biến số thật là đường đi: vòng qua cổng publish đắt hơn 78% vì thêm một chặng lên không gian người dùng. Người ta tranh cãi bridge hay host — cái đó là sai số làm tròn; cái đáng sửa là đừng đẩy lưu lượng nội bộ ra cổng ngoài rồi vòng vào. Cùng chuyện "đừng đi cổng trước để sang nhà bên cạnh" ở khắp nơi: gọi dịch vụ của chính mình qua load balancer công khai thay vì gọi nội bộ, một cuộc gọi trong-tiến-trình bị biến thành qua-mạng, một round-trip localhost thừa thay cho một bus chung. Nguyên tắc: giữ đường ngắn cho lưu lượng nội bộ — lối vào bên ngoài là để cho người ngoài, và trước khi tinh chỉnh thứ ai cũng bàn, đo xem cái số hạng nào mới đang chiếm phần lớn.
Điều thứ hai, đọc từ "gọi theo tên = theo IP": một chi phí thiết lập một lần rẻ như không chỉ khi bạn tái dùng cái nó thiết lập ra. Phân giải DNS xảy ra đúng một lần lúc mở kết nối, nên trên một kết nối keep-alive nó phân bổ về gần 0; nhưng nếu bạn mở kết nối mới cho mỗi request thì cái giá đó — cùng với bắt tay TCP, bắt tay TLS — trả đủ mỗi lần và trội hẳn lên. Cùng ý "khấu hao qua tái dùng" ở khắp nơi: pool kết nối, bắt tay TLS phân bổ qua keep-alive, prepared statement, JIT nóng máy rồi giữ tiến trình sống. Nguyên tắc: tái dùng cái đắt-để-dựng thay vì dựng-lại mỗi lần — một phí khởi tạo chỉ là "một lần" khi vòng đời của thứ nó tạo ra đủ dài để nhiều thao tác cùng hưởng; xé nhỏ vòng đời đó là biến một chi phí một-lần thành một chi phí mỗi-lần.
Phần sau đi vào lưu trữ: volume, bind mount và tmpfs khác nhau ra sao, và đo tốc độ ghi của từng loại.