Có một con số ai cũng từng nghe: "một máy chỉ mở được 65535 kết nối vì cổng chỉ có 16 bit". Câu đó vừa đúng vừa sai, và chỗ nó sai lại quan trọng hơn chỗ nó đúng. Bài này dựng một máy khách và một máy chủ thật trong container, mở kết nối cho tới khi nhân từ chối, rồi đếm chính xác trần đó là bao nhiêu — và vì sao nó không phải 65535.

Cổng và socket

Một socket là một bộ bốn

Điều đầu tiên phải sửa trong đầu: một kết nối TCP không được định danh bằng "một cổng", mà bằng bộ bốn — địa chỉ IP nguồn, cổng nguồn, địa chỉ IP đích, cổng đích. Hai kết nối khác nhau chỉ cần khác nhau ở một trong bốn thành phần đó là nhân phân biệt được.

Khi máy khách của bạn gọi connect("10.10.0.2", 9000), ba trong bốn thành phần đã cố định: IP nguồn (của chính máy), IP đích và cổng đích (9000). Chỉ cổng nguồn là còn trống, và nhân tự chọn một cổng chưa dùng từ dải cổng tạm (ephemeral port range). Vậy số kết nối đồng thời tối đa từ một máy khách tới một đích cố định chính bằng số cổng tạm còn trống — không liên quan gì tới con số 65535.

Đo trần thật: cạn cổng tạm

Tôi tạo một cặp veth nối hai không gian mạng, chạy một máy chủ chấp nhận và giữ mọi kết nối ở một đầu, rồi ở đầu kia mở socket liên tục cho tới khi connect ném lỗi. Để chắc chắn không vướng trần số file descriptor (mỗi socket là một fd), tôi nâng ulimit lên nửa triệu trước khi đo:

ip netns add srv
ip link add veth0 type veth peer name veth1
ip link set veth1 netns srv
ip addr add 10.10.0.1/24 dev veth0
ip netns exec srv ip addr add 10.10.0.2/24 dev veth1
cat /proc/sys/net/ipv4/ip_local_port_range   # 32768 60999

Dải cổng tạm mặc định là 32768 - 60999, tức 60999 − 32768 + 1 = 28232 cổng. Và đây là kết quả mở kết nối đồng thời tới 10.10.0.2:9000, chạy ba lần cho ra con số y hệt:

mở đồng thời tới :9000  ->  28232 kết nối, rồi
OSError: [Errno 99] EADDRNOTAVAIL (Cannot assign requested address)

Trần dừng đúng ở 28232 — bằng chằn chặn số cổng trong dải cổng tạm. Không phải 65535, không phải 60999. Cổng nguồn cạn thì nhân không còn cách nào tạo một bộ bốn mới tới cùng một đích, và trả về EADDRNOTAVAIL. Đây là con số thật mà "65535" che mất.

Đổi cổng đích, hạn ngạch nhân đôi

Nếu trần là do cổng nguồn cạn với một bộ bốn cố định, thì đổi một thành phần khác của bộ bốn phải mở ra một hạn ngạch mới. Tôi thử ngay: sau khi đã cạn 28232 kết nối tới :9000, tôi mở tiếp tới 10.10.0.2:9001 — cùng máy chủ, chỉ khác cổng đích:

mở tiếp tới :9001  ->  +28232 kết nối nữa  (tổng cộng 56464)

Đúng như dự đoán: mỗi cổng đích khác nhau là một họ bộ bốn riêng, và mỗi họ có trọn vẹn 28232 cổng nguồn để dùng. Với hai cổng đích, một máy khách mở được 56464 kết nối tới cùng một máy chủ. Trần đếm theo (IP đích, cổng đích), không đếm theo máy.

Điều này lật ngược cả cách hiểu về phía máy chủ. Một máy chủ nghe trên đúng một cổng (ví dụ web server ở :443) không bị chặn ở 65535 kết nối, vì mọi kết nối tới nó chia sẻ cùng (IP máy chủ, 443) nhưng khác nhau ở (IP khách, cổng khách). Số kết nối một máy chủ chịu được bị giới hạn bởi bộ nhớ và số file descriptor, chứ không bởi số cổng. Con số 65535 chỉ áp cho một máy khách nói với một đích duy nhất — đúng cái tình huống hiếm gặp nhất.

Ngay cả đóng rồi, cổng vẫn bị giữ

Có một cách cạn cổng tinh vi hơn nhiều, và nó là thủ phạm thật sự trong các sự cố sản xuất. Tôi thử mở rồi đóng ngay từng kết nối, tuần tự, không hề giữ kết nối nào mở cùng lúc:

for i in range(...): s = connect(:9000); s.close()

Trực giác nói: đóng ngay thì cổng được trả lại ngay, nên vòng lặp này chạy mãi không cạn. Đo thật thì ngược lại:

mở/đóng tuần tự tới :9000  ->  28232 lần, rồi EADDRNOTAVAIL

Chỉ 28232 vòng — đúng bằng lúc mở đồng thời — rồi cạn. Lý do: một kết nối TCP vừa đóng không trả cổng nguồn về ngay. Nó nán lại một trạng thái trung gian trong hàng chục giây (tcp_fin_timeout mặc định 60 giây) để nuốt nốt các gói đi lạc và bảo đảm đầu kia biết kết nối đã đóng hẳn. Trong suốt quãng nán đó, cổng nguồn vẫn bị ghim. Mở/đóng nhanh hơn tốc độ cổng được thả ra, và bạn cạn cổng dù chưa từng giữ quá một kết nối cùng lúc.

Đây chính là lý do vật lý của một lời khuyên: đừng mở một kết nối mới cho mỗi request rồi đóng. Một dịch vụ gọi API nội bộ kiểu "mở, gửi, đóng" vài chục nghìn lần mỗi phút sẽ cạn cổng tạm và bắt đầu ném Cannot assign requested address — một lỗi trông như hết bộ nhớ nhưng thật ra là hết cổng.

Một lần tôi đo hớ trạng thái

Khi viết phép đo tuần tự trên, tôi đã dán nhãn cho nó là "đo cạn cổng do TIME_WAIT", vì TIME_WAIT là trạng thái nán-sau-đóng mà mọi cuốn sách nhắc tới. Nhưng khi tôi đếm cho chắc bằng ss, con số không khớp:

ss -tan state time-wait | wc -l   ->  chỉ 2-3

Cạn 28232 cổng mà chỉ có 2-3 socket ở TIME_WAIT? Vô lý. Tôi đổi sang liệt kê trạng thái của tất cả socket, và sự thật hiện ra:

FIN-WAIT-2   28232
TIME-WAIT        2

28232 cổng bị ghim, nhưng chúng nằm ở FIN_WAIT_2, không phải TIME_WAIT. Nguyên nhân nằm ở máy chủ tôi tự viết: nó chấp nhận kết nối rồi giữ mãi, không bao giờ đóng đầu của nó. Khi máy khách đóng trước, nó gửi FIN và chuyển sang FIN_WAIT_2 chờ FIN đáp lại từ máy chủ — mà máy chủ không bao giờ gửi. Kết nối kẹt ở FIN_WAIT_2, và cổng bị ghim y như TIME_WAIT.

Để kiểm chứng, tôi sửa máy chủ cho nó đóng lại ngay sau khi khách đóng, rồi đo lại:

TIME-WAIT   28234

Lần này trạng thái đúng là TIME_WAIT như sách nói. Bài học có hai lớp. Thứ nhất, hành vi của máy chủ mình tự dựng cũng là một biến trong phép đo — một máy chủ không đóng đầu của nó tạo ra một trạng thái cạn cổng hoàn toàn khác. Thứ hai, và quan trọng hơn: cổng bị ghim ở cả hai trạng thái. Dù là TIME_WAIT (đóng đầy đủ hai phía) hay FIN_WAIT_2 (một phía chưa đóng), cổng nguồn đều không dùng lại được, và kết quả cạn là như nhau. Tôi đã suýt kể sai cơ chế, dù con số thì đúng.

Vì sao điều này quan trọng khi lập trình

Ba kết luận rất cụ thể cho người viết code. Thứ nhất, tái dùng kết nối: bể kết nối (connection pool) tới cơ sở dữ liệu, keep-alive cho HTTP client — tất cả né đúng cái vòng mở/đóng làm cạn cổng, đồng thời né luôn thuế bắt tay ba bước mà một phần trước đã đo. Thứ hai, khi một dịch vụ báo EADDRNOTAVAIL hay Cannot assign requested address, đừng đi tìm rò rỉ bộ nhớ — hãy đếm cổng tạm và socket ở TIME_WAIT/FIN_WAIT_2 trước. Thứ ba, nếu bạn thật sự cần nhiều kết nối tới một đích (ví dụ một proxy hay load balancer làm SNAT cho hàng vạn kết nối ra một backend), bạn phải mở rộng bộ bốn: thêm địa chỉ IP nguồn, hoặc thêm cổng đích, hoặc nới dải cổng tạm bằng net.ipv4.ip_local_port_range. Nhân đôi IP nguồn là nhân đôi hạn ngạch, đúng như đổi cổng đích đã nhân đôi ở trên.

Con số mang theo từ bài này: trần kết nối của một máy khách tới một đích không phải 65535 mà là kích thước dải cổng tạm (mặc định ~28232), nó đếm theo từng bộ bốn nên đổi bất kỳ thành phần đích nào cũng mở thêm hạn ngạch, và một kết nối vừa đóng vẫn giữ cổng thêm hàng chục giây.

Thử ba mươi giây

Trên máy của bạn, chạy cat /proc/sys/net/ipv4/ip_local_port_range để xem dải cổng tạm — hiệu hai số cộng một chính là trần kết nối đồng thời tới một đích. Bây giờ mở nhiều kết nối rồi chạy ss -tan | awk 'NR>1{c[$1]++} END{for(k in c)print k,c[k]}': bạn sẽ thấy socket của mình phân bố qua ESTAB, TIME-WAIT, có khi cả FIN-WAIT-2. Đếm số TIME-WAIT sau một đợt tải nặng là cách nhanh nhất để biết dịch vụ của bạn có đang phí cổng vì mở kết nối kiểu dùng-một-lần hay không.