39 phần trước của sê-ri này mỗi phần mổ xẻ đúng một cơ chế: MTU (Phần 39), routing giữa hai mạng (Phần 38), host.docker.internal (Phần 37), độ trễ ba đường mạng (Phần 36), publish/expose/bind (Phần 35)... Nhưng khi một dịch vụ thật sự không gọi được dịch vụ khác, bạn không biết trước đó là cơ chế nào trong số này. Bài này không thêm cơ chế mới — nó là quy trình để từ một triệu chứng duy nhất ("treo, không lỗi") đi tới đúng nguyên nhân, đo bằng một sự cố dựng sẵn từ đầu tới cuối.

Sơ đồ quy trình năm bước gỡ lỗi mạng container, mỗi bước kèm số đo thật và nhánh dừng sớm

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), tcpdump 4.99.4, curl 8.14.1.

Dựng sự cố: một luật iptables giấu trong container

Hai container trên cùng một mạng: api nghe cổng 9000, gateway gọi sang.

$ docker network create net-app
$ docker run -d --name api --cap-add=NET_ADMIN --network net-app alpine:3.20 sleep infinity
$ docker run -d --name gateway --cap-add=NET_ADMIN --cap-add=NET_RAW --network net-app alpine:3.20 sleep infinity

$ docker exec api apk add --no-cache iptables tcpdump
$ docker exec gateway apk add --no-cache tcpdump bind-tools curl

$ docker exec -d api sh -c "while true; do nc -lk -p 9000 -e /bin/true; done"

Baseline trước khi có sự cố — mọi thứ thông suốt:

$ docker exec gateway nc -zv -w3 api 9000
api (172.19.0.2:9000) open

Giờ mới chèn sự cố — một luật iptables chặn đúng cổng 9000, không log, không thông báo:

$ docker exec api iptables -A INPUT -p tcp --dport 9000 -j DROP

Từ phía gateway, mọi thứ trông như một sự cố mạng thật: curl treo, không có thông báo lỗi nào xuất hiện ngay lập tức. Sau đây là quy trình đo từng bước để tìm ra chỗ hỏng.

Bước 1–2: mạng đúng, ICMP đúng — vẫn treo

$ docker network inspect net-app --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
api 172.19.0.2/16
gateway 172.19.0.3/16

Cùng mạng, cùng subnet — loại được nguyên nhân kiểu Phần 38. Tiếp tục xuống tầng ICMP:

$ docker exec gateway ping -c3 -W2 api
PING api (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.098 ms
64 bytes from 172.19.0.2: seq=1 ttl=64 time=0.172 ms
64 bytes from 172.19.0.2: seq=2 ttl=64 time=0.135 ms

--- api ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss

0% mất gói, độ trễ dưới 0,2 ms — mạng L3 hoàn toàn khoẻ. Đây là điểm rẽ nhánh quan trọng nhất của cả quy trình: ping thông mà kết nối TCP vẫn treo gần như luôn luôn là dấu hiệu của tường lửa hoặc security group, không phải routing hay MTU — hai thứ đó thường làm ICMP hỏng theo cùng kiểu với TCP, còn tường lửa lọc theo cổng thì chỉ chặn TCP/UDP mà để ICMP đi qua như không có chuyện gì.

Bước 3: "refused" tức thì khác gì "treo tới timeout"

Hai kiểu lỗi này dễ bị coi là như nhau vì cả hai đều kết thúc bằng exit code khác 0, nhưng chúng chỉ đúng ở tầng TCP mà không phải "cùng một lỗi". Dựng lại cả hai để so trực tiếp bằng cùng một lệnh curl.

Không có gì lắng nghe cổng (chưa iptables, chưa listener) — từ chối ngay lập tức:

$ docker exec gateway curl -sv --max-time 3 http://api:9000/
* connect to 172.19.0.2 port 9000 from 172.19.0.3 port 35112 failed: Connection refused
* Failed to connect to api port 9000 after 0 ms: Could not connect to server
# exit code 7, real 0m0.00s

Có listener, có luật DROP — treo tới hết timeout:

$ docker exec gateway curl -sv --max-time 3 http://api:9000/
*   Trying 172.19.0.2:9000...
* Connection timed out after 3004 milliseconds
# exit code 28, real 0m3.00s

0 ms so với 3004 ms — chênh lệch đúng bằng giá trị --max-time. "Refused" nghĩa là gói đã tới, có ai đó trả lời RST ngay: sai cổng, ứng dụng chưa khởi động, hoặc quên publish. "Timeout" nghĩa là không có phản hồi nào cả — gói bị rớt ở đâu đó mà không ai báo lại. Phân biệt được hai trường hợp này bằng đúng một con số (thời gian chờ) tiết kiệm cả một bước debug: refused thì đi kiểm ứng dụng, timeout thì mới cần bắt gói.

Bước 4: bắt gói song song hai đầu — gói có tới nơi không

timeout nói "không ai trả lời", nhưng không nói được gói có ra khỏi gateway hay có tới api hay không. tcpdump chạy đồng thời ở cả hai container trả lời được câu đó.

$ docker exec -d gateway sh -c "tcpdump -ni eth0 'tcp port 9000' -c 4 -tttt > /tmp/gateway.txt 2>&1"
$ docker exec -d api     sh -c "tcpdump -ni eth0 'tcp port 9000' -c 4 -tttt > /tmp/api.txt 2>&1"
$ docker exec gateway curl -s --max-time 5 http://api:9000/ >/dev/null 2>&1

$ docker exec gateway cat /tmp/gateway.txt
14:16:43.948896 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...
14:16:45.000315 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...
14:16:46.026064 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...
14:16:47.044072 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...

$ docker exec api cat /tmp/api.txt
14:16:43.948912 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...
14:16:45.000356 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...
14:16:46.026106 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...
14:16:47.044123 IP 172.19.0.3.36604 > 172.19.0.2.9000: Flags [S], seq 727350123, ...

Bốn gói SYN xuất hiện ở cả hai bên, lệch nhau 16–51 µs — đúng bằng thời gian truyền trên một bridge nội bộ. Không có gói SYN-ACK nào ở chiều ngược lại trong cả hai file. Kết luận đo được: gói ra khỏi gateway, tới đúng card mạng của api, rồi biến mất trước khi ứng dụng kịp thấy. Đây là ranh giới quan trọng — nếu SYN không xuất hiện ở file của api, sự cố nằm ở đâu đó giữa đường (routing sai, MTU chặn như Phần 39); nếu SYN có tới NIC đích mà vẫn không sinh ra SYN-ACK, sự cố nằm ngay trên máy đích, thường là iptables hoặc chính ứng dụng.

Một điều đo được nhưng không giải thích được ngay: khoảng cách giữa các lần gửi lại SYN là 1,05 s rồi 1,03 s rồi 1,02 s — gần như đều nhau, không tăng gấp đôi mỗi lần như cách net.ipv4.tcp_syn_retries (đo được bằng 6 trên container này) thường được mô tả. Không đủ dữ kiện để khẳng định nguyên nhân — có thể liên quan tới cách kernel linuxkit của Docker Desktop định thời timer trong network namespace ảo hoá — nên chỉ ghi lại số đo, không đoán.

Bước 5: iptables -L -n -v — tìm đúng luật, đúng bộ đếm

SYN tới nơi mà không có phản hồi, trên chính máy đích, gần như chắc chắn là do bộ lọc gói cục bộ. Kiểm bộ đếm packet của từng luật là cách xác nhận chắc nhất — không phải đọc luật rồi đoán luật nào áp dụng, mà đọc số đã tăng bao nhiêu:

$ docker exec api iptables -L INPUT -n -v
Chain INPUT (policy ACCEPT 611 packets, 2875K bytes)
 pkts bytes target     prot opt in     out     source               destination
   17  1020 DROP       6    --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:9000

17 gói, 1020 byte, khớp đúng luật DROP tcp dpt:9000 — và 17 gói này tích luỹ đúng từ những lần thử nc/curl ở các bước trước, mỗi lần vài SYN retransmit. tcpdump bắt gói tại tầng card mạng, trước khi iptables xử lý, nên nó thấy gói tới; ứng dụng lắng nghe ở tầng socket, sau iptables, nên nó không bao giờ thấy gì. Bộ đếm packet là mắt xích nối hai quan sát đó lại thành một nguyên nhân duy nhất.

Fix và xác nhận lại

$ docker exec api iptables -D INPUT -p tcp --dport 9000 -j DROP

$ docker exec gateway sh -c "time nc -zv -w3 api 9000"
api (172.19.0.2:9000) open
real    0m0.00s

Từ 3,00 giây treo về 0,00 giây mở ngay — đúng bằng khoảng cách giữa "có luật chặn" và "không có luật chặn", không còn gì khác đổi trong hệ thống.

Khi nào không cần đi hết cả năm bước

Quy trình này không phải lúc nào cũng chạy tuần tự tới cuối — mỗi bước có một điều kiện dừng sớm, và đó chính là phần bên trái của sơ đồ:

Quan sát ở bước nào Dừng lại, đi hướng nào
docker network inspect cho thấy khác subnet Xem lại cách nối mạng — Phần 38
ping mất gói hoặc không thông Kiểm loại mạng, DNS nội bộ — Phần 34
nc/curl báo "refused" tức thì (0 ms) Không phải lỗi mạng — kiểm ứng dụng có chạy, có đúng cổng, có publish
tcpdump không thấy SYN ở NIC đích Gói mất giữa đường — nghi MTU, nghi route — Phần 39

Chỉ khi ping thông, TCP treo (không refused), và SYN tới được NIC đích mà không có hồi đáp, mới cần tới iptables -L -n -v như bước cuối của bài này.

Thử ba mươi giây

Dán thẳng để thấy sự khác biệt giữa "refused" và "timeout" trên chính máy bạn, không cần dựng lại toàn bộ sự cố:

docker run -d --rm --name thu-nghiem alpine:3.20 sleep 30
docker exec thu-nghiem sh -c '
  time nc -zv -w2 127.0.0.1 9999   # khong ai nghe -> refused tuc thi
'

Bài viết liên quan