AWS

Karpenter on EKS: Terraform과 Helm으로 구성하는 2계층 설정

Somaz 2026. 6. 16. 11:45
728x90
반응형

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가지를 한다.

  1. 빈 노드 삭제 (= WhenEmpty가 하는 것, 공통)
  2. 다중 노드 통합 — 파드를 옮겨 노드 개수를 줄임 ← WhenEmpty엔 없는 것
  3. 노드 교체 — 워크로드가 작아지면 큰 노드를 더 작은(싼) 노드로 교체

 

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한다. 파이프라인이 시작되면 다음 순서로 흐른다.

  1. 러너가 그 selector + toleration을 가진 잡 파드를 만든다.
  2. 맞는 노드가 없음 → 파드는 Pending에 머문다.
  3. Karpenter가 펜딩 파드를 보고 build NodePool에 매칭시킨 뒤, 조건에 맞는 ARM64 스팟 노드를 띄운다.
  4. 노드가 합류하고, 파드가 스케줄링되며, 잡이 실행된다.
  5. 잡이 끝나면 파드가 사라지고, 노드가 비면 → 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

728x90
반응형