GKE クラスタでは64ノードしか作れない?!Cloud NAT でハマった話

GKE クラスタでは64ノードしか作れない?!Cloud NAT でハマった話

こんにちは、SREグループのカンタンです!

GO株式会社では AWS EKS と GCP GKE の Kubernetes クラスタを活用していますが、 数週間前に本番 GKE クラスタのメンテナンス作業を実施した際にノードを65台以上に増やせなくて急遽対応が必要になりました。

まさか GKE クラスタでは64ノードしか作れないことはないですよね?

本記事では発生した問題の原因と対策方法を紹介します。

発生した問題

GKE クラスタのノードプールの再設計とマシンタイプの調整のためにメンテナンス作業を実施しました。
その作業の中でクラスタ全体のノード数を60台ぐらいから80台までに増やす必要がありましたが、ノードを増やしたら以下の現象が起きてしまいました。

  • 新しく作成されたノードの DaemonSet Pod が ImagePullBackOff 状態になった
  • 同じノードでも問題なく動いている Pod もあった
  • 時間を置いても問題が直らなかった

サポートに問い合わせたら、Cloud NAT のポート不足が発生し Pod の Docker イメージをダウンロードできなかったことが明らかになりました。
Cloud NAT に IP アドレスを追加すればポート不足問題が解決になり Docker イメージのダウンロードと Pod の起動ができるようになる見込みでした。

ただし不思議に思ったことがいくつかありました。

  • GKE クラスタベストプラクティス にしたがって作っているつもりで、Cloud NAT もデフォルト設定を利用しているのに80ノードも作れない?
  • GKE クラスタから Cloud NAT を通る通信がそこまで頻繁に発生していないのにポートが足りない?
  • Cloud NAT の設定的に VM あたり64ポートが割り当てられているため 1,000 ノード以上作れるはず
  • ダウンロードできる Docker イメージとダウンロードできない Docker イメージがある
  • 調べても事例がなくて同じ問題にハマっている人がいなさそうだった

根本原因が別にあるという気がしたため Cloud NAT の公式ドキュメントを全て読み、仕組みを理解し調査しました。

再現

調査のために別環境で問題を再現しました。GKE クラスタと Cloud NAT の構成が以下の通りでした。

  • GKE クラスタ限定公開クラスタ (プライベートクラスタ)
    • VPC ネイティブ クラスタのためエイリアス IP 範囲を利用し Pod に内部 IP アドレスを割り当てている
    • ノードにパブリック IP が付いていないためインターネットにアクセスするために Cloud NAT が必要
    • ノードの IP レンジを 10.0.0.0/16 にすることで最大65,532ノードが作れるはず (参考)
    • Pod の IP レンジを 10.52.0.0/14 にすることで最大112,640 Pod と1,024ノードが作れるはず (参考)
  • Cloud NAT はクラスタのアウトバウンド通信の IP アドレスを固定化するために利用している
    • IP 制限がかかっている外部サービスに登録するため
    • そのため NAT IP アドレスの自動割り振りを使わず IP アドレスを一つだけ手動割り当てている
    • それ以外はデフォルト設定を利用している

構成図を以下にまとめています。 network

参考までTerraform設定を以下にまとめています。

ネットワーク

  • クラスタ専用のサブネットワーク
  • ノード、Pod、Serviceそれぞれの専用 IP レンジ
locals {
  gcp_region = "asia-northeast1"
}

data "google_project" "project" {}

resource "google_compute_network" "vpc_network" {
  name = "my-vpc-network"
}

resource "google_compute_subnetwork" "cluster_subnetwork" {
  name        = "my-cluster-subnetwork"
  description = "Subnetwork for GKE cluster"

  region  = local.gcp_region
  network = google_compute_network.vpc_network.self_link

  # Nodes IP range
  ip_cidr_range = "10.0.0.0/16" # at most 65,532 nodes

  # Pods IP range
  secondary_ip_range {
    range_name    = "my-cluster-pods"
    ip_cidr_range = "10.52.0.0/14" # at most 112,640 pods and 1,024 nodes
  }

  # Services IP range
  secondary_ip_range {
    range_name    = "my-cluster-services"
    ip_cidr_range = "172.20.16.0/20" # at most 4,096 services
  }

  private_ip_google_access = true
}

クラスタとノードプール

resource "google_container_cluster" "cluster" {
  name        = "my-cluster"
  description = "GKE cluster"

  network    = data.google_compute_network.vpc_network.name
  subnetwork = google_compute_subnetwork.cluster_subnetwork.name
  location   = local.gcp_region

  remove_default_node_pool = true
  initial_node_count       = 1

  # IP ranges
  ip_allocation_policy {
    cluster_secondary_range_name  = "my-cluster-pods"
    services_secondary_range_name = "my-cluster-services"
  }

  private_cluster_config {
    enable_private_nodes    = true
    # Masters IP ranges
    # 172.16.0.0 ~ 172.16.0.15 (16 addresses)
    master_ipv4_cidr_block  = "172.16.0.0/28"
  }
}

resource "google_container_node_pool" "pool" {
  name     = "my-pool"
  location = local.gcp_region
  cluster  = "my-cluster"

  node_config {
    machine_type = "e2-medium"
    image_type   = "COS_CONTAINERD"
  }
}

Cloud NAT

  • クラスタのサブネットワークのみに適用 (クラスタ以外のVMがそのCloud NATを利用できない)
  • IP アドレスが一つ
resource "google_compute_router" "router" {
  name        = "cloud-router-${local.cluster_name}"
  network     = google_compute_network.vpc_network.name
  region      = local.gcp_region
  project     = data.google_project.project.project_id
  description = "Cloud router for GKE cluster NAT"
}

resource "google_compute_address" "nat" {
  count        = 1
  name         = "nat-ip-my-cluster-00${count.index}"
  description  = "IP address for GKE cluster NAT"
  region       = local.gcp_region
  address_type = "EXTERNAL"
}

resource "google_compute_router_nat" "nat" {
  name    = "nat-gke-cluster"
  project = data.google_project.project.project_id
  router  = google_compute_router.router.name
  region  = local.gcp_region

  nat_ip_allocate_option             = "MANUAL_ONLY"
  source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"

  nat_ips = google_compute_address.nat.*.self_link

  subnetwork {
    name                    = google_compute_subnetwork.cluster_subnetwork.name
    source_ip_ranges_to_nat = ["ALL_IP_RANGES"]
  }
}

色々試したら、問題の再現最小条件がわかりました。

  • 64ノードまでは問題ない
  • 65台目を起動すると問題が発生している (一部 Pod が ImagePullBackOff 状態になる)
  • GCRDocker Hubホスティングされているイメージは問題ない
  • ECRquay.ioホスティングされているイメージはダウンロードできない

80台どころではなく64ノードしか作れないことが分かりました。

Cloud NAT

原因を理解するため、NAT と Cloud NAT の仕組みを理解する必要があるため軽く説明します。

まずは一般的に言うとプライベートネットワークからインターネットにアクセスするため Source Network Address Translation (SNAT) という仕組みを利用できます。
パブリック IP アドレスを持っている NAT Gateway がプライベートネットワークとインターネットの間に入っていて、プライベート IP アドレスしか持っていないホストが宛先と接続を行う際、NAT Gateway が NAT ポートを発行してパケットのソース IP アドレスとポートを自分のパブリック IP アドレスと NAT ポートに変更してくれます。
送信元のソース IP とポート、宛先の IP とポートと NAT ポートの紐付け情報をマッピングテーブルに保存することで、宛先からレスポンスが返ってきた時に送信元ホストに転送できます。

最大64,512個の NAT ポートしか使えないですが宛先が異なっていれば同じポートを使いまわせるため実質宛先ごとに64,512接続が作れます。
それ以上の接続数が必要な場合、NAT Gateway にパブリック IP アドレスを増やす形で対応可能になります。

snat

今回は GKE プライベートクラスタからインターネットに接続するために Cloud NAT の Public NAT を利用して SNAT を行っています。
公式ドキュメントに記載されているように Cloud NAT は実際に動いているインスタンスが通信をプロキシしてくれるわけではなく、ソフトウェア定義サービスです。

Cloud NAT はソフトウェア定義の分散マネージド サービスです。プロキシ VMアプライアンスをベースにしていません。Cloud NAT は、Virtual Private Cloud(VPC)ネットワークを強化する Andromeda ソフトウェアを構成します。これにより、リソースに対して送信元ネットワーク アドレス変換(送信元 NAT または SNAT)を提供します。また、Cloud NAT は、確立された受信レスポンス パケットに対してのみ宛先ネットワーク アドレス変換(宛先 NAT または DNAT)を提供します。

その関係で、Cloud NAT は NAT ポートを事前に各ノード (VM) に割り当ている仕組みになっています。NAT ポートの割り当て方法が2つあります。

  • 静的ポートの割り当て:全てのノードに同じポート数が割り当てられている。そのポート数を設定で変更できるが、全部で64,512ポートしか使えないため例えば256ポートを設定した場合252ノードしか作れない。
  • 動的ポートの割り当て:各ノードのポート数が最小ポート数と最大ポート数の間に必要に応じて動的にスケールする。ポートが不足しそうになると自動的に増える仕組みになっているが瞬間的ではないためレイテンシに影響する可能性がある。

アウトバウンド通信のノードの偏りがあると、静的ポートの割り当ての場合は利用されていない無駄になっている NAT ポートが生まれてしまうため、動的ポートの割り当ての方がポートの利用効率を最適化できますが、レイテンシに影響する可能性があるためユースケース次第です。

今回は Public NAT のデフォルト設定を利用しているため、静的ポートの割り当てが有効になっています。

原因の特定

64ノードしか作れない原因を特定するため、GKE クラスタと Cloud NAT の構成を振り返りノード数を制限する要因を確認しました。

  • ノードの IP レンジに /16 CIDR を利用しているため65,532ノードまで作れる
  • Pod の IP レンジに /14 CIDR を利用しているため、1,024ノードまで作れる (ノードあたり 110 Pod まで作れるようになっていて、その場合ノード数が1,024に制限される)
  • Cloud NAT に一つの IP アドレスしか設定していない、それ以外デフォルト設定を利用しているため、
    • 静的ポートの割り当てが有効になっていて、最小ポート数がデフォルトの64になっている
    • NAT IP アドレスごとに 64,512/64=1,008ノードが作れる
    • NAT IP が一つしかないため、全部で1,008ノードまで作れる

つまり、少なくとも1,008ノードを作れるはずです。

参考に、作成した Cloud NAT の詳細画面になります。 NAT詳細

ただし、GKE の相互作用公式ドキュメントを確認すると以下が記載されています。

Google Kubernetes Engine(GKE)VPC ネイティブ クラスタは、複数の IP アドレス(/32 より小さいネットマスク)を含むエイリアス IP 範囲を各ノードに常に割り当てます。

静的ポートの割り当てが構成されている場合、Public NAT のポート予約手順では、ノードあたり 1,024 個以上の送信元ポートが予約されます。VM あたりの最小ポート数に指定された値が 1,024 を超える場合は、その値が使用されます。

つまり、GKE で Cloud NAT のデフォルト挙動である静的ポートの割り当てを利用する場合、GCP コンソールに表示されている64ポートではなく、各ノードに1,024ポートが割り当てられています!
Metrics Explorer でノードあたりに割り当てられているポート数を確認したら、確かに1,024になっていました(VM Instance > Nat > Allocated Ports)。

つまり、その場合は 64,512/1,024=63ノードしか作れないことになってしまいます。

公式ドキュメントをきちんと読めばわかる話でしたが、Cloud NAT の詳細画面だけを見るとノードあたり64ポートが設定されるように見えるため誤解していました。

なお実際に試した際に63ではなく64ノードまで作成できましたが、サポートに問い合わせた結果今回は内部の仕組みの都合でそう言う状態になったが本来は63ノードまでしか作れないとのことでした。

ちなみに、一部のPodでしか問題が起きていないのは、GCR にホスティングされているイメージをダウンロードする際 Cloud NAT を通らないためでした。
Docker Hubに関しては深掘りしていないですがalpineなどよく利用されているイメージが内部的に GCR にキャッシュされている可能性があって、同じ理由の可能性が高そうです。

対策

今回の問題を解決するため、動的ポートの割り当てを有効にするように Cloud NAT の設定を変更しました。
その場合は上述した接続成立時のレイテンシに影響する可能性があるため、Metrics Explorerで各ノードが利用しているポート数を確認した上で最小ポート数と最大ポート数を適切に設定することが重要です。
また、ダウンタイムが発生しないように最大ポート数を1,024以上に設定する必要があります。

Terraform設定的に以下の変更を行いました。

resource "google_compute_router_nat" "nat" {
  name    = "nat-gke-cluster"

  ...

  nat_ips = google_compute_address.nat.*.self_link

+ enable_dynamic_port_allocation      = true
+ enable_endpoint_independent_mapping = false
+ min_ports_per_vm                    = 64
+ max_ports_per_vm                    = 1024

  ...
}

なお動的ポートの割り当てに切り替える場合は「Endpoint-Independent Mapping」が使えなくなりますが、 NAT トラバーサルを利用しない限り必要がなさそうでした

その設定を適用することで64,512/64=1,008ノードまで作れるようになりました!念の為、最小ポート数を後から上げられるように NAT IP アドレスを一つ追加しました。

最終的な設定が以下のようになります。

# --------------------
# Cloud NAT
# --------------------
resource "google_compute_router" "router" {
  name        = "cloud-router-${local.cluster_name}"
  network     = google_compute_network.vpc_network.name
  region      = local.gcp_region
  project     = data.google_project.project.project_id
  description = "Cloud router for GKE cluster NAT"
}

resource "google_compute_address" "nat" {
  count = 2
  name         = "nat-ip-gke-cluster-00${count.index}"
  description  = "IP address for GKE cluster NAT"
  region       = local.gcp_region
  address_type = "EXTERNAL"
}

resource "google_compute_router_nat" "nat" {
  name    = "nat-gke-cluster"
  project = data.google_project.project.project_id
  router  = google_compute_router.router.name
  region  = local.gcp_region

  nat_ip_allocate_option             = "MANUAL_ONLY"
  source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"

  nat_ips = google_compute_address.nat.*.self_link

  enable_dynamic_port_allocation      = true
  enable_endpoint_independent_mapping = false
  min_ports_per_vm                    = 64
  max_ports_per_vm                    = 1024

  subnetwork {
    name                    = google_compute_subnetwork.cluster_subnetwork.name
    source_ip_ranges_to_nat = ["ALL_IP_RANGES"]
  }
}

最後に

まさか GKE クラスタでは64ノードしか作れないことはないですよね?

幸いなことにそんなことはなかったです!

GKE プライベートクラスタで NAT IP アドレスを一つだけ登録し、Cloud NAT のデフォルト設定を利用すると発生する問題のため同様にハマる方が一定数いらっしゃるかと思います。

その場合は以下の懸念を認識した上で Cloud NAT の動的ポートの割り当てに切り替えることがおすすめです。

  • NAT ポートが自動的にスケールするものの瞬間的ではないためレイテンシに影響する可能性がある
  • Endpoint-Independent Mapping が使えなくなる
  • ダウンタイムが発生しないように最大ポート数を1,024以上に設定する必要がある