Flaggerでカナリアリリースを試してみる

# TL;DR

Minikube環境でFlaggerによるカナリアリリースを試してみました。
この記事ではFlaggerでカナリアリリースする方法について説明します。

この記事で扱う設定ファイルやソースコードは下記のリポジトリに存在します。
Github: https://github.com/cacapouh/flagger-example/

最速でカナリアリリースを試したい場合は、上記のリポジトリをgit cloneして、
`run.sh`を実行するとMinikube環境に諸々のワークロードがデプロイされます。

## 最終的に出来上がるもの

以下は、この記事で取り扱うFlaggerによるリリースの構成を[KubeView](https://github.com/benc-uk/kubeview)で可視化したものです。

`simple-app`というサンプルアプリケーションを作成し、`Deployment`オブジェクトとしてデプロイし、
それをFlaggerでカナリアリリースするようにします。

`default`ネームスペース:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422592/13173151-ba61-9c94-5f4e-9d24ebeb5bd3.png)

# セットアップ

まず、[Istio](https://istio.io/latest/docs/setup/getting-started/#install), [Prometheus](https://github.com/prometheus-community/helm-charts/tree/main/charts/kube-prometheus-stack), [Flagger](https://docs.flagger.app/install/flagger-install-on-kubernetes)をインストールします。

以下のコマンドでは、`istio-system`ネームスペースにIstioとFlaggerをインストールし、
`monitoring`ネームスペースにPrometheusをインストールします。

```shell
# Istio
istioctl install --set profile=default --set components.egressGateways[0].name=istio-egressgateway --set components.egressGateways[0].enabled=true -y
kubectl label namespace default istio-injection=enabled

# Prometheus
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
kubectl create ns monitoring
helm upgrade -i prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
--set fullnameOverride=prometheus
kubectl wait --for=condition=Ready pod --all --all-namespaces --timeout=-1s

# Flagger
helm repo add flagger https://flagger.app
helm repo update
kubectl apply -f https://raw.githubusercontent.com/fluxcd/flagger/main/artifacts/flagger/crd.yaml
helm upgrade -i flagger flagger/flagger \
  --namespace=istio-system \
  --set meshProvider=istio \
  --set metricsServer=http://prometheus-prometheus.monitoring.svc.cluster.local:9090
helm upgrade -i flagger-loadtester flagger/loadtester
kubectl apply -f metrics-template.yaml
```

# サンプルアプリケーションの実装

次にサンプルアプリケーションを用意します。

以下はサンプルアプリケーションの実装です。

bottleでHTTPサーバーを立てています。
8080ポートの`/`にリクエストが来た場合は、`version: 1.0.0`という文字列を返すようにしています。
8000ポートの`/metrics`にリクエストが来た場合、Prometheusがパースできる形式のメトリクス情報をレスポンスするようにしています。

```python:main.py(version: 1.0.0)
from bottle import route, run, response
from prometheus_client import start_http_server, Summary, generate_latest

REQUEST_TIME = Summary('request_processing_seconds', 'Time spent processing request')
count = 0


@route('/')
@REQUEST_TIME.time()
def index():
    global count
    count += 1
    print("Request: {}, version: 1.0.0".format(count))  # デバッグ用にPodにログを残す
    return "version: 1.0.0"


@route('/metrics')
def metrics():
    response.content_type = 'text/plain; version=0.0.4; charset=utf-8'
    return generate_latest()


if __name__ == '__main__':
    start_http_server(8000)
    run(host='0.0.0.0', port=8080)
```

```txt:requirements.txt
bottle
prometheus_client
```

上記のPython実装の4行目で`REQUEST_TIME`を定義していますが、
これは後にカナリア分析するためのメトリクス情報として使用します。

参考までに、`/metrics`エンドポイントのレスポンスの一部を以下に示します。

```txt:/metrics
request_processing_seconds_count 1.0
request_processing_seconds_sum 0.0035420419881120324
```

また、この記事では、上記の実装のバージョンを`1.0.0`とし、
カナリアリリースで`1.0.1`にバージョンアップします。

以下は、バージョン`1.0.1`のpythonの実装です。
レスポンスするバージョン文字列を1.0.1に変更しただけです。

```python:main.py
from bottle import route, run, response
from prometheus_client import start_http_server, Summary, generate_latest

REQUEST_TIME = Summary('request_processing_seconds', 'Time spent processing request')
count = 0


@route('/')
@REQUEST_TIME.time()
def index():
    global count
    count += 1
    print("Request: {}, version: 1.0.1".format(count))
    return "version: 1.0.1"


@route('/metrics')
def metrics():
    response.content_type = 'text/plain; version=0.0.4; charset=utf-8'
    return generate_latest()


if __name__ == '__main__':
    start_http_server(8000)
    run(host='0.0.0.0', port=8080)
```

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

以下はサンプルアプリケーションのDockerfileおよびDeploymentです。
Deploymentでは、8080ポートに`simple-app`, 8000ポートに`prometheus`という名前を付けています。

```Dockerfile:Dockerfile
FROM python:3.8.18-slim

WORKDIR /app
COPY requirements.txt /app

RUN pip install -r requirements.txt

COPY main.py /app

ENTRYPOINT python -u main.py
```

```yaml:simple-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: simple-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: simple-app
  template:
    metadata:
      labels:
        app: simple-app
    spec:
      containers:
      - name: simple-app
        image: simple-app:1.0.0
        imagePullPolicy: IfNotPresent
        ports:
        - name: simple-app
          containerPort: 8080
        - name: prometheus
          containerPort: 8000
```

# PodMonitor

PrometheusのPodMonitorマニフェストを用意し、マニフェスト適用します。

PodMonitorとは、Prometheus Operatorで利用可能なカスタムリソースで、
Podからメトリクスを取得するための設定を定義します。

以下はPodMonitorのマニフェストです。

* デプロイ対象のネームスペースは`monitoring`です
* PrometheusがPodMonitorを検知できるように `labels.release: prometheus`としています
  * 参考: https://qiita.com/ipppppei/items/d6034c6bed3364f89461
* `spec.podMetricsEndpoints`でメトリクス取得元のエンドポイントを設定しています
  * `path`はPython実装の通り`/metrics`です
  * `port`は、Deploymentリソース設定で定義した`prometheus`です
    (つまり8000ポートです)
* メトリクス取得対象のネームスペースは`namespaceSelector.matchName`のところで、`default`に設定しています

```yaml:pod-monitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
  name: simple-app-canary
  namespace: monitoring
  labels:
    release: prometheus
spec:
  podMetricsEndpoints:
    - path: /metrics
      port: "prometheus"
  selector:
    matchLabels:
      app: simple-app
  namespaceSelector:
    matchNames:
      - default
```

以下は取得したメトリクス情報をPrometheusのWeb UIで可視化した結果です。

![request_processing_seconds_sum.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422592/90605d48-d433-6cc2-50df-faa29e2273b7.png)
![request_processing_seconds_count.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/422592/cb930e01-4343-24ba-00f3-7e70c93f7831.png)

# MetricsTemplate

次にMetricTemplateの設定をします。

MetricTemplateとは、Flaggerで定義されたカスタムリソースの一種で、カナリア分析に使用するメトリクスを定義するものです。

以下のマニフェストはMetricTemplateの設定です。

* `spec`
  * `provider`
    * メトリクスデータの取得元を`type: prometheus`としています
      * 他にも`dagdog`, `newrelic`, `cloudwatch`等が設定可能です
    * `address`で`monitoring`ネームスペースのPrometheusのアドレスを指定しています
  * `query`
    * 上記のPrometheusのWeb UIで示した `request_processing_seconds_sum`と `request_processing_seconds_count`を使って、平均処理時間をメトリクスとして定義しています

```yaml:metrics-template.yaml
apiVersion: flagger.app/v1beta1
kind: MetricTemplate
metadata:
  name: request-processing-seconds-avg
  namespace: default
spec:
  provider:
    address: http://prometheus-prometheus.monitoring.svc.cluster.local:9090
    type: prometheus
  query: |
    request_processing_seconds_sum / request_processing_seconds_count
```

# Canary

Canaryとは、Flaggerのカスタムリソースの一種で、
カナリアリリースのための設定を行うものです。

以下はCanaryのマニフェスト設定です。

* `spec`
  * `targetRef`のところで、カナリアリリース対象を`simple-app`のDeploymentとしています
  * `service`でServiceリソースの定義をしています
    * このCanaryのマニフェストを適用することによって、Serviceオブジェクトも自動で生成されます
  * `analysis`
    * `metrics`
      * ここではMetricsTemplateの参照と、カナリア分析の閾値を設定しています
      * 平均処理時間が3秒以下の場合、カナリア分析が失敗するようにしています
    * `webhook`
      * Flaggerのロードテスターサービスにロードテストコマンドを送っています
      * 2並列で2分間、1000QPSでリクエストを送ります
      * リクエスト送信先は`http://simple-app-canary.default:80/`ですが、この `simple-app-canary`サービスはFlaggerの方で、カナリアリリース時に自動生成されます

```yaml:canary.yaml
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: simple-app
  namespace: default
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: simple-app
  service:
    port: 80
    targetPort: 8080
  analysis:
    # schedule interval (default 60s)
    interval: 20s
    # max number of failed metric checks before rollback
    threshold: 10000
    # max traffic percentage routed to canary
    # percentage (0-100)
    maxWeight: 50
    # canary increment step
    # percentage (0-100)
    stepWeight: 10
    metrics:
    - name: request-processing-seconds-avg
      templateRef:
        name: request-processing-seconds-avg
        namespace: default
      thresholdRange:
        max: 3
      interval: 5s
    webhooks:
      - name: load-test
        url: http://flagger-loadtester.default/
        timeout: 5s
        metadata:
          cmd: "hey -z 2m -q 1000 -c 2 http://simple-app-canary.default:80/"
```

# カナリアリリースの実行

以下のコマンドで、既存のDeploymentのイメージを書き換えることによって、カナリアリリースをキックします。

```shell
kubectl set image deployment/simple-app simple-app=simple-app:1.0.1
```

以下は、イメージを書き換えた後、カナリアリリースのイベント監視をしたものです。
新しいDockerのバージョンを検知し、新しいバージョンのPodに割り振るリクエストのweightを徐々に増やしていき、`weight: 50`でのカナリア分析の結果が問題なければ、新しいバージョンに完全移行するようになっています。


`Halt advancement no values found for custom metric`とのWarningが出ていますが、
これは、まだメトリクスがPrometheusで取れていないタイミングでカナリア分析されたものかと思われます。

```txt
$ kubectl get events --watch --field-selector involvedObject.kind=Canary
LAST SEEN   TYPE     REASON   OBJECT              MESSAGE
0s          Normal   Synced   canary/simple-app   New revision detected! Scaling up simple-app.default
0s          Normal   Synced   canary/simple-app   Starting canary analysis for simple-app.default
0s          Normal   Synced   canary/simple-app   Advance simple-app.default canary weight 10
0s          Warning   Synced   canary/simple-app   Halt advancement no values found for custom metric: request-processing-seconds-avg: no values found
0s          Warning   Synced   canary/simple-app   Halt advancement no values found for custom metric: request-processing-seconds-avg: no values found
0s          Normal    Synced   canary/simple-app   Advance simple-app.default canary weight 20
0s          Normal    Synced   canary/simple-app   Advance simple-app.default canary weight 30
0s          Normal    Synced   canary/simple-app   Advance simple-app.default canary weight 40
0s          Normal    Synced   canary/simple-app   Advance simple-app.default canary weight 50
0s          Normal    Synced   canary/simple-app   Copying simple-app.default template spec to simple-app-primary.default
0s          Normal    Synced   canary/simple-app   Routing all traffic to primary
0s          Normal    Synced   canary/simple-app   (combined from similar events): Promotion completed! Scaling down simple-app.default
```

# おわりに

この記事では、Flaggerを使ったカナリアリリースの基本的なセットアップ手順及び諸概念について紹介しました。

登場するワークロードやオブジェクトの数が多く、挙動もやや複雑ですが、
カナリアリリースでより安全にリリースを実現できればと思います :thought_balloon: