IoT × Cloud:Kinesis Video Streams を活用した映像冗長化設計

|
AWS Kinesis Video Streams IoT S3 IAM CloudWatch 映像 冗長化

集合住宅の防犯カメラをKinesis Video Streams経由でクラウド録画する構成の設計・実装。ストリーム冗長化・IAM設計・コスト管理・障害検知まで実案件の知見を解説。

はじめに:「映像が録れていなかった」が最も取り返しのつかない障害

セキュリティカメラの障害には、発覚のタイミングが最悪になりやすいという特性がある。

映像系障害の典型パターン:

  事案A: 不審者侵入 → 警察が映像提出を要求
    → 確認したところ、ディスクフルでカメラが 3 週間前から停止していた
    → 肝心の時間帯の映像がない

  事案B: 機器トラブルの調査のため録画を確認
    → NVR(ネットワーク録画機)のHDDが故障していた
    → 直近 2 ヶ月分がすべて消失

  事案C: インターネット回線障害
    → ローカル録画は動いていたが、管理会社からのリモート確認ができない
    → 「見られない」=「使えない」と同義

これらは全て「単一障害点(SPOF)」の問題だ。ローカル NVR への録画のみに依存する構成では、HDD 故障・電源断・設置場所への物理アクセスのいずれひとつでシステム全体が機能不全に陥る。

この記事では、集合住宅の防犯カメラ管理システムを Kinesis Video Streams(KVS) でクラウド録画する構成の設計・実装を解説する。単なる「クラウド録画の設定方法」ではなく、なぜその設計判断をしたかというリスク管理の思考プロセスを中心に据える。


Part 1:Kinesis Video Streams を選ぶ理由 — 技術選定の判断軸

映像クラウド録画の選択肢

クラウド録画の選択肢は KVS だけではない。なぜ KVS を選んだかを、代替手段との比較で示す。

映像クラウド録画の選択肢比較:

  選択肢①: カメラベンダーのクラウドサービス(Hikvision Hik-Connect等)
    メリット: 設定が簡単、カメラと密結合
    デメリット: ベンダーロックイン、API なし、コスト不透明
              中国系ベンダーの場合は規制リスク
    判断: 法人向けセキュリティ用途では採用困難

  選択肢②: Milestone / Genetec 等のVMSクラウド
    メリット: 高機能、GUI 充実
    デメリット: ライセンスコストが高い(カメラ1台あたり月額数千円〜)
              100台超で月額数十万円規模
    判断: 大規模プロジェクトでは有効だが中小規模ではコスト超過

  選択肢③: S3 直接保存(FFmpeg → S3)
    メリット: シンプル、低コスト
    デメリット: リアルタイム視聴不可、フラグメント管理が自前
              ネットワーク断時のバッファリングが難しい
    判断: アーカイブ専用なら有効。リアルタイム性が必要なら不適

  選択肢④: Kinesis Video Streams(採用)
    メリット: AWS サービスと完全統合
              ライブ視聴(HLS/DASH)+ アーカイブ参照の両立
              ネットワーク断時の自動再接続・バッファリングをSDKが処理
              PutMedia.Success メトリクスで録画状態を監視可能
              2025年11月追加のウォームストレージで長期保存コストを最大67%削減
    デメリット: KVS SDK の学習コスト
              独自の「フラグメント」概念の理解が必要
    判断: AWS 基盤で統合管理するなら最有力

KVS のアーキテクチャ概念

KVS を扱う上で最初に理解すべきは「フラグメント」の概念だ。

KVS のデータモデル:

  映像ストリーム
  │
  ├── Fragment 1  ← キーフレーム(I-Frame)で始まる数秒単位のチャンク
  │   ├── タイムスタンプ(Producer側・Server側の両方)
  │   ├── フラグメント番号
  │   └── 映像データ(MKV形式)
  ├── Fragment 2
  └── Fragment 3 ...

  重要: KVS は「バイトストリーム」ではなく「フラグメント単位」で管理する
       フラグメントが完結しなければ保存されない
       → ネットワーク断中のフラグメントは PutMedia.ErrorAckCount に計上される

Part 2:システム設計 — SPOF を排除する冗長化の考え方

冗長化の対象と設計方針

SPOF 分析と対策:

  障害ポイント         発生確率  影響度  対策
  ────────────────────────────────────────────────────
  カメラ本体の故障      中      高    → 台数による物理冗長
  カメラ→KVS の回線断  高      中    → SDK の自動再接続 + ローカルバッファ
  KVS サービス障害      極低    高    → AWS SLA 99.9% に依拠
  KVS ストリームの誤設定 中     中    → Terraform IaC で設定ドリフトを防ぐ
  映像の誤消去(ヒューマンエラー) 低 高 → S3 Object Lock で保護
  監視の見落とし        中      高    → CloudWatch アラート + SNS

システム全体構成図

集合住宅 防犯カメラ構成(全体像):

  ┌─────────────────────────────────────────────────────────────┐
  │  集合住宅 現地                                               │
  │                                                             │
  │  [IPカメラ×N台]                                              │
  │   RTSP ストリーム                                            │
  │       │                                                     │
  │   [Raspberry Pi / 組込み Linux]  ← KVS Producer SDK (C/Python)
  │       │  KVS SDK が自動再接続・バッファリングを処理           │
  │       │                                                     │
  │   [ローカル NVR]  ← 72時間分のローカルバックアップ            │
  │   (クラウド通信不能時の一次保全)                            │
  └──────────────────────────┬──────────────────────────────────┘
                             │ HTTPS (TLS 1.2+)
                             ▼
  ┌─────────────────────────────────────────────────────────────┐
  │  AWS                                                        │
  │                                                             │
  │  [Kinesis Video Streams]                                    │
  │    ストリーム×N本(カメラ1台=1ストリーム)                   │
  │    ホット層: 7日間  ← リアルタイム視聴・直近参照             │
  │    ウォーム層: 90日間 ← 長期保存(2025年11月〜、最大67%削減) │
  │         │                                                   │
  │         │ Lambda(フラグメントイベント)                     │
  │         ▼                                                   │
  │  [S3 Glacier Instant Retrieval]                             │
  │    365日以降のアーカイブ ← Object Lock(WORM)              │
  │         │                                                   │
  │  [CloudWatch]  ← PutMedia.Success / ConnectionErrors を監視 │
  │    アラート → SNS → 管理会社へ通知                          │
  │                                                             │
  │  [Hono API(Cloudflare Workers)]                           │
  │    HLS 視聴 URL 生成 API  ← 管理ダッシュボード向け          │
  └─────────────────────────────────────────────────────────────┘

なぜ「カメラ1台=1ストリーム」にするか

「複数台のカメラを1ストリームに束ねればコスト削減できるのでは」という発想は誤りだ。

1ストリーム=1カメラ の理由:

  ① 障害切り分け
     ストリームごとに CloudWatch メトリクスが独立
     → "カメラ3番だけ映像が来ていない" が即座に分かる
     → 複数台を1ストリームに束ねると、どのカメラが問題か分からない

  ② IAM の最小権限原則
     カメラデバイスごとに IAM ロールを分離できる
     → 1台のカメラが乗っ取られても他のストリームへのアクセスは不可

  ③ 再接続の独立性
     1台のカメラのネットワーク断が他のカメラの録画に影響しない

  コスト: KVS はストリーム数で課金されず、データ量(GB)で課金
  → 1ストリーム=1カメラでコスト増はほぼゼロ

Part 3:IAM 設計 — 最小権限でデバイスを管理する

IAM の設計は映像セキュリティシステムの根幹だ。「カメラが乗っ取られた場合」を脅威モデルに含めて設計する。

デバイス用 IAM ポリシー

# terraform/iam.tf

locals {
  # カメラデバイス一覧(台数分定義)
  cameras = {
    "cam-entrance-01" = { stream_name = "building-a-entrance-01" }
    "cam-entrance-02" = { stream_name = "building-a-entrance-02" }
    "cam-parking-01"  = { stream_name = "building-a-parking-01"  }
    "cam-lobby-01"    = { stream_name = "building-a-lobby-01"    }
  }
}

# カメラごとの IAM ユーザー(IoT Greengrass 等のデバイス認証がない場合)
resource "aws_iam_user" "camera" {
  for_each = local.cameras
  name     = "kvs-device-${each.key}"
  path     = "/kvs-devices/"

  tags = {
    DeviceId   = each.key
    StreamName = each.value.stream_name
    ManagedBy  = "terraform"
  }
}

# カメラごとに「自分のストリームにのみ PutMedia できる」ポリシー
resource "aws_iam_user_policy" "camera_kvs" {
  for_each = local.cameras
  name     = "kvs-putmedia-${each.key}"
  user     = aws_iam_user.camera[each.key].name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowPutMediaToOwnStream"
        Effect = "Allow"
        Action = [
          "kinesisvideo:DescribeStream",
          "kinesisvideo:GetDataEndpoint",
          "kinesisvideo:PutMedia",
        ]
        Resource = "arn:aws:kinesisvideo:${var.aws_region}:${data.aws_caller_identity.current.account_id}:stream/${each.value.stream_name}/*"
      },
      # GetDataEndpoint はストリーム一覧のアクセスが必要なため別ステートメント
      {
        Sid    = "AllowDescribeOnOwnStream"
        Effect = "Allow"
        Action = [
          "kinesisvideo:ListStreams",  # SDK の初期化に必要
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "kinesisvideo:StreamName" = each.value.stream_name
          }
        }
      }
    ]
  })
}

# アクセスキーの発行(デバイスに書き込む認証情報)
resource "aws_iam_access_key" "camera" {
  for_each = local.cameras
  user     = aws_iam_user.camera[each.key].name

  # ローテーション管理: アクセスキーの有効期限を Terraform で追跡
  lifecycle {
    create_before_destroy = true
  }
}

# キーを AWS Secrets Manager に保管(デバイスのプロビジョニングで参照)
resource "aws_secretsmanager_secret" "camera_credentials" {
  for_each    = local.cameras
  name        = "kvs-device/${each.key}/credentials"
  description = "IAM credentials for KVS device ${each.key}"

  tags = {
    DeviceId = each.key
  }
}

resource "aws_secretsmanager_secret_version" "camera_credentials" {
  for_each  = local.cameras
  secret_id = aws_secretsmanager_secret.camera_credentials[each.key].id

  secret_string = jsonencode({
    access_key_id     = aws_iam_access_key.camera[each.key].id
    secret_access_key = aws_iam_access_key.camera[each.key].secret
    stream_name       = each.value.stream_name
    region            = var.aws_region
  })
}

管理アプリ用 IAM ポリシー(HLS 視聴)

映像を参照するアプリケーション側の権限は「読み取り専用」に限定する。

# terraform/iam_viewer.tf

# 管理ダッシュボード(Hono API)用 IAM ロール
resource "aws_iam_role" "kvs_viewer" {
  name = "kvs-viewer-hono-api"

  # Cloudflare Workers から AssumeRole できるように設定する場合の例
  # 実際は Lambda やサーバーサイドで AssumeRole するケースが多い
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy" "kvs_viewer" {
  name = "kvs-viewer-policy"
  role = aws_iam_role.kvs_viewer.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowHLSPlayback"
        Effect = "Allow"
        Action = [
          "kinesisvideo:DescribeStream",
          "kinesisvideo:GetDataEndpoint",
          "kinesisvideo:GetHLSStreamingSessionURL",  # HLS 視聴 URL の発行
          "kinesisvideo:ListFragments",
          "kinesisvideo:GetMediaForFragmentList",    # アーカイブ参照
        ]
        Resource = "arn:aws:kinesisvideo:${var.aws_region}:${data.aws_caller_identity.current.account_id}:stream/*"
      },
      # S3 に移行されたアーカイブの参照
      {
        Sid    = "AllowS3ArchiveRead"
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:ListBucket",
        ]
        Resource = [
          aws_s3_bucket.video_archive.arn,
          "${aws_s3_bucket.video_archive.arn}/*",
        ]
      }
    ]
  })
}

Part 4:Kinesis Video Streams の Terraform 構築

KVS ストリームとストレージ階層

# terraform/kvs.tf

locals {
  # ホット層(KVS標準): 7日間 → 頻繁にアクセスする直近映像
  kvs_hot_retention_hours = 168  # 7日 = 168時間

  # ウォーム層(2025年11月〜): 90日間 → 月次確認・事案調査用
  # Hot 層の最大67%コスト削減、サブ秒レイテンシでのアクセス
  kvs_warm_retention_days = 90
}

resource "aws_kinesisvideo_stream" "camera" {
  for_each = local.cameras

  name                    = each.value.stream_name
  data_retention_in_hours = local.kvs_hot_retention_hours
  media_type              = "video/h264"

  # KVS ウォームストレージは現時点でコンソール/API から設定
  # Terraform プロバイダーの対応を確認して適用
  # (2026年3月時点では aws_kinesisvideo_stream リソースに
  #   storage_tier 属性が追加される予定)

  tags = {
    DeviceId   = each.key
    Location   = "building-a"
    ManagedBy  = "terraform"
    RetentionHot  = "${local.kvs_hot_retention_hours}h"
    RetentionWarm = "${local.kvs_warm_retention_days}d"
  }
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# S3 長期アーカイブ(90日超の映像)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

resource "aws_s3_bucket" "video_archive" {
  bucket = "${var.project_name}-video-archive-${var.aws_account_id}"
}

# 誤消去・改ざん防止: WORM(Write Once Read Many)
resource "aws_s3_bucket_object_lock_configuration" "video_archive" {
  bucket = aws_s3_bucket.video_archive.id

  rule {
    default_retention {
      mode = "COMPLIANCE"   # GOVERNANCE より強い: 管理者も削除不可
      days = 365            # 法的保全のため1年間は変更不可
    }
  }
}

# ストレージクラスの自動移行(コスト最適化)
resource "aws_s3_bucket_lifecycle_configuration" "video_archive" {
  bucket = aws_s3_bucket.video_archive.id

  rule {
    id     = "video-lifecycle"
    status = "Enabled"

    transition {
      days          = 0   # 即時 Glacier Instant Retrieval に配置
      storage_class = "GLACIER_IR"
    }

    # 1年後に Glacier Deep Archive に移行(さらに低コスト)
    transition {
      days          = 365
      storage_class = "DEEP_ARCHIVE"
    }
  }
}

# バケット暗号化(映像データは機微情報)
resource "aws_s3_bucket_server_side_encryption_configuration" "video_archive" {
  bucket = aws_s3_bucket.video_archive.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.video.arn
    }
  }
}

resource "aws_kms_key" "video" {
  description             = "KMS key for video archive encryption"
  deletion_window_in_days = 30
  enable_key_rotation     = true  # 年次自動ローテーション
}

KVS → S3 アーカイブ Lambda

KVS のウォーム層保存期間(90日)を超えた映像を S3 Glacier に自動退避する。

# lambda/kvs_archiver/handler.py
import boto3
import json
import os
from datetime import datetime, timedelta, timezone

kvs_client      = boto3.client('kinesisvideo',    region_name=os.environ['AWS_REGION'])
s3_client       = boto3.client('s3',              region_name=os.environ['AWS_REGION'])
ARCHIVE_BUCKET  = os.environ['ARCHIVE_BUCKET']

def handler(event, context):
    """
    EventBridge で毎日 02:00 JST に実行
    90日以上経過したフラグメントを S3 に退避
    """
    # KVS の全ストリームを取得
    paginator = kvs_client.get_paginator('list_streams')
    streams   = []
    for page in paginator.paginate():
        streams.extend(page['StreamInfoList'])

    archived_count = 0
    error_count    = 0

    for stream in streams:
        stream_name = stream['StreamName']
        try:
            archived = archive_old_fragments(stream_name)
            archived_count += archived
        except Exception as e:
            print(f"ERROR: Failed to archive {stream_name}: {e}")
            error_count += 1

    print(json.dumps({
        "archived_streams": len(streams) - error_count,
        "archived_fragments": archived_count,
        "error_count": error_count,
    }))

    # エラーが全ストリームの10%超なら Lambda 自体をエラー終了(アラート発火)
    if error_count > len(streams) * 0.1:
        raise Exception(f"Too many archive errors: {error_count}/{len(streams)}")

    return {"statusCode": 200}


def archive_old_fragments(stream_name: str) -> int:
    """特定ストリームの90日以上前のフラグメントをS3に移動"""
    # アーカイブ対象期間: 91日前〜93日前(バッファを持たせる)
    end_time   = datetime.now(timezone.utc) - timedelta(days=91)
    start_time = end_time - timedelta(days=2)

    # データエンドポイントを取得
    endpoint_resp = kvs_client.get_data_endpoint(
        StreamName=stream_name,
        APIName='LIST_FRAGMENTS'
    )
    endpoint_url = endpoint_resp['DataEndpoint']

    # アーカイブ対象APIクライアント
    kvs_archived = boto3.client(
        'kinesis-video-archived-media',
        endpoint_url=endpoint_url,
        region_name=os.environ['AWS_REGION']
    )

    # フラグメント一覧を取得
    paginator = kvs_archived.get_paginator('list_fragments')
    fragments = []
    for page in paginator.paginate(
        StreamName=stream_name,
        FragmentSelector={
            'FragmentSelectorType': 'SERVER_TIMESTAMP',
            'TimestampRange': {
                'StartTimestamp': start_time,
                'EndTimestamp':   end_time,
            }
        }
    ):
        fragments.extend(page.get('Fragments', []))

    # S3 に映像を保存
    for fragment in fragments:
        fragment_number = fragment['FragmentNumber']
        timestamp       = fragment['ServerTimestamp']
        date_prefix     = timestamp.strftime('%Y/%m/%d')
        s3_key          = f"streams/{stream_name}/{date_prefix}/{fragment_number}.mkv"

        # GetMediaForFragmentList で映像データ取得
        media_endpoint = kvs_client.get_data_endpoint(
            StreamName=stream_name,
            APIName='GET_MEDIA_FOR_FRAGMENT_LIST'
        )['DataEndpoint']

        kvs_media = boto3.client(
            'kinesis-video-archived-media',
            endpoint_url=media_endpoint,
            region_name=os.environ['AWS_REGION']
        )

        media_resp = kvs_media.get_media_for_fragment_list(
            StreamName=stream_name,
            Fragments=[fragment_number]
        )

        # S3 に保存(Glacier Instant Retrieval は lifecycle で自動移行)
        s3_client.put_object(
            Bucket=ARCHIVE_BUCKET,
            Key=s3_key,
            Body=media_resp['Payload'].read(),
            ContentType='video/webm',
            Metadata={
                'stream-name':       stream_name,
                'fragment-number':   fragment_number,
                'server-timestamp':  timestamp.isoformat(),
            }
        )

    return len(fragments)

Part 5:CloudWatch による障害検知設計

「見るべきメトリクス」の選定理由

KVS が公開するメトリクスは多数あるが、防犯カメラ用途で監視すべき指標を絞り込む。

メトリクス選定の判断軸:

  PutMedia.Success(採用: 最重要)
    意味: フラグメントが正常に保存された割合(1=成功, 0=失敗の平均)
    なぜ重要: この値が 1.0 を下回ればフラグメントが欠落している
    アラート閾値: 平均 < 0.95(5分間)= 録画に5%以上の欠落

  PutMedia.ConnectionErrors(採用)
    意味: PutMedia 接続確立エラー数
    なぜ重要: カメラデバイス〜KVS間の接続障害を直接示す
    アラート閾値: 合計 > 5(5分間)= ネットワーク障害の疑い

  PutMedia.IncomingBytes(採用)
    意味: KVS に送られてきた映像のバイト数
    なぜ重要: 値が 0 に近づくとカメラが停止または接続断
    アラート閾値: SUM < 1MB/5分 = ほぼ映像が来ていない

  PutMedia.FragmentIngestionLatency(参考監視)
    意味: フラグメントの先頭〜末尾受信の所要時間
    なぜ重要: ネットワーク遅延の増加を早期検知
    アラート閾値: P95 > 3000ms = 映像遅延が深刻

  ─── 監視しない指標と理由 ───
  GetMedia.* : 視聴側の指標(常時視聴していない環境では意味が薄い)
  ListFragments.Latency : 管理操作の遅延(録画継続性には無関係)

Terraform による CloudWatch アラート構築

# terraform/cloudwatch_kvs.tf

locals {
  # 全ストリーム名のリスト(for_each で使い回す)
  stream_names = [for k, v in local.cameras : v.stream_name]
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SNS トピック(通知先)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

resource "aws_sns_topic" "kvs_alerts" {
  name = "kvs-camera-alerts"
}

resource "aws_sns_topic_subscription" "kvs_email" {
  topic_arn = aws_sns_topic.kvs_alerts.arn
  protocol  = "email"
  endpoint  = var.alert_email  # 管理会社メールアドレス
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PutMedia.Success アラート(録画成功率の監視)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

resource "aws_cloudwatch_metric_alarm" "kvs_putmedia_success" {
  for_each = toset(local.stream_names)

  alarm_name          = "kvs-recording-degraded-${each.key}"
  alarm_description   = "KVS stream ${each.key}: recording success rate dropped below 95%. Possible camera disconnect or network issue."
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 2       # 2期間連続で閾値を下回ったらアラート
  datapoints_to_alarm = 2
  threshold           = 0.95    # 95%未満でアラート
  treat_missing_data  = "breaching"  # データなし = アラート(録画停止と同義)

  metric_query {
    id          = "success_rate"
    expression  = "m1"
    label       = "PutMedia Success Rate"
    return_data = true
  }

  metric_query {
    id = "m1"
    metric {
      namespace   = "AWS/KinesisVideo"
      metric_name = "PutMedia.Success"
      dimensions = {
        StreamName = each.key
      }
      period = 300   # 5分ウィンドウ
      stat   = "Average"
    }
  }

  alarm_actions = [aws_sns_topic.kvs_alerts.arn]
  ok_actions    = [aws_sns_topic.kvs_alerts.arn]

  tags = {
    StreamName = each.key
    AlertType  = "recording_health"
  }
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PutMedia.IncomingBytes(映像流入量ゼロの検知)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

resource "aws_cloudwatch_metric_alarm" "kvs_no_ingestion" {
  for_each = toset(local.stream_names)

  alarm_name          = "kvs-no-video-ingestion-${each.key}"
  alarm_description   = "KVS stream ${each.key}: no video data received for 10 minutes. Camera may be offline or disconnected."
  comparison_operator = "LessThanOrEqualToThreshold"
  evaluation_periods  = 2
  datapoints_to_alarm = 2
  threshold           = 1_000_000   # 1 MB/5分 未満 = 事実上の停止
  treat_missing_data  = "breaching"

  metric_query {
    id          = "bytes_in"
    expression  = "m1"
    label       = "Incoming Bytes (5min sum)"
    return_data = true
  }

  metric_query {
    id = "m1"
    metric {
      namespace   = "AWS/KinesisVideo"
      metric_name = "PutMedia.IncomingBytes"
      dimensions = {
        StreamName = each.key
      }
      period = 300
      stat   = "Sum"
    }
  }

  alarm_actions = [aws_sns_topic.kvs_alerts.arn]

  tags = {
    StreamName = each.key
    AlertType  = "camera_offline"
  }
}

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CloudWatch ダッシュボード(全カメラの状態を一覧)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

resource "aws_cloudwatch_dashboard" "kvs_overview" {
  dashboard_name = "kvs-camera-overview"

  dashboard_body = jsonencode({
    widgets = concat(
      # タイトルウィジェット
      [{
        type   = "text"
        x      = 0
        y      = 0
        width  = 24
        height = 1
        properties = {
          markdown = "## 防犯カメラ録画状況 | KVS ストリーム監視"
        }
      }],

      # カメラごとの PutMedia.Success グラフ
      [for idx, stream_name in local.stream_names : {
        type   = "metric"
        x      = (idx % 4) * 6
        y      = 1 + (floor(idx / 4)) * 6
        width  = 6
        height = 6
        properties = {
          title  = "録画成功率: ${stream_name}"
          view   = "timeSeries"
          period = 300
          stat   = "Average"
          yAxis  = { left = { min = 0, max = 1 } }
          annotations = {
            horizontal = [{
              value = 0.95
              label = "95% 閾値"
              color = "#ff6961"
            }]
          }
          metrics = [[
            "AWS/KinesisVideo",
            "PutMedia.Success",
            "StreamName",
            stream_name,
          ]]
        }
      }]
    )
  })
}

Part 6:デバイス側の実装 — RTSP → KVS Producer

IoT デバイス(Raspberry Pi / 組込み Linux)側でRTSP映像をKVSに送信する実装。

# device/kvs_producer.py
# Raspberry Pi / 組み込み Linux で動作するKVSプロデューサー
# GStreamer + amazon-kinesis-video-streams-producer-sdk-cpp を前提

import subprocess
import logging
import time
import json
import boto3
import os
from pathlib import Path

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
log = logging.getLogger('kvs-producer')

# 認証情報は Secrets Manager から取得(デバイス起動時に1回)
def load_credentials() -> dict:
    """AWS Secrets Manager からデバイス認証情報を取得"""
    client    = boto3.client('secretsmanager', region_name=os.environ['AWS_REGION'])
    secret_id = os.environ['DEVICE_SECRET_ARN']

    resp = client.get_secret_value(SecretId=secret_id)
    return json.loads(resp['SecretString'])


def build_gstreamer_pipeline(rtsp_url: str, stream_name: str, creds: dict) -> list[str]:
    """
    GStreamer パイプライン構成:
      RTSP → H.264 デコード → KVS Sink

    KVS SDK の GStreamer プラグイン (kvssink) を使用
    SDK: https://github.com/awslabs/amazon-kinesis-video-streams-producer-sdk-cpp
    """
    return [
        'gst-launch-1.0', '-v',
        # RTSP ソース(TCP優先: パケットロス低減)
        'rtspsrc', f'location={rtsp_url}', 'protocols=tcp', 'latency=100',
        '!',
        # H.264 パーサー
        'rtph264depay', '!', 'h264parse',
        '!',
        # KVS シンク(映像をKVSに送信)
        'kvssink',
        f'stream-name={stream_name}',
        f'access-key={creds["access_key_id"]}',
        f'secret-key={creds["secret_access_key"]}',
        f'aws-region={os.environ["AWS_REGION"]}',
        'fragment-duration=5000',   # 5秒ごとにフラグメント確定
        'timecode-scale=1',
        'key-frame-fragmentation=true',
    ]


def run_with_retry(rtsp_url: str, stream_name: str, creds: dict):
    """
    GStreamer プロセスをサブプロセスで起動し、
    異常終了時に指数バックオフで再起動する
    """
    backoff_seconds = 5
    max_backoff     = 300  # 最大5分

    while True:
        cmd = build_gstreamer_pipeline(rtsp_url, stream_name, creds)
        log.info(f"Starting KVS stream: {stream_name}")

        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )

        try:
            # プロセスが終了するまで待機
            stdout, stderr = proc.communicate()

            if proc.returncode == 0:
                log.info(f"Stream {stream_name} ended normally")
                backoff_seconds = 5  # 正常終了なら backoff リセット
            else:
                log.error(
                    f"Stream {stream_name} exited with code {proc.returncode}. "
                    f"stderr: {stderr.decode()[-500:]}"  # 最後の500文字のみ
                )

        except KeyboardInterrupt:
            proc.terminate()
            log.info("Received interrupt, stopping.")
            break

        # 再接続前の待機(指数バックオフ)
        log.info(f"Restarting stream in {backoff_seconds}s...")
        time.sleep(backoff_seconds)
        backoff_seconds = min(backoff_seconds * 2, max_backoff)


if __name__ == '__main__':
    creds       = load_credentials()
    rtsp_url    = os.environ['RTSP_URL']     # rtsp://user:pass@192.168.x.x:554/...
    stream_name = creds['stream_name']

    run_with_retry(rtsp_url, stream_name, creds)
# /etc/systemd/system/kvs-producer.service
# systemd でプロデューサーをデーモン化(デバイス起動時に自動起動)

[Unit]
Description=KVS Video Producer
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=kvs
EnvironmentFile=/etc/kvs/producer.env
ExecStart=/usr/bin/python3 /opt/kvs/kvs_producer.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Part 7:コスト設計と最適化

ストレージ階層とコストの実数計算

2025年11月に追加された KVS ウォームストレージを組み込んだコスト試算。

構成前提:
  カメラ台数:     20台
  解像度:        1080p, H.264, 2Mbps
  稼働時間:      24時間365日(常時録画)
  データ量/台/月: 2Mbps × 86400秒 × 30日 ÷ 8 ÷ 1024 / 1024 ≈ 648 GB/月/台
  全台合計:      648 GB × 20台 = 12,960 GB/月

ストレージ階層別コスト試算(ap-northeast-1 / 東京):

  ① KVS ホット層(直近7日間)
     対象データ: 12,960 GB × (7日/30日) ≈ 3,024 GB
     取り込みコスト: 12,960 GB × $0.0085/GB = $110.16/月
     保存コスト: 3,024 GB × $0.023/GB = $69.55/月
     小計: ≈ $180/月

  ② KVS ウォームストレージ(8〜90日)
     2025年11月追加、Hot 層比 最大67%削減
     対象データ: 12,960 GB × (82日/30日) ≈ 35,424 GB
     保存コスト: S3 Standard-IA 相当 ≈ $0.0075/GB → 約 $266/月
     ※ 従来の Hot 層継続なら: 35,424 × $0.023 = $815/月
     → ウォーム層で約 $549/月の削減

  ③ S3 Glacier Instant Retrieval(91日〜1年)
     対象データ: 12,960 GB × (274日/30日) ≈ 118,464 GB
     保存コスト: $0.004/GB → $474/月
     (Hot 層継続比: $2,724/月 → 83%削減)

  ④ S3 Glacier Deep Archive(1年以上)
     保存コスト: $0.00099/GB → 最安
     ※ 取り出しは12時間。法的保全のみ

  ────────────────────────────────────────
  月額合計(20台・常時録画):
    ウォームストレージ活用: 約 $920/月
    全てHot層の場合:       約 $2,480/月
    → 約63%のコスト削減

コスト最適化のチェックリスト

コスト削減施策の優先順位:

  優先度高:
  ✅ 解像度の最適化
     防犯用途なら 1080p より 720p + 高フレームレート のほうが
     人物識別に有効な場合がある(ビットレートは50〜60%減)

  ✅ KVS ウォームストレージへの移行
     Hot 層 7日超のデータをウォーム層に設定(最大67%削減)
     2025年11月以降に作成するストリームから適用可能

  ✅ 動体検知時のみ録画(エッジ処理)
     カメラのモーション検知機能を使い、無人時間帯の録画量を削減
     ただし「何もなかった時間の証明」が必要な用途には不向き

  優先度中:
  ✅ フラグメント長の最適化(5秒 → 10秒)
     取り込みコストはデータ量課金なので変化なし
     だがフラグメント数が減り API コールが減少

  ✅ H.265(HEVC)への移行
     H.264 比でビットレートを約50%削減
     KVS は H.265 対応(media_type = "video/h265")
     カメラ側のハードウェアエンコーダが H.265 対応していること

Part 8:実案件で遭遇したトラブルシューティング 3 パターン

トラブル①:「映像が断続的に途切れる」(ネットワーク帯域問題)

症状:
  PutMedia.Success の平均が 0.85〜0.90 で不安定
  5〜10 分おきに PutMedia.ConnectionErrors が急増
  CloudWatch のアラートが頻発

調査手順:
  Step 1: デバイス側のログを確認
    journalctl -u kvs-producer -n 100 --no-pager | grep -i "error\|retry\|reconnect"
    → "Network error occurred. Retrying in 5s" が大量に出力

  Step 2: ネットワーク帯域を測定
    # カメラが接続されている LAN セグメントで実施
    iperf3 -c <KVS エンドポイント相当のサーバー> -t 60
    → 送信: 2.3 Mbps(20台 × 2Mbps = 40Mbps 必要なところ 2.3Mbps しか出ていない)

  Step 3: ボトルネックの特定
    → 集合住宅の共用 Wi-Fi ルーターが 100BASE-TX の古い機器
    → 上流の ISP 回線は 100Mbps だが、LAN 側が限界

根本原因:
  カメラからルーターまでの有線 LAN が 100Mbps 共有
  20台 × 2Mbps = 40Mbps の想定が実際には通らなかった

対策:
  - カメラ専用の 1Gbps スイッチを追加
  - または解像度を 1080p → 720p に下げてビットレートを削減(4台分のカメラは
    エントランス等の重要箇所のみ 1080p を維持)
  - ネットワーク設計フェーズで「カメラ台数 × ビットレート × 1.5倍(マージン)」
    の帯域設計を実施すべきだったと判断

トラブル②:「特定の時間帯だけ録画が止まる」(夜間の照明変化)

症状:
  毎晩 22:00〜23:00 に PutMedia.IncomingBytes が急減
  それ以外の時間は正常
  CloudWatch の "no-video-ingestion" アラートが毎晩発火

調査手順:
  Step 1: アラート時刻のカメラ映像を確認
    → KVS コンソールの HLS 視聴で 22:00 前後を再生
    → 照明が暗くなる瞬間にカメラが「ナイトモード切替」を開始
    → 赤外線モードへの切替中(約3秒)は映像出力が中断

  Step 2: GStreamer ログを確認
    → "Lost sync" / "EOS received" のメッセージ
    → カメラが RTSP ストリームを一瞬切断しており、GStreamer がそれを
      「ストリーム終了」と解釈して停止

  Step 3: KVS SDK の設定を確認
    → fragment-duration=5000 (5秒) の設定が問題
    → ナイトモード切替の3秒間隔断が 5 秒のフラグメントをまたぐと
      フラグメントが不完全になり、KVS が受け付けない

根本原因:
  カメラのナイトモード自動切替 + GStreamer の「ストリーム中断=終了」処理

対策:
  1. GStreamer パイプラインに "rtspsrc retry=5" を追加
     → RTSP 切断後に自動再接続
  2. key-frame-fragmentation=true を確認
     → キーフレームを起点にフラグメントを再開始する設定が有効であることを確認
  3. CloudWatch アラートの evaluation_periods を 2 → 3 に変更
     → 3×5分=15分間継続して閾値を下回った場合のみアラート
     → 瞬間的な切断でのアラート誤発を抑制

トラブル③:「コスト見積もりが2倍になった」(HLS 視聴コストの見落とし)

症状:
  月次 AWS 請求が予算の1.8倍
  KVS の Cost Explorer を確認すると
  "Kinesis Video Streams - GetHLSStreamingSessionURL" が大きな割合

調査手順:
  Step 1: KVS の課金項目を精査
    aws ce get-cost-and-usage \
      --time-period Start=2025-12-01,End=2025-12-31 \
      --granularity MONTHLY \
      --filter '{"Dimensions":{"Key":"SERVICE","Values":["Amazon Kinesis Video Streams"]}}' \
      --group-by '[{"Type":"DIMENSION","Key":"USAGE_TYPE"}]' \
      --metrics "UnblendedCost"

  Step 2: HLS 視聴 URL の発行ログを確認
    → 管理ダッシュボードで「カメラ一覧」を開くたびに
      全カメラ(20台)の HLS URL を発行していた
    → 1回の画面表示 = 20回の GetHLSStreamingSessionURL API コール
    → HLS 視聴コスト: $0.0119/GB(Hot 層取り込みの 1.4 倍)
    → ダッシュボードを頻繁に開く管理者がいたため課金が急増

根本原因:
  HLS 視聴 URL を「表示が必要なタイミングで都度発行」するのではなく
  「一覧表示時に全台まとめて発行」していた設計ミス

対策:
  1. HLS URL をオンデマンド発行に変更
     → 「このカメラを見る」ボタンを押したときだけ URL を発行
  2. HLS セッション URL のキャッシュ
     → HLS セッション URL は 5 分間有効
     → Cloudflare KV に TTL=240秒でキャッシュし、API コールを削減
  3. ダッシュボードの静止画サムネイル機能を活用
     → 一覧では静止画(GetImage API)、個別選択時に HLS 視聴
     → GetImage は $0.004/千リクエスト と安価

  教訓:
    KVS は「取り込みコスト」だけでなく「視聴コスト(Egress)」も
    設計時に必ず試算する。特に HLS は取り込みの1.4倍の単価。

Conclusion:「止まらない映像インフラ」を設計する思考プロセス

防犯カメラシステムは、「障害が起きてから気づく」では取り返しがつかない用途の代表例だ。この記事で実装した設計は、次の3つの原則に基づいている。

原則①:SPOF を列挙してから設計する

設計の最初にリスクマトリクスを書き出すことで、「どこが壊れても映像が残る」構成が自然に決まる。KVS のウォームストレージ・S3 Glacier・Object Lock の組み合わせは、「ストレージの単一障害点」「誤消去」「コスト超過」の3つのリスクを同時に解決する。

原則②:監視は「録画が続いているか」を直接計測する

PutMedia.SuccessPutMedia.IncomingBytes は「録画が続いているか」を直接示すメトリクスだ。これを treat_missing_data = "breaching" で設定することで、データが来ない=止まっているという最悪ケースを見落とさない。

原則③:コストは「視聴コスト含め全課金項目を設計時に試算する」

KVS のコスト設計で最もやりがちなミスは、取り込みコストしか見ていないことだ。HLS 視聴は取り込みの 1.4 倍の単価であり、ダッシュボードの実装次第でコストが想定の数倍になる。

設計要素 採用技術 解決したリスク
映像取り込み冗長化 KVS SDK 自動再接続 + ローカル NVR 並走 ネットワーク瞬断による録画欠落
長期保存 KVS ウォーム + S3 Glacier ストレージコスト超過
誤消去防止 S3 Object Lock(COMPLIANCE) ヒューマンエラー・改ざん
障害検知 CloudWatch PutMedia.Success 録画停止の見落とし
デバイス認証 IAM ユーザー/ストリーム分離 1台の乗っ取りによる影響範囲拡大
コスト管理 HLS キャッシュ + オンデマンド発行 視聴 API コスト超過
インフラ管理 Terraform IaC 設定ドリフト・変更履歴の欠如

この記事をシェア

Twitter / X LinkedIn
記事一覧に戻る
🤖
Cloud Assistant
IBM Watson powered

こんにちは!クラウドエンジニアのポートフォリオサイトへようこそ。AWS構成・副業サービス・お仕事のご相談など、何でも聞いてください 👋