Skip to content
ioob.dev
Go back

Kubernetes 입문 9편 — 리소스 관리와 오토스케일링

· 10분 읽기
Kubernetes 시리즈 (9/12)
  1. Kubernetes 입문 1편 — Kubernetes란
  2. Kubernetes 입문 2편 — 클러스터 구조
  3. Kubernetes 입문 3편 — Pod
  4. Kubernetes 입문 4편 — 컨트롤러
  5. Kubernetes 입문 5편 — 서비스와 네트워킹
  6. Kubernetes 입문 6편 — Ingress와 Gateway API
  7. Kubernetes 입문 7편 — ConfigMap과 Secret
  8. Kubernetes 입문 8편 — 스토리지: PV, PVC, StorageClass
  9. Kubernetes 입문 9편 — 리소스 관리와 오토스케일링
  10. Kubernetes 입문 10편 — RBAC과 보안: 최소 권한의 원칙
  11. Kubernetes 입문 11편 — 관측성: 로그, 메트릭, 추적
  12. Kubernetes 입문 12편 — Helm과 패키지 관리
Table of contents

Table of contents

리소스를 왜 명시해야 하나

쿠버네티스에 Pod를 띄우는 순간, 클러스터 스케줄러가 한 가지 질문을 던진다. “이 Pod를 어느 노드에 올려놓을까?”

답을 구하려면 Pod가 얼마나 자원을 쓸지 알아야 한다. CPU를 얼마나 요구하는지, 메모리는 몇 GB 필요한지. 이 정보가 없으면 스케줄러는 그냥 아무 노드에나 올리게 되고, 운이 나쁘면 한 노드에 자원 먹는 Pod들이 몰려서 서로 OOM으로 죽는 사태가 벌어진다.

그래서 쿠버네티스는 Pod 스펙에 resources를 쓰게 했다. 이 값이 스케줄링, OOM 처리, 오토스케일링의 기반이 된다. 잘못 설정하면 노드는 널널한데 Pod는 뻑뻑하게 도는 이상한 상황이 생긴다.

requests와 limits

리소스 명시는 두 축으로 이뤄진다.

apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  containers:
    - name: app
      image: myapp:1.0
      resources:
        requests:
          cpu: "250m"      # 0.25 vCPU
          memory: "256Mi"
        limits:
          cpu: "500m"      # 0.5 vCPU
          memory: "512Mi"

CPU는 m(millicore) 단위로 쓴다. 1000m이 1 vCPU다. 250m은 0.25코어라는 뜻이다. 메모리는 Mi(Mebibyte), Gi(Gibibyte)를 쓴다. M(Megabyte)과 Mi는 다르니(10진수 vs 2진수) 조심해야 한다. 보통은 Mi/Gi로 통일한다.

여기서 CPU와 메모리의 처리 방식이 다르다는 게 중요하다.

그래서 메모리 limit은 실제 사용량보다 여유 있게 잡는 게 안전하고, CPU limit은 신중해야 한다. 과하게 낮은 CPU limit은 GC나 순간적인 스파이크에서 애플리케이션을 느리게 만든다.

스케줄링에 어떻게 쓰이나

쿠버네티스 스케줄러는 Pod의 requests를 기준으로 “이 Pod를 받을 여유가 있는 노드”를 찾는다.

flowchart LR
    A[Pod 생성<br/>requests: 500m CPU / 1Gi Mem] --> B[스케줄러]
    B --> C{노드 1<br/>남은 공간<br/>200m / 512Mi}
    B --> D{노드 2<br/>남은 공간<br/>800m / 2Gi}
    B --> E{노드 3<br/>남은 공간<br/>1000m / 4Gi}
    C -.->|탈락| F[요청보다 부족]
    D --> G[선택 가능]
    E --> G
    G --> H[스코어링 후<br/>최종 노드 선정]

중요한 포인트 하나. 스케줄러는 실제 사용량이 아니라 requests의 합계로 판단한다. 노드에 4 vCPU가 있고 Pod들이 requests로 3.5 vCPU를 예약해뒀다면, 실제로는 CPU가 5% 밖에 안 써도 0.6 vCPU 이상 requests를 요구하는 Pod는 그 노드에 못 올라간다.

그래서 requests를 과하게 잡으면 노드 낭비가 생긴다. 반대로 너무 낮게 잡으면 실제로는 바쁜 노드에 Pod가 몰려서 전체 성능이 떨어진다. 모니터링으로 실제 사용량을 보면서 튜닝해야 하는 이유다.

QoS 클래스 — OOM 상황에서 누가 먼저 죽나

노드의 메모리가 부족해지면 커널이 OOM Killer를 발동시켜서 프로세스를 강제 종료한다. 쿠버네티스는 어떤 Pod를 먼저 죽일지 결정하기 위해 QoS 클래스를 부여한다.

분류 기준은 단순하다.

# Guaranteed
resources:
  requests:
    cpu: "500m"
    memory: "512Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

# Burstable
resources:
  requests:
    cpu: "250m"
    memory: "256Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

# BestEffort (resources 자체가 없음)

노드 메모리가 부족해지면 OOM 희생양을 고르는 순서는 이렇다.

flowchart TB
    A[노드 메모리 부족] --> B{BestEffort Pod 있나?}
    B -->|있음| C[먼저 제거]
    B -->|없음| D{Burstable 중<br/>limit 대비 사용량 높은 Pod 있나?}
    D -->|있음| E[제거]
    D -->|없음| F[Guaranteed는 최후에]

프로덕션 워크로드는 가능하면 Guaranteed로 운영하는 게 안전하다. 특히 DB나 캐시처럼 재시작 비용이 큰 Pod는 반드시 Guaranteed로 설정한다. 반대로 배치 작업이나 개발용 툴 Pod는 BestEffort로 놔둬도 무방하다. 자원이 부족하면 먼저 밀려나면서 중요한 워크로드를 살린다.

HPA — 수평 오토스케일링

트래픽이 늘어나면 Pod 개수를 늘리고, 줄어들면 다시 줄이는 게 HorizontalPodAutoscaler(HPA)의 역할이다. CPU 사용률 같은 지표를 기준으로 Deployment의 replicas를 자동 조정한다.

HPA 컨트롤러가 주기적으로 metrics-server를 조회해서 스케일 결정을 내리는 과정을 보자.

sequenceDiagram
    participant H as HPA Controller
    participant MS as metrics-server
    participant P as Pods
    participant D as Deployment
    loop 15초마다
        H->>MS: 현재 평균 CPU 사용률 조회
        MS->>P: kubelet 메트릭 수집
        P-->>MS: CPU: 700m (requests 500m → 140%)
        MS-->>H: 평균 사용률 140%
        H->>H: 140/70 = 2.0배 → 필요 replicas 계산
        H->>D: spec.replicas = N (스케일 업)
        D->>P: 새 Pod 생성
    end
    Note over H: scaleDown 요청은 5분 안정화 후 수행

HPA를 쓰려면 metrics-server가 클러스터에 설치되어 있어야 한다. 매니지드 쿠버네티스에는 대개 기본 포함이다.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: web-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
        - type: Percent
          value: 100
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60

설정을 풀어보면 이렇다.

여기서 중요한 것이 CPU 사용률 70%의 기준이다. HPA는 requests 대비로 계산한다. Pod의 CPU requests가 500m인데 실제 사용량이 350m이면 사용률은 70%다. 그래서 HPA를 제대로 쓰려면 Pod에 requests가 반드시 설정되어 있어야 한다.

커스텀 메트릭 기반 HPA

CPU나 메모리만으로 스케일링하면 부족한 경우가 많다. 비동기 워커는 큐 길이로 판단해야 하고, API 서버는 초당 요청 수(RPS)가 더 적합하다. HPA는 커스텀 메트릭 API를 통해 임의의 지표로도 스케일링할 수 있다.

metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "100"   # Pod당 평균 RPS 100이 되도록 유지

이 지표를 제공하려면 Prometheus Adapter 같은 컴포넌트를 설치해서 Prometheus 메트릭을 쿠버네티스 Custom Metrics API로 노출시켜야 한다. 처음엔 번거롭지만, 한번 세팅해두면 “지연시간이 늘어나면 Pod를 늘린다” 같은 실무적인 정책이 가능해진다.

VPA — 수직 오토스케일링

HPA가 Pod 개수를 늘리는 거라면, VerticalPodAutoscaler(VPA)는 Pod 하나의 크기(requests/limits)를 조정한다. “이 Pod는 250m CPU로 선언했는데 실제로는 늘 400m을 쓰더라. 그럼 requests를 올려줄게” 같은 방식이다.

HPA와 VPA가 같은 Pod를 다른 축으로 바꾸는 차이를 그림 한 장에 담아보자.

flowchart LR
    subgraph HPA_DEMO["HPA (수평)"]
        H1["Pod\n250m / 256Mi"] --> HOUT["Pod x3\n250m / 256Mi each"]
    end
    subgraph VPA_DEMO["VPA (수직)"]
        V1["Pod\n250m / 256Mi"] --> VOUT["Pod x1\n500m / 512Mi"]
    end

VPA는 기본 설치가 아니라 별도로 배포해야 한다. 동작 모드가 세 가지 있다.

모드동작
Off추천값만 계산하고 적용하지 않음 (분석용)
InitialPod 생성 시점에만 값을 설정
Auto실행 중인 Pod를 재생성하면서 값을 조정
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: web-vpa
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: web
  updatePolicy:
    updateMode: "Auto"
  resourcePolicy:
    containerPolicies:
      - containerName: '*'
        minAllowed:
          cpu: 100m
          memory: 128Mi
        maxAllowed:
          cpu: 2
          memory: 4Gi

여기서 주의할 점. VPA와 HPA를 같은 CPU/메모리 기준으로 동시에 쓰면 안 된다. 서로 상쇄되어서 이상한 스케일링 루프가 생긴다. HPA가 CPU 기준이면, VPA는 메모리만 조정하거나 Off 모드로 추천만 받는 식으로 써야 한다.

실무에서는 updateMode: "Off"로 VPA를 켜두고, 추천값을 참고해서 수동으로 requests를 조정하는 패턴이 흔하다. 리소스 낭비를 줄이는 데 유용하다.

LimitRange — 기본값과 최대값

네임스페이스 단위로 “이보다는 작게, 이보다는 크게 쓰지 마라”를 강제하는 게 LimitRange다.

apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: backend
spec:
  limits:
    - type: Container
      default:              # limits 기본값
        cpu: "500m"
        memory: "512Mi"
      defaultRequest:       # requests 기본값
        cpu: "100m"
        memory: "128Mi"
      max:
        cpu: "2"
        memory: "4Gi"
      min:
        cpu: "50m"
        memory: "64Mi"

이걸 걸어두면 개발자가 requests/limits를 안 쓰고 Pod를 띄워도 자동으로 기본값이 붙는다. 덕분에 BestEffort Pod가 마구 생기는 걸 막을 수 있다. 또 max를 넘는 요청은 아예 admission 단계에서 거부된다.

ResourceQuota — 네임스페이스 총량 제한

LimitRange가 Pod 단위 제한이라면, ResourceQuota는 네임스페이스 전체의 총량을 제한한다.

apiVersion: v1
kind: ResourceQuota
metadata:
  name: backend-quota
  namespace: backend
spec:
  hard:
    requests.cpu: "10"
    requests.memory: "20Gi"
    limits.cpu: "20"
    limits.memory: "40Gi"
    persistentvolumeclaims: "10"
    services.loadbalancers: "2"
    pods: "50"

백엔드 팀 네임스페이스는 requests 합계로 CPU 10 vCPU / 메모리 20Gi를 넘을 수 없다. LoadBalancer 타입 Service는 2개까지다. Pod는 50개까지.

이게 있으면 한 팀이 클러스터 리소스를 몽땅 쓰는 사태를 막을 수 있다. 반대로 “왜 Pod가 Pending이지?” 할 때 Quota 부족이 원인인 경우도 많으니, 트러블슈팅 관점에서도 알아둘 필요가 있다.

# 현재 사용량 확인
kubectl describe resourcequota -n backend

적정값을 어떻게 정하나

“그래서 requests와 limits를 얼마로 잡아야 하나?”는 영원한 숙제다. 공식은 없지만 실무에서 자주 쓰는 원칙이 몇 가지 있다.

  1. 처음엔 느슨하게 시작한다. 새 서비스는 사용량을 모르니 여유를 충분히 잡는다
  2. 부하 테스트 후 줄여나간다. Prometheus/Grafana로 실제 사용량을 관찰하며 조정
  3. P95 ~ P99 사용량을 기준 삼는다. 평균에 맞추면 스파이크 때 터진다
  4. limits는 requests의 1.5~2배 정도. JVM 앱은 Xmx를 limits의 70~80%로 맞춘다
  5. DB/캐시는 Guaranteed, 웹/API는 Burstable, 배치는 BestEffort로 분리

VPA를 Off 모드로 켜두면 이 작업이 훨씬 편해진다. 사용 패턴 기반으로 추천값을 꾸준히 만들어주기 때문이다.

실습 — HPA 동작 확인

간단한 부하 테스트로 HPA가 동작하는 걸 직접 눈으로 보자. 공식 문서의 php-apache 예제를 살짝 변형한 버전이다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cpu-load
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cpu-load
  template:
    metadata:
      labels:
        app: cpu-load
    spec:
      containers:
        - name: app
          image: k8s.gcr.io/hpa-example
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "100m"
            limits:
              cpu: "200m"
---
apiVersion: v1
kind: Service
metadata:
  name: cpu-load
spec:
  selector:
    app: cpu-load
  ports:
    - port: 80
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: cpu-load-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: cpu-load
  minReplicas: 1
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50

적용한 뒤, 다른 터미널에서 부하를 걸어본다.

kubectl apply -f hpa-demo.yaml

# 부하 생성 (종료는 Ctrl+C)
kubectl run -i --tty load --rm --image=busybox --restart=Never \
  -- /bin/sh -c "while sleep 0.01; do wget -q -O- http://cpu-load; done"

# 다른 터미널에서 HPA 상태 관찰
kubectl get hpa cpu-load-hpa --watch
# NAME           REFERENCE             TARGETS     MINPODS   MAXPODS   REPLICAS
# cpu-load-hpa   Deployment/cpu-load   0%/50%      1         10        1
# cpu-load-hpa   Deployment/cpu-load   180%/50%    1         10        1
# cpu-load-hpa   Deployment/cpu-load   180%/50%    1         10        4
# cpu-load-hpa   Deployment/cpu-load   120%/50%    1         10        8

부하가 시작되면 CPU 사용률이 타겟을 넘어가고, 얼마 뒤 replicas가 늘어나기 시작한다. 부하를 끊으면 5분의 stabilizationWindow 후에 다시 1개로 내려간다.

처음 오토스케일링을 구성할 때 이 간단한 예제를 꼭 한 번 돌려보자. “HPA는 이렇게 동작하는구나”라는 감이 잡힌다.


다음 편에서는 클러스터 내부의 보안을 다룬다. ServiceAccount가 뭔지, RBAC으로 권한을 어떻게 쪼개는지, NetworkPolicy로 Pod 간 통신을 어떻게 제한하는지 살펴본다.

10편: RBAC과 보안


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Kubernetes 입문 8편 — 스토리지: PV, PVC, StorageClass
Next Post
Kubernetes 입문 10편 — RBAC과 보안: 최소 권한의 원칙