🏌

CaddyにおけるCORS(FastAPIとNext.jsを添えて)

2024/08/22に公開

はじめに

最近流行り始めそうなWebサーバーのCaddyを使って、バックエンドへのリバースプロキシについて確認していきます。CaddyとはGoで書かれたWebサーバーで、Caddyfileを書くことでサーバーの設定することができます。

https://caddyserver.com/

加えて、フロントエンドのCORSについても確認していきます。CORSとはフロントエンドからのリクエストに対してサーバーが許可をしているかを確認するための機能です。CaddyでのCORSに関する設定を見ていきます。

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

環境

今回の環境は以下の通りです。

  • macOS: 14.5
  • Python: 3.12.4
  • Node.js: v20.15.1
  • Next.js: 14.2.5
  • Docker Desktop: 4.32.0 (157355)

事前準備

Caddyの動作確認をするために、簡単なフロントエンドとバックエンドが必要なのでそれぞれNext.jsとFastAPIで準備していきます。

バックエンド(FastAPI)の準備

今回はpoetryで環境を構築します。以下のpyproject.tomlDockerfileを用意します。

pyproject.toml
[tool.poetry]
name = "caddy-fastapi-nextjs"
version = "0.1.0"
description = ""
authors = [""]

[tool.poetry.dependencies]
python = ">=3.10,<3.13"
mangum = "^0.17.0"
fastapi = "^0.111.1"
uvicorn = {extras = ["standard"], version = "^0.30.3"}
gunicorn = "^22.0.0"
sse-starlette = "^2.1.2"

Dockerfileはpoetryからrequirements.txtを生成してインストールするようにしています。srcディレクトリはマウントするようにするのでここでCOPYはしないです。

/backend/Dockerfile
FROM python:3.12 as local

WORKDIR /opt/app/src
RUN pip install -U pip poetry==1.8.3
COPY pyproject.toml .
COPY poetry.lock .

RUN poetry export --without-hashes --only main --output requirements.txt
RUN pip install -r requirements.txt

CMD ["uvicorn", "main:app", "--reload", "--host=0.0.0.0", "--port=8080"]

次に、GET / POST / PATCHができるようにエンドポイントを作成します。最後の行でprefix="/api/v1"を指定しているのは、後述するcaddyでリバースプロキシをする際に/apiのパスを指定するためです。

/backend/src/main.py
from logging import INFO, getLogger
import os

from fastapi import APIRouter, FastAPI, Header

app = FastAPI()
api_router = APIRouter()

logger = getLogger()
logger.setLevel(INFO)

__all__ = ["app"]


@api_router.get("/user")
def get_user():
    return {"status": "success"}


@api_router.post("/user")
def post_user():
    return {"status": "success"}


@api_router.patch("/user")
def patch_user():
    return {"status": "success"}
    

app.include_router(router=api_router, prefix="/api/v1")

最後にcompose.yamlを作成します。FastAPIで、ホットリロードが効くようにvolumesを"./backend/src:/opt/app/src"でマウントするようにしています。加えて、今回はM1 Macを使っているため、platform: linux/amd64を指定しています。portsは特に理由はないですが、next.jsが3000で動くので同じ3000台のHTTPという意味をこめて3080を指定しています。

/compose.yaml
services:

  backend:
    build:
      context: .
      dockerfile: ./backend/Dockerfile
      target: local
    container_name: backend
    platform: linux/amd64
    tty: true
    ports:
      - "3080:8080"
    volumes:
      - "./backend/src:/opt/app/src"

作成したエンドポイントを叩いて、以下の結果が返ってきたらバックエンドの準備は完了です。

確認
$ curl -X GET http://localhost:3080/api/v1/user
{"status":"success"}

$ curl -X POST http://localhost:3080/api/v1/user
{"status":"success"}

$ curl -X PATCH http://localhost:3080/api/v1/user
{"status":"success"}

フロントエンド(Next.js)の準備

リポジトリ直下にて以下のコマンドを叩いてfrontendというアプリ名で初期化を進めます。

$ npx create-next-app@latest
What is your project named? frontend
(省略)

まず最初に、/frontend/app/globals.cssの中身を全削除します。
次に/frontend/app/page.tsxを次のように修正します。

/frontend/app/page.tsx
import { ClientComponent } from "./client-component";

export default function Home() {
  return (
    <main>
      <h1>test</h1>
      <ClientComponent />
    </main>
  );
}

そして、/frontend/app/client-component.tsxを作成します。このコンポーネントはユーザーからの操作を受け付けるボタンなどを実装します。buttonとFastAPIへのリクエストを実行する関数のみ定義しています。

/frontend/app/client-component.tsx
"use client"

export const ClientComponent = () => {
  const getHandler = async () => {
    const response = await fetch("http://localhost:3080/api/v1/user", {
      method: "GET",
    })
    console.log(response)
  }
  const postHandler = async () => {
    const response = await fetch("http://localhost:3080/api/v1/user", {
      method: "POST",
    })
    console.log(response)
  }
  const patchHandler = async () => {
    const response = await fetch("http://localhost:3080/api/v1/user", {
      method: "PATCH",
    })
    console.log(response)
  }
  return (
    <>
      <button onClick={getHandler}>GET</button>
      <button onClick={postHandler}>POST</button>
      <button onClick={patchHandler}>PATCH</button>
    </>
  )
}

フロントエンドをhttpsで起動できるようにpackage.jsonを一部修正します。

package.json
{
  "name": "frontend",
  "version": "0.1.0",
  "private": true,
  "scripts": {
-   "dev": "next dev",
+   "dev": "next dev --experimental-https",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  (後略)
}

/frontendにて以下のコマンドを実行したら、https://localhost:3000/ へアクセスします。

確認
$ npm run dev

以下の画面が表示されたらフロントエンドの準備は完了なので、これからテストしていきます。


作成したフロントエンドの画面

次に作成したボタンを押下して挙動を確認してみます。最初にGETボタンを押下してみると以下のような結果になります。


GETボタンを押下した時のリクエスト

GETリクエストでもCORSエラーが発生しています。特にSame Origin Policyでエラーが発生しています。そのため、Preflight Requestは発生せずにエラーが発生しています。

https://zenn.dev/tm35/articles/ad05d8605588bd
https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
https://developer.mozilla.org/ja/docs/Web/Security/Same-origin_policy

また、POSTでも同様にPreflight Requestは発生せずにSame Origin Policyでエラーが起きます。


POSTボタンを押下した時のリクエスト

ただ、PATCHの場合はPreflight Requestが発生して、いわゆるCORSエラーが出て失敗しています。


PATCHボタンを押下した時のリクエスト

特に何も設定をしていないとSame Origin PolicyエラーおよびCORSが発生することがわかりました。ここからは、Caddyを設定してオリジンが異なるフロントエンドとバックエンドで疎通ができように修正していきます。

Caddyの設定:リバースプロキシ

/caddyフォルダを新規に作成して、Caddyの設定をしていきます。

https://dev.classmethod.jp/articles/tried-https-access-with-mkcert-x-nextjs/

最初にCaddyへhttpsでアクセスできるように証明書を作成します。mkcertがない場合は適宜インストールしてください。

証明書の作成
# /caddyフォルダ以下にて実行
$ mkcert "localhost"

次にCaddyfileを作成します。{$CADDY_HOSTING_SITE_ADDRESS}はCaddyが稼働しているサーバーのアドレス(=ブラウザからアクセスするアドレス)を指定し、${API_SERVER_SITE_ADDRESS}はリバースプロキシで通す先を指定します。パス名が/api/*以下のリクエストをFastAPI側に流すようにしています。

今回はそれぞれ以下のように設定します。

  • CADDY_HOSTING_SITE_ADDRESS=https://localhost:3443: Caddyのアドレス
  • API_SERVER_SITE_ADDRESS=backend:8080: compose.yamlでのFastAPIのコンテナ名とポート
/caddy/Caddifile
{$CADDY_HOSTING_SITE_ADDRESS} {
    tls ./localhost.pem ./localhost-key.pem
    reverse_proxy /api/* {$API_SERVER_SITE_ADDRESS}
}

次にCaddyのDockerfileを作成します。

/caddy/Dockerfile
FROM caddy:latest

COPY localhost.pem .
COPY localhost-key.pem .
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--watch"]

上記のDockerfileを起動するために、compose.yamlを次のように修正します。environmentに上記でCaddyfileで指定した環境変数を受け取るようにしています。また、Caddyfileはファイルマウントすることでホットリロードが効くようにしています。

compose.yaml
services:

+ caddy:
+   container_name: caddy
+   build:
+     context: ./caddy
+   restart: unless-stopped
+   environment:
+     CADDY_HOSTING_SITE_ADDRESS: "https://localhost:3443"
+     API_SERVER_SITE_ADDRESS: "backend:8080"
+   volumes:
+     - "./caddy/Caddyfile:/etc/caddy/Caddyfile:ro"
+   ports:
+     - "3443:3443"
  backend:
    build:
      context: .
      dockerfile: ./backend/Dockerfile
      target: local
    container_name: backend
    platform: linux/amd64
    tty: true
    ports:
      - "3080:8080"
    volumes:
      - "./backend/src:/opt/app/src"

上記の設定が終わったら、再びDockerを起動して疎通確認をします。caddyで設定したアドレスhttps://localhost:3443/ を指定し、/api/*以下のリクエストのためFastAPI側に流れるようにしています。FastAPIでは/api/v1/userでリクエストを受け付けているため、処理が実行できます。

上記の状態で再びcurlを試してみます。以下のように各種エンドポイントを実行して結果が返ってきたら成功です。

確認
$ curl -X GET https://localhost:3443/api/v1/user
{"status":"success"}

$ curl -X POST https://localhost:3443/api/v1/user
{"status":"success"}

$ curl -X PATCH https://localhost:3443/api/v1/user
{"status":"success"}

curlでの疎通確認が終わったら、フロントエンドからアクセスしてみます。以下のようにURLを書き換えて実行します。

/frontend/app/client-component.tsx
"use client"

export const ClientComponent = () => {
  const getHandler = async () => {
-   const response = await fetch("http://localhost:3080/api/v1/user", { 
+   const response = await fetch("https://localhost:3443/api/v1/user", {
      method: "GET",
    })
    console.log(response)
  }
  const postHandler = async () => {
-   const response = await fetch("http://localhost:3080/api/v1/user", {
+   const response = await fetch("https://localhost:3443/api/v1/user", {
      method: "POST",
    })
    console.log(response)
  }
  const patchHandler = async () => {
-   const response = await fetch("http://localhost:3080/api/v1/user", {
+   const response = await fetch("https://localhost:3443/api/v1/user", {
      method: "PATCH",
    })
    console.log(response)
  }
  return (
    <>
      <button onClick={getHandler}>GET</button>
      <button onClick={postHandler}>POST</button>
    </>
  )
}

すると再びSame Origin Policyエラーで失敗してしまいます。先ほどと同様にGETとPOSTのどちらともPreflight Requestは飛んでいません。変わったところはスキーマがhttpからhttpsになっただけなので、当然と言えばそうなのですが・・・。


変更後のGETボタンを押下した時のリクエスト


変更後のPOSTボタンを押下した時のリクエスト

PATCHの場合は、Preflight Requestも飛んで、いわゆるCORSでエラーになっています。


PATCHボタンを押下した時のリクエスト

次からようやくCORSの設定をCaddyでします。

Caddyの設定:CORSを通す

フロントエンドから異なるオリジンへリクエストをかける場合にはCORSを満たす必要があります。

以下のGistにCaddyでの実装例がありますが、最新のCaddyだと動かないので修正します。

https://gist.github.com/ryanburnette/d13575c9ced201e73f8169d3a793c1a3

Caddyにはsnippetsという機能があり、import {スニペット名} {...引数}という形で呼び出すことが可能です。今回は、(cors_preflight)(cors)というスニペットを作成しました。それぞれのスニペットの引数https://localhost:3000はリクエスト元のNext.jsのアドレスを書いておきます。

Caddyfile
+(cors_preflight) {
+       @cors_preflight{args[0]} method OPTIONS
+       handle @cors_preflight{args[0]} {
+               header {
+                       Access-Control-Allow-Credentials "true"
+                       Access-Control-Allow-Origin "{args[0]}"
+                       Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS"
+                       Access-Control-Allow-Headers "Origin, Referer, User-Agent, Authorization, Accept, Content-Type, Access-Control-Request-Methods, Access-Control-Request-Origin"
+                       Access-Control-Max-Age "1800"
+                       defer
+               }
+               respond "" 204
+       }
+}
+
+(cors) {
+       @cors{args[0]} header Origin {args[0]}
+
+       handle @cors{args[0]} {
+               header {
+                       Access-Control-Allow-Credentials "true"
+                       Access-Control-Allow-Origin "{args[0]}"
+                       Access-Control-Allow-Headers "Origin, Referer, User-Agent, Authorization, Accept, Content-Type, Access-Control-Request-Methods, Access-Control-Request-Origin"
+                       defer
+               }
+       }
+}

{$CADDY_HOSTING_SITE_ADDRESS} {
    tls ./localhost.pem ./localhost-key.pem
+   import cors_preflight https://localhost:3000
+   import cors https://localhost:3000
    reverse_proxy /api/* {$API_SERVER_SITE_ADDRESS}
}

上記のCaddyファイルを修正して再度実行すると成功するようになります。


CORS設定後にGETボタンを押下した時のリクエスト


CORS設定後にPOSTボタンを押下した時のリクエスト

PATCHを実行した時のみ、preflight requestのOPTIONSが飛んでいることがわかります。


CORS設定後にPATCHボタンを押下した時のリクエスト

おわりに

本記事では「Caddyを軽く触ってみたい」という内容からCORSの確認をしました。今までCORSはふんわりと理解していたのですが、改めてこうしてまとめると知らない概念があったりと学びがありました。

その他の参考

https://medium.com/@devahmedshendy/traditional-setup-run-local-development-over-https-using-caddy-964884e75232

GitHubで編集を提案

Discussion