Karpenter로 EKS 노드 오토스케일링: NodePool·NodeClass, Disruption, 스팟 혼합 운영

이 글의 핵심

Karpenter는 AWS가 개발한 Kubernetes 노드 오토스케일러로, Cluster Autoscaler가 노드 그룹(ASG) 단위로만 확장한다는 한계를 피합니다. Pending Pod의 요구사항을 직접 분석해 맞는 EC2 인스턴스를 프로비저닝하고, 스팟·온디맨드 혼합과 빈 노드 정리(Consolidation)로 비용을 줄입니다. 설치, NodePool/EC2NodeClass, Disruption, 운영 시 확인할 지점을 순서대로 정리합니다.

이 글의 핵심

Karpenter는 AWS가 2021년에 공개한 Kubernetes 노드 오토스케일러로, 2024년에 v1.0이 나오면서 API(karpenter.sh/v1)가 안정화되었습니다. 코어 로직은 Kubernetes SIG Autoscaling 산하(kubernetes-sigs/karpenter)로 옮겨졌고, AWS용 구현은 aws/karpenter-provider-aws에 있습니다.

비교 대상인 Cluster Autoscaler는 노드 그룹(EKS에서는 ASG) 단위로 동작합니다. 노드 그룹마다 인스턴스 타입이 정해져 있으므로, 다양한 요구를 가진 Pod를 받으려면 노드 그룹을 여러 개 만들어 두어야 하고, Pending Pod가 생기면 “어느 노드 그룹의 desired 수를 늘릴지”만 결정할 수 있습니다. 스팟과 온디맨드를 섞거나 반쯤 빈 노드를 정리하는 것도 노드 그룹 구성에 따라 번거로워집니다.

Karpenter는 ASG를 거치지 않고 EC2 Fleet API(CreateFleet)를 직접 호출합니다. Pending Pod의 CPU·메모리·GPU·아키텍처·영역 요구를 모아 그에 맞는 인스턴스 타입 후보를 고르고, 필요 없어진 노드는 Consolidation으로 비우거나 더 싼 노드로 바꿉니다. 스팟/온디맨드, amd64/arm64 혼합도 NodePool의 requirements 한 곳에서 표현합니다.

아키텍처

┌──────────────────────────────────────────────────────┐
│ Kubernetes API Server                                │
│                                                      │
│   Pending Pod ── watch ──► Karpenter Controller      │
│                                      │               │
│                                      ▼               │
│                          NodePool / EC2NodeClass     │
│                                      │               │
└──────────────────────────────────────┼───────────────┘
                                       │
                                       ▼
                            AWS EC2 Fleet API
                                       │
                                       ▼
                   ┌─────────────────────────────────┐
                   │ EC2 인스턴스 (스팟/온디맨드)    │
                   │ kubelet → 클러스터에 노드 가입  │
                   └─────────────────────────────────┘

설치

전제 조건

EKS 클러스터와, Karpenter 컨트롤러를 올릴 고정 노드(소규모 관리형 노드 그룹 또는 Fargate 프로필)가 필요합니다. 컨트롤러가 AWS API를 호출하려면 IAM 역할을 연결해야 하므로 IAM OIDC provider(IRSA) 또는 EKS Pod Identity 중 하나를 준비합니다.

Helm 설치 (v1.x)

export CLUSTER_NAME=prod-eks
export AWS_REGION=ap-northeast-2
export KARPENTER_NAMESPACE=kube-system
export KARPENTER_VERSION=1.1.0

# 노드용 IAM 역할·컨트롤러 정책·SQS 인터럽션 큐·EventBridge 규칙 생성 (공식 CloudFormation)
curl -fsSL https://raw.githubusercontent.com/aws/karpenter-provider-aws/v${KARPENTER_VERSION}/website/content/en/preview/getting-started/getting-started-with-karpenter/cloudformation.yaml > karpenter.yaml

aws cloudformation deploy \
  --stack-name Karpenter-${CLUSTER_NAME} \
  --template-file karpenter.yaml \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides "ClusterName=${CLUSTER_NAME}"

# Karpenter 컨트롤러 설치
helm upgrade --install karpenter oci://public.ecr.aws/karpenter/karpenter \
  --version ${KARPENTER_VERSION} \
  --namespace ${KARPENTER_NAMESPACE} --create-namespace \
  --set "settings.clusterName=${CLUSTER_NAME}" \
  --set "settings.interruptionQueue=${CLUSTER_NAME}" \
  --set controller.resources.requests.cpu=1 \
  --set controller.resources.requests.memory=1Gi \
  --wait

CloudFormation 스택은 IAM 정책과 큐만 만들어 줍니다. 컨트롤러 서비스 계정에 역할을 연결하는 작업(IRSA 또는 Pod Identity association)과, 새 노드가 클러스터에 가입할 수 있도록 노드 IAM 역할을 EKS access entry(또는 예전 방식인 aws-auth ConfigMap)에 등록하는 작업은 공식 Getting Started 문서의 eksctl 단계로 따로 해야 합니다. 이 단계를 빠뜨리면 인스턴스는 뜨는데 노드가 NotReady로 남거나 아예 클러스터에 보이지 않습니다. Helm 차트는 기본으로 컨트롤러 2개(리더 선출 방식)를 띄웁니다.

NodeClass와 NodePool

v1에서는 노드 설정이 두 리소스로 나뉩니다. EC2NodeClass는 “어떤 AWS 리소스로 노드를 만들지”(AMI, 서브넷, 보안 그룹, IAM 역할, 디스크, UserData)를 정하고, NodePool은 “어떤 Pod를 어떤 조건의 노드에 올릴지”(taint, requirements, 용량 limit, Disruption 정책)를 정합니다. 하나의 EC2NodeClass를 여러 NodePool이 참조할 수 있습니다.

EC2NodeClass

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiSelectorTerms:
    - alias: al2023@latest   # amiFamily는 alias에서 추론됨

  role: "KarpenterNodeRole-prod-eks"

  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "prod-eks"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "prod-eks"

  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 100Gi
        volumeType: gp3
        iops: 3000
        encrypted: true
        deleteOnTermination: true

  tags:
    Environment: production
    ManagedBy: karpenter

al2023@latest는 편하지만, 새 AMI가 나오면 기존 노드가 drift로 판정되어 교체되기 시작합니다. 프로덕션에서는 al2023@v20240807처럼 버전을 고정하고, 스테이징에서 검증한 뒤 올리는 방식이 예측 가능성이 높습니다.

NodePool (기본 워크로드)

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default

      requirements:
        - key: "karpenter.sh/capacity-type"
          operator: In
          values: ["spot", "on-demand"]
        - key: "kubernetes.io/arch"
          operator: In
          values: ["amd64", "arm64"]
        - key: "karpenter.k8s.aws/instance-category"
          operator: In
          values: ["c", "m", "r"]
        - key: "karpenter.k8s.aws/instance-generation"
          operator: Gt
          values: ["5"]

      expireAfter: 720h   # 30일이 지난 노드는 교체 (AMI·보안 패치 반영)

  limits:
    cpu: "2000"
    memory: "4000Gi"

  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 30s

  weight: 10

capacity-type에 스팟과 온디맨드를 모두 허용하면 Karpenter는 스팟을 우선 시도하고, 스팟 용량을 얻지 못할 때 온디맨드로 띄웁니다. instance-category와 instance-generation은 c(컴퓨팅)·m(범용)·r(메모리) 계열의 6세대 이상으로 후보를 제한합니다. limits는 이 NodePool이 만들 수 있는 노드 용량의 합계 상한이라, 버그로 Pod가 폭증해도 비용이 무한정 늘지 않게 막는 안전장치 역할을 합니다. 여러 NodePool이 같은 Pod를 받을 수 있을 때는 weight가 높은 쪽이 먼저 선택됩니다.

상태 저장 워크로드용 NodePool

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: stateful
spec:
  template:
    metadata:
      labels:
        workload: stateful
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default

      taints:
        - key: workload
          value: stateful
          effect: NoSchedule

      requirements:
        - key: "karpenter.sh/capacity-type"
          operator: In
          values: ["on-demand"]    # 온디맨드만
        - key: "node.kubernetes.io/instance-type"
          operator: In
          values: ["m6i.xlarge", "m6i.2xlarge", "m6i.4xlarge"]

  limits:
    cpu: "200"

  disruption:
    consolidationPolicy: WhenEmpty   # 저활용만으로는 통합하지 않음
    consolidateAfter: 1h

  weight: 20

Postgres·Redis 같은 상태 저장 워크로드는 taint와 toleration으로 이 NodePool에만 올리고, 온디맨드만 쓰며, 노드가 완전히 비었을 때만 정리하게 해서 불필요한 Pod 이동을 줄입니다. EBS 볼륨은 특정 가용 영역에 묶이므로, 볼륨이 있는 영역의 서브넷이 EC2NodeClass에 포함되어 있는지도 확인해야 합니다.

Pod에서 활용

# 무상태 웹 (스팟 허용)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 10
  selector:
    matchLabels: {app: web}
  template:
    metadata:
      labels: {app: web}
    spec:
      containers:
        - name: app
          image: registry.example.com/web:1.0
          resources:
            requests: {cpu: 500m, memory: 512Mi}
            limits:   {cpu: 1,    memory: 1Gi}
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels: {app: web}

Karpenter는 Pod의 requests를 보고 노드 크기를 정하므로, requests가 실제 사용량과 크게 다르면 노드가 과하게 크거나 너무 빽빽하게 채워집니다. 스팟 노드가 회수되거나 Consolidation으로 노드가 비워질 때 한꺼번에 너무 많은 Pod가 내려가지 않도록 PodDisruptionBudget을 함께 둡니다.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: {name: web}
spec:
  minAvailable: 80%
  selector:
    matchLabels: {app: web}

Disruption: 비용 절감의 핵심

Karpenter의 Consolidation은 “같은 워크로드를 더 적은 노드나 더 저렴한 노드로 옮길 수 있다면 그렇게 한다”는 원리로 동작합니다.

disruption:
  consolidationPolicy: WhenEmptyOrUnderutilized
  consolidateAfter: 30s
  budgets:
    - nodes: "20%"        # 평소에는 동시에 최대 20%까지 Disrupt
    - nodes: "0"          # 평일 09:00부터 8시간 동안은 Disrupt 금지
      schedule: "0 9 * * mon-fri"
      duration: 8h

budgets가 여러 개면 그 시점에 활성화된 것 중 가장 제한적인 값이 적용됩니다. schedule은 UTC 기준 cron이므로 한국 시간 09시를 원하면 0 0 * * mon-fri처럼 9시간을 빼야 합니다.

Consolidation은 세 가지 방식으로 후보를 찾습니다. 빈 노드 정리(Empty)는 Pod가 없는 노드를 바로 삭제합니다. 다중 노드 통합(Multi-node)은 여러 노드를 한꺼번에 지우고, 필요하면 그 Pod들을 담을 더 저렴한 노드 하나로 대체합니다. 단일 노드 통합(Single-node)은 노드 하나를 지울 수 있는지, 아니면 더 저렴한 인스턴스 타입으로 교체할 수 있는지 봅니다. 어느 경우든 실제 동작은 삭제(Delete) 또는 교체(Replace) 둘 중 하나입니다.

드리프트

EC2NodeClass나 NodePool 스펙(AMI, 서브넷, requirements 등)이 바뀌면 기존 노드는 drift로 표시되고, disruption budget이 허용하는 범위에서 새 스펙의 노드로 점진적으로 교체됩니다.

스팟 운영

스팟 중단 위험을 줄이는 가장 효과적인 방법은 허용하는 인스턴스 타입과 가용 영역을 넓히는 것입니다. Karpenter는 후보 중에서 가격과 용량 상황을 고려해 고르므로(스팟은 price-capacity-optimized 전략), 후보가 많을수록 특정 스팟 풀이 고갈됐을 때 다른 풀로 피할 수 있습니다. 특정 인스턴스 타입 몇 개만 나열하는 것보다 위 예제처럼 category·generation으로 범위를 지정하는 편이 낫습니다.

중단 통지는 약 2분 전에 오며, Karpenter가 노드를 drain하면 Pod는 SIGTERM을 받습니다. 애플리케이션이 SIGTERM을 받고 진행 중인 요청을 마무리할 수 있어야 하고, terminationGracePeriodSeconds는 이 2분 안에 끝나는 값으로 둡니다. 로드밸런서가 대상에서 빠지기 전에 프로세스가 먼저 종료되는 것을 막기 위해 preStop에 짧은 대기를 넣는 패턴도 흔합니다.

containers:
  - name: app
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sh", "-c", "sleep 20"]  # LB 대상 해제가 전파될 시간을 벌어 줌

비용 측면에서는 arm64(Graviton)를 지원하는 이미지라면 아키텍처 requirements에 arm64를 함께 허용하는 것이 효과적입니다. 같은 사양 기준으로 Graviton 인스턴스의 온디맨드 가격이 x86보다 낮게 책정되어 있기 때문입니다. 다만 멀티 아키텍처 이미지를 빌드해 두지 않으면 arm64 노드에서 exec format error로 Pod가 실패하므로, 이미지가 준비된 워크로드에만 허용해야 합니다. 상시 필요한 기본 부하는 Savings Plans나 Reserved Instance로 덮고, 변동분을 스팟으로 처리하는 조합도 많이 씁니다.

관측

Karpenter는 Prometheus 형식의 메트릭을 노출하며, Helm 차트를 쓰면 ServiceMonitor를 켤 수 있습니다. 메트릭 이름은 v1에서 여러 개가 바뀌었으므로, 아래 이름은 v1 기준 예시로 보고 실제 /metrics 출력에서 확인하는 것이 안전합니다.

메트릭의미
karpenter_nodes_created_total생성된 노드 수 (카운터, rate()로 추세 확인)
karpenter_nodes_terminated_total종료된 노드 수
karpenter_pods_startup_duration_secondsPod 생성부터 Running까지 걸린 시간
karpenter_voluntary_disruption_decisions_totalConsolidation·drift 등 자발적 disruption 결정 횟수

Karpenter 저장소에는 이 메트릭을 쓰는 예제 Grafana 대시보드도 들어 있습니다.

보안

Karpenter 컨트롤러의 IAM 정책은 EC2 인스턴스 생성·삭제·태깅과 가격·AMI·SSM 파라미터 조회에 필요한 권한으로 제한하고, iam:PassRole은 노드가 실제로 쓰는 노드 역할에만 허용합니다. AMI는 alias로 지정해 보안 패치가 반영된 이미지를 쓰되, 앞에서 말했듯 버전을 고정해 교체 시점을 통제하는 편이 좋습니다. 민감한 워크로드는 전용 NodePool과 taint로 다른 워크로드와 같은 호스트에 올라가지 않게 분리하고, 필요하면 securityGroupSelectorTerms로 보안 그룹도 따로 둡니다. 노드 수준 격리와 별개로 Pod 쪽에는 Pod Security Standards의 restricted 프로필을 적용합니다.

트러블슈팅

Pending Pod가 노드를 못 받음

kubectl describe pod <pod>           # Event 확인
kubectl logs -n kube-system deploy/karpenter -c controller --tail 200

흔한 원인은 Pod의 nodeSelector·nodeAffinity·toleration이 어떤 NodePool의 requirements와도 맞지 않는 경우, NodePool의 limits에 도달한 경우, 그리고 EC2NodeClass의 서브넷·보안 그룹 selector가 태그 오타로 아무 리소스도 찾지 못한 경우입니다. kubectl get ec2nodeclass default -o yaml의 status에 해석된 서브넷·보안 그룹·AMI가 비어 있으면 selector 문제입니다.

새 노드가 Ready 상태로 못 넘어감

노드 IAM 역할이 EKS access entry(또는 aws-auth ConfigMap)에 등록되어 있지 않으면 kubelet이 클러스터에 인증하지 못합니다. 서브넷에서 EKS API 엔드포인트와 ECR에 접근할 수 있는지(프라이빗 서브넷이라면 NAT 또는 VPC 엔드포인트), 커스텀 UserData가 AMI 계열(AL2023의 nodeadm, Bottlerocket의 TOML)과 형식이 맞는지도 확인합니다.

스팟 interruption이 자주 발생

허용 인스턴스 타입과 가용 영역을 더 넓히는 것이 우선입니다. AWS Spot Instance Advisor에서 타입별 중단 빈도 범위를 참고할 수 있지만, 후보를 몇 개로 좁히면 오히려 용량 부족에 취약해집니다.

Consolidation이 일어나지 않음

PodDisruptionBudget이 maxUnavailable: 0처럼 설정되어 있으면 Pod를 옮길 수 없어 노드 정리가 막힙니다. karpenter.sh/do-not-disrupt: "true" 어노테이션이 붙은 Pod가 있는 노드도 정리 대상에서 빠지므로, 의도한 설정인지 확인해야 합니다. disruption budget이 현재 시간대에 0인지, consolidationPolicy가 WhenEmpty라서 저활용 노드는 애초에 대상이 아닌지도 봅니다. kubectl describe nodeclaim의 이벤트에 차단 사유가 남는 경우가 많습니다.

v1과 v0.x의 주요 차이

v0.xv1
ProvisionerNodePool
AWSNodeTemplateEC2NodeClass
ttlSecondsAfterEmptyconsolidationPolicy: WhenEmpty + consolidateAfter
consolidationPolicy: WhenUnderutilizedWhenEmptyOrUnderutilized
ttlSecondsUntilExpiredspec.template.spec.expireAfter

v0.x를 쓰고 있다면 중간 버전을 거치는 공식 마이그레이션 가이드 순서를 따라야 합니다. CRD가 바뀌므로 한 번에 최신 버전으로 건너뛰면 기존 리소스를 변환하지 못할 수 있습니다.


같이 보면 좋은 글