CI/CD и качество кода
Как код попадает из pull request в продакшен: из каких шагов состоит конвейер, какие проверки качества в него встраивают, как управлять пакетами и версиями и как выкладывать, чтобы откат был обычной операцией. На собеседовании это проверяют вопросами про конвейер, с которым кандидат работал сам.
Перед этой темой: Тестирование.
Что такое CI и CD и из каких шагов состоит конвейер .NET-проекта?
Заголовок раздела «Что такое CI и CD и из каких шагов состоит конвейер .NET-проекта?»Что это
- CI — изменения часто сливаются в общую ветку, и каждое автоматически собирается и тестируется.
- Continuous delivery — проверенная сборка готова к выкладке в любой момент, в прод — по кнопке.
- Continuous deployment — в прод без кнопки уходит каждое изменение, прошедшее проверки.
Шаги конвейера .NET
dotnet restore— скачать NuGet-пакеты.dotnet build— собрать решение.dotnet test— прогнать тесты.- Анализ кода: анализаторы, SonarQube.
dotnet publish— подготовить файлы приложения.- Сохранить результат как артефакт.
- Выложить его на тестовый стенд, потом в прод.
На pull request обычно идут шаги 1–4, на push в main — все.
Частая ошибка
Пересобирать проект для каждого окружения. Тогда в прод уходит не та сборка, которую проверяли.
Как устроено
- Конвейер описан кодом в репозитории:
.github/workflows/*.ymlв GitHub Actions,Jenkinsfileв Jenkins. Его версионируют и ревьюят, как остальной код. - Триггеры:
pull_requestпроверяет изменение до слияния,pushвmainсобирает релизного кандидата, ручной запуск — для выкладки и повторов. - Задачи (jobs) выполняются на агентах: облачных или своих (self-hosted), например на Windows Server рядом с IIS. Разные задачи обмениваются файлами через артефакты.
Когда что выбирать
| Признак | Continuous delivery | Continuous deployment |
|---|---|---|
| Выкладка в прод | после ручного подтверждения | автоматически |
| Что нужно | надёжный CI | ещё автотесты, мониторинг, автооткат |
Ловушки
- Долгий конвейер. Проверка PR на 40 минут — изменения копятся и сливаются пачками. Помогают кеш пакетов,
--no-restoreи--no-buildв следующих шагах, параллельные задачи. - Нестабильные тесты. «Перезапусти, иногда падает» приучает игнорировать красный конвейер.
- Секреты в YAML. Токены хранят в секретах GitHub или в Credentials Jenkins, а не в репозитории.
Правило
На PR — быстрые проверки, без зелёного статуса слияние запрещено. На main — артефакт собирается один раз и продвигается по окружениям.
Под капотом
- Артефакт неизменяем и помечен версией и commit SHA. По версии из прода находят точный коммит.
- Конфигурация окружения не вшивается в сборку, а приходит при выкладке: переменные окружения,
appsettings.{Environment}.json, хранилище секретов. Так один артефакт проходит dev, stage и prod. - Воспроизводимость:
global.jsonфиксирует SDK, lock-файлы — пакеты, агенты собираются из одного образа.
Цена
- Время обратной связи — главная метрика CI: медленный конвейер тормозит каждое изменение.
- Self-hosted-агенты видят внутреннюю сеть и дешевле на объёме, но их обновляют и защищают сами. Код из PR исполняется на них, поэтому GitHub не советует такие агенты для публичных репозиториев.
Компромиссы
- GitHub Actions — YAML рядом с кодом и готовые actions. Jenkins — свой сервер, плагины и Groovy, полный контроль ценой постоянного обновления плагинов.
- Continuous deployment требует отката за минуты и метрик, по которым его запускать. Без них ручное подтверждение честнее.
- Быстрые проверки на PR и тяжёлые по расписанию — компромисс между скоростью и полнотой.
Как проверить в проде
- DORA-метрики: частота выкладок, время от коммита до прода, доля неудачных изменений, время восстановления.
- Версия и commit SHA видны в логе старта или в служебном эндпоинте — понятно, что сейчас работает.
Итог: ценность конвейера — воспроизводимость: один неизменяемый артефакт, конфигурация снаружи и быстрая обратная связь.
name: ci
on: pull_request: branches: [main] push: branches: [main]
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: global-json-file: global.json # версия SDK из репозитория - run: dotnet restore - run: dotnet build --no-restore -c Release - run: dotnet test --no-build -c Release - run: dotnet publish src/Shop.Api --no-build -c Release -o out # Артефакт сохраняем только из main: его и выкладываем - if: github.ref == 'refs/heads/main' uses: actions/upload-artifact@v7 with: name: shop-api-${{ github.sha }} path: out// Декларативный конвейер на Windows-агенте (multibranch pipeline)pipeline { agent { label 'windows' } stages { stage('Build') { steps { bat 'dotnet restore' bat 'dotnet build --no-restore -c Release' } } stage('Test') { steps { bat 'dotnet test --no-build -c Release' } } stage('Publish') { when { branch 'main' } // артефакт — только из main steps { bat 'dotnet publish src/Shop.Api --no-build -c Release -o out' archiveArtifacts artifacts: 'out/**' } } }}- Чем continuous delivery отличается от continuous deployment? — Ручным подтверждением выкладки в прод против полностью автоматической.
- Почему артефакт собирают один раз? — Иначе в прод уходит не та сборка, которую тестировали.
- Что запускать на PR, а что на
main? — На PR — быстрые проверки для слияния, наmain— ещё публикацию артефакта и выкладку. - Как ускорить конвейер? — Кеш пакетов,
--no-restoreи--no-build, параллельные задачи, тяжёлые тесты — по расписанию. - Где хранить секреты конвейера? — В секретах GitHub или Credentials Jenkins, с минимальными правами токена.
Как автоматически следить за качеством кода?
Заголовок раздела «Как автоматически следить за качеством кода?»Что это
Автоматические проверки качества — правила, которые конвейер применяет к каждому PR.
Какие бывают
- Компилятор и анализаторы. Предупреждение
CS8602о возможномnull, правилаCAxxxxо надёжности и производительности. Встроены в .NET SDK. - Стиль.
.editorconfigзадаёт отступы, именование и важность правил,dotnet formatвыравнивает код по нему. - Покрытие. Доля кода, которую выполнили тесты.
- SonarQube. Собирает всё вместе и выносит вердикт по quality gate.
- Ревью. Человек проверяет то, что не видят инструменты: замысел и дизайн.
Как пользоваться
Включите TreatWarningsAsErrors и сделайте зелёный конвейер обязательным условием слияния в main.
Частая ошибка
Гнаться за 100% покрытия. Покрытие показывает, какой код выполнялся, а не что его результат проверили.
Как устроено
- Сборка. Анализаторы Roslyn включены по умолчанию с .NET 5.
AnalysisLevelвыбирает набор правил:latest-recommendedстроже умолчания. Правила стиляIDExxxxпри сборке из командной строки работают только сEnforceCodeStyleInBuild. - Строгость.
TreatWarningsAsErrorsделает ошибкой любое предупреждение. Мягче —<WarningsAsErrors>Nullable</WarningsAsErrors>: ошибками становятся только nullable-предупреждения. - Формат.
dotnet format --verify-no-changesпадает, если код расходится с.editorconfig. - SonarQube.
dotnet sonarscanner beginдо сборки,dotnet testс отчётом покрытия,endпосле. Сервер считает quality gate. - Ветка. Правила ветки требуют зелёных проверок и одобрения ревьюера.
Ловушки
TreatWarningsAsErrorsна старом проекте даёт сотни ошибок разом. Его включают по проектам или начинают сWarningsAsErrors.- Анализ веток и PR в SonarQube есть с Developer Edition. Бесплатная Community Build анализирует только главную ветку.
- Покрытие SonarQube не считает сам: отчёт делает coverlet, а путь передают свойством
sonar.cs.opencover.reportsPaths.
Правило
Что можно проверить при сборке — проверяйте при сборке. SonarQube держит пороги на новый код, правила ветки делают проверки обязательными.
Под капотом
- Анализатор Roslyn подписывается на синтаксические и семантические события компилятора. Он видит типы и символы, а результат одинаков в IDE и в CI.
- Важность правила задаётся слоями:
AnalysisLevel— база,.editorconfigили.globalconfig— конкретное правило (dotnet_diagnostic.CA2016.severity),#pragmaи[SuppressMessage]— конкретное место. - SonarQube считает метрики относительно периода нового кода: с прошлой версии, за N дней или от ветки. «Sonar way» проверяет только его, а покрытие и дублирование игнорирует, пока новых строк меньше 20.
Цена
- Анализаторы удлиняют сборку,
latest-allна большом решении заметен. - Подавление без ревью — технический долг без владельца.
Компромиссы
- 80% покрытия нового кода — умолчание Sonar, а не закон. Тривиальные DTO исключают через
sonar.coverage.exclusions, сложную логику — нет. - Новый SDK приносит новые правила.
AnalysisLevelс номером (10.0-recommended) фиксирует набор, и обновление становится отдельным PR. - Gate по всему коду на легаси вечно красный, и его перестают замечать. Gate на новый код держит планку для изменений, а старый код чинят, когда трогают.
Как проверить, что это работает
- Тренд подавлений (
#pragma warning disable,NoWarn) за квартал. - Доля PR, слитых в обход правил ветки или с красным gate.
Итог: строгость — в сборке и правилах ветки, SonarQube держит планку на новом коде, а подавления ревьюят как код.
<!-- Directory.Build.props в корне репозитория: действует на все проекты --><Project> <PropertyGroup> <Nullable>enable</Nullable> <!-- Набор правил зафиксирован: новый SDK не включит новые правила сам --> <AnalysisLevel>10.0-recommended</AnalysisLevel> <!-- Правила стиля IDExxxx проверяются и в dotnet build --> <EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild> <!-- Любое предупреждение ломает сборку --> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup></Project>root = true
[*.cs]# CA2016: передавать CancellationToken дальше — ошибка сборкиdotnet_diagnostic.CA2016.severity = error# IDE0161: file-scoped namespace — предупреждение (формат option:severity, .NET 9+)csharp_style_namespace_declarations = file_scoped:warning// Нужны глобальный инструмент dotnet-sonarscanner на агенте// и webhook SonarQube на <jenkins>/sonarqube-webhook/stage('Build and analyze') { steps { // withSonarQubeEnv подставляет SONAR_HOST_URL и SONAR_AUTH_TOKEN withSonarQubeEnv('SonarQube') { bat ''' dotnet sonarscanner begin /k:"shop-api" ^ /d:sonar.host.url=%SONAR_HOST_URL% ^ /d:sonar.token=%SONAR_AUTH_TOKEN% ^ /d:sonar.cs.opencover.reportsPaths=**/coverage.opencover.xml dotnet build -c Release --no-incremental dotnet test -c Release --no-build --collect:"XPlat Code Coverage;Format=opencover" dotnet sonarscanner end /d:sonar.token=%SONAR_AUTH_TOKEN% ''' } }}stage('Quality gate') { steps { timeout(time: 10, unit: 'MINUTES') { waitForQualityGate abortPipeline: true // красный gate — красная сборка } }}- Чем
TreatWarningsAsErrorsотличается отWarningsAsErrors? — Первый делает ошибками все предупреждения, второй — только перечисленные, напримерNullable. - Что проверяет quality gate «Sonar way»? — Новый код: нет новых замечаний, hotspots проверены, покрытие от 80%, дублирование до 3%.
- Почему gate смотрит на новый код, а не на весь? — Иначе легаси-проект вечно красный, а планку нужно держать на изменениях.
- Почему SonarQube показывает нулевое покрытие? — Отчёт не создан в нужном формате или путь в
sonar.cs.opencover.reportsPathsне совпал. - Как обновить SDK и не сломать сборку новыми правилами? — Зафиксировать
AnalysisLevelверсией и поднимать её отдельным PR.
Как управлять пакетами и версиями?
Заголовок раздела «Как управлять пакетами и версиями?»Что это
- SemVer — версия
MAJOR.MINOR.PATCH, например2.4.1. Ломающее изменение даёт3.0.0, новая функция —2.5.0, исправление —2.4.2. Суффикс-beta.1помечает предварительную версию. - Приватный фид — NuGet-сервер компании для внутренних пакетов: общей библиотеки логирования, клиентов к внутренним API.
Как пользоваться
- Конвейер библиотеки выполняет
dotnet packи получает.nupkg. dotnet nuget pushпубликует пакет в фид.- Фид прописан в
nuget.configв корне репозитория, и пакет ставится как обычный:dotnet add package Company.Logging.
Частая ошибка
Перезалить изменённый пакет с той же версией. У всех в кеше ~/.nuget/packages уже лежит старый, и сборки начинают расходиться.
Как устроено
- Фид. Azure Artifacts и GitHub Packages — облачные. BaGetter (продолжение BaGet), Nexus и ProGet — свои серверы. NuGet.Server — старый вариант на .NET Framework. Хороший фид проксирует nuget.org, и все пакеты идут через одну точку.
- Версии в одном месте. Central Package Management и
Directory.Packages.props— см. Платформу .NET. - Lock-файл.
RestorePackagesWithLockFileсоздаётpackages.lock.jsonс полным графом.dotnet restore --locked-modeв CI падает, если граф изменился, а файл нет. - Аудит. NuGetAudit сверяет граф с базой уязвимостей при restore и выдаёт NU1901–NU1904 — от low до critical.
- Версия из git. MinVer берёт версию из последнего тега, GitVersion — из тегов и схемы веток. Ручной
<Version>в проекте не нужен.
Когда что выбирать
| Инструмент | Когда |
|---|---|
| MinVer | версия — последний тег, простой процесс |
| GitVersion | релизные ветки, GitFlow, сложные правила |
ручной <Version> |
один пакет, редкие релизы |
Ловушки
- Lock-файл библиотеки не помогает её потребителям: граф считает приложение. Его коммитят прежде всего для приложений.
- Floating-версии (
2.*) без lock-файла делают сборку невоспроизводимой. - Свежий advisory ломает сборку с
TreatWarningsAsErrors. Заранее решите, какие коды блокируют.
Правило
Один фид, версии в Directory.Packages.props, lock-файл и --locked-mode для приложений, версия — из тега.
Под капотом
- NuGet поддерживает SemVer 2.0: предварительная версия ниже релиза (
2.0.0-rc.1меньше2.0.0), метаданные после+в сравнении не участвуют. - Версий у сборки несколько: версия пакета — для NuGet,
AssemblyVersion— для загрузчика,FileVersion— для Windows,InformationalVersion— для людей. С .NET 8 SDK дописывает вInformationalVersioncommit SHA:2.4.0+a1b2c3d…. - Source Link с .NET 8 встроен в SDK для GitHub, Azure Repos, GitLab и Bitbucket. Он записывает в PDB адрес репозитория и коммит, и отладчик скачивает исходник точной версии.
ContinuousIntegrationBuild=trueв CI нормализует пути для детерминированной сборки.
Цена
.snupkgне раздувает пакет, но поддерживается не всеми фидами: Azure Artifacts его не принимает. Альтернатива —DebugType=embedded, PDB внутри сборки ценой размера.- Аудит транзитивных пакетов находит больше уязвимостей — и больше шума.
Компромиссы
- Dependency confusion: пакет
Company.Loggingс высокой версией на nuget.org может победить внутренний. Защита — Package Source Mapping или один фид с проксированием. - Блокирующий аудит в отдельном конвейере (рецепт Microsoft с
-p:AuditPipeline=true) не останавливает срочный фикс из-за свежего advisory.
Как проверить в проде
- Версия и SHA из
InformationalVersionв логе старта ведут к коммиту релиза. dotnet package list --vulnerable --include-transitive(до .NET 10 —dotnet list package) на коммите релиза показывает уязвимости в том, что сейчас работает.- NuGet Package Explorer показывает, есть ли в пакете Source Link и символы.
Итог: пакеты — цепочка поставок: один фид, зафиксированный граф, аудит и версия, по которой находится коммит.
<!-- Directory.Build.props: правила для пакетов во всех проектах --><Project> <PropertyGroup> <!-- packages.lock.json с полным графом; в CI: dotnet restore --locked-mode --> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> <!-- Аудит и транзитивных пакетов (для net10.0 это уже умолчание) --> <NuGetAuditMode>all</NuGetAuditMode> <!-- Уязвимости high и critical ломают сборку, low и moderate — предупреждения --> <WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors> </PropertyGroup></Project><!-- nuget.config в корне репозитория --><configuration> <packageSources> <clear /> <add key="company" value="https://nuget.example.com/v3/index.json" /> <add key="nuget.org" value="https://api.nuget.org/v3/index.json" /> </packageSources> <!-- Package Source Mapping: Company.* — только из своего фида --> <packageSourceMapping> <packageSource key="company"> <package pattern="Company.*" /> </packageSource> <packageSource key="nuget.org"> <package pattern="*" /> </packageSource> </packageSourceMapping></configuration>- Когда менять major-версию? — При любом ломающем изменении публичного API, даже маленьком.
- Зачем lock-файл, если версии и так указаны? — Указаны прямые зависимости; транзитивные и floating-версии без него могут измениться.
- Как защититься от dependency confusion? — Package Source Mapping или один фид, который проксирует nuget.org.
- Что делать с уязвимостью в транзитивном пакете? — Обновить ближайший прямой пакет, иначе поднять транзитивный явной ссылкой или transitive pinning.
- Зачем Source Link? — Отладчик скачивает исходник пакета ровно той версии, из которой он собран.
Какие бывают стратегии выкладки и как откатываться?
Заголовок раздела «Какие бывают стратегии выкладки и как откатываться?»Что это
Стратегия выкладки — порядок, в котором новая версия попадает на серверы и получает трафик.
| Стратегия | Как работает |
|---|---|
| Остановить и заменить | остановили, заменили файлы, запустили — есть простой |
| Rolling | серверы за балансировщиком обновляются по одному |
| Blue-green | новая копия рядом со старой, трафик переключается разом |
| Canary | новая версия сначала получает 1–5% трафика |
Как откатываться
- Хранить предыдущий артефакт и выкладывать его одной командой.
- В blue-green — вернуть трафик на старую копию.
- Функцию под feature flag — выключить флагом, без выкладки.
Частая ошибка
Считать откат простым, пока не изменилась база. Если новая версия удалила колонку, старый код с этой базой уже не работает.
Как устроено
- Rolling. Балансировщик выводит сервер из ротации, сервер обновляется, проходит health check и возвращается.
- Blue-green. Две полные копии. Новая разворачивается и прогревается без трафика, проходит smoke-тесты, затем балансировщик переключается. Старая остаётся для отката.
- Canary. Новая версия получает малую долю трафика, её ошибки и задержки сравнивают со старой, долю увеличивают ступенями.
- Feature flags. Код выкладывается выключенным и включается для части пользователей, например через
Microsoft.FeatureManagement.
Когда что выбирать
| Стратегия | Плюс | Минус |
|---|---|---|
| Rolling | без лишних серверов | откат — такой же долгий rolling |
| Blue-green | мгновенный откат | двойные ресурсы |
| Canary | мало пострадавших | нужны метрики и маршрутизация |
Ловушки
- Схема БД. Миграцию применяют до выкладки, и она обязана работать со старым кодом: добавлять, а не удалять и переименовывать. Подробно — в изменении схемы без простоя.
- Состояние в памяти. После переключения пользователь попадает на другой экземпляр, поэтому сессии и кеши живут вне процесса.
- Две версии сразу. Сообщения в очередях и ответы API должны понимать обе версии.
Правило
Каждый релиз совместим с предыдущим по схеме, API и сообщениям. Тогда подходит любая стратегия, и откат кода не требует отката базы.
Под капотом: IIS и контейнеры
app_offline.htm. Файл в корне сайта заставляет ASP.NET Core Module корректно остановить приложение и отдавать содержимое файла. DLL освобождаются, файлы заменяют, файл удаляют — следующий запрос запускает новую версию. Это простой: годится для окна обслуживания или как шаг rolling за балансировщиком.- Два сайта. Синий и зелёный сайты со своими пулами и папками за ARR или внешним балансировщиком. Зелёный разворачивают и прогревают, проверяют
/health/ready, переключают балансировщик, синий держат до конца наблюдения. - Контейнеры. Оркестратор пускает трафик на новый экземпляр только после успешной readiness-проверки. Readiness и liveness — разные проверки: недоступная БД не повод перезапускать процесс.
Цена
Любая стратегия без простоя требует совместимости версий N и N−1: expand/contract в базе, необязательные поля в API, версионированные сообщения.
Компромиссы
- Откат быстрее, если артефакт и схема совместимы. Исправление вперёд (roll forward) — если миграция данных уже необратима.
- Feature flags отделяют выкладку от релиза, но копят мёртвые ветки кода. У флага должен быть срок жизни.
Как проверить в проде
- После переключения конвейер выжидает период наблюдения (bake time), сравнивает долю 5xx, p99 и бизнес-метрики с порогами и при нарушении сам возвращает трафик.
- Выкладка помечена в мониторинге версией, поэтому всплеск ошибок сразу связывается с релизом.
- Откат репетируют: откат, который ни разу не выполняли, не работает.
Итог: безопасная выкладка — малые шаги, совместимость N и N−1, решение по метрикам и уже проверенный откат.
# Задача выкладки в ci.yml: blue-green на IIS (для простоты новая копия — зелёная)jobs: deploy: needs: build if: github.ref == 'refs/heads/main' runs-on: [self-hosted, windows, iis] # свой агент в сети серверов environment: production # ручное подтверждение в настройках окружения defaults: run: shell: powershell steps: - uses: actions/checkout@v7 # скрипты выкладки лежат в репозитории - uses: actions/download-artifact@v8 with: name: shop-api-${{ github.sha }} path: drop # 1. Выкладка в копию без трафика - run: | Import-Module WebAdministration Stop-WebAppPool -Name shop-green Copy-Item drop\* D:\sites\shop-green -Recurse -Force Start-WebAppPool -Name shop-green # 2. Прогрев и проверка готовности до переключения - run: Invoke-WebRequest http://localhost:8081/health/ready -UseBasicParsing # 3. Переключение балансировщика и наблюдение за метриками - run: ./deploy/switch-traffic.ps1 -To green - run: ./deploy/watch-metrics.ps1 -Minutes 15 -Max5xxPercent 1 # 4. Сбой на любом шаге — трафик обратно на прежнюю копию - if: failure() run: ./deploy/switch-traffic.ps1 -To blueswitch-traffic.ps1 и watch-metrics.ps1 — скрипты проекта: первый меняет правило ARR или пул балансировщика, второй читает метрики из мониторинга. В следующий релиз цвета меняются местами.
- Чем blue-green отличается от canary? — Blue-green переключает весь трафик разом, canary — ступенями по долям.
- Как откатить релиз, если миграция удалила колонку? — Быстро никак, поэтому удаление — отдельный поздний релиз по expand/contract.
- Даёт ли
app_offline.htmвыкладку без простоя? — Нет, сайт на это время недоступен; без простоя — несколько серверов или два сайта за балансировщиком. - Чем readiness отличается от liveness? — Readiness решает, давать ли экземпляру трафик, liveness — перезапускать ли процесс.
- Что такое bake time? — Период наблюдения за метриками после выкладки, прежде чем идти дальше.