はじめに
プリザンターを Azure App Service で運用していて、添付ファイルを外部に持たせたい場合、これまでは Azure Files をパスマッピングでマウントし、BinaryStorage.json の Provider を Local にするという構成が案内されてきました。PaaS 環境ではコンテナが再起動するとローカルディスクの内容が消えてしまうため、永続化のために SMB のファイル共有をマウントする、という考え方です。
これが 1.5.7.0 で変わりました。AzureBlob というバイナリストレージプロバイダが追加され、ファイルシステムを介さずに Azure Blob Storage へ直接読み書きできるようになっています。
この記事は、すでに Azure Files マウントで運用している環境を AzureBlob へ移行する手順に絞って書きます。既存ファイルをどう運ぶのか、切り替え時に何を確認するのか、切り戻しはできるのか、という点が中心です。
バージョン 1.5.7.0 を対象にしています
AzureBlob プロバイダそのものの設定方法(ストレージアカウントとコンテナの作成、マネージド ID へのロール付与、BinaryStorage.json と環境変数の指定)は、別記事「プリザンターの添付ファイルをクラウドPaaS環境で永続化する方法」にまとめています。新規構築の場合はそちらを見てみてください。この記事では設定内容の詳細は繰り返さず、移行作業の流れの中で必要な箇所だけ触れます。
本番環境での作業は必ずメンテナンス時間帯に行い、事前にストレージアカウントとデータベースのバックアップを取得してください。
移行前後の構成
これまでの構成(Azure Files マウント)
App Service の「パス マッピング」でファイル共有をマウントし、プリザンターからは普通のディレクトリとして見えている状態です。
移行後の構成(Blob Storage 直接)
マウント設定そのものが不要になります。
移行する動機
| 観点 | Azure Files マウント | Blob Storage 直接 |
|---|---|---|
| App Service の設定 | パスマッピングが必要 | 不要 |
| 認証情報 | ストレージアカウントキーを App Service に保持 | マネージド ID(キーの保持なし) |
| プロトコル | SMB | HTTPS |
| コンテナの再作成 | マウント設定の再適用が必要 | 環境変数だけで完結 |
| Linux コンテナ | SMB マウントの制約を受けやすい | 制約なし |
一番効くのはアカウントキーを持たなくてよくなる点です。マウント方式ではストレージアカウントキーを App Service の構成に登録する必要があり、キーのローテーションのたびに再設定が発生していました。AzureBlob プロバイダは DefaultAzureCredential を使うため、マネージド ID にロールを割り当てるだけで済みます。
移行の鍵:オブジェクト名がパスと一致している
移行手順を考えるうえで一番重要なのが、ローカルフォルダ上のパスと Blob のオブジェクト名がまったく同じという点です。
ローカルフォルダ側の実装を見てみます。
private static string ResolvePath(string objectName)
{
ValidateObjectName(objectName);
var relevantPath = objectName.Replace('/', Path.DirectorySeparatorChar);
return Path.Combine(Directories.BinaryStorage(), relevantPath);
}
objectName の / をディレクトリ区切りに置き換えて、BinaryStorage.Path の下に連結しているだけです。一方 Blob 側は objectName をそのままオブジェクト名として使います。
つまり呼び出し側が使っているオブジェクト名がわかれば、そのままディレクトリ構造に対応します。
| 種別 | オブジェクト名 | ローカルフォルダ上のパス |
|---|---|---|
| 添付ファイル | 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 |
サイト画像・テナント画像のオブジェクト名は次のとおり組み立てられています。
private string ObjectName(long referenceId, Types type, SizeTypes sizeType)
{
return $"{type}/{referenceId}_{sizeType}.png";
}
SizeTypes は Regular / Thumbnail / Icon / Logo の 4 種類です。
結論として、ファイル共有のルート配下をそのまま Blob コンテナのルートへコピーすれば、パスの読み替えは一切不要です。データベース側の Binaries テーブルにも変更は要りません。Guid からオブジェクト名が組み立てられるだけなので、格納先が変わっても参照は壊れません。
移行作業の実体は「ファイルのコピー」と「設定の切り替え」の 2 つだけ、ということになります。
移行手順
全体の流れ
1. 移行先の準備
コンテナの作成と、App Service のマネージド ID への「ストレージ BLOB データ共同作成者」ロール付与を先に済ませておきます。手順は「プリザンターの添付ファイルをクラウドPaaS環境で永続化する方法」に記載しているとおりです。
移行のときに考えておきたいのは次の 2 点です。
- コンテナは既存の Azure Files と同じストレージアカウントに作ってもよい。ライフサイクル管理やアクセス制御を分けたい場合だけ別アカウントにします
-
コンテナ名は
pleasanter-binariesにしておくと設定が 1 行減る。この名前はAzureBlobContainerNameを省略したときの既定値なので、ストレージアカウント URI の指定だけで動きます
ロール割り当ての反映には数分かかることがあります。コピー作業の前に済ませておくと待ち時間を吸収できます。
2. アプリを停止する
コピーの最中にファイルが追加されると取りこぼしが発生します。メンテナンス時間を確保してアプリを停止します。
az webapp stop --resource-group rg-pleasanter --name app-pleasanter
3. ファイル共有からコンテナへコピー
azcopy を使います。ここで注意したいのが認証方式です。
AzCopy が Microsoft Entra ID(azcopy login)で認証できるのは Blob と Data Lake Storage だけで、Azure Files には SAS トークンが必要です。ファイル共有からのコピーでは、azcopy login を済ませていても URL に SAS を付けないと認証エラーになります。
そこで、まずストレージアカウントキーからファイル共有用の SAS を発行します。
$account = 'stpleasanterfiles'
$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-pleasanter `
--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 という組み合わせで問題ありません。
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 File Transfers: 12043
Number of Transfers Completed: 12043
Number of Transfers Failed: 0
Final Job Status: Completed
Failed が 0 で Final Job Status が Completed であることが確認できれば完了です。失敗があった場合は azcopy jobs show <ジョブID> で詳細を確認できます。
azcopy sync を使うと差分だけをコピーできますが、削除の同期(--delete-destination)は使わないでください。コピー元にないファイルがコンテナから消えてしまいます。初回移行では azcopy copy を使うのが安全です。
4. Provider を切り替える
Provider を Local から AzureBlob に変え、接続先を指定します。マウント前提の Path は使われなくなるため消してかまいません。
- "Provider": "Local",
- "Path": "/mnt/pleasanter-files"
+ "Provider": "AzureBlob",
+ "AzureBlobStorageAccountUri": "https://stpleasanterfiles.blob.core.windows.net",
+ "AzureBlobContainerName": "pleasanter-binaries"
設定ファイルではなく環境変数で指定することもできます。指定できるキーは前述の設定記事にまとめています。
5. アプリを起動して動作確認
az webapp start --resource-group rg-pleasanter --name app-pleasanter
移行後に確認しておきたいのは次の 4 点です。新規アップロードだけでなく、既存ファイルの参照を必ず確認してください。コピー漏れはここで初めて表面化します。
- 移行前に登録された添付ファイルがダウンロードできる
- 新規に添付ファイルをアップロードして、ダウンロードできる
- 本文に貼り付けた既存画像が表示される
- サイトアイコン(サイト画像)が表示される
いずれかが失敗する場合は SysLogs を確認します。AzureBlobBinaryStorageProvider は失敗時にエラー内容を SysLogs へ書き出します。
AzureBlob Download failed. Blob=Attachments/xxxxxxxx Status=403 ErrorCode=AuthorizationPermissionMismatch Message=...
| 症状 | 疑うところ |
|---|---|
403 AuthorizationPermissionMismatch |
ロール割り当てのスコープ、または反映待ち |
404(特定のファイルだけ) |
コピー漏れ。azcopy のログを確認 |
| すべてのファイルで失敗 | ストレージアカウント URI・コンテナ名の指定ミス |
接続や認証に連続して失敗すると、AzureBlobBinaryStorageProvider のサーキットブレーカーが働いて 30 秒 → 2 分 → 10 分と段階的にアクセスが遮断されます。移行直後にロール割り当てが未反映だとこの状態になりがちです。権限を直したあとは、遮断時間が明けるのを待つかアプリを再起動してください(回路の状態は静的フィールドで保持されているため、再起動でリセットされます)。
6. マウントを解除する
動作確認が済んだら App Service のパスマッピングを削除します。
az webapp config storage-account delete `
--resource-group rg-pleasanter `
--name app-pleasanter `
--custom-id PleasanterFiles
ファイル共有そのものは、しばらく残しておくことをおすすめします。切り戻しの必要が出たときに、Provider を Local に戻してマウントし直せば元の状態に復帰できるためです。問題がないと判断できてから削除してください。
PowerShell スクリプトでまとめて実行する
手順 2〜3(アプリ停止・SAS 発行・コピー・結果判定)をまとめたスクリプトです。PowerShell 7 以降で動作します。
事前に Azure CLI と AzCopy をインストールし、az login と azcopy login を済ませておいてください。
#Requires -Version 7.0
<#
.SYNOPSIS
プリザンターの添付ファイルを Azure Files から Blob Storage へ移行します。
.DESCRIPTION
App Service を停止し、ファイル共有の内容を Blob コンテナへコピーします。
BinaryStorage.json の書き換えとアプリの起動は行いません(動作確認前に
設定を切り替えたくないため)。コピー結果のみを返します。
.EXAMPLE
.\Migrate-PleasanterBinaries.ps1 `
-ResourceGroup rg-pleasanter `
-WebAppName app-pleasanter `
-StorageAccount stpleasanterfiles `
-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 を付ければ、停止とコピーを実行せずに動作を確認できます。
.\Migrate-PleasanterBinaries.ps1 `
-ResourceGroup rg-pleasanter `
-WebAppName app-pleasanter `
-StorageAccount stpleasanterfiles `
-ShareName pleasanter-attachments `
-WhatIf
移行前に所要時間を測る
本番のメンテナンス時間を見積もるために、検証環境で所要時間を測っておくと計画が立てやすくなります。ファイル共有側の件数と合計サイズは次のように確認できます。
$listed = & azcopy list "https://$account.file.core.windows.net/$share/?$shareSas" --machine-readable
$files = $listed | Select-String 'Content Length'
"ファイル数: $($files.Count)"
azcopy list の出力形式はバージョンによって変わることがあります。件数はあくまで目安として扱い、コピーの成否は前述のジョブサマリ(Number of Transfers Failed と Final Job Status)で判断してください。
移行時に押さえておきたい仕様
既存のカラム別ストレージ設定はそのまま使える
UseStorageSelect を有効にして、項目ごとに格納先(DataBase / LocalFolder など)を指定している環境があります。ここで LocalFolder を選んでいた項目はどうなるのか、という点は気になるところです。
実装を見ると、Provider が AzureBlob のときは LocalFolder 指定が自動的に AzureBlob に読み替えられます。
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;
}
// ...
}
サイト設定側は一切変更不要です。AutoDataBaseOrLocalFolder(サイズによって DB とファイルを使い分ける設定)も同様に、ファイル側の格納先が AzureBlob に切り替わります。
同じ理由で、LocalFolderLimitSize などのサイズ上限も移行後そのまま効きます。判定に使われているのが IsStoreExternal() で、Local と AzureBlob のどちらも同じ枠として扱われるためです。
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;
}
}
移行を機に上限を見直す必要はありません。
DB に入っているファイルは移行対象外
Provider が Rds の時期に登録された添付ファイルや、UseStorageSelect で DataBase を指定した項目のファイルは、Binaries テーブルの Bin 列に入ったままです。今回の移行では触れません。
ダウンロード時は Bin にデータがあればそこから返すフォールバックがあるため、DB 格納のファイルと Blob 格納のファイルが混在していても問題なく動きます。
すべてを Blob に寄せたい場合は別途データ移行が必要ですが、まずはファイル共有にあった分を Blob へ移し、DB にある分はそのままにしておく運用でも支障はありません。
一時ファイルの設定はそのままでよい
ロードバランサ配下で複数インスタンスを動かしていて、TemporaryBinaryStorageProvider を Rds にしている環境があります。この設定は移行後もそのまま使えます。
1.5.7.0 では、レコード保存時に一時ファイルが外部ストレージへ移送されるようになりました。Provider が AzureBlob であれば移送先も Blob になるため、「一時ファイルは DB、確定したら Blob」という構成がそのまま組めます。挙動の詳細は設定記事の該当セクションを見てみてください。
切り戻し手順
動作確認で問題が見つかった場合の切り戻しは単純です。
- アプリを停止する
-
BinaryStorage.jsonをProvider: "Local"とPathに戻す - パスマッピングを解除していた場合は再設定する
- アプリを起動する
移行作業でファイル共有側のファイルを削除していないため、コピー先の Blob を無視すれば元の状態に戻ります。移行後に登録された添付ファイルは Blob 側にしかない点だけ注意してください。切り戻す場合は、その分を Blob からファイル共有へコピーし直す必要があります。
# 移行後に増えた分をファイル共有へ戻す
# 宛先がファイル共有になるため、こちらは書き込み権限つきの SAS が必要
$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
このリスクを小さくするため、移行はアプリを停止したメンテナンス時間内に完了させ、動作確認までを一気通貫で行うのがおすすめです。
まとめ
Azure Files マウント構成から AzureBlob プロバイダへの移行についてまとめました。
- オブジェクト名とローカルフォルダのパスは完全に一致するため、ファイル共有のルート配下をコンテナのルートへコピーするだけで移行できる
-
データベース側の変更は不要。
BinariesテーブルのGuidからオブジェクト名が組み立てられるため、格納先が変わっても参照は壊れない -
UseStorageSelectで項目ごとにLocalFolderを指定していても、ProviderがAzureBlobなら自動で読み替えられる。サイト設定もサイズ上限も変更不要 - DB 格納のファイルと Blob 格納のファイルは混在できるので、DB にある分を無理に移す必要はない
- 切り戻しに備えて、ファイル共有はしばらく残しておく
移行そのものは azcopy 1 回と設定 1 か所の変更で終わります。難しいのは手順よりも、アプリを止めてコピーするタイミングの確保のほうです。添付ファイルの総量が大きい環境では、事前に azcopy の所要時間を検証環境で測っておくと計画が立てやすくなります。
アカウントキーの管理から解放される点だけでも、移行する価値のある変更だと思います。