Kubernetesのリソースリクエストとリミット

1. 概要

クラスター内の複数のPodを実行する際、各コンテナがどれだけのCPUとメモリを必要とするかをKubernetesに知らせる必要があります。宣言しない場合、あるサービスがノードのリソースをすべて占有し、他のサービスに影響を与える可能性があります。間違って宣言した場合、Podが絶えずOOMKilledされたり、スロットリングが原因で遅く実行されたりする可能性があります。

この記事では、requestsとlimitsが何かを説明し、Kubernetesがそれらをどのように使用するかを説明します。また、QoS classと、これらの値に合わせてJava/Spring Bootアプリケーションを設定する方法についても説明します。

2. Requestsとlimitsとは何か

Pod内の各コンテナは、2つのタイプの値を宣言できます。

  • requests: コンテナに保証されるリソースの最小量。スケジューラーはこの値を使用してPodを配置するノードを選択します。
  • limits: コンテナが使用できる最大レベル。Kubeletとコンテナランタイムはこのリミットをカーネルレベルで実施します。

最も一般的な2つのリソースタイプはcpuとmemoryです。それ以外にephemeral-storageやGPUなどの拡張リソースもあります。

2.1. CPUとメモリの単位

CPUはコアで計算されます。1は1つのvCPUを意味し、500m(500ミリコア)は0.5コアを意味します。メモリはバイト単位で計算されます。Kubernetesはバイナリ接尾辞(Ki、Mi、Gi)と10進接尾辞(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関数は、2文字の接尾辞(Mi)を1文字の接尾辞(M)の前にチェックします。なぜなら、"512Mi"も"i"で終わり、間違ってマッチしてはいけないからです。

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);
    }
}

2つの値は約24 MB異なります。リミットに近いコンテナの場合、この量はPodが存続するか、キルされるかを決めるのに十分です。

注意: memory: 512mと書かないでください。文字mは通常「ミリ」を意味するため、この値は0.512バイトです。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: スケジューラーは、リクエストされていない少なくとも0.5コアを持つノードにのみPodを配置します。
  • requests.memory: 512Mi: 同様に、メモリの場合。
  • limits.cpu: "1": コンテナは最大1コアを使用できます。このレベルを超えるとスロットリングされます。
  • limits.memory: 768Mi: このレベルを超えると、コンテナはカーネルによってキルされます。

マニフェストを適用して結果を確認します。

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. スケジューラーはrequestsをどのように使用するか

スケジューラーはノードの実際の使用レベルを見ません。ノード上にすでにある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%)

結果は2つのことを示しています。

  1. requestsの合計はallocatableの100%を超えることはありません。十分なスペースを持つノードがない場合、PodはPending状態にとどまり、Insufficient cpuまたはInsufficient memoryイベントが表示されます。
  2. limitsの合計は100%を超えることが許可されています。これをオーバーコミットと呼びます。オーバーコミットは、Podがリミットに同時に達しない場合は安全です。

結論:requestsを高すぎるに設定した場合、CPUが実際にはアイドル状態でもノードは「満杯」に見えます。低すぎるに設定した場合、多くのPodが1つのノードに押され、リソースを競い合います。

5. コンテナがリミットを超えたとき

CPUとメモリは2つの非常に異なる方法で処理されます。

5.1. CPU: スロットリングされる

CPUは圧縮可能なリソースです。コンテナが自身のクォータを使い果たすと、カーネル(cgroup CFS経由)はコンテナを一時的に停止し、次のサイクルまで待機します。通常、これは約100ms続きます。コンテナは死なず、ただ遅く実行されます。症状はp99レイテンシーが急上昇する一方で、kubectl topは問題なく見えます。

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はリミット1に達しているため、ほぼ確実にスロットリングされています。

5.2. メモリ: キルされる

メモリは圧縮不可能なリソースです。コンテナがlimits.memoryを超えると、カーネルのOOM killerはプロセスを即座に終了します。

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

終了コード137はプロセスがSIGKILL(128 + 9)を受け取ったことを意味します。アプリケーションはシャットダウンフックを実行したりログを記録したりする機会がありません。

別のケース:コンテナがrequests.memoryより多く、しかしリミット以下の量を使用しています。ノードがメモリ不足の場合、kubeletはリクエストされた正確な量を使用しているPodの前に、これらのPodを削除します。

6. QoS class

Requestsとlimitsの宣言方法に基づいて、Kubernetesは各Podにオブジェクトクラスを割り当てます。このクラスは、ノードがリソース不足の場合の削除順序を決定します。

QoS class 条件 削除順序
Guaranteed すべてのコンテナがCPUとメモリの両方でrequests == limitsを持つ 最後
Burstable 少なくとも1つのリクエストまたはリミットがあるが、Guaranteedを満たさない 中間
BestEffort コンテナが何も宣言しない 最初

上記のPod order-serviceはクラスBurstableに属しています。以下のコマンドで確認できます。

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

payment-serviceのような重要なサービスの場合、requestsをlimitsと同じに設定して、PodがクラスGuaranteedに属するようにできます。

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

limitsのみを宣言し、requestsを空にした場合、Kubernetesは自動的にrequestsをlimitsと同じに割り当てます。Podはその後、Guaranteedになります。

7. コンテナ内のJavaアプリケーション

Java 10以降、JVMはcgroupを認識します。Java 21はcgroup v1とv2の両方をサポートします。JVMはlimits.memoryを読み取ってヒープサイズを計算し、limits.cpuを読み取ってプロセッサー数を計算します。問題はデフォルト値にあります。最大ヒープはメモリリミットの**25%**のみです。アプリケーションはリクエストされたメモリのほとんどを浪費します。

JVMが実際に見るものをログに記録するために、Spring Bootに次のコンポーネントを追加します。

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);
    }
}

リミット768Miで追加設定なしの場合、ログは次のように出力されます。

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=192Mi

-Xmxをハードコーディングする代わりに、MaxRAMPercentageを使用してヒープ比率を調整する必要があります。

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

再デプロイ後:

INFO  ContainerResourceLogger : availableProcessors=1, maxHeap=576Mi

残りの25%はメタスペース、スレッドスタック、コードキャッシュ、直接バッファー、およびGC用に予約されています。この比率を90%以上に設定しないでください。ヒープはまだ満杯ではありませんが、プロセスの総メモリはすでにリミットを超えており、コンテナはログにOutOfMemoryErrorがなくてもOOMKilledになります。

注意: availableProcessorsはCPUリミットから計算され、その後切り上げられます。リミット500mは1プロセッサーを与えます。その場合、JVMは通常SerialGCを選択し、ForkJoinPool.commonPool()のようなスレッドプールは1つのワーカーのみを持ちます。アプリケーションが多くの並列スレッドが必要な場合は、これらの値を再度確認してください。

8. Namespaceのデフォルト値を設定する

BestEffort Podがクラスターに入るのを避けるために、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を宣言しないコンテナは、作成時に上記の値を自動的に受け取ります。

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またはメモリのクォータを持つ場合、対応するrequests/limitsが不足しているPodはAPIサーバーに拒否されます。これがLimitRangeとともに行うべき理由です。

9. よくあるエラーと使用すべきでない場合

  • 何も宣言しない。 PodはBestEffortになり、最初に削除され、スケジューラーは分配するデータを持っていません。
  • メモリリミットが起動時の必要量より低い。 Spring Bootは通常、コンテキスト初期化フェーズ中に最もメモリを使用します。番号を決める前に、kubectl topで実際の負荷サイクルを測定してください。
  • 「安全のため」requestsが高すぎる。 ノードがCPUがアイドル状態でも満杯に見え、クラスターオートスケーラーが不要なノードを追加して金を浪費します。
  • JVMアプリケーション向けのCPUリミットが厳しすぎる。 JITコンパイラーとGCはアプリケーションと並列に実行されるため、起動時に簡単にスロットリングされます。結果は起動が遅く、readiness probeが失敗します。
  • -Xmxをメモリリミットと正確に同じに設定する。 JVMがヒープ外でメモリを使用するため、コンテナは確実にOOMKilledになります。

注意: 多くのチームはレイテンシーに敏感なサービス向けにCPUリミットを設定しないことを選択し、CPUリクエストのみを設定します。コンテナはスロットリングされずにノードのアイドルCPUを使用し、リクエストは最小値を保証します。このアプローチは、複数のチーム間で共有されるクラスターには適さりません。なぜなら、リミットがなければ、1つの障害のあるサービスが他のすべてのサービスを低下させる可能性があるからです。逆に、常にメモリリミットを設定すべきです。メモリリミットがなければ、メモリリークがノード全体をメモリ不足に陥れる可能性があります。

10. 結論

この記事では、requestsがスケジューリングと保証されたリソースレベルを決定し、limitsが上限を決定することを見てきました。CPUリミットを超えるとコンテナがスロットリングされ、メモリリミットを超えるとOOMKilledになります。これら2つの値を組み合わせる方法でQoS classが作成され、QoS classはノードがリソース不足の場合の削除順序を決定します。

Javaアプリケーションでは、MaxRAMPercentageを使用してメモリリミットに応じてヒープを配分し、ヒープ外の部分に十分なスペースを確保してください。最後に、LimitRangeとResourceQuotaを使用して、namespace全体が同じ基準に従うようにしてください。最良の数値は常に実際の負荷を測定することから来ます。推測ではありません。

kubernetesresource-managementjvmdevopsspring-boot