Обновления на лету (zero-downtime deployment) вообще и в Ruby on Rails

    Сначала разберемся с определениями. Под обновлением на лету мы подразумеваем такое обновление системы, при котором не нарушается штатная ее работа: клиенты работают, посетители ходят и никто не наблюдает ошибок, увеличившегося времени отклика или таблички “УЧЁТ”.

    Зачем это нужно? Если вы задаетесь этим вопросом — вам не нужно. Вешайте табличку, садитесь обедать.

    Как это делается? Сложно. Почему? Главных причин две:
    — вы не можете обновить систему мгновенно и атомарно (то есть ровно между двумя HTTP запросами). При наивном подходе пользователи заметят как минимум долгое время отклика, а то и ошибку, если, к примеру, БД обновлена, а код еще нет;
    — состояние и конфигурация системы существуют и на клиенте и на сервере. Примеры: данные в сессии, имена полей формы, адреса в ссылках, состояние в javascript на открытой у пользователя странице.

    Общее решение


    В общем виде решение можно сформулировать так: необходимо обеспечить совместимость кода версии N+1 с состоянием версий N и N+1, затем обновить состояние до N+1.

    На практике такая вот совместимость и выливается в огромное количество (очевидных и не очень) сложностей. Разберем типичные случаи в приложении на Ruby On Rails.

    Изменение схемы БД


    Добавление поля в таблицу теоретически совместимо с предыдущей версией кода. Практически — тоже, если нет особенно злого мета-программирования.

    Удаление поля имеет очевидную несовместимость в случае, если старый код использует это поле, и неочевидную, в любом случае: ActiveRecord кэширует список полей и перечисляет все поля, например, в запросах INSERT. Выход: сначала обновить код до промежуточного, который а) не будет испльзовать удаляемое поле; б) будет сам удалять это поле из кэша, потом обновить БД, потом обновить код до конечного.

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

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

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

    Изменение взаимодействия клиент-сервер


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

    Изменение семантики данных в сессии и куках придется так же обрабатывать отдельным кодом, понимающим оба формата. Сессии живут долго, куки еще дольше. Вы же не хотите потерять данные корзины покупателя или заставлять его вводить логин-пароль лишний раз? (Хабр, shame on you!)

    Изменение адресов тех или иных страниц / action’ов приложения всегда нужно выполнять обратно-совместимым. Оставлять старые роуты, назначать на них редиректы, что угодно. URL’ы в веб-приложении должны быть самой стабильной частью системы: это ваш публичный API, которым пользуются ваши пользователи и поисковики, которые приводят ваших пользователей. Таким образом у вас не будет проблем и в части, описываемой данной статьей.

    В случае использования assets pipeline не нужно удалять ассеты предыдущей версии кода. Это просто.

    Перезапуск


    Совместимость кода — еще не всё. Как вы вводите новый код в работу? Сколько у вас веб- или апп-серверов? Рассмотрим варианты.

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

    Если у вас один сервер на Passenger позади Nginx’а или Apache httpd, придется переехать на Unicorn. Даже Passenger 3, в котором заявлен zero-downtime restart, делает его достаточно наивно: сначала убивает старые worker’ы, потом делает новые. В результате посетители получают большое время отклика, фактически не менее времени старта вашего приложения.

    Используя Unicorn, мы можем воспроизвести сценарий для нескольких серверов, но “в миниатюре”. В before_fork нужно посылать старому master процессу сигнал TTOU, в таком случае каждый новый worker будет выключать по одному старому. В конце нужно послать старому master’у QUIT, и всё. Если вам хватит памяти на двойное количество worker’ов, то можно делать проще и выводить старые процессы не постепенно, а сразу — в конце перезапуска.

    Совет: используйте опцию preload_app true, даже если вы не на ruby enterprise edition — иначе вы слишком поздно узнаете о том, что новые worker’ы падают при старте из-за ошибки.

    Заключение


    Подумайте еще раз: вам действительно все это нужно? Точно? Может быть все-таки просто вставить в страницу-заглушку свежый выпуск +100500 TED, запустить cap deploy и идти пить чай? Ах да… пользователи, продажи, прибыль…
    Cloud Castle
    Компания
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      0
      Вообще, эта статья задумывалась как некий чек-лист, чтобы с ним сверяться сотрудникам, которые начинают заниматься нагруженными (и критичными к downtime) проектами.

      Если я что-то забыл написать, пожалуйста, дополните в комментах, я добавлю в статью.
        0
        Как вам вариант реализовать взаимодействие клиент-сервер с явным указанием версии клиентской части в запросе как это советуют делать в статье "RESTful API для сервера – делаем правильно" в разделе «Разделение на версии»?

        Конечно, проблему изменения адресов это не решит, но вопросы, связанные с полями формы и данными в сессии и куках, по-моему, решит отлично.
          +2
          Я бы сказал, что этот подход не решает (по крайней мере нужны все те же шаги), но отлично формализует решение проблемы: вместо хакерства — понятная версионность.
          0
          В общем виде решение можно сформулировать так: необходимо обеспечить совместимость кода версии N+1 с состоянием версии N.


          Отсюда следует, что необходимо обеспечить совместимость базыбазы версии N+1 с состоянием кодом версии N+2.
            0
            Чувствую верную мысль, но не могу понять из-за ошибок копипасты :)
              0
              Это ошибки Swype, за ним глаз да глаз… про сути, ошибка одна: задвоение слова «базы», и на понятность утверждения влиять не должна…

              посему, поясняю: я имел ввиду, что делая фичу, потребующую изменения базы, придется сначала частично выпускать изменение базы (в рамках версии n+1), которое будет готовить её к появлению фичи, и только потом (скорее всего, следующей версией n+2) саму фичу и подчищение хвостов в базе…

              Хотя, сейчас меня по этому поводу сомнения взяли… версии всё же по всякому можно собирать…
                0
                Поправил формулировку. Это общая идея, частности (описанные ниже), как обычно, сложнее.
                  0
                  А, копипаста таки тоже: «с состоянием кода», конечно же.
                    0
                    Под состоянием (state) в данном случае подразумевается совокупность данных приложения в базе, оперативной памяти сервера, на клиенте и т. д.
              +1
              Мы сделали немножко иную схему: перед кодом приложения стоит прослойка, которая смотрит версию базы данных и в зависимости от версии базы данных передаёт управления на текущий или будущий код.

              Когда нужно обновиться, мы последовательно подгружаем на app-ноды к текущему коду новый код (а прослойке говорим, что появилась новая версия), а затем последовательно проводим обновление структур баз данных клиентов.

              Поэтому есть тормоза на время обновления БД, но нам пока везёт — базы наших клиентов небольшие, обновления происходят за 1-2 секунды, что, в целом, незаметно.
                0
                А на чем работаете (в смысле технологий)?
                  0
                  В основе всего Python + PgSQL.
                  –1
                  т.е. у вас в дополнение к фронт-ендам и БД, есть ещё один слой, отъедающий время и лишний запрос к БД за каждый запрос к серверу?
                  очень интересный у вас «high-load»
                    0
                    Не совсем =) Просто на той же ноде (на том же frontend-е) есть код, который и определяет, в какую версию кода приложения нужно «перейти». Ну и для уменьшения запросов версия структуры БД кэшируется.
                      0
                      > код, который и определяет, в какую версию кода приложения нужно «перейти»
                      ну то есть именно слой, просто прямо внутри приложения?
                      > для уменьшения запросов версия структуры БД кэшируется
                      т.е. мало того, что эта хрень жрёт лишнее время исполнения, она ещё и не риал-тайм? так зачем она вообще нужна?
                        0
                        Ну, если считать код вида if (newVersion()) { include 1; } else { include 2; } «отдельным слоем», то да, это отдельный слой.

                        Почему не realtime? Кэш с информацией о конкретной БД сбрасывается в момент обновления БД.
                  0
                    0
                    Если база классическая (MySQL, структура таблицы в sql), то изменение структуры данных (добавление поля, например) на таблице порядка 100'000 записей (или миллиона, но не больше) может занимать минут 15. В это время база вообще не работает и все красивые рассуждения рушатся. Обновление кода zero-downtime понятно как делать. Обновление базы — операция хитрая, есть разные способы в зависимости от деталей приложения. В большинстве систем, с учетом возможного кол-ва ошибок из-за сложного обновления, все равно эффективней (по деньгам для проекта) обновлять «ночью» с приостановкой сервиса, если требуется изменение SQL-структуры данных.
                      0
                      Для MySQL подход похожий, но чуть сложнее. Свежие форки table_migrator в помощь :)
                        0
                        Весомый довод в пользу PostgreSQL: у него только операции изменения поля и создания поля со значением по умолчанию происходят за O(n). Остальное — создание nullable поля, удаление поля и даже индексирование (create index concurrently) — происходят за O(1) и, следовательно, локами можно пренебречь.

                        Изменение поля при описываемом в статье подходе не требуется. Поле not null default ... во многих случаях можно заменить на прописанное в коде значение по умолчанию.

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

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