homeIcon

【静的サイト編】プロフィールサイトAWS移行メモ 〜インフラ初心者を添えて〜

インフラ
2025.09.09
2025.09.13

はじめに

この記事では、AWS CDKを使用して S3 + CloudFront を用いた静的サイトを構築していきたいと思います。
アーキテクチャ図で表すと、今回は以下の部分を構築していきます。

画像1

サイト自体はNext.jsで作成済みとして話を進めますので、ご了承ください。
また、前回の記事で環境構築を行っているので、気になる方は併せてご覧いただけますと幸いです。

【環境構築編】プロフィールサイトAWS移行メモ 〜インフラ初心者を添えて〜

No description available.

miyazaki-profile.com

OGP Image

ディレクトリ構成について

まず初めに、今回実装する全体のディレクトリ構成を示します。
以下のリポジトリを参考にさせていただきましたが、ベストプラクティスかは分からないです。

GitHub - non-97/cloudfront-s3-website

Contribute to non-97/cloudfront-s3-website development by creating an account on GitHub.

github.com

Contribute to non-97/cloudfront-s3-website development by creating an account on GitHub.

CDKでインフラを構築するにあたり、下記のような構成をとりました。

  • bin/ : CDKのエントリポイント
  • lib/ : CDK本体(Stage/Stack/Construct の3層)
  • function/ : 実行コード(CloudFront Functions)
  • parameters/ : 環境依存の値
My_profile-cdk/
├── bin/
│   └── my_profile-cdk.ts
├── function/
│   └── cf2/
│       └── directory-index.js
├── lib/
│   └── constructs/
│   │   └── dns-construct/
│   │   │   └── hosted-zone-construct.ts
│   │   └── edge-cert-construct/
│   │   │   └── certificate-construct.ts
│   │   └── website-constructs/
│   │       └── bucket-construct.ts
│   │       └── cdn-construct.ts
│   └── stacks/
│   │   └── dns-stack.ts
│   │   └── edge-cert-stack.ts
│   │   └── website-stack.ts
│   └── my_profile-cdk-stage.ts
├── parameters/
│   └── dns-parameter.ts
│   └── edge-cert-parameter.ts
│   └── website-parameter.ts
├── test/
│   └── cdk-workshop.test.ts
├── .gitignore
├── .npmignore
├── cdk.json
├── jest.config.js
├── package.json
├── README.md
└── tsconfig.json

構成としては、大きく「dns」「edge-cert」「website」の3つに分かれており、その内容は以下のようになっています。
具体的な中身については後述します。

スタック役割使用サービス
dns公開 Hosted Zone の参照Route 53
edge-certTLS 証明書の作成 or 参照ACM
websiteサイト配信の本体S3, CloudFront(+CF Functions)

Route 53 でのドメイン取得

まずはaws コンソールでドメインの取得を行います。
AWS Route53は、Amazonが提供するDNS(Domain Name System)サービスであり、今回はこのサービスを使用してドメインを取得します。
あらかじめ取得したいドメイン名を決めておいて下さい。

Route53コンソールにアクセス

  1. AWSマネジメントコンソールにログイン
  2. サービス一覧から「Route53」を選択
  3. 左サイドバーの「登録済みドメイン」を選択
  4. 「ドメインを登録」を選択
画像2

ドメインの検索と選択

  1. 希望するドメイン名を入力欄に入力
  2. 「検索」ボタンをクリックして利用可能性を確認
  3. 利用可能なドメインの中から希望するものを選択
  4. 「チェックアウトに進む」を選択
画像3

ドメインの設定

登録期間と自動更新の設定を行います。

  • 登録期間:1~10年から選択可能
  • 自動更新:個人利用の場合はオフにするのがおすすめです
画像4

連絡先情報の入力

ドメイン登録には以下の情報が必要です。
下の3つの情報については「同上」として設定できます。

  • 登録者の連絡先
  • 管理者の連絡先
  • 技術者の連絡先
  • 請求に関する連絡先

最終確認と購入

入力内容と利用規約を確認して「送信」を選択します。

認証メールの確認

登録後、以下の2つのメールが届きます。
※ 認証メールは迷惑メールフォルダに入ることがあるので、そちらもチェックしてください。

  • 購入確認メール:注文が完了したことの通知
  • ドメイン登録確認メール:認証が必要(重要)

認証完了後、その旨が記載されたメールが届きます。
これでドメイン取得は完了です。

CDK の実装

では、いよいよCDKを組んでいきます。
ですが、全てのコードを詳細に説明していると記事がものすごく長くなってしまうので、ここでは大まかな処理内容と、メインである S3, CloudFront について話していきます。
コード自体は以下のリポジトリに置いているので、気になった方は見てみてください。

GitHub - miyazaki-dev01/My_profile-infra: プロフィールサイト IaC(AWS CDK)

プロフィールサイト IaC(AWS CDK). Contribute to miyazaki-dev01/My_profile-infra development by creating an account on GitHub.

github.com

プロフィールサイト IaC(AWS CDK). Contribute to miyazaki-dev01/My_profile-infra development by creating an account on GitHub.

処理内容

dns スタック

  • 上記で作成した Route 53 Public Hosted Zone をインポート

edge-cert スタック

  • ACM証明書の 新規作成 または インポート(※新規作成の場合は、証明書は必ず us-east-1 で発行)

website スタック

  • Webサイトのコンテンツを保存するS3バケットの作成
  • ディレクトリインデックス補完用の CloudFront Function の作成
  • CloudFront OACの設定
  • CloudFront ディストリビューションの作成
  • Route 53 Public Hosted ZoneにCloudFront ディストリビューションのALIASレコードを作成

website スタックに関しては、us-east-1で発行したACM証明書をap-northeast-1で読み込むため、クロスリージョン参照(crossRegionReferences: true)を設定しています。
具体的なコードは/lib/my_profile-cdk-stage.tsをご覧ください。

S3 バケットの設定

S3 バケットについては、以下のように設定しています。
特徴としては以下になるかと思います。

  • 公開アクセスをブロックし、CloudFront から OAC(Origin Access Control)経由でのみでコンテンツを読み込む。
  • SSL強制で、アクセスを HTTPS のみに強制。
  • サーバー側暗号化を有効に設定。
/lib/constructs/website-constructs/bucket-construct.ts
import { Construct } from "constructs";
import { aws_s3 as s3, RemovalPolicy } from "aws-cdk-lib";
import { BucketProperty } from "@/parameters/website-parameter";
 
export interface BucketConstructProps extends BucketProperty {}
 
export class BucketConstruct extends Construct {
  readonly bucket: s3.Bucket;
 
  constructor(scope: Construct, id: string, props: BucketConstructProps) {
    super(scope, id);
 
    this.bucket = new s3.Bucket(this, "Bucket", {
      bucketName: props?.bucketName,                             // 新規作成時のバケット名
 
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,         // 公開アクセスをブロック
      enforceSSL: true,                                          // SSL 強制
      encryption: s3.BucketEncryption.S3_MANAGED,                // サーバー側暗号化(SSE-S3)
      objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED, // ACLを無効化して所有権をバケット側に強制
 
      versioned: false,                                          // バージョニングはオフ
 
      removalPolicy: RemovalPolicy.DESTROY,                      // Destroy 方針
      autoDeleteObjects: true,                                   // Destroy 時に中身も消す
    });
  }
}
 

CloudFront ディストリビューションの設定

CloudFront ディストリビューション周りの設定は以下コードのとおりです。
項目としては、次の5項目を設定しています。

  • セキュリティ系レスポンスヘッダ
  • キャッシュポリシー
  • ディレクトリインデックス補完
  • デフォルトビヘイビア
  • エラーハンドリング
/lib/constructs/website-constructs/cdn-construct.ts
import { Construct } from "constructs";
import {
  aws_cloudfront as cf,
  aws_cloudfront_origins as origins,
  aws_route53 as r53,
  aws_route53_targets as targets,
  aws_s3 as s3,
  Duration,
} from "aws-cdk-lib";
import { ContentsDeliveryProperty } from "@/parameters/website-parameter";
import type { ApiOriginForContact } from "@/lib/stacks/website-stack";
 
export interface CdnConstructProps extends ContentsDeliveryProperty {
  contentBucket: s3.IBucket;
  certificate: import("aws-cdk-lib/aws-certificatemanager").ICertificate;
  hostedZone: r53.IPublicHostedZone;
  apiOriginForContact: ApiOriginForContact;
}
 
export class CdnConstruct extends Construct {
  readonly distribution: cf.Distribution;
 
  constructor(scope: Construct, id: string, props: CdnConstructProps) {
    super(scope, id);
 
    // レスポンスヘッダポリシー(セキュリティ系)
    const headers = new cf.ResponseHeadersPolicy(this, "SecurityHeaders", {
      securityHeadersBehavior: {
        contentTypeOptions: { override: true },                 // X-Content-Type-Options: nosniff
        frameOptions: {                                         // X-Frame-Options: DENY
          frameOption: cf.HeadersFrameOption.DENY,
          override: true,
        },
        referrerPolicy: {                                       // Referrer-Policy
          referrerPolicy: cf.HeadersReferrerPolicy.NO_REFERRER,
          override: true,
        },
        strictTransportSecurity: {                              // HSTS(HTTPS常用)
          accessControlMaxAge: Duration.days(365),              // max-age=31536000
          includeSubdomains: true,                              // includeSubDomains
          preload: true,                                        // preload(Chromium HSTSプリロード)
          override: true,                                       // オリジン応答より優先
        },
      },
    });
 
    // キャッシュポリシー(静的サイト向け)
    const defaultCache = new cf.CachePolicy(this, "DefaultCache", {
      cachePolicyName: "default-html-aware",                   // 一意な名前
      defaultTtl: Duration.hours(1),                           // HTML等の既定TTL
      minTtl: Duration.seconds(0),                             // 即時上書きも許容
      maxTtl: Duration.days(7),                                // 最長でも7日
      enableAcceptEncodingBrotli: true,                        // Brotli対応
      enableAcceptEncodingGzip: true,                          // gzip対応
      cookieBehavior: cf.CacheCookieBehavior.none(),           // クッキーをキーに含めない
      headerBehavior: cf.CacheHeaderBehavior.none(),           // ヘッダをキーに含めない
      queryStringBehavior: cf.CacheQueryStringBehavior.none(), // クエリをキーに含めない
    });
 
    // ディレクトリインデックス補完用の CloudFront Function
    let rewriteFn: cf.Function | undefined = undefined;
    if (props.enableDirectoryIndex === "cf2") {
      const filePath = require.resolve("@/functions/cf2/directory-index.js");
      rewriteFn = new cf.Function(this, "DirectoryIndexFn", {
        code: cf.FunctionCode.fromFile({ filePath }),
      });
    }
 
    // CloudFront Distribution
    this.distribution = new cf.Distribution(this, "Distribution", {
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(            // OACでS3を私的アクセス
          props.contentBucket
        ),
        viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,   // HTTP→HTTPS
        responseHeadersPolicy: headers,                                    // セキュリティヘッダ
        functionAssociations: rewriteFn                                    // ディレクトリ補完
          ? [
              {
                eventType: cf.FunctionEventType.VIEWER_REQUEST,
                function: rewriteFn,
              },
            ]
          : undefined,
        compress: true,                                                    // 自動圧縮
        cachePolicy: defaultCache,                                         // 上記キャッシュ方針
        allowedMethods: cf.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,          // 読み取り系のみ許可
      },
      defaultRootObject: "index.html",                                     // ルート(/)はindex.html
      certificate: props.certificate,                                      // TLS終端
      domainNames: [props.domainName],                                     // ALTN(CNAME)
      errorResponses: [                                                    // 403/404は404.htmlへ
        {
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: "/404.html",
          ttl: Duration.minutes(5),
        },
        {
          httpStatus: 403,
          responseHttpStatus: 404,
          responsePagePath: "/404.html",
          ttl: Duration.minutes(5),
        },
      ],
      priceClass: cf.PriceClass.PRICE_CLASS_200,                           // コスト/性能バランス
      minimumProtocolVersion: cf.SecurityPolicyProtocol.TLS_V1_2_2021,     // TLS最低バージョン
      httpVersion: cf.HttpVersion.HTTP3,                                   // HTTP/3対応
    });
 
    ...
 
  }
}
 

ディレクトリインデックスについて

今回の構成では CF2(CloudFront Functions)を使用してディレクトリインデックスを行っています。
これについて、仕様決めで少し手間取ったので、簡単に解説したいと思います。

そもそもディレクトリインデックスとは?

ディレクトリインデックスとは、URLがファイル名を省略したときに、そのディレクトリ配下の既定ファイル(多くは index.html)を自動で返す仕組みです。
これを行う理由としては、以下があります。

  • 見た目がキレイ:/about/index.html より /about の方が読みやすい。
  • 静的サイトと相性が良い:Jamstack/SSG(Next.jsのexportなど)は about/index.html を出力します。
  • SEOの一貫性:/about/about/ を同じページとして扱い、正規URLを統一できる(リダイレクトと併用)。

今回の構成で必要になる理由

今回の構成は S3(非公開)+ CloudFront(OAC)です。← ここがポイント
S3の「静的ウェブサイトホスティング」機能は使っていないため、S3の自動インデックス返却が働きません。
CloudFront の defaultRootObject は「ルート / だけ」に効くため、 /about/ などのサブディレクトリには適用されず、/about にアクセスすると 404/403 になります。
そのため、エッジで /about/about/index.html に書き換える必要があり CF2 を使用してディレクトリインデックスを行っています。

Lambda@Edge という選択肢もある

ここで、仕様を決める際に迷った点として、このディレクトリインデックスを行うにあたって、CF2 のほかに Lambda@Edge という選択肢もありました。
結論として、以下の比較結果から、今回は CF2 を選択しました。
今回のような小規模なサイトで、限られた機能しか使わない場合は CF2 一択みたいです。

項目CloudFront FunctionLambda@Edge
言語JS(ES5.1)Node.js / Python
実行料金1億リクエストあたり 約$0.101億リクエストあたり 約$1.00(リージョン依存)
実行時間最大 1ms最大 30秒
機能軽量処理のみ(ヘッダ・URL操作)Node.jsフル機能(外部API呼び出し可)
デプロイ速度数分20〜30分(グローバル反映)
コスト構造リクエスト数が多いと単価差が目立つ単価高めだが機能豊富

それぞれのコスト比較は、以下の記事で詳しく検証されていますので、気になった方は見てみてください。

CloudFront FunctionsはLambda@Edgeより安い。それ本当?! | DevelopersIO

CloudFront FunctionsよりLambda@Edgeの方が低コストな場合もあるという話です

dev.classmethod.jp

OGP Image

デプロイ

では CDK のコードが実装できたので、デプロイを行います。
初めに CDK のプロジェクトでデプロイを行い、AWS インフラを構築します。
その後、Next.js のプロジェクトでビルドを行い、生成されたフィルを S3 にアップロードしていきます。

CDK プロジェクトでの対応

テンプレートの生成

AWS CDK アプリケーションは、あくまでコードを使用したインフラストラクチャの定義に過ぎません。
アプリケーションで定義された各スタックに対して synthesize(合成) を行い、 AWS CloudFormation テンプレートを生成します。
以下のコマンドを実行します。

$ cdk synth

環境のブートストラップ

AWS CDK アプリケーションを初めて環境(アカウント/リージョン)にデプロイする場合は、リージョンごとにブートストラップスタックをデプロイする必要があります。
このコマンドを実行することで、CDKアプリケーションのデプロイに必要な周辺リソースが自動的に作成されます。

まず、以下のコマンドでブートストラップに必要なAWSアカウントIDを環境変数に設定します。

$ ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
$ echo $ACCOUNT_ID

そして、今回は「ap-northeast-1(dns, website)」と「us-east-1(edge-cert)」の2つのリージョンにスタックをデプロイするため、以下のコマンドを実行します。

$ cdk bootstrap aws://$ACCOUNT_ID/ap-northeast-1
$ cdk bootstrap aws://$ACCOUNT_ID/us-east-1

差分を確認する

ここで一旦、AWS CDK Toolkit に差分を示してもらいましょう。
以下のコマンドは、AWS CDK Toolkit にCDKアプリケーションと現在デプロイされているものの差分を示してもらえます。
これは、デプロイを実行したときに何が起こるかを安全に確認する方法で、常に実践する価値があります。
今回は初めてのデプロイなので、実装したものが全て差分として表示されるかと思います。

$ cdk diff MyProfileCdkStage/*

デプロイ

いよいよ実装した CDKアプリケーション をデプロイします。
以下のコマンドを実行し、各スタックをデプロイしましょう。
--concurrency 1オプションは、スタックを1つずつ指定した順に沿ってデプロイすることを明示するものです。

$ cdk deploy MyProfileCdkStage/DnsStack \
             MyProfileCdkStage/EdgeCertStack \
             MyProfileCdkStage/WebsiteStack \
             --concurrency 1

これで、AWS に静的サイト用のインフラが構築されました。

Next.js プロジェクトでの対応

続けて、S3 にファイルをアップロードしていきます。

CDK のセットアップ

Next.jsのプロジェクトにAWS CLIがなければ、以下を実行してインストールします。

$ curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip"
$ unzip awscliv2.zip
$ sudo ./aws/install

そして、前回と同じように以下コマンドを実行して、初期設定を行います。

$ aws configure
AWS Access Key ID [None]: アクセスキー
AWS Secret Access Key [None]: アクセスキーのシークレット
Default region name [None]: ap-northeast-1
Default output format [None]: json

ビルドする

設定していなければ、以下のように記述して「静的エクスポート」を有効化します。

next.config.ts
import type { NextConfig } from "next";
 
const nextConfig = {
  output: "export", 
  // …他の設定…
};
 
export default nextConfig;

次に、以下コマンドでビルドを行います。

$ npm run build

S3 へアップロード

上記で./outディレクトリに出力されたファイルを、以下のコマンドで S3 にアップロードします。
BUCKET_NAMEには、作成したS3バケット名を入れます。
また--deleteオプションは、ソース側(./out)に無いファイルを、S3 側から削除します。これにより、S3 の中身を ./out と完全一致させます。

aws s3 sync ./out s3://BUCKET_NAME --delete

これで、取得したドメインにアクセスすると、作成したサイトが表示されるかと思います。

動作確認

実際にアクセスしてみます。

$ curl https://miyazaki-profile.com/ -IL
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 71820
Connection: keep-alive
Date: Tue, 09 Sep 2025 15:22:47 GMT
Last-Modified: Sun, 07 Sep 2025 11:49:08 GMT
ETag: "0d1d64387c0b5c629c6e8d1b5a99d985"
x-amz-server-side-encryption: AES256
Accept-Ranges: bytes
Server: AmazonS3
X-Cache: Miss from cloudfront
Via: 1.1 ee3c32726dc221e283f04d948b7d3908.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: NRT12-P6
Alt-Svc: h3=":443"; ma=86400
X-Amz-Cf-Id: FK686AJWWNqQhR1njavf56RWujenXsbPguIYu6orObzvLH9Qme5xjA==
X-Frame-Options: DENY
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
 
$ curl https://miyazaki-profile.com/ -IL
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 71820
Connection: keep-alive
Date: Tue, 09 Sep 2025 15:23:22 GMT
Last-Modified: Sun, 07 Sep 2025 11:49:08 GMT
ETag: "0d1d64387c0b5c629c6e8d1b5a99d985"
x-amz-server-side-encryption: AES256
Accept-Ranges: bytes
Server: AmazonS3
X-Cache: Hit from cloudfront
Via: 1.1 7eb63f1cbf80baae1aabab4881c29f24.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: NRT12-P6
Alt-Svc: h3=":443"; ma=86400
X-Amz-Cf-Id: uyXWXV6cQQgOxGcVY8oj12ox7cmnJv1ewDNzGFb2cPDsXeUYYA0NwQ==
Age: 6
X-Frame-Options: DENY
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

CloudFrontのキャッシュが効いており、上記で実装した内容がしっかりと反映されているため概ね良い状態かと思います。

まとめ

AWS CDKを使用して S3 + CloudFront を用いた静的サイトを構築しました。
AWS初学者のためお盆休み丸々使ってしまいましたが、最低限の構成はできたのではないかと思います。
次回からは、メール配信(問い合わせ)のインフラ構築を行っていきますので、そちらもぜひご覧ください!

参考

[AWS CDK] 一撃でCloudFrontとS3を使ったWebサイトを構築してみた | DevelopersIO

CloudFrontとS3を使った静的Webサイトが欲しい時に

dev.classmethod.jp

OGP Image

【Route53】深夜テンションでドメイン取得しちゃったので知見を残しておく - Qiita

2025/05/28 TLDの料金変更が発表されたため、変更後の料金を追記しました。 2025/06/18 昨日、AWS re:inforce 2025 にてアップデートの発表がありました。こちらについては後ほど追記・補足をする予定です はじめに 最近、デ...

qiita.com

OGP Image

AWS CDKで構築!Next.js SSGで作った静的サイトをホスティングする - Qiita

はじめに 私は最近AWS CDKコントリビューションに入門しいくつかプルリクエストを提出しています。しかし、肝心のCDK自体をがっつり触ったことがなく、エアプの状態でコントリビュートしているような状態でした。せっかくなので何かCDKで作ってみるか〜〜と思い、簡単な静的サイ...

qiita.com

OGP Image
Share