Во всем мире микросервисы — уже практически мейнстрим. В .NET мы только начинаем двигаться в этом направлении. Благодаря усилиям «Майкрософт» в последние несколько лет у нас появилась возможность запускать .NET в контейнерах Windows. У нас есть платформа .NET Core, которая позволяет делать это под различными версиями Linux. Соответственно, мы теперь можем запускать сервисы и микросервисы в контейнерах.
Сегодня я хочу рассказать о некоторых аспектах использования .NET. В первую очередь важно понять, для чего следует применять технологию: в данном случае — микросервисы. Если вы владеете какой-либо технологией, это не означает, что вам автоматически всегда стоит ее применять. Если все-таки вы поняли, что вам нужна микросервисная архитектура, то какие шаги необходимы для того, чтобы реализовать эту архитектуру? Мы углубимся в понимание контейнеров, чтобы разобраться в принципах их работы. Мы не будем подробно останавливаться на самой архитектуре контейнеров, но я дам основную информацию, которая позволит понять основные принципы их работы.
Преимущества и недостатки микросервисов
Очень часто мы сталкиваемся с ситуацией, когда разработчик узнает о новой технологии и думает, что она решит все его задачи. Это зачастую не так. Всегда нужно понимать все преимущества и недостатки технологии, которые проявятся при ее использовании. Чаще всего микросервисы сравнивают с монолитной архитектурой.
Ключевые преимущества микросервисной архитектуры:
- Если у вас обширный домен и бизнес хочет развиваться сразу во многих направлениях, то микросервисная архитектура позволит создать отдельные команды, каждая из которых занимается разработкой своих микросервисов. Вы сможете развивать решения параллельно, не сталкивая разработчиков лбами друг с другом.
- Каждый микросервис можно разворачивать независимо. Если одна из команд закончила разработку своей функциональности, она может запустить ее в production. При этом бизнес уже получит какие-то преимущества, пока другие команды доделывают свою часть функциональности.
- Масштабируемость. Каждый из сервисов можно запускать во многих экземплярах. Если у вас есть трудности только в одном высоконагруженном месте, то можно именно это место размножить и горизонтально масштабировать.
- Каждый из микросервисов по отдельности — намного более простое решение, чем общая монолитная система. В крайнем случае, если конкретный микросервис перестает удовлетворять конкретным требованиям бизнеса, его можно выбросить и переписать заново. Это не будет настолько сложным и дорогостоящим, как если бы вам сказали переписывать монолит, потому что вы ошиблись в выборе технологии.
- Возможность применять разные технологии. Если у вас есть специализация в конкретных сервисах, например для GPU в высоконагруженных вычислениях, вы можете написать этот сервис на C++ или библиотеке, которая работает непосредственно в GPU. Если другие сервисы .NET вам не подходят, вы можете написать front-end на Node.js и т. д. У вас появляется возможность выбирать различные технологии. Но это не означает, что вы должны строить у себя «зоопарк». Стандартизация должна быть, но если есть достаточно оснований поменять технологию, вы можете это сделать.
Среди недостатков микросервисной архитектуры можно выделить следующие:
- Усилия, которые нужно затрачивать несколько раз для решения одних и тех же задач. У каждого сервиса появляется свой CI, свое независимое тестирование. Существует множество задач, которые в мире монолита делаются один раз, и про них забывают, тогда как в мире микросервисов к этим задачам нужно постоянно возвращаться. К примеру, если вы приняли решение сменить подход к развертыванию решения, то его придется менять для каждого из микросервисов.
- Все вместе микросервисы будут более сложными для разработки, чем монолит. Есть очень хорошая фраза: «Если вы не можете построить хорошо структурированный монолит, то кто сказал, что микросервисы вам помогут?» («Distributed big balls of mud»). И это действительно так, потому что микросервисная архитектура на самом деле более сложная, чем монолит. В монолите у вас все связано: вы скомпилировали и убедились, что одна часть подсистемы, по крайней мере на уровне контрактов, взаимодействует с другой частью подсистемы. В микросервисной архитектуре приходится проверять интеграционными тестами, что контракты соблюдены.
- Увеличение операционных расходов. У вас может быть десяток, а то и сотня микросервисов. С каждым их этих микросервисов нужно поддерживать свои процессы CI, свои процессы развертывания. За каждым из сервисов нужно следить независимо: сервис может упереться в ограничение по памяти, связь с другим сервисом может оборваться и автоматически не восстановиться, могут быть утечки памяти, дедлоки и т. д. Намного усложняется процесс сопровождения вашего решения: вместо одного-двух или трех процессов приходится следить за сотнями.
- Целостность контрактов и данных. Каждый микросервис работает со своим хранилищем, данные не интегрированы между собой: нет внешнего ключа (foreign key), и, соответственно, намного сложнее сохранять целостность данных.
- Поскольку каждый из сервисов можно разрабатывать независимо (преимущество), точно так же независимо он может и поменять свой контракт. Хорошо, если мы заметим это во время интеграционного тестирования. Хуже, если мы заметим это в production.
Автоматизация всех процессов
В рамках микросервисов непрерывная интеграция (Continuous Integration или CI) выглядит немного по-другому: на выходе конкретного микросервиса должен получиться какой-то пакет, артефакт либо контейнер, который универсальным образом развертывается в production. Если Node.js будет собираться совершенно по-другому, чем .NET Core, Java и другие решения, то разворачивание таких решений в production усложняется. Поэтому на выходе микросервисов должен быть универсальный пакет, который можно было бы развернуть в любом окружении.
Следует отличать Continuous Delivery (непрерывная поставка) от Continuous Deployment (непрерывное развертывание). В рамках Continuous Delivery не каждая фиксация изменений (commit) автоматически уходит в production. Но это означает, что мы должны иметь возможность быстро запустить любое решение или коммит в production. Необходимо убедиться, что решение компилируется, протестировать его (насколько это возможно) и понимать, что его можно выпустить в production или другое окружение одним щелчком мыши.
Обязательно следует подключить автоматизированный мониторинг. В случае монолита мониторинг сервиса может осуществлять даже один или два человека в режиме 24/7. Когда же у вас есть сотня микросервисов, запущенных в нескольких экземплярах, вы не уследите за всеми ими, если у вас не будет сбора всей информации в автоматизированном виде и автоматического оповещения о проблемах. Чтобы понять суть возникшей проблемы, необходима агрегация логов. Если у вас сотня микросервисов, искать расположение конкретного файла с логами будет крайне затруднительно!
В рамках использования Continuous Integration с микросервисами мы ожидаем повторяемости: если у вас есть некий коммит и вы запустили дважды одну сборку (build) для этого коммита, вы ожидаете одинаковый результат. Отличие состоит в том, что в монолитах эти сборки создаются для всего решения часто. Если на агент сборки (build agent) вы установите обновление (обновление ОС Windows, установка новой версии .NET Framework) и из-за этого поломается сборка, вы сразу это заметите, устраните неисправность и продолжите работу.
В случае же микросервисов будет нормальной ситуацией, когда один раз написанный микросервис полностью выполняет свою бизнес-задачу. И если у бизнеса нет пожеланий изменить этот микросервис, он может жить годами в production, и никто на него не обращает внимания. Через несколько лет бизнес решает внести изменения. Вы возвращаетесь к этому микросервису. Первым делом запускаете сборку, но она уже не работает, поскольку прошло уже много времени, билд агент за это время «оброс» новыми или обновленными фреймворками. К тому же, возможно, на проекте уже не работают программисты, которые писали этот микросервис.
Вы можете разобраться в коде, но вместо того, чтобы быстро внести требуемое изменение, на первый план выходит процесс восстановления сборки. В этом нам помогут контейнеры. Если мы будем все наши сборки запускать в контейнерах, мы с точностью сможем запустить наше решение в том виде, в котором ожидаем. Это позволяет с намного большей уверенностью говорить о том, что даже через несколько лет процесс сборки будет работать. После этого мы при необходимости сможем контролируемо обновить микросервис на новую версию фреймворка и проверить, что он там тоже работает. Либо не работает, но тогда мы будем четко видеть, в чем заключается проблема.
В дополнение к этому, при наличии конфигурации сборки (описание способа создания сборки, модульного тестирования, интеграционного тестирования) в самом git-репозитории, это дополнительно повышает уверенность в том, что по прошествии нескольких лет вы сможете запустить сборку, и она будет работать точно также, как и раньше.
Контейнеры
По моему мнению, контейнеры — то недостающее звено, которое вывело микросервисы в мейнстрим. Контейнеры обеспечивают простое развертывание. В результате сборки получается контейнер, который можно развернуть практически на любой машине, не требуя дополнительных пререквизитов. Хотя, конечно, есть особенности использования контейнеров под ОС Windows и Linux, но в пределах разных ОС Linux вы сможете развернуть контейнер на любой Linux-машине. Нужно только настроить правило параметризации этого контейнера (например, определенным контейнерам нужно настроить параметры доступа к файловому хранилищу, проставить переменные окружения), и контейнер готов к запуску. Это позволяет развернуть контейнер в нескольких экземплярах, на одной машине или на десятке серверов.
Основная особенность контейнеров — это то, что они являются неизменяемыми (immutable). Если вы создали контейнер один раз, он сохраняет полный слепок файловой системы. В любой момент, независимо от среды запуска, это будет полностью идентичная копия сервиса, включая ОС и все необходимые вам
Очень часто контейнеры пытаются сравнивать с виртуальными машинами. Надо понимать, что между ними существует принципиальное различие. Принцип работы виртуальных машин: есть некая хост операционная система (далее — гипервизор), которая абстрагирует хост ОС от виртуальных машин, и образы, в каждом из которых работает сама ОС, а в ней файловая подсистема и ваше приложение. Обычно ОС на порядок больше по размерам и по потреблению ресурсов, чем непосредственно приложение, которое там работает. Использовать такую архитектуру для микросервисов — очень избыточно. Когда Azure только начинал развиваться, у них было решение PaaS Application Services. Запуская на этой платформе небольшой веб-сайт в трех экземплярах, необходимо было физически запустить три виртуальные машины, на которых процессор был загружен на 1 %, а 60 % оперативной памяти забирала на себя ОС.
Рисунок 1. Сравнение виртуальных машин и контейнеров
Как работают контейнеры? В контейнерах есть хост ОС, поверх которой работает подсистема контейнеризации (container engine). Самая популярная сейчас подсистема контейнеризации — Docker. Далее сама подсистема абстрагирует родительскую ОС от контейнеров. Каждый из контейнеров видит, что он работает в своем маленьком окружении. При этом он продолжает выполнять свои действия непосредственно в хост ОС. Это и есть ключевое преимущество, поскольку мы не тратим никаких ресурсов ОС внутри виртуальной машины. Но также это и особенность, которую нужно понимать: если конкретное приложение требует определенных действий по настройке в ядре ОС, эти настройки могут быть несовместимы с другими контейнерами, которые работают непосредственно на этой машине. Например, чтобы Redis обеспечивал низкую задержку (latency), есть рекомендацияпо настройке параметров в ядре Linux (Transparent huge pages must be disabled from your kernel). Такие параметры могут быть несовместимы с другими контейнерами, для которых они имеют другое значение.
Приведенная на рисунке 1 схема показывает, как ее видит контейнер. Сам контейнер видит свой образ файловой системы, как будто бы она была развернута специально для него. На самом деле контейнеризация еще более эффективна, поскольку общие части файловых систем разных контейнеров используются совместно. Как это происходит?
Ключевая особенность контейнеров — слои файловой системы. По умолчанию в Docker используется AuFS — файловая система, которая основана на слоях. Каждый слой является immutable, а следующий слой строится как дельта от предыдущего слоя. Это можно рассматривать по аналогии с коммитами: у нас есть предыдущий коммит, мы внесли изменения и сделали новый коммит. При этом в новом коммите хранится только дельта по отношению к предыдущему. Здесь такая же ситуация: есть предыдущий слой, поверх этого слоя мы добавляем новый файл в нужную папку. В следующем слое хранится только этот файл. В программировании также есть объекты immutable, которые можно легко совместно использовать между разными процессами и потоками (threads). Мы не боимся, что кто-то может случайно поменять этот объект. Такая же ситуация и со слоями: если у нас разные контейнеры используют общие слои, эти слои не нужно сохранять независимо. Один слой может обслуживать сразу несколько контейнеров.
На рисунке 2 показан рецепт построения Docker-контейнера.
Рисунок 2. Слои контейнера
Читать снизу-вверх (в самом верху находится самый последний слой контейнера).
Рассматривая рисунок 2 более подробно, мы видим операции, которые проводятся над контейнерами. Где-то внизу есть базовый контейнер. Следующая операция — добавление переменной окружения (новый слой). Еще добавление нового окружения (еще переменная окружения) и еще один новый слой. После этого мы запускаем команду на выполнение, которая скачивает что-то из Интернета и выкладывает в каких-то папках — еще один слой, который добавляет 50 МБ и т. д.
Чтобы эффективно строить Docker-контейнеры, необходимо понимать, что при построении нового образа Docker кеширует каждый из слоев. При повторном построении образа Docker пытается переиспользовать слои из кеша, основываясь на операции, которая применяется к предыдущему слою. Для некоторых операций сам текст строки операции полностью описывает действия, которые будут произведены над предыдущим слоем, чтобы получить новый слой. Если до этого к предыдущему слою применялась точна такая же операция, мы сразу можем использовать закешированный слой, не выполняя эту операцию.
Операция COPY копирует файлы внутрь контейнера. Чтобы понять, можно ли использовать закешированный слой, Docker рассчитывает хеш-сумму содержимого всех файлов, которые необходимо скопировать. И если она совпадает со слоем в кеше — автоматически переиспользует закешированный слой. Надо заметить, что при этом игнорируется дата изменения файлов.
Есть отдельные операции типа RUN для Docker. Операция RUN запускает скрипт на выполнение. Сама технология Docker не знает, что на самом деле мы обращаемся в Интернет и скачиваем оттуда некие бинарные файлы и запускаем их. Эта особенность, если ее неправильно использовать, может привести к возникновению проблемы: по этой строке Docker будет искать, существует ли в кеше точно такой же новый слой. И если после выполнения этой строки результат может поменяться, то Docker об этом не узнает: он все равно обратится к кешированному слою.
Когда вы выполняете определенные cURL, общаетесь с внешним миром по сети, добавляйте однозначные идентификаторы, что именно вы скачиваете. Если необходимо установить некое приложение, следует указать конкретную версию этого приложения. Если вы поменяете эту версию, Docker поймет, что скачивается новая версия приложения и не станет использовать кешированную версию.
На рисунке 3 наглядно представлено повторное использование слоев (layers reuse).
Рисунок 3. Повторное использование слоев
Представим ситуацию, что на определенной машине нужно запустить несколько экземпляров одного образа. На рисунке 3 мы видим пять экземпляров, пять контейнеров для одного образа Ubuntu. Поскольку слои являются immutable, каждый их этих контейнеров ссылается на один и тот же образ. Они не мешают друг другу, так как все эти слои доступны только для чтения (read-only). Если при выполнении контейнеру нужно будет что-то записать у себя локально, запись будет осуществляться в тонкий слой непосредственно внутри контейнера. В этом случае применяется логика копирования при записи (Copy-On-Write). Если во время выполнения контейнер захочет изменить существующий файл, который расположен в одном из этих слоев, то этот файл скопируется в слой контейнера, и дальнейшее редактирование будет происходить уже в этом слое.
То, что контейнер сохраняет у себя в read-write-слое, — временная информация. Нельзя рассчитывать на то, что при запуске контейнера вы можете сохранять информацию, и что при последующем перезапуске контейнера эта информация сохранится. Это одна из ключевых особенностей Docker-контейнеров: если необходимо постоянное хранилище, следует подключить внешние тома, в которые вы будете сохранять информацию, либо сохранять всю информацию в удаленной базе данных. Сам контейнер должен оставаться stateless и сохранять информацию с помощью подключенного тома.
Принципы работы контейнеризации
Рассмотрим принцип работы контейнеризации на примере .NET Core (см. рис. 4).
Рисунок 4. .NET Core для Web API приложения
Для демонстрации я добавил проект .NET Standard с контрактами и обычное ASP.NET Core Web API приложение, которое ссылается на него. После этого я включил поддержку Docker на проекте средствами Visual Studio. Dockerfile — рецепт, в котором описан способ построения нового образа контейнера для нашего проекта. Несмотря на простоту демонстрационного примера, в получившемся Dockerfile достаточно много чего происходит. Чтобы потом его модифицировать и улучшать, важно для начала понимать, почему автоматически сгенерированный файл имеет именно такой вид. В данном рецепте объединены два аспекта:
- Возможность переиспользования закодированных слоев от предыдущей сборки Docker-контейнера. Как только поменялся какой-то слой контейнера, все последующие слои уже не смогут быть переиспользованы из кеша. Поэтому то, что чаще всего меняется, опускается вниз Dockerfile, а то, что наиболее стабильно, вынесено наверх. Таким образом, максимальное количество слоев можно будет взять из кеша.
- Уменьшение результирующего Docker-контейнера. Поскольку каждая операция приводит к новому слою в контейнере, то при копировании файлов или генерировании файлов во время компиляции это все сохранится в слое: bin, obj и т. д. Следующей операцией может быть удаление этих файлов. Но при этом в предыдущем слое все эти файлы уже останутся, так что удаление файлов не повлияет на размер контейнера.
Сначала определяется базовый контейнер, на основании которого будет строиться окончательный образ. В нашем примере это базовый слой — aspnetcore вер. 2.0, в котором устанавливается рабочая папка /app и внешний порт: 80. Этот базовый слой умеет запускать скомпилированные ASP.NET Core приложения, но не билдить их.
После этого идет непосредственно описание процесса сборки нашего проекта. Для этого мы используем другой образ, в котором есть SDK и который позволит запустить компиляцию проекта. Устанавливаем рабочую папку внутри контейнера /src и копируем туда только файлы солюшена (.sln) и проектов (.csproj). Почему только файлы проекта? Потому что они намного реже меняются, чем все остальные cs-файлы. Операция dotnet restore достаточно тяжелая и может выполниться только на основании csproj-файлов. Соответственно, намного больше вероятность того, что операция dotnet restore будет кеширована, и нам не нужно будет скачивать все nuget-пакеты при создании каждой сборки проекта. Следующая операция уже копирует все файлы проектов. Первая точка (первый параметр) указывает, откуда копировать из source-машины, вторая — куда копировать. Откуда — это контекст сборки. Во время запуска сборки Docker-контейнера мы указываем, из какой папки мы начинаем сборку. Мы копируем все файлы из нашей текущей папки, из которой мы начинали. Поскольку мы указали в качестве рабочей папки /src, то мы скопируем файлы в эту папку. После этого создаем обычную сборку dotnet с версией release и выводом в папку /app.
Мы дали название (alias) нашему контейнеру AS build — временный alias, чтобы можно было создавать связи между разными сборками. Далее мы запускаем dotnet publish в версию release. И последний шаг: за основу мы берем базовый образ, объявленный в самом в начале, копируем из сборки publish папку /app. В ENTRYPOINT указываем, что нам нужно вызывать. Таким образом, если мы запустим этот рецепт на выполнение, получим минимальный образ, из которого мы начинали, и в него скопируем только финальные опубликованные
Как это выглядит при запуске из консоли? На рисунке 5 показано, как я запустил мою сборку дважды.
Рисунок 5. Запуск операций из кеша
При запуске указанного выше рецепта все операции используются из кеша на основе существующей сборки. Все слои могут повторно использоваться из кеша без выполнения самой сборки. Запустив дважды сборку Docker на одних src-файлах, мы за секунду получаем финальную сборку. Если какие-то cs-файлы изменились, то будут переиспользованы все операции вплоть до dotnet restore. И по-новому слои будут строиться, только начиная с копирования всех файлов.
На рисунке 6 показано, как это выглядит на уровне слоев.
Рисунок 6. Полученный контейнер, развернутый по слоям
Самые нижние слои — это слои Майкрософт (можно видеть операции, в результате которых получились эти слои): начиная от базовой ОС, из которой добавляются файлы в контейнер, далее Майкрософт добавляет/устанавливает свои слои и проставляет окружение. Дальше начинаются мои финальные операции. Весь контейнер будет занимать суммарно 300 МБ. Но если на хост-машине будут запущены несколько разных контейнеров, основанных на базовом образе aspnetcore вер. 2.0, то все эти слои будет использоваться повторно, и каждый контейнер будет добавлять только непосредственно свои файлы, как в нашем случае — 306 КБ. Если запустить образ и посмотреть его размер, вы увидите, что это минимальное решение весит 300 МБ. Не нужно пугаться: ваш слой занимает лишь маленькую часть всего образа.
Дальнейший запуск контейнеров в production
«Everyone gets upset when a kitten dies. On cloud platforms no one hears the kitten dying»
Существует стандартное сравнение, как вы относитесь к запускаемому ПО и серверам, на которых это ПО запускается: вы относитесь к ним, как к домашним животным либо как к безликому стаду. В чем разница? Если сервер — домашнее животное, это означает, что вы знаете его имя, возможно, вы на память помните его IP-адрес, вы или администратор заходите на этот сервер, время от времени проверяете и чистите логи, проводите модернизацию именно этого сервера. Основной признак того, что ваш сервер или ваше развернутое в production решение является домашним животным — всем становится грустно, оттого что он умер. Если все начинают бегать в панике, когда ваш сервер умирает, это явный признак того, что сервер — домашнее животное.
Альтернатива этому — стадо, когда администраторы следят не за каждым конкретным экземпляром сервера или решения, а за всеми ими вместе. Каждый из серверов живет сам по себе, со своими процессами, которые выполняются на этом сервере. Администратору интересно только общее количество: сколько серверов живы, сколько живых сущностей в стаде. Если кто-то из стада умрет, администратор когда-нибудь подойдет к этому серверу, отключит его и подключит новый. По сути, отношение к серверам и решениям, которые разворачиваются в микросервисной архитектуре сводится к тому, что у вас есть некий парк серверов, на которых вы запускаете сотни или тысячи процессов-контейнеров. Каким образом этого достичь?
На рисунке 7 приведен набор технологий для Docker-контейнеров.
Рисунок 7. Набор технологий для оркестрации контейнеров
В таких технологиях, как Docker Compose, вы все еще работаете со своими серверами — домашними животными, где вы указываете, что на конкретной машине нужно развернуть определенное количество экземпляров своих контейнеров. Существуют технологии, которые позволяют вам запускать контейнеры на личном кластере серверов локально (on-premises). Есть cloud-решения, которые либо используют одно из указанных выше решений для хостинга кластера серверов, либо предлагают индивидуальные решения. Например, у Amazon ECS есть индивидуальное решение Fargate или Amazon EKS Kubernetes — менеджер кластера Kubernetes в облаке. Azure предлагает Kubernetes, Docker Swarm и т. д. У вас есть выбор, на чем запускать эти решения.
Важно! Для возможности масштабирования контейнеров необходимо, чтобы контейнеры или другие исполняемые компоненты понимали, где развернут ваш контейнер. Для этого используется понятие service discovery. В облаке выполняется автоматическая адресация: само облако предоставляет готовое решение. У Kubernetes есть специальное понятие «сервис», где вы создаете сущность под названием «сервис» и указываете, что некое подмножество контейнеров (либо в их терминологии — Pods, которые могут объединить несколько контейнеров) объединены под одним названием. Вы можете использовать это название, чтобы адресовать в режиме балансирования нагрузки или прокси вызов на ваш контейнер.
Выводы
Микросервисы — не панацея. Нужно понимать, когда их следует применять. Если задача узкоспециализированная, будет разрабатываться одной небольшой командой — наверняка микросервисы не ваш выбор. Если же у вас достаточно широкое решение и бизнес хочет его развивать в разных направлениях, то можете об этом задуматься.
Необходимо все по максимуму автоматизировать: начиная от Continuous Integration и заканчивая разворачиванием и сбором логов в production.
Оркестрация контейнеров — именно те решения, которые помогают реализовывать микросервисную архитектуру. Оркестрация контейнеров помогает выполнить Push и забыть об этом контейнере, даже не зная, на каком сервере контейнер запустился. Без такой автоматизации отслеживать доступные ресурсы и быстро разворачивать нужное количество контейнеров на кластере серверов будет очень сложно.