Вам не нужна микросервисная архитектура

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

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

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

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

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

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

Альтернативой фрагментированному сервисному подходу будет выступать традиционное монолитное приложение, которое представляет из себя единый проект, в котором могут быть выделены компоненты какой-то степени абстракции и изолированности, но тем не менее, сведенные в одну кодовую базу, которая разворачивается и эксплуатируется как единое целое. Я не буду в каждом разделе говорить “а вот в монолите это было бы так”, потому что все и так примерно представляют себе, как это решается в обычном приложении.

Если сократить все нижесказанное до одной фразы, то все сводится к тому, что

Сервисы — это дорого

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

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

Рассмотрим некоторые проблемы, с которыми вы столкнетесь, если прибегнете к разработке в рамках микросервисной архитектуры.

Сеть — часть 1

Ваш код работает?
Простите, но это ничего не значит.

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

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

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

Вашу жизнь могут (и будут, я гарантирую это) портить:

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

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

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

Сеть — часть 2

Каждый сетевой вызов — это маленькая дверь в большой ад

Один и тот же код, вызываемый в рамках одного приложения, и разделенный удаленным вызовом — это совсем разный код.

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

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

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

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

А мы всего-то вызвали удаленную функцию.

Тестирование

ОНАБ (Обстановка Нормальная, Абсолютный Бардак) — все тесты проходят, ничего не работает

Допустим, вы решили делать все на совесть, и снабдить ваши сервисы тестами.

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

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

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

Представим такую ситуацию:

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

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

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

И вы (сервис А) — ломаетесь. Все ваши тесты проходят, все тесты сервисов Б и В проходят. Даже попарные интеграционные — проходят. Но все вместе — не работает. При этом версии сервисов А и Б, в момент взаимодействия которых происходит проблема — не менялись.

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

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

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

Ой, кажется у нас получилось какое-то одно, но очень большое приложение…

Деплоймент

Почувствуй себя пакетным менеджером

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

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

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

Несколько версий вашего сервиса предъявляют взаимоисключающие требования к структуре БД, на которую они опираются? Ой, у вас еще одна проблема.

Мониторинг, сбор и анализ логов

Ошибка: Возникла ошибка при записи ошибки в лог

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

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

Из данных, вам потребуется, как минимум:

  • Параметры самих машин (нагрузка на основные ресурсы — память, процессор, сеть, диск, далее везде)
  • Логи прикладного кода
  • Сбор прикладной статистики по работе сервиса
  • Результаты healthcheck’ов ваших сервисов (ой, вы не писали healthcheck’и? поздравляю, у вас проблема!)

В отличие от монолитного приложения, которое обычно тяготеет к двум состояниям — работает/не работает, в сервисном зоопарке 99% всего может работать корректно, но будет 1%, который застопорит всю работу.

API, эксплуатация

Без бумажки ты — какашка, лишь с бумажкой ты — API

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

Вам потребуется:

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

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

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

API, проектирование

Все люди не только намеренно лгут, но еще и чистосердечно ошибаются

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

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

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

Почему? — Вовсе не потому, что они идиоты — дело в том, что это объективно сложная задача, при решении которой нужно найти баланс между:

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

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

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

Обнаружение сервисов и конфигурация

Я спросил у ясеня: где моя любимая? 
Ясень мне ответил: Я ЕСТЬ ГРУТ!

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

Либо вы будет конфигурировать это на уровне каждого отдельного сервиса, и тогда рискнете получить размазанную конфигурацию, которую невозможно окинуть единым взглядом, либо вы будете использовать какой-то service locator, который будет брать эти задачи на себя.

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

Синхронизация данных

Данные в разных источниках могут «рассонхринизориваться» (и вообще быть неполн

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

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

Уроки истории

Все это было в Симпсонах

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

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

Почему так происходит?

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

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

Что делать

Если вовремя не дать ответа на вопрос “Что делать?”, ты быстро станешь ответом на вопрос “Кто виноват?”

Если вы маленькая команда, и только начинаете, то мой вам совет — пишите обычные приложения — вам нужно бизнес развивать / удовлетворять, а не упражняться в элегантных архитектурах — если выживете, появится возможность какие-то фрагменты переписать “как надо”, а если вы вместо полезных фич будете всю дорогу бороться с инфраструктурными проблемами, то такого шанса может и не представиться.

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

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

Дайте вашим сервисам вызреть, сформироваться, вырасти внутри монолитного приложения, и только потом начинайте выделять их в самостоятельные сервисы
Если вы делаете это раньше, то скорее всего, вы просто не понимаете, что вы делаете, и чем это вам грозит

Если же в один прекрасный день вы очнулись, и обнаружили себя посреди сервисного проекта, в котором интерфейсы — постоянно плывут, что происходит в системе — непонятно, мониторинга нет, данных для post mortem анализа инцидентов — нет, все вокруг горит, то… вам остается либо:

  • выклянчивать таймаут у бизнеса и налаживать технологии управления всем этим зоопарком, либо
  • пытаться кластеризовать функционал в более крупных сервисах (возможно, что-то, что выделено сейчас как отдельный сервис, можно превратить в библиотеку и включить в клиента — потребителя)

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

Резюме

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

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

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

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

Хороший разбор сильных и слабых сторон микросервисного подхода есть у Мартина Фаулера.

Хотите продолжить разговор — пишите — @dolphin278.