KEDAでEvent-driven Autoscalingを試す

## これはなに

KubernetesにはPodの起動数をリソース使用量などのメトリクスに応じて自動的に増減させる、HorizontalPodAutoscaling(以下、HPA)と呼ばれる仕組みがあります。HPAは広く使われるユースケースでは優れた仕組みですが、Podを完全にゼロにすることはできない仕組みになっています。KEDAはHPAの仕組みを拡張して、Podを完全にゼロにすることができます。

本記事ではKEDA自体のインストール方法などを確かめるためにデモ的な構成でKEDAによるPodのオートスケーリングを試してみます。

## やってみよう

### クラスタ構築

クラスタはおよそどんな環境でも動くと思います。

筆者はkindクラスタ上で動作確認をしています。

### Prometheusのインストール

メトリクスを収集してKEDAからの問い合わせに答えてもらうためにprometheusをインストールします。

ちょっと大げさですが構築が簡単なのでkube-prometheus-stackというhelmチャートを利用します。

```bash
helm upgrade -i \
    -n prometheus \
    --create-namespace \
    prom-op \
    kube-prometheus-stack \
    --repo https://prometheus-community.github.io/helm-charts \
    --version 48.4.0
```

上記で設定しているリリース名`prom-op`はこの後のingress-nginxの設定や、ScaledObject(KEDAのCustomResource)の設定などに現れるので、変更する場合は注意してください。

### ingress-nginxのインストール

Ingress Controllerとしてingress-nginxを導入します。

今回の構成ではこのIngress Controllerが公開するメトリクスをautoscalingの入力として使います。

Helmチャートを使うので、次のvaluesファイルを作っておきます:

```yaml:ingress-nginx-values.yaml
controller:
  service:
    type: ClusterIP
  extraArgs:
    metrics-per-host: 'false'
  metrics:
    enabled: true
    serviceMonitor:
      enabled: true
      namespace: prometheus
      additionalLabels:
        release: prom-op
```

`controller.extraArgs.metrics-per-host`は、この後作成するIngressリソースにhost名がないため設定しています。利用したいメトリクスは`nginx_ingress_controller_requests`という、Ingressリソースごとに公開されるメトリクスなのですが、元となるIngressリソースにhost名が設定されていないとき、デフォルトではそのIngressリソースに関連付けられるメトリクスはingress-nginx controllerから公開されません[^1]。[公式ドキュメント][ingress-nginx-monitoring-wildcardingresses]に記載の通り、この引数を付与することで回避できます。

[ingress-nginx-monitoring-wildcardingresses]: https://github.com/kubernetes/ingress-nginx/blob/8f54b538d9b32649411d9e507ca38fbee55ce51c/docs/user-guide/monitoring.md#wildcard-ingresses

[^1]: 複数のIngressのメトリクスが合算されて爆発(オーバーフローのことなのか、急増することなのかはよくわかりません)することを避けるためだそうです。

次のコマンドでインストールします:

```bash
helm upgrade -i \
  -n ingress-nginx \
  --create-namespace \
  ingress-nginx \
  ingress-nginx \
  --repo https://kubernetes.github.io/ingress-nginx \
  --version 4.7.2 \
  --values ingress-nginx-values.yaml
```

### KEDAのインストール

KEDAをデフォルトでインストールします。

```bash
helm upgrade -i \
  -n keda \
  --create-namespace \
  keda \
  keda \
  --repo https://kedacore.github.io/charts \
  --version 2.11.2
```

### サンプルアプリケーションのデプロイ

再現が簡単になるように、nginxイメージを使います。

操作を簡単にするために`app/`ディレクトリを作って以下のマニフェストをそれぞれファイルとして作成します[^2]。

[^2]: 1ファイルにまとめても構いませんし、kustomizeを使っても構いませんが、この後のコマンドに影響するので注意してください。

<details><summary>マニフェスト一式(クリックして開く)</summary>

```yaml:app/nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: nginx
  name: nginx
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: nginx
    spec:
      containers:
      - image: nginx:mainline-alpine
        name: nginx
        ports:
        - containerPort: 80
          name: http
          protocol: TCP
        resources: {}
status: {}
```

```yaml:app/nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    app: nginx
  name: nginx
  namespace: default
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: http
  selector:
    app: nginx
  type: ClusterIP
status:
  loadBalancer: {}
```

```yaml:app/nginx-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  creationTimestamp: null
  name: nginx
  namespace: default
spec:
  ingressClassName: nginx
  rules:
  - http:
      paths:
      - backend:
          service:
            name: nginx
            port:
              name: http
        path: /
        pathType: Prefix
status:
  loadBalancer: {}
```

</details>

最後に、目的の`ScaledObject`マニフェストを同じくapp/ディレクトリに作成しましょう。

```yaml:app/nginx-scaledobject.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: nginx
  namespace: default
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: nginx
  idleReplicaCount: 0
  minReplicaCount: 1
  maxReplicaCount: 10
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prom-op-kube-prometheus-st-prometheus.prometheus.svc:9090
      query: 'sum by (exported_namespace,exported_service) (irate(nginx_ingress_controller_requests[1m]))'
      activationThreshold: '0'
      threshold: '2'
    metricType: AverageValue
```

上記のScaledObjectでは、Prometheusから以下のqueryでメトリクスを取得しています。

```prometheus
sum by (exported_namespace,exported_service) (irate(nginx_ingress_controller_requests[1m]))
```

このメトリクスはingress-nginxが公開して、ServiceMonitorリソース(Prometheus operatorのCustomResource)によって自動的にPrometheusに収集設定がされたメトリクスに含まれています。したがって、動作検証に当たっては、ingress-nginxを通るようにリクエストを送信する必要があります。

以下のコマンドで作成したマニフェストをすべて適用します:

```bash
kubectl apply -f app/
```

applyできたかは以下のコマンドで確認[^4]します:

[^4]: `ScaledObject`リソースの省略形が`so`です。

```bash
kubectl get so,hpa,deploy,po,svc,ep
```

### 動作確認

ingress/nginxにリクエストを送信します。

リクエストをIngressに届ける方法は環境によっていろいろありますが、どの環境でも使える方法としてport-fowardによる方法を上げると、別のターミナルを立ち上げて以下のコマンドでport-forwardできます:

```bash
kubectl -n ingress-nginx port-forward service/ingress-nginx-controller 3080:80
```

ScaledObjectがDeactivateな状態にあると、nginx Podが一つもないため503になりますが、503エラーもScaledObjectのリクエスト数にカウントされるので、あきらめずにリクエストを送信し続けます。簡易的な以下のコマンドでもよいですし、スクリプト言語を利用しても構いません。

```bash
watch -p -n 1 curl -s localhost:3080/
```

上記コマンドなどで1秒に1回リクエストを送ると、Podが一つ立ち上がってくるはずです。

まずは普通のautoscalingと同じく、scale-outしてみます。

次のコマンド[^3]でリクエストの頻度を約1秒に3回に増やしてみます。

[^3]: `watch`コマンドが小数を取り扱えない場合は`watch -p -n 1 'for i in $(seq 3); do curl -s localhost:3080 & done'`でもよいでしょう。

```bash
watch -p -n 0.3 curl -s localhost:3080/
```

HPAオブジェクトを監視していると、メトリクスが3.3程度になることが確認でき、Podの数が2に増えるはずです。

次に、scale-in、特にゼロへのscale-inを試してみましょう。

リクエストを行っていたwatchコマンドをCtrl + Cで中断するだけです。scale-inにはcooldown期間として5分がデフォルトで設定されるので5分ほど待ちます。

するとnginx Podがすべて削除されるはずです。

もう一度リクエストを開始することでゼロからのscale-outも確認できます。

## まとめ

KEDAでゼロへのscale-in/scale-outを試してみました。

KEDAではAmazon SQSやApache Kafkaなどのメッセージブローカーのメトリクス、例えばqueueの長さやメッセージの遅延時間などを利用して、ブローカーにメッセージがたまっていることを検知し、Podをautoscaleさせる使い方がより一般的です[^5]が、今回は動作確認のしやすいWebサーバーをでもアプリとして構成してみました。

[^5]: 今回の構成の場合、Podがゼロのときのリクエストはエラーになってしまい、利用側でリトライを行う必要があります。