CDKを利用してSPA用のCloudFront+S3環境を作った
SPAをCloudFront+S3構成の環境にデプロイする機会があったのでAWS CDKを活用してみました。
本記事ではCDKを利用した以下の内容について共有します。
- CloudFront+S3構成の静的ファイルホスティング環境を構築する
- GitHub ActionsとAWSのOIDC連携用のIAM Roleを作成する
類似内容を検討している方の参考になれば幸いです。
CloudFront+S3構成の静的ファイルホスティング環境を構築する
静的ファイルのデプロイ先S3バケット作成
SANDBOX環境で実行する場合はremovalPolicy
にRemovalPolicy.DESTROY
を指定しておくと破棄が楽になると思います。
const sourceBucket = new s3.Bucket(this, "S3Bucket", {
bucketName: "cdk-static-file-deploy",
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,
enforceSSL: true,
});
CloudFront Distribution作成
アクセスログ収集先S3バケット作成
lifecycleRules
を設定して古いログはコストの低いStorageClassに移行しても良さそうです。
const cloudfrontLoggingBucket = new s3.Bucket(
this,
"CloudfrontLoggingBucket",
{
bucketName: "cdk-cloud-front-access-log",
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
enforceSSL: true,
}
);
CloudFrontからS3のアクセス制御設定作成
利用の推奨がされているOAC(Origin Access Control)方式を採用します。L2コンストラクタが存在しないので、Issueを参考にしつつ作成します。
const originAccessControl = new cloudfront.CfnOriginAccessControl(
this,
"CloudFrontOAC",
{
originAccessControlConfig: {
name: "OriginAccessControlForSourceBucket",
originAccessControlOriginType: "s3",
signingBehavior: "always",
signingProtocol: "sigv4",
description: "Origin access control provisioned by aws-cloudfront-s3",
},
}
);
SPAリダイレクト用のCloudFront Functions作成
root path以外でリロード等をしても動作するようにします。
まずは関数の中身を別ファイルで定義します。
function handler(event) {
var request = event.request;
var requiresRedirect =
request.method === "GET" && request.uri.indexOf(".") === -1;
if (requiresRedirect) {
request.uri = "/index.html";
}
return request;
}
上記ファイルを参照する形でCloudFront Functionsを作成します。
const cfFunctionForRedirect = new cloudfront.Function(
this,
"RedirectForSPA",
{
code: cloudfront.FunctionCode.fromFile({
filePath: "./infrastructures/assets/cfFunctions/redirect.js",
}),
}
);
レスポンスヘッダーポリシー作成
要件等に応じて設定します。
各項目の内容はMDNを確認してください。
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
this,
"ResponseHeadersPolicy",
{
securityHeadersBehavior: {
contentTypeOptions: { override: true },
frameOptions: {
frameOption: cloudfront.HeadersFrameOption.DENY,
override: true,
},
referrerPolicy: {
referrerPolicy: cloudfront.HeadersReferrerPolicy.SAME_ORIGIN,
override: true,
},
strictTransportSecurity: {
accessControlMaxAge: cdk.Duration.seconds(63072000),
includeSubdomains: true,
preload: true,
override: true,
},
xssProtection: {
protection: true,
modeBlock: true,
override: true,
},
},
customHeadersBehavior: {
customHeaders: [
{
header: "Cache-Control",
value: "no-cache",
override: true,
},
{
header: "pragma",
value: "no-cache",
override: true,
},
{
header: "server",
value: "",
override: true,
},
],
},
}
);
Distribution作成
これまでに作成したリソースを組み合わせてdistributionを作ります
const distribution = new cloudfront.Distribution(this, "DistributionId", {
defaultRootObject: "index.html",
defaultBehavior: {
origin: new cloudfront_origins.S3Origin(sourceBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
responseHeadersPolicy,
functionAssociations: [
{
function: cfFunctionForRedirect,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
],
},
enableLogging: true,
logBucket: cloudfrontLoggingBucket,
logFilePrefix: "distribution-access-log/",
logIncludesCookies: true,
});
const cfnDistribution = distribution.node
.defaultChild as cloudfront.CfnDistribution;
// OACの設定
cfnDistribution.addPropertyOverride(
"DistributionConfig.Origins.0.OriginAccessControlId",
originAccessControl.getAtt("Id")
);
// Originのドメイン名の設定
cfnDistribution.addPropertyOverride(
"DistributionConfig.Origins.0.DomainName",
sourceBucket.bucketRegionalDomainName
);
// デフォルトで付与されるOAIを削除
cfnDistribution.addOverride(
"Properties.DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity",
""
);
cfnDistribution.addPropertyDeletionOverride(
"DistributionConfig.Origins.0.CustomOriginConfig"
);
静的ファイルのデプロイ先S3バケットのバケットポリシー作成
CloudFront Distributionからのアクセスのみを許可します。
const bucketPolicyStatement = new iam.PolicyStatement({
actions: ["s3:GetObject"],
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
resources: [`${sourceBucket.bucketArn}/*`],
conditions: {
StringEquals: {
"AWS:SourceArn": `arn:aws:cloudfront::${cdk.Aws.ACCOUNT_ID}:distribution/${distribution.distributionId}`,
},
},
});
sourceBucket.addToResourcePolicy(bucketPolicyStatement);
S3へファイルをアップロード
手動で別途アップロードも可能ですが、手間に感じるのでCDKで実施してます。
new s3_deployment.BucketDeployment(this, "S3Deployment", {
sources: [s3_deployment.Source.asset("./build")],
destinationBucket: sourceBucket,
distribution,
distributionPaths: ["/*"],
});
GitHub ActionsとAWSのOIDC連携用のIAM Roleを作成する
引数でrepogitoryやbranchを指定できるようにしておきます。
アタッチするpolicyにAdministratorAccess
を指定していますが、必要な権限に絞ったポリシーを別途用意して指定した方が望ましいです。
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export interface GithubActionsAwsAuthCdkStackProps extends cdk.StackProps {
readonly repositoryConfig: { owner: string; repo: string; filter?: string }[];
}
export class GithubActionsOidcStack extends cdk.Stack {
constructor(
scope: Construct,
id: string,
props: GithubActionsAwsAuthCdkStackProps
) {
super(scope, id, props);
const iamRepoDeployAccess = props.repositoryConfig.map(
(r) => `repo:${r.owner}/${r.repo}:${r.filter ?? "*"}`
);
const provider = new iam.OpenIdConnectProvider(
this,
"GithubActionsProvider",
{
url: "https://token.actions.githubusercontent.com",
clientIds: ["sts.amazonaws.com"],
}
);
const role = new iam.Role(this, "GithubDeployRole", {
roleName: "GithubActionsDeployRole",
maxSessionDuration: cdk.Duration.hours(2),
assumedBy: new iam.WebIdentityPrincipal(
provider.openIdConnectProviderArn,
{
StringEquals: {
["token.actions.githubusercontent.com:aud"]: "sts.amazonaws.com",
},
StringLike: {
["token.actions.githubusercontent.com:sub"]: iamRepoDeployAccess,
},
}
),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"),
],
});
new cdk.CfnOutput(this, "GithubActionOidcIamRoleArn", {
value: role.roleArn,
description: `Arn for AWS IAM role with Github oidc auth for ${iamRepoDeployAccess}`,
exportName: "GithubActionOidcIamRoleArn",
});
}
}
GitHub Actionsを利用してデプロイworkflowを作成
developブランチにPushされたら、最新のdevelopブランチの内容でデプロイさせます。
secretsの登録
以下を登録します。
key | description |
---|---|
AWS_ROLE_ARN | 上で作成したRoleのARN |
AWS_DEPLOY_BUCKET_NAME | 静的ファイルのデプロイ先S3バケット名 |
AWS_CLOUDFRONT_DISTRIBUTION_ID | CloudFront DistributionのID |
workflow作成
OIDC連携しつつ、S3にbuild成果物をアップロードしてCloudFrontのキャッシュを削除します。
name: deploy
on:
push:
# 任意のブランチを指定してください
branches: [develop]
defaults:
run:
shell: bash
env:
DEPLOY_RESOURCE: ./build
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: stg
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: build
run: yarn build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ap-northeast-1
- name: deploy
run: aws s3 sync ${{ env.DEPLOY_RESOURCE }} s3://${{ secrets.AWS_DEPLOY_BUCKET_NAME }} --delete
- name: clear cache
run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"
最終的なコード
infrastructures/bin/cdk.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { CloudFrontToS3Stack } from "../stacks/cloudfront-s3";
import { GithubActionsOidcStack } from "../stacks/github-actions-oidc";
import { CONFIG } from "../constants";
const app = new cdk.App();
new CloudFrontToS3Stack(app, "CdkCloudFrontToS3Stack", {
env: { account: CONFIG.STG.AWS.ACCOUNT_ID, region: CONFIG.STG.AWS.REGION },
});
new GithubActionsOidcStack(app, "CdkGithubActionsOidcStack", {
env: { account: CONFIG.STG.AWS.ACCOUNT_ID, region: CONFIG.STG.AWS.REGION },
repositoryConfig: [
{ owner: CONFIG.COMMON.GITHUB.OWNER, repo: CONFIG.COMMON.GITHUB.REPO },
],
});
infrastructures/stacks/cloudfront-s3.ts
import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3_deployment from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as cloudfront_origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export class CloudFrontToS3Stack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
/**
* 静的ファイルのデプロイ先S3バケット作成
*/
const sourceBucket = new s3.Bucket(this, "S3Bucket", {
bucketName: "cdk-static-file-deploy",
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.DESTROY,
enforceSSL: true,
});
/**
* アクセスログ収集先S3バケット作成
*/
const cloudfrontLoggingBucket = new s3.Bucket(
this,
"CloudfrontLoggingBucket",
{
bucketName: "cdk-cloud-front-access-log",
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN,
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_PREFERRED,
enforceSSL: true,
}
);
/**
* OAC作成
* SEE: https://github.com/aws/aws-cdk/issues/21771
*/
const originAccessControl = new cloudfront.CfnOriginAccessControl(
this,
"CloudFrontOAC",
{
originAccessControlConfig: {
name: "OriginAccessControlForSourceBucket",
originAccessControlOriginType: "s3",
signingBehavior: "always",
signingProtocol: "sigv4",
description: "Origin access control provisioned by aws-cloudfront-s3",
},
}
);
/**
* SPAリダイレクト用Cloudfront Functions作成
*/
const cfFunctionForRedirect = new cloudfront.Function(
this,
"RedirectForSPA",
{
code: cloudfront.FunctionCode.fromFile({
filePath: "./infrastructures/assets/cfFunctions/redirect.js",
}),
}
);
/**
* レスポンスヘッダポリシー作成
*/
const responseHeadersPolicy = new cloudfront.ResponseHeadersPolicy(
this,
"ResponseHeadersPolicy",
{
securityHeadersBehavior: {
contentTypeOptions: { override: true },
frameOptions: {
frameOption: cloudfront.HeadersFrameOption.DENY,
override: true,
},
referrerPolicy: {
referrerPolicy: cloudfront.HeadersReferrerPolicy.SAME_ORIGIN,
override: true,
},
strictTransportSecurity: {
accessControlMaxAge: cdk.Duration.seconds(63072000),
includeSubdomains: true,
preload: true,
override: true,
},
xssProtection: {
protection: true,
modeBlock: true,
override: true,
},
},
customHeadersBehavior: {
customHeaders: [
{
header: "Cache-Control",
value: "no-cache",
override: true,
},
{
header: "pragma",
value: "no-cache",
override: true,
},
{
header: "server",
value: "",
override: true,
},
],
},
}
);
/**
* Cloudfront Distribution作成
*/
const distribution = new cloudfront.Distribution(this, "DistributionId", {
defaultRootObject: "index.html",
defaultBehavior: {
origin: new cloudfront_origins.S3Origin(sourceBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
responseHeadersPolicy,
functionAssociations: [
{
function: cfFunctionForRedirect,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
},
],
},
enableLogging: true,
logBucket: cloudfrontLoggingBucket,
logFilePrefix: "distribution-access-log/",
logIncludesCookies: true,
});
const cfnDistribution = distribution.node
.defaultChild as cloudfront.CfnDistribution;
// OACの設定
cfnDistribution.addPropertyOverride(
"DistributionConfig.Origins.0.OriginAccessControlId",
originAccessControl.getAtt("Id")
);
// Originのドメイン名の設定
cfnDistribution.addPropertyOverride(
"DistributionConfig.Origins.0.DomainName",
sourceBucket.bucketRegionalDomainName
);
// デフォルトで付与されるOAIを削除
cfnDistribution.addOverride(
"Properties.DistributionConfig.Origins.0.S3OriginConfig.OriginAccessIdentity",
""
);
cfnDistribution.addPropertyDeletionOverride(
"DistributionConfig.Origins.0.CustomOriginConfig"
);
/**
* S3バケットのバケットポリシー作成
* 上で作成したCloudfront Distributionからのみアクセスを許可
*/
const bucketPolicyStatement = new iam.PolicyStatement({
actions: ["s3:GetObject"],
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("cloudfront.amazonaws.com")],
resources: [`${sourceBucket.bucketArn}/*`],
conditions: {
StringEquals: {
"AWS:SourceArn": `arn:aws:cloudfront::${cdk.Aws.ACCOUNT_ID}:distribution/${distribution.distributionId}`,
},
},
});
sourceBucket.addToResourcePolicy(bucketPolicyStatement);
/**
* S3へ静的ファイルをアップロード
*/
new s3_deployment.BucketDeployment(this, "S3Deployment", {
sources: [s3_deployment.Source.asset("./build")],
destinationBucket: sourceBucket,
distribution,
distributionPaths: ["/*"],
});
}
}
infrastructures/stacks/github-actions-oidc.ts
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export interface GithubActionsAwsAuthCdkStackProps extends cdk.StackProps {
readonly repositoryConfig: { owner: string; repo: string; filter?: string }[];
}
export class GithubActionsOidcStack extends cdk.Stack {
constructor(
scope: Construct,
id: string,
props: GithubActionsAwsAuthCdkStackProps
) {
super(scope, id, props);
const iamRepoDeployAccess = props.repositoryConfig.map(
(r) => `repo:${r.owner}/${r.repo}:${r.filter ?? "*"}`
);
const provider = new iam.OpenIdConnectProvider(
this,
"GithubActionsProvider",
{
url: "https://token.actions.githubusercontent.com",
clientIds: ["sts.amazonaws.com"],
}
);
const role = new iam.Role(this, "GithubDeployRole", {
roleName: "GithubActionsDeployRole",
maxSessionDuration: cdk.Duration.hours(2),
assumedBy: new iam.WebIdentityPrincipal(
provider.openIdConnectProviderArn,
{
StringEquals: {
["token.actions.githubusercontent.com:aud"]: "sts.amazonaws.com",
},
StringLike: {
["token.actions.githubusercontent.com:sub"]: iamRepoDeployAccess,
},
}
),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"),
],
});
new cdk.CfnOutput(this, "GithubActionOidcIamRoleArn", {
value: role.roleArn,
description: `Arn for AWS IAM role with Github oidc auth for ${iamRepoDeployAccess}`,
exportName: "GithubActionOidcIamRoleArn",
});
}
}
infrastructures/assets/cfFunctions/redirect.js
/**
* pathに拡張子がない場合、index.htmlにリダイレクトする
*/
function handler(event) {
var request = event.request;
var requiresRedirect =
request.method === "GET" && request.uri.indexOf(".") === -1;
if (requiresRedirect) {
request.uri = "/index.html";
}
return request;
}
参考にした記事など
ユーザーファーストなサービスを伴に考えながらつくる、デザインとエンジニアリングの会社です。エンジニア積極採用中です!hrmos.co/pages/funteractive/jobs
Discussion