Phần 1 đo sáu trục namespace và để lại một trục cho riêng phần này: mạng. Network namespace là trục mà lập trình viên backend chạm vào thường nhất, vì mọi câu hỏi "vì sao container này không nối được container kia", "vì sao cổng không vào được từ ngoài", "IP của service là gì" đều nằm ở đây. Và nó cũng là trục dễ bị coi là phép thuật nhất: docker run xong tự nhiên container có IP, có mạng, ping được nhau. Bài này tháo lớp phép thuật đó ra bằng cách đo từng thành phần, rồi dựng lại toàn bộ cơ chế bằng tay với năm lệnh ip để thấy Docker thật ra không làm gì khác.
Ngăn mạng riêng, cặp veth và cầu docker0

Hình 1: Network namespace cho container card mạng riêng, cặp veth nối sang host cắm vào docker0, và bảy phép đo.
Network namespace cho mỗi container một ngăn mạng độc lập: card mạng riêng, bảng định tuyến riêng, bảng iptables riêng, và không gian cổng riêng. Về mặt mạng, container hành xử như một máy riêng, nó có thể nghe cổng 8000 mà không đụng cổng 8000 của host hay của container khác. Vấn đề là một ngăn mạng rỗng thì không nối được đi đâu, nên Docker nối nó ra ngoài bằng một cặp veth, viết tắt của virtual ethernet, là hai đầu của một sợi dây ảo: gói đi vào đầu này thì ra ở đầu kia. Một đầu đặt trong container và đặt tên là eth0; đầu kia nằm trên host và được cắm vào một cầu nối ảo tên docker0. Cầu docker0 là bridge mặc định với subnet 172.17.0.0/16 và gateway 172.17.0.1, đóng vai một switch ảo nối mọi container lại với nhau và ra ngoài.
Có một chi tiết cho phép ghép cặp veth một cách chính xác. Khi xem eth0 trong container, tên nó hiện là eth0@ifN, trong đó N là chỉ số interface của đầu veth bên host; đọc /sys/class/net/eth0/iflink cho ra đúng số N đó, và tìm interface có ifindex bằng N trên host là ra đầu dây bên kia. Cách đo của bài gồm bảy bước: dựng hai container net-a và net-b rồi xem IP riêng của mỗi cái; đọc subnet và gateway của docker0; ghép cặp veth qua ifindex và iflink; ping giữa hai container để đo độ trễ; đọc bảng định tuyến trong container; thử port mapping bằng -p 49231:8000 rồi curl từ host; và cuối cùng, dựng lại một network namespace bằng tay để chứng minh Docker chỉ chạy đúng năm lệnh ip mà ai cũng gõ được.
Đo thật: 172.17.0.6 và .7, ping 0,09 ms, và netns tự dựng

Hình 2: Mỗi container một IP trên docker0, veth ghép cặp qua ifindex, ping giữa hai container, port mapping, và network namespace dựng bằng tay.
| phép đo | kết quả |
|---|---|
| IP eth0 của net-a / net-b | 172.17.0.6 / 172.17.0.7 (riêng mỗi container) |
| docker0 subnet / gateway | 172.17.0.0/16 / 172.17.0.1 |
| veth net-a: ifindex trong / iflink host | 11 / 798 (eth0@if798) |
| ping net-a → net-b | 3/3 gói, 0% mất, 0,094 ms |
| route mặc định trong container | via 172.17.0.1 dev eth0 |
| port mapping -p 49231:8000, curl host | trả về nội dung container |
| netns tự dựng: ns1 inode / container inode | 4026533781 ≠ 4026533650 |
Mỗi container có IP riêng, và đó là IP nội bộ chứ không phải địa chỉ thật. net-a nhận 172.17.0.6, net-b nhận 172.17.0.7, cả hai thuộc subnet 172.17.0.0/16 của docker0. Đây là dải địa chỉ riêng tư, chỉ có ý nghĩa trong mạng nội bộ của Docker trên host này; từ bên ngoài host, không ai nối tới 172.17.0.6 được. Đó là lý do một service nói "IP của tôi là 172.17.0.6" chỉ đúng với các container cùng host, và là lý do hai container trên hai máy khác nhau không tự ping nhau bằng IP nội bộ. Bảng định tuyến trong container xác nhận cấu trúc: mọi gói không thuộc 172.17.0.0/16 đi qua gateway 172.17.0.1, tức qua docker0, để ra ngoài.
eth0 trong container là một đầu của cặp veth, kiểm chứng được bằng ifindex. Tên đầy đủ của interface là eth0@if798, và /sys/class/net/eth0/iflink trả về 798: đầu dây bên kia là interface có ifindex 798 trên host, một veth được cắm vào docker0. Hai con số này, ifindex 11 bên trong và iflink 798 chỉ ra bên ngoài, là hai đầu của cùng một sợi dây ảo; mọi gói eth0 gửi đi xuất hiện ở veth 798 trên host, và ngược lại. Đây là cơ chế vật lý thật sự nối ngăn mạng cô lập của container với phần còn lại của hệ thống, không phải một lớp trừu tượng mơ hồ mà là một cặp interface có số hiệu đọc được.
Hai container cùng docker0 ping nhau trực tiếp, 0,094 ms. net-a ping net-b được ngay, 3 trên 3 gói, 0% mất, độ trễ 0,094 ms. Chúng thấy nhau vì cùng cắm vào một cầu docker0, cầu này chuyển khung Ethernet giữa các veth như một switch; gói từ net-a đi ra veth của nó, qua docker0, vào veth của net-b, lên eth0 của net-b. Độ trễ 0,094 ms phản ánh đây hoàn toàn là chuyển mạch trong bộ nhớ kernel, không có phần cứng mạng hay dây thật nào; đó cũng là lý do giao tiếp giữa các container cùng host rất nhanh, và vì sao đặt các service hay nói chuyện với nhau trên cùng host, hoặc cùng một Docker network tùy biến, có lợi về độ trễ.
Từ ngoài vào container phải khai port mapping, và Docker dịch bằng iptables. Chạy một container với -p 49231:8000 và một server HTTP nghe cổng 8000 bên trong: docker port cho biết 8000/tcp -> 0.0.0.0:49231, nghĩa là cổng 49231 trên host được ánh xạ vào cổng 8000 của container, và curl tới 127.0.0.1:49231 trên host trả về đúng nội dung container phục vụ. Container đó có IP nội bộ 172.17.0.8, không với tới được từ ngoài host, nhưng qua port mapping thì cổng 49231 của host trở thành cửa vào. Cơ chế đằng sau là một luật iptables DNAT trên host, dịch địa chỉ đích của gói từ host:49231 thành 172.17.0.8:8000. Điểm mấu chốt về vận hành: không có -p, cổng mà container nghe chỉ sống trong ngăn mạng của nó, và mọi lỗi "service chạy trong container mà không nối được từ ngoài" gần như luôn là quên khai port mapping hoặc map nhầm cổng.
Dựng lại toàn bộ bằng năm lệnh, không có gì bí ẩn. Trong một container đặc quyền, năm lệnh ip tái tạo đúng cái Docker làm: ip netns add ns1 tạo một network namespace rỗng; ip link add veth-h type veth peer name veth-c tạo một cặp veth; ip link set veth-c netns ns1 cắm một đầu vào namespace mới; rồi gán địa chỉ 10.99.0.1 cho đầu ở host và 10.99.0.2 cho đầu trong ns1, và bật cả hai lên. Kết quả: ns1 có một interface veth-c@if13 với IP 10.99.0.2/24, và host ping được 10.99.0.2 với độ trễ 0,040 ms. Đọc inode network namespace của ns1 cho 4026533781, khác với 4026533650 của container đang chạy nó: một ngăn mạng riêng thật, dựng bằng tay, giống hệt về bản chất với cái mỗi container Docker có. Khi đã thấy năm lệnh này, "Docker tạo mạng cho container" không còn là phép thuật mà là một quy trình đơn giản: một namespace, một cặp dây, một cây cầu.
Đánh đổi cần cân nhắc
Bridge mặc định tiện cho một host, nhưng dùng network tùy biến cho ứng dụng thật. docker0 nối mọi container nhưng không cho phân giải tên: trên bridge mặc định, container chỉ gọi nhau bằng IP, mà IP lại thay đổi mỗi lần tạo lại. Tạo một Docker network riêng bằng docker network create bật DNS nội bộ, cho container gọi nhau bằng tên, và cô lập chúng khỏi các container khác; đây là mặc định đúng cho mọi ứng dụng nhiều container, và là cái mà Docker Compose tự làm. Bridge mặc định chỉ nên dùng cho thử nghiệm nhanh.
Mạng container nhanh trong một host, nhưng qua nhiều host là chuyện khác. Độ trễ 0,094 ms chỉ đúng khi hai container cùng host và cùng bridge; khi chúng ở hai máy, gói phải đi qua mạng thật, qua lớp overlay network của orchestrator như Kubernetes, với độ trễ và chi phí cao hơn nhiều. Thiết kế đặt service hay nói chuyện gần nhau, và đo độ trễ liên-service thật trên hạ tầng thật, không giả định con số nội-host.
Phạm vi đo. Các số ở đây là của bridge mặc định trên một host Docker Desktop; chế độ --network host bỏ hẳn network namespace và container dùng chung mạng host, nhanh hơn nhưng mất cô lập; chế độ none cho ngăn mạng rỗng. Chưa đo overlay network nhiều host, chưa đo IPv6, và chưa đo chi phí của chính lớp iptables DNAT dưới tải cao, nơi số luật lớn có thể thành nút thắt, chủ đề thuộc về mạng của orchestrator hơn là của một container đơn lẻ.
Ba ý mang về
- Mạng container là ba thứ ghép lại: network namespace, cặp veth, cầu docker0. Mỗi container một IP nội bộ trên
172.17.0.0/16, eth0 là một đầu veth nối sang host, docker0 là switch ảo nối tất cả; ping nội-host 0,094 ms vì hoàn toàn trong kernel. - Từ ngoài vào phải khai
-p, Docker dịch bằng iptables DNAT. IP nội bộ không với tới từ ngoài host; mọi lỗi "service không nối được từ ngoài" thường là quên port mapping. - Dựng lại bằng năm lệnh
ipchứng minh không có phép thuật.ip netns addcộng cặp veth cộng gán IP là đủ tái tạo mạng của một container; hiểu điều này giúp gỡ lỗi mạng container bằng công cụ Linux thường, không cần đoán.
Nguồn
- Linux
network_namespaces(7)— cô lập mạng, veth, chuyển interface giữa namespace: https://man7.org/linux/man-pages/man7/network_namespaces.7.html - Linux
veth(4)— cặp virtual ethernet: https://man7.org/linux/man-pages/man4/veth.4.html - Linux
ip-netns(8)— quản lý network namespace bằng tay: https://man7.org/linux/man-pages/man8/ip-netns.8.html - Docker — Bridge network driver và docker0: https://docs.docker.com/engine/network/drivers/bridge/
- Docker — Networking overview, port publishing và DNAT: https://docs.docker.com/engine/network/
- Linux
iptables(8)— DNAT và chuỗi nat cho port mapping: https://man7.org/linux/man-pages/man8/iptables.8.html
Phần sau chuyển từ namespace, thứ cô lập cái container thấy, sang cgroups, thứ giới hạn cái container dùng. Bắt đầu với CPU: đặt --cpus=0.5 rồi cho một tiến trình ăn CPU hết sức, đo nó bị ghìm lại bao nhiêu qua cpu.stat và nr_throttled, và vì sao một app Java hay Go có thể chạy chậm bất ngờ trong container dù CPU host còn rảnh.