ツナ缶雑記

ぐうたらSEのブログです。主にマイクロソフト系技術を中心に扱います。

Azure DevOpsでリリース時に構成ファイル(Web.config、App.config)の中身を書き換える(Classic Editor 編)

前回、Azure PipelinesのRelease機能を使って、appsettings.jsonの設定値をデプロイ時に書き換える方法について解説しました。 今回はその.NET Framework版ということで、Web.configや、App.configの値をデプロイ時に書き換える方法について解説します。

おおよその手順は前回と変わりません。 違うところだけかいつまんで書きますので、全体的なアーキテクチャとか、処理の流れについては前回の記事を参照してください。

tsuna-can.hateblo.jp

今回はWebアプリケーション(ASP.NET MVC 5)とコンソールアプリケーションを、同じVMにデプロイしてみようと思います。

環境

全体的な方式

前回の記事をご覧ください。 全体的な構成はほぼ変わりありませんが、今回はAzureのVMにはWebアプリケーションとコンソールアプリケーションを1つずつ配置します。 またWebアプリケーションの方は、データベース接続文字列とAppSettingsの値をそれぞれ1つ、Key Vaultに定義して差し替えるようにしていきます。

Azure Key Vaultにセキュアな設定値を定義する

これも前回の記事とほぼ同じです。 今回は接続文字列の値に加えて、AppSettingsの値を上書きするシークレット(この例では特に秘密にすような内容ではないのですが。。。)を定義しておきます。

Deployment Groupに本番環境のマシンを追加する

前回使ったマシンを再利用します。 Deployment Groupの構築については前回の記事を参考にしてください。

アプリケーションを作成する

今回はWebアプリケーションとコンソールアプリケーションを作っていきます。

ASP.NET MVC 5のアプリケーションを作成する

前回同様、Productsテーブルの全レコード数を画面表示するアプリケーションを、ASP.NET MVC 5のテンプレートに追加していきます。 データアクセス処理はEntity Framework 6.3を使い、*.edmxはデータベースファーストで自動生成した物を使います。

また今回は、AppSettingsから取得した値を画面表示するように実装していきます。 コントローラークラス(ProductController)は以下のようにしました。

using System.Configuration;
using System.Linq;
using System.Web.Mvc;
using AspNetMvcApp.Models;

namespace AspNetMvcApp.Controllers
{
    public class ProductController : Controller
    {
        public ActionResult Index()
        {
            using (var context = new TestDbContext())
            {
                ViewBag.Count = context.Products.Count();
            }

            ViewBag.ProductLabel = ConfigurationManager.AppSettings["ProductLabel"];
            return View();
        }
    }
}

Web.configには、ローカルマシンにあるデータベースへの接続文字列と、追加のAppSettings(ProductLabel)を定義しておきます。 今回はEntity Frameworkのデータベースファーストを使って、メタデータをedmxに含めるような設定を行っているため、接続文字列はかなり複雑な形式になっています。

<configuration>
  <configSections>
    <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
  </configSections>
  <appSettings>
    <add key="webpages:Version" value="3.0.0.0" />
    <add key="webpages:Enabled" value="false" />
    <add key="ClientValidationEnabled" value="true" />
    <add key="UnobtrusiveJavaScriptEnabled" value="true" />
    <add key="ProductLabel" value="Product Count"/>
  </appSettings>
  <connectionStrings>
    <add name="TestDbContext" connectionString="metadata=res://*/Models.TestDbContext.csdl|res://*/Models.TestDbContext.ssdl|res://*/Models.TestDbContext.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=localhost;initial catalog=test-db;integrated security=True;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
  </connectionStrings>

<!-- 後略 -->

ViewではViewBagに詰め込んだ2つの値を表示するように実装してあります。 実行すると以下のような画面になります。

f:id:masatsuna:20191209155808p:plain
アプリケーションの実行結果

コンソールアプリケーションを作成する

Productsテーブルの全レコードを、雑にCSV形式でファイル出力するように実装しています。 データアクセスには、Webアプリケーション同様Entity Framework 6.3のデータベースファーストを使っています。

using System;
using System.Configuration;
using System.IO;
using System.Linq;
using System.Text;
using ConsoleApp.Models;

namespace ConsoleApp
{
    class Program
    {
        static void Main(string[] args)
        {
            string outputDirectory = ConfigurationManager.AppSettings["OutputDirectory"];
            if (!Directory.Exists(outputDirectory))
            {
                Directory.CreateDirectory(outputDirectory);
            }

            string outputFilePath = Path.Combine(outputDirectory, $"{DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmss")}_ProductList.csv");
            using (var context = new TestDbContext())
            {
                var productsCount = context.Products.Count();
                var products = context.Products.ToList();
                using (var sw = new StreamWriter(outputFilePath, false, Encoding.UTF8))
                {
                    sw.WriteLine("Id,Name,Price");
                    foreach (var product in products)
                    {
                        sw.WriteLine($"{product.Id},{product.Name},{product.Price}");
                    }
                }
            }
        }
    }
}

App.configは以下のようになっています。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <configSections>
    <section name="entityFramework" type="System.Data.Entity.Internal.ConfigFile.EntityFrameworkSection, EntityFramework, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" requirePermission="false" />
  </configSections>
  <appSettings>
    <add key="OutputDirectory" value=".\out" />
  </appSettings>
  <startup>
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.8" />
  </startup>
  <entityFramework>
    <providers>
      <provider invariantName="System.Data.SqlClient" type="System.Data.Entity.SqlServer.SqlProviderServices, EntityFramework.SqlServer" />
    </providers>
  </entityFramework>
  <connectionStrings>
    <add name="TestDbContext" connectionString="metadata=res://*/Models.TestDbContext.csdl|res://*/Models.TestDbContext.ssdl|res://*/Models.TestDbContext.msl;provider=System.Data.SqlClient;provider connection string=&quot;data source=localhost;initial catalog=test-db;integrated security=True;MultipleActiveResultSets=True;App=EntityFramework&quot;" providerName="System.Data.EntityClient" />
  </connectionStrings>
</configuration>

VMの構築

普通にASP.NETアプリケーションを実行できるように構築しています。 アプリケーションはDefault Web Siteの下にぶら下げておきます。 前回作成したWebアプリケーションの隣に配置しているので、図では2つのWebアプリケーションが見えている状態です。

f:id:masatsuna:20191209162600p:plain
Webアプリケーションの構成

またコンソールアプリケーションを配置するためのディレクトリを作成しておきます。 今回はCドライブの直下に [ConsoleApp] という名前のディレクトリを作成し、その中に配置するようにします。

ビルドパイプラインを構築する

Webアプリケーションとコンソールアプリケーションをビルドして、Build Artifactsに登録するビルドパイプラインを構築します。 特に自動テストは組み込んでいないので、単純にMSBuildでアプリケーションをビルドして、Build Artifactsとして公開するだけです。

trigger:
- master

pool:
  vmImage: 'windows-latest'

variables:
  solution: '**/*.sln'
  buildPlatform: 'Any CPU'
  buildConfiguration: 'Release'

steps:
- task: NuGetToolInstaller@1
  displayName: 'NuGet のインストール'

- task: NuGetCommand@2
  displayName: 'NuGet パッケージの復元'
  inputs:
    restoreSolution: '$(solution)'

- task: VSBuild@1
  displayName: 'Web アプリケーションの発行ビルド'
  inputs:
    solution: '$(Build.SourcesDirectory)/<Webアプリケーションの*.csprojへのパス>'
    msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:PackageLocation="$(build.artifactStagingDirectory)/Web" /p:outputPath="$(Build.BinariesDirectory)/Web"'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: VSBuild@1
  displayName: 'コンソールアプリケーションのビルド'
  inputs:
    solution: '$(Build.SourcesDirectory)/<コンソールアプリケーションの*.csprojへのパス>'
    msbuildArgs: '/p:outputPath="$(build.artifactStagingDirectory)/ConsoleApp"'
    platform: '$(buildPlatform)'
    configuration: '$(buildConfiguration)'

- task: PublishBuildArtifacts@1
  displayName: 'ビルド成果物のアップロード - Web'
  inputs:
    PathtoPublish: '$(Build.ArtifactStagingDirectory)/Web'
    ArtifactName: 'AspNetMvcApp'
    publishLocation: 'Container'

- task: PublishBuildArtifacts@1
  displayName: 'ビルド成果物のアップロード - ConsoleApp'
  inputs:
    PathtoPublish: '$(Build.ArtifactStagingDirectory)/ConsoleApp'
    ArtifactName: 'ConsoleApp'
    publishLocation: 'Container'

今回は2つのアプリケーションがあるので、Build Artifactsは2つに分割してあります。 ビルドパイプラインが完成したら、後程使うので一度ビルドを実行しておきましょう。

Azure DevOpsとKey Vaultを接続して、シークレットの参照に必要な情報を定義する

前回の記事を参考にしてください。

リリースパイプラインを構築する

リリースパイプラインを作っていきます。 今回はVMに2つのアプリケーションをデプロイすることになります。 これらのアプリケーションの配置先は同一のVMなので、リリースパイプライン内に1つのStageを定義して、その中でWebアプリケーションのデプロイと、コンソールアプリケーションの配置を行っていきます。 ほとんどの箇所は前回解説した通りですので、異なる部分だけ解説します。

WebアプリケーションのWeb.configの設定値を差し替える

.NET FrameworkのWebアプリケーションの場合、アプリケーションの動作設定はWeb.configファイルに行います。 このファイルの設定値を、アプリケーションのデプロイ直前に差し替えるには、 [IIS web app deploy] のタスクを使用します。 [File Transforms & Variable Substitution Options] のセクションを開いて [XML variable substitution] のチェックをオン にします。 これが、Web.configを書き換えるための設定です。 その他の設定は、要件に合わせて変更してください。

f:id:masatsuna:20191210000253p:plain
Webアプリケーションのデプロイ設定

次にWeb.configの値をどの値に書き換えるかの定義を行います。 .NET Coreのappsettings.jsonの場合、どの設定値であっても自由に書き換えることができましたが、構成ファイルの場合は変更できる項目に制限があります。 以下のセクションの設定値のみ、書き換えが行われます。

  • applicationSettings
  • appSettings
  • connectionStrings
  • configSectionsに定義した要素

applicationSettingsセクション、configSectionsセクションに定義したセクションは、リリースパイプラインに設定する変数名に対応した属性の値が、変数の値で上書きされます。 appSettingsセクションは、リリースパイプラインに設定する変数名に対応したkey属性をもつadd要素のvalue属性値が、変数の値で上書きされます。 connectionStringsセクションは、リリースパイプラインに設定する変数名に対応したname属性をもつadd要素のconnectionString属性値が、変数の値で上書きされます。

具体的な変換例については以下を参照してください。

docs.microsoft.com

今回はconnectionStringsセクションの TestDbContext というname属性を持つadd要素のconnectionString属性値と、appSettingsセクションの ProductLabel というkey属性を持つadd要素のvalue属性値を差し替えます。 これらの値を差し替えるには、リリースパイプラインの変数に [TestDbContext] 、 [ProductLabel] を定義します。 このあたりの設定は前回解説したのとほぼ同じなので割愛します。

Azure Key Vaultに、差し替えたい値のキー名と同じ名前のシークレットを準備して、Azure DevOpsで参照すると、有無を言わさず値の差し替えが実行されてしまうことには注意が必要です。 例えば今回の構成ファイルのappSettingsセクションには、 [ClientValidationEnabled] という名前のキーが準備されています。 この状態で、 Azure Key Vaultに同名のシークレットを登録し、 [Pipelines] → [Library] メニューでVariable groupsに追加してしまうと、その設定値が上書きされてしまいます。 実際の開発で使うことを想定すると、Azure Key Vaultのシークレット名はKey Vaultのシークレットであることを表すような命名規則*1としておくことが、予期せぬ設定値の上書きを防ぐのに役立つかと思います。

App.configの設定値を差し替える

Azure Pipelinesのリリースパイプラインでコンソールアプリケーションをデプロイする場合、通常は [Copy files] などの単純なタスクを用いてファイルを配置していきます。 これらの単純なタスクには、設定値を差し替えるような機能は備わっていません。 そこで使用するのが、 [File transform] というタスクです。 これは前述したWebアプリケーションの設定差し替え機能だけを取り出したタスクになっており、今回解説しているXMLファイルの設定値差し替え以外に、前回解説したJSONファイルの設定値差し替えも行うことができます。

docs.microsoft.com

コンソールアプリケーションの場合、実装時に作成するApp.configファイルは、ビルド時に [<実行ファイルの名前>.exe.config] という名前にリネームされます。 今回は [ConsoleApp.exe] という名前の実行ファイルを作るよう設定しているので、App.configは [ConsoleApp.exe.config] という名前になります。 以上を加味すると、 [File transform] のタスクを使用して、コンソールアプリケーションの構成ファイル設定値を差し替える設定は以下のようになります。

f:id:masatsuna:20191210000147p:plain
File transformの設定

設定値の差し替えは、Webアプリケーションの場合とまったく同じで、リリースパイプラインの変数定義を使用して行います。

作成したリリースパイプラインを実行すると、デプロイする際設定値の差し替えが実行されます。

まとめ

今回はAzure DevOpsのリリースパイプライン内で、.NET Frameworkを用いて構築したアプリケーションの構成ファイルの設定値を差し替える方法について解説しました。 正直.NET Core版(というか、appsettings.jsonの設定値差し替え)の方が高機能でしたね。 時代は.NET Coreですから、できる限りそちらに移行していきたいものです。

*1:例えばシークレットの名前には、必ず [KVSecret-] を接頭辞として付与する、など。