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 FieldCountobject 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.Configuration の IConfiguration ですが、実はここから DateOnly や TimeOnly に直接マッピングできません、残念ながら。DateTime や DateTimeOffset などはできるのに!
Microsoft.Extensions.Configuration の実装を追いかけてみると TypeConverter を通してマッピングを図るようになっているのですが、.NET 6 時点では DateOnly や TimeOnly に対応する TypeConverter が登録されていないために動作しないんですね。この問題は .NET Runtime Team も認識していて、.NET 7 Preview 6 で既に修正されています。
でも我々は (というか僕は) .NET 6 でも使いたい!ということで .NET 6 でも DateOnlyConverter と TimeOnlyConverter を利用できるようにしてしまいましょう。
TypeConverter を実装
まず .NET 7 で実装された DateOnlyConverter と TimeOnlyConverter を持ってきます。一部コンパイルが通らない箇所があるのでそこだけ修正します。若干ずるいけどこれがベストなのだ...(ゴゴゴ
修正箇所
- .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 ともっと仲良くなれるといいですね!
あいうえお表、10 分で作れますか?
Burikaigi 2022 が開催されました。運営のお仕事と「C# ドキドキ・ライブコーディング対決」の登壇をしました。もう 10 年近く毎年ライブコーディング対決をしていますが、一見「こんなの簡単だろ」と思えるような問題でも全然解けないんですよねぇ...。
今回は問題を載せておきましたので、もしご興味があれば是非トライしてみてください。本記事の最後に回答例を載せておきましたが、解法はいくつもあるのでご参考まで。回答例が表示されないよう勢いよく下までスクロールせず、脳トレとして遊んでみていただけますと幸いです。

「あいうえお表」を作れ (制限時間 : 10 分)
ということで今回出されたお題がこちら (↑↑) です。事前に用意された初期コードと期待される結果は以下の通りです。SharpLab にも出題内容を用意しておきました。
初期コード
これだけです。
const string aiueo = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもや ゆ よらりるれろわ をん";
期待される結果
右寄せ & 縦書き...!これをコンソール画面に表示します。
んわらやまはなたさかあ り みひにちしきい るゆむふぬつすくう れ めへねてせけえ をろよもほのとそこお
ちっともできない言い訳
- 事前に一切問題を知らされないまま
- セッション中に突然出題されて
- 納期たった 10 分で
- 間をつなぐトークをしながら
できるわけないだろ...!と言いたくもなりますが、本当にできませんでしたww
回答例
一応の回答例を載せておきます。冷静であれば十分解けそうな気はするんですが、人間緊張するとほんと真っ白になりますね...w
using System; using System.Linq; const string aiueo = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもや ゆ よらりるれろわ をん"; const int rowsCount = 5; var columns = aiueo.Chunk(rowsCount).Reverse(); for (var i = 0; i < rowsCount; i++) { foreach (var column in columns) { var value = (i < column.Length) ? column[i] : ' '; Console.Write(value); } Console.WriteLine(); }
起動速度は?メモリ使用量は?Azure App Service における環境ごと (Windows / Linux) の ASP.NET Core 実行時のパフォーマンス差を調べてみた!
業務で (最近では副業でも) ずっと利用している Azure App Service!大変便利で使いやすく、大好きです。ところで最近の風潮的には ASP.NET Core も Docker とか Linux インスタンスでホストするのがポピュラーな感じがします。時代は Linux、ということなんでしょうか。
我らが (?) App Service は Windows インスタンスと Linux インスタンスのどちらも対応しているので、Windows / Linux の両インスタンスの実行時のパフォーマンスの違いをザックリ調べてみたくなりました。というのも、なんか体感的に Linux の起動が Windows よりも遅い気がしたので気になりました。気になったから駆動調査。
あ、「いかがでしたかブログ」的なタイトルはただただ付けてみたかっただけです。深い意味はありません!
メモリ使用量
それぞれの環境にホストしたときのインスタンスのご様子を Application Insights の Live Metrics Stream で確認してみます。結果は以下の通り。だいぶ違いますね。
| 環境 | Committed Memory |
|---|---|
| Windows | 47 MB |
| Linux | 278 MB |

Windows (IIS)、優秀ですね。
起動速度
次に App Service を「停止 -> 再開」としたときに、画面が表示されるまでの時間を計測します。それぞれ 3 回ずつ計測しました。結果は以下の通りで、これまただいぶ違いますね。
| 環境 | 1 回目 | 2 回目 | 3 回目 | 平均 |
|---|---|---|---|---|
| Windows | 19 秒 | 18 秒 | 15 秒 | 17.3 秒 |
| Linux | 78 秒 | 101 秒 | 75 秒 | 84.6 秒 |
Windows (IIS)、超優秀ですね...!!
まとめ
いかがでしたか?今回は Azure App Service における環境ごと (Windows / Linux) の ASP.NET Core 実行時のパフォーマンス差を調べてみました。調査結果は載せてないですが、レスポンス速度には特段の差はなさそうでした。App Service の内部実装に明るくないのであくまで勘ではあるのですが、
- Windows は IIS の内部 API をダイレクトに叩いているから速い
- Linux は Docker ベースで動いている分のフットプリントが大きい
ということなのかなぁなどと思ってみたりしました。実際にそうなのかを教えてくださる方がいらっしゃったら @xin9le まで!
App Service は Linux インスタンスの方がお安いのでコストメリットはありますが、デプロイ時の初速が気になったり、Kudu の使いやすいさなどを重視する場合は (ちょっとコストがかさんでも) Windows を選択するが良さそうかなって思うなどしました。サーバーのホスト環境としては Windows にも Linux にも拘りないですし。