📦

[Azure][Logic Apps] 異なるストレージ アカウント間でデータを移動する [Storage Account]

2023/12/24に公開

📃 本記事の概要

Azure Logic Apps 上で Blob コンテナのファイル/フォルダを "移動" する方法を、step-by-step で詳細に解説します。
とくに本記事では、異なるストレージ アカウント間での移動をされたい場合を想定し、その方法をご紹介します。

👨‍💻 対象読者

将来的に Azure Logic Apps を利用したい方

  • Azure Logic Apps に興味があるが、まだ触ったことがない方(チュートリアルとして触れてみたい方)
  • Azure を既に使用しており、内部/外部サービスの連携強化を検討している方
  • Azure Logic Apps でのファイル/フォルダの移動方法がわからず困っている方

本記事においては、 Logic Apps はもちろん、それ以外の設定の部分に関しても手順を詳細に解説しております。
"ファイルを移動させる手法を知る" という目的はもちろん、"Azure Logic Apps の基本的な操作方法を学ぶ" こともできるかと思います。

本記事読解のために前提としている知識・経験

  • Azure Logic Apps の基本的な操作については理解している方
  • Azure ストレージ アカウントの基本的な操作については理解している方

初学者の方でも理解できるように、画像を多用して解説しておりますが、 Azure Logic Apps の基本的な操作方法については本記事では解説しておりません。
そのため、もし Logic Apps を一度も操作したことがない/どういったものか把握していない状況うであれば、まずは下記の公式ドキュメントをご一読ください。

Azure Logic Apps の概要

🏭 はじめに

Azure Logic Apps (ロジック アプリ)はスケーラブルな統合プラットフォーム (iPaaS) であり、誰でも簡単に設定・操作することが可能です。
Logic Apps では、Azure 内外のサービスに対して "コネクタ" を用いることで連携が可能となるため、処理フローの中枢を担うことが多くあります。

しかし、コネクタは便利である一方、すべての操作が網羅されているわけではありません。
例えば、 BLOB ストレージのマネージド コネクタは下記公式ドキュメントに記載がある分のみです。

Azure Blob Storage # アクション
Azure Blob Storage # トリガー

上記リンクのコネクタの "アクション" を探してみると、コピー削除 はありますが、移動 については用意されておりません。
代わりに、コピー削除 を組み合わせることで移動処理を模倣することが可能となります。

ここで、もう少し複雑なシチュエーションを考えてみましょう。
たとえば、異なるストレージ アカウント間にある BLOB コンテナのファイルを移動させるにはどのようにすればよいでしょうか。
加えて、単一ファイルではなくフォルダ構造も考慮した複数ファイルの移動を行う場合はいかがでしょうか。
コピー削除 の組み合わせをする場合よりも、少し工夫が必要となります。

そこで本記事では、運用時のシナリオとして頻出の "Logic Apps 上でのデータのやりとり" について、とくに異なるストレージ アカウント間に着目して、データの移動 (Move) 方法をご紹介します。

🎛️ 前提と設定項目

A. 2 つの異なるストレージ アカウント

本記事では、移動元となる Source Storage Account と移動先となる Destination Storage Account という 2 点のストレージ アカウントを想定し、これらの間でファイルを移動します。

B. Logic Apps コネクタとストレージ アカウントのみで操作を完結

Azure でのデータ操作は実にさまざまなオプションが存在しますが、今回は "Logic Apps コネクタ" と "ストレージアカウント" のみで完結させます。

C. 従量課金プランでの実行

Logic Apps には現在、"従量課金プラン"[1] と "Standard プラン"[2] の 2 つがあり、コネクタの種類など違いがいくつか存在します。
今回は前者の "従量課金プラン" を想定して構成します。

D. ストレージ アカウントで "階層型名前空間" を有効化

ストレージ アカウント作成時の設定

こちらは重要なポイントです。
ファイル/フォルダの操作を想定通りのものとするため、"階層型名前空間" を有効化していることを想定します。
なお階層型名前空間を有効にするためには、Azure Data Lake Storage Gen2 としてストレージ アカウントの作成/アップグレードをする必要がございますので、ご注意ください。

階層型名前空間を有効化しない場合は、例えばある BLOB コンテナの subdir 配下のファイルを移動させた時、次のような挙動となります。

コンテナ配下に /container-01/dir/subdir/file.txt をアップロード
file.txt を削除
/container-01 のみになる!

このように、勝手に dir, subdir という仮想フォルダが消えてしまうなど、通常のファイル操作の直感とは異なる操作となってしまいます。
公式ドキュメントにおいても、/ によってディレクトリ階層を近似しているものの、下記のとおりディレクトリの移動、名前の変更、削除などについては支援が提供されていないことが示されています[3]

Atomic directory manipulation: Object stores approximate a directory hierarchy by adopting a convention of embedding slashes (/) in the object name to denote path segments. While this convention works for organizing objects, the convention provides no assistance for actions like moving, renaming or deleting directories. Without real directories, applications must process potentially millions of individual blobs to achieve directory-level tasks. By contrast, a hierarchical namespace processes these tasks by updating a single entry (the parent directory).

E. BLOB コンテナを対象とする

今回は BLOB コンテナに絞って解説します。

F. リソースにてファイアウォール等の設定は行わない

単純化のために、ストレージ アカウントや Logic Apps における、受信 IP アドレスを制限する上記の設定は行わないことを前提とします。

G. Logic Apps では "システム割り当て済み マネージド ID" を有効にする

sys-assigned-managed-id

Logic Apps から Storage Account へ、コネクタ経由での操作を可能とするために、Logic Apps のマネージド ID を有効にします。
Managed identity types

その後、Storage Account 側で適切なロールを割り当てます。
role-assignment-storage-account

ワークフロー内で Logic Apps と Storage Account の接続を行うためには、Blob コネクタで上記のとおり "認証の種類" を [Logic Apps のマネージド ID] と選択します。

logicapps-storage-connection

🛻 特定のフォルダ配下のファイルを移動する

本シナリオでは、移動元と移動先 の container-01 BLOB コンテナにおける、下記のディレクトリ構造を想定します。

Source BLOB コンテナ (移動元 📂 : Source Storage Account)

container-01 -- dir -- subdir
              |       |
              |       --📂sub_test.csv
              |
              --📂test.jpg
# "📂" マークのついているファイルを移動させます
# 移動後はファイルのみ削除します

Destination BLOB コンテナ (移動先 🆕 : Destination Storage Account)

container-01 -- 🆕dir -- 🆕subdir
              |         |
              |         --🆕sub_test.csv
              |
              --🆕test.jpg
 # "🆕" マークのついているファイル/フォルダを新たに作成させます

ワークフロー概観

workflow-overview

ワークフロー詳細 (JSON)

ワークフローを JSON で表現すると下記のとおりとなります。

{
  "definition": {
    "$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
    "contentVersion": "1.0.0.0",
    "triggers": {
      "Recurrence": {
        "type": "Recurrence",
        "recurrence": {
          "interval": 3,
          "frequency": "Month"
        }
      }
    },
    "actions": {
      "BLOB_を一覧表示する_(V2)_-_source": {
        "type": "ApiConnection",
        "inputs": {
          "host": {
            "connection": {
              "name": "@parameters('$connections')['azureblob']['connectionId']"
            }
          },
          "method": "get",
          "path": "/v2/datasets/@{encodeURIComponent(encodeURIComponent('**Source のストレージ アカウント名**'))}/foldersV2/@{encodeURIComponent(encodeURIComponent('/container-01'))}",
          "queries": {
            "nextPageMarker": "",
            "useFlatListing": true
          }
        },
        "runAfter": {},
        "metadata": {
          "JTJmY29udGFpbmVyLTAxJTJmZGlyJTJmc3ViZGlyJTJm": "/container-01/dir/subdir/",
          "JTJmY29udGFpbmVyLTAxJTJmZGlyJTJm": "/container-01/dir/"
        }
      },
      "For_each_-_under_\"container-01\"": {
        "type": "Foreach",
        "foreach": "@body('BLOB_を一覧表示する_(V2)_-_source')?['value']",
        "actions": {
          "Condition_-_isFolder": {
            "type": "If",
            "expression": {
              "and": [
                {
                  "not": {
                    "equals": [
                      "@items('For_each_-_under_\"container-01\"')?['IsFolder']",
                      true
                    ]
                  }
                }
              ]
            },
            "actions": {
              "BLOB_コンテンツを取得する_(V2)_-_source": {
                "type": "ApiConnection",
                "inputs": {
                  "host": {
                    "connection": {
                      "name": "@parameters('$connections')['azureblob']['connectionId']"
                    }
                  },
                  "method": "get",
                  "path": "/v2/datasets/@{encodeURIComponent(encodeURIComponent('**Source のストレージ アカウント名**'))}/files/@{encodeURIComponent(encodeURIComponent(items('For_each_-_under_\"container-01\"')?['Id']))}/content",
                  "queries": {
                    "inferContentType": true
                  }
                }
              },
              "BLOB_を作成する_(V2)_-_dest": {
                "type": "ApiConnection",
                "inputs": {
                  "host": {
                    "connection": {
                      "name": "@parameters('$connections')['azureblob']['connectionId']"
                    }
                  },
                  "method": "post",
                  "body": "@body('BLOB_コンテンツを取得する_(V2)_-_source')",
                  "headers": {
                    "ReadFileMetadataFromServer": true
                  },
                  "path": "/v2/datasets/@{encodeURIComponent(encodeURIComponent('**Destination のストレージ アカウント名**'))}/files",
                  "queries": {
                    "folderPath": "@{substring(items('For_each_-_under_\"container-01\"')?['Path'], 0, lastIndexOf(items('For_each_-_under_\"container-01\"')?['Path'], '/'))}",
                    "name": "@items('For_each_-_under_\"container-01\"')?['Name']",
                    "queryParametersSingleEncoded": true
                  }
                },
                "runAfter": {
                  "BLOB_コンテンツを取得する_(V2)_-_source": ["Succeeded"]
                }
              },
              "BLOB_を削除する_(V2)_-_source": {
                "type": "ApiConnection",
                "inputs": {
                  "host": {
                    "connection": {
                      "name": "@parameters('$connections')['azureblob']['connectionId']"
                    }
                  },
                  "method": "delete",
                  "headers": {
                    "SkipDeleteIfFileNotFoundOnServer": false
                  },
                  "path": "/v2/datasets/@{encodeURIComponent(encodeURIComponent('**Source のストレージ アカウント名**'))}/files/@{encodeURIComponent(encodeURIComponent(items('For_each_-_under_\"container-01\"')?['Path']))}"
                },
                "runAfter": {
                  "BLOB_を作成する_(V2)_-_dest": ["Succeeded"]
                }
              }
            },
            "else": {
              "actions": {}
            }
          }
        },
        "runAfter": {
          "BLOB_を一覧表示する_(V2)_-_source": ["Succeeded"]
        }
      }
    },
    "outputs": {},
    "parameters": {
      "$connections": {
        "type": "Object",
        "defaultValue": {}
      }
    }
  },
  "parameters": {
    "$connections": {
      "value": {
        "azureblob": {
          "id": "/subscriptions/**サブスクリプション ID**/providers/Microsoft.Web/locations/japaneast/managedApis/azureblob",
          "connectionId": "/subscriptions/**サブスクリプション ID**/resourceGroups/**リソース グループ**/providers/Microsoft.Web/connections/azureblob-3",
          "connectionName": "azureblob-3",
          "connectionProperties": {
            "authentication": {
              "type": "ManagedServiceIdentity"
            }
          }
        }
      }
    }
  }
}

JSON を見ても可読性は著しく低く、全体やフローの構成を理解するのは難しいかと思います。
そのため、下記のとおりワークフローの構成を図解し、各アクションの詳細を解説していきます。

ワークフロー詳細 (図解)

トリガー

workflow-trigger
任意のトリガーで問題ありません。
今回の場合は、簡易化のために [Recurrence] を選択します。

アクション

図のとおり少し細かい設定が必要な構成ではありますが、順番に解説していきます。

1. BLOB を一覧表示する (V2)

workflow-action-blob-list
動的なコンテンツとして [For each] で展開するため、Source の container-01 コンテナ配下のファイル/フォルダ一覧を取得します。
このとき、[単純なリスト][はい] に設定します。この設定を行うことで、後述の [For each] で展開する際に、配下すべてのファイル/フォルダのパスを取得することが可能となります。

workflow-action-blob-list-simple-list

2. For each

workflow-action-for-each
ファイル/フォルダの一覧を展開するための設定を行います。
動的なコンテンツを使用し、[Select An Output From Previous Steps][BLOB を一覧表示する (V2)] > [value] と設定します。

workflow-action-for-each-target

3. Condition

workflow-action-condition

[For each] で展開したファイル/フォルダの一覧の中から、ファイルの場合のみ後続のコピー等の処理を行うよう、条件分岐を行います。

動的なコンテンツを使用し、下記画像の赤枠のとおり、[BLOB を一覧表示する (V2)] > [IsFolder]is not equal to 比較演算子を用いて true と設定します。
これは言い換えると、 イテレート対象がフォルダでない場合、つまりファイルの場合に True となります。

workflow-action-condition-details

4. BLOB コンテンツを取得する (V2)

workflow-action-blob-read
移動の対象となる、Source のファイルのコンテンツを取得するための設定を行います。
BLOB のフォームには、動的なコンテンツを使用し、[BLOB を一覧表示する (V2)] > [Id] を設定します。

workflow-action-blob-read-details

5. BLOB を作成する (V2)

workflow-action-blob-create
直前のコンテンツを用いて、Destination へのファイル作成(実質的なコピー)を行うための設定を構成します。
本記事の中では、最もトリッキーな設定が必要となる箇所です。

まず、[フォルダーのパス] には、下記の式関数を使用します。
式関数で行っていること自体は非常に単純で、substringlastIndexOf を用いて Source のパスを加工し、Destination へのコピーのために、ファイル名を除いたフォルダのパスを取得しています。

substring(items('For_each_-_under_"container-01"')['Path'], 0, lastIndexOf(items('For_each_-_under_"container-01"')['Path'], '/'))

上記を一般化すると下記のとおりとなります。
ご利用いただいている環境に合わせて、<> 内を適宜置き換えてください。

substring(items(<For each アクションのアクション名>)['Path'], 0, lastIndexOf(items(<For each アクションのアクション名>)['Path'], '/'))
workflow-action-blob-create-folder-path

その後、動的なコンテンツを用いて、BLOB 名 フォームを [BLOB を一覧表示する (V2)] > [Name] と設定します。

workflow-action-blob-create-blob-name

最後に、こちらも動的コンテンツを用いて、BLOB コンテンツ フォームを [BLOB コンテンツを取得する (V2)] > [コンテンツ] と設定します。

workflow-action-blob-create-blob-contents

6. BLOB を削除する (V2)

workflow-action-blob-delete

最初に言及した通り、現時点では Logic Apps において BLOB の移動を直接サポートしていないため、移動元のファイルを削除する必要があります。

[BLOB] フォームには動的なコンテンツを使用し、[BLOB を一覧表示する (V2)] > [Path] を設定します。

workflow-action-blob-delete-blob

実行結果

ワークフローの実行履歴を確認すると、[BLOB を一覧表示する (V2)] にて展開された対象が dir/ のようなフォルダの場合、しっかりと false となり、False に分岐していることが確認できます。

workflow-result-dir

反対に、ファイルの場合、[BLOB を一覧表示する (V2)] にて展開された対象が true となり、True に分岐していることが確認できます。

workflow-result-dir

🦚 おわりに

運用上のシナリオ次第では、Logic Apps のアクションのみでファイルのコピー/移動の操作を完了させたい場合があるかと思います。
そのようなシナリオでは、移動対象となるストレージ アカウントが異なる可能性は往々にしてあります。
執筆時点では、Logic Apps 上で BLOB コンテナのファイルを "直接移動させる単一のアクション" はなく、ストレージ アカウント間を対象とした場合や、ファイル名にマッチする場合などカスタマイズの要素は多岐にわたります。

そのため記事内では、式関数や動的なコンテンツを用いて、移動元と移動先のストレージ アカウントをまたいだファイル/フォルダの移動を実現しました。
少しテクニックが必要な箇所もありましたので、試すうちにエラーや想定しない挙動となりましたら、ぜひ記事内の設定を参考に、手元の設定を確認してみてください。
場合によっては、コード ビューを覗くことで "動的なコンテンツがどの箇所を参照しているか" を確認することが可能となり、すぐに誤った箇所を特定することができます。

もし記事内容にご提案、誤植などございましたら、コメントにてご連絡いただけますと幸いです。

脚注
  1. See : https://learn.microsoft.com/en-us/azure/logic-apps/logic-apps-pricing#consumption-multi-tenant ↩︎

  2. See : https://learn.microsoft.com/en-us/azure/logic-apps/logic-apps-pricing#standard-single-tenant ↩︎

  3. See : https://learn.microsoft.com//azure/storage/blobs/data-lake-storage-namespace
    ↩︎

Microsoft (有志)

Discussion