Bài trước ta nhìn một kết nối TCP qua các gói tin. Cùng câu chuyện đó nhìn từ phía khác: mỗi kết nối, tại mỗi thời điểm, đang ở một trạng thái trong máy trạng thái TCP — LISTEN, ESTABLISHED, TIME-WAIT, và một chục cái tên khác. Công cụ ss (thay cho netstat cũ) chụp lại trạng thái đó ngay lúc này. Bài này theo dõi một kết nối đi qua vòng đời của nó bằng ss, và trên đường đó tôi mắc đúng cái lỗi mà cả Internet vẫn mắc khi cố "dọn" các socket TIME-WAIT.
ss và máy trạng thái TCP
Một kết nối TCP không phải "có" hay "không" — nó đi qua một chuỗi trạng thái được định nghĩa chặt chẽ. Server bắt đầu ở LISTEN (chờ). Khi bắt tay, hai bên qua SYN-SENT/SYN-RECV rồi tới ESTABLISHED (đang truyền). Khi đóng, chúng đi qua một loạt trạng thái chia tay: FIN-WAIT-1, FIN-WAIT-2, CLOSE-WAIT, LAST-ACK, và TIME-WAIT, trước khi biến mất hẳn.
ss -tan liệt kê mọi socket kèm trạng thái; thêm cờ -o còn in cả bộ đếm thời gian của trạng thái đó. Tôi dựng hai namespace client–server trong container, tạo kết nối rồi đóng theo vài kiểu khác nhau, và chụp ss ở từng pha để thấy vòng đời hiện ra.
Đo: vòng đời qua các trạng thái
| Pha | Trạng thái | ss thấy gì |
|---|---|---|
| Server chờ | LISTEN | 10.0.0.2:8080 0.0.0.0:* |
| Đang nối | ESTABLISHED | cả hai đầu, đủ bộ bốn địa chỉ |
| Bên đóng trước (cả hai đóng) | TIME-WAIT | timer:(timewait,59sec,0) |
| Nửa-đóng: bên đóng trước | FIN-WAIT-2 | chờ FIN của bên kia |
| Nửa-đóng: bên chưa đóng | CLOSE-WAIT | đã nhận FIN, chưa close() |
Vòng đời hiện rõ. Server ở LISTEN. Khi client nối vào, cả hai đầu thành ESTABLISHED, mỗi bên thấy đúng bộ bốn (IP:cổng của mình và của bên kia). Khi client chủ động đóng và server cũng đóng theo, client — bên đóng trước — rơi vào TIME-WAIT, và nhờ cờ -o tôi đọc luôn được timer:(timewait,59sec): nó sẽ nằm đó gần 60 giây rồi mới tan, mà tôi không phải ngồi chờ 60 giây để biết.
Còn nếu một bên đóng mà bên kia chưa đóng, ta có một kết nối nửa-đóng: bên đóng trước ở FIN-WAIT-2 (đã gửi FIN, đang chờ FIN của bên kia), còn bên chưa đóng ở CLOSE-WAIT (đã nhận FIN nhưng ứng dụng chưa gọi close()).
Một lần tôi đo hớ: hạ tcp_fin_timeout không rút được TIME-WAIT
Thấy TIME-WAIT nằm tới 60 giây, phản xạ của tôi là làm đúng lời khuyên nhan nhản trên các diễn đàn: "máy chủ bận có hàng nghìn socket TIME-WAIT à? Hạ tcp_fin_timeout xuống cho chúng mau tan". Tôi hạ nó xuống 5 giây, rồi đóng một kết nối mới và chụp ss.
TIME-WAIT vẫn khởi động ở ~60 giây (bộ đếm đọc 51sec vào lúc tôi chụp). tcp_fin_timeout=5 chẳng ảnh hưởng gì tới nó cả. Lời khuyên phổ biến kia — mà tôi suýt lặp lại — là sai.
Lý do: tcp_fin_timeout điều khiển trạng thái FIN-WAIT-2 (bao lâu một bên chờ FIN của bên kia trước khi bỏ cuộc), không phải TIME-WAIT. Còn TIME-WAIT có độ dài cố định 60 giây (bằng 2×MSL, là hằng số TCP_TIMEWAIT_LEN biên dịch cứng trong nhân Linux) — không có tham số sysctl nào chỉnh được nó mà không sửa mã nguồn nhân và biên dịch lại. Tôi đã gán một cái núm cho một trạng thái mà nó không hề điều khiển, chỉ vì cái tên fin_timeout nghe có vẻ liên quan tới việc kết thúc kết nối.
Bài học đo lường: đừng tin một cái núm chỉnh đúng thứ bạn muốn chỉ vì tên nó nghe hợp. Một phép đo — hạ núm, rồi đọc bộ đếm — đã tách bạch ngay điều mà vô số bài hướng dẫn chép lại của nhau vẫn nói sai. Muốn thật sự giảm áp lực TIME-WAIT, cách đúng là ở tầng khác: bật tcp_tw_reuse (cho phép dùng lại socket TIME-WAIT cho kết nối ra mới), hoặc — gốc rễ hơn — đừng mở quá nhiều kết nối ngắn (dùng keep-alive và bể kết nối), chứ không phải vặn một cái núm không có tác dụng.
TIME-WAIT so với CLOSE-WAIT: hai bệnh cùng chữ "wait"
Phép đo còn tách bạch một chỗ hay bị lẫn: TIME-WAIT và CLOSE-WAIT nghe giống nhau nhưng là hai chuyện hoàn toàn khác, và chẩn đoán ngược nhau.
TIME-WAIT xuất hiện ở bên chủ động đóng trước, sau khi mọi thứ đã đóng đúng cách. Nó bình thường, là một phần thiết kế của TCP (để các gói lạc của kết nối cũ không lẫn vào kết nối mới trùng cổng), và tự tan sau 60 giây. Thấy nhiều TIME-WAIT thường chỉ nghĩa là bạn đang mở-đóng nhiều kết nối ngắn — khó chịu khi nó ăn hết cổng tạm (như bài cổng và socket đã đo), nhưng không phải lỗi mã.
CLOSE-WAIT thì ngược lại: nó ở bên đóng sau, và xuất hiện khi bên kia đã gửi FIN (muốn đóng) nhưng ứng dụng của bạn chưa gọi close(). Nó không tự tan — nó nằm đó cho tới khi mã của bạn đóng socket. Thấy CLOSE-WAIT chồng đống là dấu hiệu rò rỉ socket trong code của bạn: ở đâu đó bạn quên đóng kết nối sau khi dùng xong. Đây là một lỗi thật, cần sửa mã, không phải chờ nhân dọn.
Cùng chữ "wait", nhưng TIME-WAIT là nhân đang làm đúng việc, còn CLOSE-WAIT là mã của bạn đang quên việc. Đọc đúng cái tên trạng thái là đã đi được nửa đường chẩn đoán.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là ss là ống nghe để chẩn đoán kết nối, và tên trạng thái là triệu chứng. Nhiều TIME-WAIT? Bạn đang mở-đóng kết nối ngắn quá nhiều — dùng lại kết nối. Nhiều CLOSE-WAIT? Mã của bạn quên close() — tìm chỗ rò. Nhiều SYN-SENT không thành ESTABLISHED? Không tới được server (chặn hoặc chết). Nhiều SYN-RECV ở server? Có thể đang bị SYN flood. Mỗi trạng thái đông bất thường trỏ tới một nguyên nhân cụ thể — ss -tan state <tên> lọc ra đúng nhóm cần xem.
Hệ quả thứ hai là biết ai chịu TIME-WAIT để thiết kế cho đúng. Bên chủ động đóng trước là bên gánh TIME-WAIT. Một server trả Connection: close sẽ tự đóng trước, và do đó tự chất đống TIME-WAIT lên chính mình — ở quy mô lớn, đủ để cạn cổng tạm cục bộ. Đây là một lý do các server hiệu năng cao ưa để client đóng trước (giữ keep-alive, đóng theo phía client), đẩy gánh TIME-WAIT sang phía có nhiều địa chỉ hơn.
Hệ quả thứ ba là nếp đo lường chốt lại: một cái tên nghe hợp lý không đảm bảo nó làm điều bạn tưởng — hãy đo. Con số mang theo: vòng đời kết nối đọc được qua ss (LISTEN → ESTABLISHED → TIME-WAIT/CLOSE-WAIT); TIME-WAIT cố định 60s và tcp_fin_timeout KHÔNG rút được nó (nó chỉnh FIN-WAIT-2); và TIME-WAIT (bên đóng trước, tự tan) khác hẳn CLOSE-WAIT (bên đóng sau, code quên close, rò rỉ). Khi kết nối cư xử lạ, ss cho bạn xem thẳng nó đang ở đâu trong vòng đời — thường đó là toàn bộ manh mối bạn cần.
Thử ba mươi giây
Trên bất kỳ máy Linux/Mac nào đang chạy mạng, gõ ss -tan (hoặc ss -tan state time-wait để lọc). Bạn sẽ thấy một bức ảnh chụp mọi kết nối TCP và trạng thái của chúng ngay lúc này: một dúm LISTEN (các dịch vụ đang chờ), nhiều ESTAB (đang hoạt động), và — nếu máy vừa duyệt web hay gọi API nhiều — có thể cả một loạt TIME-WAIT đang đếm ngược. Thử ss -tano để thấy bộ đếm thời gian, và ss -tan state close-wait: nếu dòng này nhiều và không giảm, bạn vừa tìm ra một chỗ rò socket trong ứng dụng nào đó — đúng cái phân biệt mà bài này đo, tận mắt trên máy bạn.