iptables (và người kế nhiệm nftables) là bức tường lửa nằm trong nhân Linux mà gần như mọi máy chủ đều dựa vào — chặn cổng, lọc IP, chuyển hướng gói. Một luật lọc trông đơn giản: "gói tới cổng này thì chặn". Nhưng "chặn" có nhiều kiểu, và mỗi kiểu cho một hành vi khác nhau đến mức quyết định cả trải nghiệm gỡ lỗi; còn một danh sách luật dài thì không miễn phí như tôi tưởng. Bài này dựng hai vùng mạng trong container, áp từng luật, và đo cả tác dụng lẫn chi phí — rồi vấp đúng một khác biệt mà nhiều người (kể cả tôi) hay xem nhẹ.
iptables và chuỗi luật
Netfilter tổ chức các luật thành chuỗi (chain) gắn vào những điểm trên đường đi của gói: INPUT cho gói gửi tới máy này, OUTPUT cho gói máy này gửi ra, FORWARD cho gói đi xuyên qua (như router). Mỗi gói được so với các luật trong chuỗi lần lượt từ trên xuống; gặp luật khớp đầu tiên thì thi hành hành động của nó — ACCEPT (cho qua), DROP (vứt im lặng), hay REJECT (từ chối có báo). Không luật nào khớp thì áp chính sách mặc định của chuỗi.
Hai câu hỏi thực tế nảy ra từ mô hình đó: một luật chặn thực sự gây ra hành vi gì cho bên kết nối, và việc quét cả một danh sách luật cho mỗi gói tốn bao nhiêu? Tôi đo cả hai với một client và một server trong hai namespace nối bằng veth, áp luật ở INPUT của server.
Đo: DROP treo, REJECT từ chối ngay
Server lắng nghe ở cổng 8080. Client thử kết nối, tôi đo thời gian và kết cục dưới ba tình huống:
| Luật ở server | Kết cục cho client |
|---|---|
| Không luật | KẾT NỐI OK sau 0,4 ms |
-j DROP |
TREO 7167 ms rồi mới báo lỗi |
-j REJECT |
BỊ TỪ CHỐI ngay, 0,1 ms |
Ba dòng này là ba thế giới khác nhau. Không luật: kết nối tức thì. REJECT: client bị từ chối ngay lập tức (0,1 ms) — vì server chủ động gửi lại một gói RST (hoặc ICMP unreachable) nói "cổng này đóng", client nhận được và bỏ cuộc liền. DROP mới là kẻ đáng sợ: server nuốt gói SYN trong im lặng, không trả lời gì. Client không biết gói mất hay server bận, nên nó làm đúng thứ giao thức TCP bảo: gửi lại SYN, chờ, gửi lại, chờ lâu hơn... Ở đây tôi đã hạ tcp_syn_retries xuống 2 để đo nhanh, nên client bỏ cuộc sau ~7 giây. Trên một máy mặc định (tcp_syn_retries=6), con số đó là ~127 giây — hơn hai phút treo.
Một lần tôi đo hớ: "chặn" không phải "hỏng nhanh"
Tôi bước vào phép đo này với một giả định lười: "chặn một kết nối thì nó hỏng, mà hỏng thì nhanh thôi" — và định coi DROP với REJECT như hai cách nói của cùng một việc. Con số 7167 ms (và cái đuôi ~127 giây trên máy thật) đập tan giả định đó.
DROP không làm kết nối "hỏng nhanh" — nó làm kết nối treo rất lâu trong im lặng. Với client, một cổng bị DROP trông y hệt một máy chủ đã chết, một sợi cáp đứt, hay một mạng nghẽn: chỉ có sự im lặng, và TCP kiên nhẫn thử lại cho tới khi hết số lần. Đây chính xác là gốc rễ của vô số sự cố kiểu "tự nhiên ứng dụng đơ hai phút rồi mới báo lỗi kết nối" mà người ta đổ cho ứng dụng, cho cơ sở dữ liệu, cho mạng — trong khi thủ phạm là một luật DROP im lặng ở đâu đó (một tường lửa đám mây, một security group, một iptables cấu hình vội). Nếu ai đó đổi luật thành REJECT, client đã thất bại tức thì và thông báo rõ ràng, và hàng giờ gỡ lỗi đã không xảy ra.
Bài học đo lường: "chặn" là một từ che giấu hai hành vi trái ngược về thời gian. REJECT là từ chối lịch sự và tức thì; DROP là làm ngơ tàn nhẫn khiến bên kia treo. Chọn cái nào không phải chuyện sở thích — DROP giấu sự tồn tại của cổng (tốt cho việc ẩn mình trước kẻ quét), nhưng trả giá bằng độ trễ thất bại khủng khiếp cho cả những client hợp lệ lỡ gõ nhầm. Nếu tôi tin giả định ban đầu và viết "DROP và REJECT về cơ bản giống nhau", tôi đã bỏ qua đúng cái khác biệt mà một người gỡ lỗi cần biết nhất.
Chi phí: luật quét tuyến tính, ipset thì không
Câu hỏi thứ hai: một danh sách luật dài tốn gì? Tôi nhồi vào chuỗi INPUT của server N luật không khớp với client (chặn các IP không bao giờ gửi tới), nên mỗi gói thật phải quét hết N luật rồi mới tới hành động mặc định. Đo RTT trung bình:
| Số luật | RTT trung bình |
|---|---|
| 0 | 0,092 ms |
| 2000 | 0,127 ms |
| 5000 | 0,165 ms |
| 10000 | 0,228 ms |
Rõ ràng tuyến tính: càng nhiều luật, mỗi gói càng tốn thời gian, vì iptables quét từ trên xuống cho tới khi tìm được luật khớp. Từ 0 lên 10000 luật, RTT tăng ~2,5 lần. Với một máy chủ xử lý hàng trăm nghìn gói mỗi giây, mỗi micro giây thêm vào mỗi gói cộng dồn thành tải CPU thật và độ trễ thật.
May là có cách thoát khỏi cái giá tuyến tính đó. Tôi thay 10000 luật riêng lẻ bằng một luật ipset trỏ tới một tập 10000 địa chỉ:
1 luật ipset (tập 10000 địa chỉ): 0,088 ms — về đúng mức nền
ipset lưu các địa chỉ trong một bảng băm và tra cứu trong thời gian O(1), thay vì quét O(N) như chuỗi luật thẳng. Cùng tác dụng "chặn 10000 IP", nhưng chi phí mỗi gói không còn phụ thuộc số lượng. Đây là lý do các hệ thống chặn IP quy mô lớn (chống DDoS, danh sách đen) dùng ipset/nftables sets chứ không nhồi hàng vạn luật iptables.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là dùng REJECT khi bạn muốn client thất bại nhanh, DROP khi bạn muốn ẩn mình. Với dịch vụ nội bộ — nơi một client gõ nhầm nên biết ngay là mình sai — REJECT tiết kiệm cho người gỡ lỗi hàng phút chờ đợi vô nghĩa. Với cổng hướng ra Internet công cộng — nơi bạn không muốn tiết lộ cổng nào đang mở cho kẻ quét — DROP khiến máy quét cũng phải chờ hết timeout, làm chậm chúng lại. Hiểu sự đánh đổi này giúp bạn không vô tình biến một dịch vụ nội bộ thành cỗ máy gây treo.
Hệ quả thứ hai là khi một kết nối "treo rồi mới lỗi", hãy nghi một luật DROP im lặng trước tiên. Sự khác biệt giữa "Connection refused" tức thì và "treo 30 giây rồi timeout" là một manh mối chẩn đoán cực mạnh: refused nghĩa là có ai đó chủ động nói không (không có tiến trình nghe, hoặc REJECT); treo im lặng nghĩa là gói đang bị vứt đâu đó — một tường lửa, một security group, một luật DROP. Đọc đúng triệu chứng này cắt ngắn việc gỡ lỗi đi rất nhiều, thay vì mò mẫm trong ứng dụng.
Hệ quả thứ ba là cấu trúc dữ liệu của luật lọc cũng quan trọng như bản thân luật. Chặn một IP thì luật nào cũng như nhau; chặn một trăm nghìn IP thì cách bạn tổ chức chúng quyết định máy chủ có gục dưới tải hay không. Con số mang theo: DROP làm client treo hàng chục giây tới hàng phút trong im lặng, REJECT từ chối ngay lập tức; và luật iptables quét tuyến tính (10000 luật gấp 2,5 lần RTT) trong khi một luật ipset băm O(1) cho cùng tác dụng ở mức nền. Một luật lọc không chỉ có "chặn hay không" — nó có một hành vi thời gian và một cái giá, và cả hai đều đo được.
Thử ba mươi giây
Lần tới khi một kết nối "đơ" thay vì lỗi ngay, hãy phân biệt hai triệu chứng bằng time: time nc -vz -w 30 mot-host mot-cong. Nếu nó trả Connection refused trong tích tắc, có ai đó chủ động từ chối (không có dịch vụ nghe, hoặc một luật REJECT) — vấn đề rõ ràng. Nếu nó treo rồi mới timeout sau nhiều giây, gói của bạn đang bị vứt im lặng — gần như chắc chắn là một luật DROP ở một tường lửa hay security group nào đó trên đường. Chỉ một lệnh, và cái độ dài của khoảng chờ đã nói cho bạn biết mình đang đối mặt với REJECT hay DROP — đúng khác biệt mà bài này đo, và là bước đầu tiên để tìm ra bức tường vô hình.