この「山手線 3Dジオラマ」は、いわゆる静的サイト(Static Site)です。サーバーサイドでPHPやRubyが動いているわけではなく、ブラウザがHTML/CSS/JSを読み込んで動作しています。
このようなアプリを公開する場合、レンタルサーバーやVPSを借りるよりも、AWSの「S3」と「CloudFront」を組み合わせるのが、コスト・速度・管理の面で最強の選択肢だと感じています。
構成の概要
シンプルですが、非常に強力な構成です。
- Amazon S3: 全てのファイル(HTML, 画像, 3Dモデル)を置くストレージ。
- Amazon CloudFront: S3の前段に置くCDN(コンテンツ配信ネットワーク)。世界中のエッジサーバーから爆速で配信します。
3Dモデル(.glbファイル)やテクスチャ画像など、ファイルサイズが大きくなりがちなコンテンツも、CloudFrontを通すことで遅延なくユーザーに届けることができます。
CloudFrontを使うメリット
単にS3の「静的ウェブサイトホスティング」機能を使うだけでも公開はできますが、CloudFrontを挟むことには大きなメリットがあります。
1. 無料でHTTPS化できる
S3単体だと独自ドメインでのHTTPS化が面倒ですが、CloudFrontなら「AWS Certificate Manager (ACM)」で発行した無料のSSL証明書を簡単に紐付けられます。今のWeb標準ではHTTPSは必須ですよね。
2. 転送コストの削減
S3から直接データを送信するよりも、CloudFront経由の方がデータ転送量が安くなる場合があります。また、キャッシュが効けばS3へのアクセス自体が減るため、リクエスト料金も節約できます。
実装時のハマりポイント
非常に便利な構成ですが、いくつか落とし穴もありました。
キャッシュの削除(Invalidation)
CloudFrontは強力にキャッシュするため、S3のファイルを更新しても、ユーザー側には古いファイルが表示され続けることがあります。
開発中はキャッシュ時間(TTL)を短くするか、デプロイ時に明示的にキャッシュ削除(Invalidation)を行う必要があります。
# AWS CLIでキャッシュを削除するコマンド例
aws cloudfront create-invalidation --distribution-id XXXXXX --paths "/*"
CORS(クロスオリジンリソース共有)
3Dモデルやフォントファイルを読み込む際、CORSエラーで弾かれることがありました。S3のCORS設定だけでなく、CloudFront側でも適切なヘッダー(Originなど)をフォワードする設定が必要です。
デプロイの自動化
開発中は頻繁にファイルを更新するため、デプロイ作業を手動で行うのは非効率です。そこで、AWS CLIとシェルスクリプトを組み合わせた簡易的なデプロイパイプラインを構築しました。
#!/bin/bash
# deploy.sh - S3へのアップロードとCloudFrontキャッシュ削除
# 1. S3にファイルを同期(変更があったファイルのみアップロード)
aws s3 sync ./dist s3://mini-trains-bucket \
--delete \
--cache-control "max-age=86400"
# 2. HTMLファイルはキャッシュを短めに設定
aws s3 cp ./dist/index.html s3://mini-trains-bucket/index.html \
--cache-control "max-age=300"
# 3. CloudFrontのキャッシュを削除
aws cloudfront create-invalidation \
--distribution-id E1XXXXXXXXXX \
--paths "/index.html" "/articles/*"
echo "Deploy complete!"
ポイントは、アセット(画像、3Dモデル等)のキャッシュ時間を長く(86400秒 = 24時間)、HTMLファイルのキャッシュ時間を短く(300秒 = 5分)設定していることです。3Dモデルや画像はめったに変わらないため長くキャッシュしても問題ありませんが、HTMLは頻繁に更新する可能性があるため短くしています。
将来的にはGitHub Actionsと連携し、mainブランチへのプッシュで自動デプロイされる仕組みに移行する予定です。
コストの実態
個人開発で最も気になるのがランニングコストでしょう。本サイトの月間コストの内訳を公開します(2026年1月時点、月間PV数は約5,000程度)。
- S3ストレージ: 約$0.03(保存容量:約100MB)
- S3リクエスト: 約$0.01(GET/PUTリクエスト分)
- CloudFront転送量: 約$0.50(3Dモデル等のアセット配信がメイン)
- Route 53: $0.50(ホストゾーン維持費)
- 合計: 約$1.04/月(約150円)
レンタルサーバー(月額500〜1,000円程度)と比較しても安価です。さらに、CloudFrontの無料枠(毎月1TBのデータ転送)があるため、アクセス数が増えてもある程度までは追加コストがかかりません。
ただし、3Dモデルファイル(.glb)のサイズが合計で数十MBあるため、人気が出てアクセスが急増した場合は転送コストに注意が必要です。そのために、モデルファイルにはgzip圧縮を適用し、実際の転送サイズを約60%に削減しています。
独自ドメインの設定
CloudFrontに独自ドメイン(mini-trains.com)を紐付ける手順は以下のとおりです。
- Route 53でドメインを取得(または他のレジストラから移管)
- AWS Certificate Manager (ACM)でSSL証明書を発行(※ us-east-1リージョンで作成する必要あり)
- CloudFrontディストリビューションの設定で、代替ドメイン名(CNAME)とSSL証明書を指定
- Route 53でAレコード(エイリアス)をCloudFrontに向ける
特にハマりやすいのが「ACMの証明書はus-east-1で作成しなければならない」という制約です。東京リージョン(ap-northeast-1)で証明書を作ってしまい、CloudFrontの設定画面で選択できず困った経験があります。AWSのドキュメントには書いてあるのですが、見落としがちなポイントです。
CORSで半日溶かした話
CloudFrontとS3の構成は、ドキュメント通りに組み立てれば基本的には動きます。しかし、いざ3Dモデル(.glb)をThree.jsで読み込む段になって、私はCORSエラーで半日ほど溶かしました。コンソールに真っ赤な文字で「Access-Control-Allow-Origin」のエラーが並んでいるのを見ると、原因はすぐに分かる気がするのに、ちっとも解決できないという経験、エンジニアなら一度はあるのではないでしょうか。
「CloudFront経由なら動く、S3直叩きだと動かない」の謎
事の発端は、ローカル開発時に残していたS3の直接URL(https://mini-trains-bucket.s3.ap-northeast-1.amazonaws.com/...)をコード内で使い続けていたことでした。CloudFrontのディストリビューションを立ち上げた後、本番環境では問題なく動いているのに、ローカルでThree.jsの GLTFLoader が頑なにモデルを読み込んでくれません。ブラウザのコンソールには毎回同じメッセージ。
Access to XMLHttpRequest at 'https://mini-trains-bucket.s3.ap-northeast-1.amazonaws.com/models/train.glb'
from origin 'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
最初はS3側のCORS設定が抜けているのだろうと思い、バケットのCORS設定を確認したのですが、すでに AllowedOrigins: ["*"] で設定済み。では、なぜ弾かれるのか? 実はこのとき、私はS3バケットポリシー(パブリックアクセス許可)とCORSコンフィグが別物であることをすっかり忘れていました。バケットポリシーでオブジェクトへのアクセス自体が拒否されていると、S3はCORSヘッダーを返す前にリクエストを蹴ります。その結果、ブラウザ側には「CORSエラー」としてしか見えないという、非常に紛らわしい挙動になっていたのです。
Three.jsの crossOrigin 設定の罠
もうひとつハマったのが、Three.jsの TextureLoader の crossOrigin 属性でした。デフォルトでは "anonymous" になっているのですが、一部のテクスチャ(PLATEAU由来のものなど)を読み込む際、これが原因でCanvasに描画したテクスチャを toDataURL で取り出そうとすると「Tainted canvases may not be exported」というエラーが発生しました。
// ❌ 最初はこれで動くと思っていた
const loader = new THREE.TextureLoader();
loader.load('https://cdn.mini-trains.com/textures/roof.png', (texture) => {
material.map = texture;
});
// ✅ 正しくはcrossOriginを明示し、CloudFront側でもヘッダー転送の設定が必要
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
loader.load('https://cdn.mini-trains.com/textures/roof.png', (texture) => {
texture.colorSpace = THREE.SRGBColorSpace;
material.map = texture;
});
最終的に、S3側で AllowedOrigins に本番・プレビュー・localhost の3つを明示し、CloudFrontのビヘイビア設定でOrigin/Access-Control-Request-Headers/Access-Control-Request-Method の3ヘッダーをオリジンリクエストに含めるようにしてようやく決着しました。「S3とCloudFrontの両方を整えないとCORSは通らない」という当たり前の事実に、半日かかってたどり着いたわけです。S3の直叩きURLをコードから消した時、ようやくすべてが噛み合った瞬間は、今でも忘れられません。
キャッシュ戦略の失敗と再構築
CDNを入れる最大のメリットはキャッシュですが、キャッシュ戦略は「効かせすぎても効かせなさすぎても死ぬ」という繊細なバランスの上に成り立っています。私は最初、これを完全に軽く見ていました。
「全ファイル1年キャッシュ」の悲劇
初期構築時、面倒だったので全ファイルに対して Cache-Control: max-age=31536000(1年)を設定していました。3Dモデルも画像もHTMLもCSSもまとめて1年です。結果、リリース直後にHTMLのタイポを直して再アップロードしたところ、ブラウザの画面は一切更新されないという状態に陥りました。CloudFrontのキャッシュInvalidationを実行すれば直るのですが、エッジロケーションに残っている古いHTMLがランダムに返ってくるため、「人によって見えているバージョンが違う」という最悪の状況になってしまったのです。
この事件をきっかけに、ファイルタイプごとにCache-Controlを細かく切り分けることにしました。現在はデプロイスクリプトの中で、MIMEタイプごとに異なるヘッダーを付与しています。
# HTMLは常に最新を取得させる
aws s3 cp ./dist/index.html s3://mini-trains-bucket/index.html \
--cache-control "no-cache, no-store, must-revalidate" \
--content-type "text/html; charset=utf-8"
# JS/CSSはcontent hash付き(app.8f3a2b.js 等)なので長期キャッシュ可能
aws s3 sync ./dist/assets s3://mini-trains-bucket/assets \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html"
# 画像・3Dモデルはファイル名自体が変わらないが、バージョニングで運用
aws s3 sync ./dist/models s3://mini-trains-bucket/models \
--cache-control "public, max-age=2592000, immutable"
immutableという強力なヒント
ここで効いたのが immutable ディレクティブです。これを指定すると、ブラウザは有効期限内であれば一切の再検証リクエスト(If-None-Match)すら送らなくなるため、リロードが爆速になります。content hash を付けたJSファイルに対して特に効果的で、ユーザーがサイトを再訪したときの体感速度がはっきり変わりました。ビルドツール(Vite)の出力をそのまま活かせるのもポイントです。
結局「何を長くキャッシュして、何を短く保つか」を一度ちゃんと設計すれば、CDNは裏切らない働きをしてくれます。逆に雑に設定すると、後でInvalidation祭りをやる羽目になります。私はこの教訓を「Cache-Controlは最初に設計、後から修正は血を見る」とメモに残しています。
モバイル回線での検証で見つかった問題
個人開発をしていると、つい自宅の光回線とMacBook Proでしか動作確認をしなくなります。しかし、私のサイトは電車の模型をテーマにしている以上、ユーザーの大半はスマホでアクセスするはずです。ある日、通勤中に自分のサイトを開いて、私はその現実を思い知らされました。
3G相当の回線で15秒待たされる黒画面
Chrome DevToolsの「Network」タブには、回線速度を絞るエミュレーション機能があります(Fast 3G / Slow 3G など)。試しにSlow 3Gで自分のサイトを開いてみると、3Dモデル(合計数十MB)の読み込みに約15秒かかり、その間ずっと真っ黒な画面が表示されるだけでした。ローディング表示を一切実装していなかったのです。
これは体験としてかなり致命的です。ユーザーは「壊れている」と判断して、15秒のうち平均的には5秒前後で離脱してしまう、というデータもあります。特に山手線のアニメーションが動き出す前に帰られてしまったら、このサイトの意味がありません。
GLTFLoaderのonProgressでプログレスバーを実装
幸い、Three.jsの GLTFLoader には onProgress コールバックがあり、ダウンロードの進捗を取得できます。これを使って、画面中央に簡素なプログレスバーを表示するようにしました。
const loader = new GLTFLoader();
const bar = document.getElementById('progress-bar');
loader.load(
'https://cdn.mini-trains.com/models/yamanote.glb',
(gltf) => {
scene.add(gltf.scene);
document.getElementById('loading').style.display = 'none';
},
(xhr) => {
if (xhr.lengthComputable) {
const percent = (xhr.loaded / xhr.total) * 100;
bar.style.width = percent.toFixed(1) + '%';
bar.textContent = Math.floor(percent) + '%';
}
},
(err) => {
console.error('Model load failed:', err);
document.getElementById('loading').textContent = '読み込みに失敗しました';
}
);
なお、 xhr.lengthComputable が false になるケースがあり、これはCloudFront側でgzip圧縮が有効になっているとContent-Lengthヘッダーが省略されることが原因でした。CloudFrontのレスポンスヘッダーポリシーで Content-Length を明示的に渡すように調整し、プログレスバーが安定して動くようになりました。
それ以降、私は新しい機能を入れるたびに、必ずDevToolsで「Slow 3G + CPU 4xスロットリング」をかけて動作確認するようにしています。高速な環境でしか触らないエンジニアのサイトは、低速環境で壊れる。これは経験則ではなく、ほぼ法則だと思っています。
まとめ
サーバーのOS管理やパッチ適用から解放され、コードを書くことだけに集中できるのがこの構成の最大の魅力です。
アクセスが急増しても勝手にスケールしてくれるため、「バズったらサーバーが落ちるかも」という心配も無用。個人開発のWebアプリには最適なインフラだと思います。