Тема «канареечных» (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.
Читайте также в нашем блоге:
ссылка на оригинал статьи https://habr.com/ru/company/flant/blog/697030/
Добавить комментарий