しばやん雑記

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

Azure Functions Consumption と SQL Database Serverless の組み合わせは相性が良い

プレビュー中の SQL Database Serverless は設定した時間アイドルが続くと、自動的に停止するように構成できます。Serverless Tier の説明はドキュメントや SE の雑記を見てください。

アイドルが続くと停止してしまいますが、新しく接続したタイミングで復帰するようになっているので、クライアント側でのリトライを適切に実装していれば低コストに利用出来ます。

この挙動は Azure Functions の Consumption と組み合わせるのが相性が良さそうだったので、久し振りに SQL Database を作成して試してみました。

作成時に vCore ベースを選ぶと Serverless の設定が出てくるので、vCore とメモリを設定します。今回はテスト目的だったので、設定はほぼデフォルトのままにしました。自動停止は最低の 6 時間にしてあります。

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

作成後 6 時間アイドルが続くと、自動的に一時停止状態になります。6 時間はムッシュが言ってるように長すぎるので、Aurora Serverless に対抗できるぐらいにして欲しいですね。

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

後はアプリケーション側で SQL Database への接続をリトライする処理を組み込めば良いですが、いくつか方法があると思います。例えば Polly を使って実装したり、ORM 組み込みのリトライなどがあります。

最近の SqlConnection に組み込まれているリトライ機能は、コマンド実行時のリトライになり、初回コネクションの失敗時には使えないらしいので注意したいところです。

Polly を使ってリトライを行う

ドキュメントによると中断時にはエラーコード 40613 を返すようになっているらしいので、Polly を使って以下のようなリトライポリシーを用意して試してみました。

実際に使う場合は SqlConnection の Open だけリトライだけにして、コマンド実行時のリトライは SqlConnection に組み込みの機能を使った方が良いかもしれません。

await Policy.Handle<SqlException>(ex => ex.Number == 40613)
            .WaitAndRetryAsync(10, x => TimeSpan.FromSeconds(10))
            .ExecuteAsync(async () =>
            {
                using (var sqlConnection = new SqlConnection(Environment.GetEnvironmentVariable("DefaultSqlConnection")))
                {
                    await sqlConnection.OpenAsync();

                    // SqlConnection を使って何かする
                }
            });

SQL Database Serverless が一時停止後に上のコードを組み込んだ Function を実行しても、リトライが Polly によって行われるために時間はかかりますが失敗とはなりません。

Application Insights の E2E Transaction を見ると、リトライが行われていることが確認できます。

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

初回ログイン時にエラーを返すまでに多少時間がかかるようなので、リトライ時のウェイトは 5 秒ぐらいにしておいた方が、全体としての待機時間を少なく出来そうです。

ちなみに今回の例では Exponential Backoff は向いていないので、固定値の方が良いでしょう。

Entity Framework Core でリトライポリシーを設定

Entity Framework Core には組み込みでリトライ機能が用意されているので、オプションを設定するだけで良い感じに処理してくれます。

ドキュメントも用意されているので、安全に SQL Database Serverless を利用できます。

有効にするには EnableRetryOnFailure を呼び出せばよいので、非常に簡単です。

SQL Database Serverless は復帰するまでに 1 分ほど必要とドキュメントに書かれているので、リトライ回数が少ないと上手く動作しないこともあるので気を付けましょう。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(Environment.GetEnvironmentVariable("DefaultSqlConnection"), sqlOptions => 
                sqlOptions.EnableRetryOnFailure(10, TimeSpan.FromSeconds(10), null)));
    }
}

Azure Functions で Entity Framework Core を利用する場合は、DI 経由で DbContext のインスタンス管理を行った方が楽です。詳しくは以前書いたエントリを参照してください。

作成したアプリを Azure Functions にデプロイして実行すると、SQL Database が自動的に再開されます。

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

Monitor から実行履歴を確認すると、時間はかかっていますが処理自体は成功しています。

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

更に Application Insights から該当の E2E Transaction を確認すると、何回かは SQL Database への接続に失敗していますが、最終的には成功していることが確認できます。

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

Consumption でのデフォルト functionTimeout は 5 分なので、ほとんどのケースでは SQL Database Serverless の復帰にかかる時間は無視できると思います。

Function 自体の処理に長い時間かかる場合は、適宜 functionTimeout を調整すれば良さそうです。

Startup でウォームアップを行う

SQL Database Serverless を使ってコスト削減が可能なのは、決まった時間でバッチ的に処理を行って、それ以外はほぼアクセスされないというようなケースでしょう。

そういう場合、Azure Functions では TimerTrigger を使うことになると思いますが、Azure Functions の Scale Controller は TimerTrigger で設定した時間より少し前にホストを起動する仕組みになっています。

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

この挙動を利用すると、ホスト起動のタイミングで SQL Database Serverless を自動的に再開させて、Function 本体では再開済みの状態で処理で行うことが出来そうです。

ログインだけ行えば自動で再開されるので、以下のように Open だけ行う処理を追加しました。

public class Startup : FunctionsStartup
{
    public override void Configure(IFunctionsHostBuilder builder)
    {
        builder.Services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(Environment.GetEnvironmentVariable("DefaultSqlConnection"), sqlOptions => 
                sqlOptions.EnableRetryOnFailure(10, TimeSpan.FromSeconds(10), null)));

        // SQL Database へのログインだけ実行する(失敗しても良い)
        _ = SqlDatabaseWarmupAsync();
    }

    private async Task SqlDatabaseWarmupAsync()
    {
        try
        {
            using (var sqlConnection = new SqlConnection(Environment.GetEnvironmentVariable("DefaultSqlConnection")))
            {
                await sqlConnection.OpenAsync().ConfigureAwait(false);
            }
        }
        catch
        {
            // ignored
        }
    }
}

停止状態であれば間違いなく失敗するので、それが原因で落ちないようにだけしておきます。処理自体を投げっぱなしにして、ホストの初期化が遅れないようにもしておきます。

これでデプロイすると TimerTrigger が発動する前にホストが起動され、SQL Database Serverless が非同期で再開されます。これで実際に処理が行われる前には SQL Database は再開されているので、E2E Transaction でもエラーが発生していないことが確認できます。

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

当然ながら全体の処理時間も短縮されています。もちろん 6 時間アイドルが続けば再度停止状態になりますが、また Function が起動するタイミングで自動的に再開されるようになります。

これで Azure Functions と SQL Database のそれぞれで、本当に必要な時のみ課金が行われる環境を用意できます。常時リソースが確保される場合と比べて、コストは格段に最適化されます。