В 2011 году 2 разработчика начали создавать свою информационную систему, чтобы через неё принимать заказы в Додо Пицце. 2 года назад мы рассказывали про раннюю архитектуру Dodo IS здесь и здесь. За это время монолит нашей системы пережил немало изменений, самое значительное произошло в этом году — мы перевели его весь на .NET 6 и переехали в Kubernetes. Переход оказался непростой задачей и длился в общей сложности год.
В этой статье поделимся деталями этого масштабного проекта, расскажем об особенностях монолита, которые усложняли переход, и об улучшениях, которые избавили от многих болей наших разработчиков.
Монолит, что с тобой не так?
Сам по себе монолит — это вполне нормальный паттерн проектирования, и некоторые компании прекрасно с ним живут. Но в нашем случае он превратился в серьёзную архитектурную проблему, которая мешала командам разработки приносить бизнесу пользу.
Начинался проект на .NET Framework 4.0, стал огромным — в какой-то момент в нём было 2 миллиона строк кода. Распиливать монолит мы начали в 2016 году, но процесс шёл медленно. Сейчас в нём 600 тысяч строк кода на С#, 200 проектов, из них 16 запускаемых. В начале 2021 года монолит всё ещё был на .NET Framework и запускался на Windows Server, и это вызывало кучу проблем.
Неудобная разработка
Для разработки монолита нужен был Windows. Но не все разработчики хотели работать на Windows — в основном они используют Mac, есть и те, кто работает на Linux. Для Mac приходилось использовать Parallels Desktop, а это дополнительные траты и неудобство.
Вдобавок, чтобы начать разработку монолита на свежем компьютере, приходилось тратить почти целый день на настройку окружения: устанавливать кучу разных SDK, Visual Studio определённой версии, IIS Express и много чего ещё. Завести монолит локально превращалось в целую историю. Добавляла проблем и собственная система конфигурации с генерацией XML-файлов (об этом будет дальше).
Долгая и дорогая сборка
Для сборки был Cake-скрипт на 500 строк, который быстро работал только на 16-32 ядерных Windows-серверах. При этом каждая сборка монолита занимала 15 минут, т.к. надо было готовить собственные специальные артефакты. Все шаги сборки шли последовательно, всё это было ещё и на TeamCity, для которого образы билд-агентов приходилось готовить самим.
Трудно тестировать
Интеграционные тесты монолита «по историческим причинам» были написаны на .NET Core и находились в отдельном репозитории. Чтобы их запустить, требовалось полноценное развёрнутое окружение. А запуск интеграционных тестов — это 6 билдов в TeamCity. В общем, процесс трудно поддерживать, тесты трудно отлаживать, трудно новые писать, ёще и делать это синхронно в двух репозиториях.
Медленные деплои
Мы используем собственную систему деплоев из-за особенностей кода и развёртывания. Вернее, из-за того, что однажды мы совершили грубейшую и страшнейшую ошибку.
В 2014 году открывалась Додо Пицца в Румынии. И вместо того, чтобы в код и в БД монолита ввести понятие «страна» и обслуживать Россию и Румынию на одних серверах, мы просто сделали копию Dodo IS для новой страны. В результате получили две параллельно работающие Dodo IS. И потом сделали так ещё 14 раз.
Поэтому сейчас у нас монолит расшардирован 16 раз — по количеству стран. И мы запускаем не 16 приложений, а 256 (на самом деле больше, т.к. нужны реплики). Все новые сервисы делаем работоспособными сразу же с несколькими странами (мы это называем country-agnostic).
Не совершайте похожие ошибки. Есть отличный документ от AWS о том, как делать multi-tenant системы.
До 2017 года деплой делался на bat-скриптах и копировании сайтов через Samba. Процесс ручной, долгий и не бесшовный — не было выведения из upstream-балансировщиков. Мы решили его автоматизировать.
Для начала решили узнать, как делают другие. Но статей про сайты на .NET Framework и похожей спецификой особо не было — многие выкладывали просто через WebDeploy или у них была другая нагрузка. В итоге за основу нового деплоя мы взяли процесс, описанный в статьях сервиса StackOverflow, потому что у нас был похожий стек. Всё, что нам нужно было сделать, — это повторить их сборку и деплой, добавив только усложнение с конфигурацией и странами.
Как устроена наша система конфигов
Раньше система конфигов была такая: в каждом из запускаемых проектов был свой файл конфигурации и к нему шла пара десятков файлов трансформации. Разработчик выбирал в Visual Studio профиль и по нему средствами MSBuild при сборке происходили трансформации.
Проблема такой системы в том, что мы, во-первых, не могли получить итоговые конфиги, кроме как билдить каждый раз весь проект. Во-вторых, было очень много дублирования, т.к. в appSettings напихали кучу всяких констант и даже настроек сервиса.
Хотелось делать 1 билд и получать все целевые конфигурации (их в 2016 было уже более 50), чтобы отделить билд от деплоя, а также убрать дублирование. Там и вспомнили про XSLT, написали небольшую тулзу, убрали файлы трансформации из проектов. Всё это заняло около 2-х месяцев работы одного инженера.
Деплой происходит так: заходим на сервер через PowerShell Remoting, настраиваем там IIS (например, создаём сайты), выводим IIS из балансировщика, обновляем сайты, вводим IIS обратно в балансировщик.
Так у нас получился собственный «недоKubernetes»
Сам процесс деплоя был автоматический, но настройка и обслуживание виртуалок были ручными: например, чтобы ввести новый балансировщик или сервер, требовалась масса времени.
Сделать на этой системе autoscaling, auto healing или ещё какие-то базовые фичи Kubernetes было очень трудно. Да и зачем, если уже есть Kubernetes — он у нас появился в 2018 году для .NET Core и всех остальных сервисов.
Production-окружение находилось на 11 серверах. Сервера были pets, а не cattle — мы знали их по именам и они выполняли только одну роль.
Виртуальная машина на Windows Server стоит в облаке как минимум в 2 раза дороже, чем на Linux. Причина стоимости понятна, ведь Windows Server — огромный комбайн с разными возможностями, Active Directory, Storage Spaces и т.д. Однако на наших веб-серверах работает только, собственно, веб-сервер и ничего больше, то есть мы платим ни за что. Никакой другой инфраструктуры на Windows Server у нас нет.
Команда SRE тратила время на поддержку двух инфраструктур: на Windows Server VM и на Kubernetes. Например, чтобы обновить подсистему логирования, её пришлось делать два раза: на K8s, что заняло в сумме неделю, и на Windows Server, что заняло два месяца.
3 попытки всё исправить
Kubernetes for Windows
Работало, но трудно было представить инвестиции, которые придётся сделать, чтобы K8s for Windows довести до паритета с K8s for Linux в нашей среде — логи, метрики, сборка, и т.д. К тому же K8s for Windows не окончательно готов: например, в Azure Kubernetes Service в 2020 году ещё не было Windows Server 2022, а образы Windows Server 2019 весили по 10 гигабайт.
Mono
Не работало из коробки, но можно было завести. Поскольку качество кода нашего монолита оставляло желать лучшего, на Mono встретились веселые баги: например, в некоторых собранных сайтах не хватало сборок. Похоже, что на Windows они забирались из GAC, а Mono так не умел.
Полный распил монолита
Рассматривали и такой вариант, ведь если полностью распилить монолит, проблем с ним не будет. Но сроки полного распила мы оцениваем в 2-3 года, причём с выделением на это ещё одной-двух целевых команд, потому что один сервис с полным реинжинирингом распиливается за 9-12 месяцев, а их 16. Таким образом, распил выглядит скорее долгосрочной стратегией, краткосрочно от него не получить пользы.
Более того, чтобы распилить монолит, в нём надо разрабатывать. А если в монолите медленная скорость разработки, то и распиливать его долго. Такой вот замкнутый круг.
В итоге мы посчитали, что если перейдём на .NET 6 и Kubernetes, то сэкономим около 10% стоимости нашей инфраструктуры в месяц только на серверах, не считая opportunity cost ускоренной разработки и деплоя. Сократим расходы за счёт лицензий на Windows и за счёт повышения утилизации серверов, например, autoscaling.
Перевод монолита на .NET 6
Проект стартовал в мае 2021 года. К этому моменту 4 из 16 сервисов уже были на .NET Core 2.1-3.1. Большая часть библиотек тоже была на .net standard 2.0. Соответственно, предстояло обновить 12 сервисов разного размера — в каких-то было по паре контроллеров, а в нескольких было по 300 Razor Views и контроллеров.
Список сервисов в монолите для понимания масштаба
AlertServer.Web — сервис уведомлений пиццерий, в процессе проекта был выпилен из монолита.
Admin.Web — b2b админка.
DeveloperDashboard.Web — внутренняя админка для разработчиков.
Api — API для интеграций.
ExportService — API для интеграций с 1C.
Auth.Web — сервис аутентификации и авторизации.
OfficeManager.Web — интерфейс менеджера офиса пиццерий.
CallCenter.Api — API колл-центра.
CallCenter.Web — интерфейс колл-центра.
PrivateSite — личный кабинет сотрудника.
RestaurantCashier.Web — касса ресторана.
CashHardware.Web — сервис кассового оборудования, отвечает за печать фискальных чеков.
ShiftManager.Web — интерфейс менеджера смены.
ClientSite.LegacyFacade — внутреннее API монолита.
Communications — сервис отправки СМС и имейлов.
Bus.Jobs — консьюмеры монолита.
Scheduler — сервис джоб, запускаемых по расписанию.
Хронология перехода
Начали с того, что установили .NET 5 на Windows виртуальные машины, агенты, настроили инфраструктуру стенда, почистили репозиторий и проекты. Переключились на другие задачи, сходили в отпуск.
За лето перевели AlertServer.Web. В процессе обнаружили, что в солюшене очень большая связаность, из-за которой сложно изолированно что-то обновлять, packages.config затрудняет обновление и рестор пакетов, почти невозможно пользоваться райдером.
Дело в том, что проекты на packages.config не поддерживают транзитивные ссылки на пакеты, выкачивают все свои пакеты в ./packages директорию и райдер (и VS) нещадно тормозят при любой попытке что-то сделать. При переводе csproj на PackageReference пакеты обновляются мгновенно, и ресторятся в кеш, а оттуда подтягиваются ссылками в project.assets.json.
В итоге вместе с основным проектом пришлось перевести половину репозитория и библиотек на .net standard 2.0 и PackageReference. Дальше дело пошло быстрее, т.к. основная часть перевода — это обновление пакетов.
С конца августа и до ноября переводили Auth, LegacyFacade & Consumers, отвлекались на тесты, зелёный пайплайн, перенос приватных NuGet пакетов на GitHub Packages (с myget.org).
В ноябре к нам присоединились 2 новых человека, бэкендеров на проекте стало трое. Стартанули и закончили перевод сразу двух сервисов — CallCenter.Api и Api, перенесли автотесты на .NET 5, принялись за CallCenter.Web.
Декабрь — январь: взялись за Shiftmanager.Web на мега-системе с проксированием на основе YARP, закончили с ним и с CallCenter.Web.
К концу февраля обновили весь солюшен на .NET 6.0. Тогда же подключили ещё 4 разных команды к переводу оставшихся четырёх сервисов: Admin.Web, CashHardware.Web, OfficeManager.Web, RestaurantCashier.Web. В этот момент проектом занимались уже 15 человек. При этом ребята, которые присоединились к переводу на старте, выступали уже в качестве консультантов для «новичков».
В марте занимались в основном мелкими «доделками» и приступили к переезду в Kubernetes:
подготовили сборку, тестирование на Linux & GitHub Actions;
сделали приложения кросс-платформенными;
убрали System.Drawing;
везде пофиксили пути (как минимум сепараторы с \ на /), кракозябры(html encode).
В мае начали деплоить монолит в Kubernetes, перевели первые тестовые стенды и canary-продакшены с низкой нагрузкой.
При нагрузочном тестировании обнаружили море косяков, в основном в sync over async коде. Как фиксили:
использовали Ben.BlockingDetector, чтобы найти блокирующий код;
jaeger tracing;
Обнаружили проблему с graceful shutdown: если его не сделать, поды будут убиваться во время того, пока на них идут запросы, и клиенты будут видеть ошибки. Для корректной работы в Kubernetes приложение должно правильно обрабатывать внешние сигналы об остановке (SIGTERM). Как настроить обработку SIGTERM описано тут.
И, наконец, в июне полностью вся Dodo IS заработала в Kubernetes!
Пример чеклиста для апгрейда сервиса с .NET Framework 4.8 на .NET 6
Начинаем или с .NET upgrade assistant, или с convert packages.json to nuget references (инструмент в Rider/Visual Studio).
-
Обновляем csproj файл:
обновляем заголовок файла на Project Sdk="Microsoft.NET.Sdk.Web";
обновляем версию фреймворка (TargetFramework) до NET6.0;
удаляем из проекта все неизвестные науке targets;
Удаляем все левые PropertyGroup, кроме необходимых.
Удаляем все itemgroup, относящиеся к включению в проект cs и других файлов.
Удаляем импорт csharp.targets
Удаляем все пакеты System.* Microsoft.*
Пытаемся удалить все пакеты, которые нужны только как зависимости.
Если есть бинарная сериализация, её нужно явно включить EnableUnsafeBinarySerialization
-
Обновляем Startup:
удаляем Global.asax
удаляем ненужные зависимости
удаляем owin
используем .NET 6 хост
используем UseForwardedHeaders в стартапе.
Переносим конфигурацию на Microsoft.Extensions.Configuration.
Обновляем логирование: переход на Microsoft.Extensions.Logging.
Обновляем метрики: либо последняя версия prometheus-net, либо OpenTelemetry.
По возможности отказываемся от Autofac в пользу Microsoft.Extensions.DependencyInjection.
Внимательно относимся к сервисам, которые стартуют: переделываем на HostedService.
Обновляем Swagger на ASP.NET Core версию.
Обновляем SignalR на ASP.NET Core версию.
Обновляем MassTransit на последнюю версию.
Обновляем все middleware.
Если во вьюхах попадается HtmlString, меняем его на IHtmlContent.
-
Обновляем аутентификацию и авторизацию:
анализируем использование Session и SystemUser (нашу собственную абстракцию над аутентифицированным пользователем);
не забываем, что Session в ASP.NET Core по умолчанию синхронная. Надо вызывать .LoadAsync() перед любой работой, чтобы она стала асинхронной.
-
Обновляем контроллеры:
-
перепиливаем сигнатуры методов контроллеров, чтобы везде возвращалось ActionResult, например:
было
public PartialViewResult PartialIndex()
стало
public IActionResult PartialIndex()
проверяем сериализацию: либо оставляем Newtonsoft.JSON, либо везде с него съезжаем на system.text.json;
в JSON-сериализации изменились дефолты casing, учитываем это (JsonSerializerOptions.PropertyNamingPolicy = null).
-
Настраиваем роутинг, байндинг моделей.
Не забываем перевести тестовые проекты.
Всего проделали мы это 15 раз. Какие-то сервисы занимали одну неделю (если были на OWIN и в них было 5 контроллеров), какие-то два месяца.
Резюме и итоги
Задача по обновлению монолита — процесс долгий. Нам удалось относительно быстро это сделать за счёт того, что люди добавлялись в проект поэтапно. Мы начинали с одного разработчика в мае 2021, в ноябре добавили ещё двоих, часть сервисов переводили сами команды-владельцы. Однако в конце февраля мы решили ускориться и подключили ещё 4 команды к проекту, и внезапно всё доделали за три недели. Удалось это благодаря тому, что у нас уже была экспертиза по переводу, которую мы могли пошарить на 4 команды, работая в них приходящими экспертами.
Большая часть сложности заключалась в инфраструктурных библиотеках. Т.е. сам C# код работал плюс-минус так же, а вот инфраструктурные библиотеки и фреймворк работали абсолютно по-другому. Поэтому в самом простом случае всё обновление заключалось в том, чтобы обновить все внешние библиотеки: NLog, MassTransit и т.д., обновить Startup/Program.cs. Например, мы много раз хотели отказаться от Autofac в пользу Microsoft DI и откладывали это, потому что тогда пришлось бы потрогать буквально весь солюшен.
Мы перевели 16 сайтов на .NET6, потрогали больше 400 Razor Views, выпилили несметное количество старого кода и библиотек, удалили больше 100 серверов, несколько доменов, кучу конфигураций из TeamCity.
Теперь у нас только одна инфраструктура для всех. Монолит можно разрабывать на всех платформах (Windows, Linux, MacOS), сборка занимает 6 минут, обновление на продакшене — 10 минут. Для .NET 6 мы используем GitHub Actions, в котором есть hosted runners, за которые платишь посекундно и ничего не надо настраивать. В итоге экономили 10% месячных расходов.
Надеюсь, наш опыт поможет вам избежать ошибок, если придётся столкнуться с похожей задачей.
Комментарии (16)
benjik
01.11.2022 21:38-2Какие-то уроки после этого веселья извлекли?
На net 7.0 переезжать будете, или "оно и так работает"?entropy Автор
02.11.2022 02:18+1Извлекли, конечно. В целом, мы сейчас гораздо серьезнее относимся к техдолгу.
На .NET 7 переедем, да. Инвестиции на переезд 6.0 -> 7.0 будут копеечные.
Averutin
02.11.2022 11:27А есть ли смысл переезжать на каждый новый релиз? NET6 так-то LTS, и переходить с него надо на следующий LTS имхо.
entropy Автор
02.11.2022 11:36+2https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/
Экономия на дороге не валяется. Мы переведем только нагруженные сервисы с активной разработкой, в них будет четко виден эффект. И их же потом на .NET 8 переведем, сразу же как он выйдет.
Hixon10
02.11.2022 00:23+2Привет, у меня есть два вопроса:
- Не совсем понятно одно место. У вас был монолит, а потом… бах, и сразу переход к 16 сервисам?
- Были ли какие проблемы с Ben.BlockingDetector, или просто вставили либу, и начали логи собирать?
ArkadiyShuvaev
02.11.2022 02:03+1Ben.BlockingDetector
+1 Тоже хотел бы побольше относительно этого вопроса узнать.
entropy Автор
02.11.2022 02:24+1-
У нас "модульный" монолит, в нём 200 проектов, 16 запускаемых.
Раньше весь веб и все бэкграунд джобы были в одном сайте (IIS application pool). Затем году в 2016 этот огромный сайт разделили на раздельные запускаемые проекты-сайтики и джобы, всего их около 30 получилось. До разделения оно структурировалось при помощи aspnet areas. Например, подсистема доставки работала по условному урлу backoffice.dodopizza.ru/delivery, а после разделения стала обслуживаться отдельным app pool на урле delivery.dodopizza.ru
До 2022 дожили 16 запускаемых проектов(бинарей), которые собираются из одной монолитной кодовой базы и ходят в одну базу данных.
Вставили либу, начали логи собирать. Затем по логам пописали KQL запросы, сагрегировали топ блокирующих методов и пошли их чинить.
-
hbn3
02.11.2022 00:55Как насчёт автоматизции процесса разбития монолита? Создавали инструметы для уменьшения рутины?
entropy Автор
02.11.2022 02:30+1Хотелось бы, но с нашим монолитом так не получится, as is код оттуда не вынести - слишком большая связность. Каждый вынос домена из монолита это творческое переосмысление этого домена и по сути написание кода с нуля, мало что получается переиспользовать. Но были и сервисы которые практически неизменными вышли.
В общем, могу себе представить такое для какого-то сферического идеального модульного монолита в вакууме, в котором код писали сразу же с учетом такого будущего выделения сервисов.
hbn3
02.11.2022 20:06Ну мой вопрос был скорее про инструменты которые помогают в рутинных процессах.
Например через Roslyn пройтись по коду и переделать часто встрачающиеся конструкции на одобренные в рамках новой концепции.
Ещё вопрос — самописные анализаторы специфичные для вашего проекта/стиля используете?
MiSaf
02.11.2022 11:15Поясните, пожалуйста, момент c
По возможности отказываемся от Autofac в пользу Microsoft.Extensions.DependencyInjection
почему к этому пришли? AF работает в Core, возможностей предоставляет больше чем MS DI.
Плюс их же можно подружить друг с другом.Сейчас как раз думаем над моментом использования Autofac в Core.
entropy Автор
02.11.2022 13:10+1У нас во всех сервисах кроме монолита MS DI. Фичи autofac нам не нужны, да и медленнее он(https://github.com/danielpalme/IocPerformance). В общем, у нас он лишний.
K1aidy
04.11.2022 08:27+1Обилие фич аутофака позволяет усложнять код по самое "не могу". Для огромных монолитов это со временем может вырасти в "ещё одну проблему при рефакторинге". Пользуясь стандартным функционалом неткора прям начинаешь думать, а как бы сделать проще.
Rinatsin
Спасибо!