В марте 2026 года 97 из 150 микросервисов Mindbox были реализованы на разных версиях .NET: от Core 3.1 до 9. Среди них было несколько крупных репозиториев, которые мы называем «монстрами», — туда сходятся десятки проектов, и каждое обновление приводит к каскаду изменений по зависимостям. Чтобы вручную мигрировать это все на .NET 10, понадобилось бы привлечь небольшую команду разработчиков минимум на три месяца. 

На связи Александр Корчак, архитектор Mindbox. В этой статье расскажу, как я перевел все 97 микросервисов и наш монолит на .NET 10 с помощью автономного AI‑агента за 17 рабочих дней.

Материал будет полезен разработчикам, архитекторам и платформенным инженерам, которые хотят делегировать AI‑агентам большие повторяемые задачи.

Зачем понадобилось подключать автономного AI-агента

Всю кодовую базу Mindbox поддерживают 15 команд разработки. Мы пишем на .NET, а Microsoft стабильно релизит его новые версии раз в год. Одни команды успевают регулярно обновлять фреймворк на своих проектах, а другие откладывают миграцию из‑за продуктовых задач, срочных дефектов и ежедневной рутины. Со временем управляться с «зоопарком» версий .NET становится дорого: сервисы неоптимально потребляют ресурсы, разработчики не могут использовать новые фичи языка на всех проектах, а старые версии фреймворка перестают поддерживаться. Но пока продукт стабильно работает, направлять на обновление двух‑трех разработчиков нецелесообразно.

Я решил проверить, удастся ли с помощью AI‑агента справиться с таким техдолгом, и сам занялся миграцией, не привлекая других разработчиков. Поскольку основное время у меня уходит на встречи и другие проекты, на эту задачу ежедневно я мог потратить только 2–3 часа.

Что меня вдохновило: миссия Mars Reconnaissance Orbiter и марсоход Perseverance

В декабре 2025 года марсоход Perseverance проехал 456 метров по кратеру Езеро. Обычно для таких поездок нужна команда инженеров, 5–6 часов для расчета промежуточных точек маршрута и время на обмен сигналами между Марсом и Землей, по 20 минут в одну сторону. На этот раз весь маршрут для марсохода впервые в истории освоения космоса построил AI-агент. Он получил данные миссий Mars Reconnaissance Orbiter, проанализировал орбитальные снимки, идентифицировал препятствия, написал команды для ровера и разметил промежуточные точки. После этого NASA только проверили маршрут на симуляторе, внесли небольшие правки и отправили пакет команд марсоходу. 

Меня вдохновил «космический» пример автономности — это именно та модель работы, которая была нужна для миграции. Я поселил агента в автономном workspace Coder в Kubernetes, потому что так можно было изолировать всю работу на дисковом, сетевом и процессном уровне. Агент мог строить контейнеры, ронять процессы, ставить пакеты, ломать что хочет — на хост‑машину и прод это не влияло. При этом мой ноут не был занят сборкой и тестами, я продолжал работать на нем как обычно.

Как я сделал агента автономным: инфраструктура, sandbox и настройки

Для Coder у нас есть кастомный шаблон с образом coder‑agentic‑sandbox. Вот набор основных инструментов, которые входят в образ:

  1. CLI для AI‑агента.

  2. CLI‑прокси rtk — фильтрует вывод типовых команд и экономит токены.

  3. Три SDK ‑ .NET 8, 9, 10. 

  4. GitLab CLI.

Чтобы агент был полностью автономным, потребовалось установить и настроить дополнительные инструменты, а также выдать права и зафиксировать ограничения.

Автоматическое подтверждение действий. По умолчанию AI‑агент запрашивает разрешение перед каждой операцией, которая может изменить проект или окружение. Например, «можно я выполню вот этот bash‑скрипт?», «можно создать временный python‑файл для анализа?» или «можно сделать коммит?». Это разумная защита, если он запущен на рабочем ноуте и оператор вовлечен в процесс. Однако, если агент работает в sandbox и самостоятельно решает задачу несколько часов, подтверждать его действия некому. Поэтому в нашем образе мы отключаем запросы со стороны агента целиком. Для этого запускаем его с полным набором прав.

Среда уже изолирована — pod крутится без доступа в продакшен в отдельной сети на эфемерном диске. Workspace можно пересоздавать с нуля без потерь в любой момент.

В AGENTS.md мы зафиксировали основные правила работы для агента. Ему разрешили самостоятельно выполнять ряд действий:

  • клонировать репозиторий, 

  • подтягивать зависимости, 

  • собирать солюшен,

  • прогонять тесты в Docker, 

  • создавать ветки, 

  • пушить в Git,

  • открывать MR, 

  • мониторить пайплайны CI и фиксить до зеленого. 

При этом агенту запретили заливать в main напрямую или мерджить свой собственный MR. Ревью и мердж я делаю сам.

AGENTS.md

# Coder Agentic Sandbox

You are running inside an isolated Coder workspace (Kubernetes pod). This is a fully disposable environment — nothing you do here affects production systems.

## Environment

- OS: Ubuntu

- Docker: Available via DinD sidecar at tcp://127.0.0.1:2375 (DOCKER_HOST is pre-configured)

- .NET SDK 8.0, 9.0, 10.0 are pre-installed (dotnet is in PATH)

- Private NuGet feed: https://artifactory.mindbox.ru/artifactory/api/nuget/v3/nuget/index.json

- glab (GitLab CLI) is pre-installed and configured with user credentials

- Git is pre-configured with user credentials

- GitLab: All repositories are in the development group at https://mindbox.gitlab.yandexcloud.net/development/

## What you can do

- Run any shell commands freely — this is a sandbox

- Build and run Docker containers (docker build, docker run, docker compose)

- Run tests, including integration tests that require Docker (testcontainers, DinD)

- Install additional packages via apt, npm, etc.

- Create, modify, and delete any files in the workspace

- Commit and push to feature branches

- Clone any repository if needed for the task (e.g. dependencies, shared libraries, related services)

- Create merge requests via glab mr create

- Monitor CI pipelines via glab api: .../merge_requests/<MR_ID>/pipelines (status), .../pipelines/<ID>/bridges (downstream), .../pipelines/<ID>/jobs (jobs), .../jobs/<ID>/trace (logs)

## Branch Naming

Always create branches in the format: username/slugified-task-name-in-kebab-case

The username is provided in the "Current User" section below.

## What you must NOT do

- NEVER push to main or master branches directly

- NEVER use --force push

- NEVER merge any branches (git merge, git rebase onto main, etc.)

- NEVER merge or approve merge requests via glab mr merge, glab mr approve, or any other method

- NEVER close or delete merge requests

## .NET

Always pass -maxcpucount:1 to all dotnet operations (build, test, publish, restore, etc.) to avoid overloading the pod's CPU.

## Workflow

- Before starting work in a repository, read its AGENTS.md if exist

- Work incrementally — make small commits, prefer TDD

- For NuGet packages or DLLs: check local copy first; if the package is from Mindbox GitLab — clone the source repo instead of decompiling

- When changes are ready, create an MR via glab mr create

- After pushing, monitor CI: poll every 120 seconds until green or failed, fix failures and push again (see Iteration Limits)

## Iteration Limits

- Local fixes: max 10 iterations on build/test failures. On limit: commit progress, push to branch, stop and report — what was done, what fails, why it cannot be fixed.

- CI fixes: max 5 pushes while CI is red. On limit: stop and report — what was done, what fails, why it cannot be fixed, link to MR.

- Never leave unpushed changes when stopping.

Запреты оформили только на уровне промпта, и пока что агент их не нарушает. В будущем для делегирования задач урежем scope PAT‑токена, чтобы некоторые действия были физически невозможны на уровне GitLab API.

DinD‑сайдкар. Рядом с workspace‑подом крутится отдельный контейнер с Docker daemon, а переменная окружения DOCKER_HOST=tcp://127.0.0.1:2375 указывает на него. Это значит, что внутри агентского пода можно запускать docker build, docker compose up и использовать testcontainers. Для .NET‑миграции это критично: у нас почти везде интеграционные тесты поднимают Postgres, Kafka и ClickHouse в testcontainers. С DinD‑сайдкаром агент локально прогоняет полный набор тестов, не делегируя их в CI.

Общий набор плагинов и скиллов. В Coder‑шаблоне подключается наш внутренний реестр плагинов, скиллов и MCP‑конфигураций. Он нужен, чтобы агент мог использовать готовый плагин уведомлений в корпоративный мессенджер. Сама миграция оформлена в скилл dotnet‑migration, который тоже сохраняется в реестр. В будущем разработчик из другой команды, которому понадобится мигрировать свой сервис на .NET 11, вместе с workspace получит уже готовый для этой задачи скилл.

Дополнительный контекст через MCP. Для миграции я использовал два MCP‑сервера:

  1. sourcebot, с помощью которого агент умеет искать код в наших Gitlab‑репозиториях. Например, реализацию обхода уже известных breaking change или места, где все еще используется старый API.

  2. backstage — каталог сервисов. Помогает агенту отличать репозитории микросервисов, которые крутятся в проде, от всех прочих. Это экономит время, когда в компании больше ста сервисов.

Когда я поднимаю workspace из нашего образа, мне достаточно просто залогиниться в CLI для агента. А он уже сам знает, где лежат наши репозитории, имеет к ним доступ, умеет поднимать инфраструктуру для тестов и работать по пайплайну до зеленого MR: все описано в AGENTS.md и есть в контексте агента. Токены для NuGet и git уже прокинуты в окружение через секреты Coder.

Мы интегрировали агента и корпоративный мессенджер Time от Т‑Банка с помощью бота. Когда агент заканчивает работу или ждет подтверждения, он пишет мне в личку. Я могу выдать ему задачу и заняться другими делами.

Пуши от агента приходят в Time, когда я не у компа, — можно заниматься другими делами, пока агент сам не позовет
Пуши от агента приходят в Time, когда я не у компа, — можно заниматься другими делами, пока агент сам не позовет

Как создал скилл на примере ручных миграций

После того как мы подготовили sandbox, я написал большой промпт и выдал агенту в работу первый сервис. Когда агент успешно мигрировал пару сервисов, стало ясно, что промпт удачный и его можно переселять в отдельный пакет с инструкциями, который агент будет подгружать по триггеру — skill. Для этого я использовал встроенный skill creator.

К началу моей работы некоторые команды уже перевели на .NET 10 примерно 50 микросервисов Mindbox. Я подсказал skill creator, какие успешные проекты можно изучить, он получил коммиты, проанализировал изменения и построил основные паттерны миграции. Благодаря этому я не собирал скилл с нуля, а получил его рабочий скелет за один прогон.

В финальной редакции получился скилл из десяти последовательных шагов:

Шаг 1. Клонировать репу в /tmp, переключиться на ветку username/migrate-to-netXX.

Шаг 2. Изучить конфиги:

  • Directory.Build.props,

  • Directory.Packages.props,

  • global.json,

  • Dockerfile,

  • .gitlab/*.yml.

Прогнать grep по характерным маркерам breaking changes и найти: 

  • старые API MSTest, 

  • Sentry,

  • удаленные KnownNetworks, 

  • проблемные pragma warning disable.

Шаг 3. Применить изменения по гайду миграции.

Шаг 4. Собрать солюшен через dotnet restore и dotnet build /warnaserror. Если в коде есть warning, агент будет считать их ошибками и исправлять.

Шаг 5. Запустить тесты. Сначала и всегда локально агент запускает unit‑тесты. Если они прошли успешно, то скорее всего интеграционные тоже пройдут. Поэтому первый прогон интеграционных тестов агент делегирует CI, это делается для экономии токенов. Если CI роняет тесты, тогда агент через DinD локально разбирается и фиксит до зеленого.

Шаг 6. Проверить диффы тестов. Если они большие, агент должен убедиться, что не затронул логику, а внес только механические изменения: удалил лишние импорты библиотек, переименовал метод.

Шаг 7. Обновить все пакеты до latest в рамках известных ограничений по версиям.

Шаг 8. Через glab создать MR с полным списком изменений на русском языке в описании.

Шаг 9. Ждать CI. Агент каждые 90 секунд проверяет состояние пайплайна. Если есть ошибки, находит упавшую джобу, читает ее лог, вносит изменения в код и снова пушит. И так до тех пор, пока CI успешно не завершит работу.

Шаг 10. После прохождения CI обновить описание MR.

Как менял скилл: оптимизировал структуру и использовал AI-рецензента

Поначалу скилл состоял из 87 строк инструкций и 207 строк сопроводительного гайда. Финальный SKILL.md разросся до 200 строк, а гайд миграции — до более чем 400 строк.

В первые две недели я вносил изменения в скилл по результатам работы агента. Миграция каждого следующего микросервиса преподносила свои сюрпризы. Например, были такие случаи:

  1. Sentry 6.x падал с ArgumentNullException, потому что в тестовом конфиге DSN значение null стало невалидным. 

  2. Связка docker compose up ‑-abort‑on‑container‑exit ‑exit‑code‑from давала ложный зеленый, если зависимость в depends_on падала до начала тестов. А это значило, что в некоторых репозиториях тесты не запускались годами и никто этого не замечал.

Когда я уже обновил половину микросервисов, скилл начал «забывать» инструкции. Добавляю правило, а агент его игнорирует; добавляю к правилу пометку «обрати внимание» — тот же эффект; пишу капсом «КРИТИЧНО, ПОВТОРЯЛОСЬ БОЛЬШЕ 4 РАЗ» — не помогает.

В тот момент скилл был больше похож на карту минного поля .NET 10 для нашего стека, чем на инструкцию «Как обновить TargetFramework». В нем было все:

  • таблица ограничений по версиям пакетов. Например, использовать FluentAssertions можно только до 7.2.2 версии, а в версию 8.х лезть нельзя, потому что она уже платная;

  • паттерны тестов для BackgroundService; 

  • десятки точечных правил по каждому типу .csproj.

Инструкции были свалены в кучу без приоритетов, явных preconditions и блока критических правил, а troubleshooting был размазан по тексту. Агент честно читал все, но при ограниченном внимании не выделял важное. Такое капсами не лечится, поэтому я перестал писать в скилл все новые и новые правила и занялся его структурой. 

В нашем внутреннем marketplace плагинов есть отдельный инструмент — AI-рецензент. Это скилл, который ревьюит другие скиллы. Он принимает на вход скилл-пакет и проверяет его по нескольким группам критериев: 

  1. Корректно ли оформлены SKILL.md и YAML frontmatter, вынесены ли справочные материалы в references/, не превратился ли скилл в монолит.

  2. Не описан ли workflow длинным монологом вместо пошаговой процедуры. Все ли шаги понятны, есть ли условия запуска, завершения, остановки и восстановления после падения.

  3. Эффективно ли используется progressive disclosure. Есть ли в папке references/ карта файлов и явные условия, когда обращаться к каждому файлу. 

  4. Работают ли внутренние ссылки, не сломаны ли якоря.

  5. Указаны ли версия и владелец пакета и соблюдены ли правила работы с секретами.

Я прогнал свой скилл через AI‑рецензента, получил список замечаний. На их основе вынес в отдельные блоки preconditions, критические правила, troubleshooting, секцию Done when и чек‑лист. 

В результате агент перестал забывать критические правила и стал чаще доводить миграции до готового MR без моего вмешательства. Раньше я мог принять в среднем 5–6 MR в день: часть времени уходила на исправление ошибок агента. После ревью скилла качество MR выросло, ручной работы стало меньше и в один из дней мы смогли выпустить 11 MR.

Открытые материалы, на основе которых мы собрали своего AI-рецензента
  1. Agent Skills Specification — базовая спецификация скилл-пакета.

  2. Anthropic — Agent Skills Overview — как загружаются и используются скиллы.

  3. Anthropic — Effective Context Engineering for AI Agents — checkpoints, planning artifacts, long-context failure modes.

  4. Anthropic — Building Effective Agents — практические принципы агентных workflow.

  5. Manus — Context Engineering for AI Agents — управление длинными задачам.

Как агент помог найти техдолг и навести порядок в коде

Выше я упоминал 50 микросервисов, которые перевели на .NET 10 до меня. Миграция части из них сводилась к тому, что разработчики просто подняли TargetFramework и LangVersion, убедились, что солюшен собирается, и замерджили изменения. В общем, сделали необходимый минимум. Я же решил приводить репозитории в более здоровое состояние, чем они были до миграции. Поэтому добавил в скилл инструкции, чтобы агент за один проход чинил все, что легко чинится. В итоге, помимо обновления фреймворка, агент:

  1. Централизует пакеты в Directory.Packages.props. Если файла нет, агент создает его и переносит туда версии зависимостей из всех .csproj. Без этого могут обновиться не все пакеты. 

  2. Подтягивает все наши внутренние пакеты до latest: был репозиторий, в котором одна библиотека отставала на 29 мажорных версий.

  3. Удаляет NuGet‑пакеты, которые есть в зависимостях, но давно вошли в BCL: Microsoft.Bcl.AsyncInterfaces, System.Threading.Tasks.Extensions, System.Memory и подобные.

  4. Чинит типовые проблемы после обновления фреймворка: изменения API в MSTest, Assert, Sentry и Entity Framework Core.

  5. Зачищает <NoWarn> и #pragma warning disable в продакшн‑коде. Если может пофиксить — фиксит. В остальных случаях заменяет на адресные [SuppressMessage] с Justification

  6. Подключает IDisposableAnalyzers через Directory.Build.props. Это значит, что солюшен не соберется, если разработчик забудет про using. 

  7. Проксирует через Yandex Cloud mirror все Docker‑образы, включая тестовые контейнеры: docker.io/<image> → cr.yandex/.../docker.io/<image>. По нашей политике все образы должны загружаться через прокси. 

  8. Находит и чинит нестабильные unit‑тесты. Обычно это либо забытый Dispose в тесте, либо BackgroundService‑тест без правильного паттерна синхронизации.

Благодаря этому комплексному подходу агент подсветил четыре скрытые проблемы во всех репозиториях компании. Не думаю, что мы бы их когда-нибудь нашли, если бы не AI.

Проблема № 1: Renovate «не работал» два года. Renovate был включен в большинстве репозиториев, но открытые им MR висели с мая 2023 года. Их либо не замечали, либо открывали, смотрели, что CI падает… и уходили.

Renovate-MR в одном из репозиториев
Renovate-MR в одном из репозиториев

В некоторых репозиториях Renovate вообще не работал. У нас в GitLab был баг, и джоба не запускалась. Никто этого не замечал, потому что у самой репы был зеленый CI. А отсутствие открытых MR интерпретировали как «у нас все свежее». Когда я сигнализировал о проблеме, наша SecOps‑команда все починила. После этого в одном микросервисе агент обнаружил, что аж у 16 внутренних пакетов нужно разом обновить версии. Это означало, что репозиторий отставал от внутренней экосистемы на несколько лет.

Дифф Directory.Packages.props: обновили версии 16 внутренних пакетов
Дифф Directory.Packages.props: обновили версии 16 внутренних пакетов

Проблема № 2: MSTest игнорировал тесты. В старых версиях MSTest был баг: из множества тестовых методов с одинаковым именем, но разной сигнатурой выполнялся только один. Остальные методы MSTest молча игнорировал. И тесты вроде есть, и чек зеленый, но некоторые из них никогда не выполнялись. При миграции на обновленный MSTest такие коллизии выдают ошибку компиляции. В нашем монолите 17k тестов, и агент обнаружил среди них четыре, которые никогда не выполнялись при стабильно зеленом CI.

Проблема № 3: Docker Compose скрывал падение тестов. В некоторых репозиториях запуск интеграционных тестов был настроен через Docker Compose с комбинацией флагов up ‑abort‑on‑container‑exit ‑exit‑code‑from tests. Когда одна из зависимостей из блока depends_on падала еще до запусков тестов, Docker Compose отправлял сигнал SIGTERM контейнеру tests по флагу ‑abort‑on‑container‑exit. Контейнер останавливался с кодом 0, и Docker Compose, руководствуясь флагом ‑exit‑code‑from tests, считал этот код результатом выполнения всего Compose‑сценария. То есть тесты могли не запуститься, но CI был зеленым.

Во всех проектах агент заменил команду на docker compose run ‑T tests: run. Так Docker Compose ждал healthchecks всех зависимостей и корректно обрабатывал код выхода из теста. В одном репозитории после этого исправления нашлись два стабильно падающих теста, которые до этого никогда не запускались.

Проблема № 4: настройки версии .NET противоречили друг другу. В некоторых репозиториях версия TargetFramework могла быть указана одновременно в разных местах:

  • global.json,

  • Directory.Build.props,

  • .csproj.

Зачастую это были три разные версии .NET.

При миграции агент устранял неопределенность и во всех проектах прописывал TargetFramework в Directory.Build.props.

Благодаря ревизии всей кодовой базы мне удалось выявить типичные недочеты оформления кода и работы с репозиториями. Чтобы в будущем не накапливать инфраструктурный долг, мы поменяли некоторые процессы:

  • обязали команды использовать внутренний сервис TeamSLA. Например, теперь подвисшие MR автоматически эскалируются до дежурного команды; 

  • включили Renovate и контролируем его работу во всех репозиториях. Если Renovate перестает работать, владельцам репозитория приходит уведомление;

  • подключаем IDisposableAnalyzers и аналоги во всех проектах по умолчанию через Directory.Build.props. Это делали и раньше, но не везде. А теперь это железное правило.

В целом, этот минимум при следующей миграции позволит агенту просто поднять версию фреймворка, потому что все остальное уже в порядке.

О чем не подумал на старте, но это не катастрофа

Расскажу о неочевидных проблемах, с которыми я столкнулся, используя автономного агента для решения end‑to‑end задачи. Часть проблем удалось сходу решить минимальными усилиями, а с некоторыми придется мириться, потому что так устроен мир.

Бесконтрольно расходует токены. Сразу после начала миграции стало понятно, что агент в стремлении выполнить задачу может ходить по кругу. Чтобы он почем зря не сжигал токены, я прописал два лимита в AGENTS.md:

  1. 10 локальных попыток исправить ошибку сборки,

  2. 5 попыток прогнать пайплайн в CI без ошибок.

Когда агент достигал одного из лимитов, он прекращал работу, коммитил и пушил изменения и присылал мне отчет.

Съедает машинные ресурсы. Самый «жирный» микросервис состоит из десятков проектов в одном решении. Чтобы скомпилировать этот сервис, требуется много машинных ресурсов. При миграции агент запустил сборку, и MSBuild по умолчанию развернул пул потоков на все доступные ядра пода. Пул быстро уперся в наши лимиты по CPU и памяти, и Kubernetes завершил под по OOM. И я только через час обнаружил, что агент потерял сессию и работа не выполнена.

Для крупных репозиториев параллелизм MSBuild опасен. Я добавил в AGENTS.md правило для всех dotnet‑команд: ‑maxcpucount:1. Сборка стала медленнее, но стабильнее.

Не может оценить точно, сколько потратит ресурсов. Каждый раз, когда я выдавал агенту задачу, я не мог предсказать, сколько денег и времени уйдет на миграцию сервиса. Например, маленькие heartbeat-сервисы со свежими зависимостями стоили нам 20–30 ₽ и 5–10 минут. А большие «монстры» с десятком проектов внутри — от 3000 до 6500 ₽ и несколько часов работы агента. 

К слову, вся затея с миграцией обошлась компании в 110000 ₽. В среднем 400 ₽ за один сервис.

Тупит, если контекст находится в других исходниках. После миграции могут появиться баги, и иногда они находятся вне кодовой базы микросервиса. Если стек‑трейс исключения уводит агента в одну из наших внутренних библиотек, то без ее исходников агент начинает тупить. Он может долго проверять разные гипотезы наугад и в итоге зацикливаться на неправильной. 

Чтобы этого не происходило, заставили агента клонировать репозитории библиотек и разбираться с багом, имея «на руках» все нужные исходники. Когда агент видит, что в репозитории используется Mindbox.Validation 5.3.1599, ему нужно посмотреть исходники именно этой версии. Проще всего, когда версия библиотеки помечена тегом, — тогда достаточно сделать git checkout 5.3.1599. А вот когда тега нет, агенту приходится искать конкретный коммит по всем джобам release‑пайплайнов. Это долго, а иногда нужный коммит и вовсе не удается найти. Чтобы избежать такой неопределенности, планируем автоматически тегировать все наши внутренние пакеты при выпуске.

Опережает пропускную способность человека. На ревью одного сервиса в среднем у меня уходило 20 минут, поэтому, учитывая основную занятость, я успевал просмотреть 5–6 MR в день. А агент в автономном режиме мог создавать до 40 и больше MR в день — это 13 часов на ревью. Такого фултайма у меня не было. И если бы агент допустил ошибки во всех 40 MR, мне бы пришлось просить его все переделать, а потом снова 13 часов ревьюить. Так что мощь и автономность агента уперлись в мой физический предел — узким местом проекта оказался человек.

Почему откатили монолит: сырой релиз .NET 10

Когда агент закончил миграцию и все 97 микросервисов ушли в продакшен, мы не обнаружили ни одного дефекта. Похвастаться такой же историей про монолит не могу: после обновления версии он стал нестабильно работать. 

Heartbeat-тенанты монолита падали по OOM. Потребление памяти сходу выросло. Иногда при запуске сервисов мы фиксировали двукратный рост. Из-за этого поды стали перезапускаться каждые 5–10 минут. Мы не нашли причину роста потребления, но подозреваем, что после обновления JIT-компилятор стал менее эффективно работать с выражениями. Пока что не стали вникать и разбираться — ждем, когда Microsoft доведет релиз до устойчивого состояния.

Unit-тесты замедлились в 5 раз. MR-пайплайн в монолите стал отваливаться по таймауту, потому что на 18k тестов стало уходить больше 45 минут. Раньше это занимало 7 минут.

Как выяснилось, проблема не только у нас. К тому моменту в .NET Runtime уже был открыт issue #123124. Я добавил туда данные по нашему монолиту: описал те же симптомы и масштабы замедления при переходе с .NET 9 на .NET 10. В Microsoft пообещали исправить проблему в ближайшем патче.

Собрал и передал минимальный кейс, который смогли воспроизвести в Microsoft
Собрал и передал минимальный кейс, который смогли воспроизвести в Microsoft

JIT‑компилятор терял данные. В .NET 10 при использовании collection expression spread с несколькими источниками, например return [.. head,.. tail];, терялась часть данных без каких‑либо ошибок или предупреждений. После перекомпиляции таких структур в Tier1 под Dynamic или Synthesized PGO JIT мог сгенерировать код, в котором второй цикл копирования никогда не выполнялся. Длина массива на выходе была правильной, но хвост оставался в дефолтных значениях используемого типа. Мы завели на это дело issue, его уже исправили. А мы ждем версию .NET 10.0.10 с фиксом.

Со всеми этими проблемами оставлять .NET 10 в монолите было нельзя, поэтому я откатил фреймворк до 9 версии, но оставил все улучшения, которые внес агент. Будем мигрировать монолит после того, как Microsoft окончательно устранит проблемы, а пока что дешевле оставаться на стабильной версии.

Что улучшил в монолите, несмотря на откат до .NET 9

Еще до миграции монолита на .NET 10 было очевидно, что приложение нужно оптимизировать:

  • потребление памяти у heartbeat‑сервисов приблизилось к лимиту в 704 МБ, 

  • поды перезапускались каждые 2–3 часа, 

  • GC запускался слишком часто и отбирал ресурсы.

Несмотря на возврат к .NET 9, я решил навести порядок в монолите:

  1. Собирал с продакшен‑подов трейсы и дампы памяти, передавал их агенту и просил найти аномалии или возможные причины повышенного потребления ресурсов. 

  2. Агент выдвигал гипотезы и варианты исправления, а я проверял их по коду и метрикам, уточнял или указывал на несоответствия. 

  3. Когда было понятно, как устранить проблему, агент готовил MR, а я ревьюил и мерджил.

Вот что удалось найти и пофиксить за неделю:

  1. В одной из наших библиотек кеширования профайлинг показал 28% потребления всего CPU на Monitor.Wait. Причиной был самописный async‑lock. Я заменил его на SemaphoreSlim(1,1), и потребление CPU сразу упало.

  2. В трейсах CPU было видно, что монолит почти треть времени проводит в LambdaCompiler.CreateDelegate. Это означало, что Expression<Func<>>.Compile() для часто выполняемого кода вызывался на каждый запрос вместо разовой компиляции. Я прошелся по всем найденным местам: где‑то instance‑поле заменил на static readonly, где‑то перенес компиляцию в конструктор, где‑то Expression<Func<>> заменил на обычный делегат. В итоге компиляция лямбд снизилась с 29.22% до 0.01% CPU.

  3. IDisposableAnalyzers использовался только в 5 проектах из 38. Подключил его во всех проектах через Directory.Build.props. В процессе сборки выяснилось, что в коде есть больше 80 забытых using. Агент прошелся по ним и пофиксил утечки памяти.

  4. Старый wrapper создавал инстанс SentryClient на каждую запись лога. В монолите таких записей тысячи. Я перевел wrapper на один инстанс SentryClient для всего приложения.

.NET 9 до оптимизации: расход памяти упирается в лимит 704 MB, поды перезапускаются каждые 2–3 часа
.NET 9 до оптимизации: расход памяти упирается в лимит 704 MB, поды перезапускаются каждые 2–3 часа
.NET 9 после оптимизации: GC работает стабильно, рестартов нет
.NET 9 после оптимизации: GC работает стабильно, рестартов нет

Какой получили профит от миграции микросервисов

Через пару недель после раскатки обновленных сервисов в проде я собрал метрики производительности и потребления ресурсов. Для чистоты эксперимента оценивал сервисы, у которых до и после миграции был стабильный трафик. Подключил Grafana MCP и попросил агента сравнить показатели в одинаковых 48-часовых периодах в будние дни.

Вот как улучшились показатели:

Сервис

Ресурс

Было

Стало

Динамика

PushHub

RAM

18,2 ГБ

13,5 ГБ

−26%

mta-renderer

RAM

829 МБ

702 МБ

−15%

homepage

p95 latency

47,6 мс

34 мс

−29%

Замеры показали, что на двух сервисах вместо улучшения был рост CPU на 15%. Я посмотрел код и нашел несколько мест с антипаттернами по производительности, с которыми столкнулся в монолите. Когда поправил, потребление CPU сразу снизилось до нормального.

В микросервисах, где не получили заметных улучшений, метрики либо остались прежними, либо изменились на пару процентов: где‑то выросли, а где‑то просели. Для миграции между мажорными версиями .NET это ожидаемо.

Чек-лист для подключения автономного агента

Хочу завершить свою историю несколькими базовыми рекомендациями для тех, кто думает отдавать автономному агенту большую задачу.

Создать sandbox. Автономный агент не сможет работать, если у него не будет изолированного workspace, где можно разрешить ему поднимать Docker, ронять процессы, устанавливать пакеты и работать с Git. Мы используем Coder, но это может быть, например, GitHub Codespaces или самописная связка на Kubernetes.

Выдать агенту широкие полномочия. Автономному агенту нужно делегировать задачи целиком, чтобы он решал их end‑to‑end до MR с зеленым CI.

Установить лимиты на количество подходов. Например, на решение задачи локально выделить 10 попыток, на проход пайплайна в CI — 5, после этого агент должен остановиться, сохранить изменения и уведомить оператора. Без лимитов даже одно зацикливание может стоить несколько тысяч рублей и не принести результат.

Заворачивать повторяемые задачи в скилл. Задачу лучше один раз описать как скилл: с шагами, условиями запуска, критическими правилами, лимитами на попытки и инструкциями на случай ошибки. Скилл дает агенту меньше пространства для импровизации, помогает получить более предсказуемый результат и ускорить решение таких же или похожих задач в будущем. 

Использовать отдельный инструмент для ревью скилла. Перед тем как отдавать скилл команде, нужно убедиться в корректности его структуры: работает ли он в чистом окружении, не завязан ли на настройки автора и достаточно ли в нем инструкций для других пользователей. Иначе скилл может успешно работать у владельца, но ломаться у всех остальных.