AWS 기술 블로그

Amazon EKS 클러스터를 비용 효율적으로 오토스케일링하기

애플리케이션 현대화를 위해 많은 고객이 컨테이너를 선택하며, 컨테이너 운영 부담을 완화하기 위해 Amazon Elastic Container Service (Amazon ECS) 또는 Amazon Elastic Kubernetes Service (Amazon EKS)와 같은 관리형 서비스를 도입합니다. 그럼에도 여전히 컨테이너 컴퓨팅 용량을 필요한 만큼 증설해 서비스의 안정성을 확보하고, 최대한 비용 효율적으로 조정하는 것은 쉽지 않습니다.

최근 몇 년간 애플리케이션의 요구사항이 다양해지고 용도별 컴퓨팅이 분화하면서 컴퓨팅 용량을 자동으로 조정하기 위한 오토 스케일링(Auto Scaling)도 복잡해졌습니다. 예를 들어, 비용 최적화를 위해 스팟 인스턴스, 예약 인스턴스, 온디맨드 인스턴스 등을 혼합하거나, AWS Graviton 프로세서와 Intel 프로세서를 함께 사용하는 멀티 아키텍처 구성 등의 경우가 그렇습니다. 비용 최적화를 위해 이 두 가지를 동시에 적용하려면 온디맨드보다는 스팟 인스턴스를 우선시하고, Intel보다는 Graviton 노드를 우선하여 확장할 수 있어야 합니다. 더 나아가, 일정 비율은 온디맨드로 유지하여 스팟 용량이 일시에 회수될 경우를 위한 대비도 필요합니다.

Amazon EKS 클러스터를 자동으로 조정하기 위한 대표적인 도구들로는 쿠버네티스 클러스터 오토스케일러(Kubernetes Cluster Autoscaler, 이하 CA)와 Karpenter가 있습니다. 2021년 AWS가 Karpenter를 Apache License 2.0 라이센스로 오픈 소스로 공개하기 전에는 쿠버네티스 클러스터 오토스케일러가 거의 유일한 옵션이었습니다. 이 글은 CA와 Karpenter 두 가지 도구를 이용해 온디맨드와 스팟, 그리고 멀티 아키텍처로 ARM 기반의 AWS Graviton과 Intel을 함께 혼합하여 비용 절감과 서비스 안정성 두 가지 목적을 동시에 충족하기 위한 예시를 구체적으로 살펴볼 것입니다. 이를 통해 각 도구의 동작 방식 및 제약 사항은 어떤 것들이 있는지, 그래서 어떤 클러스터 오토 스케일링 도구를 사용할지 비교해볼 수 있습니다.

요구 사항

  • 온디맨드 대신 비용이 더 저렴한 스팟 인스턴스를 우선적으로 활용하고 싶다.
  • Intel 인스턴스보다 가성비가 좋은 Graviton 인스턴스를 우선적으로 활용하고 싶다.

쿠버네티스 클러스터 오토 스케일러 (Kubernetes Cluster Auto Scaler)

Amazon EKS 클러스터에는 파드가 스케줄링되는 하나 이상의 Amazon EC2 노드(Node)가 포함되며, 노드는 Amazon EC2 오토 스케일링 그룹과 연결된 노드 그룹을 통해 배포됩니다. CA는 스케줄링 되지 못한 파드가 있으면 오토 스케일링 그룹을 통해 노드를 클러스터에 추가하여 용량을 증설합니다.

그림1. CA의 오토스케일링 과정

  1. 노드 리소스가 부족하여 Unschedulable 상태인 파드가 있는지를 감시합니다.
  2. 파드 실행에 필요한 용량만큼 오토 스케일링 그룹의 적정 인스턴스 수(Desired count)를 늘립니다.
  3. AWS Auto Scaling을 통해 적정 인스턴스 수에 맞추어 확장합니다.
  4. 새 노드가 클러스터에 추가되고 Ready 상태가 되면, Pending 상태였던 파드들이 실행됩니다.

이 때 주의해야 할 점이 있는데, CA는 하나의 노드 그룹에 속한 인스턴스 타입들은 모두 동일한 리소스, 즉 동일한 크기의 vCPU와 메모리, 혹은 GPU를 가진다고 가정한다는 것입니다. 또한, 관리형 노드 그룹은 생성 시 용량 유형(Capacity type)이 온디맨드인지, 스팟인지를 지정하게 되어 있기도 하고, 보통 애플리케이션 요구사항에 따라 파드 스케줄링 정책도 달라지기 때문에 온디맨드/스팟 노드 그룹은 분리해야 합니다. 한편, 스팟 인스턴스 용량을 적극적으로 확보하려면 여러 개의 가용영역에서 다양한 사이즈 및 패밀리를 지정하는 것이 유리합니다. 결과적으로 스팟용 노드 그룹도 다양한 vCPU/메모리 조합으로 여러 개 생성해야 합니다.

그림2. 온디맨드와 스팟 인스턴스를 위한 노드 그룹

이렇게 CA는 동작 로직의 특성상, 요구사항을 충족시키기 위해서는 여러 개의 노드 그룹을 생성해야 합니다. 그리고 구체적인 요구사항을 충족시키기 위해서 CA는 --expander라는 파라미터를 통해 5가지 옵션을 제공합니다. 사용자는 아래의 옵션 중 하나, 혹은 두 개 이상을 혼합하여 적용할 수 있습니다. (참조 1)

  • random – 기본(default) expander. 노드 그룹 선정에 대한 특별한 요구 사항이 없는 경우에 사용합니다.
  • most-pods – 최대한 많은 파드를 실행할 수 있는 노드 그룹을 선택. 이는 단순히 더 큰 노드를 스케줄링하는 방식은 아닙니다. 특정 파드가 nodeSelector 등을 이용해 특정 노드에서 실행되도록 지정하는 경우에 유용합니다.
  • least-waste – 스케일 아웃 후 유휴 CPU/메모리를 최소화하는 방식. 노드의 CPU/메모리 사이즈를 다르게 가져가는 높은 메모리 노드 등 다른 분류의 노드가 있을 때 유용하며, 큰 자원을 필요로 하는 pending 파드가 있을 때만 확장하려는 경우 사용할 수 있습니다.
  • priority – 사용자가 우선순위를 가장 높게 지정한 노드 그룹을 선택합니다.
  • price – 비용이 가장 적게 들면서 동시에 클러스터 크기와 일치하는 머신이 있는 노드 그룹을 선택합니다. (단, AWS 미지원)

(참조 1) 1.23.0 부터 expander를 여러개 설정할 수 있습니다. 예를 들어 .cluster-autoscaler --expander=priority,least-waste 와 같이 설정하면, priority expander가 여러 노드 그룹을 출력하는 경우, 그 결과를 least-waste expander의 입력으로 받아 노드 그룹을 선정하게 되며, 최종 결정은 무작위로 하나가 선택됩니다. expander 목록에는 같은 expander가 중복으로 설정되면 안됩니다. expander와 관련된 좀 더 자세한 내용은 autocaler faq를 참조하세요. 자세한 구현 예시는 eks autoscaling workshop을 참조하시기 바랍니다.

비용을 최적화하기 위한 Priority Expander

요구사항을 구현하기 위해 priority 옵션의 Expander를 사용하겠습니다. 예를 들어, 비용이 저렴한 순으로 1) Graviton 프로세스를 갖는 스팟 인스턴스 노드 그룹, 2) Intel 계열 프로세스를 갖는 스팟 인스턴스 노드그룹, 3) Graviton 프로세스를 갖는 온디맨드 인스턴스 노드 그룹, 4) Intel 프로세스를 갖는 On-Demand 인스턴스 노드그룹을 구성해보겠습니다.

그림3. 비용 최적화를 위한 노드 그룹(4개) 구성

1. 노드그룹 생성

위 그림처럼 동일한 인스턴스 크기에 대해서 4가지 노드 그룹을 생성합니다.

# 4개의 노드그룹 생성 1) ondemand-intel 2) spot-intel 3) ondemand-arm 4) spot-arm

eksctl create nodegroup \
--cluster=${CLUSTER_NAME} --region=${AWS_REGION} \
--managed --name=ng-ondemand-intel-4vcpu \
--instance-types=m5.xlarge,m5a.xlarge,m5d.xlarge,m6i.xlarge \
--node-labels="intent=apps" \
--nodes 1 --nodes-min 1 --nodes-max 3

eksctl create nodegroup \
--cluster=${CLUSTER_NAME} --region=${AWS_REGION} \
--managed --spot --name=ng-spot-intel-4vcpu\
--instance-types=m5.xlarge,m5a.xlarge,m5d.xlarge,m6i.xlarge \
--node-labels="intent=apps" \
--nodes 1 --nodes-min 1 --nodes-max 3

eksctl create nodegroup \
--cluster=${CLUSTER_NAME} --region=${AWS_REGION} \
--managed --name=ng-ondemand-arm-4vcpu\
--instance-types=m6g.xlarge,t4g.xlarge \
--node-labels="intent=apps" \
--nodes 1 --nodes-min 1 --nodes-max 3

eksctl create nodegroup \
--cluster=${CLUSTER_NAME} --region=${AWS_REGION} \
--managed --spot --name=ng-spot-arm-4vcpu\
--instance-types=m6g.xlarge,t4g.xlarge \
--node-labels="intent=apps" \
--nodes 1 --nodes-min 1 --nodes-max 3

2. Cluster Autoscaler Priority Expander 설정

Cluster Autoscaler Deployment 변경해서 Cluster Autoscaler Expander를 Priority로 설정합니다.

# CA 설정
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cluster-autoscaler
  namespace: kube-system
spec:
  repliCA: 1
          command:
          ....
            - --expander=priority
      volumes:
        - name: ssl-certs
          hostPath:
            path: "/etc/ssl/certs/ca-bundle.crt"

3. Priority 적용을 위한 ConfigMap 설정

그리고 우선순위를 cluster-autoscaler-priority-expander이라는 이름(이름은 변경 불가)을 갖는 ConfigMap에 기술합니다. 비용이 저렴한 노드 그룹 순으로 우선적으로 배정되도록 설정합니다. 숫자가 높은 순으로 우선순위가 높게 됩니다. 아래의 예는 arm 프로세스의 spot 인스턴스가 가장 높은 우선순위를 갖고, intel 계열프로세스의 on-demand가 가장 낮은 우선순위를 갖습니다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: cluster-autoscaler-priority-expander
  namespace: kube-system
data:
  priorities: |-
    10:
      - ng-ondemand-intel-4vcpu
    20:
      - ng-ondemand-arm-4vcpu
    30:
      - ng-spot-intel-4vcpu
    40:
      - ng-spot-arm-4vcpu

4. Sample 애플리케이션 배포

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-to-scaleout
spec:
  repliCA: 1
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        service: nginx
        app: nginx
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key:  intent
                operator: In
                values:
                - apps
      containers:
      - image: nginx
        name: nginx-to-scaleout
        resources:
          limits:
            cpu: 500m
            memory: 512Mi
          requests:
            cpu: 500m
            memory: 512Mi

5. CA 오토 스케일링 해보기

위와 같이 설정하고 샘플 애플리케이션의 replica를 증가시키면 우선순위를 정의한 순서로 노드가 생성되는 것을 볼 수 있습니다.
예를 들어, 다음 명령어를 이용해 replicas의 수를 40으로 증가시키면 우선순위가 가장 높은 ng-spot-arm-vcpu4 노드 그룹의 최대용량( –node-max 3)를 넘어서, 차순위인 spot-intel 노드 그룹이 배정되는 과정을 관찰할 수 있습니다.

kubectl scale --replicas=40 deployment/nginx-to-scaleout
aws autoscaling \
    describe-auto-scaling-groups \
    --query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='${CLUSTER_NAME}']].[AutoScalingGroupName, MinSize, MaxSize,DesiredCapacity]" \
    --output table
-------------------------------------------------------------------------------------
|                             DescribeAutoScalingGroups                             |
+--------------------------------------------------------------------+----+----+----+
|  eks-ng-ondemand-intel-4vcpu-30c2ef85-d71c-e1ad-47da-b98b9251ba10  |  1 |  3 |  1 |
|  eks-ng-ondemand-arm-4vcpu-32c2ef89-0820-4be0-9953-01ef130d169e    |  1 |  3 |  1 |
|  eks-ng-spot-intel-4vcpu-eac2ef87-7bb8-e366-f3cd-b931b8c40036      |  1 |  3 |  2 |
|  eks-ng-spot-arm-4vcpu-5cc2ef8b-9d2e-5a7b-6aed-7f859ee39214        |  1 |  3 |  3 |
|  eks-nodegroup-e2c2ef23-060b-93c0-8d52-736d877c8cdf                |  3 |  3 |  3 |
+--------------------------------------------------------------------+----+----+----+

위 결과를 보면 처음에 의도했던 바와 같이 스팟, 그 중에서도 Graviton 노드를 최우선으로 확장한 것을 확인할 수 있습니다. 이 때 스팟 자원이 부족할 경우에는 그 다음 우선순위를 가지는 스팟 intel 노드 그룹, 온디맨드 arm 노드 그룹 순서로 확장할 수 있으므로 결과는 다르게 나올 수 있습니다. 만약 스팟 재고가 충분하다면 위 예시처럼 eks-ng-spot-arm 으로 시작하는 노드 그룹을 최대값(MaxSize) 3개에 도달할 때까지 우선적으로 확장합니다. 이후, 그 다음으로 높은 우선순위를 가지는 eks-ng-spot-intel 노드 그룹이 순차적으로 2개까지 증가했습니다. 이런 식으로 Priority Expander를 이용하면 우선순위에 따라 어느 노드 그룹을 확장할지 효과적으로 지정할 수 있습니다.

6. CA 오토 스케일링 해보기

시간이 지나 좀 더 효과적으로 비용을 절감하고 싶어졌습니다. 앞 사례에서 우리는 네 개의 노드그룹을 만들었고, 인스턴스 패밀리와 요금제는 다르지만 모두 동일한 xlarge 사이즈입니다. 그렇기 때문에 large 사이즈 노드로 충분한 경우에도 어쩔 수 없이 xlarge 노드만을 추가하고 있는 것이죠. 이렇게 새로운 요구사항이 추가되었습니다.

  • 작은 사이즈로 충분한 경우라면 최적 사이즈로 노드를 확장하고 싶다.

이 경우에는 어떻게 CA를 이용해 해결할 수 있을까요? 앞서 언급했듯이, CA는 노드 그룹 내에 동일한 사이즈를 가진다고 가정하므로, large 사이즈 노드 그룹을 추가해야 하고, 그림 4처럼 8개의 노드 그룹이 필요합니다. 예를 들어 Graviton 프로세스를 갖는 Spot 인스턴스 (ng-spot-arm) 에 대해서 2vCPU를 갖는 노드그룹(ng-spot-arm-2vcpu), 4vCPU를 갖는 노드 그룹(ng-spot-arm-4vcpu) 그리고 8vCPU를 갖는 노드그룹(ng-spot-arm-8vcpu)을 구성해야 합니다.

그림4. 비용 최적화를 위한 노드 그룹(8개) 구성

작은 사이즈로 충분한 경우에는 작은 노드를 활용하도록 priority expander에 더해 least-waste expander를 추가합니다.

 .cluster-autoscaler --expander=priority,least-waste  

두 개 expander를 사용해 위와 같이 설정하면, 먼저 priority expander가 높은 우선순위를 가지는 노드 그룹을 출력합니다. 예를 들어, 재고가 충분하다면 Priority=40으로 설정된 Spot Arm 2xlarge 노드 그룹과 Spot Arm xlarge 노드 그룹 두 개가 배포됩니다. 이 경우 , 그 결과를 least-waste expander의 입력으로 받아 노드 그룹을 선정하게 됩니다. 최종 결정은 무작위로 하나가 선택됩니다. 이 과정의 실습을 원한다면 eks autoscaling workshop을 참고하세요.

우리는 위 방식으로 구성하여 추가 요구사항 충족했습니다. 이 방법은 least-waste 옵션을 통해 최적 사이즈의 컴퓨팅 리소스를 추가하여 불필요한 비용을 절감할 수 있는 장점이 있습니다. 게다가, xlarge 스팟 재고가 부족한 경우 large 스팟을 사용하여 여유로운 스팟 용량을 확보할 수도 있습니다. CA는 다양한 시나리오에서 활용할 수 있는 유용한 Expander 옵션을 제공하며, AWS 외의 다른 환경에서도 지원됩니다. 그러나 약간의 번거로움이 있을 수 있는데, 노드 업그레이드 작업 등을 수행할 때 여러 개의 노드 그룹이 필요하므로 적절한 우선순위를 각 노드 그룹에 설정해야 합니다.

Karpenter

Karpenter 동작 방식

이번에는 다른 클러스터 오토스케일링 옵션인 Karpenter에 대해 알아보겠습니다. Karpenter는 CA와 마찬가지로, 리소스 부족으로 인해 스케줄링되지 못한 파드가 있는지를 관찰합니다. 기존 용량이 부족하다면, Karpenter는 파드를 스케줄링하기 위해 필요한 컴퓨팅 용량을 산정하고 그 결과를 바탕으로 필요한 컴퓨팅을 스케줄링합니다. 이 때 차이점은, Karpenter는 CA와 달리 Auto Scaling Group(이하 ASG)이나 노드 그룹을 필요로 하지 않습니다. 대신 Amazon EC2와 같은 기본 클라우드 공급자의 컴퓨팅 서비스로 API를 호출하여 명령을 직접 전송합니다. 전체적인 동작 흐름은 CA와 언뜻 비슷해보이지만, 클러스터 용량 조절을 하기 위해 ASG와 노드 그룹을 필요로 하지 않는다는 것이 큰 차이점입니다.

그림5. Karpenter의 동작 방식

Karpenter 프로비저너

Karpenter는 ASG (Auto Scaling Group) 를 사용하지 않고, 클러스터 용량 조절을 위해 YAML을 사용하여 간단하게 정의하는 프로비저너 (Provisioner)라는 CRD (Custom Resource Definition)를 제공합니다. 프로비저너는 노드 그룹과는 달리 인스턴스 타입에 동일한 CPU 및 메모리를 가정하지 않기 때문에, 파드를 실행하는 데 필요한 최적의 크기, 즉 더 작은 크기로 충분하다면 해당 요구 사항을 추가하여 비용을 더 유연하게 최적화할 수 있습니다. 따라서 요구 사항을 충족시키기 위해 노드 그룹의 수를 늘리거나 복잡한 구성을 할 필요가 없습니다.

Karpenter의 프로비저너가 어떻게 클러스터에 필요한 용량을 유연하게 정의할 수 있는지 간단한 예시를 통해 살펴보겠습니다.

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  labels:
    intent: apps
  requirements: # None of these values are required.
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["spot", "on-demand"]
    - key: kubernetes.io/arch
      operator: In
      values: ["amd64"]
    - key: karpenter.k8s.aws/instance-size
      operator: NotIn
      values: [nano, micro, small, medium, large]
  limits:
    resources:
      cpu: 1000
      memory: 1000Gi
  ttlSecondsAfterEmpty: 30
  ttlSecondsUntilExpired: 2592000 # 30 Days = 60 * 60 * 24 * 30 Seconds;
  • spec.requirements: 용량 타입(capacity type), 아키텍처, 인스턴스 사이즈 외에도 OS, 가용영역 등 다양한 요구사항을 In 혹은 NotIn 오퍼레이터를 이용해 정의합니다. 이 예시에서는 nano, micro, small, medium, large 사이즈가 아닌 amd64 기반의 스팟, 온디맨드 인스턴스를 정의하고 있습니다.
  • spec.limits.resources: 프로비저너로 관리할 최대 리소스 양을 의미하며, Karpenter로 생성할 수 있는 총 인스턴스 타입은 limits으로 정의한 값을 초과할 수 없습니다.
  • spec.ttlSecondsAfterEmpty: 실행되는 파드가 없이 노드가 비었을때 노드를 제거 하기까지 대기하는 시간입니다. 이 값을 설정하지 않으면 실행되는 파드가 없어도 노드가 제거 되지 않습니다.
  • spec.ttlSecondsUntilExpired: 노드를 만료하기까지 대기하는 시간을 정의합니다. 이 예시에서는 30일이 지나면 노드를 만료시키므로, 30일마다 최신 AMI로 새 노드를 강제 실행할 수 있다는 점에서 유용합니다.

그런데 만약 예약 인스턴스를 구매했고 이를 우선적으로 사용하도록 정의하려면 어떻게 프로비저너를 구성하면 될까요? Karpenter는 한 클러스터에서 여러 프로비저너를 정의할 수 있도록 지원하며, spec.weight 필드를 사용하여 간단히 프로비저너간의 우선순위를 설정할 수 있습니다. 앞서 정의한 default 프로비저너에 더해 이번에는 reserved-instance 라는 새 프로비저너를 정의해 보겠습니다.

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: reserved-instance
spec:
# Priority given to the provisioner when the scheduler considers which provisioner
# to select. Higher weights indicate higher priority when comparing provisioners.
# Specifying no weight is equivalent to specifying a weight of 0.
  weight: 50
  requirements:
  - key: "node.kubernetes.io/instance-type"
    operator: In
    values: ["c4.large"]
  limits:
    cpu: 100
  • spec.weight: 프로비저너 간의 우선순위를 책정하는 데 사용되는 값입니다. 높을수록 더 높은 우선순위를 가져갑니다. 이 필드를 정의하지 않은 경우에 Karpenter는 해당 프로비저너의 weight가 0이라고 가정합니다.

각 옵션에 대해 보다 자세한 내용과 더 많은 사례는 Karpenter 공식 문서를 참고하시기 바랍니다.

Karpenter 사용하기

이렇게 Karpenter의 특성에 대해 간단히 살펴봤습니다. 그러면 다시 CA에서 가정했던 요구사항을 다시 살펴보겠습니다.

  • 온디맨드 대신 비용이 더 저렴한 스팟 인스턴스를 우선적으로 활용하고 싶다.
  • Intel 인스턴스보다 가성비가 좋은 Graviton 인스턴스를 우선적으로 활용하고 싶다.
  • 작은 사이즈로 충분한 경우라면 최적 사이즈로 노드를 확장하고 싶다.

위 세 가지 요구사항을 모두 충족하기 위해서는 CA를 썼을 때 최소 4개의 노드 그룹이 필요했습니다. 사이즈를 추가로 늘릴 때마다 해당 배수만큼 노드 그룹의 수도 증가시켜야 했습니다. 그러나 Karpenter를 사용하면 아래의 기본 프로비저너를 생성하는 것만으로 충분합니다.

1. Default Provisioner 사용하기

앞서 CA의 priority에서 설정한 것과 같이 프로세서의 아키텍처(ex: amd64, arm64)와 용량 타입(ex: spot, on-demand) 별로 우선순위를 주려면 어떻게 해야 할까요? Karpenter의 프로비저너가 제공하는 spec.weight 이 CA의 priority와 유사해 보입니다. 같은 방식으로 arm-spot, arm-amd64용 프로비저너를 구성하고 스팟 arm64를 우선하도록 설정할 수도 있습니다. 하지만 Karpenter에서는 그럴 필요가 없습니다. Karpenter는 기본적으로 한 프로비저너 안에서 여러 타입이 정의된 경우 비용과 용량을 최적화한 인스턴스를 우선하기 때문에, 아래 default 프로비저너 하나로 요구사항을 단번에 충족시킬 수 있습니다.

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: default
spec:
  labels:
    intent: apps
  requirements: # None of these values are required.
    - key: karpenter.sh/capacity-type
      operator: In
      values: ["spot", "on-demand"]
    - key: kubernetes.io/arch
      operator: In
      values: ["amd64", "arm64"]
    - key: karpenter.k8s.aws/instance-size
      operator: In
      values: ["xlarge"]

위와 같이 설정하면, Karpenter controller가 EC2 Fleet API를 호출해서 비용과 용량에 최적화한 인스턴스를 생성하게 됩니다. 싱거울만큼 너무 쉽게 세 요구사항을 충족시켰습니다. 그렇다면 한 걸음 더 나아가 아래 요구사항까지 넣어볼까요?

2. 추가 요건 등장(On-Demand 와 Spot 비율 조정하기)

  • 온디맨드:스팟 비율을 2:8 수준으로 유지할 수 있을까?

MSA(MicroService Architecture)로 구성된 경우라면 각 마이크로서비스별 요구사항이 다를 수 있습니다. 어떤 서비스는 상태 유지(Stateful)를 반드시 요구할 수도 있으며, 혹은 안정성을 확보하는 것이 너무나도 중요해 스팟이 부적절한 경우도 있을 수 있습니다. 이런 경우에 해당되지 않더라도, 스팟 용량이 일시에 회수되는 경우 서비스 안정성이 떨어지기 때문에 온디맨드로 최소 수준을 보장하고자 할 수 있습니다. 이런 요구사항은 특별한 것이 아니지만, 이를 CA로 적용하려고 하면 상당히 어렵습니다. priority로 노드 그룹별 우선순위를 지정할 수는 있지만 비율을 지정할 수는 없습니다. 그 외 다른 Expander로도 쉽지 않습니다.

Karpenter에서는 상대적으로 간단합니다. Karpenter의 기능을 이용해 노드에 레이블을 할당하고, 해당 레이블에 topologySpreadConstraints을 활용해 고르게 분산 배포되도록 설정하면, 온디맨드 인스턴스와 스팟 인스턴스간에 일정한 비율로 워크로드를 분할할 수 있습니다. 아래의 예와 같이 온디맨드와 스팟 프로비저너를 각각 생성하고, capacity-spread 이라는 고유한 새 레이블에 온디맨드와 스팟에서 각각 서로 겹치지 않는 값을 원하는 비율로 할당합니다. 아래 예에서는 온디맨드와 스팟의 비율을 1:4로 설정하기 위해 온프레미스 프로비저너에 하나의 값을 할당하고 스팟 프로비저너에 4개의 고유한 값을 할당합니다. 그리고 워크로드가 topologyKey 에 따라 고르게 분산되게 설정하면 온디맨드 vs 스팟 노드의 비율을 1:4로 유지할 수 있습니다. 프로비저너의 설정 예시를 살펴보겠습니다.

On-Demand Provisioner

우선 온디맨드 Provisioner를 추가합니다. capacity-typeon-demand을 기입하고, instance-sizelarge 이상의 인스턴스만 대상이 되도록 설정합니다. 그리고 가장 중요한 설정인 capacity-spread라는 사용자 정의 키를 추가하고, 임의의 겹치지 않는 숫자를 기입합니다. 1:4의 비율로 설정하기 위해서 한개의 숫자 1로 설정 했습니다.

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: on-demand
spec:
  labels:
    intent: apps
  weight: 100
  requirements:
    - key: "karpenter.sh/capacity-type"
      operator: In
      values: [ "on-demand"]
    - key: karpenter.k8s.aws/instance-size
      operator: NotIn
      values: [nano, micro, small, medium]
    - key: capacity-spread
      operator: In
      values:
      - "1"   
  limits:
    resources:
      cpu: 1000
      memory: 1000Gi
  ttlSecondsAfterEmpty: 30
  ttlSecondsUntilExpired: 2592000
  providerRef:
    name: default

Spot Provisioner

다음엔 스팟을 위한 프로비저너를 추가합니다. capacity-typespot을 기입하고, instance-sizelarge 이상의 인스턴스만 대상이 되도록 설정합니다. 그리고 사용자 정의 키인 capacity-spread에 임의의 겹치지 않는 숫자 4개를 기입합니다. 여기서는 2, 3, 4, 5 로 설정했습니다.

apiVersion: karpenter.sh/v1alpha5
kind: Provisioner
metadata:
  name: spot
spec:
  labels:
    intent: apps
  weight: 50
  requirements:
    - key: "karpenter.sh/capacity-type"
      operator: In
      values: [ "spot"]
    - key: karpenter.k8s.aws/instance-size
      operator: NotIn
      values: [nano, micro, small, medium]
    - key: capacity-spread
      operator: In
      values:
      - "2"
      - "3"
      - "4"
      - "5"
  limits:
    resources:
      cpu: 1000
      memory: 1000Gi
  ttlSecondsAfterEmpty: 30
  ttlSecondsUntilExpired: 2592000
  providerRef:
    name: default

Deployment

마지막으로 Deployment의 topologySpreadConstraintstopologyKey: capacity-spread을 설정해서, 온디맨드와 스팟의 비율(1:4)을 맞추면서 디플로이되도록 설정합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inflate-weight
spec:
  replica: 0
  selector:
    matchLabels:
      app: inflate-weight
  template:
    metadata:
      labels:
        app: inflate-weight
    spec:
      nodeSelector:
        intent: apps
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: capacity-spread
          whenUnsatisfiable: DoNotSchedule    
          labelSelector:
            matchLabels:
              app: inflate-weight  
      containers:
        - name: inflate
          image: public.ecr.aws/eks-distro/kubernetes/pause:3.2
          resources:
            requests:
              cpu: 1
              memory: 1.5Gi

현재 replicas는 0 입니다. 이제 아래와 같이 replicas를 1 에서 5까지 하나씩 증가 시키면, 노드가 하나씩 증설됩니다. 이때 watch 명령을 이용해 온디맨드와 스팟의 비율이 어떻게 되는지 확인할 수 있습니다.

kubectl scale deploy/inflate-weight --replicas 1
kubectl scale deploy/inflate-weight --replicas 2
kubectl scale deploy/inflate-weight --replicas 3
kubectl scale deploy/inflate-weight --replicas 4
kubectl scale deploy/inflate-weight --replicas 5

아래와 같이 watch 명령어를 이용해서 CAPACITY-TYPE의 비율을 확인합니다.

watch kubectl get nodes -L karpenter.sh/capacity-type -L capacity-spread -L karpenter.k8s.aws/instance-family -L karpenter.k8s.aws/instance-size -l intent=apps

Every 2.0s: kubectl get nodes -L karpenter.sh/capacity-type -L capacity-spread -L karpenter.k8s.aws/instance-family -L karpenter.k8s.aws/instance-size -l intent=apps           

NAME                                                 STATUS   ROLES    AGE     VERSION                CAPACITY-TYPE   CAPACITY-SPREAD   INSTANCE-FAMILY   INSTANCE-SIZE
ip-192-168-104-244.ap-northeast-2.compute.internal   Ready    <none>   91s     v1.24.10-eks-48e63af   spot            5                 c4                large
ip-192-168-116-33.ap-northeast-2.compute.internal    Ready    <none>   3m13s   v1.24.10-eks-48e63af   on-demand       1                 c5a               large
ip-192-168-121-44.ap-northeast-2.compute.internal    Ready    <none>   2m35s   v1.24.10-eks-48e63af   spot            3                 c4                large
ip-192-168-160-214.ap-northeast-2.compute.internal   Ready    <none>   2m51s   v1.24.10-eks-48e63af   spot            2                 c4                large
ip-192-168-173-67.ap-northeast-2.compute.internal    Ready    <none>   2m1s    v1.24.10-eks-48e63af   spot            4                 c4                large

결론

이렇게 우리는 네 가지 요구사항을 토대로 CA와 Karpenter를 비교해봤습니다.

  • 비용이 더 저렴한 스팟 노드 그룹을 온디맨드 노드 그룹보다 우선적으로 확장하고 싶다. (CA, Karpenter 모두 가능)
  • 가성비가 좋은 Graviton 인스턴스를 Intel 인스턴스보다 우선적으로 추가하고 싶다. (CA, Karpenter 모두 가능)
  • 작은 사이즈로 충분한 경우라면 최적 사이즈로 노드를 확장하고 싶다. (CA는 노드 사이즈별 노드 그룹을 따로 생성하고 정책을 지정해주어야 하며, Karpenter는 기본 로직에 포함되어 있음.)
  • 스팟:온디맨드 인스턴스 비율을 2:8 수준으로 유지 (Karpenter 가능)

CA는 사례가 많고 안정적으로 다수 고객이 사용하고 있으며, AWS 뿐만 아니라 26개 환경에서 지원합니다. 앞서 살펴본 요구사항 중 3개는 크게 어렵지 않게 충족시킬 수 있는 만큼 대부분의 시나리오에 적용이 가능합니다. 요구사항이 아주 복잡하지 않다면, CA도 훌륭한 옵션입니다. 하지만 요구사항이 복잡해질수록 구현할 수 있는 범위가 제한적입니다. 게다가 노드의 다양성을 가져갈 때마다 노드 그룹의 수가 기하급수로 증가하기 때문에 운영 부담을 증가시키는 경우도 있습니다.

반면, Karpenter는 클러스터 용량 관리를 단순화하는 것이 모토인 만큼 쉽고 유연하게 용량을 조절할 수 있게 해줍니다. 오퍼레이터(In or Not In)를 이용하면 포함하거나 배제할 인스턴스 타입을 쉽게 지정할 수 있습니다. 따라서 인스턴스 사이즈가 달라진다고 해서 노드그룹을 분리할 필요도 없습니다. 하지만 2020년에 공개된 만큼 아직은 공개된 레퍼런스도 상대적으로 적고, 향후 다른 클라우드 서비스 제공자(CSP)에서도 지원하기 위해 빠르게 발전중이지만 2023년 5월 기준 AWS에서만 지원합니다. 만약 AWS 외에 온프레미스, 혹은 타 CSP에도 클러스터 오토스케일링 설정을 통일하고 싶다면 Karpenter로는 아직 어렵습니다. 현재는 0.27.3 버전을 지원하지만, 앞으로 추가 클라우드 공급자를 구축하거나 핵심 프로젝트 기능을 개선하는 데 있어 여러분의 참여를 기다립니다.

클러스터를 확장하기 위한 요구사항을 먼저 정리하고, CA와 Karpenter로 구현했을 때 어떤 차이가 있는지, 유지관리 관점에서도 고민해보셨으면 좋겠습니다. 두 도구를 이용한 오토스케일링 실습 사례를 더 많이 접하고 싶으시거나, Amazon EKS 클러스터 오토스케일링 모범사례에 대해 더 알아보고 싶으신 분은 Amazon EKS 클러스터 오토스케일링 워크샵을 참고하세요.

Inho Kang

Inho Kang

AWS Migration & Modernization Specialist SA로 AWS 고객들의 Modernization을 돕는 역할을 하고 있습니다. 분산환경의 아키텍처와 컨테이너 기술에 관심이 많습니다.

Jimin Kim

Jimin Kim

김지민 솔루션즈 아키텍트는 AWS 코리아에 2019년 입사하여 주로 컨테이너와 SaaS (Software as a Service) 등의 영역에서 AWS 클라우드를 잘 구성하고 운영할 수 있도록 다양한 고객들께 도움을 드렸습니다. 현재는 Amazon.com US에서 솔루션즈 아키텍트로 근무하고 있습니다.