スポットインスタンスで月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年前の自分に言ってやりたいのは、「怖がっている時間そのものがコストだ」ということだ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事