본문 바로가기

Project/기록

소켓 서버 지탱하기: OS 커널부터 K8s 오케스트레이션까지의 선제적 최적화

Amazon EKS 환경에서 대규모 실시간 소켓 서버(Centrifugo)를 운영하는 것은 단순히 서버의 CPU나 메모리 자원을 증설하는 것 이상의 기술적 복잡성을 내포합니다. 이는 운영체제(OS) 커널의 한계, 네트워크 통신의 물리적 비용, 그리고 쿠버네티스(Kubernetes) 생명주기와의 상호작용을 깊이 있게 이해해야만 풀 수 있는 퍼즐과 같습니다.

이 글은 장애가 발생한 후 헐레벌떡 수습한 사후 대응 기록이 아닙니다. 컴퓨터 공학(CS)의 기본 이론에 근거하여 대규모 트래픽이 몰렸을 때 발생할 수 있는 잠재적 장애 요인들을 사전에 식별하고, 이를 코드와 인프라 레벨에서 선제적으로 차단한 아키텍처 개선기입니다.


DNS와 TCP 핸드쉐이크 제거

백엔드 서비스(NestJS)와 소켓 서버 간의 내부 통신 효율성은 전체 시스템의 응답성과 확장성을 결정하는 핵심 요소입니다. 이 구간의 아주 작은 비효율도 대규모 트래픽 환경에서는 전체 시스템을 갉아먹는 병목이 됩니다.

기존 방식은 백엔드에서 API 요청이 발생할 때마다 소켓 서버로 새로운 연결을 생성하고 있었습니다. 아무리 EKS 내부망의 CoreDNS를 통한 통신이라 해도, 도메인을 IP로 변환하는 DNS Lookup 과정은 엄연히 RTT(Round-Trip Time)를 소모합니다.

더 큰 문제는 TCP 3-Way Handshake 비용입니다. 신뢰성 있는 전송을 위해 매번 SYN → SYN-ACK → ACK 과정을 거쳐야 하며, 암호화 통신(HTTPS)이 포함된다면 TLS Key Exchange를 위한 CPU 연산 비용까지 가중됩니다. 또한, 잦은 연결 생성과 해제는 찰나의 순간에 수많은 임시 포트를 점유하여 포트 고갈 위험을 높입니다.

해결 방안: 영구적 연결(Keep-Alive)의 활성화 내부 DNS 캐싱 로직(http 라이브러리 레벨 적용)도 고려했으나, K8s Service IP의 불변성을 감안할 때 자칫 오버엔지니어링이 될 수 있었습니다. 문제를 가장 확실하게 해결하는 방법은 '연결 자체를 끊지 않는 것'이었습니다.

// websocket.module.ts

import * as http from 'node:http'
import { LookupFunction } from 'node:net'
import { HttpModule } from '@nestjs/axios'
import { Module } from '@nestjs/common'
import CacheableLookup from 'cacheable-lookup'
import { WebSocketService } from './web.socket.service'

const cacheable = new CacheableLookup({
  maxTtl: 30,
  errorTtl: 3,
})

@Module({
  imports: [
    HttpModule.register({
      httpAgent: new http.Agent({
        keepAlive: true,
        keepAliveMsecs: 1000,
        maxSockets: 20,
        maxFreeSockets: 3,
        lookup: (hostname, options, callback) => {
          return (cacheable.lookup as LookupFunction)(
            hostname,
            options,
            callback,
          )
        },
      }),
      timeout: 5000,
      maxRedirects: 5,
    }),
  ],
  providers: [WebSocketService],
  exports: [WebSocketService],
})
export class WebSocketModule {}

백엔드의 http.Agent 설정에서 keepAlive: true 옵션을 활성화하여 커넥션 풀(Connection Pool)을 구성했습니다. 한번 수립된 TCP 연결을 재사용(Reuse)함으로써, 후속 요청부터는 DNS 조회와 TCP 핸드쉐이크 비용을 동시에 '0'으로 수렴시켰습니다. 통신 지연 시간을 획기적으로 단축한 가장 우아한 해결책이었습니다.


파일 디스크립터(FD)와 물리적 메모리의 교차점

대시보드에 보이는 CPU나 메모리가 널널하다고 해서 서버가 안전한 것은 아닙니다. 리눅스 시스템의 핵심 설계 철학인 "모든 것은 파일(Everything is a file)"에 따라, 클라이언트와의 소켓 연결 하나하나는 곧 파일 디스크립터(FD) 자원을 점유합니다.

리눅스는 기본적으로 프로세스당 열 수 있는 파일 개수를 1,024개로 보수적으로 제한(ulimit)합니다. 동시 접속자가 이를 초과하는 순간 Too many open files 에러와 함께 서비스가 중단됩니다.

해결 방안: 논리적 한계 해제와 물리적 자원 검증 Docker 이미지 빌드 과정과 EKS Node 시작 스크립트에서 프로세스당 제한인 ulimit -n과 시스템 전체 제한인 fs.file-max 값을 65,535개 이상으로 대폭 상향하여 커널 레벨의 논리적 빗장을 풀었습니다.

# 1. 파일 디스크립터(FD) 논리적 한계 해제 검증
$ kubectl exec <pod name> -n <namespace> -- sh -c "ulimit -n"
65536

# 2. OS 시스템 전체의 최대 파일 허용량 검증
$ kubectl exec <pod name> -n <namespace> -- cat /proc/sys/fs/file-max
9223372036854775807

하지만 ulimit 설정은 기초적인 방어에 불과합니다. 진정한 안정성은 논리적 한계 해제가 '물리적 한계(Memory)'로 뒷받침될 때 완성됩니다. Centrifugo 소켓 연결 하나당 약 50KB의 메모리를 점유합니다. 이를 바탕으로 만 명의 동시 접속자를 가정했을 때 필요한 메모리를 정밀하게 산정했습니다.

  • 10,000 users * 50 KB/user ≈ 0.5 GB

이 계산 결과를 바탕으로 K8s Pod의 Memory Request 및 Limit 값을 설정했습니다. 단순히 FD 숫자만 늘렸을 때 발생할 수 있는 잠재적 OOM(Out of Memory) 사태를 원천 차단한 것입니다.


안정적인 연결 종료

서버의 신규 배포(Scale-out/in)는 클라우드 네이티브 환경의 일상입니다. EKS는 Pod를 종료할 때 SIGTERM 신호를 보내며, terminationGracePeriodSeconds의 유예 시간 후 강제 종료(SIGKILL)합니다. HTTP와 달리 영구적인 연결을 유지하는 WebSocket은 이 유예 시간 동안 자연적으로 종료되지 않아 클라이언트에게 강제 에러를 유발합니다.

더 심각한 잠재 장애는 대규모 연결 종료 시 발생하는 'Thundering Herd' 현상입니다. 수만 개의 연결이 동시에 종료되고 재접속을 시도할 때, TCP 프로토콜의 특성상 먼저 연결을 끊은 쪽(Active Close)의 소켓은 패킷 유실을 막기 위해 약 60초간 TIME_WAIT 상태에 머뭅니다. 이 상태의 소켓들이 시스템의 임시 포트를 모두 점유해버리면, 정작 살아서 서비스를 제공해야 할 신규 Pod들이 외부(Redis 등)와 통신할 포트조차 구하지 못하는 '임시 포트 고갈(Ephemeral Port Exhaustion)' 장애로 직결됩니다.

해결 방안: Connection Draining과 tcp_tw_reuse 수동적인 대기가 아닌 능동적 종료(Drain)를 구현했습니다. Centrifugo 서버가 SIGTERM을 받으면 K8s Endpoint에서 제외되어 신규 유입을 차단합니다. 동시에 기존 연결 클라이언트에게 WebSocket Close Frame을 전송하여 클라이언트가 '스스로' 연결을 끊고 건강한 다른 Pod로 부드럽게 재접속하도록 유도(Connection Draining)했습니다. NestJS 백엔드 역시 onModuleDestroy 훅을 통해 진행 중인 요청을 완료하고 리소스를 정리하도록 설정했습니다.

# 1. 커널 레벨 포트 고갈 방어(tcp_tw_reuse) 활성화 검증
$ kubectl exec <pod name> -n <namespace> -- cat /proc/sys/net/ipv4/tcp_tw_reuse
2

# 2. 소켓 서버의 잉여 TIME_WAIT 소켓 개수 검증
$ kubectl exec <pod name> -n <namespace> -- sh -c "netstat -an | grep TIME_WAIT | wc -l"
14

포트 고갈의 방어선으로는 EKS Node의 커널 파라미터 net.ipv4.tcp_tw_reuse = 2 설정을 활성화했습니다. TCP 타임스탬프가 활성화된 상태에서 커널이 안전하다고 판단하는 TIME_WAIT 소켓을 즉시 재사용하게 함으로써, 임시 포트 고갈을 완벽하게 차단하고 가용성을 사수했습니다.


epoll의 함정과 탄력적 신뢰성(Elastic Reliability) 확보

분산된 소켓 서버 클러스터의 오토스케일링(Autoscaling)은 매우 중요합니다. 하지만 웹 서버처럼 단순 CPU 사용률만으로 HPA(Horizontal Pod Autoscaler)를 설정하면 치명적인 함정에 빠지게 됩니다.

이른바 'HPA 기만 시나리오'입니다. 최신 리눅스 커널은 다수의 연결을 처리하기 위해 epoll이라는 비동기 I/O 멀티플렉싱 기술을 사용합니다. epoll의 "O(1) 복잡도 이벤트 처리 메커니즘" 덕분에, 수만 명의 사용자가 연결만 유지한 채 메시지를 주고받지 않는(Idle) 상태라면 서버의 CPU 점유율은 바닥을 기어 다닙니다. 이 낮은 CPU 지표가 역설적으로 K8s 표준 HPA를 기만하여 스케일아웃을 방해합니다. 정작 각 연결이 점유하는 메모리와 파일 디스크립터는 임계치에 도달했는데도 인스턴스는 증설되지 않고, 트래픽 유입 시 OOM으로 Pod가 즉시 다운되는 참사가 발생합니다.

해결 방안: 커스텀 메트릭 기반의 탄력적 신뢰성 확보 소켓 서버의 실질적인 병목은 CPU가 아니라 메모리 사용량과 활성 연결 수(Connection Count)입니다. 우리는 Prometheus Adapter를 통해 Centrifugo가 노출하는 '현재 연결된 클라이언트 소켓의 총 수'를 K8s API가 인식할 수 있는 커스텀 메트릭으로 변환하는 파이프라인을 구축했습니다.

그리고 CPU와 활성 소켓 수를 모두 포함하는 다중 조건(OR) HPA 정책을 수립했습니다.

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

이러한 측정 가능한 커스텀 메트릭의 도입은, 시스템이 물리적 한계에 부딪히기 전에 선제적으로 인프라를 확장시키는 '탄력적 신뢰성(Elastic Reliability)'으로 변환되었습니다.