[EKS Best Practices] 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 | 성능 문제와 스케일링 문제를 연결하여 분석하는가? |