EKS3年運用で月120万円の請求を半減|Kubernetes本番の痛すぎる失敗と対策

Kubernetes本番運用で実際にハマった月120万円の請求増加、OOMKill地獄、ノードプロビジョニングの失敗。教科書には載らない3年の試行錯誤から学んだ実装的な対策をまとめました。

うちのチームがKubernetes本番で痛い目を見た話

先日、開発チーム全体で「今月のEKS請求が月120万円になった」という話が出て、ようやく本気で向き合うことになったんですよ。正直、去年まではコンテナをぶち込めばどうにかなると思ってた。でも3年運用してみて気づいたのは、Kubernetesって「使える」と「運用できる」は全く別物だということ。教科書的には分からない、実務の地雷がいっぱいあるんです。

この記事では、うちのチームが実際に引っかかったKubernetes本番運用の問題と、対策として実装してきたことを正直に書きます。ネットに載ってないような「地味だけど重い問題」が大半です。

リソースリクエスト・リミットを甘く見ると月80万円が消える

去年の話なんですけど、ステージング環境で動くアプリが本番で急に落ちる現象が起きたんです。ログにはOOMKilledの文字。「メモリ不足?でもステージングでは動いてる」って、当時は本当に意味不明だった。

原因は、リソースリクエストを甘く設定しすぎてたんですよ。ステージングでは小さいワークロードばっかりだから、リクエストをmemory: 256Miとかで統一してた。本番にぶち込んだら、実際には2Gi必要なアプリまでその設定で走ってて、カーネルがOOMKillしまくったわけです。

で、その後どうしたかというと、リクエストを適切に設定し直すのと並行して、Karpenterを導入してノード自動スケーリングを全部タスクベースで動かすようにしました。今まではNode Autogroupsで「ザックリ16個のノード用意しとく」みたいな運用だったから、リソース効率が本当に悪かったんです。

# Before: 雑い設定
resources:
  requests:
    memory: "256Mi"
    cpu: "100m"
  limits:
    memory: "512Mi"
    cpu: "500m"

# After: 本番測定ベースの設定
resources:
  requests:
    memory: "2Gi"
    cpu: "1000m"
  limits:
    memory: "3Gi"
    cpu: "2000m"

Karpenterに切り替えてから、無駄なノード上げっぱなしがなくなって、月15万円くらい浮いたんですよ。実装もシンプルで、Provisionerの定義だけで「CPU密度が90%超えたら自動でノード足す」みたいなロジックが動く。

ただ、ここで地味に引っかかるポイントがあるんです。既存のCluster Autoscalerと共存させると地獄になるんですよ。両方が競合してスケーリング判定を二重にやるから、無駄なノードが立ったり消えたり。必ず片方に統一してください。

NetworkPolicyを本番で無効化してる人、多くないですか?

“セキュリティは後付けでいい”みたいなノリで、ずっとNetworkPolicyを有効にしてなかったんです。だから本番環境なのに、Podが全方向でしゃべり放題。これが原因で、セキュリティレビューで指摘されて、急遽実装することになりました。

NetworkPolicyを一気に有効化するのは危険なので、まずはMonitoring用の設定で「違反するトラフィックがあれば検出するけど、ブロックはしない」という状態を3週間保ちました。その間、ログを監視して、アプリが実際にどのトラフィックを使ってるか把握する。この期間がないと、デプロイのたびに本番が止まるんですよ。

# Monitoring mode: ブロックなし、違反検出のみ
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: app-policy
  annotations:
    # Cilium(eBPF使ったCNI)の場合
    cilium.io/debug-logging: "true"
spec:
  podSelector:
    matchLabels:
      app: api-server
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: frontend
    ports:
    - protocol: TCP
      port: 8080
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: database
    ports:
    - protocol: TCP
      port: 5432
  - to:  # DNS通信
    - namespaceSelector: {}
      podSelector:
        matchLabels:
          k8s-app: kube-dns
    ports:
    - protocol: UDP
      port: 53

これを本番に適用してから、想像以上に通信パターンが複雑なことに気づきました。サイドカープロキシが予想外の通信してたり、ヘルスチェック用の別ポートが開いてたり。ちゃんと設定しないと、デプロイのたびに本番が止まるんです。

うちはCiliumを使ってるんですけど、eBPF使ったネットワーク制御だから、ホストレベルでのフィルタリングができるんです。Flannel + Calico とかの古い構成だと、Ciliumのような柔軟性がないので、後々マイグレーションの時に苦労する可能性があります。正直、今から始めるならCilium一択だと思ってますね。

PVC・ストレージ周りでチーム全体が止まる日

ステートフルアプリケーション(データベース、キャッシュ層)をKubernetes上で動かすのは、個人的にはまだ本番では避けてます。でも、メッセージキューとか、キャッシュレイヤーとか、複製してもいいステートレスなデータストアは、Kubernetes上で動かすことが多いんです。

そこで落とし穴になるのがPVC(PersistentVolumeClaim)のライフサイクル管理なんですよ。

# Podが削除されたら、PVCも自動削除するのか、残すのか
apiVersion: v1
kind: Pod
metadata:
  name: cache-pod
spec:
  containers:
  - name: redis
    image: redis:7
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: cache-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: cache-pvc
spec:
  # 重要: StorageClass指定
  storageClassName: ebs-gp3  # AWS EBS使用
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi

うちでハマったのは、StatefulSetでRedisのレプリカを回してたんですけど、スケーリングしたときにPVが自動拡張されずに、データが詰まって本番が止まったこと。容量不足でPodがEvictされて、PVCが自動削除されずに残ってるから、復旧しようとしても前のデータが邪魔になるんです。最終的に手動でPVCを削除して、データを再構築するって流れになりました。月10万円のダウンタイムコスト。かなり痛い経験ですね。

EBS のボリューム自動拡張は、StorageClassの定義でallowVolumeExpansion: trueを明示的に設定しないと動かないんです。ドキュメントに書いてあるけど、デフォルト False なので見落としやすい。地味だけど本当に重い設定ですよ。

Ingressの複雑さで本番ぶっ壊す前に

Ingressコントローラー選びは、マジで重要です。うちは最初ALB Ingressコントローラー使ってたんですけど、複数のサービスをホスト名ベースでルーティングするようになったら、管理が地獄になったんです。

# 複数のHostがあると、ALB側でTargetGroupが増殖する
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api-service
            port:
              number: 8080
  - host: admin.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: admin-service
            port:
              number: 9000

これを20個ぐらいのホストでやると、ALBのTargetGroup数が100を超えるんですよ。AWS のアカウント当たりのリソース上限に引っかかったり、コスト管理が複雑になったりするんです。

Nginx Ingressに乗り替えてから、管理が劇的に簡単になったんですよ。Nginx側で全部ハンドルしてくれるから、AWSレベルではシンプルなNLBだけ。レイテンシも若干下がったし、ログのデバッグも楽になった。地味な改善ですけど、運用の負担がぐんと減ります。

本番環境でHPA(自動スケーリング)が暴走する仕組み

HPA(Horizontal Pod Autoscaler)の設定を甘く見てたんですよ。CPU使用率ベースで「80%超えたら2倍にスケール」みたいな設定。まあ、これが問題なんです。

トラフィックスパイクが起きたときを想像してみてください。急に1000req/s来たら、CPU使用率が一気に100%になる。HPAが「やばい、スケールしよう」って判定するんですけど、新しいPodが起動するまで30秒かかるんです。その間、既存のPodは過負荷状態。新しいPod起動後、トラフィックが落ち着いても、HPAの判定がラグで10分後に「あ、もう大丈夫だ」って気づいて縮小。こういった振動が起きるわけですね。

# Before: 甘い設定
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 50
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 80

# After: 本番向けの設定
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 3
  maxReplicas: 50
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70  # より早めにスケール
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80  # メモリも監視
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300  # 5分待ってから縮小
      policies:
      - type: Percent
        value: 50
        periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0  # すぐにスケール
      policies:
      - type: Percent
        value: 100
        periodSeconds: 30

スケールダウンのstabilizationWindowSecondsを300秒(5分)に設定することで、トラフィックが一時的に上がったときのコスト無駄を防ぎます。スケールアップは0にしておいて、スパイクに素早く対応する。こういった細かい調整が本番では効いてくるんですよ。

あと、HPAだけじゃなく、Pod Disruption Budget(PDB)も設定しないと、ノード縮小時にアプリが止まったりするので注意です。

LoggingとMonitoringの設定が甘いと、本番障害の原因究明が1週間かかる

これはKubernetes特有の話じゃないんですけど、Container Log の扱いが小さく見られてる気がします。

うちのチームは最初、各Podのログはkubectl logs で見るだけだったんですよ。本番トラブルが起きたら、該当のPodを特定して手でログ見る。でもPodって自動で再起動されるから、ログが消えちゃう。結果、「なぜ止まったか分からない」って状況に何度もハマったんです。

今はFluentd + Elasticsearch(ELK Stack)でセントラルログを集約してます。CloudWatch Logs に直送してもいいんですけど、複数のECS・EKS・Lambda から大量に送られてくるので、直送だとコスト膨大になる。Fluentd側でフィルタリングして、エラーログだけはCloudWatch、その他は Elasticsearch に放ってます。

Monitoring(メトリクス)も同じで、Prometheus + Grafana 構成で、CPU・メモリ・リクエストレート・レスポンスタイム をダッシュボード化してます。

# Fluentd設定例
apiVersion: v1
kind: ConfigMap
metadata:
  name: fluentd-config
data:
  fluent.conf: |
    <source>
      @type tail
      path /var/log/containers/*.log
      pos_file /var/log/es-containers.log.pos
      tag kubernetes.*
      read_from_head true
      <parse>
        @type json
        time_format %Y-%m-%dT%H:%M:%S.%NZ
      </parse>
    </source>
    
    <match kubernetes.**>
      @type elasticsearch
      @id output_es
      @log_level info
      include_tag_key true
      host elasticsearch.default.svc.cluster.local
      port 9200
      path _bulk
      <buffer>
        @type file
        path /var/log/fluentd-buffers/kubernetes.system.buffer
        flush_mode interval
        retry_type exponential_backoff
        flush_interval 5s
        flush_at_shutdown true
        retry_forever
        retry_max_interval 30
        chunk_limit_size "#{ENV['FLUENT_ELASTICSEARCH_BUFFER_CHUNK_LIMIT_SIZE'] || '8M'}"
      </buffer>
    </match>

本番でのアラート設定も大事です。CPU が80%超えたら通知、Pod のEviction が発生したら通知、みたいなルールを Prometheus の alerting rules で定義。Slack 連携して、チーム全体に通知が行く。こういった地道な設定が、障害検知と対応速度を大きく改善するんですよ。

本番Kubernetesは「セットアップして終わり」じゃない

正直、EKS 立ち上げるだけなら簡単です。CloudFormation なり Terraform なり、IaC で定義すれば、30分で本番環境が建つ。でも、そこからが本当の運用の始まりなんですよ。

リソース管理、ネットワーク設計、ストレージ戦略、ログ・メトリクス収集、セキュリティ設定、障害対応…全部が絡み合ってる。1個の判断ミスで月100万円のコスト無駄や、本番ぶっ壊れが起きるんです。

うちのチームは3年かけてようやく「それなりに運用できてる」という段階に来ました。でも、新しいバージョンが出たり、ワークロードが増えたり、要件が変わったりするたびに、また新しい問題に直面する。その度に、設定を見直して、改善して、また本番で検証する。その繰り返しです。

関連して、インシデント対応の最新ベストプラクティス2026で本番障害時の対応フローについても書いてるので、併せて読むといいかもしれません。

まとめ

  • リソースリクエスト・リミットは測定ベース:ステージングで動く≠本番で効率的。Karpenterでタスク単位のスケーリングを実装すると無駄が減ります。

  • NetworkPolicyは後付け不可:本番で有効化する前に、必ずMonitoring モードで3週間の検証期間を。実際の通信パターンは複雑です。

  • ストレージはステートレスを優先:PVC管理の複雑さを避けるなら、キャッシュやメッセージングなど、複製してもいいデータだけをKubernetes内に置く。本物のDBはマネージドサービスで。

  • HPA設定は本番トラフィックで再検証:CPU ベースだけじゃなく、メモリ・カスタムメトリクスを組み合わせ、スケールダウンのstabilization window を長めに設定。

  • ログとメトリクスはセントラル集約必須:Pod再起動でログが消えるから、Fluentd + Elasticsearch または CloudWatch Logs で一元管理。Prometheus + Grafana でメトリクス可視化。

Kubernetes本番は、やることが多くて大変です。でも一度ちゃんと整えると、スケーリング・デプロイ・障害対応が本当に楽になるんですよ。その投資は確実に返ってきます。うちのチームも、最初は「こんな複雑で運用できるのか」って思ってたけど、今は「Kubernetes ないと本当に困る」ってレベル。

もし今Kubernetes 本番化を進めてる方なら、最初から「セキュリティとログ・メトリクスは必須」くらいの気持ちでやった方が、後での後悔がぐんと減りますよ。

U

Untanbaby

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

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

関連記事