Разработка highload-системы на .NET Core: задачи и их решения
Меня зовут Андрей Губский. На данный момент я являюсь Software Architect в компании Video Intelligence, где занимаюсь проектом vi stories. Также я — CTO проекта Торф ТВ и создатель //devdigest. Коммерческой разработкой занимаюсь примерно с 2008 года. Окончил КПИ, кандидат технических наук. Являюсь Microsoft MVP в номинации Developer Technologies.
В этой статье я хочу поделиться своим опытом создания высоконагруженной системы, разработанной на .NET Core. Я не буду вдаваться во все тонкости архитектурных решений и наших алгоритмов, поскольку они очень специфичны для той задачи, которую мы решаем. Однако постараюсь выделить те трудности и проблемы, с которыми мы столкнулись, спешно их решили и которые могут быть достаточно типичны для различного рода проектов, работающих под высокой нагрузкой.
Немного про наш продукт: vi stories
vi stories представляет собой систему контекстной рекомендации видео. Суть ее заключается в следующем: на сайт устанавливается небольшой скрипт, который позволяет находить наиболее актуальное и релевантное видео для контента текущей страницы. Как это работает? Пользователь заходит на сайт, где установлен скрипт. В момент, когда скрипт загружается, отправляется запрос на сервис, отвечающий за подбор видео для текущей просматриваемой страницы. Информацию о самом пользователе мы при этом не собираем, так как она не важна для наших алгоритмов, да и с GDPR лучше не связываться.
Далее наш сервис в режиме реального времени анализирует контент страницы, выделяет тематику страницы, ключевые слова и в нашей обширной библиотеке подбирает видео, которое лучше всего будет иллюстрировать текст страницы.
Подобная технология позволяет увеличить количество полезного контента на странице и дать больше актуальной информации посетителю.
Что такое высоконагруженная система
Перед тем как перейти, собственно, к рассмотрению проблем высоконагруженных систем, сделаем маленькое лирическое отступление, в котором рассмотрим, что именно мы понимаем под понятием «высоконагруженная система».
У многих разработчиков уже довольно давно сложился стереотип высоконагруженной системы, которая работает с миллионами запросов в секунду, состоит из многих сотен серверов с десятками ядер и терабайтами оперативной памяти. Да, в каких-то случаях так и есть. Однако следует понимать, что высокие нагрузки начинаются не с миллионов RPS, а с того момента, когда ваша система начинает испытывать сложности с обработкой определенного количества RPS (не всегда феноменально большого). И количество это может быть любым: от десяти запросов в секунду до десяти тысяч запросов в секунду и выше. Серверные ресурсы у вас при этом тоже могут быть абсолютно любыми. Это может быть пару виртуалок или целый кластер в Kubernetes. Определяющим в высоконагруженной системе является тот факт, что горизонтальное масштабирование становится более выгодным, чем вертикальное.
Когда я начал описывать задачи, которые нам нужно было решать на разных этапах работы над проектом, я долго думал, каким образом упорядочить темы: по значимости, в хронологическом порядке или сгруппировав по технологиям. Но в итоге, спустя полдня, я пришел к наилучшему варианту упорядочивания. Все темы отсортированы в строгом соответствии с рандомом.
Задача № 0. Как из джавистов сделать дотнетчиков так, чтобы они этого не заметили
В какой-то момент, когда у нас уже был готов прототип системы и разработка шла довольно активно, а запросов по расширению функциональности стало поступать все больше и больше, менеджмент решил, что пора расширять нашу команду. Так получилось, что как раз к этому моменту почти завершилась разработка другого проекта компании, и освободилось два разработчика.
Существовал, правда, небольшой нюанс: тот проект был на Java, и, соответственно, основными инструментами коллег были Ubuntu, macOS и IntelliJ IDEA. К счастью для ребят, часть проекта уже была переведена на .NET Core и им не пришлось изучать нюансы установки Windows через Boot Camp. Поскольку IntelliJ IDEA от Rider на расстоянии больше метра вряд ли кто сможет отличить, а C# один в один похож на Java, какой она будет примерно в 2045 году — новые члены нашей команды почти не почувствовали подмены.
Интересно, что в какой-то момент в нашей команде использовались практически все существующие IDE для работы с .NET: Visual Studio, Rider, Visual Studio Code и Visual Studio for Mac.
Что касается миграции на .NET Core, проект мы переводили постепенно. Сначала в .NET Standard мы сконвертировали проекты, которые отвечали за описание базовых классов предметной области и всю ту часть, которая могла быть покрыта юнит-тестами. Затем постепенно стали переводиться отдельные сервисы. Из проектов типа Windows Services получились очень компактные и удобные консольные приложения, которые затем были аккуратно перенесены на Linux.
Итоги и выводы
Благодаря своевременному переходу на .NET Core нам удалось в очень сжатые сроки организовать процесс работы таким образом, что новые члены команды практически не почувствовали дискомфорта при смене технологического стека. При этом мы смогли в полной мере воспользоваться преимуществами контейнеризации и кросс-платформенности, не потеряв при этом ни одного из преимуществ платформы .NET.
Задача № 1. Выбор стека и определение приоритетов разработки
С самого начала, когда мы только начали разрабатывать прототип системы, возник вопрос, какой стек выбрать. С одной стороны, я на тот момент уже был готов стартовать на .NET Core, однако команда, в которой я работал, и СТО компании еще не были готовы к тому, чтобы начинать разработку на достаточно молодой (на их взгляд) платформе. Поэтому мы начали работу с теми технологиями, которые были хорошо знакомы команде и использовались компанией в предыдущих проектах.
Оригинальный стек выглядел примерно так:
- Windows Server;
- Microsoft .NET 4.6.2;
- Couchbase;
- IIS;
- MSMQ;
- MySQL;
- Elasticsearch;
- хостинг — Amazon EC 2.
Это довольно серьезно отличается от того, с чем мы работаем сейчас:
- Linux;
- .NET Core 2.2;
- Redis;
- nginx;
- Rabbit MQ;
- MariaDB;
- Elasticsearch;
- развернуто почти все в Kubernеtes.
С одной стороны, может показаться, что подобная эволюция технологий в проекте наверняка потребовала определенного оверхеда по времени разработки. С другой, могу сказать, что подход, при котором вы разрабатываете прототип на технологиях, которые максимально комфортны вашей команде, и только намного позже меняете компоненты стека на более подходящие задачам, но не знакомые команде, — эффективен. Когда вы создаете прототип, вы еще не до конца понимаете, какие требования к компонентам стека будут решающими. Поэтому главным параметром на этапе прототипирования является скорость разработки.
Через некоторое время после того, как мы переехали на .NET Core, от новых участников нашей команды (о них — чуть ниже) поступило предложение переехать в Kubernetes. Это был как раз тот момент, когда технология, которую мы планировали применить, была команде не знакома, но бенефиты перевесили страх нового.
Что мы получили, переехав в K8S?
- Независимость от облачного провайдера. Сейчас практически любой из крупных провайдеров предлагает Kubernetes as a service: Azure, AWS, Digital Ocean, Google Cloud.
- Возможность быстрого деплоя. Так как все построено на докер-контейнерах, то откатить версию или развернуть на время какую-то специфическую версию — дело нескольких секунд.
- Вся инфраструктура описана в скриптах. Эти описания могут храниться в системе контроля версий.
Условный минус — практически всем участникам команды пришлось изучить основы того, как работает Kubernetes, узнать, что такое Helm и Terraform.
В рамках этой же проблемы стоит рассмотреть такой момент, как определение сферы ответственности команды за используемые компоненты. Возможно, это звучит несколько расплывчато, постараюсь описать более понятно. В нашем случае система состоит из большого количества как наших собственных сервисов, так и сторонних. И одна из задач, которая стояла перед нами, — определить, заботу о каких компонентах должна взять на себя команда, а где стоит использовать готовые решения.
В нашем случае мы постарались задачи, которые касаются администрирования, вынести за пределы прямой ответственности команды.
Итак, что мы вывели за непосредственную сферу ответственности команды:
- Отказались от ручного администрирования Elasticsearch, перейдя на сервис от AWS. Стабильность и возможности масштабирования Amazon Elasticsearch Service полностью нас устраивали, поэтому мы смогли освободить команду от необходимости ручного управления и обслуживания кластера.
- Мы также отказались от ручного обслуживания кластера Couchbase, перейдя на Amazon ElastiCache, с которым работаем по Redis-совместимому протоколу.
- Убрали in-house библиотеку для логирования, которая была разработана в компании и использовалась до этого в ряде проектов. Ее заменили NLog.
- Отказались от различных реализаций (как собственных, так и сторонних) библиотек для периодического выполнения задач, полностью переложив эти задачи на cron. Чем также сделали архитектуру многих сервисов проще. А чем проще решение, тем меньше в нем потенциальных точек нестабильности.
Итоги и выводы
Сейчас наша система потенциально может быть развернута на любой облачной платформе в максимально сжатые сроки. При этом команда сосредоточена исключительно на значимых для бизнеса задачах, лишь иногда перенося фокус внимания на вопросы инфраструктуры и обслуживания проекта.
Задача № 2. Минимизировать побочные эффекты при работе над проектом (почему иммутабельность — это хорошо)
Итак, почему? Вот несколько основных причин:
- Нет неожиданных побочных эффектов по ходу выполнения кода — никто не изменит то, что не должно быть изменено. Не нужно держать в голове куски системы, в которых объект мог быть изменен.
- Иммутабельные коллекции потокобезопасны.
- Концепция иммутабельности является одной из концепций ФП и позволяет более четко описывать логику процессов и проще тестировать код.
Если речь идет о нагруженном проекте, над которым работает далеко не один человек, иммутабельность позволяет ввести более строгие правила работы с потоками данных в приложении и сделать код более предсказуемым.
В .NET Core (да и .NET Framework) уже есть набор иммутабельных коллекций из коробки:
- ImmutableArray
- ImmutableArray<T>
- ImmutableDictionary
- ImmutableDictionary<TKey,TValue>
- ImmutableHashSet
- ImmutableHashSet<T>
- ImmutableList
- ImmutableList<T>
- ImmutableQueue
- ImmutableQueue<T>
- ImmutableSortedDictionary
- ImmutableSortedDictionary<TKey,TValue>
- ImmutableSortedSet
- ImmutableSortedSet<T>
- ImmutableStack
- ImmutableStack<T>
Чтобы использовать эти коллекции, достаточно подключить NuGet-пакет System.Collections.Immutable.
Итоги и выводы
На данный момент при работе со списками практически все методы в нашей системе возвращают и принимают иммутабельные объекты. Тип возвращаемого объекты обычно IReadOnlyCollection<T>. Это позволило значительно упростить понимание работы и взаимодействия модулей системы между собой. Разработчики всегда понимают, какие данные в каком состоянии.
Задача № 3. Работа с пиковыми нагрузками
После запуска системы мы начали время от времени наблюдать, что нагрузка на нее возрастает в несколько раз. Причины подобных пиковых нагрузок оказались различны. В одних случаях это были автоматические запросы к нашей системе от каких-то ботов, которые после были заблокированы на уровне фаервола, в некоторых случаях это были запросы вследствие активного посещения какого-то из сайта наших паблишеров. В данном случае банальное увеличение мощности не подходило из соображений затрат на инфраструктуру. Поэтому решили прибегнуть к реализации механизма, который позволял бы отслеживать состояние системы и определять, когда нужно продолжать работать в штатном режиме с полным циклом обработки всех ресурсов, а когда — переходить в «безопасный режим» с минимальными запросами на обработку запросов.
В своих проектах подобную проблему я решал довольно давно. Еще со времен учебы в университете у меня была небольшая библиотека, написанная на .NET 4.0, из которой я время от времени брал небольшие части кода для других проектов. Я давно планировал провести рефакторинг этого кода, почистить его и оформить в виде отдельного мини-фреймворка, позволяющего красиво и с наименьшими затратами ресурсов решать задачу наблюдения за состоянием системы. Потратив несколько вечеров и пару выходных, я таки привел этот код в порядок и выложил его на GitHub. В данном случае я не буду подробно останавливаться на реализации этой библиотеки, так как она уже описана в другой статье. Опишу лишь базовые принципы.
Для наглядности определим ряд сущностей, которыми будем оперировать:
- Probe — отвечает за проверку состояния одного из показателей системы.
- Spectator — опрашивает один или несколько датчиков. Изменяет свое состояние в зависимости от текущих показаний датчиков.
- State calculator — на основе журнала метрик вычисляет текущее состояние.
- State journal — набор показателей каждого из датчиков с указанием времени опроса.
Процесс расчета состояния системы можно описать следующим образом: каждый из датчиков, внедренных в систему, может определять один из параметров наблюдаемой системы/модуля/сервиса. Например, это может быть фиксирование количества внешних активных запросов к API, объем занятой оперативной памяти, количество записей в кэше и т. д.
Каждый датчик может быть закреплен за одним или несколькими наблюдателями. Каждый наблюдатель может работать с одним или несколькими датчиками. Наблюдатель должен реализовывать интерфейс ISpectator и генерировать события в случае изменения состояния или опроса датчиков.
Во время очередной проверки состояния системы наблюдатель опрашивает все свои «датчики», формируя массив для записи в журнал состояний. Если состояние системы изменилось, наблюдатель генерирует соответствующее событие. Подписчики события, получив информацию об изменении, могут поменять параметры работы системы. При этом для определения состояния системы могут использоваться «вычислители» различных типов.
Итоги и выводы
Использование этой библиотеки помогло нам значительно увеличить устойчивость системы к внезапным пиковым нагрузкам. При этом имеющийся вариант реализации отлично подходит при внедрении в системы, построенной на базе микросервисной архитектуры. Оптимальный вариант интеграции — через использование принципа внедрения зависимостей, когда процесс внедрения датчиков реализуется с помощью IoC-контейнера, а наблюдатели представлены в виде синглтонов, где единственный экземпляр каждого из наблюдателей может быть доступен различным классам модулей и сервиса.
Задача № 4. Отладка и логирование
Наверное, нет ни одной более-менее крупной системы, где бы перед разработчиками не стояла задача эффективного логирования работы системы. В нашем проекте мы используем довольно типичную связку для работы с логами — ELK (Elasticsearch, Logstash, Kibana). Не буду вдаваться в детали настройки самой инфраструктуры логирования — на эту тему написано громадное количество руководств. Остановлюсь только на одной из главных проблем, с которой мы столкнулись при отладке. Когда система состоит из множества микросервисов, иногда бывает довольно трудно отследить, как, когда и какое именно действие повлияло на состояние системы. Когда количество самих записей превышает сотни тысяч и иногда доходит до миллионов, разобраться в таком массиве информации становится еще сложнее.
Чтобы упростить навигацию по логам и иметь возможность в точности отслеживать каждый запрос, решили ввести сквозную идентификацию всех событий и запросов в системе.
Работает это следующим образом: для каждого запроса к API формируется TransactionId, представляющий собой GUID. Далее, все операции внутри API, относящиеся к обработке этого запроса, используют данный идентификатор при записи логов. Таким образом, всегда можно понять, какие операции к какому конкретно запросу относятся. Однако это не все.
Этот идентификатор используется не только в рамках отдельного сервиса, но в целом в системе. При генерации команд, отправляемых в очередь (в нашем случае, как я уже писал выше, мы используем Rabbit MQ), каждая команда также содержит TransactionId. Более того, каждая добавленная в кэш запись также помечается TransactionId. Это позволяет отследить не только путь обработки каждого из отдельных запросов, но также понять, что текущий запрос отдал вам данные, которые были закэшированы в результате обработки другого запроса.
Итоги и выводы
Подобный механизм достаточно прост с точки зрения реализации, однако на порядок повышает удобство отладки. Помимо разработчиков, нововведение оценил также наш тестировщик, которому стало гораздо проще отслеживать происходящие в системе события.
Если в своем проекте вы используете или рассматриваете вариант использования Serilog, обратите внимание на этот раздел документации. Также будет интересен этот проект.
Задача № 5. Минимизация технического долга
Технический долг — одна из самых типичных проблем любого проекта. К сожалению, вред от него не всегда очевиден. В большинстве случаев его рассматривают только в разрезе устаревших технологий и необходимости тратить время на их поддержку. Но у технического долга есть и еще одна сторона, как ни странно, техническая. Это зависимость от устаревших пакетов и сторонних ресурсов. Да, большинство современных репозиториев пакетов (таких как NuGet в .NET, NPM в Node.js и других) обещают вам, что будут хранить все версии всех пакетов, которые когда-либо туда попали. Однако бывают и исключения. Кроме того, в скриптах сборки некоторых пакетов могут быть зависимости на внешние хранилища, которые и вовсе не гарантируют «вечную» сохранность файлов.
Итоги и выводы
С техническим долгом приходится бороться постоянно. В принципе, победить эту проблему раз и навсегда невозможно. Но если вы поддерживаете свои зависимости в актуальном состоянии — вы уже на верном пути. Главное — осознавать, что в живом проекте нет модуля, написанного «раз и навсегда». Нужно регулярно осматривать систему и быть готовым провести небольшое техническое обслуживание, чтобы избежать решения авральных проблем в будущем.
В завершение
Что в завершении всего этого хочется сказать? Кто бы что ни говорил, .NET Core уже полностью готов к использованию на реальных проектах, в том числе на проектах с высокой нагрузкой. Это, кстати, в точности совпадает с мои прогнозом из статьи 2016 года .
.NET Core удалось сохранить все преимущества классического .NET Framework и при этом обзавестись всеми плюсами кросс-платформенности. Думаю, это один из самых удачных примеров экспансии от Microsoft в новый для себя сегмент разработки. За короткое время платформа смогла пройти все болезни роста и сейчас надежно закрепилась на рынке.
Также хочется отметить, что при работе с высокими нагрузками система работает как будто под микроскопом. Проявляются все мелкие детали и особенности реализации. И если где-то что-то работает не эффективно или логика слишком запутана — все это обязательно проявится при работе под реальными нагрузками. Поэтому главный совет при разработке подобных систем — делать все максимально просто и очевидно, насколько это возможно.