Привет, Хабр! Сегодня на связи Илья Ищенко и Роман Рогов — руководитель группы администраторов хостинга и старший системный инженер Рег.ру.

В прошлом году мы закончили масштабную задачу — добавили MySQL 8 на все серверы шаред-хостинга с ispmanager. Получилась полноценная инфраструктурная перестройка: с двумя версиями MySQL на одном сервере, прозрачным переключением клиентов и возможностью безопасной миграции.

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

Навигация по тексту:

Как вообще дать клиентам MySQL 8

Первый вопрос был не технический, а архитектурный: в каком виде предоставить MySQL 8 клиентам шаред-хостинга.

Вариант 1. Отдельный сервер с MySQL 8

Самый простой путь — вынести MySQL 8 на отдельный сервер и давать доступ по сети. Мы быстро отказались от этого варианта. По сравнению с локальным MySQL задержки заметно выше, что напрямую влияет на скорость работы сайтов. Для шареда это критично.

Вариант 2. Новые серверы сразу с MySQL 8

Второй вариант — поднимать новые сервера уже с MySQL 8 вместо 5.7. Мы даже использовали его какое-то время, пока искали финальное решение. Но проблемы вскрылись довольно быстро:

  • клиенту для перехода нужен перенос на другой сервер;

  • меняются IP-адреса сайтов, иногда с ручной правкой DNS;

  • даже с проксированием небольшой даунтайм всё равно возникает;

  • клиенты вынуждены идти в поддержку;

  • инфраструктура превращается в зоопарк: часть серверов с 5.7, часть с 8.0, разные конфигурации и поведение.

Вариант 3. Два MySQL-сервера на одном хосте

В итоге мы остановились на варианте с двумя MySQL-серверами на одном сервере. Но и здесь сразу возникло несколько вопросов:

  • хватит ли оперативной памяти под второй MySQL;

  • какую версию сделать «основной»;

  • запускать альтернативную версию MySQL в Docker или собирать отдельный пакет;

  • как переключать клиентов, не заставляя их править конфиги сотен сайтов.

По памяти мы ориентировались на мониторинг — укладывались, но окончательный ответ могли дать только боевые тесты. Мы приняли решение сделать MySQL 8 основной версией, а MySQL 5.7 — альтернативной. Такой подход позволял нам привести платформу к одному виду и при этом давал возможность клиентам переходить на обновленную версию тогда, когда они к этому готовы. 

После этого мы перешли к следующему вопросу — в каком виде поставлять альтернативную версию MySQL 5.7. Рассматривали два варианта: запускать ее в Docker или собирать отдельный пакет. Docker отпал довольно быстро. Использовать контейнеризацию ради одного MySQL означало усложнить доступ к штатным консольным инструментам этой версии — таким как mysql, mysqldump, mysqlcheck, mysql_upgrade, а также к диагностике и работе с сокетами. В повседневной эксплуатации и отладке это добавляло лишний слой абстракции, который на шаред-хостинге скорее мешает, чем помогает. В итоге мы остановились на варианте с собственной сборкой пакета для MySQL 5.7.

Почему мы отказались от php.ini 

Большинство сайтов на шареде используют PHP, и первое решение напрашивалось само собой. В php.ini можно указать сокет MySQL по умолчанию:

pdo_mysql.default_socket = /var/run/mysqld/mysqld.sock

mysql.default_socket = /var/run/mysqld/mysqld.sock

mysqli.default_socket = /var/run/mysqld/mysqld.sock

Если подменить его на сокет MySQL 5.7, PHP-сайты начнут подключаться к нужной версии, даже если в конфиге указан localhost. Но довольно быстро стало понятно, что это тупиковый путь:

  • при создании новых сайтов пришлось бы постоянно править php.ini;

  • cron-задачи и консольные вызовы часто игнорируют конфиги сайтов;

  • на шареде есть проекты на Django, Flask, CGI-скрипты и самописные решения;

  • часть клиентов подключается к MySQL по TCP/IP, а не через сокет.

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

Идея с прокси и SO_PEERCRED

С TCP/IP-подключениями всё решилось довольно быстро.netfilter умеет определять UID для исходящего соединения, и мы можем сделать перенаправление в таблице NAT:

-A OUTPUT -p tcp -m tcp --dport 3306 -m owner --uid-owner u1234567 -j REDIRECT --to-ports 3310

Таким образом, для подключений по TCP/IP мы могли однозначно определить пользователя, инициировавшего соединение, и направить его трафик на нужный MySQL-сервер.

Однако часть клиентов подключается к MySQL не по TCP/IP, а через unix-сокет. На этом этапе мы исходили из предположения, что сокет не предоставляет информации о том, какой именно пользователь к нему подключается. Из этого следовало, что для маршрутизации таких соединений придётся передавать контекст извне. Отсюда и первые эксперименты: pam-модули, отдельные mount namespace и монтирование в этом namespace нужного сокета по дефолтному пути. MVP собрать удалось, но решение оказалось слишком хрупким, чтобы рассматривать его для продакшена.

Уже после мы пересобрали исходную гипотезу и посмотрели на задачу со стороны самого MySQL. При работе через unix-сокет MySQL умеет аутентифицировать пользователя без пароля, а значит, на уровне сокета доступна информация о процессе, который инициировал подключение. Изучение документации подтвердило предположение: через SO_PEERCRED сервер может получить UID процесса, подключающегося к сокету. То есть unix-сокет изначально знает, какой пользователь к нему пришел — вопрос был лишь в том, чтобы использовать эту возможность.

Так появилась финальная идея: написать прокси, который слушает стандартный сокет MySQL и в зависимости от UID пользователя прозрачно перенаправляет соединение в нужную версию базы данных. Клиенту при этом не важно, на чем написан сайт и как он запускается — процесс всё равно работает под его пользователем.

Реализация mysql_version_proxy

Готовых решений под эту задачу не оказалось: инструменты уровня MaxScale и ProxySQL не предоставляют нужного нам функционала. Поэтому решили писать свое решение с нуля.

Прокси должна была работать максимально быстро — на шаред-хостинге лишние миллисекунды задержки ощущаются сразу. По этой причине интерпретируемые языки мы исключили на раннем этапе.

C/C++ с точки зрения производительности полностью подходили под задачу, однако их использование в нашем случае означало бы разработку и длительную отладку собственной многопоточной сетевой прокси с нуля. С учетом сроков проекта и требований к стабильности мы сочли этот риск неоправданным и сознательно отказались от этого варианта.

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

Работает он так:

  1. При старте читает файл с правилами пользователей и версий MySQL, загружает их в память и отслеживает изменения.

  2. Принимает подключение на дефолтный сокет и сразу отдает его в отдельную горутину.

  3. Определяет, в какую версию MySQL должен идти пользователь.

  4. Устанавливает соединение с нужным MySQL-сервером и проксирует трафик.

Перенаправление при помощи mysql_version_proxy и NAT-правил в iptables
Перенаправление при помощи mysql_version_proxy и NAT-правил в iptables

Как мы это внедряли

На серверах, где MySQL 8 уже был основным, всё прошло относительно просто. А вот сервера с MySQL 5.7 потребовали аккуратной замены версии без поломок. Процесс выглядел так:

  • установка альтернативной сборки MySQL 5.7;

  • перенос datadir (начинается даунтайм);

  • установка MySQL 8 как нативной версии;

  • генерация правил проксирования;

  • запуск mysql_version_proxy (даунтайм сайтов заканчивается);

  • миграция системных баз;

  • перенастройка ISPmanager;

  • самотестирование.

Мы старались минимизировать простой — в среднем он составлял 2–3 минуты.

Что все-таки сломалось

Ожидать нулевого числа инцидентов было бы наивно. Проблемы возникли у нескольких клиентов  у которых путь до libmysqlclient.so.20 был явно зафиксирован в конфигурации, в частности у тех, кто использовал parser3.cgi. После второго такого случая мы добавили проверку в скрипт конвертации и стали чинить это заранее, еще до обращения в поддержку.

Инструмент миграции: как перевести клиента на MySQL 8 без участия поддержки

Когда на одном сервере работают две версии MySQL, логичный вопрос — как переводить клиентов между ними. Делать это вручную через поддержку можно, но тогда MySQL 8 остается только опцией, а не стандартом. Поэтому нам нужен был инструмент, который клиент сможет запускать сам из панели управления.

Мы сразу зафиксировали требования:

  • клиент должен понимать, что именно происходит;

  • перед миграцией нужны базовые проверки совместимости сайтов;

  • миграция выполняется только в сторону более новой версии MySQL;

  • должна быть возможность временно вернуться назад, если после переключения возникают пробл��мы. 

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

Внутренняя логика миграции

С точки зрения системы миграция выглядит довольно просто:

  • собираем все базы данных и MySQL-пользователей клиента;

  • снимаем дампы баз и сохраняем гранты;

  • создаем пустые базы и пользователей в целевой версии MySQL;

  • импортируем данные;

  • переключаем клиента на новую версию MySQL через iptables и sock-прокси.

Откат работает по той же схеме, но в обратную сторону.

Процесс миграции может занимать значительное время, особенно у клиентов с большим объёмом данных. Поэтому важно, чтобы он не зависел от веб-запроса и корректно продолжал работу после закрытия интерфейса. В ISPmanager для этого используется механизм фоновых заданий, но из-за этого структура плагина становится сложнее, чем в обычных сценариях.

Как устроены кастомные плагины в ISPmanager

Плагин в ISPmanager состоит из двух частей:

  1. XML-разметки, которая описывает интерфейс и точки подключения;

  2. обработчиков, в которых реализуется логика.

В простых случаях XML просто модифицирует существующую функцию панели. Например, если нужно изменить форму редактирования базы данных, обработчик навешивается на соответствующее событие:

<handler name="modify_db_edit.py" type="xml">

  <event name="db.edit" after="yes"/>

</handler>

При выполнении db.edit панель запускает обработчик, передаёт ему XML формы, а скрипт возвращает измененную версию обратно в панель.

Для миграции этого недостаточно. Нам нужно не только отрисовать форму, но и запустить фоновое задание после ее отправки.

Форма и фон как два отдельных обработчика

Поэтому мы реализовали кастомную функцию и связали её сразу с двумя обработчиками:

<handler name="mysql_migrate_wrapper.py" type="cgi">

  <func name="regru_mysql_migrate"/>

</handler>

<handler name="mysql_migrate_button.py" type="cgi">

  <event name="regru_mysql_migrate" final="yes" on_modify="yes"/>

</handler>

В этой схеме:

mysql_migrate_wrapper.py отвечает за интерфейс: показывает предупреждения, запускает базовые проверки и формирует форму;
mysql_migrate_button.py запускается как фоновое задание и выполняет саму миграцию баз данных.

По такому же принципу реализован и механизм отката.

Уведомления для клиента

Так как миграция идёт в фоне, важно постоянно держать клиента в курсе происходящего. Для этого мы используем нативный механизм уведомлений ISPmanager — notify.

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

Важный момент: аутентификация в MySQL 8

При переходе на MySQL 8 мы отказались от mysql_native_password в пользу caching_sha2_password. Это стандартный механизм для новой версии, но он накладывает дополнительные требования.

Во-первых, версии PHP ниже 7.4 не поддерживают этот тип аутентификации. Поэтому переход на MySQL 8 возможен только при использовании PHP 7.4 и выше. Если плагин обнаруживает сайты на более старых версиях PHP, он останавливает миграцию и сообщает об этом.

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

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

Вместо вывода

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

Если вам хотелось бы подробнее узнать про то, какие ещё решения мы применяем в хостинге — дайте знать в комментариях!