概要
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 ms | 410 ms | DataDog APM |
| Pod あたりのプロセス数 | 18 | 66 | kubectl exec ... ps |
| CFS で止められた周期の割合 | 9% | 38% | cAdvisor container_cpu_cfs_throttled_periods_total ÷ container_cpu_cfs_periods_total |
| OOMKilled(終了コード 137) | 0 | 14回/週(夕方のピーク帯) | Kubernetes events |
| HPA のレプリカ数 | 6〜9 | 常に上限の 20 | HPA 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(対比)
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 }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)
パターン名: 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点)
| # | 問題 | 数字 |
|---|---|---|
| 1 | cpu_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 |
| 5 | threads は uvicorn ワーカーでは効かない。本当にスレッドを増やすのはライブラリ: asyncio の既定 executor は min(32, process_cpu_count() + 4)、OpenBLAS はノードの CPU 数 | 導出 最大 65 × 32 = 2,080 本ずつ |
| 6 | HPA の使用率は requests 基準。requests が実使用より小さいと常に「使いすぎ」 | 導出 1.2 コア ÷ 0.5 = 240%(目標 70%)→ 常に 20 Pod |
| 7 | requests と 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_memory | 1 |
| スレッド | BLAS 系の環境変数が全部 1 | 1 |
$ 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 | 並走 | p50 | p99(1回目/2回目) | 止められた周期 |
|---|---|---|---|---|
| 0.5 コア | 0 | 2.2 ms | 4.7 / 13.5 ms | 0% |
| 0.5 コア | 1 | 5.0 ms | 60.7 / 55.1 ms | 97〜99% |
| 0.5 コア | 4 | 9.2 ms | 129.2 / 82.7 ms | 100% |
| 0.5 コア | 16 | 18.1 ms | 192.6 / 161.2 ms | 100% |
| 2 コア | 1 | 4.5 ms | 51.3 / 9.9 ms | 0% |
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 | |
|---|---|---|---|
| ワーカー数 | 17 | 65 | 2 |
| ピークのメモリ(× 140 MiB) | 2,380 MiB(4 Gi に入る) | 9,100 MiB(OOMKilled) | 280 MiB |
| クォータが尽きるまで | 25 ms | 6.25 ms | 尽きない |
| 1 周期の最大停止 | 75 ms | 93.75 ms | 0 |
変更前も「たまたま入っていた」だけで、設計としては同じ誤り(止められた周期 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 で request | host が1つでも出たら env が抜けている。リリースを止める |
| 止められた周期の割合 | 0% | 0% でなければどこかで limit が付いている(LimitRange の既定値を疑う) |
| p99 レイテンシ | 120 ms 前後 | 戻らなければ CPU 以外(DB・外部 API)が原因。ワーカーを増やして解決しない |
| working set ÷ ワーカー数 の p99 | 140 MiB 以下 | 超えたら定数を実測値に。by_memory が自動で下がる |
| OOMKilled | 0 | 1 回でも出たら RSS の前提が崩れている |
| HPA のレプリカ数 | 6〜8 | 常に上限なら requests が小さすぎる |
ロールバック: requests 2・CPU limit なしのまま 1.8.0 に戻すと、65 ワーカーが 32 コアを取り合う(悪化しうる)。manifest も一緒に戻す。
ポイント解説
- cpu_count はコンテナの上限を知らない: Go(1.25〜)・JVM・.NET はランタイムが cgroup を見るが、Python は自分で読む。知らないと同じイメージがノードによって別物になる
- CPU limit は周期ごとの予算: 平均使用率は低いのに p99 だけが悪化する(実測 4.7 → 192.6 ms)
- ワーカー数は保証された CPU とメモリの小さいほう: requests は保証、limit は罰則。数の根拠は保証のほう
- 入らない設定は起動時に落とす(第10章): ピーク帯の OOMKilled を、デプロイ直後の起動失敗に変える
- 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章
今日のまとめ
cpu_count() はノードの CPU 数を返すので、コンテナでは「置かれたノード」でワーカー数が変わる。ワーカー数は保証された CPU(requests)とメモリに入る数の小さいほうで決め、入らなければ起動時に落とし、CPU limit は外して CFS の停止を避ける。次のステップ: VPA(in-place resize)で requests が実行中に 2 → 3 に変わる。gunicorn の TTIN/TTOU でワーカー数を追従させる仕組みを、増やすときのメモリ照合・減らすときのドレイン・変化の検出方法で比べて設計せよ。