ashineko

オブジェクトストレージの上のファイルマネージャ — 共有、コメント、サムネイル、そしてトランスコードのワーカー

会員組織のためのDropbox風のファイル置き場をS3互換ストレージの上に。6GBまでストリームで流し込むアップロード、WebPのサムネイル、systemdのワーカーで回すHEVC→H.264変換。

2026 · storageapi

アップロード上限
6 GB
500MBから、ストリームで
縮小率
~136×
76.3MBが560KBに
変換後のクリップ
1.4 MB
274MBの8K原本から
バックエンドのテスト
300
共有とコメントを支える

背景

MemberHubは会員組織に19のツールを渡す一つのDjangoモノリスで、Filesはその中でいちばん素朴なものです。組織ごとのファイル置き場に、入れ子のフォルダ、ドラッグ&ドロップのアップロード、スター、ゴミ箱、操作履歴、そして公開の共有リンクがついています。

制約は、バイトがどこにあるかということでした。ファイルは行でもローカルディスクでもなく、S3互換のバケットの中のオブジェクトで、その前に立つアプリケーションは、それを書いた本人が運用する同期型のプロセスが一つきり。タスクキューもワーカー群も、二つ目のデプロイもありません。以後に足したものは、すべてその中に収まる必要がありました。アップロードごとに二つの画像派生物、動画のトランスコード、招待した相手だけに絞れる共有、ファイル単位のコメント、そして500MBから6GBへ上がった一ファイルの上限です。

もう一つ、プラットフォーム全体を形づくった前提があります。オブジェクトのキーだけを保存し、URLは読むたびに署名していたツールは、Filesだけでした。ほかの八つは、パターンをコピーした結果、署名済みURLそのものを保存していたのです。

作ったもの

モデルは四つ。どれも組織への外部キーを持っていて、テナントの境界がすべての行の性質になっています。自分自身を親に持てるフォルダ、オブジェクトのキーとメタデータを持つファイル、43文字のURLセーフなトークンと任意のパスワードを持つ共有リンク、そして15種類の操作を記録する履歴の行です。

オブジェクトのキーは組織ごとの名前空間の下でランダムに作られ、人間が読むファイル名はデータベースの行にしかありません。だから名前の変更はストレージに一切触らない一列の更新で、移動は外部キーの張り替えだけ。バケットに触るのはコピーだけです。削除は日時を刻むだけで、一覧はすべてそれで絞り込みます。フォルダを削除すればその下の全部に日時が刻まれ、親がまだゴミ箱にいるものを復元すると、見えない場所に蘇るのではなくルートに付け替えられます。

仕掛けが集まっているのはアップロードです。ブラウザはリバースプロキシ越しにファイルをアプリケーションへPOSTし、アプリケーションはそれをマルチパートの転送としてバケットへ流し込みます。同じリクエストの中でWebPの派生物が二つ作られ — 400pxのサムネイルと1600pxのミディアム、拡大は決してせず、組織の容量にも数えません — 同時に動画は調べられて、H.264でないコーデックなら行にpendingが立ちます。

  1. アップロードプロキシ越しにマルチパート転送でバケットへ流し込む
  2. 派生物400pxのサムネイルと1600pxのミディアムを同じリクエストで
  3. プローブH.264でないコーデックなら行にpending
  4. トランスコード5分ごとのタイマーがpendingを一件だけ取る
  5. ready1080pのH.264。プレビューはこちらを優先

5分ごとにタイマーが起こすワーカーが、ロック済みの行を飛ばす行ロックの下でpendingを一件だけ取り、1080pのH.264に、索引をファイルの先頭に置いた形で変換し、結果を書き戻してreadyにします。プレビューは変換済みの方を優先し、なければ元のファイルに戻ります。これを出した直後に来た最初の苦情は、あるブラウザでは再生できて別のブラウザでは再生できない動画でした。原因はコーデックではなくバケットのクロスオリジン設定です。片方のブラウザは壊れたCORS設定を隠し、もう片方はそれを暴きます。

フォルダ共有トークン一つ。公開か限定かは保存したフラグ公開リンクを持つ誰でも閲覧できる限定招待したアドレスと現任のスタッフだけ宛先を外す招待とリンクは死ぬ。解除しても権利は戻らない
  • フォルダ共有 — トークン一つ。 公開か限定かは保存したフラグ
  • 公開 — リンクを持つ誰でも閲覧できる
  • 限定 — 招待したアドレスと 現任のスタッフだけ
  • 宛先を外す — 招待とリンクは死ぬ。 解除しても権利は戻らない
  • フォルダ共有 → 公開
  • フォルダ共有 → 限定
  • 公開 → 宛先を外す
  • 限定 → 宛先を外す
共有のしくみ

設計上の判断

キーを保存し、読むときに署名する。 データベースはダウンロードURLを持ちません。レスポンスのたびに新しい署名付きURLを作り、署名する関数は許可された接頭辞の外にあるキーを拒みます。渡されたキーを何でも署名するようになった時点で、それは署名のオラクルです。許可リストは仕上げの飾りではなく、この設計のもう半分です。

派生物は同期で、変換は非同期で。 画像のリサイズ二つなら、アップロードしてきたリクエストの中に収まります。動画の変換は収まらないので、コマンド一つとタイマー一つ、一回につき一ファイルという形になりました。新しい依存は増えていません。正直に書いておく歪みは中間の場合で、動画のアップロードは今も静止画を作るために数十秒をリクエストの中で使うことがあります。引き継いだ設計判断で、まだ誰も裁定していません。

制限は保存されたフラグであって、導き出すものではない。 共有は「公開」か、招待したアドレスの一覧に絞られた「限定」かのどちらかで、招待された人にはそれぞれ個人宛のマジックリンクが届きます。もし「限定」が「宛先が一件以上ある状態」を意味していたら、最後の一人を外した瞬間に、リンクを持つ誰にでも非公開のアルバムが再公開されてしまいます。

取り消しの解除は本人であることを証明し直すだけで、権利を戻してはならない。 二人のレビュアーが、互いに反対の側から同じことを見つけました。取り消しを解除しても招待そのものが残るので、外された相手が自助的な経路から戻ってくると、スタッフより強い立場で復帰してしまうのです。いまは行の格を落とし、メールで送られたマジックリンクもそこで死にます。

うまくいかなかったこと

八つのツールが、資格情報を識別子として保存していた。 プラットフォーム中のカバー画像が、一週間はきれいに表示されて、それから黙って壊れました。どのフィールドも署名済みURLをまるごと持っていて、署名付きURLには期限があります。既定は7日、上限そのものでした。持続する事実は50文字ほどのオブジェクトのキーの方で、一つのツールが儚い方を保存した時点で、続く七つはそれをただで受け継ぎました。修正はフィールド名もAPIの形も凍結したまま保存の契約を裏返すもので — フロントエンドは一行も変わっていません — 入力は裸のキーでも完全な署名付きURLでも受け取ります。それが二つ目のバグも同時に消しました。編集フォームは保存のたびに値を送り返すので、更新のたびにクライアントが握っていたURLが書き戻されていたのです。三つ目はレビューでしか出てきませんでした。マイグレーションがその列を512文字に狭めていた一方、署名付きURLは600〜900文字になります。一度でもアップロードしたことのあるテナントでは、その変更は必ず失敗していました。

派生物が片方だけ。 同じ欠陥が三回出ました。アップロードの経路で一度、埋め戻しのコマンドでもう一度。サムネイルを書いてからミディアムを書くループで、一つ目が成功し二つ目が失敗すると、バケットには実体があるのに行のキーは空、という状態が残ります。直し方はキーごとに例外を捕まえること。書けた段だけでも記録されます。同じレビューはもっと悪いものも見つけました。そのリリースでいちばん結果の重い規則を守っていたテストが、実装が存在しないREDの段階で通っていたのです。フィクスチャが、間違った答えを観測できないようにしていました。落ちようのないテストは、カバレッジではありません。

パーセントエンコードされた一文字が、アップロードの制限を破った。 上限を6GBに上げるということは、リバースプロキシに数ギガバイトの本文を溜めさせるということです。そこで巨大な本文の許可と三並列までの同時接続制限をアップロードのエンドポイントに絞ったのですが、最初の実装はそれを生のリクエスト行で判定していました。サーバーがロケーションを照合するのはデコード済みのパスです。つまり "upload" の一文字をエンコードするだけで、巨大な本文が許されたまま制限のかからない本物のアップロードのビューに届き、同じ接頭辞の下にある別の実在するエンドポイントにも、認証なしで同じことができました。独立した二人のレビュアーが見つけ、いまは許可も制限も正規化されたパスで照合するロケーションにしか置いていません。教訓は、セキュリティ機構をハッピーパスで確かめても検証にはならない、ということです。

結果

サムネイルは狙いどおりに効きました。写真22枚のフォルダで、原本76.3MBがサムネイル560KBに、80ピクセルのタイルを並べるためのデータが約136分の1になり、既存ファイルへの埋め戻しは40件更新・0件失敗でした。トランスコードのワーカーは、274MB・22.86秒の8Kクリップを1.4MB・1080pのH.264、22.87秒に変えました。長さが一致していることが、途中で切れていない証拠です。所要はおよそ200秒の裏側の処理でした。

上限は6GBで、1.2GBのアップロードがバケットに着地するところまで通しで確かめ、それを守るすべての制限は突破の一覧表で検証し直しました。共有とコメントは、300件のバックエンドのテストと、アップロード失敗の経路を見るブラウザのハーネスの後ろで出ています。

まだ返していない宿題も隠さずに書いておきます。アップロード時に本当に効いている門はファイル数であって、容量ではありません。そして閲覧のたびに新しい署名付きURLを作るので、キャッシュのキーが毎回変わり、フォルダを開き直すたびにブラウザはサムネイルを取り直します。後者は期限を一定の窓に丸めれば直りますが、いまは意図的に外してあります。