A+B 複合 — ノードを大きくしたら coupon-api が遅くなり OOMKilled — os.cpu_count() はコンテナの CPU 上限を見ない(第10・5・3章 × CFS × requests/limits)

2026-10-10 土曜(Day 149) 土曜複合: コーディング × インフラ ★★★★★ gunicorn ワーカー数 / cgroup v2 cpu.max / Downward API GKE Standard / kind 1.37 で検証

概要

🖥️

cpu_count はノードの数

CPU 0.5 コアのコンテナで Bad の gunicorn は 5 ワーカーを起動(VM の 2 vCPU × 2 + 1)。Good は 1。

⏱️

CPU limit は周期ごとの予算

同じ 2 ms の仕事の p99: 並走 0 で 4.7 ms → 並走 16 で 192.6 ms(0.5 コア、実測)。

📏

保証された CPU とメモリの小さいほう

requests → limit → ホストの順で根拠を取り、メモリ ×0.75 ÷ 140 MiB で頭打ち。根拠を起動ログに出す。

🧪

入らない設定は起動時に落とす

19 テスト緑、変異 11 種は 1 回目で全部赤。WEB_CONCURRENCY=5 は「入るのは 2 まで」で起動失敗。

テーマ: gunicorn のワーカー数 × CFS の CPU 制限 × requests/limits の設計(「良いコード・悪いコードで学ぶ設計入門」第10章 マジックナンバーと早く失敗しない × 第5章 プリミティブ型執着 × 第3章 完全コンストラクタ / Kubernetes の CPU limit と CFS クォータ・Downward API・QoS / Go 1.25 の container-aware GOMAXPROCS との比較)

問題

MOps 販促システムチームの coupon-api は、カート画面からクーポンの適用可否を返す FastAPI のサービスで、GKE Standard の Deployment(6 Pod)上で gunicorn + uvicorn ワーカーとして動いている。9月15日、インフラ担当がノードプールを n2-standard-8(8 vCPU)から n2-standard-32(32 vCPU)に変えた(Pod の詰め込み効率を上げてノード台数を減らすため)。アプリのイメージと manifest は変えていない。

その後2週間で、次のことが分かった(本問の前提データ。実データではなく本問の設定値)。

観測変更前(9/1〜14)変更後(9/15〜28)出どころ
p99 レイテンシ120 ms410 msDataDog APM
Pod あたりのプロセス数1866kubectl exec ... ps
CFS で止められた周期の割合9%38%cAdvisor container_cpu_cfs_throttled_periods_total ÷ container_cpu_cfs_periods_total
OOMKilled(終了コード 137)014回/週(夕方のピーク帯)Kubernetes events
HPA のレプリカ数6〜9常に上限の 20HPA status
ピークのリクエスト1,800 rps同じロードバランサ

後輩から「CPU が足りずに止められていて、メモリも足りないので、CPU limit を 2→8、メモリ limit を 4Gi→12Gi に上げれば直ると思います」と提案が来た。あなたはシニアとして、gunicorn の設定コードと manifest の両方を直す設計を出す。

制約・前提条件

  • Python 3.12+(検証は 3.13.15 と 3.12 slim)、gunicorn 23、uvicorn 0.51、Kubernetes 1.30+(検証は kind の 1.37)、ノードは cgroup v2
  • 1 リクエストの CPU 時間は平均 4 ms(仮定)。ピーク 1,800 rps なら必要な CPU は 7.2 コア(導出)
  • 1 ワーカーの RSS は起動直後 45 MiB、ピーク帯で 140 MiB まで育つ(仮定)
  • アプリは numpy を import している。読み手はチームのエンジニア4名とインフラ担当1名

期待する回答形式: (1) 問題点の列挙(8点) (2) 改善後コード(Google スタイル docstring・名前付き定数・インラインコメント)と manifest (3) テスト(性質テストを含む)と効き目の確かめ方 (4) 実行例。「なぜ変更前は動いていたか」を数字で (5) 後輩の提案への回答と迷った案 (6) 見るもの・止め方 (7) 設計パターンと書籍の章

Bad → Good(対比)

コードと manifest に 8つの問題。左が Bad、右が Good の要点。
Bad: gunicorn_conf.py + Deployment
import multiprocessing

bind = "0.0.0.0:8080"
worker_class = "uvicorn_worker.UvicornWorker"
workers = multiprocessing.cpu_count() * 2 + 1
threads = multiprocessing.cpu_count()
timeout = 30
                                   
# Deployment
resources:
  requests: { cpu: 500m, memory: 1Gi }
  limits:   { cpu: "2", memory: 4Gi }
Good: cpu_budget.py + gunicorn_conf.py + Deployment
os.environ.update(native_thread_env())
lim = read_container_limits()
_plan = plan_workers(lim, explicit=WEB_CONCURRENCY)
workers = _plan.workers
max_requests = 5000; max_requests_jitter = 500  # RSS の育ちを戻す
log("worker_plan", asdict(_plan))  # cpu_source=host は WARNING

# Deployment
env: CPU_REQUEST_MILLI ← resourceFieldRef requests.cpu (1m)
resources:
  requests: { cpu: "2", memory: 1Gi }
  limits:   { memory: 1Gi }

注釈の ◀ #N は、問題点の番号に対応する。

設計の図(SVG)

① CPU limit 2 コア = 100 ms ごとに 200 ms 分の CPU 時間(CFS クォータ) Bad: 65 ワーカー × 32 コアで同時に走る 6.25 ms で尽きる残り 93.75 ms は全員停止(来たリクエストは待つ) 変更前: 17 ワーカー × 8 コア 25 ms で尽きる最大 75 ms 停止(9% の周期で起きていた) Good: 2 ワーカー、limit なし 止められない。GIL のため 2 プロセスは実質 2 コア強まで = プロセス数が上限の役 0 ms100 ms(1 周期) ② ワーカー数の決め方(plan_workers) CPU_REQUEST_MILLIDownward API(保証) cpu.maxクォータ ÷ 周期(罰則) process_cpu_countホスト(WARNING) 無ければ 無ければ ceil(空きがあれば超えて使える)floor(超えると止められる) memory.max × 0.75 ÷ 140 MiBby_memory(入り切る数) workers = min(by_cpu, by_memory) ≥ 1起動ログ: worker_plan {"workers":2,"cpu_source":"request","by_memory":5,"reason":"cpu"} WEB_CONCURRENCY > by_memory → ValueError(起動失敗)ピーク帯の OOMKilled を、デプロイ直後の起動失敗に変える(第10章)

パターン名: container-aware concurrency(Go 1.25 GOMAXPROCS・uber-go/automaxprocs と同じ問題)、fail fast、値オブジェクトで出どころを運ぶ。Go は limit だけを見て requests を見ない(リリースノート原文 "The Go runtime does not consider the "CPU requests" option.")。本解答は requests を先に見る。

ヒント(段階的開示)

ヒント1(方向性)

「ノードを変えただけで、なぜアプリの動きが変わったのか」から始める。コードの中でノードの大きさに依存している値を探す。multiprocessing.cpu_count() はコンテナの中で、コンテナに与えた CPU 上限(2 コア)とノードの論理 CPU 数(32)のどちらを返すか。

ヒント2(アプローチ)
  • CPU limit は CFS のクォータ。周期(既定 100 ms)ごとに「使ってよい CPU 時間」が決まり、使い切ると周期の残りは全プロセスが止められる。走れるプロセスが多いほど早く尽きる
  • コンテナの中では /sys/fs/cgroup/cpu.max(例 200000 100000)と memory.max が上限を教える
  • requests は「混雑しても保証される量」、limits は「超えたら止める(CPU)/殺す(メモリ)量」。HPA の使用率は requests に対する割合
  • requests は Downward API(resourceFieldRef)で環境変数として渡せる
  • ワーカー数は「CPU から見た数」と「メモリに入る数」の小さいほう。明示値が入らなければ起動時に落とす
ヒント3(誘導)
@dataclass(frozen=True, slots=True)
class ContainerLimits:
    cpu_limit: float | None      # cpu.max のクォータ ÷ 周期。"max" なら None
    cpu_request: float | None    # Downward API の requests.cpu
    memory_limit_bytes: int | None
    host_cpus: int

def plan_workers(limits, *, per_worker_mib=140, explicit=None) -> WorkerPlan:
    # CPU の根拠: requests → limit → ホスト(ホストなら警告)
    # by_memory = memory.max × (1 − 余白) ÷ 1ワーカーのピーク RSS
    # workers = min(by_cpu, by_memory)。explicit > by_memory なら ValueError
    ...

問題点分析(8点)

#問題数字
1cpu_count() はノードの論理 CPU 数を返す。3.13 の process_cpu_count() もアフィニティだけでクォータは見ない(docstring は両方 "Return the number of logical CPUs in the system."、3.14.7 で確認)導出 8 vCPU で 17、32 vCPU で 65。ps の 18 → 66 は master 1 を足した数と一致
2同期ワーカーの式(2n+1)を非同期ワーカーに当てている。GIL があり 1 プロセスはほぼ 1 コアlimit 2 なら必要なのは 2。32 倍の過剰
3メモリ上限と照合していない。起動時は入るが、ピークで RSS が育つと超える導出 起動 65 × 45 = 2,925 MiB(入る)、ピーク 65 × 140 = 9,100 MiB(入らない)。1 ワーカー平均 63.0 MiB を超えると OOMKilled。変更前 17 × 140 = 2,380 MiB は入っていた
4走れるプロセスが多いほどクォータは周期の頭で尽きる導出 32 コアで 200 ms 分が 6.25 ms で尽き、最大 93.75 ms 停止。8 コアなら 25 ms・最大 75 ms
5threads は uvicorn ワーカーでは効かない。本当にスレッドを増やすのはライブラリ: asyncio の既定 executor は min(32, process_cpu_count() + 4)、OpenBLAS はノードの CPU 数導出 最大 65 × 32 = 2,080 本ずつ
6HPA の使用率は requests 基準。requests が実使用より小さいと常に「使いすぎ」導出 1.2 コア ÷ 0.5 = 240%(目標 70%)→ 常に 20 Pod
7requests と limits が 4 倍離れている。スケジューラは requests で詰めるので、ノードのメモリを過剰に約束するピークで全 Pod が limit 近くまで使うと kubelet が退去させる
8ワーカー数の根拠がどこにも出ない。入らない設定も起動時に止まらず、ピーク帯に OOMKilled で出る(第10章)2週間「CPU 不足」と誤認された

改善後コード(Good)

構造は第13章(コマンド・クエリ分離)に沿って、読む(read_container_limits)/計算する(plan_workers、純粋関数)/副作用(gunicorn_conf: 環境変数とログ) の3つに分けた。生の int だった CPU 数は、出どころ付きの値オブジェクトにした(第5章・第3章)。

cpu_budget.py(抜粋、全文 190 行)

# 1 ワーカーのピーク RSS(MiB)。<仮定>: 本番の working set ÷ ワーカー数 の p99 で差し替える
DEFAULT_PER_WORKER_MIB = 140
# memory.max のうち、ワーカーに配らずに残す割合(master・ページキャッシュ・突発の確保)
MEMORY_HEADROOM_RATIO = 0.25
# uvicorn のワーカーは非同期。1 CPU に 1 プロセスが基本(2n+1 は同期ワーカー向けの式)
WORKERS_PER_CPU = 1
MIN_WORKERS = 1
NATIVE_THREAD_ENV = ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS")


@dataclass(frozen=True, slots=True)
class ContainerLimits:
    """コンテナから見える上限。None は「上限が設定されていない」。"""
    cpu_limit: float | None          # cpu.max のクォータ ÷ 周期
    cpu_request: float | None        # Downward API の requests.cpu(コア)
    memory_limit_bytes: int | None
    host_cpus: int                   # アフィニティ込みの論理 CPU 数(クォータは見ない)


@dataclass(frozen=True, slots=True)
class WorkerPlan:
    """起動時に1回だけ決め、ログに出す計画。"""
    workers: int
    cpu_budget: float
    cpu_source: str                  # "request" | "limit" | "host"(host は警告対象)
    by_cpu: int
    by_memory: int | None
    reason: str                      # "cpu" | "memory" | "explicit"


def _cpu_limit_v2(root: Path) -> float | None:
    raw = _read(root / "cpu.max")               # 例: "200000 100000" / "max 100000"
    if raw is None:
        return None
    quota, _, period = raw.partition(" ")
    if quota == "max":
        return None
    return int(quota) / int(period or 100000)   # 周期は 100ms とは限らない


def plan_workers(limits, *, per_worker_mib=DEFAULT_PER_WORKER_MIB, explicit=None) -> WorkerPlan:
    """ワーカー数を「保証された CPU」と「メモリに入り切る数」の小さいほうで決める。

    Raises:
        ValueError: 明示された数がメモリ上限に入らない、または 1 未満のとき。
            OOMKilled を本番のピークで起こすより、起動時に落ちるほうが原因が見える(第10章)。
    """
    if limits.cpu_request is not None:
        budget, source = limits.cpu_request, "request"
        by_cpu = max(MIN_WORKERS, math.ceil(budget * WORKERS_PER_CPU))    # 空きがあれば requests 超も使える
    elif limits.cpu_limit is not None:
        budget, source = limits.cpu_limit, "limit"
        by_cpu = max(MIN_WORKERS, math.floor(budget * WORKERS_PER_CPU))   # 超えると止められるので切り捨て
    else:
        budget, source = float(limits.host_cpus), "host"
        by_cpu = max(MIN_WORKERS, limits.host_cpus * WORKERS_PER_CPU)

    by_memory = None
    if limits.memory_limit_bytes is not None:
        usable = limits.memory_limit_bytes * (1 - MEMORY_HEADROOM_RATIO)
        by_memory = int(usable // (per_worker_mib * MIB))

    if explicit is not None:
        if explicit < MIN_WORKERS:
            raise ValueError(f"workers={explicit} は 1 未満")
        if by_memory is not None and explicit > by_memory:
            raise ValueError(f"workers={explicit} × {per_worker_mib}MiB はメモリ上限に入らない(入るのは {by_memory} まで)")
        return WorkerPlan(explicit, budget, source, by_cpu, by_memory, "explicit")

    if by_memory is not None and by_memory < MIN_WORKERS:
        raise ValueError(f"メモリ上限 {limits.memory_limit_bytes} では 1 ワーカーも入らない")
    workers = by_cpu if by_memory is None else min(by_cpu, by_memory)
    reason = "memory" if by_memory is not None and by_memory < by_cpu else "cpu"
    return WorkerPlan(workers, budget, source, by_cpu, by_memory, reason)

gunicorn_conf.py(Good)

os.environ.update(native_thread_env())          # ワーカー(子プロセス)に引き継がせる
_raw = os.environ.get("WEB_CONCURRENCY")
_plan = plan_workers(read_container_limits(), explicit=int(_raw) if _raw else None)

workers = _plan.workers                         # threads は書かない(uvicorn ワーカーでは効かない)
graceful_timeout = 25                           # terminationGracePeriodSeconds(30) より短く
max_requests = 5000                             # 断片化で RSS が育つのを定期的に戻す
max_requests_jitter = 500                       # 全ワーカーが同時に入れ替わらないように

def on_starting(server):
    level = logging.WARNING if _plan.cpu_source == "host" else logging.INFO
    server.log.log(level, "worker_plan %s", json.dumps(asdict(_plan)))

deployment.yaml(抜粋)

env:
  - name: CPU_REQUEST_MILLI            # cpu_budget が最初に見る値
    valueFrom:
      resourceFieldRef: { containerName: api, resource: requests.cpu, divisor: 1m }
resources:
  requests: { cpu: "2", memory: 1Gi }  # 実使用 1.2 コア ÷ 2 = 60%(HPA 目標 70% の内側)
  limits:   { memory: 1Gi }            # メモリは requests = limits。CPU limit は置かない

数字の根拠(導出): CPU requests 2 → 2 ワーカー。メモリ 1,024 × 0.75 ÷ 140 = 5.48 → 5 まで入る(実際は 2 × 140 = 280 MiB + master)。512Mi だと by_memory = 2 でぎりぎりなので採らなかった。CPU requests の合計は「HPA 張り付きの今」の 20 × 0.5 = 10 コアに対し 6 × 2 = 12 コア(+2)、メモリ requests は 20 Gi → 6 Gi。

テストと変異テスト

群何を守るか件数
上限の読み取りv2 の cpu.max(周期 50 ms も)、max=上限なし、v1 の -1 と巨大値=上限なし、v1 の置き場所2通り、Downward API のミリコア、ファイルが無くても落ちない7
計画32 vCPU でも limit 2 なら 2、requests が優先、端数の丸め、1 コア未満でも 1、メモリで頭打ち、host の記録、入らない明示値は失敗、入るなら尊重、0 は拒否、1 も入らなければ失敗10
性質270 組で常に 1 ≤ workers ≤ by_cpu かつ ≤ by_memory1
スレッドBLAS 系の環境変数が全部 11
$ python3 -m unittest -v test_cpu_budget      # テスト 19 件を実行する
Ran 19 tests in 0.013s
OK
$ python3 mutate.py                             # 本体を1か所ずつ壊し、テストが赤になるか見る
赤  M1 Bad と同じ: ホストの CPU 数×2+1  test_host_fallback_is_marked
赤  M2 limit を requests より先に見る  test_request_wins_over_limit
赤  M4 周期を 100000 決め打ち  test_v2_non_default_period
赤  M7 メモリで上限を掛けない  test_memory_caps_workers, test_workers_never_exceed_either_bound
赤  M8 明示値をメモリと照合しない  test_explicit_that_does_not_fit_memory_fails_at_startup
(11 種すべて 1 回目で赤)
全部赤は「テストが十分」の証明ではない。壊し方を選んだのもテストを書いた本人なので、観点が重なっている可能性がある。第三者が変異を足すと生き残る種が出うる。

実行例(input → output。実測)

① 本物の gunicorn(CPU 0.5 コア・メモリ 512 MiB のコンテナ、M1 Mac の Colima・VM 2 vCPU)

# Bad: Booting worker が 5 回(2 × 2 + 1。VM の 2 vCPU を見ている)
# Good:
[INFO] worker_plan {"workers": 1, "cpu_budget": 0.5, "cpu_source": "limit", "by_cpu": 1, "by_memory": 2, "reason": "cpu"}
[INFO] Booting worker (1 回)

$ docker run --cpus=0.5 --memory=512m -e WEB_CONCURRENCY=5 ... python3 cpu_budget.py
ValueError: workers=5 × 140MiB はメモリ上限に入らない(入るのは 2 まで)

② CFS の制限でどれだけ待たされるか(CPU 時間 2 ms の仕事 300 回の実時間。並走は busy loop。2 回測定)

cpu.max並走p50p99(1回目/2回目)止められた周期
0.5 コア02.2 ms4.7 / 13.5 ms0%
0.5 コア15.0 ms60.7 / 55.1 ms97〜99%
0.5 コア49.2 ms129.2 / 82.7 ms100%
0.5 コア1618.1 ms192.6 / 161.2 ms100%
2 コア14.5 ms51.3 / 9.9 ms0%

2 コア・並走 1 の p99 が 51.3 と 9.9 ms で大きくぶれた。VM が 2 vCPU しかなく、同じ Mac で kind も動いているため、CFS 以外の取り合いが混ざっている。傾向の確認であり、本番 32 vCPU ノードの値ではない。

③ kind(Kubernetes 1.37)の本物の Pod

# probe-limit(CPU limit 500m、requests の env なし)
{"os.cpu_count": 2, "cpu_limit": 0.5, "cpu_request": null, ..., "workers": 1, "cpu_source": "limit"}
# probe-request(CPU limit なし、requests 500m を Downward API で)
{"os.cpu_count": 2, "cpu_limit": null, "cpu_request": 0.5, ..., "workers": 1, "cpu_source": "request"}
deployment.apps/coupon-api created (server dry run)
probe-limit   Guaranteed
probe-request Burstable

④ なぜノード変更前は動いていたか(導出)

n2-standard-8(変更前)n2-standard-32(変更後)Good
ワーカー数17652
ピークのメモリ(× 140 MiB)2,380 MiB(4 Gi に入る)9,100 MiB(OOMKilled)280 MiB
クォータが尽きるまで25 ms6.25 ms尽きない
1 周期の最大停止75 ms93.75 ms0

変更前も「たまたま入っていた」だけで、設計としては同じ誤り(止められた周期 9% は変更前から出ていた)。

後輩の提案への回答と、迷った案

「CPU limit 2→8、メモリ limit 4Gi→12Gi」は採らない。 原因(ノードの大きさでワーカー数が決まる)に触れていない。limit 8 でも 65 ワーカーは 800 ms のクォータを 25 ms で使い切り最大 75 ms 止まる。n2-standard-64 にすれば 129 ワーカーで再発。メモリ 12 Gi は requests 1 Gi のままなので過剰な約束が 3 倍になり、OOMKilled が退去に変わるだけ。HPA の張り付きも直らない。ただし「止められている=CPU 上限が効いている」という観察は正しく、それをlimit を外しプロセス数を保証量に合わせる方向で使った。

案採らなかった理由前提が変わったら
CPU limit を残し requests = limits(Guaranteed)止められる周期は残る(GC・起動時のバースト)。Guaranteed の利点は退去されにくさで、今回の痛点ではない騒がしい隣人の被害が出たら。整数コア+CPU Manager static で専有コア
CPU limit を外す(採用)— 借りるのは空きだけで、他 Pod の requests 分は奪わない。2 プロセスは GIL で実質 2 コア強まで組織の方針で limit 必須なら limit = requests、ワーカーは floor(limit)
PYTHON_CPU_COUNT(3.13+)で上書き1 行で asyncio の executor にも効く(PYTHON_CPU_COUNT=3 → os.cpu_count() が 3 を確認)が、requests と二重管理。3.12 以前は効かない。端数を表せない全サービスが 3.13+ で、Downward API から直接入れる運用なら簡単
WEB_CONCURRENCY を manifest に書くgunicorn 23 は既定でこれを読む(ソースで確認)が、メモリと独立に書けて誤りが起動時に見つからない1 サービスだけで manifest の変更が必ずレビューされるなら十分
ランタイムに任せる(Go 1.25 型)Python は 3.14 時点でクォータを見ない。Go は limit だけを見て requests を見ないので、limit を外す設計と噛み合わないPython が cgroup を見るようになったら limit 側を任せる
1 Pod 1 ワーカーで HPA に任せるPod 数でしか CPU を使えず、master・サイドカーの固定費が倍Cloud Run のような 1 インスタンス 1 プロセス前提の基盤なら自然
VPA に requests を任せるワーカー数は起動時に決まる。in-place resize(1.33 で beta)では古い requests のまま入れるなら TTIN/TTOU シグナルで増減する仕組みが要る(発展問題)

直した後に見るもの・止め方

指標正常こうなったら止める/戻す
起動ログ worker_plan.cpu_source全 Pod で requesthost が1つでも出たら env が抜けている。リリースを止める
止められた周期の割合0%0% でなければどこかで limit が付いている(LimitRange の既定値を疑う)
p99 レイテンシ120 ms 前後戻らなければ CPU 以外(DB・外部 API)が原因。ワーカーを増やして解決しない
working set ÷ ワーカー数 の p99140 MiB 以下超えたら定数を実測値に。by_memory が自動で下がる
OOMKilled01 回でも出たら RSS の前提が崩れている
HPA のレプリカ数6〜8常に上限なら requests が小さすぎる

ロールバック: requests 2・CPU limit なしのまま 1.8.0 に戻すと、65 ワーカーが 32 コアを取り合う(悪化しうる)。manifest も一緒に戻す。

ポイント解説

  1. cpu_count はコンテナの上限を知らない: Go(1.25〜)・JVM・.NET はランタイムが cgroup を見るが、Python は自分で読む。知らないと同じイメージがノードによって別物になる
  2. CPU limit は周期ごとの予算: 平均使用率は低いのに p99 だけが悪化する(実測 4.7 → 192.6 ms)
  3. ワーカー数は保証された CPU とメモリの小さいほう: requests は保証、limit は罰則。数の根拠は保証のほう
  4. 入らない設定は起動時に落とす(第10章): ピーク帯の OOMKilled を、デプロイ直後の起動失敗に変える
  5. HPA の使用率は requests 基準: requests を小さくすると節約に見えて、実際は Pod が上限に張り付いて高くつく
パターン本解答の箇所章
マジックナンバーの名前付け* 2 + 1 → WORKERS_PER_CPU ほか(根拠と出所をコメントに)第10章
早く失敗する入らない明示値・1 も入らないメモリで ValueError第10章
プリミティブ型執着の解消生の int → 出どころ付きの ContainerLimits/WorkerPlan第5章
完全コンストラクタ・不変frozen=True, slots=True第3章・第4章
コマンド・クエリ分離読む/純粋な計算/副作用を分ける第13章

実務への応用

  • 他の Python サービス・ジョブを点検: cpu_count・引数なしの multiprocessing.Pool()/ProcessPoolExecutor()/ThreadPoolExecutor()・joblib n_jobs=-1 はどれもノードの CPU 数を使う
  • Argo Workflows のステップ: dbt や pandas の n_jobs=-1 は大きいノードに乗ったときだけ OOMKilled する
  • DataDog: worker_plan をファセットにし cpu_source:host を監視。止められた周期の割合をサービスごとに並べ、0% でないものを洗い出す
  • ノードプールの変更を「アプリに影響する変更」として扱う: レビューに「CPU 数に依存するアプリの一覧」を付ける
  • 費用: HPA 張り付きの解消で Pod 20 → 6〜8。詰め込み目的のノード変更の効果がようやく出る

扱わなかった範囲・一次資料

  • 本番規模(32 vCPU)での実測 — 手元は Colima(2 vCPU)と kind。傾向は実測したが本番の p99 は言えない
  • 本物の FastAPI アプリの負荷試験 — gunicorn は最小の ASGI アプリで起動数だけ確認。RSS の育ちは仮定
  • Pod 単位の resources・入れ子の cgroup — コンテナ自身の cpu.max だけを読む。親だけに上限があると host に落ちる
  • in-place resize・VPA、Cloud Run・Autopilot での見え方、本物の cgroup v1、CPU Manager static・NUMA — 未確認・範囲外
  • 世界の実物との対応: Go 1.25・JVM・automaxprocs・.NET が扱う中核概念を「cgroup v1/v2 の読み取り・クォータ ÷ 周期・上限なしの表現・端数の丸め・上限の変化への追従・手動の上書き」の6つと列挙すると、本解答は追従以外の5つを扱い、requests の参照とメモリとの照合を足した。6つは私の列挙で、各実装の文書から数えたものではない

一次資料(参照日 2026-10-10)

  • Go 1.25 Release Notes https://go.dev/doc/go1.25 — "The Go runtime does not consider the "CPU requests" option."
  • Python 3.14.7 の os.cpu_count.__doc__/os.process_cpu_count.__doc__、ThreadPoolExecutor の max_workers = min(32, (os.process_cpu_count() or 1) + 4)(手元のソースで確認。docs.python.org の取得は失敗)
  • gunicorn 23.0.0 config.py: workers の既定は WEB_CONCURRENCY、説明は「2-4 x $(NUM_CORES)」。(2 x $num_cores) + 1 は design 文書の推奨(今回は再取得に失敗)
  • Linux「CFS Bandwidth Control」、Kubernetes「Resource Management for Pods and Containers」「Downward API」「Pod QoS」— 再取得していない。挙動は kind 1.37 で確認した範囲に限る
  • 仙塲大也『良いコード/悪いコードで学ぶ設計入門』改訂新版 第3・4・5・10・13章

今日のまとめ

Python の cpu_count() はノードの CPU 数を返すので、コンテナでは「置かれたノード」でワーカー数が変わる。ワーカー数は保証された CPU(requests)とメモリに入る数の小さいほうで決め、入らなければ起動時に落とし、CPU limit は外して CFS の停止を避ける。

次のステップ: VPA(in-place resize)で requests が実行中に 2 → 3 に変わる。gunicorn の TTIN/TTOU でワーカー数を追従させる仕組みを、増やすときのメモリ照合・減らすときのドレイン・変化の検出方法で比べて設計せよ。

自己評価(あとで記入)