CaddyにおけるCORS(FastAPIとNext.jsを添えて)
はじめに
最近流行り始めそうなWebサーバーのCaddyを使って、バックエンドへのリバースプロキシについて確認していきます。CaddyとはGoで書かれたWebサーバーで、Caddyfile
を書くことでサーバーの設定することができます。
加えて、フロントエンドのCORSについても確認していきます。CORSとはフロントエンドからのリクエストに対してサーバーが許可をしているかを確認するための機能です。Caddyでの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.toml
とDockerfile
を用意します。
[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はしないです。
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
のパスを指定するためです。
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
を指定しています。
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
を次のように修正します。
import { ClientComponent } from "./client-component";
export default function Home() {
return (
<main>
<h1>test</h1>
<ClientComponent />
</main>
);
}
そして、/frontend/app/client-component.tsx
を作成します。このコンポーネントはユーザーからの操作を受け付けるボタンなどを実装します。button
とFastAPIへのリクエストを実行する関数のみ定義しています。
"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
を一部修正します。
{
"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
は発生せずにエラーが発生しています。
また、POSTでも同様にPreflight Request
は発生せずにSame Origin Policy
でエラーが起きます。
POSTボタンを押下した時のリクエスト
ただ、PATCHの場合はPreflight Request
が発生して、いわゆるCORS
エラーが出て失敗しています。
PATCHボタンを押下した時のリクエスト
特に何も設定をしていないとSame Origin Policy
エラーおよびCORS
が発生することがわかりました。ここからは、Caddyを設定してオリジンが異なるフロントエンドとバックエンドで疎通ができように修正していきます。
Caddyの設定:リバースプロキシ
/caddy
フォルダを新規に作成して、Caddyの設定をしていきます。
最初に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_HOSTING_SITE_ADDRESS} {
tls ./localhost.pem ./localhost-key.pem
reverse_proxy /api/* {$API_SERVER_SITE_ADDRESS}
}
次に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
はファイルマウントすることでホットリロードが効くようにしています。
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を書き換えて実行します。
"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だと動かないので修正します。
Caddyにはsnippetsという機能があり、import {スニペット名} {...引数}
という形で呼び出すことが可能です。今回は、(cors_preflight)
と(cors)
というスニペットを作成しました。それぞれのスニペットの引数https://localhost:3000
はリクエスト元のNext.jsのアドレスを書いておきます。
+(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はふんわりと理解していたのですが、改めてこうしてまとめると知らない概念があったりと学びがありました。
その他の参考
Discussion