본문 바로가기

Project/기록

CS 이론으로 풀어낸 EKS 소켓 서버 최적화: DNS, 커널, 그리고 오토스케일링의 함정

EKS 환경 Centrifugo 소켓 서버 잠재 장애 분석 및 해결 방안 회고

서론: 선제적 장애 대응의 중요성
Amazon EKS 환경에서 대규모 실시간 소켓 서버(Centrifugo)를 운영하는 것은 단순히 서버의 CPU나 메모리 자원을 증설하는 것 이상의 기술적 복잡성을 내포합니다. 이는 운영체제 커널의 한계, 네트워크 통신의 물리적 비용, 그리고 쿠버네티스 생명주기와의 상호작용을 깊이 있게 이해해야 하는 과제입니다. 해당 글은 장애가 발생한 후의 사후 대응 기록이 아닙니다. 대신, 컴퓨터 공학(CS)의 기본 이론에 근거하여 발생 가능한 잠재적 장애 요인들을 사전에 식별하고, 이를 코드와 인프라 레벨에서 선제적으로 해결한 과정을 상세히 기록한 내용입니다.
해당 글은 다음과 같은 네 가지 핵심 잠재 장애 영역을 분석했습니다.
  • 연결 생성 비용으로 인한 성능 저하
  • 파일 디스크립터(FD) 고갈로 인한 서비스 중단
  • 배포 및 스케일링 시 불안정한 연결 종료
  • 부적절한 메트릭 기반의 오토스케일링 실패
각 섹션은 잠재적 문제 상황을 정의하고, CS 이론에 기반하여 근본 원인을 분석한 뒤, 구체적인 해결 방안과 그로 인한 개선 효과를 순차적으로 설명하는 구조로 구성되어 있습니다.

연결 생성 비용으로 인한 성능 저하

전략적 중요성 분석

백엔드 서비스와 소켓 서버 간의 내부 통신 효율성은 전체 시스템의 응답성과 확장성을 결정하는 핵심 요소입니다. 이 과정에서 발생하는 아주 작은 비효율이라도 대규모 트래픽 환경에서는 증폭되어 전체 시스템의 성능 저하를 유발하는 병목 지점으로 작용할 수 있습니다. 따라서 이 구간을 최적화를 고민하는 것은 안정적인 대용량 서비스의 기반을 다질 것입니다.

문제 상황 정의

백엔드 애플리케이션(NestJS)에서 API 요청이 발생할 때마다 소켓 서버(Centrifugo)로의 새로운 연결을 생성하는 기존 방식은 잠재적 위험을 내포하고 있었습니다. 모든 요청은 DNS Lookup과 TCP 3-Way Handshake 과정을 반복적으로 수행하게 되며, 이는 불필요한 네트워크 지연 시간을 유발하고 양쪽 서버의 CPU 자원을 지속적으로 소모시키는 비효율의 원인이었습니다. 또한, 잦은 연결 생성과 해제는 찰나의 순간에 수많은 임시 포트(Ephemeral Port)를 점유하게 되어, 트래픽 폭주 시 포트 고갈(Port Exhaustion)로 이어질 수 있는 위험도 존재했습니다.

근본 원인 분석 

1. DNS 조회 비용과 오버엔지니어링의 경계
EKS 클러스터 내부 통신은 외부망을 거치지 않고 내부 CoreDNS를 통해 Pod의 Private IP를 조회합니다. 아무리 내부망이라 할지라도 도메인을 IP로 변환하는 과정은 RTT(Round-Trip Time)를 필요로 하는 비용입니다. 다만, Kubernetes Service(ClusterIP)를 사용하는 환경에서 Endpoint는 고정적이므로, 과도한 DNS 캐싱 로직 구현은 자칫 오버엔지니어링이 될 수 있다는 점도 함께 고려해야 했습니다.

2. TCP 핸드쉐이크 비용 (및 TLS 오버헤드)
신뢰성 있는 데이터 전송을 위해 TCP 프로토콜은 SYN → SYN-ACK → ACK 순서로 진행되는 3-Way Handshake 과정을 통해 연결을 수립합니다. 이 과정은 커널 레벨의 인터럽트와 CPU 연산을 필요로 하며, 만약 암호화 통신(HTTPS)까지 포함된다면 TLS Key Exchange를 위한 CPU 비용은 더욱 가중됩니다. 대규모 요청 환경에서 이를 매번 수행하는 것은 서버 리소스를 낭비하는 주범입니다.

해결 방안 및 구현 상세

1. 내부 DNS 캐싱 적용 
반복적인 DNS 조회를 제거하기 위해 NestJS의 http 라이브러리 레벨에서 DNS Cache 적용을 검토했습니다. EKS 환경의 Service IP 불변성을 고려했을 때 이 로직이 필수적인지 고민했으나, 혹시 모를 CoreDNS의 부하를 줄이고 애플리케이션 레벨에서 즉각적인 IP Resolution을 보장하기 위해 방어적으로 캐싱 로직을 고려했습니다. (단, Pod 재배포 상황을 대비해 적절한 TTL 설정 포함)

2. 영구적 연결 (Keep-Alive) 활성화
DNS 캐싱 고민을 넘어, 문제를 가장 확실하게 해결하는 방법은 **'연결 자체를 끊지 않는 것'**이었습니다. http.Agent 설정에서 keepAlive: true 옵션을 활성화하여 백엔드 애플리케이션의 커넥션 풀(Connection Pool)을 구성했습니다.
한번 수립된 TCP 연결을 재사용(Reuse)함으로써, 후속 요청부터는 DNS 조회 과정과 TCP Handshake 비용을 동시에 '0'으로 만듭니다. 이는 DNS 캐싱이 내포할 수 있는 오버엔지니어링 이슈를 상쇄하며, 가장 적은 비용으로 통신 효율을 극대화하는 방법입니다.

개선 효과

위 조치를 통해 백엔드와 소켓 서버 간 통신에서 발생하는 DNS 쿼리와 TCP 핸드쉐이크 비용은 최초 연결 1회를 제외하고 모두 제거되었습니다. 특히 Keep-Alive 적용을 통해 "DNS 조회를 할 필요조차 없는 상태"를 만듦으로써 통신 지연 시간을 획기적으로 단축하고 서버의 CPU 부담을 완화했습니다. 결과적으로 시스템은 더 적은 자원으로 더 많은 연결과 요청을 처리할 수 있는 견고한 기반을 마련하게 되었습니다.


파일 디스크립터(FD) 고갈로 인한 서비스 중단

전략적 중요성 분석

서버의 안정성을 평가할 때 CPU나 메모리와 같이 대시보드에서 쉽게 확인 가능한 가시적인 리소스에만 집중하기 쉽습니다. 하지만 운영체제 커널 깊숙한 곳에 존재하는 제약 조건, 특히 파일 디스크립터(File Descriptor, FD)는 대규모 소켓 기반 서비스에서 '보이지 않는 암초'와 같습니다. FD의 한계를 이해하고 사전에 관리하는 것은 대용량 트래픽을 처리하는 소켓 서버 안정성 확보의 가장 기초적이면서도 핵심적인 과제입니다.

문제 상황 정의

CPU와 메모리 자원이 충분히 여유가 있음에도 불구하고, 시스템이 더 이상 새로운 사용자 연결을 수락하지 못하고 멈춰버리는 심각한 장애 시나리오를 예측했습니다. 이는 동시 접속자 수가 리눅스 시스템의 프로세스당 기본 파일 열기 허용량인 1,024개를 초과하는 순간 Too many open files 에러가 발생하며, 사실상 서비스가 중단되는 상황입니다. 하드웨어 스펙이 아무리 좋아도 소프트웨어적 제약(Soft Limit)에 의해 성능이 봉인되는 역설적인 상황입니다.

근본 원인 분석

이 문제의 근본 원인은 리눅스 운영체제의 핵심 설계 철학인 "모든 것은 파일(Everything is a file)"에서 비롯됩니다. 커널은 네트워크 소켓 연결, 파이프, 일반 파일 등 모든 I/O 인터페이스를 '파일'로 추상화하여 관리합니다. 따라서 클라이언트와의 소켓 연결 하나하나는 곧 하나의 파일 디스크립터 자원을 점유하게 됩니다. 리눅스는 시스템 보호를 위해 ulimit이라는 커널 파라미터를 통해 프로세스당 열 수 있는 파일의 최대 개수를 제한하며, 이 기본값이 1,024개로 매우 보수적으로 설정되어 있는 것이 병목의 원인이 될 수 있습니다.

해결 방안 및 구현 상세 (장애 범위를 줄이기 위함)

1. 운영체제 및 컨테이너 환경 튜닝 (논리적 한계 해제)
현재는 1024개 이상의 값을 해제하지 않았으나, 추후 장애 상황이 발생된다면 1차적으로 먼저 늘릴 예정입니다. 방식은 Docker 이미지를 빌드하는 과정과 EKS Node의 시작 스크립트 설정에서 ulimit -n (프로세스당 파일 제한)과 fs.file-max (시스템 전체 파일 제한) 값을 65,535개 이상으로 충분히 높게 설정하여 커널 레벨의 제약을 해제 할 것입니다. 단순히 숫자를 늘리는 작업을 넘어, OS가 가진 논리적 빗장을 푸는 선결 작업을 진행 할 것입니다. 이는 장애 발생시 예측 범위를 줄여 줄 것입니다.

2. 자원 요구량 산정 (물리적 한계 검증)
FD 한계치 상향은 논리적 수용량의 천장을 제거하는 것일 뿐, 실제 하드웨어가 이를 버틸 수 있는지는 별개의 문제입니다. 따라서 메모리 산정은 논리적 한계 해제를 물리적 자원으로 뒷받침하는 필수적인 후속 조치입니다. Centrifugo 공식 문서에 따르면 소켓 연결 하나당 약 50KB의 메모리를 점유합니다. 이를 기반으로 목표 동시 접속자 수에 필요한 메모리를 정밀하게 산정했습니다.

  • 산정 공식: 동시 접속자 10만 명 가정 시, 필요한 총 메모리 = 100,000 users * 50 KB/user ≈ 5 GB

단순히 ulimit만 높일 경우 발생할 수 있는 OOM(Out of Memory) 사태를 방지하기 위해, 이 계산 결과를 바탕으로 Kubernetes Pod의 Memory Request 및 Limit 값을 설정했습니다. 성공적인 대용량 서비스는 논리적 한계(FD)와 물리적 한계(Memory)의 균형을 맞추는 엔지니어링임을 명확히 인지하고 작업을 진행 할 것입니다.

개선 효과

파일 디스크립터 한계치를 대폭 상향 조정하고 이에 맞는 메모리 자원을 확보함으로써, 서버가 물리적 메모리가 허용하는 한도 내에서 안정적으로 수만 개의 동시 연결을 수용할 수 있는 장애 대응을 마련했습니다. 이를 통해 OS 설정 미흡으로 인한 서비스 중단 위험을 원천적으로 제거했습니다. 안정적인 연결 수용 능력을 확보한 만큼, 이제 이 연결들을 어떻게 Gracefully하게 종료시킬 것인지에 대한 다음 과제로 넘어갈 수 있었습니다.


배포 및 스케일링 시 불안정한 연결 종료

전략적 중요성 분석

서버의 신규 배포, 확장(Scale-out), 축소(Scale-in)는 동적인 클라우드 네이티브 환경에서 피할 수 없는 일상적인 이벤트입니다. 이러한 시스템 변화 속에서 기존에 연결된 사용자의 세션을 얼마나 안정적으로 처리하고 전환하는지는 피크 타임 이벤트 중의 세션 연속성을 보장하고 사용자 신뢰를 확보하는 등 서비스 신뢰도와 직결되는 매우 중요한 문제입니다.

문제 상황 정의

새로운 버전 배포나 트래픽 감소로 인해 Pod가 종료될 때, 두 가지 심각한 잠재 장애 시나리오가 존재했습니다. 첫째, Pod가 즉시 종료되면서 기존에 연결된 클라이언트의 소켓이 강제로 끊겨 사용자에게 에러를 발생시키는 상황입니다. 둘째, 'Thundering Herd' 현상의 일환으로, 단시간에 수만 개의 연결이 동시에 종료되고 재접속을 시도하면서 TIME_WAIT 상태의 소켓이 급증하여, 서버가 사용할 수 있는 임시 포트(Ephemeral Port)가 고갈되는 장애 상황입니다.

근본 원인 분석

1. 프로세스 시그널(SIGTERM)과 종료 유예의 한계
EKS(Kubernetes)는 Pod를 종료시키기 위해 먼저 해당 Pod의 프로세스에 SIGTERM 신호를 보냅니다. 이는 "이제 곧 종료될 예정이니 하던 작업을 우아하게 마무리하라"는 의미의 신호입니다. 애플리케이션이 이 신호를 인지하고 적절히 처리하지 않으면, terminationGracePeriodSeconds로 설정된 유예 시간이 지난 후 커널은 강제로 프로세스를 종료(SIGKILL)시키며, 이 과정에서 모든 연결은 비정상적으로 끊어지게 됩니다. 특히 HTTP와 달리 WebSocket은 영구적인 연결을 유지하므로, 별도의 조치 없이는 이 유예 시간 동안 연결이 자연적으로 종료되지 않는다는 특성이 있습니다.

2. TCP 상태 머신과 TIME_WAIT (Active Close의 비용)
TCP 프로토콜의 설계상, 연결 종료를 먼저 요청한 쪽은 소켓을 즉시 닫지 않고 일정 시간(리눅스 기본 60초) 동안 TIME_WAIT 상태로 유지합니다. 이는 네트워크상에 남아있을지 모르는 지연 패킷으로 인한 데이터 무결성 문제를 방지하기 위한 정상적인 동작입니다. 하지만 수만 개의 연결이 동시에 종료되는 대규모 환경에서는 이 TIME_WAIT 상태의 소켓들이 임시 포트 자원을 점유하게 되어, 정작 살아서 서비스를 받아야 할 새로운 Pod들이 외부(Redis 등)와 통신할 포트가 없어지는 포트 고갈 현상으로 이어질 수 있습니다.

해결 방안 및 구현 상세

1. Graceful Shutdown 구현
수동적 대기가 아닌 능동적 종료(Drain)
SIGTERM 신호를 올바르게 처리하도록 애플리케이션 로직을 수정했습니다. Centrifugo 서버가 SIGTERM을 수신하면 Kubernetes Service Endpoint에서 제외되어 새로운 연결 유입은 자동으로 차단됩니다. 이와 동시에 기존 연결에 대해서는 단순히 대기하는 것이 아니라, 서버가 클라이언트에게 WebSocket Close Frame을 전송하여 클라이언트가 스스로 연결을 끊고 건강한 다른 Pod로 재접속하도록 유도하는 'Connection Draining' 로직을 구현했습니다. NestJS 백엔드 또한 onModuleDestroy 훅을 통해 진행 중인 요청을 완료한 후 리소스를 정리하도록 설정했습니다.

2. TIME_WAIT 소켓 재사용
포트 고갈 문제를 해결하기 위해, EKS Node의 리눅스 커널 파라미터를 튜닝했습니다. net.ipv4.tcp_tw_reuse = 1 설정을 활성화하여, TCP 타임스탬프(tcp_timestamps)가 활성화된 상태에서 커널이 프로토콜상 안전하다고 판단하는 TIME_WAIT 상태의 소켓 포트를 새로운 연결을 위해 재사용할 수 있도록 허용했습니다. 이는 대규모 재접속 상황에서도 시스템이 안정적으로 포트를 할당할 수 있도록 보장하는 핵심적인 조치입니다.

개선 효과

Graceful Shutdown 구현을 통해 서버 배포나 스케일링 중에도 사용자는 갑작스러운 에러가 아닌, 부드러운 재접속 경험을 하게 되었습니다. 또한, TIME_WAIT 소켓 재사용 설정을 통해 대규모 재접속 이벤트가 발생하더라도 포트 고갈 없이 안정적인 서비스를 지속할 수 있는 복원력을 확보했습니다. 이로써 시스템의 확장과 축소가 사용자에게 영향을 주지 않는 안정적인 운영 기반이 마련되었으며, 이제 어떤 기준으로 확장을 결정할 것인지에 대한 다음 주제로 자연스럽게 넘어갑니다.


부적절한 메트릭 기반의 오토스케일링 실패

전략적 중요성 분석

클라우드 환경의 핵심 가치인 오토스케일링(Autoscaling)의 성공은 시스템의 실제 병목 지점을 얼마나 정확하게 측정하고 이를 스케일링 기준으로 삼는지에 달려있습니다. 만약 잘못된 메트릭을 기준으로 스케일링 정책을 수립한다면, 평상시에는 불필요한 자원을 낭비하고 트래픽이 급증하는 순간에는 제때 대응하지 못해 서비스 불능으로 이어지는 최악의 결과를 초래할 수 있습니다.

문제 상황 정의

우리는 Redis Pub/Sub을 이용해 여러 Centrifugo Pod가 유기적으로 데이터를 주고받는 분산 환경을 구축했으며, 이 클러스터의 탄력적 확장이 중요 과제였습니다. 하지만 일반적인 웹 서버처럼 CPU 사용률만을 기준으로 HPA(HorizontalPodAutoscaler)를 설정할 경우, 소켓 서버는 오토스케일링의 함정에 빠지게 됩니다. 예를 들어, 수만 명의 사용자가 연결만 유지한 채 별다른 메시지를 주고받지 않는(Idle) 상황을 가정해 보겠습니다. 이 경우 서버의 CPU 사용량은 매우 낮지만, 각 연결이 점유하는 메모리와 파일 디스크립터는 이미 임계치에 가까워져 있을 수 있습니다. HPA는 CPU가 낮으므로 스케일 아웃을 트리거하지 않고, 이 상태에서 갑작스러운 트래픽 유입이 발생하면 서버는 신규 연결을 감당하지 못하고 OOM(Out Of Memory) 등으로 즉시 다운되는 심각한 장애 시나리오가 발생합니다.

근본 원인 분석

이 문제의 원인은 리소스 병목의 종류를 정확히 이해하지 못한 데 있습니다. CPU 연산이 중심이 되는(CPU-bound) 일반 웹 서버와 달리, 소켓 서버의 주된 역할은 다수의 연결을 '유지(Stateful)'하는 것입니다.

왜 연결 유지에 CPU를 쓰지 않을까요? 최신 리눅스 커널은 epoll과 같은 비동기 I/O Multiplexing 기술을 사용하기 때문입니다. 이를 통해 OS는 실제 데이터가 오가는 활성 소켓에 대해서만 CPU 인터럽트를 발생시키며, 단순히 연결되어 있는 Idle 상태의 소켓들에 대해서는 CPU 리소스를 거의 소모하지 않습니다. 따라서 소켓 서버의 실질적인 병목 지점은 CPU가 아니라 메모리 사용량과 활성 연결의 수(Connection Count)가 됩니다. CPU 사용률은 실제 부하를 제대로 반영하지 못하는, 왜곡될 수 있는 후행 지표에 불과합니다.

해결 방안 및 구현 상세

1) 사용자 정의 메트릭(Custom Metrics) 수집 파이프라인 구축
CPU 사용률을 대체할 정확한 부하 지표를 확보하기 위해 Prometheus 모니터링 시스템을 활용했습니다. Centrifugo 서버가 외부에 노출하는 메트릭 중 '현재 연결된 클라이언트 소켓의 총 수'를 핵심 지표로 정의했습니다. 이 메트릭을 Kubernetes HPA가 인식할 수 있도록 prometheus-adapter를 통해 Custom Metrics API로 변환하여 제공하는 파이프라인을 구축했습니다.

2) 다중 메트릭 HPA 정책 수립 수집된 사용자 정의 메트릭을 기반으로 Kubernetes HPA 정책을 새롭게 수립했습니다. 단일 메트릭의 한계를 보완하기 위해 CPU 사용률과 활성 소켓 수를 모두 사용하는 OR 조건의 다중 정책을 적용했습니다.

  • HPA Scale-Out 정책: "CPU 평균 사용률이 50%에 도달하거나, Pod당 활성 소켓 수가 20,000개에 도달하면" 새로운 Pod를 증설(Scale Out)한다.

이 다중 조건 정책은 시스템이 연산 중심(CPU-bound)으로 포화 상태가 되거나, 연결 유지(Memory-bound)로 포화 상태가 되는 두 가지 주요 장애 시나리오를 모두 포괄하는 이중 안전망 역할을 합니다.

개선 효과

새로운 HPA 정책을 통해, CPU가 유휴 상태일지라도 실제 시스템 부하의 핵심 선행 지표인 '연결 수'가 임계치에 도달하면 선제적으로 인스턴스를 증설하는 지능적인 스케일링 시스템을 구축했습니다. 이는 논리적 한계(Socket Limit) 또는 물리적 한계(Memory Limit)에 도달하기 전에 미리 시스템 용량을 확보하는 것을 의미합니다. 결과적으로 예측 불가능한 트래픽 급증 상황에서도 OOM Kill을 사전에 방지하며 사용자의 영향을 최소화하는 안정적인 서비스를 제공할 수 있게 되었습니다.


결론

핵심 성과 요약

본 보고서에서 다룬 네 가지 잠재 장애 시나리오와 그 해결 방안을 통해, 우리는 이론적 지식을 실제 운영 환경에 적용하여 시스템의 안정성과 예측 가능성을 크게 향상시켰습니다. 핵심적인 개선 사항은 아래 표와 같습니다.

잠재 장애 시나리오 CS 이론 기반 원인 핵심 해결 방안
연결 생성 비용으로 인한 성능 저하 반복적인 DNS 조회 및 TCP 3 Way Handshake의 높은 비용 DNS 캐싱의 한계를 넘어, Keep-Alive를 통한 연결 재사용으로 비용 0으로 수렴
파일 디스크립터(FD) 고갈로 인한 서비스 중단 리눅스의 'Everything is a file' 철학과 낮은 ulimit 기본값 커널 파라미터(ulimit, fs.file-max) 상향 및 이에 따른 물리 메모리 요구량 정밀 산정
배포 시 불안정한 연결 종료 SIGTERM 수신 시 WebSocket의 영구 연결 특성 간과 및 Active Close로 인한 TIME_WAIT 단순 대기가 아닌 클라이언트에게 종료를 알리는 'Connection Draining' 구현 및 tcp_tw_reuse 활성화
부적절한 메트릭 기반의 오토스케일링 실패 epoll 기반 소켓 서버의 특성(CPU Idle)과 HPA 기준(CPU-bound)의 불일치 Prometheus Adapter를 통해 커스텀 메트릭(연결 수)을 K8s API로 변환하여 다중 조건 HAP 정책 수