Привет, Хаброжители!

Издательство Sprint book представляет второе издание книги Брендана Бёрнса «Распределенные системы. Паттерны и парадигмы для масштабируемых и надежных систем на основе Kubernetes». Фундаментальное руководство превращает сложное искусство создания распределенных систем в понятную науку, предлагая проверенные решения для современных облачных архитектур.

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

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

Брендан Бёрнс, автор книги и эксперт в области распределенных систем, отмечает, что, несмотря на схожесть многих принципов работы таких систем, разработчики продолжают создавать их практически с нуля каждый раз. Несмотря на то, что зачастую многие их принципы и логика работы совпадают, шаблонные решения или повторно используемые компоненты не так-то просто применять. Это приводит к неоправданным затратам времени и снижению качества конечного продукта. Появление контейнерных технологий и их оркестраторов кардинально изменило ситуацию. В распоряжение разработчиков попали объект и интерфейс, которые позволяют выражать базовые паттерны проектирования распределенных систем и компоновать контейнеризированные компоненты.
Об авторе
Брендан Бёрнс — вице-президент в Microsoft, где он отвечает за руководство такими направлениями, как Azure, Azure Arc, Kubernetes на Azure, Linux на Azure и PowerShell. Живет в Сиэтле со своей женой, двумя детьми и кошкой.
Научный редактор русского издания
Дмитрий Колфилд — инженер-тестировщик в компании КРОК, специалист по тестированию высоконагруженных информационных систем и миграций в нереляционных базах данных.

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

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

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

Кому будет полезна эта книга


Издание адресовано широкому кругу специалистов — от начинающих разработчиков до опытных архитекторов распределенных систем. Сегодня даже относительно простые мобильные приложения используют облачные API, что делает знание принципов построения распределенных систем необходимым для большинства разработчиков.

Книга поможет системным инженерам и разработчикам ПО:
  • освоить проверенные подходы к проектированию распределенных систем;
  • научиться применять паттерны в контексте Kubernetes;
  • избежать типичных ошибок при создании масштабируемых приложений;
  • использовать возможности ИИ для повышения надежности систем.
Структура и содержание книги
Издание организовано в пять логических частей, каждая из которых раскрывает определенный аспект проектирования распределенных систем.

Первая часть знакомит с базовыми концепциями распределенных систем.

Вторая часть посвящена одноузловым паттернам проектирования, таким как Sidecar, Adapter и Ambassador.

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

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

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

Для опытных разработчиков автор рекомендует особое внимание уделить одноузловым паттернам как наиболее универсальным и простым для повторного использования. Части, посвященные сервисам и обработке данных, можно изучать независимо друг от друга в зависимости от профессиональных задач читателя.
Предлагаем ознакомиться с отрывком «Распространенные паттерны отказов»
До сих пор в книге описывались различные паттерны проектирования, помогающие создавать
распределенные системы. Эта глава отличается от остальной части книги — вместо того чтобы помочь вам понять, что и как делать, она рассказывает, чего делать не надо. В практике разработки, эксплуатации и отладки систем определенные виды проблем повторяются снова и снова. Представленные здесь паттерны делятся на две категории: ошибки, которые допускаются при создании систем, и часто встречающиеся виды выхода систем из строя. Понимая, чего не следует делать и что нужно попытаться предотвратить, мы можем извлечь уроки из этих типичных ошибок, чтобы не повторить их в будущем.

Грохочущее стадо


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

У любого конкретного приложения есть максимальная емкость. Обычно мы пытаемся установить емкость наших приложений так, чтобы она была больше любой нагрузки даже при пиковом наплыве пользователей. К сожалению, иногда из-за сбоев, потери емкости или непредвиденного всплеска интереса со стороны пользователей количество отправленных сервису запросов превышает его возможности. Допустим, что перегрузку вызывает трафик, насчитывающий X запросов в секунду. Перегрузка — это плохо; обработка многих, если не всех, из этих X запросов в секунду прерывается по тайм-ауту, но то, что происходит дальше, еще хуже. В следующую минуту те же самые X запросов превращаются в 2X запросов. Это удвоение является результатом сложения нового трафика (X), а также повторного запуска всех предыдущих запросов. Результат — еще больше сбоев, еще больше повторов и еще больше новых запросов. Так плохая ситуация превращается в непоправимую. Это особенно верно, когда задействовано несколько цепочек вызовов и несколько алгоритмов повтора. Превращение одного запроса в множество запросов может быстро привести к перегрузке системы.

Создавая системы, мы встраиваем повторные попытки, стремясь обеспечить безотказную обработку запросов, но, слепо повторяя попытки в случае ошибок, мы увеличиваем численность «грохочущего стада», ухудшая ситуацию. Первый способ исправить ситуацию на стороне клиента — использовать экспоненциальную задержку. Вместо немедленного запуска повторной попытки клиент некоторое время ждет и только потом пробует отправить запрос еще раз. Каждый раз, когда клиент видит ошибку, он удваивает время ожидания.

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

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

У любого конкретного приложения есть максимальная емкость. Обычно мы пытаемся установить емкость наших приложений так, чтобы она была больше любой нагрузки даже при пиковом наплыве пользователей. К сожалению, иногда из-за сбоев, потери емкости или непредвиденного всплеска интереса со стороны пользователей количество отправленных сервису запросов превышает его возможности. Допустим, что перегрузку вызывает трафик, насчитывающий X запросов в секунду. Перегрузка — это плохо; обработка многих, если не всех, из этих X запросов в секунду прерывается по тайм-ауту, но то, что происходит дальше, еще хуже. В следующую минуту те же самые X запросов превращаются в 2X запросов. Это удвоение является результатом сложения нового трафика (X), а также повторного запуска всех предыдущих запросов. Результат — еще больше сбоев, еще больше повторов и еще больше новых запросов. Так плохая ситуация превращается в непоправимую. Это особенно верно, когда задействовано несколько цепочек вызовов и несколько алгоритмов повтора. Превращение одного запроса в множество запросов может быстро привести к перегрузке системы.

Создавая системы, мы встраиваем повторные попытки, стремясь обеспечить безотказную обработку запросов, но, слепо повторяя попытки в случае ошибок, мы увеличиваем численность «грохочущего стада», ухудшая ситуацию. Первый способ исправить ситуацию на стороне клиента — использовать экспоненциальную задержку. Вместо немедленного запуска повторной попытки клиент некоторое время ждет и только потом пробует отправить запрос еще раз. Каждый раз, когда клиент видит ошибку, он удваивает время ожидания.

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

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

Отсутствие ошибок — это ошибка


Теперь перейдем от проблем, обусловленных большим трафиком, к проблемам, которые характеризуются слишком малым трафиком. Обычно в системы добавляют средства мониторинга и оповещения, которые срабатывают, когда в системе возникает слишком много ошибок. Очевидно, что слишком много ошибок — это проблема, которая требует внимания со стороны человека, чтобы тот заглянул в систему, определил причину и восстановил нормальную работу. А как насчет слишком малого количества ошибок? Это может показаться нелогичным, но некоторое количество ошибок является устойчивым состоянием для большинства распределенных систем. Обычно очень сложно, да и не нужно пытаться предотвратить появление любых ошибок, поэтому всегда существует некоторое количество ошибок, обусловленных внешними причинами, которые обрабатываются повторными попытками и другими алгоритмами исправления ошибок. Следовательно, полное отсутствие ошибок, скорее всего, указывает на серьезную проблему, а не на то, что все идет отлично.

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

Клиентские и ожидаемые ошибки


Следующий паттерн тоже связан с ошибками в системе, но на этот раз с ошибками, игнорирование которых часто считается нормальной реакцией. Многие системы принимают пользовательский ввод и запросы на обработку. В таких системах пользователи (или клиенты в целом) могут отправлять запросы, содержащие ошибки и непригодные к обработке в текущем виде. При оценке надежности сервиса, очевидно, не следует рассматривать ошибки, обусловленные плохими клиентскими запросами, как истинные ошибки, поскольку сервис не контролирует то, что вводят клиенты. Однако на практике такие ситуации, которые можно характеризовать как «не мои проблемы», часто приводят к игнорированию истинных системных ошибок. Представьте, например, ошибку авторизации. Конечно, отправка запроса неавторизованным пользователем является ошибкой клиента и не указывает на проблему с надежностью. Но что, если внезапно все пользователи окажутся неавторизованными? Это может указывать на проблему в нашей подсистеме авторизации. И если относиться ко всем ошибкам несанкционированного доступа как к «ошибкам пользователей», не пытаясь увидеть аномалии, как, например, когда все пользователи оказались неавторизованными, то, вероятно, вы не заметите, когда подсистема авторизации выйдет из строя.

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

Ошибки управления версиями


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

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

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

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

Миф о необязательных компонентах


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

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

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

Одним из наиболее типичных проявлений такой незаметности зависимости является разница между локальным и глобальным кэшем. Локальный кэш, как следует из названия, — это кэш, который поддерживается локально в памяти приложения, часто в форме простой хеш-таблицы. Такие кэши легко реализуются. К сожалению, с ростом системы пропорционально растет и общий объем таких локальных кэшей, и в конечном счете требования системы к памяти становятся слишком большими. Существует также тенденция добавлять много таких кэшей в рамках одного приложения, потому что это так легко сделать, а вот визуализировать и контролировать использование памяти такими кэшами сложно.

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

Ой, мы все стерли


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

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

Рассмотрим пример с системой хранения фотографий. Представьте, что она состоит из четырех компонентов, таких как:

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

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

Теперь рассмотрим фрагмент кода в RESTful API, выполняющий поиск пользователя. Он инициирует соединение с базой данных, ищет информацию о пользователе и возвращает ее вызывающей стороне. Но что, если по какой-то причине база данных окажется недоступна? Предположим, что человек, писавший этот код, решил вернуть код 404 (Not found — «не найдено»), указывающий, что пользователь не найден. Возможно, это не лучший способ сообщить о неудаче — код 500 (Internal error — «внутренняя ошибка») определенно лучше, но пока вы не подумаете о системе сбора мусора, вам будет трудно понять, насколько важен этот код ошибки. Когда система сбора мусора будет запрашивать информацию о пользователях, то для нее все пользователи будут выглядеть отсутствующими и она удалит все фотографии. К счастью, инженеры предвидели катастрофические проблемы и регулярно создавали резервные копии, так что они восстановили фотографии из резервных копий; но сервис был недоступен в течение многих часов, пока производилось восстановление из резервных копий в «холодном» хранилище. В этот период доверие пользователей к надежности хранения чего-то столь глубоко личного и важного, как семейные фотографии, было поколеблено, и сервису пришлось немало потрудиться, чтобы восстановить доверие.

Так что же пошло не так? В этой системе есть вторичные базовые точки отказа, которые вызвали катастрофический сбой. Первая — человеческий фактор. Человеку сложно постоянно держать в голове всю информацию о распределенной системе. Особенно если разработкой пользовательского API занимается одна команда, а разработкой системы сбора мусора — другая, возможно находящаяся в другой точке мира. Возникает соблазн сосредоточиться на этой человеческой ошибке, потому что это разработчик допустил ошибку и «вызвал» сбой. К сожалению, человеческие ошибки неизбежны при создании распределенных систем. Их нельзя предотвратить, можно только ослабить их последствия. Люди будут совершать ошибки независимо от возраста и опыта, и наши системы должны предвидеть и уменьшать последствия этих ошибок.

Это рассуждение приводит нас ко второй точке отказа: к процессу или техническому сбою в системе, а именно к ограничению скорости и автоматическому отключению в логике сбора мусора. В системе при нормальной работе учетные записи удаляют всегда примерно одинаковое количество пользователей. Допустим, один процент всех пользователей удаляет свои учетные записи в определенный день; это приведет к относительно постоянной скорости удаления изображений. Когда произошла разбираемая нами ошибка, скорость удаления фотографий увеличилась в 100 раз. Это явно необычно. Система должна была заметить такую высокую скорость удаления и прервать работу сборщика мусора. Первым усовершенствованием системы было бы добавление настроек, регулирующих частоту выполнения операции удаления. С помощью таких настроек можно было бы установить верхнюю границу удалений в секунду, чтобы значительно замедлить скорость удаления всех фотографий и дать инженерам время среагировать и остановить процесс. Второе усовершенствование — «автоматический выключатель», очень похожий на автоматические выключатели в наших домах: когда высокая частота удалений сохраняется в течение длительного времени, система сбора мусора должна прекратить удаление, пока человек не сбросит выключатель. Конечно, эти технические усовершенствования не могли полностью предотвратить последствия человеческой ошибки, но они значительно уменьшили бы негативное влияние этих последствий на клиентов сервиса и на бренд самого сервиса.

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

Проблемы с широтой входных данных


Один из самых удручающих видов ошибок — ошибки, просочившиеся через все ваши тесты и остающиеся незамеченными, пока их не обнаружит клиент. Такие ошибки расстраивают клиентов. Они задают вопрос: «Разве вы не тестируете свой код?» — на который трудно что-то ответить в таких случаях. Эти ошибки смущают еще и тем, что зачастую долгое время остаются незамеченными в вашем коде, пока кто-то не столкнется с ними и не заявит об этом во всеуслышанье.

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

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

Проблемами компилятора они называются потому, что компилятор был одной из первых программ, в которых проявились такие проблемы. Пространство допустимых программ на любом языке программирования фактически бесконечно. При наличии достаточного количества программистов, работающих над достаточно большим количеством программ, рано или поздно один из них напишет программу, которая по всем правилам является допустимой, но из-за ошибки в компиляторе терпит сбой. Иногда такие входные данные являются продуктом случайности и человеческой природы, но чаще они создаются злонамеренными фаззинг-программами, которые посылают в API случайные данные, стараясь вызвать сбой или как-то иначе воспользоваться слабостью в API. Хорошо и печально известной версией таких атак были атаки с использованием SQL-инъекций, типичные для раннего Интернета, когда фрагменты SQL-кода (на- пример, '; DROP TABLES') передавались во входных данных там, где ни один разработчик не мог ожидать получить SQL-выражение (например, в поле ввода номера кредитной карты). К настоящему времени атаки с использованием SQL-инъекций остались в прошлом, но попытки взлома легитимных пользователей или атаки методом фаззинга по-прежнему довольно распространены.

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

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

Второе решение — использовать для тестирования рандомизированные данные. Такие тесты могут быть значительно более всеобъемлющими, но и создавать их гораздо сложнее. Для тестирования простого API, который принимает лишь несколько целых чисел, легко сгенерировать случайные числа в тестах, но для более сложных API с богатой семантикой может потребоваться написать собственную логику генерации случайных входных данных (например, случайных, но допустимых IP-адресов). Последние достижения в области LLM и генеративного ИИ позволяют генерировать допустимые, но случайные входные данные для еще более широкого круга классов данных.

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

Обработка устаревших заданий


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

В разделе «Грохочущее стадо» в начале этой главы вы узнали, как такие повторные попытки могут быстро вывести из строя всю систему. Проблема, обсуждаемая в этом разделе, имеет несколько иной характер — когда система пытается наверстать упущенное.

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

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

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

А можно ли что-то предпринять, чтобы наши системы лучше реагировали на такие задержки и увеличение задержек? Да, можно. Существует три подхода к решению этой проблемы, которые можно использовать по отдельности или вместе. Первый — добавление тайм-аутов, ограничение времени действия запросов. Каждый запрос, поступающий в систему, должен быть обработан в течение некоторого времени, по истечении которого его следует объявить недействительным и невыполненным. Когда время обслуживания запроса истекает, его обработка прерывается. Тайм-ауты могут помочь системе отбросить устаревшие задания при значительной перегрузке. Однако важно отметить, что сами по себе тайм-ауты не решают описанную выше проблему. Если задержка обработки каждого запроса высока сама по себе, пусть и немного ниже значения тайм-аута, ваша система будет казаться вялой, но она будет отбрасывать задания, чтобы вернуться в нормальное состояние.

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

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

Проектирование надежных распределенных систем сопряжено не только с предотвращением сбоев, но и с быстрым восстановлением работоспособности после сбоев, когда те все же происходят. Пользователи гораздо терпимее относятся к коротким сбоям, когда система быстро восстанавливается, чем к длительным, когда восстановление после «исправления» проблемы занимает много времени.

Проблема второй системы


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

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

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

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

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

Что можно сделать, учитывая, что в вашей распределенной системе наверняка имеются конструктивные проблемы или функции, которые нужно написать или исправить, а реализация второй системы часто обречена на провал? Решение кроется в природе самой распределенной системы. Вместо замены всей распределенной системы сосредоточьтесь на совершенствовании отдельных микросервисов. Это поможет продолжить движение вперед и избежать затрат на поддержку двух систем сразу. Аналогично имеет смысл заняться абстракциями, отделяющими основную бизнес-логику от компонентов, которые может потребоваться заменить с развитием системы, таких как слой хранения. При таком разделении вы сможете развивать служебные компоненты системы, такие как хранилище, независимо от бизнес-логики и даже использовать два разных слоя хранения для оценки и тестирования.

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

Посмотрите книгу «Распределенные системы. Паттерны и парадигмы для масштабируемых и надежных систем на основе Kubernetes. 2-е изд.» на нашем сайте.

» Оглавление
» Отрывок

По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Для Хаброжителей скидка 25 % по купону — Системы

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