Isito、k8sでカナリアデプロイをする

はじめに

先月の5月18日にIstioが1.10のリリースがされたみたいです。
その中でStable Revision Labelsと言う機能を使ってカナリアップデートを行なう例が乗っていて、最初言葉尻を完全に読み間違えていてアプリのカナリアデプロイが容易になると思ったのですがそうでは無く、リビジョン付きの複数のコントロールプレーンをデプロイするサポートが入ったって感じっぽいですね。
まぁ、1.10は置いておいても、k8sやIstioを使ったカナリアリデプロイってやったことなかったので、ちょっと試してみようかと思います。
基本的にはここに乗っているのに基づいてやろうと思います。
かなり古いブログですが、これ以上に新しいものをみつけることができなかったので、もしもっと良いやり方がありそうであればコメントなどで教えてくれると嬉しいです。

やっていく

環境

今回の動作環境は以下のようになってます。

$ uname -srvmpio
Linux 5.4.0-74-generic #83-Ubuntu SMP Sat May 8 02:35:39 UTC 2021 x86_64 x86_64 x86_64 GNU/Linu

$ lsb_release -a
LSB Version:    core-11.1.0ubuntu2-noarch:security-11.1.0ubuntu2-noarch
Distributor ID: Ubuntu
Description:    Ubuntu 20.04.2 LTS
Release:    20.04
Codename:   focal


$ minikube version
minikube version: v1.21.0
commit: 76d74191d82c47883dc7e1319ef7cebd3e00ee11


$ kubectl version  -o yaml
clientVersion:
  buildDate: "2021-03-18T01:10:43Z"
  compiler: gc
  gitCommit: 6b1d87acf3c8253c123756b9e61dac642678305f
  gitTreeState: clean
  gitVersion: v1.20.5
  goVersion: go1.15.8
  major: "1"
  minor: "20"
  platform: linux/amd64
serverVersion:
  buildDate: "2021-01-13T13:20:00Z"
  compiler: gc
  gitCommit: faecb196815e248d3ecfb03c680a4507229c2a56
  gitTreeState: clean
  gitVersion: v1.20.2
  goVersion: go1.15.5
  major: "1"
  minor: "20"
  platform: linux/amd64

$ istioctl version
client version: 1.10.1
control plane version: 1.10.1
data plane version: 1.10.1 (1 proxies)

また、このブログでは特に明記しない場合k8sのコンテキストはminikubeを使っています。

下準備

下準備として特定の文字列を返すnginxのコンテナを作成しておきます。
まずは、以下のコマンドを実行して、Dockerのコンテキストをminikubeのものに変えておきます。

$ eval $(minikube docker-env)

これでDockerクライアントはMinikubeのコンテキストで動くようになりました。  

次に以下の簡単なDokcerfileを用意します。

Dockerfile-v1

FROM nginx:1.19.6

RUN echo "Hello, Canary v1" > /usr/share/nginx/html/index.html

Dockerfile-v2

FROM nginx:1.19.6

RUN echo "Hello, Canary v2" > /usr/share/nginx/html/index.html

それぞれをビルドしておきます。

$ docker build ./ -t hello-nginx-v2 -f Dockerfile-v2
$ docker build ./ -t hello-nginx-v1 -f Dockerfile-v1

$ docker images | grep hello-nginx
hello-nginx-v2                            latest     9defac4edce6   2 minutes ago   133MB
hello-nginx-v1                            latest     abd78992aeba   8 minutes ago   133MB

最後にあとで使うのでMinikubeのIPを調べておきます。

$ minikube ip
192.168.49.2

これで下準備は完了です。

どんなことを行なうか

今回は、以下のパターンをやってみようかと思います。

Kubernetesのみでのカナリアデプロイをやってみる
・Istioを使ったカナリアリデプロイをやってみる

k8sを使ったカナリアデプロイ

k8s単体でもカナリアデプロイを行なうこと自体は可能です。 それを行なうには以下の2つの方法取るようです。

selectorを用いたカナリアデプロイ

selectorを用いてカナリアデプロイをやってみようと思います。
こいつの考え方としては、古いバージョンと新しいバージョンのPod数を変更することで、新しいVersionのPodへルーティングされる割合を調整します。
実際にやってみます。 まずは、2つのDeploymentを用意します。

$ kubectl create deployment hello-nginx --image=hello-nginx-v1 --dry-run=client -o yaml > deployment.yaml
$ echo --- >> deployment.yaml 
$ kubectl create deployment hello-nginx --image=hello-nginx-v2 --dry-run=client -o yaml >> deployment.yam

できたデプロイメントが以下のようになります。

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v2
        name: hello-nginx-v2
        resources: {}
status: {}

それぞれを見分けるためnamenginx-hello-v1nginx-hello-v2のに変え環境を示すラベル(track)追加します。
また、nginx-hello-v1の方はレプリカ数も変えておきます。

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx      
    track: stable               # 環境を示すラベルを追加
  name: hello-nginx-v1          # nameにVersionを追加
spec:
  replicas: 3                   # レプリカ数を3に
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
    track: canary               # 環境を示すラベルを追加
  name: hello-nginx-v2          # nameにVersionを追加
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v2
        name: hello-nginx-v2
        resources: {}
status: {}

deploymentをapplyします。

$ kubectl apply -f deployment.yaml 
deployment.apps/hello-nginx-v1 created
deployment.apps/hello-nginx-v2 created

$ kubectl get po
NAME                              READY   STATUS    RESTARTS   AGE
hello-nginx-v1-7b8665d769-grsz5   1/1     Running   0          7s
hello-nginx-v1-7b8665d769-rdwg5   1/1     Running   0          7s
hello-nginx-v1-7b8665d769-zvlsg   1/1     Running   0          7s
hello-nginx-v2-86467b54f9-ghdnm   1/1     Running   0          7s

そして、これらのデプロイメントに対してapp: hello-nginxのラベルに対してルーティングを行なうServiceを記述してやります。   

$ kubectl create service nodeport hello-nginx --tcp=8081:80 --dry-run=client -o yaml > service.yaml

できたServiceが以下の通りになります。

apiVersion: v1
kind: Service
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx
spec:
  ports:
  - name: 8081-80
    port: 8081
    protocol: TCP
    targetPort: 80
  selector:
    app: hello-nginx
  type: NodePort
status:
  loadBalancer: {}

applyしてNodePortのポートをチェックしておきます。

$ kubectl  apply -f service.yaml

$ kubectl get svc/hello-nginx
NAME          TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
hello-nginx   NodePort   10.100.167.221   <none>        8081:32697/TCP   68m

これでおおよそ1/4程度のリクエストがv2にルーティングされるようになっているはずです。
ロードテストツールであるk6で以下のスクリプトを書いてチェックしてみます。

$ cat 100requests.js 
import http from 'k6/http';
import { sleep, check } from 'k6';

export let options = {
  vus: 10,
  duration: '10s',
};

export default function () {
  
  let res = http.get('http://192.168.49.2:32697');
  sleep(1);

  check(res, {
    'v1 responded': (r) => r.body == 'Hello, Canary v1\n',
    'v2 responded': (r) => r.body == 'Hello, Canary v2\n',
  });
}

ここでk6の使い方を詳細には説明しませんが、10並列で10秒ごとに計100回のリクエストを送り、そのレスポンスボディをチェックするスクリプトになっています。
k6に関しては過去にブログを書いているので、よろしければそちらもみてみてください)

$ docker run --network=host -i loadimpact/k6 run  - < 100requests.js

          /\      |‾‾| /‾‾/   /‾‾/   
     /\  /  \     |  |/  /   /  /    
    /  \/    \    |     (   /   ‾‾\  
   /          \   |  |\  \ |  (‾)  | 
  / __________ \  |__| \__\ \_____/ .io

  execution: local
     script: -
     output: -

  scenarios: (100.00%) 1 scenario, 10 max VUs, 40s max duration (incl. graceful stop):
           * default: 10 looping VUs for 10s (gracefulStop: 30s)


(省略)


     ✗ v1 responded
      ↳  80% — ✓ 80 / ✗ 20
     ✗ v2 responded
      ↳  20% — ✓ 20 / ✗ 80

(省略)

ここでは大体20%ぐらいのリクエストがV2に振られたみたいですね。
もう一度テストを実行すると70%、30%の割合になったので、おおよそ25%の割合で新しいVersionのPodにリクエストが割り振られているのがわかります。
Podが少ない状態である程度新しいバージョンの動作に確証が取れてきたらscaleコマンドでPodの数を調整します。

$ kubectl scale --replicas=2  deployment/hello-nginx-v2
deployment.apps/hello-nginx-v2 scaled
$ kubectl scale --replicas=2  deployment/hello-nginx-v1
deployment.apps/hello-nginx-v1 scaled

$ kubectl get po
NAME                              READY   STATUS    RESTARTS   AGE
hello-nginx-v1-7b8665d769-khzfp   1/1     Running   0          67m
hello-nginx-v1-7b8665d769-wjfxj   1/1     Running   0          67m
hello-nginx-v2-86467b54f9-ghdnm   1/1     Running   0          84m
hello-nginx-v2-86467b54f9-nsr99   1/1     Running   0          37s


$ docker run --network=host -i loadimpact/k6 run  - < 100requests.js

(省略)

     ✗ v1 responded
      ↳  50% — ✓ 50 / ✗ 50
     ✗ v2 responded
      ↳  50% — ✓ 50 / ✗ 50

(省略)

最終的にv1のスケールを0にしてv2を必要な数までスケールさせるとデプロイ完了です。
ロールバックしたい場合は、v2のスケールを0にして、v1のスケールを元の数まで戻せば良いです。

rolloutを用いたカナリアデプロイ

今度はk8sのrollout/pauseコマンドを用いてカナリアデプロイを行ってみようと思います。
考え方としては、selectorと似ており、Podの数を調整することで新しいVersionにルーティングされる割合を少しずつ増やしていき、カナリアデプロイを実現します。
具体的には、imageのアップデートが行われた際に実行されるrolloutを途中でpauseすることでより、比較的少ない新しいVersionのPodを起動して、少しずつデプロイして行きます。
selectorの例で用いていたDeploymentとServiceを削除して次のDeploymentを使用します。

deployment-rp.yaml

$ cat deployment-rp.yaml 
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx      
  name: hello-nginx
spec:
  replicas: 4
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
        imagePullPolicy: IfNotPresent
status: {}

applyしてPodが起動するのを確認します。

$ kubectl apply -f deployment-rp.yaml 
deployment.apps/hello-nginx create

$ kubectl get po
NAME                           READY   STATUS    RESTARTS   AGE
hello-nginx-59849bb7d7-cgkhw   1/1     Running   0          40s
hello-nginx-59849bb7d7-lz8bf   1/1     Running   0          40s
hello-nginx-59849bb7d7-mgrrb   1/1     Running   0          40s
hello-nginx-59849bb7d7-vl7lg   1/1     Running   0          40s

次にイメージをアップデートして、すぐにpauseコマンドを実行します。

$ kubectl rollout pause deployment/hello-nginx

$ kubectl set image deployment/hello-nginx hello-nginx=hello-nginx-v2
deployment.apps/hello-nginx image updated
$ kubectl rollout pause deployment/hello-nginx
deployment.apps/hello-nginx paused

$ kubectl get po
NAME                           READY   STATUS    RESTARTS   AGE
hello-nginx-59849bb7d7-cgkhw   1/1     Running   0          2m46s
hello-nginx-59849bb7d7-lz8bf   1/1     Running   0          2m46s
hello-nginx-59849bb7d7-mgrrb   1/1     Running   0          2m46s
hello-nginx-5d8dc5f978-4nx4g   1/1     Running   0          11s
hello-nginx-5d8dc5f978-d5p8s   1/1     Running   0          11s

今回の場合新しいPodが2つ起動されていますね。
この新しいPodはイメージが新しいものが使われているのがわかります。

$ kubectl describe po hello-nginx-5d8dc5f978-4nx4g 
Name:         hello-nginx-5d8dc5f978-4nx4g
Namespace:    default
Priority:     0
Node:         minikube/192.168.49.2
Start Time:   Sun, 20 Jun 2021 15:11:33 +0900
Labels:       app=hello-nginx
              pod-template-hash=5d8dc5f978
Annotations:  <none>
Status:       Running
IP:           172.17.0.8
IPs:
  IP:           172.17.0.8
Controlled By:  ReplicaSet/hello-nginx-5d8dc5f978
Containers:
  hello-nginx:
    Container ID:   docker://0988812d8fa7b3ccd1eb91c5fe48bbfed265ec0964407b9064e9f3dbb7801bc3
    Image:          hello-nginx-v2
    Image ID:       docker://sha256:9defac4edce66768e5023868544942142630a22e35b0770a222b973083fde7da
    Port:           <none>
    Host Port:      <none>
    State:          Running
      Started:      Sun, 20 Jun 2021 15:11:34 +0900
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-2zfxv (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  default-token-2zfxv:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-2zfxv
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                 node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  100s  default-scheduler  Successfully assigned default/hello-nginx-5d8dc5f978-4nx4g to minikube
  Normal  Pulled     100s  kubelet            Container image "hello-nginx-v2" already present on machine
  Normal  Created    100s  kubelet            Created container hello-nginx
  Normal  Started    100s  kubelet            Started container hello-nginx

もし、リリース中に問題があった場合は、rollbackコマンドを実行し、リビジョンを1つ前のものに戻します。

$ kubectl rollout resume deployment/hello-nginx
deployment.apps/hello-nginx resumed

$ kubectl rollout undo deployment/hello-nginx
deployment.apps/hello-nginx rolled back

Istioを使ったカナリアデプロイ

k8sを使ったカナリアデプロイでは限定的で以下のようなカナリアリデプロイを行なう際には課題があります。

  • 全体の1%のリクエストだけ新しいVersionのPodにルーティングしたい場合(Podを100個起動する必要がある)
  • 特定のクライテリアを満たすリクエストを新しいVersionにルーティングしたい

Istioを用いることで、上記のような、より複雑な条件でのデプロイを簡単に行なうことができるようになります。
一度、rolloutで使ったdeploymentを削除して、

deployment-istio.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx      
  name: hello-nginx-v1
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
        version: v1
    spec:
      containers:
      - image: hello-nginx-v1
        name: hello-nginx-v1
        resources: {}
        imagePullPolicy: IfNotPresent
status: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  creationTimestamp: null
  labels:
    app: hello-nginx
  name: hello-nginx-v2
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-nginx
  strategy: {}
  template:
    metadata:
      creationTimestamp: null
      labels:
        app: hello-nginx
        version: v2
    spec:
      containers:
      - image: hello-nginx-v2
        name: hello-nginx-v2
        resources: {}
        imagePullPolicy: IfNotPresent
status: {}

selectorのところで用いたdeploymentと少し似ていますが、Podに対して、versionラベルを追加しているのとPodの数を3つずつ、2つのDeploymentで計6個のPodを起動しています。

$ kubectl apply -f deployment-istio.yaml

$ kubectl get po
NAME                              READY   STATUS    RESTARTS   AGE
hello-nginx-v1-7b8665d769-bzgzg   1/1     Running   0          99s
hello-nginx-v1-7b8665d769-j569c   1/1     Running   0          99s
hello-nginx-v1-7b8665d769-wbthk   1/1     Running   0          99s
hello-nginx-v2-86467b54f9-d9txr   1/1     Running   0          8s
hello-nginx-v2-86467b54f9-hdsx9   1/1     Running   0          99s
hello-nginx-v2-86467b54f9-m8gmk   1/1     Running   0          8s

次に外部からアクセスを可能にするため、Gatewayリソースを作成します。

gateway.yaml

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: hello-nginx
spec:
  selector:
    istio: ingressgateway 
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"

そして、きもとなるVertualServiceとDestinationRouleを作成します。

destination-rule.yaml

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: hello-nginx
spec:
  host: hello-nginx
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2

DestinationRouleでPodに付与したversionのラベルを指定することで、ルーティング先をsubsetとして定義しています。

virtual-service.yaml

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: hello-nginx
spec:
  hosts:
  - "*"
  gateways:
  - hello-nginx
  http:
  - route:
    - destination:
        host: hello-nginx
        subset: v1
      weight: 90
    - destination:
        host: hello-nginx
        subset: v2
      weight: 10

ここでは、Gatewayに対するどんなHostのリクエストに対してもsubsetのv1v2に対してルーティングを行なうように設定しています。
また、weightを設定し、リクエストの10%がv2へルーティングされるようにしています。

これで準備は完了です、k6からGateway経由でリクエストを送るようにするためにGatewayのport番号を取得してk6スクリプトを修正します。

$ kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.spec.ports[?(@.name=="http2")].nodePort}'
31424

取得したPortでリクエストを送るようにk6スクリプトを修正したら、テストを実行します。

$ docker run --network=host -i loadimpact/k6 run  - < 100requests.js

(省略)

     ✗ v1 responded
      ↳  90% — ✓ 90 / ✗ 10
     ✗ v2 responded
      ↳  10% — ✓ 10 / ✗ 90

(省略)

先ほどと違い、Podの数とは関係なくルーティングが行われていることが確認できました。
より小さなリクエストの数で、新しいVersionの動作確認が取れたらweightを修正して新しいVersionにのみリクエストが送られていくように変更していきます。
今回は、Podの数は固定で行いましたが、Podの数で割合を調整する必要がなくなったので、Deploymentのオートスケールの機能と合わせて利用することも可能です。