しばやん雑記

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

ASP.NET / IIS で X-Forwarded-Proto の値を正しく扱いたい

リバースプロキシやロードバランサーで SSL offload を行った場合には、後ろにいる Web サーバーにクライアントの IP などを伝えるために X-Forwarded-* を付けますが、ASP.NET は上手く扱ってくれません。

X-Forwarded-For に関しては ARR Helper を入れることで対応できますが、X-Forwarded-Proto は完全に未対応なので自力で何とかする必要があります。

ARR Helper がインストールされている場合には X-ARR-SSL というヘッダーを渡してあげれば HTTPS = on としてくれますが、非標準なヘッダーなので ARR と組み合わせた場合しか上手く動作しません。

HTTP モジュールで上書きする

ASP.NET の世界だけで扱えれば良いのであれば、専用のマネージ HTTP モジュールを用意して HTTPS / SERVER_PORT サーバー変数を書き換えてあげれば解決します。

適当に書いたコードですが、大体こんな感じになるのではないかと思います。

public class HttpForwardedModule : IHttpModule
{
    public void Init(HttpApplication app)
    {
        app.BeginRequest += OnBeginRequest;
    }

    public void Dispose()
    {
    }

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

        if (app.Context.Request.Headers["X-Forwarded-Proto"] == "https")
        {
            app.Context.Request.ServerVariables["HTTPS"] = "on";
            app.Context.Request.ServerVariables["SERVER_PORT"] = "443";
        }
    }
}

このモジュールを PreApplicationStartMethod を使って IIS に登録してあげれば有効になります。

[assembly: PreApplicationStartMethod(typeof(WebApplication17.ModuleInitializer), "Start")]

public class ModuleInitializer
{
    public static void Start()
    {
        HttpApplication.RegisterModule(typeof(HttpForwardedModule));
    }
}

実際のところは BeginRequest のタイミングで上書きの処理が出来ればよいので、Global.asax でも同様に動作します。これで実行して X-Forwarded-Proto 付きのリクエストを投げてあげれば、ASP.NET は HTTPS なリクエストだと判断します。

URL Rewrite で上書きする

ASP.NET の世界で閉じていればマネージ HTTP モジュールで上手くいきますが、ASP.NET 側で変更したサーバー変数は IIS 側には反映されないので、後ろに PHP や HttpPlatformHandler が居る場合には使えません。

要はサーバー変数を書き換えてあげれば良いので、URL Rewrite でも同じことが実現できます。

<system.webServer>
  <rewrite>
    <!-- unlock が必要なので少し面倒 -->
    <allowedServerVariables>
      <add name="HTTPS" />
      <add name="SERVER_PORT" />
    </allowedServerVariables>
    <rules>
      <rule name="HttpForwarded">
        <match url=".*" />
        <conditions>
          <add input="{HTTP_X_FORWARDED_PROTO}" pattern="https" />
        </conditions>
        <serverVariables>
          <set name="HTTPS" value="on" />
          <set name="SERVER_PORT" value="443" />
        </serverVariables>
      </rule>
    </rules>
  </rewrite>
</system.webServer>

やっていることは HTTP モジュールと同じです。allowedServerVariables はデフォルトではロックされているので、書き換えるには applicationHost.config を直接弄るか unlock してから追加する必要があります。

上の例ではアプリケーションの Web.config で書くことを想定してますが、globalRules に入れてマシン全体で使うことも出来るので、考えることが減って少し楽になるかも知れません。

リダイレクト時に https にならない問題

ARR の時には発生しないのですが、それ以外のリバースプロキシや LB を使っている場合には、URL Rewrite でリダイレクトを行うと http として返される問題があります。

何故 ARR の時には発生しないかというと、ARR にはレスポンスヘッダーを自動で書き換える機能があるからです。デフォルトで有効になっているので、ARR / ARR Helper / ASP.NET の場合は何も考えずに済みます。

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

しかし、それ以外の組み合わせになると一気に破綻するので、リダイレクトする際には https 付きの絶対 URL として URL Rewrite を書く必要があります。地味に面倒です。

Outbound Rule を使って一括で Location ヘッダーを書き換える方法も使えると思いますが、事故りやすいのであまりお勧めは出来ないと思ってます。

App Service を使う

例によって App Service を使う場合には ARR と ARR Helper という組み合わせになるので、いい感じに HTTPS = on としてくれます。リダイレクト時の書き換え処理も走るので、ちゃんと https としてクライアントには返ります。

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

App Service で実行する場合には、特に必要な作業はありません。お手軽ですね。ちなみに App Service 以外でも ARR と ARR Helper の組み合わせであれば、同じように動作します。

根本的な対応は IIS が X-Forwarded-Proto に対応するか、専用のネイティブ HTTP モジュールを書くしかないと考えてます。ARR Helper がもっと汎用的になって Proxy Helper とでもなれば最高なのですが。

.NET Framework / .NET Core 向け Docker Image のリポジトリが変更された話

多分 .NET Framework 4.7.2 と .NET Core 2.1 のリリースを狙ったタイミングだと思ってるのですが、この 2 つを利用するための公式 Docker Image のリポジトリが変更されています。

地味に同じようなのにイメージが異なっていた .NET 系の Docker Image が統合されて、シンプルなタグで使えるようになっているので便利です。古い方はもう更新されないので注意。

.NET Framework 向け

これまでは microsoft/dotnet-frameworkmicrosoft/dotnet-framework-build で用途によってイメージ自体が分けられていましたが、現在は microsoft/dotnet-framework 側に統合されています。

  • 4.7.2-sdk
  • 4.7.2-runtime

SDK のイメージには Visual Studio Build Tools がインストールされているので、MSBuild を使ってアプリケーションをコンテナ内でビルドできるようになっています。*1

基本的には最新の .NET バージョンを対象にしているでしょうし、4.7.2 を使っておけばよいと思いますが、上のタグは LTSC 2016 の Server Core イメージが使われているのでサイズが大きいです。

必要に応じて 1709 や 1803 のイメージを使うと半分ぐらいで済むようになります。タグの詳細は Docker Hub を参照すればすぐにわかるはずです。

https://hub.docker.com/r/microsoft/dotnet-framework/

とはいえ、現時点で Hyper-V Containers を使った CI SaaS は無いみたいなので、大人しく LTSC 2016 を使うことになるのではないかと。実際に AWS CodeBuild がそうでした。

ちなみに ASP.NET を使うためのイメージはこれまでと同じ microsoft/aspnet です。

.NET Core 向け

.NET Framework 向けは Windows しかなかったので分かりやすいですが、.NET Core は複数のディストリ向けに公開されてるだけじゃなく、Nano Server のイメージもあるのでちょっと数が多いです。

しかし、ちょっと前から multi-arch なイメージに対応しているので、基本は以下の 3 つだけ知っておけば良いです。後は Docker が勝手に Ubuntu と Nano Server で切り替えてくれます。

  • 2.1-sdk
  • 2.1-aspnetcore-runtime
  • 2.1-runtime

.NET Framework 向けとは異なり、ASP.NET Core 向けのイメージも microsoft/dotnet に統合されるようになったので、今後は microsoft/aspnetcoremicrosoft/aspnetcore-build は更新されません。

例を挙げると Docker を使った CI SaaS では microsoft/dotnet:2.1-sdk を使い、ASP.NET Core を動かすための Dockerfile では microsoft/dotnet:2.1-aspnetcore-runtime を使えば良いです。

特に理由がなければシンプルなタグを使っておけば良いですが、.NET Core に関しては Ubuntu のバージョン違いだけで数種類出ているうえに、Nano Server も 1709 / 1803 / LTSC 2016 が用意されています。

https://hub.docker.com/r/microsoft/dotnet/

更に .NET Core 2.1 からは Alpine Linux のイメージと ARM32 向けイメージも追加されているので、間違わないように注意したいところです。ちなみに Alpine は microsoft/dotnet:2.1-sdk-alpine といった形で指定できます。基本的に OS 名は最後につきます。

Alpine Linux のイメージは今後主流になっていくのではないかと思ってます。

ASP.NET アプリケーションを CodePipeline と CodeBuild を使って Elastic Beanstalk にデプロイする

AWS CodeBuild が Windows にも対応してやりたいことは、やはりビルドしたアプリケーションを何処かにデプロイすることでしょう。少なくとも私はそうだったので、CodePipeline を組み合わせて試しました。

CodeBuild の Windows 対応はこないだ書いたので説明はしません。

ASP.NET アプリケーションをデプロイ用にビルドする際は、やはりビューをプリコンパイルしておきたくなるので、発行プロファイルを作成して管理することにしました。

buildspec.yml で発行プロファイルを使うように書けば、勝手にプリコンパイルされて便利です。

version: 0.2

phases:
  pre_build:
    commands:
      - nuget restore
  build:
    commands:
      - msbuild WebApplication12.sln /p:Configuration=Release /m /v:m /t:Package /p:PublishProfile=CustomProfile
artifacts:
  type: zip
  files:
    - artifacts/*
  discard-paths: yes

今回は artifacts の設定を変更して、指定したディレクトリ以下をアップロードするようにしました。前の方法では CodePipeline を組み合わせた時に 2 重に zip されてしまい、Elastic Beanstalk へのデプロイが上手くいかなかったからです。

artifacts 以下には予め Elastic Beanstalk にデプロイするためのマニフェストを入れておきました。ASP.NET Core 用みたいに紹介されてますが、MSDeploy 向けにも使えます。

{
  "manifestVersion": 1,
  "deployments": {
    "msDeploy": [
      {
        "name": "Web app",
        "parameters": {
          "appBundle": "WebApplication12.zip",
          "iisPath": "/",
          "iisWebSite": "Default Web Site"
        }
      }
    ]
  }
}

実際のリポジトリ内では以下のように配置してあります。msbuild がビルド結果の zip をこのディレクトリに保存してくれるので、良い感じのパッケージが作れるというからくりです。

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

コードの準備は出来たので CodePipeline を新しく作成します。

特に説明することはなく、GitHub からソースを取得して、CodeBuild を使ってアプリケーションをビルド、そして最後は Elastic Beanstalk にデプロイという、シンプルなパイプラインです。

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

パイプラインを実行すると、CodeBuild で ASP.NET アプリケーションがビルドされて、Elastic Beanstalk にアーティファクトがデプロイされます。ビルドログもおまけで載せます。

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

そしてしばらくすると Elastic Beanstalk へのデプロイが完了するので、無事に ASP.NET アプリケーションが見れるようになります。

ちょっとアーティファクト周りの扱いが嫌な挙動でしたが、パイプライン自体は簡単に作れました。

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

デプロイ先としては Windows の場合は Elastic Beanstalk ぐらいしか、簡単に扱えるものが無さそうなのが少し残念ですね。CodeDeploy を組み合わせれば、もうちょっと自由度は上がりそうですが EC2 の管理はあまりしたくないですね。

EKS が Windows に対応したり、CodeBuild が Windows でも Docker Image が作れるようになれば、もうちょっと楽しくなりそうだと感じました。

Visual Studio 2017 に .NET Framework 4.7.2 SDK をインストールする

通常は Visual Studio Installer から .NET Framework の SDK はインストール出来るはずですが、何故か 4.7.2 はまだ落ちてこないので手動でインストールしようとしましたが、何故か入りませんでした。

地味にはまったのでメモとして残しておきます。

.NET に関しては以下のページから .NET Core / Framework を含め SDK や Runtime をダウンロード出来ますが、少なくとも日本語でダウンロードリンクを踏むと言語パックのみ落ちてきます。これが罠でした。

ダウンロードリンクからは Developer Pack が落ちてくるように読み取れますが、実際には言語パックなので何回インストールしても当然ながら 4.7.2 向け開発が出来るようになりません。

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

本物の Developer Pack は以下のページにあるリンクから落としておけば良いです。こっちをインストール後に言語パックをインストールすれば、正しい順番となります。

What's new in the .NET Framework | Microsoft Docs

インストールすれば Visual Studio から .NET Framework 4.7.2 が選べるようになります。

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

地味に 4.7.2 はパフォーマンス周りの取り組みや、ASP.NET 周りでは DI に対応したり SameSite Cookie 対応など機能が多いので、出来れば早めに移行したいと思っています。

ASP.NET の DI はサンプルコードだと動かなかったので、ちょっと検証後にまとめようかと思います。

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 アプリケーションは同時リクエスト多いですし、使いどころはありそうです。