수평 확장: Kubernetes, Sticky Sessions, 및 Redis

발행: (2025년 12월 18일 오후 01:22 GMT+9)
15 min read
원문: Dev.to

Source: Dev.to

번역을 진행하려면 번역하고자 하는 본문 텍스트를 제공해 주세요. 현재는 소스 링크만 포함되어 있어 번역할 내용이 없습니다. 텍스트를 알려주시면 그대로 한국어로 번역해 드리겠습니다.

Introduction

Stateless HTTP 애플리케이션을 스케일링하는 것은 잘 알려진 문제입니다: 더 많은 파드를 띄우고, 앞에 로드 밸런서를 두고, 라운드‑로빈 알고리즘을 사용합니다. 하나의 파드가 실패하면, 다음 요청은 단순히 다른 정상 파드로 라우팅됩니다. 그러나 Flask‑SocketIO 로 구축된 실시간 WebSocket 애플리케이션은 이 패러다임을 근본적으로 깨뜨립니다.

WebSocket은 장시간 유지되는 상태ful TCP 연결에 의존합니다. 클라이언트가 서버 프로세스에 연결되면, 해당 프로세스가 그 사용자의 소켓 파일 디스크립터와 메모리 내 컨텍스트(룸, 세션 데이터)를 보관합니다. Flask‑SocketIO 컨테이너를 Kubernetes에 10개의 파드로 단순 복제한다면, 시스템은 배포 직후 바로 실패하게 됩니다.

Diagram of failure

이 실패는 표준 수평 확장 모델이 Socket.IO 프로토콜의 두 가지 요구사항을 고려하지 않기 때문에 발생합니다:

  • 핸드셰이크 동안 연결 지속성
  • 연결이 설정된 후 분산 이벤트 전파

Flask‑SocketIO를 효과적으로 확장하려면 단일 서버 사고방식을 넘어, Kubernetes Ingress를 이용한 세션 어피니티와 Redis를 pub/sub 메시지 버스로 활용하는 분산 아키텍처를 구현해야 합니다.

Source:

상태 저장 문제: 라운드‑로빈이 실패하는 이유

표준 로드 밸런싱이 왜 실패하는지 이해하려면 Socket.IO 프로토콜 협상을 살펴봐야 합니다. 일반 WebSocket과 달리 Socket.IO는 즉시 WebSocket 연결을 설정하지 않습니다. 대신, 제한적인 프록시 환경에서도 호환성과 견고한 연결을 보장하기 위해 보통 HTTP 롱‑폴링으로 시작합니다.

핸드쉐이크 순서는 다음과 같습니다:

Handshake Request: POST /socket.io/?EIO=4&transport=polling
서버는 세션 ID(sid)와 연결 간격을 응답합니다.

Poll Request: GET /socket.io/?EIO=4&transport=polling&sid=...

Upgrade Request: 클라이언트가 Upgrade: websocket 헤더를 보내 프로토콜을 전환합니다.

세션 어피니티가 없는 라운드‑로빈 Kubernetes 환경에서는 Handshake RequestPod A로 라우팅될 수 있습니다. Pod A는 세션 ID(예: abc-123)를 생성하고 로컬 메모리에 저장합니다. 이후 Poll Request가 Service에 의해 Pod B로 라우팅될 수 있습니다. Pod B는 메모리에 abc-123 세션에 대한 기록이 없기 때문에 400 Bad Request 혹은 {"code":1,"message":"Session ID unknown"} 오류와 함께 요청을 거부합니다.

연결이 성공적으로 WebSocket으로 업그레이드되어 TCP 연결이 단일 pod에 고정되더라도, 브로드캐스팅을 위한 시스템은 여전히 깨집니다. 사용자 A가 Pod A에, 사용자 B가 Pod B에 연결되어 있고 두 사람 모두 채팅 방 room_1에 있다면, 사용자 A가 보낸 메시지는 오직 Pod A의 메모리 안에만 존재합니다. Pod B는 그 메시지를 사용자 B에게 전달해야 한다는 사실을 알지 못합니다.

스티키 세션: Ingress‑Nginx 설정

핸드쉐이크 실패에 대한 해결책은 세션 어피니티이며, 흔히 “스티키 세션(Sticky Sessions)”이라고 부릅니다. 이는 클라이언트가 특정 파드와 핸드쉐이크를 시작하면 이후 해당 클라이언트의 모든 요청이 정확히 같은 파드로 라우팅되도록 보장합니다.

Ingress 스티키 세션 다이어그램

Kubernetes에서는 일반적으로 Ingress 컨트롤러 수준에서 이 기능을 처리합니다(서비스 수준에서도 sessionAffinity: ClientIP를 제공하지만 NAT 환경에서는 신뢰성이 떨어지는 경우가 많습니다). 많은 클러스터에서 사용되는 표준 컨트롤러인 ingress‑nginx에서는 쿠키 기반 어피니티를 통해 스티키 세션을 구현합니다.

어노테이션을 통한 설정

Ingress 리소스에 다음 어노테이션을 추가하여 라우팅 쿠키를 주입합니다:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: socketio-ingress
  annotations:
    # 쿠키 기반 어피니티 활성화
    nginx.ingress.kubernetes.io/affinity: "cookie"

    # 클라이언트에 전송되는 쿠키 이름
    nginx.ingress.kubernetes.io/session-cookie-name: "route"

    # 중요: 활성 세션이 재밸런싱되지 않도록 "persistent" 모드 사용
    nginx.ingress.kubernetes.io/affinity-mode: "persistent"

    # 해시 알고리즘 (sha1, md5, 또는 index)
    nginx.ingress.kubernetes.io/session-cookie-hash: "sha1"

    # 지속 시간 (socket.io ping timeout 로직과 일치시켜야 함)
    nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
spec:
  rules:
  - host: socket.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: socketio-service
            port:
              number: 5000

“Persistent”와 “Balanced” 트랩

일반적인 설정 실수는 affinity-mode를 무시하는 것입니다. 기본값이거나 balanced 로 설정된 경우, Nginx는 로드를 균형 잡기 위해 파드 수가 늘어나거나 줄어들면 세션을 재분배할 수 있습니다. 무상태 애플리케이션에는 문제가 없지만 WebSocket의 경우 연결이 끊어집니다. 다음과 같이 설정하면

nginx.ingress.kubernetes.io/affinity-mode: "persistent"

파드 배포가 변경되더라도 Nginx가 쿠키를 유지하도록 하여 WebSocket 연결 안정성을 보장하지만, 잠재적인 로드 불균형을 초래할 수 있습니다.

Redis 백플레인: 분산 이벤트 전파

스티키 세션은 핸드쉐이크 문제를 해결하지만 크로스‑팟 이벤트 브로드캐스팅 요구를 충족하지 못합니다. 어느 팟에 연결되어 있든 모든 클라이언트에게 이벤트(예: 채팅 메시지, 알림)를 전파하려면 공유 메시지 버스가 필요합니다. pub/sub 백플레인으로 사용되는 Redis가 이 역할을 수행합니다:

  • 각 Flask‑SocketIO 인스턴스가 이벤트를 Redis 채널에 게시합니다.
  • 모든 인스턴스가 동일한 채널을 구독하여 피어로부터 이벤트를 수신합니다.
  • Redis 서버는 안정적인 DNS 이름(예: redis-master.default.svc.cluster.local)을 가진 StatefulSet으로 배포할 수 있으며, 필요에 따라 비밀번호나 TLS로 보호할 수 있습니다.

스티키 세션(연결 지속성)과 Redis 백플레인(분산 이벤트 전파)을 결합하면 Kubernetes에서 진정으로 확장 가능한 Flask‑SocketIO 배포를 구현할 수 있습니다.

Connection Problem, but They Create a New Isolation Problem

사용자들은 이제 서로 다른 서버에 격리되어 있습니다. User A(Pod 1) 가 User B(Pod 2)에게 메시지를 보내려면, Flask 프로세스들의 격리된 메모리 공간을 연결해 주는 backplane—즉, 브리지 메커니즘이 필요합니다.

Architecture diagram

Flask‑SocketIO는 Message Queue 를 사용해 이를 해결합니다. 가장 성능이 좋고 일반적인 선택은 Redis이며, 이는 Pub/Sub (Publish/Subscribe) 패턴을 구현합니다.

How It Works Internally

Flask‑SocketIO를 Redis 메시지 큐와 함께 설정하면:

StepDescription
Subscription모든 Flask‑SocketIO 워커가 Redis에 연결하고 특정 채널(보통 flask-socketio)을 구독합니다.
EmissionPod A에서 emit('chat', msg, room='lobby') 코드를 실행하면, 자체 클라이언트 목록을 순회하지 않고 Redis에 “chatlobby에 보내라”는 메시지를 발행합니다.
DistributionRedis는 이 메시지를 다른 모든 구독 중인 Flask 워커(Pod B, Pod C, …)에게 푸시합니다.
Fan‑Out각 pod은 Redis 메시지를 받아 자신의 로컬 메모리에서 lobby에 있는 클라이언트를 확인하고, 열린 WebSocket 연결을 통해 해당 클라이언트에게 메시지를 전달합니다.

이 아키텍처는 이벤트 발생 원천과 이벤트 전달을 분리합니다.

설치: Redis 및 Flask‑SocketIO 설정

이를 구현하려면 Redis 서버를 설치해야 합니다(보통 Kubernetes에서 Helm 차트(bitnami/redis 등)를 사용)하고, Python 애플리케이션이 이를 사용하도록 구성해야 합니다.

의존성

pip install flask-socketio redis

Note: eventlet이나 gevent와 같은 비동기 워커를 사용할 경우, Redis 클라이언트가 monkey‑patch와 호환되는지 확인하거나, 호환되는 드라이버를 사용하십시오. 표준 redis-py는 올바르게 패치된 최신 버전의 eventlet과 잘 작동합니다.

애플리케이션 구성

SocketIO 생성자에 연결 문자열을 전달합니다. 이는 단일 노드 메모리 스토어에서 분산 Redis 스토어로 전환하기 위해 필요한 유일한 코드 변경 사항입니다.

from flask import Flask
from flask_socketio import SocketIO

app = Flask(__name__)

# The `message_queue` argument enables the Redis backend.
# In Kubernetes, `redis-master` is typically the service DNS name.
socketio = SocketIO(
    app,
    message_queue='redis://redis-master:6379/0',
    cors_allowed_origins="*"
)

@socketio.on('message')
def handle_message(data):
    # This emit is now broadcast via Redis to all pods
    socketio.emit('response', data)

일반적인 실수: client_manager 인자를 직접 사용하지 마세요. 이는 기본 Engine.IO 구현을 커스터마이징할 때만 필요합니다. message_queue 인자는 RedisManager를 자동으로 설정해 주는 고수준 래퍼입니다.

트레이드‑오프: 지연 및 병목 현상

이 아키텍처는 수평 확장을 가능하게 하지만, 모니터링이 필요한 특정 엔지니어링 트레이드‑오프를 도입합니다.

지연 도입

단일 노드 설정에서는 emit이 직접 메모리 연산입니다. 분산 설정에서는 모든 브로드캐스트가 Redis로의 네트워크 왕복을 포함합니다.

Client → Pod A → Redis → Pod B → Client
  • 이는 홉당 1–5 ms 정도의 일자리밀리초 지연을 추가합니다.
  • 네트워크 혼잡이나 Redis 과부하는 지연을 급격히 증가시킬 수 있습니다.

프로덕션 체크리스트

계층요구 사항
계층 1 (Ingress)MUST 스티키 세션(쿠키 어피니티)을 활성화하여 핸드쉐이크 완료를 보장해야 합니다.
계층 2 (App)MUST 격리된 워커 프로세스를 연결하기 위해 Redis 메시지 큐를 구성해야 합니다.
  • Ingress는 어피니티가 cookieaffinity-mode: persistent 로 설정됩니다.
  • Redis는 배포됩니다(가능하면 순수 Pub/Sub 성능을 위해 영속성을 비활성화하고, 저장이 필요하면 활성화합니다).
  • Flask‑SocketIO는 message_queue='redis://...' 로 초기화됩니다.
  • Redis CPU, 네트워크 지연, WebSocket 연결 상태를 모니터링합니다.
  • Gevent/Eventlet 몽키패치가 애플리케이션 진입점 최상단에 적용됩니다.

이 아키텍처를 구현함으로써 Flask‑SocketIO를 개발용 장난감에서 수만 개의 동시 연결을 처리할 수 있는 견고하고 확장 가능한 실시간 플랫폼으로 전환합니다.

Back to Blog

관련 글

더 보기 »