🐍

FastAPI x MySQL on Docker ~ マイグレーション・API作成 ~

2020/11/05に公開

FastAPI は Python ベースのフレームワークで、その名の通り API を実装するのにうってつけです。軽量で非常に高速なフレームワークですが、FastAPI そのものには DB のシードやマイグレーションといった機能はなく、SQLAlchemyAlembic など、他のライブラリを組み合わせて使うことが前提となります。

今回は、FastAPI で MySQL(厳密には MariaDB)を用いる際に、どのような実装が必要になってくるのかを見ていきたいと思います。セットアップは Docker コンテナ上で行います。

※ 最終成果物は こちら のリポジトリにアップしています。

ファイル・ディレクトリ構成概観

まずはじめに、ファイル・ディレクトリ構成を示します。

├── .env.development
├── app
│   ├── cruds
│   │   ├── __init__.py
│   │   └── user.py
│   ├── database.py
│   ├── env.py
│   ├── main.py
│   ├── models
│   │   ├── __init__.py
│   │   ├── book.py
│   │   ├── mixins.py
│   │   └── user.py
│   ├── routers
│   │   ├── __init__.py
│   │   └── user.py
│   └── schemas
│       ├── __init__.py
│       ├── book.py
│       └── user.py
├── db
│   ├── alembic.ini
│   ├── migrations
│   │   ├── README
│   │   ├── env.py
│   │   ├── script.py.mako
│   │   └── versions
│   │       └── [hash]_create_users_books.py
│   └── seed.py
├── docker
│   ├── api
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   └── db
│       ├── Dockerfile
│       └── conf.d
│           └── my.cnf
├── docker-compose.yml
├── log
│   └── db
│       └── mysqld.log
└── scripts
    └── run.sh

DB コンテナのセットアップ

今回必要となるコンテナは FastAPI用コンテナ (api)、DB用コンテナ (db) の2つです。まずは db からセットアップしていきましょう。実際の構築には Docker Compose を用います。本セクションで扱うファイルは以下の通りです。

├── .env.development
├── docker
│   └── db
│       ├── Dockerfile
│       └── conf.d
│           └── my.cnf
└── docker-compose.yml

まずは docker-compose.yml の中身を見てみましょう。

version: "3.7"

services:
  # api: APIコンテナの記述は後ほど
  db:
    image: "fastapi_starter_db:0.1.0"
    container_name: "fastapi_starter_db"
    build:
      context: ./docker/db
      dockerfile: Dockerfile
    restart: always
    tty: true
    expose:
      - "3306"
    volumes:
      - ./docker/db/conf.d:/etc/mysql/conf.d:cached
      - ./log/db:/var/log/mysql:cached
    networks:
      - fastapi_network
    environment:
      APP_ENV: "development"
      TZ: "Asia/Tokyo"
    env_file:
      - .env.development

networks:
  default:
    external:
      name: bridge
  fastapi_network:
    name: fastapi_network
    driver: bridge
    external: true

apidb コンテナをつなぐために(そして今後更に別のコンテナから接続することも考えて)、ネットワークの設定を行っています。下記のコマンドを実行して、事前にネットワークの作成を行ってください。一度だけ実行すれば大丈夫です。

$ docker network create fastapi_network

また、DBの設定関連を .env.development で行い、docker-compose.yml でファイルを読み込むようにしています。.env.development の中身は下記の通りです。

MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=password
MYSQL_HOST=db
MYSQL_DATABASE=db

PYTHONPATH=/usr/src/app/app

userpassword は何でも良いですが、プロダクション環境の設定は git に含めないように注意しましょう。また、PYTHONPATH はAPI実装で必要になります(import 用のパスを通す)。

Dockerfile の中身は次の通りです。MySQL互換の MariaDB を用います。

FROM mariadb:10.3

RUN apt update \
    && apt install --no-install-recommends -y tzdata \
    && apt clean

RUN touch /var/log/mysql/mysqld.log

API コンテナのセットアップ

続いて、api コンテナのセットアップをしていきましょう。利用するファイルは以下の通りです。

├── .env.development
├── app
│   └── main.py
├── docker
│   ├── api
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   └── db
│       ├── Dockerfile
│       └── conf.d
│           └── my.cnf
├── docker-compose.yml
└── scripts
    └── run.sh

docker-compose.yml の中身を見てみましょう。先ほどの内容に api 部分を追記します。

version: "3.7"

services:
  api:
    image: "fastapi_starter:1.0.0"
    container_name: "fastapi_starter"
    depends_on:
      - db
    build:
      context: ./docker/api
      dockerfile: Dockerfile
    ports:
      - "8888:8000"
    volumes:
      - ./app:/usr/src/app/app:cached
      - ./db:/usr/src/app/db:cached
      - ./scripts:/usr/src/app/scripts:cached
    working_dir: /usr/src/app/scripts
    command: bash -c "./run.sh"
    networks:
      - fastapi_network
    environment:
      APP_ENV: "development"
      TZ: "Asia/Tokyo"
    env_file:
      - .env.development
  db:
    # 省略

networks:
  # 省略

こちらの Dockerfilepython コンテナをベースにしています。また、api コンテナから MySQL に接続するため、MySQLクライアントをインストールしています。Dockerfile および requirements.txt の中身は以下の通りです。

# Dockerfile

FROM python:3.8

RUN apt update \
    && apt install -y default-mysql-client \
    && apt install --no-install-recommends -y tzdata \
    && apt clean

WORKDIR /usr/src/app
ADD requirements.txt .
RUN pip install -U pip \
    && pip install --trusted-host pypi.python.org -r requirements.txt
# requirements.txt

alembic==1.4.3
fastapi==0.61.1
# gunicorn==20.0.4
mysqlclient==2.0.1
pytest==6.1.2
SQLAlchemy==1.3.20
sqlalchemy_utils==0.36.8
uvicorn==0.12.2

requirements.txt 内の gunicorn は、プロダクション環境でサーバー立ち上げの際に用いるパッケージになります(uvicorn でも問題なく動くには動くのですが、基本は開発環境向けです)。プロダクション環境用の設定関連は、また別の機会に紹介したいと思います。

api コンテナでは、起動時に下記の通り ./run.sh を実行しています。

command: bash -c "./run.sh"

この run.sh の中身は下記の通りです。

#!/bin/bash

echo "Waiting for mysql to start..."
until mysql -h"$MYSQL_HOST" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" &> /dev/null
do
    sleep 1
done

cd /usr/src/app/app && uvicorn main:app --reload --port=8000 --host=0.0.0.0

ここでは、API サーバーのDB接続エラーを回避するための処理を記述しています。docker-compose.ymldepends_on: db を記述していますが、コンテナが立ち上がってから MySQL が起動するまでにタイムラグがあるため、until - do - done 内で、MySQLの起動を確実に待ってからサーバー立ち上げをするようにしています。

もしデータベースを用いず、db コンテナが不要ならば、command: の部分は下記のサーバー起動だけで問題ありません。

command: "uvicorn main:app --reload --port=8000 --host=0.0.0.0"

まだモデルの実装は行っていないので DB を用いることはないのですが、現時点で main.py を次のように実装し、docker-compose up を実行すると、2つのコンテナが無事に起動するのが確認できるかと思います。

from fastapi import FastAPI

app = FastAPI()


@app.get('/')
async def root():
    return {'message': 'Hello World'}

ホストのポートは 8888 に設定してあるので、サーバー起動後に http://localhost:8888/docs にアクセスすると、以下のようなAPI一覧の画面が表示されるかと思います。

各APIセクション右上の Try it out をクリックし、Execute をクリックすると、APIを叩いた結果を確認することができます。

モデルの実装

※ このセクションは公式チュートリアルも見ながら進めると理解が深まるかと思います。

さて、ここからは API の実装を考えていきます。まずはモデルの実装です。今回は User クラスと Book クラスを準備し、UserBook は 一対多 の関係にあるものとします(図書館の本を誰が借りているか、という状況を考えるとイメージしやすいかもしれません)。 UseruuidusernameBookuuidtitle を持ち、また両者ともにタイムスタンプ(created_at, updated_at)を持つとします。

実装していくのは以下のファイルとなります。

└── app
    ├── database.py
    ├── env.py
    └── models
        ├── __init__.py
        ├── book.py
        ├── mixins.py
        └── user.py

モデル定義自体は models/ にありますが、実装には env.py および database.py が必要となるため、まずは env.py を見てみましょう。こちらは API から DB に接続するための環境変数を読み込んでいるファイルとなります。

import os

APP_ENV = os.environ.get('APP_ENV')

DB_USER = os.environ.get('MYSQL_USER')
DB_PASSWORD = os.environ.get('MYSQL_PASSWORD')
DB_HOST = os.environ.get('MYSQL_HOST')
DB_NAME = os.environ.get('MYSQL_DATABASE')

続いて database.py です。こちらは実際に DB に接続するための実装を行っています。最後に定義している get_db が、各API実装から呼び出されることになります。

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session
from env import DB_USER, DB_PASSWORD, DB_HOST, DB_NAME

DATABASE = 'mysql://%s:%s@%s/%s?charset=utf8' % (
    DB_USER,
    DB_PASSWORD,
    DB_HOST,
    DB_NAME,
)

engine = create_engine(
    DATABASE,
    encoding='utf-8',
    echo=True
)

# 実際の DB セッション
SessionLocal = scoped_session(
    sessionmaker(
        autocommit=False,
        autoflush=False,
        bind=engine
    )
)

Base = declarative_base()
Base.query = SessionLocal.query_property()


# Dependency Injection用
def get_db():
    try:
        db = SessionLocal()
        yield db
    finally:
        db.close()

この database.py 内で定義している Base がモデルの定義で必要になってきます。では、実際のモデルの実装を見ていきましょう。user.py および book.py の中身はそれぞれ下記のようになります。

# user.py

from database import Base
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from uuid import uuid4
from .mixins import TimestampMixin  # 後述


class User(Base, TimestampMixin):
    __tablename__ = 'users'

    uuid = Column(UUIDType(binary=False),
                  primary_key=True,
                  default=uuid4)
    username = Column(String(128), nullable=False)

    # リレーション設定
    books = relationship(
        'Book',
        back_populates='user'
    )
# book.py

from database import Base
from sqlalchemy import Column, ForeignKey, String
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from uuid import uuid4
from .mixins import TimestampMixin  # 後述


class Book(Base, TimestampMixin):
    __tablename__ = 'books'

    uuid = Column(UUIDType(binary=False),
                  primary_key=True,
                  default=uuid4)
    title = Column(String(256), nullable=False)

    # リレーション設定
    user_id = Column(
        UUIDType(binary=False),
        ForeignKey('users.uuid'),
        nullable=True
    )
    user = relationship(
        'User',
        back_populates='books'
    )

両者でインポートしている TimestampMixin は、mixins.py に以下のように実装しています。

# mixins.py

from sqlalchemy import Column, text
from sqlalchemy.dialects.mysql import TIMESTAMP as Timestamp


class TimestampMixin(object):
    created_at = Column(Timestamp, nullable=False,
                        server_default=text('current_timestamp'))
    updated_at = Column(Timestamp, nullable=False,
                        server_default=text('current_timestamp on update current_timestamp'))

モデルの実装は以上ですが、マイグレーションを考える場合、__init__.py にモデルクラスをまとめてインポートしておくと、後々の実装が楽になります(ファイル内の noqa: F401 は Lint 用の記述です)。

# __init__.py

from database import Base  # noqa: F401

from .book import Book  # noqa: F401
from .user import User  # noqa: F401

モデルクラスの実装はできましたが、これだけではDBに反映されるわけではないので、別途マイグレーションの設定が必要になります。続いて、この設定方法について見ていきます。

マイグレーション設定

冒頭で触れたように、FastAPI自体にマイグレーション機能はなく、Alembic を用いることになります。セットアップするためには、コンテナ内で作業をしていく必要があります。下記でコンテナ内に入ります。

$ docker-compose run api bash

ホストのプロジェクトルートはコンテナ内では /usr/src/app/ に対応しており、DB関連は /usr/src/app/db/ まとめていきます。

# cd /usr/src/app/db

ディレクトリに移動したら、下記を実行することで設定ファイルが生成されます。

# alembic init migrations
└── db
    ├── alembic.ini
    └── migrations
        ├── README
        ├── env.py
        ├── script.py.mako
        └── versions

db ディレクトリ以下に、alembic.ini および migrations ディレクトリが生成されているのを確認してください。alembic.ini および migrations/env.py を編集することで、マイグレーションができるようになります。

まずは alembic.ini ですが、下記のように sqlalchemy.url 部分をコメントアウトします。

# alembic.ini

# A generic, single database configuration.

[alembic]

...

# 下行をコメントアウト(該当部分は env.py に移動する)
# sqlalchemy.url = driver://user:pass@localhost/dbname

[post_write_hooks]

...

続いて env.py です。こちらはいくつか追加の設定が必要です。下記コード内にコメントで書いていますが、特に uuid を使いたい場合、デフォルトでは alembic はマイグレーションに対応していませんので、自分で関数 (render_item) を実装する必要があります。

# migrations/env.py

# インポート追加
import os
import sqlalchemy_utils
import models
# インポート追加ここまで

...

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# DB接続用設定追加
# connect to database
DB_USER = os.environ.get('MYSQL_USER')
DB_PASSWORD = os.environ.get('MYSQL_PASSWORD')
DB_ROOT_PASSWORD = os.environ.get('MYSQL_ROOT_PASSWORD')
DB_HOST = os.environ.get('MYSQL_HOST')
DB_NAME = os.environ.get('MYSQL_DATABASE')

DATABASE = 'mysql://%s:%s@%s/%s?charset=utf8' % (
    DB_USER,
    DB_PASSWORD,
    DB_HOST,
    DB_NAME,
)
config.set_main_option('sqlalchemy.url', DATABASE)
# 設定追加ここまで

# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = models.Base.metadata  # 追加

...

# UUIDのマイグレーション対応用関数追加
def render_item(type_, obj, autogen_context):
    """Apply custom rendering for selected items."""

    if type_ == 'type' and isinstance(
            obj, sqlalchemy_utils.types.uuid.UUIDType):
        autogen_context.imports.add("import sqlalchemy_utils")
        autogen_context.imports.add("import uuid")
        return "sqlalchemy_utils.types.uuid.UUIDType(binary=False), default=uuid.uuid4"

    return False
# 関数追加ここまで

def run_migrations_offline():
    ...  # 変更なし

def run_migrations_online():
    ...

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata,
            render_item=render_item  # 追加
        )

        with context.begin_transaction():
            context.run_migrations()

...

これでマイグレーション用の設定は完了です。/usr/src/app/db 上で、下記を実行しましょう。

# alembic revision --autogenerate -m 'create users, books'

これにより、モデルファイルの構成が読み込まれ(env.py に記述した models.Base.metadata の部分)、現状のDBのテーブルとの差分から自動でマイグレーションファイルが db/migrations/versions/ 以下に生成されます。

└── db
    └── migrations
        └── versions
            └── 402b2dfeb53e_create_users_books.py

これ以降も、モデルファイルを変更し、マイグレーションを行いたいタイミングで alembic revision --autogenerate -m 'コメント' を実行することで、versions/ 以下にファイルが追加されていきます。

そして、実際にマイグレーションをする場合は下記を実行します。

# alembic upgrade head

複数のマイグレーションファイルが生成されており、1つずつマイグレーションしたい場合は、

# alembic upgrade +1

を実行します。また、反対にマイグレーションを1つ取り消したい場合は、

# alembic downgrade -1

すべて取り消したい場合は

# alembic downgrade base

となります。

このように、マイグレーション自体は手動で行うこととなりますが、自動で行うようにしたい場合は、scripts/run.sh を以下のように書き換えると、コンテナ起動時にマイグレーションが実行されるようになります。

#!/bin/bash

echo "Waiting for mysql to start..."
until mysql -h"$MYSQL_HOST" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" &> /dev/null
do
    sleep 1
done

# 自動マイグレーションを追加
cd /usr/src/app/db && alembic upgrade head


cd /usr/src/app/app && uvicorn main:app --reload --port=8000 --host=0.0.0.0

マイグレーション関連の実装は以上です!

シード設定

シードを行いたい場合は、自分で Python スクリプトを書くことになります。以下のように、db/seed.py を作成して、そこに実装をしていきましょう。

└── db
    └── seed.py

今回のシードデータは User 1人、Book 3冊で、すべての Book はそのひとりの User が所持しているものとします。

# seed.py

from database import SessionLocal
from models import Book, User

db = SessionLocal()


def seed():
    book_titles = [
        '深層学習教科書 ディープラーニング G検定(ジェネラリスト) 公式テキスト',
        '詳解ディープラーニング',
        'PythonとKerasによるディープラーニング'
    ]
    books = [Book(title=title) for title in book_titles]

    user = User(username='yusugomori')
    user.books = books  # リレーションも丸ごと表現できる

    db.add(user)
    db.commit()


if __name__ == '__main__':
    BOS = '\033[92m'  # 緑色表示用
    EOS = '\033[0m'

    print(f'{BOS}Seeding data...{EOS}')
    seed()

シードもコンテナ内で実行します。下記の手順でシードを行うことができます。

$ docker-compose run api bash
# cd /usr/src/app/db
# python seed.py

シードも自動で行いたい場合、マイグレーションの時と同様、run.sh に記述するなどすると良いでしょう。

APIの実装

ここまででデータ関連の実装はすべて終えたので、最後に API の実装をしてデータの取得ができるか確認してみましょう。User の Read API(Index, Detail)のみ実装してみます。

実装方法は様々ですが、下記の構成にすると、コードの可読性が高まるかと思います。

└── app
    ├── cruds
    │   ├── __init__.py
    │   └── user.py
    ├── database.py
    ├── env.py
    ├── main.py
    ├── models
    │   ├── __init__.py
    │   ├── book.py
    │   ├── mixins.py
    │   └── user.py
    ├── routers
    │   ├── __init__.py
    │   └── user.py
    └── schemas
        ├── __init__.py
        ├── book.py
        └── user.py

新しく追加されたのは cruds, routers, schemas です。それぞれ、以下の役割を担います。

cruds:    実際にDBとやりとりを行う
routers:  URLの定義および cruds の呼び出し
schemas:  Response の型定義

まずは cruds/user.py を見てみましょう。DB からデータを読み込む実装を行っています(例外処理などは簡略化しています)。

# cruds/user.py

from fastapi import HTTPException
from models import User
from sqlalchemy.orm import Session
from starlette.status import HTTP_404_NOT_FOUND
from uuid import UUID


def read_users(db: Session):
    items = db.query(User).all()
    return items


def read_user(db: Session, user_id: UUID):
    try:
        item = db.query(User).get(user_id)
    except BaseException:
        raise HTTPException(status_code=HTTP_404_NOT_FOUND,
                            detail='Record not found.')

    return item

続いて、この cruds を呼び出す routers/user.py の実装です。 User API の URL は /users/users/:user_id などが想定されますが、接頭辞の /users は後ほどまとめて設定できるので、ここでは接頭辞以降のみを記述すれば問題ありません。

# routers/user.py

import cruds.user as crud
from database import get_db
from fastapi import APIRouter, Depends
# schemas の中身は後述
from schemas.user import \
    User as UserSchema, UserDetail as UserDetailSchema
from sqlalchemy.orm import Session
from typing import List
from uuid import UUID

router = APIRouter()


# URL は /users 以降のみを書けばOK
@router.get('/', response_model=List[UserSchema])
async def read_users(db: Session = Depends(get_db)):
    return crud.read_users(db=db)


@router.get('/{user_id}', response_model=UserDetailSchema)
async def read_user(user_id: UUID, db: Session = Depends(get_db)):
    return crud.read_user(user_id=user_id, db=db)

各 API で response_model= を指定していますが、ここでデータのどの値を返すかを細かく指定することができます。逆に、これを指定しないと、リレーション込みでデータを返すことができないなど、不便な点が発生します。中身に関しては schemas/user.py, schemas/book.py で定義しています。実装は以下の通りです。

# schemas/user.py

# from datetime import datetime
from typing import List
from pydantic import BaseModel
from uuid import UUID
from .book import Book


class User(BaseModel):
    uuid: UUID
    username: str
    # 返す必要のない値は記述しなければ
    # レスポンスから切り捨てられる
    # created_at: datetime
    # updated_at: datetime

    # リレーションを張っている場合は
    # 書かないとバリデーションエラーになる
    class Config:
        orm_mode = True


class UserDetail(User):
    books: List[Book] = []
# schemas/book.py

from pydantic import BaseModel
from uuid import UUID


class Book(BaseModel):
    uuid: UUID
    title: str

    class Config:
        orm_mode = True

最後に、先ほど実装した routers/user.pymain.py で読み込み、ルーティングの設定をすれば実装は完了です!

# main.py

from fastapi import FastAPI, APIRouter
from routers.user import router as user_router

router = APIRouter()
router.include_router(
    user_router,
    prefix='/users',
    tags=['users']
)

app = FastAPI()
app.include_router(router)

以上を実装し終えたら、再度 docker-compose up でコンテナを起動した後(またはすでに起動済ならばそのまま)、http://localhost:8888/docs にアクセスすると、User APIs が追加されているのが確認でき、それぞれ実行してみると、期待通りのレスポンスが得られることが分かります。

長くなりましたが、今回の内容は以上になります。FastAPI は非常に簡単に API が実装できるフレームワークになっていますので、ぜひ活用してみてください!

Discussion