Azure Storage に保存したブロブのバックアップを高速化

先日、「Azure Storageに保存したブロブをバックアップ専用ストレージアカウントにコピーする」プログラムを書いたが、素直に実装したため激しく遅かった。並列化すらしていないから当然か。

tnakamura.hatenablog.com

そこで、『Azure Storage Data Movement Library for .Net』というライブラリを使って高速化を試みた。 Microsoft 公式の AzCopy もこいつを使っているらしい。

github.com

ディレクトリをコピーするメソッドが提供されていたので、再起呼び出しが不要になってコードがすっきりした。

using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using Microsoft.WindowsAzure.Storage.DataMovement;
using System;
using System.Threading.Tasks;

namespace AzureStorageBackup
{
    class Program
    {
        static void Main(string[] args)
        {
            if (args.Length != 3)
            {
                Console.WriteLine("AzureStorageBackup <srcConnectionString> <destConnectionString> <containerName>");
                return;
            }

            MainAsync(args).GetAwaiter().GetResult();
        }

        static async Task MainAsync(string[] args)
        {
            var startedAt = DateTimeOffset.UtcNow;

            var backuper = new Backuper(args[0], args[1], args[2]);

            await backuper.BackupAsync();

            await backuper.EraseAsync();

            var timeSpan = DateTimeOffset.UtcNow - startedAt;
            Console.WriteLine($"TimeSpan = {timeSpan}");
        }
    }

    class Backuper
    {
        CloudStorageAccount SrcAccount { get; }

        CloudStorageAccount DestAccount { get; }

        CloudBlobClient SrcBlobClient { get; }

        CloudBlobClient DestBlobClient { get; }

        string ContainerName { get; }

        public Backuper(string srcConnectionString, string destConnectionString, string containerName)
        {
            SrcAccount = CloudStorageAccount.Parse(srcConnectionString);
            DestAccount = CloudStorageAccount.Parse(destConnectionString);
            ContainerName = containerName;
            SrcBlobClient = SrcAccount.CreateCloudBlobClient();
            DestBlobClient = DestAccount.CreateCloudBlobClient();
        }

        public async Task BackupAsync()
        {
            // コピー元のコンテナが存在しなければ何もせずに即終了
            var srcContainer = SrcBlobClient.GetContainerReference(ContainerName);
            if (await srcContainer.ExistsAsync() == false)
            {
                return;
            }

            // コピー先のストレージアカウントがコピー元のコンテナにアクセスできるようにするために、
            // SAS を発行する
            var blobToken = srcContainer.GetSharedAccessSignature(new SharedAccessBlobPolicy()
            {
                Permissions = SharedAccessBlobPermissions.Read,
                SharedAccessStartTime = DateTimeOffset.UtcNow,
                SharedAccessExpiryTime = DateTimeOffset.UtcNow.AddDays(1),
            });

            // コピー先のコンテナを作成
            var destContainer = DestBlobClient.GetContainerReference(ContainerName + DateTime.UtcNow.ToString("yyyyMMddhhmmss"));
            await destContainer.CreateIfNotExistsAsync();

            // すべてのブロブをコピーする
            await BackupContainerAsync(srcContainer, destContainer, blobToken);
        }

        async Task BackupContainerAsync(CloudBlobContainer srcContainer, CloudBlobContainer destContainer, string blobToken)
        {
            var continuationToken = new BlobContinuationToken();
            while (continuationToken != null)
            {
                // ページングされているので、続きを取得するには
                // ContinuationToken を指定する必要がある
                var response = await srcContainer.ListBlobsSegmentedAsync(continuationToken);
                continuationToken = response.ContinuationToken;

                foreach (var item in response.Results)
                {
                    if (item is CloudPageBlob pageBlob)
                    {
                        var destBlob = destContainer.GetPageBlobReference(pageBlob.Name);
                        await destBlob.StartCopyAsync(new Uri(item.Uri.AbsoluteUri + blobToken));
                    }
                    else if (item is CloudBlockBlob blockBlob)
                    {
                        var destBlob = destContainer.GetBlockBlobReference(blockBlob.Name);
                        await destBlob.StartCopyAsync(new Uri(item.Uri.AbsoluteUri + blobToken));
                    }
                    else if (item is CloudBlobDirectory directory)
                    {
                        // Data Movement Library を使ってディレクトリごとコピー
                        var destDirectory = destContainer.GetDirectoryReference(directory.Prefix);
                        var status = await TransferManager.CopyDirectoryAsync(
                            sourceBlobDir: directory,
                            destBlobDir: destDirectory,
                            isServiceCopy: true,
                            options: new CopyDirectoryOptions()
                            {
                                Recursive = true,
                            },
                            context: new DirectoryTransferContext());
                    }
                    Console.WriteLine($"COPY {item.Uri}");
                }
            }
        }

        public async Task EraseAsync()
        {
            var aWeekAgo = DateTimeOffset.UtcNow.AddDays(-7);
            var continuationToken = new BlobContinuationToken();
            while (continuationToken != null)
            {
                var response = await DestBlobClient.ListContainersSegmentedAsync(
                    $"{ContainerName}",
                    continuationToken);
                continuationToken = response.ContinuationToken;

                foreach (var container in response.Results)
                {
                    // 7日前までのバックアップを保持する
                    // 7日より前は削除
                    if (container.Properties.LastModified < aWeekAgo)
                    {
                        await container.DeleteIfExistsAsync();
                    }
                }
            }
        }
    }
}

これでも、60万ファイルのコピーで5時間弱かかった。さすがにこれ以上の高速化は難しいか。 フルバックアップは週1回くらいにしておき毎日増分バックアップを取る、 といった別アプローチを検討したほうがよさそうだ。

Azure Storage に保存したブロブのバックアップ

仮想マシンのイメージなんかを Azure Storage にバックアップする記事は探せばすぐ見つかるけど、 Azure Storage に保存しているブロブを別のどこかにバックアップする記事は見つけることができなかったので、 Microsoft.WindowsAzure.Storage を使ってバックアップ用のプログラムを書いてみた。 バックアップ用のストレージアカウントを用意しておいて、 ストレージアカウントをまたいでコンテナの中身をコピーする愚直な方法。

using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Blob;
using System;
using System.Threading.Tasks;

namespace AzureStorageBackup
{
    class Program
    {
        static void Main(string[] args)
        {
            if (args.Length != 3)
            {
                Console.WriteLine("AzureStorageBackup <srcConnectionString> <destConnectionString> <containerName>");
                return;
            }

            MainAsync(args).GetAwaiter().GetResult();
        }

        static async Task MainAsync(string[] args)
        {
            var backuper = new Backuper(args[0], args[1], args[2]);
            await backuper.BackupAsync();
            await backuper.EraseAsync();
        }
    }

    class Backuper
    {
        CloudStorageAccount SrcAccount { get; }

        CloudStorageAccount DestAccount { get; }

        CloudBlobClient SrcBlobClient { get; }

        CloudBlobClient DestBlobClient { get; }

        string ContainerName { get; }

        public Backuper(string srcConnectionString, string destConnectionString, string containerName)
        {
            SrcAccount = CloudStorageAccount.Parse(srcConnectionString);
            DestAccount = CloudStorageAccount.Parse(destConnectionString);
            ContainerName = containerName;
            SrcBlobClient = SrcAccount.CreateCloudBlobClient();
            DestBlobClient = DestAccount.CreateCloudBlobClient();
        }

        public async Task BackupAsync()
        {
            // コピー元のコンテナが存在しなければ何もせずに即終了
            var srcContainer = SrcBlobClient.GetContainerReference(ContainerName);
            if (await srcContainer.ExistsAsync() == false)
            {
                return;
            }

            // コピー先のストレージアカウントがコピー元のコンテナにアクセスできるようにするために、
            // SAS を発行する
            var blobToken = srcContainer.GetSharedAccessSignature(new SharedAccessBlobPolicy()
            {
                Permissions = SharedAccessBlobPermissions.Read,
                SharedAccessStartTime = DateTimeOffset.UtcNow,
                SharedAccessExpiryTime = DateTimeOffset.UtcNow.AddDays(1),
            });

            // コピー先のコンテナを作成
            var destContainer = DestBlobClient.GetContainerReference(ContainerName + DateTime.UtcNow.ToString("yyyyMMddhhmmss"));
            await destContainer.CreateIfNotExistsAsync();

            // すべてのブロブをコピーする
            await BackupContainerAsync(srcContainer, destContainer, blobToken);
        }

        async Task BackupContainerAsync(CloudBlobContainer srcContainer, CloudBlobContainer destContainer, string blobToken)
        {
            var continuationToken = new BlobContinuationToken();
            while (continuationToken != null)
            {
                // ページングされているので、続きを取得するには
                // ContinuationToken を指定する必要がある
                var response = await srcContainer.ListBlobsSegmentedAsync(continuationToken);
                continuationToken = response.ContinuationToken;

                foreach (var item in response.Results)
                {
                    CloudBlob destBlob;
                    if (item is CloudPageBlob pageBlob)
                    {
                        destBlob = destContainer.GetPageBlobReference(pageBlob.Name);
                    }
                    else if (item is CloudBlockBlob blockBlob)
                    {
                        destBlob = destContainer.GetBlockBlobReference(blockBlob.Name);
                    }
                    else if (item is CloudBlobDirectory directory)
                    {
                        await BackupDirectoryAsync(directory, destContainer, blobToken);
                        continue;
                    }
                    else
                    {
                        continue;
                    }
                    await destBlob.StartCopyAsync(new Uri(item.Uri.AbsoluteUri + blobToken));
                    Console.WriteLine($"COPY {item.Uri}");
                }
            }
        }

        async Task BackupDirectoryAsync(CloudBlobDirectory srcDirectory, CloudBlobContainer destContainer, string blobToken)
        {
            var continuationToken = new BlobContinuationToken();
            while (continuationToken != null)
            {
                // ページングされているので、続きを取得するには
                // ContinuationToken を指定する必要がある
                var response = await srcDirectory.ListBlobsSegmentedAsync(continuationToken);
                continuationToken = response.ContinuationToken;

                foreach (var item in response.Results)
                {
                    CloudBlob destBlob;
                    if (item is CloudPageBlob pageBlob)
                    {
                        destBlob = destContainer.GetPageBlobReference(pageBlob.Name);
                    }
                    else if (item is CloudBlockBlob blockBlob)
                    {
                        destBlob = destContainer.GetBlockBlobReference(blockBlob.Name);
                    }
                    else if (item is CloudBlobDirectory directory)
                    {
                        await BackupDirectoryAsync(directory, destContainer, blobToken);
                        continue;
                    }
                    else
                    {
                        continue;
                    }
                    await destBlob.StartCopyAsync(new Uri(item.Uri.AbsoluteUri + blobToken));
                    Console.WriteLine($"COPY {item.Uri}");
                }
            }
        }

        public async Task EraseAsync()
        {
            var aWeekAgo = DateTimeOffset.UtcNow.AddDays(-7);
            var continuationToken = new BlobContinuationToken();
            while (continuationToken != null)
            {
                var response = await DestBlobClient.ListContainersSegmentedAsync(
                    $"{ContainerName}",
                    continuationToken);
                continuationToken = response.ContinuationToken;

                foreach (var container in response.Results)
                {
                    // 7日前までのバックアップを保持する
                    // 7日より前は削除
                    if (container.Properties.LastModified < aWeekAgo)
                    {
                        await container.DeleteIfExistsAsync();
                    }
                }
            }
        }
    }
}

当たり前だけど、コンテナ内のブロブが増えれば増えるほど時間がかかる。 Microsoft.WindowsAzure.Storage は内部で REST API を呼び出しているみたいだから、 ただでさえ速いとは言い難いのに。 並列化するか、他のアプローチを考えないといけないかもな。

『アオアシ(11)』を読んだ

武蔵野戦の後半で葦人が覚醒して無双し、ついにAチームへ昇進することに。化け物揃いのAチームに上がって、物語がどうなるのか楽しみ過ぎる。まぁ、すぐにどデカイ壁にぶち当たる気はするが。葦人はどんな選手になるんだろう。予想としては、元ドイツ代表のラームみたいになるんじゃないかと思ってる。

あと 11 巻でも、ダイアゴナル・ランや チャレンジ・アンド・カバーといった、新しいサッカーの知識を得ることができた。読んでいて、少しずつだがサッカーに詳しくなってきた気がするし、試合の見方が変わってきたかも。来年のワールドカップは、今までと違った楽しみ方ができるかもしれない。

Azure Storage を使っていて同時接続数でハマった

Azure Storage に添付ファイルをアップロードする処理を持つ ASP.NET Core MVC(.NET Core) アプリを、 Azure App Service にデプロイしてベンチマークをとってみたら、 スケールアウトやスケールアップしても思ったようにパフォーマンスが上がらなくて、 ボトルネックを調べることになった。 モニターでメトリクスを確認してみたが、App Service Plan の CPU パーセンテージは 30% 程度だし、 メモリも 40% でほぼ一定。SQL Database も使ってはいるが、DTU は 50% 未満でまだ余裕がある。 なのに遅い。

モニタを眺めていてもボトルネックが分からなかったので、Application Insights と、その Profiler を導入してみた。 Application Insights の Profiler でホットパスを確認したところ、AWAIT_TIME で結構時間を食っていた。 AWAIT_TIME は Task を await してから復帰するまでの時間を表しているらしい。 で、肝心の await しているものが何かというと、Azure Storage へのファイルのアップロード。 Azure Storage には 1 ストレージアカウントの IOPS に上限があるので、最初これに引っかかっているのかと思った。 ただ、App Service をスケールアウトするとパフォーマンスが上がっていくので、これが原因とは考えにくい。

Azure Storage へのアップロードには Microsoft.WindowsAzure.Storage を使っているが、 内部では REST API を呼び出しているはず。 外部への通信が詰まっているのかもと思い、情報を探し回ったら、下記の古い記事を見つけた。

ファイル・ダウンロード時の最大同時接続数を変更するには?[C#、VB] - @IT

ServicePointManager.DefaultConnectionLimit のデフォルトが 2 なのは知っていたが、 『外部からアプリケーションへの接続』ではなく『アプリケーションから外部への接続』だったのか。 勘違いしていた。 もし、.NET Core にもこの設定があって、デフォルト値が 2 のままなら、こいつが怪しい。

試しに、下記のように同時接続数の上限を変更してデプロイし直してみた。

// ASP.NET Core アプリケーションから外部のサービスを呼び出すときの
// 同時接続数を増やす。
ServicePointManager.DefaultConnectionLimit = int.MaxValue;
ServicePointManager.Expect100Continue = false;

ベンチマークをとったところ、40% 以上速くなった。 外部サービスへの通信が詰まっていたのが原因だったようだ。 これでめでたしめでたし…かと思いきや、また新たなボトルネックに遭遇。 調査はまだ続くみたいだ。とほほ。

『WEB+DB PRESS Vol.101』を読んだ

iOS 11 最前線

新たに追加された機械学習と AR のフレームワークは、どちらも面白そう。特に AR の主戦場は当分モバイルだと思うので、これから AR を駆使したどんなアプリが出てくるのか期待。自分でも何か作りたいが、残念なことにアイデアが無い。 Swift と Xcode は順当に進化してきている印象。クライアントとサーバー両方とも Swift で開発するのは夢があるが、つい先日クロスプラットフォーム対応を発表した Kotlin の方が現実的かも。あちらはサーバーサイドで Java の資産も使えるから強い。

Java 9 集中講座

ついに導入された Java のモジュールシステムについての記事は、すごく丁寧に解説してあって、SpringBoot を嗜んだ程度の Java 力の自分でも、なんとか読み進めることができた。理解できたとは言えないけど。主にフレームワークで使われると思うので、Spring あたりが対応したらソースコードを読みたいところ。

現場で使う Slack

Slack の使い方が、簡単なものから高度なものまで満遍なく紹介されていて、Slack 導入マニュアルとして保存しておきたいレベル。それにしても、Slack は結構多機能になったな。シンプルであり続けるのは難しいか。基本機能が無料で使えるようになったのは知らなかった。職場の開発マシンはインターネットから隔離されているので、仕事で Slack を使うことはまず無いけど、プライベートプロジェクトで試してみたいと思った。

継続は力なり【第9回】ログのすすめ

自分の場合、ログ(というかメモ)はGFM のタスクリスト形式で紙やテキストファイルに書いている。脳のワーキングメモリの容量が少ないので、何をどこまでやったか忘れることがまぁまぁあり、人並みに仕事をするためには記録は必須。ログを相談相手と考えたことはさすがになかったので、新しい視点だった。

どんとこい! フロントエンド開発【第3回】Vue.jsでお手軽UI構築

昨年から盛り返してきた Vue.js の紹介だけでなく、状態を管理する vuex、ルーティングを実現する vue-router といった周辺ライブラリについても触れていて、期待以上の内容だった。Vue.js はもう、React や Angular に並ぶ存在にまでなったし、むしろ Angular よりも勢いある。Vue.js は小さく始めることができるのが、個人的に一番気に入っている点。Angular も 1 .x の頃はまだ導入しやすかったんだけどねぇ。

WEB+DB PRESS Vol.101

WEB+DB PRESS Vol.101

郷家の辛ねぎらーめん

お昼無性にラーメンが食べたくなって、 「そういえば郷家の辛ねぎらーめん食べたことないな」と思ったので、 食べに行ってきた。

魚介豚骨の旨味と、辛ねぎのスパイシーさが絶妙にマッチしていて美味。 麺も細すぎずちょうどいい太さで、ツルシコで良かった。 さすが、一番人気なだけある。 支那そばと担々麺も気になるので、 近いうちにまた訪れることになりそうな予感。

.NET Core 時代のコマンドライン引数解析

.NET でコンソールアプリケーションを開発するときの悩みの種が、コマンドライン引数の解析。 .NET Frameworkコマンドラインパーサーを提供してくれないので、 仕方なくオレオレパーサーを書いたり、Mono にあるライブラリを使ったりしてきたけど、 .NET Core 時代になってようやく Microsoft がライブラリを提供してくれた。

www.nuget.org

cURL みたいな、サブコマンドを持たない CLI を書くならこんな感じになる。

using System;
using Microsoft.Extensions.CommandLineUtils;

namespace SampleCli
{
    class Program
    {
        static void Main(string[] args)
        {
            var app = new CommandLineApplication(throwOnUnexpectedArg: false);

            app.Name = nameof(SampleCli);
            app.Description = "cURL みたいな単機能 CLI";
            app.HelpOption("-h|--help");

            var methodOption = app.Option(
                template: "--method",
                description: "HTTP メソッド",
                optionType: CommandOptionType.SingleValue);

            var urlArgument = app.Argument(
                name: "url",
                description: "URL",
                multipleValues: false);

            app.OnExecute(() =>
            {
                if (urlArgument.Value == null)
                {
                    app.ShowHelp();
                    return 1;
                }

                var method = "GET";
                if (methodOption.HasValue())
                {
                    method = methodOption.Value();
                }

                Console.WriteLine($"{method} {urlArgument.Value}");
                return 0;
            });

            app.Execute(args);
        }
    }
}

Command メソッドを使えばサブコマンドも追加できるので、Git みたいな多機能な CLI も夢じゃない。

using System;
using Microsoft.Extensions.CommandLineUtils;

namespace SampleCli
{
    class Program
    {
        static int Main(string[] args)
        {
            var app = new CommandLineApplication(throwOnUnexpectedArg: false);

            app.Name = nameof(SampleCli);
            app.Description = "Git みたいな多機能 CLI";
            app.HelpOption("-h|--help");

            app.Command("clone", command =>
            {
                command.Description = "リポジトリをクローンします。";
                command.HelpOption("-h|--help");

                var urlArgument = command.Argument("url", "リポジトリの URL");

                command.OnExecute(() =>
                {
                    if (urlArgument.Value == null)
                    {
                        command.ShowHelp();
                        return 1;
                    }

                    Console.WriteLine($"clone {urlArgument.Value}");
                    return 0;
                });
            });

            app.Command("remote", remoteCommand =>
            {
                remoteCommand.Description = "リモート一覧を表示します。";
                remoteCommand.HelpOption("-h|--help");

                remoteCommand.Command("add", addCommand =>
                {
                    addCommand.Description = "リモートを追加します。";
                    addCommand.HelpOption("-h|--help");

                    var nameArgument = addCommand.Argument("name", "リモートの名前");
                    var urlArgument = addCommand.Argument("url", "リポジトリの URL");

                    addCommand.OnExecute(() =>
                    {
                        if (nameArgument.Value == null || urlArgument.Value == null)
                        {
                            addCommand.ShowHelp();
                            return 1;
                        }

                        Console.WriteLine($"remote add {nameArgument.Value} {urlArgument.Value}");
                        return 0;
                    });
                });

                remoteCommand.OnExecute(() =>
                {
                    Console.WriteLine("origin");
                    Console.WriteLine("staging");
                    Console.WriteLine("production");
                    return 0;
                });
            });

            return app.Execute(args);
        }
    }
}

サブコマンドにも対応できるくらいに高機能。自分のユースケースは全て満たしているので、もうこればかり使っている。オレオレパーサーは窓から投げ捨てた。