Анализ и проектирование систем
Микросервисы
Проектирование и рефакторинг
11 июля

Прощайте, микросервисы: от ста проблемных детей до одной суперзвезды

Перевод
Оригинал:
Alexandra Noonan
Если вы не живете в пещере, вы, возможно, знаете, что микросервисы – это архитектура сегодняшнего дня. С развитием этого тренда, в продукте Segment на раннем этапе приняли его, как лучшую практику, которая служила хорошо в одних случаях, и, как вы скоро увидите, не так хорошо в других.

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

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

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

Почему микросервисы работают работали


Инфраструктура данных клиентов Segment принимает сотни тысяч событий в секунду и перенаправляет их партнерским API, что мы называем направлениями на стороне сервера (server-side destinations). Существует более ста видов этих направлений, таких как Google Analytics, Optimizely или пользовательские веб-хуки.

Годы назад, когда продукт изначально был запущен, архитектура была простой. Был API, который принимал события и отправлял их в очередь распределенных сообщений. Событием в этом случае был JSON-объект сгенерированный веб- или мобильным приложением, содержащий информацию о пользователях и их действиях. Пример полезной нагрузки выглядел следующим образом:

{
  "type": "identify",
  "traits": {
    "name": "Alex Noonan",
    "email": "anoonan@segment.com",
    "company": "Segment",
    "title": "Software Engineer"
  },
  "userId": "97980cfea0067"
}

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

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



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

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



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

image

Случай индивидуальных репозиториев


Каждый API направлений использует разный формат запросов, требуя дополнительный код для перевода события в соответствии с этим форматом. Простой пример – назначение X требует отправки даты рождения как traits.dob, в то время как наш API принимает traits.birthday. Код преобразования для назначения X будет выглядеть примерно так:

const traits = {}
traits.dob = segmentEvent.birthday

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

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

Разделение на отдельные репозитории позволило нам легко изолировать тесты направлений. Эта изоляция позволила команде разработчиков быстро переключаться во время поддержки.

Масштабирование микросервисов и репозиториев


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

Например, если нам необходимо имя пользователя из события, event.name() может быть вызвано из кода любого направления. Общая библиотека проверяет событие на наличие свойств name и Name. Если они не существуют, она проверяет свойства firstName, first_name, и FirstName. Тоже самое делает для фамилии, проверяя случаи и комбинируя их чтобы сформировать полное имя.

Identify.prototype.name = function() {
  var name = this.proxy('traits.name');
  if (typeof name === 'string') {
    return trim(name)
  }
  
  var firstName = this.firstName();
  var lastName = this.lastName();
  if (firstName && lastName) {
    return trim(firstName + ' ' + lastName)
  }
}

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

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

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

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

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

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

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


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

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

image

Перемещение в монорепозиторий


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

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

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

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

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

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

Создание устойчивых наборов тестов


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

Для решения этих проблем мы создали Traffic Recorder. Traffic Recorder собран на основе yakbak, и отвечает за запись и сохранение тестового трафика получателей. Всякий раз, когда тест выполняется в первый раз, любые запросы и их соответствующие ответы записываются в файл. При последующих тестовых запусках запрос и ответ воспроизводятся из файла вместо выполнения запроса по назначению. Эти файлы добавляются в репозиторий, чтобы тесты были согласованны при каждом изменении. Теперь, когда тестовый набор больше не зависит от HTTP запросов через Интернет, наши тесты стали существенно устойчивей и обязательными для миграции в единый репозиторий.

Я помню, как проходили тесты для каждого пункта назначения в первый раз после того, как мы интегрировали Traffic Recorder. Потребовались миллисекунды, чтобы завершить тесты для всех 140+ наших направлений. Раньше всего один пункт мог занять пару минут. Это было похоже на магию.

Почему монолит работает


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

Доказательством стало увеличение скорости. В 2016, когда наша микросервисная архитектура еще существовала, мы сделали 32 улучшения общих библиотек. Только в этом году мы сделали 46. Мы сделали больше улучшений библиотек за последние 6 месяцев, чем за весь 2016 год.

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

Компромиссы


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

  1. Отказоустойчивость затруднена. Когда все работает в монолите, если появляется баг в одном направлении, который приводит к падению сервиса, сервис упадет для всех направлений. У нас есть всестороннее автоматическое тестирование, но тесты все равно могут подвести. В настоящее время мы работаем над гораздо более надежным способом не допустить того, чтобы один пункт назначения ломал весь сервис, сохраняя работоспособность остальных направлений в монолите.
  2. Кэширование в памяти менее эффективно. Раньше, с одним сервисом на направление у маршрутов с низким трафиком было всего несколько процессов, что означало, что их кэш в памяти оставался горячим. Теперь кэш тонко распределяется между 3000+ процессов, поэтому шансов попасть в него гораздо меньше. Мы могли бы использовать что-то вроде Redis чтобы решить эту проблему, но это добавило бы еще одну точку масштабирования. В конце концов, мы приняли эту потерю эффективности с учетом существенных эксплуатационных преимуществ.

Заключение


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

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

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

При выборе между микросервисами или монолитом следует учитывать различные факторы. В некоторых частях нашей инфраструктуры микросервисы работают хорошо, но наши серверные направления являются прекрасным примером того, как популярная тенденция может навредить продуктивности и производительности. Получается решением для нас стал монолит.
+50
28,8k 122
Комментарии 172