「社内の監視カメラ映像を1つの画面でまとめて見たい」——これは僕が実務で実際に取り組んだ課題だ。
複数のIPカメラからRTSPでストリームを取得し、FFmpegでHLSに変換し、ブラウザベースのダッシュボードに表示する。専用の監視ソフトを買わなくても、オープンソースの技術スタックだけでここまでできる。
この記事では、1台のサーバーで複数カメラを管理する監視ダッシュボードの構築方法を、アーキテクチャ設計からフロントエンドの実装まで解説する。
システム全体のアーキテクチャ
構築するシステムの全体像を整理しよう。
構成要素:
| コンポーネント | 役割 |
|---|---|
| IPカメラ(複数台) | RTSPでH.264/H.265映像を配信 |
| Node.js Stream Manager | カメラごとにFFmpegプロセスを起動・管理 |
| FFmpeg(カメラ数分) | RTSP→HLS変換を並列実行 |
| Nginx | HLSセグメントを静的ファイルとして配信 |
| ブラウザダッシュボード | hls.jsで各カメラのHLSストリームを再生 |
重要なのは、FFmpegプロセスをカメラ1台につき1つ起動するということだ。1つのFFmpegプロセスで複数のRTSP入力を扱うこともできるが、1台がフリーズしたときに他のカメラも巻き添えになるリスクがある。プロセスを分離することで障害の影響を最小限に抑える。
複数RTSPストリームを同時にHLS変換する
まずはFFmpegコマンドだけで複数カメラの同時変換を動かしてみよう。Node.js管理サーバーはその後に構築する。
ディレクトリ構造
mkdir -p /var/www/hls/{cam01,cam02,cam03}
カメラごとのFFmpegコマンド
# カメラ1(Hikvision)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam01/seg_%03d.ts" \
"/var/www/hls/cam01/index.m3u8" &
# カメラ2(Dahua)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass2@192.168.1.108:554/cam/realmonitor?channel=1&subtype=0" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam02/seg_%03d.ts" \
"/var/www/hls/cam02/index.m3u8" &
# カメラ3(ONVIF)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass3@192.168.1.100:554/onvif1" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam03/seg_%03d.ts" \
"/var/www/hls/cam03/index.m3u8" &
wait
& でバックグラウンド実行し、wait で全プロセスの終了を待つ。これでも動くが、プロセスの監視・再起動・設定管理が手動になるため、実運用ではNode.jsで管理する。
リソース見積もり
-c:v copy(再エンコードなし)の場合、1ストリームあたりのリソース消費は非常に小さい。
| カメラ数 | CPU使用率(目安) | メモリ | ネットワーク帯域 |
|---|---|---|---|
| 1-4台 | 5-10% | ~50MB/プロセス | 2-8 Mbps/カメラ |
| 5-10台 | 10-25% | ~500MB | 10-40 Mbps |
| 10-20台 | 20-50% | ~1GB | 20-80 Mbps |
H.265→H.264への再エンコードが必要な場合はCPU負荷が大幅に上がる。その場合は GPUエンコードガイド を参照して、NVENCやQSVの活用を検討してほしい。
Node.jsでストリーム管理サーバーを構築する
FFmpegプロセスの起動・停止・死活監視を行うNode.jsサーバーを構築する。
プロジェクト構成
rtsp-hls-dashboard/
├── server/
│ ├── index.mjs # エントリーポイント
│ ├── stream-manager.mjs # FFmpegプロセス管理
│ └── config.mjs # カメラ設定
├── public/
│ └── index.html # ダッシュボードUI
├── package.json
└── .env # 環境変数(認証情報)
カメラ設定ファイル
// server/config.mjs
export const cameras = [
{
id: "cam01",
name: "エントランス",
rtspUrl: process.env.CAM01_RTSP_URL,
hlsDir: "/var/www/hls/cam01",
},
{
id: "cam02",
name: "サーバールーム",
rtspUrl: process.env.CAM02_RTSP_URL,
hlsDir: "/var/www/hls/cam02",
},
{
id: "cam03",
name: "駐車場",
rtspUrl: process.env.CAM03_RTSP_URL,
hlsDir: "/var/www/hls/cam03",
},
];
RTSP URLは .env ファイルで管理する。認証情報をコードにハードコードしてはいけない。
# .env
CAM01_RTSP_URL=rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101
CAM02_RTSP_URL=rtsp://admin:pass2@192.168.1.108:554/cam/realmonitor?channel=1&subtype=0
CAM03_RTSP_URL=rtsp://admin:pass3@192.168.1.100:554/onvif1
ストリームマネージャー
// server/stream-manager.mjs
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import path from "node:path";
export class StreamManager {
#processes = new Map();
#restartTimers = new Map();
async startStream(camera) {
if (this.#processes.has(camera.id)) {
console.log(`[${camera.id}] Already running`);
return;
}
await mkdir(camera.hlsDir, { recursive: true });
const args = [
"-rtsp_transport", "tcp",
"-timeout", "5000000",
"-i", camera.rtspUrl,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-hls_flags", "delete_segments+append_list",
"-hls_segment_filename",
path.join(camera.hlsDir, "seg_%03d.ts"),
path.join(camera.hlsDir, "index.m3u8"),
];
const proc = spawn("ffmpeg", args, {
stdio: ["ignore", "pipe", "pipe"],
});
proc.stderr.on("data", (data) => {
const line = data.toString().trim();
if (line.includes("Error") || line.includes("error")) {
console.error(`[${camera.id}] ${line}`);
}
});
proc.on("exit", (code) => {
console.log(`[${camera.id}] FFmpeg exited with code ${code}`);
this.#processes.delete(camera.id);
this.#scheduleRestart(camera);
});
this.#processes.set(camera.id, proc);
console.log(`[${camera.id}] Started (PID: ${proc.pid})`);
}
stopStream(cameraId) {
const proc = this.#processes.get(cameraId);
if (proc) {
proc.kill("SIGTERM");
this.#processes.delete(cameraId);
}
const timer = this.#restartTimers.get(cameraId);
if (timer) {
clearTimeout(timer);
this.#restartTimers.delete(cameraId);
}
}
#scheduleRestart(camera) {
console.log(`[${camera.id}] Restarting in 5 seconds...`);
const timer = setTimeout(() => {
this.#restartTimers.delete(camera.id);
this.startStream(camera);
}, 5000);
this.#restartTimers.set(camera.id, timer);
}
getStatus() {
const status = {};
for (const [id, proc] of this.#processes) {
status[id] = { pid: proc.pid, running: !proc.killed };
}
return status;
}
stopAll() {
for (const [id] of this.#processes) {
this.stopStream(id);
}
}
}
ポイントは以下の3つだ。
- プロセス分離: カメラごとに独立した
spawnでFFmpegを起動。1台が落ちても他に影響しない - 自動再起動:
exitイベントで5秒後に再起動をスケジュール - クリーンシャットダウン:
SIGTERMで安全に停止
エントリーポイント
// server/index.mjs
import "dotenv/config";
import http from "node:http";
import { cameras } from "./config.mjs";
import { StreamManager } from "./stream-manager.mjs";
const manager = new StreamManager();
// 全カメラのストリームを開始
for (const cam of cameras) {
manager.startStream(cam);
}
// ステータスAPI
const server = http.createServer((req, res) => {
if (req.url === "/api/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
cameras: cameras.map((cam) => ({
id: cam.id,
name: cam.name,
hlsUrl: `/hls/${cam.id}/index.m3u8`,
...manager.getStatus()[cam.id],
})),
}));
return;
}
res.writeHead(404);
res.end("Not Found");
});
server.listen(3001, () => {
console.log("Stream manager API running on port 3001");
});
// グレースフルシャットダウン
process.on("SIGTERM", () => {
console.log("Shutting down...");
manager.stopAll();
server.close();
process.exit(0);
});
process.on("SIGINT", () => {
console.log("Shutting down...");
manager.stopAll();
server.close();
process.exit(0);
});
ブラウザで映像を一覧表示するフロントエンド
hls.jsを使ってカメラ映像をグリッドレイアウトで表示するシンプルなダッシュボードを作る。
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>監視カメラダッシュボード</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: "SF Mono", "Fira Code", monospace;
}
.header {
padding: 1rem 2rem;
border-bottom: 1px solid #1a1a1a;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.2rem; color: #b4f0a0; }
.status { font-size: 0.8rem; color: #666; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
gap: 1px;
background: #1a1a1a;
padding: 1px;
}
.camera-cell {
background: #0a0a0a;
position: relative;
}
.camera-cell video {
width: 100%;
display: block;
background: #000;
}
.camera-label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: #b4f0a0;
padding: 4px 8px;
font-size: 0.75rem;
border: 1px solid #b4f0a033;
}
.camera-status {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
}
.camera-status.offline { background: #f44336; }
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>>_ Surveillance Dashboard</h1>
<div class="status" id="clock"></div>
</div>
<div class="grid" id="grid"></div>
<script>
async function init() {
const res = await fetch("/api/status");
const data = await res.json();
const grid = document.getElementById("grid");
for (const cam of data.cameras) {
const cell = document.createElement("div");
cell.className = "camera-cell";
cell.innerHTML = `
<video id="video-${cam.id}" muted autoplay playsinline></video>
<div class="camera-label">${cam.name} [${cam.id}]</div>
<div class="camera-status ${cam.running ? "" : "offline"}"
id="status-${cam.id}"></div>
`;
grid.appendChild(cell);
const video = cell.querySelector("video");
if (Hls.isSupported()) {
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
enableWorker: true,
});
hls.loadSource(cam.hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error(`[${cam.id}] HLS error:`, data.type);
setTimeout(() => {
hls.loadSource(cam.hlsUrl);
hls.attachMedia(video);
}, 3000);
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = cam.hlsUrl;
}
}
setInterval(() => {
document.getElementById("clock").textContent =
new Date().toLocaleString("ja-JP");
}, 1000);
}
init();
</script>
</body>
</html>
ポイント:
- レスポンシブグリッド:
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr))でカメラ数に応じて自動的にレイアウトが変わる - 低遅延設定: hls.jsの
liveSyncDurationCount: 3で遅延を最小化 - エラー復帰: HLSエラー発生時に3秒後に自動再接続
- モバイル対応: 768px以下で1カラムに切り替え
録画と自動クリーンアップの仕組み
ライブ配信だけでなく、録画も同時に行いたいケースは多い。FFmpegの -f tee を使えば、1つの入力から複数の出力を作れる。
HLS配信と録画の同時実行
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101" \
-c:v copy -c:a aac -b:a 128k \
-f tee -map 0:v -map 0:a \
"[f=hls:hls_time=2:hls_list_size=10:hls_flags=delete_segments+append_list:hls_segment_filename=/var/www/hls/cam01/seg_%03d.ts]/var/www/hls/cam01/index.m3u8|[f=segment:segment_time=3600:segment_format=mp4:reset_timestamps=1:strftime=1]/var/recordings/cam01/%Y%m%d_%H%M%S.mp4"
これで1つのFFmpegプロセスから「HLSライブ配信」と「1時間ごとのMP4録画」が同時に出力される。
古い録画ファイルの自動削除
ディスク容量を管理するために、cronで古いファイルを定期削除する。
# /etc/cron.daily/cleanup-recordings
#!/bin/bash
# 30日以上前の録画ファイルを削除
find /var/recordings/ -name "*.mp4" -mtime +30 -delete
# 削除ログ
echo "[$(date)] Cleanup completed" >> /var/log/recording-cleanup.log
chmod +x /etc/cron.daily/cleanup-recordings
バッチ処理の自動化についてさらに詳しく知りたい場合は FFmpeg × Pythonで動画バッチ処理を自動化する も参考になる。
本番運用のポイント — systemd・ログ・監視
systemdサービス化
Node.jsのストリームマネージャーをsystemdサービスとして登録すれば、サーバー再起動時に自動的にストリームが復旧する。
# /etc/systemd/system/surveillance-dashboard.service
[Unit]
Description=Surveillance Dashboard Stream Manager
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=surveillance
Group=surveillance
WorkingDirectory=/opt/rtsp-hls-dashboard
ExecStart=/usr/bin/node server/index.mjs
Restart=always
RestartSec=10
Environment=NODE_ENV=production
EnvironmentFile=/opt/rtsp-hls-dashboard/.env
# セキュリティ強化
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/www/hls /var/recordings
ProtectHome=true
[Install]
WantedBy=multi-user.target
sudo systemctl enable surveillance-dashboard
sudo systemctl start surveillance-dashboard
sudo systemctl status surveillance-dashboard
ログ管理
FFmpegは大量のログを出力する。journaldで管理しつつ、エラーだけをフィルタリングする。
# リアルタイムでエラーのみ表示
journalctl -u surveillance-dashboard -f | grep -i error
# 過去1時間のログ
journalctl -u surveillance-dashboard --since "1 hour ago"
ヘルスチェック
各カメラのHLSプレイリストが更新されているか定期的にチェックする簡易スクリプト。
#!/bin/bash
# /opt/rtsp-hls-dashboard/healthcheck.sh
CAMERAS=("cam01" "cam02" "cam03")
ALERT_THRESHOLD=30 # 秒
for cam in "${CAMERAS[@]}"; do
playlist="/var/www/hls/${cam}/index.m3u8"
if [ ! -f "$playlist" ]; then
echo "[ALERT] ${cam}: playlist not found"
continue
fi
age=$(( $(date +%s) - $(stat -c %Y "$playlist") ))
if [ "$age" -gt "$ALERT_THRESHOLD" ]; then
echo "[ALERT] ${cam}: playlist is ${age}s old (threshold: ${ALERT_THRESHOLD}s)"
else
echo "[OK] ${cam}: last updated ${age}s ago"
fi
done
このスクリプトもcronで定期実行し、アラートが出たらメールやSlack通知を送る仕組みにすると安心だ。
クラウド上で同様の構成を組みたい場合は VPSでFFmpegエンコードサーバーを構築する も参考にしてほしい。
まとめ
FFmpegとNode.jsで構築する監視カメラダッシュボードの全体像をまとめると:
- アーキテクチャ: カメラ1台につきFFmpegプロセス1つを分離して実行。障害の影響範囲を最小化
- ストリーム管理: Node.jsでFFmpegプロセスの起動・停止・自動再起動を管理
- フロントエンド: hls.jsでブラウザ上にグリッドレイアウトで映像表示
- 録画:
-f teeでHLS配信と同時にMP4セグメント録画 - 運用: systemdサービス化 + ヘルスチェックで安定稼働
このシステムのソースコードは GitHub: omitsu-dev/rtsp-hls-dashboard で公開予定だ。
次のステップとして、このシステムをリモートから安全にアクセスする方法が気になる方は、VPNを使ったセキュアなリモートアクセスの記事も準備中だ。企業での導入相談は お問い合わせ からどうぞ。
- FFmpegでRTSPカメラ映像を受信・変換・配信する — RTSPの基礎
- FFmpegをGPUで高速化する完全ガイド — 大量カメラの負荷対策
- FFmpegでHLS動画をCDN配信する方法 — HLS配信の応用