概要
見せる数字と決める数字
配賦は「誰が使ったか」、回避可能原価は「止めたら減る額」。126.8 万円と 11.3 万円で 11 倍違う。
共通固定費は配らない
請求書の 71.5% は誰を止めても減らない。配るとスパイラルが起きる。
契約と階段
CUD 12 台分は 2029-03 まで払う。ノードは整数でしか減らない。判断に有効期限を書く。
遊休は別の行
GKE と同じく requests で配り、unallocated・overhead は溶かさずに見せる。
問題
MOps 販促システムチームは 6 つのサービスを 1 つの GKE Standard クラスタで動かし、BigQuery・Datadog・プラットフォーム SRE も共有している。後輩が「サービス別の採算」を出したところ、point-campaign(ポイント倍率キャンペーン配信)が赤字に見えたので停止の稟議を書いた。部長から「この数字で止めてよいか」と聞かれ、あなたがレビューする。
サービス 仮定
| サービス | Pod × requests | 月の HTTP リクエスト | 直接費 | 増分粗利 ± 標準誤差 |
|---|---|---|---|---|
| coupon-api | 14 × (1.0 vCPU, 2 GiB) | 182M | 38.0 万 | 900 ± 60 万 |
| recommend-api | 10 × (2.0, 8) | 60M | 52.0 万 | 450 ± 50 万 |
| mail-batch | 6 × (3.0, 6) | 0.2M | 21.0 万 | 300 ± 40 万 |
| point-campaign | 6 × (1.0, 2) | 90M | 11.0 万 | 90 ± 18 万 |
| segment-sync | 5 × (2.0, 8) | 5M | 26.0 万 | 120 ± 30 万 |
| banner-api | 8 × (1.0, 2) | 140M | 9.0 万 | 230 ± 30 万 |
| 共有 Pod(ingress・OTel) | 6 × (1.0, 4) | — | — | — |
制約・前提条件
- ノード n2-standard-8(allocatable 7.6 vCPU・28 GiB)が 12 台。3 年 CUD を 12 台分購入済み(2026-04〜2029-03、2.6 万円/台/月)。超過はオンデマンド 5.8 万円/台。Datadog 2,700 円/台 仮定
- 共通固定費(月): クラスタ管理料 1.1 万/BigQuery Editions ベースライン予約 180 万(1 年契約)/SRE 2 名 240 万/Datadog APM・ログ契約 60 万 仮定
- GKE cost allocation は使用量でなく resource requests で配り、
kube:unallocated・kube:system-overheadを別の行に出す 公式 docs.cloud.google.com/kubernetes-engine/docs/how-to/cost-allocations(参照日 2026-10-16) - FinOps Framework の Allocation: 初期段階では共通費を中央・プラットフォームの予算に直接置き、配れない費用の割合を KPI にする 公式 finops.org/framework/capabilities/allocation
期待する回答形式: 問題点 8 点/配賦・回避可能原価・死のスパイラルを数えるコードとテスト/実行例/推奨案と稟議の書き直し(showback の設計)/迷った案と前提が変わったときの判断/扱わなかった範囲
悪い稟議 (Before)
件名: point-campaign を 11 月末で停止する(月 36.8 万円の赤字、年 441 万円のコスト削減)
費用: 販促基盤の月額 672.5 万円(GKE ノード・管理料・BigQuery・Logging・Datadog・SRE 2 名)を各サービスの月間リクエスト数で配賦。point-campaign は 90M / 477.2M = 18.9% → 126.8 万円。
効果: ホールドアウトで測った増分粗利 90 万円/月。判定: 90 − 126.8 = −36.8 万円 → 停止。止めれば年 1,522 万円の費用が消える。今後も毎月この方法で配賦し、赤字のサービスを順に見直す。
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- 費用を 3 つに分ける: 直接費/ノード(台数でしか変わらない階段)/共通固定費
- ノードは Pod を詰め直して何台になるかで決まる。しかも CUD で 12 台分は約束済み
- 撤退判断は「増分粗利 − 回避可能原価」。配賦は別の道具
- 「毎月くり返す」をそのままシミュレーションすると、会社の利益はどう動くか
ヒント3 — 誘導
回避可能原価 = 請求書(全サービス) − 請求書(そのサービス以外)
請求書 = CUD 12 台 × 2.6万 + max(0, 台数 − 12) × 5.8万 + 台数 × 0.27万 + 共通固定費 + Σ直接費
台数 = Pod を First-Fit Decreasing で詰めた台数(CPU とメモリの両方)
止める判断 = 増分粗利 < 回避可能原価 のときだけ
showback = 直接費 + vCPU requests × vCPU 単価 + GiB requests × GiB 単価、
unallocated・overhead・共有 Pod・共通固定費は別の行問題点分析(8点)
| # | 問題点 | 分類 | 何が起きているか(出所つき) | 改善方法 |
|---|---|---|---|---|
| 1 | 配賦額を「止めれば消える費用」と読んだ | 判断 | 止めても請求書は 672.5 → 661.2 万円、11.3 万円しか減らない。増分粗利 90 万円を失い利益は月 −78.7 万円 <導出> | 撤退判断は回避可能原価(請求書の差分)で |
| 2 | 共通固定費を配った | 費用の分類 | 672.5 万円のうち 481.1 万円(71.5%)は管理料・BQ 予約・SRE・Datadog 契約 <仮定> | 配らず、プラットフォームの 1 行に(FinOps 初期段階の方針 <公式>) |
| 3 | 配賦基準がリクエスト件数 | 配賦基準 | GKE は requests で配る <公式>。CPU 7.9% のサービスに 18.9%。CPU・メモリ基準なら 47.8 万円 | vCPU・GiB の requests で |
| 4 | CUD のノードを回避可能とみなした | 契約 | 12 → 11 台に減っても CUD 12 台分は 2029-03 まで払う。浮くのは Datadog 2,700 円 | 契約で縛られた費用を外す。満了後は 17.1 万円(有効期限を書く) |
| 5 | ノード費を連続量で扱った | 階段 | ノードは整数でしか減らない。小さい Pod 1 つでは台数が変わらない構成もある(test_cost_is_a_step) | Pod を詰め直して台数を出す |
| 6 | 遊休と予約をサービスに混ぜた | 可視化 | unallocated 5.3・overhead 2.6・共有 Pod 2.2 万円が各サービスに溶けている | 別の行に出す。遊休は基盤チームの KPI |
| 7 | 毎月くり返すと死のスパイラル | 意思決定ルール | 翌月 banner-api が 239.1 > 230 で止まり、利益 1,417.5 → 1,118.0 万円(月 −299.5 万円) | 配賦結果を撤退基準にしない |
| 8 | 増分粗利の幅と代替案を見ていない | 不確実性 | 90 ± 18 万円。11.3 万円を下回るのは 4.4 標準誤差先。right-sizing を比べていない | 確率と分岐点(−87%)を添える |
「赤字 36.8 万円」の中身(point-campaign、万円/月)
| 項目 | 後輩の配賦 | 止めたら減る額 |
|---|---|---|
| 共通固定費 481.1 × 18.9% | 90.7 | 0 |
| ノード(CUD 12 台)+ Datadog 34.4 × 18.9% | 6.5 | 0.27(Datadog 1 台) |
| 全サービスの直接費 157.0 × 18.9% | 29.6 | 11.0(自分の直接費の全額) |
| 合計 | 126.8 | 11.3 |
費用の分け方・配賦と回避可能原価・死のスパイラル(SVG)
パターン: 貢献利益(セグメント・マージン)と回避可能原価による撤退判断 + requests ベースの showback(GKE cost allocation)。共通費の全額配賦を撤退基準にすると「死のスパイラル」になる、という管理会計の古典的な落とし穴。
Bad → Good(コード差分)
# Bad: 後輩の採算表(全額をリクエスト件数で配り、配賦額で撤退を決める)
pool = 6_725_350 # 請求書の全額。71.5% は誰を止めても減らない共通固定費
share = 90 / 477.2 # HTTP リクエスト件数の比。GKE は requests で配る
allocated = pool * share # 126.8 万円。遊休・予約・他人の BigQuery まで含む
if allocated > 900_000: # 増分粗利 90 万円と比べて「赤字」
stop("point-campaign") # 止めても請求書は 11.3 万円しか減らない
# 毎月くり返す → 翌月 banner-api に 239.1 万円が乗って止まる(月 −299.5 万円)# Good: 見せる数字と決める数字を分ける
def avoidable_cost_yen(services, name, cud_active=True):
rest = [s for s in services if s.name != name]
# 請求書の差分。ノードは Pod を詰め直した台数、CUD 分は払い続ける
return total_bill_yen(services, cud_active) - total_bill_yen(rest, cud_active)
keep = s.contribution_yen > avoidable_cost_yen(SERVICES, s.name) # 90 万 > 11.3 万 → 継続
sb = showback(SERVICES) # requests で配り、unallocated・overhead・共通固定費は別の行
# 判断の有効期限: CUD 満了(2029-03)で回避可能原価は 17.1 万円に変わる適用パターン: Value Object(Service を frozen dataclass で)+ 差分で原価を出す関数(avoidable_cost_yen)+ 突合せの不変条件(showback の合計 = 請求書)。書籍の章対応は無し(E の日のため)。
模範解答
配賦・回避可能原価・死のスパイラルを数えるコード(cost_alloc.py)
"""共有 GKE クラスタと共通費の配賦(showback)と、撤退判断に使う回避可能原価(Python 3.12+、標準ライブラリのみ)。
出所ラベル:
<公式> GKE cost allocation の文書(参照日 2026-10-16)
https://docs.cloud.google.com/kubernetes-engine/docs/how-to/cost-allocations
「requests ベースで配る」「kube:unallocated / kube:system-overhead を別に出す」
<仮定> ノード単価・CUD 台数・各サービスの requests・直接費・増分粗利など社内の数字
<導出> 上の 2 つから計算した値
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field, replace
# ---- ノードと単価(<仮定>。請求書 BigQuery エクスポートの実数を入れる場所) ----
NODE_CPU = 7.6 # n2-standard-8 の allocatable vCPU(system-overhead を引いた残り)
NODE_MEM = 28.0 # 同 GiB
NODE_RAW_CPU = 8.0
NODE_RAW_MEM = 32.0
ONDEMAND_NODE_YEN = 58_000 # 1 ノード 1 か月(730 時間)オンデマンド
CUD_NODE_YEN = 26_000 # 3 年 CUD で約束した 1 ノード分の月額。使わなくても払う
CUD_NODES = 12 # 2026-04 購入、2029-03 末まで(12 ノード分の vCPU・メモリを約束。ピークに合わせて買った)
DD_HOST_YEN = 2_700 # Datadog のホスト課金(ノード 1 台あたり)
CPU_COST_SHARE = 0.65 # ノード費のうち vCPU SKU の割合(残りが RAM)。N2 の vCPU:GiB 単価比から
# ---- 共通の固定費(月額、<仮定>)。サービスを 1 つ止めても請求額は変わらない ----
FIXED: dict[str, int] = {
"GKE クラスタ管理料": 10_950, # $0.10/時 × 730 × 150 円(<要確認>)
"BigQuery Editions ベースライン予約": 1_800_000, # 1 年契約
"プラットフォーム SRE 2 名(按分)": 2_400_000,
"Datadog APM・ログ基盤の契約": 600_000,
}
@dataclass(frozen=True)
class Service:
"""1 サービス。requests は GKE cost allocation が配る単位、直接費はラベルで請求書から取れる費用。"""
name: str
replicas: int
pod_cpu: float # 1 Pod の CPU requests(vCPU)
pod_mem: float # 1 Pod のメモリ requests(GiB)
monthly_requests_m: float # 月の HTTP リクエスト数(百万)。後輩の配賦基準
direct_yen: int # Cloud Logging・BQ オートスケール分・egress(ラベルで直接取れる)
contribution_yen: int # ホールドアウトで測った月の増分粗利(そのサービスが無いと消える利益)
contribution_sd: int = 0 # 増分粗利の標準誤差
@property
def cpu(self) -> float:
return self.replicas * self.pod_cpu
@property
def mem(self) -> float:
return self.replicas * self.pod_mem
SERVICES: list[Service] = [
Service("coupon-api", 14, 1.0, 2.0, 182, 380_000, 9_000_000, 600_000),
Service("recommend-api", 10, 2.0, 8.0, 60, 520_000, 4_500_000, 500_000),
Service("mail-batch", 6, 3.0, 6.0, 0.2, 210_000, 3_000_000, 400_000),
Service("point-campaign", 6, 1.0, 2.0, 90, 110_000, 900_000, 180_000),
Service("segment-sync", 5, 2.0, 8.0, 5, 260_000, 1_200_000, 300_000),
Service("banner-api", 8, 1.0, 2.0, 140, 90_000, 2_300_000, 300_000),
]
# 共有の Pod(ingress・OTel Collector など)。サービスではなくプラットフォームの持ち物
PLATFORM_PODS: list[tuple[float, float]] = [(1.0, 4.0)] * 6
# ---- ノード台数: Pod を詰めたときに何台要るか ----
def nodes_needed(services: list[Service]) -> int:
"""Pod を First-Fit Decreasing で詰めた台数。Cluster Autoscaler の近似。
ノードは整数でしか減らない(階段状の費用)。vCPU が 4 減っても 1 台減るとは限らない。
"""
pods = [(s.pod_cpu, s.pod_mem) for s in services for _ in range(s.replicas)] + PLATFORM_PODS
# 支配的な資源の割合が大きい順に詰める
pods.sort(key=lambda p: max(p[0] / NODE_CPU, p[1] / NODE_MEM), reverse=True)
free: list[list[float]] = []
for c, m in pods:
for f in free:
if f[0] >= c - 1e-9 and f[1] >= m - 1e-9:
f[0] -= c
f[1] -= m
break
else:
free.append([NODE_CPU - c, NODE_MEM - m])
return len(free)
def node_bill_yen(nodes: int, cud_active: bool = True) -> int:
"""ノード関連の月額。CUD 分は台数が下回っても払う。Datadog は実ノード台数に掛かる。"""
cud = CUD_NODES if cud_active else 0
ondemand = max(0, nodes - cud)
return cud * CUD_NODE_YEN + ondemand * ONDEMAND_NODE_YEN + nodes * DD_HOST_YEN
def total_bill_yen(services: list[Service], cud_active: bool = True) -> int:
"""この構成のときの請求書の合計(ノード+共通固定費+直接費)。"""
return (node_bill_yen(nodes_needed(services), cud_active)
+ sum(FIXED.values()) + sum(s.direct_yen for s in services))
def company_profit_yen(services: list[Service], cud_active: bool = True) -> int:
"""会社に残る月のお金 = 増分粗利の合計 − 請求書の合計。撤退判断はこれで比べる。"""
return sum(s.contribution_yen for s in services) - total_bill_yen(services, cud_active)
# ---- 後輩の配賦(Bad): 全費用をリクエスト件数で配る ----
def allocate_by_requests(services: list[Service]) -> dict[str, float]:
pool = total_bill_yen(services)
total = sum(s.monthly_requests_m for s in services)
return {s.name: pool * s.monthly_requests_m / total for s in services}
def allocate_full_by_requests_cpu_mem(services: list[Service]) -> dict[str, float]:
"""案A(採らない): 請求書の全額を CPU・メモリの requests で配り切る(フル配賦)。"""
pool = total_bill_yen(services)
cpu = sum(s.cpu for s in services)
mem = sum(s.mem for s in services)
return {s.name: pool * (CPU_COST_SHARE * s.cpu / cpu + (1 - CPU_COST_SHARE) * s.mem / mem)
for s in services}
# ---- Good の showback: requests ベース+遊休と共通費は別の行 ----
@dataclass
class Showback:
per_service: dict[str, float] = field(default_factory=dict)
unallocated: float = 0.0 # kube:unallocated(誰も requests していない容量)
overhead: float = 0.0 # kube:system-overhead(kubelet 等の予約)
platform_pods: float = 0.0 # 共有 Pod
fixed: float = 0.0 # 配らない共通固定費(予算はプラットフォームが持つ)
def showback(services: list[Service]) -> Showback:
"""GKE cost allocation と同じく requests で配り、遊休・予約・共通固定費は配らずに見せる。"""
n = nodes_needed(services)
node_cost = node_bill_yen(n)
cpu_yen = node_cost * CPU_COST_SHARE / (n * NODE_RAW_CPU) # 1 vCPU あたり
mem_yen = node_cost * (1 - CPU_COST_SHARE) / (n * NODE_RAW_MEM) # 1 GiB あたり
sb = Showback()
for s in services:
sb.per_service[s.name] = s.cpu * cpu_yen + s.mem * mem_yen + s.direct_yen
pcpu = sum(c for c, _ in PLATFORM_PODS)
pmem = sum(m for _, m in PLATFORM_PODS)
sb.platform_pods = pcpu * cpu_yen + pmem * mem_yen
sb.overhead = n * ((NODE_RAW_CPU - NODE_CPU) * cpu_yen + (NODE_RAW_MEM - NODE_MEM) * mem_yen)
used_cpu = sum(s.cpu for s in services) + pcpu
used_mem = sum(s.mem for s in services) + pmem
sb.unallocated = ((n * NODE_CPU - used_cpu) * cpu_yen + (n * NODE_MEM - used_mem) * mem_yen)
sb.fixed = sum(FIXED.values())
return sb
# ---- 撤退判断: 回避可能原価 ----
def avoidable_cost_yen(services: list[Service], name: str, cud_active: bool = True) -> int:
"""そのサービスを止めたら請求書がいくら減るか(=回避可能原価)。配賦額とは別物。"""
rest = [s for s in services if s.name != name]
return total_bill_yen(services, cud_active) - total_bill_yen(rest, cud_active)
def prob_keep_is_right(s: Service, avoidable: int) -> float:
"""増分粗利が回避可能原価を上回る確率(正規近似)。"""
if s.contribution_sd == 0:
return float(s.contribution_yen > avoidable)
return 0.5 * math.erfc((avoidable - s.contribution_yen) / (s.contribution_sd * math.sqrt(2)))
def death_spiral(services: list[Service]) -> list[tuple[str, int]]:
"""後輩のルール「配賦額 > 増分粗利なら止める」を毎月くり返したときの、止めた順と会社の利益。"""
active = list(services)
log: list[tuple[str, int]] = [("(開始)", company_profit_yen(active))]
while len(active) > 1:
alloc = allocate_by_requests(active)
losers = [s for s in active if alloc[s.name] > s.contribution_yen]
if not losers:
break
# 1 か月に 1 つ。配賦後の「赤字」が最も大きいものから止める
worst = max(losers, key=lambda s: alloc[s.name] - s.contribution_yen)
active = [s for s in active if s is not worst]
log.append((worst.name, company_profit_yen(active)))
return log
def rightsize(services: list[Service], name: str, pod_cpu: float, pod_mem: float) -> list[Service]:
"""止める代わりに requests を実測に合わせる案。"""
return [replace(s, pod_cpu=pod_cpu, pod_mem=pod_mem) if s.name == name else s for s in services]
def report() -> None:
svc = SERVICES
n = nodes_needed(svc)
print(f"ノード {n} 台(CUD {CUD_NODES}・オンデマンド {n - CUD_NODES}) 請求書 月 {total_bill_yen(svc) / 1e4:,.1f} 万円"
f" うち共通固定費 {sum(FIXED.values()) / 1e4:,.1f} 万円")
print(f"会社に残る利益 月 {company_profit_yen(svc) / 1e4:,.1f} 万円")
print()
bad = allocate_by_requests(svc)
sb = showback(svc)
full = allocate_full_by_requests_cpu_mem(svc)
print(f"{'サービス':16s} {'増分粗利':>8s} {'Bad配賦':>8s} {'Bad判定':>6s} {'案A全額':>8s} {'showback':>8s} {'回避可能':>8s} {'残す確率':>7s}")
for s in svc:
av = avoidable_cost_yen(svc, s.name)
verdict = "赤字" if bad[s.name] > s.contribution_yen else "黒字"
print(f"{s.name:16s} {s.contribution_yen / 1e4:8.1f} {bad[s.name] / 1e4:8.1f} {verdict:>6s}"
f" {full[s.name] / 1e4:8.1f} {sb.per_service[s.name] / 1e4:8.1f} {av / 1e4:8.1f} {prob_keep_is_right(s, av):7.1%}")
print(f"{'遊休 unallocated':16s} {'':8s} {'':8s} {'':6s} {'':8s} {sb.unallocated / 1e4:8.1f}")
print(f"{'system-overhead':16s} {'':8s} {'':8s} {'':6s} {'':8s} {sb.overhead / 1e4:8.1f}")
print(f"{'共有 Pod':16s} {'':8s} {'':8s} {'':6s} {'':8s} {sb.platform_pods / 1e4:8.1f}")
print(f"{'共通固定費':16s} {'':8s} {'':8s} {'':6s} {'':8s} {sb.fixed / 1e4:8.1f}")
total_sb = sum(sb.per_service.values()) + sb.unallocated + sb.overhead + sb.platform_pods + sb.fixed
print(f"showback の合計 {total_sb / 1e4:,.1f} 万円 = 請求書 {total_bill_yen(svc) / 1e4:,.1f} 万円")
print()
print("後輩のルールをくり返すと(死のスパイラル)")
for name, profit in death_spiral(svc):
print(f" 止める {name:16s} → 会社の利益 月 {profit / 1e4:,.1f} 万円")
print()
rest = [s for s in svc if s.name != "point-campaign"]
print(f"point-campaign を止める: ノード {n} → {nodes_needed(rest)} 台、請求書 −{avoidable_cost_yen(svc, 'point-campaign') / 1e4:.1f} 万円、"
f"利益 {company_profit_yen(svc) / 1e4:,.1f} → {company_profit_yen(rest) / 1e4:,.1f} 万円")
print(f"CUD 満了後(2029-04〜)の回避可能原価 {avoidable_cost_yen(svc, 'point-campaign', cud_active=False) / 1e4:.1f} 万円")
rs = rightsize(svc, "point-campaign", 0.5, 1.0)
print(f"止めずに requests を 1.0→0.5 vCPU・2→1 GiB: ノード {nodes_needed(rs)} 台、利益 {company_profit_yen(rs) / 1e4:,.1f} 万円")
if __name__ == "__main__":
report()
テスト(17 件、test_cost_alloc.py)
テストの全文を開く
"""cost_alloc の約束。各テストは「後輩の稟議のどの誤りを入れたら赤になるか」で書いた。"""
import unittest
import cost_alloc as ca
from cost_alloc import Service
SVC = ca.SERVICES
def tiny(name: str, replicas: int, cpu: float, mem: float, direct: int = 0, contrib: int = 0) -> Service:
return Service(name, replicas, cpu, mem, 1, direct, contrib)
class NodesTest(unittest.TestCase):
def setUp(self):
self._platform = ca.PLATFORM_PODS
ca.PLATFORM_PODS = []
def tearDown(self):
ca.PLATFORM_PODS = self._platform
def test_cpu_bound_packing(self):
# 7.6 vCPU のノードに 1 vCPU の Pod は 7 個まで → 8 個で 2 台
self.assertEqual(ca.nodes_needed([tiny("a", 7, 1.0, 1.0)]), 1)
self.assertEqual(ca.nodes_needed([tiny("a", 8, 1.0, 1.0)]), 2)
def test_memory_also_limits(self):
# CPU は余っていても 28 GiB を超えたら次のノード(CPU だけで台数を数えない)
self.assertEqual(ca.nodes_needed([tiny("a", 4, 0.5, 8.0)]), 2)
def test_cost_is_a_step(self):
# 小さい Pod を 1 つ消しても、ノードは減らない
base = [tiny("big", 6, 1.2, 4.0), tiny("small", 1, 0.2, 0.5)]
self.assertEqual(ca.nodes_needed(base), ca.nodes_needed(base[:1]))
class BillTest(unittest.TestCase):
def test_cud_is_paid_even_when_nodes_shrink(self):
# 約束した 12 台分は 11 台に減っても払う。Datadog は実台数
self.assertEqual(ca.node_bill_yen(11), 12 * ca.CUD_NODE_YEN + 11 * ca.DD_HOST_YEN)
def test_ondemand_above_cud(self):
self.assertEqual(ca.node_bill_yen(14) - ca.node_bill_yen(13), ca.ONDEMAND_NODE_YEN + ca.DD_HOST_YEN)
def test_after_cud_expiry_all_ondemand(self):
self.assertEqual(ca.node_bill_yen(11, cud_active=False), 11 * (ca.ONDEMAND_NODE_YEN + ca.DD_HOST_YEN))
class AllocationTest(unittest.TestCase):
def test_bad_allocation_sums_to_bill_and_flags_point_campaign(self):
bad = ca.allocate_by_requests(SVC)
self.assertAlmostEqual(sum(bad.values()), ca.total_bill_yen(SVC), places=3)
self.assertGreater(bad["point-campaign"], 900_000) # 後輩の「赤字」の再現
def test_showback_reconciles_to_bill(self):
sb = ca.showback(SVC)
total = sum(sb.per_service.values()) + sb.unallocated + sb.overhead + sb.platform_pods + sb.fixed
self.assertAlmostEqual(total, ca.total_bill_yen(SVC), places=3)
def test_idle_and_overhead_are_not_spread_to_services(self):
sb = ca.showback(SVC)
self.assertGreater(sb.unallocated, 0)
n = ca.nodes_needed(SVC)
cpu_yen = ca.node_bill_yen(n) * ca.CPU_COST_SHARE / (n * ca.NODE_RAW_CPU)
mem_yen = ca.node_bill_yen(n) * (1 - ca.CPU_COST_SHARE) / (n * ca.NODE_RAW_MEM)
s = next(x for x in SVC if x.name == "recommend-api") # メモリが重いサービスで RAM 単価も見る
self.assertAlmostEqual(sb.per_service[s.name], s.cpu * cpu_yen + s.mem * mem_yen + s.direct_yen, places=3)
def test_overhead_is_reserved_capacity(self):
sb = ca.showback(SVC)
self.assertGreater(sb.overhead, 0)
class DecisionTest(unittest.TestCase):
def test_avoidable_today_is_direct_plus_datadog(self):
# CUD がノード 12 台を覆っているので、1 台減っても浮くのは Datadog だけ
self.assertEqual(ca.avoidable_cost_yen(SVC, "point-campaign"), 110_000 + ca.DD_HOST_YEN)
def test_avoidable_grows_after_cud_expiry(self):
self.assertEqual(ca.avoidable_cost_yen(SVC, "point-campaign", cud_active=False),
110_000 + ca.DD_HOST_YEN + ca.ONDEMAND_NODE_YEN)
def test_stopping_point_campaign_loses_money(self):
rest = [s for s in SVC if s.name != "point-campaign"]
self.assertEqual(ca.company_profit_yen(SVC) - ca.company_profit_yen(rest), 900_000 - 112_700)
def test_death_spiral_takes_banner_next(self):
log = ca.death_spiral(SVC)
self.assertEqual([n for n, _ in log[1:]], ["point-campaign", "banner-api"])
profits = [p for _, p in log]
self.assertEqual(profits, sorted(profits, reverse=True))
# (90万 − 11.27万) + (230万 − 直接費 9万 − Datadog 1 台 0.27万) = 299.46万円/月 を失う
self.assertEqual(profits[0] - profits[-1], 2_994_600)
def test_spiral_stops_one_service_per_month(self):
# banner-api の増分粗利を 140 万に下げると 1 か月目から 2 つ「赤字」。1 か月に止めるのは最大の 1 つだけ
from dataclasses import replace
svc = [replace(s, contribution_yen=1_400_000) if s.name == "banner-api" else s for s in SVC]
log = ca.death_spiral(svc)
self.assertEqual(log[1][0], "banner-api")
self.assertEqual(len(log), 3)
def test_prob_keep(self):
s = next(x for x in SVC if x.name == "segment-sync")
p = ca.prob_keep_is_right(s, ca.avoidable_cost_yen(SVC, s.name))
self.assertGreater(p, 0.99)
self.assertLess(ca.prob_keep_is_right(s, 1_500_000), 0.5) # 増分粗利より高い費用なら半分未満
def test_full_allocation_is_fragile_for_segment_sync(self):
# 案A(全額を requests で配る)でも segment-sync は増分粗利の 85% まで配られる
full = ca.allocate_full_by_requests_cpu_mem(SVC)
self.assertAlmostEqual(sum(full.values()), ca.total_bill_yen(SVC), places=3)
self.assertGreater(full["segment-sync"] / 1_200_000, 0.8)
if __name__ == "__main__":
unittest.main()変異テスト(mutate.py)
赤 M1 撤退判断に配賦額を使う(後輩案) test_avoidable_grows_after_cud_expiry, test_avoidable_today_is_direct_plus_datadog
赤 M2 CUD を止めれば消える費用とみなす test_cud_is_paid_even_when_nodes_shrink, test_avoidable_today_is_direct_plus_datadog, test_death_spiral_takes_banner_next, test_stopping_point_campaign_loses_money
赤 M3 ノードを CPU だけで数える test_memory_also_limits
赤 M4 遊休をサービスに混ぜる(使った vCPU で割る) test_idle_and_overhead_are_not_spread_to_services, test_showback_reconciles_to_bill
赤 M5 Datadog を固定費扱い test_after_cud_expiry_all_ondemand, test_cud_is_paid_even_when_nodes_shrink, test_ondemand_above_cud, test_avoidable_grows_after_cud_expiry, test_avoidable_today_is_direct_plus_datadog, test_death_spiral_takes_banner_next, test_stopping_point_campaign_loses_money
赤 M6 確率の向きを逆に test_prob_keep
赤 M7 詰めるときメモリを見ない test_memory_also_limits
赤 M8 showback に直接費を入れない test_idle_and_overhead_are_not_spread_to_services, test_showback_reconciles_to_bill
赤 M9 system-overhead を 0 にする test_showback_reconciles_to_bill
赤 M10 RAM 単価に CPU の割合を掛ける test_idle_and_overhead_are_not_spread_to_services, test_showback_reconciles_to_bill
赤 M11 1 か月に赤字を全部止める test_spiral_stops_one_service_per_month
実行例
$ python3 cost_alloc.py
ノード 12 台(CUD 12・オンデマンド 0) 請求書 月 672.5 万円 うち共通固定費 481.1 万円
会社に残る利益 月 1,417.5 万円
サービス 増分粗利 Bad配賦 Bad判定 案A全額 showback 回避可能 残す確率
coupon-api 900.0 256.5 黒字 111.6 42.1 38.5 100.0%
recommend-api 450.0 84.6 黒字 203.9 59.2 52.8 100.0%
mail-batch 300.0 0.3 黒字 143.5 26.3 21.5 100.0%
point-campaign 90.0 126.8 赤字 47.8 12.8 11.3 100.0%
segment-sync 120.0 7.0 黒字 101.9 29.6 26.3 99.9%
banner-api 230.0 197.3 黒字 63.8 11.4 9.3 100.0%
遊休 unallocated 5.3
system-overhead 2.6
共有 Pod 2.2
共通固定費 481.1
showback の合計 672.5 万円 = 請求書 672.5 万円
後輩のルールをくり返すと(死のスパイラル)
止める (開始) → 会社の利益 月 1,417.5 万円
止める point-campaign → 会社の利益 月 1,338.7 万円
止める banner-api → 会社の利益 月 1,118.0 万円
point-campaign を止める: ノード 12 → 11 台、請求書 −11.3 万円、利益 1,417.5 → 1,338.7 万円
CUD 満了後(2029-04〜)の回避可能原価 17.1 万円
止めずに requests を 1.0→0.5 vCPU・2→1 GiB: ノード 11 台、利益 1,417.7 万円
- 配賦額(126.8 万円)と回避可能原価(11.3 万円)は 11 倍違う
- 2 か月目、banner-api への配賦は 197.3 → 239.1 万円に増える。banner-api 自身は何も変わっていない
- 案A(全額を requests で配る)は point-campaign を黒字と判定するが、segment-sync は増分粗利の 85% まで配られていて脆い
- requests の削減はノード 1 台を空けるが、CUD 分は払うので今は利益 +0.27 万円だけ
推奨案と稟議の書き直し
件名: point-campaign は継続。サービス別の費用は「showback」と「撤退判断」を分けて出す
結論 止めると請求書は月 11.3 万円しか減らず、増分粗利 90 万円を失う(利益 −78.7 万円/月)。止めるのは増分粗利が 11.3 万円(CUD 満了後は 17.1 万円)を下回ったとき。今の測定(90 ± 18 万円)ではその確率はほぼ 0。
理由 配賦した 126.8 万円の 7 割は誰を止めても減らない共通固定費。ノードは CUD 12 台分を 2029-03 まで払う。毎月「配賦 > 増分粗利 で停止」をくり返すと翌月 banner-api が止まり、利益は月 299.5 万円(年 3,594 万円)減る。
showback(毎月) GKE cost allocation を有効にし課金エクスポートから namespace 別に。各サービス = 直接費+ vCPU・GiB の requests 分。unallocated・overhead・共有 Pod・共通固定費は「プラットフォーム」の行に配らず出す。KPI は遊休の割合(今 15%)と、ラベルで取れない費用の割合。showback は採算の判定に使わないと明記する。
撤退・縮小の判断(四半期) 増分粗利(ホールドアウト)− 回避可能原価(契約の残期間込み)。CUD 満了・BQ 予約の更新・SRE 体制の変更で見直す。
次の一手 point-campaign の requests を実測に合わせる(来期の増設を 1 台遅らせる)。CUD 12 台はピークで買っていたので、満了前にベースラインだけ約束し直す案を試算する。
迷った案と採らなかった理由
| 案 | point-campaign | 判断 | 前提が変わったら |
|---|---|---|---|
| Bad: 全額をリクエスト件数で配って撤退判断 | 赤字 −36.8 万 | 却下。スパイラルで月 −299.5 万 | — |
| 案A: 全額を CPU・メモリ requests で配って撤退判断 | 黒字(47.8 万) | 却下。今回は答えが合うが segment-sync は 85% まで配られ脆い。誤りは「全額配賦を撤退基準にする」こと | — |
| 案B: 共通固定費は配らず showback | 12.8 万 | 採用(見せる用)。GKE の公式方式と同じ | チャージバックに進むなら共通固定費の配り方を部門間で合意 |
| 案C: 回避可能原価で撤退判断 | 11.3 万 | 採用(決める用) | CUD 満了後 17.1 万。それでも増分粗利の 19% |
| 案D: ABC(活動基準原価計算)で SRE 工数を配る | 未試算 | 保留。工数記録が無い | SRE 対応が 1 サービスに偏っていると分かったら showback に足す |
| 案E: 止めずに requests を半分に | 利益 +0.27 万 | 採用(効果は小さいと明記) | オンデマンドを足している月は 1 台 6.1 万円の効果 |
- 増分粗利が 11.3 万円を下回る(今の 1/8)→ 止める
- CUD が 9 台分だけなら → 回避可能原価 17.1 万円。判断は同じ(継続)
- BQ 予約がサービスごとの契約なら → 専用分は回避可能原価に入る。共通費は「契約上、誰を止めたら減るか」で分ける
ポイント解説
実務への応用
証券会社でいう「本部費を配ってから部門の撤退を議論する」のと同じ構造。アナリストが見るのはセグメントの貢献利益で、本部費配賦後の赤字ではない。MOps の Looker Studio では showback とプラットフォーム行を分け、撤退の議論は別シートで回避可能原価を出す。dbt で課金エクスポートを namespace × sku に集計し、CUD の残期間は契約マスタのシードで持つ。稟議テンプレートに「この数字は回避可能原価か、配賦額か」の欄を足す。
次のステップ
発展問題: 2029-03 の CUD 満了に向け、何台分を何年で約束し直すかを、各サービスの撤退確率とノード需要の分布から決める。撤退の可能性があるサービスのために CUD を少なめにする「止められる権利」はいくらの価値か。
参考: GKE cost allocation、FinOps Framework Allocation、回避可能原価・埋没原価、セグメント別損益、death spiral
扱わなかった範囲
- 数字はすべて仮定。ノード単価・CUD 単価・管理料 $0.10/時は今回公式の料金表で確かめていない(要確認)
- 台数は FFD の近似。実際の Cluster Autoscaler は PDB やローカルストレージで縮小しないことがあり、回避可能原価はさらに小さくなりうる
- Autopilot(Pod 課金で遊休が Google 側に移る)は扱わず Standard だけ
- SRE 人件費の段階的な固定費(3 サービス止めれば 1 人減る)、原価計算基準・税務上の配賦は扱わない
- 論点を 10 個挙げ 9 個を数えた(ABC は保留)。この 10 個は FinOps と管理会計の教科書的論点から自分で選んだもので、外部の網羅リストではない