Kubernetes의 Resource requests와 limits

1. 개요

같은 cluster에서 많은 Pod을 실행할 때, 각 container가 얼마나 많은 CPU와 memory가 필요한지 Kubernetes에 알려야 합니다. 선언하지 않으면, 한 service가 node의 모든 리소스를 차지하여 다른 service에 영향을 미칠 수 있습니다. 잘못 선언하면, Pod이 계속 OOMKilled 또는 throttle로 인한 느린 실행이 될 수 있습니다.

이 글에서는 requests와 limits가 무엇인지 그리고 Kubernetes가 이들을 어떻게 사용하는지 설명합니다. 또한 QoS class와 이 값들에 맞게 Java/Spring Boot 애플리케이션을 구성하는 방법도 다룹니다.

2. Requests와 limits란 무엇인가

Pod의 각 container는 두 가지 유형의 값을 선언할 수 있습니다:

  • requests: container가 보장받는 최소 리소스 양입니다. Scheduler는 이 값을 사용하여 Pod을 배치할 node를 선택합니다.
  • limits: container가 사용할 수 있는 최대 수준입니다. Kubelet과 container runtime은 kernel 수준에서 이 제한을 적용합니다.

가장 흔한 두 가지 리소스 유형은 cpu와 memory입니다. 그 외에도 ephemeral-storage와 GPU 같은 extended resource가 있습니다.

2.1. CPU와 memory 단위

CPU는 core 단위로 계산됩니다. 1은 한 개의 vCPU를 의미하고, 500m(500 millicore)은 절반의 core를 의미합니다. Memory는 byte 단위로 계산됩니다. Kubernetes는 이진 접미사(Ki, Mi, Gi)와 십진 접미사(K, M, G) 모두를 허용합니다.

초보자는 종종 512M과 512Mi를 혼동합니다. 차이를 명확히 보기 위해, 이 값들을 변환하는 작은 클래스를 작성해봅시다:

import java.util.Map;

public final class ResourceQuantity {

    private static final Map<String, Long> SUFFIXES = Map.of(
            "Ki", 1024L,
            "Mi", 1024L * 1024,
            "Gi", 1024L * 1024 * 1024,
            "K", 1_000L,
            "M", 1_000_000L,
            "G", 1_000_000_000L
    );

    private ResourceQuantity() {
    }

    public static long cpuToMillicores(String value) {
        if (value.endsWith("m")) {
            return Long.parseLong(value.substring(0, value.length() - 1));
        }
        return Math.round(Double.parseDouble(value) * 1000);
    }

    public static long memoryToBytes(String value) {
        for (String suffix : new String[]{"Ki", "Mi", "Gi", "K", "M", "G"}) {
            if (value.endsWith(suffix)) {
                String number = value.substring(0, value.length() - suffix.length());
                return Long.parseLong(number) * SUFFIXES.get(suffix);
            }
        }
        return Long.parseLong(value);
    }
}

cpuToMillicores 메서드는 m 접미사가 있는 형식과 1.5 같은 십진수 형식을 처리합니다. memoryToBytes 메서드는 "512Mi"도 "i"로 끝나기 때문에 한 글자 접미사(M)보다 먼저 두 글자 접미사(Mi)를 확인합니다.

JUnit 5와 AssertJ를 사용한 테스트:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ResourceQuantityTest {

    @Test
    void givenMillicoreOrDecimal_whenConvert_thenReturnsMillicores() {
        assertThat(ResourceQuantity.cpuToMillicores("500m")).isEqualTo(500);
        assertThat(ResourceQuantity.cpuToMillicores("1.5")).isEqualTo(1500);
    }

    @Test
    void givenBinaryAndDecimalSuffix_whenConvert_thenValuesDiffer() {
        long mebibytes = ResourceQuantity.memoryToBytes("512Mi");
        long megabytes = ResourceQuantity.memoryToBytes("512M");

        assertThat(mebibytes).isEqualTo(536_870_912L);
        assertThat(megabytes).isEqualTo(512_000_000L);
        assertThat(mebibytes - megabytes).isEqualTo(24_870_912L);
    }
}

두 값의 차이는 약 24MB입니다. limit에 가까운 container의 경우 이 정도의 차이가 Pod의 생존 여부를 결정하기에 충분할 수 있습니다.

참고: memory: 512m으로 쓰지 마세요. m은 보통 “milli”를 의미하므로 이 값은 0.512 byte입니다. Kubernetes는 여전히 이를 수용하지만, 이 오류는 발견하기 매우 어렵습니다.

3. Deployment에서 선언하기

다음 예는 Spring Boot로 작성된 order-service를 사용합니다:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 2
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: app
          image: nonla/order-service:1.4.0
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "500m"
              memory: "512Mi"
            limits:
              cpu: "1"
              memory: "768Mi"

각 값의 의미:

  • requests.cpu: 500m: scheduler는 Pod을 최소 0.5 core가 남은 node에만 배치합니다.
  • requests.memory: 512Mi: 유사하게, 하지만 memory에 대해서입니다.
  • limits.cpu: "1": container는 최대 1 core를 사용할 수 있습니다. 이를 초과하면 throttle됩니다.
  • limits.memory: 768Mi: 이를 초과하면 container는 kernel에 의해 kill됩니다.

manifest를 적용하고 결과를 확인합니다:

kubectl apply -f order-service.yaml
kubectl get pod -l app=order-service -o jsonpath='{.items[0].spec.containers[0].resources}'
{"limits":{"cpu":"1","memory":"768Mi"},"requests":{"cpu":"500m","memory":"512Mi"}}

4. Scheduler는 requests를 어떻게 사용하는가

Scheduler는 node의 실제 사용량을 보지 않습니다. 이미 node에 있는 Pod의 총 requests를 더한 후 allocatable과 비교할 뿐입니다. 이 숫자를 확인할 수 있습니다:

kubectl describe node worker-1 | grep -A 8 "Allocated resources"
Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests      Limits
  --------           --------      ------
  cpu                3200m (80%)   6 (150%)
  memory             5Gi (66%)     9Gi (118%)

결과는 두 가지를 보여줍니다:

  1. 총 requests는 절대 allocatable의 100%를 초과하지 않습니다. 남은 공간이 충분한 node가 없으면, Pod은 Pending 상태에 있으며 Insufficient cpu 또는 Insufficient memory 이벤트가 발생합니다.
  2. 총 limits는 100%를 초과할 수 있습니다. 이를 overcommit이라고 합니다. Overcommit은 Pod들이 동시에 limit에 도달하지 않을 때 안전합니다.

결과: requests를 너무 높게 설정하면, 실제 CPU가 유휴 상태일 때에도 node가 “가득 찬” 것처럼 보입니다. 너무 낮게 설정하면, 많은 Pod이 한 node에 몰려 리소스를 놓고 경쟁하게 됩니다.

5. Container가 limit을 초과할 때

CPU와 memory는 두 가지 매우 다른 방식으로 처리됩니다.

5.1. CPU: throttle됨

CPU는 compressible 리소스입니다. Container이 quota를 모두 사용하면, kernel(cgroup CFS를 통해)은 container를 일시적으로 중지했다가 다음 사이클(보통 100ms)에 재개합니다. Container는 종료되지 않고, 단지 느리게 실행됩니다. 증상은 kubectl top이 정상으로 보일 때 latency p99이 급증하는 것입니다.

kubectl top pod -l app=order-service
NAME                             CPU(cores)   MEMORY(bytes)
order-service-7d9f8c6b5-k2xqp    998m         610Mi
order-service-7d9f8c6b5-tm4wz    412m         590Mi

첫 번째 Pod은 limit 1에 거의 도달했으므로, 거의 확실히 throttle되고 있습니다.

5.2. Memory: kill됨

Memory는 incompressible 리소스입니다. Container이 limits.memory를 초과하면, kernel OOM killer이 process를 즉시 종료합니다:

kubectl describe pod order-service-7d9f8c6b5-k2xqp | grep -A 4 "Last State"
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137
      Started:      Tue, 13 Oct 2026 09:12:03 +0700

Exit code 137은 process가 SIGKILL(128 + 9)을 받았다는 의미입니다. 애플리케이션은 shutdown hook을 실행하거나 로그를 기록할 기회가 없습니다.

또 다른 경우: container가 requests.memory보다 많이 사용하지만 여전히 limit보다 낮습니다. Node이 memory가 부족할 때, kubelet은 request 수준을 정확히 사용하는 Pod보다 먼저 이 Pod들을 evict합니다.

6. QoS class

requests와 limits을 선언하는 방식에 따라, Kubernetes는 각 Pod에 QoS class를 할당합니다. 이 class는 node이 리소스가 부족할 때 evict되는 순서를 결정합니다.

QoS class 조건 Evict 순서
Guaranteed 모든 container가 CPU와 memory 모두에서 requests == limits 마지막
Burstable 최소한 하나의 request 또는 limit가 있지만 Guaranteed를 만족하지 않음 중간
BestEffort container가 아무것도 선언하지 않음 먼저

위의 order-service Pod은 class Burstable에 속합니다. 다음 명령으로 확인할 수 있습니다:

kubectl get pod -l app=order-service -o jsonpath='{.items[*].status.qosClass}'
Burstable Burstable

payment-service 같은 중요한 service의 경우, requests을 limits과 같게 설정하여 Pod이 class Guaranteed에 속하도록 할 수 있습니다:

resources:
  requests:
    cpu: "1"
    memory: "1Gi"
  limits:
    cpu: "1"
    memory: "1Gi"

requests를 비우고 limits만 선언하면, Kubernetes는 자동으로 requests을 limits과 같게 할당합니다. 이 경우 Pod도 Guaranteed가 됩니다.

7. Container 내 Java 애플리케이션

Java 10 이상부터, JVM은 cgroup을 인식합니다. Java 21은 cgroup v1과 v2 모두를 지원합니다. JVM은 limits.memory를 읽어 heap 크기를 계산하고, limits.cpu를 읽어 processor 수를 계산합니다. 문제는 기본값에 있습니다: 최대 heap은 memory limit의 **25%**일 뿐입니다. 애플리케이션은 요청한 대부분의 memory를 낭비합니다.

JVM이 실제로 보는 것을 로깅하기 위해 Spring Boot에 다음 component를 추가합니다:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ContainerResourceLogger {

    private static final Logger log = LoggerFactory.getLogger(ContainerResourceLogger.class);

    @EventListener(ApplicationReadyEvent.class)
    public void logResources() {
        Runtime runtime = Runtime.getRuntime();
        long maxHeapMi = runtime.maxMemory() / (1024 * 1024);
        log.info("availableProcessors={}, maxHeap={}Mi",
                runtime.availableProcessors(), maxHeapMi);
    }
}

limit 768Mi에서 추가 설정이 없으면, 로그가 다음과 같이 출력됩니다:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

hard-code -Xmx 대신 MaxRAMPercentage를 사용하여 heap 비율을 조정해야 합니다:

          env:
            - name: JAVA_TOOL_OPTIONS
              value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"

redeploy 후:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

남은 25%는 metaspace, thread stack, code cache, direct buffer 및 GC를 위한 것입니다. 이 비율을 90% 이상으로 설정하지 마세요. Heap이 가득 차지 않아도 process의 총 memory가 limit을 초과하면, OutOfMemoryError가 로그에 없이 container가 OOMKilled됩니다.

참고: availableProcessors는 CPU limit에서 계산된 후 올림됩니다. limit 500m은 1 processor를 산출합니다. 이 경우 JVM은 보통 SerialGC를 선택하고, ForkJoinPool.commonPool() 같은 thread pool은 worker가 1개뿐입니다. 애플리케이션이 많은 병렬 thread가 필요하면, 이 값들을 다시 확인하세요.

8. Namespace에 기본값 설정하기

Pod BestEffort이 cluster에 들어오는 것을 방지하기 위해, LimitRange를 사용하여 namespace별로 기본값을 할당할 수 있습니다:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-resources
  namespace: shop
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: "200m"
        memory: "256Mi"
      default:
        cpu: "500m"
        memory: "512Mi"

resources를 선언하지 않은 container는 생성될 때 위의 값들을 자동으로 받게 됩니다.

추가로 ResourceQuota를 결합하여 전체 namespace이 요청할 수 있는 총 리소스를 제한할 수 있습니다:

apiVersion: v1
kind: ResourceQuota
metadata:
  name: shop-quota
  namespace: shop
spec:
  hard:
    requests.cpu: "8"
    requests.memory: "16Gi"
    limits.memory: "24Gi"

Namespace이 CPU 또는 memory에 대한 quota가 있을 때, 해당하는 requests/limits이 없는 Pod은 API server에 의해 거부됩니다. 이것이 LimitRange를 함께 사용해야 하는 이유입니다.

9. 흔한 실수 및 사용하지 말아야 할 경우

  • 아무것도 선언하지 않음. Pod이 BestEffort가 되어 먼저 evict되고, scheduler는 배분할 데이터가 없습니다.
  • Startup 시 필요한 것보다 낮은 memory limit. Spring Boot는 보통 context 초기화 단계에서 가장 많은 memory를 사용합니다. 실제 부하 주기를 통해 kubectl top으로 측정한 후에 값을 확정하세요.
  • “안전하게” requests을 너무 높게 설정. CPU가 유휴 상태일 때에도 node가 가득 차 보여, cluster autoscaler가 불필요한 node를 추가하고 비용이 들게 됩니다.
  • JVM 애플리케이션에 너무 타이트한 CPU limit. JIT compiler와 GC는 애플리케이션과 병렬로 실행되므로, startup 중에 쉽게 throttle됩니다. 결과는 느린 startup과 readiness probe 실패입니다.
  • -Xmx를 정확히 memory limit과 같게 설정. JVM은 heap 외에도 memory를 사용하므로, container는 확실히 OOMKilled됩니다.

참고: 많은 team은 latency에 민감한 service에 CPU limit을 설정하지 않기로 선택하고, CPU request만 설정합니다. 이 경우 container는 throttle되지 않으면서 node의 유휴 CPU를 사용할 수 있고, request는 여전히 최소값을 보장합니다. 이 방식은 여러 team이 공유하는 cluster에는 적합하지 않습니다. limit이 없으면, 한 service의 오류가 다른 service들을 느리게 만들 수 있기 때문입니다. 반대로, 항상 memory limit을 설정해야 합니다: memory limit이 없으면, 메모리 누수가 entire node을 메모리 부족 상태로 만들 수 있습니다.

10. 결론

이 글에서, requests는 scheduling과 보장되는 리소스 수준을 결정하고, limits는 상한을 결정한다는 것을 보았습니다. CPU limit을 초과하면 container가 throttle되고, memory limit을 초과하면 OOMKilled됩니다. 이 두 값을 결합하는 방식이 QoS class를 만들고, QoS class는 node이 리소스가 부족할 때 evict되는 순서를 결정합니다.

Java 애플리케이션의 경우, MaxRAMPercentage를 사용하여 heap을 memory limit에 비례시키고, non-heap 부분을 위한 충분한 공간을 남겨두세요. 마지막으로, LimitRange와 ResourceQuota를 사용하여 전체 namespace이 동일한 표준을 따르도록 하세요. 최적의 수치는 항상 추측이 아닌 실제 부하 측정에서 나옵니다.

kubernetes리소스-관리jvmdevopsspring-boot