Прим. перев.: автор этой статьи — engineering manager из Испании, работающий в цифровой торговой площадке Adevinta, представленной в 16 странах, — делится своими наблюдениями о частых проблемах, которые он встречал у разработчиков микросервисов. Об этих вызовах стоит знать заранее, чтобы не столкнуться с ними тогда, когда их решение может оказаться слишком затратным.
Когда пост Мартина Фаулера о микросервисах вышел в 2014 году, команды, в которых я работал, уже занимались SOA-приложениями. Эта статья и последующий хайп коснулись почти каждой команды разработчиков в мире. Стек Open Source-софта от Netflix был самым крутым в то время, поскольку позволял инженерам по всему миру перенимать опыт Netflix в распределенных системах. Если мы взглянем на работу разработчиков программного обеспечения сегодня, более шести лет спустя, большая её часть касается архитектуры микросервисов.
Разработка на волне хайпа
В начале 2010-х годов многие организации столкнулись с проблемами, связанными с циклом разработки ПО. Люди, работавшие вместе с другими 50, 100 или 200 специалистами, испытывали трудности с окружениями для разработки, тяжелыми процессами QA и программируемым развёртыванием. Хотя книга Мартина Фаулера «Непрерывная доставка» (Continuous Delivery) и пролила свет на многие спорные аспекты организации таких команд, они начали понимать, что их же собственные величественные программные монолиты и создают им многочисленные организационные проблемы. Таким образом, микросервисы оказались весьма привлекательными для инженеров-программистов. Гораздо легче сразу начинать разработку на принципах непрерывной интеграции и развертывания (CI/CD), чем внедрять их в уже большой проект.
И команды начали разворачивать по три, десять, сто микросервисов. Большинство из них использовали JSON over HTTP — кто-то мог бы сказать, что RESTful — API для удаленных вызовов между компонентами. Люди были хорошо знакомы с протоколом НТТР, и это казалось относительно простым способом разделить программный монолит на более мелкие составляющие. С этого момента любая команда уже могла начать деплоить код в production менее чем за 15 минут. Больше никаких «Блииин, команда А сломала CI-пайплайн, и я не могу задеплоить свой код» — и это было круто!
Однако большинство инженеров забыли, что, решая организационную проблему на уровне архитектуры ПО, они также привнесли и большую запутанность. Обманчивость распределенных систем становилась все более очевидной и быстро превратилась в головную боль для таких команд. Даже у компаний, которые уже использовали архитектуру клиент-сервер и зарекомендовали себя в этом, все планы разлетались в пух и прах, как только в их системах было задействовано по 10+ подвижных частей.
Реальность наносит ответный удар
Значительные изменения в архитектуре не проходят бесследно. Специалисты начали понимать, что совместное использование базы данных стало единой точкой отказа. Затем они осознали, что разделение функций приложения на сервисы создало целый новый мир, в котором конечная согласованность получила значимую роль. Как насчет того, что сервис, с которого вы получаете данные, не работает?
Количество вопросов и проблем начало накапливаться. Обещания о быстром темпе разработки обернулись поиском багов, непредвиденными ситуациями, проблемами согласованности данных и т.д. Другая проблема заключалась в том, что программистам требовались централизованные логи и средства мониторинга, которые охватили бы десятки сервисов, позволив обнаруживать и исправлять эти дефекты.
Проблема #1: слишком маленькие сервисы
Возможность плодить новые сервисы каждый день позволила раскрыть креативность разработчиков в полной мере. Новая фича? Бам!.. давай-ка запустим новый сервис! И внезапно команды из 20 программистов начали поддерживать по полсотни сервисов. Это больше, чем один сервис на человека!
Проблема с кодом, как правило, в том, что он устаревает. Поддержка каждого сервиса требует вложений. Представьте себе внедрение обновления библиотеки во всём вашем «ассортименте» сервисов. Представьте себе, что эти сервисы были начаты в разные отрезки времени, с разными архитектурами и с некоторым хитросплетением между бизнес-логикой и используемыми фреймворками. Это же с ума сойти! Конечно, есть способы решения этих проблем. Большинство из них не были доступны в те дни, а другие стоили многих дней работы инженеров.
Еще об одном странном явлении я узнал, когда услышал от кого-то, что развертывание новой фичи в сервисе А также нуждается в деплое — в то же время — в сервисе B. Или когда люди начали писать сервисы для генерации CSV. Зачем кому-то вводить дополнительные сетевые хопы для создания всемирно известного формата файлов? Кто будет это поддерживать?
Некоторые команды, похоже, страдали сервиситом (от слова «цервицит» — прим. пер.). Что еще хуже, это вызывало много разногласий во время разработки. Нельзя было просто так заглянуть в проект в их IDE и понять что-то. Нужно было открыть сразу несколько проектов одновременно, чтобы хоть как-то разобраться во всей этой каше.
Проблема #2: окружения для разработки
Я уже сбился со счёта, сколько раз ко мне подходили и говорили:
Эй, João. Есть минутка? Нам нужно пофиксить наши окружения для разработки! Люди постоянно на них жалуются, и ничего не работает!
Эта проблема затрагивала различные аспекты. Мобильные разработчики не могли разрабатывать фичу до её появления в окружении для разработки. Или у бэкенд-разработчиков не получалось опробовать свой сервис, не нарушив никаких бизнес-процессов. Также было проблематично, если кто-то хотел протестировать весь рабочий цикл в мобильном приложении перед production.
Существует несколько проблем, связанных с окружением в пределах распределенных систем, в частности:
Сколько стоит запустить 200 сервисов у облачного провайдера? Справитесь с этим? Сможете ли также развернуть инфраструктуру, необходимую для их работы?
Каких временных затрат всё это стоит? Что будет, если на момент начала разработки мобильным программистом новой фичи есть набор сервисов в одной версии, а когда он завершает работу над ней, уже есть десяток других (новых) версий, развернутых в production?
Что насчет тестовых данных? У вас есть тестовые данные для всех сервисов? Согласованы ли они все между собой — так, чтобы пользователи и другие объекты совпадали?
Если вы разрабатываете мультитенантное приложение для нескольких регионов, как насчет флагов конфигурации и функций? Как поддерживать согласованность с production? Что, если значения по умолчанию изменятся?
И это лишь верхушка айсберга. Кому-то покажется, что проблема решается человеко-часами. Возможно, это даже сработает. Но я бы поспорил с тем, что большинство организаций имеют достаточно ресурсов, чтобы решать проблему таким образом. Корректно справляться с подобными задачами невероятно дорого и сложно.
Проблема #З: end-to-end-тестирование
Как вы можете себе представить, end-to-end-тесты имеют те же проблемы, что и окружение для разработки. До этого было относительно легко создать новое окружение с помощью виртуальных машин или контейнеров. Также было довольно просто создать набор тестов с использованием Selenium, чтобы прогнать их через бизнес-процессы и убедиться, что они работают, до развертывания новой версии.
С микросервисами, даже если мы сможем решить все вышеуказанные проблемы настройкой окружения, уже точно не скажешь, что система работает. В лучшем случае мы можем зафиксировать, что система с конкретными версиями запущенных сервисов и данной конфигурацией работает в конкретный момент времени. Это огромная разница!
Было крайне трудно убедить людей, что у нас не может быть больше двух таких тестов. И что недостаточно запустить их в потоке непрерывной интеграции. Они должны работать непрерывно. И они должны запускаться перед production и выдавать соответствующие предупреждения. Я много раз делился статьей Cindy Sridharan «Тестирование в production, безопасный способ» (Testing in production, the safe way), чтобы люди поняли мою точку зрения.
Проблема #4: огромные общие базы данных
Простой способ избавиться от монолитов, сохранив непротиворечивость данных между ними, — продолжать использовать общую базу данных. Такой подход не увеличит рабочую нагрузку и позволит нарезать монолит на части шаг за шагом. Вместе с тем у общей базы данных есть и значительные недостатки.
Помимо того, что это очевидная единая точка отказа, разрушающая некоторые из принципов SOA, есть и другие минусы. Вы создаете пользователя для каждого сервиса? Имеется ли у вас такое тонкое разграничение доступа, чтобы сервис А мог читать или записывать только с конкретных таблиц? Что, если кто-то непреднамеренно удалит индекс? Откуда мы знаем, сколько сервисов используют разные таблицы? Что насчёт масштабирования?
Распутывание всего этого само по себе становится совершенно новой проблемой. И процесс нетривиален в техническом смысле, учитывая, что базы данных имеют тенденцию переживать программное обеспечение. Решение проблемы с помощью репликации данных — будь то Kafka, AWS DMS или что-либо еще — приведет к необходимости для ваших команд разработчиков понимать специфику базы данных и как поступать с дублирующимися событиями, и так далее.
Проблема #5: API gateways
API gateways — типичный паттерн в сервис-ориентированной архитектуре. Они помогают отделить бэкенд- от фронтенд-консьюмера. Они также полезны, когда дело доходит до реализации агрегирования endpoint’ов, ограничения скорости или аутентификации в вашей системе. В последнее время индустрия склоняется к архитектуре backend-for-frontend, где эти шлюзы разворачиваются для каждого единственного фронтенд-консьюмера — iOS, Android, web или десктопных приложений, — что позволяет развивать их независимо друг от друга.
Как это часто бывает, у такого подхода со временем появляются новые, нестандартные варианты использования. Иногда это небольшой хак, чтобы сделать мобильное приложение обратно совместимым. И вот внезапно ваш «API-шлюз» становится единой точкой отказа — потому что людям проще обработать аутентификацию в одном месте — и с некоторой неожиданной бизнес-логикой внутри него. Вместо монолита, забирающего весь трафик, теперь у вас самодельный Spring Boot-сервис, получающий весь трафик за него!
Что может пойти не так? Инженеры быстро осознают, что это ошибка, но, поскольку сделано множество кастомизаций, иногда у них не получается воспользоваться хорошей (stateless, легко масштабируемой) заменой.
Причина бед API-шлюзов появляется тогда, когда они задействуют endpoint’ы, которые не разбиты на страницы или возвращают массивные ответы. Или когда вы создаете агрегацию без fallback-механизмов, а один единственный вызов API уничтожает ваш шлюз.
Проблема #6: время ожидания, повторные попытки и устойчивость
Распределенные системы постоянно находятся в режиме частичного отказа. Что происходит, когда сервис А не может связаться с сервисом В? Мы можем повторить запрос, верно? Но это быстро приводит к неожиданным последствиям.
Я видел, как некоторые команды использовали circuit breaker’ы, а затем увеличивали время ожидания НТТР-запроса к downstream’у сервиса. Хотя это и может быть нормальным способом выиграть время для исправления проблемы, но также создает эффекты второго порядка. Теперь все запросы, которые отменял circuit breaker, потому что они слишком долгие, «висят» больше времени. В случае увеличения трафика, все больше и больше запросов будет поставлено в очередь, что приведет к более плохой ситуации, чем та, которую вы хотели исправить.
Я видел, что инженеры изо всех сил пытаются понять теорию очередей и причины для выставления таймаутов. То же самое происходит, когда команды начинают обсуждать пулы потоков для своих НТТР-клиентов и тому подобное. Их настройка — это само по себе искусство, но установка значений, основанных на ощущениях пятой точки, может привести к сбою в работе.
Хитрость в том, что по завершении восстановления после сбоя сложно понять, что не все из этих сбоев одинаковы. Мы можем ожидать, что потребитель в некоторых случаях будет идемпотентным. Но это означает, что нужно проактивно решать, что делать в каждом из возможных сценариев. Идемпотентен ли потребитель? Можно повторить запрос? Я видел, как многие инженеры игнорировали сбои, потому что это «крайний случай», впоследствии осознавая, что у них масштабная проблема целостности данных.
Повторные попытки еще хитрее, даже если вы настроили fallback-механизмы. Представьте, что у вас есть 5 миллионов пользователей в мобильном приложении и что шина сообщений, которая обновляет настройки пользователей, перестала работать на некоторое время. Вы установили fallback-механизм для этого случая, а он в свою очередь обращается к сервису с настройками пользователей через НТТР API. Думаю, понятно, к чему я клоню…
А теперь этот сервис внезапно получил огромный всплеск трафика и, вероятно, не сможет справиться с ним. Что ещё хуже, сервис может получать все эти новые запросы, но если механизм повторных попыток не реализует экспоненциальное откладывание и джиттер, вы можете столкнуться с распределенным отказом в обслуживании (DDoS) из-за мобильных приложений.
Зная обо всех этих ужасах, вы все еще любите распределенные системы?
А что, если я скажу, что написал только о малой части всех бед, с которыми столкнулся?.. Распределенные системы трудно понять, и в последнее время большинству разработчиков регулярно приходится иметь с ними дело.
Хорошо, что на многие из этих явлений есть хорошие решения и индустрия создала неплохие инструменты, чтобы их преодоление было по силам не только FAANG.
Мне все еще нравятся распределенные системы, и я все еще думаю, что микросервисы — это хорошее решение организационных проблем. Однако проблемы начинаются, когда неудачи воспринимаются как «крайний случай» или как вещи, которые, как мы думаем, с нами никогда не произойдут. Те самые крайние случаи становятся новой нормой на определенном уровне, и мы должны справляться с этим.
P.S. от переводчика
Читайте также в нашем блоге: