KubernetesのHorizontal Pod Autoscaler(HPA)をPID制御として実測する:比率制御とPIDのオーバーシュート・整定時間を比較する

KubernetesのHPA(Horizontal Pod Autoscaler)をフィードバック制御ループとして捉え、実際のスケーリング式(比率制御+不感帯+スケールダウン安定化ウィンドウ)をPythonでシミュレーションし、同じプラントを制御するPIDコントローラと比較。3倍トラフィックスパイクに対しHPAはオーバーシュート+4.2%・PIDは+33.3%、PIDはさらに自己誘発的な二次スパイク(CPU使用率99.6%)を起こすことを数値実験で確認します。

はじめに

KubernetesのHorizontal Pod Autoscaler(HPA)は、CPU使用率などのメトリクスに応じてPodのレプリカ数を自動調整する機能です。ドキュメントを読むと単なる「しきい値ベースのスケーリング」に見えますが、実はその中身はPID制御ナイキスト線図・安定余裕と同じフィードバック制御ループそのものです。

  • 測定CPU使用率 \(y(t)\) = 制御量(プロセス変数)
  • 目標CPU使用率 \(r(t)\) = 目標値(セットポイント)
  • レプリカ数 \(N(t)\) = 操作量

この対応関係に立てば、「HPAはなぜ振動するのか」「なぜスケールダウンが遅いのか」といった運用上よく聞かれる疑問を、制御工学の言葉(オーバーシュート・整定時間・定常偏差)で定量的に説明できます。

本記事では実際にKubernetesクラスタを動かすのではなく、HPAの制御アルゴリズムと制御対象(プラント)をPythonで離散時間シミュレーションし、同じプラントをPID制御器で制御した場合と比較します。すべての数値はこの記事のために実際に実行したコードの出力であり、仮定の値は含みません。

HPAの実際のアルゴリズム

HPAのスケーリング判断は、Kubernetes公式ドキュメントに明記されている次の式に基づきます。

\[ \text{desiredReplicas} = \left\lceil \text{currentReplicas} \times \frac{\text{currentMetricValue}}{\text{desiredMetricValue}} \right\rceil \]

これは「現在のレプリカ数」を「現在の使用率と目標使用率の比」でスケールする、比率ベースの制御則です。実装上は素朴なPID則ではなく、次の要素が加わります(いずれも公式ドキュメント記載のデフォルト値)。

  1. 不感帯(tolerance band)currentMetricValue / desiredMetricValue が \(1\) に十分近い(デフォルト \(\pm 10\%\) )場合はスケーリングを行わない。
  2. 同期周期:コントロールループは --horizontal-pod-autoscaler-sync-period(デフォルト \(15\,\mathrm{s}\) )ごとに実行される離散時間系。
  3. スケールダウン安定化ウィンドウ:スケールダウン方向の推奨値は、直近のウィンドウ(デフォルト \(300\,\mathrm{s}\) )内の推奨値の最大値を採用し、一時的な負荷低下による過剰な縮退(フラッピング)を防ぐ。スケールアップ側のウィンドウはデフォルト \(0\,\mathrm{s}\) (即時反映)。

以下、本記事のシミュレーションはこの3点を含めて実装します。ただしスケールアップ時のレート制限ポリシー(1周期あたり最大で「+4 Pod」または「倍増」のどちらか大きい方、というデフォルトポリシー)は簡略化のため近似的に再現し、Kubernetesのソースコードを1行1行移植したものではなく公式ドキュメントに記載されたアルゴリズムの忠実な再現であることを明記しておきます。

プラントモデル

制御対象は「レプリカ数 → 1レプリカあたりの負荷 → 測定CPU使用率」という因果関係を持ちます。

  • 総負荷 \(L(t)\) (レプリカ換算単位):平常時 \(L_0 = 4.0\) 、これは目標使用率 \(50\%\) のとき \(N_0 = \lceil L_0 / 0.5 \rceil = 8\) レプリカで釣り合うように選んだ値です。
  • 瞬時CPU使用率:\(u_{\text{inst}}(t) = 100 \cdot L(t)/N(t)\) (%、\(100\%\) 超も許容 — 実際のK8sでもrequestに対する使用率は超過しうる)
  • メトリクス遅延:metrics-serverの集計・スクレイプ遅延を模した一次遅れフィルタ(時定数 \(\tau = 60\,\mathrm{s}\) )。
  • 測定ノイズ:\(2\%\) の乗法的ガウスノイズ。

負荷は \(t=300\,\mathrm{s}\) (\(5\) 分)から\(900\,\mathrm{s}\) (\(15\) 分)まで平常時の3倍(\(L=12.0\) )にスパイクさせ、その後平常値に戻します。

Python実装:HPA比率制御

import numpy as np

dt = 15.0                    # HPA sync period (default 15s)
target_util = 50.0           # desiredMetricValue (%)
tolerance = 0.10              # default tolerance
scaledown_window_steps = int(300.0 / dt)   # default downscale stabilization = 300s
N_min, N_max = 2, 40
L0 = 4.0
N0 = int(np.ceil(L0 / (target_util / 100.0)))   # = 8
tau_metric = 60.0
alpha = dt / (tau_metric + dt)
noise_sigma = 0.02

def load_at(t):
    return 3.0 * L0 if 300.0 <= t < 900.0 else L0

def run_hpa(n_steps):
    N = N0
    L_filt = L0
    recommend_history = []
    Ns, Us = [], []
    for i in range(n_steps):
        t = i * dt
        L = load_at(t)
        L_filt += alpha * (L - L_filt)
        noise = 1.0 + np.random.normal(0, noise_sigma)
        U_meas = 100.0 * L_filt / N * noise

        ratio = U_meas / target_util
        if abs(ratio - 1.0) <= tolerance:
            desired = N                       # 不感帯:スケーリングしない
        else:
            desired = int(np.ceil(N * ratio))  # 比率ベースの式
        desired = int(np.clip(desired, N_min, N_max))
        recommend_history.append(desired)

        if desired > N:
            max_increase = max(4, N)          # スケールアップ: +4 or 倍増(大きい方)
            new_N = min(desired, N + max_increase)
        elif desired < N:
            recent = recommend_history[-scaledown_window_steps:]
            new_N = min(max(recent), N)        # スケールダウン安定化ウィンドウ
        else:
            new_N = N
        Ns.append(N); Us.append(U_meas)
        N = int(np.clip(new_N, N_min, N_max))
    return np.array(Ns), np.array(Us)

Python実装:同じプラントを制御するPID

比較対象として、同一のプラント・同一の負荷スパイクを、通常のPID制御でレプリカ数を決定した場合を実装します。この制御ループは逆作用系(操作量=レプリカ数を増やすと、出力=CPU使用率が下がる)である点に注意し、偏差を \(e(t) = y(t) - r(t)\) (測定値 − 目標値)と定義します。\(e(t)>0\) (過負荷)のときレプリカを増やす向きに操作量を加えます。

def run_pid(n_steps, Kp, Ki, Kd):
    N = float(N0)
    L_filt = L0
    integral = 0.0
    prev_e = 0.0
    Ns, Us = [], []
    for i in range(n_steps):
        t = i * dt
        L = load_at(t)
        L_filt += alpha * (L - L_filt)
        noise = 1.0 + np.random.normal(0, noise_sigma)
        N_int = max(N_min, int(round(N)))
        U_meas = 100.0 * L_filt / N_int * noise

        e = U_meas - target_util                     # 逆作用系: e>0 なら増強方向
        tentative_integral = integral + e * dt
        derivative = (e - prev_e) / dt
        u = Kp * e + Ki * tentative_integral + Kd * derivative
        N_unclipped = N + u
        N_new = float(np.clip(N_unclipped, N_min, N_max))
        if N_new == N_unclipped:
            integral = tentative_integral          # アンチワインドアップ

        Ns.append(N_int); Us.append(U_meas)
        prev_e = e
        N = N_new
    return np.array(Ns), np.array(Us)

PIDゲインは \(K_P, K_I, K_D\) をグリッドサーチして、オーバーシュート・振動振幅・定常偏差のバランスが最も良い組み合わせを選びました。最終的に採用したのは \(K_P=0.025,\ K_I=0.0002,\ K_D=0.02\) です。この過程で分かったのは、\(K_P\) を大きくする(例えば\(0.6\) )と即座に発散する、ということです。プラントの利得 \(\partial y/\partial N = -100L/N^2\) は動作点(\(N\) )によって大きく変わる強い非線形系のため、線形の固定ゲインPIDは非常に小さいゲインしか許容しません。

実験:3倍トラフィックスパイクへの応答

\(1\) 時間(\(3600\,\mathrm{s}\) 、\(240\) ステップ)のシミュレーションを実行し、\(5\) 分時点から\(10\) 分間、負荷を3倍にしました。

HPA比率制御とPID制御の応答比較

実行結果(乱数シード固定・同一負荷波形)は次の通りです。

=== HPA比率制御 ===
スパイク前CPU使用率: 49.28%(目標50%、オフセット -0.72pt), N=8
スパイク中ピークレプリカ数: 25(理論値24、オーバーシュート +4.2%)
スパイク立ち上がり時ピークCPU使用率: 72.1%
スパイク後の最小レプリカ数: 9、スパイク後最大CPU使用率: 44.7%
長期定常状態(t>=2000s): CPU使用率 平均44.61% (std 1.00)、オフセット -5.39pt、レプリカ数 最頻値9

=== PID制御 (Kp=0.025, Ki=0.0002, Kd=0.02) ===
スパイク前CPU使用率: 49.28%(目標50%、オフセット -0.72pt), N=8
スパイク中ピークレプリカ数: 32(理論値24、オーバーシュート +33.3%)
スパイク立ち上がり時ピークCPU使用率: 87.9%
スパイク後の最小レプリカ数: 4、スパイク後最大CPU使用率: 99.6%
長期定常状態(t>=2000s): CPU使用率 平均49.91% (std 1.73)、オフセット -0.09pt、レプリカ数 最頻値8

整定時間・振動振幅も比較すると次の通りです(スパイク開始からレプリカ数がスパイク区間内の最終値の\(\pm1\) 以内に収まるまでの時間を整定時間とした)。

指標HPA比率制御PID制御
ピークレプリカ数(理論値24)25(+4.2%)32(+33.3%)
整定時間(スパイク開始から)315 s(21ステップ)420 s(28ステップ)
振動振幅(スパイク後半)3レプリカ10レプリカ
スパイク後の最大CPU使用率44.7%99.6%
ベースライン復帰時間(スパイク終了後)480 s555 s

過渡応答の拡大:なぜPIDの方がオーバーシュートするのか

スパイク応答の拡大:オーバーシュートと整定時間

直感に反するかもしれませんが、この実験ではPID制御の方がHPAの比率制御よりもオーバーシュートが大きいという結果になりました。理由はプラントの非線形性にあります。

HPAの式 \(\text{desired} = \lceil N \cdot (y/r) \rceil\) は、現在のレプリカ数 \(N\) と現在の誤差比を掛け算することで補正量を決めます。これは動作点ごとにプラントの利得が変わる非線形系に対して、実質的に利得を自動調整(ゲインスケジューリング)する比例制御として働きます。一方、固定ゲインの線形PIDは、\(N=8\) 付近で安定するように調整したゲインを\(N=25\) 付近でもそのまま使うため、補正が過剰または過小になりやすく、スパイク立ち上がり時のピークCPU使用率もHPAの\(72.1\%\) に対しPIDは\(87.9\%\) まで悪化しました。

さらに深刻なのは、PIDがスパイク終了後にレプリカ数を\(4\) まで(ベースラインの\(N_0=8\) を大きく下回って)下げすぎてしまい、負荷が平常値に戻った瞬間にCPU使用率が**\(99.6\%\) **まで跳ね上がる「自己誘発的な二次スパイク」を起こしている点です。HPAの方はスケールダウン安定化ウィンドウのおかげでここまでの過剰な縮退は起きず、スパイク後の最大CPU使用率は\(44.7\%\) に留まりました。

定常状態の比較:不感帯 vs 積分項

一方でPIDに軍配が上がる点もあります。長期定常状態でのCPU使用率のオフセットを見ると、HPAは\(-5.39\,\mathrm{pt}\) の恒常的なズレ(レプリカ数は理論値\(8\) ではなく\(9\) で落ち着く)が残るのに対し、PIDはほぼ\(0\) (\(-0.09\,\mathrm{pt}\) )です。

これはPID制御の基礎理論で説明した「P制御には定常偏差が残るが、I(積分)項があれば解消される」という性質そのものです。HPAの\(\pm10\%\) 不感帯は、比較のためにいわば「粗いP制御+ヒステリシス」として働くため、目標値ぴったりには収束せず不感帯の縁で落ち着きます。積分項を持つPIDは、誤差が残る限り操作量を出し続けるため、最終的に定常偏差をゼロに近づけられます。

まとめ

  • KubernetesのHPAは、測定CPU使用率をプロセス変数、目標CPU使用率をセットポイント、レプリカ数を操作量とするフィードバック制御ループである。
  • HPAの比率ベースの式 \(\lceil N \cdot y/r \rceil\) は、動作点ごとに利得が変わる非線形プラントに対して自動的に利得を調整する、実質的なゲインスケジューリング付き比例制御として働く。
  • 3倍トラフィックスパイクの数値実験では、HPAのオーバーシュートは\(+4.2\%\) (ピーク\(25\) レプリカ、理論値\(24\) )にとどまったのに対し、グリッドサーチで調整した固定ゲインPIDは\(+33.3\%\) (ピーク\(32\) レプリカ)まで悪化し、スパイク後には自己誘発的な二次CPUスパイク(\(99.6\%\) )を引き起こした。
  • 一方でHPAの\(\pm10\%\) 不感帯は\(-5.39\,\mathrm{pt}\) の恒常的な定常偏差を残すのに対し、積分項を持つPIDはこれをほぼゼロ(\(-0.09\,\mathrm{pt}\) )にできる — これはPID制御の基礎で説明したP制御とPI制御の違いと同じ構図である。
  • 実運用でHPAのオーバーシュートやハンチングが気になる場合、behavior.scaleUp/scaleDown のポリシーやstabilizationWindowSecondsのチューニングは、本記事でいう安定余裕・整定時間のチューニングと同じ問題であると理解できる。

関連記事

参考文献


関連ツール