Chap 11. 고급 스케줄링

ka8main.png

고가용성 확보 - Pod 레벨

ReplicaSet, Deployment 리소스의 replica property를 이용하여 서비스의 가용성을 높이는 방법에 대해 알아보았다. 이런 방법은 일정 범위 안의 트래픽에 대해서는 서비스 가용성이 유지되지만 처리량 범위를 넘어서는 트래픽에 대해서는 한계를 가진다. replica 개수가 정적으로 고정되어 있기 때문이다.

이러한 문제를 해결하고자 쿠버네티스에서는 Pod의 리소스 사용량에 따라 자동으로 확장하는 HorizontalPodAutoScaler(HPA)라는 리소스를 제공한다.

HPA는 이름에서도 짐작할 수 있듯, Pod의 개수를 수평적으로 자동 확장한다.

HPA는 metrics-server라는 컴포넌트를 사용한다. metrics-server는 Pod의 리소스 사용량을 수집하는 서버이다. 이 서버를 통해 Pod의 작업량을 모니터링하다가 사용자가 지정한 일정 수준의 임계값을 넘으면 replica 개수를 동적으로 조절하여 Pod의 개수를 늘린다. 일정 시간이 지난 이후, Pod의 작업량이 적어지게 되면 다시 Pod 개수를 줄여주는 역할도 수행한다.

metrics server 설치

helm repo add metrics-server https://kubernetes-sigs.github.io/metrics-server/
helm repo update

helm install metrics-server metrics-server/metrics-server \
  --namespace ctrl --create-namespace

shell1

metrics-server 설치가 완료되면 리소스 사용량을 모니터링할 Pod을 하나 생성한다.

⚠️ 한참 걸리니 주의 계속해서 안되면, metrics deployment에 다음 옵션을 추가한다: —kubelet-insecure-tls

kubectl run mynginx --image nginx

kubectl top pod

kubeclt top node

metrics-server가 정상적으로 설치되면 top 명령을 이용하여 Pod와 Node의 리소스를 각각 확인할 수 있다.

자동 확장할 Pod 생성

이제 본격적으로 HPA에 대해 살펴보자. 먼저 자동 확장의 대상이 될 Pod을 생성한다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: heavy-cal
spec:
  selector:
    matchLabels:
      run: heavy-cal
  replicas: 1
  template:
    metadata:
      labels:
        run: heavy-cal
    spec:
      containers:
        - name: heavy-cal
          image: k8s.gcr.io/hpa-example
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "300m"
            limits:
              cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: heavy-cal
spec:
  ports:
  - port: 80
  selector:
    run: heavy-cal

이제 HPA를 생성하자

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: heavy-cal
spec:
  maxReplicas: 50
  minReplicas: 1
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: heavy-cal
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50  # 평균 CPU 사용률이 50% 넘으면 스케일 아웃

💡 targetCPUUtilizationPercentage은 autoscaling/v1에서만 사용가능하므로, 수정한 부분이 있습니다.

또는 명령형으로 다음과 같이 수행할 수도 있다.

kubectl autoscale deployment heavy-cal --cpu-percent=50 --min=1 --max=50

이제 heavy-cal 서비스에 무한히 부하를 주는 Pod을 생성해보자

apiVersion: v1
kind: Pod
metadata:
  name: heavy-load
spec:
  containers:
    - name: busybox
      image: busybox
      command: ["/bin/sh"]
      args: ["-c", "while true; do wget -q -O- http://heavy-cal; done"]

shell2

위와 같이 점점 늘어나는 것을 확인 가능하다.

고가용성 확보 - Node 레벨

쿠버네티스는 Pod 뿐만 아니라 Cluster 레벨의 Node 또한 자동 수평확장할 수 있는 메커니즘을 제공한다. Node에 더 이상 새로운 Pod를 둘 자원이 부족한 경우, 자동으로 새로운 Node를 생성함으로써 클러스터 레벨의 고가용성을 확보할 수 있다.

다만, Cluster Autoscale의 경우 클라우드 서비스에서 지원을 해야 하기 때문에 로컬 클러스터로는 따라하기 어렵다

AWS EKS Cluster AutoScaler 설정

cluster-autoscaler chart를 설치한다.

NAME=hanson-k8s
REGION=ap-northeast-2

helm install autoscaler stable/cluster-autoscaler \
    --namespace kube-system \
    --set autoDiscovery.clusterName=$NAME,awsRegion=$REGION,sslCertPath=/etc/kubernetes/pki/ca.crt \
    --version 7.3.4

GCP GKE Cluster AutoScaler 설정

GCP의 경우 따로 cluster-autoscaler를 설치할 필요 없이 클러스터 생성 시점에서 —enable-autoscaling 옵션을 추가하기만 하면 된다.

CLUSTER_NAME=hanson-k8s
REGION=us-central1-a

gcloud container clusters create $CLUSTER_NAME \
    --enable-autoscaling \
    --min-nodes=1 \
    --num-nodes=2 \
    --max-nodes=4 \
    --node-locations=$REGION \
    --machine-type=n1-highcpu-8

EKS는 사용자가 직접 cluster-autoscaler를 설치해야하고, GKE의 경우 클라우드 서비스에서 직접 관리한다. 각 방식에 따라 서로 장단이 있다. 사용자가 직접 설치하면 약간의 귀찮음 대신 자동 확장 정책(delay time, scan interval 등)을 세밀하게 조절할 수 있다.

클라우드 플랫폼에서 관리한다면 쉽게 옵션으로 노드 자동 확장 기능을 활성화 할 수 있지만, 상세한 정책은 설정할 수 없다.

Cluster AutoScaling 활용

앞서 생성한 heavy-cal의 Pod 개수를 인위적으로 늘려보자. replica 개수를 50으로 지정하면 일부 Pod은 생성되지만, 클러스터에 가용한 자원이 더는 없어서 Pending 상태로 남아있게 된다. Cluster Autoscaler는 이러한 상황을 확인하고 자동으로 Node의 개수를 증가시킨다.

kubectl scale deplyment heavy-cal --replicas=50
...
watch kubectl get node

이러면 Pod을 생성하기 위해 노드가 확장되는 것을 확인할 수 있다.

Taint & Toleration

Pod과 Node간의 특성으로 스케줄 전략을 세우는 방법도 있다. Taint와 Toleration을 사용해서 이를 실현 가능하다.

Taint

Taint는 오염시키다, 오점을 남기다 라는 의미를 가지고 있다. 노드에 Taint(오점)을 남기게 되면 Pod들이 해당 노드에 접근(스케줄링)하지 못한다.

kubectl taint nodes $NODE_NAME <KEY>=<VALUE>:<EFFECT>
  • key: taint의 키 값을 설정한다. 임의의 문자열을 넣는다.
  • value: taint의 value값을 설정한다. 임의의 문자열을 넣는다.
  • effect:
    • PreferNoSchedule: 가장 약한 정책으로, taint된 노드에 더 이상 새로운 Pod을 스케줄링 하는 것을 지양한다. 그러나 다른 노드에 남은 리소스가 없다면 마지막으로 taint된 노드애 스케줄한다.
    • NoSchedule: taint된 노드에 더 이상 새로운 Pod을 스케줄링 하지 않는다.
    • NoExecute: 가장 강한 정책으로, taint된 노드에 새로운 Pod를 스케줄링 하지 못하게 막을 뿐만 아니라, 기존에 돌고 있던 Pod들도 제거한다.

Toleration

toleration의 사전적 의미는 ‘견디다, 용인하다’이다. 이는 Pod에 적용하는 설정값으로, Taint된 노드라 해도 Pod가 이를 용인 가능하면 스케줄링이 가능해진다.

종합해서 예제로 살펴보자.

먼저 Node에 taint를 적용한다.

# project=A 라는 taint를 설정하며 effect 종류로는 NoSchedule
kubectl taint node worker project=A:NoSchedule
apiVersion: v1
kind: Pod
metadata:
  name: tolerate
spec: 
  containers:
  - name: nginx
    image: nginx
  toleration:
  - key: "project"
    value: "A"
    operator: "Equal"
    effect: "NoSchedule"
  • toleration: toleration을 설정한다.
    • key: toleration의 키값을 설정한다. 이 키 값에 대해 tolerate하겠다는 의미이다.
    • value: taint된 value값에 대해서 tolerate하겠다는 의미이다.
    • operator: Equal, Exists 중 하나를 선택한다. Equal인 경우 key, value가 항상 동일해야하고, Exists인 경우 key의 값만 동일하면 tolerate한다.
    • effect: taint에 적용된 effect에 대해서 tolerate 하겠다는 것을 의미한다.

⚠️ 주의: 더 강한 규약을 effect에 명시하더라도, 그보다 약한 effect의 Taint를 자동으로 tolerate하지 않는다. 즉, effect: NoExecute를 toleration에 명시해도 NoSchedule이나 PreferNoSchedule Taint가 있는 노드에는 스케줄되지 않으므로, 각 effect마다 명시적으로 toleration을 추가해야 한다.

Taint가 적용된 노드를 **원래 상태(taint 제거)**로 되돌리려면, kubectl taint nodes 명령어에 **-(하이픈)**을 붙여서 Taint를 제거하면 된다.

kubectl taint node worker project-

Affinity & AntiAffinity

Taint & Toleration이 노드(Node) 가 주체가 되어 스케줄링을 막는 거였다면, Affinity & AntiAffinity는 파드(Pod) 가 주체가 된다.

💡 TaintToleration은 노드에 제약을 걸어 특정 조건의 파드만 스케줄되도록 제한하는 방식이다. 즉, 노드가 “나는 이런 조건의 파드만 받을 거야”라고 말하는 구조이다.
반면, AffinityAntiAffinity는 파드가 원하는 노드나 다른 파드에 대한 조건을 표현한다. 다시 말해, 파드가 “나는 이런 조건을 가진 노드에 배치되고 싶어” 혹은 “이런 파드랑은 같이 있고 싶지 않아”라고 말하는 방식이다.

  • Affinity:
    • NodeAffinity: 특정 Node와 가까지 할당되기 원할 때 사용한다.
    • PodAffinity: 특정 Pod끼리 가까이 할당되기 원할 때 사용한다.
  • AntiAffinity:
    • PodAntiAffinity: 특정 Pod끼리 멀리 할당되기 원할 때 사용한다.

NodeAffinity

Pod가 특정 Node에 할당되길 원할 때 사용한다. 앞서 Chapter5에서 살펴본 nodeSelector와 유사하지만 조금 더 상세한 설정이 가능하다.

apiVersion: v1
kind: Pod
metadata:
  name: node-affinity
spec:
  containers:
  - name: nginx
    image: nginx
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: disktype
            operator: In
            values:
            - ssd
  • affinity:

파드가 특정 노드 또는 다른 파드와의 관계에 따라 스케줄링되도록 제약 조건을 정의하는 섹션이다. 이 안에는 nodeAffinity, podAffinity, podAntiAffinity 설정이 올 수 있다.

  • nodeAffinity:

파드가 어떤 노드 조건을 만족하는 곳에만 스케줄되도록 하는 설정이다. 즉, 노드의 라벨을 기준으로 필터링을 걸 수 있다.

  • requiredDuringSchedulingIgnoredDuringExecution:

파드를 스케줄할 때 반드시 만족해야 하는 조건을 설정한다. 조건을 만족하는 노드가 없으면 파드는 Pending 상태에 머물게 된다. 다만, 스케줄 후에는 조건이 깨져도 파드는 계속 실행된다.

  • nodeSelectorTerms: 여러 조건 그룹을 OR 조건으로 설정할 수 있다. 각 항목은 하나의 조건 그룹을 의미하며, 여러 개가 있을 경우 하나라도 만족하면 OK다.
  • matchExpressions: 각 조건 그룹 안의 실제 조건을 정의하며, 여러 개일 경우 AND 조건으로 평가된다.
  • key: 조건에 사용할 노드 라벨의 key를 의미한다. 예: disktype
  • operator: 비교 연산자를 지정한다.
    • In: values 안에 있으면 true
    • NotIn, Exists, DoesNotExist 등도 있음
  • values: key와 함께 비교할 값 목록이다.
    • 예시: [“ssd”]
      • 이 경우 disktype=ssd 라벨이 붙은 노드에만 파드가 스케줄될 수 있다.

PodAffinity

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pod-affinity
spec:
  selector:
    matchLabels:
      app: affinity
  replicas: 2
  template:
    metadata:
      labels:
        app: affinity
    spec:
      containers:
      - name: nginx
        image: nginx
      affinity:
        podAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - affinity
            topologyKey: "kubernetes.io/hostname"
  • affinity.podAffinity:

이 파드를 특정 조건을 만족하는 다른 파드와 “같은 노드에” 배치하려는 의도를 나타냄. (반대로 podAntiAffinity는 “다른 노드에” 배치하려는 의도)

  • requiredDuringSchedulingIgnoredDuringExecution:

반드시 조건을 만족하는 경우에만 스케줄링이 가능함. 조건을 만족하는 파드가 없으면 스케줄되지 않고 Pending 상태가 됨. 한 번 스케줄되면 실행 중 조건이 깨져도 계속 실행됨.

  • labelSelector.matchExpressions:

기준이 되는 다른 파드의 라벨 조건을 지정함. 여기서는 app=frontend 라벨을 가진 파드가 존재해야 조건을 만족함.

  • topologyKey:

이 조건을 적용할 노드의 구분 기준을 설정. “kubernetes.io/hostname”이면 같은 노드를 의미함. “topology.kubernetes.io/zone”처럼 zone 단위로도 설정 가능. (GroupBy Key)

옵션 이름 설명 조건 미충족 시
requiredDuringSchedulingIgnoredDuringExecution 반드시 조건을 만족해야 파드가 스케줄됨 ❌ 스케줄 실패 (Pending 상태)
preferredDuringSchedulingIgnoredDuringExecution 조건을 만족하는 노드를 선호하지만, 만족하지 않아도 스케줄링 가능 ✅ 스케줄 성공 (조건 무시 가능)

💡topologyKey: 노드 라벨 중 하나를 사용한다. 단순히 노드를 기준으로 묶을 수도 있지만 사용자가 정의한 노드 라벨을 기준(topology)으로 묶을 수도 있다. 예시에서는 kubernetes.io/hostname 라벨로 각 노드 기준으로 묶는다.

topologyKey 의미
"kubernetes.io/hostname" 같은 노드
"topology.kubernetes.io/zone" 같은 가용 영역 (Zone)
"topology.kubernetes.io/region" 같은 리전 (Region)

PodAntiAffinity

PodAntiAffinity는 PodAffinity와 정반대 개념으로, 특정 라벨을 가진 파드와는 같은 노드(또는 같은 zone 등)에 배치되지 않도록 제약을 건다. 즉, “같이 있지 말자”는 의도를 파드가 표현하는 방식이다. 트래픽을 분산시키거나, 동일한 역할의 파드가 특정 노드에 몰리지 않도록 할 때 유용하다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pod-antiaffinity
spec:
  replicas: 3
  selector:
    matchLabels:
      app: antiaffinity
  template:
    metadata:
      labels:
        app: antiaffinity
    spec:
      containers:
      - name: nginx
        image: nginx
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - backend
            topologyKey: "kubernetes.io/hostname"
- affinity.podAntiAffinity:

해당 파드가 특정 조건을 가진 다른 파드와 떨어져 배치되도록 유도한다.

  • requiredDuringSchedulingIgnoredDuringExecution:

조건을 반드시 만족해야만 스케줄 가능하다. 조건을 만족하지 않으면 파드는 Pending 상태로 남는다. 스케줄링 이후 조건이 깨지더라도 계속 실행된다.

  • labelSelector.matchExpressions:

피해야 할 파드의 라벨 조건이다. 위 예제에서는 app=backend 라벨을 가진 다른 파드가 이미 있는 노드에는 배치되지 않도록 한다.

  • topologyKey:

파드를 분산할 범위를 정한다. “kubernetes.io/hostname”이면 노드 단위, “topology.kubernetes.io/zone”이면 zone 단위로 분산된다.

예시 활용 시나리오
Stateful한 파드 분산:

데이터베이스 파드나 Kafka 브로커처럼 장애 격리를 중요하게 여기는 경우, PodAntiAffinity로 노드 단위 분산을 강제함.

트래픽 분산:

동일한 서비스(예: app=frontend)가 특정 노드에 몰려 CPU/메모리 병목이 생기는 것을 방지할 수 있음.