Laravelとtus.ioで100GBファイルをアップロードする

投稿日 最終更新日

2025/03/28

LaravelでぺけコレクションというNAS的アプリを作ってるんだけど、100GBとかある動画ファイルのアップロードが意外と難しかった。
私はtus.ioを使ってアプローチしたのでその方法を解説する。

また、具体的な実装はペケコレ自体がOSSなので、そちらを見ていただければと思う。

大きいファイルのアップロードは難しい

Laravelで100GBのファイルをアップロードできるようにしたいときどうするだろうか。

php.iniで

upload_max_filesize=100G
post_max_size=100G

とするといけそうだが、以下の問題がある。

  1. メモリを莫大に食う
  2. 1回の通信に時間がかかる
  3. 途中でアップロードに失敗すると、また1から再アップロードが必要

これ等を回避する為、一般的にファイルを分割したアップロード方法が採用される。  

そんな分割アップロードをサポートするのがtus.ioというプロトコルである。

tus.ioとは

tusは再開可能なファイルアップロード…つまりアップロードの中断・再開が可能なファイルアップロードのHTTPプロトコル。

tus.ioを使うと100GBのファイルでも良い感じに分割してアップロードできる。

tus自体はプロトコルなので、このtusプロトコルを使ったライブラリが沢山存在する。

その中にはankitpokhrel/tus-phpというPHP用のライブラリもあるんだけど、今回は公式が提供するtusdtus-js-clientを利用する。

PHPのライブラリを使わない理由は、最近メンテがされていないのと、公式のものでも十分対応できそうだったから。

Laravelとtusの組み合わせ方

tus自体はLaravel・PHPとシナジーがあるわけでは無い。

ただ、tusのサーバーであるtusdはHTTPフックを用いた通信が可能で、特定のタイミングでLaravelと通信ができる。

フックの公式ドキュメントの通り、ファイルアップロード前やアップロード後にフックがあるので、認証等の前処理とか、圧縮やDB保存等の後処理もできるよっていう感じ。

つまり、tus.io + Laravelを使ったアップロードは以下のような流れになる

  1. ブラウザからtus-js-clientを使ってtusdへファイルアップロード開始
  2. tusdが特定のタイミングでLaravelへHTTPフックを送信
  3. LaravelがHTTPフックを元に処理

Dockerでtusdを実装する

tusdはtusのバックエンド、つまりサーバー。

フロントからtus-js-clientを使いこのサーバーに対しアップロードリクエストを送る。

Dockerfileは作らず、docker-compose.yamlで直接imageを取ってる。

私の実装を一般化したのが以下。

  tus:
    container_name: tus-container
    image: tusproject/tusd:latest
    ports:
      - "1080:1080"
    volumes:
      - ../storage/app/public:/public
    networks:
      - backend
    command:
      [
        "--port=1080",
        "--hooks-http=http://laravel/api/tusd-hooks",
        "--hooks-http-forward-headers=X-CSRF-TOKEN,Cookie",
        "--upload-dir=/public",
        "--base-path=/uploads",
        "--cors-allow-origin=http://127.0.0.1",
        "--cors-allow-credentials",
        "--cors-allow-headers=X-CSRF-TOKEN"
      ]
    working_dir: /public

各オプションの説明を

  1. --port=1080
    デフォが1080ぽいので、明示的に1080にしている。
  2. --hooks-http=http://laravel/api/tusd-hooks
    HTTPフックの送信先。つまり、Laravelへのエンドポイント。
    私は/api/tusd-hooksにした。
  3. --hooks-http-forward-headers=X-CSRF-TOKEN,Cookie
    HTTPフックを送るとき、HTTPヘッダーを転送する設定。
    つまり、「ブラウザ→tusd→Laravel」のように通信が行われるけど、ブラウザ→tusdについてたヘッダーの値をLaravelに転送できる。CSRFとユーザー認証のためにこの2つを設定。
  4. --upload-dir=/public
    アップロードされるディレクトリの設定。
  5. --base-path=/uploads
    アップロードするエンドポイントの設定。
    これだと、http://127.0.0.1:1080/uploadsがエンドポイントになる感じ。
  6. --cors-allow-origin=http://127.0.0.1
    tusdコンテナとLaravelサーバーはポートが違うのでCORSにひっかかる。
    そのため、LaravelサーバーのURLをCORS-Originで許可する必要あり。
  7. --cors-allow-credentials
    これをつけないとCORSのCookieを読み取らない
  8. --cors-allow-headers=X-CSRF-TOKEN
    Cookieはcredentialsで許可しているが、それ以外のCORSヘッダーで読み取り許可するもの。

これでdocker compose upしてもらえば127.0.0.1:1080でtusdへアクセス可能に。

Laravelの実装

各実装の具体的なものはペケコレを見てもらう感じで、ここでは抽象的な解説をする。

フロントエンドの実装

tus-js-clientを使ってtusdと通信をする。
tus-js-client自体は

npm install tus-js-client

とかでインストール。

フロントについて詳しくはtus-js-clientのドキュメントを参照

tus-js-clientでは以下のようにfileオブジェクトを引数にtus.Uploadでアップロード処理を書く。

import * as tus from "tus-js-client";

const upload = new tus.Upload(file, {
    endpoint: "http://127.0.0.1:1080/uploads/",
    // エラー時に何ミリ秒後リトライするか。複数個指定可能
    retryDelays: [2000],
    // サーバーへ自由に渡せるデータ。ここではファイル名とmimeTypeを渡している。
    metadata: {
        filename: file.name,
        mimetype: file.type,
    },
    // リクエスト送信前に、引数に送る予定のリクエストを取るメソッド。
    onBeforeRequest: function (req) {
        var xhr = req.getUnderlyingObject()
        // withCredentialsを有効にしてCookieを送っている
        xhr.withCredentials = true

        // CSRF-TOKENもヘッダーに付与してる
        xhr.setRequestHeader("X-CSRF-TOKEN", csrfToken);
    },
    onError: (error) => {
        console.error("アップロード失敗:", error);
    },
    onSuccess: () => {
        console.log("アップロード完了:", upload.file.name);
    },
});

upload.start();

これをvueやreactやbladeと組み合わせればアップロード機構は完成。

route

api.phpでルート設定。
中身は以下の様な感じ

Route::post('tusd-hooks', [MediaFileController::class, 'tusdHooks'])
    ->middleware(["web", "auth"])
    ->name('tusd-hooks');

認証方法はsanctumではなく、普通にweb認証を実装。
Cookieを使って認証するのでこれで問題なく認証できる。

Request

一般的なpostリクエストと同じくバリデーションが可能。

tusは初期設定だと1ファイルのアップロードあたり

  1. pre-create
  2. post-create
  3. post-receive
  4. post-finish
  5. post-terminate (アップロードキャンセル時のみなので、今回は使わないフック)

の計5つのフックを送ってくる。

そのため、一応簡易的にバリデーションをすると以下のような感じ。

<?php

namespace App\Http\Requests\MediaFile;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;


class TusdHookRequest extends FormRequest
{
    public function authorize(): bool
    {
        return \Auth::check();
    }

    public function rules(): array
    {
        return [
            "Type" => [
                "required",
                "string",
                Rule::in([
                    "pre-create",
                    "post-create",
                    "post-receive",
                    "post-finish",
                    "post-terminate"
                ]),
            ]
        ];
    }
}

コントローラー

tusのフック

tus.ioのフックには、レスポンス帰ってくるまで処理をブロックするフックとブロックしないフックがある。
また、順序が定まっているフックと定まっていないフックもある。

tus.ioのドキュメントを引用すると

フック名ブロックトリガーのタイミングデフォルト
pre-create新しいアップロードが作成される前
post-create✕ 新しいアップロードが作成された後○ 
post-receive✕ データ送信中に定期的に○ 
pre-finish○ 全データ受信後、レスポンス送信前✕ 
post-finish✕ 全データ受信後、レスポンス送信後○ 
pre-terminate○ アップロードが終了(削除)される直前✕ 
post-terminate✕ アップロードが終了(削除)された後○ 

という感じ。
デフォルトがバツなのは、設定で有効にしないと送られてこないやつ。

めっちゃ大事なのはpre-createフックで、このフックに対し200台以外のステータス、または"RejectUpload" => trueをレスポンスするとそのアップロードはキャンセルされるし、レスポンスが来るまでアップロード処理は開始されない。 しかも、必ずpre-createは一番最初のフックとして送信されてくる。

つまり、これでバリデーション処理が可能。

前処理の実装

簡易的にだけど、以下のように実装できる。

    public function tusdHooks(TusdHookRequest $request)
    {
        $type = $request->input('Type');

        switch ($type) {
            case "pre-create":
                $isValidate = validation($request);

                if(! $isValidate) {
                     return response()->json([
                        "HTTPResponse" => [
                            "StatusCode" => 403,
                            "Body" => json_encode(["message" => "バリデーションエラーだよ"]),
                            "Header" => [
                                "Content-Type" => "application/json"
                            ]
                        ],
                        "RejectUpload" => true
                    ], 200);
                }

                // ChangeFileInfo.Storage.Pathを指定するとそのパスに保存してくれる
                return response()->json([
                    "status" => 200,
                    "ChangeFileInfo" => [
                        "Storage" => [
                            "Path" => "hoge/hoge.mp4",
                        ]
                    ],
                ]);
            case "post-create":
                return response()->json(["status" => 200]);
            case "post-receive":
                return response()->json(["status" => 200]);
            case "post-finish":
                // 後で実装
            case "post-terminate":
                return response()->json(["status" => 200]);
        }

        return response()->json(["status" => 500]);
    }

後処理の実装

post-finishフックが来たらアップロードが完了したということなので、このフックで後処理を実装する。
やることは主に以下

  1. tusがアップロードで使ったinfoファイルの削除
  2. DBにファイル情報を保存

一応簡易的に実装すると以下。
かなり簡易的なので、ファイル削除とかは実際もっと慎重にやったほうが良い。

    public function tusdHooks(TusdHookRequest $request)
    {
        $type = $request->input('Type');

        switch ($type) {
            case "pre-create":
                // 省略
            case "post-create":
                return response()->json(["status" => 200]);
            case "post-receive":
                return response()->json(["status" => 200]);
            case "post-finish":
                $filePath = $request->input("Event.Upload.Storage.Path");
                $infoPath = $request->input("Event.Upload.Storage.InfoPath");

                Storage::delete($infoPath);
                MediaFile::create(["filePath" => $filePath]):
                return response()->json(["status" => 200]);
            case "post-terminate":
                return response()->json(["status" => 200]);
        }

        return response()->json(["status" => 500]);
    }

実際に100GBを送ってみる

今回はffmpegで以下のように同じ画像をループさせて出来た無圧縮104.3GB動画をアップロードする。

ffmpeg -loop 1 -i sample.png \
  -vf "scale=3840:2160,format=yuv420p" \
  -t 360 -c:v rawvideo -y output.avi

104GBの画像

実際のアップロード中の画面が以下のような感じで、全く問題なくアップロードできるし、中断・再開も可能。 アップロード中の画像

アップロード後の後処理も問題なくでき、ファイルもアップロードされていた。いいね。

おわりに

そんな感じでLaravelとtus.ioを組み合わせたアップロード機能を作ったよというお話でした。

バリデーションとか認証関係が少し厄介で、本番環境で動かすとなるともう少し工夫は必要なのかなと言う感じ。

それでは。