EKSの月120万円請求に震えた話|1年かけてコストを半分以下にした実装記録

「このまま年1400万円かかる」と試算が出て本気で向き合うことに。Karpenter・Spot・VPA・Kubecostなど、失敗も含めた1年分のEKSコスト最適化の記録です。

先日、うちのチームのEKSクラスターで月120万円を超えた請求書が届いて、正直かなり焦った。プロジェクト初期はとにかく動かすことが優先で、ノードサイジングもリソースリクエストもざっくり設定のまま放置してきた。その結果が3桁万円の請求だった。

「このまま行けば年間1400万円のコンピュートコスト」という試算を出したら、上から「半分以下にしろ」と言われて本気で向き合うことになった。それから約1年かけて実装した内容を、失敗も含めて整理したい。ちなみに月100万円が消える恐怖から始まったEKSコスト最適化の実装記でも同じ文脈の話を書いたけど、今回はより具体的なコードと設定ファイルを中心に書く。

現状把握:どこにお金が消えているかを見える化する

まず「どこが無駄なのか」を特定しないと何も始まらない。EKSのコスト分析は意外と面倒で、EC2コストとEKSクラスター管理費(1クラスター$0.10/時)を分けて考える必要がある。

うちのチームで最初にやったのは kubecost の導入だ。2026年時点ではKubecost 2.xが出ていて、Namespace・Deployment・Podレベルのコスト可視化が一段と使いやすくなっている。

# Kubecost 2.x をHelmで導入
helm repo add kubecost https://kubecost.github.io/cost-analyzer/
helm repo update

helm install kubecost kubecost/cost-analyzer \
  --namespace kubecost \
  --create-namespace \
  --set kubecostToken="your-token" \
  --set prometheus.nodeExporter.enabled=true \
  --set persistentVolume.size=32Gi \
  --version 2.3.0

導入して1週間眺めてわかったのは、主な無駄の原因は大きく4つに絞られていた。

問題月額コスト影響原因
ノードのリソース使用率が平均23%約40万円リクエスト過剰設定
深夜バッチ用ノードが常時起動約25万円スケールダウン未設定
dev/staging環境が本番同スペック約30万円環境差分なし
データ転送コスト約15万円AZ跨ぎ通信

使用率23%というのはかなりひどかった。開発初期に「メモリが足りなくなると困る」という恐怖から全Podに requests: memory: 1Gi を付けた結果だ。皆さんのチームでも心当たりありませんか?

# 現在のリソース使用率を確認するワンライナー
kubectl top pods --all-namespaces --sort-by=memory | head -20

# Node別使用率の確認
kubectl describe nodes | grep -A 5 "Allocated resources"

Karpenter v1.xへの移行とSpot/On-Demand混在戦略

2026年現在、KarpenterはV1.1まで進んでいる。以前 Karpenterノードプロビジョニング最適化|2026年EKS完全ガイド でも書いたけど、v1.0以降で NodePool リソースの安定版APIが使えるようになって設計がかなり楽になった。

うちのクラスターでは以下の構成でNodePoolを3種類定義している。

# 本番Webサービス用:On-Demand優先、Spot混在
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: production-web
spec:
  template:
    metadata:
      labels:
        workload-type: web
    spec:
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["on-demand", "spot"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["m6i", "m6g", "m7i", "m7g", "c6i", "c7i"]
        - key: karpenter.k8s.aws/instance-size
          operator: In
          values: ["xlarge", "2xlarge", "4xlarge"]
      nodeClassRef:
        apiVersion: karpenter.k8s.aws/v1
        kind: EC2NodeClass
        name: default
  disruption:
    consolidationPolicy: WhenUnderutilized
    consolidateAfter: 30s
    budgets:
    - nodes: "20%"  # 同時disruption上限
  limits:
    cpu: 1000
    memory: 4000Gi
---
# バッチ処理用:Spot特化
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: batch-spot
spec:
  template:
    metadata:
      labels:
        workload-type: batch
    spec:
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot"]
        - key: karpenter.k8s.aws/instance-family
          operator: In
          values: ["c6i", "c7i", "c6g", "c7g", "m6i", "m7i"]
      taints:
        - key: workload-type
          value: batch
          effect: NoSchedule
  disruption:
    consolidationPolicy: WhenEmpty
    consolidateAfter: 60s

Spotインスタンスの選択肢を広げることが重要で、1〜2ファミリーに絞るとSpot中断率が上がる。うちではm6i/m6g/m7i/m7gを候補にすることで中断率を月1〜2回程度に抑えられている。

arm64(Graviton)との混在は最初は怖かったけど、実際に試してみると特定のPythonライブラリ(C拡張系)以外はほぼ問題なかった。Graviton3はx86比でコストパフォーマンスが約40%良いので、試す価値は絶対にある。

# Graviton優先のアフィニティ設定
spec:
  affinity:
    nodeAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 70
        preference:
          matchExpressions:
          - key: kubernetes.io/arch
            operator: In
            values: ["arm64"]

VPA(Vertical Pod Autoscaler)とリソースリクエストの最適化

これが地味に一番効いた。最初のリソース使用率23%という数字を改善するためにVPAを導入した。2026年現在、VPA 1.2がリリースされていてRecommenderの精度が上がっている。

# VPA: Recommendモードで2週間様子見
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: api-server-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  updatePolicy:
    updateMode: "Off"  # まずRecommendのみ
  resourcePolicy:
    containerPolicies:
    - containerName: "*"
      minAllowed:
        cpu: 100m
        memory: 128Mi
      maxAllowed:
        cpu: 4
        memory: 8Gi
      controlledResources: ["cpu", "memory"]

updateMode: "Off" で2週間観察してから updateMode: "Auto" に切り替えた。推奨値を見て最初はびっくりした。requests: memory: 1Gi で設定していたサービスが実態は 130Mi 程度しか使っていないケースがいくつもあったからだ。

ただしVPAにはHPAと組み合わせる場合の制約がある。CPU/Memoryの両方をVPAで管理しつつHPAでスケールアウトもしたい場合、2026年現在では KEDA + VPA の組み合わせが有力だ。正直まだ検証中の部分もあるけど、KEDAのScaledObjectにVPA設定を組み合わせると干渉を避けやすい。

# VPA推奨値の確認コマンド
kubectl get vpa api-server-vpa -n production -o jsonpath='{.status.recommendation}' | jq .

実際の結果がこちら:

{
  "containerRecommendations": [
    {
      "containerName": "api-server",
      "lowerBound": {"cpu": "87m", "memory": "128Mi"},
      "target": {"cpu": "145m", "memory": "256Mi"},
      "upperBound": {"cpu": "523m", "memory": "512Mi"}
    }
  ]
}

target 値を参考に全サービスのリクエストを見直した結果、ノード使用率が23%→61%まで改善した。

AWS構成図:コスト最適化後のEKSクラスター構成

graph TB
    subgraph AWS_Account["AWS Account"]
        subgraph VPC["VPC (10.0.0.0/16)"]
            subgraph AZ_A["AZ: ap-northeast-1a"]
                subgraph NG_A["Node Group A"]
                    OD_A["On-Demand Nodes\nm7i.xlarge / m7g.xlarge"]
                    SPOT_A["Spot Nodes\nMulti-instance types"]
                end
            end
            subgraph AZ_B["AZ: ap-northeast-1c"]
                subgraph NG_B["Node Group B"]
                    OD_B["On-Demand Nodes\nm7i.xlarge / m7g.xlarge"]
                    SPOT_B["Spot Nodes\nMulti-instance types"]
                end
            end
            subgraph ControlPlane["EKS Control Plane"]
                KARPENTER["Karpenter v1.1\nNodePool管理"]
                VPA["VPA 1.2\nリソース最適化"]
                KEDA["KEDA 2.14\nイベント駆動スケール"]
            end
            subgraph CostTools["コスト管理"]
                KUBECOST["Kubecost 2.x\nNamespace別コスト"]
                CW["CloudWatch\nContainer Insights"]
            end
        end
        subgraph Managed["マネージドサービス"]
            ECR["ECR\nコンテナレジストリ"]
            S3_CACHE["S3\nBuildキャッシュ"]
            SAVINGS["Compute Savings Plans\n1年コミット"]
        end
        subgraph CostOptimize["コスト最適化設定"]
            SP["Spot Interruption Handler\naws-node-termination-handler"]
            CRON["KEDA CronScaler\n深夜スケールダウン"]
        end
    end

    KARPENTER --> OD_A
    KARPENTER --> SPOT_A
    KARPENTER --> OD_B
    KARPENTER --> SPOT_B
    VPA --> ControlPlane
    KEDA --> CRON
    SP --> SPOT_A
    SP --> SPOT_B
    KUBECOST --> CW
    SAVINGS --> OD_A
    SAVINGS --> OD_B

KEDA + CronScalerで非本番環境を夜間シャットダウン

月25万円を消費していた「深夜に誰も使っていないバッチ用ノード」の問題は、KEDAのCronScalerで解決した。

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: batch-worker-scaler
  namespace: production
spec:
  scaleTargetRef:
    name: batch-worker
  minReplicaCount: 0  # ゼロスケール可能
  maxReplicaCount: 20
  triggers:
  - type: cron
    metadata:
      timezone: Asia/Tokyo
      start: "0 8 * * 1-5"   # 平日朝8時スタート
      end: "0 22 * * 1-5"    # 平日夜10時終了
      desiredReplicas: "3"
  - type: aws-sqs-queue
    metadata:
      queueURL: https://sqs.ap-northeast-1.amazonaws.com/xxxx/batch-jobs
      queueLength: "10"
      awsRegion: ap-northeast-1

dev/staging環境はさらに積極的にゼロスケールしている。

# staging環境: 平日9-19時のみ起動
triggers:
- type: cron
  metadata:
    timezone: Asia/Tokyo
    start: "0 9 * * 1-5"
    end: "0 19 * * 1-5"
    desiredReplicas: "2"

これだけでdev/stagingのEC2コストが月30万円→8万円に落ちた。夜間や週末に誰も使っていなかったのに常時起動していたというのは、改めて見ると本当にもったいなかった。「ずっとやろうと思っていた」案件をすぐやるべきだった、と心底思った瞬間だ。

コスト削減の成果と1年後の数字

各施策の効果を時系列で見ると:

xychart-beta
    title "EKSクラスター月額コスト推移(万円)"
    x-axis ["2025-07", "2025-09", "2025-11", "2026-01", "2026-03", "2026-05", "2026-07"]
    y-axis "月額コスト(万円)" 0 --> 130
    line [120, 105, 87, 68, 55, 51, 48]

120万円→48万円という結果になった。年換算で864万円の削減だ。施策別の内訳はこう:

施策削減額(月)工数難易度
VPAによるリソース最適化約25万円1週間
Spot混在比率向上(30%→65%)約20万円2週間
非本番環境夜間停止約22万円3日
Graviton3移行(一部)約10万円3週間
Karpenter consolidation最適化約8万円1週間
AZ間通信削減約7万円2週間

この表を見ると、難易度「低」の施策だけで月47万円削減できる計算になる。まず難易度の低いところから手をつけるのが正解で、個人的にはVPAと夜間停止を最初の2週間でやり切れたのが大きかった。

AZ間通信の削減については、サービスのアフィニティ設定とTopology Spread Constraintsを組み合わせて、同一AZ内通信を優先するようにした。これは実装が地味に複雑で、インシデント対応の最新ベストプラクティス2026|DevOps・SRE必読で触れたような本番停止リスクも考慮しながら段階的に進める必要があった。

# Topology Spread Constraints で同一AZ優先
spec:
  topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels:
        app: api-server
  affinity:
    podAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          labelSelector:
            matchExpressions:
            - key: app
              operator: In
              values: ["api-server"]
          topologyKey: topology.kubernetes.io/zone

もう一点、コンテナセキュリティ完全ガイド2026|eBPF・SBOM・サプライチェーン対策で書いたSBOM・脆弱性スキャンのコスト面も見直した。ECRのスキャンを全イメージ毎日実行していたのをPUSH時のみに変更し、Inspector v2の対象をCritical/High限定にすることで月3万円ほど削減できた。小さいけど、こういう積み重ねが地味に効く。

Compute Savings PlansについてはOn-Demand比30%程度を1年コミットで購入している。Savings Plans vs Reserved Instances|2026年AWSコスト最適化判断基準でも書いたけど、Karpenterで動的プロビジョニングしている環境ではReserved InstancesよりCompute Savings Plansの柔軟性が効く。Graviton移行を進めているので特にそれが重要だった。

まとめ

1年かけたEKSコスト最適化で見えた要点を整理する。

  • まずKubecostで現状可視化:何が無駄かわからないまま最適化しようとすると空振りする。最初の2週間はひたすら現状把握に使って正解だった
  • VPAは最も費用対効果が高い:設定コストが低いわりに削減額が大きく、全チームに最初に勧めたい施策。updateMode: "Off" で安全に始められる
  • Spotはインスタンスファミリーを4種類以上広げる:選択肢が少ないと中断率が上がる。m6i/m6g/m7i/m7gの4種類で安定した
  • 非本番環境の夜間停止は即効性あり:KEDA CronScalerで1日の実装。「ずっとやろうと思っていた」案件はすぐやるべきだった
  • Graviton移行は段階的に:一部のPython/Javaライブラリでarm64非対応があるため、全移行前に必ずCI環境で動作確認する

次のアクションとしては、プロファイリングベースのリソース自動調整(VPA Auto + KEDA組み合わせ)の本番化と、EKS Auto Modeへの移行評価を進める予定だ。EKS Auto Mode完全ガイド2026|運用負荷90%削減のサーバーレスK8sで書いた内容を自分たちの環境でどこまで適用できるか、正直まだ検証中だけど年内には結論を出したい。

U

Untanbaby

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

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

関連記事