WebLog

app routerでSSGしたものを、s3とcloudfrontを使って独自ドメインでホスティングする構成をaws-cdkで作る

2023/06/12 17:50

やりたいこと

  • zenn に記事を書いたときに自分のブログにも投稿したい
  • 勉強のため aws で作りたい
  • 勉強のため app router を使いたい
  • 独自ドメインにしたい
  • aws リソースは aws-cdk で作りたい

できたもの

このチュートリアル手順 + 少し md のスタイルとかの調整などしています

HOME | WebLog
HOME | WebLog

HOME | WebLog

WebLogはweb開発に関する技術を中心に紹介するブログです。

以下のような感じになっています

  • zenn-cli を使って github リポジトリと同期(push 時に自動投稿)
  • git push 時に codebuild が走って s3 へデプロイ(自分のブログにデプロイ)

構成図

ディレクトリ構成

1mkdir zenn-next-s3-cloudfront
2cd zenn-next-s3-cloudfront
3git init
4
5npx create-next-app --ts
6
7# Need to install the following packages:
8# create-next-app
9# Ok to proceed? (y) y
10# ✔ What is your project named? … frontend
11# ✔ Would you like to use ESLint with this project? … Yes
12# ✔ Would you like to use Tailwind CSS with this project? … No
13# ✔ Would you like to use `src/` directory with this project? … Yes
14# ✔ Use App Router (recommended)? … Yes
15# ✔ Would you like to customize the default import alias? … No
16
17mkdir infra
18cd infra
19npx cdk init app --language typescript
20
21tree -aL 1
22.
23├── .git
24├── frontend
25└── infra
26
274 directories, 0 files

app router で SSG する

ライブラリのインストール

1npm i zod

ディレクトリ構成

1tree -aL 4
2
3.
4└── app
5 ├── _libs
6└── article.ts
7 ├── articles
8└── [id]
9└── page.tsx
10 ├── favicon.ico
11 ├── layout.tsx
12 └── page.tsx
13
145 directories, 5 files

作っていく

_libs/article.ts
1import { z } from "zod";
2
3export const articleSchema = z.object({
4 id: z.number(),
5 userId: z.number(),
6 title: z.string(),
7 completed: z.boolean(),
8});
9
10export const articlesSchema = z.array(articleSchema);
11
12export type Article = z.infer<typeof articleSchema>;
13export type Articles = z.infer<typeof articlesSchema>;
14
15export const getArticle = async (id: string): Promise<Article> => {
16 try {
17 const res = await fetch(
18 `https://jsonplaceholder.typicode.com/todos/${encodeURIComponent(id)}`
19 );
20 const article = await res.json();
21
22 return articleSchema.parse(article);
23 } catch (err: unknown) {
24 if (err instanceof z.ZodError) {
25 throw new Error(
26 `Invalid article data: ${err.errors.map((e) => e.message).join(", ")}`
27 );
28 }
29 throw err;
30 }
31};
32
33export const getArticles = async (): Promise<Articles> => {
34 try {
35 const res = await fetch("https://jsonplaceholder.typicode.com/todos");
36 const articles = await res.json();
37
38 return articlesSchema.parse(articles);
39 } catch (err: unknown) {
40 if (err instanceof z.ZodError) {
41 throw new Error(
42 `Invalid article data: ${err.errors.map((e) => e.message).join(", ")}`
43 );
44 }
45 throw err;
46 }
47};
48
49
page.tsx
1import Link from "next/link";
2import { getArticles } from "./_libs/article";
3
4export default async function Home() {
5 const articles = await getArticles();
6
7 return (
8 <main>
9 {articles.map((article) => {
10 return (
11 <Link
12 href={`/articles/${encodeURIComponent(article.id)}`}
13 key={article.id}
14 >
15 <h2>{`${article.id} ${article.title}`}</h2>
16 </Link>
17 );
18 })}
19 </main>
20 );
21}
22
articles/[id]/page.tsx
1import { getArticle, getArticles } from "@/app/_libs/article";
2
3export async function generateStaticParams() {
4 const articles = await getArticles();
5
6 const params = articles.map((article) => {
7 return {
8 id: article.id.toString(),
9 };
10 });
11
12 return params;
13}
14
15export default async function Article({ params }: { params: { id: string } }) {
16 const article = await getArticle(params.id);
17
18 return (
19 <div>
20 <h1>Article {article.id}</h1>
21 <p>{article.title}</p>
22 </div>
23 );
24}
25

npm scripts の設定

app router では以下のようにすることで静的ファイルのエクスポートができます

next.config.js
1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 output: "export", // next build時にoutディレクトリに静的エクスポート(以前のnext exportコマンド)
4};
5
6module.exports = nextConfig;
7
package.json
1{
2 "name": "frontend",
3 "version": "0.1.0",
4 "private": true,
5 "scripts": {
6 "dev": "next dev",
7 "build": "next build",
8 "start": "npx serve@latest out", // 'output': 'export'を設定しているときは next start ではなくこちらを使う
9 "lint": "next lint"
10 },
11 "dependencies": {
12 "@types/node": "20.3.0",
13 "@types/react": "18.2.11",
14 "@types/react-dom": "18.2.4",
15 "eslint": "8.42.0",
16 "eslint-config-next": "13.4.5",
17 "next": "13.4.5",
18 "react": "18.2.0",
19 "react-dom": "18.2.0",
20 "typescript": "5.1.3",
21 "zod": "^3.21.4"
22 }
23}
24

以上のように設定し、以下のコマンドを実行します

1npm run build # next buildコマンドが実行され ./out 配下にSSGされる
2npm start # http://localhost:3000 でサーバーが立ち上がる

aws-cdk で静的サイトホスティングを独自ドメインで作る

ドメインを取得しておく

以下のサイトとかを参考に route53 でドメインを取得します。

【AWS】Route 53でドメイン取得 | チグサウェブ
【AWS】Route 53でドメイン取得 | チグサウェブ

【AWS】Route 53でドメイン取得 | チグサウェブ

AWSのRoute 53を利用して、ドメインを登録しました。

Amazon Route53 でドメインを取得する #AWS - Qiita

Amazon Route53 でドメインを取得する #AWS - Qiita

全体の流れドメインを取得する ←今回の投稿ドメインにALBを割り当てるHTTPS化するもくじ取得するドメインを選択するメールアドレスを認証するドメインが登録されたことを確認参考ドメ…

s3+cloudfront の stack を作る

基本的には以下の記事と同じですが、route53 への A レコードの登録を追加でしたりします。

aws-cdkを使ってs3+cloudfrontの静的サイトホスティングを作る | WebLog
aws-cdkを使ってs3+cloudfrontの静的サイトホスティングを作る | WebLog

aws-cdkを使ってs3+cloudfrontの静的サイトホスティングを作る | WebLog

# はじめに aws-cli が使えるようにしておきます ```bash:aws-cliのインストール brew install awscli # aws-cliのインストール aws --version # インストールされたことの

infra/lib/infra-stack.ts
1import {
2 App,
3 CfnOutput,
4 RemovalPolicy,
5 Stack,
6 StackProps,
7 aws_cloudfront,
8 aws_cloudfront_origins,
9 aws_codebuild,
10 aws_iam,
11 aws_route53,
12 aws_route53_targets,
13 aws_s3,
14 aws_s3_deployment,
15} from "aws-cdk-lib";
16import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
17import { IHostedZone } from "aws-cdk-lib/aws-route53";
18import "dotenv/config";
19
20type ZennS3CloudFrontProps = StackProps & {
21 domainName: string;
22 certificate: Certificate;
23 publicHostedZone: IHostedZone;
24};
25
26const OWNER = process.env.GITHUB_OWNER ?? "";
27const REPO = process.env.GITHUB_REPO ?? "";
28const BRANCH = process.env.GITHUB_BRANCH ?? "";
29const GOOGLE_SEARCH_CONSOLE_VERIFICATION =
30 process.env.GOOGLE_SITE_VERIFICATION ?? "";
31
32export class ZennS3CloudFrontStack extends Stack {
33 constructor(scope: App, id: string, props: ZennS3CloudFrontProps) {
34 super(scope, id, props);
35 const { domainName, certificate, publicHostedZone } = props;
36
37 // 静的ホスティング用のバケットを作成
38 const bucket = new aws_s3.Bucket(this, "ZennStaticHostingBucket", {
39 bucketName: "zenn-static-hosting-bucket",
40 removalPolicy: RemovalPolicy.DESTROY, // destroy時にバケットを削除する
41 autoDeleteObjects: true, // destroy時にバケット内のオブジェクトも削除する
42 });
43
44 // cloudfront用のoriginAccessIdentityを作成
45 const originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(
46 this,
47 "ZennOriginAccessIdentity",
48 {
49 comment: "ZennBlogOriginAccessIdentity",
50 }
51 );
52
53 // bucket policyを作成
54 const bucketPolicy = new aws_iam.PolicyStatement({
55 actions: ["s3:GetObject"], // GetObjectのみ許可
56 resources: [`${bucket.bucketArn}/*`], // バケット内の全てのオブジェクトを対象
57 // originAccessIdentityから(CloudFront経由でのアクセス)のみを許可
58 principals: [
59 new aws_iam.CanonicalUserPrincipal(
60 originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
61 ),
62 ],
63 });
64
65 // bucketにpolicyを追加
66 bucket.addToResourcePolicy(bucketPolicy);
67
68 // ルーティングの調整を行う
69 const cloudFrontFuntion = new aws_cloudfront.Function(
70 this,
71 "CloudFrontFunction",
72 {
73 code: aws_cloudfront.FunctionCode.fromFile({
74 filePath: "lib/cloudfront-function.js",
75 }),
76 }
77 );
78
79 const distribution = new aws_cloudfront.Distribution(
80 this,
81 "BlogDistribution",
82 {
83 comment: "ZennBlogDistribution",
84 defaultRootObject: "index.html",
85 defaultBehavior: {
86 compress: true,
87 viewerProtocolPolicy:
88 aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
89 allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD,
90 origin: new aws_cloudfront_origins.S3Origin(bucket, {
91 originAccessIdentity,
92 }),
93 functionAssociations: [
94 {
95 function: cloudFrontFuntion,
96 eventType: aws_cloudfront.FunctionEventType.VIEWER_REQUEST,
97 },
98 ],
99 },
100 errorResponses: [
101 {
102 httpStatus: 404,
103 responseHttpStatus: 404,
104 responsePagePath: "/404.html",
105 },
106 ],
107 certificate,
108 domainNames: [domainName],
109 }
110 );
111
112 // Aレコードを作成
113 new aws_route53.ARecord(this, "ZennBlogARecord", {
114 recordName: domainName,
115 zone: publicHostedZone,
116 target: aws_route53.RecordTarget.fromAlias(
117 new aws_route53_targets.CloudFrontTarget(distribution)
118 ),
119 });
120
121 // Google Search Console用のTXTレコードを作成
122 new aws_route53.TxtRecord(this, "ZennBlogGoogleSearchConsoleRecord", {
123 recordName: domainName,
124 zone: publicHostedZone,
125 values: [
126 `google-site-verification=${GOOGLE_SEARCH_CONSOLE_VERIFICATION}`,
127 ],
128 });
129
130 // s3にデプロイ
131 new aws_s3_deployment.BucketDeployment(this, "ZennBlogBucketDeployment", {
132 destinationBucket: bucket,
133 distribution,
134 sources: [
135 aws_s3_deployment.Source.data(
136 "/index.html",
137 "<html><body><h1>Hello, World!</h1></body></html>"
138 ),
139 ],
140 });
141
142 const buildSpec = {
143 version: "0.2",
144 phases: {
145 pre_build: {
146 commands: ["cd frontend && npm ci"],
147 },
148 build: {
149 commands: ["npm run build"],
150 },
151 post_build: {
152 commands: [
153 "aws s3 sync ./out s3://(バケット名) --delete", // s3 cp ではなく s3 syncでデプロイすることで既存ファイルの削除も行うことができる
154 ],
155 },
156 },
157 };
158
159 // codebuild用のroleを作成
160 const codebuildRole = new aws_iam.Role(this, "CodeBuildRole", {
161 roleName: "ZennBlogCodeBuildRole",
162 assumedBy: new aws_iam.ServicePrincipal("codebuild.amazonaws.com"),
163 });
164
165 // s3へのアクセス権限を追加
166 codebuildRole.addManagedPolicy(
167 aws_iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonS3FullAccess")
168 );
169
170 const codebuild = new aws_codebuild.Project(this, "CodeBuild", {
171 projectName: "ZennBlogCodeBuild",
172 source: aws_codebuild.Source.gitHub({
173 owner: OWNER,
174 repo: REPO,
175 branchOrRef: BRANCH,
176 webhook: true,
177 }),
178 environment: {
179 privileged: true,
180 buildImage: aws_codebuild.LinuxBuildImage.STANDARD_7_0,
181 },
182 buildSpec: aws_codebuild.BuildSpec.fromObject(buildSpec),
183 role: codebuildRole,
184 cache: aws_codebuild.Cache.bucket(bucket),
185 });
186
187 new CfnOutput(this, "URL", {
188 value: `https://${domainName}`,
189 });
190 }
191}
192
lib/cloudfront-function.js
1// cloudfront functionはES6以前の記法で書く必要がある
2// アロー関数, let, constなどは使えない
3function handler(event) {
4 var request = event.request;
5 var uri = request.uri;
6
7 // '/'の場合はindex.htmlを返す
8 if (uri === "/") return request;
9
10 var filename = uri.split("/").pop();
11
12 if (!filename) {
13 return {
14 status: "302",
15 statusDescription: "Found",
16 headers: {
17 location: [
18 {
19 key: "Location",
20 value: uri.replace(/\/+$/, "") || "/",
21 },
22 ],
23 },
24 };
25 } else if (!filename.includes(".")) {
26 request.uri = uri.concat(".html");
27 }
28
29 return request;
30}
31

cloudfront 用の証明書を us-east-1 で取る必要がある

cloudfront 用の証明書は us-east-1 で取る必要があります。

AWS CDK Tips: クロスリージョンのデプロイ - maybe daily dev notes
AWS CDK Tips: クロスリージョンのデプロイ - maybe daily dev notes

AWS CDK Tips: クロスリージョンのデプロイ - maybe daily dev notes

AWS CDK TIpsシリーズの記事です。 AWSでサービスを構築する際、単一リージョンで提供するサービスであっても、クロスリージョンのデプロイが必要になる場合がまれにあります。AWS CDKでは、そのような構成も簡単に実装可能です。今回はCDKを使ったクロスリージョンアプリのデプロイ方法をまとめます。 クロスリージョンの必要な状況 まず、クロスリージョンのデプロイが必要になるのはどのような場合でしょうか? DRやレイテンシー低減を考慮したマルチリージョンのアーキテクチャでは、もちろん必要でしょう。しかし実はそうでない場合、つまり単一リージョンで提供するサービスであっても、次の場合などにクロ…

aws-cdk では同じ app construct で別の region の stack をデプロイすることができます

[AWS CDK] 同じApp Construct内で異なるリージョンのStackをデプロイできるのか試してみた | DevelopersIO
[AWS CDK] 同じApp Construct内で異なるリージョンのStackをデプロイできるのか試してみた | DevelopersIO

[AWS CDK] 同じApp Construct内で異なるリージョンのStackをデプロイできるのか試してみた | DevelopersIO

結論:できました。

これを用いて、

  1. us-east-1 で証明書を取得
  2. ap-northeast-1 などの別リージョン(s3 + cloudfront を立てたいリージョン)で証明書を使う

という方法でいきます。

infra/lib/us-east-1-stack.ts
1import {
2 App,
3 Stack,
4 StackProps,
5 aws_certificatemanager,
6 aws_route53,
7} from "aws-cdk-lib";
8import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
9import { IHostedZone } from "aws-cdk-lib/aws-route53";
10
11type ZennUsEast1StackProps = StackProps & {
12 domainName: string;
13};
14
15export class ZennUsEast1Stack extends Stack {
16 public readonly publicHostedZone: IHostedZone;
17 public readonly certificate: Certificate;
18
19 constructor(scope: App, id: string, props: ZennUsEast1StackProps) {
20 super(scope, id, props);
21
22 const { domainName } = props;
23
24 // パブリックホストゾーンを作成
25 const publicHostedZone = aws_route53.HostedZone.fromLookup(
26 this,
27 "ZennHostedZone",
28 {
29 domainName,
30 }
31 );
32
33 // cloudfrontように証明書を作成(cloudfrontの制約でus-east-1で作成する必要がある)
34 const certificate = new aws_certificatemanager.Certificate(
35 this,
36 "ZennCertificate",
37 {
38 domainName,
39 certificateName: "ZennCertificate",
40 validation:
41 aws_certificatemanager.CertificateValidation.fromDns(
42 publicHostedZone
43 ),
44 }
45 );
46
47 this.certificate = certificate;
48 this.publicHostedZone = publicHostedZone;
49 }
50}
51

ルーティングの調整を cloudfront function で行う

そのままの設定だとルーティングがうまくいかないので、lambda@edge または cloudfront function を使って調整をします。 cloudfront function は古い JS の記法しか使えなかったり容量制限がありますが、lambda@edge に比べて安いみたいです。

Next.js で SSG したサイトを AWS CloudFront + S3 にデプロイする #AWS - Qiita

Next.js で SSG したサイトを AWS CloudFront + S3 にデプロイする #AWS - Qiita

はじめに最近 Next.js の SSG (Static Site Generator: 静的サイト生成) の機能が強化されにわかに盛り上がっています。SSG の用途では今までは Gatsby.…

クロスリージョンな stack のデプロイ

上で作成した二つの stack をデプロイします。stack を作成する際のコンストラクタの引数として、作成先の region と、crossRegionReferences: trueを指定することでクロスリージョン参照ができるようになります。

infra/bin/infra.ts
1#!/usr/bin/env node
2import "source-map-support/register";
3import * as cdk from "aws-cdk-lib";
4import { ZennUsEast1Stack } from "../lib/us-east-1-stack";
5import { ZennS3CloudFrontStack } from "../lib/infra-stack";
6
7const app = new cdk.App();
8
9const domainName = '独自ドメイン(例.https://google.com)'
10
11const usEast1 = new ZennUsEast1Stack(app, "UsEast1Stack", {
12 env: {
13 account: 'アカウントID'
14 region: "us-east-1",
15 },
16 crossRegionReferences: true, // クロスリージョン参照をON
17 domainName,
18});
19
20new ZennS3CloudFrontStack(app, "ZennS3CloudFrontStack", {
21 env: {
22 account: 'アカウントID'
23 region: 's3 + cloudfrontを作成したい先のregion(例. ap-northeast-1)'
24 },
25 crossRegionReferences: true, // クロスリージョン参照をON
26 domainName,
27 // usEast1スタックからの参照
28 certificate: usEast1.certificate,
29 publicHostedZone: usEast1.publicHostedZone,
30});

デプロイ

1npx cdk deploy --all

お片付け

1npx cdk destroy

最新の投稿