Мы не раз разбирали в своём блоге теорию и практику по разным вариантам развёртывания новой версии приложения в Kubernetes. Сегодня я хочу рассмотреть ещё одну реализацию canary-деплоя: с пассивными healthcheck и использованием Argo Rollouts и Istio. Этот вариант подойдёт, если к вашему приложению обращаются не через Ingress, а ходят внутри кластера по адресу service.

Преимущества и нюансы канареечного деплоя

Перед тем как мы перейдём к делу, коротко напомню, что «канарейки» хороши тем, что пользовательский трафик перенаправляется на новую версию приложения постепенно. Это позволяет дополнительно протестировать новую функциональность на небольшой группе пользователей до того, как катить её на всех. У стратегии canary-деплоя есть и другие преимущества: 

  1. Экономия ресурсов. Переход на новую версию происходит плавно, поэтому можно постепенно наращивать рабочую нагрузку с новой версией и, наоборот, сокращать количество экземпляров стабильной версии. Так что нет необходимости сразу поднимать аналогичное по ресурсам окружение.

  2. Простой откат на стабильную версию. Он выполняется переключением трафика на версию, в которой вы уверены.

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

Стоит отметить, что canary-деплой подразумевает одновременную работу двух разных версий приложения, что влечёт за собой некоторые нюансы. 

Если приложение работает с базой данных, то нужно учитывать, что при выкатке новой версии у вас будут одновременно работать две версии приложения. Поэтому важно, чтобы схема базы данных не ломалась при деплое и была совместима с обеими версиями ПО. Эту проблему можно решить несколькими способами: например, использовать последовательные миграции (одна миграция — для подготовки схемы к работе с новой версией, а после успешного деплоя следующая миграция — для зачистки устаревшей схемы) либо не выполнять изменений в БД, которые ломают обратную совместимость.

Также необходимо реализовать привязку пользовательских сессий к одной и той же версии ПО, иначе запросы пользователя будут попадать на разные версии приложения. Одним из вариантов решения может быть использование Hash / Consistent Hashing.

Как я уже говорил, в этой статье мы разберём реализацию канареечного деплоя приложения с пассивными healthcheck с использованием Argo Rollouts и Istio service mesh. О варианте с Ingress NGINX Controller (далее также — Ingress NGINX) для переключения части трафика между версиями мы рассказывали на Хабре несколько лет назад. Он подходит, если к приложению обращаются через ingress. Если же к сервису ходят внутри кластера по адресу Service, то с помощью Ingress NGINX справиться уже не получится. Тут-то нам и поможет Istio. К тому же Istio предоставляет больше возможностей как с точки зрения Observability, так и с точки зрения Security.

Так как чаще всего приложение принимает трафик извне, то сначала рассмотрим кейс с переключением трафика на уровне Ingress NGINX, а затем покажем, как работает обновление на примере запросов по адресу внутреннего сервиса. 

Подготовка окружения

Мы будем настраивать канареечный деплой на базе Open Source-платформы Deckhouse Kubernetes Platform Community Edition (DKP CE). Нам понадобятся следующие компоненты:

  1. Deckhouse Kubernetes Platform CE

  2. Ingress NGINX Controller

  3. Istio service mesh

  4. Argo Rollouts

  5. Prometheus в лице Deckhouse Prom++

Для развёртывания кластера с DKP CE вы можете использовать быстрый старт. Когда платформа будет готова, нужно будет выполнить ещё три подготовительных шага. 

Первым делом создаём Ingress NGINX Controller, через который пользователи будут получать доступ к нашему приложению. Обязательно включаем параметр enableIstioSidecar, чтобы контроллер NGINX был под контролем Istio:

apiVersion: deckhouse.io/v1
kind: IngressNginxController
metadata:
  name: nginx
spec:
  ingressClass: nginx
  inlet: HostPort
  enableIstioSidecar: true
  hostPort:
    httpPort: 80
    httpsPort: 443
  nodeSelector:
    node-role.kubernetes.io/worker: ""
  tolerations:
  - effect: NoSchedule
    key: node-role.kubernetes.io/worker
    operator: Exists

Далее включаем модуль Istio. Здесь d8 — это утилита для управления Kubernetes-платформой Deckhouse:

# d8 system module enable istio

Необходимо дождаться, пока выполнятся задачи по включению модуля. В DKP за этим можно следить, просматривая очередь задач:

# d8 system queue list
Summary:
- 'main' queue: empty.
- 124 other queues (0 active, 124 empty): 0 tasks.
- no tasks to handle.

Очередь задач пуста, можно двигаться дальше.

Устанавливаем Argo Rollouts. При настройке реальных стендов установка утилит производится через Git с использованием GitOps-подхода, но сейчас для простоты сделаем всё через применение манифестов напрямую:

kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml

Реализация canary-деплоя

Рассмотрим основные компоненты стенда: 

  1. NGINX Ingress Controller — контроллер на базе nginx для приёма пользовательского трафика и проксирования на наше целевое приложение.

  2. Istio — реализация технологии сервис-меш (service mesh). В данном случае позволяет нам выполнять умную маршрутизацию трафика между разными версиями приложения.

  3. Argo Rollouts — оператор и набор CRD для реализации более сложных стратегий деплоя, чем Kubernetes изначально предоставляет«из коробки» (canary, blue/green).

  4. Deckhouse Prom++ — встроенное в DKP решение для сбора метрик.

В качестве примера бизнес-приложения напишем небольшой сервис на Golang в трёх версиях. Первая будет являться стабильной, иметь тег v1 и отдавать код 200 на каждый запрос. Вторая будет имитировать релиз с багами, на каждый второй запрос отдавать 500, таким образом имитируя баг, и иметь тег v2. Третья версия будет иметь тег v3, она будет играть роль пофикшенной второй версии и отдавать статус код 200.

В конце у нас получится реализация вот такой схемы трафика и основных компонентов:

Кликните на схему, если хотите посмотреть поближе
Кликните на схему, если хотите посмотреть поближе

Пишем манифесты

За распределение пользовательского трафика у нас отвечает Istio. Применим необходимые манифесты для нашего приложения:

---
apiVersion: v1
kind: Service
metadata:
  name: app
spec:
  ports:
  - port: 80
    targetPort: http
    protocol: TCP
    name: http
  selector:
    app: backend
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: app-vsvc
spec:
  hosts:
  - app
  http:
  - name: primary
    route:
    - destination:
        host: app
        subset: stable
      weight: 100
    - destination:
        host: app
        subset: canary
      weight: 0

---
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: app-destrule
spec:
  host: app
  subsets:
  - name: stable
    labels:
      app: backend
  - name: canary
    labels:
      app: backend

Обратите внимание на манифест DestinationRule. Istio позволяет выделить так называемые subsets — наборы эндпойнтов одного и того же Deployment с выборкой по лейблам. В данном случае мы выделяем subset для стабильной версии с именем stable и для новой версии с именем canary.

В VirtualService мы указываем два destination, причём для stable-версии вес указываем 100, а для canary — 0. Именно эти значения будет менять оператор Argo Rollouts в процессе канареечного деплоя, увеличивая таким образом долю трафика, приходящегося на новую версию приложения.

Создаём Rollout:

---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: app
spec:
  strategy:
    canary:
      analysis:
        templates:
        - templateName: success-rate
        startingStep: 1
        args:
        - name: service-name
          value: app.default.svc.cluster.local
      trafficRouting:
        istio:
          virtualService:
            name: app-vsvc
            routes:
            - primary
          destinationRule:
            name: app-destrule
            canarySubsetName: canary
            stableSubsetName: stable
      steps:
      - setWeight: 20
      - pause: {duration: 1m}
      - setWeight: 40
      - pause: {duration: 1m}
      - setWeight: 60
      - pause: {duration: 1m}
      - setWeight: 80
      - pause: {duration: 1m}
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
      - name: backend
        image: rinamuka/canary:v1
        ports:
        - name: http
          containerPort: 80
          protocol: TCP
        resources:
          requests:
              memory: 5Mi
              cpu: 5m
            limits:
              memory: 10Mi
              cpu: 10m

Rollout — это CRD, который приезжает вместе с Argo Rollouts и представляет собой обёртку над объектом Deployment, но с дополнительными настройками стратегии деплоя. Во время деплоя оператор патчит объекты DestinationRule, меняя там набор лейблов, и VirtualService, меняя проценты распределения трафика между subset’ами.

Как видим, манифест почти ничем не отличается от ванильного Deployment, за исключением раздела canary. Тот состоит из трёх подразделов:

  1. analisys — информация о том, какой шаблон healthcheck использовать. Тут стоит отметить параметр startingStep, который отвечает за то, с какого шага начинать делать анализ новой версии — те самые запросы в Prometheus. В данном случае проверка происходит в фоновом режиме, но есть возможность выполнять inline-анализ, то есть запускать проверку как самостоятельный step.

  2. trafficRouting — описание, к каким destinationRule и virtualService привязан наш rollout.

  3. steps — описание самого процесса канареечного деплоя: какими частями переключать трафик и сколько ждать, прежде чем перенести следующую порцию трафика на новую версию.

Чтобы наше приложение было частью сервис-меша, нужно повесить лейбл istio-injection: enabled на пространство имён, в котором оно находится. После этого в под с нашим приложением будет подселён sidecar-контейнер с Istio-агентом.

Дальше создадим объект Ingress, чтобы открыть наше приложение миру:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
    nginx.ingress.kubernetes.io/service-upstream: "true"
    nginx.ingress.kubernetes.io/upstream-vhost: app.default.svc

spec:
  tls:
  - hosts:
    - app.31.184.210.137.sslip.io
    secretName: app-tls
  rules:
    - host: app.31.184.210.137.sslip.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app
                port:
                  number: 80

Обращу внимание на две аннотации для Ingress:

  1. nginx.ingress.kubernetes.io/service-upstream: "true" — с этой аннотацией Ingress-контроллер будет отправлять запросы на ClusterIP сервиса (из диапазона Service CIDR) вместо того, чтобы слать их напрямую в поды приложения. Sidecar-контейнер istio-proxy перехватывает трафик только в сторону диапазона Service CIDR, остальные запросы отправляются напрямую.

  2. nginx.ingress.kubernetes.io/upstream-vhost: app.default.svc. В Istio вся маршрутизация осуществляется на основе заголовка Host. Чтобы не сообщать Istio о существовании внешнего домена app.31.184.210.137.sslip.io, мы просто используем внутренний домен, о котором Istio осведомлён.

Следующим шагом создадим манифест с AnalysisTemplate. Это описание того, как оценить работоспособность свежей версии приложения. В данном случае будет выполняться запрос в кластерный Prometheus, который входит в DKP «из коробки». Он будет оценивать долю 500-х ответов от общего числа. Если этот показатель оказывается более 5 %, деплой останавливается и весь трафик переключается обратно на стабильную версию приложения. Если он меньше, то мы продолжаем наращивать часть трафика, который идёт на новую версию:

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
spec:
  args:
    - name: service-name
    - name: api-token
      valueFrom:
        secretKeyRef:
          name: rollout-token
          key: token
  metrics:
    - name: success-rate
      interval: 1m
      successCondition: result[0] >= 0.95
      failureLimit: 2
      provider:
        prometheus:
          address: https://prometheus.d8-monitoring:9090
          insecure: true
          headers:
            - key: Authorization
              value: "Bearer {{ args.api-token }}"
          query: |
            sum(irate(istio_requests_total{reporter="source",destination_service=~"{{args.service-name}}",response_code!~"5.*"}[5m])) /
            sum(irate(istio_requests_total{reporter="source",destination_service=~"{{args.service-name}}"}[5m]))

Встроенный в DKP Prometheus закрыт авторизацией, поэтому при обращении к нему необходимо передавать кубовый токен. Чтобы получить его, создадим ServiceAccount, Role, RoleBinding и Secret, в котором будет храниться токен от нашего ServiceAccount:

---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: rollout
  namespace: default

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: app:prometheus-access
rules:
- apiGroups: ["monitoring.coreos.com"]
  resources: ["prometheuses/http"]
  resourceNames: ["main", "longterm"]
  verbs: ["get","create"]

---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: app:prometheus-access
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: app:prometheus-access
subjects:
- kind: ServiceAccount
  name: rollout
  namespace: default

---
apiVersion: v1
kind: Secret
metadata:
  name: rollout-token
  annotations:
    kubernetes.io/service-account.name: rollout
type: kubernetes.io/service-account-token

Обновляем приложение

Приступим к обновлению нашего приложения. За переключением трафика удобно наблюдать в веб-интерфейсе Isio — Kiali. Откроем его и запустим процесс выкатки новой версии v2.

После изменения тега в Rollout происходит следующее:

1. Поднимется новая реплика с новой версией образа — revision 2.

root@master-0:~# kubectl argo rollouts get rollout app
Name:            app
Namespace:       default
Status:          ॥ Paused
Message:         CanaryPauseStep
Strategy:        Canary
  Step:          1/8
  SetWeight:     20
  ActualWeight:  20
Images:          rinamuka/canary:v1 (stable)
                 rinamuka/canary:v2 (canary)
Replicas:
  Desired:       1
  Current:       2
  Updated:       1
  Ready:         2
  Available:     2

NAME                             KIND        STATUS     AGE    INFO
⟳ app                            Rollout     ॥ Paused   3m28s
├──# revision:2
│  └──⧉ app-655bb4c96c           ReplicaSet  ✔ Healthy  14s    canary
│     └──□ app-655bb4c96c-p2lsv  Pod         ✔ Running  14s    ready:2/2
└──# revision:1
   └──⧉ app-84975c75b            ReplicaSet  ✔ Healthy  3m28s  stable
      └──□ app-84975c75b-tfkqv   Pod         ✔ Running  3m28s  ready:2/2

2. Далее начнут выполняться шаги из манифеста Rollout. Argo Rollout патчит объекты DestinationRule и VirtualService

В DestinationRule он добавляет выборку по лейблу с хешем ReplicaSet. Один subset указывает на старую версию приложения (stable), а второй — на новую (canary):

subsets:
    - labels:
        app: backend
        rollouts-pod-template-hash: 84975c75b
      name: stable
    - labels:
        app: backend
        rollouts-pod-template-hash: 655bb4c96c
      name: canary

А в VirtualService меняются веса у destination:

spec:
    hosts:
    - app
    http:
    - name: primary
      route:
      - destination:
          host: app
          subset: stable
        weight: 80 # Изначально weight имел значение 100.
      - destination:
          host: app
          subset: canary
        weight: 20 # Изначально weight имел значение 0.

Также Argo Rollout-оператор создаёт объект с типом AnalysisRun. Именно он отвечает за проверку метрик в Prometheus. В describe объекта можно увидеть статусы проверок:

Status:
  Completed At:  2025-08-23T11:59:39Z
  Dry Run Summary:
  Message:  Metric "success-rate" assessed Failed due to failed (3) > failureLimit (2)
  Metric Results:
    Count:   3
    Failed:  3
    Measurements:
      Finished At:  2025-08-23T11:57:39Z
      Phase:        Failed
      Started At:   2025-08-23T11:57:39Z
      Value:        [0.9033333333333333]
      Finished At:  2025-08-23T11:58:39Z
      Phase:        Failed
      Started At:   2025-08-23T11:58:39Z
      Value:        [0.7958333333333333]
      Finished At:  2025-08-23T11:59:39Z
      Phase:        Failed
      Started At:   2025-08-23T11:59:39Z
      Value:        [0.8058333333333334]

Две из трёх проверок завершились некорректно: доля 500-х ошибок была больше 5 %. А в Kiali мы можем увидеть, как произошло переключение трафика:

Так как проверка не прошла, будет выполнено переключение на stable-версию:

Normal   RolloutResumed          2m29s                  rollouts-controller  Rollout is resumed
  Normal   Updated VirtualService  2m29s                  rollouts-controller  VirtualService `app-vsvc` set to desiredWeight '40'
  Normal   TrafficWeightUpdated    2m29s                  rollouts-controller  Traffic weight updated from 20 to 40
  Normal   RolloutStepCompleted    2m29s                  rollouts-controller  Rollout step 3/8 completed (setWeight: 40)
  Normal   AnalysisRunRunning      2m29s                  rollouts-controller  Background Analysis Run 'app-655bb4c96c-2' Status New: 'Running' Previous: ''
  Warning  AnalysisRunFailed       29s                    rollouts-controller  Background Analysis Run 'app-655bb4c96c-2' Status New: 'Failed' Previous: 'Running'

После отката под с canary-репликой будет удалён, а весь трафик будет переключён на stable:

С обновлением на релиз с багом и последующим откатом разобрались. Теперь обновим приложение на релиз, работающий корректно, — на версию v3. Выставляем в Rollout тег образа v3:

Name:            app
Namespace:       default
Status:          ॥ Paused
Message:         CanaryPauseStep
Strategy:        Canary
  Step:          1/8
  SetWeight:     20
  ActualWeight:  20
Images:          rinamuka/canary:v1 (stable)
                 rinamuka/canary:v3 (canary)
Replicas:
  Desired:       1
  Current:       2
  Updated:       1
  Ready:         2
  Available:     2

NAME                             KIND         STATUS        AGE    INFO
⟳ app                            Rollout      ॥ Paused      11m
├──# revision:3
│  └──⧉ app-76dfffd666           ReplicaSet   ✔ Healthy     52s    canary
│     └──□ app-76dfffd666-6tk68  Pod          ✔ Running     51s    ready:2/2
├──# revision:2
│  ├──⧉ app-655bb4c96c           ReplicaSet   • ScaledDown  7m46s  delay:passed
│  └──α app-655bb4c96c-2         AnalysisRun  ✖ Failed      4m38s  ✖ 3
└──# revision:1
   └──⧉ app-84975c75b            ReplicaSet   ✔ Healthy     11m    stable
      └──□ app-84975c75b-tfkqv   Pod          ✔ Running     11m    ready:2/2

Смотрим на результаты проверки:

Status:
  Dry Run Summary:
  Metric Results:
    Consecutive Success:  3
    Count:                3
    Measurements:
      Finished At:  2025-08-23T12:04:33Z
      Phase:        Successful
      Started At:   2025-08-23T12:04:33Z
      Value:        [1]
      Finished At:  2025-08-23T12:05:33Z
      Phase:        Successful
      Started At:   2025-08-23T12:05:33Z
      Value:        [1]
      Finished At:  2025-08-23T12:06:33Z
      Phase:        Successful
      Started At:   2025-08-23T12:06:33Z
      Value:        [1]

Все три проверки выполнились успешно, поэтому трафик будет постепенно переключён на новую версию:

Name:            app
Namespace:       default
Status:          ॥ Paused
Message:         CanaryPauseStep
Strategy:        Canary
  Step:          5/8
  SetWeight:     60
  ActualWeight:  60
Images:          rinamuka/canary:v1 (stable)
                 rinamuka/canary:v3 (canary)
Replicas:
  Desired:       1
  Current:       2
  Updated:       1
  Ready:         2
  Available:     2

NAME                             KIND         STATUS        AGE    INFO
⟳ app                            Rollout      ॥ Paused      19m
├──# revision:3
│  ├──⧉ app-76dfffd666           ReplicaSet   ✔ Healthy     9m1s   canary
│  │  └──□ app-76dfffd666-6tk68  Pod          ✔ Running     9m     ready:2/2
│  └──α app-76dfffd666-3         AnalysisRun  ◌ Running     5m53s  ✔ 6
├──# revision:2
│  ├──⧉ app-655bb4c96c           ReplicaSet   • ScaledDown  15m    delay:passed
│  └──α app-655bb4c96c-2         AnalysisRun  ✖ Failed      12m    ✖ 3
└──# revision:1
   └──⧉ app-84975c75b            ReplicaSet   ✔ Healthy     19m    stable
      └──□ app-84975c75b-tfkqv   Pod          ✔ Running     19m    ready:2/2

Напоследок проверим тот же самый кейс, но обращаясь не через Ingress NGINX, а напрямую к сервису. Это тот самый случай, где Ingress NGINX не справится.

Запустим под, имитирующий клиента, и будем выполнять запросы по имени service(app.default), а не по хосту Ingress. И да, в этом случае переключение и балансировка трафика также работают, так как весь роутинг контролируется Istio sidecar:

Итог

Можно настроить канареечный деплой вашего приложения в Kubernetes, используя всего пару инструментов — Argo Rollouts и Istio. В статье я пользовался только базовыми возможностями Argo Rollouts. Помимо упомянутых функций, он поддерживает интеграцию с HPA и VPA, работу с различными балансировщиками нагрузки для роутинга трафика между разными версиями приложений, интеграцию с различными источниками метрик и так далее. Так что можно настроить всё под себя.

Плюс описанного подхода в том, что всё происходит в автоматическом режиме: переключение трафика, проверка состояния нового релиза, откат. Также этот метод не требует сильного изменения манифестов вашего приложения, ведь объект Rollout практически аналогичен ванильному Deployment. Использование же Istio даёт хороший фундамент для дальнейшего развития процесса деплоя в случае необходимости.

Ну и на всякий случай напомню, что каждая стратегия деплоя имеет свои преимущества и недостатки. Выбирайте её, отталкиваясь от особенностей вашего приложения.

Полезные ссылки

Литература по теме

  • Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation by David Farley, Jez Humble

  • Cloud Native Patterns: Designing change-tolerant software First Edition by Cornelia Davis

P. S. 

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

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