blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2023.06.22 技術記事

DeNA 的 GKE 運用 ~ Pod 集約率編 ~ [DeNA インフラ SRE]

by Jun Matsuura

#infrastructure #container #kubernetes #gcp #gke

はじめに

こんにちは。インフラエンジニアの松浦です。 IT 基盤部に所属し、全世界向けのゲームタイトルのインフラ運用を担当しています。

先日あるプロジェクトで Google Kubernetes Engine (以降 GKE) でインフラを構築することになり、そこで GKE のコスト削減のために、 ノード 数削減すなわち Pod 集約率の向上 (1台のノードで出来るだけ多くの Pod を動かす) に取り組みました。

今回はそこで行ったことをご紹介します。

概要

今回のプロジェクトでは、 Kubernetes (以降 K8s) の Horizontal Pod Autoscaler 使用してアプリケーション Pod をオートスケールさせます。 このため、スケールアウト・スケールイン後 (Pod の増減により クラスタオートスケーラー (以降 CA) がノードを増減させた後) でも、Pod 集約率が高くなっていることをゴールとします。

Pod 集約率は、ノード単位で下記のように定義することにします。 $$ Pod 集約率 = \frac{リソースリクエスト}{割当可能なリソース}$$

Pod の設定の一つに リソースリクエスト がありますが、これは Pod 内の各コンテナで必要とする CPU とメモリ等のリソース量を指定したものです。 K8s スケジューラ はこのリソースリクエストを元に、どのノードにどの程度 Pod を配置するかを決定します。

GKE コンソールからクラスタ>ノードと辿ると、ノード単位での リクエストされた CPU割当可能な CPU 等が確認できます。これらの値が近いほど Pod 集約率が高いと言えます。

Pod 集約率の変化もわかるように、下記のような Monitoring ダッシュボードを作成して確認していきました。

ダッシュボードサンプル の Terraform コード
resource "google_monitoring_dashboard" "sample_dashboard" {
  dashboard_json = <<EOF
{
  "displayName": "${local.sample_dashboard.display_name}",
  "mosaicLayout": {
    "columns": 12,
    "tiles": [
      {
          "height": 4,
          "widget": {
            "title": "Node Memory Requests / Available",
            "xyChart": {
              "chartOptions": {
                "mode": "COLOR"
              },
              "dataSets": [
                {
                  "plotType": "LINE",
                  "targetAxis": "Y1",
                  "timeSeriesQuery": {
                    "prometheusQuery": "sum(kube_pod_container_resource_requests{resource=\"memory\",job=\"kube-state-metrics\"}  * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) ((kube_pod_status_phase{phase=~\"Pending|Running\"} == 1))) by (node) / max(kube_node_status_allocatable{resource=\"memory\"}) by (node)\n"
                  }
                }
              ],
              "timeshiftDuration": "0s",
              "yAxis": {
                "label": "y1Axis",
                "scale": "LINEAR"
              }
            }
          },
          "width": 6,
          "yPos": 24
      },
      {
          "height": 4,
          "widget": {
            "title": "Node CPU Requests / Available",
            "xyChart": {
              "chartOptions": {
                "mode": "COLOR"
              },
              "dataSets": [
                {
                  "plotType": "LINE",
                  "targetAxis": "Y1",
                  "timeSeriesQuery": {
                    "prometheusQuery": "sum(kube_pod_container_resource_requests{resource=\"cpu\",job=\"kube-state-metrics\"}  * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) ((kube_pod_status_phase{phase=~\"Pending|Running\"} == 1))) by (node) / \nmax(kube_node_status_allocatable{resource=\"cpu\"}) by (node)\n"
                  }
                }
              ],
              "timeshiftDuration": "0s",
              "yAxis": {
                "label": "y1Axis",
                "scale": "LINEAR"
              }
            }
          },
          "width": 6,
          "xPos": 6,
          "yPos": 24
      }
    ]
  },
  "name": "projects/${local.sample_dashboard.project_number}/dashboards/${local.sample_dashboard.id}"
}
EOF
}

このダッシュボードサンプルでは、ノード単位で、ステータスが Pending または Running である Pod のリソースリクエスト合計を割当可能なリソースで割ったものを、 PromQL を使用して表示しています。 Managed Service For Prometheus を使用してメトリクスを取得することを想定しており、 kube-state-metrics の導入が必要です。

ダッシュボードではなく、Monitoring > Metrics Explorer > CODE EDITOR で、下記のように PromQL だけを指定して確認することもできます。

sum(kube_pod_container_resource_requests{resource="memory",job="kube-state-metrics"}  * on (namespace, pod, cluster) group_left() max by (namespace, pod, cluster) ((kube_pod_status_phase{phase=~"Pending|Running"} == 1))) by (node) / max(kube_node_status_allocatable{resource="memory"}) by (node)

リソースリクエストの決定

リソースリクエストが大きすぎると、ノード上で起動できる Pod が減ってしまい、Pod 集約率は低くなってしまいます。よってまずは適切なリソースリクエストを決定し設定しておく必要があります。

Pod 内の各コンテナが使用する CPU やメモリは、Pod に負荷をかけながら、kubectl top pod を実行することで確認できます。

kubectl top pod ${POD_NAME} --containers

また Monitoring ダッシュボード > GKE Compute Resources - Workload ViewCPU usage by containerMemory usage by container でも確認できます。

これを参考に、Pod 内の各コンテナに必要であろう値をリソースリクエストとして設定します。

これでスケールアウト後の Pod 集約率をある程度改善できました。

マシンタイプの決定

リソースリクエストの調整によってある程度の改善はできましたが、メモリに比べて CPU に関しては Pod 集約率がまだ低い状態でした。

アプリケーションによって使用する CPU とメモリの量と比率は異なるので、そのアプリケーションの Pod が適切に収まるスペックのマシンタイプを選択する必要があります。

今回は既存のマシンタイプに適切なものが無かったので、 カスタムマシンタイプ を定義して使用しました。

これで Pod が使用する CPU とメモリの比率に合ったリソースを持つノードになり、さらに Pod 集約率を改善することができました。

スケールイン後の Pod 集約率改善

ここまでの設定値の状態で、負荷を増減させてスケールアウトやスケールイン時の Pod 集約率を確認したところ、スケールアウト時は最適な集約率が維持できていましたが、スケールイン時は期待した集約率になりませんでした。

GKE には 自動スケーリングプロファイル という設定があり、下記のいずれかを選択できます。

  • balanced : デフォルトのプロファイル。
  • optimize-utilization: クラスタ内で余剰リソースを保持するよりも使用率の最適化を優先させます。

この設定を optimize-utilization に変更すれば使用率の最適化が優先され、結果的に Pod 集約率も上がると考え、変更してみました。

しかしこの設定だけでは Pod 集約率は上がりませんでした。

GKE コンソール>クラスタ>対象のクラスタ>ログ>オートスケーラーログを確認したところ、下記のような noScaleDownno.scale.down.node.pod.not.enough.pdb のログ が出ていました。

※ ログサンプル

{
  "insertId": "63bed721-f0de-4d2f-be2f-2d405e8dc470@a1",
  "jsonPayload": {
    "noDecisionStatus": {
      "measureTime": "1676365117",
      "noScaleDown": {
        "nodesTotalCount": 9,
        "nodes": [
          {
            "reason": {
              "parameters": [
                "my-application-ccbfd99c8-8fwcv"
              ],
              "messageId": "no.scale.down.node.pod.not.enough.pdb"
            },
以下略

このログは対象の Pod に十分な PodDisruptionBudget (Pod 停止予算、以降 PDB) が残っていないためスケールインできないことを表しています。

PDB は、同時に停止できる (又は同時に起動している必要のある) Pod のレプリカ数を指定する設定です。「PDB が残っていない」というのは、何らかの理由でレプリカが必要数分起動しておらず、PDB の設定を守るためにこれ以上 Pod を停止できないことを表しています。

もともと安全のためにアプリケーション Pod の PDB は maxUnavailable: 1 (同時に停止できるレプリカ数は最大 1 個) で設定していたのですが、これを maxUnavailable: 10% (同時に停止できるレプリカ数は必要なレプリカ数の最大 10%) に変更したところ、ログは出なくなりスケールインできるようになりました。

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: my-application-pdb
spec:
  maxUnavailable: 10%
  selector:
    matchLabels:
      app: my-application

このように PDB 設定が厳しすぎるとスケールインのブロッカーとなってしまうようです。

これでスケールイン後の Pod 集約率を上げることができました。

スケジューラと CA の実装について

前述の通り、今回は GKE の自動スケーリングプロファイルを optimize-utilization に変更し PDB を調整することで Pod 集約率を改善できました。

今回行った設定が関係していると思われる部分に関して、仕様や実装をもう少し確認してみました。

GKE の実装

※ GKE や K8s のドキュメントでは、スケールインのことがスケールダウンと記述されています。

GKE のドキュメントの optimize-utilization の説明 には下記のようにあります。

  • クラスタ オートスケーラーはクラスタをより積極的にスケールダウンします
  • Pod 仕様のスケジューラ名が gke.io/optimize-utilization-scheduler に設定されます

この点から GKE 標準のスケジューラと CA の実装について、下記のように推測しました。

  • 実装は github で公開されているものと基本的に同じはず ( GKE のドキュメント内には K8s ドキュメントや github への参照リンクがあるので )
  • optimize-utilization を設定した場合の変化
    • スケジューラのプロファイルに optimize-utilization-scheduler ( NodeResourcesFit プラグイン のスコアリングストラテジが MostAllocated に変更されたもの ) が追加され、スケジューラを指定していない Pod についてはこのプロファイルでスケジュールされると思われる
    • CA のパラメータ scale-down-utilization-threshold はデフォルトの 0.5 より大きくなると思われる

スケジューラ

K8s のスケジューラは スケジューリングフレームワーク と呼ばれる設計で作られています。

下記図のように拡張点があり、ここに プラグイン を登録し、各処理を実装・変更できます。

scheduling-framework-extensionshttps://github.com/kubernetes/enhancements/tree/master/keps/sig-scheduling/624-scheduling-framework より転載

前述の NodeResourcesFit プラグインは図のスケジューリングサイクルの拡張点 Score などに登録するものです。 またそのスコアストラテジ MostAllocated で動くスコアリングロジックは、Pod 集約率が高いノードほど高スコアになり、優先して Pod が配置されます。

このため、スケールインする際の Pod の移動 (削除するノードから Pod を削除し別のノードで起動させる) において、Pod が集約されやすくなります。

ちなみにデフォルトのスコアストラテジ LeastAllocated の場合は、逆に Pod 集約率の低いノードに優先して配置さるため、集約されにくくなります。

コード上では下記のように実装されていました。

tag=v1.24.11 を確認しています

  1. main 関数 でコマンド作成
  2. コマンド実行
  3. コマンド引数とオプションに基づいてスケジューラを作成
    1. スケジューラ作成
    2. プラグインレジストリ構築
    3. NodeResourcesFit プラグイン初期化
    4. スコアストラテジ取得
    5. スコアストラテジとその実装のマッピング
    6. ストラテジの実装
      1. MostAllocated: Pod 集約率が高いノードを優先
      2. LeastAllocated (default): Pod 集約率が低いノードを優先
  4. スケジューラ実行
  5. 1つの Pod に対するスケジュール処理を実行
  6. Pod をノードリスト内のいずれかのノードにスケジュール
    1. SchedulePod 関数は Scheduler オブジェクト作成時に初期化
  7. Pod に適合するノードをフィルタリング
  8. 複数の候補ノードがある場合はスコアプラグインで優先順位を付ける
    1. スコアプラグイン実行
    2. 1つのスコアプラグイン実行
    3. ScorePlugin ingerface の Score を実行
    4. スコア拡張点で NodeResourcesFit プラグインを実行
    5. スコアストラテジにマッピングしていた実装を実行
    6. 前述の MostAllocated などの実装を実行
  9. 優先順位をつけたノードの中から1つを選ぶ

CA

CA の起動パラメータ scale-down-utilization-threshold はスケールイン対象とするノードの Pod 集約率のしきい値を指定するものです。このしきい値を下回るノードが削除候補となります。よってこの値を大きくすることでより多くのノードが削除対象となる可能性があります。

スケールインでは PDB も考慮されます。 CAはノードの終了を開始する前に、そこにスケジュールされている Pod の PDB で少なくとも 1 つのレプリカを削除できることを確認します。 例えば PDB 設定が maxUnavailable: 1 の場合、1個でも起動できていないレプリカがあると、CA は PDB が残っていないと判断し、レプリカを削除できないのでノードを削除しません。 したがって、PDB の設定が厳しいと、何かしらの理由で Pod のレプリカが全て起動できていない場合にスケールインがブロックされやすくなります。

コード上では下記のように実装されていました。

tag=cluster-autoscaler-1.24.2 を確認しています

  1. CA の main ループから一連の処理を実行
  2. スケールインが有効な場合、不要なノード (全ての Pod を移動可能なノード) を決定
    1. 対象ノードの Pod 集約率を確認
      1. Pod 集約率がしきい値を下回っているか判定
      2. scale-down-utilization-threshold の値をしきい値として判定
    2. 候補の中から削除するノードを探す
    3. ノード単位で他の場所に移動させるべきPodのリストを計算
    4. 現在の PDB 状態から Pod を移動可能か判定
    5. PDB が残っているか確認
  3. スケールイン実行

おわりに

以上で、スケールイン・スケールアウト後にも Pod 集約率が高くなり、ノード数を削減しコストを削減することができました。

IT 基盤部では様々なサービスのインフラを、今回の GKE のような CaaS や IaaS を使って構築・運用しています。

こちらで IT 基盤部の紹介をしていますので、ぜひご覧ください。

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。