Overview
프로덕션 EKS 클러스터에서 Cluster Autoscaler를 Karpenter로 교체했다. 이 글에 실제로 어떻게 구성했는지를 그대로 정리했다.
Cluster Autoscaler는 노드 그룹(node group) 단위로 동작한다.
인스턴스 타입을 고정한(혹은 몇 개로 제한한) Auto Scaling Group을 미리 만들어두면, 파드가 스케줄링되지 못할 때 CA가 ASG의 desired count를 올리고 새로 뜬 노드에 그 파드에 맞기를 기대하는 식이다. 그러다 보니 인스턴스 형태, taint, AZ에 따라 노드 그룹을 잘게 쪼개두게 되고, 그 그룹들을 균형 있게 유지하는 운영 부담까지 함께 떠안는다.
Karpenter는 이 방식을 버린다. 펜딩 상태인 파드를 직접 들여다보고 실제 리소스 요청, 어피니티, toleration을 확인한 뒤, EC2 Fleet API로 몇 초 만에 딱 맞는 노드를 띄운다. 맞춰야 할 노드 그룹 형태라는 게 아예 없다. 파드를 더 적은 수의 노드에 빈 패킹(bin-packing)하고, 놀고 있는 노드는 다시 통합(consolidation)으로 줄이며, 스팟 중단(spot interruption)이 오면 AWS가 회수하기 전에 곧 사라질 노드를 미리 드레인한다.
이 글에서 특히 짚고 싶은 건 내가 택한 소유권 분리(ownership split) 방식이다. 이렇게 나누면 구성이 깔끔해지고 리뷰하기도 좋다.
- AWS 인프라는 Terraform에 둔다 — IAM 역할, SQS 중단 큐, EventBridge 규칙, 디스커버리 태그.
- 클러스터 내부 동작은 Helm/helmfile에 둔다 — Karpenter 컨트롤러 차트와 EC2NodeClass, NodePool 커스텀 리소스.
두 계층을 잇는 계약(contract)은 결국 문자열 두 개로 끝난다. 자세한 건 뒤에서 다룬다.

아키텍처: 두 개의 계층
전체 구조를 한눈에 보면 다음과 같다.
┌──────────────────────────────────────────────────────────────┐
│ Layer 1 — Terraform (terraform-infra-aws) │
│ │
│ • Controller IAM role (via EKS Pod Identity) │
│ • Node IAM role + instance profile │
│ • SQS interruption queue (Karpenter-prod-myapp-v1) │
│ • 5 EventBridge rules → SQS │
│ • Discovery tags on private subnets + node SG │
│ • EKS access entry for the node role │
│ │
│ outputs: node_iam_role_name, queue_name ───────┐ │
└─────────────────────────────────────────────────────────┼────┘
│
(the contract: 2 strings)
│
┌─────────────────────────────────────────────────────────┼────┐
│ Layer 2 — Helm / helmfile (kubernetes-infra) ◀─┘ │
│ │
│ release 1: karpenter (controller, chart v1.13.0) │
│ release 2: karpenter-cr (EC2NodeClass + NodePool) │
│ │
│ Managed node groups still exist for always-on: │
│ • system (controllers, core add-ons) │
│ • observability (monitoring stack) │
│ Karpenter-provisioned, scale-from-zero: │
│ • build (CI jobs, SPOT) │
│ • game (latency-sensitive, ON_DEMAND) │
└──────────────────────────────────────────────────────────────┘
매니지드 노드 그룹을 그대로 남겨둔 건 의도적이다. Karpenter 컨트롤러 자체와 핵심 애드온, 모니터링 스택은 늘 떠 있어야 하는(always-on) 워크로드라, Karpenter가 관리하는 노드 위에 올리면 안 된다. 컨트롤러가 자기가 올라가 있는 노드를 스스로 드레인하는 상황은 만들고 싶지 않기 때문이다.
반대로 잠깐 떴다 사라지거나 부하가 튀는 워크로드 — CI 러너, 게임 세션 — 는 전부 Karpenter에 맡긴다.
가장 중요한 두 가지 이벤트의 런타임 흐름은 다음과 같다.
Scale-up:
pending pod ──▶ karpenter controller ──▶ EC2 Fleet (RunInstances)
│
▼
node joins cluster ──▶ pod scheduled
Spot interruption:
AWS spot warning ──▶ EventBridge rule ──▶ SQS queue
│
▼
karpenter controller polls queue
│
▼
cordon + drain node within ~2-min window
Layer 1: Terraform AWS 인프라
`terraform-aws-modules/eks` 의 Karpenter 서브모듈을 사용한다. AWS 쪽 전체는 모듈 호출 한 번과 몇 가지 보조 리소스로 끝난다.
Karpenter 모듈 호출
module "karpenter" {
source = "../../../modules/eks/v21.23.0/modules/karpenter"
cluster_name = module.eks.cluster_name
namespace = "kube-system"
service_account = "karpenter"
create_pod_identity_association = true
enable_inline_policy = true
enable_spot_termination = true
node_iam_role_additional_policies = local.eks_node_additional_policies
node_iam_role_use_name_prefix = false
tags = {
"karpenter.sh/discovery" = module.eks.cluster_name
}
}
이 플래그 중 몇 개는 짚고 넘어갈 만하다.
`create_pod_identity_association = true` — IRSA가 아니라 Pod Identity. 이 한 줄이 전체 구성에서 가장 크게 단순화해주는 부분이다. IRSA를 쓴다면 OIDC 프로바이더, OIDC issuer와 sub 클레임을 고정하는 trust policy JSON, 그리고 서비스 어카운트에 붙이는 `eks.amazonaws.com/role-arn` 어노테이션이 필요하다.
`node_iam_role_use_name_prefix = false` 옵션을 줘야 karpenter role 이름 뒤에 random suffix가 붙지 않는다
(e.g Karpenter-<cluster_name>)
Pod Identity에는 이런 게 하나도 없다. 모듈은 `kube-system/karpenter` 서비스 어카운트를 컨트롤러 역할에 묶어주는 `aws_eks_pod_identity_association` 하나만 만들고, 역할의 `trust policy` 도 정적 `principal` 하나로 끝난다.
{
"Effect": "Allow",
"Principal": { "Service": "pods.eks.amazonaws.com" },
"Action": ["sts:AssumeRole", "sts:TagSession"]
}
trust policy에 OIDC issuer URL이 박히지 않으니, 같은 역할을 여러 클러스터에서 재사용할 수 있다. IAM이 클러스터의 OIDC 아이덴티티와 완전히 분리되는 셈이다.
enable_inline_policy = true — 6,144자 문제. Karpenter 컨트롤러의 권한 세트는 크다. AWS managed/customer-managed 정책은 6,144자가 한계인데, Karpenter 컨트롤러 정책 전체는 이를 가뿐히 넘긴다.
그래서 모듈은 이를 managed policy로 붙이는 대신 인라인 역할 정책(inline role policy)으로 붙인다. 인라인 정책은 한계가 10,240자로 더 넉넉하다. 부여되는 권한 그룹을 요약하면 다음과 같다.
- `ec2:RunInstances`, `ec2:CreateFleet` — 태그 조건(`kubernetes.io/cluster/<name> = owned` 및 `karpenter.sh/nodepool` 태그)으로 스코프를 좁혀서, Karpenter가 이 클러스터의 NodePool에 대해서만 인스턴스를 띄울 수 있게 한다.
- `ec2:CreateTags` — 자기가 띄운 인스턴스에 태그를 다는 용도.
- `ec2:TerminateInstances` — 자기가 소유한 인스턴스로 한정.
- `ec2:Describe*` 읽기 전용 호출 — 리전 단위로 제한.
- `ssm:GetParameter` — SSM 공개 파라미터(al2023@latest 별칭)에서 AMI ID를 읽기 위함.
- `pricing:GetProducts` — 스팟/온디맨드 가격을 고려한 프로비저닝용.
- `sqs:ReceiveMessage` / `sqs:DeleteMessage` / `sqs:GetQueueAttributes` — 중단 큐 대상.
- `iam:CreateInstanceProfile` / `AddRoleToInstanceProfile` 등 — Karpenter가 인스턴스 프로파일을 직접 관리한다.
- `iam:PassRole` — 노드 역할용이며 `iam:PassedToService` = `ec2.amazonaws.com` 으로 제한.
- `eks:DescribeCluster` — 클러스터 엔드포인트와 CA 인증서를 읽기 위함.
노드 IAM 역할 추가 정책
Karpenter가 띄우는 인스턴스에는 자체 역할이 필요하다.
모듈이 EKS 기본 정책(`AmazonEKSWorkerNodePolicy`, `AmazonEC2ContainerRegistryPullOnly`, `CNI` 정책)을 붙여주고, 여기에 내 워크로드가 실제로 필요로 하는 것을 더한다.
node_iam_role_additional_policies = {
ebs_csi = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
efs_csi = "arn:aws:iam::aws:policy/service-role/AmazonEFSCSIDriverPolicy"
ssm = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
스토리지용 EBS/EFS CSI, 그리고 SSH나 bastion 없이 `aws ssm start-session` 으로 Karpenter 노드에 바로 들어가기 위한 SSM Managed Instance Core를 더했다.
SQS 중단 큐 + EventBridge
이게 스팟에서 살아남게 해주는 장치다. AWS가 스팟 인스턴스를 회수하려 할 때, 대략 2분 전에 경고를 보낸다. Karpenter는 이 경고를 받으려고 SQS 큐를 폴링하다가, 인스턴스가 사라지면서 파드가 그냥 죽어버리게 두는 대신 노드를 미리 graceful하게 드레인한다.
resource "aws_sqs_queue" "this" {
name = "Karpenter-${var.cluster_name}" # -> Karpenter-prod-myapp-v1
message_retention_seconds = 300
sqs_managed_sse_enabled = true
}
큐 정책은 `events.amazonaws.com` 과 `sqs.amazonaws.com` 에 `SendMessage` 를 허용하고, TLS가 아닌 접근은 전부 거부한다(`aws:SecureTransport = false`).
{
"Sid": "DenyHTTP",
"Effect": "Deny",
"Principal": "*",
"Action": "sqs:*",
"Resource": "<queue-arn>",
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}
다섯 개의 EventBridge 규칙이 이 큐로 이벤트를 흘려보낸다.
| 규칙 | 잡아내는 이벤트 |
| Spot Interruption Warning | 약 2분 전 스팟 회수 통지 |
| Instance Rebalance Recommendation | "이 스팟 노드가 위험하다"는 조기 신호 |
| Instance State-change | 인스턴스가 stopping / terminated로 진입 |
| AWS Health Event | 예정된 유지보수 / 성능 저하 |
| Capacity Reservation Interruption | ODCR 기반 예약 용량 회수 |
이 중 하나라도 발생하면 컨트롤러는 곧 사라질 노드를 cordon하고 경고 시간 안에 드레인해서, 인스턴스가 없어지기 전에 파드를 멀쩡한 노드로 옮긴다. CI를 스팟에서 안심하고 돌릴 수 있는 건 바로 이 덕분이다.
디스커버리 태깅
Karpenter는 서브넷 ID나 보안 그룹 ID를 직접 받지 않는다. 대신 EC2NodeClass가 런타임에 태그를 보고 골라낸다. 그래서 Terraform이 할 일은 그 태그를 미리 달아두는 것뿐이다.
# Tag private subnets for Karpenter subnet discovery
resource "aws_ec2_tag" "karpenter_subnet_discovery" {
for_each = toset(local.private_subnets)
resource_id = each.value
key = "karpenter.sh/discovery"
value = module.eks.cluster_name
}
# Tag the node security group (in the eks module call)
node_security_group_tags = {
"karpenter.sh/discovery" = var.eks_cluster_name
}
- 이 `karpenter.sh/discovery = prod-myapp-v1` 태그가 곧 Layer 2의 EC2NodeClass에서 `subnetSelectorTerms` 와 `securityGroupSelectorTerms` 가 고를떄 쓰는 기준이다.
- Terraform에서 태그를 달고, EC2NodeClass에서 그 태그로 고른다.
클러스터 컨텍스트
참고로 관련 tfvars 는 다음과 같다.
region = "eu-central-1"
eks_cluster_name = "prod-myapp-v1"
eks_kubernetes_version = "1.35"
eks_public_access_cidrs = ["203.0.113.0/32"]
# Managed node groups (always-on); build + game are Karpenter-provisioned
eks_node_system = {
instance_type = "t4g.medium"
capacity_type = "ON_DEMAND"
min_size = 2
desired_size = 2
max_size = 4
}
클러스터는 `authentication_mode = "API"`(`aws-auth` ConfigMap 없이 access entry 사용), `enable_irsa = true`(Karpenter는 Pod Identity를 쓰지만 다른 워크로드에는 여전히 IRSA가 쓸모 있다), IMDSv2 강제로 운영한다.
Karpenter 컨트롤러와 핵심 애드온은 t4g.medium(Graviton, 온디맨드) 위의 system 매니지드 노드 그룹에 올라간다.
핸드오프: Helm이 받아 쓰는 Terraform 출력
이게 Overview에서 말한 내용이다. Terraform이 출력 몇 개를 내놓고, Helm이 그걸 받아 쓴다.
output "karpenter_node_iam_role_name" { value = module.karpenter.node_iam_role_name } # -> EC2NodeClass spec.role
output "karpenter_queue_name" { value = module.karpenter.queue_name } # -> Helm settings.interruptionQueue
output "karpenter_controller_iam_role_arn" { value = module.karpenter.iam_role_arn }
두 계층을 잇는 인터페이스는 이게 전부다. 노드 IAM 역할 이름(띄운 인스턴스가 올바른 역할을 assume하도록 EC2NodeClass가 필요로 한다)과 중단 큐 이름(컨트롤러가 폴링하려고 필요로 한다),
딱 문자열 두 개다. 그 외 나머지는 각 계층 안에서만 돌아간다. 덕분에 AWS 변경과 클러스터 내부 변경을 서로 부딪힐 걱정 없이 별도 PR로 나눠 리뷰할 수 있다.
Layer 2: Helm / helmfile 클러스터 내부 설정
클러스터 내부 설정은 helmfile 컴포넌트 하나에 모아둔다. 레이아웃은 다음과 같다.
karpenter/
├── Chart.yaml # controller chart version pin (v1.13.0)
├── helmfile.yaml.gotmpl # 2 releases: karpenter (controller) -> karpenter-cr (CRs)
├── values.yaml # upstream defaults
├── values/
│ ├── prod.yaml # controller overrides
│ └── prod-cr.yaml # NodeClass + NodePool definitions
├── cr-chart/ # the published somaz94/karpenter-cr chart source
└── README.md / README-en.md
- 컨트롤러와 커스텀 리소스를 helm 릴리스 두 개로 일부러 갈라놨는데, 이 순서가 중요하다.
Helmfile 토폴로지와 CRD 부트스트랩 문제
repositories:
- name: somaz94
url: https://charts.somaz.blog
releases:
- name: karpenter
namespace: kube-system
chart: oci://public.ecr.aws/karpenter/karpenter
version: 1.13.0
wait: true
values:
- values/prod.yaml
hooks:
- events: ["prepare"]
command: "bash"
args:
- "-c"
- |
if [ "${HELMFILE_SKIP_CLUSTER_HOOKS:-}" = "1" ]; then exit 0; fi
helm template karpenter-crd \
oci://public.ecr.aws/karpenter/karpenter-crd --version 1.13.0 \
| kubectl apply --server-side --force-conflicts -f -
- name: karpenter-cr
namespace: kube-system
chart: somaz94/karpenter-cr
version: 0.1.0
needs:
- kube-system/karpenter
values:
- values/prod-cr.yaml
여기엔 닭이 먼저냐 달걀이 먼저냐 하는 문제가 하나 있다. 두 번째 릴리스는 EC2NodeClass와 NodePool 리소스를 배포한다.
그런데 (변경할 때마다 내가 돌리는) `helmfile diff` 는 이 리소스 종류(kind)를 실제 API 서버에 물어보며 해석하려 든다.
CRD가 아직 안 깔려 있으면 API 서버는 EC2NodeClass가 뭔지 모르고, 그래서 diff가 실패한다. 공식 Karpenter 차트가 CRD를 karpenter-crd라는 별도 차트로 떼어둔 것도, 이 라이프사이클을 직접 다루라는 의도다.
prepare 훅이 이걸 풀어준다. 다른 게 돌기 전에 karpenter-crd OCI 차트에서 CRD를 곧바로 server-side-apply한다. 그러면 diff가 CR 릴리스를 평가할 때쯤엔 이미 해당 종류가 존재한다.
이 방식을 CI에서도 잘 돌게 해주는 디테일이 두 가지 있다.
- `HELMFILE_SKIP_CLUSTER_HOOKS=1` — CI에서는 클러스터 접근 없이 helmfile lint만 돌리는데, 이 환경변수가 훅을 건너뛰게 해서 파이프라인이 있지도 않은 kubeconfig를 찾으려 하지 않는다.
- CR 릴리스의 `needs: [kube-system/karpenter]` — 컨트롤러가 먼저, CR이 나중이라는 순서를 못 박는다. NodePool을 reconcile할 컨트롤러도 없는데 NodePool부터 올려봤자 소용없다.
EC2NodeClass와 NodePool은 매니페스트를 그대로 인라인으로 박지 않고, 따로 퍼블리시한 차트(`somaz94/karpenter-cr`, 내 https://charts.somaz.blog에서 서빙)로 관리한다. 그래야 컨트롤러 차트처럼 CR 정의도 버전을 매기고 여러 클러스터에서 돌려쓸 수 있다.
컨트롤러 values
`values/prod.yaml`
replicas: 1 # single dev/prod cluster; upstream default is 2
nodeSelector:
nodegroup-workload: system # controller runs on always-on managed nodes
serviceAccount:
create: true
name: karpenter # Pod Identity -> no role-arn annotation needed
controller:
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: "1", memory: 1Gi }
settings:
clusterName: prod-myapp-v1
clusterEndpoint: https://EXAMPLE0123456789ABCDEF.gr7.eu-central-1.eks.amazonaws.com
interruptionQueue: Karpenter-prod-myapp-v1
서비스 어카운트에 어노테이션이 없다는 점을 보자. 이것도 Pod Identity 덕분이다. IRSA였다면 여기 `eks.amazonaws.com/role-arn: ...` 이 붙어 있었을 것이다. Pod Identity에서는 연결(association)이 통째로 AWS 쪽에 있어서, 컨트롤러는 SA 이름만 맞으면 그만이다.
interruptionQueue와 clusterName이 바로 Terraform 출력에서 곧장 넘어온 두 값이다. 계약이 실제로 작동하는 지점이다.
업스트림 차트 기본값에는 컨트롤러를 지키는 합리적인 안전장치가 몇 개 들어 있는데, 알아둘 만하다.
- `karpenter.sh/nodepool DoesNotExist` 라는 nodeAffinity 덕분에 컨트롤러가 자기가 관리하는 노드에는 절대 안 뜬다(여기에 컨트롤러를 system 그룹에 고정하는 내 nodeSelector까지 더하면 이중으로 안전하다).
- AZ 간 topologySpreadConstraints.
- `priorityClassName: system-cluster-critical`
거의 기본값 그대로 둔 feature gate와 배치 튜닝 중 눈여겨볼 것들이다.
- `spotToSpotConsolidation: false` — 스팟을 스팟으로 통합하겠다고 스팟 노드를 굳이 갈아치우지 않는다.
- `reservedCapacity: true` — 스케줄링 시 용량 예약을 고려한다.
- `batchMaxDuration: 10s` / `batchIdleDuration: 1s` — Karpenter는 무엇을 띄울지 정하기 전에 짧은 시간 동안 펜딩 파드를 모아서(batch) 본다. 그래서 한꺼번에 몰린 파드 10개에 노드가 10개 따로 뜨는 대신, 잘 패킹된 노드 하나로 묶인다.
NodePool과 EC2NodeClass
여기가 전체 설정의 핵심이다. `values/prod-cr.yaml` 이 노드 클래스와 풀을 정의한다. 먼저 공유 기본값이다.
clusterName: prod-myapp-v1
nodeClassDefaults:
role: Karpenter-prod-myapp-v1 # Karpenter-<cluster_name>
amiAlias: al2023@latest
nodeClasses:
- name: build
- name: game
# both inherit: AL2023 arm64, IMDSv2 (httpTokens: required),
# 50Gi gp3 encrypted root, subnet/SG via karpenter.sh/discovery=prod-myapp-v1
여기서 role이 바로 Terraform 출력의 노드 IAM 역할 이름, 계약의 나머지 절반이다. `amiAlias: al2023@latest` 는 Karpenter가 launch 시점에 SSM으로 최신 Amazon Linux 2023 AMI를 읽어온다는 뜻이다(컨트롤러의 `ssm:GetParameter` 권한이 이걸 위한 것이었다).
두 노드 클래스 모두 ARM64 AL2023, IMDSv2 필수(`httpTokens: required`), 50Gi 암호화 gp3 루트 볼륨, 그리고 Terraform에서 달아둔 `karpenter.sh/discovery = prod-myapp-v1` 태그 기반 서브넷/SG 디스커버리를 물려받는다.
이제 재미있는 부분이다. 전략을 일부러 다르게 잡은 NodePool 두 개를 보자.
build NodePool — SPOT, CI 잡용
- name: build
nodeClassRef: build
labels:
nodegroup-workload: build
taints:
- key: dedicated
value: build
effect: NoSchedule
requirements:
- { key: kubernetes.io/arch, operator: In, values: ["arm64"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["spot"] }
- { key: karpenter.k8s.aws/instance-family, operator: In, values: ["c7g","c6g","m7g","m6g"] }
- { key: karpenter.k8s.aws/instance-size, operator: In, values: ["xlarge"] }
limits:
cpu: "24"
CI 잡은 교과서적인 스팟 워크로드다. 완전히 일회성이고, 재시작해도 문제없고, 지켜보는 사용자도 없다. 그래서 이 풀은 100% 스팟으로 잡았다. 핵심은 멀티 패밀리 구성이다. `c7g`, `c6g`, `m7g`, `m6g` 네 개를 열어뒀다.
인스턴스 패밀리를 하나로 고정했다가 그 스팟 풀이 고갈되면 CI 전체가 멈춘다. 호환되는 ARM 패밀리를 여럿 열어두면 Karpenter가 용량이 여유 있는 풀로 넘어갈 수 있어서, 한 풀이 고갈돼도 빌드가 밀리지 않는다.
`dedicated=build:NoSchedule taint` 는 이 풀을 CI 전용으로 묶어둔다.
이 `taint` 를 `tolerate` 하는 파드만 여기에 들어온다. `cpu: "24"` limit은 풀을 24 vCPU로 묶어서, 파이프라인이 폭주해도 노드를 무한정 띄우지 못하게 막는다.
그리고 기본 disruption 정책(`WhenEmptyOrUnderutilized`) 덕분에, 잡이 끝나면 Karpenter가 풀을 다시 0으로 줄인다. 빌드와 빌드 사이에 노는 비용이 없다.
game NodePool — ON_DEMAND, 지연에 민감한 세션용
- name: game-shared
nodeClassRef: game
labels:
nodegroup-workload: game
game-env: live
requirements:
- { key: kubernetes.io/arch, operator: In, values: ["arm64"] }
- { key: karpenter.sh/capacity-type, operator: In, values: ["on-demand"] }
- { key: karpenter.k8s.aws/instance-family, operator: In, values: ["c7g"] }
- { key: karpenter.k8s.aws/instance-size, operator: In, values: ["large","xlarge"] }
disruption:
consolidationPolicy: WhenEmpty
limits:
cpu: "12"
게임 세션은 CI와 정반대다. 세션 도중에 스팟이 회수되는 것 — 플레이 중인 유저를 강제로 끊는 일 — 은 절대 안 되므로, 이 풀은 온디맨드로 간다. 여기에 가드를 둘 더 얹었다.
- `consolidationPolicy: WhenEmpty` — Karpenter는 노드가 완전히 비었을 때만 회수한다. 사용률이 낮아 보여도, 세션이 살아 있는 노드를 bin-packing하겠다고 evict하는 일은 절대 없다. (build 풀의 `WhenEmptyOrUnderutilized` 와 대조된다.)
- 세션 파드 자체에는 `karpenter.sh/do-not-disrupt: "true"` 어노테이션을 붙인다. voluntary disruption이 일어나도 이 파드만큼은 아예 건드리지 말라는 뜻이다.
대신 콜드 스타트 지연이라는 대가가 있다. 이 풀은 0에서 올라오므로, 한동안 비어 있다가 들어온 첫 세션은 새 노드가 뜨고 클러스터에 합류할 때까지 1~2분쯤 기다린다. 게임 백엔드에서는 이 정도는 감수할 만하지만, 사용자에게 바로 노출되는 서비스라면 최소한의 워밍 노드를 띄워두는 편이 낫다.
이 풀은 예시라서 `game-env: live` 로 뒀다. 풀은 데이터만으로 얼마든지 늘릴 수 있다. 환경이나 인스턴스 프로파일이 다른 풀이 필요하면 이 `nodePools` 리스트에 항목 하나만 추가하면 차트가 알아서 렌더링한다. 템플릿은 안 건드리고 데이터만 더하면 된다.
Karpenter consolidation(통합) 정책
| WhenEmpty | WhenEmptyOrUnderutilized |
| 회수 대상 | 파드 0개인 노드만 |
| 돌고 있는 파드 | 절대 안 건드림 |
| 잉여 노드 | 영원히 잔존 |
| 파드 중단 | 0 (재배치 없음) |
| 노드 수 | 낭비될 수 있음 |
WhenEmptyOrUnderutilized는 3가지를 한다.
- 빈 노드 삭제 (= WhenEmpty가 하는 것, 공통)
- 다중 노드 통합 — 파드를 옮겨 노드 개수를 줄임 ← WhenEmpty엔 없는 것
- 노드 교체 — 워크로드가 작아지면 큰 노드를 더 작은(싼) 노드로 교체
WhenEmpty는 1번만 한다.
트레이드오프 (왜 원래 WhenEmpty를 골랐었나)
| WhenEmpty | WhenEmptyOrUnderutilized | |
| 장점 | 돌던 파드 절대 안 끊김 | 노드 항상 최적 |
| 단점 | 잉여 노드 낭비 | 가끔 파드 재배치(중단 이벤트) |
원래 App 을 WhenEmpty로 둔 건 "consolidation이 라이브 App 파드를 옮기면 세션 끊긴다"는 우려 때문이다.
그런데 만약 App을 분석하여 stateless HTTP + Redis 세션 + graceful drain이라 옮겨도 안 끊깁니다 → 이제 WhenEmptyOrUnderutilized의 단점(중단)이 consolidateAfter(예: 5m)는 그 "가끔 재배치"의 빈도를 조절하는 값 — 노드가 5분 이상 저활용이어야 통합을 시작하게 해 잦은 churn을 막는다.
한 줄 요약: WhenEmpty는 우연히 빈 노드만 치우는 소극적 정책, WhenEmptyOrUnderutilized는 불필요해진 노드를 적극적으로 합쳐 없애는 정책. 후자가 "최적 노드 유지"의 정답이다. replica 2 + PDB minAvailable:1 해당 조합을 같이 쓰면 된다.
Pending 파드가 실제로 Karpenter를 트리거하는 방식
전체를 하나로 묶어주는 조각이다. NodePool 요구사항과 맞아떨어지는 파드가 Pending에 빠지기 전까지는 아무 일도 일어나지 않는다. 내 CI 러너(GitLab Runner)는 딱 그런 파드를 만들어내도록 설정해뒀다.
[runners.kubernetes.node_selector]
"nodegroup-workload" = "build"
[runners.kubernetes.node_tolerations]
"dedicated=build" = "NoSchedule"
러너 잡 파드는 `nodegroup-workload: build`(풀의 라벨과 일치)를 달고 있으면서, 동시에 `dedicated=build:NoSchedule` (풀의 taint와 일치)을 tolerate한다. 파이프라인이 시작되면 다음 순서로 흐른다.
- 러너가 그 selector + toleration을 가진 잡 파드를 만든다.
- 맞는 노드가 없음 → 파드는 Pending에 머문다.
- Karpenter가 펜딩 파드를 보고 build NodePool에 매칭시킨 뒤, 조건에 맞는 ARM64 스팟 노드를 띄운다.
- 노드가 합류하고, 파드가 스케줄링되며, 잡이 실행된다.
- 잡이 끝나면 파드가 사라지고, 노드가 비면 → consolidation이 노드를 회수한다.
game 풀도 같은 방식으로 돌아간다. 용량이 온디맨드이고 `do-not-disrupt` 보호가 붙는다는 점만 다르다.
여기서 `karpenter.sh/do-not-disrupt: "true"` 가 정확히 무엇을 막아주는지 짚어둘 필요가 있다. 이 어노테이션이 붙은 파드는 Karpenter의 자발적 중단(voluntary disruption) 대상에서 빠진다.
노드 통합(consolidation)이나 drift 정리처럼 Karpenter가 스스로 노드를 비우려는 동작에서, 그 파드가 떠 있는 노드는 아예 손대지 않는다는 뜻이다. 그래서 사용률이 낮아 보여도 세션이 끝나 파드가 사라지기 전까지는 노드가 회수되지 않고, 플레이 중인 유저가 중간에 끊기는 일이 없다.
한 가지 구분해둘 점은, do-not-disrupt가 막아주는 건 어디까지나 Karpenter가 스스로 일으키는 중단이라는 것이다. 스팟 회수나 노드 하드웨어 장애 같은 비자발적 중단(involuntary disruption)까지 막아주지는 않는다.
다만 이 풀은 애초에 온디맨드라 스팟 회수 자체가 없고, 그래서 사실상 세션이 외부 요인으로 끊길 일이 거의 없다. do-not-disrupt(자발적 중단 차단)와 온디맨드(비자발적 중단 제거)가 한 쌍으로 맞물려, 라이브 세션을 양쪽 모두로부터 지켜주는 셈이다.

운영(Operations)
평소 helmfile 워크플로는 늘 쓰는 명령어 세 개다.
helmfile -e prod lint # CI-safe with HELMFILE_SKIP_CLUSTER_HOOKS=1
helmfile -e prod diff # preview controller + CR changes
helmfile -e prod apply # CRDs (prepare hook) -> controller -> CRs
그리고 수시로 꺼내 쓰는 kubectl 명령어들이다.
kubectl get nodepool # the pool definitions
kubectl get ec2nodeclass # the node-class definitions
kubectl get nodeclaim # in-flight / active node requests
kubectl get nodes -l karpenter.sh/nodepool # only Karpenter-managed nodes
사람들이 잘 놓치는 게 nodeclaim이다. "이런 요구사항으로 노드 하나를 EC2에 요청했다"는 Karpenter의 기록이다. Pending에 묶인 파드를 디버깅할 때는 이걸 들여다보면 된다. NodeClaim이 아예 없으면 그 파드가 어떤 NodePool에도 매칭되지 않은 것이고, NodeClaim은 있는데 Ready로 안 넘어가면 AWS 쪽 launch 문제(용량, 서브넷, IAM)다.
업그레이드. 컨트롤러 차트는 `aws/karpenter-provider-aws` 릴리스를 지켜보다가 `Chart.yaml` 을 올려주는 작은 `./upgrade.py` 로 관리한다. CR 차트에는 따로 `./cr-chart/upgrade.py` 가 있다. CRD는 별도 업그레이드 단계가 필요 없다. prepare 훅이 apply할 때마다 컨트롤러와 같은 버전의 karpenter-crd를 `server-side-apply` 하니, CRD가 컨트롤러 차트 버전을 그대로 따라간다.
마무리
이 구성을 만들려는 사람에게 전하고 싶은 몇 가지를 정리한다.
- Karpenter에는 IRSA보다 Pod Identity가 낫다. OIDC 프로바이더도, issuer를 고정하는 trust policy JSON도, SA 어노테이션도 필요 없다. 정적 `pods.eks.amazonaws.com principal` 하나면 끝이고, 역할은 여러 클러스터에서 재사용할 수 있다.
- Terraform↔Helm 계약은 문자열 두 개로 충분하다. 노드 IAM 역할 이름과 중단 큐 이름. 인터페이스를 이만큼 작게 유지하면 두 계층을 따로따로 리뷰할 수 있다.
- 용량 타입은 장애를 얼마나 견딜 수 있는지에 맞춘다. 장애에 강하거나 일회성인 작업(CI)에는 스팟, 상태를 들고 있거나 지연에 민감한 작업(라이브 세션)에는 온디맨드 + WhenEmpty + do-not-disrupt.
- 멀티 패밀리 ARM 스팟 풀이 회복력을 높인다. 호환되는 Graviton 패밀리를 여럿 열어두면 한 스팟 풀이 고갈돼도 워크로드가 멈추지 않는다.
- 가격 대비 성능을 위해 Graviton(arm64 c7g/m7g)을 쓴다. 전 구간 AL2023 arm64.
- CRD prepare-hook 패턴이 부트스트랩 문제를 푼다. helmfile diff가 EC2NodeClass/NodePool 종류를 해석하려면 그 전에 CRD가 있어야 한다. prepare 훅에서 karpenter-crd 차트로 server-side-apply하고, CI에서는 HELMFILE_SKIP_CLUSTER_HOOKS로 건너뛴다.
Reference
Somaz | DevOps Engineer | Kubernetes & Cloud Infrastructure Specialist
'AWS' 카테고리의 다른 글
| AWS 모니터링 스택 완전 분석 - CloudWatch vs X-Ray vs 3rd Party 솔루션 (0) | 2026.04.21 |
|---|---|
| AWS Lambda vs ECS vs EKS 컨테이너 전략 (0) | 2026.04.14 |
| AWS CDN: CloudFront vs Global Accelerator - 글로벌 콘텐츠 전송 최적화 완벽 가이드 (0) | 2026.02.24 |
| EKS Fargate vs EC2 Node Groups 완전 분석 - Kubernetes 워커 노드 옵션 (0) | 2026.02.17 |
| AWS Load Balancer 완전 비교 가이드 (0) | 2026.01.27 |