Techfirm Cloud Architect Blog

テックファーム株式会社クラウドインフラグループのブログ

CloudFront+S3環境へデプロイするCI/CDパイプラインの設計

Vue.js等で開発されたフロントエンドをS3バケットにデプロイし、バックエンドはAPI GatewayとLambdaで実装するサーバレス構成も増えてきました。
今回は、フロントエンドのCI/CDパイプラインをどのように設計するか考えていきたいと思います。

構成とキャッシュ

S3バケットでWebコンテンツを配信する場合はCDNサービスであるCloudFrontと組み合わせることが多いと思います。
このときにキャッシュを行うノードは以下のようになります。

キャッシュされる場所

最初にキャッシュされるのはCDNのキャッシュサーバ(今回はCloudFrontのエッジサーバ)です。
これはCDNの仕様によってキャッシュする期間や条件を設定したりキャッシュをクリアしたりできます。
CloudFrontはキャッシュからファイルを削除する仕組みがあるので、リリース時にはこれを利用します。

次に忘れてはいけないのが、ブラウザキャッシュ等のクライアント側のキャッシュです。 クライアントのネットワーク構成によっては、プロキシサーバがキャッシュしている場合もあります。
これらは、リリース時にサーバ側からクライアントへキャッシュを削除するよう指示できません。

そこで、クライアントへのレスポンスにCache-Controlヘッダを含めることでキャッシュの扱いを指示します。
具体的には、max-ageによってキャッシュの有効期間を指定したり、no-cacheによって毎回最新か確認するよう要求できます。
max-ageを長くすればリクエスト数を削減できますが、リリースしたコンテンツが全ユーザに反映されるまで時間がかかります。
no-cacheを指定すると、毎回リクエストが発生するためにリクエスト数が増えることになります。
Cache-Controlヘッダはサービスの要件にあわせてレスポンスへ付与する必要があるので、忘れないようにしましょう。

ちなみに、CloudFrontではレスポンスヘッダーポリシーでヘッダの付与が可能です。

ファイル名

ところで、CloudFrontではファイル名等にバージョン識別子を付けることが推奨されています。
Vue.js等のフレームワークでも、設定にもよりますが生成されるファイル名にハッシュ値が含まれます。

HTMLファイルとそこから参照されるjsファイルやcssファイルは、同じタイミングでキャッシュ有効期限が切れるとは限りません。
index.htmlはキャッシュ有効期限が切れていて、他からも頻繁に参照されるjsファイルやcssファイルはキャッシュされている時を考えます。

ファイルバージョン違い

index.htmlはキャッシュにないので最新のものが取得されますが、script.jsやstyle.cssはキャッシュにある古いバージョンが使われます。
そのため、スクリプトがエラーになったりCSSが正常に適用できず表示が崩れる可能性があります。
CloudFrontのキャッシュも同じ状態になると、多くのユーザに影響してしまうことになります。

この問題は、キャッシュを有効にしたままでもファイル名にバージョン等を含むことで解決できます。

バージョン付きファイル

jsファイルやcssファイルのファイル名にバージョンを入れておきます。
サイトを更新するとき、jsファイルやcssファイルは上書きではなく新しいファイルなります。 HTMLファイルは対応するバージョンのファイルをリンクするのでバージョンのミスマッチが発生しなくなります。

リリースパイプラインのフロー

リリースパイプラインを設計するときに気を付けなければいけないのは、 キャッシュが有効な間は新旧バージョンが混在する ということです。
ブラウザキャッシュやアクセスしたエッジサーバの状態等によるため、新バージョンを見るか古いバージョンを見るか人によって変わってきます。
そのため、新しいファイルをリリースしても、すぐに古いファイルを削除してはいけません。

例として、以下のようなパイプラインの流れが考えられます。

リリースパイプライン

# 処理 概要
1 ソース取得 CodeCommitのソースファイルを取得します
2 ビルド Vue.jsのビルド等、コンテンツファイルを生成します
3 S3デプロイ CodePipelineのS3デプロイアクションでコンテンツファイルを配置します
4 キャッシュ削除 CloudFrontのファイルの無効化を実施してキャッシュを削除します
5 旧ファイル削除 S3バケットににある不要なファイルを削除します

ここでポイントとなるのは、S3デプロイの時点で古いファイルの削除は行われず、旧ファイルの削除は別に実施するという点です。
デプロイ時のアクセスは以下のようになります。

デプロイ時アクセス

No 内容
新しいファイルが配置されても、CloudFrontにキャッシュがあれば古いファイルが返されます。
キャッシュが期限切れになったファイルやキャッシュのないエッジサーバへのアクセスから順次新しいコンテンツに切り替わります。
CloudFrontのキャッシュが明示的に削除されると、CloudFrontは新しいファイルを返すようになります。
しかし、ブラウザキャッシュが残っているユーザは古いファイルを見る可能性があります。
ブラウザのキャッシュも期限切れになると、すべてのユーザが新しいファイルを参照するようになります。

人によって表示されるファイルが違う可能性があるため、すべてのキャッシュの期限切れ(③の状態)まで古いファイルを削除できない点に注意してください。

実装ポイント

実際に実装したときのポイントを記載します。
(コードはブログに書くには長くなっていたので記載しませんが)

ビルド

CodeBuildでビルドし、デプロイするファイルを作成します。
それだけでなく、旧ファイル削除に必要な情報がある場合は出力アーティファクトとして用意します。
たとえば、buildspec.ymlは以下のようになります。(Vue.js等を利用しておりnpm run buildでビルドできる環境です)

version: 0.2
env:
  variables:
    METAFILE_BUILD_DATETIME: "build_datetime.txt"
phases:
  install:
    runtime-versions:
      nodejs: 16
    commands:
      - n 18
      - npm install
  build:
    on-failure: ABORT
    commands:
      - mkdir -vp dist
      - echo Build start on `date`
      - npm run build
    finally:
      - echo Build finished on `date`
  post_build:
    on-failure: ABORT
    commands:
      - "date --iso-8601='seconds' > ${METAFILE_BUILD_DATETIME}"
artifacts:
  files:
    - 'dist/**/*'
  secondary-artifacts:
    deploy_resource:
      base-directory: dist
      files:
        - "**/*"
    cleanup_resource:
      files:
        - build/*
        - $METAFILE_BUILD_DATETIME

出力アーティファクトはビルドした結果(deploy_resource)と最後の旧ファイル削除に必要な情報(cleanup_resource)の2つです。
旧ファイル削除に必要な情報として、ここではビルド終了日時をテキストファイルに保存しています。
また、cleanup_resourceには上記テキストファイルの他に、旧ファイル削除処理を定義したbuildspec.ymlやスクリプトも含めます。

S3デプロイ

CodeBuildから直接デプロイしてもいいのですが、CodePipelineにはS3デプロイアクションが用意されています。

設定パラメータのExtracttrueに設定することで、ビルドしたリソースをS3バケットへ展開することが可能です。

このフェーズをCodeBuild等で実装する場合は、古いファイルを削除しないようにすることが重要です。
前述したように、キャッシュがあるため古いファイルへアクセスされる可能性があります。

キャッシュ削除

要件に合わせてCloudFrontがキャッシュしているファイルを無効化(キャッシュから削除)します。
すべてのファイル(/*)を無効にするのがもっとも簡単ですが、オリジンへの通信が増える要因になるので注意が必要です。

CloudFrontのファイルの無効化はどのくらい時間がかかるか分からないですが、CodePipelineならLambdaで実装可能です。 CodePipelineから呼び出すLambda関数は以下が参考になります。

CodePipelineから呼び出されたLambda関数は、return値ではなくSDKを通してCodePipelineへ成功失敗を通知します。
このときに継続トークン(continuationToken)を指定すると、CodePipelineは継続トークンをデータに加えてLambdaを再実行してくれます。
そこで、最初の起動でInvalidationを作成し、2回目以降の起動は作成したInvalidationのStatusを確認するよう実装します。
これにより、LambdaだけでInvalidationが完了するまで待機できます。

旧ファイル削除

アクセスされなくなった旧ファイルを削除します。
削除しないという選択肢もあるとは思いますが、たとえば以下のような問題に注意してください。

  • 検索エンジンや外部サイトからのリンクで古いファイルが表示される可能性がある
  • セキュリティホールのあるJavaScript等が残ったままになる可能性が高くなる
  • 著作権や版権に問題のあるファイルが残ったままになる可能性が高くなる

バージョン管理から外れた古いファイルが残り続け、どのようなファイルが公開されているか把握できなくなることで発生する問題です。

削除自体は、S3バケットにあるファイルからLastModifiedが今回リリース日時より前のものを削除するだけです。 (変更のなかったファイルも含めてS3デプロイでデプロイされていることが前提です)
ビルド時にファイル一覧を作成しておいて、そこにないファイルを削除するようなことも可能かと思います。

ただし、前述したようにブラウザキャッシュの有効期限が過ぎるのを待つ必要があります。
Cache-Controlmax-ageを大きくしている場合は、StepFunctionsで実装したり非同期実行にする必要があるかもしれません。

私が実装したときは、Cache-Controlno-cacheとしていたのでブラウザキャッシュを考慮する必要はほとんどありませんでした。
ビルド時の出力アーティファクトに旧ファイル削除に必要なファイルをすべてを含んでおいたので、それをCodeBuildで実行します。
(Lambdaでも実行できますが、将来的に実行時間を気にしたくないのでCodeBuildで実装しました)

最後に

昔、CloudFront+S3環境へのリリースパイプラインでCloudFrontのキャッシュ削除の設定を間違えていたことがあります。
何回かそのパイプラインで問題無くリリースできていたのですが、あるときにリリースしたものが動作しないと報告を受けて問題に気付くことができました。
キャッシュが関連する問題は、状態(タイミング)によって動作が変わるため見つけにくいです。
Webサイトのリリースはファイルを置きかえる簡単なものと考えず、慎重に設計していきましょう。