Classi でソフトウェアエンジニアをやっている koki です。
S3 + CloudFront でホスティングしている SPA (Single Page Application) で Pull Request ごとにプレビュー環境をデプロイする仕組みを作ってみたところ、かなり体験が良かったので紹介します。
前提
Classi で提供している学習トレーニング機能には、それを裏で支えるコンテンツ管理システム ( 以下、内部 CMS ) が存在しています。
この内部 CMS については以下の記事でも簡単に紹介されているので、こちらをご参照ください。
tech.classi.jp
内部 CMS のフロントエンドは Angular を使用した SPA になっており、 Amazon S3 と Amazon Cloudfront を使用してホスティングされています。
抱えていた課題 / つくったもの
内部 CMS のフロントエンド開発をしている中で、他メンバーが作成した Pull Request をレビューする際、動作確認するためには「ローカルに branch を pull して」「ローカルで起動する」必要があり、レビューするまでの手間がかかるという課題がありました。
必要に応じてスクリーンショットや画面録画を Pull Request に貼るなどの運用もしていましたが、それでも限界があります。
「Pull Request ごとにサクッとプレビューできる環境が欲しいよね〜」ということで、仕組みを作りました。
Pull Request 作成時のプレビュー環境がデプロイされるイメージ
この記事では Pull Request ごとに SPA のプレビュー環境を S3 + CloudFront へデプロイする仕組みや構成などについて解説していきます。
インフラ構成について
全体の構成としては以下のようなイメージで、一般的な S3 + CloudFront での静的ホスティング構成に CloudFront Function を加えただけの非常にシンプルな構成です。
この記事では、 https://pr-<PR番号>.classi.example
のような URL から各 Pull Request ごとのプレビュー環境にアクセスできる状態を目指します。
全体構成図
💡 Route53 はあくまで DNS なので実際にリクエストが図のように Route53 を経由してそのまま CloudFront Distribution に送られるわけではありませんが、わかりやすくするために簡略化してます。
ひとつずつ順を追って説明していきます。
なお、今回は Terraform を用いてリソースを構築していきます。
S3 バケットを作成する
まずは S3 バケットを作成します。
この S3 バケットは SPA のビルド済み静的ファイルを配置するために使用します。
resource "aws_s3_bucket" "example" {
bucket = "<任意のバケット名>"
}
resource "aws_s3_bucket_server_side_encryption_configuration" "example" {
bucket = aws_s3_bucket.example.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "example" {
bucket = aws_s3_bucket.example.id
ignore_public_acls = true
restrict_public_buckets = true
block_public_acls = true
block_public_policy = true
}
この段階では特に変わった設定はしていません。
CloudFront Distribution を作成する
上述した S3 バケットをオリジンとする CloudFront Distribution を作成します。
また、 CloudFront Distribution 経由で S3 バケットにアクセスできるように OAC の作成および S3 バケットポリシーの設定もしています。
data "aws_cloudfront_origin_request_policy" "cors_s3_origin" {
name = "Managed-CORS-S3Origin"
}
data "aws_cloudfront_cache_policy" "caching_disabled" {
name = "Managed-CachingDisabled"
}
resource "aws_cloudfront_distribution" "example" {
enabled = true
origin {
origin_id = aws_s3_bucket.example.id
domain_name = aws_s3_bucket.example.bucket_regional_domain_name
origin_access_control_id = aws_cloudfront_origin_access_control.example.id
}
viewer_certificate {
cloudfront_default_certificate = true
}
default_cache_behavior {
target_origin_id = aws_s3_bucket.example.id
viewer_protocol_policy = "redirect-to-https"
cached_methods = ["GET", "HEAD"]
allowed_methods = ["GET", "HEAD"]
compress = true
origin_request_policy_id = data.aws_cloudfront_origin_request_policy.cors_s3_origin.id
cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled.id
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
}
resource "aws_cloudfront_origin_access_control" "example" {
name = "<任意のOAC名>"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_s3_bucket_policy" "example" {
bucket = aws_s3_bucket.example.id
policy = data.aws_iam_policy_document.allow_cloudfront_to_access_s3.json
}
data "aws_iam_policy_document" "allow_cloudfront_to_access_s3" {
statement {
sid = "AllowCloudFrontToAccessS3"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.example.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "aws:SourceArn"
values = [aws_cloudfront_distribution.example.arn]
}
}
}
ドメインの設定や CloudFront Function の関連付けも行いますが、その辺りは後述します。
Route53 Hosted Zone とレコード / ACM 証明書を作成する
プレビュー環境に使用するドメイン名で Route53 Hosted Zone を作成します。
次のコードは preview.classi.example
というドメイン名で作成する例です。
resource "aws_route53_zone" "example" {
name = "preview.classi.example"
}
また、この例では Apex ドメインの Hosted Zone ( 今回の例では classi.example
) に NS レコードを作成して権限移譲を行う必要があります。
詳細な手順については以下の記事がわかりやすいので、こちらをご参照ください。
dev.classmethod.jp
💡 Apex ドメインの Hosted Zone をそのまま利用しても動作上は問題ありませんが、環境分離の観点からプレビュー環境用の Hosted Zone は分けて作成する方が管理上は望ましいでしょう。
作成した Route53 Hosted Zone 内に *.preview.classi.example
という名前で A レコードを作成し、 CloudFront Distribution に関連付けます。
resource "aws_route53_record" "example" {
zone_id = aws_route53_zone.example.zone_id
name = "*.preview.classi.example"
type = "A"
alias {
name = aws_cloudfront_distribution.example.domain_name
zone_id = aws_cloudfront_distribution.example.hosted_zone_id
evaluate_target_health = false
}
}
ワイルドカード ( *
) を使用することで任意のサブドメインをマッチさせることができます。
Route53 における Hosted Zone およびレコードでのワイルドカードの使用について詳しくは以下の公式ドキュメントをご参照ください。
docs.aws.amazon.com
続いて、 *.preview.classi.example
の ACM 証明書を作成します。
なお、この ACM 証明書は CloudFront Distribution に関連付けして使用するため、 us-east-1 リージョンに作成する必要があることに注意してください。 *1
provider "aws" {
alias = "virginia"
region = "us-east-1"
}
resource "aws_acm_certificate" "example" {
provider = aws.virginia
domain_name = "*.preview.classi.example"
subject_alternative_names = ["preview.classi.example"]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "example_certificate_validation" {
for_each = {
for options in aws_acm_certificate.example.domain_validation_options : options.domain_name => {
name = options.resource_record_name
type = options.resource_record_type
value = options.resource_record_value
}
}
zone_id = aws_route53_zone.example.zone_id
name = each.value.name
type = each.value.type
records = [each.value.value]
ttl = 3600
}
先ほど作成した CloudFront Distribution にカスタムドメインと ACM 証明書を関連付けます。
resource "aws_cloudfront_distribution" "example" {
# ...
+ aliases = ["*.preview.classi.example"]
viewer_certificate {
- cloudfront_default_certificate = true
+ acm_certificate_arn = aws_acm_certificate.example.arn
+ ssl_support_method = "sni-only"
+ minimum_protocol_version = "TLSv1.2_2021"
}
}
これにより、例えば https://hoge.preview.classi.example
のような URL で CloudFront Distribution にアクセスできるようになります。
CloudFront Function を作成する
リクエストされたドメインに応じて配信するファイルを切り替える CloudFront Function を作成します。
任意のファイル名で次の JavaScript プログラムを作成します。
処理内容の詳細についてはコメントをご参照ください。
function handler(event) {
const request = event.request;
const host = request.headers.host.value;
if (!request.uri.includes(".")) {
request.uri = "/index.html";
}
const matches = host.match(/^pr-(\d+)\.preview\.classi\.example$/);
if (!matches) {
return { statusCode: 403, statusDescription: "forbidden" };
}
const pullRequestNumber = matches[1];
request.uri = `/pull-requests/${pullRequestNumber}${request.uri}`;
return request;
}
この JavaScript プログラムを使用する CloudFront Function を作成し、 CloudFront Distribution に関連付けます。
resource "aws_cloudfront_function" "example" {
name = "<任意の関数名>"
runtime = "cloudfront-js-2.0"
code = file("${path.module}/path/to/example.js")
}
resource "aws_cloudfront_distribution" "example" {
# ...
default_cache_behavior {
# ...
+ function_association {
+ event_type = "viewer-request"
+ function_arn = aws_cloudfront_function.example.arn
+ }
}
}
これにより、リクエストされたドメイン名やパスに応じて対応するファイルが配信されるようになります。
https://pr-111.preview.classi.example
→ s3://<S3_BUCKET>/pull-requests/111/index.html
を返す
https://pr-111.preview.classi.example/sub/page
→ s3://<S3_BUCKET>/pull-requests/111/index.html
を返す
https://pr-222.preview.classi.example/hoge/fuga.png
→ s3://<S3_BUCKET>/pull-requests/222/hoge/fuga.png
を返す

https://pr-111.preview.classi.example
にアクセスする例

https://pr-222.preview.classi.example
にアクセスする例
これでプレビュー環境をホスティングする仕組みの構築は完了です。
あとは Pull Request ごとに S3 バケット内に静的ファイルをアップロードすればいいだけです。
デプロイフロー
以下は Pull Request が作成 / 更新されるたびにプレビュー環境をデプロイする GitHub Actions ワークフローの例です。
処理内容はただ SPA をビルドして静的ファイルを S3 バケットの /pull-requests/<PR番号>/
配下にアップロードするだけです。
name: Deploy Preview
on:
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: <IAMロールのARN>
aws-region: ap-northeast-1
- run: aws s3 sync "<ビルドしたファイル群が格納されているディレクトリのパス>" "s3://<プレビュー環境用のS3バケット>/pull-requests/${PR_NUMBER}/" --acl private
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
これにより、 Pull Request を作成するたびに S3 バケットに SPA の静的ファイルがアップロードされ、 https://pr-<PR番号>.preview.classi.example
からアプリケーションにアクセスできるようになります。
💡 上記の例ではわかりやすくするために actions/checkout
や aws-actions/configure-aws-credentials
を Git タグで参照していますが、実際にはコミット SHA を指定して参照するのがセキュリティ的には望ましいです。
# 例
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
リリースされたアクションバージョンのコミットSHAを使用するのが、安定性とセキュリティのうえで最も安全です。
GitHub Actions のワークフロー構文 - GitHub Docs
実際、 tj-actions/changed-files や reviewdog/action-* などのアクションの Git タグが書き換えられ、これらのアクションを Git タグで参照している GitHub Actions Workflow 内で悪意のあるコードが実行されてしまうという出来事もありました。
unit42.paloaltonetworks.com
なお、 Git タグからコミット SHA への書き換えは pinact などのツールによって自動化が可能です。
プレビュー環境の削除
プレビュー環境は一時的なものなので、レビュー後は削除する必要があります。
今回は S3 バケットのライフサイクルルール *2 で S3 バケット内のオブジェクトを自動削除することで、プレビュー環境の削除を実現します。
resource "aws_s3_bucket_lifecycle_configuration" "example" {
bucket = aws_s3_bucket.example.id
rule {
id = "expiration"
status = "Enabled"
filter {
prefix = ""
}
expiration {
days = 14
}
}
}
ライフサイクルルールを使わずに Pull Request がマージされたときに S3 からファイルを削除する GitHub Actions Workflow を構築してもいいのですが、そもそも実装コストがかかることや、実装に不具合があった場合などにファイルが削除されずに残り続ける可能性がある、という懸念があります。
ライフサイクルルールを使えば、複雑な Workflow を構築することなく削除漏れを防止できるという利点があります。
なお、 Pull Request が Open のままでも一定期間以上放置されるとプレビュー環境が消えてしまうというデメリット (?) はありますが、必要に応じてワークフローを Re-run するだけですぐ再デプロイが可能なので、大した問題にはなりません。
まとめ
今回の仕組みを導入してからフロントエンドの Pull Request レビューが大変捗るようになったので、とてもおすすめです。
参考
今回のこちらの仕組みについては以下の記事を参考にさせていただきました。
こちらの記事では CloudFront Function ではなく Lambda@Edge を利用したパターンが紹介されています。あわせてご参照ください。
levelup.gitconnected.com
Appendix
デプロイ完了後に Pull Request に URL 付きのコメントをつける
プレビュー環境のデプロイ完了後に Pull Request に URL 付きのコメントを作成するようにするとサクッとプレビュー環境にアクセスできるので便利です。

以下はプレビュー環境のデプロイ完了後に peter-evans/find-comment
アクションと peter-evans/create-or-update-comment
アクションを使用して Pull Request にコメントを作成する例です。
もしワークフローが複数回実行されても、複数のコメントが作成されるのではなく単一のコメントが更新され続けます。
github.com
github.com
name: Deploy Preview
jobs:
publish:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Get expiration date
id: expiration
env:
TZ: Asia/Tokyo
run: echo "date=$(date -d "+14 days" "+%Y-%m-%d %a")" >> "${GITHUB_OUTPUT}"
- uses: peter-evans/find-comment@v3
if: github.event_name == 'pull_request'
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: 'Deployed to Preview Environment'
- uses: peter-evans/create-or-update-comment@v4
if: github.event_name == 'pull_request'
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
- **https://pr-${{ github.event.pull_request.number }}.preview.classi.example** (commit: ${{ github.event.pull_request.head.sha }})
:information_source: This preview environment will be deleted on _**${{ steps.expiration.outputs.date }}**_.
edit-mode: replace
プレビュー環境での API 通信について
Classi の内部 CMS のフロントエンドアプリケーションでは元々ローカル開発用に msw を導入しており、プレビュー環境でもそれをそのまま流用して API 通信を全てモックにしています。
www.npmjs.com
そのため、各プレビュー環境は backend とは切り離された状態での動作確認が可能となっています。
アプリケーションの特性によってはプレビュー環境でも実際の backend と通信させても問題ないものもあると思われるので、そこは要件に合わせて設計するといいでしょう。