CI/CDツールからS3へ静的ファイルをエクスポートするための設定

こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。

皆さん、CI/CDツールを使用していますか?

筆者が開発を担当しているSTORYでは、GitHub ActionsやCodePipelineCodeBuildCodeDeploy)を使用して各環境にアプリケーションのコードをデプロイしています。今回はCI/CDツールからS3にファイルをエクスポートしたいという要件に対して実装した変更について述べていこうかと思います。

目次

背景

本題の設定内容に入る前に、STORYの現状と今回の改修を入れるようにした背景について述べていきます。本題とは少しずれますので、興味がない人は読み飛ばしていただいて問題ありません。

まず、STORYというアプリケーションの特徴として以下のことが挙げられます。

  • バックエンドとフロントエンドのコードが一つのリポジトリで管理されている。
    • 静的ファイルをリクエストする際、example.js?v=100 のようにクエリパラメータを付与することでデプロイ時のブラウザやCDNのキャッシュを回避している。
  • アプリケーションの実行環境はAWS ECSである。
  • 本番環境、ステージング環境へのデプロイは、CodePipelineを使用している。
    • デプロイはGitHubへのブランチへのマージをトリガーにして自動で行われる。
  • 通常のステージングとは別に各メンバーが独自の検証環境を作成できるマルチステージング環境が存在する。
    • デプロイはGitHub Actionsを使用して任意のタイミングで行われる。

STORYのデプロイサイクルやマルチステージング環境については以下のエントリーを読んでいただけるとよりイメージが湧くかと思います。

あわせて読みたい
ECSでマルチステージング環境を実現した設計と実装
ECSでマルチステージング環境を実現した設計と実装こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。PR TIMES STORYは弊社のMissionである「行動者発の情報が、人の心を揺さ...
あわせて読みたい
CloudFrontのディストリビューションを分割して、マルチステージング環境をさらに便利にした話
CloudFrontのディストリビューションを分割して、マルチステージング環境をさらに便利にした話こんにちは、普段PRTIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。以前ECSのマルチステージング環境を設計・実装した記事を書きました。http...

こちらの構成はデプロイの作業自体を設定ファイルに基づいてCI/CDツールが自動でやってくれるので大変便利です。ただ、別件でSTORYは一つ課題を抱えていました。それは静的ファイルの配信とアプリケーションの実行が同じECS Taskで行われているというものです。

STORYはECSのローリングアップデートを採用しており、設定値は以下のようになっています。

resource "aws_ecs_service" "sample" {
  name                               = "sample"
  deployment_minimum_healthy_percent = 100
  deployment_maximum_percent         = 200
}

つまり新Taskと旧Taskが混在する期間がデプロイメント中に生じることになります。このような状況で前述した静的ファイルの配信とアプリケーションの実行が同じECS Taskで行われているという状態だと、問題が表面化します。

これだけ伝えてもあまりピンとこないと思うので実際のデプロイの例を出して説明します。

通常時、ECSのリクエストはALB経由で各タスクに到達しています。

デプロイ時、上記のような設定をしていると新ECS Taskがhealthyになるまで新旧Taskの混在期間が生じることになります。混在期間も当然リクエストはECSまで届きます。ALB → ECS間のリクエストは何も設定しない限りラウンドロビンのため、新旧Taskに均等にリクエストが流れます。

この新旧Taskの混在期間に、

  1. 新Taskのアプリケーションの画面を表示しているブラウザからJSファイルをECSに対してリクエストする。
  2. 1のリクエストが旧Taskへリクエストが到達して、デプロイ前の状態のJS, CSSファイルを返す。

上記の2点が満たされた時問題が表面化します。

図示したものが以下です。

前提として、デプロイ前のCDNやブラウザキャッシュにはexample.js?v=100というリクエストの結果がキャッシュされています。旧バージョンを表示しているブラウザはexample.js?v=100 をアプリケーションで使用します。

この状態でデプロイが走ると、新Taskの画面が返ってくるようになります。図の①で表している赤線のリクエストです。新Taskの画面ではexample.js?v=101が必要なため、再びブラウザからリクエストが飛びます。これが図の②で表した緑色のリクエストの流れです。example.js?v=101 の結果はブラウザにもCDNにもキャッシュされていないのでECSまでリクエストが到達します。新旧両方のECS Task混在中では、半分の確率で旧Taskからjsが返ってきます。旧Taskデプロイされている静的ファイルは当然ながら旧バージョンの状態です。

このように画面を表示しているアプリケーションのバージョンと、その後レスポンスされた静的ファイルのバージョンの間にズレが生じた時に、それが原因となってデザイン崩れやバグが生じることがありました。また、旧Taskのレスポンスがexample.js?v=101の結果としてCDNにキャッシュされるため、最初のリクエストの後に画面を見たユーザーにも同様の不具合が生じることになります。

上記のようなケースはデプロイのたびに確実に発生するわけではありませんが、一定の割合で生じており、CDNのキャッシュ削除や空デプロイの実行などでその場しのぎ的に対応していました。

恒久的な対応をするためには、旧Taskのリクエスト(example.js?v=100)ではキャッシュからレスポンスをかえして、新Taskのリクエスト(example.js?v=101)にはデプロイした時にビルドされたJSファイルを確実に返す必要があります。

そのために、STORYではS3にコンテナビルド時に生成された静的ファイルを置いて、ECSではなくてS3経由で静的ファイル配信を行うことにしました。幸い、PR TIMESでは既にFastly + S3でファイル配信を行う基盤ができていたため、このS3にCode BuildやGitHub Actionsからファイルをエクスポートできれば要件はクリアできそうだなと考えました。

あわせて読みたい
PR TIMESにおけるフロントエンド開発基盤の構築
PR TIMESにおけるフロントエンド開発基盤の構築こんにちは、21新卒エンジニアの柳(@apple_yagi)です。今月から新卒2年目となり、一年早かったなとしみじみしています。昨年PR TIMESでは企業ページをフルスクラッチでR...

CI/CDツールからS3へのファイルエクスポート

CodeBuildやGitHub ActionsからS3へファイルをエクスポートするのはそんなに複雑なことはありません。

  1. S3へとPutするためのIAMポリシーがあるIAMロールをCodeBuild、GitHub Actionsで引き受ける。
  2. CodeBuildやGitHub Actionsの実行中に、S3にファイルをエクスポートするコマンドを叩く。

の上記2点さえできれば実現できます。

CodeBuildでの実行方法

前述した手順の1を実現するためには、CodeBuildのサービスロールを編集します。注意として、CodePipelineのIAMロールではありません。ロールに以下のポリシーを付与します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Action": [
                "s3:PutObjectAcl",
                "s3:PutObject",
                "s3:ListBucket",
                "s3:GetObjectAcl",
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::<対象Bucket名>/story/*",
                "arn:aws:s3:::<対象Bucket名>"
            ]
        }
    ]
}

エクスポートできるディレクトリを/storyに限定することで、最小権限のポリシーをつくることができます。

手順2を実現するためには、buildspec.ymlを更新します。追加したコードは以下です。

version: 0.2
phases:
  install:
    〜〜〜省略〜〜〜
  pre_build:
    commands:
      〜〜〜省略〜〜〜
  build:
    commands:
      - docker build -t $REPOSITORY_URI:latest -f Dockerfile .
      - docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG
  post_build:
    commands:
      〜〜〜省略〜〜〜

      # JS, CSSなどの静的ファイルを配信用S3にコピーする
      - echo Deploy Static Files to S3
      - docker create --name stub_container $REPOSITORY_URI:$IMAGE_TAG
      - docker cp stub_container:$STUB_CONTAINER_WORKDIR/public/story ./
      - aws s3 cp --recursive
        ./story/ s3://$FRONTEND_STATIC_BUCKET_NAME/story/
        --exclude "*"
        --include "js/*.js"
        --include "css/*.css"
        --region ap-northeast-1
        --acl public-read
        --cache-control "public, max-age=31536000"
        --metadata-directive REPLACE

      〜〜〜省略〜〜〜
    files:
      〜〜〜省略〜〜〜

ポイントは、S3のバケット名を環境変数としているところです。buildspec.ymlは本番環境と検証環境で同じファイルを使い回すことがあると思います。そのため、バケット名はbuildspec.yml上では環境変数として、CodeBuildの環境変数の中にそれぞれの環境に応じたバケット名を予め入れておきます。

また、Buildし終わった後のイメージから直接ファイルを取得することはできないため、docker createコマンドで予めコンテナを作成して、docker cpコマンドを実行することでコンテナ→ ローカルへBuild済みのファイルを保存しています。

GitHub Actionsでの実行方法

マルチステージングをデプロイするために使用しているGitHub ActionsでもS3へエクスポートできるようにしました。

1の手順についてはCodeBuild編とおなじようなポリシーをGitHubActionsが使うロールに付与するだけでOKです。余談ですが、GitHub ActionsではIAMユーザーのアクセスキーとシークレットアクセスキーをSecretとしてGitHubに登録する方法ではなく、OIDCを使ったIAMロール引き受けが可能なのでそちらを使うことをおすすめします。

参考記事↓

クラスメソッド発「やってみた」系技術メディア | DevelopersIO
GitHub Actions OIDCでconfigure-aws-credentialsでAssumeRoleする | DevelopersIO
GitHub Actions OIDCでconfigure-aws-credentialsでAssumeRoleする | DevelopersIO2022/1/8 aws-actions/configure-aws-credentials@v1アップデートを反映しました https://dev.classmethod.jp/articles/aws-configu …

2の手順では以下のようなコードをGitHub Actionsのworkflowファイルに追加しました。

name: Deploy to Amazon ECS

〜〜〜省略〜〜〜

jobs:
  deploy:
    〜〜〜省略〜〜〜
      - name: Push php image to ECR
        env:
          〜〜〜省略〜〜〜
        run: |
          〜〜〜省略〜〜〜
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG --build-arg FRONTEND_ENV=${FRONTEND_ENV} .

          # FrontendのファイルをS3にデプロイするために、コンテナ->ホストへフォルダをコピーする。
          docker create --name stub_container $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker cp stub_container:$STUB_CONTAINER_WORKDIR/public/story public/

          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
      
      - name: Deploy Static Files to S3
        run: >
          aws s3 cp --recursive
          public/story/ s3://<s3-bucket-name>/story/$SUBDOMAIN/
          --exclude "*"
          --include "js/*.js"
          --include "css/*.css"
          --region ap-northeast-1
          --acl public-read
          --cache-control "public, max-age=31536000"
          --metadata-directive REPLACE

基本的にCodeBuildで使用したコードの使い回しですが、異なる点もあります。

ファイルをエクスポートするS3のパスにs3://<s3-bucket-name>/story/$SUBDOMAIN/ となっている点です。マルチステージングは任意のサブドメインで自由にECSの環境を用意できるのですが、それぞれの環境ごとにS3Bucketを作成していたら管理が大変です。そこで開発環境のBucketを一つにまとめるために/storyの後ろにサブドメインごとのパスを付けて、その中にファイルをエクスポートするようにしました。こうすることで通常のステージング環境と同じバケットの中でマルチステージングごとのファイルを配信できることができます。

まとめ

デプロイ中に他のバージョンのファイルが配信されてしまう事象はあるあるかと思いますが、静的ファイルの配信場所を切り替えることで簡単に障害を防ぐことができます。

PR TIMESでは、上記のようにAWSのマネージドサービスを駆使した技術的なチャレンジが可能です。会社の雰囲気や技術スタックにご興味がある方は、ぜひ一度カジュアル面談に応募してくださると幸いです。

株式会社PR TIMES
カジュアル面談申し込みフォーム - 株式会社PR TIMES
カジュアル面談申し込みフォーム - 株式会社PR TIMES株式会社PR TIMESでは現在00-0. カジュアル面談(全職種OK)を募集しています。

この記事を書いた人

PR TIMES STORYの開発をしています。
AWSのSAA/DVA/SOA/SAPを持ってます。

目次
閉じる