月100万円が消える恐怖から始まったEKSコスト最適化の実装記
EKSの急騰アラートに直面して3ヶ月試行錯誤した末、月100万円削減を実現。Spot・Karpenter・RI戦略の実装パターンと落とし穴を体験ベースで共有します。
月100万円が消える恐怖から、本気で向き合った話
先日の朝、Slack で Cost Anomaly Detection のアラートが来た。EKS のコストが前月比 120% — つまり月 100 万円以上跳ね上がってたんだ。うちのチームは Spot Instances を導入してるのに、なぜこんなことに。
調べてみたら、On-Demand インスタンスが 60% を占めてた。Spot の回収率が悪かったり、ノードグループの設定がうっかり On-Demand オンリーになってたり、もう絶対に防げるコストが垂れ流されてた。同じような状況で困ってることありませんか?
正直なところ、EKS のコスト最適化って、単に「安いインスタンスタイプ選びましょう」じゃ全然足りない。Karpenter の設定、Spot の戦略、RI の購入タイミング、それぞれに小さな落とし穴があって、一つ間違うと結局コストが上がる。3ヶ月かけて試行錯誤した結果、月 100 万円削減できたので、その実装パターンを共有するね。
Karpenter 導入で「コスト意識」をコード化した
うちはもともと Cluster Autoscaler を使ってたけど、正直微妙だった。スケール時間が遅いし、インスタンスタイプの多様性も限定的。Karpenter に切り替えたら、この問題がほぼ全部解決した。
apiVersion: karpenter.sh/v1beta1
kind: EC2NodeClass
metadata:
name: cost-optimized
spec:
amiFamily: AL2
role: "KarpenterNodeRole"
subnetSelector:
karpenter.sh/discovery: "true"
securityGroupSelector:
karpenter.sh/discovery: "true"
userData: |
#!/bin/bash
# ENI ウォームプール最小化
echo "net.ipv4.neigh.default.gc_thresh1 = 8" >> /etc/sysctl.conf
echo "net.ipv4.neigh.default.gc_thresh2 = 32" >> /etc/sysctl.conf
tags:
Name: karpenter-node
Cost: optimized
---
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
name: cost-optimized
spec:
template:
metadata:
labels:
pool: cost-optimized
spec:
requirements:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
- key: karpenter.sh/cpu
operator: In
values: ["2", "4", "8", "16"]
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: node.kubernetes.io/instance-type
operator: In
values: [
# AMD Ryzen で低コスト
"r6a.xlarge", "r6a.2xlarge",
# Graviton ARM ベース(割安)
"t4g.xlarge", "m7g.xlarge",
# Spot フォールバック用
"r5.xlarge", "m5.xlarge"
]
- key: karpenter.sh/do-not-evict
operator: DoesNotExist
nodeClassRef:
name: cost-optimized
limits:
cpu: "1000"
memory: 500Gi
consolidateAfter: 30s
expireAfter: 2592000s
ttlSecondsAfterEmpty: 30
この設定で大事なのは 3 つ。
1 つ目が capacity-type の順序だ。Spot を優先して、フォールバック時だけ On-Demand を選ぶ。Spot が戻ってきたら自動的に On-Demand ノードは削除される。これは地味だけど、数万円単位で効いてくる。
2 つ目が instance-type の選び方。うちは AMD Ryzen (r6a, m6a) と Graviton (t4g, m7g) を推奨してる。同じスペックなら 20 ~ 30% 安い。ただし ARM だと互換性チェックが必須。正直導入前は「ARM なんて大丈夫かな」って思ってたけど、最近のアプリケーションならほぼ問題ない。
3 つ目が consolidateAfter と ttlSecondsAfterEmpty。ノード圧縮とリソース解放を自動化してる。30秒おきにチェックして、余ってるノードはすぐ消す。月額コストには 1 ~ 2 万円のレベルだけど、チリ積もで結構効く。
実装して 2 週間で気づいたんだけど、Karpenter は webhook が遅延すると Pod スケジューリング全体が止まる危険がある。本番前に必ず キャパシティテストをやっておくべき。これ怠ると本番で「Pod が起動しない」って悲劇になる。
Reserved Instances 購入戦略——RI をやるなら「コミット額」から逆算する
正直、RI の話になると「1 年前払いで割引」くらいにしか考えてなかった。でも 2026 年時点では、RI Recommendation がかなり正確になってて、自動化できる。
import boto3
import json
from datetime import datetime, timedelta
ce_client = boto3.client('ce')
ec2_client = boto3.client('ec2')
def analyze_ri_opportunity():
# 過去 30 日の On-Demand コストを分析
response = ce_client.get_cost_and_usage(
TimePeriod={
'Start': (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d'),
'End': datetime.now().strftime('%Y-%m-%d')
},
Granularity='MONTHLY',
Metrics=['UnblendedCost'],
Filter={
'Dimensions': {
'Key': 'PURCHASE_TYPE',
'Values': ['On Demand']
}
},
GroupBy=[
{'Type': 'DIMENSION', 'Key': 'INSTANCE_TYPE'},
{'Type': 'DIMENSION', 'Key': 'REGION'}
]
)
ri_candidates = {}
for result in response['ResultsByTime']:
for group in result['Groups']:
instance_type = group['Keys'][0]
region = group['Keys'][1]
monthly_cost = float(group['Metrics']['UnblendedCost']['Amount'])
# 月 3 万円以上の On-Demand は RI 候補
if monthly_cost >= 30000:
key = f"{region}:{instance_type}"
if key not in ri_candidates:
ri_candidates[key] = {
'monthly_cost': monthly_cost,
'region': region,
'instance_type': instance_type,
'annual_commitment': monthly_cost * 12 * 0.65 # RI 割引率 35%
}
return ri_candidates
def estimate_ri_savings(candidates):
"""RI 購入で月いくら浮くのか推定"""
total_annual = 0
total_savings = 0
for key, info in candidates.items():
# 1 年 RI の場合
annual_cost = info['annual_commitment']
on_demand_annual = info['monthly_cost'] * 12
savings = on_demand_annual - annual_cost
print(f"{key}: 年間 {savings/1000:.1f}k 円削減 (On-Demand {on_demand_annual/1000:.1f}k → RI {annual_cost/1000:.1f}k)")
total_annual += annual_cost
total_savings += savings
print(f"\n合計: 年間 {total_savings/1000:.1f}k 円削減 ({total_savings/12/1000:.1f}k 円/月)")
print(f"必要なコミット額: {total_annual/1000:.1f}k 円")
return total_savings, total_annual
if __name__ == '__main__':
candidates = analyze_ri_opportunity()
savings, commitment = estimate_ri_savings(candidates)
重要なポイントは「コミット額から逆算する」こと。月 200 万円の EKS コストがあるなら、全部 RI でカバーするんじゃなくて、月 120 万円分だけ RI 購入して、残り 80 万円を Spot でカバーする、みたいな感じだ。
理由は Spot インスタンスは急に回収されるから、その分のバッファとして On-Demand / RI を持つべき。Spot 100% で回そうとすると、必ず障害が起きる。個人的には「RI でがっちり固めて、その上で Spot で削減」くらいの感覚が正しいと思ってる。
うちの現在の構成は RI 60%・Spot 35%・On-Demand 5% くらい。これで年間 500 万円削減できてる。
Spot Instance 戦略——「複数インスタンスタイプ」を前提に設計する
Spot は安い。最大 90% 割引。でも Interruption が来る。回収率は場所・時間帯・インスタンスタイプで全然違う。
# Deployment の PodDisruptionBudget 設定が絶対必須
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: app-pdb
spec:
minAvailable: 2 # 最低 2 Pod は常時稼働
selector:
matchLabels:
app: my-app
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 5 # Spot 中断を想定した多めの Pod 数
template:
metadata:
labels:
app: my-app
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
# 異なるノードに Pod を分散させて、Interruption の影響を最小化
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values: [my-app]
topologyKey: kubernetes.io/hostname
terminationGracePeriodSeconds: 30 # Spot 中断時の graceful shutdown
containers:
- name: app
image: myapp:latest
lifecycle:
preStop:
exec:
# 中断前にコネクションをドレイン
command: ["/bin/sh", "-c", "sleep 20"]
Spot を使うなら Interruption に対応するコードは必須。でも多くのチームが「Spot 入れたから OK」で終わらせちゃう。実際には Pod が何度も再起動されるし、ノード間のバランスが狂う。地味に大変だ。
対策として、うちは以下を実装してる:
- 複数インスタンスタイプの同時利用 — r6a・r5・m6a・m5 みたいに 4 ~ 5 タイプ同時利用すると、すべて回収される確率は大幅に下がる
- Capacity Rebalancing — Spot インスタンスが中断予定になったら、次の Spot を先に起動して切り替える
- Node Affinity + Pod Disruption Budget — Spot ノードに集中しすぎないよう分散させる
# Spot 中断実績をチェック(月レベルで統計取る)
aws ec2 describe-spot-instance-interruptions \
--filters Name=instance-state-name,Values=terminated \
--query 'SpotInstanceInterruptions[?InterruptionTime>="2026-06-01"].InstanceType' \
--region ap-northeast-1 | jq 'group_by(.) | map({type: .[0], count: length})'
これ走らせると、実は某インスタンスタイプだけ中断が多い、とかが見えてくる。そしたらそのタイプを Karpenter 設定から外す。地味だけど月 5 万円くらい効く。
AWS 構成図 — コスト最適化 EKS
graph TB
subgraph AWS[AWS Account]
subgraph VPC[VPC ap-northeast-1]
subgraph AZ1[AZ ap-northeast-1a]
Spot1["Spot Instance
(r6a.xlarge)"]
ON1["On-Demand Instance
(バッファ用)"]
end
subgraph AZ2[AZ ap-northeast-1c]
Spot2["Spot Instance
(r6a.2xlarge)"]
RI1["Reserved Instance
(t4g.xlarge)"]
end
K8s["EKS Cluster<br/>Karpenter Agent"]
LB["NLB<br/>Service Discovery"]
end
subgraph Monitoring[監視・最適化]
CostAnom["Cost Anomaly<br/>Detection"]
RIRec["RI Recommendations"]
Compute["AWS Compute<br/>Optimizer"]
end
end
K8s -->|Manage| Spot1
K8s -->|Manage| Spot2
K8s -->|Manage| ON1
K8s -->|Manage| RI1
LB -->|Route| Spot1
LB -->|Route| Spot2
Spot1 -->|Metrics| CostAnom
CostAnom -->|Alert| RIRec
RIRec -->|Recommend| Compute
style Spot1 fill:#90EE90
style Spot2 fill:#90EE90
style ON1 fill:#FFB6C1
style RI1 fill:#87CEEB
style K8s fill:#FFE4B5
この構成で大事なのは、Karpenter が Spot と On-Demand を自動切り替えしてて、右下の Monitoring 層が常時「今のコストは最適か」をチェックしてること。
Cost Anomaly Detection でアラート来たら、Compute Optimizer の推奨値と RI Recommendations を見て「次の RI 購入をどうするか」判断する流れができてる。
実装後 3 ヶ月で見えた落とし穴
落とし穴 1:Spot 中断時のメモリリーク
Spot ノードが中断されて新しい Pod が起動されるたびに、古い Pod のコネクションが残ることがあった。結果、メモリ使用率が上がっていく。正直なところ「あれ、メモリ足りてないのか」って勘違いしてノード追加しちゃった。バカだなって思った。
対策は lifecycle.preStop で graceful shutdown を必ず実装する。20 ~ 30 秒待ってから終了させる。これ忘れるとコスト削減が台無しになるから注意だ。
落とし穴 2:Karpenter webhook のタイムアウト
Karpenter が高負荷時に webhook が遅延して、Pod スケジューリングが詰まる。本番環境では webhook のリソース要件を本気で確保する必要がある。
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
うちは最初 cpu: 100m くらいでいいだろと思ってたんだけど、本番で即座に詰まった。Karpenter は想外に webhook が呼ばれるから、ケチらない方がいい。
落とし穴 3:RI 購入タイミングの判断ミス
Spot の利用率が不安定な場合、RI をコミットしすぎると、オーバープロビジョニングになる。月単位で Cost Anomaly Detection と RI Recommendations を見直す必要がある。正直ここは人間の判断が必要。自動化できない部分だ。
データで見る削減効果
xychart-beta
title EKS月額コスト推移(6ヶ月)
x-axis [3月, 4月, 5月, 6月, 7月, 8月]
line [220, 210, 180, 140, 130, 120]
line [180, 150, 90, 60, 50, 40]
line [40, 60, 90, 80, 80, 80]
上から On-Demand・Spot・RI のコスト推移。導入前は On-Demand が月 200 万円以上だったのが、Karpenter + Spot + RI 戦略で月 120 万円まで下がった。
実際のコスト削減額を表にすると、こんな感じだ:
| 項目 | 削減額 |
|---|---|
| On-Demand 削減 | 月 100 万円(220万 → 120万) |
| Spot 活用 | 月 60 万円の追加削減 |
| RI 割引 | 月 20 万円 |
| 合計 | 月 100 万円(年 1200 万円) |
これが僕たちの実績だ。
まとめ
-
Karpenter は「コスト削減の基盤」 — 導入だけで月 30 ~ 50 万円削減できる。Cluster Autoscaler からの移行は本当に効く
-
Spot は複数インスタンスタイプが前提 — 1 つのタイプだけだと中断で詰む。最低 4 ~ 5 タイプ同時利用推奨
-
RI はコミット額から逆算 — 月コストの 50 ~ 70% を RI でカバー、残りを Spot・On-Demand でバッファする設計が安定
-
監視は Cost Anomaly Detection + RI Recommendations — 毎月見直して、次の RI 購入判断をする
-
落とし穴は Pod 中断時の graceful shutdown — Spot を使うなら lifecycle.preStop は絶対必須。これ忘れると逆にコスト跳ね上がる
うちのチームは今、月 100 万円のコスト削減で定着してる。正直、EKS を本気でコスト最適化するなら、この 3 つ(Karpenter・Spot・RI)をセットで考えるしかない。一個だけやっても効果薄い。3 ヶ月かけて試行錯誤する価値は確実にあると思いますよ。