Mindbox — экосистема для персонализации маркетинга и цен, которая объединяет 12 сервисов. Ключевое место в инфраструктуре занимает ClickHouse. В нем хранятся данные по аналитике, отчетности и мониторингу, события, логи, метрики и другая служебная информация. Недавно мы перенесли ClickHouse на новые Kubernetes‑кластеры в Yandex Cloud, чтобы избавиться от ограничений и легаси прежнего окружения.

Меня зовут Дмитрий Рыбалка, я SRE‑инженер Mindbox. В этой статье расскажу, как мы за два месяца мигрировали 40 кластеров ClickHouse:

  • какие придумали и применили стратегии миграции,

  • как наладили связь между Kubernetes‑кластерами без VPN,

  • как переключали пользователей на новые кластеры через cutover без даунтайма,

  • что не учли и как это чинили уже после переключения.

Материал пригодится SRE‑, DevOps‑ и DBA‑инженерам, которые работают с ClickHouse в Kubernetes‑кластерах.

Карта миграции ClickHouse: новая схема и декларативный деплой

К моменту миграции у нас было 40 ClickHouse‑кластеров под разные сервисы и окружения. Кластеры управлялись разными версиями ClickHouse, отличались объемом, схемами репликации и способами записи. Все это работало на двух Kubernetes‑кластерах. Один отвечал только за staging, а второй — сразу за два продакшен‑окружения, beta и stable.

Чтобы навести порядок, мы отказались от схемы переезда «один в один». После переезда, по нашему замыслу, ClickHouse должен был работать на трех Kubernetes‑кластерах — каждый для своего окружения. Версию ClickHouse стремились поднять как минимум до 24.x. Если совместимость позволяла, стремились к 25.x.

Карта миграции ClickHouse. Переносим два source Kubernetes-кластера в три target
Карта миграции ClickHouse. Переносим два source Kubernetes‑кластера в три target

Нам нужно было перенести данные, а запуски CI должны были проходить безопасно на старой и новой инфраструктуре. Поэтому мы декомпозировали исходную задачу с формулировкой «мигрировать данные и переключить трафик» на две независимые задачи и решали их параллельно.

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

Привести ClickHouse‑деплой к управляемому декларативному виду. Мы не могли заменить старый ClickHouse новым во всех местах за один заход. Миграция шла последовательно: сервис за сервисом, окружение за окружением. Какое‑то время одновременно работали старые и новые Kubernetes‑кластеры, разные версии нашего Helm‑чарта, разные версии ClickHouse и разные состояния сервисов. Где‑то данные уже переехали, где‑то клиенты еще ходили в старый кластер, где‑то мы только готовились переключить окружение.

Из‑за этой асинхронности конфиг helmfile — инструмента, с помощью которого мы управляли деплоями, — должен был находиться в своего рода суперпозиции разных состояний. На старом кластере он не должен был ломать legacy‑деплой. На новом — должен был разворачивать ClickHouse через новый чарт. Мы не фокусировались на том, чтобы наводить красоту в values, решив, что миграция — неподходящий момент для подобных исправлений. Вместо этого поставили перед собой прагматичную цель: описать реальное рабочее состояние так, чтобы следующий CI‑прогон ничего не сломал.

Для каждого сервиса финальное состояние должно было выглядеть так: ClickHouse работает в target‑кластере, клиенты переключены, старый ingest остановлен, временные миграционные ресурсы убраны, helmfile diff пустой. 

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

Инвентаризация: какие кластеры нужно было переносить

Чтобы выбрать стратегию миграции, сначала пришлось проанализировать все разнообразие существующих кластеров и их особенностей.

Версии ПО. Мы использовали разные версии ClickHouse от 23.4. Сам ClickHouse на старых кластерах разворачивали с помощью Altinity Operator и нашего внутреннего Helm‑чарта. Со временем чарт эволюционировал, и в эксплуатации одновременно находились практически все его версии — от 0.2.7 до 4.x.

Архитектура. У нас были как реплицируемые, так и standalone‑конфигурации. Отличался и способ хранения данных. Одни кластеры работали на обычном MergeTree, другие использовали cold‑tier с хранением данных в S3. Для координации мог использоваться ZooKeeper, а иногда координатор не использовался вовсе.

Размер. От кластера к кластеру объем данных существенно отличался. Большинство были маленькими — в единицы гигабайт. Средних кластеров было 7, а крупных — всего 3. Отдельно стоял APM‑кластер с 14 TB данных в cold‑tier S3.

У нас было 30 мелких, 7 средних и 3 больших кластера
У нас было 30 мелких, 7 средних и 3 больших кластера

Тип записи. Одни кластеры получали данные через Kafka и materialized view. Для них после переключения была возможность повторно дочитать события в пределах retention. В часть кластеров данные сохранялись прямым INSERT из приложений. В этом случае механизма повторного воспроизведения данных не существовало, а ошибка миграции во время записи означала потерю строк.

Еще при переезде нужно было учесть общие для этого «зоопарка» ограничения и условия: 

  1. Между старыми и новыми Kubernetes‑кластерами не было прямой плоской сети. Нужно было придумать, как организовать их взаимодействие.

  2. Для основной массы ClickHouse‑кластеров нужен был нулевой или околонулевой даунтайм. Где‑то сервис не должен был видеть простоя, где‑то допускалась пауза в пределах пары минут.

  3. Один из старых Kubernetes‑кластеров отвечал сразу за beta и stable окружения. Бывали случаи, когда у сервиса в одном Kubernetes namespace могли одновременно работать два CHI: ch‑<service>‑stable для stable и ch‑<service>‑beta для beta. Это тоже нужно было учесть, поэтому фильтрация по имени CHI появилась во всех фазах миграции.

  4. Namespace нельзя было просто создать из скрипта: ими управлял Terraform, у них были свои labels. Миграционный инструмент должен был вписаться в существующую инфраструктурную модель, а не обходить ее через kubectl create ns.

В результате инвентаризации мы поняли, что кластеров много, но они не похожи друг на друга, а значит, одной универсальной стратегией миграции не обойтись.

Cross‑cluster‑соединение без VPN: как связали source и target

Мы используем CNI cilium, поэтому Pod‑сети target и source Kubernetes‑кластеров не были связаны. Поднимать отдельный VPN только ради миграции мы не стали. Вместо этого создали временную схему связности из четырех компонентов:

  1. Source Load Balancer. С ним target получал доступ к source ZooKeeper через порт 2181, через порт 9009 — забирал данные во время FETCH, а через 9000 — выполнял запросы к source ClickHouse функцией remote().

  2. DNS‑прокси в target namespace. Во время FETCH ClickHouse обращается к исходным репликам по их внутренним DNS‑именам. В целевом кластере таких подов и DNS‑записей не было. Поэтому в target namespace создавали headless Service и EndpointsSlices с DNS‑именами source ClickHouse‑нод. Эти имена указывали на IP Source Load Balancer. В результате target ClickHouse продолжал обращаться к source‑нодам по ожидаемым именам, но фактически трафик уходил через балансировщик в source‑кластер. Поскольку beta‑ и stable‑окружения находились в одном namespace, для однозначного выбора нужного ClickHouse‑кластера Kubernetes Service пришлось добавить label migration‑chi=<chi> с именем новой инсталляции. 

  3. Target Load Balancer. Его поднимали, чтобы source мог проверять работоспособность нового target‑кластера и сравнивать состояния source и target. А после миграции на него переводили клиентов

  4. Source → target proxy. В source namespace создавали DNS‑алиасы с привычными для клиентов именами, но направляли их на Target Load Balancer. Это позволяло постепенно переключать клиентов на новый ClickHouse без изменения их конфигурации и без одновременного перевода всего трафика, а также проводить незаметную для клиента репликацию в новый кластер.

IP‑адрес Load Balancer динамический, то есть может измениться при пересоздании Load Balancer. Поэтому мы размещали DNS‑прокси в target‑кластере, а Source Load Balancer во время миграции старались не трогать.

Схема взаимодействия кластеров получилась не очень изящная, зато нам не пришлось менять сетевую архитектуру
Схема взаимодействия кластеров получилась не очень изящная, зато нам не пришлось менять сетевую архитектуру

Дерево стратегий: что планировали и к чему пришли

Чтобы выбрать стратегию миграции при таком разнообразии ClickHouse‑кластеров, я собрал блок‑схему, которая учитывала разные топологии и размеры:

Исходная схема для выбора стратегии
Исходная схема для выбора стратегии

Стратегия A: подключение target как новой реплики. Это основной сценарий для миграции кластеров, в которых все таблицы используют семейство движков Replicated* и требуется минимальный даунтайм.

Target‑кластер временно подключается к тому же ZooKeeper, который обслуживает source‑кластер. Таблицы в target создаются как дополнительные реплики существующих таблиц. После этого ClickHouse автоматически синхронизирует состояние реплик и переносит недостающие parts с source‑нод через порт 9009. Когда target полностью догнал source, нагрузку можно переключить на новый кластер.

Предполагалось применять такой способ миграции при сравнительно небольших объемах данных — до 20 GiB, когда полная синхронизация через механизм репликации занимает приемлемое время.

Стратегия A2: перенос ZooKeeper без остановки кворума. Это усложненный вариант стратегии A, когда недопустим даже короткий даунтайм. 

При A2 к существующему ZooKeeper‑кворуму временно добавляются ноды из target‑кластера. Например, кворум расширяется с трех до пяти участников — трех source‑нод и двух target‑нод. После синхронизации source‑ноды ZooKeeper поочередно выводятся из кворума. В результате кворум продолжает работать во время всего переноса, а ZooKeeper постепенно переезжает в target‑кластер без одновременной остановки всех нод.

Стратегия A+C: начальный снапшот и последующая репликация изменений. Этот сценарий я думал использовать для крупных replicated‑кластеров, которым нужен околонулевой даунтайм.

Если сразу подключить пустой target как новую реплику, ему придется передать весь объем данных через механизм репликации. Для большого кластера это может занять слишком много времени и создать значительную нагрузку на сеть и source‑ноды. Идея была в том, чтобы сначала перенести основной объем данных с помощью снапшота PVC. А уже полученную копию использовать как начальное состояние target‑кластера. После запуска target подключать к ZooKeeper и через стандартную репликацию получать только изменения, которые появились после создания снапшота.

Стратегия C: перенос снапшота PVC с остановкой. Это самый простой сценарий, когда остановка ClickHouse на время миграции допустима.

Запись в source‑кластер останавливается, после чего для persistent volumes создаются снапшоты. Они переносятся или восстанавливаются в target‑кластере, и новый ClickHouse запускается на полученных данных.

Стратегия требует даунтайма, поскольку данные на source не должны изменяться во время создания снапшота и восстановления из него. 

Стратегия D: копирование standalone‑таблиц через remote(). Этот сценарий используется для standalone‑таблиц на обычном MergeTree, у которых нет репликации через ZooKeeper. В target‑кластере сначала создаются таблицы с той же структурой, а затем данные копируются SQL‑запросом вида:

INSERT INTO target_table
SELECT *
FROM remote(...);

Функция remote() подключается к source ClickHouse по нативному протоколу через порт 9000 и читает данные непосредственно из исходной таблицы.

Чтобы не копировать весь объем повторно, заранее определяется граница — cutoff. После основного переноса отдельно копируется только дельта: данные, которые появились или изменились после этой границы. После проверки полноты данных выполняется переключение на target.

Стратегия D‑backup: перенос через backup в S3. Этот вариант используется для standalone‑кластеров с cold‑tier или другими сценариями, в которых данные хранятся в S3. И при этом и source, и target имеют доступ к общему bucket.

Вместо построчного копирования через remote() в source создается ClickHouse backup:

BACKUP ... TO S3(...)

Затем этот backup восстанавливается в target:

RESTORE ... FROM S3(...)

S3 выступает промежуточным хранилищем между кластерами. Такой подход годится для таблиц с cold‑tier, потому что учитывает их модель хранения и не требует прогонять весь объем через обычный INSERT SELECT.

Я не ограничился теорией и каждый сценарий прогнал на тестовом стенде. В итоге от некоторых стратегий пришлось отказаться: 

  1. Стратегию A2 признал слишком рискованной. Она требует изменять состав ZooKeeper‑кворума в работающей системе и временно усложняет управление кворумом, потому что может появиться четное число нод и возникнет риск split‑brain. Поэтому для всех случаев вместо A2 применяли обычную стратегию A, поскольку она не меняет source ZooKeeper, а только предоставляет доступ к нему через Load Balancer.

  2. Стратегия A+C тоже не подошла, несмотря на потенциальный выигрыш в скорости. Тесты показали: снапшот диска не гарантирует, что после запуска ClickHouse признает находящиеся на нем parts актуальными. Пока снапшот создается, source‑кластер продолжает работать, выполняет merge parts и обновляет состояние в ZooKeeper. В результате во время запуска target‑кластера данные на диске могут не соответствовать метаданным в ZooKeeper. А когда мы останавливали merge, тесты ловили переполнение диска.

Финальная схема выбора стратегии значительно упростилась. Все replicated‑кластеры, включая крупные, мы мигрировали через стратегию A. Standalone — через стратегии D или D‑backup.

Финальная схема выбора стратегии
Финальная схема выбора стратегии

Оркестратор как конечный автомат: как запускали и проводили миграцию

Для каждого сценария миграции я подготовил исполняемый файл‑оркестратор: migrate‑a1.sh, migrate‑standalone.sh и migrate‑backup‑s3.sh. Каждый поддерживал принцип полной автоматизации, разбивая миграцию на контролируемые шаги. Оркестратор хранил состояние в файле state/tmp/<cluster>‑state.json. Там он фиксировал завершенные фазы, параметры запуска, найденные IP‑адреса и другую информацию, необходимую для работы.

Оркестратор давал возможность продолжить прерванную миграцию с помощью параметра ‑resume. Если какой‑то шаг завершался ошибкой, пропадала сеть или оператор останавливал процесс вручную, при следующем запуске оркестратор читал сохраненное состояние, пропускал успешно завершенные фазы и повторял только незаконченный шаг.

С помощью параметра ‑skip‑cutover я предусмотрел безопасную подготовку к переключению на новый кластер. В таком режиме оркестратор выполнял все необходимые действия — от создания нужных элементов до миграции данных, — проверял target‑кластер и останавливался перед необратимой частью миграции. Переключение запускали уже после того, как вручную подтверждали, что все остальные шаги успешны и новый кластер точно готов.

Обязательным параметром ‑chi‑name мы передавали оркестратору имя исходного ресурса ClickHouseInstallation, который нужно было мигрировать. Я намеренно отказался от идеи автоматического определения CHI, потому что в одном namespace могли одновременно находиться beta‑ и stable‑кластеры. При дефолтном выборе мы рисковали запустить миграцию не для того кластера. 

Имя Helm release для target задавали параметром ‑target‑release и оставляли таким же, как у source. Конфликта не было, поскольку релизы находились в разных Kubernetes‑кластерах. Так мы минимизировали изменения в helmfile и получали нулевой diff после миграции.

Новое имя для target CHI задавали параметром ‑target‑chi‑name. Имя должно было отличаться от существующего, чтобы target не конфликтовал с source‑репликами в общем ZooKeeper. Решение не самое очевидное, но так мы одновременно сохраняли декларативность конфигурации и безопасно разворачивали новый ClickHouse, не удаляя исходный.

Чаще всего мы применяли стратегию A, поэтому на ее примере разберу работу оркестратора.

Оценка и построение плана. На шаге assess оркестратор собирал информацию о source‑кластере: проверял версии ClickHouse, движки таблиц, объем данных и особенности топологии. Тут мы могли отловить нереплицируемые таблицы, несовместимые версии ClickHouse и другие условия, при которых стратегия A была неприменима. После этого на шаге plan формировались параметры следующих шагов.

Доступ к source‑кластеру. На шаге expose оркестратор создавал временную сетевую связность, необходимую target‑кластеру для доступа к source ZooKeeper и ClickHouse. 

На шаге generate_values конфигурация target формировалась на основе фактической конфигурации source. Это предотвращало расхождения между сгенерированными Helm values и реальными параметрами исходного кластера. После этого deploy_target разворачивал ClickHouse в новом Kubernetes‑кластере. На этом этапе создавались пользователи, секреты и основная конфигурация.

Настройка DNS и создание схемы. Во время репликации ClickHouse обращался к source‑нодам по их исходным DNS‑именам. Поскольку таких нод в target‑кластере не существовало, на шаге create_dns_proxies в target namespace создавались DNS‑алиасы source‑нод, указывающие на Source Load Balancer. Без этого FETCH мог завершаться ошибками разрешения имен, хотя внешне оба кластера были доступными. Далее на шаге create_schema создавались таблицы в target‑кластере. При этом мы пропускали создание Kafka‑таблиц и materialized views и откладывали этот процесс до переключения, иначе source и target могли начать одновременно обрабатывать один поток данных.

Синхронизация и проверка данных. На шаге wait_sync_verify оркестратор ожидал, пока target полностью догонит source. Проверялись состояние реплик, очередь репликации, количество строк, объем данных и другие контрольные показатели. Переход к cutover был запрещен, пока реплики не синхронизируются. Для крупных и критичных таблиц оркестратор дополнительно сравнивал count, sum, выборочные checksum и другие метрики, которые позволяли выявить расхождения.

Подготовка клиентского доступа. После синхронизации шаг expose_target_for_clients предоставлял доступ к target ClickHouse со стороны source‑кластера. Затем verify_target_from_source проверял, что сервисы, которые после переключения должны обращаться к новому кластеру, действительно могут до него достучаться.

Шаг create_source_target_proxies создавал в source namespace DNS‑алиасы, направленные на target. Так клиенты могли обращаться к target ClickHouse по прежним адресам, и нам не нужно было менять конфигурацию клиентских сервисов.

Шаг verify_migrator_auth подтверждал, что в target создан пользователь, от имени которого работают внутренние приложения и инструменты миграции.

Шаг validate_source_clients был последним перед необратимыми действиями. Оркестратор останавливался и ожидал ручного подтверждения, что target синхронизирован, клиентские маршруты работают, проверки пройдены, а команда готова к переключению.

Cutover. Сначала шаг stop_source_kafka останавливал поступление новых данных в source ClickHouse. После этого мы ждали, пока очередь опустеет, и еще раз сверяли данные.

Затем cutover завершал отделение target от source‑инфраструктуры и переключал клиентские подключения на новый ClickHouse. После этого apply_deferred применял отложенные объекты, включая Kafka Engine и materialized views, и обработка новых данных возобновлялась уже на target‑кластере.

Переключение занимало несколько минут. Основной объем данных переносился заранее, поэтому во время cutover требовалось только остановить запись, дождаться, пока target применит последние изменения и полностью догонит source, а затем переключить маршрутизацию.

Наблюдение и очистка. После переключения мы некоторое время контролировали Kafka lag, скорость поступления строк, ключевые агрегаты, ошибки materialized views, QPS и клиентские ошибки. Старый кластер не удаляли сразу, оставляя его доступным на случай расхождений или отката. 

Шаг cleanup удалял временные Load Balancer, DNS‑прокси, служебные ресурсы миграции и устаревшие записи о репликах.

Шаг protect_source_helm_resources защищал ресурсы, которые формально принадлежали старому Helm release, но продолжали использоваться клиентами. Без этого обычный helm uninstall source‑кластера мог удалить необходимые секреты или другие общие объекты.

Последовательность шагов оркестратора
Последовательность шагов оркестратора

Переключение сервисов: как переводили трафик на новые кластеры

Оркестратор не переключал клиентский трафик автоматически. После подготовительного запуска с параметром ‑skip‑cutover target проверяли вручную, а клиентов переводили на него отдельным шагом без оркестратора. Универсального механизма не было: где‑то меняли DNS или имя Kubernetes Service, где‑то — connection string с последующим редеплоем приложения. Сервисы, которые записывают данные в Kafka, можно было не переключать, так как они продолжали писать в прежние топики. Чтение же возобновлялось и продолжалось с последнего сохраненного offset той же consumer group после создания Kafka‑таблиц и materialized views в target‑кластере. После ручного перевода клиентов оркестратор запускали повторно с ‑resume, уже без ‑skip‑cutover.

Переключения клиентского трафика и ZooKeeper выполнялись независимо. Клиентские сервисы уже могли работать с target‑кластером, но при этом временно продолжали использовать source ZooKeeper. Поэтому переключение ZooKeeper можно было запланировать на отдельное техническое окно. Для этого запускали скрипт cutover‑zk.sh как расширение основного скрипта оркестратора. Он отключал внешний ZooKeeper, выполнял rolling restart ClickHouse‑подов, восстанавливал состояние реплик через SYSTEM RESTORE REPLICA и проверял, что они вышли из режима read‑only.

На тестовом стенде переключение занимало 2–3 минуты, но для продакшена закладывали 5–10 минут. Основное время уходило не на SYSTEM RESTORE REPLICA, а на rolling restart: scheduling подов, readiness‑пробы и задержки из‑за нагрузки на Kubernetes‑ноды.

Тесты до и после cutover: как проверяли каждый шаг миграции

Чтобы проконтролировать миграцию, подобную нашей, нужно было проявить изобретательность. Дело в том, что сбои в процессе могут проявляться не явной ошибкой. Например, пустая очередь репликации сама по себе еще не доказывает, что target полностью синхронизировался, потому что передача данных могла вообще не начаться. Успешный тестовый запрос к БД подтверждает только доступность, но не гарантирует, что реальная нагрузка приложения будет работать корректно. Мы контролировали миграцию на каждом этапе: от сетевой доступности до наблюдения за продакшен‑нагрузкой после переключения. 

Сначала проверяли сетевую связность. Целевой ClickHouse‑pod должен был устанавливать TCP‑соединение с Source Load Balancer. Так мы могли сразу заметить проблемы с сетью, балансировщиком или маршрутизацией между Kubernetes‑кластерами.

На шаге wait_sync_verify мы контролировали состояние синхронизации. Мониторили очереди и отставание репликации, проверяли количество синхронизированных записей, версии ClickHouse и состояние ZooKeeper. Переходить к переключению можно было только после того, как target догонял source: очередь репликации пуста, объемы данных совпадают, а таблицы работают штатно.

Дополнительно проверяли целостность данных. Для таблиц на схлопывающихся движках контрольные запросы выполняли с FINAL, чтобы сравнивать уже логически обработанное состояние данных. System.parts сверяли по пользовательским таблицам. Это помогало обнаружить недостающие parts и рассинхронизацию отдельных реплик.

Затем тестировали клиентский путь. Подключения обычным clickhouse‑client от имени технического пользователя было недостаточно. Мы подключали настоящий клиентский pod с теми учетными данными, которые после миграции должно было использовать приложение. Так мы ловили ситуации, когда target в принципе доступен, но нет конкретного пользователя или ему не хватает прав, или когда клиент не может пройти по своему сетевому маршруту.

Pre-cutover: что проверяем в процессе миграции
Pre‑cutover: что проверяем в процессе миграции

После cutover проверки продолжились. Мы подтверждали, что реплики вышли из режима read‑only, выполняли тестовую запись в БД и наблюдали за метриками, ошибками и объемом поступающих данных. В целом pre‑cutover‑проверки покрывали известные типы проблем и отказов. Но были и неожиданные сбои уже после подключения реальной нагрузки.

Один такой случай произошел после ночного переключения. Базовые запросы и короткие отчеты выполнялись успешно, но отчеты за большой период начали падать. Оказалось, что генерируемый SQL превышал установленный на target лимит max_query_size, равный 2 097 152 байтам. Предварительная проверка эту проблему не обнаружила, потому что использовала более короткий простой запрос.

Из этого инцидента мы сделали два вывода:

  1. Проверять нужно не любые успешные запросы, а сценарии, близкие к реальной нагрузке сервиса, включая тяжелые и пограничные случаи. 

  2. Миграция, увы, не заканчивается в момент переключения, и необходим post‑cutover‑мониторинг, потому что часть проблем проявляется только на продакшен‑трафике.

Post-cutover: с чем сталкиваемся после вывода кластера в продакшен
Post‑cutover: с чем сталкиваемся после вывода кластера в продакшен

Нулевой helmfile diff: как приводили новые кластеры к декларативному состоянию

После миграции новый ClickHouse переходил под управление helmfile, а состояние кластера приводили в соответствие с описанием в репозитории. Иначе следующий запуск CI мог неожиданно изменить уже работающий кластер, вернуть старые значения или пересоздать отдельные ресурсы. Поэтому одновременно с миграцией данных мы приводили новые кластеры к состоянию, которое можно безопасно воспроизвести обычным helmfile apply. И завершали миграцию только при пустом helmfile diff.

Имена Helm release и CHI. Чтобы после миграции не переписывать конфигурацию в helmfile, имя Helm release в target оставляли таким же, как у source. При этом target CHI получал новое имя, потому что во время миграции оба ClickHouse‑кластера использовали общий ZooKeeper и одинаковые имена CHI могли привести к конфликту путей репликации.

Чтобы клиентским сервисам не пришлось менять настройки подключения из‑за нового CHI‑имени, в чарт‑версии 4.2+ использовали fullnameOverride и externalName. Таким образом, у target было уникальное имя внутри инфраструктуры, но клиенты могли обращаться к нему по прежнему адресу.

Один helmfile для старых и новых кластеров. Во время миграции нам нужно было поддерживать два типа деплоя: для старых и новых кластеров на разных версиях чарта. Обе конфигурации объединили в одном helmfile, а нужный вариант выбирали по значению переменной окружения CLUSTER_NAME. Благодаря этому кластеры можно было переносить по одному, сохраняя единый источник декларативной конфигурации.

Неочевидные расхождения в конфигурации. Стремясь получить пустой helmfile diff, мы постоянно натыкались на что‑то новое, а точнее — старое, обусловленное историческими особенностями существующих кластеров. Например, пользователи из additionalUsers, для которых не был явно указан passwordFrom, получали пароль через getOrGenerateSecret. При развертывании target это могло создать новый пароль и сломать авторизацию приложений. Поэтому для таких пользователей пришлось явно сохранить связь с существующими секретами.

helm uninstall при удалении source‑релиза мог удалить Secret или IngressRoute, которые продолжали использовать клиентские сервисы. Аннотация helm.sh/resource‑policy=keep не всегда спасала, потому что старая версия чарта ориентировалась на манифест, сохраненный внутри Helm release. В итоге перед тем как удалить source, мы сохраняли YAML с описанием нужных ресурсов и восстанавливали их после uninstall.

Даже после того, как данные переехали, а клиенты начали работать с target‑кластером, миграция еще продолжалась. Мы формально завершали ее только после того, как новая инфраструктура становилась полностью управляемой:

— target‑кластер полностью описан в helmfile,

— все необходимые секреты и сетевые ресурсы сохранены,

— обычный helmfile apply не меняет рабочую конфигурацию,

— source можно удалить без потери ресурсов.

AI‑агент: что и как автоматизировали

В процессе миграции AI‑агент помогал нам с рутиной:

— проверял параметры запуска,

— проходил по чек‑листу, 

— находил пропущенные проверки, 

— сопоставлял текущее состояние с runbook и напоминал о незавершенных шагах.

Runbook написали и для инженера, и для AI‑агента. В файле skill/SKILL.md описали инструкцию, по которой агент понимал, какой выбрать сценарий для миграции, какие выполнить проверки, на каких этапах остановиться и передать управление человеку. 

В файле automation‑gaps.md мы хранили список, где процесс миграции требовал ручных действий или не учитывал отдельные сценарии. Агент брал оттуда очередной пункт и помогал подготовить исправление. Мы проверяли его на тестовом стенде и, если проверка проходила успешно, обновляли список и фиксировали изменение в репозитории. Так нашли и закрыли 37 проблемных мест, среди которых были:

— DNS‑прокси, 

— read‑only реплики, 

— работа с секретами, 

— cleanup временных ресурсов, 

— алиасы между source и target, 

— проверки авторизации,

— ситуации, когда в одном namespace находились несколько ClickHouse‑кластеров.

В каталоге memory хранили ранее найденные проблемы и способы их решения в виде коротких заметок. Благодаря этому агент мог учитывать накопленный опыт и не предлагать уже проверенные нерабочие решения. Например, в заметках описывали порядок восстановления зависшей очереди репликации или поведение вызова helm uninstall, который мог удалить нужный секрет даже при наличии resource‑policy: keep.

Универсального автоматического сценария миграции не было, поэтому мы не автоматизировали критические этапы:

— переход через ‑skip‑cutover;

— финальную проверку target;

— переключение клиентского трафика;

— необратимые действия во время cutover.

Для этих этапов агент собирал данные, проверял условия и сообщал о готовности, но решение идти дальше принимал инженер.

Бонус: почему отказались от снапшот‑миграции

Мы планировали ускорить перенос крупных replicated‑кластеров с помощью снапшотов. Вместо того чтобы передавать весь объем через сеть, можно снять образы с дисков source‑кластера и восстановить их в target‑кластере, а затем через репликацию синхронизировать те изменения, которые накопились после снимка. Для этого подготовили отдельный оркестратор migrate‑snapshot.sh и проверили весь сценарий на тестовом стенде. Однако ни для одного replicated‑кластера снапшот‑миграцию в проде мы так и не использовали.

Длительный даунтайм недопустим. Снапшот сохранял содержимое диска только в конкретный момент времени. При этом source‑кластер продолжал работать, и к моменту запуска target данные на восстановленном диске могли уже не соответствовать актуальному состоянию в ZooKeeper. Во время RESTORE REPLICA ClickHouse сверял локальные parts с ZooKeeper, признавал часть данных устаревшими, переносил такие parts в detached и начинал заново получать актуальные parts через FETCH. Например, снапшот мог содержать part, который после создания снимка уже вошел в результат merge на source. Для ZooKeeper старый part был уже неактуальным, поэтому target не мог продолжить работу с восстановленным диском. В итоге большую часть данных все равно приходилось передавать по сети.

С этим можно было бы побороться, если на время создания снимка и восстановления target остановить merges на source, тем самым согласовав снапшот с состоянием ZooKeeper. Но тогда при непрерывной записи накапливались мелкие parts, что приводило к нагрузке на диск и потенциальной ошибке Too many parts. Альтернативой было ограничить работу продакшен‑кластера на время создания и восстановления снапшота. Но для больших дисков даунтайм занимал несколько часов, что было недопустимо.

ClickHouse не всегда принимал восстановленные parts. Когда мы гоняли решение на стенде, обнаружили, что восстановленная реплика может увидеть слишком много неизвестных ей parts и перейти в режим read‑only с ошибкой TOO_MANY_UNEXPECTED_DATA_PARTS. Это происходило из‑за того, что в снапшот попадали уже отсоединенные parts, поэтому target наследовал накопившееся служебное состояние source. 

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

  • source оставался доступным для записи;

  • target получал только актуальные parts;

  • при перезапуске pod репликация продолжалась;

  • данные на диске с ZooKeeper не требовалось согласовывать.