kubernetes
Karpenter는 워크로드 수요에 맞춰 EC2 노드를 자동으로 증설/축소해 주는 오토스케일러입니다. 그러나 무제한 확장을 허용할 경우, 특정 AZ로의 쏠림, 예산 초과, 구세대 인스턴스 사용 등 예측하기 어려운 문제가 발생할 수 있습니다. 이를 방지하기 위해 Karpenter는 NodePool 단위 제약(Constraints) 과 자원 상한(Limits) 기능을 제공합니다. 즉, 확장 방향과 범위를 통제해 안정성과 비용 효율성을 동시에 확보할 수 있습니다.
이번에는 다음 세 가지를 종합적으로 다룹니다.
단순한 오토스케일링을 넘어, 예측 가능한 성능/비용 관리를 가능하게 하는 핵심 설정을 실습합니다.
하나의 공통 EC2NodeClass를 기반으로 두 개의 NodePool을 운영하는 구조를 설계합니다. NodeClass는 AMI 패밀리, 서브넷·보안 그룹, IAM Role 같은 공통 인프라 경계를 정의하고, 그 위에 서로 다른 정책을 가진 NodePool을 얹어 운영합니다. 이렇게 하면 비용 최적화와 안정성을 동시에 확보하면서도 워크로드 특성에 맞춰 선택적으로 노드를 띄울 수 있습니다.
┌───────────────────────────────────────┐
│ EC2NodeClass (default) │
└──────────────────┬────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
NodePool: zoned-spot NodePool: family-gen-limit
(특정 AZ에서 Spot만 허용) (On-Demand + 특정 타입/세대 + 상한)
첫 번째 NodePool은 zoned-spot으로, ap-northeast-2a
와 2c
가용영역만 허용하고 Spot 인스턴스
만 사용하도록 제한합니다. 이렇게 하면 특정 AZ 안에서 저렴한 자원을 활용할 수 있지만, 회수 위험이 있으므로 반드시 장애 내성을 갖춘 워크로드에 배치하는 것이 좋습니다.
두 번째 NodePool은 family-gen-limit으로, On-Demand
전용에 더해 인스턴스 타입을 m5.large
, m5.xlarge
, c6g.large
, c6g.xlarge
로 고정하고, 풀 단위 CPU 합계를 64로 제한합니다. 이는 예산을 보호하면서도 성능이 일정한 인스턴스만 쓰도록 강제하여, 핵심 서비스가 안정적으로 운영될 수 있게 합니다.
이처럼 NodePool을 둘로 나누는 이유는 명확합니다. Spot과 On-Demand를 같은 풀에서 섞으면 정책이 복잡해지고, 장애나 비용 급등 시 영향을 예측하기 어려워집니다. 반면 풀을 분리하면 “비용 최적화용”과 “안정성 보장용”이라는 의도가 명확히 구분되고, 운영과 디버깅도 단순해집니다.
동작 흐름은 다음과 같습니다. 먼저 공통 NodeClass가 전체 인프라 범위를 정의합니다. 이후 zoned-spot 풀은 AZ와 용량 타입을 제한해 비용 중심 자원을 확보하고, family-gen-limit 풀은 인스턴스 타입과 세대를 고정한 뒤 자원 상한을 걸어 안정성과 예측 가능성을 보장합니다. 워크로드는 라벨과 affinity를 통해 어떤 풀을 사용할지 선택하는데, 일반적으로 내구성이 있는 Job은 Spot 풀을, 중단에 민감한 서비스는 On-Demand 풀을 사용하도록 배치합니다.
Karpenter에서 노드가 실제로 어떤 EC2 리소스를 사용할지는 NodePool이 아닌 EC2NodeClass에서 정의합니다. NodeClass는 말 그대로 노드 템플릿에 해당하며, AMI 종류, IAM Role, 서브넷, 보안 그룹 같은 인프라 레벨 속성을 지정합니다. 이후 모든 NodePool은 이 NodeClass를 참조하여 노드를 생성하게 됩니다.
아래 예시는 가장 기본적인 NodeClass 템플릿입니다. 운영 환경에 맞춰 <YOUR-CLUSTER-NAME> 부분만 교체하면 됩니다.
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
name: default
spec:
amiFamily: AL2023
role: "KarpenterNodeRole-<YOUR-CLUSTER-NAME>" # 교체
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "<YOUR-CLUSTER-NAME>" # 교체
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "<YOUR-CLUSTER-NAME>"
amiFamily: AL2023
- Amazon Linux 2023 기반 AMI를 사용합니다. (운영 요구사항에 따라 Bottlerocket, Ubuntu 등으로 변경 가능)role
- 노드에 연결할 IAM Role입니다. KarpenterNodeRole-<CLUSTER-NAME>
형태로 생성해 두어야 하며, EC2 인스턴스가 EKS 노드로 정상 동작하려면 이 Role이 필수입니다.subnetSelectorTerms / securityGroupSelectorTerms
- 서브넷과 보안 그룹은 태그 기반으로 자동 탐색되므로, EKS 클러스터 생성 시 부여된 karpenter.sh/discovery:<CLUSTER-NAME>
태그를 그대로 사용하면 됩니다.NodeClass가 참조하는 서브넷은 반드시 여러 AZ에 고르게 분포되어야 합니다. 그렇지 않으면 NodePool에서 특정 AZ를 지정해도 실제로는 노드를 띄울 수 없습니다. 따라서 VPC 및 서브넷 태그 구성이 올바른지 항상 먼저 확인해야 합니다. 또한, NodeClass는 노드 OS 이미지와 네트워크 보안 경계를 정의하는 역할만 하므로, 인스턴스 타입, AZ, Spot/On-Demand 같은 제약은 NodePool에서 관리합니다. 즉, NodeClass는 “노드가 속할 인프라 환경”, NodePool은 “노드가 가져야 할 스펙/정책”이라고 구분하면 이해가 쉽습니다.
NodePool은 Karpenter가 어떤 조건의 인스턴스를 언제, 얼마나 띄울지를 정의하는 핵심 리소스입니다. NodeClass가 공통 인프라 속성을 담당한다면, NodePool은 정책/제약/상한선을 구체적으로 기술하는 레이어라고 볼 수 있습니다. 아래 두 가지 예시는 Spot 기반 풀과 On-Demand 기반 풀을 나눠 설정하는 전형적인 방식입니다.
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: zoned-spot
spec:
template:
metadata:
labels:
nodeclass: zoned
capacity: spot
spec:
nodeClassRef:
name: default
requirements:
# Spot 전용
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
# 포함: 2a, 2c만 허용
- key: topology.kubernetes.io/zone
operator: In
values: ["ap-northeast-2a", "ap-northeast-2c"]
# (선택) 제외: 2d는 사용하지 않기
# - key: topology.kubernetes.io/zone
# operator: NotIn
# values: ["ap-northeast-2d"]
# 일반 컴퓨트 계열만
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["t", "m", "c"]
# 멀티 아키텍처 허용 예시
- key: kubernetes.io/arch
operator: In
values: ["amd64", "arm64"]
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 30s
이 풀은 Spot 인스턴스만 사용하도록 강제하면서, 특정 AZ(ap-northeast-2a
, ap-northeast-2c
)로 제한합니다. 이렇게 하면 가격과 가용성 변동성이 큰 Spot을 제어된 환경에서 활용할 수 있습니다.
capacity-type=spot
은 비용 최적화를 위한 핵심 제약입니다.topology.kubernetes.io/zone
을 활용해 허용할 AZ를 명시적으로 지정함으로써, 불필요한 AZ에서 리소스를 소모하지 않도록 합니다. 단, AZ를 너무 적게 지정하면 특정 구간의 용량 부족이나 가격 변동에 취약해질 수 있으므로 최소 2개 이상 AZ를 쓰는 것이 바람직합니다.instance-category
로는 범용 계열(t
, m
, c
)만 지정해 과도하게 특수화된 인스턴스(예: GPU, I/O 최적화 등)가 잘못 사용되지 않도록 제한합니다.운영 시 주의할 점은 Spot은 언제든 회수될 수 있다는 것입니다. 따라서 이 풀은 배치 작업, 재시도 가능한 워크로드를 주로 배치하는 것이 적합합니다. 또한 In과 NotIn 조건은 동시에 쓰면 공집합이 생겨 의도치 않게 노드가 하나도 안 뜰 수 있으니 한쪽만 선택적으로 사용해야 합니다.
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: family-gen-limit
spec:
template:
metadata:
labels:
nodeclass: family-gen
capacity: ondemand
spec:
nodeClassRef:
name: default
requirements:
# On-Demand만
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"]
# 특정 인스턴스 타입만 허용 (정확히 지정)
- key: karpenter.k8s.aws/instance-type
operator: In
values: ["m5.large", "m5.xlarge", "c6g.large", "c6g.xlarge"]
# (대신 사용할 수 있는 패턴) 세대 제한 예시
# - key: karpenter.k8s.aws/instance-generation
# operator: Gt
# values: ["4"] # 5세대 이상만
disruption:
consolidationPolicy: WhenUnderutilized
consolidateAfter: 30s
# 풀 단위 상한: 총합 CPU를 제한 → 노드 수 간접 제한 효과
limits:
resources:
cpu: "64" # vCPU 총합 64로 제한 (예: m5.xlarge(4 vCPU)라면 16노드 최대)
# memory: "256Gi" # 필요 시 메모리 상한도 함께
이 풀은 On-Demand 인스턴스만 허용하고, 타입을 엄격히 제한하여 예측 가능한 성능과 비용을 보장합니다.
이 풀은 주로 레이턴시에 민감하거나 안정성이 중요한 서비스 워크로드에 적합합니다. Spot 풀과 달리 예측 가능성이 보장되지만, 상한선을 넘는 부하가 들어오면 더 이상 확장되지 않으므로, 모니터링과 알림 체계를 반드시 연동해야 합니다.
NodeClass와 NodePool은 반드시 올바른 순서로 배포해야 합니다. NodeClass가 먼저 존재해야 NodePool에서 참조할 수 있기 때문입니다. 아래 순서를 그대로 따라가면 의존성 문제 없이 리소스를 적용할 수 있습니다.
# 1) 공통 NodeClass 생성
# AMI, 서브넷, SG, IAM Role을 정의한 기본 템플릿
kubectl apply -f ec2nodeclass.yaml
# 2) AZ 제한 Spot NodePool 생성
# ap-northeast-2a/2c만 허용하고, Spot 전용으로 동작
kubectl apply -f nodepool-zoned-spot.yaml
# 3) Family/Generation 제한 + 자원 상한 NodePool 생성
# On-Demand 전용, 특정 타입만 허용, CPU 합계 제한
kubectl apply -f nodepool-family-gen-limit.yaml
적용 후에는 Karpenter 컨트롤러가 새로 정의된 NodePool을 인식하고, 워크로드 스케줄링 시점에 해당 조건에 맞는 노드를 자동으로 생성하게 됩니다. NodePool 자체를 적용하는 순간에는 바로 노드가 뜨지 않고, 실제로 Pod가 스케줄링될 때 필요한 조건이 맞으면 노드가 만들어진다는 점을 기억해야 합니다.
리소스를 적용한 후에는 반드시 실제 동작이 의도대로 이루어지는지 확인해야 합니다. 단순히 YAML이 잘 적용되는 것만으로는 충분하지 않고, 실제 노드가 뜬 위치(AZ), 인스턴스 타입, 그리고 Pool 단위 상한이 모두 올바르게 반영되는지 검증해야 합니다.
# 노드가 뜬 AZ를 확인 (wide 옵션으로 AZ 라벨이 표시되는 환경)
kubectl get nodes -o wide | awk '{print $1, $7}'
# 또는 Node 라벨을 직접 지정해서 확인
kubectl get nodes -L topology.kubernetes.io/zone -l nodeclass=zoned
zoned-spot 풀에서 생성된 노드라면 topology.kubernetes.io/zone 라벨이 ap-northeast-2a 또는 ap-northeast-2c 중 하나여야 합니다. 다른 AZ가 보인다면 서브넷 태그나 NodePool 요구조건을 다시 확인해야 합니다.
kubectl get nodes -l nodeclass=family-gen --show-labels | grep 'karpenter.k8s.aws/instance'
출력된 라벨에는 karpenter.k8s.aws/instance-type과 karpenter.k8s.aws/instance-generation 값이 포함되어야 합니다. 노드가 반드시 m5.large, m5.xlarge, c6g.large, c6g.xlarge 중 하나여야 하며, 지정하지 않은 다른 타입이 뜬다면 requirements 설정을 다시 점검해야 합니다.
NodePool 단위로 CPU 합계가 64 vCPU를 넘지 않게 설정했으므로, 워크로드 수요가 늘어나더라도 그 이상으로는 노드가 확장되지 않아야 합니다.
# family-gen NodePool에 생성된 노드들의 vCPU 용량 확인
kubectl get nodes -l nodeclass=family-gen -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.capacity.cpu}{"\n"}{end}'
출력된 각 노드의 vCPU 수를 합산했을 때 총합이 64를 초과하지 않아야 합니다. 만약 그 이상으로 확장된다면 limits.resources가 올바르게 적용되지 않은 것이므로 설정 파일을 재검토해야 합니다.
운영 환경에서는 단순 합산 대신 **메트릭 서버(Metrics Server) + 모니터링 시스템(Prometheus, CloudWatch 등)**과 연동해 자동 경고를 걸어두는 것을 권장합니다. 이렇게 해야 CPU 상한 도달 시 워크로드 대기 상황을 조기에 인지하고 대응할 수 있습니다.
이번 단계에서는 Karpenter에서 NodePool Constraints & Limits를 활용해 확장 전략을 제어하는 방법을 살펴보았습니다. Spot 전용 풀(zoned-spot)은 특정 AZ만 허용해 비용 최적화를 실현하고, On-Demand 전용 풀(family-gen-limit)은 인스턴스 타입을 고정하고 CPU 총합 상한을 설정해 안정성과 예측 가능성을 확보했습니다. NodeClass가 공통 인프라 환경을 정의하고, NodePool이 정책적 제약과 자원 상한을 맡는 구조라는 점을 다시 한 번 확인할 수 있었습니다.
궁극적으로 이 설계는 “무제한 확장”의 위험을 줄이고, 비용 절감과 안정성 확보 사이에서 균형을 잡는 운영 전략을 가능하게 합니다. 실무에서는 Spot과 On-Demand 풀을 명확히 분리하고, 라벨·어피니티·상한 정책을 병행해 워크로드 특성에 맞게 배치하는 것이 모범 사례입니다. 앞으로도 TTL, 모니터링 알림, 아카이빙 같은 운영 도구와 결합하면 훨씬 더 견고한 Karpenter 환경을 구축할 수 있습니다.