A Technical Primer for EKS Autoscaling

EKS Autoscaling의 여러 가지 전략들에 대해 알아보자

Sigrid Jin
53 min readApr 6, 2024
CON324_Optimizing-Amazon-EKS-for-performance-and-cost-on-AWS.pdf

프로덕션 환경에서 쿠버네티스를 사용하다보면 특정 시점에서의 트래픽이 몰리거나 어떤 서비스의 변경에 따라 리소스의 동적인 분배가 필요한 경우 AutoScaling의 문을 두드리게 된다. 쿠버네티스에서는 크게 1) Pod의 Scale In-Out에 유용한 HPA 2) Pod의 Scale Up-Down에 유용한 VPA 3) 노드 레벨에서 확장하는 Cluster AutoScaler가 있으며 그 외에 Production-Ready use로는 Karpenter가 주로 활용되고 있다.

HPA (Horizontal Pod AutoScaler)

기본적인 HPA의 아키텍처는 다음과 같이 이루어진다.

  • 각 노드에 배포된 DaemonSet인 cAdvisor가 컨테이너의 메모리와 CPU 사용량을 수집한다.
  • metrics-server는 kubelet을 통해서 메트릭을 수집하고 이를 API Server에 등록한다.
  • HPA 리소스는 API Server (Resource API) 를 통하여 15초마다 메모리와 CPU를 수집해서 정책에 따라 동작할 수 있도록 한다.

간단하게 실습을 위하여 php-apache deploy를 만들어보자. 해당 도커 파일은 CPU의 과부하 연산을 수행하기 위하여 100만번의 덧셈을 실행하도록 하였다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: php-apache
spec:
selector:
matchLabels:
run: php-apache
template:
metadata:
labels:
run: php-apache
spec:
containers:
- name: php-apache
image: registry.k8s.io/hpa-example
ports:
- containerPort: 80
resources:
limits:
cpu: 500m
requests:
cpu: 200m
---
apiVersion: v1
kind: Service
metadata:
name: php-apache
labels:
run: php-apache
spec:
ports:
- port: 80
selector:
run: php-apache
  • 간단하게 php-apache 서버를 2대 띄우고, 터미널을 통해 모니터링을 수행한다. 이 때 HPA를 생성하고 부하를 만들어내는 과정에서, 증가 기본 대기 시간은 30분이니 감소 시의 기본 대기 시분 5분이다. 이는 조정이 가능하다.
# 반복 접속 2 (서비스명 도메인으로 파드들 분산 접속) >> 증가 확인(몇개까지 증가되는가? 그 이유는?) 후 중지 >> 중지 5분 후 파드 갯수 감소 확인
모킹을 위해서 무진장 부담을 가지는 100만번 로직을 수행
사진을 통해 HPA가 이루어졌음을 확인할 수 있다.

KEDA

KEDA (Kubernetes Based Event Driven Autoscaler) 의 경우 특정한 이벤트를 기반으로 스케일 여부가 결정된다. 이는 CPU Mem 과 메모리 메트릭을 기반으로 스케일 여부를 결정하는 HPA와 다르다. 대표적인 예시로는 Airflow를 알 수 있다. metadb를 통해 현재 실행 중이거나 대기 중인 task가 얼마나 이러한 이벤트를 활용하여 worker의 scale을 결정한다면 queue에 task가 많이 추가되는 시점에 더 빠르게 확장할 수 있습니다. 아래와 같이 레디스에도 똑같은 이슈가 있어 적절한 파라미터만 넣을 수 있도록 설정값을 일부 열어주었다.

triggers:
- type: kafka
metadata:
bootstrapServers: kafka.svc:9092 # Comma separated list of Kafka brokers “hostname:port” to connect to for bootstrap.
consumerGroup: my-group # Name of the consumer group used for checking the offset on the topic and processing the related lag.
topic: test-topic # Name of the topic on which processing the offset lag. (Optional, see note below)
lagThreshold: '5' # Average target value to trigger scaling actions. (Default: 5, Optional)
offsetResetPolicy: latest # The offset reset policy for the consumer. (Values: latest, earliest, Default: latest, Optional)
allowIdleConsumers: false # When set to true, the number of replicas can exceed the number of partitions on a topic, allowing for idle consumers. (Default: false, Optional)
scaleToZeroOnInvalidOffset: false
version: 1.0.0 # Version of your Kafka brokers. See samara version (Default: 1.0.0, Optional)

아래와 같은 명령어로 먼저 KEDA를 설치하고 해당 네임스페이스에 Deployment를 생성하고 ScaledObject 정책을 생성하여 CronJob을 통해 이를 정해진 일자에 수행한다. 특히 필요한 경우에는 뒤에 이어서 나올 Karpenter와 KEDA를 사용하여 특정 시간에 노드를 AutoScaling도 할 수 있다.

cat <<EOT > keda-values.yaml
metricsServer:
useHostNetwork: true

prometheus:
metricServer:
enabled: true
port: 9022
portName: metrics
path: /metrics
serviceMonitor:
# Enables ServiceMonitor creation for the Prometheus Operator
enabled: true
podMonitor:
# Enables PodMonitor creation for the Prometheus Operator
enabled: true
operator:
enabled: true
port: 8080
serviceMonitor:
# Enables ServiceMonitor creation for the Prometheus Operator
enabled: true
podMonitor:
# Enables PodMonitor creation for the Prometheus Operator
enabled: true

webhooks:
enabled: true
port: 8080
serviceMonitor:
# Enables ServiceMonitor creation for the Prometheus webhooks
enabled: true
EOT

helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --version 2.13.0 --namespace keda -f keda-values.yaml
# ScaledObject 정책 생성 : cron
cat <<EOT > keda-cron.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: php-apache-cron-scaled
spec:
minReplicaCount: 0
maxReplicaCount: 2
pollingInterval: 30
cooldownPeriod: 300
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: php-apache
triggers:
- type: cron
metadata:
timezone: Asia/Seoul
start: 00,15,30,45 * * * *
end: 05,20,35,50 * * * *
desiredReplicas: "1"
EOT

kubectl apply -f keda-cron.yaml -n keda

VPA — Vertical Pod Autoscaler

VPA는 기존 파드를 종료하고 Admission Controller를 사용하여 Mutant Webhook으로 Pod의 Resources.request를 최적값으로 수정하려는 것이며, 이에 따라 수정된 requests 값이 기존의 값보다 상위 또는 하위 범위에 속할 수 있으므로 이를 Vertical이라고 부르고 있다. 파드마다 resource.requests를 최적값으로 설정하게 되면, 쿠버네티스 노드의 자원 효율성이 좋아진다. VPA를 설정하면 쿠버네티스 노드의 CPU와 메모리 자원을 최대로 확보할 수 있게 된다.

VPA는 HPA와 함께 사용할 수 없다고 한다. VPA는 파드의 CPU와 메모리의 Requests를 조정하지만 HPA는 파드의 레플리카 수를 조정하기 때문에 두 개가 모두 동작한다면 VPA가 파드의 리소스 Requests를 수정하는 동안 HPA는 변경된 메트릭으로 레플리카 수를 조정하려 할 수 있으므로 이 사이에서 무언가 불일치가 발생할 수 있다. 또한 VPA는 포드의 리소스 요청량을 업데이트하기 위해 파드를 종료하고 새로운 리소스 할당으로 다시 시작해야 하지만 HPA는 레플리카 수를 조정할 때 기존 파드를 그대로 유지한다는 큰 차이점도 있다.

Requests의 최적값을 계산할 때는 파드의 최근 자원 사용량을 기준으로 마진을 추가한 것이라고 한다. 내부 알고리즘에 따라 최근 사용량 + 마진값으로 파드의 최적값을 계산하는데 이를 위해서는 kubernetes-sig/metrics-server를 이용해서 파드 메트릭을 수집해와야 한다.

https://malwareanalysis.tistory.com/603

Admission Controller란 무엇인가?
— Admission Controller는 Kubernetes API 서버의 플러그인으로, API 요청을 가로채서 추가적인 검증, 변조, 거부 등의 작업을 수행한다.
— API 서버는 Admission Controller를 사용하여 리소스 생성, 업데이트, 삭제 등의 요청을 처리하기 전에 사전 정의된 규칙과 정책을 적용할 수 있습니다.
— Admission Controller는 크게 두 가지 유형으로 나눌 수 있다.
1. Mutating Admission Controller: 리소스 생성 또는 업데이트 요청을 가로채서 리소스의 필드를 수정하거나 기본값을 설정할 수 있습니다.
2. Validating Admission Controller: 리소스 생성 또는 업데이트 요청을 가로채서 리소스의 유효성을 검사하고, 유효하지 않은 요청을 거부할 수 있습니다.

2. Mutating Webhook:
— Mutating Webhook은 사용자 정의 Admission Controller의 일종으로, 외부의 웹훅 서버를 호출하여 리소스 생성 또는 업데이트 요청을 변조할 수 있습니다.
— Kubernetes API 서버는 Mutating Webhook이 등록되어 있는 경우, 관련 리소스의 생성 또는 업데이트 요청을 웹훅 서버로 전달합니다.
— 웹훅 서버는 요청을 받아 리소스의 필드를 수정하거나 기본값을 설정한 후 응답을 반환합니다.
— API 서버는 웹훅 서버의 응답을 기반으로 리소스를 변조하고 요청을 처리합니다.
— Mutating Webhook은 사용자 정의 로직을 구현하여 리소스를 동적으로 수정할 수 있게 해주므로 유연성과 확장성이 높습니다.
— VPA에서는 Mutating Webhook을 사용하여 포드의 리소스 요청량(resources.requests)을 분석하고 최적화된 값으로 수정한다.

VPA의 동작 과정을 한번 다시 생각해보자.

1. VPA는 포드의 리소스 사용량을 모니터링하고 분석하여 최적의 리소스 할당을 결정한다.
2. 새로운 포드 생성 요청이 발생하면 VPA의 Mutating Webhook이 해당 요청을 가로챈다.
3. Mutating Webhook은 VPA의 분석 결과를 기반으로 포드의 리소스 요청량(resources.requests)을 최적화된 값으로 수정한다.
4. 수정된 포드 매니페스트는 Kubernetes API 서버로 다시 전달되어 처리된니다.
5. 최적화된 리소스 할당으로 포드가 생성되고 스케줄링된다.

이제 Hamster Deployment로 실행되는 파드의 예제를 따라해보자.

# 코드 다운로드
git clone https://github.com/kubernetes/autoscaler.git
cd ~/autoscaler/vertical-pod-autoscaler/
tree hack

# Deploy the Vertical Pod Autoscaler to your cluster with the following command.
watch -d kubectl get pod -n kube-system
cat hack/vpa-up.sh
./hack/vpa-up.sh
kubectl get crd | grep autoscaling
kubectl get mutatingwebhookconfigurations vpa-webhook-config
kubectl get mutatingwebhookconfigurations vpa-webhook-config -o json | jq
  • 공식 예제에 따르면 파드가 실행되고 약 2~3분 뒤에 pod resource.request가 VPA에 의해 수정된다고 한다.
cd ~/autoscaler/vertical-pod-autoscaler/
cat examples/hamster.yaml | yh
kubectl apply -f examples/hamster.yaml && kubectl get vpa -w
  • VPA에 spec.updatePolicy.updateMode를 Off 로 변경 시 파드에 Spec을 자동으로 변경해서 재실행하지 않는다. 기본값은 Auto이다.
  • Pod Resource Requests를 확인해본다.
  • VPA에 의해 기존 파드 삭제되고 신규 파드가 생성된 것을 확인할 수 있겠다.

CA (Cluster AutoScaler)

https://haereeroo.tistory.com/24

Cluster AutoScaler는 리소스 부족으로 스케줄링이 되지 않고 있는 Pending 상태의 Pod가 존재할 때 Node를 스케일 아웃하는 로직을 의미한다. 클러스터에 오랜 시간 동안 유틸이 낮은 노드가 있을 수 있고, 특정 노드에 있는 파드를 다른 노드에 재배치할 수 있을 때 노드를 스케일 인을 한다. 따라서 CA는 10초에 한 번씩 파드를 검사하여 빠르게 스케일 아웃 및 스케일 인을 하게 된다. 노드가 한 번 스케일 아웃을 하게 되면 스케일 인 검사를 하기 까지 약 10분 정도의 대기 시간이 소요된다. 따라서 스케일 아웃은 비교적 빠르게 이루어지지만 스케일 인은 비교적 천천히 진행된다고 볼 수 있다.

CA를 우리 AWS에서 적용하기 위해서는 CA 실행 인자에 직접 ASG의 이름을 넣어주거나, CA에 ASG의 Node Selector Tag, Taint Tag를 달아주어야 할 필요성이 있다. 일반적으로 후자가 권고되는 방식이다. 만약 노드 그룹이 여러 개이고 조건에 맞는 ASG가 여러 개가 있다고 하면 — expander flag를 이용하여 여러 개의 노드와 다수의 노드 그룹 중 어디에 추가하면 될 지를 결정하게 된다. 기본 값은 무작위 선택인데, most-pods나 lead-waste와 같이 어떤 파드를 죽일 것인가에 대한 설정 값도 세세하게 지정이 가능하다.

- k8s.io/cluster-autoscaler/node-template/label/<label 이름> : ASG의 노드가 이 라벨을 가지고 있다고 간주한다.

- k8s.io/cluster-autoscaler/node-template/taint/<taint 이름> : ASG의 노드가 이 taint를 가지고 있다고 간주한다.

만약 PodDisruptionBudget이 설정되어 있다면 일정 개수 이상의 파드가 떠있을 수 있도록 CA의 노드 Scale In을 방지할 수 있다. 또는 ”cluster-autoscaler.kubernetes.io/safe-to-evict” : “false” 로 설정되어 있다면 CA가 파드를 다른 노드에 옮기거나 해당 파드가 실행되고 있는 노드를 스케일 인 할 수 없게 된다.

apiVersion: policy/v1beta1
kind: PodDisruptionBudget
metadata:
name: practice
namespace: default
labels:
app: practice
spec:
minAvailable: 2
selector:
matchLabels:
app: practice

만약 노드를 스케일 아웃 하는 것이 인스턴스를 새로 띄우는 일이므로 다소 오래 시간이 걸린다고 하면 시간 전략을 위해서 인스턴스를 미리 띄워놓는 것도 방법이 될 수 있다. 대신 미리 띄워둔 인스턴스의 우선순위는 다른 파드에 비하여 훨씬 낮아야 한다. 일반 파드가 새롭게 생성될 때 노드에 자리가 부족하게 되면 1의 파드를 내쫓고 그 자리를 차지하게 된다. 내쫓김을 당한 파드는 스케일 아웃 하게 된다.

---
apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
name: overprovisioning
value: -1
globalDefault: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: overprovisioning
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
run: overprovisioning
template:
metadata:
labels:
run: overprovisioning
spec:
priorityClassName: overprovisioning
containers:
- name: reserve-resources
image: k8s.gcr.io/pause
resources:
requests:
cpu: 1600m
memory: 550Mi

이제 실습을 진행해본다.

# 설정 전 확인
aws ec2 describe-instances --filters Name=tag:Name,Values=$CLUSTER_NAME-ng1-Node --query "Reservations[*].Instances[*].Tags[*]" --output yaml | yh
...
- Key: k8s.io/cluster-autoscaler/myeks
Value: owned
- Key: k8s.io/cluster-autoscaler/enabled
Value: 'true'

# 현재 autoscaling(ASG) 정보 확인

aws autoscaling describe-auto-scaling-groups \
--query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='myeks']].[AutoScalingGroupName, MinSize, MaxSize,DesiredCapacity]" \
--output table

# MaxSize 6개로 수정
export ASG_NAME=$(aws autoscaling describe-auto-scaling-groups --query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='myeks']].AutoScalingGroupName" --output text)
aws autoscaling update-auto-scaling-group --auto-scaling-group-name ${ASG_NAME} --min-size 3 --desired-capacity 3 --max-size 6

# 확인
aws autoscaling describe-auto-scaling-groups --query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='myeks']].[AutoScalingGroupName, MinSize, MaxSize,DesiredCapacity]" --output table

Cluster AutoScaler 컨트롤러를 배포해본다.

# 배포 : Deploy the Cluster Autoscaler (CA)
curl -s -O https://raw.githubusercontent.com/kubernetes/autoscaler/master/cluster-autoscaler/cloudprovider/aws/examples/cluster-autoscaler-autodiscover.yaml
sed -i "s/<YOUR CLUSTER NAME>/$CLUSTER_NAME/g" cluster-autoscaler-autodiscover.yaml
kubectl apply -f cluster-autoscaler-autodiscover.yaml

# 확인
kubectl get pod -n kube-system | grep cluster-autoscaler
kubectl describe deployments.apps -n kube-system cluster-autoscaler
kubectl describe deployments.apps -n kube-system cluster-autoscaler | grep node-group-auto-discovery
--node-group-auto-discovery=asg:tag=k8s.io/cluster-autoscaler/enabled,k8s.io/cluster-autoscaler/myeks

# (옵션) cluster-autoscaler 파드가 동작하는 워커 노드가 퇴출(evict) 되지 않게 설정
kubectl -n kube-system annotate deployment.apps/cluster-autoscaler cluster-autoscaler.kubernetes.io/safe-to-evict="false"

이제 모든 준비는 되었으니 실제로 nginx 파드를 활용하여 클러스터 내부에서 스케일 아웃이 되는지를 확인해본다.

# 모니터링 
kubectl get nodes -w
while true; do kubectl get node; echo "------------------------------" ; date ; sleep 1; done
while true; do aws ec2 describe-instances --query "Reservations[*].Instances[*].{PrivateIPAdd:PrivateIpAddress,InstanceName:Tags[?Key=='Name']|[0].Value,Status:State.Name}" --filters Name=instance-state-name,Values=running --output text ; echo "------------------------------"; date; sleep 1; done

# Deploy a Sample App
# We will deploy an sample nginx application as a ReplicaSet of 1 Pod
cat <<EoF> nginx.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-to-scaleout
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
service: nginx
app: nginx
spec:
containers:
- image: nginx
name: nginx-to-scaleout
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 500m
memory: 512Mi
EoF
kubectl apply -f nginx.yaml
kubectl get deployment/nginx-to-scaleout

# Scale our ReplicaSet
# Let’s scale out the replicaset to 15
kubectl scale --replicas=15 deployment/nginx-to-scaleout && date

# 확인
kubectl get pods -l app=nginx -o wide --watch
kubectl -n kube-system logs -f deployment/cluster-autoscaler

위와 같이 노드가 증가하였음을 확인할 수 있다. 노드 개수 축소의 경우 위에서 언급되었듯이 노드 갯수 축소 — 기본은 10분 후 scale down 되는 것으로 보인다. 물론 다음과 같은 flag 로 시간 수정 가능하긴 한데, Deployment 삭제 10분 기다리고 나서 체크하자.

# and --scale-down-delay-after-failure flag. 
# E.g. --scale-down-delay-after-add=5m to decrease the scale down delay to 5 minutes after a node has been added.

ClusterAutoScaler에도 단점은 있다. 먼저 AWS의 Auto Scaling Group (ASG)과 EKS에서 각자의 방식으로 노드를 관리하기 때문에 관리 정보가 서로 동기화되지 않는 문제가 있다. ClusterAutoScaler는 ASG에만 의존하고 실제 노드의 생성과 삭제에 직접 관여하지 않는다. EKS에서 노드를 삭제해도 해당 인스턴스가 실제로 삭제되지 않는 경우가 있다고 한다.

또한, 클러스터 축소 시 특정 노드가 먼저 축소되도록 제어하기가 매우 어렵다. 예를 들어, 파드 수가 적은 노드나 이미 드레인된 노드를 우선적으로 축소하는 것이 이상적이지만, ClusterAutoScaler는 이러한 세밀한 제어를 제공하지 않는다. 특정 노드를 삭제하면서 동시에 노드 개수를 줄이는 것도 쉽지 않다. ClusterAutoScaler의 삭제 정책 옵션이 다양하지 않기 때문이다.

ClusterAutoScaler는 폴링 방식으로 동작하므로 확장 여유를 너무 자주 확인하면 API 제한에 도달할 수 있습니다. 또한, ClusterAutoScaler가 처음 파드가 켜질 떄의 스케일링 속도가 매우 느리다는 점도 주의해야 합니다.

또한, Pending 상태의 파드가 생길 때 비로소 ClusterAutoScaler가 동작하기 시작합니다. 이는 파드의 리소스 Request와 Limit를 적절하게 설정하지 않으면 실제 노드의 부하 평균이 낮은 상황에서도 불필요한 스케일 아웃이 발생하거나, 반대로 부하 평균이 높은 상황임에도 스케일 아웃이 되지 않는 문제로 이어질 수 있다.

리소스에 의한 스케줄링은 기본적으로 리소스 Request를 기준으로 이루어진다. 따라서 리소스 요청을 초과하여 할당한 경우, 최소 리소스 요청만으로도 리소스가 꽉 차서 신규 노드를 추가해야 할 수 있다. 반대로 리소스 요청을 낮게 설정하고 Limit와의 차이가 큰 경우, 실제 리소스 사용량이 높아졌더라도 리소스 요청 합계로는 아직 스케줄링이 가능하다고 판단되어 클러스터 스케일 아웃이 발생하지 않을 수 있습니다.

CPA — Cluster Proportional AutoScaler

CPA는 Cluster Propotional AutoScaler의 약자로 노드 수의 증가에 비례하여 성능 처리가 필요한 파드의 개수를 관리하는 방법이다. 노드가 추가될 때마다 core dns pod 개수 증가시켜, coredns부하를 줄이거나 CPA를 사용하면 node개수가 2개일 때 coredns 1개를, node개수가 5개일 때 coredns를 3개 등을 설정할 수 있다. 또한, Metrics server 등을 사용하지 않고 kubapi server API를 사용한다.

예제를 따라해보기 위해서는 노드 개수에 따라 nginx pod 개수를 설정해야 하고 워커 노드 1~2개면 족하다고 알려져있다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
resources:
limits:
cpu: "100m"
memory: "64Mi"
requests:
cpu: "100m"
memory: "64Mi"
ports:
- containerPort: 80

먼저 nginx deployment부터 하고 CPA 규칙을 설정해서 helm chart를 릴리즈해줄 것이다. 먼저 파드부터 배포하고 CPA 규칙을 설정해보자.

# nginx 디플로이먼트 배포
cat <<EOT > cpa-nginx.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:latest
resources:
limits:
cpu: "100m"
memory: "64Mi"
requests:
cpu: "100m"
memory: "64Mi"
ports:
- containerPort: 80
EOT
kubectl apply -f cpa-nginx.yaml

# CPA 규칙 설정
cat <<EOF > cpa-values.yaml
config:
ladder:
nodesToReplicas:
- [1, 1]
- [2, 2]
- [3, 3]
- [4, 3]
- [5, 5]
options:
namespace: default
target: "deployment/nginx-deployment"
EOF

kubectl describe cm cluster-proportional-autoscaler

helm repo add cluster-proportional-autoscaler https://kubernetes-sigs.github.io/cluster-proportional-autoscaler
# 모니터링
watch -d kubectl get pod

# helm 업그레이드
helm upgrade --install cluster-proportional-autoscaler -f cpa-values.yaml cluster-proportional-autoscaler/cluster-proportional-autoscaler

# 노드 5개로 증가
export ASG_NAME=$(aws autoscaling describe-auto-scaling-groups --query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='myeks']].AutoScalingGroupName" --output text)
aws autoscaling update-auto-scaling-group --auto-scaling-group-name ${ASG_NAME} --min-size 5 --desired-capacity 5 --max-size 5
aws autoscaling describe-auto-scaling-groups --query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='myeks']].[AutoScalingGroupName, MinSize, MaxSize,DesiredCapacity]" --output table

# 노드 4개로 축소
aws autoscaling update-auto-scaling-group --auto-scaling-group-name ${ASG_NAME} --min-size 4 --desired-capacity 4 --max-size 4
aws autoscaling describe-auto-scaling-groups --query "AutoScalingGroups[? Tags[? (Key=='eks:cluster-name') && Value=='myeks']].[AutoScalingGroupName, MinSize, MaxSize,DesiredCapacity]" --output table

Karpenter

https://devblog.kakaostyle.com/ko/2022-10-13-1-karpenter-on-eks/

AWS에서 쿠버네티스 클러스터를 자동으로 확장하는 오픈소스 도구이다. AWS EC2 인스턴스를 관리하고 클러스터의 리소스 요구사항에 따라 인스턴스를 동적으로 조정하여 클러스터의 확장성과 효율성을 꾀할 수 있다. 쿠버네티스 클러스터의 Auto Scaling 기능을 확장시켜 클러스터 내에서 실행 중인 워커로드의 요구사항에 따라 EC2 인스턴스를 자동으로 확장하거나 축소할 수도 있다.

일반적으로 CA 방식은 위에서 언급했다시피 AWS의 리소스인 ASG에 대한 의존도가 높기 때문에 노드를 추가하는데 다소 시간이 소요되게 된다. Node의 EBS 타입을 gp2 -> gp3로 바꾼다고 했을 때 하나씩 롤링 업데이트가 이루어지므로 전체 노드에 대해서 변경이 완료될 때까지는 기다리면 될 것이며, 노드에 Launch Template 등을 별도로 관리해야 하는 커스텀 정보의 니즈가 있다면 워크로드 별로 인스턴스의 요구사항이 달라지게 되며 그러면 관리형 Node Group은 여러 벌의 ASG를 사용해야 하는 일이 벌어지게 된다.

따라서 Karpenter는 다음과 같이 동작하게 된다.

  1. Horizontal Pod AutoScaler(HPA) 에 의한 Pod의 수평적 확장이 한계에 다다르면, Pod 는 적절한 Node 를 배정받지 못하고 pending 상태에 빠진다.
  2. 이때 Karpenter 는 지속해서 Unscheduled Pod 를 관찰하고 있다가, 새로운 Node 추가를 결정하고 직접 배포한다.
  3. 추가된 Node가 Ready 상태가 되면 Karpenterkube-scheduler 를 대신하여 Pod 의 Node binding 요청도 수행한다.
  4. 따라서 모든 Worker Node 는 Karpenter 에 의해 lifecycle 이 결정된다.

Karpenter의 가장 큰 장점은 사용 리전 내 인스턴스 가용성을 걱정해야 할 일도 줄어들고, 신규 Node 추가 시 Karpenter 가 요청 Pod 에 가장 적절한 인스턴스 타입을 골라 기동하므로 Kubernetes 클러스터 운영 효율을 논할 때 늘 나오는 bin packing 문제도 개선이 가능하다고 한다.

간단하게 실습 환경을 배포해보고 따라해보도록 한다.

# YAML 파일 다운로드
curl -O https://s3.ap-northeast-2.amazonaws.com/cloudformation.cloudneta.net/K8S/karpenter-preconfig.yaml

# CloudFormation 스택 배포
예시) aws cloudformation deploy --template-file karpenter-preconfig.yaml --stack-name myeks2 --parameter-overrides KeyName=kp-gasida SgIngressSshCidr=$(curl -s ipinfo.io/ip)/32 MyIamUserAccessKeyID=AKIA5... MyIamUserSecretAccessKey='CVNa2...' ClusterBaseName=myeks2 --region ap-northeast-2

# CloudFormation 스택 배포 완료 후 작업용 EC2 IP 출력
aws cloudformation describe-stacks --stack-name myeks2 --query 'Stacks[*].Outputs[0].OutputValue' --output text

# 작업용 EC2 SSH 접속
ssh -i ~/.ssh/kp-gasida.pem ec2-user@$(aws cloudformation describe-stacks --stack-name myeks2 --query 'Stacks[*].Outputs[0].OutputValue' --output text)

# 변수 정보 확인
export | egrep 'ACCOUNT|AWS_' | egrep -v 'SECRET|KEY'

# 변수 설정
export KARPENTER_NAMESPACE="kube-system"
export K8S_VERSION="1.29"
export KARPENTER_VERSION="0.35.2"
export TEMPOUT=$(mktemp)
export ARM_AMI_ID="$(aws ssm get-parameter --name /aws/service/eks/optimized-ami/${K8S_VERSION}/amazon-linux-2-arm64/recommended/image_id --query Parameter.Value --output text)"
export AMD_AMI_ID="$(aws ssm get-parameter --name /aws/service/eks/optimized-ami/${K8S_VERSION}/amazon-linux-2/recommended/image_id --query Parameter.Value --output text)"
export GPU_AMI_ID="$(aws ssm get-parameter --name /aws/service/eks/optimized-ami/${K8S_VERSION}/amazon-linux-2-gpu/recommended/image_id --query Parameter.Value --output text)"
export AWS_PARTITION="aws"
export CLUSTER_NAME="${USER}-karpenter-demo"
echo "export CLUSTER_NAME=$CLUSTER_NAME" >> /etc/profile
echo $KARPENTER_VERSION $CLUSTER_NAME $AWS_DEFAULT_REGION $AWS_ACCOUNT_ID $TEMPOUT $ARM_AMI_ID $AMD_AMI_ID $GPU_AMI_ID

# CloudFormation 스택으로 IAM Policy, Role(KarpenterNodeRole-myeks2) 생성 : 3분 정도 소요
curl -fsSL https://raw.githubusercontent.com/aws/karpenter-provider-aws/v"${KARPENTER_VERSION}"/website/content/en/preview/getting-started/getting-started-with-karpenter/cloudformation.yaml > "${TEMPOUT}" \
&& aws cloudformation deploy \
--stack-name "Karpenter-${CLUSTER_NAME}" \
--template-file "${TEMPOUT}" \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides "ClusterName=${CLUSTER_NAME}"

# 클러스터 생성 : myeks2 EKS 클러스터 생성 19분 정도 소요
eksctl create cluster -f - <<EOF
---
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig
metadata:
name: ${CLUSTER_NAME}
region: ${AWS_DEFAULT_REGION}
version: "${K8S_VERSION}"
tags:
karpenter.sh/discovery: ${CLUSTER_NAME}

iam:
withOIDC: true
serviceAccounts:
- metadata:
name: karpenter
namespace: "${KARPENTER_NAMESPACE}"
roleName: ${CLUSTER_NAME}-karpenter
attachPolicyARNs:
- arn:${AWS_PARTITION}:iam::${AWS_ACCOUNT_ID}:policy/KarpenterControllerPolicy-${CLUSTER_NAME}
roleOnly: true

iamIdentityMappings:
- arn: "arn:${AWS_PARTITION}:iam::${AWS_ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}"
username: system:node:{{EC2PrivateDNSName}}
groups:
- system:bootstrappers
- system:nodes

managedNodeGroups:
- instanceType: m5.large
amiFamily: AmazonLinux2
name: ${CLUSTER_NAME}-ng
desiredCapacity: 2
minSize: 1
maxSize: 10
iam:
withAddonPolicies:
externalDNS: true
EOF

# eks 배포 확인
eksctl get cluster
eksctl get nodegroup --cluster $CLUSTER_NAME
eksctl get iamidentitymapping --cluster $CLUSTER_NAME
eksctl get iamserviceaccount --cluster $CLUSTER_NAME
eksctl get addon --cluster $CLUSTER_NAME

# default 네임스페이스 적용
kubectl ns default


# 노드 정보 확인
kubectl get node --label-columns=node.kubernetes.io/instance-type,eks.amazonaws.com/capacityType,topology.kubernetes.io/zone

# ExternalDNS
MyDomain=<자신의 도메인>
echo "export MyDomain=<자신의 도메인>" >> /etc/profile
MyDomain=gasida.link
echo "export MyDomain=gasida.link" >> /etc/profile
MyDnzHostedZoneId=$(aws route53 list-hosted-zones-by-name --dns-name "${MyDomain}." --query "HostedZones[0].Id" --output text)
echo $MyDomain, $MyDnzHostedZoneId
curl -s -O https://raw.githubusercontent.com/gasida/PKOS/main/aews/externaldns.yaml
MyDomain=$MyDomain MyDnzHostedZoneId=$MyDnzHostedZoneId envsubst < externaldns.yaml | kubectl apply -f -

# kube-ops-view
helm repo add geek-cookbook https://geek-cookbook.github.io/charts/
helm install kube-ops-view geek-cookbook/kube-ops-view --version 1.2.2 --set env.TZ="Asia/Seoul" --namespace kube-system
kubectl patch svc -n kube-system kube-ops-view -p '{"spec":{"type":"LoadBalancer"}}'
kubectl annotate service kube-ops-view -n kube-system "external-dns.alpha.kubernetes.io/hostname=kubeopsview.$MyDomain"
echo -e "Kube Ops View URL = http://kubeopsview.$MyDomain:8080/#scale=1.5"


# [터미널1] eks-node-viewer
cd ~/go/bin && ./eks-node-viewer --resources cpu,memory

# k8s 확인
kubectl cluster-info
kubectl get node --label-columns=node.kubernetes.io/instance-type,eks.amazonaws.com/capacityType,topology.kubernetes.io/zone
kubectl get pod -n kube-system -owide
kubectl describe cm -n kube-system aws-auth

# Karpenter 설치를 위한 변수 설정 및 확인
export CLUSTER_ENDPOINT="$(aws eks describe-cluster --name "${CLUSTER_NAME}" --query "cluster.endpoint" --output text)"
export KARPENTER_IAM_ROLE_ARN="arn:${AWS_PARTITION}:iam::${AWS_ACCOUNT_ID}:role/${CLUSTER_NAME}-karpenter"
echo "${CLUSTER_ENDPOINT} ${KARPENTER_IAM_ROLE_ARN}"

# EC2 Spot Fleet의 service-linked-role 생성 확인 : 만들어있는것을 확인하는 거라 아래 에러 출력이 정상!
# If the role has already been successfully created, you will see:
# An error occurred (InvalidInput) when calling the CreateServiceLinkedRole operation: Service role name AWSServiceRoleForEC2Spot has been taken in this account, please try a different suffix.
aws iam create-service-linked-role --aws-service-name spot.amazonaws.com || true

# docker logout : Logout of docker to perform an unauthenticated pull against the public ECR
docker logout public.ecr.aws

# helm registry logout
helm registry logout public.ecr.aws

# karpenter 설치
helm install karpenter oci://public.ecr.aws/karpenter/karpenter --version "${KARPENTER_VERSION}" --namespace "${KARPENTER_NAMESPACE}" --create-namespace \
--set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=${KARPENTER_IAM_ROLE_ARN}" \
--set "settings.clusterName=${CLUSTER_NAME}" \
--set "settings.interruptionQueue=${CLUSTER_NAME}" \
--set controller.resources.requests.cpu=1 \
--set controller.resources.requests.memory=1Gi \
--set controller.resources.limits.cpu=1 \
--set controller.resources.limits.memory=1Gi \
--wait

# 확인
kubectl get-all -n $KARPENTER_NAMESPACE
kubectl get all -n $KARPENTER_NAMESPACE
kubectl get crd | grep karpenter

# APi 변경
v1alpha5/Provisioner → v1beta1/NodePool
v1alpha1/AWSNodeTemplate → v1beta1/EC2NodeClass
v1alpha5/Machine → v1beta1/NodeClaim

먼저 노드 풀 (Provisioner)을 만들어보자. Karpenter의 NodePool은 노드 그룹을 관리하고 프로비저닝하는 역할을 한다. NodePool은 Karpenter에서 노드를 동적으로 생성하고 관리하기 위한 핵심 리소스다. 이전에는 Provisioner라고 불렸지만, NodePool로 명칭이 변경되었다.

securityGroupSelector와 subnetSelector

  • securityGroupSelector는 노드에 적용할 보안 그룹을 선택하는 데 사용됩니다. 레이블 셀렉터를 사용하여 적절한 보안 그룹을 찾습니다.
  • subnetSelector는 노드를 생성할 서브넷을 선택하는 데 사용됩니다. 레이블 셀렉터를 사용하여 적절한 서브넷을 찾습니다.
  • 이를 통해 Karpenter는 동적으로 생성된 노드에 적절한 네트워크 설정을 적용할 수 있습니다.

consolidationPolicy

  • consolidationPolicy는 미사용 노드를 정리하는 정책을 정의해서 Karpenter가 불필요한 노드를 식별하고 종료하는 방법을 제어한다.
  • none: 노드 정리를 수행하지 않는다.
  • delete: 미사용 노드를 즉시 삭제한다.
  • pack: 미사용 노드를 최대한 활용하여 파드를 다시 스케줄링한 후 불필요한 노드를 삭제합니다.
  • 데몬셋(DaemonSet)으로 실행되는 파드는 consolidationPolicy의 영향을 받지 않는다. 데몬셋은 클러스터의 모든 노드 또는 특정 노드에서 실행되어야 하므로 노드 정리 과정에서 제외된다.

NodePool을 생성할 때는 다음과 같은 추가 설정도 고려할 수 있다.

  • nodeTemplate: 노드 템플릿을 정의하여 노드의 인스턴스 유형, 레이블, 테인트 등을 설정할 수 있다.
  • ttlSecondsAfterEmpty: 노드가 비어있는 상태로 유지되는 최대 시간을 설정하여 불필요한 노드를 자동으로 종료할 수 있다.
  • ttlSecondsUntilExpired: 노드의 만료 시간을 설정하여 노드의 수명을 제어할 수 있다.
  • providerRef: 클라우드 공급자를 참조하여 노드를 프로비저닝할 수 있다.
cat <<EOF | envsubst | kubectl apply -f -
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: kubernetes.io/os
operator: In
values: ["linux"]
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
- key: karpenter.k8s.aws/instance-category
operator: In
values: ["c", "m", "r"]
- key: karpenter.k8s.aws/instance-generation
operator: Gt
values: ["2"]
nodeClassRef:
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
name: default
limits:
cpu: 1000
disruption:
consolidationPolicy: WhenUnderutilized
expireAfter: 720h # 30 * 24h = 720h
---
apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
name: default
spec:
amiFamily: AL2 # Amazon Linux 2
role: "KarpenterNodeRole-${CLUSTER_NAME}" # replace with your cluster name
subnetSelectorTerms:
- tags:
karpenter.sh/discovery: "${CLUSTER_NAME}" # replace with your cluster name
securityGroupSelectorTerms:
- tags:
karpenter.sh/discovery: "${CLUSTER_NAME}" # replace with your cluster name
amiSelectorTerms:
- id: "${ARM_AMI_ID}"
- id: "${AMD_AMI_ID}"
# - id: "${GPU_AMI_ID}" # <- GPU Optimized AMD AMI
# - name: "amazon-eks-node-${K8S_VERSION}-*" # <- automatically upgrade when a new AL2 EKS Optimized AMI is released. This is unsafe for production workloads. Validate AMIs in lower environments before deploying them to production.
EOF

# 확인
kubectl get nodepool,ec2nodeclass

이제 Deployment를 Scale Up 해보자.

# pause 파드 1개에 CPU 1개 최소 보장 할당
cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
name: inflate
spec:
replicas: 0
selector:
matchLabels:
app: inflate
template:
metadata:
labels:
app: inflate
spec:
terminationGracePeriodSeconds: 0
containers:
- name: inflate
image: public.ecr.aws/eks-distro/kubernetes/pause:3.7
resources:
requests:
cpu: 1
EOF

# Scale up
kubectl get pod
kubectl scale deployment inflate --replicas 5
kubectl logs -f -n "${KARPENTER_NAMESPACE}" -l app.kubernetes.io/name=karpenter -c controller
kubectl logs -f -n "${KARPENTER_NAMESPACE}" -l app.kubernetes.io/name=karpenter -c controller | jq '.'

이제 Deployment를 Scale Down 해보자.

# Now, delete the deployment. After a short amount of time, Karpenter should terminate the empty nodes due to consolidation.
kubectl delete deployment inflate && date
kubectl logs -f -n "${KARPENTER_NAMESPACE}" -l app.kubernetes.io/name=karpenter -c controller

Karpenter Disruption

Karpenter Disruption(Consolidation)은 노드의 수명 주기와 효율적인 리소스 활용을 관리하는 역할을 수행한다.

  1. Expiration: 기본적으로 720시간(30일) 후에 인스턴스를 자동으로 만료시켜 강제로 노드를 최신 상태로 유지한다. 이를 통해 노드가 오래된 구성으로 인해 문제가 발생하는 것을 방지할 수 있다.
  2. Drift: NodePool이나 EC2NodeClass와 같은 구성 변경 사항을 감지하여 필요한 변경 사항을 적용한다. 이를 통해 노드의 구성이 의도한 상태에서 벗어나는 것을 방지할 수 있다.
  3. Consolidation: Karpenter는 스팟 인스턴스를 사용하여 비용을 절감할 수 있다. 스팟 인스턴스 시작 시 Karpenter는 AWS EC2 Fleet Instance API를 사용하여 NodePool 구성을 기반으로 인스턴스 유형을 선택한다. 만약 인스턴스를 시작할 수 없을 경우 Karpenter는 대체 용량을 요청하거나 워크로드 제약 조건을 조정할 수 있다.

Spot-to-Spot Consolidation은 스팟 인스턴스 간의 통합을 의미한다. 이를 위해서는 Karpenter에 최소 15개 이상의 인스턴스 유형이 포함된 다양한 인스턴스 구성이 필요하다. 이는 가용성이 낮고 중단 빈도가 높은 인스턴스를 선택하는 위험을 줄이기 위함이다.

AWS EC2 Fleet Instance API는 Amazon EC2에서 제공하는 API로, 단일 API 호출을 통해 여러 인스턴스 유형과 구매 옵션(온디맨드, 예약 인스턴스, 스팟 인스턴스)에 걸쳐 대규모 인스턴스 그룹을 시작할 수 있도록 해준다.

Karpenter는 EC2 Fleet Instance API를 활용하여 NodePool 구성에 따라 최적의 인스턴스 유형과 구매 옵션을 선택하여 노드를 프로비저닝한다. 만약 요청한 인스턴스를 시작할 수 없는 경우, Karpenter는 다른 인스턴스 유형을 요청하거나 워크로드에 대한 제약 조건을 조정하여 유연하게 처리할 수 있다.

--

--