paint-brush
Kiểm soát việc truyền lại TCP: Phát hiện vấn đề sớm để ngăn ngừa mất dữ liệuby@koilas
1,004
1,004

Kiểm soát việc truyền lại TCP: Phát hiện vấn đề sớm để ngăn ngừa mất dữ liệu

Oleg Tolmashov14m2024/01/23
Read on Terminal Reader

Trong bài viết này, tôi sẽ đề cập đến một khía cạnh quan trọng của giao tiếp TCP: quản lý hiệu quả các tình huống trong đó máy chủ không phản hồi. Tôi tập trung vào một tình huống cụ thể trong đó ứng dụng chỉ gửi dữ liệu qua TCP mà không nhận được bất kỳ phản hồi cấp ứng dụng nào từ máy chủ. Khám phá này bao gồm giao tiếp TCP từ góc nhìn của ứng dụng, làm nổi bật cả lớp ứng dụng và các hoạt động cơ bản của hệ điều hành. Bạn sẽ tìm hiểu cách đặt thời gian chờ hiệu quả để tránh mất dữ liệu trong các phiên bản máy chủ không phản hồi.
featured image - Kiểm soát việc truyền lại TCP: Phát hiện vấn đề sớm để ngăn ngừa mất dữ liệu
Oleg Tolmashov HackerNoon profile picture
0-item
1-item

Giới thiệu

Trong bài viết này, tôi sẽ đề cập đến một khía cạnh quan trọng của giao tiếp TCP: quản lý hiệu quả các tình huống trong đó máy chủ không phản hồi. Tôi tập trung vào một tình huống cụ thể trong đó ứng dụng chỉ gửi dữ liệu qua TCP mà không nhận được bất kỳ phản hồi cấp ứng dụng nào từ máy chủ.


Khám phá này bao gồm giao tiếp TCP từ góc nhìn của ứng dụng, làm nổi bật cả lớp ứng dụng và các hoạt động cơ bản của hệ điều hành. Bạn sẽ tìm hiểu cách đặt thời gian chờ hiệu quả để tránh mất dữ liệu trong các phiên bản máy chủ không phản hồi. Tôi sẽ cung cấp các ví dụ về mã trong Ruby, nhưng ý tưởng vẫn giữ nguyên đối với mọi ngôn ngữ.

Thách thức của máy chủ TCP im lặng

Hãy tưởng tượng bạn đang làm việc với một ứng dụng truyền dữ liệu một cách nhất quán qua ổ cắm TCP. Mặc dù TCP được thiết kế để đảm bảo phân phối gói ở mức truyền tải trong các cấu hình ngăn xếp TCP đã xác định, nhưng thật thú vị khi xem xét điều này ngụ ý gì ở cấp ứng dụng.


Để hiểu rõ hơn về điều này, hãy xây dựng một máy chủ và máy khách TCP mẫu bằng Ruby. Điều này sẽ cho phép chúng ta quan sát quá trình giao tiếp đang diễn ra.


server.rb :

 # server.rb require 'socket' require 'time' $stdout.sync = true puts 'starting tcp server...' server = TCPServer.new(1234) puts 'started tcp server on port 1234' loop do Thread.start(server.accept) do |client| puts 'new client' while (message = client.gets) puts "#{Time.now}]: #{message.chomp}" end client.close end end


client.rb :

 require 'socket' require 'time' $stdout.sync = true socket = Socket.tcp('server', 1234) loop do puts "sending message to the socket at #{Time.now}" socket.write "Hello from client\n" sleep 1 end


Và hãy gói gọn thiết lập này trong các thùng chứa bằng Dockerfile này:

 FROM ruby:2.7 RUN apt-get update && apt-get install -y tcpdump # Set the working directory in the container WORKDIR /usr/src/app # Copy the current directory contents into the container at /usr/src/app COPY . .


docker-compose.yml :

 version: '3' services: server: build: context: . dockerfile: Dockerfile command: ruby server.rb volumes: - .:/usr/src/app ports: - "1234:1234" healthcheck: test: ["CMD", "sh", "-c", "nc -z localhost 1234"] interval: 1s timeout: 1s retries: 2 networks: - net client: build: context: . dockerfile: Dockerfile command: ruby client.rb volumes: - .:/usr/src/app - ./data:/data depends_on: - server networks: - net networks: net:


Bây giờ, chúng ta có thể dễ dàng chạy cái này với docker compose up và xem trong nhật ký cách máy khách gửi tin nhắn và máy chủ nhận được nó:


 $ docker compose up [+] Running 2/0 ⠿ Container tcp_tests-server-1 Created 0.0s ⠿ Container tcp_tests-client-1 Created 0.0s Attaching to tcp_tests-client-1, tcp_tests-server-1 tcp_tests-server-1 | starting tcp server... tcp_tests-server-1 | started tcp server on port 1234 tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:08 +0000 tcp_tests-server-1 | new client tcp_tests-server-1 | 2024-01-14 08:59:08 +0000]: Hello from client tcp_tests-server-1 | 2024-01-14 08:59:09 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:09 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:10 +0000 tcp_tests-server-1 | 2024-01-14 08:59:10 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:11 +0000 tcp_tests-server-1 | 2024-01-14 08:59:11 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:12 +0000 tcp_tests-server-1 | 2024-01-14 08:59:12 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 08:59:13 +0000

Khá dễ dàng cho đến nay phải không?


Tuy nhiên, tình huống trở nên thú vị hơn khi chúng tôi mô phỏng lỗi máy chủ đối với một kết nối đang hoạt động.


Chúng tôi thực hiện việc này bằng cách sử dụng docker compose stop server :


 tcp_tests-server-1 | 2024-01-14 09:04:23 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:24 +0000 tcp_tests-server-1 | 2024-01-14 09:04:24 +0000]: Hello from client tcp_tests-server-1 exited with code 1 tcp_tests-server-1 exited with code 0 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:25 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:26 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:27 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:28 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:29 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:30 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:31 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:04:32 +0000


Chúng tôi quan sát thấy rằng máy chủ hiện ngoại tuyến, tuy nhiên máy khách hoạt động như thể kết nối vẫn đang hoạt động, tiếp tục gửi dữ liệu đến ổ cắm mà không do dự.


Điều này khiến tôi đặt câu hỏi tại sao điều này xảy ra. Về mặt logic, máy khách sẽ phát hiện thời gian ngừng hoạt động của máy chủ trong một khoảng thời gian ngắn, có thể là vài giây, vì TCP không nhận được xác nhận cho các gói của nó, khiến kết nối bị đóng.


Tuy nhiên, kết quả thực tế lại khác với kỳ vọng này:

 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:11 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:12 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:13 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:14 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:15 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:16 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:17 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:18 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:19 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:20 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:21 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:22 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-14 09:20:23 +0000 tcp_tests-client-1 | client.rb:11:in `write': No route to host (Errno::EHOSTUNREACH) tcp_tests-client-1 | from client.rb:11:in `block in <main>' tcp_tests-client-1 | from client.rb:9:in `loop' tcp_tests-client-1 | from client.rb:9:in `<main>' tcp_tests-client-1 exited with code 1


Trên thực tế, khách hàng có thể không biết về kết nối bị gián đoạn trong khoảng thời gian 15 phút !


Điều gì gây ra sự chậm trễ trong việc phát hiện này? Hãy cùng tìm hiểu sâu hơn để hiểu lý do.

Chuyên sâu: Cơ chế truyền thông TCP

Để giải quyết đầy đủ trường hợp này, trước tiên chúng ta hãy xem lại các nguyên tắc cơ bản, sau đó là kiểm tra cách máy khách truyền dữ liệu qua TCP.

Khái niệm cơ bản về TCP

Đây là sơ đồ cơ bản minh họa luồng TCP:

luồng TCP

Khi kết nối được thiết lập, việc truyền từng tin nhắn thường bao gồm hai bước chính:


  1. Máy khách gửi tin nhắn, được đánh dấu bằng cờ PSH (Đẩy).


  2. Máy chủ xác nhận đã nhận bằng cách gửi lại phản hồi ACK (Xác nhận).

Giao tiếp giữa ứng dụng và socket

Dưới đây là sơ đồ trình tự đơn giản minh họa việc mở ổ cắm TCP bởi một ứng dụng và quá trình truyền dữ liệu tiếp theo qua nó:


Giao tiếp với ổ cắm TCP


Ứng dụng thực hiện hai thao tác:

  1. Mở ổ cắm TCP


  2. Gửi dữ liệu đến ổ cắm mở


Ví dụ: khi mở một ổ cắm TCP, như được thực hiện bằng lệnh Socket.tcp(host, port) của Ruby, hệ thống sẽ đồng bộ tạo một ổ cắm bằng lệnh gọi hệ thống socket(2) và sau đó thiết lập kết nối thông qua lệnh gọi hệ thống connect(2) .


Đối với việc gửi dữ liệu, việc sử dụng lệnh như socket.write('foo') trong ứng dụng chủ yếu sẽ đặt thông báo vào bộ đệm gửi của socket. Sau đó nó trả về số byte đã được xếp hàng thành công. Việc truyền dữ liệu thực tế này qua mạng tới máy chủ đích được quản lý không đồng bộ bởi ngăn xếp TCP/IP.


Điều này có nghĩa là khi một ứng dụng ghi vào ổ cắm, nó không liên quan trực tiếp đến hoạt động mạng và có thể không biết trong thời gian thực liệu kết nối có còn hoạt động hay không. Xác nhận duy nhất mà nó nhận được là tin nhắn đã được thêm thành công vào bộ đệm gửi TCP.

Điều gì xảy ra khi máy chủ TCP ngừng hoạt động?

Vì máy chủ không phản hồi bằng cờ ACK, ngăn xếp TCP của chúng tôi sẽ bắt đầu truyền lại gói chưa được xác nhận cuối cùng:


điều gì xảy ra khi máy chủ ngừng hoạt động


Điều thú vị ở đây là theo mặc định, TCP thực hiện 15 lần truyền lại với thời gian chờ theo cấp số nhân, dẫn đến gần 15 phút thử lại!


Bạn có thể kiểm tra số lần thử lại được đặt trên máy chủ của mình:

 $ sysctl net.ipv4.tcp_retries2 net.ipv4.tcp_retries2 = 15


Sau khi đi sâu vào tài liệu, mọi chuyện trở nên rõ ràng; tài liệu ip-sysctl.txt nói:


Giá trị mặc định là 15 mang lại thời gian chờ giả định là 924,6 giây và là giới hạn dưới cho thời gian chờ hiệu quả. TCP sẽ hết thời gian chờ một cách hiệu quả ở RTO đầu tiên vượt quá thời gian chờ giả định.


Trong thời gian này, ổ cắm TCP cục bộ vẫn hoạt động và chấp nhận dữ liệu. Khi tất cả các lần thử lại được thực hiện, ổ cắm sẽ bị đóng và ứng dụng sẽ nhận được lỗi khi cố gắng gửi bất cứ thứ gì đến ổ cắm.

Tại sao nó thường không phải là một vấn đề?

Tình huống trong đó máy chủ TCP đột ngột ngừng hoạt động mà không gửi cờ TCP FIN hoặc RST hoặc khi có sự cố kết nối là khá phổ biến. Vậy tại sao những tình huống như vậy thường không được chú ý?


Bởi vì, trong hầu hết các trường hợp, máy chủ sẽ phản hồi bằng một số phản hồi ở cấp độ ứng dụng. Ví dụ: giao thức HTTP yêu cầu máy chủ phản hồi mọi yêu cầu. Về cơ bản, khi bạn có mã như thế connection.get , nó sẽ thực hiện hai thao tác chính:

  1. Ghi tải trọng của bạn vào bộ đệm gửi của ổ cắm TCP.

    Từ thời điểm này trở đi, ngăn xếp TCP của hệ điều hành chịu trách nhiệm phân phối các gói này đến máy chủ từ xa một cách đáng tin cậy với sự đảm bảo của TCP.


  2. Đang chờ phản hồi trong bộ đệm nhận TCP từ máy chủ từ xa


    Thông thường, các ứng dụng sử dụng các lần đọc không chặn từ bộ đệm nhận của cùng một ổ cắm TCP.


Cách tiếp cận này đơn giản hóa đáng kể vấn đề vì trong những trường hợp như vậy, chúng ta có thể dễ dàng đặt thời gian chờ ở cấp ứng dụng và đóng ổ cắm nếu không có phản hồi từ máy chủ trong khung thời gian xác định.


Tuy nhiên, khi chúng tôi không mong đợi bất kỳ phản hồi nào từ máy chủ (ngoại trừ các xác nhận TCP), việc xác định trạng thái kết nối từ cấp ứng dụng sẽ trở nên khó khăn hơn.

Tác động của việc truyền lại TCP dài

Cho đến nay, chúng tôi đã thiết lập được những điều sau:

  1. Ứng dụng mở một ổ cắm TCP và thường xuyên ghi dữ liệu vào đó.


  2. Tại một thời điểm nào đó, máy chủ TCP ngừng hoạt động mà thậm chí không gửi gói RST và ngăn xếp TCP của người gửi bắt đầu truyền lại gói chưa được xác nhận cuối cùng.


  3. Tất cả các gói khác được ghi vào ổ cắm đó sẽ được xếp hàng đợi trong bộ đệm gửi của ổ cắm.


  4. Theo mặc định, ngăn xếp TCP cố gắng truyền lại gói chưa được xác nhận 15 lần, sử dụng thời gian chờ theo cấp số nhân, dẫn đến thời lượng khoảng 924,6 giây (khoảng 15 phút).


Trong khoảng thời gian 15 phút này, ổ cắm TCP cục bộ vẫn mở và ứng dụng tiếp tục ghi dữ liệu vào đó cho đến khi bộ đệm gửi đầy (thường có dung lượng hạn chế, thường chỉ vài megabyte). Khi ổ cắm cuối cùng được đánh dấu là đã đóng sau tất cả các lần truyền lại, tất cả dữ liệu trong bộ đệm gửi sẽ bị mất .


Điều này là do ứng dụng không còn chịu trách nhiệm về nó sau khi ghi vào bộ đệm gửi và hệ điều hành chỉ cần loại bỏ dữ liệu này.


Ứng dụng chỉ có thể phát hiện kết nối bị hỏng khi bộ đệm gửi của ổ cắm TCP đầy. Trong những trường hợp như vậy, việc cố gắng ghi vào socket sẽ chặn luồng chính của ứng dụng, cho phép ứng dụng xử lý tình huống này.


Tuy nhiên, hiệu quả của phương pháp phát hiện này phụ thuộc vào kích thước của dữ liệu được gửi.


Ví dụ: nếu ứng dụng chỉ gửi một vài byte, chẳng hạn như số liệu, thì ứng dụng đó có thể không lấp đầy hoàn toàn bộ đệm gửi trong khung thời gian 15 phút này.


Vì vậy, làm cách nào để triển khai cơ chế đóng kết nối với thời gian chờ được đặt rõ ràng khi máy chủ TCP ngừng hoạt động để tránh 15 phút truyền lại và mất dữ liệu trong khoảng thời gian này?

Hết thời gian chờ truyền lại TCP bằng tùy chọn ổ cắm

Trong các mạng riêng, việc truyền lại rộng rãi thường không cần thiết và có thể định cấu hình ngăn xếp TCP để chỉ thử một số lần thử lại có giới hạn. Tuy nhiên, cấu hình này áp dụng toàn cầu cho toàn bộ nút. Vì nhiều ứng dụng thường chạy trên cùng một nút nên việc thay đổi giá trị mặc định này có thể dẫn đến những tác dụng phụ không mong muốn.


Một cách tiếp cận chính xác hơn là đặt thời gian chờ truyền lại dành riêng cho ổ cắm của chúng tôi bằng cách sử dụng tùy chọn ổ cắm TCP_USER_TIMEOUT . Bằng cách sử dụng tùy chọn này, ngăn xếp TCP sẽ tự động đóng ổ cắm nếu việc truyền lại không thành công trong thời gian chờ đã chỉ định, bất kể số lần truyền lại TCP tối đa được đặt trên toàn cầu là bao nhiêu.


Ở cấp độ ứng dụng, điều này dẫn đến lỗi nhận được khi cố gắng ghi dữ liệu vào ổ cắm đóng, cho phép xử lý ngăn ngừa mất dữ liệu thích hợp.


Hãy đặt tùy chọn ổ cắm này trong client.rb :

 require 'socket' require 'time' $stdout.sync = true socket = Socket.tcp('server', 1234) # set 5 seconds restransmissions timeout socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_USER_TIMEOUT, 5000) loop do puts "sending message to the socket at #{Time.now}" socket.write "Hello from client\n" sleep 1 end


Ngoài ra, theo quan sát của tôi, tùy chọn ổ cắm TCP_USER_TIMEOUT không khả dụng trên macOS.


Bây giờ, hãy bắt đầu lại mọi thứ với docket compose up và tại một thời điểm nào đó, hãy dừng máy chủ lại bằng docker compose stop server :

 $ docker compose up [+] Running 2/0 ⠿ Container tcp_tests-server-1 Created 0.0s ⠿ Container tcp_tests-client-1 Created 0.0s Attaching to tcp_tests-client-1, tcp_tests-server-1 tcp_tests-server-1 | starting tcp server... tcp_tests-server-1 | started tcp server on port 1234 tcp_tests-server-1 | new client tcp_tests-server-1 | 2024-01-20 12:37:38 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:38 +0000 tcp_tests-server-1 | 2024-01-20 12:37:39 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:39 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:40 +0000 tcp_tests-server-1 | 2024-01-20 12:37:40 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:41 +0000 tcp_tests-server-1 | 2024-01-20 12:37:41 +0000]: Hello from client tcp_tests-server-1 | 2024-01-20 12:37:42 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:42 +0000 tcp_tests-server-1 | 2024-01-20 12:37:43 +0000]: Hello from client tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:43 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:44 +0000 tcp_tests-server-1 | 2024-01-20 12:37:44 +0000]: Hello from client tcp_tests-server-1 exited with code 1 tcp_tests-server-1 exited with code 0 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:45 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:46 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:47 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:48 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:49 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:50 +0000 tcp_tests-client-1 | sending message to the socket at 2024-01-20 12:37:51 +0000 tcp_tests-client-1 | client.rb:11:in `write': Connection timed out (Errno::ETIMEDOUT) tcp_tests-client-1 | from client.rb:11:in `block in <main>' tcp_tests-client-1 | from client.rb:9:in `loop' tcp_tests-client-1 | from client.rb:9:in `<main>' tcp_tests-client-1 exited with code 1


Vào lúc ~ 12:37:45, tôi đã dừng máy chủ và chúng tôi thấy rằng máy khách đã nhận được Errno::ETIMEDOUT gần như sau 5 giây, thật tuyệt.


Hãy chụp một tcpdump bằng docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234 :


tcpdump


Điều đáng chú ý là thời gian chờ thực sự xảy ra trong hơn 5 giây một chút. Điều này là do việc kiểm tra vượt quá TCP_USER_TIMEOUT xảy ra ở lần thử lại tiếp theo. Khi ngăn xếp TCP/IP phát hiện đã vượt quá thời gian chờ, nó sẽ đánh dấu ổ cắm là đã đóng và ứng dụng của chúng tôi nhận được lỗi Errno::ETIMEDOUT


Ngoài ra, nếu bạn đang sử dụng tính năng lưu giữ TCP, tôi khuyên bạn nên xem bài viết này từ Cloudflare . Nó đề cập đến các sắc thái của việc sử dụng TCP_USER_TIMEOUT kết hợp với các phần giữ lại của TCP.