Mindbox — высоконагруженный сервис, который в пике выдерживает 4,5 млн распределенных транзакций в минуту. Чтобы он бесперебойно работал 24/7, мы развернули инфраструктуру в трех независимых зонах доступности: если одна из них выходит из строя, нагрузка перераспределяется между двумя другими. Недавно для проверки устойчивости системы мы начали проводить хаос‑тесты с помощью open source проекта Chaos Mesh. Тесты симулируют отказ компонентов в одной из зон и помогают проверить, как локальный сбой скажется на работе всего сервиса. На одной из плановых проверок мы заметили, что завершенный тест может внезапно и неконтролируемо запуститься повторно. В лучшем случае такой инцидент поднимает на уши всю команду, а в худшем — роняет прод. 

На связи Дмитрий Рыбалка, SRE‑инженер Mindbox. В статье расскажу, как мы столкнулись с неконтролируемым запуском «зомби‑тестов» в Chaos Mesh и победили их с помощью патча для workflow.

Как устроен workflow и что происходило во время проверки

Чтобы последовательно описать проблему, сначала расскажу, как устроен workflow нашего теста. Он состоит из четырех шагов:

  • уведомление о старте,

  • выбор зоны доступности,

  • запуск нужной ветки PodChaos,

  • уведомление об окончании.

Обвязку вокруг PodChaos — уведомления и выбор зоны — мы реализовали через Task. Для Chaos Mesh это стандартный способ описывать произвольные команды. 

Поначалу все работало в штатном режиме: планировщик Chaos Mesh запускал тест по расписанию и все шаги успешно выполнялись с ожидаемым результатом. Но периодически и внезапно завершенный Task мог запуститься снова за пределами тестового окна.

Из‑за этого повторно выполнялся не только сам тест, но и все связанные shell‑команды: уведомления, служебные действия, выбор зоны, отключение и включение алертов. В худшем случае такой повторный запуск мог наложиться на другой хаос‑тест, вывести из строя вторую зону доступности и уронить продакшен. Подобные инциденты недопустимы, поэтому мы начали срочно искать источник проблемы.

Конфиг workflow
apiVersion: chaos-mesh.org/v1alpha1
kind: Schedule
metadata:
  name: test-chaos-schedule
spec:
  concurrencyPolicy: Forbid
  historyLimit: 1
  schedule: "*/10 * * * *"
  type: Workflow
  workflow:
    entry: workflow
    templates:
      - templateType: Serial
        name: workflow
        children:
          - notify-test-started
          - pod-chaos-random-zone
          - notify-test-stopped

      - templateType: Task
        name: notify-test-started
        task:
          container:
            command: ["/bin/sh", "-c"]
            args:
              - echo chaos-test started

      - templateType: Task
        name: pod-chaos-random-zone
        conditionalBranches:
          - expression: stdout == "0"
            target: pod-chaos-zone-a
          - expression: stdout == "1"
            target: pod-chaos-zone-b
          - expression: stdout == "2"
            target: pod-chaos-zone-d
        task:
          container:
            command: ["/bin/sh", "-c"]
            args:
              - |
                echo $((RANDOM % 3))
      - templateType: PodChaos
        name: pod-chaos-zone-a
	   	...
      - templateType: PodChaos
        name: pod-chaos-zone-b
...
      - templateType: PodChaos
        name: pod-chaos-zone-d
		...
      - templateType: Task
        name: notify-test-stopped
        task:
          container:
            command: ["/bin/sh", "-c"]
            args:
              - echo chaos-test stopped

Какие настройки Chaos Mesh бесполезны в борьбе с «зомби‑тестами»

Сначала решили поэкспериментировать с настройками Chaos Mesh, которые отвечают за жизненный цикл workflow и его шагов.

concurrencyPolicy: Forbid. Настройка запрещает запуск нового workflow, если какой‑либо другой workflow еще не завершен. Это не помогло защититься от повторного выполнения шагов внутри уже завершенного workflow.

startingDeadlineSeconds не позволяет догонять пропущенные запуски workflow. Это тоже было бесполезно, потому что в нашем случае повторно запускался Task выполненного, а не пропущенного workflow. 

deadline ограничивает время жизни самого Task и не дает pod зависать. Но и это не мешало уже завершенному Task повторно стартовать после удаления pod.

Как искали рабочее решение: устройство Chaos Mesh и три полумеры

Когда победить «зомби‑запуски» с помощью настроек не удалось, мы стали разбираться, как работает workflow в Chaos Mesh, и обнаружили объект WorkflowNode. Он описывает состояние конкретного шага: что уже выполнено, что еще должно выполниться и в каком статусе шаг находится сейчас. 

Если в workflow значение параметра historyLimit: было равно единице, то этот завершенный workflow сохранялся в кластере до следующего запуска вместе со всеми своими объектами, включая Task pod. При этом, как эфемерный объект, Task pod мог быть удален, вытеснен из узла или перезапущен. В таких случаях Chaos Mesh воспринимал исчезновение pod как необходимость восстановить структуру объектов до состояния шага, сохраненного в WorkflowNode, и создавал pod заново. Для контроллера это было частью стандартного жизненного цикла Task, но для нас — самым настоящим повторным запуском уже завершенного теста.

Полумера № 1. Удаляем workflow с помощью параметра historyLimit: 0

Решением «с первой полки» было бы установить параметру historyLimit: значение 0. Тогда завершенный workflow сразу удалялся вместе со всеми связанными объектами, включая WorkflowNode и Task pod. И запускать повторно было уже нечего.

Это сразу решало проблему зомби‑запусков, но при этом мы теряли данные последнего теста и не могли сравнивать последующие результаты с предыдущими. В итоге оставались без ответов на ключевые вопросы:

  1. Был ли запущен тест?

  2. Завершился ли он?

  3. Каков был результат последнего выполнения?

  4. Что происходило на каждом шаге?

Кроме того, значение 0 для historyLimit не логично. Сохранять результаты хотя бы одного из прошлых тестов — полезная фича Chaos Mesh, и глупо от нее отказываться. К тому же в текущей версии значение 0 еще допустимо, но в будущих релизах такая конфигурация может попасть под жесткую валидацию. Тогда workaround с historyLimit: 0 просто перестанет работать, а значит, построить на этом быстрый фикс мы не можем.

Полумера № 2. Избавляемся от рандомного выбора зоны

Когда поняли, что избавиться от повторных запусков полностью не получается, решили исключить хотя бы самый опасный сценарий — произвольный выбор зоны для хаос‑теста. 

Изначально Task для выбора зоны запускал shell‑команду, которая генерировала случайное число от 0 до 2. Затем вывод этой команды в stdout использовался в conditionalBranches, чтобы запустить PodChaos для нужной зоны. А значит, потенциальный «зомби‑тест» мог отработать не там, где мы запустили проверку.

В упрощенном виде это выглядело так:

- name: pod-chaos-random-zone
  templateType: Task
  conditionalBranches:
    - expression: stdout == "0"
      target: pod-chaos-zone-a
    - expression: stdout == "1"
      target: pod-chaos-zone-b
    - expression: stdout == "2"
      target: pod-chaos-zone-d
  task:
    container:
      image: alpine
      name: random-number-selector
      command:
        - /bin/sh
        - -c
      args:
        - |
          echo $((RANDOM % 3))

Изучая работу Chaos Mesh, мы заметили закономерность. Если результат выполнения shell‑команды в новом Task pod для выбора зоны совпадал с предыдущим запуском, то новый WorkflowNode для теста не создавался. И следовательно, повторно не запускался Pod Chaos. 

Решили использовать эту закономерность в борьбе с «зомби‑запусками»: переписали Task для выбора зоны, чтобы он возвращал одно и то же число для каждого конкретного workflow.

Получилось так:

- name: select-zone-safely
  templateType: Task
  deadline: 20s
  conditionalBranches:
  - expression: stdout == "0"
    target: pod-chaos-zone-a
  - expression: stdout == "1"
    target: pod-chaos-zone-b
  - expression: stdout == "2"
    target: pod-chaos-zone-d
  task:
    container:
      command: ["/bin/sh", "-c"]
      args:
        - |
          SOURCE="${CONTROLLED_BY:-default-seed}"
          SEED=$(echo "$SOURCE" | md5sum | awk '{print substr($1,1,8)}')
          echo $((SEED % 3))
      env:
        - name: CONTROLLED_BY
          valueFrom:
            fieldRef:
              fieldPath: metadata.labels['chaos-mesh.org/workflow']
      image: alpine
      name: random-number-selector

Значение для переменной CONTROLLED_BY выбиралось из labels текущего pod и содержало уникальное имя workflow.

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

Полумера № 3. Проверяем статус WorkflowNode из task pod

Разобравшись с рандомным выбором зоны, мы пошли дальше. Добавили в shell‑команды Task дополнительную проверку через API Kubernetes. При запуске контейнер обращался к объекту WorkflowNode и проверял состояние текущего шага. Если у свойства Status было значение Accomplished true, основная логика не выполнялась, а контейнер сразу завершался. 

Выглядело это примерно так:

- templateType: Task
  ...
  task:
    container:
      ...
      env:
        - name: TASK_NAME
          valueFrom:
              fieldRef:
                  fieldPath: "metadata.labels['chaos-mesh.org/controlled-by']"
        - name: NAMESPACE
          valueFrom:
            fieldRef: 
              fieldPath: "metadata.namespace"

      command: ["/bin/sh", "-c"]
      args:
        - |

          TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
          URL="https://kubernetes.default.svc/apis/chaos-mesh.org/v1alpha1/namespaces/$NAMESPACE/workflownodes/$TASK_NAME"

          curl -s --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt -H "Authorization: Bearer $TOKEN" $URL | \
          tr -d ' \n\r' | grep -q '"status":"True","type":"Accomplished"' && \
          { echo ">>> Задача уже помечена как Accomplished. Выходим."; exit 0; }

          echo "Приступаем к задаче..."

И хотя теперь повторный запуск pod уже не приводил к выполнению всех команд Task, у подхода было четыре очевидных минуса:

  1. Приходилось расширять RBAC‑права, чтобы pod мог читать WorkflowNode.

  2. Каждая такая проверка создавала дополнительную нагрузку на Kubernetes API.

  3. Одну и ту же защитную логику нужно было дублировать в разных Task. 

  4. Это был «костыль» на уровне пользовательского сценария.

Что придумали в результате: EphemeralTask, который не зависит от Task pod

Когда разобрались в особенностях работы workflow, мы поняли, как победить «зомби‑тесты» на уровне самого Chaos Mesh. Нужно было изменить жизненный цикл стандартного Task. Так появилась идея создать EphemeralTask — наш собственный кастомный тип, в котором не будет зависимости логики от существования Task pod. Pod должен был играть роль одноразового исполнителя.

В официальной документации Chaos Mesh для девелоперов мы нашли способ и инструкции, как реализовать собственный тип тасков для использования в workflow. Для этого понадобился стандартный набор шагов: новая схема, регистрация CRD, логика в контроллере и проверка в кластере.

Мы стали хранить результат выполнения шага во внешнем состоянии workflow, сделав его источником истины. Для этого использовали аннотации на WorkflowNode:

  1. pod‑created фиксировал факт создания pod.

  2. result‑collected показывал, что результат уже сохранен.

  3. context хранил результат выполнения шага.

С EphemeralTask мы добились желаемого:

  • сохранили контракт обычного Task;

  • оставили stdout и exitCode доступными для conditionalBranches;

  • создаем Task pod только один раз и удаляем его после завершения;

  • трактуем исчезновение pod до сбора результата как failed outcome, без повторного запуска пользовательской логики.

При этом для автора workflow изменения выглядят точечными:

- name: select-zone-safely
  templateType: EphemeralTask
  task:
    container:
      image: alpine:latest
    ...

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

Все свои изыскания оформили в issue, чтобы решение можно было обсудить и, если получится, довести до основной ветки Chaos Mesh.

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