スポットインスタンスで月200万円削減できた話|ECS・EKS・バッチの実装パターン
「突然落ちるのが怖い」と敬遠していたスポットインスタンス。本番移行で月200万円削減できた実装パターンと、2年間の試行錯誤で見えてきた割り込み対策のリアルを共有します。
スポットインスタンスを「怖いから使わない」と言っていた過去の自分を殴りたい
2年前、うちのチームはスポットインスタンスをほぼ使っていなかった。理由は単純で「突然落ちるのが怖い」だった。月のEC2コストが600万円を超えたとき、さすがに重い腰を上げてスポット移行を本格的に検討し始めた。
結果から言うと、今は月のコンピューティングコストが約200万円減った。正直、もっと早くやればよかったと思っている。ただ「ただ置き換えればいい」という話でもなくて、ワークロードの種類によって使い方を変えないと本当に本番で火を噴く。ECS・EKS・バッチ処理それぞれで試行錯誤した実装パターンと、2026年現在でも使っているノウハウを共有したい。
関連して、EKS環境でのコスト最適化全般についてはEKS コスト最適化2026|Spot/On-Demand戦略とKarpenter活用ガイドも読むと、Karpenter周りの深い話がまとまっているので合わせて参照してほしい。
スポットインスタンスの割り込み率と向き合う
「突然落ちる」のがスポットへの恐怖感の根本だけど、実際の割り込み率を確認したことある人って意外と少ない。AWS公式のSpot Instance Advisor(2026年版)で確認すると、インスタンスタイプと可用性ゾーンによって割り込み頻度は全然違う。
実際にうちのワークロードで計測した割り込み頻度はこんな感じだった。
xychart-beta
title "インスタンスファミリー別 月次割り込み発生回数(本番実測値)"
x-axis [m5系, m6i系, m7i系, c5系, c6i系, r5系]
y-axis "割り込み回数/月" 0 --> 25
bar [22, 14, 8, 18, 10, 6]
見てわかる通り、世代が新しいほど割り込みが少ない傾向がある。m7i・c6i・r5系あたりが比較的安定していた。ただこれはうちのリージョン(ap-northeast-1)での話で、us-east-1だと傾向が変わるから注意。
割り込み通知を捕まえる仕組み
割り込みが発生する2分前にEC2メタデータエンドポイントに通知が来る。これを無視してると本番で泣く。うちでは以下のスクリプトをsystemdで回して、割り込みを検知したらECSタスクをdrainingに切り替えている。
import time
import requests
import boto3
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
METADATA_URL = "http://169.254.169.254/latest/meta-data/spot/interruption-action"
TOKEN_URL = "http://169.254.169.254/latest/api/token"
def get_imds_token():
resp = requests.put(
TOKEN_URL,
headers={"X-aws-ec2-metadata-token-ttl-seconds": "21600"},
timeout=2
)
return resp.text
def check_interruption(token: str) -> bool:
try:
resp = requests.get(
METADATA_URL,
headers={"X-aws-ec2-metadata-token": token},
timeout=2
)
return resp.status_code == 200
except requests.exceptions.RequestException:
return False
def drain_ecs_container_instance():
"""ECSコンテナインスタンスをDRAININGに変更"""
instance_id_url = "http://169.254.169.254/latest/meta-data/instance-id"
token = get_imds_token()
instance_id = requests.get(
instance_id_url,
headers={"X-aws-ec2-metadata-token": token},
timeout=2
).text
ecs = boto3.client("ecs", region_name="ap-northeast-1")
cluster_name = "production-cluster" # 実際はSSMから取得
# コンテナインスタンスARNを特定
paginator = ecs.get_paginator("list_container_instances")
for page in paginator.paginate(cluster=cluster_name):
desc = ecs.describe_container_instances(
cluster=cluster_name,
containerInstances=page["containerInstanceArns"]
)
for ci in desc["containerInstances"]:
if ci["ec2InstanceId"] == instance_id:
ecs.update_container_instances_state(
cluster=cluster_name,
containerInstances=[ci["containerInstanceArn"]],
status="DRAINING"
)
logger.info(f"Draining container instance: {ci['containerInstanceArn']}")
return
if __name__ == "__main__":
token = get_imds_token()
logger.info("Spot interruption monitor started")
while True:
if check_interruption(token):
logger.warning("Spot interruption detected! Draining ECS instance...")
drain_ecs_container_instance()
break
time.sleep(5)
このスクリプトをAMIに焼き込んで全スポットインスタンスで起動するようにした。ECSのDRAININGステータスに移行すると、新規タスクのスケジューリングが止まって既存タスクが別インスタンスに移り始める。2分間でどこまで逃がせるかが勝負になる。
ECS on Spot:本番投入で気づいた3つのポイント
ECSでのスポット活用は、Capacity Providerを使うのが今の定番だ。うちの構成はOn-Demand:Spotの比率を20:80にしている。最初は10:90を試みたんだけど、割り込み頻度が重なったときにOn-Demandが足りなくて詰まったので、20:80が現実的な落としどころになった。
graph TB
subgraph VPC["VPC (ap-northeast-1)"]
subgraph AZ_A["Availability Zone A"]
subgraph ECS_A["ECS Cluster"]
OD_A["On-Demand\nm6i.xlarge x2"]
SPOT_A["Spot\nm7i.xlarge x4\nc6i.2xlarge x3"]
end
end
subgraph AZ_B["Availability Zone B"]
subgraph ECS_B["ECS Cluster"]
OD_B["On-Demand\nm6i.xlarge x2"]
SPOT_B["Spot\nm7i.xlarge x4\nc6i.2xlarge x3"]
end
end
subgraph AZ_C["Availability Zone C"]
subgraph ECS_C["ECS Cluster"]
OD_C["On-Demand\nm6i.xlarge x2"]
SPOT_C["Spot\nm7i.xlarge x3\nc6i.2xlarge x2"]
end
end
ALB["Application Load Balancer"]
CP["Capacity Provider\nOD:20% / Spot:80%"]
end
ALB --> ECS_A
ALB --> ECS_B
ALB --> ECS_C
CP --> OD_A
CP --> OD_B
CP --> OD_C
CP --> SPOT_A
CP --> SPOT_B
CP --> SPOT_C
3つのポイントを順に整理する。
ポイント1:インスタンスタイプは複数指定が必須
Capacity Providerに1種類しか指定しないのは本当に危険で、特定インスタンスタイプのスポット在庫が枯渇するとそこで詰まる。うちではm7i系・c6i系・m6i系の3ファミリーをAZ横断で指定している。個人的には「絶対に複数ファミリー」をチームのルールにしてから、在庫切れによるトラブルはゼロになった。
ポイント2:ヘルスチェックのderegistration_delayを短くする
デフォルト300秒(5分)だと割り込み2分前通知の時間内に間に合わない。本番では30秒に設定している。これを変えるだけでサービス影響がかなり減った。地味に効くのでまだデフォルトのままの人はすぐ変えてほしい。
ポイント3:ステートフルなワークロードは素直にOn-Demandで動かす
当たり前に聞こえるかもしれないけど、最初の頃に「このコンテナもスポットで動かせるはず」と無理して痛い目を見た。セッション情報を持つサービスや、長時間のトランザクションを扱うワーカーはOn-Demand固定にして、その他をスポットに乗せるという割り切りが大事。欲を出しすぎると必ずどこかで刺さる。
バッチ処理×スポット:これが一番コスト効果が高かった
正直、スポットインスタンスが最もハマるのはバッチ処理だと思っている。割り込まれても再実行すればいいし、処理が冪等に設計されていれば問題ない。うちでは機械学習の学習ジョブとデータ集計バッチをほぼ100%スポットで動かしている。
バッチ処理設計|スケーラブルなシステム構築ガイドでも触れているように、チェックポイント設計がバッチとスポットの組み合わせの核心になる。SageMaker TrainingJobなら組み込みでチェックポイントをS3に保存してくれるから楽だけど、自前バッチの場合は自分で実装する必要がある。
import json
import boto3
from pathlib import Path
from datetime import datetime
class CheckpointManager:
def __init__(self, bucket: str, job_id: str):
self.s3 = boto3.client("s3")
self.bucket = bucket
self.key_prefix = f"checkpoints/{job_id}"
def save(self, state: dict, step: int):
"""処理状態をS3に保存"""
checkpoint = {
"step": step,
"timestamp": datetime.utcnow().isoformat(),
"state": state
}
key = f"{self.key_prefix}/step_{step:06d}.json"
self.s3.put_object(
Bucket=self.bucket,
Key=key,
Body=json.dumps(checkpoint),
ContentType="application/json"
)
# 最新チェックポイントのポインタを更新
self.s3.put_object(
Bucket=self.bucket,
Key=f"{self.key_prefix}/latest.json",
Body=json.dumps({"step": step, "key": key}),
ContentType="application/json"
)
def load_latest(self) -> dict | None:
"""最新のチェックポイントを復元"""
try:
resp = self.s3.get_object(
Bucket=self.bucket,
Key=f"{self.key_prefix}/latest.json"
)
pointer = json.loads(resp["Body"].read())
checkpoint_resp = self.s3.get_object(
Bucket=self.bucket,
Key=pointer["key"]
)
return json.loads(checkpoint_resp["Body"].read())
except self.s3.exceptions.NoSuchKey:
return None
def process_batch(job_id: str, records: list):
checkpoint_mgr = CheckpointManager(
bucket="my-batch-checkpoints",
job_id=job_id
)
# 前回のチェックポイントから再開
checkpoint = checkpoint_mgr.load_latest()
start_step = 0
if checkpoint:
start_step = checkpoint["step"] + 1
print(f"Resuming from step {start_step}")
for i, record in enumerate(records[start_step:], start=start_step):
# バッチ処理のメイン処理
result = process_record(record)
# 100件ごとにチェックポイント保存
if i % 100 == 0:
checkpoint_mgr.save(
state={"processed_ids": list(range(start_step, i + 1))},
step=i
)
print("Batch processing completed")
これに加えて、AWS Batch(Managed ComputeEnvironment)でspot_bid_percentageを指定してスポット確保をコントロールしている。うちでは100%(オンデマンド上限価格)に設定することで、在庫があれば必ずスポットで動かすようにしている。
実際のコスト削減効果を計測するとこうなった。
xychart-beta
title "バッチ処理コスト推移(万円/月)"
x-axis ["2025-09", "2025-10", "2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04"]
y-axis "コスト(万円)" 0 --> 180
line [160, 158, 145, 62, 58, 55, 52, 48]
2025年11月からスポット移行を段階的に進め、12月に大部分の移行が完了。3ヶ月で月100万円以上の削減になった。正直ここまで効くとは思っていなかった。チェックポイントさえ実装しておけばほぼノーリスクで試せるので、バッチを抱えているチームは本当に今すぐ動いてほしい。
EKS + Karpenter:2026年のスポット活用の本命
Karpenterの話は冒頭で紹介した記事に詳しいけど、ここでは特にスポットに絞ったNodePoolの書き方を共有する。2026年現在、Karpenter v1.xではNodePoolとEC2NodeClassが分離されていて、スポット優先でOn-Demandにフォールバックする設定がシンプルに書ける。
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: spot-preferred
spec:
template:
metadata:
labels:
billing/spot: "true"
spec:
nodeClassRef:
group: karpenter.k8s.aws
kind: EC2NodeClass
name: default
requirements:
# スポット優先、在庫なければOn-Demandに自動フォールバック
- key: karpenter.sh/capacity-type
operator: In
values: ["spot", "on-demand"]
# インスタンスファミリーを複数指定して在庫枯渇リスクを下げる
- key: node.kubernetes.io/instance-type
operator: In
values:
- m7i.xlarge
- m7i.2xlarge
- m6i.xlarge
- m6i.2xlarge
- c7i.xlarge
- c7i.2xlarge
- c6i.xlarge
- c6i.2xlarge
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: topology.kubernetes.io/zone
operator: In
values:
- ap-northeast-1a
- ap-northeast-1c
- ap-northeast-1d
# Spot割り込みハンドリング
terminationGracePeriod: 120s
limits:
cpu: "200"
memory: 400Gi
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized
consolidateAfter: 1m
# Spot割り込み時の挙動
budgets:
- nodes: "20%" # 同時にDrainできるノード数の上限
KarpenterがSpotを選ぶときは、要件を満たすインスタンスタイプの中から自動的に価格最適なものを選んでくれる。capacity-type: spot を第一候補にして在庫がなければOn-Demandに切り替わる動きは、Cluster Autoscalerの時代と比べて体感的にかなりスムーズになった。
ただし一点ハマったのが、terminationGracePeriodを短くしすぎてPodのGraceful Shutdownが間に合わなかったケース。WebhookやgRPCのロングポーリング接続を持つPodが途中で切れる問題が発生した。PodのterminationGracePeriodSecondsとKarpenterの設定値を揃えることが重要で、うちでは120秒を標準にした。これに気づくまでに何度か本番で接続断が出て、地味に原因特定に時間がかかったので先に書いておく。
Spot向けPodの設定
KarpenterでスポットノードにスケジュールされたPodに対して、割り込み耐性を上げるための設定をDeploymentに追加している。
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
replicas: 6
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # 同時に落ちていいPodは1つ
maxSurge: 2
template:
spec:
# スポット割り込み時のGraceful Shutdown時間
terminationGracePeriodSeconds: 120
# 複数AZに分散
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: api-server
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: api-server
# スポットノードを優先しつつOn-Demandにも乗れる
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: karpenter.sh/capacity-type
operator: In
values: ["spot"]
containers:
- name: api-server
image: my-api:latest
lifecycle:
preStop:
exec:
# 接続のドレイン待ち
command: ["/bin/sh", "-c", "sleep 10"]
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
topologySpreadConstraintsで同一ノードや同一AZへの偏りを防いでいる。これがないと、割り込みが連鎖したときに一気に複数Podが落ちるという最悪のケースが起きる。最初に設定し忘れていて、AZ-aで割り込みが集中したときにPodの4割が同時に落ちるという経験をした。二度とやりたくない。
コストとリスクのトレードオフ整理
「どのワークロードをスポットに乗せるべきか」について、うちのチームで使っている判断基準をまとめると以下の通りだ。チームに共有したら「意外とWebAPIもスポットで行けるんだ」という反応が多かった。最初から全部On-Demandで考えていた人が多かったんだと思う。
| ワークロードタイプ | スポット適性 | 推奨Spot比率 | 理由 |
|---|---|---|---|
| 機械学習学習ジョブ | ◎ 非常に高い | 100% | チェックポイントで再開可能 |
| 非同期バッチ処理 | ◎ 非常に高い | 90〜100% | 冪等設計で再実行可 |
| データ集計・ETL | ○ 高い | 80〜90% | 失敗しても再実行コスト低 |
| ステートレスAPI | ○ 高い | 60〜80% | 複数AZ分散で影響局所化 |
| WebSocket/gRPC長時間接続 | △ 中程度 | 30〜50% | 接続切断の影響を最小化する設計が必要 |
| DBプロキシ・キャッシュ | △ 低い | 20〜30% | ウォームアップコストが高い |
| プライマリDB・ステートフル | ✗ 不適 | 0% | データ損失リスクあり |
スポット活用の欲が出てくると「DBもいけるんじゃ?」という発想になりがちだけど、そこは本当に手を出さないほうがいい。個人的にはプライマリDBをスポットに乗せようとした提案を2回却下した経験がある。
コスト削減の観点ではSavings Plans vs Reserved Instances|2026年AWSコスト最適化判断基準も合わせて読んでほしい。スポット・RI・Savings Plansの三つ巴でどう使い分けるかは、コスト最適化の核心的な話になる。うちでは「ベースロードはSavings Plans、バーストはSpot」という構成に落ち着いた。
また、コスト管理の監視体制についてはAWS夜中のコストアラートで飛び起きた話|Budgets・Cost Anomaly Detection実運用3ヶ月の知見が参考になる。スポットを積極活用すると逆にコストの可視化がしにくくなるので、Cost Anomaly Detectionの設定は必須だと思っている。
まとめ
2年間スポットインスタンスと格闘してわかった要点を絞ると:
1. 割り込み通知の2分を最大限使う仕組みを先に作る EC2メタデータエンドポイントのポーリング+ECS DRAININGへの移行スクリプトは、スポット移行の大前提。これなしに始めると必ず本番障害を踏む。
2. インスタンスタイプは必ず複数ファミリーを指定する 単一インスタンスタイプへの依存は在庫枯渇リスクに直結する。m7i・c6i・m6i系など3〜4ファミリーを組み合わせることで安定性が大きく上がる。
3. バッチ処理が一番効果的で難易度も低い まずバッチから始めるのが正解。チェックポイント設計さえ入れれば、ほぼリスクなしに70〜80%コスト削減できる。
4. EKS+Karpenterの組み合わせは2026年時点でかなり成熟している NodePool設定でスポット優先+On-Demandフォールバックが宣言的に書けるようになった。topologySpreadConstraintsと組み合わせることで割り込み耐性が格段に上がる。
5. ステートフルワークロードへの適用は慎重に スポット活用の欲が出てきたとき、DBやキャッシュまで乗せようとすると詰む。「このワークロードは本当に割り込まれても大丈夫か?」を毎回問い直す習慣が大事。
次のアクションとしては、まずSpot Instance Advisorで自分のリージョン・インスタンスタイプの割り込み頻度を確認するところから始めてほしい。その上で、最もリスクの低いバッチ処理から段階的に移行していくのが、チームにとって心理的ハードルが低くてうまくいきやすい。
「怖くて使えていない」という状況なら、ぜひ一度バッチだけでも試してみてほしい。2年前の自分に言ってやりたいのは、「怖がっている時間そのものがコストだ」ということだ。