Published on

EKS에서 Karpenter 노드 자동확장 튜닝 실전

Authors

EKS에서 노드 자동확장을 “잘” 만든다는 건 단순히 노드가 늘고 줄어드는 수준이 아닙니다.

  • 필요한 순간에 수십 초 내로 용량이 붙고
  • 불필요한 노드는 안전하게 정리되며
  • 워크로드 특성(아키텍처, 디스크, 네트워크, AZ, GPU 등)에 맞는 인스턴스가 정확히 선택되고
  • 비용(온디맨드/스팟), 안정성(PDB/중단 예산), 운영 편의성(드리프트/업그레이드)까지 균형을 잡아야 합니다.

Karpenter는 EKS에서 이런 목표를 달성하기 위한 강력한 구성요소입니다. 하지만 기본 예제만 적용하면 다음 문제가 자주 발생합니다.

  • 스케줄링은 되는데 특정 워크로드가 계속 Pending
  • 스팟 중단/회수 이후 복구가 느림
  • 노드가 너무 자주 교체되거나(드리프트/통합) 반대로 정리가 안 됨
  • 인스턴스 타입 선택이 비효율적이라 비용이 튐
  • 노드 부팅은 됐는데 네트워크/권한 문제로 파드가 못 뜸

이 글은 위 문제를 줄이기 위한 Karpenter 튜닝 체크리스트를 설정 예제와 함께 정리합니다.

관련 운영 이슈를 함께 보면 원인 파악이 더 빨라집니다.

Karpenter 튜닝의 핵심: “제약을 명확히, 선택지는 넓게”

Karpenter는 크게 두 단계를 반복합니다.

  1. 스케줄러가 파드를 배치하지 못하면(리소스/제약 충돌) Pending 발생
  2. Karpenter가 Pending 파드를 관찰하고, 요구사항을 만족하는 노드를 프로비저닝

여기서 튜닝 포인트는 두 가지입니다.

  • 요구사항(requirements)을 과하게 좁히지 말기: 인스턴스 타입을 1~2개로 고정하면 가용성과 가격 경쟁이 떨어지고, 특정 AZ에서 용량 부족 시 확장이 멈춥니다.
  • 반대로 운영상 반드시 필요한 제약은 명확히: 아키텍처, AZ, 디스크, 네트워크, 스팟/온디맨드 혼합 정책 같은 필수 조건은 모호하면 장애가 납니다.

사전 점검: EKS 네트워크와 권한이 “노드 부팅 후”를 보장하는가

Karpenter는 노드를 빠르게 띄우는 데 강하지만, 노드가 떠도 다음이 깨지면 파드는 계속 실패합니다.

  • 노드 IAM 역할(인스턴스 프로파일) 권한 부족
  • CNI 설정/보안그룹/서브넷 라우팅 문제
  • VPC 엔드포인트 정책 문제로 ECR/S3 접근 실패

특히 ECR 이미지 풀, S3 접근, CloudWatch 로그 전송은 노드가 뜬 직후 바로 필요해집니다. ImagePullBackOffAccessDenied 가 보이면 Karpenter 문제가 아니라 권한/엔드포인트/라우팅을 먼저 확인하세요.

NodePool/EC2NodeClass 설계: 워크로드 클래스로 분리하기

운영에서 가장 효과가 큰 튜닝은 “하나의 풀로 모든 걸 해결”하려는 시도를 버리고, 워크로드 성격에 따라 풀을 나누는 것입니다.

  • 기본 서비스용(온디맨드 위주, 안정성)
  • 배치/비동기 작업용(스팟 위주, 비용)
  • 메모리 집약형(메모리 최적화 인스턴스)
  • 네트워크 집약형(ENA 성능 좋은 계열)

아래 예시는 Karpenter v1 계열 리소스(일반적으로 NodePool + EC2NodeClass) 형태로 작성했습니다. 클러스터 버전에 따라 CRD 이름이 다를 수 있으니 설치한 Karpenter 버전에 맞춰 확인하세요.

apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default-ec2
spec:
  amiFamily: AL2
  role: eks-karpenter-node-role
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: my-eks
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: my-eks
  tags:
    karpenter.sh/discovery: my-eks
    owner: platform
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: on-demand-general
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default-ec2
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["5"]
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand"]
      taints:
        - key: dedicated
          value: general
          effect: NoSchedule
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    expireAfter: 720h
  limits:
    cpu: "2000"

튜닝 포인트 1: 인스턴스 선택을 “패밀리”로 열어두기

instance-type 를 고정하기보다 instance-category, generation, arch 같은 조건으로 범위를 열어두면:

  • 특정 타입 용량 부족 시 대체 타입으로 즉시 우회
  • 스팟 시장에서 더 싼 타입을 자동으로 선택

반대로, 워크로드가 특정 타입에 강하게 의존(예: 로컬 NVMe, 특정 네트워크 대역폭)한다면 그때만 좁히는 게 좋습니다.

튜닝 포인트 2: 풀별 taint 로 “의도치 않은 혼재” 방지

기본 풀에 taint를 걸고, 워크로드가 tolerations 로 들어오게 하면 다음이 좋아집니다.

  • 배치 파드가 서비스 노드를 잠식하는 상황 방지
  • 스팟 노드에 올라가면 안 되는 파드(상태ful, 중요한 API)를 보호

예시 워크로드:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      tolerations:
        - key: dedicated
          operator: Equal
          value: general
          effect: NoSchedule
      containers:
        - name: api
          image: public.ecr.aws/nginx/nginx:stable
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"

스팟 튜닝: 중단 내성 설계가 먼저, 그 다음이 비용

스팟을 쓰면 비용은 내려가지만, “중단 이벤트를 정상 시나리오로 받아들이는 설계”가 필요합니다.

1) 스팟 전용 NodePool을 따로 만들기

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: spot-batch
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default-ec2
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["5"]
      taints:
        - key: dedicated
          value: batch
          effect: NoSchedule
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    expireAfter: 168h

배치 워크로드는 tolerations 로만 스팟 풀에 들어오게 하고, 중요한 서비스는 온디맨드 풀에 남겨두는 것이 기본입니다.

2) PDB와 종료 유예시간으로 “중단 시 품질” 지키기

스팟 회수나 통합(consolidation)으로 노드가 종료될 때, 파드가 한꺼번에 내려가면 장애로 이어집니다.

  • PodDisruptionBudget 로 동시에 내려갈 수 있는 파드 수를 제한
  • terminationGracePeriodSeconds 를 충분히 부여
  • 프리스탑 훅으로 커넥션 드레인
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api

이 설정은 Karpenter가 노드를 정리할 때도 영향을 줍니다. 즉, 통합 정책을 공격적으로 잡아도 PDB가 안전장치가 됩니다.

통합(Consolidation)과 만료(Expire): 비용 최적화의 스위치

Karpenter 비용 최적화의 핵심은 통합입니다.

  • WhenEmptyOrUnderutilized: 비거나 덜 찬 노드를 더 적은 노드로 합치기
  • expireAfter: 노드 수명을 제한해 장기 드리프트/누적 문제를 정리

하지만 통합을 켜면 “노드 교체가 잦다”는 체감이 생길 수 있습니다. 이때는 아래 순서로 접근하세요.

  1. 먼저 PDB로 서비스 품질을 방어
  2. 그 다음 통합을 켜서 비용을 줄임
  3. 마지막으로 expireAfter 로 정기 교체 주기를 부여

운영 팁:

  • Stateful 워크로드가 많으면 통합이 기대만큼 안 될 수 있습니다(볼륨/어피니티 제약).
  • 통합이 안 되는 이유의 대부분은 requests 과도 설정, 과한 nodeAffinity, PDB 과보호 중 하나입니다.

스케줄링 튜닝: requests 가 곧 “용량 계획”이다

Karpenter는 파드의 resources.requests 를 기준으로 노드를 고릅니다. 즉, requests 가 부정확하면:

  • 과대 요청: 필요 이상 큰 인스턴스가 뜨고 비용 증가
  • 과소 요청: 노드는 작게 떠서 실제 사용량이 치솟고 OOM/CPU 스로틀링

특히 JVM/Node.js 같은 런타임은 메모리 피크가 튀기 쉬워서, 요청을 너무 낮게 잡으면 노드가 자주 불안정해집니다.

실무적으로는 다음을 권장합니다.

  • 서비스: requests 는 “평균 사용량 + 여유”, limits 는 “폭주 상한”
  • 배치: requests 를 높게 잡아도 되지만, 병렬도를 조절해 노드 낭비를 줄이기

멀티 AZ와 서브넷 선택: 확장 실패의 숨은 원인

Karpenter가 노드를 만들 때 서브넷을 고르는 로직은 subnetSelectorTerms 에 달려 있습니다.

  • 특정 AZ에만 서브넷이 선택되면, 그 AZ 용량 부족 시 확장이 멈춥니다.
  • 반대로 모든 서브넷을 열어두되, 워크로드가 AZ 편향(예: 특정 데이터 저장소)이라면 cross-AZ 트래픽 비용이 늘 수 있습니다.

권장 패턴:

  • 기본은 2~3개 AZ 모두 열어두기
  • 데이터 로컬리티가 필요한 워크로드는 topology.kubernetes.io/zone 어피니티를 “필요한 만큼만” 추가

주의: 어피니티를 너무 강하게 걸면 Pending 이 늘고, 그걸 해결하려고 인스턴스 타입을 좁히면 악순환이 됩니다.

네트워크 병목 튜닝: 노드가 늘어도 연결이 끊기면 의미가 없다

노드 자동확장을 해도, 노드 단위 커널/네트워크 한계로 장애가 나면 “확장할수록 더 망가지는” 상황이 됩니다. 대표적으로 conntrack 포화가 그렇습니다.

  • 고QPS API, 프록시, NAT, egress가 많은 워크로드는 conntrack 엔트리가 급증
  • 노드가 늘어나면 총 연결 수가 늘어 포화가 더 빨리 오기도 함

이 경우는 Karpenter 튜닝보다 노드 커널 파라미터, CNI, 서비스 메시/프록시 구성 점검이 먼저입니다. 자세한 원인/대응은 다음 글이 도움이 됩니다.

관측과 디버깅: “왜 Pending인가”를 1분 안에 답하기

Karpenter 튜닝은 관측 없이는 불가능합니다. 최소한 아래는 상시 확인 가능해야 합니다.

  • 특정 파드가 Pending 인 이유(스케줄러 이벤트)
  • Karpenter가 어떤 요구사항으로 노드를 만들려 했는지
  • 실제 생성된 노드의 인스턴스 타입, AZ, 용량 타입(스팟/온디맨드)

필수 커맨드

kubectl describe pod -n myns mypod

Events 에서 0/.. nodes are available 뒤에 붙는 사유(taint 미허용, affinity 불일치, 리소스 부족)를 먼저 봅니다.

kubectl get events -A --sort-by=.lastTimestamp | tail -n 50
kubectl -n karpenter logs deploy/karpenter -f

Karpenter 로그에서 “어떤 제약 때문에 어떤 인스턴스 후보가 탈락했는지”가 드러납니다. 여기서 자주 보이는 안티패턴은:

  • nodeSelector 로 특정 라벨을 강제했는데 해당 라벨을 가진 노드는 Karpenter가 만들 수 없음
  • instance-type 를 너무 좁혀서 후보가 0개
  • 스팟만 허용했는데 해당 시점에 스팟 용량이 없음

자주 겪는 튜닝 시나리오 5가지

1) 노드는 뜨는데 파드가 계속 ContainerCreating

  • CNI 문제, 보안그룹, 서브넷 라우팅, ECR 접근 문제 가능성이 큼
  • VPC 엔드포인트 정책이 S3/ECR을 막는 경우도 흔함

네트워크/엔드포인트 이슈는 아래 글의 케이스와 유사하게 나타납니다.

2) 특정 워크로드만 Pending

  • tolerations 누락(taint 풀에 못 들어감)
  • 강한 nodeAffinity 또는 topologySpreadConstraints 충돌
  • requests 가 커서 어떤 인스턴스에도 안 들어감

해결 순서:

  1. kubectl describe pod 이벤트 확인
  2. 요구사항을 “필수/선호”로 재분류(가능하면 선호로)
  3. 인스턴스 후보군 확대

3) 비용이 기대보다 안 내려감

  • 통합이 꺼져 있거나, PDB/어피니티 때문에 통합이 불가
  • requests 과대 설정으로 노드가 비효율적으로 큼
  • 스팟 풀에 들어가야 할 배치가 온디맨드에 올라감(taint/toleration 설계 미흡)

4) 노드가 너무 자주 갈아끼워짐

  • 통합이 너무 공격적이거나 expireAfter 가 너무 짧음
  • 워크로드가 자주 스케일 인/아웃하며 underutilized 판정이 잦음

대응:

  • 서비스 계열은 expireAfter 를 길게(예: 30일)
  • 배치 계열은 짧게(예: 7일)
  • PDB로 안전장치를 두고 통합 강도를 조절

5) 스팟 중단 후 복구가 느림

  • 스팟 후보군이 좁음(타입/세대/AZ)
  • 이미지 풀 시간이 길고, 워밍업이 없음

대응:

  • 인스턴스 후보군 확대
  • 컨테이너 이미지 최적화, 레이어 정리, 프리풀 전략 검토

운영용 권장 템플릿: “온디맨드 기본 + 스팟 배치”

실무에서 가장 무난한 조합은:

  • 온디맨드 NodePool: API/웹/핵심 서비스
  • 스팟 NodePool: 배치/큐 컨슈머/비동기
  • 공통 EC2NodeClass: 서브넷/보안그룹/태그 표준화
  • taint 로 워크로드 분리
  • PDB로 중단 안전성 확보
  • 통합은 켜되, expireAfter 는 워크로드별로 다르게

이 조합을 기준으로, 워크로드가 커지면 메모리 최적화 풀, GPU 풀 등을 추가하는 방식이 확장성도 좋습니다.

마무리: 튜닝 체크리스트

  • 인스턴스 타입을 고정하지 말고 카테고리/세대/아키텍처로 범위를 열어둘 것
  • 워크로드 클래스로 NodePool을 분리하고 taint 로 의도치 않은 혼재를 막을 것
  • 스팟은 “중단 내성(PDB/드레인)”이 설계된 워크로드에만 적용할 것
  • 통합은 비용 최적화의 핵심이지만, PDB/어피니티/requests가 발목을 잡는지 먼저 점검할 것
  • Pending 디버깅은 kubectl describe pod 이벤트에서 시작하고, Karpenter 로그로 후보 탈락 이유를 확인할 것
  • 노드가 늘어도 네트워크가 버티는지(conntrack, 엔드포인트, 라우팅) 함께 볼 것

위 원칙대로 구성하면 Karpenter는 “그냥 오토스케일러”가 아니라, EKS 운영의 비용과 안정성을 동시에 끌어올리는 핵심 레이어가 됩니다.