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: {}
それぞれを見分けるためname
をnginx-hello-v1
とnginx-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を使ったカナリアデプロイでは限定的で以下のようなカナリアリデプロイを行なう際には課題があります。
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のv1
とv2
に対してルーティングを行なうように設定しています。
また、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のオートスケールの機能と合わせて利用することも可能です。