Тема «канареечных» (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
- .gitlab-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
- .gitlab-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.
Читайте также в нашем блоге: