В 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% месячных расходов.
Надеюсь, наш опыт поможет вам избежать ошибок, если придётся столкнуться с похожей задачей.