Next.js を S3 + CloudFront にデプロイする


はじめに

Next.js をプロジェクトに採用したものの、S3 + CloudFront の構成にどうやって組み込むのかを色々苦心したので同じような悩みを持つ人のために記事に残しておきたいと思います。Vercel を使えば Next.js との相性が良いのでとても楽ですが、構成にAWS縛りがあるとか色々事情はありますよね。そこをなんとか解決していきたいと思います。

また、以下の記事にもあるように、普通にデプロイしようとすると CloudFront はサブディレクトリからルートオブジェクトを返さないので、Lambda@Edgeと統合させてわざわざindex.htmlを追加する処理を書いてあげたりしなければいけないわけです。そういう処理をしたくない方も本記事は参考になるかと思います。

https://aws.amazon.com/jp/premiumsupport/knowledge-center/cloudfront-default-root-object-subdirectory/

ゴール

  • CloudFront のドメインからページにアクセスできる
  • 直接、S3の静的ホスティングされたサイトにはアクセスできない

を今回のゴールとしてやっていきます。

やらないこと

以下については説明しません。

  • AWSのアカウント作成
  • node や npm、npx のインストール
  • 各種ソースコードの説明

Next.js の設定

プロジェクト作成

まずは、デプロイするものが無ければ話は始まりません。実際にNext.jsのプロジェクトを立ち上げて行きましょう。今回はコンポーネントを書いていくとかではなく、デプロイすることがゴールなので中身はどうでもいいです。

$ npx create-next-app --typescript

これを実行すると設定をきかれますが、今回プロジェクト名は my-next-app とかにしときましょう。また、今回はyarnを使いたいので、yarnに置き換えます。

$ rm package-lock.json
$ yarn install
$ yarn add --dev @types/node 

一旦これでローカル環境で確認してみます。

$ yarn dev


ちゃんと見れていたら、問題ありません。

ビルドの設定

続いて、ビルド設定についてやっていきます。next.config.js に、trailingSlash: true を追加してください。

この設定について詳しく

この設定を true にすることで、/pages 配下のファイルの生成時、デフォルトでは、pagename.html (about.html とか)になるんですが、index.htmlを全ページで生成してくれる様になります。加えて、例えば /about に来たリクエストを /about/ にリダイレクトしてくれます。/about/ の下に隠れた index.html があるわけですね。
https://nextjs.org/docs/api-reference/next.config.js/trailing-slash

next.config.js
module.exports = {
  reactStrictMode: true,
+ trailingSlash: true,
}

合わせて、package.json も編集します。Next.jsに備わっている静的htmlをエクスポートする機能を使うために、以下に書き換えます。next export と書けばhtmlを出力してくれます。

https://nextjs.org/docs/advanced-features/static-html-export
package.json
{
  "name": "my-next-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
-   "build": "next build",
+   "build": "next build && next export",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
  # 以下割愛

ページの修正と追加

index ページはデフォルトだと next/image なるものを使っています。しかし、これをこのままGitHub Actionsで利用しようとすると以下のようにこけます。

info  - No "exportPathMap" found in "/home/runner/work/my-next-app/my-next-app/next.config.js". Generating map from "./pages"
Error: Image Optimization using Next.js' default loader is not compatible with `next export`.
  Possible solutions:
    - Use `next start` to run a server, which includes the Image Optimization API.
    - Use any provider which supports Image Optimization (like Vercel).
    - Configure a third-party loader in `next.config.js`.
    - Use the `loader` prop for `next/image`.
  Read more: https://nextjs.org/docs/messages/export-image-api

Image Optimization using Next.js' default loader is not compatible with 'next export'
「Next.jsのデフォルトのloaderを使っている画像最適化は、next export と互換性がありません」とのことです。今回はあくまでホスティングがテーマなので、next/imageの記述は削除してしまって以下のように書き換えます。

index.tsx
import styles from '../styles/Home.module.css';

const Home = () => {
    return <div className={styles.container}>Hello World!</div>
}

export default Home;

実際にホスティングした際にindex ページ以外も見れているか確認するために、about ページも追加しときます。

$ touch pages/about.tsx
about.tsx
import styles from '../styles/Home.module.css';

const About = () => {
    return <div className={styles.container}>About Page</div>
}

export default About;

また、ローカルサーバーを立ち上げ、localhost:3000/about にアクセスした際に以下のように出ていればOKです。

GitHub Actions 用の設定

デプロイはGitHub Actionsを使って自動でやりたいので、その設定ファイルを追加します。
ルートディレクトリ配下に.github/workflowsディレクトリを作成し、ymlファイルを書いていきます。

https://docs.github.com/ja/actions/learn-github-actions/understanding-github-actions#create-an-example-workflow

$ mkdir .github
$ mkdir .github/workflows
$ touch .github/workflows/manual_deploy.yml
name: Manually deploy my-next-app
on: workflow_dispatch

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Setup node
        uses: actions/setup-node@v3
        with:
          node-version: '16'

      - name: Install Dependencies
        run: yarn install

      - name: Build
        run: yarn build

      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_ACCESS_KEY }}
        run: |
          echo "AWS s3 sync"
          aws s3 sync --region ap-northeast-1 ./out s3://${{ secrets.AWS_S3_BUCKET}} --delete
          echo "AWS CF reset"
          aws cloudfront create-invalidation --region ap-northeast-1 --distribution-id ${{ secrets.AWS_CF_ID }} --paths "/*"

はい、これでリポジトリでの作業は完了しましたので、GitHubにリポジトリ作ってpushします。
残り、ymlファイル内で使う環境変数ですが、まだ値を入れる設定をしていないので、動きません。また戻ってきて設定するとして、一旦AWS側の設定に移りたいと思います。

後で設定する環境変数たち

ACCESS_KEY_ID # AWS CLIのアクセスキーID
SECRET_ACCESS_KEY # AWS CLIのアクセスキーの値
AWS_S3_BUCKET # S3バケット名
AWS_CF_ID # CloudFrontのディストリビューションID

S3 の設定

これから、AWS側の設定をしていきます。まずはホスティングする先のS3バケットを作成して行きます。

# 一般的な設定
バケット名: my-nextjs-app-bucket
AWSリージョン: アジアパシフィック(東京)ap-northeast-1

# オブジェクト所有者
ACL有効
オブジェクト所有者: オブジェクトライター

# このバケットのブロックパブリックアクセス設定
### ブロックパブリックアクセスをオフにします
すべてのチェックボックスを外します。

# 今回はテスト用のバケットなので、これ以下は全てデフォルト設定にします。

作成が完了しました。

静的ウェブサイトホスティング

バケット作成が完了したら、静的ウェブサイトホスティング設定をしていきます。
バケットを選択し、[プロパティ]タブから、[静的ウェブサイトホスティング]>[編集]を開きます。

無効となっているのを有効に変更します。
そして、以下のように編集して、保存します。

はい、静的ウェブサイトホスティングの設定は完了しました。

実際に出来上がったURLにアクセスしてみましょう。
403 Forbidden となっていれば問題ありません。

CloudFront の設定

続いて、CloudFrontのディストリビューションを作成していきます。
設定する箇所で重要なのはオリジンの部分だけです。他は全てデフォルトのままで問題ないです。運用に乗せる必要がある場合はもちろん全部確認する必要がありますが、今回はテストなので大丈夫でしょう。

# オリジン
### 先ほど作成したS3の静的サイトホスティングのURLを入力します。
### プルダウンには出てこないので注意してください。
オリジンドメイン: http://my-nextjs-app-bucket.s3-website-ap-northeast-1.amazonaws.com
名前: my-nextjs-app-bucket.s3-website-ap-northeast-1.amazonaws.com

上記で作成します。作成が完了したら、カスタムヘッダーを追加します。
その前に、ディストリビューションを作成すると、xxxx.cloudfront.net のようにディストリビューションドメイン名が自動で作成されます。これをまずコピーしときます。そして、[オリジン]タブ>[編集]を開きます。

# ヘッダー名: 値
Referer: https://xxxx.cloudfront.net/*

上記を追加して、保存します。
デプロイには数分程度かかりますが、まだ見ることができない状態です。

S3の設定 パート2

見れるようにするための最後の手順になります。S3バケットポリシーを追加します。
バケットを選択し、[アクセス許可]タブから、[バケットポリシー]>[編集]を開きます。
以下のように記入して、保存します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::{your_bucket_name}/*",
      "Condition": {
          "StringLike": {
              "aws:Referer": "https://xxxx.cloudfront.net/*"
          }
      }
    },
    { // 拒否するポリシーは無くてもいいかもしれませんが念のため
      "Sid": "DenyWithoutCloudFront",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::{your_bucket_name}/*",
      "Condition": {
          "StringNotLike": {
              "aws:Referer": "https://xxxx.cloudfront.net/*"
          }
      }
    }
  ]
}

これでようやく見れるようになったかと思います。CloudFront のURLにアクセスしてみて、403 Forbidden404 Not Found となっていれば成功です。また、S3の静的サイトホスティングの方のURLは 403 Forbidden のままだったら CloudFront からしかアクセスできないようにするという要件が達成できています。

GitHub Actions でデプロイ

最後、デプロイします。その前に、環境変数の設定をまだしてませんでしたので完了させます。

設定する環境変数

ACCESS_KEY_ID # AWS CLIのアクセスキーID
SECRET_ACCESS_KEY # AWS CLIのアクセスキーの値
AWS_S3_BUCKET # S3バケット名
AWS_CF_ID # CloudFrontのディストリビューションID

[Settings]タブ>[Secrets]>[Actions]から、シークレットを登録します。以下のように、4つ全て登録したら完了です。

GitHub Actions を動かす

[Acitons]タブから、workflowsを選択します。(ここでは、Manually deploy my-next-app)
ブランチを選択して、走らせます。

動作確認

index ページ

about ページ

S3バケット内
about.html ではなく、about/ となってますね。trailingSlash が効いているようです。

疲れましたが、無事デプロイすることができました。✌️

疑問

1. CloudFrontのOAIを使えばもっと簡単に出来るんじゃないの?

と思った方もいると思います。実際自分もそう思いました。しかし冒頭にも述べたように、OAIを利用する場合は Lambda@Edgeを使わなくてはいけない問題が発生するんですね。これはやってみればわかります。

では仮に、 index ページと about ページを表示させたいとします。OAI設定の詳細な手順は省きますが、静的サイトホスティングのURLを指定したオリジンドメインをS3バケットを指定し、OAIを自動作成するように項目を選択するだけです。

CloudFrontはデフォルトルートオブジェクトのみ設定できるので、デフォルトルートオブジェクトに index.html を設定します。

はい、これは見れてますね。

about ページはどうでしょうか。/about とアクセスしてみましょう。だめでした。

about ページを表示するには、/about/index.html とアクセスする必要があります。/index.html を追加する処理をLambda@Edgeで補うというわけですね。

今回は、trailingSlashtrue に指定したので、/about/index.html となりますが、trailingSlash が未指定 or false の場合は、about.html という様に生成されます。ただこれも同様に、実際にアクセスされる形は /about になるので、.html をくっ付けてあげる必要があるわけですね。


2. Referer の値がバレたらS3の静的ホスティングサイトにアクセスされちゃうのでは?

その通りです。

静的ホスティングサイトのURL、CloudFrontのURL、Refererの構造がもし全て外部に漏れた場合は、アクセスされてしまいます。それをどうしても防ぎたい場合は、OAIと Lambda@Egde を利用する方法を選択する必要があるかと思います。

Postmanで叩いてみると、欲しいhtmlページが返って来ているのが確認できます。