Kubernetes: Service の静的ノードポート用レンジを分割する (ServiceNodePortStaticSubrange)

Service の静的ノードポート用のレンジを分割する [`ServiceNodePortStaticSubrange`](https://kubernetes.io/docs/concepts/services-networking/service/#reserve-nodeport-ranges-to-avoid-collisions-when-port-assigning) という機能が、Kubernetes v1.28 からデフォルトで有効になりました。これは、Service の動的なノードポートの割当と、静的なノードポートの衝突リスクを減らすための機能です。この記事では Kubernetes v1.28.1 時点で、この機能について調べた内容を記載しています。

## まとめ
:::note info
kube-apiserver の `--service-node-port-range` で指定される全体のポートレンジが 2 分割され、ポートの値が小さい方は静的割り当て用の優先レンジとなりました。静的なノードポートの指定する場合は、この優先レンジを使うと衝突のリスクを減らせます。

例えばポートレンジのデフォルト値 `30000-32767` の場合は、`30000-30085` が静的割当の優先レンジとなっています。静的なノードポートを使う場合は、ポートレンジの小さい値(この例だと `30000`)から使っていくと良いでしょう。
:::


## 背景
Service のノードポートは、動的・静的どちらの割り当ても可能です。動的・静的どちらの割り当ても、API サーバーが管理するノードポートのレンジから選択されます。しかし動的なノードポートの割当はこのレンジ内からランダムに選択されるため、静的なノードポートと衝突する可能性がありました。

例えば以下のように Service に `type: NodePort` を指定し `.spec.ports[].nodePort` を指定しない場合は、動的にノードポートが割り当てられます。

```yaml:動的なノードポートの Service
apiVersion: v1
kind: Service
metadata:
  name: nodeport-dynamic
spec:
  type: NodePort
  selector:
    run: nginx
  ports:
  - port: 80
```

今回はノードポートに `30296` が割り当てられました。

```
$ kubectl get svc nodeport-dynamic
NAME               TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
nodeport-dynamic   NodePort   10.97.31.191   <none>        80:30296/TCP   69s
                                                              ^^^^^
```

一方、`.spec.ports[].nodePort` を指定することで静的にノードポートを指定できます。ここで指定したポートが既に動的なポートで使われていた場合に衝突が起こります。

```yaml:静的なノードポートの Service
apiVersion: v1
kind: Service
metadata:
  name: nodeport-static
spec:
  type: NodePort
  selector:
    run: nginx
  ports:
  - port: 80
    # 静的にポートを指定できる
    nodePort: 30296
```

この例のように、静的なノードポートの指定が衝突した場合、以下のようなエラーで作成に失敗します。今回の機能が実装されるまでは、どのポートを指定しても常にこのリスクがありました。

```
The Service "nodeport-static" is invalid: spec.ports[0].nodePort: Invalid value: 30296: provided port is already allocated
```

この機能の以前に、Service の IP アドレスの衝突を避ける `ServiceIPStaticSubrange` という機能が Kubernetes v1.26 からデフォルトで有効となっています。今回の `ServiceNodePortStaticSubrange` という機能はそれのノードポート版と言えます。

https://qiita.com/tkusumi/items/da474798c5c9be88d9c5

## 分割されるポートのレンジ
この衝突の問題を解決するのが、`ServiceNodePortStaticSubrange` という機能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100377/bdccf896-f85d-ed37-bb6d-36dc03e77548.png)

ノードポートで利用できる動的・静的を含めた全体のレンジは kube-apiserver の `--service-node-port-range` で指定します。デフォルト値は 30000 〜 32767 となっています。この機能では、この全体のレンジを２つに分割し、ポートの値が小さい方を静的割り当て用の優先レンジ、値が大きい方を動的割り当て用の優先レンジとして分割して管理します。

例えばデフォルト値の 30000 〜 32767 の場合は以下のように分割されます。

ポート範囲 | 個数 | 用途
--- | --- | ---
30000-30085 | 86   | 静的割り当て優先レンジ
30086-32767 | 2682 | 動的割り当て優先レンジ


:::note warn
このレンジはあくまで優先して使われるレンジであり、動的割り当て用レンジが枯渇した場合、静的割り当て用レンジからも動的に割り当てられる点にご注意ください。
:::

## 静的割り当て用レンジのサイズ
静的割り当て用の優先レンジは、`--service-node-port-range` のポートの個数(式の`nodeport-size`)に応じて 0〜128 個の範囲で分割されます。この静的用レンジの大きさは、下記の計算式求められます。ただしポートの個数が 16 以下の場合は 0 となります。

```
min(max(16, nodeport-size / 32), 128)
```

以下はポートレンジごとの例です、カッコ内はポートの個数を示しています。

--service-node-port-range | 静的割り当て優先レンジ | 動的割り当て優先レンジ
--- | --- | ---
30000-30015 (16)   | 無し | 30000-30015
30000-30127 (128)  | 30000-30015 (16) | 30016-30127 (112)
30000-32767 (2768) | 30000-30085 (86) | 30086-32767 (2682)
30000-34095 (4906) | 30000-30127 (128) | 30128-34095 (3968)


この計算式と例は Kubernetes 公式ブログの記事 [Kubernetes 1.27: Avoid Collisions Assigning Ports to NodePort Services](https://kubernetes.io/blog/2023/05/11/nodeport-dynamic-and-static-allocation/#how-can-you-avoid-nodeport-service-port-conflicts) で紹介されています。詳細はそちらをご覧ください。


## デモ
Kubernetes v1.28.1 の環境(minikube)で、動的用レンジの個数分 NodePort を使った Service を作り、ポートの割当を確認しました。以下の gif は割当の状況を早送りしたものです。静的用のレンジ (`30000-30085`) には割当が行われていないことが確認できます。`.` は空き、`x` は割り当て済みを表しています。

![node-port-static-subrange.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100377/9b0c8a1c-cc69-8038-b656-9cc76c5d8171.gif)

以下はデモ全体の動画です。動的用のレンジを使い切ると静的用のレンジが使われます。この様子はデモの [2:09](https://asciinema.org/a/0WSpqaHID1ktzuDWKR0jQs3oE?t=2:09) から確認できます。

[![ServiceNodePortStaticSubrange Demo](https://asciinema.org/a/0WSpqaHID1ktzuDWKR0jQs3oE.svg)](https://asciinema.org/a/0WSpqaHID1ktzuDWKR0jQs3oE)

なお、全体のレンジを使い切ると、下記のように `allocate a nodePort: range is full` というエラーが返るようになります。

```console
Error from server (InternalError): error when creating "svc.yaml": Internal error occurred: failed to 
allocate a nodePort: range is full    
```

### 参考: デモのスクリプト
Service の作成につかったスクリプトは以下です。高速化のため 100 個単位でまとめて送信しています。

:memo: `metadata.generateName` を使うと、オブジェクトの名前生成を Kubernetes に任せることができます。

```bash
#!/bin/bash
n=${1:-0}

svc=$(mktemp)
cat >"$svc" <<EOF
apiVersion: v1
kind: Service
metadata:
  generateName: nodeport-
spec:
  type: NodePort
  selector:
    run: nginx
  ports:
  - port: 80
---
EOF

for ((i = 0; i < n / 100; i++)); do
  for ((j = 0; j < 100; j++)); do
    cat "$svc"
  done | kubectl create -f -
done

for ((i = 0; i < (n % 100); i++)); do
  kubectl create -f "$svc"
done
```

ノードポートの割当表示は以下のように kubectl で取得したノードポート一覧を ruby のスクリプトで整形し、それを watch で 1 秒おきに表示しています。

```console
watch --color -n 1 "kubectl get svc -o json | jq '.items[].spec.ports[] | select(.nodePort != null) | .nodePort'| ./plot.rb"
```

```ruby
#!/usr/bin/env ruby
require 'set'

allocated = Set.new
while line = gets
  allocated.add(line.chomp)
end

# header
print '      | '
10.times do |n|
  printf '%-10d', n * 10
end
puts
print '------+ '
puts '-' * 100

(30_000..32_767).each do |n|
  print "#{n} | " if (n % 100).zero?
  if allocated.include?(n.to_s)
    print "\e[1m\e[31mx\e[0m"
  else
    print "\e[32m.\e[0m"
  end
  puts if ((n + 1) % 100).zero?
end
puts
```

## 参考
- [KEP-3668: Reserve Nodeport Ranges For Dynamic And Static Port Allocation](https://github.com/kubernetes/enhancements/tree/master/keps/sig-network/3668-reserved-service-nodeport-range)
- https://kubernetes.io/docs/concepts/services-networking/service/#reserve-nodeport-ranges-to-avoid-collisions-when-port-assigning
- https://kubernetes.io/blog/2023/05/11/nodeport-dynamic-and-static-allocation/