Cloudflare Workersで、GatsbyをS3でホストする際の問題をまるっと解決する

2021.12.23

Gatsbyで構築したサイトをS3でホストすることがありました。

S3には静的サイトホスティング機能(ウェブサイトのエンドポイント)がありますし、もしくはREST API エンドポイントでもCloudFrontと組み合わせてGatsbyのサイトをホストできますが、

ReactベースのGatsbyでは、そのままS3でホストした場合に、トップページ以外の子ページをリロードすると403エラーが返るという問題が発生します。

下記はAngularですが、同様の問題が発生する例です。

https://dev.classmethod.jp/articles/s3-cloudfront-spa-angular-403-access-denied/

なぜリロードすると403になるのか

S3の本来の機能はオブジェクトストレージです。基本的には、アクセスされた際のURLに対してそのまま対応するパスのオブジェクトを返します。

Gatsbyの場合、ユーザーは「https://example.com/child-page/」のようなURLで各ページにアクセスするのですが、S3にはこの「/child-page/」に直接対応するオブジェクトは存在しません。

GatsbyがデプロイされたS3バケットの中身をみるとわかりますが、ファイルの実態は「/child-page/index.html」という形で置かれています。そのため、ユーザーがアクセスするURLと実際に置かれているS3オブジェクトのURLに不整合が起こり、結果として403を返すということになっています。

オブジェクトがないなら404なのでは?と思いますが、403なのは、デフォルトだとユーザーにS3の権限がないからで、バケットポリシーで s3:ListBucket権限を付与すると、同じく子ページリロードの際に404が返るようになります。

(また、Gatsby自体のルーティングの仕様で、「/child-page/index.html」にアクセスすると、今度はGatsbyの404テンプレートが返るという、少々ややこしい状態です。)

CloudFrontのカスタムエラーレスポンスの設定で一部解決できるが、また別の問題が・・・

上記の問題を解決するため、多くの記事で紹介されているのが、

CloudFrontのカスタムエラーレスポンス機能を使い、403(もしくは404でも可)のときに /index.html へ転送するよう設定する

という方法です。

手っ取り早くはあるのですが、403(もしくは404)エラーを無理やり200にしているため、本当にアプリ側に存在しないページにアクセスした際にも200が返ってきてしまう気持ち悪さがあります(Gatsbyにより404ページのテンプレート自体は表示されるのですが、SEO的にもよくない)。

また、(こちらの方が大きい問題かもしれません)どうやらCloudFrontがエラーレスンポンスをカスタムする段階で、/index.html を取得する設定にしているためか、og:image などの一部HTMLタグが削除されてレスポンスしてしまうことがわかりました。

今回の解決策イメージ

上記を踏まえて、今回はCloudflare Workersに、ユーザーからS3(+CloudFront)へのリクエストの仲介に入ってもらうことにしました。

cloudflare-s3

  • ユーザーは通常通りのパスで各ページにアクセスする
  • Workersは受け取ったリクエストのパスの末尾に /index.htmlを付与してからS3にリクエストを流す。
  • WorkersがS3から受け取ったレスポンスが200であればそのままコンテンツをユーザーに返し、404であれば404テンプレートを返す

これにより、前述の問題は全て解決できます。

Workers実装

コードです。

async function handleRequest(request) {
    const req_url = new URL(request.url)
    if(req_url.pathname.match(/\..{1,15}$/) || req_url.pathname.match(/^\/api\//)){
        return fetch(request)
    }else{
        const req_url_with_html = req_url.protocol + "//" + req_url.hostname + req_url.pathname.replace(/\/$/, '') + "/index.html" + req_url.search
        const req_new = new Request(req_url_with_html, {
            headers: request.headers,
        })
        const res = await fetch(req_new)
        if(res.status === 404){ //404なら
            const res_new_404 = new Response("404: " + req_url, {
                status: 404,
                headers: request.headers,
            });
            return res_new_404
        }else{ //404でなければ
            return res
        }
    }
}

addEventListener("fetch", async event => {
  event.respondWith(handleRequest(event.request))
})

注意点

Gatsbyでは、ルートディレクトの /static のフォルダ内に置かれたファイルを公開できます(例: /static/image.png として設置したファイルは https://example.com/image.png として公開される )。画像やzipファイル、cssやjsなど静的なファイル置き場として使えます。

また /api ディレクトリもサーバレスファンクションのために利用され、オブジェクトが htmlではなくjsになります。

そのため、上記コードではそれらの条件に合致した際に、CloudFront+S3に取得しに行かずに、そのままリクエストを通しています。

構築中のアプリの構成によって、末尾に index.html をつける条件は適宜検討する必要があります。

CloudFront + S3の設定

CloudFrontとS3を使ったホスティング方法は既に記事がたくさんあるので詳細は省きますが、S3はWEBサイトエンドポイントではなく、REST API エンドポイントとOAIでCloudFrontと繋ぎます。

かつ、Cloudfrontにはカスタムエラーレスポンスは設定する必要はありません。

最後に

同様の処理はLambda@Edgeでも可能です。障害発生時に時にAWS内で調査・対応を完結したいという場合などに検討できると思います。