Один из ведущих принципов создания решений в Amazon – не входить в двери, которые открываются только в одну сторону. Это значит, что мы воздерживаемся от необратимых и ограниченных решений. Мы применяем этот принцип на всех этапах разработки программного обеспечения: от проектирования продуктов, возможностей, API и базовых систем до развертывания. В этой статье я расскажу о том, как этот принцип реализован в развертывании.

При развертывании среда ПО переходит из одного состояния (версии) в другое. ПО может работать идеально в любом из этих состояний. Но во время или после перехода вперед (обновления или восстановления) или назад (перехода к прежней версии или отката) могут случаться ошибки. Если ПО не работает должным образом, может прерваться работа сервиса, и клиенты сочтут его ненадежным. В этой статье предполагается, что обе версии ПО работают, как ожидалось. Рассмотрим, как обеспечить отсутствие ошибок после отката вперед или назад.

Перед выпуском новой версии ПО мы тестируем его в бета- или гамма-среде тестирования по многим параметрам, таким как функциональность, способность к параллельному выполнению, производительность, масштабирование и обработка сбоев «сверху вниз». Такой метод тестирования помогает нам обнаружить любые неполадки в новой версии и устранить их. Но не всегда достаточно обеспечить успешное развертывание. В рабочей среде мы можем наткнуться на неожиданные обстоятельства или непредусмотренное поведение ПО. В Amazon мы стремимся избежать ситуаций, когда откат облака развертывания может привести к ошибкам работы ПО у клиентов. Поэтому мы полностью подготовлены к откату перед каждым развертыванием. Версия ПО, для которой можно выполнить откат без ошибок или перебоев в работе функций, которые были доступны в прежней версии, называется обратно совместимой. Мы строим планы и проводим проверки таким образом чтобы обеспечить обратную совместимость нашего ПО в каждой редакции.

Перед тем как подробно объяснить подход Amazon к обновлению ПО, обсудим различия между автономным и распределенным развертыванием.

Сравнение автономного и распределенного развертывания ПО

Развертывание автономного ПО, которое выполняется как один процесс на одном устройстве, является атомарным. Две версии ПО никогда не работают одновременно. Если автономное ПО использует сохранение состояния, то новая версия должна прочесть данные (то есть, выполнить десериализацию данных), которые записаны (то есть, сериализованы) прежней версией и наоборот. Если это условие удовлетворено, то можно без проблем проводить откат развертывания вперед и назад.
 
В распределенной системе развертывание становится сложнее. Оно проводится в виде последовательных обновлений, чтобы не понизить доступность. Новая версия передается на подмножество хостов одновременно, чтобы остальные хосты могли продолжать обслуживать запросы. Обычно эти хосты взаимодействуют друг с другом с использованием удаленного вызова процедур (RPC) или общего постоянного состояния (например, метаданных или контрольных точек). Такое взаимодействие или общее состояние может порождать дополнительные проблемы. Запись и чтение могут выполняться разными версиями программного обеспечения. В результате они могут по-разному интерпретировать данные. Данные могут быть не прочитаны вообще, а это приведет к простою.

Неполадки из-за смены протокола

Мы обнаружили, что самой распространенной причиной невозможности отката является смена протокола. Рассмотрим изменение в коде, который начинает сжимать данные, сохраняя их на диск. После того как новая версия запишет фрагмент сжатых данных, откат будет невозможен. В прежней версии не предусмотрена распаковка данных после их чтения. Если данные хранятся в BLOB‑объекте или хранилище документов, то другим серверам не удастся прочитать их, даже если производится развертывание. Если данные передаются между двумя процессами или серверами, то получателю не удастся их прочитать.

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

Теперь рассмотрим развертывание, которое продлевает частоту тактовых импульсов до 10 секунд. Изменение в коде кажется незначительным: всего лишь замена числа. Но теперь откаты вперед и назад становятся небезопасными. Во время развертывания сервер с новой версией отправляет тактовый импульс каждые 10 секунд. Соответственно, сервер с прежней версией не обнаруживает тактового импульса в течение более пяти секунд и закрывает соединение с сервером, на котором выполняется новая версия. Если используется группа компьютеров, это может произойти с несколькими соединениями, что приведет к снижению доступности.

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

Технология двухфазного развертывания

Одним из способов, который гарантирует безопасный откат назад, является технология, известная как двухфазное развертывание. Рассмотрим следующий гипотетический сценарий с сервисом, который управляет данными (пишет и читает их) в Amazon Simple Storage Service (Amazon S3). Сервис работает на группе серверов в нескольких зонах доступности, чтобы обеспечить масштабирование и доступность.

На данный момент для хранения данных сервис использует формат XML. Как показано на приведенной ниже диаграмме, в версии 1 все серверы читают и записывают код XML. По экономическим причинам от нас требуется хранить данные в формате JSON. Если мы внесем изменения в одно развертывание, то серверы, на которых будет введено это изменение, станут записывать данные в формате JSON. Но другие серверы еще «не знают», как читать JSON. Это приводит к ошибкам. Поэтому мы делим такое изменение на две части и выполняем двухфазное развертывание.

Как показано на приведенной выше диаграмме, мы называем первую фазу подготовкой. На этом этапе мы готовим все серверы к чтению данных в формате JSON (в дополнение к XML), но на момент развертывания версии 2 они продолжают записывать данные в формате XML. С точки зрения работы ПО это ничего не меняет. Все серверы по-прежнему способны читать данные в формате XML, в котором они и продолжают записываться. Если мы решим выполнить откат этого изменения, серверы вернутся в состояние, в котором они не способны читать данные в формате JSON. Это не проблема, поскольку никакие данные еще не записывались в формате JSON.

Как показано на приведенной выше диаграмме, мы называем вторую фазу активацией. На этом этапе мы активируем серверы для использования формата JSON при записи данных, развертывая версию 3. По мере внедрения этого изменения на серверах они начинают записывать данные в формате JSON. Серверы, на которых это изменение не внедрено, все еще способны читать данные в формате JSON, поскольку они подготовлены к этому на первом этапе. Если мы решим выполнить откат этого изменения, то все данные, записанные серверами, которые временно находились на этапе активации, останутся в формате JSON. Данные, записанные серверами, которые не были на этапе активации, останутся в формате XML. Это хорошо, поскольку, как показано в версии 2, серверы после отката все еще способы читать данные в обоих форматах.

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

Предварительные меры при двухфазном развертывании

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

Многие инструменты развертывания позволяют пользователям считать развертывание успешным, если минимальное число хостов принимает изменение и сообщает об отсутствии ошибок. Например, AWS CodeDeploy имеет конфигурацию развертывания minimumHealthyHosts.

Критическим допущением в примере двухфазного развертывания является то, что в конце первого этапа все серверы обновлены для чтения XML и JSON. Если на первом этапе нескольким серверам не удается принять обновление, они не будут способны читать данные во время второго этапа и после него. Поэтому мы проводим явную проверку, чтобы гарантировать, что все серверы на этапе подготовки приняли изменение.

Когда я работал над Amazon DynamoDB, мы решили изменить протокол взаимодействия между огромным количеством серверов, которые совместно используют множество микросервисов. Я координировал развертывание со всеми микросервисами, чтобы все серверы сначала перешли на этап подготовки, а затем – активации. В качестве предварительной меры я применил проверку успешности развертывания на каждом сервере в конце каждого этапа.

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

После этапа активации мы не способны безопасно удалить возможность ПО читать данные в формате XML. Это небезопасно, поскольку все данные до этапа подготовки записаны в формате XML. Способность чтения формата XML можно удалить после того как мы убедимся, что все объекты перезаписаны в формате JSON. Мы называем этот процесс заполнением. Для него могут потребоваться дополнительные инструменты, которые способны работать одновременно, пока сервис записывает и считывает данные.

Рекомендации по сериализации

Большинство программ используют сериализацию данных: как для хранения, так и для передачи по сети. По мере их улучшения логика сериализации обычно меняется. Такими изменениями могут быть как добавление нового поля, так и полная смена формата. За многие годы мы разработали некоторые рекомендации, которых придерживаемся при сериализации.

• Обычно мы избегаем разработки собственных форматов сериализации.

Исходная логика сериализации может казаться тривиальной и даже обеспечивать более высокую производительность. Но последующие итерации этого формата вызывают проблемы, которые уже решены хорошо известными платформами: Protocol Buffers, Cap’n Proto и FlatBuffers. При надлежащем использовании эти платформы предоставляют функции безопасности, такие как экранирование символов, обратная совместимость и отслеживание наличия атрибутов (то есть, задано ли значение поля непосредственно или оно получило значение по умолчанию).

• При каждом изменении мы назначаем средствам сериализации новые версии.

Это делается независимо от версий исходного кода или компоновок. Также мы сохраняем версию средства сериализации вместе с данными или в метаданных. Более старые версии средств сериализации продолжают функционировать в новом ПО. Обычно полезно давать метрику для версии данных, которые записываются или читаются. В случае ошибки это обеспечит для операторов прозрачность и даст им информацию для устранения неполадок. Все это относится и к версиям RPC и API.

• Мы избегаем сериализации структур данных, которые не можем контролировать.

Например, мы можем сериализовать коллекции объектов Java с помощью рефлексии. Но если мы попытаемся обновить JDK, то базовая реализация этих классов может измениться, что приведет к сбою десериализации. Этот риск распространяется и на классы из библиотек, которые совместно используются несколькими коллективами.

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

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

Проверка безопасности отката изменения

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

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

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

В случае изменений, которые подразумевают координирование микросервисов, мы сохраняем порядок развертывания для всех микросервисов в среде тестирования и в рабочей среде. Но порядок отката вперед и назад может быть разным. Например, в контексте сериализации мы обычно следуем определенному порядку. То есть, при откате вперед сначала внедряются средства чтения, затем – средства записи, а при откате назад – наоборот. Соответствующий порядок соблюдается как в среде тестирования, так и в рабочей среде.

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

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

Выводы

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

Дополнительные сведения

Чтобы больше узнать о том, как Amazon повышает безопасность и доступность сервисов, при этом повышая удовлетворенность клиентов и производительность разработчиков, см. статью Непрерывная доставка – возможность работать быстрее.


Об авторе

Сандип Поккунури – главный инженер в AWS. Он начал сотрудничать с Amazon в 2011 году. Работал над разными сервисами, включая Amazon DynamoDB и Amazon Simple Queue Service (SQS). Сейчас он занимается технологиями машинного обучения с использованием естественных языков (например, ASR, NLP, NLU и машинный перевод) и является главным инженером в Amazon Lex. Перед AWS он работал в Google над проблемами машинного обучения, такими как обнаружение спама и оскорбительного контента в социальных средах и выявление аномалий в журналах доступа к сети.

Непрерывная доставка – возможность работать быстрее