プラガブルストレージドライバー仕様
概要
現在のストレージ実装は AWS S3 互換オブジェクトストレージ(RustFS)に直接依存している。 本仕様では、ストレージバックエンドを差し替え可能なドライバー方式に改め、以下を実現する。
- ローカルファイルシステム:S3 が設定されていない環境(開発・軽量デプロイ)でのフォールバック
- S3 互換:現行の RustFS / MinIO / AWS S3 などをそのまま継続利用
- 将来の拡張:Google Cloud Storage、Azure Blob Storage など他バックエンドを追加しやすい構造
現状の問題
設計方針
- トレイトによる抽象化:
StorageDriverトレイトを定義し、バックエンドはそれを実装する - 列挙型ディスパッチ:
dyn Traitによる動的ディスパッチではなくenum Storageでバックエンドを保持し、Cloneを容易にする - パスベース入力:アップロード時はバイトストリームではなく一時ファイルのパスを受け取ることで、S3 固有型(
ByteStream)をトレイトから排除する - URL 生成の統一:ダウンロード URL の生成をトレイトメソッドに集約し、バックエンドごとに実装を切り替える
- 自動検出フォールバック:
STORAGE_DRIVER未設定時は S3 接続情報の有無で自動選択
トレイト定義
// apps/api/src/utils/storage/driver.rs
use std::{path::Path, time::Duration};
use anyhow::Result;
#[async_trait::async_trait]
pub trait StorageDriver: Send + Sync {
/// 一時ファイルをストレージにアップロードする。
/// `key` はストレージ内の一意なパス(例: `{user_id}/{file_id}`)。
async fn upload(&self, key: &str, path: &Path, content_type: &str) -> Result<()>;
/// ストレージからオブジェクトを削除する。
async fn delete(&self, key: &str) -> Result<()>;
/// ファイルをダウンロードできる URL を返す。
/// - S3 バックエンド:署名付き URL(期限あり)
/// - ローカルバックエンド:API 経由の署名付きエンドポイント URL(期限あり)
async fn get_download_url(&self, key: &str, expires_in: Duration) -> Result<String>;
}
バックエンド実装
ディレクトリ構造
apps/api/src/utils/storage/
├── mod.rs # Storage 列挙型 + 選択ロジック
├── driver.rs # StorageDriver トレイト定義
├── s3.rs # S3Driver(現 StorageClient を移植)
└── local.rs # LocalDriver(新規)
S3Driver(s3.rs)
現行の StorageClient をリネームして StorageDriver トレイトを実装する。
upload メソッドは受け取ったパスから ByteStream::from_path で変換してから送信する。
pub struct S3Driver { /* 現 StorageClient のフィールドをそのまま */ }
#[async_trait]
impl StorageDriver for S3Driver {
async fn upload(&self, key: &str, path: &Path, content_type: &str) -> Result<()> {
let stream = ByteStream::from_path(path).await?;
// 現行実装と同じ put_object 呼び出し
}
async fn delete(&self, key: &str) -> Result<()> { /* 現行と同じ */ }
async fn get_download_url(&self, key: &str, expires_in: Duration) -> Result<String> {
// 現行 presigned_get_url をそのまま移植
}
}
LocalDriver(local.rs)
ファイルをサーバーローカルのディレクトリに保存する。
pub struct LocalDriver {
pub base_path: PathBuf, // 保存先ディレクトリ(例: ./data/uploads)
pub base_url: String, // API のベース URL(例: http://localhost:3400)
pub secret: String, // 署名付き URL 生成用 HMAC シークレット
}
#[async_trait]
impl StorageDriver for LocalDriver {
async fn upload(&self, key: &str, path: &Path, _content_type: &str) -> Result<()> {
let dest = self.base_path.join(key);
// 親ディレクトリを作成してからコピー
if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await?;
}
// 同一ファイルシステムなら rename(移動)で済ませてディスク I/O を最小化。
// クロスデバイスなど rename が失敗した場合は copy のみ行い、
// 一時ファイルの削除は呼び出し元の NamedTempFile ドロップ(RAII)に委ねる。
if tokio::fs::rename(path, &dest).await.is_err() {
tokio::fs::copy(path, &dest).await?;
}
Ok(())
}
async fn delete(&self, key: &str) -> Result<()> {
let target = self.base_path.join(key);
match tokio::fs::remove_file(&target).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), // 存在しない場合は正常
Err(e) => {
tracing::warn!("ローカルストレージの削除失敗 key={key}: {e}");
Err(e.into())
}
}
}
async fn get_download_url(&self, key: &str, expires_in: Duration) -> Result<String> {
let exp = (Utc::now() + expires_in).timestamp() as u64;
let sig = self.sign(key, exp);
// key はパーセントエンコーディングしてクエリパラメータに安全に埋め込む
let encoded_key = urlencoding::encode(key);
Ok(format!(
"{}/v1/internal/download?key={encoded_key}&exp={exp}&sig={sig}",
self.base_url
))
}
}
ローカルバックエンド用ダウンロードエンドポイント
GET /v1/internal/download?key={key}&exp={unix_ts}&sig={hmac}
expの UNIX タイムスタンプを確認し、期限切れなら 410 Gone を返すsigをHMAC-SHA256(key + ":" + exp, secret)で検証し、不一致なら 403 を返すkeyのパスコンポーネントを検証し、..や絶対パスを含まないことを確認する- 結合後のパス(
base_path.join(key))をcanonicalizeし、base_path自体も事前にcanonicalizeした上でstarts_withで比較する(base_pathにシンボリックリンクや相対パスが含まれる場合のパス・トラバーサル対策。canonicalizeはファイルが存在しない場合エラーになるため 404 を返す) - 検証通過後、
base_path / keyをContent-Disposition: attachmentでストリーミング配信
このエンドポイントはセッション認証不要(署名が認可を代替)。URL を知っていれば期限内は誰でもダウンロードできるため、S3 の署名付き URL と同等の挙動となる。
Storage 列挙型(mod.rs)
#[derive(Clone)]
pub enum Storage {
S3(S3Driver),
Local(LocalDriver),
}
// マクロまたは手動で StorageDriver の各メソッドを列挙型にデリゲートする
#[async_trait]
impl StorageDriver for Storage {
async fn upload(&self, key: &str, path: &Path, content_type: &str) -> Result<()> {
match self {
Self::S3(d) => d.upload(key, path, content_type).await,
Self::Local(d) => d.upload(key, path, content_type).await,
}
}
// delete, get_download_url も同様
}
設定
環境変数
# バックエンドを明示指定(省略時は自動検出)
# STORAGE_DRIVER=s3 # "s3" | "local"
# S3 バックエンド設定(すべて設定されている場合に自動選択)
# S3_ENDPOINT=http://localhost:9000
# S3_ACCESS_KEY=minioadmin
# S3_SECRET_KEY=minioadmin
# S3_BUCKET=hyperdrive
# S3_FORCE_PATH_STYLE=true
# ローカルバックエンド設定
LOCAL_STORAGE_PATH=./data/uploads
LOCAL_BASE_URL=http://localhost:3400
LOCAL_SIGNED_URL_SECRET=<32文字以上のランダム文字列>
ローカル開発用 .env(S3 なし)
DATABASE_URL=postgres://coder@localhost/coder?host=/var/run/postgresql
REDIS_URL=redis://localhost:6379
LOCAL_STORAGE_PATH=./data/uploads
LOCAL_BASE_URL=http://localhost:3400
LOCAL_SIGNED_URL_SECRET=local-dev-secret-change-this-in-production
自動検出ロジック
STORAGE_DRIVER が設定されている
→ 指定されたバックエンドを使用(不正値はエラー)
STORAGE_DRIVER が未設定
→ S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET が
すべて設定されている → S3 バックエンド
→ いずれか欠けている → ローカルバックエンド(警告ログを出力)
LOCAL_SIGNED_URL_SECRET 未設定、または 32 文字未満でローカルバックエンドを選択した場合はサーバー起動時にエラーとする(セキュリティ上必須)。build_storage() 内でドライバー選択後に即座に検証する。
Settings 構造体の変更
#[derive(Clone, Debug, Deserialize)]
pub struct Settings {
pub database_url: String,
pub redis_url: String,
pub allow_origin: String,
// ストレージ共通
#[serde(default)]
pub storage_driver: Option<String>, // "s3" | "local" | None(自動検出)
// S3 設定(すべて Optional に)
pub s3_endpoint: Option<String>,
pub s3_access_key: Option<String>,
pub s3_secret_key: Option<String>,
pub s3_bucket: Option<String>,
#[serde(default = "default_true")]
pub s3_force_path_style: bool,
// ローカル設定
#[serde(default = "default_local_storage_path")]
pub local_storage_path: String,
pub local_base_url: Option<String>,
pub local_signed_url_secret: Option<String>,
}
AppState の変更
// 変更前
pub struct AppState {
pub storage: StorageClient,
// ...
}
// 変更後
pub struct AppState {
pub storage: Storage, // Storage 列挙型(StorageDriver を実装)
// ...
}
ハンドラー側は state.storage.upload(...) / state.storage.delete(...) / state.storage.get_download_url(...) を呼ぶだけで、バックエンドを意識しない。
ハンドラーへの影響
upload_file
// 変更前
let stream = ByteStream::from_path(ff.tmp.path()).await?;
state.storage.upload(&storage_key, stream, &mime).await?;
// 変更後
state.storage.upload(&storage_key, ff.tmp.path(), &mime).await?;
get_file
// 変更前
let url = state.storage.presigned_get_url(&file.url, Duration::from_secs(3600)).await?;
// 変更後
let url = state.storage.get_download_url(&file.url, Duration::from_secs(3600)).await?;
その他のハンドラーへの変更はなし(delete のシグネチャは変わらない)。
将来のバックエンド追加手順
apps/api/src/utils/storage/gcs.rs等を追加しStorageDriverを実装Storage列挙型にバリアントを追加- 自動検出ロジックに分岐を追加
- 環境変数を
Settingsに追加
トレイト定義・ハンドラー・AppState には変更不要。
移行計画
未決事項
LOCAL_SIGNED_URL_SECRETのローテーション戦略(複数シークレット対応)- ローカルバックエンドでの大容量ファイルストリーミング時のメモリ効率
- ローカルバックエンドの保存ディレクトリのパーミッション管理(Docker 環境での UID 問題)