Mỗi liên kết mạng có một trần: gói lớn nhất nó cho đi qua trong một lần, gọi là MTU (Maximum Transmission Unit). Trên Ethernet, con số quen thuộc là 1500 byte. Nhưng ứng dụng của bạn thường muốn gửi nhiều hơn thế — một datagram UDP 4000 byte, một khối dữ liệu lớn. Chuyện gì xảy ra khi cái bạn gửi vượt MTU? Bài này dựng một đường mạng thật trong container rồi ép nó vượt trần, đo từng byte của kết quả.
Ngưỡng chính xác, đo bằng cờ DF
Có hai cách hệ thống phản ứng khi gói vượt MTU: cắt nhỏ (phân mảnh), hoặc từ chối nếu gói mang cờ DF (Don't Fragment). Tôi bắt đầu bằng cách thứ hai vì nó cho một ngưỡng sắc như dao. Tạo một cặp veth MTU 1500, rồi ping với cờ DF và tăng dần kích thước:
ip link add veth0 type veth peer name veth1
ip link set veth0 mtu 1500
ping -c1 -M do -s 1472 10.10.0.2 # -M do = đặt cờ DF
ping -c1 -M do -s 1473 10.10.0.2
Kết quả rạch ròi:
ping -M do -s 1472 -> 1480 bytes from 10.10.0.2 ... time=0.036 ms
ping -M do -s 1473 -> ping: sendmsg: Message too long
Ngưỡng nằm đúng ở 1472 byte dữ liệu. Vì sao không phải 1500? Vì gói còn phải mang header: 20 byte IP cộng 8 byte ICMP là 28 byte cố định. 1500 − 28 = 1472. Vượt thêm đúng một byte, và nhân từ chối gửi ngay tại nguồn với lỗi Message too long — nó không thử, không cắt, không đẩy ra dây. Đây là hành vi của DF: thà báo lỗi còn hơn phân mảnh. Con số 28 byte header ở đây chính là phần đã đo kỹ trong bài về các tầng header — nó quay lại quyết định ngưỡng MTU.
Phân mảnh, nhìn từ trên dây
Bỏ cờ DF đi và gửi một gói lớn hơn MTU, nhân sẽ tự cắt nó thành nhiều mảnh IP. Tôi ping 2000 byte (không DF) và bắt gói bằng tcpdump ở đầu nhận:
id=63307 offset=0 flags=[+] length=1500 (mang 1480 byte)
id=63307 offset=1480 flags=[none] length= 548 (mang 528 byte)
Một gói ICMP 2008 byte (2000 dữ liệu + 8 header) biến thành hai mảnh đi riêng trên dây. Ba chi tiết đáng chú ý:
- Cùng
id= 63307: cả hai mảnh mang chung một số nhận dạng để đầu kia biết chúng thuộc về một gói gốc và ghép lại đúng. offset: mảnh đầu ở offset 0, mảnh sau ở offset 1480 — cho biết mỗi mảnh nằm ở đâu trong gói gốc. Reassembly là xếp các mảnh theo offset.- Cờ
[+]trên mảnh đầu nghĩa là "còn mảnh nữa phía sau" (More Fragments); mảnh cuối mang cờ[none]để đánh dấu hết.
Cộng lại: 1480 + 528 = 2008, khớp chính xác với gói gốc. Chú ý mảnh đầu chỉ chở 1480 byte chứ không phải 1500 — vì 20 byte đầu của nó là header IP của chính mảnh đó. Mỗi mảnh là một gói IP hoàn chỉnh, nên mỗi mảnh gánh riêng một header IP. Phân mảnh không miễn phí: nó nhân thêm header.
MTU là của từng liên kết, không cố định
Một hiểu nhầm phổ biến là "MTU của mạng là 1500". Không có "MTU của mạng" — chỉ có MTU của từng liên kết, và một gói đi qua nhiều liên kết phải lọt qua cái nhỏ nhất. Tôi chứng minh bằng cách hạ MTU của veth xuống 1000 rồi hỏi lại ngưỡng:
MTU = 1000:
ping -M do -s 1472 -> Message too long (trước đó vừa khít với 1500)
ping -M do -s 972 -> OK (972 = 1000 - 28)
Cùng một gói -s 1472 vừa lọt qua liên kết 1500 giờ bị chặn ở liên kết 1000. Ngưỡng dịch chính xác theo MTU mới: 1000 − 28 = 972. Đây là lý do tồn tại của Path MTU Discovery: máy gửi dùng chính cờ DF để dò ra MTU nhỏ nhất dọc đường — nó gửi gói lớn có DF, nếu một router giữa đường có liên kết nhỏ hơn, router đó trả về một thông báo ICMP "cần phân mảnh nhưng DF đã đặt" kèm MTU của nó, và máy gửi hạ kích thước xuống cho vừa. MTU thật của một đường truyền là cái nhỏ nhất trong tất cả các chặng, không phải cái ở hai đầu.
Phân mảnh khuếch đại mất gói
Đây là phần quan trọng nhất và cũng ngược trực giác nhất. Một datagram bị cắt thành nhiều mảnh chỉ được đầu kia ghép lại nếu mọi mảnh đều tới. Mất một mảnh là hỏng cả datagram — và không có cơ chế gửi lại riêng từng mảnh ở tầng IP. Vậy nếu đường truyền rơi 10% số gói, một datagram nhiều mảnh sẽ mất nhiều hơn 10% bao nhiêu?
Tôi đo trực tiếp. Đặt netem làm rơi 10% gói trên đường đi, rồi gửi 300 datagram UDP ở ba cỡ khác nhau, đếm số datagram được ghép lại trọn vẹn ở đầu nhận. Lấy trung vị của ba lần chạy:
Cỡ datagram Số mảnh IP Datagram tới (/300) Mất thực tế
1000 byte 1 mảnh ~270 ~10%
4000 byte 3 mảnh ~229 ~24%
7000 byte 5 mảnh ~190 ~37%
Datagram 1000 byte gói gọn trong một mảnh (1008 byte < 1480), nên nó mất đúng ở tỷ lệ gốc ~10% — đây là mẫu đối chứng. Nhưng datagram 4000 byte cắt thành 3 mảnh mất tới ~24%, và 7000 byte cắt thành 5 mảnh mất ~37%. Con số khớp với phép tính xác suất: một datagram sống sót chỉ khi tất cả mảnh của nó sống sót, tức xác suất (1 − 0,1) lũy thừa số-mảnh: 0,9^3 = 73% và 0,9^5 = 59% tương ứng với ~27% và ~41% mất — cùng hình dạng với số đo được. Càng nhiều mảnh, một tỷ lệ rơi nhỏ càng bị khuếch đại thành một tỷ lệ hỏng lớn. Đây là lý do thật sự vì sao người ta sợ phân mảnh, chứ không chỉ vì nó tốn thêm header.
Một lần tôi đặt sai chỗ đo
Lần đầu chạy phép đo khuếch đại này, tôi gắn netem nhầm chỗ — lên veth1, tức đầu của máy chủ nhận. Kết quả làm tôi bối rối:
netem trên veth1:
size=1000 -> 300/300
size=4000 -> 300/300
size=7000 -> 300/300
Không mất một datagram nào, ở mọi cỡ. Suýt nữa tôi kết luận "phân mảnh chẳng khuếch đại gì cả, số học ở trên chỉ là lý thuyết suông". May là con số quá đẹp — 300/300 tuyệt đối — khiến tôi nghi. Vấn đề: netem là kỷ luật hàng đợi ở chiều ra (egress) của một giao diện. Luồng UDP của tôi đi một chiều từ khách (veth0) sang chủ (veth1); gắn netem lên veth1 chỉ làm rơi gói ở chiều máy chủ gửi ra — mà máy chủ của tôi không gửi gì về, nên chẳng có gói nào để rơi. Các mảnh đi tới đều nguyên vẹn.
Cách sửa là gắn netem lên veth0, tức chiều ra của máy gửi — nơi các mảnh thật sự rời đi. Chuyển xong, con số khuếch đại 24% và 37% mới hiện ra. Bài học đo lường: khi làm rơi gói bằng tc, phải biết chính xác luồng đi hướng nào và gắn kỷ luật vào đúng giao diện chiều-ra trên hướng đó — nếu không bạn đang làm nhiễu một con đường mà lưu lượng không hề đi qua, và một kết quả "hoàn hảo" 300/300 chính là dấu hiệu bạn đã đo nhầm chỗ.
Vì sao điều này quan trọng khi lập trình
Ba hệ quả rất cụ thể. Thứ nhất, TCP gần như không bao giờ phân mảnh, và đó là một tính năng: khi bắt tay, hai bên khai báo MSS (Maximum Segment Size) đúng bằng MTU trừ header, nên TCP tự cắt dữ liệu thành segment vừa vặn từ đầu. Đây là một lý do nữa để dùng TCP cho dữ liệu lớn thay vì tự gói vào UDP — bạn tránh được cả cơ chế khuếch đại mất mát vừa đo ở trên. Với UDP, gánh nặng đó rơi lên bạn.
Thứ hai, đường hầm và VPN hạ MTU. Một gói WireGuard hay IPsec bọc thêm header của chính nó, nên MTU khả dụng cho gói bên trong tụt xuống dưới 1500 (thường còn ~1420). Nếu ứng dụng vẫn tưởng MTU là 1500 và Path MTU Discovery bị chặn (nhiều tường lửa chặn ICMP một cách thiếu suy nghĩ), gói lớn có DF sẽ bị âm thầm rơi — triệu chứng kinh điển là "SSH kết nối được nhưng treo khi in ra nhiều dòng", hay "trang web tải được phần đầu rồi đứng". Đó là hố đen PMTU: máy gửi tưởng gói đi được, ICMP báo lỗi thì bị chặn, nên nó cứ gửi lại gói quá khổ mãi.
Thứ ba, DNS trên UDP là nạn nhân trực tiếp: một phản hồi DNSSEC lớn có thể vượt MTU, bị phân mảnh, rồi mất một mảnh và hỏng cả câu trả lời — chính xác cái khuếch đại đo ở trên. Đây là động lực đằng sau việc DNS chuyển sang TCP khi phản hồi lớn, và đằng sau các giới hạn kích thước EDNS0.
Con số mang theo: vượt MTU thì gói hoặc bị cắt thành mảnh, hoặc bị chặn thẳng nếu đặt DF; MTU là của từng liên kết và đường truyền chỉ nhanh bằng chặng có MTU nhỏ nhất; và mỗi mảnh thêm vào là một cơ hội rơi nữa, biến một tỷ lệ mất nhỏ thành một tỷ lệ hỏng lớn.
Thử ba mươi giây
Tìm Path MTU tới một đích thật bằng chính cờ DF: chạy ping -M do -s 1472 8.8.8.8. Nếu qua, thử tăng dần; nếu gặp Message too long hoặc mất gói im lặng, giảm -s xuống cho tới khi thông. Số lớn nhất còn thông, cộng 28, chính là Path MTU của đường ra Internet của bạn — và nếu nó nhỏ hơn 1500, bạn đang đi qua một đường hầm hay một liên kết nào đó đã hạ trần mà bạn không hề hay.