Suốt sê-ri này, mọi phép đo mạng đều chạy trong các network namespace (vùng mạng) — công cụ cho phép tôi dựng client, router, server tách biệt ngay trên một máy. Nó cũng chính là nền tảng của container: mỗi container Docker là một network namespace riêng. Lần này tôi quay ống kính vào chính công cụ đó, đo trực tiếp sự cô lập mà nó đem lại: hai vùng mạng tách nhau tới đâu, và ranh giới đó rò rỉ ở chỗ nào. Kết quả có một con số làm tôi giật mình trong nửa giây, và một sự thật về "danh tính" của một cổng mà tôi từng hiểu sai.

Cô lập mạng

Network namespace tách ra những gì

Một network namespace là một ngăn xếp mạng độc lập bên trong cùng một nhân Linux. Mỗi vùng có riêng: danh sách giao diện mạng, bảng định tuyến, loopback 127.0.0.1, không gian cổng (port), và bộ quy tắc tường lửa. Hai tiến trình ở hai vùng khác nhau nhìn thấy hai "mạng" hoàn toàn khác — dù chúng chạy trên cùng một máy, cùng một nhân. Đây là lý do một container tưởng như có cả một máy mạng riêng cho nó.

Điều quan trọng: namespace chỉ cô lập mạng. Tiến trình vẫn chia sẻ CPU, và (trừ khi kết hợp thêm các namespace khác) vẫn có thể chung hệ tập tin, danh sách tiến trình. Ở đây tôi chỉ đo phần mạng: hai vùng tách nhau ra sao, và nối lại thế nào.

Đo: bốn tính chất cô lập

Tôi tạo hai vùng AB trong container rồi đo bốn thứ.

Một — một vùng mới sinh ra là gì. Vùng A vừa tạo chỉ có lo (loopback, và ở trạng thái DOWN) cùng vài thiết bị tunnel cụt. ping 8.8.8.8 từ trong A trả về Network unreachable — không có tuyến, không ra được đâu. Một vùng mới bị bịt kín hoàn toàn cho tới khi bạn cắm cho nó một veth.

Hai — loopback là riêng. Tôi chạy một dịch vụ trên 127.0.0.1:9000 bên trong A:

Từ chính A gọi 127.0.0.1:9000  -> "toi-la-A"   (được)
Từ B      gọi 127.0.0.1:9000  -> TỪ CHỐI
Từ host   gọi 127.0.0.1:9000  -> TỪ CHỐI

127.0.0.1 của A, 127.0.0.1 của B, và 127.0.0.1 của host là ba loopback khác nhau. "localhost" không phải một thứ toàn cục — nó cục bộ theo từng vùng.

Ba — không gian cổng là riêng. Đây là phép đắt giá nhất. Tôi cho AB cùng bind 0.0.0.0:8080:

A bind :8080 -> OK
B bind :8080 -> OK          (cùng số cổng, không hề xung đột!)
B gọi sang A (10.1.0.1:8080) -> "server-trong-A"
A gọi sang B (10.1.0.2:8080) -> "server-trong-B"

Hai server sống cùng lúc trên cùng một số cổng 8080, mỗi cái phục vụ đúng vùng của nó. Nhưng khi tôi bind :8080 lần hai trong cùng vùng A:

OSError: [Errno 98] Address already in use

Cùng vùng thì đụng; khác vùng thì không.

Bốn — cô lập tốn gần như không gì. Ping loopback trong A cho trung vị 0,044 ms; ping từ A sang B qua veth cho 0,059 ms. Vượt ranh giới hai vùng chỉ thêm ~0,015 ms — cô lập gần như miễn phí về độ trễ.

Một lần tôi đo hớ: dãy thiết bị lạ trong vùng "trống"

Khi liệt kê giao diện của vùng A vừa tạo, tôi chờ thấy đúng một dòng: lo. Sách vở luôn nói "một netns mới chỉ có loopback". Nhưng lệnh trả về cả một dãy: lo, tunl0, gre0, gretap0, erspan0, sit0, ip6tnl0, ip6gre0... Phản xạ đầu tiên của tôi là hoảng: "cô lập bị rò à? Sao thiết bị của host lại lọt vào vùng mới?".

Tôi dừng lại kiểm tra thay vì kết luận. Tất cả những thiết bị đó đều ở trạng thái DOWN, không có địa chỉ nào, và quan trọng nhất, ping ra ngoài vẫn Network unreachable. Chúng không phải thiết bị của host lọt vào — chúng là các thiết bị tunnel "cụt" mà nhân tự tạo sẵn cho mỗi namespace khi các module tunnel được nạp. Chúng trơ, không nối đi đâu.

Bài học đo lường: phép thử cô lập không phải "có những giao diện nào" mà là "với tới được gì". Nếu tôi tin vào cái danh sách giao diện và kết luận "namespace rò rỉ", tôi đã sai hoàn toàn — trong khi phép thử đúng (thử reach ra ngoài) cho câu trả lời rõ ràng: không tới được gì cả. Một danh sách tài nguyên trông "nhiều" không có nghĩa là ranh giới bị thủng; phải kiểm bằng hành vi thật, không bằng vẻ ngoài.

Danh tính thật của một socket nghe

Phép đo thứ ba chạm vào một hiểu lầm nền tảng mà tôi từng mắc. Trước đây, khi hai server của tôi tình cờ dùng chung cổng 8080 và cái thứ hai chết vì "Address already in use", tôi mặc định trong đầu rằng "cổng 8080" là một tài nguyên toàn cục của cả máy — dùng rồi thì không ai dùng được nữa. Đó là lý do khi ấy tôi phải tách mỗi thứ ra một container riêng.

Phép đo này cho thấy mô hình đó sai. Danh tính của một socket nghe không phải số cổng, mà là bộ ba (network namespace, địa chỉ IP, cổng). AB cùng giữ 0.0.0.0:8080 mà không đụng nhau vì chúng ở hai namespace khác nhau — hai bộ ba khác nhau. Chỉ khi bind lại :8080 trong cùng một namespace, bộ ba mới trùng và nhân từ chối bằng Errno 98. "Cổng đã bị chiếm" là một mệnh đề chỉ đúng trong phạm vi một namespace, không phải toàn máy.

Đây chính xác là cơ chế cho phép hàng trăm container trên một máy chủ, mỗi cái chạy một web server trên cổng 80, mà không cái nào giẫm chân cái nào: mỗi container một namespace, mỗi namespace một không gian cổng riêng. Cổng 80 của container này khác cổng 80 của container kia.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là hiểu container mạng thật sự là gì. Khi bạn docker run -p 8080:80, Docker tạo một network namespace cho container (nơi ứng dụng bind cổng 80 trong không gian cổng riêng của nó), rồi dựng một veth cùng quy tắc NAT/chuyển tiếp để nối cổng 8080 của host vào cổng 80 trong vùng. Mọi mảnh trong câu đó — namespace, veth, NAT — đều là thứ sê-ri này đã đo tay. "Container mạng" không phải phép màu; nó là netns + veth + iptables ghép lại, và giờ bạn đo được từng mảnh.

Hệ quả thứ hai là gỡ lỗi mạng container đúng chỗ. Khi một dịch vụ trong container "không kết nối được", câu hỏi đầu tiên là ở namespace nào. Một curl 127.0.0.1:9000 chạy trên host sẽ không thấy dịch vụ bind 127.0.0.1:9000 bên trong container — vì hai loopback khác nhau, đúng như phép đo hai cho thấy. Rất nhiều giờ gỡ lỗi "cổng mở mà không vào được" tan biến khi bạn nhớ ra localhost là cục bộ theo vùng: dịch vụ phải bind 0.0.0.0 (không phải 127.0.0.1) mới nhận được kết nối đi qua veth từ ngoài vào.

Hệ quả thứ ba là một cách nhìn chung: cô lập là một tài nguyên rẻ và có thể dựng bằng tay. Phép đo bốn cho thấy vượt ranh giới namespace chỉ tốn ~0,015 ms — gần như miễn phí. Bạn không cần Docker để có mạng cô lập; ip netns cùng vài lệnh là đủ dựng một phòng thí nghiệm mạng nhiều nút ngay trên máy, đúng như cả sê-ri này đã làm. Con số mang theo: mỗi network namespace là một ngăn xếp mạng riêng — loopback riêng, không gian cổng riêng — nên hai vùng cùng giữ cổng 8080 mà không xung đột (danh tính socket là bộ ba vùng+IP+cổng, không phải số cổng), và ranh giới đó gần như miễn phí. Hiểu nó là hiểu tận gốc mọi thứ chạy trong container.

Thử ba mươi giây

Nếu có một máy Linux (hay WSL), thử dựng một vùng mạng chỉ bằng ba lệnh: sudo ip netns add thu, rồi sudo ip netns exec thu ip addr (thấy chỉ có lo DOWN — vùng trống), rồi sudo ip netns exec thu ping 8.8.8.8 (thấy Network unreachable — bị bịt kín). Ba lệnh đó cho bạn tận tay cái cô lập mà bài này đo. Muốn nối nó ra, thêm một cặp veth và gán IP — chính là bước mọi container làm ngầm cho bạn. Còn nếu đang dùng Docker: chạy hai container, mỗi cái một web server trên cổng 80, và để ý cả hai cùng sống — đó là hai namespace, hai không gian cổng, cùng một máy, không hề đụng nhau.