В werf 1.2 для обновления ресурсов в Kubernetes мы использовали механизм под названием 3-way merge. Он достался нам от Helm 3, который мы использовали как подсистему развертывания. Хотя 3-way merge и решил часть проблем, существовавших в 2-way merge, многие проблемы, приводящие к некорректным обновлениям ресурсов, так и остались нерешёнными.

В werf 2.0 и Nelm мы пошли дальше и заменили 3-way merge на более современный механизм обновления ресурсов Kubernetes — Server-Side Apply. Он решает все проблемы 3-way merge и гарантирует корректные обновления ресурсов в кластере при развёртывании.

Подробнее о 3-way merge и 2-way merge читайте в нашей статье от 2019 года.

В этой статье мы расскажем, какие проблемы испытывают пользователи Helm 3 и как Server-Side Apply помогает их преодолеть.

Некорректные обновления ресурсов в Helm 3

Helm 3 и werf 1.2 используют 3-way merge (далее — 3WM) для обновления ресурсов в кластере. Но с 3WM можно нередко обнаружить, что развёрнутые ресурсы не соответствуют тому, что описано в Helm-чарте. Давайте попробуем воспроизвести подобный сценарий.

Допустим, у нас есть Helm-чарт с Deployment:

$ cat chart/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: main
        image: nginx

…и хуком Job:

$ cat chart/templates/job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: myjob
  annotations:
    helm.sh/hook: "post-install,post-upgrade"
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
      - name: main
        image: alpine
        command: ["echo", "succeeded"]

Сделаем первый релиз, используя последнюю версию Helm 3:

$ helm upgrade --install myapp chart
Release "myapp" has been upgraded. Happy Helming!

Теперь в Deployment чарта заменим один контейнер main на два — backend и frontend:

$ cat chart/templates/deployment.yaml
...
      containers:
      - name: backend
        image: nginx
      - name: frontend
        image: nginx

…и одновременно с этим «случайно» сломаем хук Job:

$ cat chart/templates/job.yaml
...
      containers:
      - name: main
        image: alpine
        command: ["fail"]

Делаем второй релиз, в этот раз ожидаемо неудачный:

$ helm upgrade --install myapp chart
Error: UPGRADE FAILED: post-upgrade hooks failed: 1 error occurred:
       * job myjob failed: BackoffLimitExceeded

Поняв, что допустили ошибку в Job в чарте, исправим её:

$ cat chart/templates/job.yaml
...
      containers:
      - name: main
        image: alpine
        command: ["echo", "succeeded"]

…а заодно, решив, что у новых контейнеров в Deployment не очень удачные названия, переименуем их из backend и frontend в app и proxy:

$ cat chart/templates/deployment.yaml
...
      containers:
      - name: app
        image: nginx
      - name: proxy
        image: nginx

Делаем третий, успешный релиз:

$ helm upgrade --install myapp chart
Release "myapp" has been upgraded. Happy Helming!

А теперь проверим, совпадают ли Deployment в чарте и Helm-релизе с Deployment в кластере:

$ cat chart/templates/deployment.yaml
...
      containers:  # корректно      
      - name: app
      - name: proxy

$ helm get manifest myapp
...
      containers:  # корректно
      - name: app
      - name: proxy

$ kubectl get deploy myapp -oyaml
...
      containers:  # некорректно
      - name: app
      - name: proxy
      - name: backend
      - name: frontend

…и обнаружим, что Deployment в чарте/релизе имеет два контейнера, а Deployment в кластере — почему-то четыре: два правильных контейнера app и proxy и два старых контейнера frontend и backend.

Новый релиз не поможет избавиться от ненужных контейнеров frontend и backend:

$ helm upgrade --install myapp chart
$ kubectl get deploy myapp -oyaml
...
      containers:
      - name: app
      - name: proxy
      - name: backend
      - name: frontend

Rollback до самой первой ревизии тоже не поможет:

$ helm rollback myapp 1
$ kubectl get deploy myapp -oyaml
...
      containers:
      - name: main
      - name: backend
      - name: frontend

На этом этапе самый простой способ избавиться от ненужных контейнеров — это удалить их в кластере вручную через kubectl edit.

И этот случай не уникален — примерно то же самое регулярно происходит с большинством полей большинства ресурсов при развертывании с Helm 3. А триггером может выступать не только неудачный, но и отменённый релиз (когда Helm получил сигнал INT, TERM или KILL).

Почему это происходит и что делать

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

Но почему бы тогда просто не удалять всё, чего нет в манифесте ресурса в чарте? Да потому что Kubernetes или Kubernetes-операторы могут вносить в ресурс в кластере такие изменения, которые Helm никогда не должен удалять. Например, если Istio добавляет istio-proxy sidecar-контейнер в Deployment, Helm этот sidecar-контейнер удалять не должен, хотя его и нет в чарте.

Чтобы понять, что делать, Helm должен разделить «лишние» поля (те, которые есть только в ресурсе в кластере, но не в чарте) на «свои» и «чужие». «Свои» поля ему удалять можно, а «чужие» — нельзя. При использовании helm upgrade «своими» полями считаются все поля, присутствующие в ресурсе нового релиза и предыдущего удачного релиза. Здесь обычно и возникает проблема: что будет, если предыдущий релиз был неудачным или отменённым, то есть был задеплоен не полностью, и при этом в нём произошло что-то важное, например появились новые «свои» поля? Это приведет к тому, что Helm некоторые «свои» поля начнёт расценивать как «чужие», и никогда не будет их удалять.

В итоге, чем чаще релизы заканчиваются неудачно или отменяются, тем больше «осиротевших» полей можно обнаружить на ресурсах в кластере. Иногда это что-то безобидное, а иногда это может быть что-то, приводящее к отказу в обслуживании или даже к порче/потере данных.

В рамках Helm простого решения для этой проблемы нет. Одним из способов могла бы быть новая схема Helm-релизов, где мы для каждого отдельного ресурса фиксировали бы его последнее применённое состояние. Но есть способ лучше — использование Server-Side Apply вместо 3WM.

Что такое Server-Side Apply

В Kubernetes 1.22 включена поддержка нового способа обновления ресурсов в кластере, который называется Server-Side Apply (далее — SSA). Давайте сравним обновление ресурсов через 3WM с обновлением через SSA.

Чтобы обновить ресурс через 3WM, нужно выполнить следующие шаги:

  1. Взять манифест ресурса из последнего удачного релиза.

  2. Взять манифест ресурса из чарта.

  3. Взять манифест ресурса из кластера.

  4. На основе этих трёх манифестов составить 3WM-патч.

  5. Отправить в Kubernetes HTTP PATCH-запрос с 3WM-патчем.

Чтобы обновить ресурс через SSA, необходимо выполнить всего лишь две операции:

  1. Взять манифест ресурса из чарта.

  2. Отправить в Kubernetes HTTP PATCH-запрос с манифестом ресурса.

Преимущества SSA:

  • Простота использования.

  • Не нужен манифест ресурса из последнего релиза, который иногда может быть недостоверным.

  • Не надо вычислять, какие поля ресурса «свои», а какие нет — Kubernetes сам отслеживает это в поле managedFields ресурса.

  • Обновление ресурса и сохранение информации о «своих» полях — это одна атомарная операция.

Если бы мы сумели заменить 3WM на SSA в Helm, нам бы больше не понадобилось обращаться к манифестам предыдущих релизов — за исключением случаев, когда нам необходимо понять, какие ресурсы надо удалить целиком, если они уже были удалены из чарта. Это бы полностью решило проблемы с «осиротевшими» полями в ресурсах в кластере.

Поддержка Server-Side Apply в Helm, werf и других инструментах

Flux, ArgoCD, kubectl/kustomize уже имеют поддержку SSA, хотя по умолчанию пока она включена только во Flux. За внедрение SSA в Helm 3 так никто и не взялся, даже несмотря на то, что поддержка SSA была уже в Kubernetes 1.16 (включалась через feature gate), а в Kubernetes 1.22 она и вовсе была включена по умолчанию.

В werf 2.0 мы разработали и внедрили новый движок развёртывания Nelm, который пришёл на смену Helm 3. В Nelm, помимо всего прочего, мы полностью заменили 3WM на SSA. Внедрение SSA позволило решить ещё целый ряд проблем вроде этой (до сих пор существует в Helm, а появилась ещё в версии 2). Также SSA позволил реализовать несколько фич вроде автоматического сброса при новом релизе тех изменений ресурсов, которые были внесены вручную через kubectl edit.

SSA в экспериментальном режиме был внедрён уже в werf 1.2 и эксплуатировался (в том числе в production) более года. Все пользователи werf 2.0 уже используют SSA по умолчанию, все проблемы 3WM исчезли, и на этом этапе мы рекомендуем werf 2.0 и SSA к использованию в production.

Для пользователей werf 1.2: миграция на werf 2.0 очень проста, и если не учитывать более строгую валидацию Helm-чартов, то в них почти ничего менять не нужно.

P. S.

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