Домашний NVR на коленке: RTSP, кольцевой буфер, ffmpeg

Четыре камеры, один SSD и желание не покупать коробку за двадцать тысяч. Оказалось, что 90% задачи закрывает один флаг ffmpeg.

Готовых NVR полно — Frigate, Shinobi, ZoneMinder. Все они хорошие и все хотят от меня либо детекции с нейросетью, либо PHP, либо и того и другого. Мне нужно было принципиально меньше: писать четыре потока на диск, хранить неделю, уметь достать кусок за вчерашний вечер. Всё.

Главный флаг

Первое, что нужно понять про запись RTSP: перекодировать не надо. Камера уже отдаёт H.264, аппаратно сжатый её собственным чипом. Всё, что требуется — снять пакеты с сети и разложить по файлам.

ffmpeg -rtsp_transport tcp -i rtsp://cam1/stream \
       -c copy -f segment -segment_time 300 \
       -reset_timestamps 1 -strftime 1 \
       /srv/nvr/cam1/%Y%m%d-%H%M%S.mkv

Разберём по частям, потому что каждый флаг здесь выстрадан:

MKV, а не MP4 — осознанно. MP4 хранит индекс (moov) в конце файла: если процесс умрёт посреди сегмента, файл не откроется вообще. Matroska пишется потоково, и оборванный файл проигрывается до места обрыва. Для записи, которая по определению может прерваться, это решающий аргумент.

Кольцо

«Кольцевой буфер» звучит солидно, на деле это find по крону. Неделя хранения:

find /srv/nvr -name '*.mkv' -mmin +10080 -delete

Работает, но плохо: диск переполнится раньше, если камера вдруг начнёт отдавать битрейт выше расчётного (а она начнёт — ночью, когда шум с матрицы убивает компрессию). Поэтому ретеншен не по времени, а по месту:

#!/bin/sh
# держим занятость раздела ниже 85%, удаляя самые старые сегменты
TARGET=85
while [ "$(df --output=pcent /srv/nvr | tr -dc '0-9')" -gt "$TARGET" ]; do
    OLDEST=$(find /srv/nvr -name '*.mkv' -printf '%T@ %p\n' | sort -n | head -1 | cut -d' ' -f2-)
    [ -n "$OLDEST" ] || break
    rm -f "$OLDEST"
done

Раз в пять минут по крону. Тупо, предсказуемо, никогда не переполняет диск. Глубина хранения плавает от пяти дней до девяти — и это честнее, чем гарантировать неделю и однажды упасть.

Про износ диска

Опасение было такое: непрерывная запись убьёт SSD. Посчитаем. Четыре камеры по 4 Мбит/с — это 2 МБ/с, около 170 ГБ в сутки, порядка 60 ТБ в год. Для дешёвого потребительского SSD на 1 ТБ с ресурсом ~600 TBW это десять лет. Спокойно.

Важнее другое: запись строго последовательная, крупными блоками, без random write. Это самый щадящий из возможных паттернов. А вот mdraid1 из двух дисков я всё-таки собрал — не ради ресурса, а потому что дешёвый SSD умирает не по износу, а внезапно.

Что действительно стоит сделать — вынести систему на отдельный носитель. Логи, база и swap на том же разделе, куда льётся видео, — вот это уже random write, и вот это его и убьёт.

Когда камера отваливается

ffmpeg при обрыве RTSP просто завершается. Нужен присмотр, и systemd для этого достаточно:

[Service]
ExecStart=/usr/local/bin/record-cam1.sh
Restart=always
RestartSec=5
StartLimitIntervalSec=0

StartLimitIntervalSec=0 здесь принципиально: без него systemd после пяти рестартов подряд решит, что юнит сломан, и опустит руки. А камера может не отвечать полчаса — питание моргнуло, роутер перезагрузился. Нам нужен именно бесконечный ретрай.

Плюс таймауты, чтобы ffmpeg не висел вечно на мёртвом сокете:

-timeout 5000000 -reconnect 1 -reconnect_streamed 1

Достать нужный кусок

Ради этого всё и затевалось. Имена файлов — это время, значит:

# всё, что писалось вчера с 21:00 до 22:00
ls /srv/nvr/cam1/20260410-21*.mkv

Склеить в один файл, если нужно отдать:

printf "file '%s'\n" /srv/nvr/cam1/20260410-21*.mkv > /tmp/list.txt
ffmpeg -f concat -safe 0 -i /tmp/list.txt -c copy /tmp/evening.mkv

Снова -c copy, снова мгновенно.

Итог

Весь «NVR» — это systemd-юнит на камеру, один скрипт ретеншена в кроне и папка на диске. Сотня строк вместе с пробелами. CPU под нагрузкой — 8% на четыре потока, память — десятки мегабайт.

Чего тут нет и не будет: детекции движения, приложения, уведомлений. Если понадобится — рядом встанет Frigate и будет смотреть тот же поток, не мешая записи. Но пока не понадобилось: за год я лазил в архив четыре раза, и каждый раз хватало ls с маской по времени.

← все заметки