Post

[EKS 재구축 #7] Karpenter 이해하고 설치하기 — 강의(v0.33·IRSA)를 v1.14·Pod Identity로 옮기며 만난 것들

📚 들어가며

6편에서 스토리지를 다뤘다. 이번 편은 노드 오토스케일러 Karpenter다.

강의 자료는 2024년 1월 기준 Karpenter v0.33.1 + IRSA + aws-auth ConfigMap 구성이다. 이 프로젝트는 지금까지 해온 대로 Karpenter 1.14.1 + EKS Pod Identity + Access Entry로 옮겨서 설치했다. 버전이 v0.x에서 v1.x로 넘어간 만큼 달라진 게 많다.

 강의 (2024년 1월)이번 실습 (2026년 10월)
Karpenter 버전v0.33.11.14.1
CRD APIv1beta1v1
컨트롤러 권한IRSA (OIDC 신뢰 + SA 어노테이션)EKS Pod Identity
노드의 클러스터 합류 권한aws-auth ConfigMap 수정EKS Access Entry
권한·태그 준비 방법공식 홈페이지 셸 스크립트Terraform 모듈 (eks//modules/karpenter)
스팟 회수 처리설정 없음SQS 큐 + EventBridge 규칙
AMIAmazon Linux 2Amazon Linux 2023

이번 편은 개념 → 권한 구성 → 버전 선택 → 설치 중 만난 에러 → Helm 설치까지다. NodePool 설계와 실제 오토스케일링 실습은 다음 편에서 다룬다. 처음 보는 사람도 읽을 수 있게 노드 오토스케일링, Cluster Autoscaler, CRD 같은 개념에는 짧게 설명을 붙였다.

이번 편 학습 지도

1
2
3
4
5
6
7
개념 (1장)              권한·버전 (2~4장)          실전 (5~6장)
──────────              ─────────────────          ────────────
HPA vs 노드 오토스케일러  셸 스크립트 → TF 모듈       에러 ① 고정 이름 충돌 🔦
Cluster Autoscaler       IAM 역할이 두 개인 이유     에러 ② 정책 6,144자 제한
Karpenter의 장점·주의점  모듈은 AWS만, 설치는 Helm   OCI 차트 · values 비교
NodePool / EC2NodeClass  1.15가 아니라 1.14.1       설치 전 검증 두 가지
고정 노드가 필요한 이유   고정 노드 크기 다시 잡기

1. 개념 — 왜 노드 오토스케일러가 필요하고, 왜 Karpenter인가

1-1. 파드 오토스케일링 vs 노드 오토스케일링

  • HPA (Horizontal Pod Autoscaler) — 부하가 늘면 파드 개수를 늘린다.
  • 그런데 파드를 늘려도 올라갈 노드에 자리가 없으면 파드는 Pending으로 멈춘다. 이때 노드를 늘려주는 것이 노드 오토스케일러의 일이다. 반대로 노드가 놀고 있으면 줄여서 비용을 아낀다.

즉 HPA는 “파드가 몇 개 필요한가”, 노드 오토스케일러는 “그 파드들을 담을 그릇이 충분한가”를 맡는다. 둘은 경쟁 관계가 아니라 위아래로 맞물리는 관계다.

1-2. 기존 방식: Cluster Autoscaler

Cluster Autoscaler(CA) 는 쿠버네티스 커뮤니티의 전통적인 노드 오토스케일러다.

  • 노드를 노드 그룹(AWS에서는 Auto Scaling Group, ASG) 단위로 관리한다.
  • Pending 파드가 생기면 “어느 노드 그룹의 노드 개수(desired) 를 늘릴지”를 정하고, 실제 EC2 생성은 ASG가 한다.
  • 그래서 노드 그룹마다 인스턴스 타입이 미리 정해져 있어야 하고, 다양한 타입을 쓰려면 노드 그룹을 여러 개 만들어 관리해야 한다. 강의 기준으로 노드가 준비되기까지 2~3분 이상 걸린다.

1-3. Karpenter

AWS가 만든 오픈소스 노드 관리 도구다. CA와 가장 큰 차이는 중간에 ASG가 없다는 점이다.

flowchart TB
    subgraph CA["Cluster Autoscaler"]
      direction LR
      P1["Pending 파드"] --> C1["CA<br>'어느 노드 그룹을<br>+1 할까?'"]
      C1 -->|desired 변경| ASG["ASG<br>(타입 미리 고정)"]
      ASG --> E1["EC2"]
    end
    subgraph KP["Karpenter"]
      direction LR
      P2["Pending 파드<br>(requests 합산)"] --> K["Karpenter<br>'이 파드들에 맞는<br>가장 싼 타입은?'"]
      K -->|EC2 Fleet API 직접| E2["EC2<br>(그때그때 최적 타입)"]
    end

    style CA fill:#3a2a1a,color:#fff
    style KP fill:#0a3a1a,color:#fff
    style ASG fill:#5a3a1a,color:#fff

강의 슬라이드에서 짚은 장점을 정리하면 이렇다.

장점내용
단순한 구성ASG를 거치지 않고 EC2를 직접 띄운다 (EC2 Fleet API). 노드 그룹을 미리 여러 개 만들 필요가 없다
파드에 맞춘 타입 선택Pending 파드들의 requests(CPU·메모리 요청량)를 보고, 허용된 타입 중 가장 저렴하게 들어맞는 인스턴스를 고른다
빠른 노드 준비강의 기준 약 45초 내외 (CA는 2~3분 이상)
비용 최적화 재배치 (Consolidation)노드가 비거나 덜 쓰이면 파드를 모아서 노드를 줄이거나 더 싼 노드로 교체한다

1-4. 주의할 점 — Karpenter는 노드를 “적극적으로” 바꾼다

Consolidation 덕분에 비용은 줄지만, 반대로 말하면 노드가 자주 바뀐다. 노드가 정리되면 그 위의 파드도 같이 종료되므로, 서비스가 흔들리지 않게 미리 준비해야 한다.

준비이유
Request 조정 (krr, kubecost 등으로 실사용량 측정)Karpenter는 requests만 보고 노드 크기를 정한다. 과하면 비용 낭비, 부족하면 노드에 파드가 너무 몰린다
LimitRangerequests를 안 적은 파드에 기본값을 넣어준다
PDB (PodDisruptionBudget)“이 앱은 최소 N개는 항상 떠 있어야 한다”를 보장해서, 노드 정리가 한꺼번에 다 죽이지 않게 한다
PriorityClass자원이 부족할 때 중요한 파드를 먼저 살린다
Graceful Shutdown파드가 종료 신호를 받고 진행 중인 요청을 마무리할 시간을 준다

6편 3-4에서 busybox가 SIGTERM을 무시해 grace period 30초를 꽉 채우고서야 죽는 걸 봤다. Karpenter 환경에서는 그런 파드가 노드 정리 때마다 30초씩 버틴다. Graceful Shutdown이 왜 목록에 있는지 바로 와닿는 대목이다.

1-5. CRD와 Karpenter의 세 가지 오브젝트

CRD (Custom Resource Definition) 는 쿠버네티스가 기본 제공하는 Pod, Service, Deployment 말고 새로운 종류의 리소스를 정의하는 방법이다. Karpenter는 Helm 차트와 함께 CRD를 설치하고, 우리는 그 CRD로 Karpenter에게 규칙을 알려준다.

flowchart LR
    NC["EC2NodeClass<br>(사용자 작성)<br>AWS에서 어떻게?<br>AMI · IAM · 서브넷 · SG · 디스크"]
    NP["NodePool<br>(사용자 작성)<br>무엇을 언제?<br>타입 · AZ · 스팟 · 수명 · 상한"]
    CL["NodeClaim<br>(Karpenter가 생성)<br>'이런 노드 하나 주세요'<br>노드 1개 = 1개"]
    N["EC2 노드"]

    NP -->|nodeClassRef| NC
    NP -->|파드가 Pending이면| CL
    CL --> N

    style NC fill:#1F6FEB,color:#fff
    style NP fill:#5A45FF,color:#fff
    style CL fill:#00875A,color:#fff
오브젝트누가 만드나역할
EC2NodeClass사용자노드를 AWS에서 어떻게 만들지 (AMI, IAM 역할, 서브넷, 보안 그룹, 디스크)
NodePool사용자어떤 노드를, 언제 만들고 없앨지 (인스턴스 타입·AZ·스팟 조건, 수명, 정리 정책, 자원 상한)
NodeClaimKarpenter“이런 노드 하나 주세요”라는 실제 요청 기록. 노드 하나당 하나 생긴다

이름의 역사: v0.32(2023년 10월) 이전에는 Provisioner, AWSNodeTemplate이라는 이름이었고, v0.32에서 NodePool, EC2NodeClass로 바뀌었다(v1beta1). 강의가 이 버전이다. 이후 v1.0에서 API가 v1으로 정식화되면서 필드 위치가 일부 바뀌었다. 자세한 차이는 다음 편에서 다룬다. 구글링하면 세 세대의 예제가 섞여 나오니, apiVersion부터 확인하는 습관이 필요하다.

1-6. Karpenter 자신은 어디서 도나 — “고정 노드”가 필요한 이유

Karpenter 컨트롤러도 결국 파드라서 어떤 노드 위에서 돌아야 한다.

flowchart TB
    subgraph BAD["❌ 자기가 만든 노드 위에 올라가면"]
      direction TB
      B1["Karpenter 노드 A<br>(Karpenter 컨트롤러 실행 중)"]
      B1 -->|Consolidation으로 정리| B2["노드 A 종료"]
      B2 --> B3["컨트롤러도 같이 종료<br>→ 새 노드를 만들 주체가 없음 💥"]
    end
    subgraph GOOD["✅ 고정 노드 위에 올리면"]
      direction TB
      G1["관리형 노드 그룹<br>(Terraform이 관리, Karpenter가 손대지 않음)"]
      G1 --> G2["Karpenter 컨트롤러"]
      G2 -->|생성·정리| G3["Karpenter 노드들"]
    end

    style BAD fill:#4a1a1a,color:#fff
    style GOOD fill:#0a3a1a,color:#fff

자기가 만든 노드 위에 올라가면, Consolidation으로 그 노드가 정리될 때 Karpenter도 같이 죽어서 새 노드를 만들 주체가 사라진다. 그래서 Karpenter가 관리하지 않는 고정 노드(Terraform이 만든 관리형 노드 그룹)를 최소한으로 두고, 컨트롤러는 거기에만 올린다.


2. 강의(IRSA, 셸 스크립트)를 Pod Identity로 옮기기

2-1. 강의가 손으로 하던 일 → 이 프로젝트의 대응

강의는 Karpenter 공식 홈페이지의 셸 스크립트로 IAM 역할을 만들고, 태그를 달고, aws-auth ConfigMap을 직접 편집했다. 이 프로젝트에서는 EKS 모듈 안에 있는 Karpenter 전용 서브모듈이 이걸 대부분 대신한다.

강의이 프로젝트
KarpenterControllerRole 생성, 신뢰 정책에 OIDC 조건 (IRSA)모듈이 컨트롤러 역할 생성. 신뢰 대상은 pods.eks.amazonaws.com (Pod Identity)
Helm values에 eks.amazonaws.com/role-arn 어노테이션삭제. 모듈이 kube-system/karpenter SA에 Pod Identity association을 자동 생성 (create_pod_identity_association)
KarpenterNodeRole 생성 + 정책 연결모듈이 노드 역할 생성 (create_node_iam_role 기본 true)
aws-auth ConfigMap에 노드 역할 등록모듈이 Access Entry 생성 (create_access_entry 기본 true). 이 클러스터는 authentication_mode = "API"라 aws-auth는 아예 동작하지 않는다 (2편)
스크립트로 서브넷·보안그룹에 karpenter.sh/discovery 태그2편에서 main.tf에 미리 넣어둠 (private_subnet_tags, node_security_group_tags)
(없음)모듈이 스팟 회수 알림용 SQS 큐 + EventBridge 규칙 생성

2-2. IAM 역할이 왜 두 개인가

flowchart LR
    subgraph POD["Karpenter 컨트롤러 파드"]
      KC["kube-system/karpenter SA"]
    end
    CR["컨트롤러 역할<br>ec2:CreateFleet<br>ec2:TerminateInstances<br>iam:PassRole ..."]
    NR["노드 역할<br>클러스터 합류<br>이미지 pull · VPC CNI"]
    EC2["Karpenter가 띄운<br>EC2 노드"]
    EKS["EKS 클러스터"]

    KC -->|Pod Identity| CR
    CR -->|"EC2 생성하며<br>PassRole로 넘겨줌"| NR
    NR --> EC2
    EC2 -->|Access Entry로 합류| EKS

    style CR fill:#FF9900,color:#000
    style NR fill:#1F6FEB,color:#fff
역할쓰는 주체필요한 권한
컨트롤러 역할Karpenter 컨트롤러 파드EC2를 띄우고 지우기(ec2:CreateFleet, ec2:TerminateInstances 등), 노드에 역할 넘겨주기(iam:PassRole)
노드 역할Karpenter가 띄운 EC2 노드클러스터 합류, 이미지 pull, VPC CNI 동작 (관리형 노드 그룹의 노드 역할과 같은 성격)

주체가 다르니 역할도 다르다. 컨트롤러는 “노드를 만드는 쪽”, 노드 역할은 “만들어진 노드가 쓰는 쪽”이다. 컨트롤러 역할에 iam:PassRole이 필요한 이유는, EC2를 띄울 때 노드 역할을 그 EC2에 넘겨줘야 하기 때문이다.

2-3. 실제 Terraform 코드 (tf-eks/karpenter.tf)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
module "karpenter" {
  source  = "terraform-aws-modules/eks/aws//modules/karpenter"   # EKS 모듈 안의 서브모듈
  version = "~> 21.0"

  cluster_name = module.eks.cluster_name

  # 컨트롤러 → Pod Identity (Helm 설치 때 이름을 맞춰야 해서 명시)
  create_pod_identity_association = true
  namespace                       = "kube-system"
  service_account                 = "karpenter"

  enable_inline_policy = true   # 5-2 에러 때문에 추가

  # 노드 역할: 이름을 고정 (EC2NodeClass YAML에 직접 적어야 하므로)
  node_iam_role_use_name_prefix = false
  node_iam_role_name            = "KarpenterNodeRole-${local.name}"
  node_iam_role_additional_policies = {
    AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  }

  create_access_entry     = true   # aws-auth ConfigMap 수정을 대체
  enable_spot_termination = true   # 스팟 회수 알림 SQS + EventBridge

  tags = local.tags
}

node_iam_role_use_name_prefix = false인 이유: 3편에서 겪었듯 이 모듈 계열은 이름을 접두사로 쓰고 랜덤 문자열을 붙이는 게 기본이다. 그런데 노드 역할 이름은 EC2NodeClass YAML의 spec.role에 직접 적어야 해서, apply할 때마다 바뀌면 곤란하다. 그래서 강의와 같은 이름 규칙으로 고정했다.

다음 단계(Helm values, EC2NodeClass)에서 쓸 값은 outputs.tf에 output으로 뽑아뒀다.

1
2
karpenter_queue_name         = "Karpenter-mydev"           # Helm values의 interruptionQueue
karpenter_node_iam_role_name = "KarpenterNodeRole-mydev"   # EC2NodeClass의 role

2-4. 오해: “main.tf에 이미 karpenter가 있던데?”

main.tf에서 karpenter라는 글자는 세 군데에 있었지만, Karpenter를 설치하는 코드는 하나도 없었다.

위치정체
eks_managed_node_groups = { karpenter = {...} }관리형 노드 그룹의 이름. 1-6의 “고정 노드”가 바로 이것이다. 이름만 karpenter일 뿐 평범한 노드 그룹
node_security_group_tags보안 그룹에 붙인 karpenter.sh/discovery 태그 (Karpenter가 노드에 붙일 SG를 찾는 표식)
private_subnet_tags서브넷에 붙인 같은 태그 (Karpenter가 노드를 띄울 서브넷을 찾는 표식)

즉 2편에서 “나중에 Karpenter가 올라갈 자리와 길잡이 표식” 을 미리 깔아둔 것이고, 실제 설치는 이번이 처음이다.

2-5. Terraform 모듈을 썼는데 왜 Helm 설치가 또 필요한가

이름이 “karpenter 모듈”이라 Karpenter를 설치해줄 것 같지만, 모듈 소스를 열어 리소스 종류를 세어보면 전부 AWS 리소스다.

1
2
3
4
5
aws_iam_role (2)          aws_iam_role_policy        aws_iam_role_policy_attachment (4)
aws_iam_policy            aws_iam_instance_profile
aws_eks_pod_identity_association                     aws_eks_access_entry
aws_sqs_queue             aws_sqs_queue_policy
aws_cloudwatch_event_rule aws_cloudwatch_event_target

helm_release나 kubernetes_* 리소스는 0개다. 컨트롤러 파드를 클러스터에 올리는 건 Helm의 몫이다. (aws_iam_policy와 aws_iam_role_policy는 둘 다 정의돼 있지만 enable_inline_policy 값에 따라 둘 중 하나만 만들어진다 — 5-2에서 다룬다)

지금까지 붙인 컨트롤러들과 비교하면 이렇다.

 AWS 쪽 준비 (Terraform)클러스터에 설치
LB Controller (3~4편)Pod Identity 모듈Helm
EBS CSI (6편)Pod Identity 모듈Terraform (EKS 애드온)
Karpenterkarpenter 서브모듈Helm

EBS CSI는 EKS 애드온이라 AWS API로 설치할 수 있었지만, Karpenter는 EKS 애드온으로 제공되지 않는다. 이 프로젝트는 Terraform이 aws provider만 쓰도록 경계를 정해뒀기 때문에(2편에서 kubernetes provider를 지운 이유), AWS 리소스는 Terraform, 클러스터 안은 Helm/kubectl로 나눴다. 공식 예제처럼 Terraform의 helm_release로 Helm 설치까지 한 번에 하는 방법도 있다.

2-6. SQS와 EventBridge — 스팟 회수를 미리 아는 법

sequenceDiagram
    participant AWS as AWS (EC2)
    participant EB as EventBridge 규칙
    participant Q as SQS 큐<br>(Karpenter-mydev)
    participant K as Karpenter
    participant N as 스팟 노드

    AWS->>EB: 스팟 회수 2분 전 알림
    EB->>Q: 이벤트 전달
    K->>Q: 큐 폴링
    Q-->>K: "노드 X 곧 회수됨"
    K->>N: cordon + drain (파드를 다른 노드로)
    K->>AWS: 대체 노드 미리 생성
    Note over N: 2분 뒤 회수돼도<br>파드는 이미 이동 완료
  • 원리: AWS는 스팟 인스턴스를 회수하기 2분 전에 알림을 보낸다. EventBridge가 그 알림을 SQS 큐에 넣고, Karpenter가 큐를 보다가 회수 전에 파드를 다른 노드로 미리 옮긴다. 큐 이름을 Helm values에 안 넣으면 이 기능이 꺼지고, 스팟이 회수될 때 노드가 그냥 사라진다.
  • 과금: SQS는 매달 100만 요청까지 무료이고 Karpenter의 사용량은 그보다 훨씬 적다. EventBridge는 AWS 서비스 이벤트를 받는 규칙이라 무료다. 사실상 $0.
  • 삭제: Terraform이 만든 리소스라 terraform destroy로 함께 지워진다. 5편의 “Terraform이 모르는 리소스”가 아니다.

3. 버전 정하기

3-1. 호환표 확인

Karpenter 공식 호환표(2026년 10월 기준)다.

Kubernetes1.331.341.351.361.37
Karpenter≥ 1.5≥ 1.6≥ 1.9≥ 1.131.15.x

클러스터가 1.34라 1.6 이상이면 된다.

3-2. 1.15.0이 아니라 1.14.1을 고른 이유

버전출시일판단
1.15.02026-10-10 (설치 시점 기준 이틀째)보류
1.14.12026-08-21✅ 선택
  • 최신 1.15.0은 갓 나온 .0 릴리스였다.
  • Terraform 모듈(v21.25.0, 8월)이 만드는 컨트롤러 IAM 정책이 1.15에서 새로 필요해진 권한까지 담고 있는지 확인되지 않았다. Karpenter는 버전이 오르며 호출하는 AWS API가 늘어나기도 해서, 정책이 따라오지 못하면 특정 동작만 AccessDenied로 실패할 수 있다.
  • 그래서 직전 라인의 패치 버전 1.14.1을 선택했다. 최신이 꼭 필요하면 1.15로 가고 문제가 생기면 대응하는 방법도 있다.

1편부터 “강의 자료의 시점을 의심하라”고 써왔는데, 반대 방향도 있다는 걸 이번에 느꼈다. 너무 새것도 의심해야 한다.


4. 내 계정이라 생긴 준비 작업 — 고정 노드 크기

이 장은 내 학습 계정의 제한 때문에 생긴 작업이다. 일반 계정이라면 4-3의 제약은 없을 수 있다.

4-1. 지금 노드로는 Karpenter가 못 뜬다

설치 전에 노드를 확인했더니 스팟이 t3.micro 2대를 잡았고, 할당 가능한 메모리가 대당 약 512Mi였다. 여기에 이미 LB Controller, external-dns, sealed-secrets, EBS CSI 컨트롤러, snapshot-controller, coredns가 올라가 있었다. Karpenter 컨트롤러(기본 replica 2개, 메모리를 꽤 쓰는 프로그램)가 들어갈 자리가 없다.

1
kubectl get nodes -o custom-columns='NAME:.metadata.name,TYPE:.metadata.labels.node\.kubernetes\.io/instance-type,CPU:.status.allocatable.cpu,MEM:.status.allocatable.memory'

4-2. “메인은 t3.small, micro는 대체 후보”라는 의도가 통하지 않은 이유

3편에서 instance_types = ["t3.small", "t3.micro"]로 두면서 “t3.small을 메인으로, t3.micro는 대체 후보”라고 생각했다. 그런데 스팟은 목록에 있는 타입 중 가격과 확보 가능성이 좋은 것을 AWS가 고른다. 목록의 순서는 우선순위가 아니다. 그래서 더 싼 t3.micro가 잡혔다.

💡 스팟의 instance_types는 우선순위 목록이 아니라 “후보 집합”이다. 크기를 보장하려면 목록에 그 크기의 타입만 넣어야 한다.

4-3. m7i-flex.large 하나로

내 계정은 “Free Tier 대상 인스턴스만 띄울 수 있다”는 제한이 있다(3편). 허용 목록 중 x86_64이고 우리 세 AZ(2a/2b/2c)를 모두 지원하는 large급은 m7i-flex.large(2vCPU/8GB)뿐이었다. 3편에서 만든 “AZ 지원 여부부터 확인” 습관을 그대로 썼다.

1
2
3
4
5
6
aws ec2 describe-instance-types --filters Name=free-tier-eligible,Values=true \
  --query 'InstanceTypes[].InstanceType'
aws ec2 describe-instance-type-offerings --location-type availability-zone \
  --filters Name=instance-type,Values=m7i-flex.large
aws ec2 describe-spot-price-history --instance-types m7i-flex.large \
  --product-descriptions "Linux/UNIX"
  • 스팟 단가: 시간당 $0.027~0.036 → 2대 기준 월 $40~52 (2026년 10월 조회 기준)
  • 약점: 타입이 하나라 스팟 풀이 3곳(타입 1 × AZ 3)뿐이다. 스팟을 못 잡으면 이 노드 그룹만 온디맨드로 바꾸는 것을 검토한다.

바꾼 뒤 클러스터를 다시 만들고 노드를 확인하면 이렇게 나온다.

1
2
3
4
$ kubectl get nodes -L node.kubernetes.io/instance-type,topology.kubernetes.io/zone,karpenter.sh/controller,eks.amazonaws.com/nodegroup
NAME                                              INSTANCE-TYPE    ZONE              CONTROLLER   NODEGROUP
ip-10-120-5-12.ap-northeast-2.compute.internal    m7i-flex.large   ap-northeast-2a   true         karpenter-a1b2c3d4e5f6...
ip-10-120-40-25.ap-northeast-2.compute.internal   m7i-flex.large   ap-northeast-2c   true         karpenter-a1b2c3d4e5f6...

NODEGROUP 이름이 karpenter가 아니라 karpenter-<랜덤> 이고 CONTROLLER=true 라벨이 붙어 있다. 왜 이렇게 됐는지가 바로 다음 장의 에러 ①이다.


5. apply 중 만난 에러 두 개

5-1. 🔦 에러 ① NodeGroup already exists with name karpenter

4-3에서 instance_types를 바꾸고 apply했더니 이 에러가 났다.

1
2
Error: creating EKS Node Group (mydev:karpenter): ... ResourceInUseException:
NodeGroup already exists with name karpenter and cluster name mydev

3편에서 똑같은 메시지를 본 적이 있다. 그때는 CREATE_FAILED 잔재가 남아서였다. 그런데 이번엔 실패한 노드 그룹이 없었다. 그래서 모듈 소스를 직접 열어봤다.

1
2
3
4
5
6
7
8
9
10
# .terraform/modules/eks/modules/eks-managed-node-group/main.tf
resource "aws_eks_node_group" "this" {
  ...
  lifecycle {
    create_before_destroy = true
    ignore_changes = [
      scaling_config[0].desired_size,
    ]
  }
}

원인이 여기 있었다.

  1. instance_types는 노드 그룹에서 수정할 수 없는 속성이라, 값을 바꾸면 노드 그룹이 통째로 교체된다 (2편의 ForceNew와 같은 개념).
  2. 모듈은 교체할 때 새 것을 먼저 만들고, 성공하면 옛것을 지운다 (create_before_destroy). 노드가 하나도 없는 순간을 피하려는 설계다.
  3. 그런데 우리는 use_name_prefix = false로 이름을 karpenter로 고정해두었다. 옛것이 아직 살아있는데 같은 이름으로 하나 더 만들려다 충돌한 것이다.
sequenceDiagram
    participant TF as Terraform
    participant EKS as EKS

    Note over EKS: 기존 노드 그룹 "karpenter" (t3.small/micro)
    TF->>TF: instance_types 변경 → 교체 필요 (ForceNew)
    Note over TF: create_before_destroy = true<br>→ 새 것 먼저 만들기
    TF->>EKS: 노드 그룹 "karpenter" 생성 요청
    EKS-->>TF: ❌ ResourceInUseException<br>(같은 이름이 아직 살아있음)
    Note over TF,EKS: 옛것을 지우는 단계까지 가지도 못함

교훈: “먼저 만들고 나중에 지우기”와 “고정 이름”은 함께 쓸 수 없다.

겉보기엔 2편의 IAM EntityAlreadyExists와 비슷하다. 하지만 그때는 삭제 직후의 전파 지연이었고, 이번엔 설계상 반드시 겹치는 구조적 충돌이다. 재시도해도 절대 안 풀린다.

해결: use_name_prefix = true로 바꿔 실제 이름이 karpenter-<랜덤>이 되게 했다. 3편에서 “랜덤 suffix는 재생성 충돌을 원천 차단한다”고 정리했던 그 장점을 이번엔 의도적으로 쓴 것이다.

대신 이름이 매번 바뀌니, 강의처럼 노드 그룹 이름(eks.amazonaws.com/nodegroup = karpenter)으로 고정 노드를 가리킬 수 없다. 그래서 라벨을 달았다.

1
2
3
4
5
6
7
8
karpenter = {
  name            = "karpenter"
  use_name_prefix = true
  labels = {
    "karpenter.sh/controller" = "true"   # Helm 설치 때 nodeSelector로 사용
  }
  ...
}

terraform plan으로 확인하니 의도대로 바뀌었다.

1
2
~ instance_types         = [ # forces replacement
+ node_group_name_prefix = "karpenter-" # forces replacement

3편의 CREATE_FAILED 때와 달리 이번엔 생성 요청 자체가 거절돼서 AWS에 남은 잔재가 없었고, 수동 정리 없이 다시 apply하면 됐다.

5-2. 에러 ② Cannot exceed quota for PolicySize: 6144

1
Error: creating IAM Policy (KarpenterController-...): LimitExceeded: Cannot exceed quota for PolicySize: 6144
  • 독립된 IAM 정책(managed policy)은 6,144자 제한이 있는데, Karpenter 컨트롤러 정책이 그보다 길었다.
  • 모듈의 variables.tf를 검색했더니, 이 에러 메시지가 설명에 그대로 적힌 전용 옵션이 있었다.
1
2
3
4
5
6
variable "enable_inline_policy" {
  description = "... This can be enabled when the error `LimitExceeded: Cannot exceed quota
                 for PolicySize: 6144` is received since standard IAM policies have a limit
                 of 6,144 characters versus an inline role policy's limit of 10,240 ..."
  default     = false
}

enable_inline_policy = true로 정책을 역할 안에 직접 넣는 인라인 정책(제한 10,240자)으로 바꿨다. 권한 내용은 같고 담기는 위치만 다르다.

 Managed policy (기본)Inline policy
형태독립 리소스 (aws_iam_policy) → 역할에 연결역할 안에 직접 포함 (aws_iam_role_policy)
크기 제한6,144자10,240자
재사용여러 역할에 붙일 수 있음그 역할 전용

💡 모듈에서 낯선 에러가 나면 variables.tf 설명부터 검색하자. 자주 나는 에러는 대개 모듈 작성자가 해결 옵션을 이미 만들어뒀다. 에러 메시지 일부를 그대로 grep하면 바로 찾힌다.


6. Helm으로 컨트롤러 설치

6-1. 차트 받기 — OCI 레지스트리

Karpenter 차트는 helm repo add가 아니라 OCI 레지스트리(컨테이너 이미지 저장소와 같은 방식)에서 바로 받는다.

1
2
3
4
5
cd practice/karpenter
helm pull oci://public.ecr.aws/karpenter/karpenter --version 1.14.1
# values 파일을 먼저 만들어둔 karpenter-1.14.1/ 폴더 안에 풀기
tar -xzf karpenter-1.14.1.tgz --strip-components=1 -C karpenter-1.14.1
rm karpenter-1.14.1.tgz

강의는 tar xvfz 후 mv karpenter karpenter-v0.33.1로 폴더 이름을 바꿨다. 나는 ci/ 폴더를 미리 만들어둬서, mv를 하면 압축 푼 폴더가 기존 폴더 안으로 들어가버린다. 그래서 --strip-components=1(맨 위 karpenter/ 디렉토리 한 단계를 벗김)과 -C(풀 위치 지정)로 기존 폴더 안에 바로 풀었다.

6-2. values — 강의 버전과 비교

강의 values:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::${AWS_ACCOUNT_ID}:role/KarpenterControllerRole-${CLUSTER_NAME}
settings:
  clusterName: ${CLUSTER_NAME}
affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
      - matchExpressions:
        - key: karpenter.sh/nodepool
          operator: DoesNotExist
      - matchExpressions:
        - key: eks.amazonaws.com/nodegroup
          operator: In
          values: [${NODEGROUP}]

이번 values (karpenter-1.14.1/ci/mydev-values.yaml):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
serviceAccount:
  create: true
  name: karpenter          # Pod Identity association과 정확히 같아야 함. role-arn 어노테이션은 없음

settings:
  clusterName: mydev
  interruptionQueue: Karpenter-mydev   # 스팟 회수 알림 큐 (강의엔 없음)
  # clusterEndpoint는 일부러 생략 → 시작할 때 EKS API로 스스로 알아냄

nodeSelector:
  karpenter.sh/controller: "true"   # 고정 노드에만 뜨도록 (노드 그룹 이름 대신 라벨)

controller:
  resources:
    requests: { cpu: 250m, memory: 1Gi }
    limits:   { memory: 1Gi }
항목이유
role-arn 어노테이션 삭제Pod Identity는 SA 이름만 맞으면 권한이 연결된다. 남겨두면 IRSA 웹훅이 끼어들어 자격증명 경로가 꼬일 수 있다 (4편 LB Controller에서 겪은 문제)
interruptionQueue 추가2-6의 스팟 회수 처리를 켠다
clusterEndpoint 생략클러스터를 재생성하면 엔드포인트가 바뀐다(4편). 적어두면 5편의 vpcId처럼 재생성할 때마다 고쳐야 하는 값이 하나 더 생긴다. 비워두면 EKS API(DescribeCluster)로 알아낸다
affinity → nodeSelector 라벨노드 그룹 이름이 karpenter-<랜덤>이 되어 이름 기반 조건이 깨진다(5-1). 차트 기본값에 이미 karpenter.sh/nodepool DoesNotExist(Karpenter 노드 회피)와 hostname anti-affinity, AZ 분산(topologySpread)이 들어 있어서 그 부분은 따로 안 적어도 된다
resources 명시차트 기본값이 {}로 비어 있다(주석의 권장 예시는 1 CPU / 1Gi). 고정 노드가 2vCPU라 다른 컨트롤러와 나눠 쓰도록 CPU 요청은 낮게, 메모리는 요청 = 제한으로

5편에서 “하드코딩한 값의 대가”를 치른 경험이 clusterEndpoint를 비워두는 판단으로 이어졌다. 재생성하면 바뀌는 값은 애초에 적지 않는다.

6-3. IMDS 문제는 없나?

4편에서 LB Controller는 노드 IMDS의 hop limit 1 때문에 VPC ID를 못 찾아 크래시가 났다. Karpenter도 시작할 때 리전 정보가 필요한데, 같은 문제가 생기지 않을까?

확인해보니 Pod Identity가 파드에 AWS_REGION 환경변수를 넣어준다. 이미 Pod Identity로 돌고 있던 LB Controller 파드에서 확인했다.

1
2
3
kubectl get pod -n kube-system -l app.kubernetes.io/name=aws-load-balancer-controller \
  -o jsonpath='{.items[0].spec.containers[0].env[*].name}'
# AWS_STS_REGIONAL_ENDPOINTS AWS_DEFAULT_REGION AWS_REGION AWS_CONTAINER_CREDENTIALS_FULL_URI ...

리전은 환경변수로, 클러스터 엔드포인트는 EKS API로 알아내니 IMDS를 볼 일이 없다. 4편에서 “IMDS를 막아두고 Pod Identity라는 통제된 경로로만 권한을 준다”고 정리했는데, Pod Identity가 자격증명뿐 아니라 리전 같은 기본 정보까지 함께 챙겨준다는 걸 알게 됐다.

6-4. 설치 전 검증 두 가지

① helm template으로 렌더링 확인 (클러스터에 아무것도 안 만든다, 4편)

1
helm template karpenter . -n kube-system -f ci/mydev-values.yaml | grep -A3 nodeSelector

SA 이름 karpenter, role-arn 어노테이션 0개, nodeSelector에 라벨, CLUSTER_NAME / INTERRUPTION_QUEUE 환경변수가 의도대로 들어간 것을 확인했다.

② 매니페스트를 차트의 CRD 스키마와 대조

NodePool/EC2NodeClass는 CRD가 설치되기 전엔 kubectl apply --dry-run=server로 검증할 수 없다. 서버가 그 리소스 종류를 아직 모르기 때문이다. 그래서 차트의 crds/ 폴더에 있는 스키마와 매니페스트 필드를 스크립트로 대조했다. v1에서 필드 위치가 바뀐 게 많아서(다음 편) 이 검증이 쓸모 있었다.

6-5. 설치와 확인

1
2
3
4
5
6
cd karpenter-1.14.1
helm install karpenter -f ci/mydev-values.yaml . -n kube-system

kubectl get pods -n kube-system -l app.kubernetes.io/name=karpenter -o wide   # 고정 노드 2대에 하나씩
kubectl get crd | grep karpenter        # ec2nodeclasses, nodeclaims, nodepools
kubectl logs -n kube-system -l app.kubernetes.io/name=karpenter

4편에서 배운 대로 -n kube-system을 빠뜨리지 않는다. Pod Identity association이 kube-system/karpenter에 걸려 있으니, 네임스페이스가 틀리면 에러 없이 권한만 안 붙는다.


📝 이번 편 요약

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
개념
├─ HPA는 파드 개수, 노드 오토스케일러는 그 파드를 담을 노드
├─ Cluster Autoscaler: 노드 그룹(ASG) 단위, 타입 고정, 2~3분+
├─ Karpenter: ASG 없이 EC2 직접, requests 맞춤 최저가 타입, ~45초, Consolidation
│    └─ 노드를 자주 바꾸므로 requests · PDB · PriorityClass · Graceful Shutdown 필수
├─ EC2NodeClass(어떻게) / NodePool(무엇을 언제) / NodeClaim(실제 요청 기록)
└─ 컨트롤러는 Karpenter가 관리하지 않는 고정 노드에

권한·버전
├─ 셸 스크립트 + IRSA + aws-auth → karpenter 서브모듈 하나
│    컨트롤러 = Pod Identity, 노드 = Access Entry, 스팟 회수 = SQS + EventBridge
├─ IAM 역할 두 개: 노드를 만드는 쪽(컨트롤러) / 만들어진 노드가 쓰는 쪽(노드)
├─ 모듈은 AWS 리소스만. 컨트롤러 설치는 Helm
└─ 1.15.0(이틀째)이 아니라 1.14.1 — 너무 새것도 의심

실전
├─ 스팟 instance_types는 우선순위가 아니라 후보 집합
├─ 에러 ① create_before_destroy + 고정 이름 = 구조적 충돌 → 접두사 + 라벨 🔦
├─ 에러 ② 정책 6,144자 초과 → enable_inline_policy (variables.tf에 답이 있었다)
└─ values: role-arn 삭제, interruptionQueue 추가, clusterEndpoint 생략, 라벨로 nodeSelector
개념한 줄 정의
노드 오토스케일러Pending 파드가 생기면 노드를 늘리고, 노드가 놀면 줄이는 컨트롤러
Cluster AutoscalerASG의 desired 값을 조정하는 전통적 방식. 노드 그룹마다 타입 고정
KarpenterASG 없이 EC2 Fleet API로 파드에 맞는 노드를 직접 띄우는 AWS 오픈소스
Consolidation덜 쓰이는 노드의 파드를 모아 노드를 줄이거나 더 싼 노드로 교체
CRD쿠버네티스에 새로운 리소스 종류를 추가하는 정의
EC2NodeClass / NodePool / NodeClaimAWS에서 어떻게 / 무엇을 언제 / 실제 노드 요청 기록
고정 노드Karpenter가 관리하지 않아 컨트롤러가 안전하게 머무는 관리형 노드 그룹
create_before_destroy교체 시 새 리소스를 먼저 만들고 옛것을 나중에 지우는 Terraform lifecycle 옵션
Inline policy역할 안에 직접 포함되는 IAM 정책. managed policy보다 크기 제한이 큼 (10,240자)
OCI 레지스트리컨테이너 이미지 저장소 방식으로 Helm 차트를 배포·수신하는 방법

💭 느낀 점

1. “karpenter”라는 글자가 세 군데 있었는데, Karpenter는 하나도 없었다.

main.tf를 보고 “이미 있는 거 아냐?” 했던 게 머쓱했다. 노드 그룹 이름, 태그 두 개 — 전부 2편에서 미래의 Karpenter를 위해 깔아둔 자리와 표식이었다. 그때는 “나중에 쓴다”는 주석만 보고 넘어갔는데, 7편에 와서야 그 자리에 실제로 뭔가가 올라갔다. 미리 준비해둔 게 의미를 갖는 순간이 꽤 짜릿했다.

2. 에러 ①은 메시지만 보면 3편과 똑같았다.

NodeGroup already exists with name karpenter. 3편에서 그대로 본 문장이라 반사적으로 “CREATE_FAILED 잔재 지우면 되겠지” 할 뻔했다. 그런데 잔재가 없었다. 그래서 모듈 소스의 lifecycle 블록까지 열어봤고, 원인은 완전히 달랐다. 2편의 already exists(전파 지연), 3편의 already exists(실패 잔재), 이번의 already exists(구조적 충돌). 같은 문장, 세 가지 원인. 에러 메시지는 출발점일 뿐이라는 걸 또 배웠다.

3. 모듈 소스를 여는 게 점점 자연스러워졌다.

1편에서는 Terraform이 “디렉터리 단위로 동작한다”는 것부터 배웠는데, 이번엔 .terraform/modules/ 안의 lifecycle 블록과 variables.tf 설명을 직접 읽어서 에러 두 개를 다 풀었다. 모듈은 블랙박스가 아니라 그냥 남이 쓴 .tf 파일이라는 감각이 생긴 게 이번 편의 가장 큰 수확 같다.

4. 너무 새것도 의심해야 한다.

시리즈 내내 “강의가 오래됐으니 최신으로”를 외쳐왔는데, 이번엔 처음으로 최신을 일부러 피했다. 이틀 된 1.15.0과, 8월에 나온 Terraform 모듈의 IAM 정책이 서로 맞는지 확신이 없었기 때문이다. 오래된 것과 너무 새것 사이에서 “검증된 최신” 을 고르는 게 실제 판단이라는 걸 느꼈다.

5. 하드코딩을 피하는 판단이 자연스럽게 나왔다.

clusterEndpoint를 values에 적을 수도 있었지만 비워뒀다. 5편에서 vpcId 때문에 재생성할 때마다 커밋을 하나씩 만들었던 경험 덕분이다. 같은 실수를 다른 자리에서 반복하지 않은 것 자체가 이 시리즈를 기록하며 공부한 보람인 것 같다.


🔗 다음 편 예고

이번엔 Karpenter 컨트롤러까지만 올렸다. 아직 노드를 하나도 만들지 않았다 — 무엇을 만들지 알려주는 NodePool이 없으니까.

  • EC2NodeClass와 NodePool 작성 — v1beta1 → v1에서 바뀐 필드
  • 강의의 service / platform 두 NodePool이 각각 무슨 역할인지 (라벨과 taint의 차이)
  • eks-node-viewer로 파드를 늘렸다 줄이며 노드가 생기고 사라지는 걸 지켜보는 실습
  • 6편에서 예고한 “볼륨이 있는 AZ에 노드가 있어야 한다”는 제약이 NodePool 설계와 어떻게 얽히는지

📚 참고

이 글의 계정 ID·IAM 역할 이름의 랜덤 suffix·클러스터명·노드 호스트명은 예시 값으로 치환했다. 버전 정보(1.15.0 출시일, 호환표)와 스팟 단가는 2026년 10월 기준이다. “Free Tier 대상 인스턴스만 띄울 수 있다”는 제한은 내 학습 계정 특유의 것이다.

This post is licensed under CC BY 4.0 by the author.

Comments powered by Disqus.