11 min read

험난한 Kubernetes 전환기 (2) - Amazon EKS와 Karpenter

Table of Contents

(26.03.22 업데이트)

작년부터 시작한 Kubernetes 전환 작업이 조금씩 진행되고 있습니다.
이번에는 Azure Kubernetes Service에 이어 Amazon EKS 환경을 구성하며 겪었던 이슈들을 정리해 보았습니다.

2번 타자, AWS

AWS는 사내 주 클라우드 환경으로 사용하고 있습니다. 이미 기존에도 많은 서비스가 AWS 기반이었고, 아무래도 AWS 환경이 많은 개발자에게 익숙하기 때문에 Azure보다는 비교적 수월하게 진행할 수 있을 것이라 기대했습니다.

다만 앞에서 진행한 Azure 환경이 더 우선순위가 높았고, AWS에서는 4월부터 Infra Team EKS 클러스터를 시작으로 Kubernetes 환경을 구축하기 시작했습니다.

Infra Team EKS

Infra Team EKS 클러스터는 다음과 같은 목적을 가지고 구성되었습니다.

  1. 역할 분리를 명확하게 하고 싶었습니다.
  2. 팀에서 사용하는 도구들은 특성상 거의 내부에서만 사용되고 외부에는 제한적으로만 노출되기 때문에, 분리해 두는 게 좋다고 생각했습니다.
  3. 파편화되어 있던 인프라 팀의 관리 범위를 줄이고 싶기도 했습니다.

여기서 배운 점들을 활용하여 이후 EKS 클러스터를 더 쉽게 구성하고 싶었기 때문에, 최대한 많은 것을 조사하고 테스트하는 과정이 있었습니다.

AWS 환경으로 넘어오며 달라진 점이 있다면, Azure 진영에서는 클러스터 구성을 할 때 Terraform 코드를 직접 작성하거나 모듈도 제한적으로 사용했지만, AWS는 모듈이 잘 정리되어 있어 활용하기가 매우 좋았습니다. 특히 terraform-aws-eks 모듈의 Karpenter 예제1를 변형하여 사용했는데, 약간의 수정만으로 초기 환경을 쉽게 구성할 수 있었습니다.

다만 Karpenter는 추가 작업이 필요했는데 이는 나중에 설명하겠습니다.

VPC 구성하기

EKS 클러스터를 구성할 때 가장 신경 썼던 부분은 네트워크 구성이었습니다.
우선 VPC 설정이 필요했는데, 이는 VPC 모듈을 활용하여 구성했습니다. VPC 모듈은 잘 사용하면 네트워크 관련 설정을 상당히 압축할 수 있고, 옵션도 매우 많기 때문에 README를 중심으로 자세하게 확인해 보시는 것을 추천합니다.

EKS에서 사용할 Public, Private 서브넷을 모두 정의하고, 비용 효율성을 위해 Single NAT 구성을 채택했습니다. 또한 이건 Karpenter 예제에도 있는 내용인데, AWS Load Balancer Controller와 Karpenter를 위한 서브넷 태그 설정이 필요합니다.

public_subnet_tags = {
  "kubernetes.io/role/elb" = 1
}

private_subnet_tags = {
  "kubernetes.io/role/internal-elb" = 1
  "karpenter.sh/discovery" = var.name
}

특별한 경우가 아니라면 변경될 일이 없겠지만 중요한 부분입니다. 이 부분이 없으면 Load balancer나 Karpenter 노드가 배포되며 자동으로 서브넷을 찾아 연결하지 못하는 문제가 발생합니다.

트래픽 흐름 구성

저희는 기본적으로 2가지 시나리오를 구상했습니다.

  1. 외부 트래픽: AWS Load Balancer Controller로 생성된 ALB(Application Load Balancer) 사용
    • 외부 트래픽 > ALB > Ingress > Service > Pod
  2. 내부 트래픽: NGINX Ingress Controller + NLB(Network Load Balancer) 사용
    • 내부 트래픽 > NLB > NGINX Ingress > Service > Pod

Azure 환경을 구성할 때 외부 로드 밸런서는 L7 로드 밸런서를 사용해야 한다는 점2을 배웠기 때문에 ALB를 사용하였고, 내부 트래픽은 간편하게 NLB + NGINX Ingress Controller를 사용할 수 있도록 했습니다.

이외에 사내 VPN을 내부 통신에 연동하는 작업도 있었지만 이 글의 범위를 벗어나기 때문에 생략하겠습니다.

EKS Add-ons 설정

EKS는 핵심 구성 요소를 Add-on 형태로 제공합니다. 클러스터를 구성할 때 필요한 Add-on을 명시적으로 선언해 두면 관리가 편리합니다.

cluster_addons = {
  coredns                      = {}
  eks-pod-identity-agent       = {}
  kube-proxy                   = {}
  vpc-cni                      = {}
  aws-ebs-csi-driver           = {
    service_account_role_arn = module.ebs_csi_irsa_role.iam_role_arn
  }
  aws-efs-csi-driver           = {}
  aws-mountpoint-s3-csi-driver = {}
}

여기서 aws-ebs-csi-driver에는 반드시 IRSA Role을 부여해야 합니다. 이 Role이 없으면 Karpenter 노드가 생성되거나 삭제될 때 EBS 볼륨 관련 문제가 발생합니다. 이에 대해서는 아래 IAM Role 설정하기 섹션에서 자세히 다루겠습니다.

ACM 인증서

EKS에서 HTTPS를 사용하려면 ACM(AWS Certificate Manager) 인증서가 필요합니다. 설정하면서 알게 된 몇 가지 사항을 정리합니다.

  • 같은 도메인에 대해 여러 AWS 계정에서 각각 ACM 인증서를 생성할 수 있습니다. 다른 계정에서 생성한 인증서는 사용할 수 없기 때문에, 계정별로 따로 생성해야 합니다.
  • 도메인 관리가 다른 AWS 계정에 있는 경우, DNS 인증 시 CNAME 레코드를 해당 계정에 수동으로 등록해야 합니다.
  • 여러 인증서를 NLB에 적용하려면 ARN을 콤마로 구분하여 Annotation에 지정합니다. 다만 Helm 차트 등에서 콤마가 포함된 값을 전달할 때 파싱 오류가 발생할 수 있는데3, YAML의 multi-line string 문법(>-)을 사용하면 해결됩니다.4
service.beta.kubernetes.io/aws-load-balancer-ssl-cert: >-
  arn:aws:acm:region:account:certificate/cert1,arn:aws:acm:region:account:certificate/cert2

Karpenter 개선하기

기본적인 구성을 하고, 테스트 앱을 배포해 보면서 네트워크 테스트와 기본적인 Karpenter 동작을 확인했습니다.
이대로 문제가 없다고 생각했지만, 본격적으로 실제 서비스 배포 과정에서 부족한 부분도 있었고, 시행착오도 있었습니다. 그리고 그 중심에는 예제 코드대로만 구성했던 Karpenter가 있었습니다.

Karpenter란?

앞에서 몇 번 언급하긴 했지만, 여기서부터 제대로 정리해 두려 합니다.
Karpenter는 Kubernetes 클러스터의 노드를 동적으로 프로비저닝하는 오토스케일러입니다. 기존의 Cluster Autoscaler와 달리, Karpenter의 노드 관리 방식은 더 효율적이고 유연합니다.

  1. Karpenter는 지속적으로 Pod의 상태를 관찰하여 스케줄링된 현 상태와 스케줄링이 되지 않는 리소스를 파악합니다. 그리고 그 데이터를 바탕으로 조건 내의 최적의 VM을 선정하여 프로비저닝합니다. 그래서 최대한 버리는 리소스를 줄이고, Spot 인스턴스를 활용하는 등 비용 면에서도 이점을 가집니다.
  2. 또한 Karpenter는 EC2 API를 직접 호출하여 인스턴스를 프로비저닝하기 때문에 속도도 매우 빠릅니다. 실제로 일반적인 상황에서 Karpenter가 필요한 인스턴스를 판단하고 프로비저닝하는데 1분이 걸리지 않았습니다. Azure에서 사용했던 Cluster Autoscaler가 3~4분 정도의 시간이 걸렸던 것을 감안하면 매우 빠르다고 할 수 있습니다.

저희 회사는 현재도 클라우드 비용으로 인한 지출이 많아 비용에 매우 민감했고, EKS를 쓴다면 Karpenter는 꼭 사용해야 한다는 팀원들의 긍정적인 의견도 있었습니다.

Karpenter의 동작 방식

Karpenter 구조

Image: Karpenter Overview from Karpenter official homepage, Apache 2.0

자세한 구조는 더 복잡하겠지만, Karpenter의 구조는 다음과 같이 생각할 수 있습니다.

  • 노드를 조율하는 Karpenter controller가 있고,
  • Karpenter controller가 정의된 EC2NodeClass와 NodePool을 바탕으로 Self-managed 노드 풀을 정의하고 관리합니다.

이러한 Karpenter의 동작 방식을 처음에는 이해하지 못했습니다. 특히 오해했던 내용은 크게 2가지였습니다.

  • Karpenter controller가 배치되는 곳은 관리 노드 풀이라는 것, 그래서 시스템에서 관리하고, Auto-scaling도 Managed 노드 풀의 설정을 따라간다는 점
  • Karpenter가 생성하는 노드는 별도의 NodePool과 EC2NodeClass 리소스 정의를 참조하고, 관리 노드 풀과는 관련이 없다는 점
    • 참고로 이렇게 Karpenter 노드 풀이 Self-managed 형태로 관리되는 것은 의도된 것이고, System에서 관리되지 않는 것이 정상입니다.
    • 하지만 예제의 리소스는 일반적인 케이스로 정의되어 있기 때문에 최적화와 수정 작업이 필요했습니다.

앱을 배포하는 과정에서 Karpenter 노드가 볼륨 설정과 Auto-scaling이 원활하게 작동하지 않는다는 것을 발견했을 때, 처음에는 Managed 노드 풀의 설정을 변경하면 해결될 것이라 생각해서 혼선이 있었습니다. 자세한 조사를 하고 나서야 고쳐야 할 부분을 알 수 있었고, 문제를 해결하기 위해 여러 가지 설정을 변경했습니다.

gp3 볼륨 사용하기

gp3 볼륨은 차세대 EBS 볼륨으로, 기존 gp2 볼륨에 비해 IOPS가 높고 비용도 더 저렴합니다. 그래서 보통은 gp3 볼륨을 사용하는 것이 좋은데, EKS는 별도 설정을 하지 않을 경우 기본적으로 gp2 볼륨을 사용합니다.
이점도 많고 서비스를 배포할 때 gp3 볼륨을 가정하고 StorageClass를 설정하는 경우도 많기 때문에, gp3 볼륨을 명시적으로 사용하도록 설정해야 했습니다.

위에서 설명한 것처럼 Karpenter 노드에 gp3 볼륨을 사용하도록 설정하려면 해당 노드가 참고하는 EC2NodeClass 객체를 수정해야 합니다.
예를 들어, 다음과 같이 설정해야 합니다.

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

여기서 /dev/xvda, /dev/xvdb는 각각 파일 시스템 파티션을 의미하는데,

  • /dev/xvda는 루트 파일 볼륨,
  • /dev/xvdb는 데이터 파일 볼륨을 의미합니다.

이 볼륨은 적절하게 설정해 주어야 하는데, 보통은 루트 파일 볼륨인 dev/xvda를 사용하지만 Bottlerocket AMI처럼 볼륨을 분리하여 사용하는 경우 Docker 이미지를 가져오는 작업 등에 데이터 파일 볼륨인 dev/xvdb를 사용할 수 있습니다.5

참고로 Managed 노드 풀에 gp3 볼륨을 설정하는 방법은 다음과 같습니다.

block_device_mappings = {
  xvda = {
    device_name = "/dev/xvda"
    ebs = {
      volume_size           = 10
      volume_type           = "gp3"
      iops                  = 3000
      throughput            = 125
      encrypted             = true
      delete_on_termination = true
    }
  }
  xvdb = {
    device_name = "/dev/xvdb"
    ebs = {
      volume_size           = 20
      volume_type           = "gp3"
      iops                  = 3000
      throughput            = 125
      encrypted             = true
      delete_on_termination = true
    }
  }
}

마지막으로, 기본 StorageClass도 gp3로 설정해야 PVC가 gp3 볼륨으로 생성됩니다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: gp3
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: ebs.csi.aws.com
volumeBindingMode: WaitForFirstConsumer
parameters:
  type: gp3
  encrypted: "true"

여기서 Provisioner는 ebs.csi.aws.com이어야 EC2에서 제대로 PVC가 할당됩니다.

IAM Role 설정하기

Karpenter가 제대로 작동하기 위해서는 여러 IAM Role 설정이 필요합니다.

  1. EBS CSI 드라이버에 IRSA Role을 부여해야 합니다.
    • 이 Role이 없으면 Karpenter 노드 생성, 또는 노드가 사라지고 잔여 볼륨을 정리할 때 문제가 생깁니다.
    • 실제로 EBS 볼륨을 사용하는 PVC가 Karpenter 노드가 없어진 뒤에도 회수되지 않는 문제가 있었습니다.
    • Role은 모듈을 사용하여 간단히 생성할 수 있습니다.6
module "ebs_csi_irsa_role" {
  source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"

  role_name             = "${var.name}-ebs-csi-role"
  attach_ebs_csi_policy = true

  oidc_providers = {
    ex = {
      provider_arn               = module.eks.oidc_provider_arn
      namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
    }
  }

  tags = var.tags
}
  1. Karpenter controller에 IRSA Role을 부여해야 합니다.
    • 이 Role은 Karpenter가 EC2 인스턴스나 Launch template 등을 생성하고 실행할 수 있도록 해 줍니다.
    • Karpenter 모듈에서 enable_irsatrue로 설정하고 관련 설정을 추가하면 됩니다.
  2. Karpenter node IAM role에 EBS CSI 드라이버 정책을 추가해야 합니다.
    • 이를 통해 Karpenter 노드에서 EBS 볼륨을 생성하고 사용할 수 있습니다.
module "karpenter" {
  create = var.enable_karpenter
  source = "terraform-aws-modules/eks/aws//modules/karpenter"

  enable_irsa = true
  irsa_oidc_provider_arn = module.eks.oidc_provider_arn
  irsa_namespace_service_accounts = [
    "kube-system:karpenter"
  ]

  node_iam_role_additional_policies = {
    AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
    AmazonEBSCSIDriverPolicy     = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy"
  }

  tags = var.tags
}
  1. 끝으로 Karpenter 서비스 계정에 역할 ARN을 지정해야 합니다.
serviceAccount:
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/karpenter-controller-role

Spot 인스턴스 사용하기

Karpenter 노드가 Auto-scaling을 하지 못하는 문제를 확인해 보니, Spot 인스턴스를 생성하지 못하고 있었습니다. Karpenter는 Spot 인스턴스를 우선적으로 사용하고, 별도 권한이 없으면 Spot 인스턴스를 생성하지 못하기 때문입니다.

이를 해결하기 위해 두 가지 설정을 추가했습니다:

  1. 서비스 연결 역할로 Spot 인스턴스 권한을 부여했습니다.
resource "aws_iam_service_linked_role" "spot" {
  aws_service_name = "spot.amazonaws.com"
}
  1. NodePool 리소스에 karpenter.sh/capacity-type 속성으로 요구사항을 추가했습니다. 아래는 제한 없이 모든 인스턴스 타입을 사용할 수 있도록 설정한 예시입니다.
requirements:
  - key: karpenter.sh/capacity-type
    operator: In
    values: ["spot", "on-demand", "reserved"]

NodePool 설정

운영 환경에서는 NodePool을 세밀하게 설정해야 합니다. 아래는 실제로 사용한 설정이고, 여러 개의 NodePool을 정의할 수도 있습니다.

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: common-arm64
spec:
  template:
    spec:
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      requirements:
        - key: "karpenter.k8s.aws/instance-family"
          operator: In
          values: ["m6g"]
        - key: "karpenter.k8s.aws/instance-size"
          operator: In
          values: ["large", "xlarge", "2xlarge", "4xlarge"]
        - key: "karpenter.k8s.aws/instance-hypervisor"
          operator: In
          values: ["nitro"]
        - key: "karpenter.sh/capacity-type"
          operator: In
          values: ["spot", "on-demand"]
  limits:
    cpu: 1000
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 600s
    budgets:
      - nodes: "1"

몇 가지 포인트를 짚어 보겠습니다.

인스턴스 제한: requirements 블록으로 인스턴스 패밀리, 사이즈, 아키텍처를 제한합니다. 제한하지 않으면 Karpenter가 모든 인스턴스 타입을 후보로 고려하게 되므로, 의도하지 않은 인스턴스가 프로비저닝될 수 있습니다.

Disruption 설정: consolidationPolicy는 노드를 언제 정리할지 결정합니다. WhenEmptyOrUnderutilized는 빈 노드뿐 아니라 활용도가 낮은 노드도 정리 대상으로 삼습니다. consolidateAfter는 정리 판단까지의 대기 시간이며, 너무 짧으면 노드가 자주 교체되어 불안정해질 수 있습니다. budgets는 동시에 정리할 수 있는 노드 수를 제한하여 대규모 교체를 방지합니다.7

AMI 버전: EC2NodeClass에서 AMI를 지정하지 않으면 latest가 기본값인데, 이 경우 새 AMI가 나올 때마다 노드가 교체될 수 있습니다. 안정성이 중요하다면 AMI 버전을 고정하는 것도 고려해 볼 수 있습니다.

Weighted NodePool: On-demand와 Spot 노드 풀을 분리하고 .spec.weight로 우선순위를 부여하면, Spot을 우선 사용하되 Spot이 부족할 때 On-demand로 폴백하는 구성이 가능합니다.8 예를 들어 Reserved Instance를 보유하고 있다면, 해당 인스턴스 타입의 NodePool에 높은 weight를 부여하여 우선적으로 활용할 수 있습니다.
추가적으로 특정 비율로 On-demand와 Spot을 사용할 수도 있습니다. capacity-spread 관련 정보를 찾아보세요.

Managed Node Group의 desired_size 문제

terraform-aws-eks 모듈은 Managed Node Group의 desired_sizeignore_changes로 처리합니다.9 이는 Cluster Autoscaler나 Karpenter 같은 오토스케일러가 노드 수를 조절하는 것을 방해하지 않기 위한 의도적인 설계입니다.

하지만 이로 인해 min_size를 현재 desired_size보다 높게 설정하면 AWS API 오류가 발생합니다. Terraform이 desired_size 변경을 무시하기 때문입니다. 이런 경우 AWS CLI로 직접 desired_size를 먼저 조정해야 합니다.10

마치며

이렇게 모든 설정을 완료한 뒤에는 실제 서비스 배포에도 문제가 없었습니다.
무작정 도입했다가 제대로 사용을 못 할 수도 있었는데, 미리 문제를 발견하고 해결할 수 있어 다행이었다고 생각하네요.

Karpenter를 포함해 EKS를 구성하는 과정에서 그래도 Azure에 비해 참고할 자료가 많아졌지만, 여전히 운영 환경은 다르다는 것을 한 번 더 느끼기도 했습니다.
팀 차원에서 Cloud 환경에 Kubernetes 적용이 처음이라는 점, 그리고 여기에 모두 적지는 않았지만 기존 서비스와 동일한 조건을 가져가기 위해 설정할 부분도 많았기 때문입니다. 그래도 EKS 클러스터 하나가 온전히 구성되었고, 이를 바탕으로 운영 환경 EKS를 구성하는 데는 상대적으로 적은 시간이 걸렸습니다.

이제 새로 배포하는 서비스는 EKS 환경에서 배포를 기본으로 하고, 기존 서비스들도 옮기는 작업을 진행하고 있습니다.
또한 팀 내에서도 Argo CD, Harbor 등 기존에 사용하지 못했던 도구들을 검증하고 적용하여 Cloud Native 환경을 구성하는 것이 최종적인 목표입니다.

다소 속도가 느려질 수는 있지만, 안정적이고 탄력적인 환경을 구성할 수 있도록 여유를 가지고 진행하려 합니다.

그 외 참고 자료

Footnotes

  1. https://github.com/terraform-aws-modules/terraform-aws-eks/tree/master/examples/karpenter

  2. https://blog.haulrest.me/blog/250225-01/#azure-load-balancer%EC%99%80-azure-application-gateway

  3. https://github.com/hashicorp/terraform-provider-helm/issues/316

  4. https://github.com/kubernetes-sigs/aws-load-balancer-controller/issues/3942

  5. https://awslabs.github.io/data-on-eks/docs/bestpractices/scalability/preload-container-images

  6. https://github.com/terraform-aws-modules/terraform-aws-iam/tree/master/examples/iam-role-for-service-accounts

  7. https://karpenter.sh/docs/concepts/disruption/

  8. https://karpenter.sh/docs/concepts/scheduling/#weighted-nodepools

  9. https://github.com/terraform-aws-modules/terraform-aws-eks/issues/2030

  10. https://github.com/bryantbiggs/eks-desired-size-hack