Azure Blob Storage のBlobファイルが消し飛んで復旧した話とそこから得た教訓

こんにちは、GxPの平山です。
この記事は[グロースエクスパートナーズ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/gxp)の4日目です。

今回はAzure環境で起きた事故と、そこから得た学び（教訓）についてお話ししたいと思います。

## 概要

- Azure Blob StorageでBlobファイルが消し飛んで、本番環境で障害が発生した
- 調査の結果、ストレージアカウントのライフサイクル管理機能が有効になっており、指定日数経過後に即時削除される設定になっていた
- BLOB の論理的な削除機能が有効になっていたおかげで、削除されたファイルが復旧でき、事なきを得た
- 復旧作業で Microsoftさんのドキュメントがとても役に立った（ありがとうMicrosoftさん）

## （前置き）システム構成
とある業務システムにて、ファイル自体をAzure Blob Storageで、ファイル名や拡張子などのメタデータはDB上で管理しており、以下のような流れでファイルをダウンロードする機能をユーザーに提供していました。

1. クライアントから要求を受けたWebAPIが、DBからダウンロードするファイルの情報を取得
1. ストレージアカウントからBlobの[SAS](https://docs.microsoft.com/ja-jp/azure/storage/common/storage-sas-overview)（一時トークン）を取得
1. 1、2 で取得した情報をもとに、ダウンロード用のURLを作成し、URLを返却します。
1. 返却されたダウンロードURLを元に、ユーザーが（実際にはユーザーが操作する画面が）Blob Storageからファイルをダウンロード

図にすると以下のような感じです。
※実際の構成の一部のみ公開しています。ネットワークコンポーネント等、本主題に関係のない部分は省略しています
![system_image2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/93be05e3-d750-87d5-b9f0-140f553efeda.png)


## （ここから本題）何が起きたのか？
本番環境のリリースから数カ月、大きなトラブルもなく運用できていたところ、突如としてファイルがダウンロードできないといった問い合わせが複数上がりました。
調査の結果、いくつかのBlobコンテナ内のBlobファイルが存在しないことが発覚しました。

障害発生当初は、Blobコンテナへのファイル配置側に問題があったのでは？と疑ったものの、エラーログ等の痕跡はなく、これまで同様の問い合わせがなかったため、はっきりとしたことはわかりませんでした
何らかの作業を行った覚えもなく、原因がつかめないまま迎えた翌日、確かにあったはずのファイルが無くなっており、これは何やらきな臭いことが起きているな…。
と、環境周りの設定を見直してみたところ…。

――――はい、原因が特定できました。

## なぜ消えたのか
ストレージアカウントの「[ライフサイクル管理](https://docs.microsoft.com/ja-jp/azure/storage/blobs/lifecycle-management-overview)」機能で、90日間変更のなかったBlobファイルが自動的に削除される設定になっていました。
↓実際に設定されていた内容
![スクリーンショット 2021-11-08 200456.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/8a942557-7dec-7072-da5a-4fb81592aaa9.png)

ストレージアカウントでライフサイクル設定がなされていることは、関係者全員把握できておりませんでした…。
詳細を確認したところ、使用したARMテンプレートがそのように構築する内容になっていたようで、意図した設定ではなかったことがわかりました。

ちなみに、システムの仕様上、同名のBlobファイルは配置後に更新されることはないため、上記設定は完全にアウトです。
こちらの設定を無効とすることで、ひとまず原因自体を取り除くことはでました。


## 復旧作業
原因が取り除けたところで、復旧作業に取り掛かりました。
幸いにも、Blob Storageの論理削除機能、およびバージョン管理機能が有効になっており、削除前のバージョンが確認できたため、そこから復旧することとなりました。
[公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/storage/blobs/soft-delete-blob-manage?tabs=dotnet)にまさしく求めていた情報があったので、これを頼りにリカバリ用のプログラムを実装しました。

以下にリカバリ用プログラムのソースコードと、独自に追加、修正した部分について紹介しておきます。

````csharp:program.cs
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace restore_blob
{
    public static class Program
    {
        // 実行方法：dotnet run {inputFilePath} {logFilePath} {StorageAccountConnectionString}
        public static void Main(string[] args)
        {
            const string connectionString = args[2];
            var dicRestoreTarget = new Dictionary<string, List<string>>();
            string line;

            using var reader = new StreamReader(args[0]);
            while ((line = reader.ReadLine()) != null)
            {
                var items = line.Split(",");
                if (!dicRestoreTarget.ContainsKey(items[0]))
                {
                    var list = new List<string>();
                    dicRestoreTarget.Add(items[0], list);
                }
                dicRestoreTarget[items[0]].Add(items[1]);
            }

            using var logFile = new StreamWriter(args[1], true, System.Text.Encoding.GetEncoding("UTF-8"));
            Console.SetOut(logFile);
            foreach (var containerName in dicRestoreTarget.Keys)
            {
                var container = new BlobContainerClient(connectionString, containerName);
                foreach (var blobName in dicRestoreTarget[containerName])
                {
                    var blockBlob = container.GetBlobClient(blobName);
                    try
                    {
                        // MS公式ドキュメントここから

                        // List blobs in this container that match prefix.
                        // Include versions in listing.
                        Pageable<BlobItem> blobItems = container.GetBlobs
                                        (BlobTraits.None, BlobStates.Version, prefix: blockBlob.Name);

                        // Get the URI for the most recent version.
                        BlobUriBuilder blobVersionUri = new BlobUriBuilder(blockBlob.Uri)
                        {
                            VersionId = blobItems
                                        .OrderByDescending(version => version.VersionId)
                                        .ElementAtOrDefault(0)?.VersionId
                        };

                        // Restore the most recently generated version by copying it to the base blob.
                        Console.WriteLine($"Restore Started: container:{containerName}, blob:{blobName}");
                        var result = blockBlob.StartCopyFromUri(blobVersionUri.ToUri());
                        Console.WriteLine($"Restore Finished: container:{containerName}, blob:{blobName}, operationId:{result.Id}, HasCompleted:{result.HasCompleted}");

                        // MS公式ドキュメントここまで

                    }
                    catch (Exception e)
                    {
                        Console.WriteLine($"Restore Failed: container:{containerName}, blob:{blobName}");
                        Console.WriteLine(e.Message);
                        Console.WriteLine(e.StackTrace);
                    }
                }
            }
        }
    }
}

```

####独自に追加、修正した部分
- コンソールアプリとして起動できるようにしました。
    - dotnet run {inputFilePath} {logFilePath} {StorageAccountConnectionString}
- 複数のコンテナを対象に、Blobファイルを一括でリカバリできるようにしました
    - 以下のようなフォーマットのCSVファイルを入力として想定しています（{inputFilePath}にファイルパスを指定）。

```csv
BlobContainerName1,BlobName1
BlobContainerName1,BlobName2
BlobContainerName1,BlobName3
BlobContainerName2,BlobName4
BlobContainerName3,BlobName5
…
BlobContainerNameN,BlobNameM
```

- （かなりおおざっぱですが…）例外処理、ログ機能入れました
- Blobファイルの取得したバージョンリストから最新バージョンを取得するようにしました（公式はなぜか1つ前のバージョンを取得するよう実装されていてうまくいきませんでした…）

```diff_c_sharp
// Get the URI for the most recent version.
BlobUriBuilder blobVersionUri = new BlobUriBuilder(blockBlob.Uri)
{
    VersionId = blobItems
                .OrderByDescending(version => version.VersionId)
-                 .ElementAtOrDefault(1)?.VersionId // MS公式ドキュメントの実装
+                 .ElementAtOrDefault(0)?.VersionId // 修正結果
};
```

## 補足
今回の環境では、論理削除機能の有効化だけでなく、バージョン機能も有効化されていたため、[公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/storage/blobs/soft-delete-blob-manage?tabs=dotnet)に記載のPowershell、あるいはAzure CLI の復旧方法はうまくいきませんでした（初めはそっちが楽そうだったので試してみたところ、バージョン機能が有効化されていると、[削除の取り消し]操作自体が機能しなくなるようです…削除前のバージョンに戻して、ということですかね）

論理削除機能だけの場合は、[az storage blob undelete](https://docs.microsoft.com/ja-jp/cli/azure/storage/blob?view=azure-cli-latest#az_storage_blob_undelete)とかでできそうです（コマンドの量産はお好みの方法で）。


## 今回の件で得た教訓
というわけで、なんとか復旧を成し遂げて事なきを得ましたが、また同じような思いをしないよう、今回の経験から得た教訓を書いて、

### 論理削除機能、バージョン管理機能は有効化しておこう
- これがあったおかげで復旧できた…本番では**なるべく有効化しておきましょう**
- 論理削除機能、バージョン管理機能を使用する場合は課金にご注意ください

論理削除機能に関する課金について[公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/storage/blobs/soft-delete-blob-overview#pricing-and-billing)より引用
> 論理的に削除されたデータはすべて、アクティブなデータと同じレートで課金されます。

バージョン管理機能に関する課金について[公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/storage/blobs/versioning-overview#pricing-and-billing)より引用
> BLOB のスナップショットと同様に、BLOB のバージョンは、アクティブなデータと同じレートで課金されます。 バージョンの課金方法は、ベース BLOB またはそのいずれかのバージョン (またはスナップショット) のどちらに対して層を明示的に設定したかによって異なります。


### 環境についてはちゃんと理解しておこう
- 作業担当範囲でなくても、インフラ設定は一度目を通しておこう
    - ライフサイクル設定のことが分かってたら未然に防げた。一度でも設定を見ておけば…
    - 論理削除機能が有効になっていると知ってたら、もっと早く復旧できた（精神的につらくなかった）。一度でも以下略
- 保守にかかわるなら、少なくともバックアップ設定については抑えておこう（そこからのリカバリ作業のイメージができているとなお良し

### ライフサイクル設定は計画的に
- 指定日時経過後にいきなり削除するのは、明確な意図がなければ個人的にはあまりオススメしない（
- 基本は以下のようにコールドストレージ、アーカイブ、削除と段階的に移すのがよいと思う
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2247089/48e7391b-caf6-d931-2d4e-deb9bd856533.png)
- ただ、今回の件に関しては、障害対策の点で効果があったかどうかは微妙ですが…
- 余談ですが、クール層、アーカイブ層を利用する場合は、早期削除ペナルティといったものもあるようなので注意が必要です
    - https://docs.microsoft.com/ja-jp/azure/storage/blobs/access-tiers-overview

### IaCは本番デプロイを見据えて設計しよう
- 今回のライフサイクル設定がどのような意図だったかはわからないものの、開発と本番で同様のテンプレートを使用する場合は、本番を想定した設計にするのがよいかと思いました。
- コストの削減などの理由で一部の内容を変更（スペックを落とすとか）したい場合には、本番向けのテンプレートを開発用にメンテしたり、あるいは、パラメータで制御するようにした方がよいかと思いました。

## あとがき
というわけで、ほんとにあった怖い本番トラブル対応のお話でした。
AzureでBlob Storageを利用されている方は、一度手元の環境設定を見直してみてはいかがでしょうか？
論理削除機能が無効であれば、有効にすることをオススメします。

## （余談）いまだ残る謎
- 別のストレージアカウントにも同様のライフサイクル設定がなされていたが、何故か古いファイルが削除されていなかった
- MS公式ドキュメントでなぜ.ElementAtOrDefault(**1**)?.VersionId にしていたか（ElementAtOrDefault(**0**)には削除した結果がくる想定だった…？）