이 기사에서는 TCP 통신의 중요한 측면, 즉 서버가 응답하지 않는 시나리오를 효과적으로 관리하는 방법에 대해 설명합니다. 저는 애플리케이션이 서버로부터 애플리케이션 수준 응답을 받지 않고 TCP를 통해서만 데이터를 보내는 특정 시나리오에 중점을 둡니다.
이 탐색에서는 애플리케이션 관점에서 TCP 통신을 다루며 애플리케이션 계층과 기본 OS 작업을 모두 강조합니다. 서버 인스턴스가 응답하지 않는 동안 데이터 손실을 방지하기 위해 효과적인 시간 초과를 설정하는 방법을 알아봅니다. Ruby로 코드 예제를 제공하겠지만 아이디어는 모든 언어에서 동일하게 유지됩니다.
TCP 소켓을 통해 일관되게 데이터를 전송하는 애플리케이션으로 작업하고 있다고 상상해 보십시오. TCP는 정의된 TCP 스택 구성 내의 전송 수준에서 패킷 전달을 보장하도록 설계되었지만 이것이 애플리케이션 수준에서 의미하는 바를 고려하는 것은 흥미롭습니다.
이를 더 잘 이해하기 위해 Ruby를 사용하여 샘플 TCP 서버와 클라이언트를 구성해 보겠습니다. 이를 통해 우리는 실제 의사소통 과정을 관찰할 수 있습니다.
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
그리고 이 Dockerfile
사용하여 이 설정을 컨테이너에 캡슐화해 보겠습니다.
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:
이제 docker compose up
사용하여 이를 쉽게 실행하고 클라이언트가 메시지를 보내고 서버가 메시지를 받는 방법을 로그에서 확인할 수 있습니다.
$ 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
지금까지는 꽤 쉽죠?
그러나 활성 연결에 대한 서버 오류를 시뮬레이션하면 상황이 더욱 흥미로워집니다.
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
이제 서버는 오프라인이지만 클라이언트는 연결이 여전히 활성 상태인 것처럼 동작하며 주저 없이 계속해서 소켓에 데이터를 보냅니다.
이로 인해 왜 이런 일이 발생하는지 의문이 생깁니다. 논리적으로 클라이언트는 TCP가 패킷에 대한 승인을 받지 못해 연결 종료를 요청하므로 짧은 시간(몇 초) 내에 서버의 가동 중지 시간을 감지해야 합니다.
그러나 실제 결과는 이러한 기대와 달랐다.
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
실제로 클라이언트는 15분 동안 연결 중단을 인식하지 못할 수도 있습니다!
감지가 지연되는 원인은 무엇입니까? 이유를 이해하기 위해 더 자세히 살펴보겠습니다.
이 사례를 완전히 다루기 위해 먼저 기본 원칙을 다시 살펴보고 클라이언트가 TCP를 통해 데이터를 전송하는 방법을 살펴보겠습니다.
다음은 TCP 흐름을 보여주는 기본 다이어그램입니다.
연결이 설정되면 각 메시지의 전송에는 일반적으로 두 가지 주요 단계가 포함됩니다.
클라이언트는 PSH(푸시) 플래그가 표시된 메시지를 보냅니다.
서버는 ACK(승인) 응답을 다시 전송하여 수신을 확인합니다.
다음은 애플리케이션에 의한 TCP 소켓 열기 및 이를 통한 후속 데이터 전송을 보여주는 단순화된 시퀀스 다이어그램입니다.
애플리케이션은 다음 두 가지 작업을 수행합니다.
TCP 소켓 열기
열린 소켓으로 데이터 보내기
예를 들어 Ruby의 Socket.tcp(host, port)
명령을 사용하여 TCP 소켓을 열 때 시스템은 socket(2)
시스템 호출을 사용하여 동기적으로 소켓을 생성한 다음 connect(2)
시스템 호출을 통해 연결을 설정합니다. .
데이터 전송의 경우, 애플리케이션에서 socket.write('foo')
와 같은 명령을 사용하면 기본적으로 메시지가 소켓의 전송 버퍼에 배치됩니다. 그런 다음 성공적으로 대기열에 추가된 바이트 수를 반환합니다. 네트워크를 통해 대상 호스트로의 이 데이터의 실제 전송은 TCP/IP 스택에 의해 비동기적으로 관리됩니다.
이는 애플리케이션이 소켓에 쓸 때 네트워크 작업에 직접 관여하지 않으며 연결이 여전히 활성 상태인지 실시간으로 알 수 없음을 의미합니다. 수신되는 유일한 확인은 메시지가 TCP 전송 버퍼에 성공적으로 추가되었다는 것입니다.
서버가 ACK 플래그로 응답하지 않으므로 TCP 스택은 승인되지 않은 마지막 패킷의 재전송을 시작합니다.
여기서 흥미로운 점은 기본적으로 TCP가 지수 백오프를 사용하여 15번의 재전송을 수행하므로 거의 15분의 재시도 시간이 걸린다는 것입니다.
호스트에 설정된 재시도 횟수를 확인할 수 있습니다.
$ sysctl net.ipv4.tcp_retries2 net.ipv4.tcp_retries2 = 15
문서를 살펴보니 명확해졌습니다. ip-sysctl.txt 문서에 따르면:
기본값 15는 924.6초의 가상 시간 초과를 생성하며 이는 유효 시간 초과의 하한입니다. TCP는 가상의 시간 초과를 초과하는 첫 번째 RTO에서 효과적으로 시간 초과됩니다.
이 기간 동안 로컬 TCP 소켓은 활성화되어 데이터를 받아들입니다. 모든 재시도가 수행되면 소켓이 닫히고 애플리케이션은 소켓에 무엇이든 보내려고 시도할 때 오류를 수신합니다.
FIN 또는 RST TCP 플래그를 보내지 않거나 연결 문제가 있는 경우 TCP 서버가 예기치 않게 다운되는 시나리오는 매우 일반적입니다. 그렇다면 왜 그러한 상황은 종종 눈에 띄지 않습니까?
왜냐하면 대부분의 경우 서버는 애플리케이션 수준에서 일부 응답으로 응답하기 때문입니다. 예를 들어, HTTP 프로토콜에서는 서버가 모든 요청에 응답해야 합니다. 기본적으로 connection.get
과 같은 코드가 있으면 두 가지 주요 작업이 수행됩니다.
TCP 소켓의 전송 버퍼에 페이로드를 씁니다.
이 시점부터 운영 체제의 TCP 스택은 TCP 보장을 통해 이러한 패킷을 원격 서버에 안정적으로 전달하는 역할을 담당합니다.
원격 서버의 TCP 수신 버퍼에서 응답을 기다리는 중
일반적으로 애플리케이션은 동일한 TCP 소켓의 수신 버퍼에서 비차단 읽기를 사용합니다.
이 접근 방식은 문제를 상당히 단순화합니다. 이러한 경우 애플리케이션 수준에서 쉽게 시간 제한을 설정하고 정의된 시간 내에 서버에서 응답이 없으면 소켓을 닫을 수 있기 때문입니다.
그러나 서버로부터 어떤 응답도 기대하지 않는 경우(TCP 승인 제외) 애플리케이션 수준에서 연결 상태를 확인하는 것이 덜 간단해집니다.
지금까지 우리는 다음을 설정했습니다.
애플리케이션은 TCP 소켓을 열고 정기적으로 데이터를 씁니다.
어떤 시점에서 TCP 서버는 RST 패킷도 보내지 않고 다운되고, 보낸 사람의 TCP 스택은 승인되지 않은 마지막 패킷을 재전송하기 시작합니다.
해당 소켓에 기록된 다른 모든 패킷은 소켓의 전송 버퍼에 대기됩니다.
기본적으로 TCP 스택은 지수 백오프를 사용하여 확인되지 않은 패킷을 15번 재전송하려고 시도하며 그 결과 지속 시간은 약 924.6초(약 15분)입니다.
이 15분 동안 로컬 TCP 소켓은 열린 상태로 유지되며 전송 버퍼가 가득 찰 때까지(일반적으로 용량이 몇 메가바이트에 불과함) 애플리케이션은 계속해서 여기에 데이터를 씁니다. 모든 재전송 후에 소켓이 결국 닫힌 것으로 표시되면 전송 버퍼의 모든 데이터가 손실됩니다 .
이는 응용 프로그램이 전송 버퍼에 쓴 후에는 더 이상 이에 대한 책임을 지지 않으며 운영 체제는 이 데이터를 단순히 삭제하기 때문입니다.
애플리케이션은 TCP 소켓의 송신 버퍼가 가득 찼을 때만 연결이 끊어졌음을 감지할 수 있습니다. 이러한 경우 소켓에 쓰기를 시도하면 애플리케이션의 기본 스레드가 차단되어 상황을 처리할 수 있습니다.
그러나 이 탐지 방법의 효율성은 전송되는 데이터의 크기에 따라 달라집니다.
예를 들어 애플리케이션이 메트릭과 같은 몇 바이트만 전송하는 경우 이 15분 기간 내에 전송 버퍼를 완전히 채우지 못할 수 있습니다.
그렇다면 이 기간 동안 15분간의 재전송과 데이터 손실을 방지하기 위해 TCP 서버가 다운되었을 때 명시적으로 설정된 시간 초과로 연결을 닫는 메커니즘을 어떻게 구현할 수 있습니까?
개인 네트워크에서는 일반적으로 광범위한 재전송이 필요하지 않으며 제한된 수의 재시도만 시도하도록 TCP 스택을 구성할 수 있습니다. 그러나 이 구성은 전체 노드에 전역적으로 적용됩니다. 여러 애플리케이션이 동일한 노드에서 실행되는 경우가 많기 때문에 이 기본값을 변경하면 예기치 않은 부작용이 발생할 수 있습니다.
보다 정확한 접근 방식은 TCP_USER_TIMEOUT 소켓 옵션을 사용하여 소켓에 대해서만 재전송 시간 제한을 설정하는 것입니다. 이 옵션을 사용하면 전역적으로 설정된 최대 TCP 재전송 횟수에 관계없이 지정된 제한 시간 내에 재전송이 성공하지 못하면 TCP 스택이 자동으로 소켓을 닫습니다.
애플리케이션 수준에서는 닫힌 소켓에 데이터를 쓰려고 할 때 오류가 수신되어 적절한 데이터 손실 방지 처리가 가능합니다.
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
또한 내 관찰에 따르면 macOS에서는 TCP_USER_TIMEOUT 소켓 옵션을 사용할 수 없습니다.
이제 docket compose up
사용하여 모든 것을 다시 시작하고 어느 시점에서 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
~12:37:45에 서버를 중지했고 클라이언트가 거의 5초 만에 Errno::ETIMEDOUT
얻는 것을 보았습니다. 훌륭했습니다.
docker exec -it tcp_tests-client-1 tcpdump -i any tcp port 1234
사용하여 tcpdump를 캡처해 보겠습니다.
시간 초과가 실제로 5초가 조금 넘는 후에 발생한다는 점은 주목할 가치가 있습니다. 이는 다음 재시도 시에 TCP_USER_TIMEOUT 초과 여부를 확인하기 때문입니다. TCP/IP 스택이 시간 초과가 초과되었음을 감지하면 소켓이 닫힌 것으로 표시되고 애플리케이션은 Errno::ETIMEDOUT
오류를 수신합니다.
또한 TCP keepalive를 사용하는 경우 Cloudflare에서 이 기사를 확인하는 것이 좋습니다. TCP keepalive와 함께 TCP_USER_TIMEOUT을 사용하는 미묘한 차이를 다룹니다.