しばやん雑記

ASP.NET とメイドさんが大好きなフリーランスのプログラマーのブログ

SignalR のメトリクスを Application Insights に直接送信する

まともに触ったのは数年振りという感じがしますが、前々から SignalR のメトリクスはパフォーマンスカウンターに書き込まれるようになっていて、App Service などから扱いにくいなと思ってました。

最近は ARM 経由で App Service のパフォーマンスカウンターが取れるようになってますが、一部の値だけなので扱いにくいことには変わりないです。

特に SignalR のカウンターはインストールが必要なので、この時点で App Service では無理です。

そもそも Application Insights を使っているなら、パフォーマンスカウンターを経由せずにテレメトリを送信すればよいのではと思ったので、Telemetry Module を書いてみました。

インストールして、ApplicationInsights.config に TelemetryModule を追加すれば良いです。

<?xml version="1.0" encoding="utf-8"?>
<ApplicationInsights xmlns="http://schemas.microsoft.com/ApplicationInsights/2013/Settings">
  <TelemetryModules>
    <Add Type="SignalR.AppInsights.SignalRPerformanceCollectorModule, SignalR.AppInsights" />
  </TelemetryModules>
</ApplicationInsights>

アプリケーションをデプロイすれば、SignalR のメトリクスが Application Insights で確認できるようになります。今は 60 秒間隔でカスタムのパフォーマンスカウンターとして送信しています。

Application Insights で確認できるようになるまで少しかかりますが、しばらくすると選択肢に SignalR のパフォーマンスカウンターが出てくるようになります。

f:id:shiba-yan:20180416134247p:plain

実際に Web App を 3 インスタンス用意して、Backplane として Redis を設定したアプリケーションに対して、トランスポートを切り替えて 200 接続ぐらい行った時の値です。

接続毎にメッセージを 100ms 間隔で送信しているので、秒間のメッセージ数がかなり多いことが把握できます。Backplane のメトリクスはパフォーマンスカウンターがないと把握出来ない値でしたが、Application Insights に送信すれば一目で分かりますね。

f:id:shiba-yan:20180416134235p:plain

今は SignalR のパフォーマンスカウンターの値を全て送信していますが、この辺り設定可能にしておいても良いのかなと少し思っています。

余力があれば config から変更可能にしようかなという気分です。

ASP.NET でも Cookie Policy への対応を行えるか試した

ASP.NET Core 2.1 で入った Cookie Policy が面白かったのと、ASP.NET というか IIS でも HttpModule を使うことで対応出来そうだったので実装して試すことにしました。

行うことは非常に単純なので、HttpModule を書いたことがあれば大体は想像がつくはずです。

  • トラッキングを許可するクッキーがあるか確認
  • ある場合
    • 何もしない
  • ない場合
    • 確認のメッセージを出す
    • クライアントにクッキーを送信しない

別に Global.asax に書いても良いですが、分けた方が好きなので HttpModule として分けました。ただし DynamicModuleHelper を使って動的に追加するようにしています。

何はともあれ、今回でっち上げたコードは以下のような感じです。難しいことはしていません。

using System;
using System.Web;

using Microsoft.Web.Infrastructure.DynamicModuleHelper;

[assembly: PreApplicationStartMethod(typeof(WebApplication4.CookiePolicyModule), "Register")]

namespace WebApplication4
{
    public class CookiePolicyModule : IHttpModule
    {
        public void Init(HttpApplication context)
        {
            context.BeginRequest += OnBeginRequest;
            context.PreSendRequestHeaders += OnPreSendRequestHeaders;
        }

        private void OnBeginRequest(object sender, EventArgs e)
        {
            var application = (HttpApplication)sender;

            var cookie = application.Request.Cookies[CookiePolicy.CookieName];

            application.Context.Items[CookiePolicy.CanTrackKey] = cookie?.Value == CookiePolicy.CookieValue;
        }

        private void OnPreSendRequestHeaders(object sender, EventArgs e)
        {
            var application = (HttpApplication)sender;

            if (CookiePolicy.CanTrack)
            {
                return;
            }

            // Clear all cookies
            application.Response.Cookies.Clear();
        }

        public void Dispose()
        {
        }

        public static void Register()
        {
            DynamicModuleUtility.RegisterModule(typeof(CookiePolicyModule));
        }
    }
}
using System.Web;

namespace WebApplication4
{
    public static class CookiePolicy
    {
        public static string CookieName { get; set; } = "ASP.NET_CookiePolicy";

        internal const string CookieValue = "yes";

        public const string CanTrackKey = "CanTrack";

        public static bool CanTrack => (bool?)HttpContext.Current.Items[CanTrackKey] ?? false;

        public static string CreateConsentCookie()
        {
            return $"{CookieName}={CookieValue}; path=/";
        }
    }
}

難しいことはしていないですが、Web.config に HttpModule の追加設定を書くのが面倒だったので、DynamicModuleUtility を使って実行時に HttpModule を追加するようにしました。こっちの方が楽ですが使っていいのかわかってません。

とりあえず Web Forms で画面を作ってブラウザで実行してみます。初回なのでクッキーはありません。

f:id:shiba-yan:20180307004527p:plain:w400

Page_Load でセッションに書き込むようにしたので、通常であればセッション用のクッキーが出力されるはずですが、ブラウザで確認しても存在しないです。ちゃんと HttpModule で削除されています。

f:id:shiba-yan:20180307004641p:plain:w550

accept cookie を押して、専用のクッキーを書き込むとセッションクッキーも書き込まれるようになりました。ちなみに専用のクッキーは JavaScript で書き込む必要があるので少し注意が必要ですね。

f:id:shiba-yan:20180307004653p:plain:w550

ASP.NET Core と同じように ASP.NET / IIS でも実現出来ました。試していませんが IIS の HttpModule として実装しているので、原理的には PHP などのアプリケーションでも同様に動作するはずです。

こういうプライバシーの問題は対応が難しいですね。

ASP.NET Web Forms のコンパイル周りの挙動を調べる

最近は流石に Web Forms を新規開発で使うことは少なくなってきていると信じたいところですが、基本的に Web Forms から MVC への移行はフルスクラッチになってしまうという現状もあり、どうにか Web Forms のままアーキテクチャを改善したいというケースは多いのではないでしょうか。

ASP.NET アプリケーションのモダナイゼーションを行っている立場としても、ここで Web Forms についてさらに深いところまで知っておくべきだと思ったので、まずは基本的な部分を確認しておきました。

コードビハインドと実体のクラスは別

今更言うまでもない感じもしますが Inherits で指定されている通り、コードビハインドで書いたクラスと実際にコンパイルされた ASPX のページクラスは別物です。

f:id:shiba-yan:20180128225548p:plain

あくまでも継承元のクラスを書いているにすぎません。ASPX の実体はランタイムによって実行時にコンパイルが行われるか、予め aspnet_compiler を使ってプリコンパイルすることで生成されます。

コードビハインドは必須ではない

Visual Studio で Web Forms を追加するとコードビハインドがセットになりますが、必要ない場合は削除することが出来ます。この場合は自動的に Page クラスから派生されることになります。

Web Pages や Razor Pages のようにインラインでコードを書くことも出来ますが、基本的に書くべきではありません。あくまでも ASPX を純粋なテンプレートエンジンとして使う場合に限った方が良いでしょう。

<%@ Page Language="C#" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title></title>
</head>
<body>
    <div>
        <%: DateTime.Now %>
    </div>
</body>
</html>

懐かしの ASP.NET MVC 3 より前で良く使われていた書き方です。

ちなみに Inherits を追加すれば、カスタムクラスから派生させることも出来るので、応用範囲としては広いと思います。必要ないクラスを作ることはありません。

ASPX 単位では C# / VB の共存が可能

普通はやらないと思いますが、ASP.NET はビューがアプリケーションとは別にコンパイルが行われるので、C# プロジェクトであっても VB で書かれた ASPX を追加できます。ただしコードビハインドは無理です。

Language に VB を指定して、Visual Studio でファイルを開き直せば VB のコードが書けるようになります。

<%@ Page Language="vb" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
            <%: Me.Context.Request.RawUrl %>
        </div>
    </form>
</body>
</html>

ちゃんと ASPX は VB ファイルとしてコンパイルされていることが確認できます。

f:id:shiba-yan:20180128220250p:plain

中身を確認しておきましたが、ちゃんと VB のコードになっています。

f:id:shiba-yan:20180128221019p:plain

C# プロジェクトで VB を使って ASPX を書くメリットは無いと思いますが、逆に VB プロジェクトでは C# を使って ASPX を書けた方が良い場合はありそうです。

余談 : IHttpHandler について

ASP.NET の仕様では IHttpHandler がプリミティブな処理単位となっています。ASHX で書くジェネリック HTTP ハンドラーと同じで、ASPX もコンパイルされた結果は IHttpHandler を実装したクラスになります。

ちなみにコンパイルされた結果のクラスは BuildManager.GetCompiledType を呼び出すと取得できます。

// コンパイルされた About.aspx の型を取得
var type = BuildManager.GetCompiledType("~/About.aspx");

// About.aspx の実体をインスタンス化
var page = (Page)Activator.CreateInstance(type);

そして HttpContext には RemapHandler という IHttpHandler を入れ替えるメソッドがあります。

HttpContext.RemapHandler(IHttpHandler) Method (System.Web) | Microsoft Docs

ASP.NET Routing は IHttpModule と RemapHandler を組み合わせて、ASP.NET MVC と同じようなルーティングを実装しています。ASP.NET MVC も BuildManager 周りを上手く抽象化してテンプレートエンジンとして実装しています。

最終的に IHttpHandler にしてしまえば、後は ASP.NET ランタイムに食わせて処理が可能ということです。

ASP.NET 4.7.1 の Configuration Builder を使って Docker での運用を楽にしたい

最近は ASP.NET に Docker での運用を意識した機能が入り始めていて、早く Server Core の Docker Image が 1GB ぐらいにならないかと思い続ける日々です。

とはいえ、現時点でも Server Core を使った ASP.NET の Dockernize は十分に可能なので、Configuration Builder を試しておくことにします。

Configuration Builder 関連のライブラリは NuGet で配布されていますが、.NET Framework 4.7.1 がターゲットになっていないと当然ながら使えないので、少しだけ注意が必要です。

NuGet Gallery | Packages matching Microsoft.Configuration.ConfigurationBuilders

用意されているパッケージは以下の 4 種類です。個人的に使いそうなのは Environment Variables ぐらいかなと思いますが、Azure KeyVault は高セキュリティが求められる部分で便利かもしれません。

  • Json
  • User Secret
  • Environment Variables
  • Azure KeyVault

何はともあれ、こういった各ソースからのデータをいい感じに ASP.NET アプリケーションから統一された方法で扱えるようになるのは、Docker 関係なく便利ですね。

この辺りの設計は Azure App Service と ASP.NET Core を意識して作られているように思います。昔やったようにリフレクションで ConfigurationManager を弄るよりも圧倒的に安心感があります。

とりあえず実際に分かりやすい環境変数を使ってみることにします。Docker では設定値を環境変数経由で渡すことが非常に多いので、一番使う機会が多いパッケージだと思っています。

パッケージをインストールしたら最低限の設定が追加されるので、多少カスタマイズしておきました。Builder は複数設定できるので、App Settings と Connection Strings に対しても適用できます。

<configuration>
  <configSections>
    <section name="configBuilders" type="System.Configuration.ConfigurationBuildersSection, System.Configuration, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" restartOnExternalChanges="false" requirePermission="false" />
  </configSections>
  <configBuilders>
    <builders>
      <add name="AppSetting" mode="Strict" prefix="APPSETTING_" stripPrefix="true" type="Microsoft.Configuration.ConfigurationBuilders.EnvironmentConfigBuilder, Microsoft.Configuration.ConfigurationBuilders.Environment, Version=1.0.0.0, Culture=neutral" />
      <add name="ConnectionString" mode="Strict" prefix="CONNSTRING_" stripPrefix="true" type="Microsoft.Configuration.ConfigurationBuilders.EnvironmentConfigBuilder, Microsoft.Configuration.ConfigurationBuilders.Environment, Version=1.0.0.0, Culture=neutral" />
    </builders>
  </configBuilders>
  <appSettings configBuilders="AppSetting">
    <add key="CUSTOM_VALUE" value="1" />
  </appSettings>
  <connectionStrings configBuilders="ConnectionString">
    <add name="DefaultSqlConnection" connectionString="Data Source=.\SQLEXPRESS;Initial Catalog=SampleDB;Integrated Security=True" providerName="System.Data.SqlClient" />
  </connectionStrings>
</configuration>

App Settings と Connection Strings に対して Configuration Builder を使うように設定しました。

とりあえずローカルの Docker でテストしておきたいので、docker-compose.yml を開いてオーバーライドしたい名前と値で環境変数を追加しておきます。この辺りの使い方は App Service っぽさがあります。

version: '3'

services:
  webapplication1:
    image: webapplication1
    build:
      context: .\WebApplication1
      dockerfile: Dockerfile
    environment:
      - APPSETTING_CUSTOM_VALUE=10
      - CONNSTRING_DefaultSqlConnection=Data Source=sample.database.windows.net; Initial Catalog=AzureDB; User Id=hoge; Password=hogehoge

実行すると、環境変数で設定した値でオーバーライドされていることが分かります。良い感じですね。

f:id:shiba-yan:20171227230311p:plain

折角なので AKS 上に構築した Windows Containers の環境にデプロイして、ちゃんと反映されるかも確認したかったのですが、手持ちの AKS クラスターの調子が悪くてデプロイ出来なかったので諦めました。

最新の Visual Studio 2017 を使うと ASP.NET アプリケーションの Docker Image をコンテナーレジストリにデプロイすることが可能となってます。これは便利。

f:id:shiba-yan:20171227231234p:plain

Docker Image を環境ごとにビルドするとか非効率かつ不安定なことをしたくないので、Docker を使って ASP.NET アプリケーションを運用する場合には必須の機能ですね。

実際に Docker Hub にデプロイしてみましたが、あっさりと終ったので特に書くこともありません。

f:id:shiba-yan:20171227234327p:plain

本当なら ACI にでもサクッとデプロイしようかと思っていましたが、プロジェクトのデフォルトだと Windows Server Version 1709 ベースだったので、実行環境が非常に限られてしまうのが難点です。

来年こそは ASP.NET アプリケーションを Dockernize する仕事をやってみたいですね。

ASP.NET でセッション単位の同時リクエスト数を制限する

ASP.NET の SessionState Module の実装では、セッションにロックがかかっている場合にはタイマーで polling して待つ実装になってますが、ここで F5 とかされまくってしまったら IIS のリソースを食いすぎてしまうので、.NET 4.7 から上限が設定されました。

このあたりの変更に関しては、ちゃんと GitHub にドキュメントが公開されています。

In the .NET Framework 4.6.2 and earlier, ASP.NET executes requests with the same Sessionid sequentially, and ASP.NET always issues the Sessionid through cookie by default. If a page takes a long time to respond, it will significantly degrade server performance just by pressing F5 on the browser. In the fix, we added a counter to track the queued requests and terminate the requests when they exceed a specified limit. The default value is 50. If the the limit is reached, a warning will be logged in the event log, and an HTTP 500 response may be recorded in the IIS log.

dotnet/throttle-concurrent-requests-per-session.md at master · Microsoft/dotnet · GitHub

.NET 4.6.2 までは無制限にリクエストを待機させる実装でしたが、.NET 4.7 からは 50 リクエストまでに制限されました。それ以上のリクエストが来ると HttpException を投げるので 500 になります。

互換性を重視する場合には int.MaxValue の値を設定すればよいです。微妙に前回の Async SessionState にも関係してくる部分なので、ついでに試しておくことにしました。

Async SessionState Module にも同じ実装が入っているので、互換性はちゃんとあります。

試す場合には 50 リクエストというのは難易度が高いので、今回は 2 とか少なくして試します。Web.config にキーを追加すれば、その値が上限になるという簡単な設定方法です。

<appSettings>
  <add key="aspnet:RequestQueueLimitPerSession" value="2" />
</appSettings>

名前からわかるようにセッション単位の制限なので、50 というのは割と多いかもしれません。この辺りは適宜調整をすればよいのではないかと思いました。

Async SessionState で用意したように、10 秒ぐらい待たせるアクションに対してリクエストを複数回投げると、以下のように 500 エラーが返ってきます。

f:id:shiba-yan:20171126022631p:plain

セッションのロックで待つケースは大体不具合だと思いますし、結構長時間待たされてリクエストも複数回投げられてしまうはずなので、上限を設定しておくのは悪くない方法だと思いました。

Redis Session Provider を使っている場合には、ちょいちょいロック周りで問題が発生しているみたいですし、そういった不具合の緩和策としても多少は役に立つかもしれません。理想としては素早く処理を終わらせることですけどね。

ASP.NET の Async SessionState Module を試してみる

あまり使うつもりがなかったというか、対応したセッションプロバイダーが In-Memory と SQL Server ぐらいしかなかったので触れてなかったですが、ASP.NET 4.6.2 から非同期対応の SessionState Module が使えるようになってます。

全然情報を見なかったのと、Cosmos DB のプロバイダーが出たので調べました。

単純に Task ベースで書き直されたという感じですが、モダンなストレージクライアントでは Task を使うのが当たり前という状況なので、新しいプロバイダーは作りやすくなったと思います。

ちなみに 1.1.0 から非同期以外にロックなしのセッションに対応しています。以下のブログで紹介はされていますが、このタイトルで SessionState Module の新機能が紹介されているとか気が付かないです。

ASP.NET のセッションはストレージから取得する時にロックをかけて、排他制御するように実装されています。それによって、同じセッションのリクエストが複数来た場合に、どれかで待ち時間が発生するとロックが解除されるまで待機するようになっています。

何らかの理由でロックが解除されない場合には、AcquireRequestState の内部で最長 125 秒の待ちが発生します。この挙動はこれまで変更することが出来ませんでしたが、Async SessionState Module の 1.1.0 で設定が追加されました。

appSettings に aspnet:AllowConcurrentRequestsPerSession を追加すると、ロックされないようになります。

<appSettings>
  <add key="aspnet:AllowConcurrentRequestsPerSession" value="true"/>
</appSettings>

実際に確認するために、適当にアクションを用意しました。セッションプロバイダーとしては Async SessionState Module に対応した Cosmos DB を利用します。

Task.Delay を使って 20 秒ほど待たせる、特に説明することのないアクションです。

public async Task<ActionResult> Index()
{
    Session["test"] = DateTime.Now.ToString();

    await Task.Delay(TimeSpan.FromSeconds(20));

    return View();
}

このサンプルを使って、AllowConcurrentRequestsPerSession の設定によってどのように挙動が変わるかを簡単に確認してみます。

AllowConcurrentRequestsPerSession = false

アプリケーションを実行し、待機中の時点で Cosmos DB に格納されたデータを表示すると、locked = true となっていて確かにセッションがロックされていることが分かります。これまで通りの挙動です。

f:id:shiba-yan:20171126015715p:plain

20 秒が経過し、アクションの実行が完了すると locked = false となりロックが解除されました。

f:id:shiba-yan:20171126015721p:plain

ちゃんとロックされていることが確認できたので、実際にロック中に別リクエストを投げてみたところ、想定通りに別リクエストでは 20 秒の待ち時間が発生しました。

f:id:shiba-yan:20171126015747p:plain

1 つのリクエストが詰まると、同じセッションのリクエスト全てで時間がかかってしまうわけです。ASP.NET ではクッキーを持っていればデフォルトでセッションを復元するので、困った挙動となります。

AllowConcurrentRequestsPerSession = true

設定を有効にして、同じようにリクエストを投げると、当然ながら locked は常に false のままとなり、ロックはかかりません。なので、自由にセッションの読み書きが行えるようになっています。

先ほどと同様に待機中にリクエストを投げても、待ちが発生することなく完了します。

f:id:shiba-yan:20171126015755p:plain

これまでは一貫性が保証されていたセッションをロックなしにすると、よくある非同期での問題が発生することになるので、セッションを使っている部分をちゃんと理解して設定を有効にする必要があるでしょう。

とはいえ、最近の Web アプリケーションは同時リクエスト多いですし、使いどころはありそうです。

ASP.NET のセッションプロバイダーに Cosmos DB が仲間入り

ASP.NET 4.7.1 に関する情報がブログに公開されてました。Package-First なアプローチらしいです。

要するに ASP.NET の新機能と .NET Framework のアップデートのタイミングが別々になるということでしょう。最近はこの方式を採用しているケースが多いですね。

既にいろいろと紹介されている Configuration Builder が大部分を占めていますが、最後にひっそりと Cosmos DB を使ったセッションプロバイダーが載っていました。

バージョンは 1.0.0 ですが .NET 4.6.2 から対応した Async SessionState に対応しています。

Async SessionState に関しては、去年 NuGet でモジュールがリリースされています。

セッションプロバイダーを書いたことがある人ならわかると思いますが、これまでの SessionState は非同期とか全く考えられていない時に作られたものなので、基本的に全て同期処理されるようになってます。

なので、最近の新しいストレージを使う場合には、わざわざ Task が使われている部分を Wait や Result で待つ実装が必要でしたが、これが地味に大変。

話がずれたので Cosmos DB のセッションプロバイダーに戻ります。例によって NuGet でインストールするだけの簡単セットアップなので、特に紹介することはないです。

f:id:shiba-yan:20171118143956p:plain

インストールすると Web.config に必要な設定が追加されるので、少し弄るだけで使えるようになってます。

Endpoint と AccessKey は appSettings にキーが追加されているので、正しい値をセットしておきます。

f:id:shiba-yan:20171118144823p:plain

DatabaseId と CollectionId はダミー文字列なので適当に名前を決めて設定しておきます。存在しない場合には offerThroughput の値で新規に作成されます。デフォルトは 5000 RU なので少し注意。

パーティションキーの指定とか、優先的に使うリージョンの設定も出来るみたいですが、もうちょっとドキュメントと検証が必要な感じです。

適当に ASP.NET 側でセッションに値をセットすると、ちゃんと Cosmos DB にドキュメントとしてデータが保存されていました。ID がそのままセッション ID になっている、非常にわかりやすい実装です。

f:id:shiba-yan:20171118150218p:plain

データ自体はいつも通り BinarySeiralizer でシリアライズされているみたいで、そして多分ロックなしの実装です。Redis も同じように Async SessionState で実装されると嬉しいですね。

特徴としては Cosmos DB の TTL 機能が使われているので、勝手に期限切れのデータが消えてくれるのが便利です。timeout を 60 分に設定したところ、ちゃんと ttl は 3600 秒になってます。

f:id:shiba-yan:20171118182155p:plain

セッションプロバイダーとして Cosmos DB を使うことで、高い可用性と低いレイテンシ、そしてジオレプリケーションなど Cosmos DB の利点を全て得ることが出来ますね。

しかし RU の関係もあり、セッションに何でもかんでも入れるような実装では、一瞬で RU が足りなくなって高額になってしまいそうですし、設計時点で考慮する必要があるでしょう。

Azure 上の Visual Studio 2017 を使って Windows Containers サポートを試してみた

手持ちの MacBook Pro だと Hyper-V を使った時の動作に不安があったので、最近 Nested Virtualization に対応した Dv3 / Ev3 インスタンスが追加された Azure 上で試すことにしました。

Dv3 / Ev3 については、既にぶちぞう RD が書いてるので、そっちを読むと良いです。

Dv3 / Ev3 VM と Nested Virtualization | ブチザッキ

ちなみに Visual Studio 2017 の Tools for Docker は Windows と Linux の両方に対応していると、ちゃんとドキュメントに書かれています。

Microsoft Visual Studio 2017 with Docker for Windows supports building, debugging, and running .NET Framework and .NET Core web and console applications using Windows and Linux containers.

Visual Studio Tools for Docker | Microsoft Docs

クライアント OS で試したかったので Windows 10 と Visual Studio 2017 (15.2) のイメージを選んで、Dv3 インスタンスを East US に作成しました。

念のためメモリが 2 かずあき載った D4s v3 を用意しました。

f:id:shiba-yan:20170716171057p:plain

Windows 10 で Hyper-V Container を使う場合のドキュメントは公式に提供されているので、それを読んで作業すれば問題ないです。Hyper-V は普通にインストールできました。

日本語版は情報が古いので、基本的に英語版を参照することをお勧めします。

Windows Server 2016 は Docker EE ですが、Windows 10 の場合は Docker CE になります。

とはいえ普通に Docker for Windows をインストールして、通知領域アイコンのメニューから Windows Containers への切り替えを実行すれば使えるようになります。

f:id:shiba-yan:20170716020859p:plain

Windows Containers と Docker を使う準備は出来たので、Visual Studio 2017 を使って新しく ASP.NET MVC プロジェクトを作成していきます。

ASP.NET Core の場合はこの画面で Docker サポートを追加出来ますが、ASP.NET では選べません。

f:id:shiba-yan:20170716014100p:plain

しかし、プロジェクトを作成した後にソリューションエクスプローラーの右クリックメニューから追加を選ぶと、Docker Support という項目があるので、これを選ぶと必要なファイルが追加されます。

f:id:shiba-yan:20170716014341p:plain

追加を行うと Docker Compose に必要なファイルと、Dockerfile が作成されます。この辺りは ASP.NET Core と変わっていないように思います。

f:id:shiba-yan:20170716014434p:plain

ちなみに生成される Dockerfile は以下のような形式です。wwwroot にアプリをコピーするだけです。

FROM microsoft/aspnet
ARG source
WORKDIR /inetpub/wwwroot
COPY ${source:-obj/Docker/publish} .

Docker Support を追加すると、デバッグ実行のボタンが Docker という表示に変わります。

f:id:shiba-yan:20170716014641p:plain

これからはアプリケーションの実行とデバッグは全て Docker を利用して行われます。実際に実行すると Docker Image のビルドが行われた後に、アプリケーションが立ち上がってきます。

f:id:shiba-yan:20170716014817p:plain

初回は Docker Image の pull に時間がかかるのでのんびり待ちましょう。

コンテナが立ち上がってしまえば、後はこれまで通りの ASP.NET アプリケーションです。

f:id:shiba-yan:20170716015031p:plain

Docker Compose 時にリモートデバッグモニタも起動するので、コンテナ内で動作している ASP.NET アプリケーションに対しても、これまで通りのデバッグが行えます。

ブレークポイントを置くとちゃんと止まりますし、変数の確認などもこれまで通りですが、Application Insights を使ったトレースは動いていない気がしました。

f:id:shiba-yan:20170716015156p:plain

ASP.NET Core アプリケーションの場合は、この後 App Service on Linux に直接デプロイすることが出来るのですが、Windows Containers を使った ASP.NET アプリケーションはデプロイ機能が用意されていません。

現実的には AppVeyor と連携して、Docker Image を作成後に Windows の Kubernetes クラスタなどへデプロイする形になるのではないかと思っています。

ベースとなる Windows Server Core イメージが相変わらず巨大という問題はありますが、開発環境と実行環境としては整ってきた感があります。今後も注目していく予定です。

ASP.NET の App Settings と Connection Strings の値を環境変数からオーバーライド可能にする

App Service は Azure Portal から設定した App Settings と Connection Strings の値を、いい感じにオーバーライドしてくれるのが非常に便利だったので、同じように環境変数からオーバーライド出来るようにするライブラリを書きました。主に Docker 向けとして考えてます。

リポジトリは用意しましたが、NuGet とかにはしてないです。今後考えます。

やってることは App Service と同じプリフィックスが付いた環境変数を読み取って、ConfigurationManager の値を書き換えているだけです。App Service がどう実装してるのかは謎です。

Application_Start より早く処理しておきたいので、PreApplicationStartMethod を使いました。

using System;
using System.Collections;
using System.Configuration;
using System.Linq;
using System.Web;

[assembly: PreApplicationStartMethod(typeof(OverrideConfig.OverrideBootstrapper), "Start")]

namespace OverrideConfig
{
    public class OverrideBootstrapper
    {
        public static void Start()
        {
            var environmentVariables = Environment.GetEnvironmentVariables()
                                                  .Cast<DictionaryEntry>()
                                                  .ToDictionary(x => (string)x.Key, x => (string)x.Value);

            foreach (var appSetting in environmentVariables.Where(x => x.Key.StartsWith(AppSettingsPrefix, StringComparison.OrdinalIgnoreCase)))
            {
                ConfigurationManager.AppSettings[appSetting.Key.Substring(AppSettingsPrefix.Length)] = appSetting.Value;
            }

            var connectionStrings = new ConnectionStringSettingsCollectionWrapper(ConfigurationManager.ConnectionStrings);

            foreach (var connectionString in environmentVariables.Where(x => x.Key.StartsWith(SqlServerConnStrPrefix, StringComparison.OrdinalIgnoreCase)))
            {
                connectionStrings.AddOrUpdate(connectionString.Key.Substring(SqlServerConnStrPrefix.Length), connectionString.Value, SqlServerProviderName);
            }
        }

        private const string AppSettingsPrefix = "APPSETTINGS_";
        private const string SqlServerConnStrPrefix = "SQLSERVERCONNSTR_";

        private const string SqlServerProviderName = "System.Data.SqlClient";
    }
}

ループを 2 つ回してるのがちょっとイケてない感じしますが、Connection Strings は後付けだったので暇なときに何とかする方向にします。

AppSettings は読み書きが自由なので問題なかったのですが、ConnectionStrings は読み取り専用になってしまっていて、そのままだとコレクションの操作が出来なかったです。少し調べてみると、リフレクションを使って内部のフラグを書き換えろとありました。

ちょっと無理やりな気もしますが、それ以外は Web.config を物理的に書き換える方法しか見つからなかったので、リフレクションを使って書き換える方法を採用することにしました。

最近は Reference Source のおかげで、こういった内部のフラグを調べるのが楽になりましたね。

https://referencesource.microsoft.com/#System.Configuration/System/Configuration/ConfigurationElement.cs,58
https://referencesource.microsoft.com/#System.Configuration/System/Configuration/ConfigurationElementCollection.cs,29

AppSettings とインターフェースを出来るだけ合わせるために、Wrapper を用意して内部でリフレクションを使ってフラグを書き換えるようにしました。

この時 Collection と Element の両方で書き換え処理が必要なので注意です。

internal class ConnectionStringSettingsCollectionWrapper
{
    public ConnectionStringSettingsCollectionWrapper(ConnectionStringSettingsCollection connectionStrings)
    {
        _connectionStrings = connectionStrings;

        typeof(ConfigurationElementCollection).GetField("bReadOnly", BindingFlags.Instance | BindingFlags.NonPublic)
                                                .SetValue(_connectionStrings, false);
    }

    private readonly ConnectionStringSettingsCollection _connectionStrings;

    public void AddOrUpdate(string name, string connectionString, string providerName)
    {
        var settings = _connectionStrings[name];

        if (settings == null)
        {
            _connectionStrings.Add(new ConnectionStringSettings
            {
                Name = name,
                ConnectionString = connectionString,
                ProviderName = providerName
            });
        }
        else
        {
            typeof(ConfigurationElement).GetField("_bReadOnly", BindingFlags.Instance | BindingFlags.NonPublic)
                                        .SetValue(settings, false);

            settings.ConnectionString = connectionString;
            settings.ProviderName = providerName;
        }
    }
}

既に同名のキーが存在する場合には上書き、存在しない場合には新規追加という風にしました。

最後に簡単に動作確認だけしておきます。今回は以下のような設定を Web.config に追加しました。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <appSettings>
    <add key="TEST" value="11111"/>
  </appSettings>
  <connectionStrings>
    <add name="DefaultConnection" connectionString="Server=.\sqlexpress;Database=default;Trusted_Connection=True;" providerName="System.Data.SqlClient"/>
  </connectionStrings>
</configuration>

今回、環境変数として登録した値は以下の通りです。プリフィックスは省略しますが TEST / TEST2 / DefaultConnection をオーバーライドするように環境変数を追加してあります。

f:id:shiba-yan:20170506230052p:plain

そして上で作成したクラスライブラリを読み込むようにした ASP.NET アプリケーションを作成して、AppSettings と ConnectionStrings の値を一覧表示したものが以下になります。

f:id:shiba-yan:20170506230116p:plain

正しく環境変数に追加した値が反映されていることが確認できました。面倒だったので載せてはいないですが、Application_Start の時点でもオーバーライドされた値が取れるようになっているので安心です。

これで Windows Server Containers で ASP.NET アプリケーションを動かす時に、環境変数から接続文字列などを渡すことが出来るようになりました。アプリケーション側の改修が要らないのがメリットです。

AppVeyor を使って ASP.NET MVC アプリケーションの Docker Image を自動で作成してみる

前に AppVeyor の Windows Server 2016 と Docker 対応について調べたので、今回は実際に ASP.NET MVC アプリケーションをビルドして、Docker Image の作成までを行ってみます。

やることは CircleCI と Docker を使って App Service on Linux への CI/CD を試した時とほぼ変わりません。処理をそのまま AppVeyor と ASP.NET 向けに修正した形です。

Windows Server Core のイメージが 10GB ぐらいあるので、ビルドにも時間がかかって使い物にならないかもと思いましたが、今回の場合 2 分以下で終わりました。実用的な気がしてきます。

最初に ASP.NET MVC アプリケーションを作成してから、appveyor.yml を書いていきます。

appveyor.yml を用意する

とりあえず作成した appveyor.yml を全て載せておきます。build / test / deploy のそれぞれでカスタムなスクリプトを書いていますが、やっていること自体は大したことはありません。

テストでは作成したイメージを起動して、ちゃんとレスポンスを返すか確認しています。*1

version: '{build}'

image: Visual Studio 2017

environment:
  DOCKER_USER: XXXXXXX
  DOCKER_PASS:
    secure: XXXXXXX

before_build:
  - nuget restore

build_script:
  - msbuild .\AspNetApplication\AspNetApplication.csproj /nologo /verbosity:m /t:Build /t:pipelinePreDeployCopyAllFilesToOneFolder /p:_PackageTempDir="..\publish";AutoParameterizationWebConfigConnectionStrings=false;Configuration=Release
  - docker build -t shibayan/appveyor-aspnet-docker .

test_script:
  - ps: |
      $cid = docker run -d shibayan/appveyor-aspnet-docker
      $hostip = docker inspect -f "{{ .NetworkSettings.Networks.nat.IPAddress }}" $cid
      curl "http://${hostip}" -UseBasicParsing

deploy_script:
  - docker login -u %DOCKER_USER% -p %DOCKER_PASS%
  - docker push shibayan/appveyor-aspnet-docker

面倒なのが IP を inspect で取得する部分です。これはしばらく改善しなさそうなので諦め気味です。

用意した Dockerfile は以前に試したものとほぼ変わらず、wwwroot 以下にビルドした成果物をコピーするだけの簡単なものです。ベースイメージは 4.6.2 とタグを固定しました。

FROM microsoft/aspnet:4.6.2

COPY ./publish/ /inetpub/wwwroot

MSBuild によってビルドされたファイルは publish というディレクトリに全て保存されるので、その中身をそっくり wwwroot にコピーしています。

イメージのテストが通れば、後は Docker Hub に push するだけという簡単な処理です。

AppVeyor で実際にビルド

GitHub にリポジトリを作成して、AppVeyor で実際にビルドしてみました。事前に調べていた甲斐もあって、特に失敗することなく Docker Hub への push まで上手くいきました。

MSBuild 周りが一番複雑な部分ですが、App Service で良く使っていたコマンドなので問題なかったです。

f:id:shiba-yan:20170502184818p:plain

MSBuild が終わると Docker Image を作成します。microsoft/aspnet の pull に 1 分ぐらいかかるので、予め pull しておきたい気持ちでいっぱいです。

Docker Image の作成が終わると、実際に起動してテストを行っています。

f:id:shiba-yan:20170502184851p:plain

今回のコマンドでは Razor 周りのプリコンパイルまでは行っていないので、起動するまでに少し時間がかかりますが、それでも数秒で成功します。

最後に Docker Hub に push して完了ですが、例によって Azure Container Registry などに push する形にしても良いかと思います。特に ACS を使う場合には便利だと思います。

作成したイメージの動作確認

AppVeyor でのビルドが成功すると Docker Hub に作成されたイメージが追加されているので、検証用の Windows Server 2016 が入ったサーバーを使って動作確認をしておきます。

テストで利用しているコマンドとほぼ同じものを実行すると、Docker Hub から pull してくれます。

f:id:shiba-yan:20170502182213p:plain

一応 PowerShell 上でも同じように URL を叩いてレスポンスが返ってくるのを確認しました。

コンテナホストからは確認できたので、外部の PC からブラウザを使って確認してみました。外部からのアクセス時には WinNAT によってポート変換がされるので 8080 が必要です。

f:id:shiba-yan:20170502182915p:plain

すんなりと ASP.NET MVC アプリケーションが動作しました。コンテナとホストでビルド番号を合わせる必要があるという手間はかかってきますが、手軽にアプリケーションを実行できるのは魅力です。

Azure Container Service や EC2 Container Service などの Windows Server Containers に対応した環境にそのまま持っていけるはずですが、まだ正式リリースされていないのが課題という感じです。

*1:個人的にはちゃんと動かないイメージを生焼けと呼んでます。どうでもいい話だった。