3-way merge в werf: деплой в Kubernetes с Helm «на стероидах»

Случилось то, чего мы (и не только мы) долго ждали: werf, наша Open Source-утилита для сборки приложений и их доставки в Kubernetes, теперь поддерживает применение изменений с помощью 3-way-merge-патчей! В дополнение к этому, появилась возможность adoption«а существующих K8s-ресурсов в Helm-релизы без пересоздания этих ресурсов.

osycopyy-kknbg7fnwuf8cjohxi.png

Если совсем коротко, то ставим WERF_THREE_WAY_MERGE=enabled — получаем деплой «как в kubectl apply», совместимый с существующими инсталляциями на Helm 2 и даже немного больше.

Но давайте начнём с теории: что вообще такое 3-way-merge-патчи, как люди пришли к подходу с их генерацией и почему они важны в CI/CD-процессах с инфраструктурой на базе Kubernetes? А после этого — посмотрим, что же представляет собой 3-way-merge в werf, какие режимы используются по умолчанию и как этим управлять.

Что такое 3-way-merge-патч?


Итак, начнем с задачи выката ресурсов, описанных в YAML-манифестах, в Kubernetes.

Для работы с ресурсами Kubernetes API предлагает такие основные операции: create, patch, replace и delete. Предполагается, что с их помощью нужно сконструировать удобный непрерывный выкат ресурсов в кластер. Как?

Императивные команды kubectl


Первый подход к управлению объектами в Kubernetes — использование императивных команд kubectl для создания, изменения и удаления этих объектов. Проще говоря:
Такой подход может показаться удобным с первого взгляда. Однако есть проблемы:

  1. Его тяжело автоматизировать.
  2. Как отразить конфигурацию в Git? Как делать review изменений, происходящих с кластером?
  3. Как обеспечить воспроизводимость конфигурации при перезапуске?


Понятно, что такой подход плохо сочетается с хранением вместе с кодом приложения и инфраструктуры как кода (IaC; или даже GitOps как более современного варианта, набирающего популярность в Kubernetes-экосистеме). Поэтому дальнейшего развития эти команды в kubectl не получили.

Операции create, get, replace и delete


С первичным созданием все просто: отправляем манифест в операцию create у kube api и ресурс создан. YAML-представление манифеста можно хранить в Git, а для создания — использовать команду kubectl create -f manifest.yaml.

С удалением тоже просто: подставляем тот же manifest.yaml из Git в команду kubectl delete -f manifest.yaml.

Операция replace позволяет полностью заменить конфигурацию ресурса на новую, без пересоздания ресурса. Это означает, что перед тем, как делать изменение в ресурс, логично запросить текущую версию операцией get, изменить ее и обновить операцией replace. В kube apiserver встроен optimistic locking и, если после операции get объект поменялся, то операция replace не пройдет.

Чтобы хранить конфигурацию в Git и обновлять с помощью replace, надо делать операцию get, мержить конфиг из Git«а с тем, что мы получили, и выполнять replace. Штатно kubectl позволяет лишь пользоваться командой kubectl replace -f manifest.yaml, где manifest.yaml — уже полностью подготовленный (в нашем случае — смерженный) манифест, который требуется установить. Получается, пользователю необходимо реализовать merge манифестов, а это дело нетривиальное…

Также стоит отметить, что хотя manifest.yaml и хранится в Git, мы не можем знать заранее, надо создавать объект или обновлять его — это должен делать пользовательский софт.

Итого: можем ли мы построить непрерывный выкат только с помощью create, replace и delete, обеспечив хранение конфигурации инфраструктуры в Git«е вместе с кодом и удобный CI/CD?

В принципе, можем… Для этого потребуется реализовать операцию merge манифестов и какую-то обвязку, которая:

  • проверяет наличие объекта в кластере,
  • выполняет первичное создание ресурса,
  • обновляет или удаляет его.


При обновлении надо учесть, что ресурс мог поменяться со времени последнего get и автоматически обрабатывать случай optimistic locking — делать повторные попытки обновления.

Однако зачем изобретать велосипед, когда kube-apiserver предлагает другой способ обновления ресурсов: операцию patch, которая снимает с пользователя часть описанных проблем?

Patch


Вот мы и добрались до патчей.

Патчи — это основной способ применения изменений к существующим объектам в Kubernetes. Операция patch работает так, что:

  • пользователю kube-apiserver требуется послать патч в JSON-виде и указать объект,
  • , а apiserver сам разберется с текущим состоянием объекта и приведет его к требуемому виду.


Optimistic locking в данном случае не требуется. Эта операция более декларативная по сравнению с replace, хотя сначала может показаться наоборот.

Таким образом:

  • с помощью операции create мы создаем объект по манифесту из Git«а,
  • с помощью delete — удаляем, если объект больше не требуется,
  • с помощью patch — изменяем объект, приводя его к виду, описанному в Git.


Однако, чтобы это сделать, необходимо создать правильный патч!

Как работают патчи в Helm 2: 2-way-merge


При первой установке релиза Helm выполняет операцию create для ресурсов чарта.

При обновлении релиза Helm для каждого ресурса:

  • считает патч между версией ресурса из прошлого чарта и текущей версией чарта,
  • применяет этот патч.


Такой патч мы будем называть 2-way-merge patch, потому что в его создании участвуют 2 манифеста:

  • манифест ресурса из предыдущего релиза,
  • манифест ресурса из текущего ресурса.


При удалении операция delete в kube apiserver вызывается для ресурсов, которые были объявлены в прошлом релизе, но не объявлены в текущем.

Подход с 2 way merge patch имеет проблему: он приводит к рассинхрону реального состояния ресурса в кластере и манифеста в Git.

Иллюстрация проблемы на примере


  • В Git, в чарте хранится манифест, в котором поле image у Deployment имеет значение ubuntu:18.04.
  • Пользователь через kubectl edit поменял значение этого поля на ubuntu:19.04.
  • При повторном деплое чарта Helm не генерирует патч, потому что поле image в предыдущей версии релиза и в текущем чарте одинаковы.
  • После повторного деплоя image остается ubuntu:19.04, хотя в чарте написано ubuntu:18.04.


Мы получили рассинхронизацию и потеряли декларативность.

Что такое синхронизированный ресурс?


Вообще говоря, полное соответствие манифеста ресурса в работающем кластере и манифеста из Git получить невозможно. Потому что в реальном манифесте могут быть служебные аннотации/лейблы, дополнительные контейнеры и другие данные, добавляемые и удаляемые из ресурса динамически какими-то контроллерами. Эти данные мы не можем и не хотим держать в Git. Однако мы хотим, чтобы при выкате те поля, которые мы явно указали в Git«е, принимали соответствующие значения.

Получается такое общее правило синхронизированного ресурса: при выкате ресурса можно менять или удалять только те поля, которые явно прописаны в манифесте из Git«а (или были прописаны в предыдущей версии, а теперь удалены).

3-way-merge patch


Основная идея 3-way-merge patch: генерируем патч между последней применённой версией манифеста из Git«а и целевой версией манифеста из Git«а с учетом текущей версии манифеста из работающего кластера. Итоговый патч должен соответствовать правилу синхронизированного ресурса:

  • новые поля, добавленные в целевую версию, добавляются с помощью патча;
  • ранее существовавшие поля в последней применённой версии и не существующие в целевой — обнуляются с помощью патча;
  • поля в текущей версии объекта, отличающиеся от целевой версии манифеста, — обновляются с помощью патча.


Именно по такому принципу генерирует патчи kubectl apply:

  • последняя примененная версия манифеста сохраняется в аннотации самого объекта,
  • целевая — берется из указанного YAML-файла,
  • текущая — из работающего кластера.


Теперь, когда разобрались с теорией, пора рассказать, что же мы сделали в werf.

Применение изменений в werf


Ранее werf, как и Helm 2, использовал 2-way-merge-патчи.

Repair patch


Для того, чтобы перейти на новый вид патчей — 3-way-merge, — первым шагом мы ввели так называемые repair-патчи.

При деплое используется стандартный 2-way-merge-патч, но werf дополнительно генерирует такой патч, который бы синхронизировал реальное состояние ресурса с тем, что написано в Git (создается такой патч с использованием того же правила синхронизированного ресурса, описанного выше).

В случае возникновения рассинхрона, в конце деплоя пользователь получает WARNING с соответствующим сообщением и патчем, который надо применить, чтобы привести ресурс к синхронизированному виду. Также этот патч записывается в специальную аннотацию werf.io/repair-patch. Предполагается, что пользователь руками сам применит этот патч: werf его применять не будет принципиально.

Генерация repair-патчей — это временная мера, которая позволяет испытать на деле создание патчей по принципу 3-way-merge, но автоматически эти патчи не применять. На данный момент такой режим работы включен по умолчанию.

3-way-merge patch только для новых релизов


Начиная с 1 декабря 2019 г. beta- и alpha-версии werf начинают по умолчанию использовать полноценные 3-way-merge-патчи для применения изменений только для новых Helm-релизов, выкатываемых через werf. Уже существующие релизы продолжат использовать подход с 2-way-merge + repair-патчами.

Данный режим работы можно включить явно настройкой WERF_THREE_WAY_MERGE_MODE=onlyNewReleases уже сейчас.

3-way-merge patch для всех релизов


Начиная с 15 декабря 2019 г. beta- и alpha-версии werf начинают по умолчанию использовать полноценные 3-way-merge-патчи для применения изменений для всех релизов.

Данный режим работы можно включить явно настройкой WERF_THREE_WAY_MERGE_MODE=enabled уже сейчас.

Как быть с автомасштабированием ресурсов?


В Kubernetes существует 2 типа автомасштабирования: HPA (горизонтальный) и VPA (вертикальный).

Горизонтальный автоматически выбирает количество реплик, вертикальный — количество ресурсов. И количество реплик, и требования к ресурсам указываются в манифесте ресурса (см. spec.replicas или spec.containers[].resources.limits.cpu, spec.containers[].resources.limits.memory и другие).

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

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

  • werf.io/set-replicas-only-on-creation=true
  • werf.io/set-resources-only-on-creation=true


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

Подробнее — см. в документации проекта по HPA и VPA.

Запретить использование 3-way-merge patch


Пользователь пока может запретить использование новых патчей в werf с помощью переменной окружения WERF_THREE_WAY_MERGE_MODE=disabled. Однако начиная с 1 марта 2020 года данный запрет перестанет работать и возможно будет лишь использование 3-way-merge-патчей.

Adoption ресурсов в werf


Освоение метода применения изменений 3-way-merge-патчами позволило нам сразу реализовать такую фичу, как adoption существующих в кластере ресурсов в Helm-релиз.

Helm 2 имеет проблему: нельзя добавить в манифесты чарта ресурс, который уже существует в кластере, без пересоздания с нуля этого ресурса (см. #6031, #3275). Мы научили werf принимать существующие ресурсы в релиз. Для этого нужно установить на текущую версию ресурса из работающего кластера аннотацию (например, с помощью kubectl edit):

"werf.io/allow-adoption-by-release": RELEASE_NAME


Теперь ресурс нужно описать в чарте и при следующем деплое werf«ом релиза с соответствующим именем существующий ресурс будет принят в этот релиз и останется под его управлением. Более того, в процессе принятия ресурса в релиз werf приведет текущее состояние ресурса из работающего кластера к состоянию, описанному в чарте, используя те же 3-way-merge-патчи и правило синхронизированного ресурса.

(Примечание: настройка WERF_THREE_WAY_MERGE_MODE не влияет на adoption ресурсов — в случае adoption всегда используется 3-way-merge-патч).

Подробности — в документации.

Выводы и дальнейшие планы


Надеюсь, после этой статьи стало понятнее, что такое 3-way-merge-патчи и почему к ним пришли. С практической точки зрения развития проекта werf их реализация стала еще одним шагом на пути улучшения Helm-подобного деплоя. Теперь можно забыть о проблемах с синхронизацией конфигурации, которые часто возникали при использовании Helm 2. Вместе с тем, была добавлена новая полезная фича adoption«а уже выкаченных Kubernetes-ресурсов в Helm-релиз.

В Helm-подобном деплое по-прежнему остаются некоторые проблемы и трудности, такие как использование Go-шаблонов, и мы продолжим их решать.

Информацию о методах обновления ресурсов и adoption«е можно также найти на этой странице документации.

Helm 3


Отдельного замечания достойна вышедшая буквально на днях новая мажорная версия Helm — v3, — которая также использует 3-way-merge-патчи и избавляется от Tiller. Новая версия Helm требует миграции уже существующих установок, чтобы сконвертировать их в новый формат хранения релизов.

Werf со своей стороны на данный момент уже избавился от использования Tiller, переключился на 3-way-merge и добавил многое другое, при этом оставшись совместимым с уже существующими инсталляциями на Helm 2 (никаких скриптов миграции выполнять не нужно). Поэтому, пока werf не переключен на Helm 3, пользователи werf не теряют основных преимуществ Helm 3 перед Helm 2 (в werf они также есть).

Тем не менее, переключение werf на кодовую базу Helm 3 неизбежно и произойдет в ближайшем будущем. Предположительно это будет werf 1.1 или werf 1.2 (на данный момент, главная версия werf — 1.0; подробнее про устройство версионирования werf см. здесь). За это время Helm 3 успеет стабилизироваться.

P.S.


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

© Habrahabr.ru