📹

k8sを用いた監視カメラシステムの構築

commits9 min read

RaspberryPiのカメラモジュールを用いて監視カメラを構築するのは定石ですが、今回は裏方などでよく見る監視カメラの一元管理システムを開発してみました。

監視カメラの一元管理システムなんてかっこつけてますが、要は複数台のカメラの映像を一つのブラウザ画面上で閲覧できるやつです。↓みたいな...

本記事では、ざっくりどう構成・実装したのかを説明していきたいと思います。

概要

リポジトリ

詳しいコードは以下をご覧ください。

https://github.com/nkte8/camsmonitor/tree/2021-11-28-r01

全体構成

以下の2パターンのデバイスがあります。

  • エッジデバイス
    • 低スペック・複数台を想定
    • カメラデバイスと接続されている
    • 今回は RaspberryPi 3+ Model A (RAM 512MB) を利用
  • ストリーミングサーバ
    • クラウド環境を想定
    • 今回はkubernetesクラスタを利用
      • RaspberryPi 4 Model B (RAM 8GB) 6台で構成されている

カメラからの映像は以下のような経路でクライアントに配信されます。

エッジデバイスからはデータを垂れ流すだけで、動画の処理や配信はクラウド上のサーバで行います。
ちなみにエッジデバイスにはRaspberryPi 3Aを用いました。5GHz帯のWifiを利用したかったためです。

コンポーネントについて

どういったソフトウェアを使っていくのか記述していきます。  

今回は以下のコンポーネントを使用します。コアなものについては太字にしています。

  • v4l2rtspserver
  • opencv
    • 高性能画像処理ライブラリ
    • rtspを直接受信し、フレーム単位で画像に処理を行う
  • ffmpeg
    • 高性能動画処理アプリケーション
    • opencvから出力されるフレーム情報をリアルタイムで連結し、HLSとして配信
    • 定期的にHLSとして作成された映像をmp4に変換
  • nginx
    • OSSのWebサーバ
    • HLSの動画リスト(m3u8)を再生するクライアントとして使用

ざっくり流れをかくと、v4l2rtspserverから配信されるストリーミング(RTSP)をopencvでフレーム単位で取得、編集したフレームを止めどなくffmpegに入力として渡します。ffmpegはこれをHLS形式に変換し、変換されたファイルをnginxで閲覧するという具合です。

k8s上での展開方法について


今回開発といった開発を行った項目を太字にしておきます。

  • rtspサーバー
    • docker-composeでv4l2rtspserverを起動
      • kubernetes管理ではない
      • RaspberryPi 3Aでkubeletが安定して動作しなかったため
  • rtsp2hls
    • opencvでrtspを受信し標準出力、標準出力をffmpegでhlsに変換するスクリプト
    • deploymentで動作させる。configmapを用いてアクセスするrtspサーバを指定
    • 排他処理はプレイリストの有無で判断
      • rtspサーバへの接続台数が取得できたらよかった
  • hls2mpeg
    • ffmpegでhlsをmp4で結合するスクリプト
    • cronjobで実施
  • nginx
    • videojsを用いてhlsを再生
      • https://videojs.com
      • videoタグで囲むだけでm3u8をあらゆるブラウザで再生できるようになる

図に書きそびれたのですが、クライアントはnginxコンテナをServiceExternalIP越しで閲覧できるということです。

v4l2rtspserver(エッジ側)の実装

v4l2rtspserverをコンテナとしてエッジデバイスで稼働します。

docker-compose

v4l2rtspserverをコンテナとしてビルドしたものを、docker-composeでデプロイするだけです。
commandの部分にv4l2rtspserverのオプションを記載します。

docker-compose.yaml
version: '3'
services:
  edge-client:
    image: registry.neko.lab:5005/root/monitoring/v4l2rtspserver
    container_name: edge-client
    command: -W 640 -H 480 -F 10
    ports:
      - 8554:8554
    devices:
      - /dev/video0
    restart: always
    privileged: true
    logging:
      driver: json-file
      options:
        max-file: '1'
        max-size: 3m

rtsp2hls(RTSP受信・加工&HLSに変換)の実装

rtspサーバからのストリーミングデータを、ブラウザがネイティブで対応しているストリーミング動画形式であるHLSフォーマットに、リアルタイム変換するスクリプトです。

構成

今回はDeploymentを用いて実装することを考えました。

カメラ映像の受信は1台1プロセスという単位で処理する必要があり、本来はカメラごとにPodを用意する必要があります。このためk8s上で実装するのであれば、Podを用いてyamlおよび設定ファイルをカメラ台数分作成しなければなりません。

Deploymentを持ちいれば、yamlファイルの数は1つで十分になります。ただしDeploymentではレプリカはあくまでPodの複製のため、カメラごとの個別に設定ファイルを作成することはできません

このため、今回はカメラ全ての設定ファイルをアプリケーションで読み込み、アプリケーション内で排他処理を行い実現しました。

実際の実装

ワークフロー

こちらはrtsp2hlsコンテナの中で実施している内容になります。

bashスクリプト内でpython(opencv)とffmpegを制御しています。rtspをpythonで受け、opencvにより加工を行った後、標準出力にフレームのデータを出力します。これをffmpegの入力として受け、入力の有る限り変換をし続けるという仕組みになっています。

RTSP→HLS変換

rtsp2frame.pyはstdoutに対してバイナリで書き込みを実施しています。

rtsp2frame.py
from camera import VideoCamera
import sys

if __name__ == '__main__':
    # ....中略(環境変数の読み込み)....
    cap = VideoCamera(f"rtsp://{addr}:8554/unicast",rotate)

    while True:
        jpeg = cap.get_frame()
        if not jpeg is None:
            sys.stdout.buffer.write( jpeg.tobytes() )
    # ....中略(例外処理等)....

importしているcamera.pyでは、opencvによりフレーム情報を取得・加工の処理を行っています。

camera.py
class VideoCamera(object):
    def get_frame(self):
        rc = self.video.grab()
        success, image = self.video.retrieve()
        if image is None:
            return None
    # ....中略(画像の加工を実施)....
        ret, jpeg = cv2.imencode('.jpg', image)
        return jpeg

上記をまとめると、opencvによりjpegにエンコードされたバイナリデータを標準出力に流しています。これをffmpegで入力として受付ます。

entrypoint.sh
# ....中略(環境変数作成・排他処理など)....
python3 /app/rtsp2frame.py ${IP_ADDR} ${FRAME_ROTATE} | ffmpeg -r ${SEG_FPS} -i - -c:v libx264 -strftime 1 -strftime_mkdir 1 -hls_segment_filename ${DEV_NAME}/%Y-%m-%d/v%H%M%S.ts -sc_threshold 0 -g ${SEG_FPS} -keyint_min $(awk "BEGIN { print $SEG_FPS * $SEG_TIME }") -hls_time ${SEG_TIME} ${DEV_NAME}.m3u8
  • -i -
    • 標準入力をinputとして扱うというオプション
  • -hls_segment_filename
    • hlsで生成されるファイル名を設定するオプション
    • -strftime 1フラグを指定すると%Y-%m-%dといったフォーマットが利用できる
    • -strftime_mkdir 1を設定すると、存在しないディレクトリについてはffmpegがよしなにしてくれる
  • -sc_threshold 0-g-keyint_min、`-hls_time`
    • `-hls_time`を指定するとセグメントの動画時間を設定できるが、実際は指定した時間からキーフレームが挿入されるまではセグメントが分割されず、うまく機能しないことがある
    • -g-keyint_minにより強制的にキーフレームを挿入&-sc_threshold 0により自動キーフレーム挿入を阻止することで、セグメントの動画時間を固定することができる。
    • -g-keyint_minの違いがわかっていないので、今後調査対象です

別解

今回はアプリケーション側で対処を行いましたが、今回の対応はあまりk8sらしい実装とは言い難く、本来は次のように構成すべきです。

構成でも記載しましたが、各カメラごとに設定が異なり、プロセスも各カメラごとに設定ファイルを持つ必要があるため、k8sで実装するなら最小単位はPodです。

ただ、Podはオートヒールしないこと、設定ファイルを書く量が増えることを考慮すると、Podを制御するcontrollerのようなサービスを実装し、controllerから直接kube-api-serverへデプロイをさせるのがスマートな構成だと考えています。

hls2mpeg(HLSセグメント→mp4変換)の実装

hlsのセグメントをmp4に変換するスクリプトになります。

構成

こちらはCronjobを用いて構築します。毎日、蓄積した動画に対して変換を実行するだけのため、デーモン化の必要がないためです。

HLS動画をmp4に変換するだけですが、プロセスが途中で停止してしまったり、動画変換が失敗してしまった時のことを考え、最低限の排他処理は実装しました(lockファイルなどの簡易なものです)

実際の実装

ワークフロー

コンテナが途中で終了されてしまった場合にもlockファイルは削除せず、再変換等は実施しません。

セグメントは一度全て結合したmp4にしてから変換をかけています、ファイル数が多いとIOの効率が悪いためです。また、結合しただけだとファイルサイズがかなり大きいため、画質を落として動画を24倍速(動画時間1時間)のアーカイブに変換しています。

HLS→mp4変換

m3u8は常に更新されているため、予め動画ファイルの一覧(mylist.txt)を作成しておき、これをinputとします。
ファイル一覧で壊れた動画ファイルが混在してしまうと、全体の処理が中断終了(なぜかrc=0)してしまうため、リスト作成時にセグメントが正常かどうかを判定しています。

entrypoint.sh
filelist=$(find ${TARGET_PATH} -type f -name '*.ts' | sort)
touch /tmp/mylist.txt
for f in ${filelist};do
    su rstpusr -c "ffmpeg -v error -i $f -f null -  >/dev/null 2>&1"
    if [[ $? -eq 0 ]];then
        echo "file '$f'" | tee -a /tmp/mylist.txt
    fi
done

ffmpeg -f concat -nostdin -safe 0 -i /tmp/mylist.txt -vcodec copy -an ${TARGET_PATH}-ts.mp4

また、上記で出力したファイルは以下のコマンドで30fps化&24倍速に変換します。

entrypoint.sh
ffmpeg -i ${TARGET_PATH}-ts.mp4 -r 30 -vf setpts=PTS/24.0 -crf 30 ${TARGET_PATH}.mp4

crf値は大きいほど動画が軽量化するオプションで、本システムでは30に指定して大体1ファイル70MB程度(元動画は6GB)になります。

別解

こちらはrtsp2hlsとは異なり、設定ファイルなどは用いないため、純粋な排他制御とサブプロセスのマイクロサービス化が課題になってきます。

特に、ffmpegをコンテナ内でサブプロセスとして複数回実行するのは処理単位としては大きめで、本来は全てのプロセスがコンテナとして分断するべきです。
とはいえ、分断しすぎてもわかりづらくなってしまうこともあるため、最適とも言えないところが難しい部分ではあります。

今後の改善

想定より開発に時間がかかり、いったん完成させようという方向で進めたため、細かいところで気にいっていないところ、いけてない部分など、改善点多いです。

  • Webインターフェースの改善
    • 現在はnginxにべたうちでm3u8を読み込むように記述している
      • configmap等をを読み込んでhtmlを動的に作成したい
    • hlsではなくrtspで直接接続できるようにしたい
      • hlsよりrtspの方が低遅延なため
  • 構成の改善
    • インフラとアプリケーションの分断によるマイクロサービス化
      • マイクロサービス化はチーム開発の際に真価を発揮する
      • 現状は個人開発のため、勉強目的でいじっていきたい
  • コンテナアプリケーションの改善
    • サブプロセスをあまり使わないようにしたい
      • コンテナの思想が1プロセス1コンテナであるため
      • 拗らせすぎると逆に管理困難になるため、さじ加減が大事
    • bashではなくpython内でffmpegを利用する方法にしたい
      • ffmpegはC言語で書かれているため、最終的にはCでのincludeが理想

備考

プロジェクト初期は、MQTTを使うことやWebsocketで直接受信することなどを試しましたが、結局遅延が大きくなったり、処理が複雑になったりして、最終的にこの方法に落ち着きました。
rtspでのストリーミング配信には多少マシンパワーを必要とすると考えていましたが、近年のラズベリーパイのスペックの向上は目を見張るものがあり、十分に動作を確認できたという次第です。
改善点が多いため、よりいっそう良いシステムにしていきたいです。

参考

https://qiita.com/wktq/items/a6e169e85a8a75c8524f
https://www.dpsj.co.jp/tech-articles/wowza-blog-hls
https://did2memo.net/2017/02/20/http-live-streaming/
https://github.com/mpromonet/v4l2rtspserver
https://opencv.org
https://www.ffmpeg.org
https://videojs.com
GitHubで編集を提案

Discussion

ログインするとコメントできます