Тема «канареечных» (canary) релизов поднималась в нашем блоге уже не раз — см. ссылки в конце статьи. Но не будет лишним напомнить, зачем они нужны.

Canary-развертывание используется, чтобы протестировать новую функциональность на отдельной группе пользователей. Группа выделяется по определенному признаку. Тест при этом не должен затрагивать работу основной версии приложения и его пользователей. Нагрузка между двумя версиями приложения должна распределяться предсказуемо. 

Canary-релизы достаточно просто реализуются на уровне Ingress-контроллеров. В статье рассмотрен практический пример настройки таких релизов в Kubernetes на базе Ingress NGINX Controller.

Примечание

Реализация применима только для приложений, к которым обращаются именно через Ingress. Если ваше приложение взаимодействует с окружением исключительно на уровне Service, рассмотренный метод не подойдет.

Готовим приложение для тестов

Для примера нам потребуется небольшое приложение. Возьмем базовый NGINX, который будет отдавать одну HTML-страницу, и Ingress-контроллер, через который будем обращаться к веб-серверу.

Получился такой чарт:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Chart.Name }}
spec:
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: {{ .Chart.Name }}
  replicas: 1
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/10-nginx-config.yaml") . | sha256sum }}
      labels:
        app: {{ .Chart.Name }}
    spec:
      volumes:
      - name: configs
        configMap:
          name: {{ .Chart.Name }}-configmap
      containers:
      - name: nginx
        imagePullPolicy: Always
        image: {{ index .Values.werf.image "nginx" }}
        lifecycle:
          preStop:
            exec:
              command: [ "/bin/bash", "-c", "sleep 5; kill -QUIT 1" ]
        command: ["/usr/sbin/nginx", "-g", "daemon off;"]
        ports:
        - containerPort: 80
          name: http
          protocol: TCP
        volumeMounts:
        - name: configs
          mountPath: /etc/nginx/nginx.conf
          subPath: nginx.conf
        resources:
          requests:
            cpu: 50m
            memory: 128Mi
          limits:
            memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: {{ .Chart.Name }}
spec:
  clusterIP: None
  selector:
    app: {{ .Chart.Name }}
  ports:
  - name: http
    port: 80

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Chart.Name }}-configmap
data:
  nginx.conf: |
    error_log  /dev/stderr;
    events {
      worker_connections 100000;
      multi_accept on;
    }
    http {
        charset utf-8;

        server {
            listen 80;
            index index.html;
            root /app;
            error_log /dev/stderr;
            location / {
                try_files $uri /index.html$is_args$args;
            }
        }
    }

---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Chart.Name }}
spec:
  rules:
  - host: "canary-example.flant.com"
    http:
      paths:
      - path: "/"
        pathType: Prefix
        backend:
          service:
            name: {{ .Chart.Name }}
            port:
              number: 80

В корень проекта добавим страницу, которую будет отдавать веб-сервер. Назовем ее index.html:

<!DOCTYPE html>
<html>
<body>
Hi! I'm another one typical nginx!
</body>
</html>

Также для деплоя нашего приложения потребуется CI*.

* Примечание

В примере рассмотрен деплой на базе GitLab CI + werf.

Создадим в корне проекта файл конфигурации werf — werf.yaml:

project: canary-example
configVersion: 1
deploy:
 helmRelease: '[[ project ]]"'
 namespace: "[[ project ]]"

---
image: nginx
from: nginx:stable
git:
- add: /
  to: /app
  excludePaths:
  - .helm
  - werf.yaml
  - .getlab-ci.yml

… и файл конфигурации GitLab CI — .gitlab-ci.yml **:

stages:
  - converge

.base_converge: &base_converge
  stage: converge
  script:
    - werf converge 
  except:
    - schedules
  tags:
    - werf

Converge base:
  <<: *base_converge
  environment:
    name: canary-example
  when: manual
** Примечание

Подробнее про использование werf для сборки образов и развертывания приложений можно прочитать в документации. Все исходные коды приложения и чартов можно найти в нашем репозитории.

Развернем приложение в кластере, запустив CI, и проверим, что оно работает:

$ curl canary-example.flant.com

Hi! I'm another one typical nginx!

Отлично! 

Переходим к реализации canary-релизов.

Делаем «канареечный» релиз

Подготовим две параллельно работающие версии приложения. Для этого нам понадобятся два отдельных Helm-релиза. 

Модифицируем созданный ранее CI, добавив в него отдельный Job для canary-деплоя и переменную $CANARY_DEPLOY, которую будем подставлять в название Helm-релиза. 

Внесем изменения в файлы проекта — в .gitlab-ci.yml:

stages:
  - converge

.base_converge: &base_converge
  stage: converge
  script:
    - export CI_HELM_RELEASE=${CANARY_DEPLOY}
    - werf converge 
      --set "global.canary_deploy=${CANARY_DEPLOY:-}"
  except:
    - schedules
  tags:
    - werf

Converge base:
  <<: *base_converge
  environment:
    name: canary-example
  when: manual
  variables:
    CANARY_DEPLOY: ""

Converge canary::
  <<: *base_converge
  environment:
    name: canary-example
  when: manual
  variables:
    CANARY_DEPLOY: "-canary"

… и в werf.yaml:

project: canary-example
configVersion: 1
deploy:
 helmRelease: '[[ project ]]{{ env "CI_HELM_RELEASE" }}'
 namespace: "[[ project ]]"

---
image: nginx
from: nginx:stable
git:
- add: /
  to: /app
  excludePaths:
  - .helm
  - werf.yaml
  - .getlab-ci.yml

Обратите внимание, что в .gitlab-ci.yml переменная $CANARY_DEPLOY используется в обоих вариантах деплоя (base и canary). Но в первом случае она содержит лишь пустую строку, а при canary — значение -canary. Соответственно, релиз основной версии будет называться nginx-example, а canary-релиз — nginx-example-canary.

Чтобы имена ресурсов в релизах не совпадали, немного модифицируем чарт. Для этого переопределим названия ресурсов по шаблону «название чарта + значение переменной global.canary_deploy»:

{{ $name := printf "%s%s" (.Chart.Name) (.Values.global.canary_deploy) }}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ $name }}
spec:
  revisionHistoryLimit: 3
  selector:
    matchLabels:
      app: {{ $name }}
  replicas: 1
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/10-nginx-config.yaml") . | sha256sum }}
      labels:
        app: {{ $name }}
    spec:
      imagePullSecrets:
      - name: registrysecret
      volumes:
      - name: configs
        configMap:
          name: {{ $name }}-configmap
      containers:
      - name: nginx
        imagePullPolicy: Always
        image: {{ index .Values.werf.image "nginx" }}
        lifecycle:
          preStop:
            exec:
              command: [ "/bin/bash", "-c", "sleep 5; kill -QUIT 1" ]
        command: ["/usr/sbin/nginx", "-g", "daemon off;"]
        ports:
        - containerPort: 80
          name: http
          protocol: TCP
        volumeMounts:
        - name: configs
          mountPath: /etc/nginx/nginx.conf
          subPath: nginx.conf
        resources:
          requests:
            cpu: 50m
            memory: 128Mi
          limits:
            memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name:{{ $name }}
spec:
  clusterIP: None
  selector:
    app: {{ $name }}
  ports:
  - name: http
    port: 80

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ $name }}-configmap
data:
  nginx.conf: |
    error_log  /dev/stderr;
    events {
      worker_connections 100000;
      multi_accept on;
    }
    http {
        charset utf-8;

        server {
            listen 80;
            index index.html;
            root /app;
            error_log /dev/stderr;
            location / {
                try_files $uri /index.html$is_args$args;
            }
        }
    }

В завершение добавим на Ingress аннотацию, которая определяет, какой процент трафика мы хотим направить в canary-версию приложения:

{{ $name := printf "%s%s" (.Chart.Name) (.Values.global.canary_deploy) }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ $name }}
{{- if ne .Values.global.canary_deploy "" }}
  annotations:
   nginx.ingress.kubernetes.io/canary: "true"
   nginx.ingress.kubernetes.io/canary-weight: "30"
{{- end }}
spec:
  rules:
  - host: "canary-example.flant.com"
    http:
      paths:
      - path: "/"
        pathType: Prefix
        backend:
          service:
            name: {{ $name }}
            port:
              number: 80

Аннотация nginx.ingress.kubernetes.io/canary-weight: "30" говорит о том, что 30% запросов должны быть направлены в новую версию приложения. 

Подробнее ознакомиться с этой функциональностью можно в документации контроллера.

Проверяем работоспособность

Так как для выката мы используем werf, подразумевается, что проект должен быть Git-репозиторием. Создадим отдельную ветку и изменим в ней содержимое веб-страницы:

<!DOCTYPE html>
<html>
<body>
Wow! I'm canary nginx!
</body>
</html>

Развернем новую версию приложения из созданной ветки и проверим, что получилось:

$ for ((i=1;i<=10;i++)); do   curl -s "canary-example.flant.com"; done

Hi! I'm another one typical nginx!
Hi! I'm another one typical nginx!
Hi! I'm another one typical nginx!
Hi! I'm another one typical nginx!
Hi! I'm another one typical nginx!
Wow! I'm canary nginx!
Wow! I'm canary nginx!
Hi! I'm another one typical nginx!
Hi! I'm another one typical nginx!
Wow! I'm canary nginx!

Как видно, в трех случаях из десяти мы получили ответ от новой версии приложения. При этом остальные семь запросов были обработаны базовой версией приложения.

Отлично, но можно лучше!

Пробуем альтернативный вариант — с header'ом

Зачастую требуется управлять балансировкой трафика между версиями приложения более гибко, нежели просто процентным соотношением. Реализовать это можно с помощью специального header'а или cookie в клиентском запросе. Способы практически не отличаются по реализации, поэтому рассмотрим вариант с header'ом.

Передадим в CI ключ и значение для header’а и немного изменим аннотации в Ingress-контроллере:

{{- if ne .Values.global.canary_deploy "" }}
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-by-header: {{ $.Values.global.canary_header | quote }}
    nginx.ingress.kubernetes.io/canary-by-header-value: {{ $.Values.global.canary_header_value | quote }}
{{- end }}

Добавим нужные переменные в CI:

.base_converge: &base_converge
  stage: converge
  script:
    - export CI_HELM_RELEASE=${CANARY_DEPLOY}
    - werf converge 
      --set "global.canary_deploy=${CANARY_DEPLOY:-}"
      --set "global.canary_header=${CANARY_HEADER:-}"
      --set "global.canary_header_value=${CANARY_HEADER_VALUE:-}"
  except:
    - schedules
  tags:
    - werf

Converge to canary:
  <<: *base_converge
  environment:
    name: canary-example
  when: manual
  variables:
    CANARY_DEPLOY: "-canary"
    CANARY_HEADER: "x-version"
    CANARY_HEADER_VALUE: "canary"

Развернем приложение и проверим, что получилось:

$ curl canary-example.flant.com

Hi! I'm another one typical nginx!

Всё работает. 

Теперь передадим в запросе нужный header:

$ curl -H "x-version: canary" canary-example.flant.com

Wow! I'm canary nginx!

Тоже всё работает.

Как развернуть «канареечные» релизы внутри кластера

В статье мы рассмотрели лишь один из вариантов canary-релиза. Главный недостаток такого подхода — необходимость использовать Ingress. Это отлично работает для frontend-приложений. В то время как у backend зачастую только Service, и обращаются к нему уже внутри кластера Kubernetes.

Для таких приложений задачу canary-развертывания отлично решает Service Mesh наподобие Istio — мы используем его, например, в нашей Kubernetes-платформе Deckhouse (познакомиться с функциями, которые решает Istio в рамках Deckhouse, можно в документации). Другие примеры реализации можно найти в официальной документации Istio и в нашем переводе.

P.S.

Читайте также в нашем блоге:

Комментарии (0)