Kubernetes のインスタンスコストを 0.6x した話

10X の Kubernetes おじさん兼娘ちゃん好き好きおじさんこと SRE の @tapih です。

この記事は 10X プロダクトアドベントカレンダー2023 の 8 日目の記事です。昨日は PdM の @enaminnn さんの記事でした。

note.com

本記事では、 2023 年 1 月頃に行っていたインフラコスト削減の施策についての話をご紹介します。

施策を行った背景

弊社が提供している Stailer では、サービスローンチ当初から Kubernetes を採用しています。

自分が入社した 2021 年 5 月時点では数パートナー企業にのみサービス展開をしておりました。ありがたいことにそこから多くの企業様にご導入いただき、短期間で十数パートナーまでサービスを展開することができました (導入の状況は Culture Deck をチェック!)。

一方で、ありがたくないことにインフラコストは増える一方です。サービスが拡大しているのでコストが増えるのは当然ではあるのですが、絶対値で見たときに「ここまでかからないだろう…」という水準に差し掛かっている感覚がチーム内に漂っていました。

では、問題を解消しようとすると、アプリからインフラまで様々なレイヤに手を入れる必要があり、結構大変な印象でした。しかし、一連の施策によってコストカット以外の様々なメリットが得られる期待もあります。このような施策を “10x 案件” と弊社内で呼んだりするのですが、育休復帰後の私は「10x するぞ!(コストは下げるけどね)」と意を決して施策をスタートすることとしました。

Goals / Non Goals

Goal はまず当たり前ですが、 Kubernetes のインスタンスコストを削減することです。その How として Node pool の作り直しが Must であったため、その部分の設計を行うことも当初のゴールと設定しました。また、低リスクで拾える他の改善ポイントがあればついでに拾うこととしました。

一方で、 Cloud Run / GKE Autopilot への移行といった Kubernetes クラスタに閉じない施策は工数や影響範囲が大きく、問題が顕在化した状態で不確実性の高い施策をするのは避けたい、という観点から一旦 Non Goal としました。また、 Kubernetes 以外のコストで大きいものの削減に関しては、他チームに移譲していたり、弊社でコントロールしきれない部分があったため、これも Non Goal としています。

Node pool の命名規則

Node pool を作り直そうとしたところ、 Pod の Node Selector で cloud.google.com/gke-nodepool ラベルを参照していました。このラベルは GKE 側で自動で設定されるものであり、仮に新しい Node pool を作ると別の値が自動で設定されます。そして、この新しい Node pool に Pod をスケジュールさせるには Node Selector の設定を変える必要があります。Node Selector の変更はエディタの一括置換で行えないこともないですが、 Kubernetes マニフェストは現状様々なレポジトリに散在しており、作業は割りと面倒です。 Node pool を作り直すオペレーション自体は今後も発生しうるため、 SRE / SWE の責務を分離できるような状態にしておくのがよさそうでした。

クラスタ上で動いているすべてのワークロードを整理した結果、以下の 2 つの Label / Taint を設定することとしました。

  • 10x.co.jp/owned-by:
    • system (For GCP)
    • platform (For SRE)
    • services (For SWE)
  • 10x.co.jp/instance-type:
    • highmem
    • highcpu
    • standard

また、このタイミングで Spot VM の稼働を開始し、非同期ワーカーの一部の Pod を Spot VM 上で動かすこととしました。以上から Node pool の命名は <10x.co.jp/owned-by>-<10x.co.jp/instance-type>-<cloud.google.com/gke-spot>-YYYYMMDD のようにしました (上限は 39 文字)。なお、 Node Auto-Provisioning のような自動化の機能もありますが、自動化により別の問題が発生する不確実性を排除したかったことから採用を見送っています。 Node pool を分割しすぎると待機リソースが増えてしまうため、このルールに従いつつも必要最小限の Node pool のみを作成しました。

Pod の移行

その後、マニフェストを変更して移行を進めます。この作業は、理想的には各チームに移行を依頼したかったのですが、自分でゴリッと設定していくことにしました。

現在は、ドメインに対する理解をより深めるためにドメインベースの開発体制に移行が進んでおり、各ワークロードに対する Ownership がある程度定まっています。しかし、施策を行った当時は、パートナー企業グループ単位でのチーム分割が行われており、各チーム内で理解が十分でないワークロードがありました。その状況の中では、①それでも各チームに頼む、②チーム移行を待つ、③自分でやってしまう、といったアクションが考えられ、 SRE チームとしては別の施策のブロッカーを解消したかったため、各チームの OnCall-er に軽く共有だけしつつ、③の自分でやってしまうを選択しました (そして後思いの外大変でプチ後悔します)。

Node pool に付与したラベルを指定して移行を行います。

...
spec:
    affinity:
      nodeAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
          nodeSelectorTerms:
          - matchExpressions:
            - key: 10x.co.jp/owned-by
              operator: In
              values:
              - services
            - key: 10x.co.jp/instance-type
              operator: In
              values:
              - highcpu
    tolerations:
      - key: 10x.co.jp/owned-by
        operator: Equal
        value: services
        effect: NoSchedule
...

AOT コンパイル

以上はよりインフラに近い施策でしたが、よりアプリケーションに近い施策として AOT コンパイル化を行いました。

弊社ではサーバサイドでも Dart を採用しています。 Dart (Native) は実行モードとして JIT / AOT コンパイルモードの 2 つをサポートしており、ざっくり前者は開発用途、後者は本番用途での利用が推奨されております。

しかし、実態は JIT モードで動いている Pod が多く存在しました。 gRPC サーバのような比較的長い時間動き続ける Pod に関しては JIT コンパイラの最適化が働きやすく CPU 効率はむしろ AOT モードよりも良かったのですが、その他に以下のような問題が発生していました。

  • メモリ使用量がそもそも多い
  • メモリ使用量が不安定
  • 最適化の効きづらい CronJob の実行時間が増大
  • Pod の起動時間が遅い

AOT モードへの移行は簡単で、 dart compile exe を実行するだけです。このタイミングで Multi-stage build 対応していない Dockerfile の変更も行いました。

移行しても動くだろうと思いつつ、若干自信がなかったので、検証をはさみながら移行を進めたところ、 AOT モードに移行すると動かない CronJob があったことがハマりどころでした。コード内にファイルパスを自動で解決する処理があり、この値が AOT / JIT で違う値を返していたことが原因でした。対策としてはシンプルで、パスを外から渡すようにコードを書き換えました。

final scriptPath =
        io.Platform.script.path.replaceFirst(RegExp(r'pkg/stailer.*'), '');

リソース設定

AOT コンパイルモードに移行した後、 Node pool の移行に合わせてリソース設定によるコストの最適化を行いました。弊社の Kubernetes 上で動いている主なワークロードは以下の通りです。

  • Deployment: gRPC
  • Deployment: 非同期ワーカー
  • CronJob

お客様からのリクエストを直接受ける数種類の gRPC サーバに関しては低負荷時の CPU throttling を避けるために limit を設定していません (詳細はこちらの Issue や様々なブログで言及されてるで割愛)。その他に関しては現状 QoS が Guaranteed になるように設定しています。

特に CronJob に関しては以下の要素に応じて、設定値にどのくらい余裕をもたせるかの判断を行いました。

  • 実行頻度
  • 並列実行数
  • 失敗し続けたときのクリティカル度合い
  • 実装 (特に DB 周り)

振り返り

コスト削減を入り口にスタートしましたが、振り返ってみるとひたすら基本的な設定をし続けている期間でした。

欲を言えば、ドメイン体制での Ownership 移譲が先にできているともう少しよい進め方ができていたと感じています。その他にもデプロイの仕組みが十分整備されていなかったり、マニフェストが散在していたりと、あれが先にできてたらもう少し楽なのに…と思うことが多々ありました。そのような状況でも SRE チームのインセンティブと照らし合わせて、地道な火消しにまず振り切ったことは良かったポイントでした。また、ドメイン体制移行後は、各チームメンバーが自分のした設定を土台にリソース設定を都度ブラッシュアップしてくれており、体制移行をスムーズにすることに役立ったと実感しております。

インスタンスコスト削減

インスタンスコストは 4 割減 (0.6x) となりました。また、インスタンスコストが安定したことで確定利用割引を利用しやすくなりました。最近、他のチームメンバーが割引を有効にするタスクを推進してくれたことで、さらなるコスト削減効果が得られました。

cost
インスタンスコスト削減

CronJob 実行時間の短縮

CronJob 実行時間が短縮されました。

execution time
CronJob 実行時間の短縮

リソース使用の安定化

リソース設定以前は CPU request が設定されていなかったり、メモリ設定が不適切な Pod が多く存在しており、 Noisy Neighbor の影響と思われる現象が発生しておりました。その数をちゃんと計測しているわけではないので主観が入りますが、施策実施後はクラスタ全般がより安定したことを感じています。

今後について

マニフェストレポジトリの統一と自動化

以上により、既存の Pod のマニフェストは適切に設定がなされるようになりました。一方で、新規に追加されるマニフェストについては適切な設定がなされる保証はありません。

マニフェストが追加される頻度はそこまで高くないため、現状は SRE による CODEOWNER レビューによってこれを担保しています。今後は、マニフェストレポジトリの統一と Open Policy Agent (OPA) の導入によって自動化を進められれば、と考えています。

Re-architecture の機運

以上により、 Kubernetes 周りに顕在化していた問題が概ね解消され、運用コストが削減されました。また、その後ドメイン体制移行によって運用面の各チームへの移譲が進んだこと、様々な施策によって Toil が削減されたこと、ローンチが一段落したことも相まって、SRE チームとしてより将来に目を向けられる体制になってきたことを感じています。

現在は Re-architecture に対する機運が高まっていることを感じます。その中でいよいよ Kubernetes クラスタをリプレースしたり、はたまた Cloud Run に移行する話などが上がってくるかもしれませんが、今回の施策を行ったことでそのような議論にもじっくり腰を据えて向き合えようになりました。

Wrap up

弊社では、今後のさらなる 10x 案件に向けて採用を行っています。弊チームは SLO の策定等、スタートアップの事業フェーズながらも SRE プラクティスに向き合える環境を作ってきました。弊社に興味がある方はカジュアル面談へのご応募をお待ちしております!

明日は SWE お買い物チームの @takanamito さんが記事を公開する予定です。お楽しみに!