Phần 38 dựng router hai chân để nối net-frontend và net-backend, và kết luận container đứng trên hai mạng không tự thành router nếu không bật ip_forward. Bài này dùng lại đúng mô hình router hai chân đó, nhưng đổi câu hỏi: nếu hai mạng có MTU khác nhau, chuyện gì xảy ra với dữ liệu đi qua giữa chúng? Câu trả lời đo được không phải là lỗi rõ ràng — mà là treo im lặng, không một dòng thông báo.
Toàn bộ lệnh chạy trên Docker 29.7.2 (Docker Desktop, macOS, arm64), container nền alpine:3.20.10, iptables v1.8.10 (nf_tables).
Thử ngây thơ trước: hai mạng, hai MTU, không hề treo
Cách nghĩ đầu tiên ai cũng thử: dựng hai mạng Docker với --opt com.docker.network.driver.mtu khác nhau, đặt client và server mỗi bên một mạng.
$ docker network create --opt com.docker.network.driver.mtu=1500 net-wide
$ docker network create --opt com.docker.network.driver.mtu=1400 net-narrow
$ docker run -d --name server --network net-wide alpine:3.20 \
sh -c "dd if=/dev/urandom of=/www/big.bin bs=1M count=5 2>/dev/null && sleep infinity"
$ docker run -d --name client --network net-narrow alpine:3.20 sleep infinity
Nối hai mạng bằng router hai chân như Phần 38, bật ip_forward, thêm route tĩnh hai chiều, rồi tải file 5 MB từ server (MTU 1500) xuống client (MTU 1400):
$ docker exec client sh -c "time nc -w8 <ip-server> 6001 > /tmp/got.bin"
real 0m 0.00s
$ docker exec client ls -la /tmp/got.bin
-rw-r--r-- 1 root root 5242880 ... /tmp/got.bin
Đủ 5 MB, tức thì. Thử tắt hẳn GSO/TSO/GRO trên mọi interface liên quan (ethtool -K eth0 tso off gso off gro off) đề phòng offload che mất chuyện gì đó — vẫn không đổi, vẫn 5 MB tức thì. Chỗ đặt hai MTU khác nhau trực tiếp lên hai đầu không tái hiện được cú treo.
Lý do, sau khi soát lại bằng tcpdump phần bắt tay: mỗi bên quảng cáo MSS dựa trên MTU của chính interface mình trong gói SYN. Client có MTU 1400 thì SYN của nó tự mang mss 1360; server nhận SYN đó là phải tuân theo — mọi segment nó gửi ra vốn đã nằm dưới 1400 byte ngay từ gói đầu tiên, không cần Path MTU Discovery (PMTUD) can thiệp. Đây chính là lý do MTU khác nhau ở hai đầu kết nối hiếm khi gây sự cố: cơ chế thương lượng MSS lúc bắt tay tự lo được. Sự cố thật xảy ra khi chỗ hẹp nằm ở giữa đường đi — một tunnel VPN, một overlay VXLAN — mà cả hai đầu vẫn báo MTU 1500 như bình thường, không đầu nào biết đường ở giữa hẹp hơn.
Dựng lại đúng cơ chế: MTU hẹp nằm giữa đường, không nằm ở hai đầu
Lần này cả hai mạng cùng MTU 1500 — giống thật hơn, vì VPN hay overlay không đổi MTU trên interface eth0 của container, chúng chỉ ăn bớt khoảng trống mang được trên đường đi.
$ docker network create --opt com.docker.network.driver.mtu=1500 net-wide
$ docker network create --opt com.docker.network.driver.mtu=1500 net-b
$ docker run -d --name router --cap-add=NET_ADMIN \
--sysctl net.ipv4.ip_forward=1 --network net-wide alpine:3.20 sleep infinity
$ docker network connect net-b router
$ docker run -d --name server --cap-add=NET_ADMIN --network net-b alpine:3.20 \
sh -c "dd if=/dev/urandom of=/www/big.bin bs=1M count=5 2>/dev/null && \
dd if=/dev/urandom of=/www/small.bin bs=1k count=1 2>/dev/null && sleep infinity"
$ docker run -d --name client --cap-add=NET_ADMIN --network net-wide alpine:3.20 sleep infinity
$ docker exec client ip route add 172.20.0.0/16 via 172.19.0.2 # subnet net-b qua router
$ docker exec server ip route add 172.19.0.0/16 via 172.20.0.2 # subnet net-wide qua router
--sysctl net.ipv4.ip_forward=1 phải khai lúc docker run, không sửa được lúc container đã chạy — sysctl -w bên trong container gặp thẳng Read-only file system vì Docker mount /proc/sys/net chỉ đọc trừ khi bạn khai --sysctl từ đầu. Đổi route cũng cần NET_ADMIN, thiếu quyền này ip route add báo Operation not permitted.
Xác nhận đường đi thông và MTU hai đầu đều 1500:
$ docker exec client ping -c2 -W2 <ip-server>
2 packets transmitted, 2 received, 0% packet loss
$ docker exec client ip link show eth0 | grep mtu
mtu 1500
$ docker exec server ip link show eth0 | grep mtu
mtu 1500
Baseline — tải file 5 MB, chưa can thiệp gì: tức thì, đủ byte, y hệt lần thử ngây thơ ở trên. Giờ mới mô phỏng "đường ở giữa hẹp": trên router, rớt thẳng mọi gói forward lớn hơn 1400 byte, không gửi ICMP — đúng kiểu một tường lửa/security group chặn ICMP loại 3 mã 4 (Fragmentation Needed) mà rất nhiều VPC cloud làm mặc định:
$ docker exec router iptables -A FORWARD -m length --length 1401:65535 -j DROP
Gói nhỏ qua trót lọt, gói lớn treo — đo bằng chính con số
$ docker exec client sh -c "time nc -w8 <ip-server> 6003 > /tmp/small.bin" # file 1 KB
real 0m 0.00s
$ docker exec client ls -la /tmp/small.bin
-rw-r--r-- 1 root root 1024 ... /tmp/small.bin
File 1 KB qua ngay, không khác gì baseline — mọi segment của nó vốn đã nhỏ hơn 1400 byte. Giờ đến file 5 MB, đường đi giống hệt, chỉ khác kích thước:
$ docker exec client sh -c "time nc -w8 <ip-server> 6002 > /tmp/blocked.bin"
real 0m 16.03s
$ docker exec client ls -la /tmp/blocked.bin
-rw-r--r-- 1 root root 0 ... /tmp/blocked.bin
0 byte, sau 16 giây chờ. Không có "connection refused", không có "network unreachable" — nc chỉ ngồi im tới khi TCP tự bỏ cuộc. Dùng curl cho gần với tình huống thật hơn:
$ docker exec client curl -sS --max-time 6 -o /tmp/x.bin http://<ip-server>:6020/
curl: (28) Operation timed out after 6010 milliseconds with 0 bytes received
curl cũng chỉ báo hết giờ (exit code 28) — không manh mối gì cho biết đây là chuyện MTU chứ không phải server chết hay mạng đứt. Đây chính là phần "khó hiểu" của tiêu đề bài: kết nối TCP bắt tay thành công, ping vẫn chạy tốt, chỉ riêng dữ liệu thật là không bao giờ tới.
Bắt được dấu vết bằng tcpdump: không phải rớt sạch, mà rớt gần hết
Bật lại kết nối, bắt gói trên client trong lúc treo:
$ docker exec client tcpdump -i eth0 -n -tt 'tcp port 6010'
... Flags [S], seq 1930108400, options [mss 1460, ...], length 0
... Flags [S.], seq 2915118985, options [mss 1460, ...], length 0
... Flags [.], ack 1, length 0 # bắt tay xong bình thường
... Flags [P.], seq 7241:8193, ..., length 952 # LỌT một segment nhỏ, muộn
... Flags [.], ack 1, ..., sack 1 {7241:8193}, length 0 # SACK báo "có mảnh này,
# còn thiếu 1..7240"
Điều bất ngờ: đây không phải black hole tuyệt đối. MSS bắt tay là 1460 (cả hai vẫn báo MTU 1500 nên không đầu nào tự hạ), server gửi các segment cỡ ~1460 byte và gần như toàn bộ bị router rớt — nhưng có lúc server gửi một segment nhỏ hơn ngưỡng (952 byte, do cửa sổ nghẽn hoặc do ghi lẻ), lọt qua trót lọt. Client SACK báo đã nhận byte 7241–8193 nhưng vẫn thiếu 1–7240. Đây đúng là kiểu dấu vết hay gặp trong sự cố thật: không phải mất kết nối hẳn, mà là một luồng SACK lặp đi lặp lại không bao giờ lấp đầy khoảng trống — nhìn tcpdump không có lỗi, chỉ có cùng một khoảng SEQ được nhắc đi nhắc lại.
Dò ra ranh giới thật bằng ping -M do
Cách nhanh nhất để xác nhận nghi ngờ "có phải MTU không" mà không cần tcpdump: ping với cờ Don't-Fragment và dò kích thước.
$ for s in 1360 1372 1373 1380 1400 1450; do
docker exec client ping -c1 -W2 -M do -s $s <ip-server> >/dev/null \
&& echo "size=$s (gói $((s+28)) byte): OK" \
|| echo "size=$s (gói $((s+28)) byte): MẤT"
done
size=1360 (gói 1388 byte): OK
size=1372 (gói 1400 byte): OK
size=1373 (gói 1401 byte): MẤT
size=1380 (gói 1408 byte): MẤT
size=1400 (gói 1428 byte): MẤT
size=1450 (gói 1478 byte): MẤT
Ranh giới rơi đúng vào 1400 byte tổng gói IP — khớp chính xác với luật length 1401: đã đặt trên router. -s của ping là phần payload ICMP, cộng 8 byte header ICMP và 20 byte header IP mới ra tổng; nhớ cộng đủ 28 byte khi so với con số MTU/giới hạn đang nghi ngờ, không thì dò lệch mất vài chục byte oan uổng.
Vá bằng TCPMSS clamp — không sửa được đường ở giữa thì ép hai đầu nói nhỏ lại
Không đụng gì tới luật rớt gói trên router (giả lập một đường truyền thật không tự sửa được), chỉ chèn thêm một luật ép MSS lúc bắt tay:
$ docker exec router iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,SYN SYN \
-j TCPMSS --set-mss 1360
Luật này viết đè giá trị MSS trong gói SYN đi qua router, buộc cả hai đầu thương lượng segment nhỏ hơn 1400 byte ngay từ đầu — giống hệt cơ chế đã cứu lần thử ngây thơ ở phần đầu bài, chỉ khác là giờ ép bằng tay thay vì tự nhiên đến từ MTU cấu hình sẵn trên interface.
$ docker exec client sh -c "time nc -w8 <ip-server> 6004 > /tmp/fixed.bin"
$ docker exec client ls -la /tmp/fixed.bin
-rw-r--r-- 1 root root 5242880 ... /tmp/fixed.bin
Đủ 5 MB trở lại, dù luật rớt gói length 1401: vẫn còn nguyên trên router. Đây chính là cách sửa dùng trong thực tế khi không kiểm soát được MTU của đường truyền giữa (VPN của bên thứ ba, overlay của nhà cung cấp cloud): TCPMSS --clamp-mss-to-pmtu (tự tính theo MTU thật của interface ra) hoặc --set-mss <giá-trị> (ép cứng khi biết chính xác ngưỡng, như bài này đo được là 1360) chèn trên router/gateway biên, không cần sửa gì ở client lẫn server.
Ba nơi thật hay gặp đúng cơ chế này
- VPN/tunnel: WireGuard thường MTU ~1420, OpenVPN ~1400–1450 — thấp hơn 1500 Ethernet chuẩn, nhưng hai máy ở hai đầu tunnel vẫn thấy
eth0của mình là 1500 như thường. - Overlay VXLAN của Docker Swarm: đóng gói thêm 50 byte, container trên overlay tự động được đặt MTU 1450. Underlay hẹp hơn 1500 sẵn (VPC hạn chế, một chặng PPPoE) thì phép trừ 50 byte mặc định vẫn không đủ.
- Security group cloud chặn ICMP: nhiều VPC mặc định drop toàn bộ ICMP, kể cả loại 3 mã 4 — tắt hẳn feedback mà PMTUD cần, y hệt luật
DROPkhông kèm ICMP trong bài này.
Tổng kết
Đo được ba điều, cả ba đều đi ngược trực giác ban đầu: đặt MTU khác nhau trực tiếp lên hai đầu kết nối không tái hiện được cú treo — MSS thương lượng lúc bắt tay tự lo được, phải đưa chỗ hẹp ra giữa đường mới đúng cơ chế thật. Cú treo không phải mất kết nối tuyệt đối — tcpdump cho thấy một luồng SACK lặp lại không lấp đầy, không phải im lặng hoàn toàn. Và sửa không cần đụng tới đường truyền hẹp: một luật TCPMSS clamp trên gateway biên là đủ, đo được đủ 5 MB trở lại dù luật rớt gói vẫn còn nguyên.
Thử ba mươi giây
Đã có container client và server với route qua router như bài dựng ở trên (còn nguyên luật length 1401:65535 DROP), dò ranh giới MTU thật của một đường truyền nghi ngờ chỉ bằng một dòng:
$ docker exec client sh -c \
'for s in 1500 1472 1400 1300 1200; do
ping -c1 -W1 -M do -s $((s-28)) <ip-đich> >/dev/null 2>&1 \
&& echo "$s byte: OK" || echo "$s byte: MẤT"
done'
Byte nhỏ nhất báo "MẤT" trừ đi khoảng lệch nhị phân giữa hai mốc gần nhất chính là nơi cần đặt --set-mss hoặc --opt com.docker.network.driver.mtu cho đúng.