Часть I. Микросервисы
В любой сфере деятельности есть знаковые фигуры. Признанные эксперты. Лидеры мнений. Программирование не является исключением. Всем нам знакомы имена Кернигана, Кнута, Торвальдса, Скита. Не последним в этом ряду будет и имя Мартина Фаулера.
Он написал книгу Рефакторинг, которую обязан прочитать любой профессиональный программист. Он предложил термин Dependency Injection. Он участвовал в подготовке каталога действительно полезных паттернов проектирования. Он был одним из авторов Манифеста Гибкой Разработки Программ.
В 2014-м вместе с Джеймсом Льюисом Фаулер написал статью о микросервисах, которая начинается словакми: «Термин „микросервисная архитектура‟ уже несколько лет применяется, чтобы описать способ проектирования программ»… Очевидно, теме микросервисов без малого десять лет. Можно ли добавить что-нибудь к тому, что уже было сказано и написано за это время?
Оказывается, можно.
Термин «микросервис» появился в 2005-м. Он не выстрелил тогда, потому что ему не хватало нескольких важных деталей, последнюю из которых изобрели только в 2013-м. Мы поговорим об этом в статье.
Практический возраст микросервисов — семь или восемь лет. Я занимаюсь ими пять. Мне довелось как принимать решения, так и разгребать их последствия, что, конечно, самая значимая часть опыта.
Моё намерение — этим опытом поделиться.
План статьи должен был оказаться простым. Сначала определение, затем обзор средств C#, в конце заключение. Реальность внесла свои коррективы и трудности возникли уже с определением. Собирая в сети разные формулировки, я обнаружил, что они фундаментально друг с другом не совпадают. «Фундаментально» в буквальном смысле слова, как будто у авторов разные фундаменты в картинах мира.
Но нельзя написать статью о микросервисах, не сказав, что это такое. Чтобы справиться с этой сложностью, я решил «строить» понятие микросервиса по частям.
Микросервисы — это…
Начну с общих мест, с которым согласно большинство авторов.
Не монолит
В первую очередь микросервисы противопоставляют монолиту. Многие считают, что монолит — это такая архитектура, но я бы с этим не согласился. Вряд ли существуют принципы, которые предписывают разрабатывать что-то похожее на монолиты. Скорее, мы можем говорить о признаках, которые соответствуют монолитной системе. В такую систему трудно вносить изменения, даже небольшие. Трудности возникают потому, что разные части системы сильно зависят друг от друга.
Программисты называют такие части сильно сцепленными. Понятие сцепленность (coupling) в наше время обычно относят к классам и к объектно-ориентированному проектированию. Но Ларри Константин, который ввёл его в статье 1974 года, говорил не о классах, а модулях.
Классы и модули — это разные способы разбиения большой системы на части. Мы можем сказать, что микросервисы — такие же строительные кирпичики. Пока просто констатируем, что они крупнее классов и модулей.
Высокая сцепленность замедляет работу разработчиков, даже если речь идёт об одной команде на проекте. Ситуация усложняется, когда мы говорим о нескольких командах, каждая из которых владеет своей частью системы. Чтобы реализовать одну, даже небольшую функцию, вам приходится ждать правок от других команд.
Микросервисы призваны решить эту проблему. В идеале они должны быть совершенно независимы, но можем ли мы достичь идеала?
Нас должен насторожить тот факт, что с 1974-го индустрия продолжает разрабатывать сильно сцепленные программы. Кажется, что разбить большой проект на независимые модули — очень непростая задача.
Unix Way
Идея «собирать» программу из кирпичиков не нова. Ею руководствовались создатели UNIX. Набор принципов, которые применялись при разработке этой системы известен как Unix Way или Unix Philosophy. Самый первый принцип гласит: make each program do one thing well, то есть пусть каждая программа делает одну работу и делает её хорошо.
Командная строка UNIX позволяет связать несколько программ в цепочку. Саму эту цепочку можно считать новой программой, которая решает нетривиальную задачу.
Утилита ps
печатает список запущенных процессов. Первая строка её вывода — поясняющий заголовок. Утилита tail
умеет пропускать строки в начале текстового файла, а утилита wc
— считать количество строк, слов или букв в тексте.
Чтобы узнать количество запущенных процессов, можно запустить составную команду
ps aux | tail -n +2 | wc -l
Можно считать, что в высоком философском смысле микросервисы следуют духу UNIX, но на практике, между типичной утилитой и микросервисом есть существенные отличия.
RPC
У классического решения Unix есть несколько ограничений. Первое касается масштабируемости. Все программы, которые мы используем, работают на одном и том же компьютере. Чтобы справляться с большой нагрузкой, мы должны увеличивать производительность компьютера, но она не может расти бесконечно. Самые производительные серверы всего в несколько раз быстрее офисных лошадок.
Повышение мощности компьютера, где работает программа, называется вертикальным масштабированием.
Запуская разные программы на разных компьютерах, мы можем повысить производительность гораздо больше, потому что этих компьютеров может быть очень много. В современном мире никого не удивляют тысячи и десятки тысяч серверов в одном дата-центре.
Масштабирование за счёт увеличения количества машин называется горизонтальным.
Горизонтальное масштабирование, в отличие от вертикального, позволяет увеличивать производительность системы в тысячи раз.
Конечно, запускать обычные программы на разных компьютерах, передавая между ними большие объёмы данных, не очень эффективно.
Нам нужно что-то более тонкое, чтобы мы могли передать небольшую команду и получить небольшой ответ. В индустрии существует несколько стандартов для отправки команд на другой компьютер. Их объединяет общее название RPC — Remote Procedure Call, удалённый вызов процедур.
Классические RPC-системы живут в процедурном мире. Мы знаем адрес компьютера, имя процедуры, типы параметров и тип результата. Для удалённого вызова процедуры нам достаточно упаковать имя и параметры в строку и отправить её на другой компьютер.
Такие системы появились в 80-е годы и уже тогда обеспечивали и низкую сцепленность и высокую горизонтальную масштабируемость. Им не суждена была долгая жизнь, потому что одновременно с ними на сцене появились объекты.
ООП
Самый первый объектный язык назывался Simula-67 и разработан он был — сюрприз — в 1967-м году. Через пять лет Алан Кей анонсировал SmallTalk, а в 1985-м состоялся первый коммерческий выпуск C++.
На платформу PC объектно-ориентированное программирование пришло в конце 80-х, когда Borland выпустила Turbo Pascal «с классами», а Datalight — компилятор Zortech C++.
В те времена объекты казались панацеей. Сейчас, тридцать лет спустя, мы относимся к ним гораздо скептичнее, однако индустрия не отказалась от объектного подхода. Большинство популярных языков программирования поддерживают эту парадигму.
Нет ничего удивительного в том, что в 90-х удалённый вызов процедур должен был стать объектным. Поскольку у объектов мы вызываем методы, а не процедуры, концепция получила новое имя — Remote Method Invocation, удалённый вызов методов.
В 1991-м году появился стандарт CORBA, который описывал, как компоненты могут посылать друг другу запросы. Поначалу стандарт был адаптирован к C++ и C, что может показаться удивительным, ведь C — не объектно-ориентированный язык. Однако, по зрелому размышлению, мы понимаем, что за объектным интерфейсом может скрываться реализация, написанная на любом языке.
В 1996-м Sun реализовала поддержку CORBA для Java. В том же году Microsoft выпустила распределённую версию COM — DCOM (Distributed Component Object Model). Молодым программистам аббревиатуры DCOM и CORBA ничего не скажут, но разработчики постарше помнят, какая борьба развернулась между этими подходами в конце прошлого века.
По современным меркам, CORBA — сложный стандарт. Вы не можете использовать его с любым языком программирования, если для него не существует инструментов и библиотек. Разработать такие инструменты непросто. Обычную реализацию CORBA делали большие коллективы, а результат продавали за деньги.
DCOM, в свою очередь, несколько проще, но завязан на Windows.
Помимо частных недостатков, оба подхода подвержены проблеме «дырявой абстракции». И CORBA, и DCOM считают удалённые объекты во всём похожими на локальные, но доступ к ним осуществляется по сети, а это не всегда стабильно и не всегда быстро.
Есть ещё одна проблема. В большой корпоративной локалке непросто прорваться из одного сегмента сети в другой. Вам потребуется тонкая натройка файрволов, чтобы компьютеры могли вызывать процедуры удалённо.
HTTP
Спасением от этих сложностей стала возникшая тогда же всемирная паутина. Первый коммерческий браузер Netscape Navigator умел показывать HTML-страницы, отображать графику и скачивать файлы. Уже тогда было понятно, что люди захотят передавать и другую информацию, например, структурированные документы.
Для описания структурированных документов в то время применяли SGML, мощный, но сложный язык разметки. Из-за сложности языка разрабатывать парсеры SGML тоже оказалось непросто. Участники консорциума W3C решили упростить SGML, чтобы разработка парсеров стала обычной задачей. Так на свет появился XML.
Одновременно с этим программисты осознали, что отправка запроса HTTP сродни отправке команды, то есть удалённому вызову.
Все эти озарения привели к созданию SOAP — Simple Object Access Protocol, простому протоколу доступа к объектам.
В действительности SOAP не такой уж и простой. В лучших традициях программистского универсализма, он позволяет работать поверх разных протоколов, хотя в подавляющем большинстве случаев нужен только HTTP.
Структура самих запросов также универсальна, так что написать такой запрос вручную — нетривиальная задача.
В качестве примера из документации Яндекс.Директ, посмотрите, как выглядит вызов метода с одним параметром.
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:ns0="API"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/">
<SOAP-ENV:Header>
<locale>ru</locale>
<token>0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f</token>
</SOAP-ENV:Header>
<SOAP-ENV:Body>
<ns0:GetClientInfo SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<params soapenc:arrayType="xsd:string[]">
<xsd:string>agrom</xsd:string>
<Login>agrom</Login>
</params>
</ns0:GetClientInfo>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
Конечно, в развитых IDE, таких как Visual Studio, подобный код генерируется с помощью библиотеки. Но, когда десять лет назад я разрабатывал Android-приложение, мне пришлось полагаться на ручной разбор XML, потому что для мобильного приложения такие библиотеки — непозволительная роскошь. По крайней мере, так было тогда. Тонким моментом в SOAP оказалось состояние. Мы знаем, что «на той стороне» у нас объект, а у объектов есть данные, которые мы меняем, вызывая методы. Это правильно в теории, но на практике такой сервис нельзя масштабировать горизонтально. Два последовательных запроса от клиента могут попасть на два соседних сервера, у каждого из которых своя оперативная память и своё состояние объекта.
Индустрия учла проблемы, которые возникают при работе через SOAP, так что сейчас у нас не принято расшифровывать букву O как Object. SOAP превратилось в термин, похожий на аббревиатуру SOA — Service-Oriented Architecture.
Речь об объектах и их состоянии больше не идёт. Сейчас мы говорим о сервисах, которые обрабатывает наши запросы, не храня никакого состояния.
REST
Как мы помним, SOAP нельзя назвать простым протоколом, разве что по сравнению с CORBA. К счастью, в индустрии, наряду с тенденцией делать всё как можно универсальнее, существует и другая тенденция. Опытные проектировщики называют её смешной аббревиатурой KISS (Keep It Simple, Studip! — Будь проще, тупица!).
В соответствии с принципом KISS, программисту следует предпочитать простые решения сложным. Например, поддерживать не все существущие протоколы обмена данными, а только самый популярный — HTTP. Возвращать результат не в громоздком XML, а в простом и компактном JSON.
Рой Филдинг описал принципы REST в диссертации 2000-го года. Массово этот подход стали применять приблизительно с 2005-го. В отличие от CORBA и SOAP, REST очень демократичен. Он позволяет разрабатывать приложения без дополнительных инструментов, практически на любом языке программирования. Достаточно, чтобы в библиотеке были средства работы с сокетами и JSON. Даже JSON, при необходимости, можно собирать и разбирать вручную.
Однако, у подхода есть и недостатки. Первый и главный заключается в том, что формально REST не является стандартом — это набор принципов. Академический подход в отношении REST API отличается от прагматичного, поэтому в интернете вы найдёте десятки статей на тему «ваш REST на самом деле не REST».
Второй недостаток сугубо практический. В HTTP и, следовательно, в REST мы предоставляем доступ к ресурсам. Набор методов у нас ограничен: GET
, PUT
, PATCH
, DELETE
и POST
.
В то же время наши программы написаны на объектно-ориентированных языках программирования, и оперируют они не ресурсами, а классами. Набор методов у классов не ограничен.
Эти подходы как два естественных языка, не сильно похожих друг на друга. Идеи, которые хорошо формулируются на одном, на другом просто невозможны. Нам, как переводчикам, приходится пересказывать их своими словами, с той или иной степенью достоверности.
Третья проблема REST — производительность. У обычных сервисов такой проблемы нет, но у крупных компаний, таких как Google, слишком много запросов. Для них значение имеют каждый байт и каждая микросекунда.
Сети, откуда поступают запросы могут быть и мобильными, и проводными. Сервисы могут отдавать и текст, и видео. Сценарии взаимодействия клиента и сервера сильно отличаются от приложения к приложению.
Если учитывать детали, можно повысить скорость обмена. Не удивительно, что именно Google стал инициатором разработки HTTP/2, protobuf и gRPC — улучшенных версий HTTP, XML/JSON и REST.
Веб-приложения
Подведём промежуточные итоги. Мы разбиваем крупные приложения на несцепленные части, чтобы упростить процесс разработки. Эти части являются, по-существу, веб-приложениями, которые обмениваются между собой запросами в соответствии с соглашениями REST, SOAP или gRPC.
Чтобы решить проблему горизонтального масштабирования, мы разворачиваем копии сервисов на разных машинах и балансируем нагрузку.
Балансировка нагрузки — отдельная интересная тема, в которую я не буду углубляться, но про которую надо помнить, потому что масштабируемость — важное преимущество микросервисов.
Понятие веб-приложение в индустрии существует очень давно. Уже в 1993-м, предтеча Apache, веб-сервер NCSA httpd
, позволял запускать внешние программы в ответ на HTTP-запросы. Программы должны были следовать соглашениям, которые сейчас известны как CGI.
Поначалу CGI применялись для динамической генерации HTML-кода, но в случае с SOAP и REST они отправляли XML и JSON.
Долгое время запуск веб-приложений требовал интеграции с веб-сервером Apache или IIS, что осложняло их развёртывание. Добавить одну или две внешних программы кажется нетрудным делом, но когда речь идёт о десятках и сотнях, открываются врата в ад системного администратора.
На помощь пришли виртуальные машины. На базе одного мощного физического сервера можно запустить сотни небольших виртуальных компьютеров, что сильно повышает безопасность. На каждой виртуалке установлен отдельный веб-сервер, который запускает несколько связанных приложений. Но, если так, сам веб-сервер оказывается ненужным. Там много кода, предназначенного для решения задач администрирования, но обработка запросов HTTP сама по себе проста. Вот пример программы на языке Python, которая является простым, но работающим веб-сервером.
# Python 3 server example
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
hostName = "localhost"
serverPort = 8080
class MyServer(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(bytes("<html><head><title>https://pythonbasics.org</title></head>", "utf-8"))
self.wfile.write(bytes("<p>Request: %s</p>" % self.path, "utf-8"))
self.wfile.write(bytes("<body>", "utf-8"))
self.wfile.write(bytes("<p>This is an example web server.</p>", "utf-8"))
self.wfile.write(bytes("</body></html>", "utf-8"))
if __name__ == "__main__":
webServer = HTTPServer((hostName, serverPort), MyServer)
print("Server started http://%s:%s" % (hostName, serverPort))
try:
webServer.serve_forever()
except KeyboardInterrupt:
pass
webServer.server_close()
print("Server stopped.")
Это так называемое Self-hosted Web Application, автономное веб-приложение. Его запуск не требует установки и администрирования веб-сервера.
Почему это важно? Потому что обеспечивает Continuous Deployment, непрерывное развёртывание. Возможность автоматизировать сборку готового продукта из исходников позволяет быстро удовлетворять потребности заказчика — внедрять новые функции и исправлять ошибки.
Виртуализация
Впервые слово микросервис употребил инженер Питер Роджерс в презентации 2005-го года. Широкую известность микросервисы получили практически через десять лет, когда появилась статья Фаулера и Льюиса. Почему путь от идеи до реализации оказался настолько долгим?
Потому что не хватало одного важного ингредиента.
Контейнеров.
Мы уже упоминали виртуальные машины, как средство администрирования веб-приложений. К сожалению, классические программные виртуальные машины довольно медленные.
В программных эмуляторах машинный код выполняет не процессор, а программа, запускаемая на другом процессоре, возможно, с другой архитектурой. Эффективность кода на эмуляторе можно сравнить с эффективностью футбольного тренера, который во время матча руководит командой через переводчика.
В реальных условиях нам редко требуется эмулировать одно железо на другом. Обычно нас интересуют изоляция приложений или их запуск в другом окружения. Часто на машине с Windows мы запускаем виртуальный Linux, и наоборот. Казалось бы, гостевые программы можно выполнять на процессоре основной машины непосредственно, без программной эмуляции. В конце концов, процессор виртуальных и физических машин — один и тот же.
Оказывается, не всё так просто. Современных процессоры не только изолируют приложения друг от друга, но и запускают их с разными привилегиями. Код ядра операционной системы должен работать на уровне с максимальными привилегиями, поэтому нельзя запустить ни ядро Linux внутри Windows, ни ядро Windows внутри Linux.
Нельзя — в обычных условиях, если разработчики процессора не придумали аппаратной поддержки такой возможности. Но они придумали. Подобный механизм есть и в процессорах Intel (VT-x), и в процессорах AMD (AMD-V).
Начиная с 2005 года мы используем быстрые виртуальные машины, где программы работают практически с той же скоростью, что и на реальном железе.
Впрочем, это решение всё ещё требует серьёзных ресурсов. Гетерогенные среды, именуемые в просторечии «зоопарком», трудно поддерживать, поэтому администраторы предпочитают устанавливать что-нибудь одно. Если на основной машине стоит RedHat, с большой степенью вероятности на виртуальных тоже будет стоять RedHat.
Если вы создали десять виртуальных машин, все системные файлы хранятся на диске в десяти экземплярах. И код ядра, одинаковый во всех системах, также в десяти экземплярах находится в оперативной памяти.
Было бы очень здорово не дублировать ни файлы, ни процессы. Для этого надо доработать операционную систему так, чтоб доступ к ресурсам у пользовательских программ стал чуть более косвенным. Задача эта непростая. Эксперименты с таким подходов велись в Linux с 2005-го года. Настоящим прорывом на этом пути стал Docker, выпущенный в 2013-м.
Программа, запущенная в контейнере Docker полностью изолирована от других программ. Для неё всё выглядит так, будто она работает на собственном компьютере с собственной операционной системой. Но с точки зрения системы-хоста она — обычный процесс.
В мире Linux контейнеры позволяют чуть больше волшебства за счёт дистрибутивов. Мы помним, что контейнерная виртуализация работает, если у хост-системы и гостевых систем одно и то же ядро. В Linux разные дистрибутивы действительно имеют одно ядро, поэтому, например, можно запустить контейнер Debian внутри RedHat.
Впрочем, Docker позволяет работать и с аппаратной виртуализацией. Если нам нужен контейнер Linux внутри Windows, мы можем запустить виртуальную машину с ядром Linux, и уже внутри него запустить контейнер.
Последнее, о чём я хочу упомянуть в связи с темой контейнеров это оркестрация. Docker позволяет запускать множество контейнеров одновременно на разных физических и виртуальных машинах.
В случае наплыва посетителей надо добавить в кластер новые контейнеры для горизонтального масштабирования. Когда пик нагрузки спадёт, надо остановить ненужные контейнеры. Если в контейнере возникла неустранимая ошибка, его надо перезапустить.
Системные администраторы — тоже люди и рутинная работа им не нравится. Было бы неплохо иметь средство для автоматического управления контейнерами в зависимости от нагрузки. Такое средство действительно есть, оно называется Kubernetes.
Появление Docker и Kubernetes стало решающим вкладом в популярность микросервисов.
Картина целиком
Итак, микросервисы — это веб-приложения, которые работают где-то в интернете. Пользователь не посылает запросы напрямую в микросервисы, ему для этого нужен клиент, скажем, мобильное приложение.
Должен ли клиент обращаться к микросервисам напрямую?
Может. Но это неудобно для разработчиков. В микросервисах пришлось бы дублировать много кода, связанного с безопасностью. На клиенте — хранить адреса (возможно) десятков сервисов, которые всё время появляются, исчезают, изменяются.
Очевидное решение — спрятать все микросервисы за одной точкой входа, которая называется API Gateway или шлюз. Закроем доступ к микросервисам из интернета и будем принимать запросы только от шлюза. У нас появится внешняя защита микросервисов, так что мы можем упросить код каждого из них.
Одним из толчков к появлению микросервисов была высокая сцепленность кода. Чтобы её избежать, надо соблюдать два принципа. Принцип первый: микросервисы не знают друг о друге. Не может возникнуть ситуации, когда один микросервис вызывает другой микросервис.
О микросервисах знает шлюз. Именно он пересылает запросы микросервисам и компонует их ответы. Альтернативным способом связи между микросервисами может стать очередь сообщений.
Принцип второй: у каждого микросервиса своя база данных. Разные микросервисы не должны работать с одной и той же базой. Поэтому разработчики иногда дублируют одни и те же данные в нескольких базах. Это нарушает нормализацию, но считается неизбежным злом.
Представим несколько разных клиентских приложений, работающих с одним и тем же ядром. Например, это будет сайт для покупателей, сайт для сотрудников, а ещё мобильное приложение для покупателей. Быстрее всего работа над таким проектом будет двигаться, если за каждое клиентское приложение будет отвечать кросс-функциональная команда, работающая сразу и над фронтендом, и над бэкендом. Чтобы команды не мешали друг другу, каждая из них должна написать свой шлюз и свой набор микросервисов «только для себя».
При этом остаются и микросервисы «для всех», которые нужны разным клиентским приложениям.
Выделенные шлюзы, в отличие от общего API Gateway, называются BFF — Backends for Frontends.
Для того, чтобы микросервисы было удобно разворачивать и обновлять, программисты следуют принципам двенадцатифакторных приложений.