kubernetes

NodePool Constraints & Limits for Kapenter

Karpenter는 워크로드 수요에 맞춰 EC2 노드를 자동으로 증설/축소해 주는 오토스케일러입니다. 그러나 무제한 확장을 허용할 경우, 특정 AZ로의 쏠림, 예산 초과, 구세대 인스턴스 사용 등 예측하기 어려운 문제가 발생할 수 있습니다. 이를 방지하기 위해 Karpenter는 NodePool 단위 제약(Constraints) 과 자원 상한(Limits) 기능을 제공합니다. 즉, 확장 방향과 범위를 통제해 안정성과 비용 효율성을 동시에 확보할 수 있습니다.

이번에는 다음 세 가지를 종합적으로 다룹니다.

  • AZ 분산 제어: 특정 가용영역(AZ)을 포함하거나 제외
  • Family/Generation 제한: 인스턴스 계열/세대를 세밀하게 지정
  • 풀 단위 자원 상한: CPU/메모리 총량 기준으로 확장 규모 제한

단순한 오토스케일링을 넘어, 예측 가능한 성능/비용 관리를 가능하게 하는 핵심 설정을 실습합니다.

1. 설계 개요

하나의 공통 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-2a2c 가용영역만 허용하고 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 풀을 사용하도록 배치합니다.


2. 공통 EC2NodeClass (이전 단계와 동일)

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은 “노드가 가져야 할 스펙/정책”이라고 구분하면 이해가 쉽습니다.


3. NodePool 예시

NodePool은 Karpenter가 어떤 조건의 인스턴스를 언제, 얼마나 띄울지를 정의하는 핵심 리소스입니다. NodeClass가 공통 인프라 속성을 담당한다면, NodePool은 정책/제약/상한선을 구체적으로 기술하는 레이어라고 볼 수 있습니다. 아래 두 가지 예시는 Spot 기반 풀과 On-Demand 기반 풀을 나눠 설정하는 전형적인 방식입니다.

3.1 AZ 분산 — 특정 Zone 포함/제외 + Spot 전용

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 최적화 등)가 잘못 사용되지 않도록 제한합니다.
  • arch는 amd64와 arm64 둘 다 허용했으므로, 워크로드가 멀티 아키텍처 이미지를 지원할 경우 다양한 타입이 선택될 수 있습니다.

운영 시 주의할 점은 Spot은 언제든 회수될 수 있다는 것입니다. 따라서 이 풀은 배치 작업, 재시도 가능한 워크로드를 주로 배치하는 것이 적합합니다. 또한 In과 NotIn 조건은 동시에 쓰면 공집합이 생겨 의도치 않게 노드가 하나도 안 뜰 수 있으니 한쪽만 선택적으로 사용해야 합니다.


3.2 Family/Generation 제약 + On-Demand + 자원 상한

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 인스턴스만 허용하고, 타입을 엄격히 제한하여 예측 가능한 성능과 비용을 보장합니다.

  • instance-type을 In으로 특정 값 집합만 허용하면 가장 확실하게 원하는 스펙만 사용할 수 있습니다.
  • instance-generation을 활용하면 특정 세대 이상만 쓰도록 할 수 있어, 구세대 인스턴스를 피하고 최신 세대만 활용할 수 있습니다. 예를 들어 Gt: 4라고 하면 5세대 이상 인스턴스만 선택됩니다. 다만 타입을 직접 지정했을 경우에는 generation 제약은 큰 의미가 없습니다.
  • limits.resources.cpu=64는 풀 단위로 총 vCPU 합계를 제한해, 무분별한 확장을 방지합니다. 이는 곧 예산을 보호하는 효과를 가지며, 운영자가 노드 수 상한을 간접적으로 제어할 수 있게 합니다. 필요하다면 memory도 함께 제한할 수 있습니다.

이 풀은 주로 레이턴시에 민감하거나 안정성이 중요한 서비스 워크로드에 적합합니다. Spot 풀과 달리 예측 가능성이 보장되지만, 상한선을 넘는 부하가 들어오면 더 이상 확장되지 않으므로, 모니터링과 알림 체계를 반드시 연동해야 합니다.


4. 적용 순서

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가 스케줄링될 때 필요한 조건이 맞으면 노드가 만들어진다는 점을 기억해야 합니다.


5. 검증 방법

리소스를 적용한 후에는 반드시 실제 동작이 의도대로 이루어지는지 확인해야 합니다. 단순히 YAML이 잘 적용되는 것만으로는 충분하지 않고, 실제 노드가 뜬 위치(AZ), 인스턴스 타입, 그리고 Pool 단위 상한이 모두 올바르게 반영되는지 검증해야 합니다.

5.1 AZ 제한 확인

# 노드가 뜬 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 요구조건을 다시 확인해야 합니다.

5.2 인스턴스 타입/세대 확인

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 설정을 다시 점검해야 합니다.

5.3 자원 상한 동작 확인

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 환경을 구축할 수 있습니다.


참고 자료