目次を表示する

システム設計とCS概念

動画配信(YouTube/Netflix)── エンコードパイプラインと適応ビットレート

動画配信(YouTube/Netflix)── エンコードパイプラインと適応ビットレート

扱うCS概念:DAG(有向非巡回グラフ)タスクスケジューリング、アダプティブビットレートストリーミング(ABR)、CDN、チャンク転送エンコーディング


動画配信パイプライン — DAGエンコード・ABR・CDN

この章で何ができるようになるか:動画ファイルがアップロードされてから視聴できるまでの処理パイプラインを設計できるようになる。回線速度が変わっても途切れない動画再生がどう実現されているかを説明できる。


問題設定

YouTube の動画アップロード〜配信パイプラインを設計する。

規模感:
  - 動画アップロード:1分間に500時間分の動画がアップロードされる
  - 視聴数:1日あたり数十億回の再生
  - サポートするデバイス:スマートフォン(低速回線)〜4Kテレビ(超高速回線)
  - アップロード後の公開まで:数分以内(短尺)〜十数分(長尺)

技術的課題:
  1. アップロードされた動画を複数の画質・フォーマットに変換する
  2. 世界中のユーザーに低レイテンシで配信する
  3. 回線速度に合わせて動的に画質を切り替える

フェーズ1:動画アップロードとチャンク転送

大きな動画ファイル(数GB)を1回のHTTPリクエストで送るのは現実的でない。

# クライアント側:チャンク分割アップロード
import requests

def upload_video(file_path: str, chunk_size: int = 5 * 1024 * 1024):  # 5MB
    # 1. アップロードセッションを開始
    session = requests.post("/api/upload/init", json={"filename": file_path})
    upload_id = session.json()["upload_id"]
    
    file_size = os.path.getsize(file_path)
    with open(file_path, 'rb') as f:
        chunk_num = 0
        while chunk := f.read(chunk_size):
            offset = chunk_num * chunk_size
            
            # 2. 各チャンクを送信
            resp = requests.put(
                f"/api/upload/{upload_id}/chunk",
                headers={
                    "Content-Range": f"bytes {offset}-{offset+len(chunk)-1}/{file_size}",
                    "Content-Length": str(len(chunk))
                },
                data=chunk,
                timeout=30
            )
            
            if resp.status_code == 308:  # Resume Incomplete
                chunk_num += 1
            elif resp.status_code != 200:
                raise Exception(f"Upload failed at chunk {chunk_num}")
    
    # 3. アップロード完了を通知
    requests.post(f"/api/upload/{upload_id}/complete")

なぜチャンク分割か

  • ネットワーク障害時に失敗したチャンクだけ再送できる
  • 複数チャンクを並列アップロードしてスループットを上げられる
  • サーバーはチャンクを受け取りながらすぐに処理を開始できる(パイプライン化)

フェーズ2:動画処理パイプライン(DAG タスクグラフ)

アップロードされた動画は複数の処理ステップを経て配信可能な状態になる。これを DAG(有向非巡回グラフ) として設計する。

graph TD
    Upload[動画アップロード完了] --> Validate[メタデータ検証<br/>コーデック確認]
    Validate --> Split[チャンク分割<br/>GOP単位で分割]
    Split --> T1[トランスコード<br/>240p/30fps]
    Split --> T2[トランスコード<br/>480p/30fps]
    Split --> T3[トランスコード<br/>720p/30fps]
    Split --> T4[トランスコード<br/>1080p/60fps]
    Split --> T5[トランスコード<br/>4K/60fps]
    T1 & T2 & T3 --> Watermark[ウォーターマーク付与]
    T4 & T5 --> Watermark
    Watermark --> Manifest[マニフェスト生成<br/>HLS/DASH]
    Manifest --> CDN[CDN へプッシュ]
    Split --> Thumbnail[サムネイル生成<br/>複数フレームから選択]
    Split --> Subtitle[字幕抽出<br/>音声認識]

これは並列処理できる箇所とシリアルに処理する必要がある箇所が混在する。DAG で依存関係を表現することで、最大限並列化できる。

Apache Airflow / AWS Step Functions がこのような DAG タスクスケジューリングを実装するツールだ。

# Airflow での動画処理パイプライン定義(概念コード)
from airflow import DAG
from airflow.operators.python import PythonOperator

dag = DAG("video_processing", schedule_interval=None)

validate = PythonOperator(task_id="validate", python_callable=validate_video, dag=dag)
split = PythonOperator(task_id="split", python_callable=split_into_gops, dag=dag)
transcode_240p = PythonOperator(task_id="transcode_240p", ..., dag=dag)
transcode_1080p = PythonOperator(task_id="transcode_1080p", ..., dag=dag)
generate_manifest = PythonOperator(task_id="manifest", ..., dag=dag)

# 依存関係の定義
validate >> split
split >> [transcode_240p, transcode_480p, transcode_720p, transcode_1080p, transcode_4k]
[transcode_240p, transcode_480p, transcode_720p, transcode_1080p, transcode_4k] >> generate_manifest

GOP(Group of Pictures):並列エンコードの鍵

動画ファイルは全フレームを独立して圧縮しているわけではない。動画圧縮(H.264/H.265/AV1)は:

I フレーム(Intra-coded):完全な画像データ(JPEG のようなもの)
P フレーム(Predicted):前のフレームとの差分
B フレーム(Bi-directional):前後のフレームとの差分

GOP = I フレームから次の I フレームまでの単位
  例:30fps 2秒 GOP = 1 I フレーム + 59 P/B フレーム

なぜ GOP 単位で分割するのが重要か:

GOP の境界(Iフレーム)で分割すれば、
各部分が独立してデコードできる
→ 動画を並列にエンコードできる
→ 長時間動画も短時間で処理できる

GOP の中間で分割すると、
そのフレームは他のフレームに依存しているため
独立してデコードできない

フェーズ3:アダプティブビットレートストリーミング(ABR)

「4Kテレビで観るユーザー」と「地下鉄の弱い LTE で観るユーザー」に同じ動画を送れない。ABR は回線速度に合わせて画質を動的に切り替える仕組みだ。

HLS(HTTP Live Streaming)の仕組み

Apple が開発した HLS は現在の標準。動画を小さなチャンク(通常2〜6秒)に分割し、マニフェストファイルで管理する。

# マスタープレイリスト(video.m3u8)
#EXTM3U
#EXT-X-VERSION:3

# 240p
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=426x240
240p/playlist.m3u8

# 480p
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=854x480
480p/playlist.m3u8

# 720p
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1280x720
720p/playlist.m3u8

# 1080p
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080
1080p/playlist.m3u8
# 720p のメディアプレイリスト(720p/playlist.m3u8)
#EXTM3U
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0

#EXTINF:6.0,
segment000.ts
#EXTINF:6.0,
segment001.ts
#EXTINF:5.8,
segment002.ts
...
# クライアント側の ABR ロジック(概念)
class ABRPlayer:
    def __init__(self):
        self.buffer_seconds = 0
        self.current_quality = "480p"
        self.download_speeds = []  # 直近のダウンロード速度履歴

    def select_quality(self) -> str:
        avg_speed_kbps = sum(self.download_speeds[-5:]) / 5

        if self.buffer_seconds < 5:
            # バッファが少ない → 低画質に下げる(途切れ防止優先)
            return "240p"
        elif avg_speed_kbps > 5000 and self.buffer_seconds > 15:
            # 高速回線+バッファ十分 → 高画質に上げる
            return "1080p"
        elif avg_speed_kbps > 2500:
            return "720p"
        elif avg_speed_kbps > 1000:
            return "480p"
        else:
            return "240p"

    def download_next_segment(self):
        quality = self.select_quality()
        start_time = time.time()
        
        segment = download(f"{quality}/segment{self.next_segment}.ts")
        
        elapsed = time.time() - start_time
        speed_kbps = len(segment) * 8 / elapsed / 1000
        self.download_speeds.append(speed_kbps)
        self.buffer_seconds += 6  # 6秒チャンクをバッファに追加
        self.current_quality = quality
graph LR
    subgraph ネットワーク速度
        Fast[高速 >5Mbps] --> Q4[1080p]
        Mid[中速 2-5Mbps] --> Q3[720p]
        Slow[低速 0.5-2Mbps] --> Q2[480p]
        Poor[弱い <0.5Mbps] --> Q1[240p]
    end
    subgraph バッファ量
        High[バッファ >15秒] -->|品質上げる| Fast
        Low[バッファ <5秒] -->|品質下げる| Poor
    end

CDN(Content Delivery Network)の役割

動画ファイルを世界中のユーザーに低レイテンシで届けるには、ユーザーの近くにキャッシュを配置する必要がある。

graph TD
    Origin[オリジンサーバー<br/>(東京)] -->|プッシュまたはプル| Tokyo[東京エッジ]
    Origin --> Singapore[シンガポールエッジ]
    Origin --> London[ロンドンエッジ]
    Origin --> NewYork[ニューヨークエッジ]
    
    UserTokyo[東京のユーザー] --> Tokyo
    UserSG[シンガポールのユーザー] --> Singapore
    UserUK[英国のユーザー] --> London

プッシュ vs プル

プッシュ型:
  人気コンテンツを事前に全エッジへ配信(プロアクティブ)
  → メリット:キャッシュミスが起きない
  → デメリット:人気でないコンテンツも全エッジに送る(ストレージ無駄)

プル型(オンデマンドキャッシュ):
  初めてリクエストされたときだけオリジンから取得してキャッシュ
  → メリット:人気コンテンツだけキャッシュされる(効率的)
  → デメリット:最初のユーザーだけ遅い(キャッシュミスペナルティ)

実際:
  新規公開 + 話題性が高い動画 → プッシュ
  ロングテールの動画 → プル

Anycast:CDN エッジのIPアドレスをグローバルに同じにし、ルーターが「最も近いエッジ」へ自動的にルーティングする技術。ユーザーは常に最近傍のエッジに接続される。


Netflix の Adaptive Streaming:独自の工夫

Netflix は 2015年に「Per-Title Encoding(タイトル単位エンコード)」を導入し、さらに 2018年に「Shot-based Encoding(ショット単位エンコード)」へと進化させた。

従来:
  動画全体を一定のビットレートで均一にエンコード
  → 動きの少ない場面(顔のアップ)に無駄なビットを使っている
  → 動きの激しい場面(アクション)が画質劣化

Shot-based Encoding:
  シーンの複雑さを分析し、動きの激しい場面に多く、静止場面に少なくビットを割り当てる
  → 同じデータ量でも体感画質が大幅に向上

また Netflix は AV1 コーデック(2020年に Android 向けで初導入、その後 TV・Web へ拡大)を採用。H.264 に比べて同画質で約50%のデータ量を実現する(計算コストは高い)。


まとめ

フェーズ課題解決策CS概念
アップロード大ファイルの転送チャンク分割 + 並列送信マルチパート転送
処理複数形式への変換を高速化DAG による並列トランスコードタスクグラフ・GOPベース分割
配信世界中に低レイテンシでCDN + Anycastエッジキャッシング
再生回線速度の変動への対応ABR(HLS/DASH)アダプティブビットレート
画質効率同じデータ量で高画質Shot-based Encoding + AV1知覚的コーデック最適化

動画配信は「アップロード → 処理 → 保存 → 配信 → 再生」の各レイヤーで異なる技術的問題が積み重なっている。各レイヤーで CS の基礎(グラフアルゴリズム・圧縮理論・キャッシング)が応用されている好例だ。