Задача: есть 30-40 клиентских проектов на разных системах (CMS, фреймворки), с разными базами (mysql в основном, но есть и mongodb и elasticsearch), хотим организовать бэкап с одновременным хранением истории версий. Размещены кто где – есть VPS, есть shared хостинги (как нормальные с ssh, так и экзотика типа хостинга в Швейцарии с древним софтом).

Основные требования, которые мы ставили:
  • универсальность, решение не должно быть привязано к конкретной CMS а также к специфическим настройкам хостинга;
  • контроль целостности дампов;
  • защита от копирования чужих дампов по клиентскому доступу;
  • быстрое развертывание на новых проектах


UPD по итогам первых комметов:
Да, мы знаем про такой вариант, но он не подходит. Причины ниже.
ssh -l user remoteserver "mysqldump -u user --password='dbpass' database" > /gitlocalpath/localfile.sql.gz



Предыстория откуда взялась идея
Предыстория: у Evergreen есть клиент с большой системой мультисайтов на MODx. Особенность MODx в том, что верстка и скрипты могут хранится как в БД, так и в файлах, но с точки зрения скорости, хранение в базе выгоднее, поэтому мы храним в БД. Однако при этом появляется специфичные для MODx сложности в доработке сайта, если на проекте более одного разработчика. Перенос по таблицам достаточно трудоемкий и кропотливый особенно в случае хотфиксов на production, когда нужно фиксировать и вручную разрешать множество конфликтов. По мере развития проекта, все чаще начали возникать конфликты из-за одновременных операций в базе данных: или клиент что-то добавит через админку во время технических работ, или мы заливаем на production без изменений клиента или сами перетираем наши же хотфиксы и самое печальное, что мы вовремя не видим этого, а обнаруживаем ошибки на тестировании перед запуском или в самых печальных случаях уже на работающем сайте.

На решение этих проблем тратились время и нервы, до тех пор, пока мы не решили сделать сохранение клиентской базы данных в git-репозиторий. После разработки решение хорошо показало себя и для других систем, как CMS так и framework-проектах.


Почему не код приведенный выше?
  1. Есть сайты без ssh, на shared hosting
  2. Есть сайты с БД в 1,5Гб. Во первых мы должны передавать по сети такой объем без компрессии — зачем? Плюс обрыв соединения.
  3. Есть сайты вообще не с mysql, а например mongodb или elasticsearch.
  4. Можно настроить снова-таки зоопарк разных решений под каждый хостинг, но как их админить и сколько времени на настройку нового? (сейчас настройка бэкапа для нового проекта занимает несколько минут)


Если подытожить то образно говоря мы на клиенте запаковали версией скрипта любые данные в архив, зашифровали и отправили. И знаем что сервер это принял и положил в git.

Универсальность также требует, возможность использовать репозиторий любому количеству клиентов, вне зависимости от того где размещены сайты — на хостинге, или собственном сервере. А значит удаленный репозиторий и доступ в него по ssh отпадает. Централизованный сбор баз через серверный mysqldump — тоже отпадает, т.к. не везде есть удаленный доступ к mysql. В качестве универсального решения подходит только клиент-серверная реализация с FTP связью.

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

Контроль целостности — это просто. Пакуем БД в .gz архив и на серверной стороне проверяем распаковку gzip -t. Если тест прошел — можно обрабатывать и добавлять, если тест провален — файл надо оставить в папке загрузки, т.к. может быть он просто недокачан.

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

Первая версия системы


Первая версия системы не поддерживала шифрования, а просто переносила файл в директорию за пределами FTP-доступа как только прекращалась запись, и вполне может использоваться в небольших SOHO проектах, т.к. там нет специальных требований и ничего кроме mysqldump и gzip не требуется. Но такая упрощенная защита не годится для широкого применения. И разработка достаточно надежной защиты от копирования потребовала немало интересных часов на обдумывание и тестовые реализации.

Варианты, которые мы рассматривали:

  • мгновенное перемещение загруженных файлов за пределы FTP-доступа;
  • отдельные папки загрузки под каждого клиента;
  • случайно генерируемые папки загрузки;
  • шифрование gpg;
  • шифрование openssl.

Всё, кроме шифрования openssl, было либо слишком сложно с явными точками отказа, либо добавляло требования к клиентской стороне и противоречило требованию универсальности. Поэтому мы остановились на openssl.

Сохранение базы клиента в git, как это работает в целом



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

База клиента шифруется одноразовым паролем плюс с определенного url’а, указанного в конфигах, забирается initialization vector, пароль шифруется открытым ключом сервера, зашифрованные файлы передаются на наш сервер, где база распаковывается и добавляется в git. Перехват какого-либо из файлов не дает возможности злоумышленнику расшифровать файл. Даже получив оба файла, их невозможно расшифровать без закрытого ключа сервера и без упомянутого выше IV.

Нет ограничений по клиентскому ПО кроме наличия openssl и любого скрипта, который сможет выполнить операции по дампу и шифрованию, с последующей передачей по FTP. У нас сейчас часть клиентов на sh, а часть на php скриптах

Технические подробности реализации


Система состоит из двух частей.

Клиентская должна забрать initialization vector с определенного url’a, указанного в конфиге, и сгенерировать две случайные переменные, ARCH_NAME — для имени архива и файла с одноразовым паролем, и PASSWD — этот самый одноразовый пароль.

Затем делается дамп базы. Для удобного просмотра изменений, mysqldump надо делать с опцией --skip-extended-insert. Тогда генерируется отдельный оператор insert для каждой строки данных. Без неё все строки таблицы заносятся одним, очень-очень длинным оператором insert, что, естественно, создаёт определённые трудности для, сравнения отличий между двумя dump-ами.

Дамп пакуем в архив, архив ARCH_NAME.tar.gz шифруется паролем PASSWD + полученным с урла IV, при помощи openssl в ARCH_NAME.enc.file, файл с паролем шифруется открытым ключом сервера в ARCH_NAME.otp.key, все это передается на сервер по FTP и удаляется локально.

Серверная с помощью incron проверяет, что попало в папку загрузки

  • если .otp.key, то сразу перемещаем за пределы директории загрузки и расшифровываем приватным ключом. Если уже есть соответствующий .enc.file, то запускаем алгоритм распаковки и добавления в локальный репозиторий с последующей выгрузкой в удаленный репозиторий. Если еще нет, то оставляем на 6 часов. Нет никакой причины оставлять именно на 6 часов, это время можно поменять;
  • если .enc.file, то пробуем расшифровать и если не можем — оставляем на 6 часов в директории загрузки на тот случай, если он еще докачается;
  • если любое другое расширение, то удаляем файл и отправляем уведомление на почтовый ящик администратора (указывается в конфиге), добавляя в него часть журнала FTP-сервера.

Все действия журналируются (см. примеры логов на github). Удаленный bare-репозиторий проекта подключен в наш redmine, что позволяет в удобном виде просматривать список БД и изменений в них.

image

Таким образом мы получили простую, защищенную систему хранения резервных копий баз данных клиентов, с версионностью и удобным визуальным отображением внутри Redmine. Вместо Redmine можно использовать любое другое GUI решение для Git.

А что будет если одновременно несколько баз прилетит на сервер и будут добавляться в репозиторий, выдержит ли система?
У нас такое невозможно. Бекапы запускает не cron на клиенте, а служебный bash-скрипт db-collector.sh расположенный на нашем сервере. Проходит по списку с достаточным таймаутом между каждым пунктом.

Тем не менее мы проверили… и запланировали на будущее проверку index.lock на серверной стороне, так как в случае одновременной работы с репозиторием несколькими процессами git, действительно все может застопориться.

Так же в планах есть обратная связь между db-collector.sh и db2repo-server.sh

На нашем тестовом двухъядерном VDS проверочный дамп базы данных размером 40 МБ распаковывается, добавляется в локальный и потом в удаленный репозитории за 7 секунд. В редмайне видно время добавления с точностью до минуты, а по логам серверного скрипта все можно отследить с точностью до секунды. Таким образом, с двухминутным интервалом между коммитами, в сутки в репозиторий можно упаковать около 20ГБ данных. Естественно, чем больше размер дампа БД, тем больше времени требуется на обработку. Интервалы надо подбирать индивидуально в зависимости от ваших проектов.

Блок-схема, без детализации по функциям
image


Инструкция по установке и настройке


Сервер


На сервере должен быть настроен git, и FTP-сервер.

Серверный скрипт: https://github.com/evergreen-it-dev/mysql-2-git/blob/master/server/db2repo-server.sh
Серверный конфиг: https://github.com/evergreen-it-dev/mysql-2-git/blob/master/server/db2repo-server.conf.example

1. Для начала нужно создать структуру директорий на сервере
Например

foo
|- tmp
|- complete
|- repo

tmp — домашняя директория фтп-пользователей, сюда загружаются все файлы
complete — недоступная для фтп-пользователей директория, куда перемещаются из tmp файлы ключей и распакованные архивы
repo — собственно директория локального репозитория с деревом файлов

2. Выложить initialization vector в HEX-формате куда-нибудь в веб-доступ, если вам так будет удобно. Нам — удобно, потому что его легко менять и система продолжает работать. В планах есть генерация уникального разового IV для каждого архива. Но если хотите — его можно жестко задать в скриптах.

3. После того как создана структура директорий, надо установить (или настроить, если он уже установлен) incron (смотрите руководство для своей операционной системы) и настроить в нем мониторинг tmp вот такой записью
/foo/tmp IN_CLOSE_WRITE /path/to/db2repo-server.sh $#
Таким образом как только открытый для записи файл в /foo/tmp был закрыт, выполнится db2repo-server.sh. Обратите внимание на $# — это имя файла с которым произошло наблюдаемое событие. Очень нужная штука в процессе распаковки и добавления в репозиторий!

4. Ну и напоследок – указать параметры в db2repo-server.conf и убедится, что в db2repo-server.sh указан правильный путь к конфигу напротив source.

Параметры которые надо указать в db2repo-server.conf
TEMP_DIR — это tmp из описанной выше структуры
UPLOADED_DIR — это complete из описанной выше структуры
REPO_DIR — это repo из описанной выше структуры
BARE_REPO — удаленный репозиторий в формате user@server:repo.git
PRIVATE_KEY — путь к приватному ключу id_rsa для распаковки архива с одноразовым паролем
MAIN_LOG — главный логфайл сервера, хранится постоянно
TEMP_LOG — временный логфайл процесса распаковки и добавления в репозиторий, используется для отправки на почту
INCRON_FILE_NAME — не трогать, смотреть документацию по incron если интересно что это
FTP_LOG — путь к лог файлу фтп, используется для отправки уведомлений, если в temp_dir загрузили что-то странное
MAIL_ADDR — почта для отправки уведомлений

5. chmod +x db2repo-server.sh и готово.

6. Если вы хотите централизовано собирать бэкапа клиентских баз данных, создайте скрипт db-collector.sh, который по очереди вызывает скрипты на клиентских серверах, пример файла:

#!/bin/bash
echo "Project1"
sshpass -p 'superpass'  ssh -p 3389  user@google.com '/path/to/script/db2repo-client.sh'
sleep 60
echo "Project2 - mysql"
ssh user@gov.ua '/path/to/script/db-mysql-2repo-client.sh'
sleep 60
echo "Project2 - elastic"
ssh user@gov.ua '/path/to/script/db-elstcsrch-2repo-client.sh'
sleep 60
echo "Project3 via php script"
wget  -qO --no-check-certificate https://site.us/dmpr/index.php?key=secretpass &>/dev/null
sleep 60
....

Клиент


На клиентском сервере нужно сделать дамп базы, зашифровать openssl и отправить на ftp, соответственно на клиентской стороне должны быть инструменты для этого, чтобы клиентский скрипт выполнялся.

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

Клиентский скрипт: https://github.com/evergreen-it-dev/mysql-2-git/blob/master/client/db2repo-client.sh
Клиентский конфиг: https://github.com/evergreen-it-dev/mysql-2-git/blob/master/client/db2repo-client.conf.example

1. Переименовать db2repo-client.conf.example в db2repo-client.conf и разместить на клиентском сервере.

2. В файле db2repo-client.conf заполнить параметры:

DB_CONFIG — путь к конфигурационному файлу сайта (зачем это надо — см. ниже)
SOURCE_DIR — папка где будет происходить таинство дампа и шифрования
DUMP_NAME — имя файла дампа БД, предлагаемый шаблон содержит: имя сайта — тип базы данных (mysql\mongo\elastic\etc) — имя базы — тип сайта (прод\препрод\тест\и т.д.)
MIN_DUMP_SIZE — минимальный размер файла дампа в Мб, если получившийся дамп будет меньше этого размера, то процесс прервется и вы получите уведомление на почту
BACKUP_NAME — ЧПУ имя этого бекапа, используется в теме уведомления о слишком маленьком размере файла дампа
PUB_KEY — путь к файлу открытого ключа бекап-сервера
IV_URL — ссылка на страницу с initialization vector в HEX-формате, если у вас не используется — просто удалите эту переменную
IV — переменная использующаяся при шифровании, если вы не будете использовать IV_URL, то просто укажите initialization vector в HEX-формате здесь
MAIL_TO — почтовый адрес для алерта о слишком маленьком размере файла дампа
TEMP_LOG — временный лог процесса (я знаю что эта переменная практически не используется в клиентском скрипте и переехала туда ХЗ откуда, но возможно я добавлю больше журналирования, поэтому пусть будет)
FTP_USER, FTP_PASS, FTP_HOST — параметры FTP

Переменные ниже вполне очевидны сами по себе, но могут вызвать недоумение их предлагаемые в conf.example значения.

Дело в том, что в скриптах бекапа баз принципиально стараемся не использовать жестко заданные пароли. Намного надежнее получать их из конфигов сайта или /etc/mysql/debian.cnf. Поэтому вы можете пойти нашим путем и подобрать парсер параметров, или просто вбить имя базы, пользователя и пароль в клиентский скрипт db2repo-client.conf и удалить переменную DB_CONFIG
DB_NAME
DB_USER
DB_PASS
А где же DB_HOST? Его нету, потому что в большинстве случаев он не нужен, а если понадобится — его легко добавить прямо в клиентский скрипт db2repo-client.sh. Возможно в будущем добавим эту переменную в конфиг.

3. После того как вы указали все переменные в db2repo-client.conf, надо в файле db2repo-client.sh прописать путь к файлу конфига во второй строчке, напротив source

4. И в принципе — все, можно делать chmod +x db2repo-client.sh и запускать

В результате, на бекап-сервере в папке /foo/tmp/ должны появиться два файла: зашифрованный архив с базой, и зашифрованный ключом пароль к архиву. Правда, если вы уже настроили incron и db2repo-server, то появятся они там очень ненадолго, и сразу же будут перемещены\распакованы в /foo/complete/, а еще через пару секунд, единственным признаком завершившегося процесса, будет файл базы в /foo/repo/

Как писалось выше, для хостингов без ssh мы разработали php-скрипт бекапа совместимого с серверным скриптом – опишем его в следующей части саги.

Пример лога добавления файла
если добавление прошло с ошибкой, то соответствующий пункт будет ERROR а не SUCCESS
###################################

30-11-2016 aXKbCRW5siSFS5aYqK.enc.file has arrived
removing 6+ hours old uploaded files in /foo/tmp/, listed below
Wed Nov 30 12:25:46 EET 2016 SUCCESS
------------------------------
file.ext is .enc.file SUCCESS
------------------------------
keyfile for encrypted file found SUCCESS
------------------------------
checking keyfile integrity and get one-time password
keyfile integrity checking SUCCESS
------------------------------
decrypting encrypted file to /foo/complete/
.enc file has been decrypted SUCCESS
deleting unnesesarry aXKbCRW5siSFS5aYqK.otp.key from /path/to/dir/complete/
Wed Nov 30 12:25:47 EET 2016 SUCCESS
deleting unnesesarry aXKbCRW5siSFS5aYqK.enc.file from /path/to/dir//tmp/
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
checking compressed file integrity
gzip integrity checking OK
------------------------------
uploaded database is sitename-mysql-dbname-prod.sql
------------------------------
uncompress uploaded file
file unpacked SUCCESS
------------------------------
remove aXKbCRW5siSFS5aYqK.tar.gz after unpack
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
get remote repo
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
remove old dump from local repo
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
moving uploaded file to local repo dir
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
commit DB file to local repo
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
git push to remote repo
Wed Nov 30 12:25:47 EET 2016 SUCCESS
------------------------------
Wed Nov 30 12:25:47 EET 2016 adding sitename-mysql-dbname-prod.sql to user@domain.com:reponame.git SUCCESS
------------------------------


Пример лога уведомления о постороннем файле (взят общий лог сервера)
###################################

02-12-2016 1j23hk1jh3o.jpg has arrived
removing 6+ hours old uploaded files in /foo/tmp/, listed below
Fri Dec 2 16:26:14 EET 2016 SUCCESS
------------------------------
file.ext is NOT .otp.key or .enc.file
removing the bullshit file 1j23hk1jh3o.jpg
Fri Dec 2 16:26:14 EET 2016 SUCCESS
------------------------------
Fri Dec 02 15:53:20 2016 0 ::ffff:95.67.95.254 41725 /some/dir/here/fail-cover.jpg b _ i r user ftp 0 * c
Fri Dec 02 15:53:48 2016 0 ::ffff:95.67.95.254 23962 /some/dir/here/checklist_prev.png b _ i r user ftp 0 * c
Fri Dec 02 16:11:07 2016 0 ::ffff:95.67.95.254 38908 /some/dir/here/concept/design-6.png b _ i r user ftp 0 * c
Fri Dec 02 16:12:21 2016 0 ::ffff:95.67.95.254 14017 /some/dir/here/stats_cover.png b _ i r user ftp 0 * c
Fri Dec 02 16:26:14 2016 0 ::ffff:46.219.221.132 99470 /foo/tmp/1j23hk1jh3o.jpg b _ i r user ftp 0 * c


Пользуйтесь, если это также вам нужно! И буду рад вашим комментариям и идеям.
Поделиться с друзьями
-->

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


  1. Suvitruf
    07.02.2017 16:39
    +1

    клиент что-то добавит через админку во время технических работ
    Тех. работы на то и тех. работы, что клиентам сервис недоступен на этот момент.


    1. evergreenteam
      07.02.2017 20:23

      Если это сайт, то вы сталкиваетесь с тем что на нем заливают новости, или добавляют комментарии и т.п. в реальном времени. Если мы сделали себе на dev копию, и 2 недели правили сайт, а клиент через CMS наполнял сайт, то в момент merge dev и того что правил клиент на prod мы вручную проверяем что было добавлено и куда.


  1. MetaDone
    07.02.2017 16:40
    +1

    Может я чего-то не понял, но что вам мешало сделать примерно так
    предположим, на компе-сборщике уже настроен гит с удаленным репозиторием
    подключаемся по ssh к серверу и запускаем mysqldump
    например

    ssh -l user remoteserver "mysqldump -u user --password='dbpass' database" > /gitlocalpath/localfile.sql.gz
    

    при этом содержимое дампа будет на компе-сборщике, который может быть и в локальной сети
    а далее просто
    git commit
    git push
    

    и все, не нужно париться с шифрованием и дополнительной настройкой сервера, а вся история изменений так же попадет в git


    1. evergreenteam
      07.02.2017 20:45

      Спасибо, решение классное и очевидно простое. Мы так делали до того как городить весь этот огород.
      Почему нет:

      1. Есть сайты без ssh, на shared hosting
      2. Есть сайты с БД в 1,5Гб. Во первых мы должны передавать по сети такой объем без компрессии — зачем? Плюс обрыв соединения
      3. Есть сайты вообще не с mysql, а например mongodb или elasticsearch.

      Если подытожить то образно говоря мы на клиенте запаковали версией скрипта любые данные в архив, зашифровали и отправили. И знаем что сервер это принял и положил в git.


      1. MetaDone
        07.02.2017 20:54

        1. На таких хостингах как правило можно разрешить удаленное подключение для определенного хоста
        2. Запустить по ssh удаленно команду, далее — rsync, после — удалить исходный файл. И большие БД держать на шаред-хостинге неразумно, потому есть возможность настроить реплику в локальной сети и с нее снимать дампы
        3. Это был пример как с удаленного хоста положить дамп себе на локальный комп


        1. evergreenteam
          07.02.2017 20:58

          Да, всё можно сделать. Но мы получаем «зоопарк» систем и методов управления. У нас порядка 30-40 разнородных систем которые мы собираем сейчас к себе.
          Сейчас настройка очередного «бэкапа клиента» занимает несколько минут.


          1. MetaDone
            07.02.2017 21:17

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


            1. evergreenteam
              07.02.2017 23:41

              Согласен с вами. Для всех нормальных хостингов, которые это поддерживают — это самый простой вариант. Остался вопрос больших БД на VPS и их передача в момент обрыва соединения.


              1. MetaDone
                07.02.2017 23:58

                Запустить по ssh удаленно команду, далее — rsync который корректно продолжит после обрыва соединения, после — по ssh удалить исходный файл.
                Или сделать тоннель
                ssh -fNg -L 5555:localhost:5432 user@host
                далее делать дамп будто на локальной машине


                1. evergreenteam
                  08.02.2017 00:35

                  Пока не вижу причин, чтобы это не работало. Обсудим это с коллегами, напишу найденные проблемы этого подхода или что проблем не нашли.


  1. nikitasius
    07.02.2017 17:12
    +1

    Собсна, а чем вариант:

    ecryptfs
    mysqldump blabla
    git blabla

    не угодил?


    1. evergreenteam
      07.02.2017 20:41

      Можете уточнить при чем ecryptfs на локальной машине к удаленной БД?


  1. quantum
    07.02.2017 18:31
    +2

    >Особенность MODx в том, что верстка и скрипты могут хранится как в БД, так и в файлах, но с точки зрения скорости, хранение в базе выгоднее, поэтому мы храним в БД.

    Если верстку и скрипты с точки зрения скорости выгоднее хранить в БД, то что-то здесь не так


    1. TyVik
      07.02.2017 19:43

      Это мы привыкли к хорошему — докеры всякие, линуксы да макоси… А тут всё жёстко — код, который хранится в базе, который работает с базой чтобы поменять сам себя. Утрирую, конечно, MODX видел всего пару раз в жизни, но желания вернуться не возникало.


    1. evergreenteam
      07.02.2017 20:47

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


      1. startsevdenis
        07.02.2017 21:30

        Для этого уже давно придумали инструменты для сравнения схем и состава данных


        1. evergreenteam
          07.02.2017 22:13

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