React+Fastapi+Mariadbで、Dockerを使ったフルスタックアプリを作る


- [記事の概要](#記事の概要)
- [本記事の対象者](#本記事の対象者)
- [環境などの前提条件](#環境などの前提条件)
- [フロントエンドの作成](#フロントエンドの作成)
- [バックエンドの作成](#バックエンドの作成)
- [Dockerコンテナの作成](#dockerコンテナの作成)
- [DBの作成](#dbの作成)
- [バックエンドとDBの接続](#バックエンドとdbの接続)
- [終わりに](#終わりに)

## 記事の概要

React+Fastapi+Mariadbで、Dockerを使ったフルスタックアプリを作るための記事。ローカル環境での動作確認はもちろん、サーバー（RaspberryPi）にデプロイをして動作確認も行った。

作るアプリはかなり単純で、ただ特定の文字をDBから取得してフロントに表示するというものである。

## 本記事の対象者

私自身がDockerを使ったアプリ開発の練習として作成したものであるため、Docker関連の勉強に重きを置いている。そのため、フルスタックアプリ開発自体や、React、Fastapiなどのフレームワークについての理解を深めたい方は、他の記事を参照されたい。

![Screenshot 2025-01-24 at 18.37.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3509941/03f4c2b2-6265-5b8c-906e-1dd8831740a1.png)

## 環境などの前提条件

各種ツールのバージョンや開発環境

- PC: MacBook Air M2
- OS: macOS Sonoma 14.5
- VSCode: 1.96.3
- Docker: 24.0.5
- Vite: 6.0.5
- React: 18.3.1
- python: 3.12
- Fastapi: 0.115.6
- Mariadb: 11.4.4

以下のようなファイル構成で作成する

```console
.
├── backend
│   ├── Dockerfile
│   ...
├── db
│   ├── Dockerfile
│   ...
│── frontend
│   ├── Dockerfile
│   ...
└── docker-compose.yml
```

## フロントエンドの作成

### 起動

任意のアプリ名のフォルダを作成し、その中でfrontendという名前でReactアプリを作成する。(viteの使い方については[こちら](https://ja.vite.dev/guide/))

```console
npm create vite@latest
```

- ProjectName: frontend
- framework: React
- variant: TypeScript + SWC

```console
cd frontend
npm install
npm run dev
```

これで起動画面が立ち上がればOK。[http://localhost:5173](http://localhost:5173)にアクセスして確認すると、以下のような画面が表示される。

![Screenshot 2025-01-24 at 18.40.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3509941/b7e4713d-f8e0-7183-961c-78b7f336d2b8.png)

### 編集

フロント画面においては特別な記述は行わない。が、唯一バックエンドのAPIを叩く必要はあるので、その部分と文字の表示部分だけここで実装してしまう。

`App.tsx`を以下のように編集する。

```tsx
import { useEffect, useState } from 'react';

const DEFAULT_BASE_API_URL = 'http://localhost:8080';

function App() {
  const [testWord, setTestWord] = useState<string>('');

  useEffect(() => {
    const fetchTestWord = async () => {
      const response = await fetch(`${DEFAULT_BASE_API_URL}/get-test-word`);
      const data = await response.json();
      setTestWord(data.data);
    };

    fetchTestWord();
  }, []);

  return <>Test word: {testWord}</>;
}

export default App;
```

その他、`index.css`, `App.css`など、不要なファイルやディレクトリは削除しても良い。

## バックエンドの作成

### 起動

frontendと同じ階層に、backendという名前でFastapiアプリを作成する(Fastapiの使い方については[こちら](https://fastapi.tiangolo.com/ja/))。
また、pythonを使った開発ではpoetryを使うことが多いので、ここでもpoetryを使って環境構築を行ってみる(poetryの使い方については[こちら](https://qiita.com/ksato9700/items/b893cf1db83605898d8a))。pythonのバージョンは3.12を使う（補足だが、pythonのバージョン管理はpyenvがおすすめ）。

```console
poetry new backend
cd backend
poetry add fastapi uvicorn
```

こうすると、`backend`ディレクトリの中に`backend`というディレクトリができてしまってわかりにくいので、`app`ディレクトリに変更する

```console
.
├── backend
│   ├── README.md
│   ├── backend   <-これをappに変更した
│   ├── poetry.lock
│   ├── pyproject.toml
│   └── tests
└── frontend
    ...
```

そしたら、appディレクトリの中に`main.py`を作成し、以下のように記述する。

```python
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/get-test-word")
async def root():

    return {"status": 200, "errorMsg": [], "data": "Congratulation!"}
```

以下のコマンドでサーバーを起動する。

```console
uvicorn app.main:app --reload --port 8080
```

そしたら、[http://localhost:8080/get-test-word](http://localhost:8080/get-test-word)にアクセスして、以下のようなJSONを取得できればOK。

```json
{
  "status": 200,
  "errorMsg": [],
  "data": "Congratulation!"
}
```

## Dockerコンテナの作成

ここまで作ってしまえば、frontendとbackendをどちらも起動させて[http://localhost:3000](http://localhost:3000)にアクセスすれば、バックエンドからの文字を得ることができる。ここではDockerfileをそれぞれで作成して、docker-composeで起動させることで、他のサーバーでも同じように動作できるようにする。

Dockerについての基本的な使い方について、私は以下の本を参考にした。

- [仕組みと使い方がわかる Docker＆Kubernetesのきほんのきほん (Compass Booksシリーズ)](https://www.amazon.co.jp/%E4%BB%95%E7%B5%84%E3%81%BF%E3%81%A8%E4%BD%BF%E3%81%84%E6%96%B9%E3%81%8C%E3%82%8F%E3%81%8B%E3%82%8B-Docker%EF%BC%86Kubernetes%E3%81%AE%E3%81%8D%E3%81%BB%E3%82%93%E3%81%AE%E3%81%8D%E3%81%BB%E3%82%93-Compass-Books%E3%82%B7%E3%83%AA%E3%83%BC%E3%82%BA-%E5%B0%8F%E7%AC%A0%E5%8E%9F-ebook/dp/B08T961HKP?dib=eyJ2IjoiMSJ9.HU92bcLVRwjyO3cIBYytmu4oBtO0GV47693IJX_Y9QHefiy8KmUxoccXTOpt_BkDZwfWvM7Ftn1jsXTF8MWkYRdryRNpPowlLWqh06fDEJ1MSu9dk3sXa37it9ajtmpNq-JkP-hv5Iurw52gFhhjG5zQG8oylBdR2Ja-o0XnQyCpeep6PlQUw81dmUDenC7MszSg8JiBbGV49g56bCkPW5SKumgrnAKuHeybBpX2NuncjZo08fE2WxeYMVwS6Iq5ZBad3ce-tru9IrmsHWO_Beo0DZvzHBKajw2hhfRnFGHHCLS6xyFng12kY20pCDP5bgAeHTc2ZFi82eGE6QH-9UpRf9P3RPU5UqTpEshVc7iCwoaztArT-vQQwW2Ba2bSDEFJmdgD2SDvNacdjTvZdrAAAA7IxgBwA3COvb7ACZUbz_WpLoPLpTSeKh0WmEh7.Tah-V0WSH1J0VLyD2mNOrY6y6SoVeXsWnaRAmmtH4uw&dib_tag=se&keywords=docker&qid=1737702570&sr=8-5)

また、Linuxについての基礎知識も必要になるので、以下の本も参考にした。

- [https://www.oreilly.co.jp/books/9784814400218/](https://www.oreilly.co.jp/books/9784814400218/)

### frontend

frontendディレクトリの中に`Dockerfile`を作成し、以下のように記述する。

```Dockerfile
# Build Stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Production Stage
FROM nginx:stable-alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```

概要を簡単に説明する。まずnodeのバージョンを指定して、frontendのアプリをビルドする。その後nginxのコンテナにビルドしたファイルをコピーして、80番ポートで起動するということである。

### backend

同様にbackendディレクトリの中に`Dockerfile`を作成し、以下のように記述する。

```Dockerfile
FROM python:3.12.0-bullseye
ENV PATH /root/.local/bin:$PATH
ENV PYTHONPATH=/app

WORKDIR /app

COPY pyproject.toml poetry.lock ./
COPY ./app ./app

RUN apt-get update
RUN curl -sSL https://install.python-poetry.org | python3 -
RUN poetry config virtualenvs.create false
RUN poetry install --no-root

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
```

こちらも簡単に説明すると、pythonのバージョンを指定して、pythonの起動ディレクトリを指定して、poetryを使って環境構築を行い、uvicornでサーバーを起動するということである。hostは0.0.0.0にしておくことで、外部からアクセスできるようにしている。

また、`pyproject.toml`は以下のように記述しておく。readme = "README.md"の部分を記述していると、dockerのpoetry install時にエラーが出るので、削除しておく。

```toml
[tool.poetry]
name = "app"
version = "0.1.0"
description = ""
authors = ["ユーザー名 <ユーザーのメールアドレス>"]
# readme = "README.md" <- これは削除する

[tool.poetry.dependencies]
python = "^3.12"
fastapi = "^0.115.7"
uvicorn = "^0.34.0"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

```

### docker-compose.yml

あとは同時に起動できた方が便利なので、アプリのルートディレクトリに`docker-compose.yml`を作成し、以下のように記述する。

```yml
version: '3.9'
services:
  frontend:
    container_name: docker_test_app_frontend
    build: ./frontend
    ports:
      - '3000:80'
    restart: always
  backend:
    container_name: docker_test_app_backend
    build: ./backend
    ports:
      - 8080:80
    restart: always
```

これでルートディレクトリで`docker compose up -d`を実行すると、frontendとbackendが同時に起動される。[http://localhost:3000](http://localhost:3000)にアクセスして、またCongratulation!と表示されれば成功である。

![Screenshot 2025-01-24 at 18.37.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3509941/03f4c2b2-6265-5b8c-906e-1dd8831740a1.png)

## DBの作成

最後にDBを作成する。ここではMariadbを使う。マイグレーションなど便利なツールを使う方法もあるが、今回はテーブルなどは手作業で作成する。DB自体はconfファイルとdocker-compose.ymlを使って作成できる。

ディレクトリ構成は以下のようになる。

```console
.
├── backend
│   ├── Dockerfile
│   ├── README.md
│   ├── app
│   ├── poetry.lock
│   ├── pyproject.toml
│   └── tests
├── db
│   ├── Dockerfile
│   └── my.cnf
├── docker-compose.yml
└── frontend
    ├── Dockerfile
    ...
```

### confファイル

まずはDBの設定ファイルを作成する。`db`ディレクトリの中に`my.cnf`を作成し、以下のように記述する。これはMariadbの設定ファイルとなる。

```cnf
[mysqld]
default-time-zone = 'Asia/Tokyo'
character-set-server=utf8mb4

log-bin=mysql-bin
max_binlog_size=256M
expire_logs_days=7

slow_query_log=1

[mysql]
default-character-set = utf8mb4

[client]
default-character-set=utf8mb4
```

### Dockerfile

次にDBのコンテナを作成する。`db`ディレクトリの中に`Dockerfile`を作成し、以下のように記述する。

```Dockerfile
FROM mariadb:lts

COPY ./my.cnf /etc/mysql/conf.d/my.cnf
RUN chmod 644 /etc/mysql/conf.d/my.cnf
```

やっているのは、Mariadbのイメージを使って、設定ファイルをコピーしているだけである。

### docker-compose.yml

次に`docker-compose.yml`を以下のように記述する。

```diff
version: '3.9'
services:
  frontend:
    container_name: docker_test_app_frontend
    build: ./frontend
    ports:
      - '3000:80'
    restart: always
  backend:
    container_name: docker_test_app_backend
    build: ./backend
    ports:
      - 8080:80
    restart: always
+  mariadb:
+    container_name: docker_test_app_db
+    build: ./db
+    ports:
+      - 3306:3306
+    environment:
+      MYSQL_ROOT_PASSWORD: password
+      MYSQL_USER: docker_test_user
+      MYSQL_PASSWORD: password
+      MYSQL_DATABASE: docker_test_db
+    volumes:
+      - ./db/data:/var/lib/mysql
+    restart: always
```

これでDB自体は起動できるようになったが、テーブルも何もないので、手動で作成する必要がある。以下のコマンドでDBに接続できる。

私はDBeaverを使ってDBに接続してテーブルを作成した。DBeaverの使い方については[こちら](https://qiita.com/12345/items/48f6856e32fd618ea307)を参考にしてほしい。

テーブル定義としては至極シンプルなもので、以下のようになる。正直コマンドで作成する方が早い気もする。

```mermaid
erDiagram
    docker_test_table {
        id int PK
        test_word varchar
    }
```

そして、データは以下を入れるだけで良い

- id: 1
- test_word: "Congratulation!"

これでDBの準備は完了である。

## バックエンドとDBの接続

最後にバックエンドとDBを接続するために、バックエンドに移動して編集を行う。ここでは`pymysql`を使って接続する。backendディレクトリに移動してから、以下のコマンドを実行して、`pymysql`をインストールする。

```console
poetry add pymysql
```

そして、`main.py`を以下のように編集する。

```diff
+ import pymysql.cursors
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/get-test-word")
async def root():
+    connect = pymysql.connect(
+        host="mariadb",
+        user="docker_test_user",
+        password="password",
+        db="docker_test_db",
+        charset="utf8mb4",
+        cursorclass=pymysql.cursors.DictCursor,
+    )
+
+    test_word = ""
+
+    with connect.cursor() as cursor:
+        cursor.execute("SELECT * FROM docker_test_table")
+        result = cursor.fetchall()
+        for row in result:
+            test_word = row["test_word"]

-    return {"status": 200, "errorMsg": [], "data": "Congratulation!"}
+    return {"status": 200, "errorMsg": [], "data": test_word}

```

cursorclassの設定だけ解説すると、DictCursorを指定することで、取得したデータを辞書型で取得できるようになる。

DictCursor設定しない場合は返ってくる値が以下のようにタプルになる

```python
(1, "Congratulation!")
```

しかし、DictCursorを設定することで以下のように辞書型で取得できるようになる

```python
{"id": 1, "test_word": "Congratulation!"}
```

これで全ての準備が整ったので、リビルドするために`docker compose down`してから`docker compose up --build -d`を実行して、[http://localhost:3000](http://localhost:3000)にアクセスすれば、無事にDBから取得した文字がフロントに表示されるはずである。

### 補足

フロントで設定したバックエンドのURLは、`http://localhost:8080`になっているが、たとえばサーバーにあげた際などは、サーバーのホスト名に変更すれば同じような動作を確認できる。

## 終わりに

今回はDockerを使って、RDBを使ったフルスタックアプリの開発を行なった。エラーハンドリングやテストなどは何も考えなかったので、実際の開発現場に応用する際には、それらの部分も考慮した上で実装しなければならない。

また、devcontainerを使った開発ができるようにもしたり、AWSなどの仮想環境にデプロイするCICDも用意したりと、開発環境をよくするためにできることは山ほどあるので、その辺りも考慮した上でのテンプレートを今後作成していきたい。