[EKS Best Practices] Scalability - Node and Workload Efficiency

원문: Scalability - Node and Workload Efficiency

Node and Workload Efficiency 요약

워크로드와 노드를 효율적으로 운영하면 복잡성과 비용은 줄이면서 성능과 확장성은 높일 수 있습니다. 이 문서의 핵심 메시지는 단 하나 — Kubernetes Scheduler의 논리와 Linux CFS의 성능 규칙을 혼동하지 말라는 것입니다.


1. Node Selection (노드 선택)

1-1. 큰 노드가 유리한 이유

노드에는 DaemonSet, kubelet Reserved, kube-reserved 등 오버헤드가 존재합니다. 노드가 클수록 오버헤드 비율이 줄어들어 Pod에 사용 가능한 공간이 늘어납니다.

인스턴스 패밀리 총 vCPU 오버헤드 비율 (예시) Pod 가용 비율
m5.xlarge (4 vCPU) 4 ~15-20 % ~80-85 %
m5.4xlarge (16 vCPU) 16 ~5-8 % ~92-95 %
m5.12xlarge (48 vCPU) 48 ~2-4 % ~96-98 %

권장: 4xlarge ~ 12xlarge 범위의 인스턴스를 사용하되, 너무 큰 노드에 Pod를 과도하게 밀어 넣으면 노드 포화(saturation)가 발생할 수 있으므로 반드시 모니터링해야 합니다.

1-2. 워크로드 특성별 노드 그룹 분리

Churn Rate(교체 빈도)가 크게 다른 워크로드는 노드 그룹을 분리하는 것이 좋습니다.

워크로드 유형 특성 권장 인스턴스
소규모 배치, 높은 churn 빈번한 생성/삭제 4xlarge 계열
대규모 상시 앱 (예: Kafka) 8 vCPU 이상, 낮은 churn 12xlarge 계열

1-3. CPU Threading 주의사항

컨테이너 내부에서 cgroups전체 노드의 vCPU 수를 숨기지 않습니다. JVM, Go, .NET 같은 런타임은 노드의 전체 CPU 수만큼 OS 스레드를 생성할 수 있어 예측 불가능한 Latency가 발생합니다.

flowchart LR
    A["컨테이너 런타임
(JVM, Go 등)"] -->|"runtime.NumCPU() = 64"| B["64개 OS 스레드 생성"] B --> C["실제 할당은 2 vCPU"] C --> D["Context Switching 폭증
→ Latency 증가"]

권장: 이런 경우 CPU Pinning을 사용하세요. 다만 Kubernetes는 수평 확장이 기본 원칙이므로 NUMA 노드 크기의 성능 저하를 감수할 필요는 대부분 없습니다.


2. Node Bin-Packing (노드 Bin 패킹)

이 섹션은 문서에서 가장 중요한 내용입니다. Kubernetes Scheduler와 Linux CFS의 근본적 차이를 이해해야 합니다.

2-1. 두 가지 스케줄링 규칙

flowchart TB
    subgraph K8s["Kubernetes Scheduler"]
        direction TB
        K1["Pod의 request 값을 기준으로
노드에 배치"] K2["'코어' 단위로 사고"] K3["4 Pod × 1 core request = 노드 Full"] end subgraph CFS["Linux CFS (Completely Fair Scheduler)"] direction TB C1["'코어' 개념 없음
→ CPU 시간 비율로 분배"] C2["Busy 컨테이너만 Share 계산에 포함"] C3["1개만 busy → 전체 CPU 사용 가능"] end K8s -->|"Pod 배치 후"| CFS
구분 Kubernetes Scheduler Linux CFS
기준 단위 Core (request 값) CPU 시간 Share
코어 개념 있음 없음
리소스 분배 정적 (request 기반) 동적 (busy 프로세스만)
노드 "Full" 판단 request 합산 ≥ 노드 capacity 전체 vCPU가 100% busy

2-2. Dev와 Prod의 성능 차이 함정

flowchart LR
    subgraph Dev["Dev 환경"]
        D1["NGINX 1개만 busy"]
        D2["4 vCPU 전부 사용 가능"]
        D3["성능: 우수"]
    end
    subgraph Prod["Prod 환경"]
        P1["4개 Pod 모두 busy"]
        P2["각 Pod가 1 core분 CPU만 확보"]
        P3["성능: 1/4로 저하"]
    end
    Dev -->|"동일 설정으로 배포"| Prod

함정: Dev에서 성능이 좋다고 해서 같은 설정을 Prod에 적용하면 성능이 급격히 저하됩니다. CFS가 busy 컨테이너에만 Share를 분배하기 때문입니다.

2-3. Application Right Sizing (Sweet Spot)

모든 애플리케이션에는 포화점(Saturation Point) 이 존재합니다. 이 지점을 넘어서면 처리시간이 증가하고, 훨씬 넘어서면 트래픽을 Drop합니다.

graph LR
    A["부하 증가"] --> B["Sweet Spot
(최적 성능 구간)"] B --> C["Saturation Point
(포화점)"] C --> D["Latency 급증"] D --> E["트래픽 Drop"] style B fill:#2d6a4f,color:#fff style C fill:#e76f51,color:#fff style D fill:#d62828,color:#fff

핵심: 포화점에 도달하기 전에 Scale Out 해야 합니다. 이 적정 지점(Sweet Spot)을 찾는 것이 가장 중요합니다.

2-4. Pod Sprawl 문제 (Pod 폭증)

Request 값을 과소 설정하면 불필요한 Pod가 대량 생성됩니다.

시나리오 Pod 수 설명
올바른 설정 1 Pod (2 vCPU request) 100 req/s 처리에 ~2 vCPU 사용
Request 절반 설정 4 Pods (0.5 vCPU request) 동일 처리에 4배 Pod 필요
+ HPA 50% 기본 설정 8 Pods 반쯤 빈 Pod가 추가 스케일링

결과: 10개 Pod의 Sweet Spot이 잘못 설정되면 80개 Pod로 폭증할 수 있으며, 그만큼 인프라 비용도 증가합니다.

2-5. Request 설정의 균형

접근법 문제점
Request = Sweet Spot (예: 2 vCPU) 평균 사용량 1 vCPU면 50% CPU 낭비
Request를 너무 낮게 설정 Pod Sprawl 발생
Best Practice: 다양한 특성의 컨테이너 혼합 Bursty 워크로드 + 저 CPU 워크로드를 함께 배치 → Sweet Spot 도달 가능

핵심 교훈: Kubernetes Scheduler의 "코어" 개념으로 Linux 컨테이너 성능을 판단하면 잘못된 의사결정을 내리게 됩니다. 둘은 별개의 시스템입니다.


3. Utilization vs. Saturation (사용률 vs. 포화도)

3-1. CPU 사용률만으로 스케일링하면 안 되는 이유

Kubernetes에서 불필요하고 예측 불가능한 스케일링이 발생하는 가장 큰 원인은 잘못된 메트릭을 스케일링 지표로 사용하는 것입니다.

flowchart TB
    subgraph Simple["단순 웹서버"]
        S1["요청 → 웹서버가 직접 처리"]
        S2["CPU 사용률 ≈ 실제 포화도"]
        S3["CPU가 좋은 지표"]
    end
    subgraph Complex["실제 애플리케이션"]
        C1["요청 → DB 레이어"]
        C2["요청 → Auth 레이어"]
        C3["요청 → 캐시 레이어"]
        C4["CPU 사용률 ≠ 실제 포화도"]
        C5["CPU가 나쁜 지표"]
    end
구분 Utilization (사용률) Saturation (포화도)
정의 리소스가 사용 중인 비율 리소스가 추가 작업을 처리할 수 없는 정도
예시 CPU 100% 사용 중 CPU 100% + 대기 작업 20개
문제 높은 사용률이 반드시 문제는 아님 포화는 항상 Latency를 유발

3-2. 포화 지표(Saturation Metric) 선택 가이드

# 고려 사항 설명
1 언어 런타임 이해 멀티스레드(JVM, Go) vs 싱글스레드(Node.js) 앱은 노드에 미치는 영향이 다름
2 올바른 수직 스케일 새 Pod를 추가하기 전 얼마나 버퍼를 둘 것인지 결정
3 진정한 포화 메트릭 Kafka Producer의 포화 지표와 복잡한 웹앱의 포화 지표는 다름
4 다른 앱의 상호 영향 같은 노드의 다른 워크로드가 성능에 큰 영향을 미침

3-3. Node Saturation과 Pressure Stall Information (PSI)

두 경우 모두 "CPU 100%"로 보이지만 실제 상태는 완전히 다릅니다. 사용률 메트릭만 보면 포화 문제를 감지할 수 없습니다.

PSI (Pressure Stall Information) 메트릭 활용

총 Pod 수를 노드에 늘릴 때 반드시 PSI 메트릭을 모니터링해야 합니다.

I/O Stall 감지 PromQL:

topk(3, ((irate(node_pressure_io_stalled_seconds_total[1m])) * 100))
감지 항목 설명
CPU 대기 스레드 스레드가 CPU를 기다리고 있는지
전체 스레드 Stall 모든 스레드가 리소스(메모리, I/O) 대기 중인지
CPU 시간 낭비 예: 45%의 시간을 I/O 대기에 소비 → 해당 CPU 사이클 낭비

참고: PSI 메트릭에 대한 상세 내용은 Facebook PSI 문서를 참조하세요.


4. HPA v2 (Horizontal Pod Autoscaler)

4-1. 반드시 autoscaling/v2를 사용해야 하는 이유

항목 autoscaling/v1 autoscaling/v2
스케일링 멈춤 Edge Case에서 발생 가능 해결됨
스케일링 단계 Pod 수 2배씩만 증가 유연한 증가
다중 메트릭 불가 복수 기준 지원
Custom/External 메트릭 제한적 완전 지원

4-2. 다중 메트릭 HPA 구성 예시

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: php-apache
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: php-apache
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 50
  - type: Pods
    pods:
      metric:
        name: packets-per-second
      target:
        type: AverageValue
        averageValue: 1k
  - type: Object
    object:
      metric:
        name: requests-per-second
      describedObject:
        apiVersion: networking.k8s.io/v1
        kind: Ingress
        name: main-route
      target:
        type: Value
        value: 10k

위 설정은 아래 조건 중 하나라도 충족되면 스케일링합니다:

조건 메트릭 임계값
1 전체 Pod 평균 CPU 사용률 > 50%
2 Ingress packets-per-second > 평균 1,000
3 Ingress requests-per-second > 10,000

4-3. Custom Metric으로 진정한 포화도 기반 스케일링

CPU 사용률 대신 애플리케이션의 실제 포화도를 반영하는 Custom Metric을 사용해야 합니다.

예시: Apache Active Thread Queue Count

Active Thread 기준 스케일링 장점 설명
더 "부드러운" 스케일링 프로필 CPU 기반의 급격한 증감 없음
DB 대기든 로컬 처리든 관계없음 스레드가 active이면 포화 지표로 유효
전체 스레드 사용 = 앱 포화 확인 명확한 포화 시점 판별
트래픽 버퍼 제어 가능 스케일링 임계값 조정으로 버퍼 크기 결정

4-4. Smooth vs. Spiky Scaling

CPU 메트릭 기반 올바른 포화 메트릭 기반
50 → 250 Pod 급등 후 즉시 감소 부드럽고 효율적인 스케일링
비효율적 → 클러스터 Churn의 주요 원인 소수의 Pod가 수백 개 Pod의 일을 처리
비용 급증 비용 절감

실제 데이터 기반 결론: 올바른 포화 메트릭 사용은 Kubernetes 클러스터 확장성의 가장 중요한 요인입니다.


5. CPU Limits 설정

5-1. CPU Limit의 동작 원리

CPU Limit은 100ms마다 리셋되는 카운터입니다. Linux가 특정 컨테이너의 CPU 사용 시간을 100ms 주기로 추적합니다.

5-2. 흔한 오해와 실제 동작

flowchart TB
    A["흔한 오해:
'내 앱은 싱글스레드라 1 core만 씀'"] B["실제:
CFS는 코어를 할당하지 않음"] C["GC 등이 64개 코어에서
스레드를 실행할 수 있음"] D["100ms 동안 전체 CPU 사용 시간 합산"] E["Limit 초과 → Throttling 발생"] A --> B --> C --> D --> E
오해 실제
앱이 "할당된" vCPU에서만 실행됨 CFS는 코어를 할당하지 않음 → 모든 vCPU에서 실행 가능
싱글스레드면 Limit 문제 없음 GC, Runtime 스레드가 64개 코어에 분산 실행 가능
Limit을 바로 설정해도 됨 메트릭으로 실제 사용량을 반드시 확인한 후 설정해야 함

5-3. 현재 CPU 사용량 측정

topk(3, max by (pod, container)(
  rate(container_cpu_usage_seconds_total{image!="", instance="$instance"}[$__rate_interval])
)) / 10

Throttling 로직이 100ms 단위이고 이 메트릭은 초 단위이므로 /10으로 100ms 기간에 맞춥니다.

5-4. Throttling 모니터링

topk(3, max by (pod, container)(
  rate(container_cpu_cfs_throttled_seconds_total{image!="", instance="$instance"}[$__rate_interval])
)) / 10

참고: Using Prometheus to Avoid Disasters with Kubernetes CPU Limits에서 상세 분석을 볼 수 있습니다.


6. Memory 관리

6-1. Memory vs. CPU의 근본적 차이

특성 CPU Memory
Request 역할 스케줄링 후에도 Share로 작동 스케줄링 이후 사용되지 않음 (CGroup v1)
압축 가능 여부 가능 (시간 분할) 불가능 (CGroup v1)
Limit 초과 시 Throttling (성능 저하) OOM Kill (Pod 즉시 종료)
성격 점진적 성능 저하 All-or-Nothing

6-2. Memory Caching 복잡성

Linux는 파일시스템 성능 향상을 위해 메모리를 캐시로 사용합니다:

flowchart LR
    A["앱 시작"] --> B["메모리 사용 증가"]
    B --> C["파일시스템 캐시 증가"]
    C --> D["총 메모리 사용량 높아 보임"]
    D --> E["실제 필수 메모리는
얼마인지 파악 어려움"]

캐시는 시간이 지남에 따라 증가하며, 성능에 필수적인 캐시와 "있으면 좋은" 캐시를 구분하기 어렵습니다.

6-3. CGroup v2의 Memory 개선사항

CGroup v2는 메모리 "압축"을 도입하여 최소 메모리를 올바르게 설정할 수 있게 했습니다.

파라미터 역할
memory.high 이 값에 도달하면 커널이 적극적으로 캐시 메모리를 회수
memory.min 커널이 절대 회수하지 않는 최소 메모리 보장
flowchart LR
    A["컨테이너 메모리 사용 증가"] --> B{"memory.high 도달?"}
    B -->|Yes| C["커널이 적극적 메모리 회수
(memory.min까지)"] B -->|No| D["정상 동작"] C --> E["다른 컨테이너에
메모리 재분배"]

7. 핵심 정리

혼동하기 쉬운 두 가지 개념

flowchart TB
    subgraph Trap1["함정 1: Utilization ≠ Saturation"]
        U1["사용률 100% ≠ 문제"]
        U2["포화도 100% = 반드시 문제"]
    end
    subgraph Trap2["함정 2: K8s Scheduler ≠ Linux CFS"]
        K1["Scheduler: 코어 기반 정적 배치"]
        K2["CFS: 시간 Share 기반 동적 분배"]
    end
    Trap1 --> Result["혼동 시: 잘못된 스케일링 → 성능 저하 → 비용 증가"]
    Trap2 --> Result

실무 체크리스트

# 항목
1 노드 크기를 4xlarge ~ 12xlarge 범위로 선택했는가?
2 Churn Rate가 다른 워크로드를 별도 노드 그룹으로 분리했는가?
3 멀티스레드 런타임에서 CPU Pinning을 검토했는가?
4 K8s Scheduler 논리와 Linux CFS 동작을 구분하여 설계했는가?
5 각 앱의 Sweet Spot을 부하 테스트로 확인했는가?
6 CPU 사용률 대신 진정한 포화 메트릭을 HPA에 사용하는가?
7 HPA autoscaling/v2를 사용하는가?
8 CPU Limit 설정 전 container_cpu_usage_seconds_total로 실제 사용량을 확인했는가?
9 Limit 설정 후 container_cpu_cfs_throttled_seconds_total로 Throttling을 모니터링하는가?
10 PSI 메트릭으로 노드 포화도를 모니터링하는가?
11 CGroup v2의 memory.min/memory.high를 활용하는가?
12 성능 문제와 스케일링 문제를 연결하여 분석하는가?