E 会計/ファイナンス・コスト効率 — 「リクエスト件数で配賦したら赤字、だから止める」稟議を、回避可能原価と showback の設計で書き直す(配賦 126.8 万円 vs 止めて減る 11.3 万円 × CUD は払い続ける × 毎月くり返すと死のスパイラルで月 −299.5 万円)

2026-10-16 金曜 金曜 E: 会計/ファイナンス・コスト効率 ★★★★★ 回避可能原価 / 共通費配賦 / 死のスパイラル GKE cost allocation / FinOps Allocation / CUD

概要

✂️

見せる数字と決める数字

配賦は「誰が使ったか」、回避可能原価は「止めたら減る額」。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-api14 × (1.0 vCPU, 2 GiB)182M38.0 万900 ± 60 万
recommend-api10 × (2.0, 8)60M52.0 万450 ± 50 万
mail-batch6 × (3.0, 6)0.2M21.0 万300 ± 40 万
point-campaign6 × (1.0, 2)90M11.0 万90 ± 18 万
segment-sync5 × (2.0, 8)5M26.0 万120 ± 30 万
banner-api8 × (1.0, 2)140M9.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)

この稟議には 8つの問題 が隠れています。見つけてから先へ進んでください。
後輩の稟議

件名: 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 万円の費用が消える。今後も毎月この方法で配賦し、赤字のサービスを順に見直す。

問題点サマリー(8点)
1-2何を比べたか — 配賦額を回避可能原価と読んだ/共通固定費 71.5% を配った
3配賦基準 — リクエスト件数(GKE は requests)
4-5契約と階段 — CUD は払い続ける/ノードは整数
6-8運用 — 遊休を溶かす/死のスパイラル/幅と代替案なし

ヒント(段階的開示)

ヒント1 — 方向性
「point-campaign を止めた翌月の請求書は、どの行がいくら減るか」を 1 行ずつ考える。配賦額は「止めれば消える費用」か。止めたあと、残った 5 サービスへの配賦はどうなるか。
ヒント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 で
4CUD のノードを回避可能とみなした契約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.70
ノード(CUD 12 台)+ Datadog 34.4 × 18.9%6.50.27(Datadog 1 台)
全サービスの直接費 157.0 × 18.9%29.611.0(自分の直接費の全額)
合計126.811.3

費用の分け方・配賦と回避可能原価・死のスパイラル(SVG)

① 同じ請求書から、2 つの別の数字を出す(showback と撤退判断)請求書 月 672.5 万円課金 BigQuery エクスポートGKE cost allocation 有効直接費 157.0 万ラベルで取れる(ログ・BQ・egress)ノード 34.4 万(階段)CUD 12 台・Datadog は台数共通固定費 481.1 万管理料・BQ予約・SRE・契約showback(見せる)requests で配る・遊休は別行撤退判断(決める)請求書(全部) − 請求書(それ以外)② point-campaign の「費用」はどれか(万円/月)導出: 仮定の入力から cost_alloc.py で計算。縦線 = 増分粗利 90 万円(± 18 万円の帯)増分粗利 90(± 18 の帯)Bad: リクエスト比で全額共通固定費 90.7直接費 29.6126.8 → 赤字案A: requests で全額47.8showback(配らない)12.8回避可能原価(今)11.3回避可能原価(CUD 満了後)17.1③ 「配賦 > 増分粗利 なら止める」を毎月くり返すと(死のスパイラル)1,417.5 万円開始point 126.8 > 901,338.7 万円point-campaign 停止banner 197.3 → 239.1 > 2301,118.0 万円banner-api 停止coupon への配賦 256.5 → 480.0会社の利益は月 −299.5 万円(年 −3,594 万円)。止めたサービスの費用はほとんど消えず、残りへの配賦が増えるだけ(縦軸は 1,000 万円から)

パターン: 貢献利益(セグメント・マージン)と回避可能原価による撤退判断 + requests ベースの showback(GKE cost allocation)。共通費の全額配賦を撤退基準にすると「死のスパイラル」になる、という管理会計の古典的な落とし穴。

Bad → Good(コード差分)

Bad
# 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
# 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
1 回目は 11 種中 M11(1 か月に赤字を全部止める)が生存。今のデータでは毎月の「赤字」が 1 つだけで区別できなかった。banner-api の増分粗利を 140 万円にして 2 つ同時に赤字になるテストを足し、全部赤。テストの期待値も 1 回目は自分の計算違い(banner-api 停止でノードがもう 1 台減る分を見落とした)で赤になり、期待値の方を直した。最小 PATH・macOS 同梱 Python 3.9.6 でも 17 件緑。

実行例

$ 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: 共通固定費は配らず showback12.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 予約がサービスごとの契約なら → 専用分は回避可能原価に入る。共通費は「契約上、誰を止めたら減るか」で分ける

ポイント解説

1「配賦額」と「止めたら減る額」は別の問いの答え — 126.8 万円と 11.3 万円で 11 倍違う
2共通固定費を配って撤退判断すると死のスパイラル — 費用はほぼ消えず、残りへの配賦が増えて次が赤字に見える。月 −299.5 万円
3契約と階段で回避可能性が決まる — CUD は払い続ける。ノードは整数。判断に有効期限を書く
4showback は requests で、遊休は別の行 — 溶かすと誰も詰め直さない

実務への応用

証券会社でいう「本部費を配ってから部門の撤退を議論する」のと同じ構造。アナリストが見るのはセグメントの貢献利益で、本部費配賦後の赤字ではない。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 と管理会計の教科書的論点から自分で選んだもので、外部の網羅リストではない

今日のまとめ

共通費を配った「赤字」は、止めても消えない費用でできている。撤退は「増分粗利 − 回避可能原価(契約と階段込み)」で決め、配賦は GKE と同じく requests で配って遊休と共通費を別の行に出す、使い方を変えるための数字にとどめる。

自己評価(あとで記入)