Запуск новой версии в боевую эксплуатацию — всегда нервное мероприятие. Особенно если процесс включает в себя множество ручных операций. Человеческий фактор — страшная штука. “Хорошо бы этот процесс автоматизировать” — эта идея стара как весь ИТ-мир. И термин для этого есть — Continuous Deployment. Да вот беда, нет единственно верного способа настроить этот continuous deployment. Очень сильно сей процесс завязан на технологический стек проекта и на его окружение.

В этой статье я хочу поделиться практическим опытом настройки автоматического обновления системы без прерывания ее работы для конкретного технологического окружения, а именно: веб приложение на ASP.NET MVC + Azure SQL + Entity Framework в режиме Code First, развернуто приложение в Azure в виде App Service, а сборка и развертывание выполняются через Azure DevOps (бывший Visual Studio Team Services).



На первый взгляд все очень просто, в Azure App Service есть понятие deployment slot — загружай туда новую версию и включай. Но это было бы просто, если бы в основе проекта лежала нереляционная СУБД, в которой нет жесткой схемы данных. В таком случае да — просто новая версия подхватывает трафик и вуаля. Но вот с реляционной СУБД все несколько сложнее.

Основные факторы, которые мешают нам внедрить continuous deployment для нашего технологического стека следующие:

  • Старая версия приложения не может работать с новой структурой БД
  • Обновление структуры БД может занимать значительное время и не всегда возможно средствами самого приложения через механизм автоматического применения миграций.

Поясню. Предположим, что вы развернули новую версию в параллельном слоте или в резервном дата-центре и запустили применение миграций. Допустим, что миграций у нас три и, о ужас, две накатились, а третья упала. В этот момент с работающими серверами ничего не случится, Entity Framework не проверяет версию на каждый запрос, но ведь быстро решить проблему скорее всего не удастся. А в это время нагрузка на приложение может возрасти, и платформа запустит вам дополнительный экземпляр приложения, и он… естественно не запустится, так как структура БД поменялась. Существенная часть пользователей начнет получать ошибки. Таким образом, риск автоматического применения миграций велик.



А что касается второго пункта, ваша миграция может содержать какие то команды, время выполнения которых превышает 30 секунд и стандартная процедура упадет по таймауту. Ну и вдобавок к этим пунктам, лично мне не нравится то, что при автоматических миграциях вы вынуждены часть инфраструктуры обновить на новую версию. И если для режима со слотами в Azure это не так страшно, то для режима с резервным дата-центром у вас появляется часть инфраструктуры с заведомо неработающим приложением. Опасно это все, выстрелит в самый неподходящий момент.

Что же делать


Начнем с самого сложного — с базы данных. Итак, неплохо бы как-то автоматически обновлять структуру базу данных чтобы при этом старые версии приложения продолжали работать. Кроме этого хорошо бы учесть тот факт, что бывают такие обновления, при которых отдельная команда может выполняться значительное время, а значит нам нужно обновлять базу не используя встроенные механизмы а путем выполнения отдельного SQL скрипта. Вопрос: как его подготовить? Можно сделать этот процесс ручным. Если у вас в команде есть отдельная роль релиз-менеджера, можно заставить его в среде Visual Studio выполнять команду:

update-database -script

Она будет генерировать скрипт, и этот человек будет класть этот скрипт в определенную папку проекта. Но согласитесь, это все-таки неудобно, тут во-первых человеческий фактор, во-вторых лишние сложности, если между релизами было более одной миграции. Или по каким-то причинам на целевой системе один релиз был пропущен. Придётся городить какой-то непростой огород с отслеживанием, какие миграции уже есть и какие надо запустить. Сложно и, главное, это тот же велосипед, который уже сделан в механизме миграций.

А правильно будет процесс генерации и исполнения скрипта встроить в процесс выкладки релиза. Для того, чтобы сгенерировать скрипт миграций, можно воспользоваться утилитой migrate.exe, которая входит в комплект Entity Framework. Обращаю внимание, что вам нужна версия Entity Framework 6.2 или выше, так как опция генерации скрипта появилась в этой утилите только в апреле 2017 года. Вызов утилиты выглядит так:

migrate.exe Context.dll /connectionString="Data
Source=localhost;Initial Catalog=myDB;User
Id=sa;Password=myPassword;"
/connectionProviderName="System.Data.SqlClient" /sc
/startUpDirectory="c:\projects\MyProject\bin\Release" /verbose

Указывается название сборки где находится ваш Контекст, строка подключения к целевой базе данных, провайдер и, очень важно, стартовый каталог, в котором лежит как сборка с контекстом, так и сборки Entity Framework. Не стоит экспериментировать с названиями рабочего каталога, будьте проще. Мы наткнулись на то, что migrate.exe не смог прочитать каталог, в названии которого были пробелы и небуквенные символы.

Тут нужно сделать важное отступление. Дело в том, что после выполнения вышеуказанной команды будет сгенерирован единый SQL скрипт, содержащий все команды для всех миграций, которые необходимо применить к целевой базе данных. Для Microsoft SQL Server’а это не очень хорошо. Дело в том, что команды без разделителя GO сервер выполняет как единый пакет, а некоторые операции невозможно выполнить вместе в одном пакете.

К примеру, в некоторых случаях не работает добавление поля в таблицу и сразу же создание индекса на эту таблицу с новым полем. Но и этого мало, некоторые команды требуют определенных настроек окружения при выполнении скрипта. Такие настройки включены по умолчанию когда вы соединяетесь с SQL Server’ом через SQL Server Management Studio, но когда выполняется скрипт через консольную утилиту SQLCMD — их надо выставлять вручную. Чтобы всё это учесть, придется доработать напильником процесс генерации скрипта миграции. Для этого создаем дополнительный класс рядом с дата контекстом, который делает все что нужно:

    public class MigrationScriptBuilder : SqlServerMigrationSqlGenerator
    {
        public override IEnumerable<MigrationStatement> Generate(IEnumerable<MigrationOperation> migrationOperations, string providerManifestToken)
        {
            var statements = base.Generate(migrationOperations, providerManifestToken);
            var result = new List<MigrationStatement>();
            result.Add(new MigrationStatement { Sql = "SET QUOTED_IDENTIFIER ON;" });
            foreach (var item in statements)
            {
                item.BatchTerminator = "GO";
                result.Add(item);
            }
            return result;
        }
    }

И чтобы Entity Framework мог его использовать, регистрируем его в классе Configuration, который обычно находится в папке Migrations:

        public Configuration()
        {
            SetSqlGenerator("System.Data.SqlClient", new MigrationScriptBuilder());
	….
        }

После этого результирующий скрипт миграции будет содержать GO между каждым оператором, а в начале файла будет содержать SET QUOTED_IDENTIFIER ON;

Ура, подготовка сделана, осталось настроить сам процесс. В общем то в рамках релизного процесса в Azure DevOps (VSTS/TFS) это уже достаточно просто. Нам понадобится создать PowerShell скрипт вот такого вида:

param 
(
	[string] [Parameter(Mandatory=$true)] $dbserver,
	[string] [Parameter(Mandatory=$true)] $dbname,
	[string] [Parameter(Mandatory=$true)] $dbserverlogin,
	[string] [Parameter(Mandatory=$true)] $dbserverpassword,
	[string] [Parameter(Mandatory=$true)] $rootPath,
	[string] [Parameter(Mandatory=$true)] $buildAliasName,
	[string] [Parameter(Mandatory=$true)] $contextFilesLocation,
)

Write-Host "Generating migration script..."
$fullpath="$rootPath\$buildAliasName\$contextFilesLocation"
Write-Host $fullpath
& "$fullpath\migrate.exe" Context.dll /connectionProviderName="System.Data.SqlClient" /connectionString="Server=tcp:$dbserver.database.windows.net,1433;Initial Catalog=$dbname;Persist Security Info=False;User ID=$dbserverlogin;Password=$dbserverpassword;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" /startUpDirectory=$fullpath /verbose /scriptFile=1.SQL
Write-Host "Running migration script..."
& "SQLCMD" -S "$dbserver.database.windows.net" -U $dbserverlogin@$dbserver -P $dbserverpassword -d $dbname  -i 1.SQL
Write-Host "====Finished with migration script===="

И добавить блок выполнения PowerShell скрипта в процесс выкладки релиза. Блок и его настройки могут выглядеть так:



Настройка PowerShell выглядит как то так:



Важно не забыть добавить в проект файл migrate.exe из папки <ВашПроект>/packages/EntityFramework.6.2.0/tools/ и выставить ему свойство Copy Always чтобы эта утилита копировалась в выходной каталог при сборке проекта и вы могли получить к ней доступ в релизе Azure DevOps.

Нюанс. Если ваш проект при развертывании в Azure App Service также использует WebJob, то просто так добавить Migrate.exe в проект небезопасно. Мы столкнулись с тем, что в той папке, куда публикуется ваш WebJob платформа Azure запускает тупо первый попавшийся exe файл. И если ваш WebJob по алфавиту стоит позже migrate.exe (а у нас так и было) то она пытается запустить migrate.exe вместо вашего проекта!

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

  <entityFramework>
    <contexts>
      <context type="<full namespace for your DataContext class>, MyAssembly" disableDatabaseInitialization="true"/>
    </contexts>
  </entityFramework>

Где full namespace for your DataContext class это полный путь с неймспейсом до вашего наследника от DbContext, и MyAssembly — это название сборки, где лежит ваш контекст.

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

  <system.webServer>
    <applicationInitialization doAppInitAfterRestart="true">
      <add initializationPage="/" hostName="" />
    </applicationInitialization>
  </system.webServer>

Вы можете добавить несколько ссылок просто добавляя /> Утверждается, что в Azure при переключении слотов платформа дожидается окончания инициализации приложения и только потом переключает трафик на новую версию.

А если проект на .NET Core?


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

Ну а конкретно по шагам процесс выглядит так. В процесс сборки добавляем задачу:



Настраиваем ее для генерации файла с миграциями:



Не забываем добавить в проект PowerShell скрипт, который будет выполнять миграцию (описан выше) и собственно файл миграции. В результате после сборки проекта артефакты могли бы выглядеть как то так (кроме собственно архива со сборкой имеется дополнительно PS скрипт и SQL скрипт с миграциями):



Остается только в соответствующем Release шаге настроить выполнение этого PowerShell скрипта по аналогии с тем, как описано выше.

Об авторе


Павел Кутаков — эксперт по облачным технологиям, разработчик и архитектор программных систем в различных отраслях бизнеса — от банковской ИС, работающей по всему миру от США до Папуа-Новой Гвинеи, до облачного решения для национального оператора лотерей.

Комментарии (3)


  1. UnclShura
    22.01.2019 10:50

    Допустим база накатилась. А дальше что? Выкатили софтина, а она не работает. Базу ответить нельзя, а старый софт не работает с новой базой. Или хуже того работает, но неправильно. И в любом случае не тестировался...


  1. granit1986
    22.01.2019 22:23

    а что делать если такие кейсы:
    1) генерируется пересоздание таблицы вместо обычного alter table? а в таблице несколько миллионов записей и пересоздание нам не надо.
    2) несколько разработчиков выкатывают свои изменения схемы и вместе со схемой надо обновить какие-то данные и если это запускать единоразово — то скорее всего один из скриптов сломается.


    1. pkut
      23.01.2019 16:48

      1) Если нужен какой то кастомный SQL — он пишется в коде миграции через отдельные команды. Написать можно все что угодно, что не сломает механизм миграций.
      2) Предполагается что прежде чем вы запускаете миграцию на боевой базе, вы точно такие же действия проводите на тестовой и там выясняете кто и что сломал. Описанный подход никак не отменяет тестирования. Порядок генерирования скрипта обновления в точности повторяет порядок миграций в вашем коде, этим вы можете управлять. Соответственно можно организовать этот порядок так, чтобы все было сделано правильно.