2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人用YouTubeをAWSだけで本番稼働させた話

2
Last updated at Posted at 2026-08-18

はじめに

この記事はClaudeと一緒に書きました

「YouTubeって中身どうなってるんだろう」
と気になって調べ始めたら、そのまま個人用の動画配信アプリをAWSだけで作って本番稼働させるところまで行ってしまった。
動くところまで行けたが、途中で法律を調べたり、ALBの仕様に阻まれて設計を1回まるごとやり直したりと、なかなか濃い数時間だった。これはその記録である。

まずYouTubeの中身を聞いてみた

いきなり作り始める前に、そもそもYouTubeがどう作られているのかをおさらいした。
ざっくり言うと以下の要素で構成されている。

  • アップロード〜トランスコード: アップロードされた動画を144p〜4K/8Kまで複数の解像度・コーデックに変換する。大量のワーカーで並列処理している
  • CDN配信: Google Global Cache(GGC)というISP内に直接置かれた専用キャッシュノードで配信している。アダプティブビットレート配信(ネットワーク状況で解像度を自動で切り替える仕組み)を使う
  • メタデータ: VitessというMySQLをシャーディングでスケールさせるミドルウェアや、Bigtable/Spannerで管理している
  • レコメンド: 候補生成→ランキングの2段階構成の大規模MLシステム
  • Content ID: 音声・映像の指紋照合で著作権侵害コンテンツを検出する仕組み
  • 広告配信: Google Ad Manager基盤と連携

image.png

AWSだけで再現できるかやってみた

上記のうち、素直にAWSのマネージドサービスに置き換えられる部分は多い。
S3(アップロード)→MediaConvert(トランスコード)→CloudFront(配信)→DynamoDB(メタデータ)→OpenSearch(検索)→Personalize(推薦)
という構成で骨格は十分組める。

ただしどうしても埋まらない部分がある。

  • Content ID: 音声・映像フィンガープリンティングと数十億本規模の参照DBを突き合わせる仕組みに対応するAWSのマネージドサービスはない。Audible MagicやACRCloudのようなサードパーティSaaSに頼るか、Chromaprintのようなオープンソースの音響指紋ライブラリを自前で組むしかない
  • Google Global Cache的なISP内キャッシュ: CloudFrontは世界中のエッジロケーションを使うが、ISP内に直接設置されたキャッシュノードのような密度はない
  • 広告オークション: Google Ad Manager級の仕組みを自作するのは現実的ではない。AdSense/AdMobのような外部アドネットワークに委託するのが妥当な落とし所

今回は個人利用なのでContent IDも広告も最初から実装しないことにした。
この判断の理由は後述する法律の話と直結している。

image.png

作る前に法律を調べた

「動画共有サービスを個人で作って公開する」と、実は日本の法律がいくつか絡んでくる。
ここをちゃんと詰めてから実装に入った。

まず電気通信事業法。
動画を一方向に配信するだけなら「他人の通信を媒介する」には当たらず、原則届出は不要という整理になる。
ただしコメント欄やDMのような利用者同士のやり取りを媒介する機能を足すと話が変わってくる。

次に情報流通プラットフォーム対処法(旧プロバイダ責任制限法)。
他ユーザーが投稿できるUGCプラットフォームにすると、著作権侵害・誹謗中傷の削除申請を受け付ける窓口設置が実質必須になる。
月間利用者が大規模(国内1,000万人超)だとさらに重い義務が乗る。

最後に著作権法30条の私的使用の複製。
ここが一番実務に効いてくる論点だった。非公開で自分だけがアクセスできる状態なら私的使用の範囲に収まるが、公開してしまうと「公衆送信」に該当し、この例外は及ばなくなる。
さらに「認証をかけて承認制にすれば安全か」も検討したが、著作権法上の「公衆」には「特定かつ多数の者」も含まれるため、認証の有無ではなく見せる相手の人数・関係性の濃さが線引きになる。
家族程度のごく少人数なら私的使用の範囲、友人知人まで広げると
「特定多数」とみなされるリスクが出てくる
という整理になった。

結論として、
他ユーザー投稿は一切実装しない・自分専用のアカウント1つだけで運用する
という方針に決めた。
これで電気通信事業法もプラットフォーム対処法も関係なくなるし、著作権法上も私的使用の範囲に収まる。

構成を決めてCDKで組み始めた

方針は以下のとおり。

  • 自分専用(他ユーザー投稿・サインアップは実装しない)
  • AWS環境のみ(Vercelは普段使っているが今回は不採用)
  • Content ID・広告配信は実装しない
  • アップロードはS3→MediaConvertでトランスコード→CloudFront配信
  • WebUIはECS Fargate + ALB + CloudFront(App RunnerはAWS側が新規受付を停止していたので選択肢から除外した)

CDKでVPC、S3、DynamoDB、MediaConvert起動用のLambda、ECS、CloudFront、Cognitoを一通り組んでいった。ここまでは順調だった。

デプロイしたらALBのCognito認証がHTTPS必須で死んだ

最初はALBのauthenticate-cognitoアクションでログインを組んでいた。
設定自体は簡単で、ALBのリスナールールにCognito認証を挟むだけでアプリ側に認証コードを書かずに済む。

ところがデプロイすると盛大に失敗した。

Resource handler returned message: "Actions of type 'authenticate-cognito' are supported only on HTTPS listeners"

ALBのCognito認証はHTTPSリスナーでしか使えない。
ACM証明書が要る。ACM証明書はDNS検証できる実在のドメインが必要で、今回のプロジェクトにはカスタムドメインを用意していなかった。

ここで2つの道が浮かんだ。
ドメインを取ってHTTPS化するか、認証をアプリ側に持っていくか
ドメイン費用と設定の手間を天秤にかけて、後者(Next.jsのproxy.tsで認証ゲートを実装する方式)を選んだ。

認証をアプリ側に移したらNAT Gatewayが無いことに躓いた

Cognito App Clientをシークレット無しのパブリッククライアントにして、PKCE(Proof Key for Code Exchange)でトークン交換を保護する方式に作り直した。ブラウザがCognitoのトークンエンドポイントを直接叩く設計にすれば、ALBのHTTPS制約とは無関係になる。

これでいけると思ってデプロイしたら、今度はログインコールバックが500エラーになった。

⨯ [TypeError: fetch failed] {
  [cause]: AggregateError:
      at ignore-listed frames {
    code: 'ETIMEDOUT',

原因はNAT Gatewayを置いていなかったこと。
コスト削減のためVPCエンドポイントだけでインターネット経由の通信を賄う構成にしていたのだが、CognitoのHosted UI/OAuthドメイン(*.auth.<region>.amazoncognito.com)にはVPCエンドポイントが存在しない。
ECSタスクからは物理的に到達できなかった。

ここでもう一段設計を変えて、トークン交換自体をサーバー側ではなくブラウザ側で行う方式にした。
サーバー側でどうしても必要なのはJWT検証(JWKS取得)だけで、これはcognito-idpのVPCエンドポイントで賄える。
ブラウザは普通にインターネットに出られるので、Cognitoのトークンエンドポイントを直接叩ける。
この設計変更で、NAT Gateway無しのままログインが動くようになった。

ARM64を指定し忘れてコンテナが起動しなかった

やっと通ったと思ってデプロイしたら、今度はECSタスクが延々とクラッシュを繰り返した。CloudWatch Logsを見るとこれだけ書いてある。

exec /usr/local/bin/docker-entrypoint.sh: exec format error

Apple SiliconのMacでDockerビルドするとイメージはarm64になる。Fargateのデフォルトはx86_64なので、アーキテクチャが合わずにコンテナが起動すらできていなかった。
CDKのFargateTaskDefinitionにruntimePlatformでARM64を明示したら解決した。
ついでにECSのdeployment circuit breakerも入れた。
これが無いと起動失敗の検知に最大3時間かかると警告が出ていて、実際このタイミングで40分くらい気づかず放置していた。

ALBのヘルスチェックが認証にリダイレクトされて再度死んだ

circuit breaker入れて再デプロイしたら、今度は別の理由で失敗した。
ヘルスチェックのパスを/にしていたのだが、/はproxy.tsの認証ゲートを通るので、未ログイン状態のヘルスチェックが302を返す。
ALBはこれを異常と判定し続けてデプロイが失敗する。

認証を通さない専用の/api/healthを追加してヘルスチェック先を差し替えたら通った。
地味だが典型的なハマりどころだと思う。

動画は再生できたがマニフェストのパスが壊れていた

ようやくデプロイが通り、動画をアップロードしてMediaConvertのジョブも完走した。
ところが再生ページで動画が真っ黒のまま動かない。

原因はMediaConvertの完了イベントのoutputGroupDetails.playlistFilePathsがs3://bucket/keyという完全なURIで返ってくること。
これをそのままDynamoDBのmanifestKeyに保存していたので、アプリが/${manifestKey}という相対パスを組み立てると/s3://bucket/...という壊れたURLになっていた。

MediaConvertのジョブ作成時に自分で指定したDestinationプレフィックスから、バケット内の相対キーを決定的に組み立てる方式に直したら直った。
イベントの生の値を鵜呑みにしないほうがいい、といういい教訓。

CloudFrontの署名付きCookieがワイルドカードに対応していなかった

/renditions/*(動画本体)をCloudFrontのKey Groupで保護しようとして、@aws-sdk/cloudfront-signerのgetSignedCookiesにurl+dateLessThanを渡すいわゆる「canned policy」形式で組んだ。
ワイルドカードを含むURLを渡せば配下を丸ごと許可できるだろうと思っていたのだが、実際に試すとAccessDeniedとなった。

{"error":"AccessDenied","message":"Access denied"}

Key Pair IDが正しいことも自分で署名付きCookieを生成して個別に検証した上で確認した(このAWSアカウントには別プロジェクトのCloudFront Public Keyも登録されていて、最初はそちらのIDを誤って使っていた、というオチも一つあった)。
結局、policyパラメータでResourceにワイルドカードを含むカスタムポリシーJSONを明示的に組み立てる形式に変えたら通った。
canned policyのワイルドカードは
見た目上受け付けても実際にはマッチしない
という挙動は把握しておいたほうがいい。

削除機能を作ったら消したはずの動画が復活した

一通り動くようになったので削除機能を足した。
DynamoDBのレコードとS3のオブジェクトを消すだけの単純な機能のつもりだったが、2つバグを踏んだ。

1つ目はIAM権限。
grantWrite/grantDeleteはオブジェクトレベルの権限(s3:PutObject*, s3:DeleteObject*)だけで、削除処理内のListObjectsV2が必要とするs3:ListBucket(バケットレベル)が含まれていなかった。
grantReadを追加したら直った。

2つ目がもっと厄介だった。
処理中の動画を削除すると、後からMediaConvertの完了通知を受け取ったLambdaが同じvideoIdに対してUpdateItemを叩く。
DynamoDBのUpdateItemは対象が存在しないと新規作成してしまう仕様なので、削除したはずのレコードが復活していた。
ConditionExpression: 'attribute_exists(videoId)'を付けて、存在しないキーへの更新は無視するようにガードした。

ALBのセキュリティグループが実は全開放のままだった

CloudFront経由以外のアクセスを塞ごうとして、ALBのセキュリティグループをCloudFrontのマネージドプレフィックスリスト(pl-58a04531)に絞るコードを書いた。
ところがデプロイして実際に確認すると、0.0.0.0/0のルールがまだ生きていた。

{
  "IpRanges": [{"CidrIp": "0.0.0.0/0", "Description": "Allow from anyone on port 80"}],
  "PrefixListIds": [{"PrefixListId": "pl-58a04531"}]
}

原因はALBのaddListenerのデフォルト値open: true。
これがリスナーのポートに対する0.0.0.0/0の受信ルールを自動で足していた。
自分で書いたセキュリティグループのルールとは別に、CDKが気を利かせて(?)全開放ルールを追加してくれていたことになる。
open: falseを明示したら消えた。「絞ったつもりが絞れていない」系のバグは、実際にAWS CLIで最終状態を見ないと気づけない典型例だと思う。

コストを見積もったらNAT Gateway回避の意味が薄かった

固定費を概算してみた。

項目 月額目安
VPCインターフェースエンドポイント×5 約$50.4
ALB 約$20〜23
ECS Fargate(0.25vCPU/0.5GB, ARM64) 約$8.9
Secrets Manager $0.4
合計 約$83

NAT Gatewayのコスト(東京リージョンで概算$44.6/月+データ処理料)を避けるために5つのVPCエンドポイントを使う構成にしたのだが、計算してみるとエンドポイント5つの合計はNAT Gateway単体とほぼ同額かむしろ高い。
今回の規模だと「NAT Gateway回避=コスト削減」が成立していなかった。
NAT Gateway 1本に統合すれば月$5〜6程度は浮くが、単一障害点になる。
個人利用の単一ユーザーアプリなのでそのリスクは許容範囲と判断して、結局エンドポイント構成のまま維持することにした。

動いているもの

  • Cognito(PKCE、自分専用アカウント1つ)でログイン
  • 動画アップロード→S3→Lambda→MediaConvertでHLSにトランスコード
  • CloudFront+署名付きCookieで動画本体もアクセス制御
  • 削除機能、日英切り替え、YouTube風のダーク基調UI

スクリーンショット 2026-08-18 22.25.32.png

スクリーンショット 2026-08-19 0.26.45.png

スクリーンショット 2026-08-18 23.44.24.png

スクリーンショット 2026-08-18 23.44.58.png

コードはGitHubに公開している。
READMEに、他ユーザー投稿を実装しなかった法的理由もそのまま書いた。

おまけ

一番時間がかかったのはコードそのものより「なぜ動かないのか」のログ調査だった。
exec format errorもAccessDeniedもInvalidKeyも、エラーメッセージ自体は素っ気ないのに原因は毎回別物で、CloudWatch LogsとAWS CLIを行き来する時間が一番長かった気がする。
個人開発でここまでAWSのマネージドサービスを積み上げると、動くこと自体より「なぜ動かないか」を特定する力のほうが試される。

最後まで読んでいただきありがとうございました。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?