Phần 33 đo bốn driver mạng bằng iperf3 và trả lời câu hỏi "mạng nào truyền được nhiều byte/giây hơn". Bài này đổi ống kính: không hỏi thông lượng, mà hỏi một gói tin mất bao lâu để đi và quay về — độ trễ (latency), thứ quyết định cảm giác "nhanh" của một request đơn lẻ nhiều hơn là băng thông. Ba đường đi được so: bridge (hai container riêng, mạng tự định nghĩa), host (hai container --network host), và trong cùng một container (hai tiến trình gọi nhau qua 127.0.0.1).

Ba cột minh hoạ ba đường đi mạng Docker: bridge qua veth và bridge ảo, host dùng chung network namespace, và cùng container qua loopback nội bộ, kèm số đo ping và curl

Môi trường đo

Docker Engine 29.7.2 trên Docker Desktop (macOS), daemon chạy trong VM linuxkit — container nào cũng chạy alpine:3.20. Điểm này quan trọng cho mục sau về --network host, nên nhắc trước: trên Docker Desktop, "host" nghĩa là netns của VM, không phải netns của macOS thật. Trên Linux thật, --network host là netns của chính máy chủ — số đo dưới đây có thể lệch đi nếu chạy trên Linux, nhưng đường đi (không veth, không bridge ảo) thì giống hệt.

$ docker version --format '{{.Server.Version}}'
29.7.2

Ba đường đi, dựng bằng ba cách nối container

  • bridge: hai container lat-clientlat-server trên một mạng tự định nghĩa (docker network create), mỗi bên một network namespace riêng, nối nhau qua cặp veth + bridge ảo.
  • host: hai container --network host. Cả hai không có netns riêng — chúng dùng chung đúng một netns với nhau (và với chính VM), nên "container A gọi container B" thực chất là gọi 127.0.0.1 trong một netns dùng chung, chẳng khác gì hai tiến trình bất kỳ trên cùng máy.
  • cùng container: một container alpine chạy cả httpd (bind cổng 8082) lẫn curl, gọi nhau qua 127.0.0.1 trong netns của riêng nó — không container thứ hai, không mạng ảo nào.

Bridge: hai lượt qua veth và bridge ảo

$ docker network create latnet
$ docker run -d --name lat-server --network latnet alpine:3.20 sleep 3600
$ docker run -d --name lat-client --network latnet alpine:3.20 sleep 3600
$ docker exec lat-client ping -c 100 -i 0.2 -q 172.19.0.2
100 packets transmitted, 100 received, 0% packet loss, time 20745ms
rtt min/avg/max/mdev = 0.024/0.110/0.224/0.040 ms

Gói tin rời eth0 của client, qua veth của nó, tới bridge ảo latnet, qua veth của server, vào eth0 của server — rồi ngược lại y hệt để về. Bốn chặng veth/bridge cho một round-trip.

Host: chung một netns, không phải một đường ống riêng

$ docker run -d --name lat-host2 --network host alpine:3.20 sleep 3600
$ docker exec lat-host2 ping -c 100 -i 0.2 -q 127.0.0.1
100 packets transmitted, 100 packets received, 0% packet loss
round-trip min/avg/max = 0.024/0.083/0.198 ms

Không có veth, không có bridge ảo — gói tin (nếu còn gọi là "gói tin" hợp lý ở đây) đi thẳng qua loopback của netns dùng chung. Về bản chất mạng, đây là cùng một lớp thao tác với kịch bản "cùng container", chỉ khác ở chỗ có hai tiến trình httpd/PID namespace tách biệt thay vì một.

Cùng container: loopback không đi đâu cả

$ docker exec lat-solo ping -c 100 -i 0.2 -q 127.0.0.1
100 packets transmitted, 100 received, 0% packet loss, time 20747ms
rtt min/avg/max/mdev = 0.013/0.058/0.146/0.021 ms

0,058 ms trung bình — thấp nhất trong ba kịch bản, và thấp nhất một cách nhất quán ở mọi lần đo lại bên dưới. Không có veth, không có bridge ảo, không có netns thứ hai nào để băng qua.

Đo lại ba lần: bridge và host đổi chỗ nhau, không giải thích được

Theo đúng quy tắc của sê-ri này — không đoán khi không có bằng chứng — đây là ba lần chạy lại ping -c 100 liên tiếp, cùng lệnh, cách nhau vài phút:

Lần đo bridge (avg) host (avg) cùng container (avg)
1 (n=50, interval mặc định) 0,096 ms 0,081 ms 0,053 ms
2 (n=100, interval 0,2s) 0,097 ms 0,119 ms 0,056 ms
3 (n=100, interval 0,2s) 0,110 ms 0,083 ms 0,058 ms

bridge thắng host ở lần 1 và 3, thua ở lần 2. Chênh lệch giữa hai cột đó (0,02–0,04 ms) nhỏ hơn cả độ lệch chuẩn (mdev) đo được trong từng lần — tức là nằm trong vùng nhiễu của môi trường đo (VM, scheduler, tải nền của macOS), không phải một quy luật của bản thân Docker. Kết luận trung thực: ở tầng ICMP, không đủ căn cứ để nói bridge hay host nhanh hơn nhau. Chỉ có một điều lặp lại ổn định ở cả ba lần: cùng container luôn thấp hơn hẳn cả hai, chênh khoảng 40–50%.

Ở tầng TCP + HTTP, thứ tự ổn định trở lại — nhưng chênh lệch co gần hết

ping đo ICMP thuần, còn ứng dụng thật gọi nhau qua TCP rồi HTTP. Dựng httpd (busybox) ở mỗi kịch bản, gọi curl -w '%{time_connect} %{time_total}' 60 lần mỗi bên:

$ docker exec lat-client sh -c \
    'for i in $(seq 60); do curl -s -o /dev/null -w "%{time_connect} %{time_total}\n" http://172.19.0.2/; done'
Kịch bản time_connect (bắt tay TCP) time_total (cả request)
bridge 0,034 ms 0,149 ms
host 0,029 ms 0,142 ms
cùng container 0,027 ms 0,138 ms

Lần này thứ tự giữ nguyên và lặp lại qua nhiều lần chạy: cùng container < host < bridge, cả ở bắt tay TCP lẫn tổng thời gian request. Nhưng biên độ chỉ còn 7–20 µs (micro giây) — nhỏ hơn 1% của chính con số time_total, và nhỏ hơn hàng trăm tới hàng nghìn lần so với độ trễ một truy vấn database hay một lần gọi API bên ngoài thường gặp trong ứng dụng thật.

Vậy chọn đường mạng nào?

  • Đừng chọn network mode vì vài chục micro giây ping. Ở quy mô một request ứng dụng thật (thường tính bằng mili giây, không phải micro giây), chênh lệch này biến mất trong nhiễu.
  • --network host đáng cân nhắc vì lý do khác: bỏ NAT port-mapping và một lớp bridge, có ích rõ nhất khi cần thông lượng cao hoặc container cần thấy IP nguồn thật của client — đúng góc đo của Phần 33, không phải góc độ trễ của bài này.
  • Đừng nhét nhiều tiến trình vào một container chỉ để ăn phần loopback nhanh nhất. Số đo ở đây xác nhận nó nhanh hơn thật, nhưng đổi lại là mất khả năng scale từng tiến trình độc lập, mất cô lập lỗi — một sự đánh đổi không đáng cho vài chục micro giây.
  • bridge vẫn là lựa chọn mặc định hợp lý, đúng kết luận của Phần 33: cô lập tốt, độ trễ thêm vào là có thật nhưng nhỏ tới mức không quyết định trải nghiệm người dùng cuối.

Bài viết liên quan

Thử ba mươi giây

Dán thẳng vào terminal — dựng một cặp container bridge và đo ping 20 gói, không cần dọn dẹp gì thêm ngoài docker rm -f:

docker network create latnet-test
docker run -d --name srv --network latnet-test alpine:3.20 sleep 300
docker run --rm --network latnet-test alpine:3.20 \
  ping -c 20 -q $(docker inspect -f '{{.NetworkSettings.Networks.latnet-test.IPAddress}}' srv)
docker rm -f srv >/dev/null; docker network rm latnet-test >/dev/null