Kubernetes 확장 및 안정성 설계

Kubernetes는 애플리케이션을 컨테이너 단위로 실행하고 관리하는 오케스트레이션 플랫폼이다. 하지만 컨테이너를 배포하는 것만으로 안정적인 서비스를 보장할 수는 없다. 트래픽 증가에 대응하고, 리소스 사용량을 제어하며, 장애가 발생한 애플리케이션을 감지하고 복구하는 체계가 필요하다.

Kubernetes는 HPA, Requests와 Limits, Probe, Rolling Update 등의 기능을 통해 애플리케이션의 확장성과 안정성을 관리한다. 여기에 애플리케이션 자체의 Graceful Shutdown과 장애 복구 로직을 결합하면 서비스 중단을 최소화할 수 있다.

HPA(Horizontal Pod Autoscaler)

HPA는 애플리케이션의 부하에 따라 Pod의 개수를 자동으로 조정하는 기능이다. CPU 사용률, 메모리 사용률 또는 외부 메트릭을 기준으로 필요한 Pod 개수를 계산한다.

예를 들어 게임 플랫폼에서 평소에는 소수의 Pod로 서비스를 운영하다가 이벤트나 대규모 업데이트로 접속자가 증가하면 HPA가 Pod를 추가로 생성할 수 있다. 트래픽이 감소하면 불필요한 Pod를 줄여 리소스 비용을 절감한다.

HPA의 동작 원리

HPA는 지정된 메트릭의 현재 값과 목표 값을 비교해 원하는 복제본 수를 계산한다.

일반적인 계산 개념은 다음과 같다.

원하는 Pod 수 = 현재 Pod 수 × (현재 메트릭 값 / 목표 메트릭 값)

예를 들어 현재 Pod가 적고 CPU 사용률이 목표보다 높다면 Pod 수를 늘린다. 반대로 CPU 사용률이 목표보다 낮다면 Pod 수를 줄일 수 있다.

실제 동작에서는 최소·최대 복제본 수, 메트릭 수집 주기, 안정화 정책 등의 조건이 함께 적용된다.

HPA 설정 예시

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: game-api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: game-api
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60

이 설정은 game-api Deployment의 Pod 수를 최소 두 개, 최대 열 개로 유지하면서 평균 CPU 사용률이 목표 수준에 가까워지도록 조정한다.

CPU 사용률을 기준으로 HPA를 사용하려면 컨테이너에 CPU Requests가 설정되어 있어야 한다. 메트릭을 수집할 수 있는 Metrics Server 등의 구성도 필요하다.

HPA 설계 시 주의점

  • Pod가 증가해도 데이터베이스나 외부 API가 병목이면 전체 처리량은 증가하지 않을 수 있다.
  • 새로운 Pod가 준비되기까지 시간이 필요하므로 급격한 트래픽 증가에는 대응이 늦을 수 있다.
  • 최소 복제본 수는 장애 허용 수준과 비용을 고려해 결정해야 한다.
  • CPU만으로 부하를 판단하기 어려운 경우 요청 대기열 길이, 동시 접속자 수, 초당 요청 수 등의 메트릭을 활용할 수 있다.
  • scale-down 안정화 정책을 적용하면 부하가 일시적으로 감소했을 때 Pod가 과도하게 제거되는 현상을 줄일 수 있다.

HPA는 Pod의 수를 조절하는 기능이다. 실제 서버 자원이 부족하다면 노드를 추가하는 Cluster Autoscaler 등의 기능도 함께 고려해야 한다.

Requests와 Limits

Requests와 Limits는 컨테이너가 사용할 CPU와 메모리 자원을 관리하기 위한 설정이다.

Requests는 스케줄러가 Pod를 어느 노드에 배치할지 결정할 때 참고하는 자원 요청량이다. Limits는 컨테이너가 사용할 수 있는 자원의 상한을 설정한다.

Requests

Requests는 컨테이너가 정상적으로 실행되기 위해 필요한 자원량을 나타낸다.

Kubernetes 스케줄러는 노드의 할당 가능한 자원과 각 Pod의 Requests를 고려해 배치 위치를 결정한다. Requests가 지나치게 높으면 노드에 자원이 남아 있어도 Pod가 배치되지 않을 수 있다.

반대로 Requests가 지나치게 낮으면 실제 부하를 감당할 자원이 충분히 확보되지 않을 수 있다.

Limits

Limits는 컨테이너의 자원 사용량을 제한한다.

CPU Limit에 도달하면 CPU 사용이 제한되어 처리 지연이 증가할 수 있다. 메모리 Limit을 초과하면 컨테이너가 OOMKilled 상태로 종료될 수 있다.

CPU와 메모리는 동작 방식이 다르다는 점에 주의해야 한다.

  • CPU는 압축 가능한 자원이므로 Limit을 초과하는 사용량이 제한될 수 있다.
  • 메모리는 일반적으로 압축 불가능한 자원이므로 사용량이 한도를 넘으면 프로세스가 종료될 수 있다.

설정 예시

resources:
  requests:
    cpu: "250m"
    memory: "256Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

이 설정은 컨테이너가 CPU 250m와 메모리 256Mi를 요청하며, CPU 사용량은 최대 500m, 메모리 사용량은 최대 512Mi로 제한한다.

CPU의 m은 밀리코어 단위다. 1000m는 CPU 코어 하나에 해당한다.

자원 설정 전략

Requests와 Limits를 설정할 때는 실제 부하 테스트와 운영 환경의 메트릭을 참고해야 한다.

  • Requests는 정상적인 부하에서 필요한 자원을 기준으로 설정한다.
  • Limits는 순간적인 부하와 메모리 사용량의 변동을 고려해 결정한다.
  • CPU Limit이 너무 낮으면 처리량이 감소하거나 지연 시간이 증가할 수 있다.
  • 메모리 Limit은 메모리 누수와 순간적인 메모리 급증을 고려해 설정한다.
  • 여러 컨테이너가 동일한 노드에 배치될 수 있으므로 노드 전체 자원도 함께 확인한다.

특히 실시간 게임 서버는 CPU 제한으로 인해 틱 처리나 메시지 전송이 지연될 수 있다. 평균 사용량뿐 아니라 피크 사용량과 p95·p99 지연 시간도 함께 확인하는 것이 좋다.

Liveness·Readiness·Startup Probe

Probe는 Kubernetes가 컨테이너의 상태를 판단하기 위해 사용하는 검사 기능이다. 컨테이너 프로세스가 실행 중이라는 사실만으로 애플리케이션이 정상이라고 판단할 수는 없다.

Kubernetes는 목적에 따라 Liveness, Readiness, Startup Probe를 구분한다.

Liveness Probe

Liveness Probe는 애플리케이션이 정상적으로 동작하고 있는지 확인한다.

예를 들어 프로세스가 실행 중이지만 내부 교착 상태에 빠져 요청을 처리하지 못한다면 Liveness Probe가 이를 감지할 수 있다. 검사에 반복적으로 실패하면 Kubernetes는 해당 컨테이너를 재시작할 수 있다.

다만 외부 데이터베이스 연결이 잠시 끊겼다는 이유만으로 Liveness Probe가 실패하도록 설계하면 문제가 커질 수 있다. 여러 Pod가 동시에 재시작하면서 복구가 지연될 수 있기 때문이다.

Liveness는 일반적으로 애플리케이션 내부의 비정상 상태를 판단하는 데 집중해야 한다.

Readiness Probe

Readiness Probe는 애플리케이션이 실제 요청을 처리할 준비가 되었는지 확인한다.

검사에 실패한 Pod는 일반적으로 Service의 정상적인 트래픽 전달 대상에서 제외된다. 컨테이너가 종료되지 않더라도 요청을 받지 않도록 제어할 수 있다.

게임 서버가 시작된 직후 데이터 로딩이나 초기화 작업을 진행하고 있다면, 준비가 완료될 때까지 Readiness Probe를 실패 상태로 유지할 수 있다.

Readiness는 장애가 발생한 Pod로 요청이 전달되는 것을 방지하는 데 중요하다.

Startup Probe

Startup Probe는 애플리케이션의 초기 시작이 완료되었는지 확인한다.

초기 데이터 로딩이나 캐시 구성에 시간이 오래 걸리는 애플리케이션은 시작 직후 Liveness Probe의 검사에 실패할 수 있다. Startup Probe를 사용하면 초기화가 완료될 때까지 Liveness와 Readiness 검사를 유예할 수 있다.

Startup Probe가 활성화된 동안에는 해당 Probe가 성공할 때까지 Liveness와 Readiness 검사가 실행되지 않는다.

Probe 설정 예시

containers:
  - name: game-api
    image: game-api:latest
    ports:
      - containerPort: 8080

    startupProbe:
      httpGet:
        path: /health/startup
        port: 8080
      periodSeconds: 5
      failureThreshold: 30

    livenessProbe:
      httpGet:
        path: /health/live
        port: 8080
      periodSeconds: 10
      failureThreshold: 3

    readinessProbe:
      httpGet:
        path: /health/ready
        port: 8080
      periodSeconds: 5
      failureThreshold: 3

이 설정은 시작 상태를 확인하고, 애플리케이션의 생존 여부와 요청 처리 준비 상태를 각각 검사한다.

failureThreshold는 연속 실패 허용 횟수이며, 실제 감지 시간은 periodSeconds, timeoutSeconds, 초기 지연 등의 설정에 따라 달라진다.

애플리케이션에서는 각 엔드포인트의 의미를 명확히 분리해야 한다. Liveness 검사에서 모든 외부 의존성의 상태를 확인하거나 Readiness 검사를 지나치게 엄격하게 구성하면 불필요한 장애 전파가 발생할 수 있다.

Rolling Update

Rolling Update는 기존 Pod를 한 번에 모두 제거하지 않고 새 버전의 Pod를 점진적으로 배포하는 방식이다.

애플리케이션을 업데이트할 때 모든 Pod를 동시에 종료하면 서비스가 중단될 수 있다. Rolling Update는 새 Pod를 생성하고 정상적으로 준비된 것을 확인한 뒤 기존 Pod를 줄이는 방식으로 가용성을 유지한다.

Rolling Update의 동작

  • 새 버전의 Pod를 생성한다.
  • Readiness Probe 등을 통해 새 Pod가 요청을 처리할 준비가 되었는지 확인한다.
  • 준비가 완료되면 기존 Pod를 점진적으로 제거한다.
  • 배포가 완료될 때까지 이 과정을 반복한다.

Deployment는 maxSurge와 maxUnavailable을 사용해 업데이트 중 Pod 수의 변화를 제어한다.

maxSurge는 업데이트 과정에서 원하는 복제본 수를 초과해 추가로 생성할 수 있는 Pod 수를 의미한다. maxUnavailable은 업데이트 중 사용할 수 없는 상태로 허용되는 Pod 수를 의미한다.

Rolling Update 설정 예시

apiVersion: apps/v1
kind: Deployment
metadata:
  name: game-api
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: game-api
  template:
    metadata:
      labels:
        app: game-api
    spec:
      containers:
        - name: game-api
          image: game-api:v2
          ports:
            - containerPort: 8080

이 설정은 원하는 Pod 수를 네 개로 유지하면서 업데이트 중 추가 Pod를 하나까지 허용하고, 업데이트 전략상 사용 불가능한 Pod를 허용하지 않는다.

단, 새 Pod가 정상적으로 준비되지 않으면 배포가 진행되지 않고 정체될 수 있다. 또한 실제 서비스의 가용성을 보장하려면 노드 장애, 리소스 부족, 외부 의존성 장애도 고려해야 한다.

무중단 배포를 위한 조건

Rolling Update만으로 완전한 무중단 배포가 보장되지는 않는다.

  • 새 버전이 정상적으로 시작되고 Readiness Probe를 통과해야 한다.
  • 기존 버전과 새 버전이 함께 실행되는 기간을 고려해야 한다.
  • API와 데이터베이스 스키마가 이전 버전과 호환되어야 한다.
  • 충분한 여유 자원이 있어야 한다.
  • 진행 중인 요청과 WebSocket 연결을 안전하게 처리해야 한다.

특히 데이터베이스 스키마 변경은 이전 버전과 새 버전이 공존하는 상황에서도 호환되도록 설계해야 한다. 일반적으로 새 컬럼을 추가하고 애플리케이션을 전환한 뒤 불필요한 기존 컬럼을 제거하는 점진적 마이그레이션 방식이 안전하다.

Graceful Shutdown

Graceful Shutdown은 애플리케이션이 종료 신호를 받았을 때 진행 중인 작업을 정리하고 안전하게 종료하는 방식이다.

Kubernetes는 Pod를 제거하거나 업데이트할 때 컨테이너에 종료 신호를 전달한다. 애플리케이션이 이 신호를 무시하거나 즉시 종료되면 진행 중인 HTTP 요청, 데이터베이스 트랜잭션, WebSocket 연결이 중단될 수 있다.

따라서 애플리케이션은 종료 절차를 명시적으로 구현해야 한다.

Graceful Shutdown의 처리 과정

  • 종료 신호를 감지한다.
  • 새로운 작업의 수락을 중단한다.
  • Readiness 상태를 변경해 새 요청의 유입을 차단한다.
  • 진행 중인 요청과 작업이 완료될 때까지 기다린다.
  • WebSocket 연결과 메시지 처리 작업을 정리한다.
  • 데이터베이스 연결과 메시지 브로커 등의 리소스를 해제한다.
  • 제한 시간 안에 종료한다.

Kubernetes의 terminationGracePeriodSeconds는 종료 절차에 허용되는 시간을 설정한다. 제한 시간을 초과하면 프로세스가 강제로 종료될 수 있다.

Node.js 예시

const server = app.listen(8080);

let shuttingDown = false;

app.get("/health/ready", (req, res) => {
  if (shuttingDown) {
    return res.status(503).json({ status: "not_ready" });
  }

  res.json({ status: "ready" });
});

process.on("SIGTERM", async () => {
  if (shuttingDown) return;

  shuttingDown = true;

  server.close(async () => {
    try {
      await closeDatabase();
      await closeMessageBroker();
      process.exit(0);
    } catch (error) {
      console.error("Shutdown failed", error);
      process.exit(1);
    }
  });
});

이 코드는 종료 신호를 받으면 준비 상태를 해제하고 HTTP 서버의 연결 종료를 기다린 뒤 외부 리소스를 정리하는 기본 구조다. closeDatabase()와 closeMessageBroker()는 애플리케이션에 맞게 구현해야 한다.

실제 운영 코드에서는 종료 제한 시간, 정리 작업의 실패 처리, WebSocket 연결 종료, 진행 중인 비동기 작업의 취소 등을 추가해야 한다. 서버 종료가 영원히 대기하지 않도록 제한 시간을 두는 것도 중요하다.

WebSocket 서버의 Graceful Shutdown

실시간 게임 플랫폼에서는 WebSocket 연결을 별도로 관리해야 한다.

Pod가 종료될 때 연결을 즉시 끊으면 클라이언트는 비정상 종료를 경험할 수 있다. 서버는 신규 연결을 차단하고 기존 연결에 종료를 알린 뒤 일정 시간 동안 정상적인 연결 종료를 유도해야 한다.

클라이언트는 연결 종료를 감지하면 재접속을 시도할 수 있어야 한다. 이때 지수 백오프와 무작위 지연을 적용하면 여러 클라이언트가 동시에 재접속하는 현상을 완화할 수 있다.

또한 재접속 후에는 인증과 세션 복구를 수행하고, 마지막으로 처리한 이벤트나 게임 상태를 기준으로 일관성을 회복할 수 있어야 한다.

장애 발생 시 재시작 및 복구

Kubernetes는 컨테이너나 노드에 장애가 발생했을 때 여러 계층에서 복구를 시도한다. 하지만 모든 장애를 자동으로 해결할 수 있는 것은 아니다. 재시작은 복구 수단 중 하나이며, 근본적인 장애 원인을 제거하는 것과는 다르다.

컨테이너 재시작

Pod의 컨테이너가 종료되면 restartPolicy와 컨트롤러의 동작에 따라 재시작이 시도될 수 있다.

일반적인 Deployment의 Pod는 restartPolicy: Always를 사용한다. 컨테이너가 종료되면 Kubernetes는 다시 실행하려고 시도한다.

다만 애플리케이션이 동일한 오류로 계속 종료되면 재시작이 반복되고, 일정 조건에서 CrashLoopBackOff 상태가 나타날 수 있다. 이는 재시작 사이에 대기 시간을 두어 반복적인 실패를 완화하는 상태다.

주요 원인으로는 다음과 같은 항목이 있다.

  • 잘못된 환경변수나 설정 파일
  • 데이터베이스 연결 실패
  • 메모리 부족으로 인한 OOMKilled
  • 애플리케이션 내부 예외
  • 시작 명령이나 실행 파일의 오류
  • CPU 또는 메모리 리소스 부족

장애가 발생하면 Pod 상태와 이벤트를 확인하고 컨테이너 로그, 종료 코드, 이전 컨테이너 로그 등을 분석해야 한다.

kubectl get pods
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl logs <pod-name> --previous
kubectl get events --sort-by=.metadata.creationTimestamp

--previous는 재시작된 컨테이너의 이전 실행 로그를 확인할 때 유용하다.

Pod 복구와 재생성

Deployment는 원하는 복제본 수를 유지하도록 관리한다. Pod가 삭제되거나 비정상적으로 종료되면 컨트롤러가 새로운 Pod를 생성하려고 시도한다.

Pod는 영구적인 실행 단위가 아니다. 장애가 발생한 Pod를 그대로 복구하는 대신 새로운 Pod를 생성하는 것이 Kubernetes의 일반적인 운영 방식이다.

따라서 Pod 내부의 파일 시스템에 중요한 데이터를 저장해서는 안 된다. 영속성이 필요한 데이터는 데이터베이스, 오브젝트 스토리지 또는 PersistentVolume과 같은 적절한 저장소를 사용해야 한다.

노드 장애 대응

Worker Node 자체에 장애가 발생하면 해당 노드에서 실행되던 Pod도 영향을 받는다. Kubernetes는 노드 상태를 감지하고 상황에 따라 다른 노드에서 Pod를 다시 생성할 수 있다.

그러나 새 Pod가 실행되려면 다른 노드에 충분한 자원이 있어야 한다. 노드가 여러 개라는 사실만으로 고가용성이 보장되지는 않는다.

노드 장애에 대비하려면 다음 사항을 고려해야 한다.

  • 여러 노드에 Pod를 분산 배치한다.
  • Pod Anti-Affinity나 Topology Spread Constraints로 장애 영역의 집중을 줄인다.
  • PodDisruptionBudget으로 자발적인 중단 시 가용 Pod 수를 보호한다.
  • 여러 가용 영역에 노드를 분산한다.
  • 노드와 클러스터의 자원 여유를 확보한다.

PodDisruptionBudget은 자발적인 중단에 대한 보호 장치다. 노드 자체의 장애나 모든 종류의 비자발적 중단을 막아 주는 기능은 아니다.

장애 복구 설계의 한계

자동 재시작이 항상 정답은 아니다. 데이터베이스 연결이 끊겼는데 애플리케이션이 계속 재시작되면 연결 요청이 집중되어 장애가 확대될 수 있다.

장애 유형에 따라 복구 전략을 분리해야 한다.

  • 일시적인 네트워크 오류에는 제한된 재시도를 적용한다.
  • 반복적인 외부 서비스 실패에는 타임아웃과 Circuit Breaker를 고려한다.
  • 데이터베이스 연결 실패에는 연결 풀과 재연결 정책을 점검한다.
  • 메모리 누수에는 메모리 프로파일링과 사용량 추적을 적용한다.
  • 반복적인 시작 실패에는 설정 검증과 명확한 오류 로그를 제공한다.

안정적인 시스템은 장애가 발생하지 않는 시스템이 아니라, 장애를 감지하고 영향을 제한하며 예측 가능한 방식으로 복구하는 시스템이다.

확장성과 안정성을 함께 설계하기

Kubernetes의 확장 및 안정성 기능은 서로 독립적으로 동작하지 않는다. HPA가 Pod를 늘려도 Requests가 부적절하면 스케줄링이 실패할 수 있다. Probe가 잘못 설계되면 정상적인 Pod가 재시작될 수 있다. Rolling Update가 진행되더라도 Graceful Shutdown이 없다면 기존 연결이 중단될 수 있다.

실제 운영 환경에서는 다음과 같은 흐름으로 설계하는 것이 효과적이다.

  • 부하 대응: HPA와 적절한 리소스 요청량을 통해 트래픽 변화에 대응한다.
  • 상태 감지: Startup, Liveness, Readiness Probe를 목적에 맞게 분리한다.
  • 안전한 배포: Rolling Update와 호환 가능한 API 및 데이터베이스 변경 전략을 사용한다.
  • 정상 종료: 종료 신호를 처리하고 진행 중인 요청과 연결을 정리한다.
  • 장애 복구: 컨테이너 재시작, Pod 재생성, 노드 분산과 모니터링을 결합한다.
  • 지속적인 검증: 부하 테스트와 장애 주입 테스트로 실제 복구 동작을 확인한다.

결국 Kubernetes의 안정성은 개별 기능을 활성화하는 것만으로 완성되지 않는다. 자원 관리, 상태 검사, 배포 전략, 애플리케이션 종료 처리, 장애 관측 체계가 일관되게 설계되어야 한다.

특히 실시간 게임 플랫폼에서는 HTTP 요청뿐 아니라 WebSocket 연결, 매칭 작업, 게임 상태, 보상 지급과 같은 비동기 작업까지 고려해야 한다. 인프라가 Pod를 재생성할 수 있더라도 유실된 게임 상태나 중복 처리된 보상을 자동으로 복구해 주지는 않는다.

따라서 인프라 수준의 자동 복구와 애플리케이션 수준의 데이터 정합성 보장을 함께 설계해야 신뢰할 수 있는 서비스를 운영할 수 있다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다