しばやん雑記

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

Durable Functions を使って時間のかかる処理を非同期で行う

前回は Durable Functions について大雑把に試したりしていましたが、ちょうど今仕事で Durable Functions を適用するのにちょうどよいタスクがあったので、実際に使ってみることにしました。

内容はタイトルの通りです。Durable Functions については前回のエントリを。

あまり実装したくない部類の機能ですが、どうしても必要となるのがデータのエクスポート機能だったりします。一瞬で終わるようなデータ量なら良いのですけど、こういうのに限って大量のデータだったり、複雑なデータ構造を要求されたりします。

今回はデータ量はそこまで多くないはずですが、全て Cosmos DB で作られているので考慮せずにクエリを投げまくると RU が爆発して即死します。今回の機能は要件としては以下のような感じです。

  1. 時間はかかっても良いからリソースを集中して消費しないように
  2. エラーが発生しても柔軟にリトライしたい
  3. ファイルの準備が完了したかどうか確認したい
  4. 間違ったときのためにキャンセルもしたい

Durable Functions を使うだけで上の 4 つを全て満たす処理を書くことが出来るわけです。

今回のデータエクスポートで必要な処理は以下の通りです。

  1. エクスポートの対象をリストアップする
  2. 実際のエクスポートするデータを取得する
  3. データを整形して Blob Storage に書き出す

流石に本番のコードを出すことは出来ないので、同じようなサンプルを用意したので各アクティビティ単位で見ていくことにします。サンプルなのでわざと Task.Delay を入れてます。

エクスポートする対象を抽出するアクティビティ

対象を抽出するためのアクティビティです。サンプルなので適当に10 件分のデータを返しています。並列処理が必要な場合はいい感じに分割しても良いと思います。

[FunctionName("AggregateTargets")]
public static async Task<string[]> AggregateTargets([ActivityTrigger] DurableActivityContext context)
{
    await Task.Delay(TimeSpan.FromSeconds(10));

    return Enumerable.Range(0, 10).Select(x => $"target-{x}").ToArray();
}

単純に 1 クエリ引くだけで終わる処理の場合は Durable Functions にする必要はないわけです。複数のテーブルやコレクションから引っ張ってくるからこそ、今回 Durable Functions を使うように実装しました。

実際のデータを取得するアクティビティ

実際のデータを何かしらのストレージから取得するアクティビティです。仕事では全て Cosmos DB なので地味につらかったですが、今回はあくまでもサンプルなので入力値を適当に加工して返すだけです。

ActivityTrigger では DurableActivityContext を指定することも出来るので、覚えておいて損はないです。実行中の InstanceId が必要になった場合には、このコンテキスト経由で取得できます。

[FunctionName("GetDataFromStorage")]
public static async Task<string> GetDataFromStorage([ActivityTrigger] DurableActivityContext context)
{
    var input = context.GetInput<string>();

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

    return $"{input}: sample data";
}

Durable Functions では一度実行されたアクティビティの結果は Table Storage に保存されるようになってますが、当然ながら Table Storage のエンティティサイズの上限という壁があることをお忘れなく。

Table Storage では 1MB 以上のデータは保存できないので、アクティビティで巨大なデータを返すと死にます。その場合はいったんファイルに書き出して、そのパスを返すなどしましょう。

Storage Blob へ書き出すアクティビティ

取得したデータを整形して Blob Storage に保存するアクティビティです。Blob を使うのは地味に面倒ですが、Azure Functions なら Binder を使って楽が出来ます。

[FunctionName("WriteToBlobFile")]
public static async Task WriteToBlobFile([ActivityTrigger] DurableActivityContext context, Binder binder)
{
    var outputs = context.GetInput<IList<string>>();

    var filePath = "outputs/sample.txt";

    using (var destination = await binder.BindAsync<CloudBlobStream>(new BlobAttribute(filePath, FileAccess.Write)))
    using (var writer = new StreamWriter(destination))
    {
        foreach (var value in outputs)
        {
            writer.WriteLine(value);
        }
    }
}

GetInput を使ってデータを取得して、BindAsync を使って Blob へ書き込んでいきます。拍子抜けするぐらい簡単なコードで実現することが出来ました。

アクティビティの入力も Table Storage に保存されているので、出力と同様にサイズ制限があることをお忘れなく。1MB が上限といっても、他のカラムにもデータが入っていて目減りするので注意。

アクティビティを実行するオーケストレーター

最後は作成したアクティビティを実行していくオーケストレーターを用意します。といっても大したコード量ではないですし、await で分かりやすいので説明は不要な気がします。

[FunctionName("ExportFile")]
public static async Task<int> RunOrchestrator([OrchestrationTrigger] DurableOrchestrationContext context)
{
    var targets = await context.CallActivityAsync<string[]>("AggregateTargets");

    var outputs = new List<string>();

    foreach (var target in targets)
    {
        outputs.Add(await context.CallActivityAsync<string>("GetDataFromStorage", target));
    }

    await context.CallActivityAsync("WriteToBlobFile", outputs);

    return outputs.Count;
}

今回は並列処理をさせたくなかったので foreach の中で await して結果を待つようにしています。これで Durable Functions が順番を保ったままアクティビティを実行してくれます。

動作を確認する

コードを完成したので実際に動かしてテストします。とりあえずサクッとオーケストレーターを起動。

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

起動に成功すると 202 とオーケストレーターの状態を確認できる URL などが返ってきます。この URL を叩けばオーケストレーターの状態を JSON で取得できます。

実際に叩いてみると Running と返ってきました。ちゃんと動いているようですね。

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

地味に起動の応答は素早く返して、非同期で実行させながら状態を確認する API を作るのはめんどくさいですが、Durable Functions ならデフォルトで用意されてます。

何回か確認しているうちに runtimeStatus が Completed に変わりました。そして output には 10 が返ってきていますが、これは実際に処理された件数となります。

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

オーケストレーターの戻り値が output の値になるので、正常終了時に追加の情報を返すことも簡単です。

Storage Explorer で Blob を確認すると、ファイルがちゃんと生成されています。

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

ダウンロードして開いてみると、実行順を保ったまま書き出されていることが確認できます。

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

これまで Azure Functions では画像のサイズ変換などを行うサンプルが多かったですが、Durable Functions を使うと処理の状態や結果を返すように出来ますね。処理を中断出来るのもポイントです。

補足 : シングルトンで処理を行う

良くある話として、同じデータに対する処理を同時に実行させたくないことが多いと思います。特に Durable Functions のように非同期でオーケストレーターが走る場合には簡単に並列実行が出来てしまいます。

同じデータへの並列実行を防ぐためにシングルトンでの実行をさせます。Durable Functions のシングルトンは単純でインスタンス ID を入力値から一意に決めてしまえば実装出来ます。

エクスポートする場合には ID や日付などで範囲を指定することがあると思うので、そういったデータからハッシュを計算してインスタンス ID とすれば良いでしょう。

[FunctionName("ExportFile_HttpStart")]
public static async Task<HttpResponseMessage> HttpStart([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestMessage req,
                                                        [OrchestrationClient] DurableOrchestrationClient starter,
                                                        TraceWriter log)
{
    // POST されたデータから InstanceId を一意に生成
    var instanceId = "...";

    var existingInstance = await starter.GetStatusAsync(instanceId);
    if (existingInstance == null)
    {
        // Function input comes from the request content.
        await starter.StartNewAsync("ExportFile", instanceId, null);

        log.Info($"Started orchestration with ID = '{instanceId}'.");
        return starter.CreateCheckStatusResponse(req, instanceId);
    }

    return req.CreateErrorResponse(HttpStatusCode.Conflict, $"An instance with ID '{instanceId}' already exists.");
}

これで同じパラメータに対して、複数のオーケストレーターが実行されることは無くなりました。Durable Functions はもっと早くから触っておけばよかったと実感する日々です。

今更ながら Azure Functions の Durable Functions を試した

前から Durable Functions が面白そうだと思いつつ、Visual Studio 2017 15.5 が必要とかで中々試していなかったのですが、少し時間があったので一通り調べて動かしてみました。

当然ながら全ては .NET Core の v2 ランタイム向けで試してあります。まずはドキュメントを。

後は牛尾さんの Qiita エントリを読んでおくと基本的な知識が入るはずです。

Visual Studio 向け拡張もアップデートが進んで、Durable Functions が簡単に追加できるようになっているので Qiita に書かれた当時と比べて、さらにイケてる機能となっています。

元々 App Service も Azure Functions も Cloud Design Pattern の集大成という感じなのですが、Durable Functions は更にいろんなパターンが内部で使われていて面白いです。とりあえず話を進めます。

Durable Functions の作成とデプロイ

Visual Studio で Azure Functions プロジェクトを新規作成し、新規追加から Azure Function を選ぶとダイアログが表示されます。そこで Durable Functions Orchestration を選べば必要なものが追加されます。

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

ローカルでの実行はストレージが必要になるっぽいので、Azure Storage Emulator か Azure Storage の接続文字列を設定すればよいと思います。私は久し振りに Storage Emulator を起動しました。

Azure へのデプロイはこれまで通りですが、.NET Core (v2) を選んでいるのでベータ版のランタイムを有効化する必要があります。Visual Studio が教えてくれるので簡単です。

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

デプロイはこれで終わりです。Azure 環境に置いておいた方がログとか見やすいのでデプロイしておきました。次は Durable Functions を構成する要素を大雑把につまみ食いして説明します。

ちなみに Durable Functions で実装されているクラスはこれだけしかないので、簡単に理解できます。

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

分かりやすいようにサンプルコードベースで説明することにします。

オーケストレーターは起点

OrchestrationTrigger が付けられているものが Durable Functions ではオーケストレーターとして扱われます。

渡される DurableOrchestrationContext には後述するアクティビティを実行するメソッドや、更にオーケストレーターを実行するもの、外部からのイベントを待機したり、入力値を取得するものがあります。

[FunctionName("Function1")]
public static async Task<List<string>> RunOrchestrator(
    [OrchestrationTrigger] DurableOrchestrationContext context)
{
    var outputs = new List<string>();

    // Replace "hello" with the name of your Durable Activity Function.
    outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Tokyo"));
    outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "Seattle"));
    outputs.Add(await context.CallActivityAsync<string>("Function1_Hello", "London"));

    // returns ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]
    return outputs;
}

オーケストレーターはべき等である必要があるので、内部でネットワーク・ファイル I/O などは厳禁です。行える処理は DurableOrchestrationContext を介したものだけです。タスクベースのインターフェースなので、特に説明もなく使えると思います。

CallActivityAsync を呼び出すと、Queue Storage を経由して実際のアクティビティが実行されます。実行が Queue を経由するという部分は非常に重要なポイントとなってきます。

アクティビティが実際の処理を行う

オーケストレーターではネットワーク・ファイル I/O などの、べき等ではない処理は行えませんがアクティビティでは書くことが出来ます。SQL DB や Cosmos DB へのアクセスも問題なく行えます。

アクティビティはスケーリングされて実行される可能性があるので、基本的にはステートレスで書いた方がパフォーマンスが良いです。状態が必要になるとも思いにくいですし。

[FunctionName("Function1_Hello")]
public static string SayHello([ActivityTrigger] string name, TraceWriter log)
{
    log.Info($"Saying hello to {name}.");
    return $"Hello {name}!";
}

本来ならネットワークアクセスや CPU を食う処理などを適切な粒度で行わせた方が良いのですが、あくまでもサンプルコードなので気にしない方向で。

タスクベースで抽象化されているので、アクティビティがどのように実行されているか考える必要がないですが、スレッドプールではなく別インスタンス上で実行される可能性もあることを頭に入れて、オーバーヘッドのことも考慮しつつ設計する必要があるでしょう。

非常に細かくアクティビティを作成することも出来ますが、同一プロセス内の別スレッドで実行するのと比べて、数桁のオーバーヘッドが発生する可能性も十分あります。そもそも Queue Storage で処理されるので数秒は遅延が発生します。

オーケストレーターを起動する

ドキュメント的にはオーケストレーターを直接実行できるみたいですが、何故か上手くいかなかったので HttpTrigger と StartNewAsync を使って実行するようにします。

StartNewAsync を実行後に返ってくるインスタンス ID を使ってオーケストレーターを識別するので、レスポンスとして返す必要があります。

[FunctionName("Function1_HttpStart")]
public static async Task<HttpResponseMessage> HttpStart(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")]HttpRequestMessage req,
    [OrchestrationClient]DurableOrchestrationClient starter,
    TraceWriter log)
{
    // Function input comes from the request content.
    string instanceId = await starter.StartNewAsync("Function1", null);

    log.Info($"Started orchestration with ID = '{instanceId}'.");

    return starter.CreateCheckStatusResponse(req, instanceId);
}

DurableOrchestrationClient には名前の通りオーケストレーターの開始、状態取得、終了を行うメソッドがあります。後述する RaiseEventAsync といったイベントを発火させるメソッドもあります。

Durable Functions は同期的に動く部分が存在しないので、インスタンス ID を使ってオーケストレーターの状態を取得することは重要です。

外部イベントを受け取る

面白いと思ったのが外部イベントの仕組みです。オーケストレーター内で WaitForExternalEvent を待機させると、Webhook か RaiseEventAsync でイベントが発火されるまでは待ち続けます。

公式のサンプルでは SMS を使った 2FA を実装していましたが、外部に DB などを必要することなく実装出来ていて興味深いサンプルとなっています。

使い方もタスクベースなので説明はほぼ不要ですね。イベント名だけ決めれば良いです。

var eventValue = await context.WaitForExternalEvent<string>("SampleEvent");

イベントを発火させるには HttpTrigger などで DurableOrchestrationClient の RaiseEventAsync を呼び出すのが簡単です。当然ながらインスタンス ID が必要なので保持しておく必要があります。

一緒に渡すパラメータは普通にクラスでも良いです。

await client.RaiseEventAsync(instanceId, "SampleEvent", "testvalue");

もう一つは Durable Functions が標準で提供している Webhook を使う方法です。

Webhook なので上手く使えば、GitHub にマージされたタイミングでリリース確認のメールを送信し、更にメール内のリンクを踏めばリリース処理が継続といったことも出来そうです。

POST http://{host}/runtime/webhooks/DurableTaskExtension/instances/{instanceId}/raiseEvent/{eventName}?taskHub=DurableFunctionsHub&connection=Storage&code=***

この辺りを活用すれば Logic Apps とほぼ同じことが実現できます。

スケーリングを検証

使っていて気になるのが大体スケーリング周りですが、Durable Functions は内部的に Queue Storage が使われているので、ステートレスなアクティビティを書いている限りは幾らでもスケールアウト出来そうです。

ドキュメントには仕組みが分かりやすく絵入りで書いてあったので必見ですね。

実際にどんな感じでスケールするのか確認したかったので、以下のようなオーケストレーターを書いてステートレスなアクティビティを大量に実行するようにしました。サンプルを少し変えただけです。

元々は CallActivityAsync の時点で await していたのを、最後に Task.WhenAll するように変えただけです。

public static async Task<IList<string>> Run([OrchestrationTrigger] DurableOrchestrationContext context)
{
    var outputs = new List<Task<string>>();

    for (int i = 0; i < 100; i++)
    {
        outputs.Add(context.CallActivityAsync<string>("SayHello", i.ToString()));
    }

    return await Task.WhenAll(outputs);
}

[FunctionName("SayHello")]
public static string SayHello([ActivityTrigger] string name, TraceWriter log)
{
    return $"Hello {name}! from {Environment.MachineName}";
}

Durable Functions のランタイムによって Azure Storage にいくつかキューが作成されます。

ドキュメントによるとステートレスなアクティビティの場合は workitems が使われるらしいので、スケーリングの上限は秒間 2000 メッセージといったところでしょうか。

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

オーケストレーターを実行すると workitems に大量のメッセージが追加されます。このキューの長さを元にして Azure Functions のランタイムがオートスケールを行ってくれます。

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

Azure Functions の Consumption プランでは何インスタンスが動作しているか確認しにくいですが、ARM Explorer を使えば現在のインスタンス ID が取得できるので大体は把握が可能です。

実際に確認するとインスタンス ID が 2 つ取れたので、2 インスタンスが割り当てられています。

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

今回試して分かったこととして、Azure Functions のオートスケールは思っていたよりも速いということでした。素早くインスタンスが増えて、処理が終わればあっという間にいなくなっていて驚きました。

最後に出力結果を確認しておきます。マシン名を追加したので一目でスケールしたことが分かります。

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

ARM Explorer 上では 2 インスタンスしか確認できませんでしたが、マシン名を見るとユニークなものが 4 つほどあるので、実際には 4 インスタンスで実行されたということになります。

普通に Azure Functions を直接使うより、Durable Functions を被せておいた方が有利な場面が多そうに感じました。こっちの方が洗練されたサーバーレスという感じがします。

Azure VM のセルフサービスメンテナンスを実施してみた

2018 年になってすぐに Azure のプラットフォーム側のアップデートという非常に大きなイベントがあります。ホスト OS が Windows Server 2016 ベースになるという話だったので期待してました。

日本語の公式ブログで翻訳が公開されているので、まずは読んでおいたほうが良いでしょう。

再起動を伴う仮想マシン メンテナンスへの新しいエクスペリエンス – Japan Azure Technical Support Engineers' Blog

[告知] 2018 年 1 月 2 日より Azure IaaS 仮想マシンのメンテナンス期間が開始します – Japan Azure Technical Support Engineers' Blog

公式ドキュメントにもメンテナンスに関して通知を受ける方法などが書いてあります。

基本的には可用性セットを組んでおけば、更新ドメイン単位で処理が行われるのでサービスはダウンしないはずですが、とはいえ必ず再起動が行われるので VM で運用しているケースでは悩ましいでしょう。

今回のケースに合わせて期間中なら任意のタイミングでメンテナンスを実行できる機能が用意されると聞いたので、体験するためだけに VM を立ち上げて待っておきました。

そして今日になって Azure Portal を確認すると、きっちりとメンテナンス通知が来ていました。

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

セルフサービスメンテナンスが可能な期間は 2 週間ちょっとなので、年が明ける前に終わらせておいた方が安心して過ごせそうですね。ちなみに可用性セットを組んでいる場合は実行しない方が良いらしいです。

表示されている通知をクリックすると、メンテナンスのタイムラインとセルフサービスメンテナンスを実行するためのボタンが表示されます。

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

メンテナンス用に実行しておいた VM なので、迷うことなくメンテナンスを開始しました。

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

5,6 分でメンテナンスが完了して、Azure Portal には完了したという通知が表示されました。実際には VM を止めて、別のホストに移して起動してるだけっぽいので割と短時間で終わります。

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

VM からすれば変化は全くわからないですが、アップデートは必須なので淡々と行いましょう。

メンテナンスを実行する前に IP アドレスを確認するのを忘れてしまっていたので、今回のセルフサービスメンテナンスでパブリック IP が変わるのか確認できなかったのが残念です。

ASP.NET Core で Azure AD B2C を使った際に謎のエラーが多発する問題

ASP.NET Core の OpenID Connect Middleware を使って Azure AD B2C なログインを GitHub で公開されているサンプルとほぼ同じように実装したら、よくわからないエラーが多発して困ったという話です。

実際に Application Insights からデータを引っ張ってきました。本番のデータなので URL は隠しました。

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

エラーメッセージの内容自体は Remote Authentication Handler が自動的に使っている CSRF 対策のクッキー検証に失敗したというだけなんですが、正直なところこのエラーの発生率は異常でした。

よくわからないし、実害あまりなさそうなので放っておいたのですが、件数多いし何とか対応しないといけない機運になったので、詳細に調べてみました。GitHub にも Issue がいくつか上がっているようでしたが、決定的な原因と言えるものは見当たらず。

再現しないなーと思っていたら、ブラウザで戻った時に発生するという話が Stack Overflow で見つかったので、あーこれが原因なんだろうなとほぼ確信しました。

signin-oidc に戻ってしまった場合にエラー画面に飛ぶのは、明らかに問題が多いです。理想的な挙動としては Correlation failed の場合はもう一度サインイン画面に飛ばすべきでしょう。

エラー時の処理は OnRemoteFailure イベントを使えばよいので簡単です。AAD B2C のサンプルでは以下のようなイベントハンドラが登録されています。

public Task OnRemoteFailure(RemoteFailureContext context)
{
    context.HandleResponse();
    // Handle the error code that Azure AD B2C throws when trying to reset a password from the login page 
    // because password reset is not supported by a "sign-up or sign-in policy"
    if (context.Failure is OpenIdConnectProtocolException && context.Failure.Message.Contains("AADB2C90118"))
    {
        // If the user clicked the reset password link, redirect to the reset password route
        context.Response.Redirect("/Session/ResetPassword");
    }
    else if (context.Failure is OpenIdConnectProtocolException && context.Failure.Message.Contains("access_denied"))
    {
        context.Response.Redirect("/");
    }
    else
    {
        context.Response.Redirect("/Home/Error?message=" + context.Failure.Message);
    }
    return Task.FromResult(0);
}

context.Failure.Message に Correlation failed が含まれていたら、サインイン画面にリダイレクトするように条件を追加すればよい感じですね。

Correlation failed とは別に、AAD B2C 固有っぽいエラーも発生していました。これもまた Application Insights から本番のエラーデータを引っ張ってきました。

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

何故か改行コードがヘッダーに混ざっていると言ってます。まあ、普通はあり得ないです。

調べてみるとどうやら AAD B2C が返すエラーメッセージは改行されているものがあるらしく、Redirect で URL エンコードしていないサンプルコードのせいで発生していたみたいです。

public Task OnRemoteFailure(RemoteFailureContext context)
{
    context.HandleResponse();
    // Handle the error code that Azure AD B2C throws when trying to reset a password from the login page 
    // because password reset is not supported by a "sign-up or sign-in policy"
    if (context.Failure is OpenIdConnectProtocolException && context.Failure.Message.Contains("AADB2C90118"))
    {
        // If the user clicked the reset password link, redirect to the reset password route
        context.Response.Redirect("/Session/ResetPassword");
    }
    else if (context.Failure is OpenIdConnectProtocolException && context.Failure.Message.Contains("access_denied"))
    {
        context.Response.Redirect("/");
    }
    else if (context.Failure.Message.Contains("Correlation failed"))
    {
        context.Response.Redirect("/Session/SignIn");
    }
    else
    {
        context.Response.Redirect("/Home/Error?message=" + WebUtility.UrlEncode(context.Failure.Message));
    }
    return Task.FromResult(0);
}

ちゃんと URL エンコードしてから渡すことでエラーは発生しなくなりました。

そもそもこの処理が必要かどうかは別の話なので、実際の場合は汎用的なエラー画面を出しておきつつ、内部では Application Insights にテレメトリを送っておけば良いです。

App Service の Japan East / West にも Windows Server 2016 がデプロイされていました

手持ちの App Service を確認したところ、初期に Japan East に作成した App Service が Windows Server 2016 にアップグレードされていました。数日前に Japan West にはデプロイされていたので、Japan East でも開始されたみたいです。

ちゃんと Windows Server 2016 と .NET Framework 4.7.1 になっています。

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

今回の OS アップグレードはペアリージョンで同時には行わないと宣言されていたので、既に Japan West に関しては完了していると考えて良さそうです。East はスタンプによってはまだ 2012 のままです。

アップグレードに伴い基本的な変更点に関しては、前回 West Central US で試した時のエントリを参照してください。ちなみに HTTP/2 は Japan East / West では予定通り無効になっています。

普段使いしていた App Service が Windows Server 2016 になったので、アプリケーションの互換性を少し見ておこうかなという気持ちになりました。

In/Out IP アドレスは変わるのか?

僕の尊敬する田口社長が Windows Server 2016 へのアップグレードに伴って IP アドレスが変わるのか心配していたので、2016 にアップグレードされた Web App で確認しておきました。

App Service Plan は S2 を使っていて、A レコードを当てていたので確認は簡単です。変更なしです。

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

地味に気になるのが Outbound IP ですが、こちらもフォーラムで公開されている IP と現在の IP を比較したところ、変更はされていませんでした。なので安心してアップグレードを待てばよいでしょう。

恐らく大半の人は Windows Server 2016 にアップグレードされたことに気が付かないはずです。

2012 と 2016 で設定の違いはない

運よく Japan East に Windows Server 2012 と 2016 の Web App が用意できたので、applicationHost.config 周りで Diff を確認してみましたが、特に変化はありませんでした。

1 点挙げると、Azure App Service のランタイムバージョンが 2016 の方が新しくなっていたので、不具合の修正などが行われている可能性があります。

TCP Fast Open は無効

HTTP/2 よりも互換性面で影響が大きそうだったので有効化はされていないと思ってましたが、一応軽く確認だけしておきました。TCP Fast Open は無効となっています。

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

Windows Server 2016 の Networking Stack 自体がもうちょっと検証されないと難しそうな気もしますが、今後に期待ということで適当にまとめたいと思います。

Azure Functions Runtime を Intel NUC にインストールした

自宅で Windows Server 2016 用として動かしていた Intel NUC を流用して、Windows 10 Pro をインストールし直し Azure Functions Runtime の環境に作り変えました。

Windows Server 2016 より Windows 10 Pro の方が FCU が扱いやすいので、個人的にお勧めしてます。ドキュメントには Creators Update と書いてましたが、FCU でも問題なく動きました。

インストールの詳細な手順はドキュメントに任せて、自分がはまった部分だけ軽く書いておきます。

Azure Functions Runtime をインストールして、セットアップを行っている途中に SQL Server と接続する必要があります。ドキュメントには書いてないですが、TCP を有効にしないと繋がらないみたいでした。

サーバー名も最初 localhost や .\SQLEXPRESS など試行錯誤しましたが、SQL Browse サービスが動いてないので名前付きインスタンスは TCP で使えず、結局マシン名だけで良いという結論でした。

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

書いてある通りに sysadmin の権限を持ったログインを作っておく必要があります。データベースの prefix は空っぽで問題なかったのでそのままにしました。

後は設定を順番にポチポチしていけばはまることなく完了します。最後にポータルを開けば完了です。

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

Function Portal は Windows 認証を使っているみたいなので、Windows へのログインユーザーとパスワードで入れます。Windows 認証をアプリではあまり使ったことないですが、地味に便利。

ログインすると、Azure Portal っぽさある画面になります。

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

書いてある通りに、最初にサブスクリプションを作成します。将来的には Function の上限をサブスクリプション単位で指定できるのかもしれないですが、今は単なる入れ物っぽいです。

選べる項目も DefaultPlan しかないので、適当に名前を付ければ OK です。

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

ローカルマシンに対する処理なので、一瞬で作成などは完了するのが新鮮です。

サブスクリプションを作成したら Function App を作成します。この辺りからは普通の Azure Functions と変わりないですが、作成時に Function Runtime のバージョンを選べます。

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

.NET Framework を選んだ方が選べるトリガーが多いですが、例によって Server Core で実行されるので多少重いです。.NET Core の方は Nano Server なので有利ではあります。

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

ちなみに HttpTrigger 系はありません。なので多少デバッグが行いにくいですが、大体の場合は TimerTrigger で動作を確認すればよい感じです。

実際に TimerTrigger な Function を作成すると、いろいろと興味深いログが出力されています。

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

今の App Service のように ACL でサンドボックスを頑張るのではなく、Docker を利用して環境への依存を減らしつつ、Hyper-V Containers を使った高度な分離まで実現出来ているようです。

同時にインストールされる Command Pronpt ショートカットを管理者として起動すれば、Docker コマンドを使ってもうちょっと中身を詳細にみることが出来ます。

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

Creators Update を対象としているので、FCU からの軽量化されたイメージが使えていないのは残念ですが、Azure Functions Runtime に App Service の未来を見た気がしました。

Windows Containers ベースの App Service への期待が個人的に高まっています。

Cosmos DB の SQL クエリパフォーマンスを調査する

仕事で Cosmos DB を使っていますが、最近は RU の消費が気になってきて、実際に投げたクエリがどのように実行されているのか知りたくなったので、例によっておーみさんに聞きました。

実行計画とはいかなくとも、非常に参考になるメトリックを返してくれるようになっているみたいです。

インデックスが本当に使われているのかどうかも、このメトリックから読み取れるようになってます。

ドキュメントを読めば大体わかりますが、FeedOptions で PopulateQueryMetrics = true とするとレスポンスにクエリの実行にかかった諸々の時間やデータサイズなどが返ってきます。

百聞は一見に如かずということで、適当なデータを作成して試してみました。まずは普通にインデックスが効いているであろうというケースです。

RDB でもインデックスを普通に作れば、Index Seek で引けるはずですね。

var feedOptions = new FeedOptions
{
    MaxItemCount = 1,
    EnableCrossPartitionQuery = true,
    PopulateQueryMetrics = true
};

var documentQuery = Client.CreateDocumentQuery<MemberDocument>(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), feedOptions)
                            .Where(x => x.Email == "test-1@example.com")
                            .AsDocumentQuery();

var response = await documentQuery.ExecuteNextAsync<MemberDocument>();

foreach (var item in response.QueryMetrics)
{
    var data = JsonConvert.SerializeObject(item.Value, Formatting.Indented);
}

インデックスの作成ポリシーはデフォルトのままなので、当然ながら Email に対してもインデックスが作成されています。それではインデックスが実際に使われているのかを確認してみます。

QueryMetrics の中身を表示するのが面倒だったので、適当に JSON にしたものを貼り付けておきます。

{
  "TotalTime": "00:00:00.0006100",
  "RetrievedDocumentCount": 1,
  "RetrievedDocumentSize": 853,
  "OutputDocumentCount": 1,
  "IndexHitRatio": 1.0,
  "QueryPreparationTimes": {
    "CompileTime": "00:00:00.0000600",
    "LogicalPlanBuildTime": "00:00:00.0000200",
    "PhysicalPlanBuildTime": "00:00:00.0000300",
    "QueryOptimizationTime": "00:00:00"
  },
  "QueryEngineTimes": {
    "IndexLookupTime": "00:00:00.0003000",
    "DocumentLoadTime": "00:00:00.0000200",
    "WriteOutputTime": "00:00:00.0000200",
    "RuntimeExecutionTimes": {
      "TotalTime": "00:00:00.0000100",
      "SystemFunctionExecutionTime": "00:00:00",
      "UserDefinedFunctionExecutionTime": "00:00:00"
    }
  },
  "Retries": 0
}

データの意味はドキュメントとキー名を見ればわかると思います。インデックスが使われているかどうかは IndexHitRatio と IndexLookupTime の値を見れば良いです。それぞれが 1.0 と 0.3ms となっているので、インデックスはちゃんと使われています。

ちなみに Time で終わるキーの値が 00:00:00 の場合は実行されていない扱いです。なので今回の場合は QueryOptimizationTime や Function の呼び出しが該当します。

Cosmos DB のインデックス周りは優秀みたいで、効かないかと思った条件でも割とインデックスが使われていました。しかし、SQL で言う LIKE の場合はスキャンになるみたいなので試しました。

var feedOptions = new FeedOptions
{
    MaxItemCount = 1,
    EnableCrossPartitionQuery = true,
    PopulateQueryMetrics = true
};

var documentQuery = Client.CreateDocumentQuery<MemberDocument>(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), feedOptions)
                            .Where(x => x.FirstName.Contains("kazuakix98"))
                            .AsDocumentQuery();

var response = await documentQuery.ExecuteNextAsync<MemberDocument>();

foreach (var item in response.QueryMetrics)
{
    var data = JsonConvert.SerializeObject(item.Value, Formatting.Indented);
}

こういう条件の場合は Azure Search を使えといわれそうですが、適当に試した感じではこれぐらいしかスキャンにならなかったので勘弁してください。

そして QueryMetrics の中身は以下の通りです。IndexHitRatio と IndexLookupTime に注目。

{
  "TotalTime": "00:00:00.0008900",
  "RetrievedDocumentCount": 100,
  "RetrievedDocumentSize": 85643,
  "OutputDocumentCount": 1,
  "IndexHitRatio": 0.01,
  "QueryPreparationTimes": {
    "CompileTime": "00:00:00.0000800",
    "LogicalPlanBuildTime": "00:00:00.0000400",
    "PhysicalPlanBuildTime": "00:00:00.0000300",
    "QueryOptimizationTime": "00:00:00"
  },
  "QueryEngineTimes": {
    "IndexLookupTime": "00:00:00",
    "DocumentLoadTime": "00:00:00.0003200",
    "WriteOutputTime": "00:00:00.0000200",
    "RuntimeExecutionTimes": {
      "TotalTime": "00:00:00.0002100",
      "SystemFunctionExecutionTime": "00:00:00.0000400",
      "UserDefinedFunctionExecutionTime": "00:00:00"
    }
  },
  "Retries": 0
}

全くインデックスが使われていないことが分かります。その代わりにスキャンが行われているので、DocumentLoadTime に割と時間がかかっていることも見て取れますね。

RetrievedDocumentCount が 100 となっているので、1 件を返すために 100 件をスキャンしていることも分かります。これだと RU も消費するし、時間もかかってくるので改善が必要となります。

RU と同じように Application Insights に送りたいのですが、PopulateQueryMetrics のオーバーヘッドがどのくらいなのかわからないため、ちょっとローカルのみで検証して様子見です。

Windows Server 2016 になった Azure App Service を試す

GitHub の Issue を眺めていたら、West Central US の App Service には Windows Server 2016 をデプロイしたと書いてあったので、早速新しく Web App をデプロイして試していました。

中の人曰く、West Central US をテストの場として使っているみたいです。ちゃんと 2016 です。

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

軽く触ってみましたが、当然ながら構成は全く同じです。なので互換性を気にする程ではないです。

Kudu API に OS の名称とビルド番号を返す機能が追加されたので、それを使って使われているバージョンを詳細に取ってきました。この辺りは予告通りですね。

14393.1794.amd64fre.rs1_release(bryant).171110-1651

ちまちま確認するのはアレなので、GitHub に置いてあるバージョンなどをいい感じに表示するアプリをデプロイして、サクッと確認してみました。

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

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

ちゃんと .NET Framework 4.7.1 がインストールされています。App Service で動かしているアプリが 2016 で動くか心配な場合は、West Central US で試して見ると良いでしょう。

さて、アナウンスでは HTTP/2 対応は後回しと書いてありましたが、West Central US にデプロイしたアプリを確認すると、既に HTTP/2 が有効になっていました。

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

気になったので確認すると、West Central US だけ先行して有効にしたらしいです。なので、今後グローバルでロールアウトされる場合には HTTP/2 は無効な状態となるはずです。

他に変更された部分がないか気になったので、Qualys の SSL Server Test を実行しました。このリージョンは HTTP/2 が有効なので ALPN が対応になってます。

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

そして HTTP/2 で必要な暗号スイートである TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 が追加されています。この辺りは HTTP/2 が無効になっていても影響を受けない気がしますね。

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

後は ECC 向けに secp384r1 曲線が追加されてました。Windows Server 2016 で追加された感あります。

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

TCP Fast Open が有効になっているかとか気になりますが、確認がめんどくさかったのでやりません。数年振りとなるプラットフォーム側のアップデートなので、今後の展開にも期待しています。

Azure App Service が Windows Server 2016 に段階的にアップグレードされます

12/4 から App Service のホスト OS が Windows Server 2012 R2 から Windows Server 2016 へアップグレードされているらしいです。とはいえ非常にゆっくりロールアウトするらしいので、実際に 2016 なスケールユニットが使えるようになっているかはわかりません。

詳細はさとうなおきさんがブログで書いてるので、そっちを読むと良いです。日本語です。

Azure App Service、Azure FunctionsのWindows Server 2016へのアップグレード | S/N Ratio (by SATO Naoki (Neo))

ちなみに自分はまだ 2016 になったスケールユニットを引き当てていません。いろんなリージョンに App Service を作って試しましたが、リージョンが公開されていないと辛いですね。

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

某社の T 田さんからメッセージで「互換性とか大丈夫なんすか?」みたいなことを聞かれたので、今のところ分かっている情報をベースに軽くまとめておくことにします。

IIS が 8.5 から 10.0 に

Windows Server 2016 へのアップグレードと同時に IIS もこれまでの 8.5 から 10.0 にアップデートされます。公式サイトで新機能が紹介されてますが、基本的には HTTP/2 だけです。

スケールユニットが 2016 にアップデートされても、そのタイミングでは HTTP/2 は解放されないようです。

フロントにいる ARR もアップデートされるので、グローバルで有効化するタイミングを合わせたいという理由なのかも知れません。

URL Rewrite も新しいバージョンがリリースされているので、そういった部分も一気に更新されてくる可能性がありそうです。実際に 2016 のスケールユニットを引き当ててから確認予定です。

2016 から TCP Fast Open など、ネットワークスタックにアップデートが行われていますが、アプリケーション側で対応できるものではないですし、アプリケーションの動作が変わる部分ではないので除外します。

.NET Framework も 4.7.1 に

今回の 2016 へのアップグレードと同時に .NET Framework 4.7.1 もインストールされます。特に書いてなかったのですが、コメントで聞いてみたら 4.7.1 も入ると教えてくれました。

むしろ OS のアップグレードよりも .NET Framework のアップデートの方が注意したい部分ですね。とはいえ 4.7 から 4.7.1 は特に互換性に影響の出る内容はなさそうなので、個人的には特に確認はしないです。

ASP.NET 周りのアップデートも多いので、万全を期すためには Visual Studio 2017 15.5 と .NET Framework 4.7.1 SDK の環境で、予め動作確認をしておけば良いでしょう。

とりあえず、早く 2016 が動いているスケールユニットを引きたいです。

Azure Functions and Web Jobs Tools のアップデートが失敗するのを直した

Visual Studio 2017 の 15.5 が出たので、やっと Azure Functions の新しいツールがインストール出来ると楽しみにしてましたが、無情にも謎のエラーが出てアップデートできませんでした。

インストールの途中ぐらいで以下の画像のようなエラーが出てしまいます。

エラーログには Azure Functions のツールをアンインストールしろと書いてますが、Visual Studio Installer からアンインストールすると Azure 開発周りもごっそり削除する割に、結局直らなかったので最悪です。

どうしようもないので Visual Studio Gallery の Q&A を見たら、同じ状況の人と回答を発見しました。割と前から発生していたようで、GitHub の Issue が割と伸びていました。

正直この Issue も解決したのかしてないのかわからない感じですが、エラーログからアンインストール出来ない拡張のパスを拾ってきて、そのディレクトリを削除するとアップデート出来るようになりました。

以下のようにディレクトリ名はランダムなので、エラーログから間違えないように拾ってきます。

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

削除後には Visual Studio を起動して、機能拡張のアップデートを確認すると良いです。そこでアップデートか、もしくはインストールを実行すれば最新版が正しく入りました。

少しイレギュラーな対応をしてしまったので、動作に問題がないか少し不安でしたが、ちゃんと .NET Core 向けの Azure Functions が作れるようになっていたので問題なさそうです。

3 回ぐらい Azure 開発周りの再インストールを繰り返してしまったので、忘れないように残します。

Surface Book 2 でもアップデートを試しましたが、こっちはすんなりとアップデートが行えたので、特定のバージョンが入っている環境でのみ発生する問題のようでした。