В 2020-м мне понадобилось версионировать схему ClickHouse в CI/CD, а готового инструмента под Go с поддержкой ClickHouse не нашлось ни одного. Пришлось написать свой — db-migrator. Со временем он оброс поддержкой Postgres, MySQL, а недавно и Iceberg, и разошёлся среди коллег по отрасли. В статье расскажу, чем миграции в ClickHouse отличаются от привычного Postgres/MySQL и какие инженерные развилки из-за этого пришлось пройти: ON CLUSTER для обычного реплицированного кластера, режим Replicated-базы, аккуратная таблица истории миграций на шардированном кластере и маскирование секретов в логах.
Инструмент: https://github.com/raoptimus/db-migrator.go
С чего всё началось
В 2020-м мне нужно было версионировать схему ClickHouse в CI — а готового инструмента под Go с поддержкой ClickHouse не нашлось ни одного. Хотелось того же, что уже давно есть в мире Postgres: миграций, которые лежат в git рядом с кодом и проходят через пайплайн. А в ответ — пусто.
Все известные мне утилиты (goose, golang-migrate и прочие) жили в мире «обычных» баз: одно подключение, транзакция, накатил — откатил. ClickHouse в эту модель не укладывался.
Вариантов оставалось два: катить схему руками и дальше (спасибо, не хочется) или написать инструмент под себя. Я выбрал второе. Так появился db-migrator — сначала заточенный именно под ClickHouse, а уже потом доросший до Postgres, MySQL и (экспериментально) Tarantool.
Тогда я писал его под одну свою боль и на свой прод. Что им будут пользоваться незнакомые мне люди в других компаниях — я не закладывал. Но об этом ниже; сначала — про то, почему ClickHouse вообще пришлось обходить не так, как Postgres.
Зачем вообще версионировать схему: короткая иллюстрация
Прежде чем уходить в технику — момент, ради которого всё и затевалось.
Одной смежной команде я помогал внедрить миграции ClickHouse в CI/CD на базе db-migrator. До этого схему они катали руками, и выглядело это так. На dev всё зелёное. На прод тот же инженер переносит DDL руками — и где-то теряет шаг. Расхождение всплывает уже на живом кластере [подставь свою реальную деталь: например, ночью, когда падает отчёт / когда данные едут не в ту колонку]. И цена ошибки тут не «откатить транзакцию», а перелить терабайты на работающем проде.
Корень проблемы простой: то, что протестировано, и то, что приехало на прод, — это два разных набора изменений. Где-то опечатка, где-то «поправил по ходу», где-то забыли шаг.
Версионированные миграции в CI/CD убирают этот класс ошибок в принципе: один и тот же файл проходит весь путь dev → test → prod без ручного вмешательства. Но дело не только в том, что «руками не трогаем». Подход даёт ещё несколько вещей, которых при ручной доставке схемы просто нет:
-
Контроль изменений через git. Каждое изменение схемы — это файл в репозитории. Видно, кто, когда и что менял; история БД лежит рядом с историей кода и связана с ней. Откатиться к нужному состоянию или понять, «когда же появилась эта колонка», становится тривиально.
-
Ревью. Раз миграция — это файл в PR, её можно ревьюить как обычный код. Опасный
ALTER, забытый индекс, неверный движок таблицы — всё это ловится глазами коллег до прода, а не после. Для ClickHouse это особенно важно: цена ошибки в DDL на большом кластере высокая. -
Автоматизация. Накат миграций становится обычным шагом пайплайна — предсказуемым, повторяемым, одинаковым во всех окружениях. Человек убран из критичного пути, а вместе с ним убран и человеческий фактор.
-
Быстрый откат. Каждая миграция — это версия с парой up/down, поэтому откатить неудачное изменение можно одной командой, а не вспоминать в спешке, что и как правили руками. (С важной оговоркой про ClickHouse — про границы атомарности будет отдельно ниже.)
Ради этой связки «версионирование + ревью + автоматизация» такой инструмент и нужен. А дальше начинается специфика ClickHouse.
Чем ClickHouse отличается с точки зрения миграций
Нет полноценных транзакций. В Postgres ты заворачиваешь миграцию в транзакцию и спишь спокойно: упало на середине — откатилось целиком. В ClickHouse так не выйдет. «Атомарно накатить или атомарно откатить» — не про него, и любой честный мигратор должен это признавать, а не делать вид.
Распределённость. ClickHouse в проде — это обычно не одна нода, а кластер: шарды и реплики. DDL нужно раскатать согласованно по всем нодам, а не выполнить на одной. Отсюда конструкция ON CLUSTER <имя_кластера> и реплицируемые движки семейства ReplicatedMergeTree.
Два разных режима репликации. И вот тут начинается то, ради чего во многом и писалась поддержка ClickHouse.
Режим 1: обычный реплицированный кластер (ON CLUSTER)
Самый распространённый вариант. DDL выполняется с явным указанием кластера, таблицы создаются на реплицируемом движке:
CREATE TABLE IF NOT EXISTS events ON CLUSTER {cluster} ( id UInt64, event_time DateTime, user_id UInt32) ENGINE = ReplicatedMergeTreeORDER BY (event_time, id);
Имя кластера не зашивается в SQL руками, а подставляется через плейсхолдер {cluster} из переменной окружения MIGRATION_CLUSTER_NAME (или флага cn). Это удобно: один и тот же файл миграции едет в dev/stage/prod с разными именами кластеров без правки SQL — что, как мы только что обсудили, и есть главная цель.
DSN="clickhouse://user:pass@host:9000/mydb?compress=true" \MIGRATION_PATH=./migrations \MIGRATION_CLUSTER_NAME=my_cluster \MIGRATION_REPLICATED=true \db-migrator up
Режим 2: Replicated-база
А вот тут интереснее — и именно это понадобилось той самой смежной команде на их продакшен-кластере.
Когда база создана на движке Replicated, модель работы с DDL меняется: репликацией DDL занимается сама база, и указывать ON CLUSTER ... в запросах уже не нужно — более того, это становится лишним. То есть один и тот же CREATE TABLE для обычного кластера и для Replicated-базы должен выглядеть по-разному: в первом случае с ON CLUSTER {cluster}, во втором — без него, потому что репликацию база берёт на себя сама.
Небольшая историческая ремарка: раньше этот движок включался флагом
allow_experimental_database_replicatedи считался экспериментальным. К текущим версиям ClickHouse он стабилизировался, но встретить упоминание старого флага в чужих конфигах и статьях всё ещё можно — отсюда и его частая ассоциация с этим режимом.
Для мигратора это означает, что он не может генерировать DDL «вслепую» — ему нужно знать, в каком режиме работает кластер, и вести себя соответственно. В db-migrator это решается переключателем: переменная окружения MIGRATION_REPLICATED (флаг cr). В этом режиме мигратор не навязывает ON CLUSTER, и миграции пишутся под модель, где репликацию DDL обеспечивает сам ClickHouse.
Про Replicated-базу в рунете написано мало, поэтому подчеркну ключевое для тех, кто будет это внедрять: разница не косметическая. Если по привычке оставить ON CLUSTER в Replicated-базе, поведение будет не тем, что вы ожидаете. Поэтому режим репликации — это осознанный выбор на старте, а не флаг, который переключают туда-сюда между миграциями.
Таблица истории миграций на шардированном кластере
Отдельная тонкость, которую легко упустить. Мигратор хранит историю применённых миграций в служебной таблице (по умолчанию migration). В обычной БД это просто табличка. А на шардированном/реплицированном кластере сама таблица истории тоже должна создаваться правильно — иначе на разных нодах история разъедется, и мигратор будет видеть разное состояние в зависимости от того, на какую ноду он попал. А это прямой путь к повторному применению миграции или, наоборот, к её пропуску.
Поэтому db-migrator создаёт таблицу истории согласованно по кластеру — через ON CLUSTER и на реплицируемом движке, единую на весь кластер, с учётом тех же настроек MIGRATION_CLUSTER_NAME / MIGRATION_REPLICATED, что и пользовательские миграции. Тогда какую бы ноду ни выбрал мигратор, он видит одну и ту же, общую для кластера историю.
Секреты в DDL: словари ClickHouse и маскирование в логах
Ещё один практический момент, специфичный для ClickHouse. Иногда учётные данные нужно передавать прямо в DDL — например, при создании словарей (DICTIONARY) с источником из внешней БД: чтобы словарь ходил за данными, в его определении указывают логин и пароль для подключения к источнику.
Здесь возникают сразу две задачи. Первая — не зашивать логин/пароль в текст миграции руками, иначе они утекут в репозиторий. Для этого есть плейсхолдеры {username} и {password}, которые подставляются из DSN на лету:
CREATE DICTIONARY IF NOT EXISTS my_dict ON CLUSTER {cluster} ( id UInt64, name String)PRIMARY KEY idSOURCE(CLICKHOUSE( USER '{username}' PASSWORD '{password}' DB 'source_db' TABLE 'source_table'))LAYOUT(HASHED())LIFETIME(300);
Вторая задача — чтобы эти секреты не утекли в другую сторону, в логи. Мигратор пишет выполняемый SQL в вывод (консоль, CI-логи, debug), и без защиты пароль из словаря оказался бы прямо в логах сборки. Поэтому значения {username} / {password} в выводе автоматически маскируются на ****:
> execute SQL: CREATE DICTIONARY ... SOURCE(CLICKHOUSE(USER '****' PASSWORD '****' ...)) ...
Мелочь, но именно из таких мелочей складывается разница между «работает на dev» и «можно катить в CI на прод, не боясь утечки секретов».
release / rollback — и честно про границы атомарности
Кроме обычных up / down есть две команды, работающие с релизом как с единицей:
-
release— накатить все ожидающие миграции одной пачкой, пометив их общимapply_time; -
rollback— откатить последний релиз целиком (батч определяется поMAX(apply_time)).
Идея в том, чтобы оперировать релизом, а не отдельными миграциями.
Но тут важно не приукрашивать. Атомарно, в одной транзакции, это работает только в PostgreSQL и частично в MySQL. Кстати, отсюда же и суффикс .safe. в именах файлов миграций: он помечает миграцию, которую можно выполнить в одной транзакции — там, где СУБД это умеет. В ClickHouse транзакций нет, поэтому release/rollback там — это батч, объединённый общим apply_time, но без транзакционного отката: если что-то упадёт в середине, автоматического «как будто ничего не было» не случится. Это не недоработка инструмента, а ограничение самой СУБД, и его надо просто держать в голове при планировании релизов на ClickHouse.
Как инструмент вырос за пределы моей задачи
Я писал db-migrator под конкретную боль — свою. Но когда выложил его в открытый доступ, случилось то, ради чего вообще стоит делиться кодом: им начали пользоваться другие. Сначала коллеги, потом знакомые из смежных команд, потом люди, которых я не знаю лично. Кому-то он закрыл ровно ту же дыру с ClickHouse, кому-то оказался удобен как один инструмент на несколько СУБД. Обратная связь от них, в свою очередь, толкала инструмент дальше — так он и оброс тем, чего в первой версии не было.
Что в нём есть сейчас, помимо разобранной выше специфики ClickHouse:
-
Несколько СУБД в одном инструменте — ClickHouse, PostgreSQL, MySQL и (экспериментально) Tarantool. Один синтаксис команд, один формат истории миграций.
-
Поддержка Iceberg. Появилась работа с Iceberg через его API — версионировать схему теперь можно не только для «классических» СУБД, но и для таблиц в data lake. Тема большая и заслуживает отдельного разговора, поэтому здесь просто отмечу, что направление есть и развивается.
-
CLI и Go-библиотека. Мигратор можно запускать как обычную консольную утилиту в пайплайне, а можно встроить в свой Go-код и вызывать миграции программно — например, при старте сервиса.
-
dry-run. Можно прогнать миграцию «вхолостую» и увидеть, какой именно SQL уйдёт в базу, ничего при этом не применяя. На проде и в ревью это бесценно. -
Разные способы установки.
go install, готовый Docker-образ, а теперь ещё и собранные бинарники под разные ОС и архитектуры и установка через Homebrew (brew). Никакой обязательной сборки из исходников — берёшь готовое под свою систему.
Ничего из этого не появилось по грандиозному плану. Это просто накопившийся ответ на реальные запросы людей, которые взяли инструмент в работу.
Про Tarantool — честно, это эксперимент
Поддержку Tarantool я добавлял скорее как эксперимент, чтобы держать всё в одном инструменте: Lua-миграции вместо SQL, базовые операции работают. Но кластер там не поддержан, и фичу я не довёл. Если вам нужны миграции в Tarantool всерьёз — у него есть штатный путь: фреймворк Cartridge с миграциями из коробки, и в большинстве случаев правильнее взять его. Поддержку в db-migrator я оставил для случаев, когда удобнее один инструмент на всё, — и не более того.
Когда брать db-migrator, а когда — нет
Я не пытаюсь продать инструмент всем подряд, поэтому — честно про обе стороны.
Стоит посмотреть, если:
-
у вас ClickHouse, особенно кластер с шардами/репликами или Replicated-база;
-
хочется один инструмент на несколько СУБД (ClickHouse + Postgres + MySQL);
-
нужен и CLI, и использование как Go-библиотеки в коде;
-
версионируете не только БД, но и таблицы Iceberg и хотите единый подход.
Скорее не нужно / подумайте об альтернативах, если:
-
у вас только Postgres без экзотики — здесь хватит любого привычного мигратора;
-
вам нужны миграции Tarantool всерьёз — берите Cartridge;
-
вы рассчитываете на гарантированную атомарность миграций в ClickHouse — её нет ни у кого, это ограничение самой СУБД, а не инструментов.
Инструмент с открытым кодом: ставится через go install, Homebrew или Docker, есть готовые бинарники под разные системы, поддерживает dry-run и работу как Go-библиотека — https://github.com/raoptimus/db-migrator.go
Я писал это в 2020-м, чтобы больше никогда не катать ClickHouse руками. Оказалось, руками его катают до сих пор — поэтому и делюсь. Если гоняете кластер: расскажите в комментариях, как у вас устроено версионирование, — особенно если пробовали Replicated-базу на проде.
ссылка на оригинал статьи https://habr.com/ru/articles/1075014/