mybook

設計問題: 動画配信プラットフォーム — YouTube を設計する

「動画はバイト数が違う」

「今日は動画プラットフォーム」とレイカが言った。「YouTubeを設計してみよう。」

ソウタは少し考えた後、口を開いた。「動画ってデータが大きいですよね。普通のWebAPIとは全然違う気がします。」

「その直感は正しい。でも、なぜ違うのか言語化できる?」

「えっと……ファイルサイズが大きくて、途中で止まったりするし、画質も選べる……」

「惜しい。データのサイズ処理の複雑さ配信の非同期性、この3つが本質だ。1本の動画をアップロードして視聴できる状態にするまでに、実際にはこれだけのことが起きる。」

レイカはホワイトボードに書き出した。

アップロード受付 → S3への保存 → トランスコード(複数画質) → HLSセグメント生成
  → サムネイル自動生成 → CDNへの配布 → メタデータDB更新 → 視聴可能状態に

「これを全部同期でやろうとしたら、ユーザーが待てない。だから全部非同期パイプラインにするんだ。これが動画プラットフォーム設計の核心になる。」

ソウタは先週の課題を思い出した。「先週のシステム設計の復習会でも、画像と動画は別物って言われました。画像は数MB、動画は数GBですもんね。」

「その通り。だから今日学ぶことは、スケールの問題でもある。面接官が動画設計を出すのは、そのスケールへの感覚とパイプライン設計の理解を同時に試せるからだよ。」


Step 1: 要件の確認

「まず要件を整理する。面接では、曖昧に始めると後で設計の方向性がずれる。」

機能要件:
- 動画のアップロード(最大10GB、途中再開対応)
- 動画の視聴(ストリーミング、アダプティブ画質)
- 複数画質(360p / 720p / 1080p / 4K)
- 動画検索(タイトル・説明文・タグ)
- サムネイル表示(自動生成 + ユーザー指定)
- ライブ配信(今回はスコープ外として触れる程度)
- コメント・いいね(設計外、メタデータDBのスキーマのみ触れる)

非機能要件:
- DAU: 5億(500M)
- 1日のアップロード本数: 50万本(500K)
- 動画の平均長さ: 5分
- 元動画の平均ファイルサイズ: 600MB
- ストリーミングはシームレス(バッファリング最小化)
- アップロードの途中再開(レジューム)対応
- 可用性: 99.99%(年間停止時間 52分以内)
- アップロードからの視聴可能状態への到達時間: 5分以内

INFO

面接では「コメント機能は?」など確認を入れてスコープを絞るのが重要。全機能を設計しようとすると時間が足りなくなる。「今回はアップロードと配信に集中し、コメントはデータモデルのみ説明します」と伝えると評価される。


Step 2: 規模の概算

「次に数字を計算する。ストレージと帯域のイメージをつかむ。」

アップロード側:
  1日あたり: 500K本 × 600MB = 300TB/day
  必要なアップロード帯域: 300TB ÷ 86,400秒 ≒ 3.5GB/秒

トランスコード後のストレージ:
  360p  ≒  50MB(H.264, 1Mbps)
  720p  ≒ 150MB(H.264, 3Mbps)
  1080p ≒ 300MB(H.264, 6Mbps)
  4K    ≒   1GB(H.265, 20Mbps)
  合計(元データ込み): 600MB + 1.5GB ≒ 2.1GB/本

  1日の増分ストレージ: 500K本 × 2.1GB = 1.05PB/day
  1年で: ≒ 383PB(Glacier移行なしのケース)

ストリーミング(視聴)帯域:
  DAU 500M × 1日30分視聴
  平均ビットレート(画質混合): 3Mbps
  ピーク時(夜20時)はDAUの20%が同時視聴と仮定
  同時視聴: 100M人 × 3Mbps = 300Tbps(CDNで分散)

CDNキャッシュ率の見積もり:
  上位20%の動画が視聴の80%を占める(パレートの法則)
  ホットコンテンツ: 500K本/日 × 20% × 2.1GB ≒ 210TB/日
  CDNがこれをキャッシュすれば、オリジンへの負荷を80%削減できる

「この規模感を頭に入れておくと、設計の各所でなぜそうするかの根拠になる。」


Step 3: 高レベル設計

Loading diagram...

「アップロードパスと配信パスは完全に分離する。これが非同期パイプラインの基本構造だ。」


Step 4: 詳細設計

4-1. マルチパートアップロード

「動画ファイルは1GBを超えることもある。普通のHTTPでアップロードしたら、途中でタイムアウトする。だからマルチパートアップロードを使う。」

マルチパートアップロードの仕組み:

1. クライアント → APIサーバーに「アップロード開始」リクエスト
2. APIサーバーがS3でMultipart Upload IDを発行
3. クライアントはファイルを5MB単位のチャンクに分割
4. 各チャンクを並列送信(最大10並列)
5. 全チャンク完了後「アップロード完了」を通知
6. S3がチャンクを結合して1ファイルに

利点:
- チャンク単位でリトライ可能(レジューム対応)
- 並列送信で高速化(10並列 = 10倍速)
- ネットワーク不安定でも途中から再開できる

Railsでのマルチパートアップロード実装:

# app/controllers/uploads_controller.rb
class UploadsController < ApplicationController
  before_action :authenticate_user!
 
  # Step 1: アップロード開始(Multipart Upload IDを発行)
  def initiate
    video = Video.create!(
      user: current_user,
      title: params[:title],
      status: 'uploading',
      filename: params[:filename],
      content_type: params[:content_type]
    )
 
    response = S3_CLIENT.create_multipart_upload(
      bucket: ENV['S3_RAW_BUCKET'],
      key: "raw/#{video.id}/#{video.filename}",
      content_type: params[:content_type],
      metadata: {
        'video-id' => video.id.to_s,
        'user-id'  => current_user.id.to_s
      }
    )
 
    render json: {
      video_id:  video.id,
      upload_id: response.upload_id,
      key:       "raw/#{video.id}/#{video.filename}"
    }
  end
 
  # Step 2: チャンクごとのPresigned URL発行
  def presigned_url
    url = S3_CLIENT.presigned_url(
      :upload_part,
      bucket:      ENV['S3_RAW_BUCKET'],
      key:         params[:key],
      upload_id:   params[:upload_id],
      part_number: params[:part_number].to_i,
      expires_in:  3600
    )
    render json: { url: url }
  end
 
  # Step 3: アップロード完了通知 → トランスコードキューへ
  def complete
    S3_CLIENT.complete_multipart_upload(
      bucket:           ENV['S3_RAW_BUCKET'],
      key:              params[:key],
      upload_id:        params[:upload_id],
      multipart_upload: { parts: params[:parts] }
    )
 
    video = Video.find(params[:video_id])
    video.update!(status: 'queued')
 
    # SQS経由でトランスコードをキューに積む
    SQS_CLIENT.send_message(
      queue_url:    ENV['TRANSCODE_QUEUE_URL'],
      message_body: { video_id: video.id, key: params[:key] }.to_json
    )
 
    render json: { status: 'queued', video_id: video.id }
  end
 
  # アップロード中断(チャンクの削除)
  def abort
    S3_CLIENT.abort_multipart_upload(
      bucket:    ENV['S3_RAW_BUCKET'],
      key:       params[:key],
      upload_id: params[:upload_id]
    )
    Video.find(params[:video_id]).update!(status: 'aborted')
    head :no_content
  end
end

Golangでのマルチパートアップロード実装(高スループット向け):

// internal/uploader/multipart.go
package uploader
 
import (
    "bytes"
    "context"
    "fmt"
    "io"
    "sort"
    "sync"
 
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "github.com/aws/aws-sdk-go-v2/service/s3/types"
)
 
const chunkSize = 5 * 1024 * 1024 // 5MB(S3の最小チャンクサイズ)
const maxParallel = 10
 
type MultipartUploader struct {
    client *s3.Client
    bucket string
}
 
func (u *MultipartUploader) Upload(ctx context.Context, key string, r io.Reader) error {
    // Multipart Upload 開始
    createOut, err := u.client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
        Bucket:      aws.String(u.bucket),
        Key:         aws.String(key),
        ContentType: aws.String("video/mp4"),
    })
    if err != nil {
        return fmt.Errorf("create multipart upload: %w", err)
    }
    uploadID := *createOut.UploadId
 
    // チャンクを並列アップロード
    sem := make(chan struct{}, maxParallel)
    var mu sync.Mutex
    var parts []types.CompletedPart
    var uploadErr error
    var wg sync.WaitGroup
 
    for partNum := int32(1); ; partNum++ {
        buf := make([]byte, chunkSize)
        n, readErr := io.ReadFull(r, buf)
        if n == 0 {
            break
        }
        if readErr != nil && readErr != io.ErrUnexpectedEOF {
            uploadErr = readErr
            break
        }
        chunk := buf[:n]
        pn := partNum
 
        wg.Add(1)
        sem <- struct{}{}
        go func() {
            defer wg.Done()
            defer func() { <-sem }()
 
            out, err := u.client.UploadPart(ctx, &s3.UploadPartInput{
                Bucket:     aws.String(u.bucket),
                Key:        aws.String(key),
                UploadId:   aws.String(uploadID),
                PartNumber: aws.Int32(pn),
                Body:       bytes.NewReader(chunk),
            })
            if err != nil {
                mu.Lock()
                uploadErr = err
                mu.Unlock()
                return
            }
            mu.Lock()
            parts = append(parts, types.CompletedPart{
                ETag:       out.ETag,
                PartNumber: aws.Int32(pn),
            })
            mu.Unlock()
        }()
    }
    wg.Wait()
 
    if uploadErr != nil {
        // 失敗時はアップロードを中断してリソースを解放
        u.client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
            Bucket:   aws.String(u.bucket),
            Key:      aws.String(key),
            UploadId: aws.String(uploadID),
        })
        return uploadErr
    }
 
    // パーツ番号でソートしてから完了通知(順序が必要)
    sort.Slice(parts, func(i, j int) bool {
        return *parts[i].PartNumber < *parts[j].PartNumber
    })
 
    _, err = u.client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
        Bucket:          aws.String(u.bucket),
        Key:             aws.String(key),
        UploadId:        aws.String(uploadID),
        MultipartUpload: &types.CompletedMultipartUpload{Parts: parts},
    })
    return err
}

WARNING

S3のマルチパートアップロードは、開始して放置すると不完全なパーツがストレージに残り続けてコストが発生する。必ず AbortIncompleteMultipartUpload のS3ライフサイクルポリシーを設定し、7日経過した未完了アップロードを自動削除すること。


4-2. トランスコードパイプライン

「アップロードされた生動画を複数画質に変換するパイプラインを設計しよう。」

Loading diagram...

トランスコードの各ステージ:

Stage 1: コンテナ解析(Probe)
  - ffprobe で入力ファイルのコーデック・解像度・フレームレートを確認
  - 4K入力でなければ4Kエンコードをスキップ(無駄なコストを省く)

Stage 2: 映像エンコード(複数画質、並列)
  - 360p:  H.264, 1Mbps,  CRF 28(低画質・低容量)
  - 720p:  H.264, 3Mbps,  CRF 23(標準画質)
  - 1080p: H.264, 6Mbps,  CRF 20(高画質)
  - 4K:    H.265, 20Mbps, CRF 22(超高画質、H.265で圧縮効率向上)

Stage 3: 音声エンコード(AAC 128kbps、全画質共通)

Stage 4: HLSセグメント化
  - 各画質を6秒単位のセグメント(.ts)に分割
  - マニフェストファイル(.m3u8)を生成

Stage 5: サムネイル生成(別Lambda)
  - 動画の10%, 30%, 50%, 70%, 90% 時点でスクリーンショット
  - CloudFront経由で配信

Stage 6: メタデータ更新
  - 処理完了後にRDSのvideosテーブルをupdateし status = 'ready'

Railsでのトランスコードジョブ実装:

# app/jobs/transcode_job.rb
class TranscodeJob < ApplicationJob
  queue_as :transcoding
  retry_on Aws::MediaConvert::Errors::ServiceError,
           wait: :exponentially_longer, attempts: 3
 
  QUALITY_PROFILES = [
    { name: '360p',  height: 360,  bitrate: 1_000_000,  codec: 'H_264', preset: 'FASTER' },
    { name: '720p',  height: 720,  bitrate: 3_000_000,  codec: 'H_264', preset: 'DEFAULT' },
    { name: '1080p', height: 1080, bitrate: 6_000_000,  codec: 'H_264', preset: 'SLOWER' },
    { name: '4k',    height: 2160, bitrate: 20_000_000, codec: 'H_265', preset: 'SLOWER' }
  ].freeze
 
  def perform(video_id)
    video = Video.find(video_id)
    video.update!(status: 'transcoding', transcoding_started_at: Time.current)
 
    # 入力ファイルの解像度を確認して不要なプロファイルをスキップ
    profiles = filter_profiles_by_resolution(video)
 
    response = MEDIACONVERT_CLIENT.create_job(
      role:     ENV['MEDIACONVERT_ROLE_ARN'],
      settings: build_job_settings(video, profiles)
    )
 
    video.update!(transcode_job_id: response.job.id)
    Rails.logger.info "Transcode started: #{response.job.id} for video #{video_id}"
  rescue ActiveRecord::RecordNotFound => e
    Rails.logger.error "Video #{video_id} not found: #{e.message}"
  end
 
  private
 
  def filter_profiles_by_resolution(video)
    return QUALITY_PROFILES unless video.original_height
    QUALITY_PROFILES.select { |p| p[:height] <= video.original_height }
  end
 
  def build_job_settings(video, profiles)
    input_key     = "raw/#{video.id}/#{video.filename}"
    output_prefix = "processed/#{video.id}/"
    {
      inputs: [{
        file_input: "s3://#{ENV['S3_RAW_BUCKET']}/#{input_key}",
        audio_selectors: { 'Audio Selector 1' => { default_selection: 'DEFAULT' } }
      }],
      output_groups: [
        hls_output_group(output_prefix, profiles),
        thumbnail_output_group(output_prefix)
      ]
    }
  end
 
  def hls_output_group(prefix, profiles)
    {
      name: 'HLS Group',
      output_group_settings: {
        type: 'HLS_GROUP_SETTINGS',
        hls_group_settings: {
          destination:        "s3://#{ENV['S3_PROCESSED_BUCKET']}/#{prefix}hls/",
          segment_length:     6,
          min_segment_length: 0
        }
      },
      outputs: profiles.map { |p| hls_output(p) }
    }
  end
 
  def hls_output(profile)
    {
      name_modifier: "_#{profile[:name]}",
      container_settings: { container: 'M3U8' },
      video_description: {
        height: profile[:height],
        codec_settings: {
          codec: profile[:codec],
          h264_settings: {
            bitrate:           profile[:bitrate],
            rate_control_mode: 'CBR',
            codec_profile:     'MAIN',
            codec_level:       'AUTO'
          }
        }
      },
      audio_descriptions: [{
        audio_source_name: 'Audio Selector 1',
        codec_settings: {
          codec: 'AAC',
          aac_settings: {
            bitrate:     128_000,
            coding_mode: 'CODING_MODE_2_0',
            sample_rate: 48_000
          }
        }
      }]
    }
  end
 
  def thumbnail_output_group(prefix)
    {
      name: 'Thumbnails',
      output_group_settings: {
        type: 'FILE_GROUP_SETTINGS',
        file_group_settings: {
          destination: "s3://#{ENV['S3_PROCESSED_BUCKET']}/#{prefix}thumbnails/"
        }
      },
      outputs: [{
        container_settings: { container: 'RAW' },
        video_description: {
          codec_settings: {
            codec: 'FRAME_CAPTURE',
            frame_capture_settings: {
              framerate_numerator:   1,
              framerate_denominator: 10,
              max_captures:          10
            }
          }
        }
      }]
    }
  end
end

4-3. HLSによるアダプティブストリーミング

「動画ストリーミングの業界標準が**HLS(HTTP Live Streaming)**だ。回線速度に応じて自動で画質が切り替わる仕組みを『アダプティブビットレート』という。」

HLSの動作フロー:
1. クライアントがマスターマニフェスト(master.m3u8)を取得
2. 各画質のプレイリスト(720p.m3u8 など)が列挙されている
3. クライアントは回線速度から最適画質を選ぶ
4. 選んだ画質のプレイリストからセグメント(.ts)を順に取得
5. バッファが2〜3セグメント分たまったら再生開始
6. 回線が遅くなったら次のセグメントから画質を下げる

ファイル構成:
  s3://processed/video-abc123/hls/
  ├── master.m3u8
  ├── _360p.m3u8
  ├── _360p_00001.ts(6秒)
  ├── _360p_00002.ts
  ├── _720p.m3u8
  ├── _720p_00001.ts
  └── _1080p.m3u8 ...
# HLSマスターマニフェスト(master.m3u8)の内容
#EXTM3U
#EXT-X-VERSION:3
 
# 360p: 低帯域向け
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360,CODECS="avc1.42c01e,mp4a.40.2"
_360p.m3u8
 
# 720p: 標準
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.4d401f,mp4a.40.2"
_720p.m3u8
 
# 1080p: 高画質
#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
_1080p.m3u8

Golangでのセグメント配信ハンドラ(プリサインドURL生成):

// internal/streaming/handler.go
package streaming
 
import (
    "fmt"
    "net/http"
    "time"
 
    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/service/s3"
)
 
type StreamHandler struct {
    presigner *s3.PresignClient
    bucket    string
}
 
// マニフェスト・セグメントへのプリサインドURLを発行してリダイレクト
func (h *StreamHandler) ServeSegment(w http.ResponseWriter, r *http.Request) {
    videoID := r.PathValue("video_id")
    path    := r.PathValue("path") // 例: hls/_720p_00001.ts
 
    key := fmt.Sprintf("processed/%s/%s", videoID, path)
 
    presigned, err := h.presigner.PresignGetObject(r.Context(), &s3.GetObjectInput{
        Bucket: aws.String(h.bucket),
        Key:    aws.String(key),
    }, s3.WithPresignExpires(15*time.Minute))
    if err != nil {
        http.Error(w, "failed to presign", http.StatusInternalServerError)
        return
    }
 
    // CDNキャッシュを活かすためリダイレクト(302)
    http.Redirect(w, r, presigned.URL, http.StatusFound)
}

4-4. サムネイル自動生成(Lambda + FFmpeg)

「アップロード直後にサムネイルがないとユーザー体験が悪い。MediaConvertのフレームキャプチャと並行して、Lambdaでも生成する。」

# app/jobs/thumbnail_generation_job.rb
class ThumbnailGenerationJob < ApplicationJob
  queue_as :thumbnails
 
  TIMESTAMP_RATIOS = [0.1, 0.3, 0.5, 0.7, 0.9].freeze
 
  def perform(video_id)
    video      = Video.find(video_id)
    local_path = download_raw_sample(video)
    duration   = probe_duration(local_path)
    return unless duration&.positive?
 
    TIMESTAMP_RATIOS.each_with_index do |ratio, index|
      timestamp  = duration * ratio
      thumb_path = "/tmp/thumb_#{video_id}_#{index}.jpg"
 
      system(
        'ffmpeg', '-ss', timestamp.to_s,
        '-i', local_path,
        '-vframes', '1',
        '-q:v', '2',
        '-vf', 'scale=1280:720:force_original_aspect_ratio=decrease',
        thumb_path, '-y'
      )
 
      S3_CLIENT.put_object(
        bucket:       ENV['S3_PROCESSED_BUCKET'],
        key:          "processed/#{video_id}/thumbnails/thumb_#{index}.jpg",
        body:         File.binread(thumb_path),
        content_type: 'image/jpeg'
      )
    end
 
    video.update!(
      thumbnail_url: "https://cdn.example.com/processed/#{video_id}/thumbnails/thumb_2.jpg",
      status:        'ready'
    )
  end
 
  private
 
  def probe_duration(path)
    out = `ffprobe -v error -show_entries format=duration \
           -of default=noprint_wrappers=1:nokey=1 #{path} 2>&1`
    out.strip.to_f
  end
 
  def download_raw_sample(video)
    path = "/tmp/raw_#{video.id}_sample.mp4"
    # 先頭50MBのみダウンロード(duration取得なら十分)
    S3_CLIENT.get_object(
      bucket: ENV['S3_RAW_BUCKET'],
      key:    "raw/#{video.id}/#{video.filename}",
      range:  'bytes=0-52428800'
    ) { |chunk| File.open(path, 'ab') { |f| f.write(chunk) } }
    path
  end
end

CDN戦略(CloudFront)

Loading diagram...

CloudFrontのキャッシュポリシー設定:

# CloudFront ディストリビューション設定(概念)
distribution:
  origins:
    - id: s3-processed
      domain: my-processed-bucket.s3.ap-northeast-1.amazonaws.com
      s3_origin_config:
        origin_access_control: true  # OACでS3を非公開に
 
  cache_behaviors:
    # HLSセグメント(.ts): 不変なので長期キャッシュ
    - path_pattern: "*/hls/*.ts"
      ttl_default: 2592000   # 30日
      ttl_max:     2592000
      compress: false         # 動画は圧縮不要(エンコード済み)
      viewer_protocol: redirect-to-https
 
    # HLSマニフェスト(.m3u8): 短めに設定
    - path_pattern: "*/hls/*.m3u8"
      ttl_default: 300        # 5分(ライブ更新のケースに備えて短く)
      ttl_max:     3600
      cache_policy: CachingOptimized
 
    # サムネイル: 中程度
    - path_pattern: "*/thumbnails/*.jpg"
      ttl_default: 86400      # 24時間
      ttl_max:     604800     # 7日
 
  # オリジンフェイルオーバー(高可用性)
  origin_groups:
    - id: s3-failover
      members:
        - s3-processed           # プライマリ
        - s3-processed-replica   # リプリカ(別リージョン)
      failover_criteria:
        status_codes: [500, 502, 503, 504]
 
  price_class: PriceClass_All    # 全PoP(400+)を利用
  http_version: http2and3        # HTTP/3 (QUIC) で低レイテンシ

INFO

HLSセグメント(.ts)は一度生成されたら内容が変わらない「不変ファイル」なので、TTLを30日に設定してCDNに長期キャッシュさせる。一方でマニフェスト(.m3u8)はライブ配信では頻繁に更新されるため、TTLを短くする。この使い分けがCDNコスト最適化の要点になる。


AWSアーキテクチャ全体

Loading diagram...

AWSサービス構成の詳細:

アップロードパス:
  Route53:    upload.example.com → ALB
  ALB:        HTTPS終端、ヘルスチェック
  ECS Fargate:
    - Upload API (Rails)
    - 2vCPU / 4GB RAM
    - Auto Scaling: CPU 70%でスケールアウト
  S3 Raw Bucket:
    - 暗号化: SSE-S3
    - ライフサイクル: 30日後 Glacier へ
    - AbortIncompleteMultipartUpload: 7日後
 
トランスコードパス:
  SQS:
    - キュー: transcode-jobs.fifo
    - 可視性タイムアウト: 30分
    - DLQ: 3回失敗後に移動
  ECS Worker (Go):
    - MediaConvertジョブを投入して完了を待機
  AWS MediaConvert:
    - マネージドトランスコード
    - オンデマンド課金
    - 並列ジョブ上限: 20(増加申請可能)
  Lambda(完了通知):
    - EventBridge で MediaConvert 完了イベントを受信
    - Aurora の動画ステータスを 'ready' に更新
 
配信パス:
  CloudFront: 400+ PoP、HTTP/3対応、OAC
  S3 Processed:
    - Intelligent-Tiering(自動ティア移行)
    - クロスリージョンレプリケーション(DR対応)
 
データストア:
  Aurora PostgreSQL:
    - 動画メタデータ(videos, users, channels)
    - Read Replica x 2(読み取り分散)
  ElastiCache Redis:
    - 動画メタデータキャッシュ(TTL: 1時間)
    - 再生位置保存(視聴再開機能)
  OpenSearch:
    - 全文検索(タイトル・説明文・タグ)
    - DynamoDBストリームで自動インデックス更新
  DynamoDB:
    - 視聴履歴(ユーザーID × 動画ID)
    - 再生数カウンター(アトミック更新)

コスト最適化戦略

「ストレージコストは膨大になる。S3のストレージクラスを賢く使い分けることで大幅に削減できる。」

# app/jobs/storage_tiering_job.rb
# 古い動画をGlacierに移行するジョブ(週次実行)
class StorageTieringJob < ApplicationJob
  queue_as :maintenance
 
  def perform
    old_videos = Video.where(status: 'ready')
                      .where('last_viewed_at < ?', 90.days.ago)
                      .where(archived: false)
 
    old_videos.find_each do |video|
      archive_to_glacier(video)
      video.update!(storage_class: 'GLACIER', archived: true)
    end
 
    Rails.logger.info "Archived #{old_videos.count} videos to Glacier"
  end
 
  private
 
  def archive_to_glacier(video)
    prefix = "processed/#{video.id}/"
 
    S3_CLIENT.list_objects_v2(
      bucket: ENV['S3_PROCESSED_BUCKET'],
      prefix: prefix
    ).contents.each do |object|
      S3_CLIENT.copy_object(
        bucket:             ENV['S3_PROCESSED_BUCKET'],
        copy_source:        "#{ENV['S3_PROCESSED_BUCKET']}/#{object.key}",
        key:                object.key,
        storage_class:      'GLACIER',
        metadata_directive: 'COPY'
      )
    end
  end
end
ストレージコスト比較(S3 ap-northeast-1):
  Standard:              $0.025/GB/月
  Intelligent-Tiering:   $0.023/GB/月(アクセスパターン自動最適化)
  Glacier Instant:       $0.005/GB/月(取得数ミリ秒)
  Glacier Deep Archive:  $0.002/GB/月(取得12時間)

90日間未視聴の動画をGlacierに移行するだけで
ストレージコストを最大80%削減できる。
1PB/月なら $25,000 → $5,000 に圧縮。

ボトルネック対策

5-1. アップロードのボトルネック

問題: APIサーバー経由でアップロードすると帯域がボトルネック

解決: クライアントが直接S3にPresigned URLでアップロード
  - APIサーバーはPresigned URLを発行するだけ(軽量)
  - 実際のデータ転送はクライアント ↔ S3(直接)
  - APIサーバーの帯域を消費しない
  - S3のスループット上限は事実上ないため、スケールの問題なし

5-2. トランスコードのボトルネック

問題: 1日500K本のトランスコードは
      MediaConvertの同時ジョブ数制限に引っかかる

解決策:
1. MediaConvertキューを複数作成(優先度キュー + 通常キュー)
   - 新規アップロード → 優先度高キュー(即時処理)
   - 再エンコード(画質追加)→ 通常キュー(オフピーク処理)

2. ECSワーカーをSQSの深さに応じてAuto Scaling
   - SQS深さ > 1000 → ワーカー数を2倍
   - SQS深さ < 100  → ワーカー数を最小に

3. 高解像度(4K)はバッチ処理に分けてコスト最適化

5-3. 配信のボトルネック

問題: 人気動画の公開直後にアクセスが集中して
      CDNキャッシュが効かない(キャッシュウォーミング問題)

解決策:
1. 動画公開前にCloudFrontのキャッシュをウォームアップ
   - Lambda で全主要エッジに segment001.ts をプリフェッチ
   - 公開5分前に実行

2. CloudFront のオリジンシールドを使用
   - リージョナルキャッシュ層を追加
   - オリジン(S3)へのリクエストをさらに削減

3. ライブ配信の場合はMediaPackageを使用(後述)

著作権侵害検出(Content ID / pHash)

「面接でよく聞かれる応用問題だ。YouTubeのContent IDシステムをどう設計するか?」

Loading diagram...
# app/services/copyright_detector.rb
class CopyrightDetector
  # pHash(知覚的ハッシュ): 色調が似ていれば同じハッシュ値になる
  # 完全一致ではなくハミング距離で類似判定(改変にも対応)
  HAMMING_DISTANCE_THRESHOLD = 10
 
  def detect(video_id)
    video       = Video.find(video_id)
    fingerprint = extract_fingerprint(video)
 
    matches = query_fingerprint_db(fingerprint)
    return :clean if matches.empty?
 
    best_match = matches.min_by { |m| hamming_distance(fingerprint, m[:fingerprint]) }
    similarity = 1.0 - best_match[:distance].to_f / 64
 
    handle_copyright_match(video, best_match[:rights_holder]) if similarity >= 0.90
  end
 
  private
 
  def extract_fingerprint(video)
    # FFmpegで1秒ごとにフレームを抽出してpHashを計算
    frames = extract_frames(video, interval: 1)
    frames.map { |frame| phash(frame) }
  end
 
  def hamming_distance(hash1, hash2)
    (hash1 ^ hash2).to_s(2).count('1')
  end
 
  def handle_copyright_match(video, rights_holder)
    case rights_holder[:policy]
    when 'block'
      video.update!(status: 'blocked', block_reason: 'copyright')
    when 'monetize'
      video.update!(monetize_for: rights_holder[:id])
    end
  end
end

INFO

pHash(知覚的ハッシュ)は画像の輝度分布を64ビットのハッシュ値に圧縮する技術。完全一致では検出できないリサイズ・色調変更・文字挿入などの改変にも対応できる。ハミング距離が10以下(約84%以上の一致)であれば同一コンテンツと判定するのが一般的な閾値設定。


ライブ配信との比較

「ライブ配信はVODと何が違うか説明できるか?」とレイカが聞いた。

「ライブは動画ファイルがなくて、リアルタイムで映像が届くので……トランスコードも即座にやらないといけない?」

「正解。ライブ配信はレイテンシとの戦いだ。」

VOD(録画配信)とライブ配信の比較:

         VOD                    ライブ配信
プロトコル: HTTP(S3/CloudFront)   RTMP(受信)→ HLS(配信)
遅延:     なし(既存ファイル)        5〜10秒(LL-HLS なら 2秒以下)
トランスコード: 非同期(数分かかっても良い)  リアルタイム(1秒以内)
ストレージ: 全セグメント保存          直近30分分のみ保存(ライブ中)
AWSサービス: MediaConvert           MediaLive + MediaPackage

ライブ配信フロー:
  配信者 → [RTMP] → MediaLive(リアルタイムエンコード)
         → MediaPackage(HLSパッケージング・DVR)
         → CloudFront(グローバル低遅延配信)
         → 視聴者

Low Latency HLS(LL-HLS):
  - セグメント長を0.5〜2秒に短縮
  - HTTP/2サーバープッシュで次のセグメントを先送り
  - 遅延を2秒以下に抑える

監視と可観測性

# config/initializers/cloudwatch_metrics.rb
module Metrics
  def self.record_transcode_result(video_id, success:, duration_sec:)
    CLOUDWATCH_CLIENT.put_metric_data(
      namespace:   'VideoApp/Transcode',
      metric_data: [
        {
          metric_name: 'JobCompleted',
          value:       success ? 1.0 : 0.0,
          unit:        'Count',
          dimensions:  [{ name: 'Environment', value: Rails.env }]
        },
        {
          metric_name: 'JobDurationSeconds',
          value:       duration_sec,
          unit:        'Seconds'
        }
      ]
    )
  end
 
  def self.record_upload_complete(file_size_mb:)
    CLOUDWATCH_CLIENT.put_metric_data(
      namespace:   'VideoApp/Upload',
      metric_data: [{
        metric_name: 'FileSizeMB',
        value:       file_size_mb,
        unit:        'Count'
      }]
    )
  end
end
# CloudWatch アラーム設定(概念)
alarms:
  - name: TranscodeFailureRate
    metric: JobCompleted (sum 0) / total jobs
    threshold: 2%   # 2%以上失敗したらSlack通知
    period: 300sec
 
  - name: CDNCacheHitRateLow
    metric: CloudFront CacheHitRate
    threshold: 80%  # 80%を下回ったらコスト増のサイン
    period: 600sec
 
  - name: TimeToViewableHigh
    metric: VideoApp/Pipeline/TimeToViewableSeconds P95
    threshold: 300  # 5分以内が目標
    period: 300sec

よくある面接官の深掘り質問

「最後に、面接官がさらに突っ込んでくる質問を練習しておこう。」

Q: 動画の再生位置を保存する設計は?

「ElastiCache(Redis)にユーザーIDと動画IDをキーにして保存。5秒ごとにクライアントからAPIを叩いて位置を更新する。アプリを閉じても続きから再生できる。」

「5秒ごとに全ユーザーからAPIが来ると負荷がかかる。対策は?」

「クライアントサイドでバッファして、30秒分まとめて送る。タブを閉じるときは navigator.sendBeacon で確実に送信する。」

beforeunload は信頼性が低いから sendBeacon が正解だ。」

Q: 不正アップロード(ウイルス入り動画)への対策は?

1. アップロード直後にLambdaでウイルススキャン(ClamAV)
2. MIMEタイプの検証(Content-Typeだけでなく実バイト列を確認)
3. ffprobe で動画として解析できるかチェック
4. 解析失敗したら即座にS3から削除・ユーザーに通知

Q: 同時に100万人が同じ動画にアクセスしたらどうなるか?

「CDNのエッジキャッシュがすでにセグメントを持っていれば、S3には1リクエストも来ない。CloudFrontの全PoP(400+箇所)が独立してキャッシュを持つので、100万人でも実際のオリジン負荷は数百リクエスト程度に収まる。」

「それが正しい回答だ。ここまで言えれば面接官は満足する。」

Q: 動画のエンコード設定でコーデックとビットレートの選び方は?

コーデックの選択:
  H.264 (AVC):  互換性最高。全デバイスで再生可能。1080p以下に最適
  H.265 (HEVC): H.264比50%の容量削減。ただしCPU負荷が高くiOS/Safari向き
  VP9:          Google開発。YouTubeが採用。Chromeで最適化されている
  AV1:          次世代コーデック。H.265比20%削減。エンコードが非常に遅い

ビットレートと画質の関係:
  解像度      推奨ビットレート  H.265での圧縮後
  360p        1 Mbps           0.5 Mbps(50%削減)
  720p        3 Mbps           1.5 Mbps
  1080p       6 Mbps           3 Mbps
  4K          20 Mbps          10 Mbps

実際の戦略:
  - デフォルトは H.264(互換性重視)
  - 4KコンテンツのみH.265を使用(容量節約効果が大きい)
  - AV1は将来対応として留保

WARNING

動画設計の落とし穴: 「そのままS3にアップロードして再生する」と答えてしまうこと。トランスコード・マルチ画質・HLSによるアダプティブストリーミング・CDNによる配信を説明できないと、スケーラビリティの欠如を指摘される。この章で学んだパイプラインの全体像を必ず説明できるようにしておくこと。


「今日は情報量が多かったですね」とソウタが言った。画面を埋め尽くすメモをスクロールしながら。

「動画は最も複雑なシステムの一つ。でも分解すれば理解できる。アップロード → 非同期パイプライン → トランスコード → HLS → CDN配信。この流れを自分の言葉で説明できるようになれば、面接で確実に差がつく。」

「マルチパートアップロードとアダプティブビットレート、今夜復習します。」

「それで十分だ。次は検索エンジンの設計に挑む。」