CI/CD: Azure Pipelines

Настраиваем конвейер, который сам собирает код после каждого коммита, прогоняет тесты и выкатывает приложение в App Service — без единого клика мышью.

CI/CD — непрерывная интеграция (каждый коммит автоматически собирается и тестируется) и непрерывная доставка (прошедшая проверки сборка автоматически едет в облако).

Зачем это нужно на практике

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

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

В Azure DevOps весь пайплайн — это YAML-файл azure-pipelines.yml в самом репозитории. Значит, пайплайн тоже код: живёт рядом с приложением, меняется через pull request, откатывается вместе с ним.

Пайплайн сборки и деплоя целиком

Разберём рабочий пример: .NET-приложение, которое собирается и уезжает в Azure App Service.

trigger:
  branches:
    include:
      - main

variables:
  buildConfiguration: 'Release'
  webAppName: 'shop-prod-web'

stages:
- stage: Build
  displayName: 'Сборка и тесты'
  jobs:
  - job: build
    pool:
      vmImage: 'ubuntu-latest'
    steps:
    - task: UseDotNet@2
      inputs:
        version: '8.0.x'

    - script: dotnet restore
      displayName: 'Восстановление пакетов'

    - script: dotnet build --configuration $(buildConfiguration) --no-restore
      displayName: 'Сборка'

    - script: dotnet test --configuration $(buildConfiguration) --logger trx
      displayName: 'Тесты'

    - script: dotnet publish -c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)
      displayName: 'Публикация'

    - publish: $(Build.ArtifactStagingDirectory)
      artifact: drop

- stage: Deploy
  displayName: 'Деплой в App Service'
  dependsOn: Build
  condition: succeeded()
  jobs:
  - deployment: deployWeb
    environment: 'production'
    pool:
      vmImage: 'ubuntu-latest'
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: 'sc-azure-prod'
              appType: 'webAppLinux'
              appName: $(webAppName)
              package: '$(Pipeline.Workspace)/drop/*.zip'
              deployToSlotOrASE: true
              slotName: 'staging'

Построчный разбор

trigger — когда запускать. Здесь: на каждый push в main. Если убрать этот блок, Azure DevOps по умолчанию запускает пайплайн на все ветки — и вы удивитесь счёту за минуты агента.

variables — обычные переменные, подставляются синтаксисом $(имя).

stages — крупные фазы. Разделение на Build и Deploy — не украшательство: между стадиями можно вставить ручное подтверждение, а одну и ту же сборку переиспользовать для нескольких сред. Собрали один раз — задеплоили в test, потом ровно тот же артефакт в prod. Пересобирать под каждую среду — верный способ выкатить в прод не то, что тестировали.

pool: vmImage: 'ubuntu-latest' — на какой машине выполнять. Это агент, о нём ниже.

steps — шаги. script — просто команда в шелле агента. task — готовое действие из каталога (установить .NET, задеплоить в App Service, залогиниться в реестр). Записи вида UseDotNet@2 читаются как «таск UseDotNet версии 2».

publish кладёт результат сборки в артефакт — хранилище, переживающее агента. В стадии Deploy job типа deployment скачивает артефакт автоматически, поэтому путь и выглядит как $(Pipeline.Workspace)/drop/.

environment: 'production' — не просто ярлык. Это объект в Azure DevOps: к нему привязывается история деплоев и правила — например, approval check, из-за которого стадия встанет и будет ждать, пока тимлид нажмёт «Approve».

slotName: 'staging' — деплой не сразу в прод, а в слот: копию приложения с отдельным URL. Прогреваем, проверяем, а потом делаем swap — Azure меняет слоты местами мгновенно, без даунтайма, и с возможностью так же мгновенно откатиться.

Агенты: где вообще всё это выполняется

Пайплайн выполняется не «в облаке вообще», а на конкретной виртуалке — агенте.

ТипПлюсыМинусы и цена
Microsoft-hosted (vmImage: ubuntu-latest)чистая машина на каждый запуск, ничего не настраивать, предустановлены .NET, Node, Docker, Pythonприватным проектам бесплатно ограниченное число минут (порядка 1800 в месяц на одном параллельном job); дальше параллельные job'ы покупаются помесячно. Публичным open-source проектам — бесплатно и щедро
Self-hostedсвои зависимости, доступ во внутреннюю сеть, кэш между запусками, нет лимита минутвы сами обновляете и чините машину, а виртуалка тикает по счётчику 24/7, даже когда пайплайн не запускался

Ключевое свойство hosted-агента: он одноразовый. Каждый запуск получает свежую машину, а после — умирает. Ничего не «остаётся с прошлого раза»: ни кэш npm, ни установленные глобально пакеты, ни файлы. Отсюда и главный источник недоумения новичков — «локально работает, в пайплайне нет»: локально у вас накопилось окружение, а у агента его нет.

Секреты в пайплайне

Правило номер один: никаких паролей в YAML. Он лежит в git и виден всем, у кого есть доступ к репозиторию.

Правильных способов два. Первый — service connection: объект в настройках проекта, через который пайплайн ходит в Azure (в примере это azureSubscription: 'sc-azure-prod'). Никаких ключей в коде; современный вариант — workload identity federation, где долгоживущего секрета нет вообще.

Второй — variable group, связанная с Azure Key Vault: секреты лежат в хранилище, пайплайн подтягивает их по имени.

variables:
- group: shop-prod-secrets   # связана с Key Vault

steps:
- script: |
    ./migrate.sh
  displayName: 'Миграции БД'
  env:
    DB_PASSWORD: $(dbPassword)   # секрет пробрасываем ЯВНО через env

Обратите внимание на блок env. Обычные переменные Azure Pipelines сам кладёт в окружение процесса, а секретные — нет: это защита от того, чтобы секрет случайно утёк в дочерний процесс или в лог. Пробрасывать их нужно руками, ровно туда, где они нужны. Это не баг, а фича, но именно на ней спотыкается каждый второй.

Как это работает

При push в main Azure DevOps ловит событие, читает azure-pipelines.yml из того самого коммита и ставит запуск в очередь. Свободный агент забирает задание, клонирует репозиторий в чистую рабочую папку и выполняет шаги по порядку. Каждый шаг — отдельный процесс; ненулевой код возврата валит job (поэтому dotnet test с падающим тестом останавливает конвейер сам, без дополнительных настроек).

Результат сборки уезжает в хранилище артефактов, агент уничтожается. Стадия Deploy ждёт успеха Build (dependsOn + condition), при необходимости упирается в approval-чек и только потом берёт новый агент, скачивает артефакт и через service connection дёргает API Azure — по сути тот же ARM, что и в прошлом уроке.

Важная деталь про логи: Azure Pipelines маскирует значения секретных переменных в выводе, заменяя их на ***. Но маскирование посимвольное и наивное — если вы закодируете секрет в base64 или разрежете его пополам, он утечёт в лог как есть. Не печатайте секреты. Никогда.

Частые ошибки

  • Забыли trigger или указали слишком широкий. Пайплайн начинает запускаться на каждую ветку и каждый коммит, бесплатные минуты сгорают за неделю, а после — вы платите. Ограничивайте ветки и пути (paths: exclude: ['docs/*']).
  • Self-hosted агент на постоянно включённой VM. Пайплайн работает 10 минут в день, виртуалка — 24 часа. Это чистые деньги в никуда: либо scale set с автоотключением, либо hosted-агент.
  • Пересборка под каждую среду. В test уехал один артефакт, в prod — собранный заново. Формально «тот же коммит», фактически другой билд. Собирайте один раз, деплойте много.
  • Секрет в echo «для отладки». Логи пайплайна видит вся команда, и они хранятся месяцами. Утёкший ключ придётся ротировать.
  • Прямой деплой в прод без слота. Приложение стартует несколько секунд и всё это время отдаёт ошибки. Слот + swap решают проблему бесплатно (на тарифах Standard и выше слоты уже включены в цену).
  • Плавающая версия инструмента. version: '8.x' или ubuntu-latest означают, что однажды образ обновится и «ничего не менявший» пайплайн покраснеет. Для критичных вещей фиксируйте версии явно.

Итоги

  • Пайплайн — это YAML в репозитории: живёт рядом с кодом, ревьюится и откатывается вместе с ним.
  • Стадии Build и Deploy разделяйте: собрали артефакт один раз — задеплойте его во все среды.
  • Hosted-агент одноразовый и чистый: всё, что нужно сборке, должно быть в репозитории или в шагах.
  • Секреты — только через service connection и variable group с Key Vault; секретные переменные пробрасывайте явно в env.
  • environment даёт историю деплоев и ручные approval'ы, слот со swap — деплой без даунтайма и мгновенный откат.
  • Следите за минутами агентов и не оставляйте self-hosted VM включённой круглосуточно — это самая тихая статья расходов.
Проверьте себя
1. Почему секретные переменные Azure Pipelines приходится явно пробрасывать в блок env шага?
AИз-за бага в агентах Linux — на Windows-агентах они подставляются автоматически
BПотому что секреты хранятся в Key Vault и подгружаются только по требованию шага
CПотому что в целях безопасности секретные переменные не попадают в окружение процесса автоматически, в отличие от обычных
DПотому что YAML не поддерживает подстановку $(...) внутри блока script
2. В чём главный смысл разделения пайплайна на стадии Build и Deploy с передачей артефакта?
AОдин и тот же собранный артефакт деплоится во все среды, поэтому в прод едет ровно то, что тестировали
BЭто единственный способ запустить стадии параллельно и ускорить пайплайн
CСтадия Deploy не умеет собирать код — в ней запрещены шаги script
DТак пайплайн расходует меньше бесплатных минут агента