Kubernetes の外部メトリクスを自作する

Kubernetes の外部メトリクスを次のフレームワークで実装してみました。

* [custom-metrics-apiserver](https://github.com/kubernetes-sigs/custom-metrics-apiserver)

# はじめに

Kubernetes は Aggregation Layer という仕組みを使って Kubernetes の API を拡張できるようになっており、外部メトリクスもこれを使います。

これにより、Kubernetes の API 呼び出し時に Kubernetes API サーバー（kube-apiserver）がプロキシとなって、```APIService``` で設定した拡張 API サーバー（extension-apiserver）を呼び出すようになります。

この extension-apiserver をカスタムメトリクスや外部メトリクス用に実装するためのフレームワークが [custom-metrics-apiserver](https://github.com/kubernetes-sigs/custom-metrics-apiserver) です。

# 実装

[custom-metrics-apiserver](https://github.com/kubernetes-sigs/custom-metrics-apiserver) で外部メトリクス用の extension-apiserver を実行するのに最低限必要な処理は次のようになります。

||処理内容|
|---|---|
|(a)|```ExternalMetricsProvider``` インターフェースを実装した Provider を用意|
|(b)|```AdapterBase``` へ (a) を ```WithExternalMetrics``` して ```Run``` を実施|

デフォルト設定で実行する場合のコードはこのようになります。
ポート番号 443 で extension-apiserver が立ち上がり、GetExternalMetric 等を呼び出すリクエストハンドラが適用されます。

```golang:main.go
package main

import (
	"context"

	"k8s.io/apimachinery/pkg/api/resource"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/util/wait"
	"k8s.io/klog/v2"
	"k8s.io/metrics/pkg/apis/external_metrics"
	basecmd "sigs.k8s.io/custom-metrics-apiserver/pkg/cmd"
	"sigs.k8s.io/custom-metrics-apiserver/pkg/provider"
)

type SampleProvider struct{}
// ExternalMetricsProvider インターフェースの実装
func (p *SampleProvider) ListAllExternalMetrics() []provider.ExternalMetricInfo {
    ...省略
}
// ExternalMetricsProvider インターフェースの実装
func (p *SampleProvider) GetExternalMetric(ctx context.Context, namespace string, metricSelector labels.Selector, info provider.ExternalMetricInfo) (*external_metrics.ExternalMetricValueList, error) {
    ...省略
}

func main() {
	cmd := basecmd.AdapterBase{}

	provider := SampleProvider{}

	cmd.WithExternalMetrics(&provider)

	if err := cmd.Run(wait.NeverStop); err != nil {
		klog.Fatal(err)
	}
}
```

## ExternalMetricsProvider の実装

ExternalMetricsProvider は次のように実装します。

|メソッド名|処理内容|
|---|---|
|ListAllExternalMetrics|その時点で有効なメトリクス名の一覧を返す|
|GetExternalMetric|条件に合致したメトリクスの内容を返す|

ここでは、固定の内容を返すように実装しました。

```golang:main.go　ExternalMetricsProvider実装例
func (p *SampleProvider) ListAllExternalMetrics() []provider.ExternalMetricInfo {
	return []provider.ExternalMetricInfo{
		{
			Metric: "sample1",
		},
		{
			Metric: "sample2",
		},
	}
}

func (p *SampleProvider) GetExternalMetric(ctx context.Context, namespace string, metricSelector labels.Selector, info provider.ExternalMetricInfo) (*external_metrics.ExternalMetricValueList, error) {
	name := info.Metric

	items := []external_metrics.ExternalMetricValue{
		{
			MetricName: name,
			MetricLabels: map[string]string{
				"app":      "sample",
				"category": "1",
			},
			Timestamp: metav1.Now(),
			Value:     *resource.NewQuantity(123, resource.DecimalSI),
		},
		{
			MetricName: name,
			MetricLabels: map[string]string{
				"app":      "sample",
				"category": "2",
			},
			Timestamp: metav1.Now(),
			Value:     *resource.NewScaledQuantity(456, resource.Micro),
		},
	}

	return &external_metrics.ExternalMetricValueList{
		Items: items,
	}, nil
}
```

## 注意点

12/23 の時点で ```go mod tidy``` に任せると、Kubernetes 関連ライブラリでバージョン ```v0.28.1``` と ```v0.29.0``` が混在してビルドエラーになりました。

そこで、とりあえず go.mod を以下のように編集してから ```go mod tidy``` を実施する事で回避しました。

```golang:go.mod バージョン混在問題の回避措置
module sample

go 1.21.5

require (
	k8s.io/apimachinery v0.28.1
	k8s.io/metrics v0.28.1
)
```

# 設定

extension-apiserver を Pod で実行し、APIService 登録すると、```/apis/{group}/{version}/...``` の API 呼び出し時に該当する extension-apiserver が呼び出されるようになります。

extension-apiserver は次のような権限が必要となるため、これらの権限を付与したサービスアカウントを使用します。

* kube-system の ```extension-apiserver-authentication``` ConfigMap 読み込み
* kube-apiserver へ ```SubjectAccessReview``` リクエスト送信

## サービスアカウント設定

必要な権限を付与するための Role は次のようになります。

|Role kind|Role name|適用される権限|
|---|---|---|
|Role|extension-apiserver-authentication-reader|extension-apiserver-authentication ConfigMap 読み込み|
|ClusterRole|system:auth-delegator|kube-apiserver へ SubjectAccessReview リクエスト送信許可|

これらを付与したサービスアカウントの設定はこのようになります。

```yaml:sample-metrics.yaml（ServiceAccount設定部分）
kind: ServiceAccount
apiVersion: v1
metadata:
  name: sample-metrics-apiserver
  namespace: default

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: sample-metrics:system:auth-delegator
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
- kind: ServiceAccount
  name: sample-metrics-apiserver
  namespace: default

---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: sample-metrics-auth-reader
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: extension-apiserver-authentication-reader
subjects:
- kind: ServiceAccount
  name: sample-metrics-apiserver
  namespace: default
```

## Deployment・Service 設定

Pod で実行するための設定を行います。
今回は手動でインポートしたコンテナイメージを使うため imagePullPolicy を Never にしています。

```yaml:sample-metrics.yaml（Pod設定部分）
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: sample-metrics-apiserver
  name: sample-metrics-apiserver
spec:
  replicas: 1
  selector:
    matchLabels:
      app: sample-metrics-apiserver
  template:
    metadata:
      labels:
        app: sample-metrics-apiserver
      name: sample-metrics-apiserver
    spec:
      serviceAccountName: sample-metrics-apiserver
      containers:
      - name: sample-metrics-apiserver
        image: sample-metrics
        imagePullPolicy: Never
        ports:
        - containerPort: 443
          name: https

---
apiVersion: v1
kind: Service
metadata:
  name: sample-metrics-apiserver
spec:
  ports:
  - name: https
    port: 443
  selector:
    app: sample-metrics-apiserver
```

## APIService 設定

外部メトリクスとして ```sample-metrics-apiserver``` を適用するための設定を行います。
接続先のポートはデフォルトで 443 になります。

```yaml:sample-metrics.yaml（APIService設定部分）
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  name: v1beta1.external.metrics.k8s.io
spec:
  service:
    name: sample-metrics-apiserver
    namespace: default
  group: external.metrics.k8s.io
  version: v1beta1
  insecureSkipTLSVerify: true
  groupPriorityMinimum: 100
  versionPriority: 100
```

なお、groupPriorityMinimum と versionPriority は必須で 1以上の値を指定する必要がありました。

# 動作確認

今回は [K3s](https://k3s.io/) 環境で実行しました。

## ビルド

このような Dockerfile を使用しました。

```docker:Dockerfile
FROM golang:1.21-bullseye AS build

WORKDIR /app

COPY go.mod ./
COPY go.sum ./
RUN go mod download

COPY *.go ./

RUN go build -o mainapp

FROM debian:bullseye-slim

WORKDIR /app

RUN apt-get update && rm -rf /var/lib/apt/lists/*

COPY --from=build /app/mainapp .

CMD ["./mainapp"]
```

今回は [BuildKit](https://github.com/moby/buildkit) でビルドし、そのコンテナイメージを（K3s へ）インポートします。

```sh:ビルド例
$ sudo buildctl build --frontend dockerfile.v0 --local context=. --local dockerfile=. --output type=oci,name=sample-metrics > sample-metrics.tar
```
```sh:インポート例
$ sudo k3s ctr images import sample-metrics.tar
```

## 実行

kubectl apply して Pod を実行します。

```sh:apply 例
$ kubectl apply -f sample-metrics.yaml 
serviceaccount/sample-metrics-apiserver created
clusterrolebinding.rbac.authorization.k8s.io/sample-metrics:system:auth-delegator created
rolebinding.rbac.authorization.k8s.io/sample-metrics-auth-reader created
deployment.apps/sample-metrics-apiserver created
service/sample-metrics-apiserver created
apiservice.apiregistration.k8s.io/v1beta1.external.metrics.k8s.io created
```

```sh:Pod の一覧
$ kubectl get pod
NAME                                        READY   STATUS    RESTARTS   AGE
sample-metrics-apiserver-7bc64b769f-6pdpw   1/1     Running   0          5s
```

## 動作確認

実行した外部メトリクスを動作確認します。

```/apis/external.metrics.k8s.io/v1beta1``` で ListAllExternalMetrics の戻り値に基づいたレスポンスが得られます。

```sh:メトリクス名の取得例
$ kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1"
{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"external.metrics.k8s.io/v1beta1","resources":[{"name":"sample1","singularName":"","namespaced":true,"kind":"ExternalMetricValueList","verbs":["get"]},{"name":"sample2","singularName":"","namespaced":true,"kind":"ExternalMetricValueList","verbs":["get"]}]}
```

```/apis/external.metrics.k8s.io/v1beta1/namespaces/{namespace}/{メトリクス名}``` で GetExternalMetric の結果を得られます。

```sh:sample1 メトリクス取得例
$ kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/default/sample1"
{"kind":"ExternalMetricValueList","apiVersion":"external.metrics.k8s.io/v1beta1","metadata":{},"items":[{"metricName":"sample1","metricLabels":{"app":"sample","category":"1"},"timestamp":"2023-12-23T04:16:47Z","value":"123"},{"metricName":"sample1","metricLabels":{"app":"sample","category":"2"},"timestamp":"2023-12-23T04:16:47Z","value":"456u"}]}
```

また、ListAllExternalMetrics の結果に無いメトリクス名を指定しても問題ありません。

```sh:aaa メトリクス取得例
$ kubectl get --raw "/apis/external.metrics.k8s.io/v1beta1/namespaces/default/aaa"
{"kind":"ExternalMetricValueList","apiVersion":"external.metrics.k8s.io/v1beta1","metadata":{},"items":[{"metricName":"aaa","metricLabels":{"app":"sample","category":"1"},"timestamp":"2023-12-23T04:17:20Z","value":"123"},{"metricName":"aaa","metricLabels":{"app":"sample","category":"2"},"timestamp":"2023-12-23T04:17:20Z","value":"456u"}]}
```