「山手線 3Dジオラマ」はAmazon S3上に静的ファイルとしてホストされ、CloudFront経由で配信されています。公開初期の頃は、変更のたびに私がローカルから aws s3 sync を手で叩いてデプロイしていました。
しかし、ちょっとしたtypoの修正のために毎回AWS CLIの引数を思い出し、ファイル単位のMIME typeを気にしながらアップロードする作業は、控えめに言って地獄でした。今回は、そこからGitHub Actionsによる自動デプロイパイプラインを構築するまでの試行錯誤を記録します。

手動デプロイ時代の苦労

自動化する前の私は、修正のたびに以下のような長いコマンドをターミナルに打ち込んでいました。--exclude で除外ファイルを指定し、--cache-control を書き換え、最後にCloudFrontのInvalidationを手動で発行する。地味にミスが多く、ある時は node_modules をまるごとS3にアップロードしてしまい、公開用バケットに数百MBのゴミを残して数時間後に気づくというミスもやりました。AWSの請求ダッシュボードで急にストレージ料金が跳ねているのを見て、ようやく気づいたのです。削除作業自体は簡単でしたが、バージョニングが有効だったため古いオブジェクトが残り続け、結局ライフサイクルルールを書き直して完全に消すまで丸一日かかりました。

さらに深刻だったのは、「アップロード途中で新しい index.html と古い main.js が混在する」という問題です。クライアントがその瞬間にアクセスしてしまうと、古いJSから未来の関数を呼び出し、コンソールに Uncaught TypeError が並ぶことになります。Twitter上で「読み込みエラーになる」という報告を受けて、デプロイの順序まで含めて設計しなければならないと痛感しました。報告された時刻を確認すると、ちょうど私が aws s3 cp でHTMLを先にアップロードしていた瞬間と一致しており、背筋が凍る思いをしました。

この「本番配信中にファイル群の整合性が崩れる問題」は、個人開発だと軽く見られがちですが、リアルタイムにアクセスがある状態ではユーザー体験を直撃します。私はこれを機に、真面目にCI/CDを整える決心をしました。手動でやっている間は無意識に「誰も見ていないタイミングを狙う」という小細工で乗り切っていましたが、海外からのアクセスが増えてくると日本の深夜も安全ではなく、どの時間帯でも事故を起こさない仕組みが必要だと腹を括った瞬間でもあります。

GitHub Actions × OIDC でキーレス認証

最初は深く考えず、AWSのIAMユーザーを作成して AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY をGitHub Secretsに登録する方式を試しました。しかしこの時、ヒヤッとする事件が起きました。

ローカルの .env.local をうっかり git add . でまとめてステージングしてしまい、commit直前で git diff --cached を確認して気づいた、という話です。本番のアクセスキーがコミットに混ざる寸前でした。push前に気づけたのは幸運ですが、冷や汗で半日仕事が手につきませんでした。この一件から、私は「そもそも長期認証情報を持たない」設計に切り替えました。

採用したのは、GitHub Actionsが発行するOIDCトークンをAWSのSTSに信頼させ、その場で一時クレデンシャルを取得する方式です。AWS側には以下のようなIAMロールと信頼ポリシーを設定します。

# 信頼ポリシー(抜粋)
{
  "Effect": "Allow",
  "Principal": {
    "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
  },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:myname/diorama_tokyo:ref:refs/heads/*"
    }
  }
}

sub の条件を repo:myname/diorama_tokyo:ref:refs/heads/* のように絞ることで、自分のリポジトリの特定ブランチからのみ このロールを引き受けられるようにしています。最初はここを * にしてしまい、「全世界のリポジトリから引き受け可能」という恐ろしい状態を作ってしまったことがあり、後でセキュリティレビューで発覚しました。読者の皆さんも、信頼ポリシーを書いた後は必ず aws iam simulate-custom-policy や IAM Access Analyzer で意図通りの絞り込みになっているかを確認することを強くおすすめします。私は一度、このチェックを怠ったまま1週間ほど公開してしまい、気づいた瞬間に全身が凍るような感覚に襲われました。

OIDC方式のもう一つの良いところは、AWSマネジメントコンソールでロールを無効化すれば即座にデプロイを止められる点です。キーを共有している運用だと、漏洩時の対応はローテーションと再配布が必要になりますが、ロール方式なら信頼ポリシーを書き換えるだけで済みます。一度、コードの脆弱性を踏んでしまったかもしれないと焦った時に、この設計のおかげで10秒でデプロイ経路を遮断できたのは大きな安心材料でした。

ワークフローの実装

本番用のワークフローは、masterブランチにpushされた時にS3へ同期し、CloudFrontのキャッシュを無効化する、というシンプルな構成です。

# .github/workflows/deploy.yml
name: Deploy to S3
on:
  push:
    branches: [master]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
          aws-region: ap-northeast-1
      - name: Sync assets (long cache)
        run: |
          aws s3 sync ./public s3://mini-trains.com/ \
            --exclude "*.html" \
            --cache-control "public,max-age=31536000,immutable" \
            --delete
      - name: Sync html (short cache)
        run: |
          aws s3 sync ./public s3://mini-trains.com/ \
            --exclude "*" --include "*.html" \
            --cache-control "public,max-age=60,must-revalidate"
      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id E1XXXXXXXXXXX \
            --paths "/*.html" "/"

この構成のポイントは、aws s3 syncを2回に分けて実行している点です。HTML以外(JS、WASM、画像、glbモデルなど)は先に同期して長期キャッシュを載せ、最後にHTMLだけを短期キャッシュで書き換える。こうすることで、HTMLが先に差し替わってまだアップロードされていないJSを呼ぶ、という事故を防げます。この順序を逆にしていた最初のバージョンでは、デプロイ直後の30秒ほど古いHTMLと新しいJSが混在する時間帯が生まれ、ユーザーから「ボタンを押すとエラーが出る」と連絡をもらう原因になりました。

もう一つ地味にハマったのが、aws s3 sync のMIME type自動判定です。.glb はデフォルトで binary/octet-stream が割り当てられるのですが、これだとCloudFrontのBrotli圧縮が効かず、転送量がかさみます。結局、拡張子ごとに --content-type を明示的に指定するスクリプトを間に挟み、.glbmodel/gltf-binary として扱うようにしました。気づいたきっかけは、Lighthouseのレポートで「不要な転送量」の警告が出ていたことで、ここでも計測の大切さを思い知らされました。

キャッシュ戦略の落とし穴

実は上記の構成に至るまでに、私は一度大きな事故を起こしています。最初は全ファイルに max-age=31536000(1年)を設定していました。この設定だと、ユーザーが一度読み込んだHTMLは次回アクセス時にもキャッシュから返るため、何日経っても古いバージョンが表示され続けるのです。

「修正したはずの機能が反映されていない」というDMが届き、検証したところ、私のブラウザでは何度もcommand+shift+Rでスーパーリロードしていたため、長期キャッシュの問題に気づきませんでした。ユーザーからの報告を受けて初めて、自分が開発者以外の環境を想像できていなかったことを痛感しました。それ以来、私は *.html だけはかならず max-age=60 程度の短期キャッシュに留めるようにしています。

ブランチごとのステージング環境

当初は本番しかなかったのですが、大規模な改修を行うときに「お試し環境が欲しい」と切実に感じる場面がありました。そこで、develop ブランチへpushすると、ステージング用のS3バケット stg.mini-trains.com に同期されるワークフローを追加しました。

# ブランチに応じてデプロイ先を切り替える
- name: Set deploy target
  id: target
  run: |
    if [ "${{ github.ref }}" = "refs/heads/master" ]; then
      echo "bucket=mini-trains.com" >> $GITHUB_OUTPUT
      echo "dist=E1XXXXXXXXXXX" >> $GITHUB_OUTPUT
    else
      echo "bucket=stg.mini-trains.com" >> $GITHUB_OUTPUT
      echo "dist=E2YYYYYYYYYYY" >> $GITHUB_OUTPUT
    fi

この構成にしてから、「本番でしか再現しない不具合」を恐れず検証できるようになりました。特にPLATEAUの建物表示は実データでないと再現しないものが多く、ステージングでの事前検証が非常に役立っています。ステージング環境はBasic認証をかけて一般のクローラから隔離し、robots.txtDisallow: / を設定しています。ここを忘れると、Google検索に stg.mini-trains.com がインデックスされ、重複コンテンツ扱いでSEO評価を落とす事故につながります。実際、私は公開初期にこれをやらかし、Search Consoleで大量の重複URLを見て青ざめた経験があります。

ステージングで最終確認した変更が本番に上がる瞬間は、何度経験しても少し緊張します。特にキャッシュ関連や音声関連は、ステージングで問題なくても本番の実トラフィック下で初めて顕在化する不具合があり、油断は禁物です。デプロイ直後は必ず自分でスマホ実機からアクセスし、主要な導線を手動で確認する習慣を付けました。

Invalidationの課金とパス指定

CloudFrontのInvalidationは月1000パスまで無料ですが、うっかり /* を毎回指定すると、パスの数ではなく「呼び出し回数」でカウントされます。しかし私はある時、意味もなく --paths "/*" "/index.html" "/app.js" ... と20個近く羅列するスクリプトを書いてしまい、1日に50回以上のデプロイでInvalidation無料枠を溶かしかけたことがあります。以降は、HTMLと / のみをInvalidation対象とし、残りのアセットはファイル名ハッシュでキャッシュバスティングする方針にしています。

また、Invalidationは非同期処理なので、CIログ上では成功していても実際にキャッシュが切り替わるまでに数秒から数十秒のラグがあります。このラグを知らずに「デプロイ直後にF5で確認」を繰り返し、「反映されていない!」と慌てていた時期がありました。今は aws cloudfront wait invalidation-completed をCI内で走らせ、完了を待ってからSlackに通知するようにしています。

失敗からの学び

CI/CDを整備する過程で得た一番の気づきは、「自動化は安全になるのではなく、事故の速度と範囲が変わる」ということです。手動の頃は一回あたりのミスの影響は大きくても、頻度は限られていました。自動化すると1日に何十回もデプロイできる代わりに、悪いコードが紛れ込めば即座に本番へ届きます。そのため、ブランチ保護ルールで master への直pushを禁止し、必ずPRレビュー(一人プロジェクトでもセルフレビュー)を挟むようにしました。

もう一つ、個人開発者として強く勧めたいのはSlack通知です。デプロイの開始・成功・失敗をSlackに流すだけで、ターミナルを閉じている時にも状況を把握でき、外出先でスマホから確認できます。Actionsの失敗メールはノイズに埋もれがちですが、Slackのチャンネル通知なら見逃しません。

まとめ

手動デプロイで毎週のように小さな事故を起こしていた頃から比べると、git pushだけで数分後に本番が更新される現在の環境は、精神的な余裕がまるで違います。
特にOIDC認証への移行は、キー漏洩の不安から開放されるという意味で、最も投資対効果の高い変更でした。個人開発でも「本番でキーを持たない」はもう標準にして良いと、私は考えています。今後は、Pull Request単位のプレビュー環境構築や、E2Eテストをデプロイ前に自動実行する仕組みにも挑戦していきたいと思っています。