32blogby StudioMitsu
ffmpeg15 min read

VPSでFFmpegエンコードサーバーを構築する

VPSにFFmpegをインストールしてエンコードサーバーを構築する方法。ウォッチフォルダ、systemdサービス、簡易APIまで実装する。

FFmpegVPS動画エンコードサーバー構築自動化
目次

ローカルPCで動画をエンコードしていると、CPUが占有されてほかの作業ができなくなる。エンコードが終わるまでの数十分、PCがまともに動かなくなる経験は誰でもあるはずだ。

この記事では、VPSにFFmpegエンコードサーバーを構築して、ローカルPCの負荷をゼロにする方法を僕が解説する。ウォッチフォルダで自動エンコード、systemdで常駐化、簡易REST APIでリモートからジョブ投入まで、一通り実装する。

この記事でわかること

  • VPSでFFmpegエンコードサーバーを動かす理由とメリット
  • スペック選定の考え方
  • FFmpegのインストールから初期設定まで
  • inotifywaitを使ったウォッチフォルダの実装
  • systemdサービスで24時間常駐化する方法
  • FastAPIで簡易REST APIを構築する方法
  • 本番運用で必要なセキュリティと監視の設定

なぜVPSでエンコードするのか

ローカルエンコードの問題は「PCが使えなくなる」だけじゃない。夜中に走らせておいて翌朝完了しているはずが、スリープしてエンコードが止まっていた、という経験もあるはずだ。

VPSエンコードサーバーの利点は3つある。

ローカルPCが解放される。エンコードはVPS上で動くので、手元のPCはWebブラウジングや開発作業に使い続けられる。ファイルをアップロードしてしまえばそれで終わりだ。

24時間稼働が保証される。VPSはデータセンターで動いている。スリープも電源断もない。長時間エンコードも確実に完走する。

スケールしやすい。処理が追いつかなくなったらVPSのスペックを上げるか、台数を増やせばいい。ローカルPCの買い替えよりずっと柔軟だ。

コスト的には月500〜2,000円程度のVPSで、一般的な動画処理は十分こなせる。専用エンコードPCを買うより格段に安い。

VPSの選び方とスペック目安

エンコード用VPSはCPUコア数が最重要だ。FFmpegはマルチスレッドで動くので、コア数が多いほど速くなる。

スペックの目安

用途CPUメモリストレージ
テスト・個人用2コア2GB50GB SSD
中規模(日数本処理)4コア4GB100GB SSD
本格運用8コア以上8GB以上200GB+ SSD

GPUエンコード(NVENC、VAAPI)を使いたい場合はGPU搭載プランが必要だが、月額が跳ね上がる。CPU専用でも libx264libx265 で十分な品質は出せる。

国内おすすめVPS

  • ConoHa VPS — 512MB〜のプランがあり、用途に合わせて選びやすい
  • Xserver VPS — 国内最安クラス。2コア/2GBで月数百円台から
  • さくらのVPS — 老舗。安定性が高く長期利用向き

OSはUbuntu 22.04 LTSを選ぶ。サポートが2027年まであり、ドキュメントも豊富だ。

FFmpegのインストールと初期設定

VPSにSSHでログインしたら、まず必要なパッケージを入れる。

bash
# システムを最新化
sudo apt update && sudo apt upgrade -y

# FFmpeg と関連ツールをインストール
sudo apt install -y ffmpeg inotify-tools python3-pip python3-venv

# バージョン確認
ffmpeg -version

FFmpegのバージョンは 6.x 系が入る。ffmpeg version 6.x と表示されれば成功だ。

次に作業用のディレクトリ構成を作る。

bash
# エンコードサーバー用のディレクトリ構造を作成
sudo mkdir -p /opt/encoder/{watch,processing,done,failed,logs}

# 実行ユーザーを作成(rootで動かさない)
sudo useradd -r -s /bin/false encoder

# ディレクトリのオーナーを変更
sudo chown -R encoder:encoder /opt/encoder

ディレクトリの役割は次のとおりだ。

  • watch/ — ここにファイルを置くとエンコードが始まる
  • processing/ — エンコード中のファイルが一時的に移動される
  • done/ — エンコード完了後のファイルが入る
  • failed/ — 失敗したファイルが入る
  • logs/ — ログファイルの置き場

FFmpegのエンコードオプションは用途によって変わる。汎用的な設定をシェルスクリプトにまとめておくと管理しやすい。

bash
# /opt/encoder/encode.sh を作成
sudo tee /opt/encoder/encode.sh > /dev/null << 'EOF'
#!/bin/bash
set -euo pipefail

INPUT="$1"
BASENAME=$(basename "$INPUT" | sed 's/\.[^.]*$//')
OUTPUT="/opt/encoder/done/${BASENAME}_encoded.mp4"

ffmpeg -i "$INPUT" \
  -c:v libx264 \
  -preset slow \
  -crf 23 \
  -c:a aac \
  -b:a 128k \
  -movflags +faststart \
  "$OUTPUT" \
  2>> /opt/encoder/logs/ffmpeg.log

echo "Done: $OUTPUT"
EOF

sudo chmod +x /opt/encoder/encode.sh
sudo chown encoder:encoder /opt/encoder/encode.sh

-crf 23 は品質と圧縮率のバランスが取れた値だ。数値を下げると高品質・大容量、上げると低品質・小容量になる。18〜28が実用的な範囲だ。

ウォッチフォルダで自動エンコード

inotifywait コマンドでウォッチフォルダを実装する。ファイルが watch/ に配置されたタイミングで自動的にエンコードを開始する。

bash
# /opt/encoder/watch.sh を作成
sudo tee /opt/encoder/watch.sh > /dev/null << 'EOF'
#!/bin/bash
set -euo pipefail

WATCH_DIR="/opt/encoder/watch"
PROCESSING_DIR="/opt/encoder/processing"
FAILED_DIR="/opt/encoder/failed"
LOG="/opt/encoder/logs/watch.log"

log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"
}

log "Encoder watch started. Watching: $WATCH_DIR"

inotifywait -m -e close_write --format '%f' "$WATCH_DIR" | while read -r FILENAME; do
  FILEPATH="${WATCH_DIR}/${FILENAME}"

  # 対応拡張子のみ処理(mp4, mkv, mov, avi)
  EXT="${FILENAME##*.}"
  EXT_LOWER=$(echo "$EXT" | tr '[:upper:]' '[:lower:]')
  if [[ ! "$EXT_LOWER" =~ ^(mp4|mkv|mov|avi)$ ]]; then
    log "Skipped (unsupported format): $FILENAME"
    continue
  fi

  log "Detected: $FILENAME"

  # processing ディレクトリへ移動
  PROC_PATH="${PROCESSING_DIR}/${FILENAME}"
  mv "$FILEPATH" "$PROC_PATH"
  log "Moved to processing: $FILENAME"

  # エンコード実行
  if /opt/encoder/encode.sh "$PROC_PATH"; then
    rm -f "$PROC_PATH"
    log "Success: $FILENAME"
  else
    mv "$PROC_PATH" "${FAILED_DIR}/${FILENAME}"
    log "Failed: $FILENAME — moved to failed/"
  fi
done
EOF

sudo chmod +x /opt/encoder/watch.sh
sudo chown encoder:encoder /opt/encoder/watch.sh

close_write イベントを使う理由は、create イベントだとファイルが書き込み途中でも発火してしまうからだ。close_write は書き込みが完了してファイルが閉じられたときに発火する。SCPやrsyncでファイルを転送する場合も、転送完了後に正しく動く。

動作確認はこう行う。

bash
# watch.sh をテスト実行
sudo -u encoder /opt/encoder/watch.sh &

# 別ターミナルでテストファイルを配置
cp /path/to/test.mp4 /opt/encoder/watch/

# ログを確認
tail -f /opt/encoder/logs/watch.log

systemdサービスで常駐化する

ウォッチスクリプトを手動で起動するのは運用として成立しない。systemdサービスとして登録して、OS起動と同時に自動スタートさせる。

ini
# /etc/systemd/system/encoder.service を作成
[Unit]
Description=FFmpeg Encoding Watch Service
After=network.target

[Service]
Type=simple
User=encoder
Group=encoder
ExecStart=/opt/encoder/watch.sh
Restart=on-failure
RestartSec=5s
StandardOutput=append:/opt/encoder/logs/systemd.log
StandardError=append:/opt/encoder/logs/systemd.log

# セキュリティ強化
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/encoder
PrivateTmp=true

[Install]
WantedBy=multi-user.target

サービスを有効化する。

bash
# サービスファイルをsystemdに読み込ませる
sudo systemctl daemon-reload

# 起動時の自動スタートを有効化
sudo systemctl enable encoder.service

# 今すぐ起動
sudo systemctl start encoder.service

# 状態確認
sudo systemctl status encoder.service

Active: active (running) と表示されれば成功だ。ログは journalctl でも確認できる。

bash
# リアルタイムログ確認
sudo journalctl -u encoder.service -f

# 過去ログの確認
sudo journalctl -u encoder.service --since "1 hour ago"

簡易REST APIでリモート投入

SCPでファイルをウォッチフォルダに転送するのは確実だが、もっと手軽にエンコードを投入したい場合はREST APIが便利だ。FastAPIで簡単なAPIサーバーを作る。

まずFastAPIをインストールする。

bash
# Python仮想環境の作成
python3 -m venv /opt/encoder/venv

# FastAPIとUvicornをインストール
/opt/encoder/venv/bin/pip install fastapi uvicorn python-multipart

APIサーバーのコードを書く。

python
# /opt/encoder/api.py
import os
import shutil
import subprocess
from pathlib import Path

from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
from fastapi import Security, Depends

app = FastAPI(title="FFmpeg Encoding API")

# 環境変数からAPIキーを取得
API_KEY = os.environ.get("ENCODER_API_KEY", "")
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)

WATCH_DIR = Path("/opt/encoder/watch")
DONE_DIR = Path("/opt/encoder/done")
FAILED_DIR = Path("/opt/encoder/failed")
PROCESSING_DIR = Path("/opt/encoder/processing")


def verify_api_key(key: str = Security(api_key_header)) -> str:
    if not API_KEY:
        raise HTTPException(status_code=500, detail="API key not configured")
    if key != API_KEY:
        raise HTTPException(status_code=403, detail="Invalid API key")
    return key


@app.post("/encode")
async def submit_encode(
    file: UploadFile = File(...),
    _: str = Depends(verify_api_key),
) -> JSONResponse:
    """動画ファイルをウォッチフォルダに投入する"""
    allowed_exts = {".mp4", ".mkv", ".mov", ".avi"}
    ext = Path(file.filename).suffix.lower()

    if ext not in allowed_exts:
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported file type: {ext}. Allowed: {allowed_exts}",
        )

    dest = WATCH_DIR / file.filename
    with dest.open("wb") as f:
        shutil.copyfileobj(file.file, f)

    return JSONResponse(
        status_code=202,
        content={"status": "queued", "filename": file.filename},
    )


@app.get("/status")
async def get_status(_: str = Depends(verify_api_key)) -> JSONResponse:
    """各フォルダのファイル数を返す"""
    return JSONResponse(
        content={
            "watching": len(list(WATCH_DIR.iterdir())),
            "processing": len(list(PROCESSING_DIR.iterdir())),
            "done": len(list(DONE_DIR.iterdir())),
            "failed": len(list(FAILED_DIR.iterdir())),
        }
    )


@app.get("/health")
async def health_check() -> JSONResponse:
    """ヘルスチェック(認証不要)"""
    return JSONResponse(content={"status": "ok"})

APIサーバーもsystemdサービスとして登録する。

bash
# APIサービスのsystemdユニットファイル
sudo tee /etc/systemd/system/encoder-api.service > /dev/null << 'EOF'
[Unit]
Description=FFmpeg Encoding API Server
After=network.target

[Service]
Type=simple
User=encoder
Group=encoder
WorkingDirectory=/opt/encoder
Environment="ENCODER_API_KEY=your-secret-key-here"
ExecStart=/opt/encoder/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable encoder-api.service
sudo systemctl start encoder-api.service

APIキーは ENCODER_API_KEY 環境変数で設定する。ランダムな文字列を使う。

bash
# ランダムなAPIキーを生成
openssl rand -hex 32

APIの使い方はこうだ。

bash
# ファイルを投入
curl -X POST http://your-vps-ip:8000/encode \
  -H "X-API-Key: your-secret-key-here" \
  -F "file=@/path/to/video.mp4"

# ステータス確認
curl http://your-vps-ip:8000/status \
  -H "X-API-Key: your-secret-key-here"

外部からアクセスする場合は、Nginxをリバースプロキシとして立ててHTTPS化することを強く推奨する。

本番運用の注意点

ディスク容量の管理

動画ファイルはディスクを圧迫しやすい。done/ フォルダのファイルは定期的に削除するか、転送後に手動で消す運用を決めておく。cronで自動削除する場合は次のとおりだ。

bash
# 7日以上前のファイルを削除するcron(crontab -e で設定)
0 3 * * * find /opt/encoder/done -type f -mtime +7 -delete

リソース制限

エンコードが同時に複数走るとCPUが飽和する。ウォッチスクリプトにセマフォを入れて同時実行数を制限することを検討しよう。nice コマンドでエンコードプロセスの優先度を下げるのも有効だ。

bash
# encode.sh の ffmpeg 実行を nice で低優先度に変更
nice -n 10 ffmpeg -i "$INPUT" ...

ログローテーション

/opt/encoder/logs/ 以下のログは放置すると肥大化する。logrotateで管理する。

bash
# /etc/logrotate.d/encoder を作成
sudo tee /etc/logrotate.d/encoder > /dev/null << 'EOF'
/opt/encoder/logs/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
}
EOF

ファイル転送方法

ローカルPCからウォッチフォルダへファイルを転送する最もシンプルな方法はSCPだ。

bash
# ローカルからVPSのウォッチフォルダへ転送
scp /path/to/video.mp4 user@your-vps-ip:/opt/encoder/watch/

SSHの公開鍵認証を設定しておけば、パスワード入力なしで転送できる。頻繁に使うなら ~/.ssh/config にエイリアスを登録しておくと快適だ。

関連記事:

まとめ

VPSにFFmpegエンコードサーバーを構築する手順を解説した。

  • なぜVPSか: ローカルPCを解放、24時間稼働、柔軟なスケール
  • スペック目安: 個人用は2コア/2GB、本格運用は8コア以上
  • FFmpegインストール: apt install ffmpeg inotify-tools で一発
  • ウォッチフォルダ: inotifywait -e close_write で転送完了を正確に検知
  • systemd: encoder.service で起動時自動スタート、クラッシュ時自動再起動
  • REST API: FastAPI + APIキー認証でリモートからジョブ投入
  • 本番運用: ディスク管理、リソース制限、ログローテーション

最小構成から始めて、必要に応じてAPIを拡張していくのがおすすめだ。僕もこの構成からスタートして、徐々に拡張した。VPSのスペックを上げれば同じコードのまま処理速度が上がるのも、このアーキテクチャの利点だ。