3年間のKubernetes運用を振り返る
KGAがKubernetesを本番環境に導入して3年が経過した。EKS(AWS)をメインに、一部GKEも運用している。この3年で大小合わせて23件の本番インシデントを経験し、そのうち5件はSeverity 1(サービス全停止)だった。この記事では恥を忍んで失敗談を共有し、同じ轍を踏まないための教訓をまとめる。
教訓1: HPAの設定ミスは致命的
Horizontal Pod Autoscaler (HPA)の設定ミスで2回のSev-1インシデントが発生した。
- 回目: CPU使用率80%でスケールアウトするように設定したが、CPU requestsを実際の使用量より大幅に低く設定していた。結果、実際のCPU使用率が80%に達してもHPAのメトリクスでは200%と表示され、Podが際限なく増殖。最終的にNodeのリソースを食い尽くしてクラスタ全体がダウンした。
教訓: CPU/memory requestsは実際の使用量の80%を設定し、limitsはrequestsの150%にする。この比率はKGAの3年間の運用で最も安定した値だ。
- 回目: スケールダウンのstabilizationWindowSecondsを短くしすぎた(60秒)。トラフィックのスパイクに対してスケールアウト→スケールイン→スケールアウトが激しく繰り返され、コネクションの断絶が多発。300秒に延長して解決。
教訓2: PDBを設定しないと痛い目に見る
PodDisruptionBudget (PDB)を設定していなかったために、Nodeのローリングアップデート中に全Podが同時に退避され、サービスが5分間完全停止した。maxUnavailable: 1 または minAvailable: N-1 のPDBを全Deploymentに設定することを義務化した。
これは基本中の基本だが、KGAでは初期のスピード優先でスキップしてしまった。Infrastructure as Codeで全リソースを管理し、PDBのないDeploymentはCI/CDパイプラインでrejectする仕組みを導入した。
教訓3: Observabilityは投資ではなく保険
KGAのObservabilityスタックは、Prometheus + Grafana(メトリクス)、Loki(ログ)、Tempo(トレース)のGrafana三兄弟構成。導入コスト(人件費込み)は約500万円、月間運用コストは約30万円。高く感じるかもしれないが、Observabilityなしで発生していたインシデントの平均復旧時間は4時間。導入後は平均22分に短縮された。月1回のSev-1インシデントの機会損失を考えると、ROIは1ヶ月で回収できている。
具体的に役立ったダッシュボードを紹介する。Golden Signals(レイテンシ、トラフィック、エラー率、サチュレーション)のリアルタイム表示。Pod restart historyとOOMKilled検出。Node別のリソース使用率ヒートマップ。APIエンドポイント別のp50/p95/p99レイテンシ。
教訓4: コスト最適化は継続的な取り組み
Kubernetesのコストは放置すると際限なく膨らむ。KGAのEKSクラスタの月額費用推移。導入直後: $3,200/月。6ヶ月後: $8,500/月(リソース要求の見直しなし)。最適化後: $4,100/月。
最も効果があったのはSpot Instanceの活用だ。ステートレスなワークロード(Webサーバー、APIサーバー)をSpot Instance上で動かし、Nodeの中断に対してはPDBとgraceful shutdownで対応。これだけで月額の35%を削減した。
次に効果があったのはright-sizingだ。Kubecostを導入してPod単位のコストと実際のリソース使用量を可視化。多くのPodがrequestsの20-30%しかリソースを使っていないことが判明し、requestsを適正値に調整。これで月額の20%を削減。
教訓5: Namespaceの分離戦略
最初は全環境(dev、staging、production)を1クラスタのNamespace分離で運用していた。コスト的には効率的だが、dev環境のメモリリークがproduction Nodeのリソースを圧迫するインシデントが発生。以後、productionは独立クラスタに分離した。
ResourceQuotaとLimitRangeでNamespace単位のリソース制限は設定していたが、Node自体は共有だったため、Nodeレベルのリソース競合を防げなかった。Namespace分離は論理的な分離であり、物理的な分離ではないことを身をもって学んだ。
実際のインシデント事後分析
- 年9月のSev-1インシデントを詳細に共有する。発生: 金曜日18:00(よりによって)。症状: 全APIがタイムアウト。原因: CronJobで実行していたバッチ処理がメモリリークし、OOMKillerが関連のないPodを道連れにした。対応: 手動でCronJobのPodを削除し、影響を受けたPodが再起動するまで23分のダウンタイム。再発防止: CronJobを専用NodePoolに隔離、メモリlimitsの厳格化、OOMKillerの挙動監視アラート追加。
このインシデントから学んだ最大の教訓は「バッチ処理とオンラインサービスは物理的に分離せよ」だ。NodePoolを分け、taint/tolerationで確実に分離する。論理的な分離だけでは不十分。