こんにちは、普段PR TIMES STORY(以下STORY)の開発リーダーをしている岩下(@iwashi623)です。
皆さん、CI/CDツールを使用していますか?
筆者が開発を担当しているSTORYでは、GitHub ActionsやCodePipeline(CodeBuild、CodeDeploy)を使用して各環境にアプリケーションのコードをデプロイしています。今回はCI/CDツールからS3にファイルをエクスポートしたいという要件に対して実装した変更について述べていこうかと思います。
背景
本題の設定内容に入る前に、STORYの現状と今回の改修を入れるようにした背景について述べていきます。本題とは少しずれますので、興味がない人は読み飛ばしていただいて問題ありません。
まず、STORYというアプリケーションの特徴として以下のことが挙げられます。
- バックエンドとフロントエンドのコードが一つのリポジトリで管理されている。
- 静的ファイルをリクエストする際、
example.js?v=100
のようにクエリパラメータを付与することでデプロイ時のブラウザやCDNのキャッシュを回避している。
- 静的ファイルをリクエストする際、
- アプリケーションの実行環境はAWS ECSである。
- 本番環境、ステージング環境へのデプロイは、CodePipelineを使用している。
- デプロイはGitHubへのブランチへのマージをトリガーにして自動で行われる。
- 通常のステージングとは別に各メンバーが独自の検証環境を作成できるマルチステージング環境が存在する。
- デプロイはGitHub Actionsを使用して任意のタイミングで行われる。
STORYのデプロイサイクルやマルチステージング環境については以下のエントリーを読んでいただけるとよりイメージが湧くかと思います。


こちらの構成はデプロイの作業自体を設定ファイルに基づいて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の混在期間に、
- 新Taskのアプリケーションの画面を表示しているブラウザからJSファイルをECSに対してリクエストする。
- 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からファイルをエクスポートできれば要件はクリアできそうだなと考えました。

CI/CDツールからS3へのファイルエクスポート
CodeBuildやGitHub ActionsからS3へファイルをエクスポートするのはそんなに複雑なことはありません。
- S3へとPutするためのIAMポリシーがあるIAMロールをCodeBuild、GitHub Actionsで引き受ける。
- 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ロール引き受けが可能なのでそちらを使うことをおすすめします。
参考記事↓

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