company_banner

Run, config, run: как мы ускорили деплой конфигов в Badoo

    Файлы конфигурации (конфиги) — неотъемлемая часть большинства приложений, но, как показывает практика, это не самая популярная тема для обсуждения. Чаще всего разговоры о конфигах ограничиваются обсуждением работы с ними непосредственно в коде: как их структурировать, использовать переменные окружения или нет, где хранить пароли и т. п. 

    На мой взгляд, есть и другая сторона работы с конфигами, которая заслуживает внимания, — развёртывание (деплой). За свою карьеру я видел довольно много способов деплоя конфигов и уверен, что каждый может узнать об этом что-то новое.

    Несколько лет назад я работал над системой, которая позволила нам ускорить процесс деплоя конфигов на 1000+ серверов с минуты до нескольких секунд. 

    Если вам интересно узнать, как устроен процесс деплоя конфигов в Badoo и какие инструменты мы для этого используем, добро пожаловать под кат.


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

    Случай из жизни

    За прошедшие годы в Badoo было написано более ста различных сервисов, и их количество продолжает расти. При этом у каждого сервиса может быть от двух-трёх до нескольких сотен инстансов, а значит, в нашем коде должна быть возможность оперативно убирать запросы с определённого инстанса. Ведь как гласит закон Мёрфи, «если что-то может пойти не так, оно пойдёт не так».

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

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

    Для отключения сервисов мы используем свою систему с говорящим названием «Выключалка хостов» (disable hosts). Принцип её работы довольно прост:

    • выбираем в веб-интерфейсе сервисы, которые нужно отключить (или, наоборот, включить);

    • нажимаем на кнопку Deploy;

    • изменения сохраняются в базе данных и затем доставляются на все машины, на которых выполняется PHP-код.

    В коде при подключении к сервису стоит проверка вида:

    if (\DownChecker\Host::isDisabled($host)) {
       $this->errcode = self::ERROR_CONNECT_FAILED;
       return false;
    }

    В первой версии «Выключалки хостов» для доставки изменений на серверы мы решили использовать нашу основную систему деплоя конфигов под названием mcode. Она по SSH выполняет определённый набор команд с дополнительной обвязкой этого процесса.

    Процесс деплоя разбит на несколько шагов:

    • упаковываем конфиги в tar-архив;

    • копируем архив на сервер через rsync или scp;

    • распаковываем архив в отдельную директорию;

    • переключаем симлинк на новую директорию.

    Особенность mcode — в том, что она ждёт завершения выполнения текущего шага на каждом сервере, прежде чем перейти к следующему. С одной стороны, это даёт больше контроля на каждом этапе и гарантирует, что на 99% серверов изменения доедут примерно в один момент времени (псевдоатомарность). С другой — проблемы с одним из серверов приводят к тому, что процесс деплоя зависает, ожидая завершения выполнения текущей команды по тайм-ауту. Если на каком-то сервере несколько раз подряд не удалось выполнить команду, то он временно исключается из раскладки. Это позволяет уменьшить влияние проблемных серверов на общую продолжительность деплоя. 

    Таким образом, mcode не даёт никаких гарантий высокой скорости доставки изменений. Можно лишь спрогнозировать её максимальную продолжительность, как сумму таймаутов всех шагов.

    Теперь представим, что у нас начались проблемы с каким-либо сервисом. Мы добавили его в список отключённых хостов, но в процессе раскладки конфига один из серверов стал временно недоступен (например, возникли проблемы с сетью). Пока система ждёт завершения текущей команды по тайм-ауту, мы теряем драгоценное время. 

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

    В поисках альтернативного транспорта

    Тут может возникнуть резонный вопрос: зачем изобретать велосипед, если можно использовать классическую схему с базой данных (БД) и кешированием?

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

    • использование БД и кеша — это дополнительная завязка на внешний сервис, а значит, ещё одна потенциальная точка отказа;

    • чтение из локального файла всегда быстрее запроса по сети, а нам, как бы громко это ни звучало, важна каждая миллисекунда; 

    • ещё одним плюсом чтения из файла является то, что весь конфиг по сути является статическим массивом, а значит, после первого исполнения он осядет в OPCache, что даст небольшой прирост производительности.

    Но схему с базой данных можно немного доработать, использовав вместо непосредственного чтения из первоисточника (базы данных) скрипт, который запускается на каждом сервере с заданной периодичностью (например, через cron), считывает нужные данные из базы данных и обновляет конфиг. Думаю, многие из вас использовали такой подход, например, для прогрева кеша. 

    Но в этой схеме нам не понравилась идея с запросами в цикле. У нас около 2000 серверов, на которых может выполняться PHP-код. Если мы будем делать запрос в базу/кеш хотя бы один раз в секунду, то это будет создавать довольно большой фон запросов (2k rps), а сами данные при этом обновляются не так часто. На этот случай есть решение — событийная модель, например шаблон проектирования Publisher-Subscriber (PubSub). Из популярных решений можно было использовать Redis, но у нас он не прижился (для кеширования мы используем Memcache, а для очередей у нас есть своя отдельная система). 

    Зато прижился Consul, у которого есть механизм отслеживания изменений (watches) на базе  блокирующих запросов. Это в целом похоже на PubSub и вписывается в нашу схему с обновлением конфига по событию. Мы решили сделать прототип нового транспорта на базе Consul, который со временем эволюционировал в отдельную систему под названием AutoConfig.

    Как работает AutoConfig

    Общая схема работы выглядит следующим образом:

    • разработчик вызывает специальный метод, который обновляет значение ключа в базе данных и добавляет ключ в очередь на деплой;

    • в облаке работает скрипт, который исключает пачку ключей из очереди и записывает их в Consul; помимо самих ключей, в Consul хранится специальная индексная карта со списком всех ключей (о ней я расскажу чуть позже);

    • на каждом сервере запущен Consul watch, который отслеживает изменения индексной карты и вызывает специальный скрипт-обработчик, когда она обновляется;

    • обработчик записывает изменения в файл.

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

    Обновление ключа

    $record = \AutoConfig\AutoConfigRecord::initByKeyData('myKey', 'Hello, Habr!', 'Eugene Tupikov');
    $storage = new \AutoConfig\AutoConfigStorage();
    $storage->deployRecord($record);

    Чтение ключа

    $reader = new \AutoConfig\AutoConfigReader();
    $config = $reader->getFromCurrentSpace('myKey');

    Удаление ключа

    $storage = new \AutoConfig\AutoConfigStorage();
    $storage->removeKey('example');

    Подробнее про Consul watch

    Consul watch реализован с использованием блокирующих запросов в HTTP API, работу которых лучше продемонстрировать на примере отслеживания изменений ключа.

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

    curl -X PUT --data 'hello, habr!' http://127.0.0.1:8500/v1/kv/habr-key

    Затем отправляем запрос на чтение нашего ключа

    curl -v http://127.0.0.1:8500/v1/kv/habr-key

    API обрабатывает запрос и возвращает ответ, содержащий HTTP-заголовок X-Consul-Index с уникальным идентификатором, который соответствует текущему состоянию нашего ключа.

    ...
    ...
    < X-Consul-Index: 266834870
    < X-Consul-Knownleader: true
    ...
    ...
    <
    [
      {
        "LockIndex": 0,
        "Key": "habr-key",
        "Flags": 0,
        "Value": "aGVsbG8sIGhhYnIh",
        "CreateIndex": 266833109,
        "ModifyIndex": 266834870
      }
    ]

    Мы отправляем новый запрос на чтение и дополнительно передаем значение из заголовка X-Consul-Index в параметре запроса index

    curl http://127.0.0.1:8500/v1/kv/habr-key?index=266834870
    
    Ждем изменений ключа...
    
    

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

    Затем открываем новую вкладку терминала и отправляем запрос на обновление ключа

    curl -X PUT --data 'updated value' http://127.0.0.1:8500/v1/kv/habr-key

    Возвращаемся на первую вкладку и видим, что запрос на чтение вернул обновленное значение (изменились ключи Value и ModifyIndex):

    [
      {
        "LockIndex": 0,
        "Key": "habr-key",
        "Flags": 0,
        "Value": "dXBkYXRlZCB2YWx1ZQ==",
        "CreateIndex": 266833109,
        "ModifyIndex": 266835734
      }
    ]

    При вызове команды

    consul watch -type=key -key=habr_key <handler>

    Consul watch автоматически выполнит указанную выше последовательность запросов и вызовет обработчик в случае изменения значения ключа.

    Зачем нужна индексная карта

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

    consul watch -type=keyprefix -prefix=auto_config/ <handler>

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

    По этому поводу на GitHub уже довольно давно открыт Issue и, судя по комментариям, лёд тронулся. Разработчики Consul начали работу над улучшением подсистемы блокирующих запросов, что должно решить описанную выше проблему.

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

    Она имеет следующий формат:

    return [
        'value' => [
            'version' => 437036,
            'keys' => [
                'my/awesome/key' => '80003ff43027c2cc5862385fdf608a45',
                ...
                ...
            ],
            'created_at' => 1612687434
        ]
    ]

    В случае если карта обновилась, обработчик:

    • считывает текущее состояние карты с диска;

    • находит изменившиеся ключи (для этого и нужен хеш значения);

    • вычитывает через HTTP API актуальные значения изменившихся ключей и обновляет нужные файлы на диске;

    • сохраняет новую индексную карту на диск.

    И ещё немного про Consul

    Consul — отличная система, но за годы её использования в качестве транспорта для AutoConfig мы обнаружили ряд ограничений, под которые пришлось адаптировать нашу систему.

    Ограничение размера значения ключа (и не только)

    Consul — это распределённая система, и для достижения согласованности используется протокол Raft. Для стабильной работы протокола в Consul установлен максимальный размер значения одного ключа — 512 Кб. Его можно изменить, воспользовавшись специальной опцией, но делать это крайне не рекомендуется, так как изменение может привести к непредсказуемому поведению всей системы. 

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

    Чтобы обойти эти ограничения, мы сделали следующее:

    • разбили индексную карту на несколько частей (по 1000 ключей в каждой) и обновляем только части, содержащие изменённые ключи;

    • ограничили максимальный размер одного ключа AutoConfig 450 Кб, чтобы оставить место для шардов индексной карты (значение выбрано опытным путём);

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

      • сначала вычитывает N ключей из очереди и проверяет их суммарный размер;

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

    Отсутствие встроенной репликации

    У Consul из коробки отсутствует репликация между дата-центрами. У нас их несколько, поэтому мы вынуждены записывать изменения в каждый дата-центр последовательно. Периодически возникают ситуации, когда в один дата-центр данные записались, а в другой — нет, что приводит к неконсистентности. Если произошла временная ошибка, мы используем механизм повторного выполнения запросов (Retry), но это не решает проблему полностью.

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

    В качестве заключения 

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

    Если какой то сервер временно недоступен, система автоматически восстанавливает актуальное состояние конфига, как только он приходит в норму.

    За последние пару лет система стала популярна у наших разработчиков и сегодня фактически является стандартом при работе с конфигами, для которых не требуется атомарная раскладка. Например, через AutoConfig у нас деплоятся параметры A/B-тестов, настройки промокампаний, на его базе реализован функционал Service Discovery и многое другое.

    Общее количество ключей на данный момент — порядка 16 000, а их суммарный размер — примерно 120 Мб.

    Спасибо за внимание!

    Расскажите в комментариях, как вы деплоите конфиги в своих проектах.

    Badoo
    Big Dating

    Похожие публикации

    Комментарии 11

      0

      Эх, а я надеялся, что вы через MDK сделали :). Но Consul тоже хорошее решение, оно вроде как для таких вещей и предназначено, насколько я понимаю.

        0
        Мы одно время думали перевести часть конфигов на mdk, но в данном случае это не решило бы проблемы с залипающими серверами, потому что у mdk тоже 2 фазы (копирование нужных файлов и переключение симлинка). Нужна была именно pull модель, а тут хорошо подошел Consul. Все подводные камни и ограничения всплывали по мере того, как AutoConfig набирал популярность. При этом не все ограничения изначально были отражены в документации Consul, какие то вещи я находил уже в исходниках.
          0
          Инвертировать не проще? Пусть сервера сами ходят за конфигом в общее хранилище.

          Сделать абсолютно надежное хранение файликов решаемо. Обновление упрощается до обновления файлика в хранилище. Там все в одном экземпляре и обновление тривиально.
            0
            Так ведь схема с Consul это и есть инвертированный подход и в качестве абсолютно надежного хранилища выступает Consul (у нас насколько знаю 5 серверных нод консула)
            Просто у Consul-а есть свои ограничения, которые постепенно всплывали в процессе эксплуатации и нужно было адаптироваться под них.

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

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

              А не было попыток заменить redis/cassandra на scylladb ?

                0
                вообще само по себе выглядит странным поднимать кластер C*/scylladb ради хранения конфигов. Но если без привязки к этому, то мы пока только присматриваемся к scylladb, но пока не больше этого. У нее есть свои на количество нод в кластере для open-source версии
              0
              Вы все равно файлики раскладываете. А почему бы не ходить по сети за конфигами? Допустим в s3. Надежнее некуда. Куда их можно класть через любую удобную систему генерации.

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

              Читать постоянно надежнее. Это не то место где стоило бы экономить. Объемы смешные, читать файлик дешево. Чем проще тем лучше. Ловить баги в таких местах совсем не хочется.

              Кластер в век менеджед БД стоит недорого. Тем более маленький и очень важный кластер. Ноды поменьше, штук побольше, l3 балансировка конечно. Ломаться нечему.
                0
                Все верно говорите. Только вот заранее городить такую схему не хотелось, учитывая что изначально система делалась под конкретную задачу (выключалка хостов), и только со временем превратилась в систему общего назначения. На мой взгляд любая система должна развиваться итерационно. Учитывая, что сам транспорт скрыт от пользователя, мы можем прозрачно перейти на новый траспорт (например, тот же s3)

                Основной посыл статье про сам подход — pull модель, вместо push модели (а не показать насколько хороша схема с Consul-ом)

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


                конфиги бывают разные и для каждого типа есть свои требования к скорости доставки на сервера. AutoConfig используется там где важна именно скорость, а для остальных конфигов мы продолжаем использовать традиционный подход (mcode в нашем случае)
            0
            А если под задачи доставки конфигов поднять GlusterFS и просто класть в него свежие файлы с конфигами на одной из нод, а GlusterFS сам позаботится о доставке их на остальные сервера. Если одна из нод тупит — остальным это не помешает. Сам пока не пробовал, не те масштабы на данный момент.
              0
              Не работал с этой файловой системой, поэтому не могу сказать насколько она бы зашла тут. Да и как я уже писал ранее наша задача была найти альтернативный транспорт используя существующую инфраструктуру. Учитывая, что консул агент уже был на всех серверах, то не было необходимости настраивать/поднимать что-то новое

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

            Самое читаемое