Ở phần trước ta thấy subnet mask quyết định một đích là "cùng mạng con" (trên-link) hay không. Nhưng khi đích ở trên-link, máy vẫn chưa gửi được ngay: nó biết địa chỉ IP 10.10.0.2, còn khung Ethernet trên dây lại cần địa chỉ MAC của máy kia. Bước dịch từ IP (tầng 3) sang MAC (tầng 2) đó chính là ARP. Bài này dựng hai máy ảo trong container rồi bắt đúng một vòng ARP, đo xem nó tốn bao nhiêu.
Một vòng ARP nhìn từ trên dây
Tôi tạo một cặp veth, đặt hai đầu vào cùng mạng con 10.10.0.0/24, rồi xóa sạch bảng hàng xóm và bắt gói ARP trong lúc gửi một ping:
ip link add veth0 type veth peer name veth1
ip addr add 10.10.0.1/24 dev veth0 # máy A
# veth1 ở netns khác, 10.10.0.2/24 # máy B
tcpdump -i veth0 arp -w a.pcap
ip neigh flush all && ping -c1 10.10.0.2
Bản bắt cho đúng hai gói, và đó là toàn bộ giao thức:
- Yêu cầu:
ai-là 10.10.0.2? báo 10.10.0.1. Địa chỉ đích của khung làff:ff:ff:ff:ff:ff— quảng bá, gửi tới mọi máy trong mạng con. Máy A chưa biết ai giữ10.10.0.2nên phải hỏi to cho cả phòng nghe. - Trả lời:
10.10.0.2 ở-tại 0a:49:88:d7:39:80. Lần này là gửi riêng (unicast) thẳng về máy A. Máy B nghe thấy tên mình được gọi, nó biết A là ai (từ chính gói hỏi), nên trả lời riêng chứ không cần quảng bá lại.
Cả hai khung đều dài 42 byte: 14 byte header Ethernet cộng 28 byte thân ARP. Đáng chú ý là trên veth nó không được đệm lên mức tối thiểu 60 byte của Ethernet vật lý — một chi tiết mà card mạng thật sẽ làm còn veth thì bỏ qua.
Một vòng "hỏi to, đáp riêng" đó là tất cả những gì ARP làm. Không có số thứ tự, không bắt tay, không mã hóa. Ai đang lắng nghe và giữ đúng IP đó thì trả lời.
Cái giá: trả một lần rồi cache
Vòng ARP không miễn phí, nhưng nó chỉ xảy ra một lần cho mỗi hàng xóm. Để đo, tôi so thời gian mở một kết nối TCP khi bảng ARP lạnh (vừa xóa, buộc phải phân giải) với khi nó ấm (đã có sẵn trong cache). Tôi lấy trung vị của 40 lần ấm và 8 lần lạnh, lặp ba vòng:
| connect ấm (cache) | connect lạnh (có ARP) | ARP thêm | |
|---|---|---|---|
| Lần 1 | 8,0 us | 189,5 us | 181,5 us |
| Lần 2 | 10,9 us | 221,9 us | 211,0 us |
| Lần 3 | 6,6 us | 197,2 us | 190,6 us |
Con số ổn định: một vòng ARP thêm khoảng 190 micro giây, tức gấp chừng 20 lần bản thân việc mở kết nối khi đã có cache. Nhưng cái giá đó chỉ trả một lần. Gói đầu tiên tới một hàng xóm mới phải chờ ARP xong; mọi gói sau đó đi thẳng nhờ địa chỉ MAC đã nằm trong cache. Đây là lý do gói đầu tiên của một kết nối tới một máy chưa từng nói chuyện luôn chậm hơn những gói tiếp theo một chút — và trên đường truyền xa, "một chút" đó có thể là cả một vòng khứ hồi mạng thật thay vì 190 micro giây trong phòng thí nghiệm.
Máy trạng thái của một hàng xóm
Cache ARP không chỉ là "có" hay "không". ip neigh cho thấy một máy trạng thái nhỏ. Tôi xóa rồi theo dõi:
ngay sau flush -> (trống)
gói đầu gửi đi -> INCOMPLETE (đã hỏi, đang chờ trả lời)
nhận được reply -> REACHABLE (kèm lladdr 96:e4:46:c6:f2:4e)
im một lúc -> STALE (còn dùng nhưng sẽ kiểm lại)
Trạng thái REACHABLE là lúc mọi thứ trơn tru: máy tin rằng địa chỉ MAC còn đúng và gửi thẳng. Sau một khoảng im lặng nó chuyển sang STALE — vẫn dùng địa chỉ cũ nhưng sẽ xác nhận lại ở lần sau. Cơ chế này là cách mạng chịu được việc một máy đổi card, đổi IP, hoặc biến mất.
Điều gì xảy ra khi hỏi một địa chỉ không có ai giữ? Tôi ping 10.10.0.99:
1 packets transmitted, 0 received, 100% packet loss
10.10.0.99 INCOMPLETE
Máy A gửi gói ARP ai-là 10.10.0.99 ra, không ai đáp. Hàng xóm kẹt ở INCOMPLETE, gói dữ liệu bị giữ lại chờ rồi rơi, và ứng dụng nhận "mất gói". Chú ý triệu chứng: không phải "kết nối bị từ chối", cũng không phải lỗi ngay — mà là một khoảng chờ rồi im lặng. Nếu bạn từng thấy một kết nối tới một IP trong cùng mạng con "treo" thay vì báo lỗi ngay, rất có thể ARP đang gõ cửa một địa chỉ trống.
Cú bắt hụt cả cuộc trao đổi
Lần chạy đầu tiên, phần bắt ARP của tôi trả về rỗng — không một gói nào, dù ping rõ ràng đã chạy. Tôi tưởng bộ lọc arp sai, nhưng vấn đề nằm ở thứ tự thời gian.
Tôi đã xóa bảng hàng xóm, khởi động tcpdump ở nền, chờ 0,4 giây rồi mới ping. Trên một container vừa cài gói xong, tcpdump cần một khoảnh khắc để thật sự gắn vào giao diện và bắt đầu nghe. Vòng ARP chỉ có đúng hai gói và xảy ra trong vài micro giây; nếu tcpdump chưa sẵn sàng đúng lúc đó, nó lỡ mất cả cuộc trao đổi. Với luồng nhiều gói thì bạn không nhận ra vì vẫn bắt được phần sau, nhưng ARP một-lần thì mất là mất trắng.
Cách chữa là đảo thứ tự: cho tcpdump khởi động trước 0,6 giây, rồi mới xóa cache và ping. Sau đó nó bắt đủ hai gói mọi lần. Điều rút ra: khi cần bắt một sự kiện chỉ xảy ra một lần và rất ngắn, hãy để công cụ bắt sẵn sàng hẳn rồi mới kích hoạt sự kiện — đừng tin vào một khoảng chờ ngắn ngay sau khi khởi động.
Vì sao điều này quan trọng
ARP là mắt xích thầm lặng giữa "tôi biết IP của bạn" và "tôi gửi được khung cho bạn". Nó giải thích vài điều thực tế: gói đầu tiên tới một máy mới luôn tốn thêm một vòng phân giải; cache ARP đầy lên theo số hàng xóm bạn nói chuyện; và một mạng con lớn (/16 chẳng hạn) đồng nghĩa miền quảng bá lớn — mỗi vòng ARP làm phiền nhiều máy hơn. Đó cũng là lý do người ta chia mạng thành các subnet nhỏ: không chỉ để đủ địa chỉ, mà để giữ tiếng ồn quảng bá trong tầm kiểm soát.
Có một điểm dễ quên: khi đích ngoài-link (khác mạng con), máy không ARP hỏi MAC của đích — nó ARP hỏi MAC của gateway, rồi gửi khung tới gateway để router lo phần còn lại. Nghĩa là gói ra Internet đầu tiên của bạn vẫn trả một vòng ARP, chỉ là cho router chứ không cho đích. Và có một biến thể tên là gratuitous ARP: một máy tự phát gói "10.10.0.2 ở-tại MAC-của-tôi" mà không ai hỏi, để báo cho cả mạng cập nhật cache — đây chính là cơ chế đằng sau việc chuyển địa chỉ IP nổi giữa hai máy chủ khi một máy chết, thứ mà một sê-ri trước đã đo ở tầng ứng dụng.
Cái mô hình "trước khi nói chuyện được với ai, phải phân giải danh-tính-logic (IP) thành địa-chỉ-vật-lý (MAC) qua một bước hỏi-đáp, rồi cache kết quả với một máy trạng thái có hạn tươi" là một khuôn lặp lại ở mọi tầng phần mềm, không riêng mạng. Trong Java, đây đúng là service discovery: một client gọi service theo tên logic (user-service) phải phân giải nó thành một địa chỉ instance thật qua Eureka/Consul/DNS trước lời gọi đầu, rồi cache — và cái bug "kẹt INCOMPLETE khi không ai giữ IP" song hành với việc gọi một service không có instance nào đăng ký: request treo tới timeout chứ không lỗi ngay; STALE-rồi-xác-nhận-lại chính là mô hình cache có TTL + health check. Go dựng đúng vào net.Resolver và các client discovery, và cái bẫy đo "tcpdump chưa kịp gắn" song hành với race condition kinh điển khi start một watcher rồi ngay lập tức trigger — phải chờ nó ready thật. Cơ sở dữ liệu có phiên bản riêng: một connection string dùng hostname phải phân giải qua DNS trước kết nối đầu (và cache theo TTL), và service mesh (Envoy sidecar) làm đúng cái "phân giải + cache endpoint" mà ARP làm ở tầng 2. Mẫu số chung: giao tiếp qua bất kỳ ranh giới nào thường có một bước phân giải-danh-tính ẩn (tên→địa chỉ, IP→MAC, service→instance) trả một-lần rồi cache — nên gói/lời-gọi đầu tiên luôn đắt hơn, một danh tính không phân giải được biểu hiện là treo rồi timeout chứ không phải lỗi tức thì, và cache đó cần một cơ chế làm-tươi để chịu được thay đổi; biết vậy giúp bạn không hoảng khi lần đầu chậm, và biết nhìn đúng chỗ (bảng phân giải/discovery, không phải logic ứng dụng) khi một kết nối "treo" thay vì báo lỗi.
Muốn tự thấy thì chạy ip neigh show trên máy của bạn, rồi để ý cột trạng thái. REACHABLE là hàng xóm vừa nói chuyện, STALE là đã lâu chưa xác nhận, FAILED hoặc INCOMPLETE là gõ cửa mà không ai mở. Bây giờ ip neigh flush all rồi ping một máy trong mạng LAN: gói đầu sẽ nhỉnh hơn các gói sau đúng một vòng ARP — cái giá bạn vừa đo, tận mắt.