xin9le.net

Microsoft の製品/技術が大好きな Microsoft MVP な管理人の技術ブログです。

Azure Functions (Isolated Worker) で AppInsights に出力したログが LogLevel.Warning 以上にフィルターされてしまう問題への対処

タイトルがだいぶ長いんですが、執筆時点でタイトル通りの問題が発生します。最近 .NET 7 がリリースされたので、業務コードを順次 C# 11 / .NET 7 へ移行を開始しています。そんな中 Azure Functions を In-Process Model から Isolated Model に乗せ換えるにあたって Application Insights のログ周りでハマったので、今回はその回避方法を紹介します。まずは発生状況を箇条書きで整理しましょう。

  1. Azure Functions (Isolated Worker) を利用したい
    • .NET 7 を利用する場合はこれ一択
  2. Application Insights にログを出力するに際して下記 NuGet パッケージを利用する *1
  3. LogLevel.Warning 未満のログを出力したい
    • LogLevel.Information とか高頻度で使いますよね?

つまり .NET 7 + Azure Functions の環境では発生しそうです。

問題を再現させてみる

Step.1 : NuGet Package を参照

先に示した通り Microsoft.Azure.Functions.Worker.ApplicationInsights のパッケージを参照します。執筆時点では 1.0.0-preview3 が最新です。

<ItemGroup>
    <PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.10.0" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.0.0-preview3" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.0.13" />
    <PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.7.0" />
</ItemGroup>

Step.2 : AppInsights SDK を DI に登録

公式の Isolated Process Model の導入ガイドにも書かれていますが、以下のような初期化コードを書きましょう。

new HostBuilder().ConfigureFunctionsWorkerDefaults(static (context, builder) =>
{
    builder.AddApplicationInsights();
    builder.AddApplicationInsightsLogger();
});

Step.3 : 関数を実装

検証目的ということで、今回は HttpTrigger が叩かれた際に全ての LogLevel を出力するコードを書いていきます。

public sealed class SampleFunction
{
    [Function(nameof(SampleFunction))]
    public async Task<HttpResponseData> EntryPoint(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData request,
        FunctionContext context)
    {
        var logger = context.GetLogger<SampleFunction>();
        var levels = new[]
        {
            // わかりやすいように Enum.GetValues<LogLevel>() と同じ配列を自前で作る
            LogLevel.Trace,        // 0
            LogLevel.Debug,        // 1
            LogLevel.Information,  // 2
            LogLevel.Warning,      // 3
            LogLevel.Error,        // 4
            LogLevel.Critical,     // 5
            LogLevel.None,         // 6
        };
        foreach (var x in levels)
        {
            await Task.Delay(10);  // ログの出力順が綺麗になるように少し delay を入れておく
            logger.Log(x, "LogLevel.{LogLevel}", x);
        }

        return request.CreateResponse(HttpStatusCode.OK);
    }
}

Step.4 : LogLevel の設定を環境変数に追加

最後に Azure Functions のインスタンスの環境変数として以下を設定します。Application Insights に出力する LogLevel を最低の Trace に設定しておきます。

{
    "Logging": {
        "ApplicationInsights": {
            "LogLevel": {
                "Default": "Trace"
            }
        }
    }
}

実行結果

ザックリとな再現手順の解説でしたが、ここまで終わりです。実際に Azure Functions に Zip Deploy をして API を叩いてみましょう。すると次のようなログが出力されます。

見事に LogLevel.Warning 以上ですね。困ったぞ、と。

原因

ではどこでこのフィルターが働いているのかというと、AppInsights の Core SDK の奥底にある既定のフィルタールールに依ります。

// The default behavior is to capture only logs above Warning level from all categories.
// This can achieved with this code level filter -> loggingBuilder.AddFilter<Microsoft.Extensions.Logging.ApplicationInsights.ApplicationInsightsLoggerProvider>("",LogLevel.Warning);
// However, this will make it impossible to override this behavior from Configuration like below using appsettings.json:
// {
//     "Logging": {
//         "ApplicationInsights": {
//             "LogLevel": {
//                 "": "Error"
//             }
//         }
//     },
//     ...
// }
// The reason is as both rules will match the filter, the last one added wins.
// To ensure that the default filter is in the beginning of filter rules, so that user override from Configuration will always win,
// we add code filter rule to the 0th position as below.

loggingBuilder.Services.Configure<LoggerFilterOptions>(static options =>
{
    options.Rules.Insert(0, new LoggerFilterRule
    (
        "Microsoft.Extensions.Logging.ApplicationInsights.ApplicationInsightsLoggerProvider",
        null,
        LogLevel.Warning,
        null
    ));
});

これまでの In-Process Model な Azure Functions では環境変数等に書いた LogLevel がこのフィルタールールを override する形で設定されていました。これは ASP.NET Core + App Service でも同じ挙動ですし、AppInsights のコード上にもコメントで「ユーザー設定で override できるように 0 番目に挿入する」というメモまで残っています。ですが、Isolated Model 向けの AppInsights SDK を利用するとその意に反して override されない状態になっています。Isolated Model 向け SDK の初期化順序が良くないということなんだと思います。

回避方法

実運用において LogLevel.Information が出ないのは全く使い物にならないということで workaround していきましょう。初期化処理に以下のようなコードを付け加えます。

new HostBuilder().ConfigureFunctionsWorkerDefaults(static (context, builder) =>
{
    builder.AddApplicationInsights();
    builder.AddApplicationInsightsLogger();

    // 方法 1 : 構成情報で上書きする
    builder.Services.AddLogging(logging =>
    {
        var config = context.Configuration.GetSection("Logging");
        logging.AddConfiguration(config);
    });

    // 方法 2 : AppInsights 向け LoggerProvider の特別ルールを削除して既定に戻す
    builder.Services.Configure<LoggerFilterOptions>(static options =>
    {
        var rule = options.Rules.FirstOrDefault(static x => x.ProviderName == typeof(ApplicationInsightsLoggerProvider).FullName);
        if (rule is not null)
            options.Rules.Remove(rule);
    });
});

ふたつ方法を載せましたが、必要な方を選べばよいと思います。僕は念のため (?) どちらも書いておきました。この実装を加えたものをデプロイしてログの出力状況を確認してみましょう。結果は以下の通りです。これで使い物になりそうですね!

*1:Microsoft.Azure.Functions.Worker.ApplicationInsights の NuGet Package を利用しない場合は再現しない

C#/.NET で和暦の元年表記をするときの注意点

ちょっとした Tips というか「あれ?」となったので備忘録としてメモします。

ひと言まとめ

元年表記したければ y をくっ付けろ

日頃から数字と文字の間に半角スペースを入れる癖があって、何気なくいつも通り半角スペースを入れたら 令和 1 年 になって気付いたという。

サンプルコード

var culture = new CultureInfo("ja-jp");
culture.DateTimeFormat.Calendar = new JapaneseCalendar();
    
var date = new DateOnly(2019, 5, 1);
date.ToString("ggy年", culture);    // 令和元年
date.ToString("gg y年", culture);   // 令和 元年
date.ToString("ggy 年", culture);   // 令和1 年
date.ToString("gg y 年", culture);  // 令和 1 年

SqlBulkCopy + IDataReader を利用した IEnumerable<T> の高効率なバルク挿入

C# / .NET + SQL Server 環境において Bulk Insert をするのはなかなかお手間です。それもこれも SqlBulkCopy という専用クラスがなかなか曲者なためなのですが。ただただ IEnumerable<T> のようなコレクションを挿入するのにひと工夫というか、ひと手間必要なのが心理的にハードルが高い。初見殺し!

僕自身、過去にやっていたのは DataTable を利用した方法でした。以下の記事に良い例が載っているので詳細はそちらに譲りますが、DataTable のインスタンスを作るにあたって一度データのコピーが必要になる上に、要素数が多いと内部の動的配列の拡張が何度も発生して遅くなってしまいます。折角パフォーマンスよくデータ挿入ができる SqlBulkCopy なのに、その事前準備で遅くなってどうするんだ!ということでなんとかしたい。

IDataReader を独自実装

SqlBulkCopy はそのデータソースとして IDataReader をとることができます。これを実装しさえすれば任意のデータソースと繋ぎ込めるというわけですね。ということで、今回は IEnumerable<T> なコレクションを IDataReader 経由で投げ込むことを考えてみます。

IDataReader に求められる実装

IDataReader を実装しようとすると、とんでもなくたくさんのプロパティとメソッドの実装を要求されます。ブログに書くには長過ぎるので折り畳んでおきますが、正直これを見たら卒倒するか吐くかしますね、普通。

IDataReader が要求する実装

public class DummyDataReader : IDataReader
{
    #region IDataReader implementations
    /// <inheritdoc/>
    public object this[int i]
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public object this[string name]
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public int Depth
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public bool IsClosed
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public int RecordsAffected
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public int FieldCount
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public void Close()
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public void Dispose()
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public bool GetBoolean(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public byte GetByte(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public char GetChar(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public long GetChars(int i, long fieldoffset, char[]? buffer, int bufferoffset, int length)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public IDataReader GetData(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public string GetDataTypeName(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public DateTime GetDateTime(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public decimal GetDecimal(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public double GetDouble(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.PublicProperties)]
    public Type GetFieldType(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public float GetFloat(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public Guid GetGuid(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public short GetInt16(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public int GetInt32(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public long GetInt64(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public string GetName(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public int GetOrdinal(string name)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public DataTable? GetSchemaTable()
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public string GetString(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public object GetValue(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public int GetValues(object[] values)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public bool IsDBNull(int i)
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public bool NextResult()
        => throw new NotImplementedException();


    /// <inheritdoc/>
    public bool Read()
        => throw new NotImplementedException();
    #endregion
}

しかし、実はこれ全部を実装する必要はありません。SqlBulkCopy の内部実装を追いかけたりしたところ、実際に必要となるのは以下の 3 つのメソッド / プロパティだけです。

  • int FieldCount
  • object GetValue(int i)
  • bool Read()

他は全て NotImplementedException のままでも大丈夫です。なんだよー、これなら全然行けるじゃん...!

public class SqlBulkCopyDataReader : IDataReader
{
    #region IDataReader implementations
    /// <inheritdoc/>
    public int FieldCount
        => 0;


    /// <inheritdoc/>
    public object GetValue(int i)
        => null;


    /// <inheritdoc/>
    public bool Read()
        => true;
    #endregion
}

ざっくりと実装してみる

ということで実装していきましょう。いろいろ端折ってますが、概ね以下のような感じになります。

public class SqlBulkCopyDataReader<T> : IDataReader
{
    private IEnumerator<T> DataEnumerator { get; }

    public SqlBulkCopyDataReader(IEnumerator<T> enumerator)
        => this.DataEnumerator = enumerator;

    public SqlBulkCopyDataReader(IEnumerable<T> data)
        : this(data.GetEnumerator())
    { }

    #region IDataReader implementations
    public int FieldCount
        => PropertyInfoCache<T>.Instances.Length;

    public void Dispose()
        => this.DataEnumerator.Dispose();

    public object GetValue(int i)
    {
        // 対象テーブルの列とプロパティの個数 / 並び順が一致している前提
        var prop = PropertyInfoCache<T>.Instances[i];
        var obj = this.DataEnumerator.Current;
        return prop.GetValue(obj)!;
    }

    public bool Read()
        => this.DataEnumerator.MoveNext();
    #endregion

    private static class PropertyInfoCache<U>
    {
        public static PropertyInfo[] Instances { get; }

        static PropertyInfoCache()
            => Instances = typeof(U).GetProperties(BindingFlags.Instance | BindingFlags.Public);
    }
}

IEnumerator<T>.Dispose() を呼び出すために Dispose() メソッドも追加になっていますが、それでもメチャ短い!

SqlBulkCopy に食わせてみる

下準備が整ったので実際に SqlBulkCopy と組み合わせて使ってみましょう。折角なので SqlConnection の拡張メソッドでも作ってみます。実際にはまだもうちょっと考慮すべき点がありそうですが、サンプルとしてはだいぶいい感じになっていると思います。

public static class SqlConnectionExtensions
{
    public static async ValueTask<int> BulkInsertAsync<T>(this SqlConnection connection, IEnumerable<T> data, SqlBulkCopyOptions options = default, int? timeout = null, CancellationToken cancellationToken = default)
    {
        using (var executor = new SqlBulkCopy(connection, options, null))
        {
            // テーブル名と型名が一致しているとする
            executor.DestinationTableName = typeof(T).Name;

            // タイムアウトを指定できるようにしておくと優しそう
            executor.BulkCopyTimeout = timeout ?? executor.BulkCopyTimeout;

            // データを流し込む
            using (var reader = new SqlBulkCopyDataReader<T>(data))
                await executor.WriteToServerAsync(reader, cancellationToken);

            // 影響した行数 (= 流し込んだ件数) を返すのが一般的
            return executor.RowsCopied;
        }
    }
}

まとめ

ずっと IDataReader の実装が嫌過ぎて、単純な食わず嫌いで DataTable に甘んじてましたが、気が向いて SqlBulkCopy の内部実装を追いかけてみたら実は全然大した実装が必要ないことを知ってぴえん。10 年くらい無駄なことをしてたと思うとだいぶ勿体ないことをしてました。反省。

.NET 6 で Microsoft.Extensions.Configuration から DateOnly / TimeOnly 型に直接マッピングする

.NET 6 で DateOnly および TimeOnly 型が追加されました。日付や時間のみを扱う (若干残念な名前を除けば) 待望の子ですね。

ところで、アプリケーション構成として日付や時間 (特に時間) を扱うことはちょくちょくあるのではないかと思います。現代の .NET (Core 系) でアプリケーションの構成情報を利用すると言えば Microsoft.Extensions.ConfigurationIConfiguration ですが、実はここから DateOnlyTimeOnly に直接マッピングできません、残念ながら。DateTimeDateTimeOffset などはできるのに!

Microsoft.Extensions.Configuration の実装を追いかけてみると TypeConverter を通してマッピングを図るようになっているのですが、.NET 6 時点では DateOnlyTimeOnly に対応する TypeConverter が登録されていないために動作しないんですね。この問題は .NET Runtime Team も認識していて、.NET 7 Preview 6 で既に修正されています。

でも我々は (というか僕は) .NET 6 でも使いたい!ということで .NET 6 でも DateOnlyConverterTimeOnlyConverter を利用できるようにしてしまいましょう。

TypeConverter を実装

まず .NET 7 で実装された DateOnlyConverterTimeOnlyConverter を持ってきます。一部コンパイルが通らない箇所があるのでそこだけ修正します。若干ずるいけどこれがベストなのだ...(ゴゴゴ

修正箇所

  • .NET 7 以降ではこの実装は不要なので #if NET6_0 を追加
  • String Resources を直接展開
  • .NET 7 で新規に追加された TimeOnly.Microsecond プロパティに関する箇所を削除

実装の全貌

そこそこ実装が長いので折り畳み状態にしました。ご了承ください。

DateOnlyConverter.cs

#if NET6_0

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.Design.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace System.ComponentModel;

/// <summary>
/// Provides a type converter to convert <see cref='System.DateOnly'/> objects to and from various other representations.
/// </summary>
public class DateOnlyConverter : TypeConverter
{
    /// <summary>
    /// Gets a value indicating whether this converter can convert an object in the given source type to a <see cref='System.DateOnly'/>
    /// object using the specified context.
    /// </summary>
    /// <inheritdoc />
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
    }

    /// <inheritdoc />
    public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType)
    {
        return destinationType == typeof(InstanceDescriptor) || base.CanConvertTo(context, destinationType);
    }

    /// <summary>
    /// Converts the given value object to a <see cref='System.DateOnly'/> object.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (value is string text)
        {
            text = text.Trim();
            if (text.Length == 0)
            {
                return DateOnly.MinValue;
            }

            try
            {
                // See if we have a culture info to parse with. If so, then use it.
                DateTimeFormatInfo? formatInfo = null;

                if (culture != null)
                {
                    formatInfo = (DateTimeFormatInfo?)culture.GetFormat(typeof(DateTimeFormatInfo));
                }

                if (formatInfo != null)
                {
                    return DateOnly.Parse(text, formatInfo);
                }
                else
                {
                    return DateOnly.Parse(text, culture);
                }
            }
            catch (FormatException e)
            {
                var message = $"{text} is not a valid value for {nameof(DateOnly)}.";
                throw new FormatException(message, e);
            }
        }

        return base.ConvertFrom(context, culture, value);
    }

    /// <summary>
    /// Converts the given value object from a <see cref='System.DateOnly'/> object using the arguments.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
    {
        if (destinationType == typeof(string) && value is DateOnly dateOnly)
        {
            if (dateOnly == DateOnly.MinValue)
            {
                return string.Empty;
            }

            culture ??= CultureInfo.CurrentCulture;

            DateTimeFormatInfo? formatInfo = (DateTimeFormatInfo?)culture.GetFormat(typeof(DateTimeFormatInfo));

            if (culture == CultureInfo.InvariantCulture)
            {
                return dateOnly.ToString("yyyy-MM-dd", culture);
            }

            string format = formatInfo!.ShortDatePattern;

            return dateOnly.ToString(format, CultureInfo.CurrentCulture);
        }

        if (destinationType == typeof(InstanceDescriptor) && value is DateOnly date)
        {
            return new InstanceDescriptor(typeof(DateOnly).GetConstructor(new Type[] { typeof(int), typeof(int), typeof(int) }), new object[] { date.Year, date.Month, date.Day });
        }

        return base.ConvertTo(context, culture, value, destinationType);
    }
}

#endif

TimeOnlyConverter.cs

#if NET6_0

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel.Design.Serialization;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;

namespace System.ComponentModel;

/// <summary>
/// Provides a type converter to convert <see cref='System.TimeOnly'/> objects to and from various other representations.
/// </summary>
public class TimeOnlyConverter : TypeConverter
{
    /// <summary>
    /// Gets a value indicating whether this converter can convert an object in the given source type to a <see cref='System.TimeOnly'/>
    /// object using the specified context.
    /// </summary>
    /// <inheritdoc />
    public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
    {
        return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
    }

    /// <inheritdoc />
    public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType)
    {
        return destinationType == typeof(InstanceDescriptor) || base.CanConvertTo(context, destinationType);
    }

    /// <summary>
    /// Converts the given value object to a <see cref='System.TimeOnly'/> object.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
    {
        if (value is string text)
        {
            text = text.Trim();
            if (text.Length == 0)
            {
                return TimeOnly.MinValue;
            }

            try
            {
                // See if we have a culture info to parse with. If so, then use it.
                DateTimeFormatInfo? formatInfo = null;

                if (culture != null)
                {
                    formatInfo = (DateTimeFormatInfo?)culture.GetFormat(typeof(DateTimeFormatInfo));
                }

                if (formatInfo != null)
                {
                    return TimeOnly.Parse(text, formatInfo);
                }
                else
                {
                    return TimeOnly.Parse(text, culture);
                }
            }
            catch (FormatException e)
            {
                var message = $"{text} is not a valid value for {nameof(TimeOnly)}.";
                throw new FormatException(message, e);
            }
        }

        return base.ConvertFrom(context, culture, value);
    }

    /// <summary>
    /// Converts the given value object from a <see cref='System.TimeOnly'/> object using the arguments.
    /// </summary>
    /// <inheritdoc />
    public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType)
    {
        if (destinationType == typeof(string) && value is TimeOnly timeOnly)
        {
            if (timeOnly == TimeOnly.MinValue)
            {
                return string.Empty;
            }

            culture ??= CultureInfo.CurrentCulture;

            DateTimeFormatInfo formatInfo = (DateTimeFormatInfo)culture.GetFormat(typeof(DateTimeFormatInfo))!;

            return timeOnly.ToString(formatInfo.ShortTimePattern, CultureInfo.CurrentCulture);
        }

        if (destinationType == typeof(InstanceDescriptor) && value is TimeOnly time)
        {
            if (time.Ticks == 0)
            {
                return new InstanceDescriptor(typeof(TimeOnly).GetConstructor(new Type[] { typeof(long) }), new object[] { time.Ticks });
            }

            return new InstanceDescriptor(typeof(TimeOnly).GetConstructor(new Type[] { typeof(int), typeof(int), typeof(int), typeof(int) }),
                                            new object[] { time.Hour, time.Minute, time.Second, time.Millisecond });
        }

        return base.ConvertTo(context, culture, value, destinationType);
    }
}

#endif

参照元

TypeConverter を登録

TypeConverter の porting ができたら、.NET Runtime が認識できるよう登録していきましょう。ザックリ以下のようにします。

#if NET6_0

public static class TypeConverterShims
{
    public static void Register()
    {
        register<DateOnly, DateOnlyConverter>();
        register<TimeOnly, TimeOnlyConverter>();

        static void register<TObject, TConverter>()
        {
            var attribute = new TypeConverterAttribute(typeof(TConverter));
            TypeDescriptor.AddAttributes(typeof(TObject), attribute);
        }
    }
}

#endif

最後にこれをアプリケーション起動時に一度呼び出せば OK です。IConfiguration.Get<T>(); などの前に呼び出すことを忘れずに!

TypeConverterShims.Register();

Azure App Service の Always On リクエストにのみ応答する

Azure App Service を使っている場合、特に本番環境では Always On を有効化することになると思います。日本語の Azure Portal だと「常時接続」と表記されるもので、一定間隔でホストしている Web アプリに対してリクエストを投げることで、アプリがアイドル状態にならないようにするものです。Cold Start になると初速が出ないので、その対策に使われるものですね。

この Always On 設定を有効化していると、Azure App Service が定期的に Root URL (= /) に対して GET メソッドでアクセスしてきます。「それが何だよ」って話なのですが、Web アプリケーションの作りに依っては 404 (Not Found) を返してエラーとして検知してしまうことがあります。

UI のある Web サービスであれば GET / がメインの Landing Page になるため特段問題にならないのですが、Web API なサービスをホストしている場合には API 定義として GET / でアクセス可能な Endpoint を用意していないことが結構よくあるんですよね。ってゆーか用意しないですよね、ほぼ!

Always On は有効にしたいけど 404 は検知したくない!だって気持ち悪いもん!

となるわけです。なりませんか?w

どう対策するか

ということで 200 OK を返すだけの GET / な Endpoint を用意すればよいですね。以上終了。

とはならないです。エラーを回避するためだけに実際の運用要件として不要な GET / な API を公開するのは好ましくありません。特段問題にもなりにくいですが、不要なものを workaround として用意するのは気持ち悪いです。なので、可能であれば Azure App Service から飛んでくる Always On リクエストにのみ 200 OK で応答したい です。

そこで Always On リクエストについて調べてみると、かなり特殊なアクセスのされ方をしていることが分かりました。下記記事に詳細があるので引用します。

REQUEST_URI     = /
REQUEST_METHOD  = GET
SERVER_PROTOCOL = HTTP/1.1
REMOTE_ADDR     = ::1
REMOTE_PORT     = 21353
REMOTE_HOST     = ::1
HTTP_REFERER    =
HTTP_USER_AGENT = AlwaysOn
HTTP_CONNECTION = Keep-Alive

このうち以下であるかどうかを見分けられれば良さそうですね。

  • GET メソッドでのリクエスト
  • アクセス URL は /
  • リクエスト元が自分自身 (= Loopback アドレス)
  • User-Agent が AlwaysOn

実装してみる

Always On リクエストに対しては 200 OK を返すだけなので、Request Pipeline の早い段階で応答してしまうのが効率がよさそうです。ということで Middleware を実装しましょう。ASP.NET Core Middleware 自体やカスタム Middleware の作り方については公式ドキュメントをご覧ください。

internal sealed class AzureAppServiceAlwaysOnResponseMiddleware
{
    private RequestDelegate Next { get; }

    public AzureAppServiceAlwaysOnResponseMiddleware(RequestDelegate next)
        => this.Next = next;

    public async Task InvokeAsync(HttpContext http)
    {
        // Always On リクエストなら 200 OK を返す
        if (isAlwaysOn(http))
        {
            http.Response.StatusCode = (int)HttpStatusCode.OK;
            return;
        }

        // それ以外は Request Pipeline を継続
        await this.Next(http);

        // ローカル関数
        static bool isAlwaysOn(HttpContext http)
        {
            // アクセス元 IP を取得
            var ip = http.Connection.RemoteIpAddress;
            if (ip is null)
                return false;

            // Loopback アドレスか
            if (!IPAddress.IsLoopback(ip))
                return false;

            // GET でアクセスされているか
            var request = http.Request;
            if (!HttpMethods.IsGet(request.Method))
                return false;

            // 「/」へのリクエストか
            var path = request.Path;
            if (!path.HasValue)
                return false;

            const StringComparison comparison = StringComparison.Ordinal;
            if (!path.Value.AsSpan().Equals("/", comparison))
                return false;

            // User-Agent が「AlwaysOn」であるか
            foreach (var ua in request.Headers.UserAgent)
            {
                if (ua.AsSpan().Equals("AlwaysOn", comparison))
                    return true;  // カカロット、お前が Always On だ
            }

            return false;
        }
    }
}
public static class IApplicationBuilderExtensions
{
    public static IApplicationBuilder UseAzureAppServiceAlwaysOnResponse(this IApplicationBuilder builder)
        => builder.UseMiddleware<AzureAppServiceAlwaysOnResponseMiddleware>();
}

上記の実装ができたら、例えば以下のように Request Pipeline のそこそこ早い段階に差し込みます。これで完成です!

// Configure application
var builder = WebApplication.CreateBuilder(args);
builder.Host.ConfigureServices(static (context, services) =>
{
    services.AddControllers();
});

// Configure HTTP pipelines
var app = builder.Build();
app.UseHsts();
app.UseHttpsRedirection();
app.UseAzureAppServiceAlwaysOnResponse();  // 例えばこの辺とか
app.UseEndpoints(static endpoints =>
{
    endpoints.MapControllers();
});

// Run application
app.Run();

まとめ

いかがでしたか?今回は Azure App Service の Always On にのみ応答する ASP.NET Core Middleware を実装してみました。Azure App Service ともっと仲良くなれるといいですね!