Skip to content

添付ファイルの保存先(クラウド PaaS での永続化と Blob Storage への移行) ​

第4版作成 最終更新 (日本時間)
確認バージョン1.5.7.01.5.8.1

Azure App Service・AWS ECS・Google Cloud Run などの PaaS では、コンテナやインスタンスの再起動・スケールでローカルストレージの内容が消えるため、添付ファイルを永続化する仕組みが必要です。このページでは、添付ファイルの格納方式と各クラウドでの構成、既存の Azure Files マウント構成から AzureBlob プロバイダへの移行手順をまとめます。

  • 追加設定なしで済ませるなら Rds(DB 格納)。ただし DB の容量とコストに直結します。
  • Azure では 1.5.7.0 で追加された AzureBlob が、マウント不要・アカウントキー不要でもっとも扱いやすい構成です。
  • それ以外のクラウドでは Local にして、クラウドのファイルストレージ(EFS・Filestore など)をマウントします。
  • Azure Files マウントから AzureBlob への移行は「ファイル共有の中身をコンテナのルートへコピー」と「Provider の切り替え」だけで済み、DB の変更は不要です。

INFO

バージョン 1.5.7.0 が対象です。以下のストレージアカウント名・リソースグループ名・App Service 名・ID・IP アドレスなどはすべてダミー値です。実際の環境の値に置き換えてください。

格納方式 ​

BinaryStorage.json ​

添付ファイルの格納方式は BinaryStorage.json で設定します。

json
{
    "Provider": "Rds",
    "AzureBlobStorageAccountUri": null,
    "AzureBlobContainerName": null,
    "Path": null,
    "Attachments": true,
    "Images": true,
    "UseStorageSelect": false,
    "TemporaryBinaryStorageProvider": null
}

Provider の比較 ​

Provider に指定できる値は Rds・Local・AzureBlob の 3 種類です。AzureBlob は 1.5.7.0 で追加されたプロバイダで、1.5.6.0 以前は Rds と Local の 2 択でした。

項目RdsLocalAzureBlob
格納先データベース(Binaries テーブル)ファイルシステムAzure Blob Storage
データ形式バイナリカラム(Bin)バイナリファイル(GUID 名)BLOB(Attachments/{Guid})
DB 負荷大きい(ファイルサイズ分)小さい(メタデータのみ)小さい(メタデータのみ)
バックアップDB バックアップに含まれる別途ファイルバックアップが必要ストレージアカウント側で管理
PaaS 対応追加設定不要永続ストレージのマウントが必要マウント不要(HTTPS でアクセス)

PaaS での選び方 ​

図を読み込み中…

Rds モード ​

Provider を Rds にすると、添付ファイルはすべてデータベースに格納されるため、ファイルストレージのマウントは不要です。PaaS ではもっともシンプルな構成です。

json
{
    "Provider": "Rds"
}

ただし次の点に注意が必要です。

  • データベースの容量が添付ファイルの分だけ増えます。
  • SQL Server Express を使っている場合、10GB の容量制限があります。
  • 大量の添付ファイルを扱う場合、データベースのパフォーマンスに影響する可能性があります。
  • データベースのバックアップサイズが大きくなります。

WARNING

大量の添付ファイルを扱う場合や、ファイルサイズが大きい場合は、Local モードや AzureBlob モードを検討してください。

Local モード(ファイルストレージをマウント) ​

Provider を Local にし、Path にマウント先のパスを指定します。クラウド側のストレージ設定が違うだけで、プリザンターからはどれも同じファイルシステムとして透過的に扱えます。

json
{
    "Provider": "Local",
    "Path": "/mnt/pleasanter-files"
}
クラウドPaaS サービスファイルストレージマウント方式
AzureApp ServiceAzure Filesパスマッピング
AzureContainer AppsAzure Filesボリュームマウント
AWSECS(Fargate)Amazon EFSEFS ボリューム
AWSElastic BeanstalkAmazon EFS.ebextensions
Google CloudCloud RunFilestoreNFS ボリューム
Google CloudGKEFilestorePersistentVolume
DockerDocker Composeホストディレクトリバインドマウント

以下の各構成で、プリザンター側の設定は上の BinaryStorage.json と同じです。

Azure App Service + Azure Files ​

Azure Files(SMB ファイル共有)をパスマッピングでマウントします。

図を読み込み中…

  1. ストレージアカウントとファイル共有を作成します。

    bash
    # リソースグループの作成
    az group create --name rg-example --location japaneast
    
    # ストレージアカウントの作成
    az storage account create \
      --name stexamplefiles \
      --resource-group rg-example \
      --location japaneast \
      --sku Standard_LRS
    
    # ファイル共有の作成
    az storage share-rm create \
      --storage-account stexamplefiles \
      --name pleasanter-attachments \
      --quota 100
  2. App Service にマウントします。Azure Portal では「構成」>「パス マッピング」から設定します。Azure CLI では次のとおりです。

    bash
    # ストレージアカウントキーの取得
    STORAGE_KEY=$(az storage account keys list \
      --account-name stexamplefiles \
      --resource-group rg-example \
      --query '[0].value' -o tsv)
    
    # App Service にマウント
    az webapp config storage-account add \
      --resource-group rg-example \
      --name app-example \
      --custom-id PleasanterFiles \
      --storage-type AzureFiles \
      --share-name pleasanter-attachments \
      --account-name stexamplefiles \
      --access-key "$STORAGE_KEY" \
      --mount-path /mnt/pleasanter-files
  3. BinaryStorage.json を Provider: "Local"、Path: "/mnt/pleasanter-files" にします。

ストレージマウントの詳細は Microsoft のドキュメント「Azure Storage をローカル共有としてマウントする」を参照してください。

TIP

1.5.7.0 以降の Azure App Service では、マウントを使わない AzureBlob モード が推奨です。既存のマウント構成からの移行は Azure Files マウントから AzureBlob へ移行する を参照してください。

Azure Container Apps + Azure Files ​

Container Apps 環境にストレージを追加し、アプリ定義でボリュームマウントします。

bash
# Container Apps 環境にストレージを追加
az containerapp env storage set \
  --name cae-example \
  --resource-group rg-example \
  --storage-name pleasanterfiles \
  --azure-file-account-name stexamplefiles \
  --azure-file-account-key "$STORAGE_KEY" \
  --azure-file-share-name pleasanter-attachments \
  --access-mode ReadWrite
yaml
properties:
  template:
    containers:
      - name: pleasanter
        image: your-registry/pleasanter:latest
        volumeMounts:
          - volumeName: pleasanter-files
            mountPath: /mnt/pleasanter-files
    volumes:
      - name: pleasanter-files
        storageType: AzureFile
        storageName: pleasanterfiles

AWS ECS(Fargate)+ Amazon EFS ​

Amazon EFS(Elastic File System)は NFS v4 プロトコルのマネージドファイルストレージで、ECS タスクから直接マウントできます。

図を読み込み中…

  1. EFS ファイルシステムとマウントターゲットを作成します。

    bash
    # EFS ファイルシステムの作成
    aws efs create-file-system \
      --performance-mode generalPurpose \
      --throughput-mode bursting \
      --tags Key=Name,Value=pleasanter-attachments \
      --region ap-northeast-1
    
    # マウントターゲットの作成(VPC のサブネットごとに作成)
    aws efs create-mount-target \
      --file-system-id fs-0123456789abcdef0 \
      --subnet-id subnet-0123456789abcdef0 \
      --security-groups sg-0123456789abcdef0
  2. タスク定義でボリュームとマウントポイントを設定します。

    json
    {
        "family": "pleasanter",
        "networkMode": "awsvpc",
        "requiresCompatibilities": ["FARGATE"],
        "cpu": "1024",
        "memory": "2048",
        "volumes": [
            {
                "name": "pleasanter-files",
                "efsVolumeConfiguration": {
                    "fileSystemId": "fs-0123456789abcdef0",
                    "rootDirectory": "/pleasanter-attachments",
                    "transitEncryption": "ENABLED"
                }
            }
        ],
        "containerDefinitions": [
            {
                "name": "pleasanter",
                "image": "your-registry/pleasanter:latest",
                "mountPoints": [
                    {
                        "sourceVolume": "pleasanter-files",
                        "containerPath": "/mnt/pleasanter-files",
                        "readOnly": false
                    }
                ]
            }
        ]
    }
  3. BinaryStorage.json を Provider: "Local"、Path: "/mnt/pleasanter-files" にします。

EFS のマウント方法は AWS のドキュメント「Amazon EFS ファイルシステムのマウント」を参照してください。

AWS Elastic Beanstalk + Amazon EFS ​

.ebextensions で EFS をマウントします。

yaml
packages:
  yum:
    amazon-efs-utils: []

commands:
  01_mount_efs:
    command: |
      mkdir -p /mnt/pleasanter-files
      mount -t efs -o tls fs-0123456789abcdef0:/ /mnt/pleasanter-files
      chown webapp:webapp /mnt/pleasanter-files
    test: "! mountpoint -q /mnt/pleasanter-files"

Google Cloud Run + Filestore(NFS) ​

Filestore(NFS のマネージドサービス)を使います。Cloud Run の第 2 世代実行環境では NFS ボリュームのマウントがサポートされています。

図を読み込み中…

  1. Filestore インスタンスを作成します。

    bash
    # Filestore インスタンスの作成
    gcloud filestore instances create pleasanter-files \
      --zone=asia-northeast1-a \
      --tier=BASIC_HDD \
      --file-share=name=pleasanter_attachments,capacity=1TB \
      --network=name=default
  2. Cloud Run の YAML 定義で NFS ボリュームをマウントし、デプロイします。

    yaml
    apiVersion: serving.knative.dev/v1
    kind: Service
    metadata:
      name: pleasanter
    spec:
      template:
        metadata:
          annotations:
            run.googleapis.com/execution-environment: gen2
            run.googleapis.com/network-interfaces: '[{"network":"default","subnetwork":"default"}]'
        spec:
          containers:
            - image: your-registry/pleasanter:latest
              volumeMounts:
                - name: pleasanter-files
                  mountPath: /mnt/pleasanter-files
          volumes:
            - name: pleasanter-files
              nfs:
                server: 10.0.0.2
                path: /pleasanter_attachments
                readOnly: false
    bash
    # デプロイ
    gcloud run services replace service.yaml --region=asia-northeast1
  3. BinaryStorage.json を Provider: "Local"、Path: "/mnt/pleasanter-files" にします。

Cloud Run での NFS マウントは Google Cloud のドキュメント「Cloud Run でネットワーク ファイル システムを使用する」を参照してください。

GKE + Filestore ​

Filestore CSI ドライバーを使い、PersistentVolume としてマウントします。

yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pleasanter-pv
spec:
  capacity:
    storage: 100Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: 10.0.0.2
    path: /pleasanter_attachments
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pleasanter-pvc
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 100Gi
  volumeName: pleasanter-pv
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pleasanter
spec:
  template:
    spec:
      containers:
        - name: pleasanter
          image: your-registry/pleasanter:latest
          volumeMounts:
            - name: pleasanter-files
              mountPath: /mnt/pleasanter-files
      volumes:
        - name: pleasanter-files
          persistentVolumeClaim:
            claimName: pleasanter-pvc

Docker Compose ​

ローカル開発や小規模な環境では、ホストのディレクトリをバインドマウントするか、Docker ボリュームを使います。

yaml
services:
  pleasanter:
    image: your-registry/pleasanter:latest
    volumes:
      - pleasanter-files:/mnt/pleasanter-files
    environment:
      - ASPNETCORE_URLS=http://+:5000

volumes:
  pleasanter-files:
    driver: local

AzureBlob モード ​

1.5.7.0 以降で使えます。

1.5.7.0 で追加された AzureBlob プロバイダは、ファイル共有をマウントせずに Azure Blob Storage へ直接読み書きします。認証には DefaultAzureCredential を使うため、App Service のマネージド ID をそのまま利用でき、接続文字列やアカウントキーを保持する必要がありません。

図を読み込み中…

設定手順 ​

  1. ストレージアカウントとコンテナを作成します。

    bash
    az storage account create \
      --name stexampleblob \
      --resource-group rg-example \
      --location japaneast \
      --sku Standard_LRS
    
    az storage container create \
      --account-name stexampleblob \
      --name pleasanter-binaries \
      --auth-mode login
  2. App Service のマネージド ID に「ストレージ BLOB データ共同作成者」ロールを付与します。

    bash
    # システム割り当てマネージド ID を有効化
    PRINCIPAL_ID=$(az webapp identity assign \
      --resource-group rg-example \
      --name app-example \
      --query principalId -o tsv)
    
    # ストレージ BLOB データ共同作成者ロールを付与
    az role assignment create \
      --assignee "$PRINCIPAL_ID" \
      --role "Storage Blob Data Contributor" \
      --scope "/subscriptions/<subscription-id>/resourceGroups/rg-example/providers/Microsoft.Storage/storageAccounts/stexampleblob"
  3. BinaryStorage.json を設定します。

    json
    {
        "Provider": "AzureBlob",
        "AzureBlobStorageAccountUri": "https://stexampleblob.blob.core.windows.net",
        "AzureBlobContainerName": "pleasanter-binaries"
    }

AzureBlobStorageAccountUri と AzureBlobContainerName は環境変数でも指定できます。コンテナ名を省略した場合の既定値は pleasanter-binaries です。

環境変数対応する設定
Implem.Pleasanter_BinaryStorage_AzureBlobStorageAccountUriAzureBlobStorageAccountUri
Implem.Pleasanter_BinaryStorage_AzureBlobContainerNameAzureBlobContainerName

環境変数名の先頭は Service.json の Name(既定は Implem.Pleasanter)です。Service.json の EnvironmentName を設定している場合は、{EnvironmentName}_BinaryStorage_... が先に参照されます。BinaryStorage.json に値があればそちらが優先です(Initializer.cs)。

INFO

AzureBlobBinaryStorageProvider にはサーキットブレーカーが組み込まれており、接続・認証に連続して失敗すると 30 秒 → 2 分 → 10 分と段階的にアクセスを遮断します。遮断中は IOException が返り、失敗の内容は SysLogs に記録されます。回路の状態は静的フィールドで保持されているため、アプリを再起動するとリセットされます。

オブジェクト名とローカルフォルダのパス ​

ローカルフォルダ側のプロバイダは、objectName の / をディレクトリ区切りに置き換えて BinaryStorage.Path の下に連結しているだけです。Blob 側は objectName をそのままオブジェクト名として使います。

csharp
private static string ResolvePath(string objectName)
{
    ValidateObjectName(objectName);
    var relevantPath = objectName.Replace('/', Path.DirectorySeparatorChar);
    return Path.Combine(Directories.BinaryStorage(), relevantPath);
}

LocalFolderBinaryStorageProvider.cs#L14-L19

そのため、Blob のオブジェクト名と Local モードでのファイルパスは完全に対応します。

種別オブジェクト名ローカルフォルダ上のパス
添付ファイルAttachments/{Guid}{Path}/Attachments/{Guid}
本文の画像Images/{Guid}{Path}/Images/{Guid}
画像のサムネイルImages/{Guid}_thumbnail{Path}/Images/{Guid}_thumbnail
サイト画像SiteImage/{ReferenceId}_{SizeType}.png{Path}/SiteImage/{ReferenceId}_{SizeType}.png
テナント画像TenantImage/{ReferenceId}_{SizeType}.png{Path}/TenantImage/{ReferenceId}_{SizeType}.png

サイト画像・テナント画像のオブジェクト名は次のように組み立てられます。SizeTypes は Regular / Thumbnail / Icon / Logo の 4 種類です。

csharp
private string ObjectName(long referenceId, Types type, SizeTypes sizeType)
{
    return $"{type}/{referenceId}_{sizeType}.png";
}

ImageData.cs#L286-L289

データベースの Binaries テーブルは Guid を持つだけで、オブジェクト名はそこから組み立てられます。格納先が変わっても参照は壊れません。

UseStorageSelect によるカラム別の格納先 ​

UseStorageSelect を有効にして項目ごとに格納先(DataBase / LocalFolder など)を指定している場合、Provider が AzureBlob なら LocalFolder の指定は自動的に AzureBlob に読み替えられます。

csharp
public static string BinaryStorageProvider(Column column = null)
{
    if (Parameters.BinaryStorage.UseStorageSelect && column != null)
    {
        var selected = string.IsNullOrEmpty(column?.BinaryStorageProvider)
            ? Parameters.BinaryStorage.DefaultBinaryStorageProvider
            : column?.BinaryStorageProvider;
        if (selected == BinaryStorageProviderNames.LocalFolder
            && Parameters.BinaryStorage.IsAzureBlob())
        {
            return BinaryStorageProviderNames.AzureBlob;
        }
        return selected;
    }
    // ...
}

BinaryUtilities.cs#L773-L800

AutoDataBaseOrLocalFolder(サイズによって DB とファイルを使い分ける設定)も同様に、ファイル側の格納先が AzureBlob になります。

LocalFolderLimitSize などのサイズ上限もそのまま効きます。判定に使われる IsStoreExternal() が Local と AzureBlob を同じ「外部ストレージ」として扱うためです。

csharp
if (Parameters.BinaryStorage.IsStoreExternal(
    BinaryUtilities.BinaryStorageProvider(column, attachment.Size.GetValueOrDefault())))
{
    if (attachment.Size > column.LocalFolderLimitSize * 1024 * 1024)
    {
        return Error.Types.OverLocalFolderLimitSize;
    }
}
else
{
    if (attachment.Size > column.LimitSize * 1024 * 1024)
    {
        return Error.Types.OverLimitSize;
    }
}

BinaryValidators.cs#L462-L482

DB 格納のファイルとの混在 ​

Provider が Rds の時期に登録されたファイルや、UseStorageSelect で DataBase を指定した項目のファイルは、Binaries テーブルの Bin 列に入ったままです。ダウンロード時は Bin にデータがあればそこから返すフォールバックがあるため、DB 格納のファイルと Blob 格納のファイルが混在していても問題なく動きます。すべてを Blob に寄せたい場合は別途データ移行が必要です。

ロードバランサ配下での一時ファイル ​

複数インスタンスで運用する場合は、一時ファイルの保存先に注意が必要です。

TemporaryBinaryStorageProvider ​

TemporaryBinaryStorageProvider を Rds にすると、Web UI からのアップロード時に一時ファイルが Binaries テーブルに BinaryType = 'Temporary' として格納されます。アップロード用のリクエストと保存用のリクエストが別インスタンスに振り分けられても、一時ファイルを見失わないための仕組みです。

1.5.7.0 では、レコード保存時に一時ファイルを DB から外部ストレージへ移し替える処理(MoveBinFromRdsToExternal)が追加されました。Provider が Local や AzureBlob の構成であれば、Bin 列のデータは外部ストレージへアップロードされたうえで NULL に更新されます。

csharp
// TemporaryBinaryStorageProvider が "Rds" かつ Web UI の場合
if (Parameters.BinaryStorage.TemporaryBinaryStorageProvider == BinaryStorageProviderNames.Rds
    && context.Api != true)
{
    if (provider != null)
    {
        // 一時ファイルの Bin を外部ストレージへ移送
        MoveBinFromRdsToExternal(
            context: context,
            provider: provider);
    }
    statements.Add(Rds.UpdateBinaries(
        param: Rds.BinariesParam()
            .TenantId(context.TenantId)
            .ReferenceId(referenceId, _using: referenceId != 0)
            .Guid(Guid)
            .BinaryType("Attachments")
            .Bin(raw: "NULL", _using: provider != null)   // 移送済みなら NULL にする
            // ...
        where: Rds.BinariesWhere().Guid(Guid)));
}

MoveBinFromRdsToExternal() は BinaryType = 'Temporary' のレコードを読み出し、Attachments/{Guid} というオブジェクト名で外部ストレージへアップロードします。アップロードに失敗した場合は SysLogs に記録したうえで例外を送出するため、DB 側だけ更新されて実体が消えることはありません。

図を読み込み中…

WARNING

1.5.6.0 以前には移送処理がありません。TemporaryBinaryStorageProvider を Rds にすると、Provider が Local でもファイルが Bin 列に残り続け、Local モードのメリット(DB サイズの削減)が得られません。

推奨構成 ​

バージョン構成
1.5.7.0 以降Provider を Local または AzureBlob にし、TemporaryBinaryStorageProvider を Rds にする。一時ファイルはインスタンス間で共有され、保存時に外部ストレージへ移送される
1.5.6.0 以前TemporaryBinaryStorageProvider は Rds にせず、Provider を Local にして Azure Files・Amazon EFS・Filestore などの共有ストレージをマウントする。全インスタンスが同じファイルシステムを見るため、一時ファイルの問題は起きない
容量を気にしない場合Provider を Rds にしてすべてを DB に格納する
json
{
    "Provider": "AzureBlob",
    "AzureBlobStorageAccountUri": "https://stexampleblob.blob.core.windows.net",
    "AzureBlobContainerName": "pleasanter-binaries",
    "TemporaryBinaryStorageProvider": "Rds"
}

Azure Files マウントから AzureBlob へ移行する ​

1.5.7.0 以降で使えます。

すでに Azure App Service で「Azure Files をパスマッピングでマウントし、Provider を Local」にしている環境を、AzureBlob へ移行する手順です。

WARNING

本番環境での作業は必ずメンテナンス時間帯に行い、事前にストレージアカウントとデータベースのバックアップを取得してください。

移行する理由 ​

観点Azure Files マウントBlob Storage 直接
App Service の設定パスマッピングが必要不要
認証情報ストレージアカウントキーを App Service に保持マネージド ID(キーの保持なし)
プロトコルSMBHTTPS
コンテナの再作成マウント設定の再適用が必要環境変数だけで完結
Linux コンテナSMB マウントの制約を受けやすい制約なし

一番大きいのはアカウントキーを持たなくてよくなる点です。マウント方式ではキーを App Service の構成に登録する必要があり、キーのローテーションのたびに再設定が発生します。

移行の考え方 ​

オブジェクト名とローカルフォルダのパス が完全に一致しているため、ファイル共有のルート配下をそのまま Blob コンテナのルートへコピーすれば、パスの読み替えは不要です。Binaries テーブルの変更も要りません。移行作業の実体は「ファイルのコピー」と「設定の切り替え」の 2 つだけです。

また、次の設定は移行後もそのまま使えます。

  • UseStorageSelect の項目別設定・サイズ上限(UseStorageSelect によるカラム別の格納先 を参照)。サイト設定の変更は不要です。
  • DB に入っているファイルは移行対象外で、Blob と混在したまま動きます(DB 格納のファイルとの混在)。
  • TemporaryBinaryStorageProvider が Rds の構成。移送先が Blob になり、「一時ファイルは DB、確定したら Blob」という構成になります。

全体の流れ ​

図を読み込み中…

1. 移行先の準備 ​

AzureBlob モードの設定手順 の 1〜2(コンテナの作成とマネージド ID へのロール付与)を先に済ませます。

  • コンテナは既存の Azure Files と同じストレージアカウントに作ってかまいません。ライフサイクル管理やアクセス制御を分けたい場合だけ別アカウントにします。
  • コンテナ名を pleasanter-binaries(AzureBlobContainerName の既定値)にしておくと、ストレージアカウント URI の指定だけで動きます。

INFO

ロール割り当ての反映には数分かかることがあります。コピー作業の前に済ませておくと待ち時間を吸収できます。

2. アプリを停止する ​

コピー中にファイルが追加されると取りこぼしが発生するため、アプリを停止します。

powershell
az webapp stop --resource-group rg-example --name app-example

3. ファイル共有からコンテナへコピーする ​

azcopy を使います。

WARNING

AzCopy が Microsoft Entra ID(azcopy login)で認証できるのは Blob と Data Lake Storage だけで、Azure Files には SAS トークンが必要です。azcopy login を済ませていても、ファイル共有の URL に SAS を付けないと認証エラーになります。

まずストレージアカウントキーからファイル共有用の SAS を発行します。

powershell
$account   = 'stexamplefiles'
$share     = 'pleasanter-attachments'
$container = 'pleasanter-binaries'
$expiry    = (Get-Date).ToUniversalTime().AddHours(4).ToString('yyyy-MM-ddTHH:mmZ')

$key = az storage account keys list `
    --account-name $account `
    --resource-group rg-example `
    --query '[0].value' -o tsv

# 読み取り + 一覧の SAS
$shareSas = az storage share generate-sas `
    --account-name $account `
    --account-key $key `
    --name $share `
    --permissions rl `
    --expiry $expiry -o tsv

コピー先の Blob コンテナは azcopy login の Entra ID 認証が使えます。ソースは SAS、宛先は OAuth という組み合わせで問題ありません。

powershell
azcopy login

azcopy copy `
    "https://$account.file.core.windows.net/$share/*?$shareSas" `
    "https://$account.blob.core.windows.net/$container/" `
    --recursive=true `
    --preserve-smb-info=false
  • --recursive=true により、Attachments/・Images/・SiteImage/ などのサブディレクトリがそのままコンテナ内の階層(オブジェクト名のプレフィックス)になります。
  • ソース URL の末尾を /* にしているのは、共有フォルダ自体ではなく中身をコンテナ直下へ展開するためです。
  • リネームや階層の作り替えは行いません。

コピー後、azcopy のジョブサマリで Number of Transfers Failed が 0、Final Job Status が Completed であることを確認します。失敗があった場合は azcopy jobs show <ジョブID> で詳細を確認できます。

text
Number of File Transfers: 12043
Number of Transfers Completed: 12043
Number of Transfers Failed: 0
Final Job Status: Completed

WARNING

azcopy sync で差分だけをコピーすることもできますが、削除の同期(--delete-destination)は使わないでください。コピー元にないファイルがコンテナから消えてしまいます。初回移行では azcopy copy を使うのが安全です。

4. Provider を切り替える ​

Provider を Local から AzureBlob に変え、接続先を指定します。Path は使われなくなるので削除してかまいません。環境変数で指定することもできます(AzureBlob モードの設定手順 を参照)。

diff
- "Provider": "Local",
- "Path": "/mnt/pleasanter-files"
+ "Provider": "AzureBlob",
+ "AzureBlobStorageAccountUri": "https://stexamplefiles.blob.core.windows.net",
+ "AzureBlobContainerName": "pleasanter-binaries"

5. アプリを起動して動作確認する ​

powershell
az webapp start --resource-group rg-example --name app-example

次の 4 点を確認します。新規アップロードだけでなく、既存ファイルの参照を必ず確認してください。コピー漏れはここで初めて表面化します。

  1. 移行前に登録された添付ファイルがダウンロードできる
  2. 新規に添付ファイルをアップロードして、ダウンロードできる
  3. 本文に貼り付けた既存画像が表示される
  4. サイトアイコン(サイト画像)が表示される

失敗する場合は SysLogs を確認します。AzureBlobBinaryStorageProvider は失敗時に次のようなエラーを書き出します。

text
AzureBlob Download failed. Blob=Attachments/xxxxxxxx Status=403 ErrorCode=AuthorizationPermissionMismatch Message=...
症状疑うところ
403 AuthorizationPermissionMismatchロール割り当てのスコープ、または反映待ち
404(特定のファイルだけ)コピー漏れ。azcopy のログを確認
すべてのファイルで失敗ストレージアカウント URI・コンテナ名の指定ミス

WARNING

移行直後にロール割り当てが未反映だと、連続失敗でサーキットブレーカーが働き、30 秒 → 2 分 → 10 分と段階的にアクセスが遮断されます。権限を直したあとは、遮断時間が明けるのを待つかアプリを再起動してください。

6. マウントを解除する ​

動作確認が済んだら App Service のパスマッピングを削除します。

powershell
az webapp config storage-account delete `
    --resource-group rg-example `
    --name app-example `
    --custom-id PleasanterFiles

ファイル共有そのものは、切り戻しに備えてしばらく残しておきます。問題がないと判断できてから削除してください。

スクリプトでまとめて実行する ​

手順 2〜3(アプリ停止・SAS 発行・コピー・結果判定)をまとめた PowerShell 7 以降用のスクリプトです。事前に Azure CLI と AzCopy をインストールし、az login と azcopy login を済ませておきます。

powershell
#Requires -Version 7.0
<#
.SYNOPSIS
    プリザンターの添付ファイルを Azure Files から Blob Storage へ移行します。

.DESCRIPTION
    App Service を停止し、ファイル共有の内容を Blob コンテナへコピーします。
    BinaryStorage.json の書き換えとアプリの起動は行いません(動作確認前に
    設定を切り替えたくないため)。コピー結果のみを返します。

.EXAMPLE
    .\Migrate-PleasanterBinaries.ps1 `
        -ResourceGroup rg-example `
        -WebAppName app-example `
        -StorageAccount stexamplefiles `
        -ShareName pleasanter-attachments
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    [Parameter(Mandatory)][string]$ResourceGroup,
    [Parameter(Mandatory)][string]$WebAppName,
    [Parameter(Mandatory)][string]$StorageAccount,
    [Parameter(Mandatory)][string]$ShareName,
    [string]$ContainerName = 'pleasanter-binaries',
    [int]$SasExpiryHours = 4,
    [switch]$SkipAppStop
)

$ErrorActionPreference = 'Stop'

function Assert-Command {
    param([string]$Name)
    if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
        throw "$Name が見つかりません。インストールしてパスを通してください。"
    }
}

function Invoke-Az {
    param([string[]]$Arguments)
    $result = & az @Arguments
    if ($LASTEXITCODE -ne 0) {
        throw "az $($Arguments -join ' ') が失敗しました(終了コード $LASTEXITCODE)。"
    }
    return $result
}

# --- 事前チェック ---------------------------------------------------------
Assert-Command -Name 'az'
Assert-Command -Name 'azcopy'

Write-Host '移行対象を確認しています...'
$expiry = (Get-Date).ToUniversalTime().AddHours($SasExpiryHours).ToString('yyyy-MM-ddTHH:mmZ')

# コンテナの存在確認(無ければ先に作成しておく)
$containerExists = Invoke-Az @(
    'storage', 'container', 'exists',
    '--account-name', $StorageAccount,
    '--name', $ContainerName,
    '--auth-mode', 'login',
    '--query', 'exists', '-o', 'tsv')
if ($containerExists -ne 'true') {
    throw "コンテナ $ContainerName が存在しません。先に作成してください。"
}

# --- アプリ停止 -----------------------------------------------------------
if (-not $SkipAppStop) {
    if ($PSCmdlet.ShouldProcess($WebAppName, 'App Service を停止')) {
        Write-Host "App Service $WebAppName を停止しています..."
        Invoke-Az @('webapp', 'stop', '--resource-group', $ResourceGroup, '--name', $WebAppName) | Out-Null
        # 実行中のリクエストが落ち着くまで少し待つ
        Start-Sleep -Seconds 15
    }
}

# --- SAS の発行 -----------------------------------------------------------
# AzCopy は Azure Files に対して Entra ID 認証を使えないため SAS が必須
Write-Host 'ファイル共有の SAS を発行しています...'
$accountKey = Invoke-Az @(
    'storage', 'account', 'keys', 'list',
    '--account-name', $StorageAccount,
    '--resource-group', $ResourceGroup,
    '--query', '[0].value', '-o', 'tsv')

$shareSas = Invoke-Az @(
    'storage', 'share', 'generate-sas',
    '--account-name', $StorageAccount,
    '--account-key', $accountKey,
    '--name', $ShareName,
    '--permissions', 'rl',
    '--expiry', $expiry,
    '-o', 'tsv')

# --- コピー ---------------------------------------------------------------
$source = "https://$StorageAccount.file.core.windows.net/$ShareName/*?$shareSas"
$destination = "https://$StorageAccount.blob.core.windows.net/$ContainerName/"

if ($PSCmdlet.ShouldProcess($destination, 'azcopy でコピー')) {
    Write-Host 'コピーを開始します(件数によっては時間がかかります)...'
    $output = & azcopy copy $source $destination `
        --recursive=true `
        --preserve-smb-info=false 2>&1
    $output | Write-Host

    # ジョブサマリから結果を判定する
    $summary = $output -join "`n"
    $failed = [regex]::Match($summary, 'Number of Transfers Failed:\s*(\d+)')
    $status = [regex]::Match($summary, 'Final Job Status:\s*(\S+)')

    if (-not $status.Success) {
        throw 'azcopy のジョブサマリを解析できませんでした。出力を確認してください。'
    }
    if ($status.Groups[1].Value -ne 'Completed' -or
        ($failed.Success -and [int]$failed.Groups[1].Value -gt 0)) {
        throw "コピーが正常に完了しませんでした(Status=$($status.Groups[1].Value) Failed=$($failed.Groups[1].Value))。" +
              ' azcopy jobs show <ジョブID> で詳細を確認してください。'
    }

    $completed = [regex]::Match($summary, 'Number of Transfers Completed:\s*(\d+)')
    Write-Host ''
    Write-Host "コピー完了: $($completed.Groups[1].Value) 件" -ForegroundColor Green
}

Write-Host ''
Write-Host '次の手順:' -ForegroundColor Cyan
Write-Host '  1. BinaryStorage.json の Provider を AzureBlob に変更'
Write-Host "  2. az webapp start --resource-group $ResourceGroup --name $WebAppName"
Write-Host '  3. 既存ファイルの参照と新規アップロードを確認'

このスクリプトは意図的に次の 2 つを行いません。

  • BinaryStorage.json の書き換え: 設定の反映方法(ファイル配置・環境変数・デプロイパイプライン)は環境ごとに違うためです。
  • アプリの起動: 設定を切り替える前に起動すると、まだ Local を見たままファイルを書き込んでしまいます。停止したまま設定を切り替えてから起動してください。

-WhatIf を付けると、停止とコピーを実行せずに動作を確認できます。

powershell
.\Migrate-PleasanterBinaries.ps1 `
    -ResourceGroup rg-example `
    -WebAppName app-example `
    -StorageAccount stexamplefiles `
    -ShareName pleasanter-attachments `
    -WhatIf

所要時間の見積もり ​

本番のメンテナンス時間を見積もるため、検証環境でコピーの所要時間を測っておくと計画が立てやすくなります。ファイル共有側の件数は次のように確認できます。

powershell
$listed = & azcopy list "https://$account.file.core.windows.net/$share/?$shareSas" --machine-readable
$files = $listed | Select-String 'Content Length'
"ファイル数: $($files.Count)"

INFO

azcopy list の出力形式はバージョンによって変わることがあります。件数は目安として扱い、コピーの成否はジョブサマリ(Number of Transfers Failed と Final Job Status)で判断してください。

切り戻し手順 ​

  1. アプリを停止する
  2. BinaryStorage.json を Provider: "Local" と Path に戻す
  3. パスマッピングを解除していた場合は再設定する
  4. アプリを起動する

移行作業ではファイル共有側のファイルを削除していないため、これで元の状態に戻ります。ただし移行後に登録された添付ファイルは Blob 側にしかありません。切り戻す場合は、その分を Blob からファイル共有へコピーし直します。宛先がファイル共有なので、書き込み権限付きの SAS が必要です。

powershell
# 移行後に増えた分をファイル共有へ戻す
$shareSas = az storage share generate-sas `
    --account-name $account `
    --account-key $key `
    --name $share `
    --permissions rcwl `
    --expiry $expiry -o tsv

azcopy copy `
    "https://$account.blob.core.windows.net/$container/*" `
    "https://$account.file.core.windows.net/$share/?$shareSas" `
    --recursive=true

このリスクを小さくするため、移行はアプリを停止したメンテナンス時間内に完了させ、動作確認まで一気に行うことをおすすめします。

関連ページ ​

変更履歴

第4版「外部連携・AI」「構築・運用」「内部実装を読む」に対応バージョンを表示
第3版「構築・運用」を 1.5.8.1 のソースで検証して修正
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「構築・運用」に証明書のインポートと添付ファイルの保存先を追加