20201024のdockerに関する記事は17件です。

Dockerを組み込むために参考にした記事

参考にした記事

DockerをMacにインストールする
https://qiita.com/kurkuru/items/127fa99ef5b2f0288b81

はじめてのDocker 導入から開発の流れまで
https://qiita.com/m-dove/items/173d08a5d8d910e10283

最初はわからなさすぎたのでYouTubeなども見ましたー。

もう環境構築で悩まない!Dockerを使ってRails環境構築!
https://www.youtube.com/watch?v=BZS8AHF3TTo&t=503s

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FastAPI + SQLAlchemy(postgresql)でAPI実装してみた(Dockerやミドルウェア, テストコードなども含む)

アーキテクチャ

python: v3.8.5
postgresql: v12.4
fastapi: v0.60.2
SQLAlchemy: v1.3.18

マイグレーションツール

alembic: v1.4.2

FastAPIとは

詳細は既にQiitaにチョコチョコ記事あるので割愛します。
 
ドキュメントが豊富(読みきれてない)で、Swaggerと互換性あるのがとても素晴らしいです。
(パフォーマンスも良いらしいがベンチマーク測ったことないので断言できない、でも多分速い。)
 
しっかり触ったことのあるフレームワークはDjangoだけなのでミドルウェアやテストコード周り苦労した・・・。
(Djangoはフルスタックフレームワークだからミドルウェアとかあまり気にしたことない)

Python3.8.5の仮装環境作成

pyenv, virtualenvが入ってない場合は下記を参考に入れてください。
Mac
Windows

$ pyenv virtualenv 3.8.5 env_fastapi_sample

仮装環境を適用

プロジェクトルートを作成

$ mkdir fastapi_sample

プロジェクトルートにcd

$ cd fastapi_sample

仮装環境を適用

$ pyenv local env_fastapi_sample

requirements.txt作成/インストール

$ touch requirements.txt

requirements.txtにfastapiを追記

fastapi_sample/requirements.txt
fastapi==0.60.2
uvicorn==0.11.8

仮装環境にインストール

$ pip3 install -r requirements.txt

エントリーポイント(main.py)を作成して、Swagger-UIを表示してみる

エントリーポイント(main.py)作成

$ touch main.py

main.pyを編集

fastapi_sample/main.py
from fastapi import FastAPI

app = FastAPI()


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

Swagger-UI表示

$ uvicorn main:app --reload

ブラウザから「http://127.0.0.1:8000」にアクセスして{"message":"Hello World"}が表示されていれば完了です。

Docker化

Nginxのリバースプロキシによってアプリケーションをhttpsで公開するようにします。

ベースのdocker-compose.yml

fastapi_sample/docker-compose.yml
version: '3'
services:
# Nginxコンテナ
  nginx:
    container_name: nginx_fastapi_sample
    image: nginx:alpine
    depends_on:
      - app
      - db
    environment:
      TZ: "Asia/Tokyo"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - ./docker/nginx/ssl:/etc/nginx/ssl

# アプリケーションコンテナ
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: app_fastapi_sample
    volumes:
      - '.:/fastapi_sample/'
    environment:
      - LC_ALL=ja_JP.UTF-8
    expose:
      - 8000
    depends_on:
      - db
    entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample
    command: bash /fastapi_sample/docker/rundevserver.sh
    restart: always
    tty: true

# DBコンテナ
  db:
    image: postgres:12.4-alpine
    container_name: db_fastapi_sample
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=db_fastapi_sample
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --locale=C
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

volumes:
  db_data:
    driver: local

Nginxコンテナの設定

confファイルを用意する

$ mkdir -p nginx/conf.d
$ touch nginx/conf.d/app.conf
fastapi_sample/docker/nginx/conf.d/app.conf
upstream backend {
    server app:8000;  # appはdocker-compose.ymlの「app」
}

# 80番ポートへのアクセスは443番ポートへのアクセスに強制する
server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    ssl_certificate     /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;
    ssl_protocols        TLSv1.2 TLSv1.3;

    location / {
        proxy_set_header    Host    $http_host;
        proxy_set_header    X-Real-IP    $remote_addr;
        proxy_set_header    X-Forwarded-Host      $http_host;
        proxy_set_header    X-Forwarded-Server    $http_host;
        proxy_set_header    X-Forwarded-Server    $host;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
        proxy_redirect      http:// https://;

        proxy_pass http://backend;
    }

    # ログを出力したい場合はコメントアウト外してください
    # access_log /var/log/nginx/access.log;
    # error_log /var/log/nginx/error.log;
}

server_tokens off;

NginxのSSL化に必要なファイルを用意

$ mkdir -p nginx/conf.d nginx/ssl

OpenSSLを使って秘密鍵(server.key)を生成する

# ディレクトリ移動
$ cd nginx/conf.d

# 秘密鍵生成
$ openssl genrsa 2024 > server.key

# 確認
$ ls -ll
total 8
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

証明書署名要求(server.csr)を生成

$ openssl req -new -key server.key > server.csr
$ openssl req -new -key server.key > server.csr
...
..
.
Country Name (2 letter code) [AU]:JP  # 国を示す2文字のISO略語
State or Province Name (full name) [Some-State]:Tokyo  # 会社が置かれている都道府県
Locality Name (eg, city) []:Chiyodaku  # 会社が置かれている市区町村
Organization Name (eg, company) [Internet Widgits Pty Ltd]:fabeee  # 会社名
Organizational Unit Name (eg, section) []:-  # 部署名(ハイフンにしました。)
Common Name (e.g. server FQDN or YOUR name) []:localhost(ウェブサーバのFQDN。一応localhostにした)
Email Address []:  # 未入力でエンター

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:  # 未入力でエンター
An optional company name []:  # 未入力でエンター
...
..
.
$ ls -ll
total 16
-rw-r--r--  1 user  staff   980 10 24 13:27 server.csr  # 証明書署名要求ができた
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

サーバ証明書(server.crt)を生成

openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt

% ls -ll
total 24
-rw-r--r--  1 tabata  staff  1115 10 24 13:40 server.crt  # サーバ証明書ができた
-rw-r--r--  1 tabata  staff   948 10 24 13:29 server.csr
-rw-r--r--  1 tabata  staff  1647 10 24 13:23 server.key

サーバ証明書を信頼する

server.crtをダブルクリック
image.png

「この証明書を使用するとき」のプルダウンから「常に信頼する」を選択する
image.png

最終的にこうなっていればNginxの設定は完了です

fastapi_sample
├── docker
│   └── nginx
│       ├── conf.d
│       │   └── app.conf
│       └── ssl
│           ├── server.crt
│           ├── server.csr
│           └── server.key
├── docker-compose.yml
├── main.py
└── requirements.txt

app(FastAPI)コンテナの設定

Dockerfile

イメージはubuntu20.04です。
・pyenvでコンテナ内のpythonバージョンをv3.8.5にしている
・apt installで必要なモジュールをインストール(不要なものは削ってもらって構いません)
pip3 install -r requirements.txtでpythonモジュールをコンテナ内にインストール

FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive
ENV HOME /root
ENV PYTHONPATH /fastapi_sample/
ENV PYTHON_VERSION 3.8.5
ENV PYTHON_ROOT $HOME/local/python-$PYTHON_VERSION
ENV PATH $PYTHON_ROOT/bin:$PATH
ENV PYENV_ROOT $HOME/.pyenv

RUN mkdir /fastapi_sample \
    && rm -rf /var/lib/apt/lists/*

RUN apt update && apt install -y git curl locales python3-pip python3-dev python3-passlib python3-jwt \
    libssl-dev libffi-dev zlib1g-dev libpq-dev postgresql

RUN echo "ja_JP UTF-8" > /etc/locale.gen \
    && locale-gen

RUN git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \
    && $PYENV_ROOT/plugins/python-build/install.sh \
    && /usr/local/bin/python-build -v $PYTHON_VERSION $PYTHON_ROOT

WORKDIR /fastapi_sample
ADD . /fastapi_sample/
RUN LC_ALL=ja_JP.UTF-8 \
    && pip3 install -r requirements.txt

wait-for.it.sh

データベースが立ち上がるのを待ってからappコンテナを起動するようにするためのシェルスクリプトです。
 
docker-componseに記載しているdepends_onコンテナの起動順は制御できますが、
データベースが立ち上がっていない場合、appコンテナでエラーが発生することがあります。
(appコンテナ起動時にDB操作をするようなコマンドを実行しようとした場合、データベースが立ち上がっていないためにエラー、とか)

fastapi_sample/docker/wait-for-it.sh
#!/bin/sh

set -e

# 引数はdocker-compose.ymlで指定している。
# app:
#   ...
#   ..
#   .
#   entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample  # ココ
#   ...
host="$1"
shift
port="$1"
shift
user="$1"
shift
password="$1"
shift
database="$1"
shift
cmd="$@"

echo "Waiting for postgresql"
until pg_isready -h"$host" -U"$user" -p"$port" -d"$database"
do
  echo -n "."
  sleep 1
done

>&2 echo "PostgreSQL is up - executing command"
exec $cmd

rundevserver.sh

uvicornを起動してアプリケーションを起動するためのシェルスクリプトファイルです。
docker-compose up時に毎回requirements.txtのモジュールを読み込む
・uvicornでアプリケーションを起動する
・--reloadでホットリロード(pythonファイルを変更すると即反映してくれる)
・--portを8000から変える場合はnginxのapp.conf内の8000も変える必要がある

pip3 install -r requirements.txt

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

最終的なディレクトリ構成

fastapi_sample
├── Dockerfile
├── docker
│   ├── nginx
│   │   ├── conf.d
│   │   │   └── app.conf
│   │   └── ssl
│   │       ├── server.crt
│   │       ├── server.csr
│   │       └── server.key
│   ├── rundevserver.sh
│   └── wait-for-it.sh
├── docker-compose.yml
├── main.py
└── requirements.txt

コンテナ起動

$ docker-compose up -d

こんなエラーが出た場合は、Dockerイメージがディスクを逼迫している可能性があるので、docker image pruneで不要なイメージを削除してください。(僕はこれで40GB分ディスクに空きができました?)

.
..
...
Get:1 http://security.debian.org/debian-security buster/updates InRelease [65.4 kB]
Get:2 http://deb.debian.org/debian buster InRelease [122 kB]
Get:3 http://deb.debian.org/debian buster-updates InRelease [49.3 kB]
Err:1 http://security.debian.org/debian-security buster/updates InRelease
  At least one invalid signature was encountered.
Err:2 http://deb.debian.org/debian buster InRelease
  At least one invalid signature was encountered.
Err:3 http://deb.debian.org/debian buster-updates InRelease
  At least one invalid signature was encountered.
Reading package lists...
W: GPG error: http://security.debian.org/debian-security buster/updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://security.debian.org/debian-security buster/updates InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster-updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster-updates InRelease' is not signed.
...
..
.

ブラウザからアクセス

コンテナの起動が完了したか確認します。
STATUS列が"Up..."となっていればOKです。

$ docker ps
CONTAINER ID        IMAGE                  COMMAND                  CREATED              STATUS              PORTS                                      NAMES
b77fa2465fb1        nginx:alpine           "/docker-entrypoint.…"   About a minute ago   Up About a minute   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp   nginx_fastapi_sample
eb18438efd2c        fastapi_sample_app     "/fastapi_sample/doc…"   About a minute ago   Up About a minute   8000/tcp                                   app_fastapi_sample
383b0e46af68        postgres:12.4-alpine   "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:5432->5432/tcp                     db_fastapi_sample

https://localhostにアクセスして、{"message":"Hello World"}が表示されれば完了です。

ライブラリ「pydantic」を使用して環境変数(.env)を扱う

.envファイル作成

$ touch .env
fastapi_sample/.env
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample

「pydatic」を使って環境変数を読み込む設定ファイルを作成

ライブラリ「pydantic」を使うと、.env から値を読み込む処理を簡単に実装できます。
また型に合わせてキャストしてくれたりするので超便利。
os.getenv('DEBUG') == 'True' みたいな気持ち悪い条件式を書かなくてよくなります。(distutils.util.strtobool使えって話ですが、、、)

pydanticをインストール

fastapi_sample/requirements.txt
pydantic[email]==1.6.1  # 追記したらpip3 install

フォルダ「core」を作成、その直下に「config.py」を作成

$ mkdir core
$ touch core/config.py
fastapi_sample/core/config.py
import os
from functools import lru_cache
from pydantic import BaseSettings

PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool  # .envから読み込んだ値をbool型にキャッシュ
    database_url: str

    class Config:
        env_file = os.path.join(PROJECT_ROOT, '.env')


@lru_cache
def get_env():
    """ 「@lru_cache」でディスクから読み込んだ.envの結果をキャッシュする
    """
    return Environment()

alembic(マイグレーションツール)環境の用意 と モデルを用意

何はともあれインストール

requirements.txtに alembic と sqlalchemy 追記してからpip3 install -r requirements.txtでインストール
(Dockerの手順を踏んでいる人はdocker-compose restart app または docker-compose exec app pip3 install -r requiements.txt

fastapi_samepl/requirements.txt
alembic==1.4.2  # 追記
.
..
...
psycopg2==2.8.6  # 追記
.
..
...
SQLAlchemy==1.3.18  # 追記
SQLAlchemy-Utils==0.36.8  # 追記

プロジェクトルート直下にalembic環境を作成する

$ alembic init migrations  # Dockerの手順を踏んだ人はdocker-compose exec app alembic init migrations

プロジェクトルートにalembicテンプレートが作成されます。(alembic.ini, migrationsフォルダ)

fastapi_sample(プロジェクトルート)
├── ...
├── ..
├── .
├── alembic.ini
├── migrations
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions
└── ...

ベースモデルを用意

modelsフォルダを作成し、まずはベースモデルを実装します。

mkdir models
touch models/base.py
fastapi_sample/migrations/models/base.py
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import INTEGER, TIMESTAMP
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    """ ベースモデル
    """
    __abstract__ = True

    id = Column(
        INTEGER,
        primary_key=True,
        autoincrement=True,
    )

    created_at = Column(
        'created_at',
        TIMESTAMP(timezone=True),
        server_default=current_timestamp(),
        nullable=False,
        comment='登録日時',
    )

    updated_at = Column(
        'updated_at',
        TIMESTAMP(timezone=True),
        onupdate=current_timestamp(),
        comment='最終更新日時',
    )

    @declared_attr
    def __mapper_args__(cls):
        """ デフォルトのオーダリングは主キーの昇順

        降順にしたい場合
        from sqlalchemy import desc
        # return {'order_by': desc('id')}
        """
        return {'order_by': 'id'}

ユーザーモデルを用意

モデルはなんでもいいですが、認証編を書くときに使えそうなのでユーザーモデルを作成します。

fastapi_sample/migrations/models/user.py
from migrations.models.base import BaseModel
from sqlalchemy import (
    BOOLEAN,
    VARCHAR,
    Column,
)


class User(BaseModel):
    __tablename__ = 'users'

    email = Column(VARCHAR(254), unique=True, nullable=False)
    password = Column(VARCHAR(128), nullable=False)
    last_name = Column(VARCHAR(100), nullable=False)
    first_name = Column(VARCHAR(100), nullable=False)
    is_admin = Column(BOOLEAN, nullable=False, default=False)
    is_active = Column(BOOLEAN, nullable=False, default=True)

ユーザーモデルがマイグレーション対象にする

__init__.pyを作成

touch models/__init__.py
fastapi_sample/migrations/models/__init__.py
from migrations.models import user  # これだけ追記
# from migrations.models import school  # モデルが増えた場合はこのファイルにインポート文を忘れずに追加する


# インポート文を大量に書きたくない人は動的インポートをお試しあれ(こっちにする場合は、上のインポート文は消してください)
# import importlib
# import os

# IGNORE_FILE_NAMES = ['__init__.py', '__pycache__']
# MODEL_FILES = os.listdir(os.path.dirname(__file__))
# [
#     importlib.import_module(
#         f'migrations.models.{model_file.replace(".py", "")}',
#     )
#     for model_file in MODEL_FILES
#     if model_file not in IGNORE_FILE_NAMES
# ]

env.pyを編集する

fastapi_sample/migrations/env.py
from migrations import models  # __init__.pyをロード
...
..
.
target_metadata = models.base.Base.metadata  # メタデータをセット
...
..
.

DBの接続先を変更

モデルを実装したところで、早速マイグレーションを行いたいところです。
が、alembicがプロジェクトテンプレートのままなのでalembic.iniのなかのデータベースURLもテンプレートのままです。

fastapi_sample/alembic.ini
.
..
...
sqlalchemy.url = driver://user:pass@localhost/dbname
...
..
.

なので勿論alembicのマイグレーションファイル生成コマンドなどは失敗してしまいます。

$ docker-compose exec app alembic revision --autogenerate
Traceback (most recent call last):
  File "/root/local/python-3.8.5/bin/alembic", line 8, in <module>
    sys.exit(main())
  ...
  (長いので省略)
  .
    cls = registry.load(name)
  File "/root/local/python-3.8.5/lib/python3.8/site-packages/sqlalchemy/util/langhelpers.py", line 267, in load
    raise exc.NoSuchModuleError(
sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver

alembic.iniを直接書き換えるのもいいですが、開発環境やステージング、本番環境でそれぞれ異なるalembic.iniファイルができてしまい気持ち悪いです。
なので、マイグレーションファイル生成時やマイグレート実行時は、alembic.iniのデータベースURLを一時的に.envDATABASE_URLで書き換えるようにしてみます。

python-dotenvをインストール

fastapi_sample/requirements.txt
python-dotenv==0.14.0  # 追記したらインストールする

env.pyを修正

fastapi_sample/migrations/env.py
import os
from core.config import PROJECT_ROOT
from dotenv import load_dotenv
.
..
...

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.

# alembic.iniの'sqlalchemy.url'を.envのDATABASE_URLで書き換える
load_dotenv(dotenv_path=os.path.join(PROJECT_ROOT, '.env'))
config.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL'))


def run_migrations_offline():
    ...
    ..
    .

再度マイグレーションファイル生成コマンド実行

% docker-compose exec app alembic revision --autogenerate
None /fastapi_sample
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
  Generating /fastapi_sample/migrations/versions/2bc0a23e563c_.py ...  done

マイグレーションファイルを生成できました。

migrations
├── ...
├── ...
├── models
└── versions
    └── 2bc0a23e563c_.py  # マイグレーションファイルができた。

ただ、今のままだとマイグレーションファイルの生成順がパッと見でわからないのと、マイグレーションファイル内に記載してある生成日時(Create Date)がUTCのままなので日本時間に変えたいです。

alembic.iniを修正します。

fastapi_sample/alembic.ini
.
..
...
[alembic]
# path to migration scripts
script_location = migrations

# ファイルの接頭に「YYYYMMDD_HHMMSS_」がつくようにする
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s

# タイムゾーンを日本時間に
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
timezone = Asia/Tokyo
...
..
.

先ほど生成したマイグレーションファイルを消して、再度マイグレーションファイルを生成します。

migrations
├── ...
├── ...
├── models
└── versions
    └── 20201024_200033_8a19c4c579bf_.py  # ファイル内のCreate Dateも日本時間になっている

マイグレート実行

$ alembic upgrade head  # Dockerの手順を踏んだ人はdocker-compose exec app alembic upgrade head

ちゃんとユーザーテーブルが作成されています。
(alembic_versionはalembicがバージョン管理に使用するテーブルで、自動生成されます)
image.png

データアクセスクラス作成

ベースのデータアクセスクラスを作成

単純な全件取得や1件取得、登録、更新、削除などを記載します。
base.pyに記載しているget_db_session()は後述するリクエストミドルウェアやテストコード実行時などで大変重要な役割を果たします、今は気にしなくて良いです。

mkdir crud
touch crud/base.py
fastapi_sample/crud/base.py
from core.config import get_env
from migrations.models.base import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session, query
from typing import List, TypeVar

ModelType = TypeVar("ModelType", bound=Base)

connection = create_engine(
    get_env().database_url,
    echo=get_env().debug,
    encoding='utf-8',
)

Session = scoped_session(sessionmaker(connection))


def get_db_session() -> scoped_session:
    yield Session


class BaseCRUD:
    """ データアクセスクラスのベース
    """
    model: ModelType = None

    def __init__(self, db_session: scoped_session) -> None:
        self.db_session = db_session
        self.model.query = self.db_session.query_property()

    def get_query(self) -> query.Query:
        """ ベースのクエリ取得
        """
        return self.model.query

    def gets(self) -> List[ModelType]:
        """ 全件取得
        """
        return self.get_query().all()

    def get_by_id(self, id: int) -> ModelType:
        """ 主キーで取得
        """
        return self.get_query().filter_by(id=id).first()

    def create(self, data: dict = {}) -> ModelType:
        """ 新規登録
        """
        obj = self.model()
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.add(obj)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def update(self, obj: ModelType, data: dict = {}) -> ModelType:
        """ 更新
        """
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def delete_by_id(self, id: int) -> None:
        """ 主キーで削除
        """
        obj = self.get_by_id(id)
        if obj:
            obj.delete()
            self.db_session.flush()
        return None

ユーザーデータアクセスクラスを作成

touch crud/crud_user.py
fastapi_sample/crud/crud_user.py
from crud.base import BaseCRUD
from migrations.models.user import User


class CRUDUser(BaseCRUD):
    """ ユーザーデータアクセスクラスのベース
    """
    model = User

API実装

お待たせしました、ようやくAPI実装編です。

API実装前準備

リクエスト情報にDBセッションを格納する依存性注入用(Depends)の関数を実装します。
このFastAPIの依存性注入システムがとても強力で、パラメーターをまとめたりもすることができます。
完全に理解しているわけではないので、申し訳ないのですが詳細は公式ドキュメントを参照してください。

mkdir dependencies
touch dependencies/__init__.py
dependencies/__init__.py
from fastapi import Depends, Request
from crud.base import get_db_session
from sqlalchemy.orm import scoped_session


async def set_db_session_in_request(
    request: Request,
    db_session: scoped_session = Depends(get_db_session)
):
    """ リクエストにDBセッションをセットする
    """
    request.state.db_session = db_session

APIを実装していくフォルダ作成

mkdir -p api/v1

ユーザーの一覧取得API実装

touch api/v1/user.py
fastapi_sample/api/v1/user.py
from crud.base import Session
from crud.crud_user import CRUDUser
from fastapi import Request
from fastapi.encoders import jsonable_encoder


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request):
        """ 一覧取得
        """
        # 依存性注入システムを用いて、リクエスト情報にDBセッションをセットしたので、
        # ここで「request.state.db_session」を使用することができる
        return jsonable_encoder(CRUDUser(request.state.db_session).gets())

APIルーター実装

ルーターのdependencies=[Depends(set_db_session_in_request)]が今後重要な機能を果たしてくれます

mkdir -p api/endpoints/v1
touch api/endpoints/v1/user.py
fastapi_sample/api/endpoints/v1/user.py
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request

router = APIRouter()


@router.get(
    '/',
    dependencies=[Depends(set_db_session_in_request)])
async def gets(request: Request):
    return UserAPI.gets(request)

ルーターをエントリーポイントに登録する

APIルーターを作成

touch api/endpoints/v1/__init__.py
fastapi_sample/api/endpoints/v1/__init__.py
from fastapi import APIRouter
from api.endpoints.v1 import user

api_v1_router = APIRouter()
api_v1_router.include_router(
    user.router,
    prefix='users',
    tags=['users'])

main.pyを編集

fastapi_sample/main.py
from api.endpoints.v1 import api_v1_router  # 追記
from fastapi import FastAPI

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')  # 追記


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

ブラウザからhttps://localhost/docsアクセスすると実装したAPIが表示されているはずです。
image.png

登録/更新用にスキーマ(フォーム?)を定義する

mkdir api/schemas
touch api/schemas/user.py
fastapi_sample/api/schemas/user.py
from pydantic import BaseModel, EmailStr
from typing import Optional


class BaseUser(BaseModel):
    email: EmailStr
    last_name: str
    first_name: str
    is_admin: bool


class CreateUser(BaseUser):
    password: str


class UpdateUser(BaseUser):
    password: Optional[str]
    last_name: Optional[str]
    first_name: Optional[str]
    is_admin: bool


class UserInDB(BaseUser):
    class Config:
        orm_mode = True

ユーザーの登録/更新/削除のAPIを追加

fastapi_sample/api/v1/user.py
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from crud.crud_user import CRUDUser
from fastapi import Request
# from fastapi.encoders import jsonable_encoder  # 削除
from typing import List


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request) -> List[UserInDB]:
        """ 一覧取得
        """
        return CRUDUser(request.state.db_session).gets()  # jsonable_encoderは使わない

    @classmethod
    def create(
        cls,
        request: Request,
        schema: CreateUser
    ) -> UserInDB:
        """ 新規登録
        """
        return CRUDUser(request.state.db_session).create(schema.dict())

    @classmethod
    def update(
        cls,
        request: Request,
        id: int,
        schema: UpdateUser
    ) -> UserInDB:
        """ 更新
        """
        crud = CRUDUser(request.state.db_session)
        obj = crud.get_by_id(id)
        return CRUDUser(request.state.db_session).update(obj, schema.dict())

    @classmethod
    def delete(cls, request: Request, id: int) -> None:
        """ 削除
        """
        return CRUDUser(request.state.db_session).delete_by_id(id)

ユーザーのAPIルーターを編集

fastapi_sample/api/endpoints/v1/user.py
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from typing import List

router = APIRouter()


@router.get(
    '/',
    response_model=List[UserInDB],  # response_modelを追加
    dependencies=[Depends(set_db_session_in_request)])
def gets(request: Request) -> List[UserInDB]:
    """ 一覧取得
    """
    return UserAPI.gets(request)


@router.post(
    '/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def create(request: Request, schema: CreateUser) -> UserInDB:
    """ 新規登録
    """
    return UserAPI.create(request, schema)


@router.put(
    '/{id}/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def update(request: Request, id: int, schema: UpdateUser) -> UserInDB:
    """ 更新
    """
    return UserAPI.update(request, id, schema)


@router.delete(
    '/{id}/',
    dependencies=[Depends(set_db_session_in_request)])
def delete(request: Request, id: int) -> None:
    """ 削除
    """
    return UserAPI.delete(request, id)

ブラウザからアクセスして確認

CRUDのAPIを無事実装できました。
image.png

routerにresponse_modelを指定すると、DBから取得したオブジェクトのJSONシリアライズを開発者が明示的に実装する必要がなくなります。(jsonable_encoder()を使わなくて良くなったのはこれのおかげ)
また、swagger-UI上にも反映してくれます。
※一覧取得のレスポンス
image.png

リクエストミドルウェアを実装する

さて、APIは実行できましたが、DBセッションのコミット実行していないので登録/更新/削除を実行してもDBが変更されません。
変更をDBに反映させるため、以下のようなリクエストミドルウェアを実装して適用します。
・リクエストが完了したらDBセッションコミットしてからDBセッション破棄
・エラー発生時はロールバックしてからDBセッション破棄

mkdir middleware
touch middleware/__init__.py
fastapi_sample/middleware/__init__.py
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            request.state.db_session.rollback()
            raise e

        # 正常終了時
        else:
            request.state.db_session.commit()
            return response

        # DBセッションの破棄は必ず行う
        finally:
            request.state.db_session.remove()

エントリーポイントにミドルウェアを適用する

fastapi_sample/main.py
from api.endpoints.v1 import api_v1_router
from fastapi import FastAPI
from middleware import HttpRequestMiddleware  # 追加

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)  # 追加


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

CRUD実行で確認

無事DBに反映されるようになりました。
image.png

テストコード実装編

CRUDを実装できたので、次はテストコードを実装していきます。
とはいえ、単純にテストコードからAPIを実行してしまうと、DBに登録・更新・削除が実行されるため、テスト実行ごとに結果が変わってしまう可能性があります。
それを防ぐため、テスト関数ごとにクリーンなDBを作成し、そのDBを使用してテストするようにしたいと思います。

pytestをインストール

fastapi_sample/requirements.txt
pytest==6.1.0  # 追記したらrequirements.txtをインストールし直すのを忘れずに

テスト用のDB接続情報を環境変数に追加

fastapi_sample/.env
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample
TEST_DATABASE_URL=postgresql://postgres:postgres@db:5432/test_db_fastapi_sample  # 追加
fastapi_sample/core/config.py
class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool
    database_url: str
    test_database_url: str  # 追加

テストコード実装用のフォルダ作成

mkdir tests

APIのベーステストクラスを作成

touch tests/base.py
fastapi_sample/tests/base.py
import psycopg2
from core.config import get_env
from crud.base import Base, get_db_session
from fastapi.testclient import TestClient
from main import app
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy_utils import database_exists, drop_database

test_db_connection = create_engine(
    get_env().test_database_url,
    encoding='utf8',
    pool_pre_ping=True,
)


class BaseTestCase:
    def setup_method(self, method):
        """ 前処理
        """
        # テストDB作成
        self.__create_test_database()

        self.db_session = self.get_test_db_session()

        # APIクライアントの設定
        self.client = TestClient(app, base_url='https://localhost',)

        # DBをテスト用のDBでオーバーライド
        app.dependency_overrides[get_db_session] = \
            self.override_get_db

    def teardown_method(self, method):
        """ 後処理
        """
        # テストDB削除
        self.__drop_test_database()

        # オーバーライドしたDBを元に戻す
        app.dependency_overrides[self.override_get_db] = \
            get_db_session

    def override_get_db(self):
        """ DBセッションの依存性オーバーライド関数
        """
        yield self.db_session

    def get_test_db_session(self):
        """ テストDBセッションを返す
        """
        return scoped_session(sessionmaker(bind=test_db_connection))

    def __create_test_database(self):
        """ テストDB作成
        """
        # テストDBが削除されずに残ってしまっている場合は削除
        if database_exists(get_env().test_database_url):
            drop_database(get_env().test_database_url)

        # テストDB作成
        _con = \
            psycopg2.connect('host=db user=postgres password=postgres')
        _con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
        _cursor = _con.cursor()
        _cursor.execute('CREATE DATABASE test_db_fastapi_sample')

        # テストDBにExtension追加
        _test_db_con = psycopg2.connect(
            'host=db dbname=test_db_fastapi_sample user=postgres password=postgres'
        )
        _test_db_con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)

        # テストDBにテーブル追加
        Base.metadata.create_all(bind=test_db_connection)

    def __drop_test_database(self):
        """ テストDB削除
        """
        drop_database(get_env().test_database_url)

ポイントはこのDBオーバーライド部分です。
アプリケーションが参照するDBをテストDBに切り替えています。

# DBをテスト用のDBでオーバーライド
app.dependency_overrides[get_db_session] = self.override_get_db

image.png

こうすることで、APIやミドルウェアで使用している「request.state.db_session」もテストDBに切り替えることができます。

一覧取得のテストコードを実装してみる

requirements.txt
requests==2.24.0  # 追記したら必ずrequirements.txtをインストール
mkdir -p tests/api/v1
touch tests/api/v1/test_user.py  # "test_"を接頭に付けないとpytest実行時にスルーされてしまうので必ずつける
fastapi_sample/tests/api/v1/test_user.py
import json
from crud.crud_user import CRUDUser
from fastapi import status
from tests.base import BaseTestCase


class TestUserAPI(BaseTestCase):
    """ ユーザーAPIのテストクラス
    """
    TEST_URL = '/api/v1/users/'

    def test_gets(self):
        """ 一覧取得のテスト
        """
        # テストユーザー登録
        test_data = [
            {
                'email': 'test1@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': False
            },
            {
                'email': 'test2@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': True
            },
            {
                'email': 'test3@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': False
            },
        ]
        for data in test_data:
            CRUDUser(self.db_session).create(data)
            self.db_session.commit()

        response = self.client.get(self.TEST_URL)

        # ステータスコードの検証
        assert response.status_code == status.HTTP_200_OK

        # 取得した件数の検証
        response_data = json.loads(response._content)
        assert len(response_data) == len(test_data)

        # レスポンスの内容を検証
        expected_data = [{
            'email': item['email'],
            'last_name': item['last_name'],
            'first_name': item['first_name'],
            'is_admin': item['is_admin'],
        } for i, item in enumerate(test_data, 1)]
        assert response_data == expected_data

テスト実行

pytest  # Dockerの手順を踏んだ方は、docker-compose exec app pytest
# print()文の出力を確認したい場合は、pytest -v --capture=no
% docker-compose exec app pytest -v --capture=no
================================================================================================== test session starts ===================================================================================================
platform linux -- Python 3.8.5, pytest-6.1.0, py-1.9.0, pluggy-0.13.1 -- /root/local/python-3.8.5/bin/python3.8
cachedir: .pytest_cache
rootdir: /fastapi_sample
collected 1 item

tests/api/v1/test_user.py::TestUserAPI::test_gets PASSED  # 成功

==================================================================================================== warnings summary ====================================================================================================
<string>:2
  <string>:2: SADeprecationWarning: The mapper.order_by parameter is deprecated, and will be removed in a future release. Use Query.order_by() to determine the ordering of a result set.

-- Docs: https://docs.pytest.org/en/stable/warnings.html

テストも無事成功しました。
データベースを確認してみましょう。
API実装時に試しに登録したデータしかありません。
テスト用のDBに切り替えてのテスト実行は成功したようです!

image.png

ローカルDBを汚さず、かつ テストケースごとのテストデータも干渉することなくテストコードを実行することができるようになりました。
やったね。

リクエストミドルウェア実装後、swagger-UI表示時「Internal Server Error」になる

swagger-UI表示時、「request.state」に「db_session」が存在しないため、Key Errorで「Internal Server Error」となります。

手取り早く直したい場合

リクエストミドルウェアの「request.state.db_session」を以下のように書き換えます。

fastapi_sample/middleware/__init__.py
from crud.base import Session  # 追加
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            getattr(request.state, 'db_session', Session).rollback()  # これに書き換え
            raise e

        # 正常終了時
        else:
            getattr(request.state, 'db_session', Session).commit()  # これに書き換え
            return response

        # DBセッションの破棄は必ず行う
        finally:
            getattr(request.state, 'db_session', Session).remove()  # これに書き換え

リクエストミドルウェアは触りたくない場合

swagger-uiで表示するエンドポイントを全てオーバーライドするしかありません。

FastAPIクラスをオーバーライド

touch core/fastapi.py
fastapi_sample/core/fastapi.py
import fastapi
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import (
    http_exception_handler,
    request_validation_exception_handler,
)
from fastapi.openapi.docs import (
    get_redoc_html,
    get_swagger_ui_html,
    get_swagger_ui_oauth2_redirect_html,
)
from fastapi.responses import JSONResponse, HTMLResponse
from starlette.exceptions import HTTPException


class FastAPI(fastapi.FastAPI):
    def setup(self) -> None:
        if self.openapi_url:
            urls = (server_data.get("url") for server_data in self.servers)
            server_urls = {url for url in urls if url}

            async def openapi(req: Request) -> JSONResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                if root_path not in server_urls:
                    if root_path and self.root_path_in_servers:
                        self.servers.insert(0, {"url": root_path})
                        server_urls.add(root_path)
                return JSONResponse(self.openapi(self, req))  # 書き換えているのはこの部分だけ

            self.add_route(self.openapi_url, openapi, include_in_schema=False)
        if self.openapi_url and self.docs_url:

            async def swagger_ui_html(req: Request) -> HTMLResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                openapi_url = root_path + self.openapi_url
                oauth2_redirect_url = self.swagger_ui_oauth2_redirect_url
                if oauth2_redirect_url:
                    oauth2_redirect_url = root_path + oauth2_redirect_url
                return get_swagger_ui_html(
                    openapi_url=openapi_url,
                    title=self.title + " - Swagger UI",
                    oauth2_redirect_url=oauth2_redirect_url,
                    init_oauth=self.swagger_ui_init_oauth,
                )

            self.add_route(
                self.docs_url,
                swagger_ui_html,
                include_in_schema=False
            )

            if self.swagger_ui_oauth2_redirect_url:

                async def swagger_ui_redirect(req: Request) -> HTMLResponse:
                    return get_swagger_ui_oauth2_redirect_html()

                self.add_route(
                    self.swagger_ui_oauth2_redirect_url,
                    swagger_ui_redirect,
                    include_in_schema=False,
                )
        if self.openapi_url and self.redoc_url:

            async def redoc_html(req: Request) -> HTMLResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                openapi_url = root_path + self.openapi_url
                return get_redoc_html(
                    openapi_url=openapi_url, title=self.title + " - ReDoc"
                )

            self.add_route(self.redoc_url, redoc_html, include_in_schema=False)
        self.add_exception_handler(HTTPException, http_exception_handler)
        self.add_exception_handler(
            RequestValidationError, request_validation_exception_handler
        )

swagger-ui用のルーターを用意

touch api/endpoints/swagger_ui.py
fastapi_sample/api/endpoints/swagger_ui.py
from core.fastapi import FastAPI
from crud.base import Session
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi

swagger_ui_router = APIRouter()


@swagger_ui_router.get(
    '/docs',
    include_in_schema=False,
    dependencies=[Depends(set_db_session_in_request)])
async def swagger_ui_html(request: Request):
    return get_swagger_ui_html(
        openapi_url=FastAPI().openapi_url,
        title=FastAPI().title,
        oauth2_redirect_url=FastAPI().swagger_ui_oauth2_redirect_url,
    )


@swagger_ui_router.get(
    '/redoc',
    include_in_schema=False,
    dependencies=[Depends(set_db_session_in_request)])
async def redoc_html(request: Request):
    return get_redoc_html(
        openapi_url=FastAPI().openapi_url,
        title=FastAPI().title + " - ReDoc",
    )


def openapi(app: FastAPI, request: Request):
    request.state.db_session = Session

    if app.openapi_schema:
        return app.openapi_schema
    openapi_schema = get_openapi(
        title="FastAPI",
        version="0.1.0",
        routes=app.routes,
    )
    app.openapi_schema = openapi_schema
    return app.openapi_schema

エントリーポイント修正

fastapi_sample/main.py
from api.endpoints.swagger_ui import openapi, swagger_ui_router
from api.endpoints.v1 import api_v1_router
from core.config import get_env
# from fastapi import FastAPI
from core.fastapi import FastAPI  # オーバーライドしたFastAPIクラスを使用する
from middleware import HttpRequestMiddleware

app = FastAPI(
    docs_url=None,  # Noneを設定しないとswagger-uiのルーターで定義したものに変わらない
    redoc_url=None,  # Noneを設定しないとswagger-uiのルーターで定義したものに変わらない
)

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)


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

# swagger-uiは開発時のみ表示されるようにする
if get_env().debug:
    app.include_router(swagger_ui_router)
    app.openapi = openapi

無事swagger-uiのエラーが解消され、表示されるようになりました。
image.png

終わり

FastAPIでCRUDを実装してみました。
フルスタックフレームワークのDjango(RestはDRF)ばかり使っていたせいで、初め「なんだこの使いづらいフレームワークは・・・」とか思っていましたが、今ではもうDjangoよりFastAPI派になってしまいました。
ガシガシ環境周りのコードを自分で実装できて楽しいですし、何より成長に繋がりました。
 
公式ドキュメントを読みきれていないので、他にもいい方法はあるかと思いますので、
コメントでこっそり教えてもらえると嬉しいです( ´ノω`)
 
私が所属しているFabeee株式会社はお仕事 と 一緒に働くお仲間を随時募集しております!!!!(宣伝)

話を聞いてみたい方はこちら
SES/受託開発のご依頼についてはこちら

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FastAPI + SQLAlchemy(postgresql)でCRUD APIを実装してみた(Dockerやミドルウェア, テストコードなども含む)

アーキテクチャ

python: v3.8.5
postgresql: v12.4
fastapi: v0.60.2
SQLAlchemy: v1.3.18

マイグレーションツール

alembic: v1.4.2

FastAPIとは

詳細は既にQiitaにチョコチョコ記事あるので割愛します。
 
ドキュメントが豊富(読みきれてない)で、Swaggerと互換性あるのがとても素晴らしいです。
(パフォーマンスも良いらしいがベンチマーク測ったことないので断言できない、でも多分速い。)
 
しっかり触ったことのあるフレームワークはDjangoだけなのでミドルウェアやテストコード周り苦労した・・・。
(Djangoはフルスタックフレームワークで、勝手にいろいろやってくれるからミドルウェアとかテストの実装環境とかあまり気にしたことない)

Python3.8.5の仮装環境作成

pyenv, virtualenvが入ってない場合は下記を参考に入れてください。
Mac
Windows

$ pyenv virtualenv 3.8.5 env_fastapi_sample

仮装環境を適用

プロジェクトルートを作成

$ mkdir fastapi_sample

プロジェクトルートにcd

$ cd fastapi_sample

仮装環境を適用

$ pyenv local env_fastapi_sample

requirements.txt作成/インストール

$ touch requirements.txt

requirements.txtにfastapiを追記

fastapi_sample/requirements.txt
fastapi==0.60.2
uvicorn==0.11.8

仮装環境にインストール

$ pip3 install -r requirements.txt

エントリーポイント(main.py)を作成して、Swagger-UIを表示してみる

エントリーポイント(main.py)作成

$ touch main.py

main.pyを編集

fastapi_sample/main.py
from fastapi import FastAPI

app = FastAPI()


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

Swagger-UI表示

$ uvicorn main:app --reload

ブラウザから「http://127.0.0.1:8000」にアクセスして{"message":"Hello World"}が表示されていれば完了です。

Docker化

Nginxのリバースプロキシによってアプリケーションをhttpsで公開するようにします。

ベースのdocker-compose.yml

fastapi_sample/docker-compose.yml
version: '3'
services:
# Nginxコンテナ
  nginx:
    container_name: nginx_fastapi_sample
    image: nginx:alpine
    depends_on:
      - app
      - db
    environment:
      TZ: "Asia/Tokyo"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - ./docker/nginx/ssl:/etc/nginx/ssl

# アプリケーションコンテナ
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: app_fastapi_sample
    volumes:
      - '.:/fastapi_sample/'
    environment:
      - LC_ALL=ja_JP.UTF-8
    expose:
      - 8000
    depends_on:
      - db
    entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample
    command: bash /fastapi_sample/docker/rundevserver.sh
    restart: always
    tty: true

# DBコンテナ
  db:
    image: postgres:12.4-alpine
    container_name: db_fastapi_sample
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=db_fastapi_sample
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --locale=C
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

volumes:
  db_data:
    driver: local

Nginxコンテナの設定

confファイルを用意する

$ mkdir -p nginx/conf.d
$ touch nginx/conf.d/app.conf
fastapi_sample/docker/nginx/conf.d/app.conf
upstream backend {
    server app:8000;  # appはdocker-compose.ymlの「app」
}

# 80番ポートへのアクセスは443番ポートへのアクセスに強制する
server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    ssl_certificate     /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;
    ssl_protocols        TLSv1.2 TLSv1.3;

    location / {
        proxy_set_header    Host    $http_host;
        proxy_set_header    X-Real-IP    $remote_addr;
        proxy_set_header    X-Forwarded-Host      $http_host;
        proxy_set_header    X-Forwarded-Server    $http_host;
        proxy_set_header    X-Forwarded-Server    $host;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
        proxy_redirect      http:// https://;

        proxy_pass http://backend;
    }

    # ログを出力したい場合はコメントアウト外してください
    # access_log /var/log/nginx/access.log;
    # error_log /var/log/nginx/error.log;
}

server_tokens off;

NginxのSSL化に必要なファイルを用意

$ mkdir -p nginx/conf.d nginx/ssl

OpenSSLを使って秘密鍵(server.key)を生成する

# ディレクトリ移動
$ cd nginx/conf.d

# 秘密鍵生成
$ openssl genrsa 2024 > server.key

# 確認
$ ls -ll
total 8
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

証明書署名要求(server.csr)を生成

$ openssl req -new -key server.key > server.csr
$ openssl req -new -key server.key > server.csr
...
..
.
Country Name (2 letter code) [AU]:JP  # 国を示す2文字のISO略語
State or Province Name (full name) [Some-State]:Tokyo  # 会社が置かれている都道府県
Locality Name (eg, city) []:Chiyodaku  # 会社が置かれている市区町村
Organization Name (eg, company) [Internet Widgits Pty Ltd]:fabeee  # 会社名
Organizational Unit Name (eg, section) []:-  # 部署名(ハイフンにしました。)
Common Name (e.g. server FQDN or YOUR name) []:localhost(ウェブサーバのFQDN。一応localhostにした)
Email Address []:  # 未入力でエンター

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:  # 未入力でエンター
An optional company name []:  # 未入力でエンター
...
..
.
$ ls -ll
total 16
-rw-r--r--  1 user  staff   980 10 24 13:27 server.csr  # 証明書署名要求ができた
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

サーバ証明書(server.crt)を生成

openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt

% ls -ll
total 24
-rw-r--r--  1 tabata  staff  1115 10 24 13:40 server.crt  # サーバ証明書ができた
-rw-r--r--  1 tabata  staff   948 10 24 13:29 server.csr
-rw-r--r--  1 tabata  staff  1647 10 24 13:23 server.key

サーバ証明書を信頼する

server.crtをダブルクリック
image.png

「この証明書を使用するとき」のプルダウンから「常に信頼する」を選択する
image.png

最終的にこうなっていればNginxの設定は完了です

fastapi_sample
├── docker
│   └── nginx
│       ├── conf.d
│       │   └── app.conf
│       └── ssl
│           ├── server.crt
│           ├── server.csr
│           └── server.key
├── docker-compose.yml
├── main.py
└── requirements.txt

app(FastAPI)コンテナの設定

Dockerfile

イメージはubuntu20.04です。
・pyenvでコンテナ内のpythonバージョンをv3.8.5にしている
・apt installで必要なモジュールをインストール(不要なものは削ってもらって構いません)
pip3 install -r requirements.txtでpythonモジュールをコンテナ内にインストール

FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive
ENV HOME /root
ENV PYTHONPATH /fastapi_sample/
ENV PYTHON_VERSION 3.8.5
ENV PYTHON_ROOT $HOME/local/python-$PYTHON_VERSION
ENV PATH $PYTHON_ROOT/bin:$PATH
ENV PYENV_ROOT $HOME/.pyenv

RUN mkdir /fastapi_sample \
    && rm -rf /var/lib/apt/lists/*

RUN apt update && apt install -y git curl locales python3-pip python3-dev python3-passlib python3-jwt \
    libssl-dev libffi-dev zlib1g-dev libpq-dev postgresql

RUN echo "ja_JP UTF-8" > /etc/locale.gen \
    && locale-gen

RUN git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \
    && $PYENV_ROOT/plugins/python-build/install.sh \
    && /usr/local/bin/python-build -v $PYTHON_VERSION $PYTHON_ROOT

WORKDIR /fastapi_sample
ADD . /fastapi_sample/
RUN LC_ALL=ja_JP.UTF-8 \
    && pip3 install -r requirements.txt

wait-for-it.sh

データベースが立ち上がるのを待ってからappコンテナを起動するようにするためのシェルスクリプトです。
 
docker-componseに記載しているdepends_onコンテナの起動順は制御できますが、
データベースが立ち上がっていない場合、appコンテナでエラーが発生することがあります。
(appコンテナ起動時にDB操作をするようなコマンドを実行しようとした場合、データベースが立ち上がっていないためにエラー、とか)

fastapi_sample/docker/wait-for-it.sh
#!/bin/sh

set -e

# 引数はdocker-compose.ymlで指定している。
# app:
#   ...
#   ..
#   .
#   entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample  # ココ
#   ...
host="$1"
shift
port="$1"
shift
user="$1"
shift
password="$1"
shift
database="$1"
shift
cmd="$@"

echo "Waiting for postgresql"
until pg_isready -h"$host" -U"$user" -p"$port" -d"$database"
do
  echo -n "."
  sleep 1
done

>&2 echo "PostgreSQL is up - executing command"
exec $cmd

# 僕は優しいのでMySQL用も書いてあげるのだ
#!/bin/sh

# set -e

# host="$1"
# shift
# user="$1"
# shift
# password="$1"
# shift
# cmd="$@"

# echo "Waiting for mysql"
# until mysqladmin ping -h "$host" --silent
# do
#     echo -n "."
#     sleep 1
# done

# >&2 echo "MySQL is up - executing command"
# exec $cmd

rundevserver.sh

uvicornを起動してアプリケーションを起動するためのシェルスクリプトファイルです。
docker-compose up時に毎回requirements.txtのモジュールを読み込む
・uvicornでアプリケーションを起動する
・--reloadでホットリロード(pythonファイルを変更すると即反映してくれる)
・--portを8000から変える場合はnginxのapp.conf内の8000も変える必要がある

pip3 install -r requirements.txt

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

最終的なディレクトリ構成

fastapi_sample
├── Dockerfile
├── docker
│   ├── nginx
│   │   ├── conf.d
│   │   │   └── app.conf
│   │   └── ssl
│   │       ├── server.crt
│   │       ├── server.csr
│   │       └── server.key
│   ├── rundevserver.sh
│   └── wait-for-it.sh
├── docker-compose.yml
├── main.py
└── requirements.txt

コンテナ起動

$ docker-compose up -d

こんなエラーが出た場合は、Dockerイメージがディスクを逼迫している可能性があるので、docker image pruneで不要なイメージを削除してください。(僕はこれで40GB分ディスクに空きができました?)

.
..
...
Get:1 http://security.debian.org/debian-security buster/updates InRelease [65.4 kB]
Get:2 http://deb.debian.org/debian buster InRelease [122 kB]
Get:3 http://deb.debian.org/debian buster-updates InRelease [49.3 kB]
Err:1 http://security.debian.org/debian-security buster/updates InRelease
  At least one invalid signature was encountered.
Err:2 http://deb.debian.org/debian buster InRelease
  At least one invalid signature was encountered.
Err:3 http://deb.debian.org/debian buster-updates InRelease
  At least one invalid signature was encountered.
Reading package lists...
W: GPG error: http://security.debian.org/debian-security buster/updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://security.debian.org/debian-security buster/updates InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster-updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster-updates InRelease' is not signed.
...
..
.

ブラウザからアクセス

コンテナの起動が完了したか確認します。
STATUS列が"Up..."となっていればOKです。

$ docker ps
CONTAINER ID        IMAGE                  COMMAND                  CREATED              STATUS              PORTS                                      NAMES
b77fa2465fb1        nginx:alpine           "/docker-entrypoint.…"   About a minute ago   Up About a minute   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp   nginx_fastapi_sample
eb18438efd2c        fastapi_sample_app     "/fastapi_sample/doc…"   About a minute ago   Up About a minute   8000/tcp                                   app_fastapi_sample
383b0e46af68        postgres:12.4-alpine   "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:5432->5432/tcp                     db_fastapi_sample

https://localhostにアクセスして、{"message":"Hello World"}が表示されれば完了です。

ライブラリ「pydantic」を使用して環境変数(.env)を扱う

.envファイル作成

$ touch .env
fastapi_sample/.env
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample

「pydatic」を使って環境変数を読み込む設定ファイルを作成

ライブラリ「pydantic」を使うと、.env から値を読み込む処理を簡単に実装できます。
また型に合わせてキャストしてくれたりするので超便利。
os.getenv('DEBUG') == 'True' みたいな気持ち悪い条件式を書かなくてよくなります。(distutils.util.strtobool使えって話ですが、、、)

pydanticをインストール

fastapi_sample/requirements.txt
pydantic[email]==1.6.1  # 追記したらpip3 install

フォルダ「core」を作成、その直下に「config.py」を作成

$ mkdir core
$ touch core/config.py
fastapi_sample/core/config.py
import os
from functools import lru_cache
from pydantic import BaseSettings

PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool  # .envから読み込んだ値をbool型にキャッシュ
    database_url: str

    class Config:
        env_file = os.path.join(PROJECT_ROOT, '.env')


@lru_cache
def get_env():
    """ 「@lru_cache」でディスクから読み込んだ.envの結果をキャッシュする
    """
    return Environment()

alembic(マイグレーションツール)環境の用意 と モデルを用意

何はともあれインストール

requirements.txtに alembic と sqlalchemy 追記してからpip3 install -r requirements.txtでインストール
(Dockerの手順を踏んでいる人はdocker-compose restart app または docker-compose exec app pip3 install -r requiements.txt

fastapi_samepl/requirements.txt
alembic==1.4.2  # 追記
.
..
...
psycopg2==2.8.6  # 追記
.
..
...
SQLAlchemy==1.3.18  # 追記
SQLAlchemy-Utils==0.36.8  # 追記

プロジェクトルート直下にalembic環境を作成する

$ alembic init migrations  # Dockerの手順を踏んだ人はdocker-compose exec app alembic init migrations

プロジェクトルートにalembicテンプレートが作成されます。(alembic.ini, migrationsフォルダ)

fastapi_sample(プロジェクトルート)
├── ...
├── ..
├── .
├── alembic.ini
├── migrations
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions
└── ...

ベースモデルを用意

まずはベースモデルを実装します。

touch models.py
fastapi_sample/migrations/models.py
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import INTEGER, TIMESTAMP
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    """ ベースモデル
    """
    __abstract__ = True

    id = Column(
        INTEGER,
        primary_key=True,
        autoincrement=True,
    )

    created_at = Column(
        'created_at',
        TIMESTAMP(timezone=True),
        server_default=current_timestamp(),
        nullable=False,
        comment='登録日時',
    )

    updated_at = Column(
        'updated_at',
        TIMESTAMP(timezone=True),
        onupdate=current_timestamp(),
        comment='最終更新日時',
    )

    @declared_attr
    def __mapper_args__(cls):
        """ デフォルトのオーダリングは主キーの昇順

        降順にしたい場合
        from sqlalchemy import desc
        # return {'order_by': desc('id')}
        """
        return {'order_by': 'id'}

ユーザーモデルを用意

モデルはなんでもいいですが、認証編を書くときに使えそうなのでユーザーモデルを作成します。
モジュール分割はあえてしていません。(マイグレーションファイル生成時などで不都合発生して手間なので、しない方がいいかも?)

fastapi_sample/migrations/models.py
from sqlalchemy import (
    BOOLEAN,  # 追加
    Column
    INTEGER,
    TIMESTAMP,
    VARCHAR,  # 追加
)
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    ...
    ..
    .


class User(BaseModel):
    __tablename__ = 'users'

    email = Column(VARCHAR(254), unique=True, nullable=False)
    password = Column(VARCHAR(128), nullable=False)
    last_name = Column(VARCHAR(100), nullable=False)
    first_name = Column(VARCHAR(100), nullable=False)
    is_admin = Column(BOOLEAN, nullable=False, default=False)
    is_active = Column(BOOLEAN, nullable=False, default=True)

env.pyを編集する

fastapi_sample/migrations/env.py
from migrations.models import Base  # 追加
...
..
.
target_metadata = Base.metadata  # メタデータをセット
...
..
.

DBの接続先を変更

モデルを実装したところで、早速マイグレーションを行いたいところです。
が、alembicがプロジェクトテンプレートのままなのでalembic.iniのなかのデータベースURLもテンプレートのままです。

fastapi_sample/alembic.ini
.
..
...
sqlalchemy.url = driver://user:pass@localhost/dbname
...
..
.

なので勿論alembicのマイグレーションファイル生成コマンドなどは失敗してしまいます。

$ docker-compose exec app alembic revision --autogenerate
Traceback (most recent call last):
  File "/root/local/python-3.8.5/bin/alembic", line 8, in <module>
    sys.exit(main())
  ...
  (長いので省略)
  .
    cls = registry.load(name)
  File "/root/local/python-3.8.5/lib/python3.8/site-packages/sqlalchemy/util/langhelpers.py", line 267, in load
    raise exc.NoSuchModuleError(
sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver

alembic.iniを直接書き換えるのもいいですが、開発環境やステージング、本番環境でそれぞれ異なるalembic.iniファイルができてしまい気持ち悪いです。
なので、マイグレーションファイル生成時やマイグレート実行時は、alembic.iniのデータベースURLを一時的に.envDATABASE_URLで書き換えるようにしてみます。

python-dotenvをインストール

fastapi_sample/requirements.txt
python-dotenv==0.14.0  # 追記したらインストールする

env.pyを修正

fastapi_sample/migrations/env.py
import os
from core.config import PROJECT_ROOT
from dotenv import load_dotenv
.
..
...

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.

# alembic.iniの'sqlalchemy.url'を.envのDATABASE_URLで書き換える
load_dotenv(dotenv_path=os.path.join(PROJECT_ROOT, '.env'))
config.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL'))


def run_migrations_offline():
    ...
    ..
    .

再度マイグレーションファイル生成コマンド実行

% docker-compose exec app alembic revision --autogenerate
None /fastapi_sample
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
  Generating /fastapi_sample/migrations/versions/2bc0a23e563c_.py ...  done

マイグレーションファイルを生成できました。

migrations
├── ...
├── ...
├── models.py
└── versions
    └── 2bc0a23e563c_.py  # マイグレーションファイルができた。

ただ、今のままだとマイグレーションファイルの生成順がパッと見でわからないのと、マイグレーションファイル内に記載してある生成日時(Create Date)がUTCのままなので日本時間に変えたいです。

alembic.iniを修正します。

fastapi_sample/alembic.ini
.
..
...
[alembic]
# path to migration scripts
script_location = migrations

# ファイルの接頭に「YYYYMMDD_HHMMSS_」がつくようにする
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s

# タイムゾーンを日本時間に
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
timezone = Asia/Tokyo
...
..
.

先ほど生成したマイグレーションファイルを消して、再度マイグレーションファイルを生成します。

migrations
├── ...
├── ...
├── models.py
└── versions
    └── 20201024_200033_8a19c4c579bf_.py  # ファイル内のCreate Dateも日本時間になっている

マイグレート実行

$ alembic upgrade head  # Dockerの手順を踏んだ人はdocker-compose exec app alembic upgrade head

ちゃんとユーザーテーブルが作成されています。
(alembic_versionはalembicがバージョン管理に使用するテーブルで、自動生成されます)
image.png

コンテナ起動時にマイグレートが実行されるようにする

これでコンテナを起動するたびにマイグレートが実行されるようになります。

fastapi_sample/docker/rundevserver.sh
pip3 install -r requirements.txt

alembic upgrade head  # 追記

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

データアクセスクラス作成

ベースのデータアクセスクラスを作成

単純な全件取得や1件取得、登録、更新、削除などを記載します。
base.pyに記載しているget_db_session()は後述するリクエストミドルウェアやテストコード実行時などで大変重要な役割を果たします、今は気にしなくて良いです。

mkdir crud
touch crud/base.py
fastapi_sample/crud/base.py
from core.config import get_env
from migrations.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session, query
from typing import List, TypeVar

ModelType = TypeVar("ModelType", bound=Base)

connection = create_engine(
    get_env().database_url,
    echo=get_env().debug,
    encoding='utf-8',
)

Session = scoped_session(sessionmaker(connection))


def get_db_session() -> scoped_session:
    yield Session


class BaseCRUD:
    """ データアクセスクラスのベース
    """
    model: ModelType = None

    def __init__(self, db_session: scoped_session) -> None:
        self.db_session = db_session
        self.model.query = self.db_session.query_property()

    def get_query(self) -> query.Query:
        """ ベースのクエリ取得
        """
        return self.model.query

    def gets(self) -> List[ModelType]:
        """ 全件取得
        """
        return self.get_query().all()

    def get_by_id(self, id: int) -> ModelType:
        """ 主キーで取得
        """
        return self.get_query().filter_by(id=id).first()

    def create(self, data: dict = {}) -> ModelType:
        """ 新規登録
        """
        obj = self.model()
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.add(obj)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def update(self, obj: ModelType, data: dict = {}) -> ModelType:
        """ 更新
        """
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def delete_by_id(self, id: int) -> None:
        """ 主キーで削除
        """
        obj = self.get_by_id(id)
        if obj:
            obj.delete()
            self.db_session.flush()
        return None

ユーザーデータアクセスクラスを作成

touch crud/crud_user.py
fastapi_sample/crud/crud_user.py
from crud.base import BaseCRUD
from migrations.models import User


class CRUDUser(BaseCRUD):
    """ ユーザーデータアクセスクラスのベース
    """
    model = User

API実装

お待たせしました、ようやくAPI実装編です。

API実装前準備

リクエスト情報にDBセッションを格納する依存性注入用(Depends)の関数を実装します。
このFastAPIの依存性注入システムがとても強力で、パラメーターをまとめたりもすることができます。
完全に理解しているわけではないので、申し訳ないのですが詳細は公式ドキュメントを参照してください。

mkdir dependencies
touch dependencies/__init__.py
dependencies/__init__.py
from fastapi import Depends, Request
from crud.base import get_db_session
from sqlalchemy.orm import scoped_session


async def set_db_session_in_request(
    request: Request,
    db_session: scoped_session = Depends(get_db_session)
):
    """ リクエストにDBセッションをセットする
    """
    request.state.db_session = db_session

APIを実装していくフォルダ作成

mkdir -p api/v1

ユーザーの一覧取得API実装

touch api/v1/user.py
fastapi_sample/api/v1/user.py
from crud.base import Session
from crud.crud_user import CRUDUser
from fastapi import Request
from fastapi.encoders import jsonable_encoder


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request):
        """ 一覧取得
        """
        # 依存性注入システムを用いて、リクエスト情報にDBセッションをセットしたので、
        # ここで「request.state.db_session」を使用することができる
        return jsonable_encoder(CRUDUser(request.state.db_session).gets())

APIルーター実装

ルーターのdependencies=[Depends(set_db_session_in_request)]が今後重要な機能を果たしてくれます

mkdir -p api/endpoints/v1
touch api/endpoints/v1/user.py
fastapi_sample/api/endpoints/v1/user.py
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request

router = APIRouter()


@router.get(
    '/',
    dependencies=[Depends(set_db_session_in_request)])
async def gets(request: Request):
    return UserAPI.gets(request)

ルーターをエントリーポイントに登録する

APIルーターを作成

touch api/endpoints/v1/__init__.py
fastapi_sample/api/endpoints/v1/__init__.py
from fastapi import APIRouter
from api.endpoints.v1 import user

api_v1_router = APIRouter()
api_v1_router.include_router(
    user.router,
    prefix='users',
    tags=['users'])

main.pyを編集

fastapi_sample/main.py
from api.endpoints.v1 import api_v1_router  # 追記
from fastapi import FastAPI

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')  # 追記


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

ブラウザからhttps://localhost/docsアクセスすると実装したAPIが表示されているはずです。
image.png

登録/更新用にスキーマ(フォーム?)を定義する

mkdir api/schemas
touch api/schemas/user.py
fastapi_sample/api/schemas/user.py
from pydantic import BaseModel, EmailStr
from typing import Optional


class BaseUser(BaseModel):
    email: EmailStr
    last_name: str
    first_name: str
    is_admin: bool


class CreateUser(BaseUser):
    password: str


class UpdateUser(BaseUser):
    password: Optional[str]
    last_name: Optional[str]
    first_name: Optional[str]
    is_admin: bool


class UserInDB(BaseUser):
    class Config:
        orm_mode = True

ユーザーの登録/更新/削除のAPIを追加

fastapi_sample/api/v1/user.py
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from crud.crud_user import CRUDUser
from fastapi import Request
# from fastapi.encoders import jsonable_encoder  # 削除
from typing import List


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request) -> List[UserInDB]:
        """ 一覧取得
        """
        return CRUDUser(request.state.db_session).gets()  # jsonable_encoderは使わない

    @classmethod
    def create(
        cls,
        request: Request,
        schema: CreateUser
    ) -> UserInDB:
        """ 新規登録
        """
        return CRUDUser(request.state.db_session).create(schema.dict())

    @classmethod
    def update(
        cls,
        request: Request,
        id: int,
        schema: UpdateUser
    ) -> UserInDB:
        """ 更新
        """
        crud = CRUDUser(request.state.db_session)
        obj = crud.get_by_id(id)
        return CRUDUser(request.state.db_session).update(obj, schema.dict())

    @classmethod
    def delete(cls, request: Request, id: int) -> None:
        """ 削除
        """
        return CRUDUser(request.state.db_session).delete_by_id(id)

ユーザーのAPIルーターを編集

fastapi_sample/api/endpoints/v1/user.py
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from typing import List

router = APIRouter()


@router.get(
    '/',
    response_model=List[UserInDB],  # response_modelを追加
    dependencies=[Depends(set_db_session_in_request)])
def gets(request: Request) -> List[UserInDB]:
    """ 一覧取得
    """
    return UserAPI.gets(request)


@router.post(
    '/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def create(request: Request, schema: CreateUser) -> UserInDB:
    """ 新規登録
    """
    return UserAPI.create(request, schema)


@router.put(
    '/{id}/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def update(request: Request, id: int, schema: UpdateUser) -> UserInDB:
    """ 更新
    """
    return UserAPI.update(request, id, schema)


@router.delete(
    '/{id}/',
    dependencies=[Depends(set_db_session_in_request)])
def delete(request: Request, id: int) -> None:
    """ 削除
    """
    return UserAPI.delete(request, id)

ブラウザからアクセスして確認

CRUDのAPIを無事実装できました。
image.png

routerにresponse_modelを指定すると、DBから取得したオブジェクトのJSONシリアライズを開発者が明示的に実装する必要がなくなります。(jsonable_encoder()を使わなくて良くなったのはこれのおかげ)
また、swagger-UI上にも反映してくれます。
※一覧取得のレスポンス
image.png

リクエストミドルウェアを実装する

さて、APIは実行できましたが、DBセッションのコミット実行していないので登録/更新/削除を実行してもDBが変更されません。
変更をDBに反映させるため、以下のようなリクエストミドルウェアを実装して適用します。
・リクエストが完了したらDBセッションコミットしてからDBセッション破棄
・エラー発生時はロールバックしてからDBセッション破棄

mkdir middleware
touch middleware/__init__.py
fastapi_sample/middleware/__init__.py
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            request.state.db_session.rollback()
            raise e

        # 正常終了時
        else:
            request.state.db_session.commit()
            return response

        # DBセッションの破棄は必ず行う
        finally:
            request.state.db_session.remove()

エントリーポイントにミドルウェアを適用する

fastapi_sample/main.py
from api.endpoints.v1 import api_v1_router
from fastapi import FastAPI
from middleware import HttpRequestMiddleware  # 追加

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)  # 追加


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

CRUD実行で確認

無事DBに反映されるようになりました。
image.png

テストコード実装編

CRUDを実装できたので、次はテストコードを実装していきます。
とはいえ、単純にテストコードからAPIを実行してしまうと、DBに登録・更新・削除が実行されるため、テスト実行ごとに結果が変わってしまう可能性があります。
それを防ぐため、テスト関数ごとにクリーンなDBを作成し、そのDBを使用してテストするようにしたいと思います。

pytestをインストール

fastapi_sample/requirements.txt
pytest==6.1.0  # 追記したらrequirements.txtをインストールし直すのを忘れずに

テスト用のDB接続情報を環境変数に追加

fastapi_sample/.env
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample
TEST_DATABASE_URL=postgresql://postgres:postgres@db:5432/test_db_fastapi_sample  # 追加
fastapi_sample/core/config.py
class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool
    database_url: str
    test_database_url: str  # 追加

テストコード実装用のフォルダ作成

mkdir tests

APIのベーステストクラスを作成

touch tests/base.py
fastapi_sample/tests/base.py
import psycopg2
from core.config import get_env
from crud.base import get_db_session
from fastapi.testclient import TestClient
from main import app
from migrations.models import Base
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy_utils import database_exists, drop_database

test_db_connection = create_engine(
    get_env().test_database_url,
    encoding='utf8',
    pool_pre_ping=True,
)


class BaseTestCase:
    def setup_method(self, method):
        """ 前処理
        """
        # テストDB作成
        self.__create_test_database()

        self.db_session = self.get_test_db_session()

        # APIクライアントの設定
        self.client = TestClient(app, base_url='https://localhost',)

        # DBをテスト用のDBでオーバーライド
        app.dependency_overrides[get_db_session] = \
            self.override_get_db

    def teardown_method(self, method):
        """ 後処理
        """
        # テストDB削除
        self.__drop_test_database()

        # オーバーライドしたDBを元に戻す
        app.dependency_overrides[self.override_get_db] = \
            get_db_session

    def override_get_db(self):
        """ DBセッションの依存性オーバーライド関数
        """
        yield self.db_session

    def get_test_db_session(self):
        """ テストDBセッションを返す
        """
        return scoped_session(sessionmaker(bind=test_db_connection))

    def __create_test_database(self):
        """ テストDB作成
        """
        # テストDBが削除されずに残ってしまっている場合は削除
        if database_exists(get_env().test_database_url):
            drop_database(get_env().test_database_url)

        # テストDB作成
        _con = \
            psycopg2.connect('host=db user=postgres password=postgres')
        _con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
        _cursor = _con.cursor()
        _cursor.execute('CREATE DATABASE test_db_fastapi_sample')

        # テストDBにExtension追加
        _test_db_con = psycopg2.connect(
            'host=db dbname=test_db_fastapi_sample user=postgres password=postgres'
        )
        _test_db_con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)

        # テストDBにテーブル追加
        Base.metadata.create_all(bind=test_db_connection)

    def __drop_test_database(self):
        """ テストDB削除
        """
        drop_database(get_env().test_database_url)

ポイントはこのDBオーバーライド部分です。
アプリケーションが参照するDBをテストDBに切り替えています。

# DBをテスト用のDBでオーバーライド
app.dependency_overrides[get_db_session] = self.override_get_db

image.png

こうすることで、APIやミドルウェアで使用している「request.state.db_session」もテストDBに切り替えることができます。

一覧取得のテストコードを実装してみる

requirements.txt
requests==2.24.0  # 追記したら必ずrequirements.txtをインストール
mkdir -p tests/api/v1
touch tests/api/v1/test_user.py  # "test_"を接頭に付けないとpytest実行時にスルーされてしまうので必ずつける
fastapi_sample/tests/api/v1/test_user.py
import json
from crud.crud_user import CRUDUser
from fastapi import status
from tests.base import BaseTestCase


class TestUserAPI(BaseTestCase):
    """ ユーザーAPIのテストクラス
    """
    TEST_URL = '/api/v1/users/'

    def test_gets(self):
        """ 一覧取得のテスト
        """
        # テストユーザー登録
        test_data = [
            {
                'email': 'test1@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': False
            },
            {
                'email': 'test2@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': True
            },
            {
                'email': 'test3@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': False
            },
        ]
        for data in test_data:
            CRUDUser(self.db_session).create(data)
            self.db_session.commit()

        response = self.client.get(self.TEST_URL)

        # ステータスコードの検証
        assert response.status_code == status.HTTP_200_OK

        # 取得した件数の検証
        response_data = json.loads(response._content)
        assert len(response_data) == len(test_data)

        # レスポンスの内容を検証
        expected_data = [{
            'email': item['email'],
            'last_name': item['last_name'],
            'first_name': item['first_name'],
            'is_admin': item['is_admin'],
        } for i, item in enumerate(test_data, 1)]
        assert response_data == expected_data

テスト実行

pytest  # Dockerの手順を踏んだ方は、docker-compose exec app pytest
# print()文の出力を確認したい場合は、pytest -v --capture=no
% docker-compose exec app pytest -v --capture=no
================================================================================================== test session starts ===================================================================================================
platform linux -- Python 3.8.5, pytest-6.1.0, py-1.9.0, pluggy-0.13.1 -- /root/local/python-3.8.5/bin/python3.8
cachedir: .pytest_cache
rootdir: /fastapi_sample
collected 1 item

tests/api/v1/test_user.py::TestUserAPI::test_gets PASSED  # 成功

==================================================================================================== warnings summary ====================================================================================================
<string>:2
  <string>:2: SADeprecationWarning: The mapper.order_by parameter is deprecated, and will be removed in a future release. Use Query.order_by() to determine the ordering of a result set.

-- Docs: https://docs.pytest.org/en/stable/warnings.html

テストも無事成功しました。
データベースを確認してみましょう。
API実装時に試しに登録したデータしかありません。
テスト用のDBに切り替えてのテスト実行は成功したようです!

image.png

ローカルDBを汚さず、かつ テストケースごとのテストデータも干渉することなくテストコードを実行することができるようになりました。
やったね。

リクエストミドルウェア実装後、swagger-UI表示時「Internal Server Error」になる

swagger-UI表示時、「request.state」に「db_session」が存在しないため、Key Errorで「Internal Server Error」となります。

手取り早く直したい場合

リクエストミドルウェアの「request.state.db_session」を以下のように書き換えます。

fastapi_sample/middleware/__init__.py
from crud.base import Session  # 追加
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            getattr(request.state, 'db_session', Session).rollback()  # これに書き換え
            raise e

        # 正常終了時
        else:
            getattr(request.state, 'db_session', Session).commit()  # これに書き換え
            return response

        # DBセッションの破棄は必ず行う
        finally:
            getattr(request.state, 'db_session', Session).remove()  # これに書き換え

リクエストミドルウェアは触りたくない場合

swagger-uiで表示するエンドポイントを全てオーバーライドするしかありません。

FastAPIクラスをオーバーライド

touch core/fastapi.py
fastapi_sample/core/fastapi.py
import fastapi
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import (
    http_exception_handler,
    request_validation_exception_handler,
)
from fastapi.openapi.docs import (
    get_redoc_html,
    get_swagger_ui_html,
    get_swagger_ui_oauth2_redirect_html,
)
from fastapi.responses import JSONResponse, HTMLResponse
from starlette.exceptions import HTTPException


class FastAPI(fastapi.FastAPI):
    def setup(self) -> None:
        if self.openapi_url:
            urls = (server_data.get("url") for server_data in self.servers)
            server_urls = {url for url in urls if url}

            async def openapi(req: Request) -> JSONResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                if root_path not in server_urls:
                    if root_path and self.root_path_in_servers:
                        self.servers.insert(0, {"url": root_path})
                        server_urls.add(root_path)
                return JSONResponse(self.openapi(self, req))  # 書き換えているのはこの部分だけ

            self.add_route(self.openapi_url, openapi, include_in_schema=False)
        if self.openapi_url and self.docs_url:

            async def swagger_ui_html(req: Request) -> HTMLResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                openapi_url = root_path + self.openapi_url
                oauth2_redirect_url = self.swagger_ui_oauth2_redirect_url
                if oauth2_redirect_url:
                    oauth2_redirect_url = root_path + oauth2_redirect_url
                return get_swagger_ui_html(
                    openapi_url=openapi_url,
                    title=self.title + " - Swagger UI",
                    oauth2_redirect_url=oauth2_redirect_url,
                    init_oauth=self.swagger_ui_init_oauth,
                )

            self.add_route(
                self.docs_url,
                swagger_ui_html,
                include_in_schema=False
            )

            if self.swagger_ui_oauth2_redirect_url:

                async def swagger_ui_redirect(req: Request) -> HTMLResponse:
                    return get_swagger_ui_oauth2_redirect_html()

                self.add_route(
                    self.swagger_ui_oauth2_redirect_url,
                    swagger_ui_redirect,
                    include_in_schema=False,
                )
        if self.openapi_url and self.redoc_url:

            async def redoc_html(req: Request) -> HTMLResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                openapi_url = root_path + self.openapi_url
                return get_redoc_html(
                    openapi_url=openapi_url, title=self.title + " - ReDoc"
                )

            self.add_route(self.redoc_url, redoc_html, include_in_schema=False)
        self.add_exception_handler(HTTPException, http_exception_handler)
        self.add_exception_handler(
            RequestValidationError, request_validation_exception_handler
        )

swagger-ui用のルーターを用意

touch api/endpoints/swagger_ui.py
fastapi_sample/api/endpoints/swagger_ui.py
from core.fastapi import FastAPI
from crud.base import Session
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi

swagger_ui_router = APIRouter()


@swagger_ui_router.get(
    '/docs',
    include_in_schema=False,
    dependencies=[Depends(set_db_session_in_request)])
async def swagger_ui_html(request: Request):
    return get_swagger_ui_html(
        openapi_url=FastAPI().openapi_url,
        title=FastAPI().title,
        oauth2_redirect_url=FastAPI().swagger_ui_oauth2_redirect_url,
    )


@swagger_ui_router.get(
    '/redoc',
    include_in_schema=False,
    dependencies=[Depends(set_db_session_in_request)])
async def redoc_html(request: Request):
    return get_redoc_html(
        openapi_url=FastAPI().openapi_url,
        title=FastAPI().title + " - ReDoc",
    )


def openapi(app: FastAPI, request: Request):
    request.state.db_session = Session

    if app.openapi_schema:
        return app.openapi_schema
    openapi_schema = get_openapi(
        title="FastAPI",
        version="0.1.0",
        routes=app.routes,
    )
    app.openapi_schema = openapi_schema
    return app.openapi_schema

エントリーポイント修正

fastapi_sample/main.py
from api.endpoints.swagger_ui import openapi, swagger_ui_router
from api.endpoints.v1 import api_v1_router
from core.config import get_env
# from fastapi import FastAPI
from core.fastapi import FastAPI  # オーバーライドしたFastAPIクラスを使用する
from middleware import HttpRequestMiddleware

app = FastAPI(
    docs_url=None,  # Noneを設定しないとswagger-uiのルーターで定義したものに変わらない
    redoc_url=None,  # Noneを設定しないとswagger-uiのルーターで定義したものに変わらない
)

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)


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

# swagger-uiは開発時のみ表示されるようにする
if get_env().debug:
    app.include_router(swagger_ui_router)
    app.openapi = openapi

無事swagger-uiのエラーが解消され、表示されるようになりました。
image.png

【オマケ①】CORS問題を回避

このままだと、フロントエンドからのAPI呼び出し時にCORSエラー発生してしまいます。
image.png

この問題を回避するため、CORSミドルウェアを実装します。

.env環境変数を扱うクラスを編集

fastapi_sample/.env
ALLOW_HEADERS='["*"]'  # 追加
ALLOW_ORIGINS='["*"]'  # 追加
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample
TEST_DATABASE_URL=postgresql://postgres:postgres@db:5432/test_db_fastapi_sample
fastapi_sample/core/config.py
class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    allow_headers: list  # 追加
    allow_origins: list  # 追加
    ...
    ..
    .

CORSミドルウェアを実装・エントリポイントに適用

fastapi_sample/middleware/__init__.py
from core.config import get_env  # 追加
from crud.base import Session
from fastapi import Request, Response
from starlette.middleware import cors  # 追加
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp  # 追加


class CORSMiddleware(cors.CORSMiddleware):
    """ CORS問題を回避するためのミドルウェア
    """
    def __init__(self, app: ASGIApp) -> None:
        super().__init__(
            app,
            allow_origins=get_env().allow_origins,
            allow_methods=cors.ALL_METHODS,
            allow_headers=get_env().allow_headers,
            allow_credentials=True,
        )


class HttpRequestMiddleware(BaseHTTPMiddleware):
    ...
    ..
    .
fastapi_sample/main.py
from api.endpoints.swagger_ui import openapi, swagger_ui_router
from api.endpoints.v1 import api_v1_router
from core.config import get_env
from core.fastapi import FastAPI
from middleware import (
    CORSMiddleware,  # 追加
    HttpRequestMiddleware
)

...
..
.

# ミドルウェアの設定
# ・ミドルウェアは 後 に追加したものが先に実行される
# ・CORSMiddlewareは必ず一番 後 に追加すること
# ・ミドルウェアを追加する場合はCORSMiddleware と ProcessRequestMiddlewareより 前 に追加すること
app.add_middleware(HttpRequestMiddleware)
app.add_middleware(CORSMiddleware)  # 追加
...
..
.

ポイントはミドルウェアを追加する順番です。
後に書いたミドルウェアが先に実行されるので、CORSミドルウェアは一番最後に書きましょう。
 
 
無事、フロントエンドのCORSエラーは回避することができるようになりました。
(文字列"ログイン成功"を返すだけのAPIを作成してフロントエンドから実行)
image.png

【オマケ②】カスタム例外

HTTPExceptionを継承してカスタム例外を作成してみます。
エラーレスポンスはこんな感じで、エラーコードとエラーメッセージを複数返せるような形式します。

detail: [
  {
    error_code: 'エラーコード',
    error_message: 'エラーメッセージ'
  },
  {
    error_code: 'エラーコード',
    error_message: 'エラーメッセージ'
  },
  { ... },
  { ... },
]

メッセージを管理するiniファイルを作成

touch core/error_messages.ini
fastapi_sample/core/error_messages.ini
[messages]
INTERNAL_SERVER_ERROR=システムエラーが発生しました。管理者に問い合わせてください
FAILURE_LOGIN=ログイン失敗

カスタム例外を実装するフォルダ作成

mkdir exception
touch exception/__init__.py
touch exception/error_messages.py

エラーコードとエラーメッセージの管理クラス

fastapi_sample/exception/error_messages.py
import os
from configparser import ConfigParser
from core.config import PROJECT_ROOT
from functools import lru_cache

MESSAGES_INI = ConfigParser()
MESSAGES_INI.read(
    os.path.join(PROJECT_ROOT, 'core/error_messages.ini'),
    'utf-8'
)


@lru_cache
def get(key: str, *args):
    return MESSAGES_INI.get('messages', key).format(*args)


INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'
FAILURE_LOGIN = 'FAILURE_LOGIN'

fastapi_sample/exception/__init__.py
from fastapi import status, HTTPException
from exception import error_messages


class ApiException(HTTPException):
    """ API例外
    """
    default_status_code = status.HTTP_400_BAD_REQUEST

    def __init__(
        self,
        *errors,
        status_code: int = default_status_code
    ) -> None:
        self.status_code = status_code
        self.detail = [
            {
                'error_code': error['error_code'],
                'error_msg': error_messages.get(
                    error['error_code'],
                    *error['msg_params']),
            } for error in list(errors)
        ]
        super().__init__(self.status_code, self.detail)


def create_error(error_code: str, *msg_params) -> dict:
    """ エラー生成
    """
    return {
        'error_code': error_code,
        'msg_params': msg_params,
    }

カスタム例外をスローする

fastapi_sample/api/v1/auth.py
from fastapi import Request
from exception import (
    ApiException,
    create_error,
    error_messages,
)


class AuthAPI:
    """ 認証に関するAPI
    """
    @classmethod
    def login(cls, request: Request):
        """ ログインAPI
        """
        # カスタム例外をスロー
        raise ApiException(
            create_error(error_messages.FAILURE_LOGIN)
        )
        return 'ログイン成功'

リクエストミドルウェアを修正する

fastapi_sample/middleware/__init__.py
from exception import ApiException  # 追加
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)


        # アプリケーション例外
        except ApiException as ae:  # 追加
            request.state.db_session.rollback()
            raise ae  # 追加

        except Exception as e:
            request.state.db_session.rollback()
            raise e

        else:
            request.state.db_session.commit()
            return response

        finally:
            request.state.db_session.remove()

▼フロント側(エラーレスポンスを確認したいだけなのでスルーでOKです)

sample.js
axios.interceptors.response.use(
  response => {
    return response;
  },
  error => {
    // エラーレスポンスをコンソール表示
    console.log(error.response);
    ...
    ..
    .

カスタム例外がスローされ、フロント側でエラーレスポンスを確認することができました。
image.png

システムエラーについて

アプリが意図的に返す例外は問題ないですが、意図せぬ例外(システムエラー)はどうでしょうか。

fastapi_sample/api/v1/auth.py
from fastapi import Request


class AuthAPI:
    """ 認証に関するAPI
    """
    @classmethod
    def login(cls, request: Request):
        """ ログインAPI
        """
        result = 1 / 0  # 0除算でエラー
        return result

 
結果はCORSエラーが返却された上に、エラーレスポンス(error.response)の中身がundefinedです。
(CORSエラーは多分エラーの形式が不正だから・・・??)
image.png

システム例外用のカスタムExceptionを作成

fastapi_sample/exception/__init__.py
import traceback  # 追加
from fastapi import status, HTTPException
from exception import error_messages


class ApiException(HTTPException):
    ...
    ..
    .


class SystemException(HTTPException):
    """ システム例外
    """
    def __init__(self, e: Exception) -> None:
        self.exc = e
        self.stack_trace = traceback.format_exc()
        self.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
        self.detail = [
            {
                'error_code': error_messages.INTERNAL_SERVER_ERROR,
                'error_msg': error_messages.get(
                    error_messages.INTERNAL_SERVER_ERROR)
            }
        ]
        super().__init__(self.status_code, self.detail)

リクエストミドルウェアを修正

fastapi_sample/middleware/__init__.py
from exception import (
    ApiException,
    SystemException,  # 追加
)
from fastapi import Request, Response
from fastapi.responses import JSONResponse  # 追加
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)


        except ApiException as ae:
            request.state.db_session.rollback()
            # アプリケーション例外もシステム例外に合わせてResponseを返却する形式に変える
            return JSONResponse(
                se.detail,
                status_code=se.status_code)

        except Exception as e:
            request.state.db_session.rollback()
            se = SystemException(e)  # カスタムシステム例外に変換
            # raise seとしても、フロント側のエラーレスポンスはなぜか"undefined"のままなのでResponseを返却する
            # API例外と同じくHTTPExceptionを継承しているのに何故・・・∑(゚Д゚)
            # raise se
            return JSONResponse(
                se.detail,
                status_code=se.status_code)

        else:
            request.state.db_session.commit()
            return response

        finally:
            request.state.db_session.remove()

フロント側でシステムエラーのエラーレスポンスを受け取ることができました。
image.png

終わり

FastAPIでCRUDを実装してみました。
フルスタックフレームワークのDjango(RestはDRF)ばかり使っていたせいで、初め「なんだこの使いづらいフレームワークは・・・」とか思っていましたが、今ではもうDjangoよりFastAPI派になってしまいました。
ガシガシ環境周りのコードを自分で実装できて楽しいですし、何より成長に繋がりました。
 
公式ドキュメントを読みきれていないので、他にもいい方法はあるかと思いますので、
コメントでこっそり教えてもらえると嬉しいです( ´ノω`)
 
私が所属しているFabeee株式会社はお仕事 と 一緒に働くお仲間を随時募集しております!!!!(宣伝)

話を聞いてみたい方はこちら
SES/受託開発のご依頼についてはこちら

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FastAPI + SQLAlchemy(postgresql)によるCRUD API実装ハンズオン(Dockerやミドルウェア, テストコードなども含む)

アーキテクチャ

python: v3.8.5
postgresql: v12.4
fastapi: v0.60.2
SQLAlchemy: v1.3.18

マイグレーションツール

alembic: v1.4.2

FastAPIとは

詳細は既にQiitaにチョコチョコ記事あるので割愛します。
 
ドキュメントが豊富(読みきれてない)で、Swaggerと互換性あるのがとても素晴らしいです。
(パフォーマンスも良いらしいがベンチマーク測ったことないので断言できない、でも多分速い。)
 
しっかり触ったことのあるフレームワークはDjangoだけなのでミドルウェアやテストコード周り苦労した・・・。
(Djangoはフルスタックフレームワークで、勝手にいろいろやってくれるからミドルウェアとかテストの実装環境とかあまり気にしたことない)

Python3.8.5の仮装環境作成

pyenv, virtualenvが入ってない場合は下記を参考に入れてください。
Mac
Windows

$ pyenv virtualenv 3.8.5 env_fastapi_sample

仮装環境を適用

プロジェクトルートを作成

$ mkdir fastapi_sample

プロジェクトルートにcd

$ cd fastapi_sample

仮装環境を適用

$ pyenv local env_fastapi_sample

requirements.txt作成/インストール

$ touch requirements.txt

requirements.txtにfastapiを追記

fastapi_sample/requirements.txt
fastapi==0.60.2
uvicorn==0.11.8

仮装環境にインストール

$ pip3 install -r requirements.txt

エントリーポイント(main.py)を作成して、Swagger-UIを表示してみる

エントリーポイント(main.py)作成

$ touch main.py

main.pyを編集

fastapi_sample/main.py
from fastapi import FastAPI

app = FastAPI()


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

Swagger-UI表示

$ uvicorn main:app --reload

ブラウザから「http://127.0.0.1:8000」にアクセスして{"message":"Hello World"}が表示されていれば完了です。

Docker化

Nginxのリバースプロキシによってアプリケーションをhttpsで公開するようにします。

ベースのdocker-compose.yml

fastapi_sample/docker-compose.yml
version: '3'
services:
# Nginxコンテナ
  nginx:
    container_name: nginx_fastapi_sample
    image: nginx:alpine
    depends_on:
      - app
      - db
    environment:
      TZ: "Asia/Tokyo"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - ./docker/nginx/ssl:/etc/nginx/ssl

# アプリケーションコンテナ
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: app_fastapi_sample
    volumes:
      - '.:/fastapi_sample/'
    environment:
      - LC_ALL=ja_JP.UTF-8
    expose:
      - 8000
    depends_on:
      - db
    entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample
    command: bash /fastapi_sample/docker/rundevserver.sh
    restart: always
    tty: true

# DBコンテナ
  db:
    image: postgres:12.4-alpine
    container_name: db_fastapi_sample
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=db_fastapi_sample
      - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --locale=C
    volumes:
      - db_data:/var/lib/postgresql/data
    ports:
      - '5432:5432'

volumes:
  db_data:
    driver: local

Nginxコンテナの設定

confファイルを用意する

$ mkdir -p nginx/conf.d
$ touch nginx/conf.d/app.conf
fastapi_sample/docker/nginx/conf.d/app.conf
upstream backend {
    server app:8000;  # appはdocker-compose.ymlの「app」
}

# 80番ポートへのアクセスは443番ポートへのアクセスに強制する
server {
    listen 80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    ssl_certificate     /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;
    ssl_protocols        TLSv1.2 TLSv1.3;

    location / {
        proxy_set_header    Host    $http_host;
        proxy_set_header    X-Real-IP    $remote_addr;
        proxy_set_header    X-Forwarded-Host      $http_host;
        proxy_set_header    X-Forwarded-Server    $http_host;
        proxy_set_header    X-Forwarded-Server    $host;
        proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto  $scheme;
        proxy_redirect      http:// https://;

        proxy_pass http://backend;
    }

    # ログを出力したい場合はコメントアウト外してください
    # access_log /var/log/nginx/access.log;
    # error_log /var/log/nginx/error.log;
}

server_tokens off;

NginxのSSL化に必要なファイルを用意

$ mkdir -p nginx/conf.d nginx/ssl

OpenSSLを使って秘密鍵(server.key)を生成する

# ディレクトリ移動
$ cd nginx/conf.d

# 秘密鍵生成
$ openssl genrsa 2024 > server.key

# 確認
$ ls -ll
total 8
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

証明書署名要求(server.csr)を生成

$ openssl req -new -key server.key > server.csr
$ openssl req -new -key server.key > server.csr
...
..
.
Country Name (2 letter code) [AU]:JP  # 国を示す2文字のISO略語
State or Province Name (full name) [Some-State]:Tokyo  # 会社が置かれている都道府県
Locality Name (eg, city) []:Chiyodaku  # 会社が置かれている市区町村
Organization Name (eg, company) [Internet Widgits Pty Ltd]:fabeee  # 会社名
Organizational Unit Name (eg, section) []:-  # 部署名(ハイフンにしました。)
Common Name (e.g. server FQDN or YOUR name) []:localhost(ウェブサーバのFQDN。一応localhostにした)
Email Address []:  # 未入力でエンター

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:  # 未入力でエンター
An optional company name []:  # 未入力でエンター
...
..
.
$ ls -ll
total 16
-rw-r--r--  1 user  staff   980 10 24 13:27 server.csr  # 証明書署名要求ができた
-rw-r--r--  1 user  staff  1647 10 24 13:23 server.key

サーバ証明書(server.crt)を生成

openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt

% ls -ll
total 24
-rw-r--r--  1 tabata  staff  1115 10 24 13:40 server.crt  # サーバ証明書ができた
-rw-r--r--  1 tabata  staff   948 10 24 13:29 server.csr
-rw-r--r--  1 tabata  staff  1647 10 24 13:23 server.key

サーバ証明書を信頼する

server.crtをダブルクリック
image.png

「この証明書を使用するとき」のプルダウンから「常に信頼する」を選択する
image.png

最終的にこうなっていればNginxの設定は完了です

fastapi_sample
├── docker
│   └── nginx
│       ├── conf.d
│       │   └── app.conf
│       └── ssl
│           ├── server.crt
│           ├── server.csr
│           └── server.key
├── docker-compose.yml
├── main.py
└── requirements.txt

app(FastAPI)コンテナの設定

Dockerfile

イメージはubuntu20.04です。
・pyenvでコンテナ内のpythonバージョンをv3.8.5にしている
・apt installで必要なモジュールをインストール(不要なものは削ってもらって構いません)
pip3 install -r requirements.txtでpythonモジュールをコンテナ内にインストール

FROM ubuntu:20.04
ENV DEBIAN_FRONTEND=noninteractive
ENV HOME /root
ENV PYTHONPATH /fastapi_sample/
ENV PYTHON_VERSION 3.8.5
ENV PYTHON_ROOT $HOME/local/python-$PYTHON_VERSION
ENV PATH $PYTHON_ROOT/bin:$PATH
ENV PYENV_ROOT $HOME/.pyenv

RUN mkdir /fastapi_sample \
    && rm -rf /var/lib/apt/lists/*

RUN apt update && apt install -y git curl locales python3-pip python3-dev python3-passlib python3-jwt \
    libssl-dev libffi-dev zlib1g-dev libpq-dev postgresql

RUN echo "ja_JP UTF-8" > /etc/locale.gen \
    && locale-gen

RUN git clone https://github.com/pyenv/pyenv.git $PYENV_ROOT \
    && $PYENV_ROOT/plugins/python-build/install.sh \
    && /usr/local/bin/python-build -v $PYTHON_VERSION $PYTHON_ROOT

WORKDIR /fastapi_sample
ADD . /fastapi_sample/
RUN LC_ALL=ja_JP.UTF-8 \
    && pip3 install -r requirements.txt

wait-for-it.sh

データベースが立ち上がるのを待ってからappコンテナを起動するようにするためのシェルスクリプトです。
 
docker-componseに記載しているdepends_onコンテナの起動順は制御できますが、
データベースが立ち上がっていない場合、appコンテナでエラーが発生することがあります。
(appコンテナ起動時にDB操作をするようなコマンドを実行しようとした場合、データベースが立ち上がっていないためにエラー、とか)

fastapi_sample/docker/wait-for-it.sh
#!/bin/sh

set -e

# 引数はdocker-compose.ymlで指定している。
# app:
#   ...
#   ..
#   .
#   entrypoint: /fastapi_sample/docker/wait-for-it.sh db 5432 postgres postgres db_fastapi_sample  # ココ
#   ...
host="$1"
shift
port="$1"
shift
user="$1"
shift
password="$1"
shift
database="$1"
shift
cmd="$@"

echo "Waiting for postgresql"
until pg_isready -h"$host" -U"$user" -p"$port" -d"$database"
do
  echo -n "."
  sleep 1
done

>&2 echo "PostgreSQL is up - executing command"
exec $cmd

# 僕は優しいのでMySQL用も書いてあげるのだ
#!/bin/sh

# set -e

# host="$1"
# shift
# user="$1"
# shift
# password="$1"
# shift
# cmd="$@"

# echo "Waiting for mysql"
# until mysqladmin ping -h "$host" --silent
# do
#     echo -n "."
#     sleep 1
# done

# >&2 echo "MySQL is up - executing command"
# exec $cmd

rundevserver.sh

uvicornを起動してアプリケーションを起動するためのシェルスクリプトファイルです。
docker-compose up時に毎回requirements.txtのモジュールを読み込む
・uvicornでアプリケーションを起動する
・--reloadでホットリロード(pythonファイルを変更すると即反映してくれる)
・--portを8000から変える場合はnginxのapp.conf内の8000も変える必要がある

pip3 install -r requirements.txt

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

最終的なディレクトリ構成

fastapi_sample
├── Dockerfile
├── docker
│   ├── nginx
│   │   ├── conf.d
│   │   │   └── app.conf
│   │   └── ssl
│   │       ├── server.crt
│   │       ├── server.csr
│   │       └── server.key
│   ├── rundevserver.sh
│   └── wait-for-it.sh
├── docker-compose.yml
├── main.py
└── requirements.txt

コンテナ起動

$ docker-compose up -d

こんなエラーが出た場合は、Dockerイメージがディスクを逼迫している可能性があるので、docker image pruneで不要なイメージを削除してください。(僕はこれで40GB分ディスクに空きができました?)

.
..
...
Get:1 http://security.debian.org/debian-security buster/updates InRelease [65.4 kB]
Get:2 http://deb.debian.org/debian buster InRelease [122 kB]
Get:3 http://deb.debian.org/debian buster-updates InRelease [49.3 kB]
Err:1 http://security.debian.org/debian-security buster/updates InRelease
  At least one invalid signature was encountered.
Err:2 http://deb.debian.org/debian buster InRelease
  At least one invalid signature was encountered.
Err:3 http://deb.debian.org/debian buster-updates InRelease
  At least one invalid signature was encountered.
Reading package lists...
W: GPG error: http://security.debian.org/debian-security buster/updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://security.debian.org/debian-security buster/updates InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster InRelease' is not signed.
W: GPG error: http://deb.debian.org/debian buster-updates InRelease: At least one invalid signature was encountered.
E: The repository 'http://deb.debian.org/debian buster-updates InRelease' is not signed.
...
..
.

ブラウザからアクセス

コンテナの起動が完了したか確認します。
STATUS列が"Up..."となっていればOKです。

$ docker ps
CONTAINER ID        IMAGE                  COMMAND                  CREATED              STATUS              PORTS                                      NAMES
b77fa2465fb1        nginx:alpine           "/docker-entrypoint.…"   About a minute ago   Up About a minute   0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp   nginx_fastapi_sample
eb18438efd2c        fastapi_sample_app     "/fastapi_sample/doc…"   About a minute ago   Up About a minute   8000/tcp                                   app_fastapi_sample
383b0e46af68        postgres:12.4-alpine   "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:5432->5432/tcp                     db_fastapi_sample

https://localhostにアクセスして、{"message":"Hello World"}が表示されれば完了です。

ライブラリ「pydantic」を使用して環境変数(.env)を扱う

.envファイル作成

$ touch .env
fastapi_sample/.env
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample

「pydatic」を使って環境変数を読み込む設定ファイルを作成

ライブラリ「pydantic」を使うと、.env から値を読み込む処理を簡単に実装できます。
また型に合わせてキャストしてくれたりするので超便利。
os.getenv('DEBUG') == 'True' みたいな気持ち悪い条件式を書かなくてよくなります。(distutils.util.strtobool使えって話ですが、、、)

pydanticをインストール

fastapi_sample/requirements.txt
pydantic[email]==1.6.1  # 追記したらpip3 install

フォルダ「core」を作成、その直下に「config.py」を作成

$ mkdir core
$ touch core/config.py
fastapi_sample/core/config.py
import os
from functools import lru_cache
from pydantic import BaseSettings

PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool  # .envから読み込んだ値をbool型にキャッシュ
    database_url: str

    class Config:
        env_file = os.path.join(PROJECT_ROOT, '.env')


@lru_cache
def get_env():
    """ 「@lru_cache」でディスクから読み込んだ.envの結果をキャッシュする
    """
    return Environment()

alembic(マイグレーションツール)環境の用意 と モデルを用意

何はともあれインストール

requirements.txtに alembic と sqlalchemy 追記してからpip3 install -r requirements.txtでインストール
(Dockerの手順を踏んでいる人はdocker-compose restart app または docker-compose exec app pip3 install -r requiements.txt

fastapi_samepl/requirements.txt
alembic==1.4.2  # 追記
.
..
...
psycopg2==2.8.6  # 追記
.
..
...
SQLAlchemy==1.3.18  # 追記
SQLAlchemy-Utils==0.36.8  # 追記

プロジェクトルート直下にalembic環境を作成する

$ alembic init migrations  # Dockerの手順を踏んだ人はdocker-compose exec app alembic init migrations

プロジェクトルートにalembicテンプレートが作成されます。(alembic.ini, migrationsフォルダ)

fastapi_sample(プロジェクトルート)
├── ...
├── ..
├── .
├── alembic.ini
├── migrations
│   ├── README
│   ├── env.py
│   ├── script.py.mako
│   └── versions
└── ...

ベースモデルを用意

まずはベースモデルを実装します。

touch models.py
fastapi_sample/migrations/models.py
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import INTEGER, TIMESTAMP
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    """ ベースモデル
    """
    __abstract__ = True

    id = Column(
        INTEGER,
        primary_key=True,
        autoincrement=True,
    )

    created_at = Column(
        'created_at',
        TIMESTAMP(timezone=True),
        server_default=current_timestamp(),
        nullable=False,
        comment='登録日時',
    )

    updated_at = Column(
        'updated_at',
        TIMESTAMP(timezone=True),
        onupdate=current_timestamp(),
        comment='最終更新日時',
    )

    @declared_attr
    def __mapper_args__(cls):
        """ デフォルトのオーダリングは主キーの昇順

        降順にしたい場合
        from sqlalchemy import desc
        # return {'order_by': desc('id')}
        """
        return {'order_by': 'id'}

ユーザーモデルを用意

モデルはなんでもいいですが、認証編を書くときに使えそうなのでユーザーモデルを作成します。
モジュール分割はあえてしていません。(マイグレーションファイル生成時などで不都合発生して手間なので、しない方がいいかも?)

fastapi_sample/migrations/models.py
from sqlalchemy import (
    BOOLEAN,  # 追加
    Column
    INTEGER,
    TIMESTAMP,
    VARCHAR,  # 追加
)
from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.sql.functions import current_timestamp

Base = declarative_base()


class BaseModel(Base):
    ...
    ..
    .


class User(BaseModel):
    __tablename__ = 'users'

    email = Column(VARCHAR(254), unique=True, nullable=False)
    password = Column(VARCHAR(128), nullable=False)
    last_name = Column(VARCHAR(100), nullable=False)
    first_name = Column(VARCHAR(100), nullable=False)
    is_admin = Column(BOOLEAN, nullable=False, default=False)
    is_active = Column(BOOLEAN, nullable=False, default=True)

env.pyを編集する

fastapi_sample/migrations/env.py
from migrations.models import Base  # 追加
...
..
.
target_metadata = Base.metadata  # メタデータをセット
...
..
.

DBの接続先を変更

モデルを実装したところで、早速マイグレーションを行いたいところです。
が、alembicがプロジェクトテンプレートのままなのでalembic.iniのなかのデータベースURLもテンプレートのままです。

fastapi_sample/alembic.ini
.
..
...
sqlalchemy.url = driver://user:pass@localhost/dbname
...
..
.

なので勿論alembicのマイグレーションファイル生成コマンドなどは失敗してしまいます。

$ docker-compose exec app alembic revision --autogenerate
Traceback (most recent call last):
  File "/root/local/python-3.8.5/bin/alembic", line 8, in <module>
    sys.exit(main())
  ...
  (長いので省略)
  .
    cls = registry.load(name)
  File "/root/local/python-3.8.5/lib/python3.8/site-packages/sqlalchemy/util/langhelpers.py", line 267, in load
    raise exc.NoSuchModuleError(
sqlalchemy.exc.NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:driver

alembic.iniを直接書き換えるのもいいですが、開発環境やステージング、本番環境でそれぞれ異なるalembic.iniファイルができてしまい気持ち悪いです。
なので、マイグレーションファイル生成時やマイグレート実行時は、alembic.iniのデータベースURLを一時的に.envDATABASE_URLで書き換えるようにしてみます。

python-dotenvをインストール

fastapi_sample/requirements.txt
python-dotenv==0.14.0  # 追記したらインストールする

env.pyを修正

fastapi_sample/migrations/env.py
import os
from core.config import PROJECT_ROOT
from dotenv import load_dotenv
.
..
...

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.

# alembic.iniの'sqlalchemy.url'を.envのDATABASE_URLで書き換える
load_dotenv(dotenv_path=os.path.join(PROJECT_ROOT, '.env'))
config.set_main_option('sqlalchemy.url', os.getenv('DATABASE_URL'))


def run_migrations_offline():
    ...
    ..
    .

再度マイグレーションファイル生成コマンド実行

% docker-compose exec app alembic revision --autogenerate
None /fastapi_sample
INFO  [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO  [alembic.runtime.migration] Will assume transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'users'
  Generating /fastapi_sample/migrations/versions/2bc0a23e563c_.py ...  done

マイグレーションファイルを生成できました。

migrations
├── ...
├── ...
├── models.py
└── versions
    └── 2bc0a23e563c_.py  # マイグレーションファイルができた。

ただ、今のままだとマイグレーションファイルの生成順がパッと見でわからないのと、マイグレーションファイル内に記載してある生成日時(Create Date)がUTCのままなので日本時間に変えたいです。

alembic.iniを修正します。

fastapi_sample/alembic.ini
.
..
...
[alembic]
# path to migration scripts
script_location = migrations

# ファイルの接頭に「YYYYMMDD_HHMMSS_」がつくようにする
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(rev)s_%%(slug)s

# タイムゾーンを日本時間に
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
timezone = Asia/Tokyo
...
..
.

先ほど生成したマイグレーションファイルを消して、再度マイグレーションファイルを生成します。

migrations
├── ...
├── ...
├── models.py
└── versions
    └── 20201024_200033_8a19c4c579bf_.py  # ファイル内のCreate Dateも日本時間になっている

マイグレート実行

$ alembic upgrade head  # Dockerの手順を踏んだ人はdocker-compose exec app alembic upgrade head

ちゃんとユーザーテーブルが作成されています。
(alembic_versionはalembicがバージョン管理に使用するテーブルで、自動生成されます)
image.png

コンテナ起動時にマイグレートが実行されるようにする

これでコンテナを起動するたびにマイグレートが実行されるようになります。

fastapi_sample/docker/rundevserver.sh
pip3 install -r requirements.txt

alembic upgrade head  # 追記

uvicorn main:app\
    --reload\
    --port 8000\
    --host 0.0.0.0\
    --log-level debug

データアクセスクラス作成

ベースのデータアクセスクラスを作成

単純な全件取得や1件取得、登録、更新、削除などを記載します。
base.pyに記載しているget_db_session()は後述するリクエストミドルウェアやテストコード実行時などで大変重要な役割を果たします、今は気にしなくて良いです。

mkdir crud
touch crud/base.py
fastapi_sample/crud/base.py
from core.config import get_env
from migrations.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session, query
from typing import List, TypeVar

ModelType = TypeVar("ModelType", bound=Base)

connection = create_engine(
    get_env().database_url,
    echo=get_env().debug,
    encoding='utf-8',
)

Session = scoped_session(sessionmaker(connection))


def get_db_session() -> scoped_session:
    yield Session


class BaseCRUD:
    """ データアクセスクラスのベース
    """
    model: ModelType = None

    def __init__(self, db_session: scoped_session) -> None:
        self.db_session = db_session
        self.model.query = self.db_session.query_property()

    def get_query(self) -> query.Query:
        """ ベースのクエリ取得
        """
        return self.model.query

    def gets(self) -> List[ModelType]:
        """ 全件取得
        """
        return self.get_query().all()

    def get_by_id(self, id: int) -> ModelType:
        """ 主キーで取得
        """
        return self.get_query().filter_by(id=id).first()

    def create(self, data: dict = {}) -> ModelType:
        """ 新規登録
        """
        obj = self.model()
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.add(obj)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def update(self, obj: ModelType, data: dict = {}) -> ModelType:
        """ 更新
        """
        for key, value in data.items():
            if hasattr(obj, key):
                setattr(obj, key, value)
        self.db_session.flush()
        self.db_session.refresh(obj)
        return obj

    def delete_by_id(self, id: int) -> None:
        """ 主キーで削除
        """
        obj = self.get_by_id(id)
        if obj:
            obj.delete()
            self.db_session.flush()
        return None

ユーザーデータアクセスクラスを作成

touch crud/crud_user.py
fastapi_sample/crud/crud_user.py
from crud.base import BaseCRUD
from migrations.models import User


class CRUDUser(BaseCRUD):
    """ ユーザーデータアクセスクラスのベース
    """
    model = User

API実装

お待たせしました、ようやくAPI実装編です。

API実装前準備

リクエスト情報にDBセッションを格納する依存性注入用(Depends)の関数を実装します。
このFastAPIの依存性注入システムがとても強力で、パラメーターをまとめたりもすることができます。
完全に理解しているわけではないので、申し訳ないのですが詳細は公式ドキュメントを参照してください。

mkdir dependencies
touch dependencies/__init__.py
dependencies/__init__.py
from fastapi import Depends, Request
from crud.base import get_db_session
from sqlalchemy.orm import scoped_session


async def set_db_session_in_request(
    request: Request,
    db_session: scoped_session = Depends(get_db_session)
):
    """ リクエストにDBセッションをセットする
    """
    request.state.db_session = db_session

APIを実装していくフォルダ作成

mkdir -p api/v1

ユーザーの一覧取得API実装

touch api/v1/user.py
fastapi_sample/api/v1/user.py
from crud.base import Session
from crud.crud_user import CRUDUser
from fastapi import Request
from fastapi.encoders import jsonable_encoder


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request):
        """ 一覧取得
        """
        # 依存性注入システムを用いて、リクエスト情報にDBセッションをセットしたので、
        # ここで「request.state.db_session」を使用することができる
        return jsonable_encoder(CRUDUser(request.state.db_session).gets())

APIルーター実装

ルーターのdependencies=[Depends(set_db_session_in_request)]が今後重要な機能を果たしてくれます

mkdir -p api/endpoints/v1
touch api/endpoints/v1/user.py
fastapi_sample/api/endpoints/v1/user.py
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request

router = APIRouter()


@router.get(
    '/',
    dependencies=[Depends(set_db_session_in_request)])
async def gets(request: Request):
    return UserAPI.gets(request)

ルーターをエントリーポイントに登録する

APIルーターを作成

touch api/endpoints/v1/__init__.py
fastapi_sample/api/endpoints/v1/__init__.py
from fastapi import APIRouter
from api.endpoints.v1 import user

api_v1_router = APIRouter()
api_v1_router.include_router(
    user.router,
    prefix='users',
    tags=['users'])

main.pyを編集

fastapi_sample/main.py
from api.endpoints.v1 import api_v1_router  # 追記
from fastapi import FastAPI

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')  # 追記


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

ブラウザからhttps://localhost/docsアクセスすると実装したAPIが表示されているはずです。
image.png

登録/更新用にスキーマ(フォーム?)を定義する

mkdir api/schemas
touch api/schemas/user.py
fastapi_sample/api/schemas/user.py
from pydantic import BaseModel, EmailStr
from typing import Optional


class BaseUser(BaseModel):
    email: EmailStr
    last_name: str
    first_name: str
    is_admin: bool


class CreateUser(BaseUser):
    password: str


class UpdateUser(BaseUser):
    password: Optional[str]
    last_name: Optional[str]
    first_name: Optional[str]
    is_admin: bool


class UserInDB(BaseUser):
    class Config:
        orm_mode = True

ユーザーの登録/更新/削除のAPIを追加

fastapi_sample/api/v1/user.py
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from crud.crud_user import CRUDUser
from fastapi import Request
# from fastapi.encoders import jsonable_encoder  # 削除
from typing import List


class UserAPI:
    """ ユーザーに関するAPI
    """
    @classmethod
    def gets(cls, request: Request) -> List[UserInDB]:
        """ 一覧取得
        """
        return CRUDUser(request.state.db_session).gets()  # jsonable_encoderは使わない

    @classmethod
    def create(
        cls,
        request: Request,
        schema: CreateUser
    ) -> UserInDB:
        """ 新規登録
        """
        return CRUDUser(request.state.db_session).create(schema.dict())

    @classmethod
    def update(
        cls,
        request: Request,
        id: int,
        schema: UpdateUser
    ) -> UserInDB:
        """ 更新
        """
        crud = CRUDUser(request.state.db_session)
        obj = crud.get_by_id(id)
        return CRUDUser(request.state.db_session).update(obj, schema.dict())

    @classmethod
    def delete(cls, request: Request, id: int) -> None:
        """ 削除
        """
        return CRUDUser(request.state.db_session).delete_by_id(id)

ユーザーのAPIルーターを編集

fastapi_sample/api/endpoints/v1/user.py
from api.schemas.user import CreateUser, UpdateUser, UserInDB
from api.v1.user import UserAPI
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from typing import List

router = APIRouter()


@router.get(
    '/',
    response_model=List[UserInDB],  # response_modelを追加
    dependencies=[Depends(set_db_session_in_request)])
def gets(request: Request) -> List[UserInDB]:
    """ 一覧取得
    """
    return UserAPI.gets(request)


@router.post(
    '/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def create(request: Request, schema: CreateUser) -> UserInDB:
    """ 新規登録
    """
    return UserAPI.create(request, schema)


@router.put(
    '/{id}/',
    response_model=UserInDB,
    dependencies=[Depends(set_db_session_in_request)])
def update(request: Request, id: int, schema: UpdateUser) -> UserInDB:
    """ 更新
    """
    return UserAPI.update(request, id, schema)


@router.delete(
    '/{id}/',
    dependencies=[Depends(set_db_session_in_request)])
def delete(request: Request, id: int) -> None:
    """ 削除
    """
    return UserAPI.delete(request, id)

ブラウザからアクセスして確認

CRUDのAPIを無事実装できました。
image.png

routerにresponse_modelを指定すると、DBから取得したオブジェクトのJSONシリアライズを開発者が明示的に実装する必要がなくなります。(jsonable_encoder()を使わなくて良くなったのはこれのおかげ)
また、swagger-UI上にも反映してくれます。
※一覧取得のレスポンス
image.png

リクエストミドルウェアを実装する

さて、APIは実行できましたが、DBセッションのコミット実行していないので登録/更新/削除を実行してもDBが変更されません。
変更をDBに反映させるため、以下のようなリクエストミドルウェアを実装して適用します。
・リクエストが完了したらDBセッションコミットしてからDBセッション破棄
・エラー発生時はロールバックしてからDBセッション破棄

mkdir middleware
touch middleware/__init__.py
fastapi_sample/middleware/__init__.py
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            request.state.db_session.rollback()
            raise e

        # 正常終了時
        else:
            request.state.db_session.commit()
            return response

        # DBセッションの破棄は必ず行う
        finally:
            request.state.db_session.remove()

エントリーポイントにミドルウェアを適用する

fastapi_sample/main.py
from api.endpoints.v1 import api_v1_router
from fastapi import FastAPI
from middleware import HttpRequestMiddleware  # 追加

app = FastAPI()

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)  # 追加


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

CRUD実行で確認

無事DBに反映されるようになりました。
image.png

テストコード実装編

CRUDを実装できたので、次はテストコードを実装していきます。
とはいえ、単純にテストコードからAPIを実行してしまうと、DBに登録・更新・削除が実行されるため、テスト実行ごとに結果が変わってしまう可能性があります。
それを防ぐため、テスト関数ごとにクリーンなDBを作成し、そのDBを使用してテストするようにしたいと思います。

pytestをインストール

fastapi_sample/requirements.txt
pytest==6.1.0  # 追記したらrequirements.txtをインストールし直すのを忘れずに

テスト用のDB接続情報を環境変数に追加

fastapi_sample/.env
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample
TEST_DATABASE_URL=postgresql://postgres:postgres@db:5432/test_db_fastapi_sample  # 追加
fastapi_sample/core/config.py
class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    debug: bool
    database_url: str
    test_database_url: str  # 追加

テストコード実装用のフォルダ作成

mkdir tests

APIのベーステストクラスを作成

touch tests/base.py
fastapi_sample/tests/base.py
import psycopg2
from core.config import get_env
from crud.base import get_db_session
from fastapi.testclient import TestClient
from main import app
from migrations.models import Base
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
from sqlalchemy_utils import database_exists, drop_database

test_db_connection = create_engine(
    get_env().test_database_url,
    encoding='utf8',
    pool_pre_ping=True,
)


class BaseTestCase:
    def setup_method(self, method):
        """ 前処理
        """
        # テストDB作成
        self.__create_test_database()

        self.db_session = self.get_test_db_session()

        # APIクライアントの設定
        self.client = TestClient(app, base_url='https://localhost',)

        # DBをテスト用のDBでオーバーライド
        app.dependency_overrides[get_db_session] = \
            self.override_get_db

    def teardown_method(self, method):
        """ 後処理
        """
        # テストDB削除
        self.__drop_test_database()

        # オーバーライドしたDBを元に戻す
        app.dependency_overrides[self.override_get_db] = \
            get_db_session

    def override_get_db(self):
        """ DBセッションの依存性オーバーライド関数
        """
        yield self.db_session

    def get_test_db_session(self):
        """ テストDBセッションを返す
        """
        return scoped_session(sessionmaker(bind=test_db_connection))

    def __create_test_database(self):
        """ テストDB作成
        """
        # テストDBが削除されずに残ってしまっている場合は削除
        if database_exists(get_env().test_database_url):
            drop_database(get_env().test_database_url)

        # テストDB作成
        _con = \
            psycopg2.connect('host=db user=postgres password=postgres')
        _con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
        _cursor = _con.cursor()
        _cursor.execute('CREATE DATABASE test_db_fastapi_sample')

        # テストDBにExtension追加
        _test_db_con = psycopg2.connect(
            'host=db dbname=test_db_fastapi_sample user=postgres password=postgres'
        )
        _test_db_con.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)

        # テストDBにテーブル追加
        Base.metadata.create_all(bind=test_db_connection)

    def __drop_test_database(self):
        """ テストDB削除
        """
        drop_database(get_env().test_database_url)

ポイントはこのDBオーバーライド部分です。
アプリケーションが参照するDBをテストDBに切り替えています。

# DBをテスト用のDBでオーバーライド
app.dependency_overrides[get_db_session] = self.override_get_db

image.png

こうすることで、APIやミドルウェアで使用している「request.state.db_session」もテストDBに切り替えることができます。

一覧取得のテストコードを実装してみる

requirements.txt
requests==2.24.0  # 追記したら必ずrequirements.txtをインストール
mkdir -p tests/api/v1
touch tests/api/v1/test_user.py  # "test_"を接頭に付けないとpytest実行時にスルーされてしまうので必ずつける
fastapi_sample/tests/api/v1/test_user.py
import json
from crud.crud_user import CRUDUser
from fastapi import status
from tests.base import BaseTestCase


class TestUserAPI(BaseTestCase):
    """ ユーザーAPIのテストクラス
    """
    TEST_URL = '/api/v1/users/'

    def test_gets(self):
        """ 一覧取得のテスト
        """
        # テストユーザー登録
        test_data = [
            {
                'email': 'test1@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': False
            },
            {
                'email': 'test2@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': True
            },
            {
                'email': 'test3@example.com',
                'password': 'password',
                'last_name': 'last_name',
                'first_name': 'first_name',
                'is_admin': False
            },
        ]
        for data in test_data:
            CRUDUser(self.db_session).create(data)
            self.db_session.commit()

        response = self.client.get(self.TEST_URL)

        # ステータスコードの検証
        assert response.status_code == status.HTTP_200_OK

        # 取得した件数の検証
        response_data = json.loads(response._content)
        assert len(response_data) == len(test_data)

        # レスポンスの内容を検証
        expected_data = [{
            'email': item['email'],
            'last_name': item['last_name'],
            'first_name': item['first_name'],
            'is_admin': item['is_admin'],
        } for i, item in enumerate(test_data, 1)]
        assert response_data == expected_data

テスト実行

pytest  # Dockerの手順を踏んだ方は、docker-compose exec app pytest
# print()文の出力を確認したい場合は、pytest -v --capture=no
% docker-compose exec app pytest -v --capture=no
================================================================================================== test session starts ===================================================================================================
platform linux -- Python 3.8.5, pytest-6.1.0, py-1.9.0, pluggy-0.13.1 -- /root/local/python-3.8.5/bin/python3.8
cachedir: .pytest_cache
rootdir: /fastapi_sample
collected 1 item

tests/api/v1/test_user.py::TestUserAPI::test_gets PASSED  # 成功

==================================================================================================== warnings summary ====================================================================================================
<string>:2
  <string>:2: SADeprecationWarning: The mapper.order_by parameter is deprecated, and will be removed in a future release. Use Query.order_by() to determine the ordering of a result set.

-- Docs: https://docs.pytest.org/en/stable/warnings.html

テストも無事成功しました。
データベースを確認してみましょう。
API実装時に試しに登録したデータしかありません。
テスト用のDBに切り替えてのテスト実行は成功したようです!

image.png

ローカルDBを汚さず、かつ テストケースごとのテストデータも干渉することなくテストコードを実行することができるようになりました。
やったね。

リクエストミドルウェア実装後、swagger-UI表示時「Internal Server Error」になる

swagger-UI表示時、「request.state」に「db_session」が存在しないため、Key Errorで「Internal Server Error」となります。

手取り早く直したい場合

リクエストミドルウェアの「request.state.db_session」を以下のように書き換えます。

fastapi_sample/middleware/__init__.py
from crud.base import Session  # 追加
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)

        # 予期せぬ例外
        except Exception as e:
            print(e)
            getattr(request.state, 'db_session', Session).rollback()  # これに書き換え
            raise e

        # 正常終了時
        else:
            getattr(request.state, 'db_session', Session).commit()  # これに書き換え
            return response

        # DBセッションの破棄は必ず行う
        finally:
            getattr(request.state, 'db_session', Session).remove()  # これに書き換え

リクエストミドルウェアは触りたくない場合

swagger-uiで表示するエンドポイントを全てオーバーライドするしかありません。

FastAPIクラスをオーバーライド

touch core/fastapi.py
fastapi_sample/core/fastapi.py
import fastapi
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.exception_handlers import (
    http_exception_handler,
    request_validation_exception_handler,
)
from fastapi.openapi.docs import (
    get_redoc_html,
    get_swagger_ui_html,
    get_swagger_ui_oauth2_redirect_html,
)
from fastapi.responses import JSONResponse, HTMLResponse
from starlette.exceptions import HTTPException


class FastAPI(fastapi.FastAPI):
    def setup(self) -> None:
        if self.openapi_url:
            urls = (server_data.get("url") for server_data in self.servers)
            server_urls = {url for url in urls if url}

            async def openapi(req: Request) -> JSONResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                if root_path not in server_urls:
                    if root_path and self.root_path_in_servers:
                        self.servers.insert(0, {"url": root_path})
                        server_urls.add(root_path)
                return JSONResponse(self.openapi(self, req))  # 書き換えているのはこの部分だけ

            self.add_route(self.openapi_url, openapi, include_in_schema=False)
        if self.openapi_url and self.docs_url:

            async def swagger_ui_html(req: Request) -> HTMLResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                openapi_url = root_path + self.openapi_url
                oauth2_redirect_url = self.swagger_ui_oauth2_redirect_url
                if oauth2_redirect_url:
                    oauth2_redirect_url = root_path + oauth2_redirect_url
                return get_swagger_ui_html(
                    openapi_url=openapi_url,
                    title=self.title + " - Swagger UI",
                    oauth2_redirect_url=oauth2_redirect_url,
                    init_oauth=self.swagger_ui_init_oauth,
                )

            self.add_route(
                self.docs_url,
                swagger_ui_html,
                include_in_schema=False
            )

            if self.swagger_ui_oauth2_redirect_url:

                async def swagger_ui_redirect(req: Request) -> HTMLResponse:
                    return get_swagger_ui_oauth2_redirect_html()

                self.add_route(
                    self.swagger_ui_oauth2_redirect_url,
                    swagger_ui_redirect,
                    include_in_schema=False,
                )
        if self.openapi_url and self.redoc_url:

            async def redoc_html(req: Request) -> HTMLResponse:
                root_path = req.scope.get("root_path", "").rstrip("/")
                openapi_url = root_path + self.openapi_url
                return get_redoc_html(
                    openapi_url=openapi_url, title=self.title + " - ReDoc"
                )

            self.add_route(self.redoc_url, redoc_html, include_in_schema=False)
        self.add_exception_handler(HTTPException, http_exception_handler)
        self.add_exception_handler(
            RequestValidationError, request_validation_exception_handler
        )

swagger-ui用のルーターを用意

touch api/endpoints/swagger_ui.py
fastapi_sample/api/endpoints/swagger_ui.py
from core.fastapi import FastAPI
from crud.base import Session
from dependencies import set_db_session_in_request
from fastapi import APIRouter, Depends, Request
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi

swagger_ui_router = APIRouter()


@swagger_ui_router.get(
    '/docs',
    include_in_schema=False,
    dependencies=[Depends(set_db_session_in_request)])
async def swagger_ui_html(request: Request):
    return get_swagger_ui_html(
        openapi_url=FastAPI().openapi_url,
        title=FastAPI().title,
        oauth2_redirect_url=FastAPI().swagger_ui_oauth2_redirect_url,
    )


@swagger_ui_router.get(
    '/redoc',
    include_in_schema=False,
    dependencies=[Depends(set_db_session_in_request)])
async def redoc_html(request: Request):
    return get_redoc_html(
        openapi_url=FastAPI().openapi_url,
        title=FastAPI().title + " - ReDoc",
    )


def openapi(app: FastAPI, request: Request):
    request.state.db_session = Session

    if app.openapi_schema:
        return app.openapi_schema
    openapi_schema = get_openapi(
        title="FastAPI",
        version="0.1.0",
        routes=app.routes,
    )
    app.openapi_schema = openapi_schema
    return app.openapi_schema

エントリーポイント修正

fastapi_sample/main.py
from api.endpoints.swagger_ui import openapi, swagger_ui_router
from api.endpoints.v1 import api_v1_router
from core.config import get_env
# from fastapi import FastAPI
from core.fastapi import FastAPI  # オーバーライドしたFastAPIクラスを使用する
from middleware import HttpRequestMiddleware

app = FastAPI(
    docs_url=None,  # Noneを設定しないとswagger-uiのルーターで定義したものに変わらない
    redoc_url=None,  # Noneを設定しないとswagger-uiのルーターで定義したものに変わらない
)

app.include_router(api_v1_router, prefix='/api/v1')

# ミドルウェアの設定
app.add_middleware(HttpRequestMiddleware)


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

# swagger-uiは開発時のみ表示されるようにする
if get_env().debug:
    app.include_router(swagger_ui_router)
    app.openapi = openapi

無事swagger-uiのエラーが解消され、表示されるようになりました。
image.png

【オマケ①】CORS問題を回避

このままだと、フロントエンドからのAPI呼び出し時にCORSエラー発生してしまいます。
image.png

この問題を回避するため、CORSミドルウェアを実装します。

.env環境変数を扱うクラスを編集

fastapi_sample/.env
ALLOW_HEADERS='["*"]'  # 追加
ALLOW_ORIGINS='["*"]'  # 追加
DEBUG=True
DATABASE_URL=postgresql://postgres:postgres@db:5432/db_fastapi_sample
TEST_DATABASE_URL=postgresql://postgres:postgres@db:5432/test_db_fastapi_sample
fastapi_sample/core/config.py
class Environment(BaseSettings):
    """ 環境変数を読み込むファイル
    """
    allow_headers: list  # 追加
    allow_origins: list  # 追加
    ...
    ..
    .

CORSミドルウェアを実装・エントリポイントに適用

fastapi_sample/middleware/__init__.py
from core.config import get_env  # 追加
from crud.base import Session
from fastapi import Request, Response
from starlette.middleware import cors  # 追加
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp  # 追加


class CORSMiddleware(cors.CORSMiddleware):
    """ CORS問題を回避するためのミドルウェア
    """
    def __init__(self, app: ASGIApp) -> None:
        super().__init__(
            app,
            allow_origins=get_env().allow_origins,
            allow_methods=cors.ALL_METHODS,
            allow_headers=get_env().allow_headers,
            allow_credentials=True,
        )


class HttpRequestMiddleware(BaseHTTPMiddleware):
    ...
    ..
    .
fastapi_sample/main.py
from api.endpoints.swagger_ui import openapi, swagger_ui_router
from api.endpoints.v1 import api_v1_router
from core.config import get_env
from core.fastapi import FastAPI
from middleware import (
    CORSMiddleware,  # 追加
    HttpRequestMiddleware
)

...
..
.

# ミドルウェアの設定
# ・ミドルウェアは 後 に追加したものが先に実行される
# ・CORSMiddlewareは必ず一番 後 に追加すること
# ・ミドルウェアを追加する場合はCORSMiddleware と ProcessRequestMiddlewareより 前 に追加すること
app.add_middleware(HttpRequestMiddleware)
app.add_middleware(CORSMiddleware)  # 追加
...
..
.

ポイントはミドルウェアを追加する順番です。
後に書いたミドルウェアが先に実行されるので、CORSミドルウェアは一番最後に書きましょう。
 
 
無事、フロントエンドのCORSエラーは回避することができるようになりました。
(文字列"ログイン成功"を返すだけのAPIを作成してフロントエンドから実行)
image.png

【オマケ②】カスタム例外

HTTPExceptionを継承してカスタム例外を作成してみます。
エラーレスポンスはこんな感じで、エラーコードとエラーメッセージを複数返せるような形式します。

detail: [
  {
    error_code: 'エラーコード',
    error_message: 'エラーメッセージ'
  },
  {
    error_code: 'エラーコード',
    error_message: 'エラーメッセージ'
  },
  { ... },
  { ... },
]

メッセージを管理するiniファイルを作成

touch core/error_messages.ini
fastapi_sample/core/error_messages.ini
[messages]
INTERNAL_SERVER_ERROR=システムエラーが発生しました。管理者に問い合わせてください
FAILURE_LOGIN=ログイン失敗

カスタム例外を実装するフォルダ作成

mkdir exception
touch exception/__init__.py
touch exception/error_messages.py

エラーコードとエラーメッセージの管理クラス

fastapi_sample/exception/error_messages.py
import os
from configparser import ConfigParser
from core.config import PROJECT_ROOT
from functools import lru_cache

MESSAGES_INI = ConfigParser()
MESSAGES_INI.read(
    os.path.join(PROJECT_ROOT, 'core/error_messages.ini'),
    'utf-8'
)


@lru_cache
def get(key: str, *args):
    return MESSAGES_INI.get('messages', key).format(*args)


INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR'
FAILURE_LOGIN = 'FAILURE_LOGIN'

fastapi_sample/exception/__init__.py
from fastapi import status, HTTPException
from exception import error_messages


class ApiException(HTTPException):
    """ API例外
    """
    default_status_code = status.HTTP_400_BAD_REQUEST

    def __init__(
        self,
        *errors,
        status_code: int = default_status_code
    ) -> None:
        self.status_code = status_code
        self.detail = [
            {
                'error_code': error['error_code'],
                'error_msg': error_messages.get(
                    error['error_code'],
                    *error['msg_params']),
            } for error in list(errors)
        ]
        super().__init__(self.status_code, self.detail)


def create_error(error_code: str, *msg_params) -> dict:
    """ エラー生成
    """
    return {
        'error_code': error_code,
        'msg_params': msg_params,
    }

カスタム例外をスローする

fastapi_sample/api/v1/auth.py
from fastapi import Request
from exception import (
    ApiException,
    create_error,
    error_messages,
)


class AuthAPI:
    """ 認証に関するAPI
    """
    @classmethod
    def login(cls, request: Request):
        """ ログインAPI
        """
        # カスタム例外をスロー
        raise ApiException(
            create_error(error_messages.FAILURE_LOGIN)
        )
        return 'ログイン成功'

リクエストミドルウェアを修正する

fastapi_sample/middleware/__init__.py
from exception import ApiException  # 追加
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)


        # アプリケーション例外
        except ApiException as ae:  # 追加
            request.state.db_session.rollback()
            raise ae  # 追加

        except Exception as e:
            request.state.db_session.rollback()
            raise e

        else:
            request.state.db_session.commit()
            return response

        finally:
            request.state.db_session.remove()

▼フロント側(エラーレスポンスを確認したいだけなのでスルーでOKです)

sample.js
axios.interceptors.response.use(
  response => {
    return response;
  },
  error => {
    // エラーレスポンスをコンソール表示
    console.log(error.response);
    ...
    ..
    .

カスタム例外がスローされ、フロント側でエラーレスポンスを確認することができました。
image.png

システムエラーについて

アプリが意図的に返す例外は問題ないですが、意図せぬ例外(システムエラー)はどうでしょうか。

fastapi_sample/api/v1/auth.py
from fastapi import Request


class AuthAPI:
    """ 認証に関するAPI
    """
    @classmethod
    def login(cls, request: Request):
        """ ログインAPI
        """
        result = 1 / 0  # 0除算でエラー
        return result

 
結果はCORSエラーが返却された上に、エラーレスポンス(error.response)の中身がundefinedです。
(CORSエラーは多分エラーの形式が不正だから・・・??)
image.png

システム例外用のカスタムExceptionを作成

fastapi_sample/exception/__init__.py
import traceback  # 追加
from fastapi import status, HTTPException
from exception import error_messages


class ApiException(HTTPException):
    ...
    ..
    .


class SystemException(HTTPException):
    """ システム例外
    """
    def __init__(self, e: Exception) -> None:
        self.exc = e
        self.stack_trace = traceback.format_exc()
        self.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
        self.detail = [
            {
                'error_code': error_messages.INTERNAL_SERVER_ERROR,
                'error_msg': error_messages.get(
                    error_messages.INTERNAL_SERVER_ERROR)
            }
        ]
        super().__init__(self.status_code, self.detail)

リクエストミドルウェアを修正

fastapi_sample/middleware/__init__.py
from exception import (
    ApiException,
    SystemException,  # 追加
)
from fastapi import Request, Response
from fastapi.responses import JSONResponse  # 追加
from starlette.middleware.base import BaseHTTPMiddleware


class HttpRequestMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        try:
            response = await call_next(request)


        except ApiException as ae:
            request.state.db_session.rollback()
            # アプリケーション例外もシステム例外に合わせてResponseを返却する形式に変える
            return JSONResponse(
                se.detail,
                status_code=se.status_code)

        except Exception as e:
            request.state.db_session.rollback()
            se = SystemException(e)  # カスタムシステム例外に変換
            # raise seとしても、フロント側のエラーレスポンスはなぜか"undefined"のままなのでResponseを返却する
            # API例外と同じくHTTPExceptionを継承しているのに何故・・・∑(゚Д゚)
            # raise se
            return JSONResponse(
                se.detail,
                status_code=se.status_code)

        else:
            request.state.db_session.commit()
            return response

        finally:
            request.state.db_session.remove()

フロント側でシステムエラーのエラーレスポンスを受け取ることができました。
image.png

終わり

FastAPIでCRUDを実装してみました。
フルスタックフレームワークのDjango(RestはDRF)ばかり使っていたせいで、初め「なんだこの使いづらいフレームワークは・・・」とか思っていましたが、今ではもうDjangoよりFastAPI派になってしまいました。
ガシガシ環境周りのコードを自分で実装できて楽しいですし、何より成長に繋がりました。
 
公式ドキュメントを読みきれていないので、他にもいい方法はあるかと思いますので、
コメントでこっそり教えてもらえると嬉しいです( ´ノω`)
 
私が所属しているFabeee株式会社はお仕事 と 一緒に働くお仲間を随時募集しております!!!!(宣伝)

話を聞いてみたい方はこちら
SES/受託開発のご依頼についてはこちら

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

nginxコンテナの設定を変更する

はじめに

初心者向け。nginxのコンテナの設定変更をしてみようの巻!

設定ファイル

参考: 【Qiita】nginxについてまとめ(導入編)

上記参考の記事によると、nginxの設定ファイルは、非コンテナの場合は /usr/local/etc/nginx/ にあるが、コンテナ(DockerHubの nginx:1.18 の場合)では見つからない。

こういう時は docker exec -it nginx bash でコンテナに潜り込んで確認してみよう。

コンテナの場合は、/etc/nginx 配下に nginx.conf があった。
server 設定については、このファイル内で include /etc/nginx/conf.d/*.conf; が参照されていて、デフォルトでは /etc/nginx/conf.d/default.conf の中で設定されているようだ。

静的コンテンツをキャッシュする

公式サイトを参考にしながら超粗く設定すると、以下のような感じになる。
nginxのデフォルトコンテナでは /data/nginx/cache がないので、コンテナ起動する場合はDockerfileの中で作っておこう。

/etc/nginx/nginx.conf
http {
(中略)
    proxy_cache_path /data/nginx/cache keys_zone=cachezone:10m max_size=200m;

    include /etc/nginx/conf.d/*.conf;
(中略)
}
/etc/nginx/conf.d/default.conf
server {
    listen       80;
    listen  [::]:80;
    server_name  localhost;

    location / {
        proxy_cache cachezone;
        proxy_pass http://127.0.0.1:8080;
        proxy_cache_key $host$uri$is_args$args;
        proxy_cache_valid 200 10m;
    }
}

server {
    listen       8080;
    listen  [::]:8080;
    server_name  localhost2;
(以下略)
}

だいたい公式サイトの通りなのだが、デフォルトでは全部キャッシュするよ、と言っておきながら、

        proxy_cache_key $host$uri$is_args$args;

を設定しておかないと必ずキャッシュミスになるので注意が必要。

キャッシュヒットしているかの確認

ログを見るのが一番手っ取り早い。

/etc/nginx/nginx.conf
http {
(中略)
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for" "$upstream_cache_status" "$server_port"';
(中略)
}

な感じで、"$upstream_cache_status""$server_port" をログ出力しておくようにしよう。

キャッシュなし版では

XXX.XXX.XXX.XXX - - [24/Oct/2020:12:32:01 +0000] "GET /xxxxx/ HTTP/1.1" 200 4668 "-" "python-requests/2.24.0" "-" "-" "80"

でプロキシ機能のログが出ていないことがわかる。
一方で、キャッシュあり版では、

127.0.0.1 - - [24/Oct/2020:12:56:34 +0000] "GET / HTTP/1.0" 200 956 "-" "curl/7.61.1" "-" "-" "8080"
XXX.XXX.XXX.XXX - - [24/Oct/2020:12:56:34 +0000] "GET / HTTP/1.1" 200 956 "-" "curl/7.61.1" "-" "MISS" "80"
XXX.XXX.XXX.XXX - - [24/Oct/2020:12:56:54 +0000] "GET / HTTP/1.1" 200 956 "-" "curl/7.61.1" "-" "HIT" "80"

といった感じで、内部通信のログと、キャッシュヒット有無が表示されるプロキシ機能のログが出力される。

性能差はどれくらいか?

キャッシュなし

キャッシュなし_number_of_users_1603540075.png
キャッシュなし_total_requests_per_second_1603540075.png
キャッシュなし_response_times_(ms)_1603540075.png

まだまだ余裕そうな雰囲気だが、性能ツール側を止めてしまったので一旦ここまで。

キャッシュあり

キャッシュあり_number_of_users_1603536854.png
キャッシュあり_total_requests_per_second_1603536854.png
キャッシュあり_response_times_(ms)_1603536854.png

あれ?キャッシュなしの方が性能が良いように見える。
たぶん、今回の負荷モデルは「それほど大きくないコンテンツに集中的にアクセス」なので、そもそもOSのバッファキャッシュに乗ってしまって、かえってキャッシュありの方がオーバーヘッドになっているのではないかという気がする。
厳密に差分を確認するには、キャッシュの恩恵を得られるような「大きいコンテンツに分散アクセス」しないと分からないかもしれない。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Locustで詳細な統計情報を取得したり出力内容を編集したりする

はじめに

locustはお手軽で良い。サクサクっと性能測定して、自分でグラフを作るまでもなくWebUIで動作が確認できる。
でも、WebUIだと95%タイルと中央値しか表示されなくて物足りないんじゃー!平均とかも確認させろ!な人向け。

やること

起動時のマスターノードのオプションを以下のようにすること。以上。簡単!

locust --master --csv=log/test --csv-full-history

これで、(起動ディレクトリ)/log 配下に以下のようにファイルが出力されるようになる。

/locust$ ls log
test_failures.csv  test_stats.csv  test_stats_history.csv

コンテナ起動している場合、素のままだとログファイルがコンテナに閉じ込められてしまうので、docker-compose等で

docker-compose.yml
services:
  locust-master:
    volumes:
      - ./log:/locust/log

とか定義しておこう。

これで

test_stats_history.csv
Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size
1603540111,17,GET,/xxxxx/,0.000000,0.000000,2,2,3,3,4,28,28,28,28,28,28,11,0,2,4.694659181702511,1.4053259992579115,28.377445999467454,14265.0
1603540111,17,GET,/yyyyy/,0.000000,0.000000,3,3,3,4,9,9,9,9,9,9,9,10,0,3,3.410866200010787,2.1423650005090167,8.981184999356628,93670.0
1603540111,17,GET,/zzzzz/,0.000000,0.000000,2,2,3,3,4,6,19,24,24,24,24,76,0,2,2.9205118157374512,1.2653539997700136,24.40452499922685,4668.0
1603540111,17,,Aggregated,0.000000,0.000000,2,3,3,3,4,8,24,28,28,28,28,97,0,2,3.1722557834523895,1.2653539997700136,28.377445999467454,14931.783505154639
(以下略)

な感じで、リソース単位の集計を取ることができるようになる!

補足

ログに出力するパーセンタイルも調整可能っぽい。

公式ドキュメントによると、

import locust.stats
locust.stats.CONSOLE_STATS_INTERVAL_SEC = 15

みたいな感じで PERCENTILES_TO_REPORT を変更することができるようだ。

ドキュメントが不親切でどう変更したら良いかわからないので、ソースを見てみると

grep PERCENTILES_TO_REPORT /usr/local/lib/python3.9/site-packages/locust/stats.py
PERCENTILES_TO_REPORT = [0.50, 0.66, 0.75, 0.80, 0.90, 0.95, 0.98, 0.99, 0.999, 0.9999, 1.0]

な感じで書かれているので、locustfile.py に

import locust.stats
locust.stats.PERCENTILES_TO_REPORT = [0.95]

を書き加えてオーバーライドすると

Timestamp,User Count,Type,Name,Requests/s,Failures/s,95%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size
1603542253,4,GET,/xxxxx/,0.000000,0.000000,22,2,0,2.2806119995948393,12.121530499825894,2.2806119995948393,21.96244900005695,14265.0
1603542253,4,GET,/yyyyy/,0.000000,0.000000,3,2,0,2.666434999810008,2.6112880000255245,2.556141000241041,2.666434999810008,93670.0
1603542253,4,GET,/zzzzz/,0.000000,0.000000,28,4,0,2.067968999654113,8.805921999964994,2.067968999654113,27.854616000695387,4668.0
1603542253,4,,Aggregated,0.000000,0.000000,28,8,0,3,8.086165624945352,2.067968999654113,27.854616000695387,29317.75

お、なんかスッキリした出力になった!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker mysql Quick Refernce

Docker Hub の mysql のクイックリファレンス の日本語訳。

MySQL とは?

MySQL は世界で最も人気のあるオープンソースデータベースです。
実証済みのパフォーマンス、信頼性、使いやすさを備えた MySQL は、個人のプロジェクトや Web サイト、 e コマースや情報サービスなど、全範囲をカバーする Web ベースのアプリケーションの主要なデータベースの選択肢になりました。
Facebook、Twitter、YouTube、Yahoo! などの Web プロパティなどでも使用されています。
MySQL Server およびその他の MySQL 製品の詳細および関連するダウンロードについては www.mysql.com にアクセスしてください。

イメージの使い方

mysql サーバーインスタンスの開始

MySQL インスタンスの開始は簡単です。

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

some-mysql はコンテナに割り当てる名前、 my-secret-pw は MySQL root ユーザーに設定するパスワード、 tag は必要な MySQL バージョンを指定するタグです。
関連するタグについては、上記のリストを参照してください。
MySQL コマンドラインクライアントから MySQL に接続します。
次のコマンドは、別の mysql コンテナインスタンスを起動し、元の mysql コンテナに対して mysql コマンドラインクライアントを実行して、データベースインスタンスに対して SQL ステートメントを実行できるようにします。

$ docker run -it --network some-network --rm mysql mysql -hsome-mysql -uexample-user -p

some-mysql は、元の mysql コンテナ(Docker ネットワークに接続されている some-network )の名前です。
このイメージは Docker 以外のインスタンスまたはリモートインスタンスのクライアントとしても使用できます。

$ docker run -it --rm mysql mysql -hsome.mysql.host -usome-mysql-user -p

MySQL コマンドラインクライアントの詳細については MySQL のドキュメントを参照してください。

docker stack deploy または docker-compose を介した使用法

stack.yml記述例
# Use root/example as user/password credentials
version: '3.1'

services:

  db:
    image: mysql
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

docker stack deploy -c stack.yml mysql (または docker-compose -f stack.yml up)を実行し、完全に初期化されるのを待って http://swarm-ip:8080, http://localhost:8080, または http://host-ip:8080 へアクセスします。

コンテナシェルへのアクセスと MySQL ログの表示

docker exec コマンドを使用すると Docker コンテナ内でコマンドを実行できます。
次のコマンドラインは mysql コンテナ内に bash シェルを提供します。

$ docker exec -it some-mysql bash

ログは Docker のコンテナログから入手できます。

$ docker logs some-mysql

カスタム MySQL 構成ファイルの使用

MySQL のデフォルト設定は /etc/mysql/my.cnf にあります。
これには /etc/mysql/conf.d/etc/mysql/mysql.conf.d などの追加のディレクトリが含まれている場合があります。
詳細については mysql イメージ内の関連するファイルとディレクトリを調べてください。
/my/custom/config-file.cnf がカスタム構成ファイルのパスと名前である場合、次のように mysql コンテナーを開始できます(このコマンドでは、カスタム構成ファイルのディレクトリー・パスのみが使用されることに注意してください)。

$ docker run --name some-mysql -v /my/custom:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

これにより MySQL インスタンスが /etc/mysql/my.cnf/etc/mysql/conf.d/config-file.cnf の起動設定を組み合わせて使用​​し、後者の設定が優先される新しいコンテナ some-mysql が起動します。

cnf ファイルを使用しない設定方法

多くの構成オプションをフラグとして mysqld に渡すことができます。これにより cnf ファイルを必要とせずにコンテナーをカスタマイズできる柔軟性が得られます。
たとえば、すべてのテーブルのデフォルトのエンコーディングと照合順序を変更して UTF-8(utf8mb4)を使用する場合は、次のコマンドを実行するだけです。

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

利用可能なオプションの完全なリストを確認したい場合は以下を実行してください。

$ docker run -it --rm mysql:tag --verbose --help

環境変数

mysql イメージを起動するときに docker run コマンドラインで 1 つ以上の環境変数を渡すことにより、 MySQL インスタンスの構成を調整できます。
すでにデータベースが含まれているデータディレクトリでコンテナを起動した場合、以下の変数はいずれも効果がないことに注意してください。
既存のデータベースは、コンテナの起動時に常に変更されません。
MySQL 自体が尊重する環境変数(特にこのイメージと一緒に使用すると問題を引き起こすことが知られている MYSQL_HOST のような変数)のドキュメントについては、https://dev.mysql.com/doc/refman/5.7/en/environment-variables.html も参照してください。

MYSQL_ROOT_PASSWORD

この変数は必須であり MySQL ルートスーパーユーザーアカウントに設定されるパスワードを指定します。
上記の例では my-secret-pw に設定されています。

MYSQL_DATABASE

この変数はオプションです。
イメージの起動時に作成されるデータベースの名前を指定できます。
ユーザー / パスワードが指定された場合(以下を参照)、そのユーザーには、このデータベースへのスーパーユーザーアクセス( GRANT ALL に対応)が付与されます。

MYSQL_USER, MYSQL_PASSWORD

これらの変数はオプションです。
新しいユーザーを作成し、そのユーザーのパスワードを設定するために組み合わせて使用​​されます。
このユーザーには MYSQL_DATABASE 変数で指定されたデータベースに対するスーパーユーザー権限(上記を参照)が付与されます。
ユーザーを作成するには、両方の変数が必要です。
ルートスーパーユーザーを作成するためにこの仕組みを使用する必要はないことに注意してください。
ルートスーパーユーザーは、デフォルトで MYSQL_ROOT_PASSWORD 変数で指定されたパスワードで作成されます。

MYSQL_ALLOW_EMPTY_PASSWORD

これはオプションの変数です。
yes のように空でない値に設定するとパスワードなしの root ユーザーでコンテナーを開始できます。

注: この変数を yes に設定することは、何をしているのかを本当に理解していない限りお勧めしません。
これにより MySQL インスタンスが完全に保護されなくなり、誰でも完全なスーパーユーザーアクセスを取得できるようになります。

MYSQL_RANDOM_ROOT_PASSWORD

これはオプションの変数です。
( pwgen を使用して) root ユーザーのランダムな初期パスワードを生成するには yes などの空でない値に設定します。
生成された root パスワードは stdout に出力されます( GENERATED ROOT PASSWORD: ...... )。

MYSQL_ONETIME_PASSWORD

初期化が完了すると root ユーザー( MYSQL_USER で指定されたユーザーではない)を期限切れとして設定し、最初のログイン時にパスワードの変更を強制します。
空でない値があると、この設定がアクティブになります。

注: この機能は MySQL 5.6 以降でのみサポートされています。
MySQL 5.5 でこのオプションを使用すると、初期化中にエラーが送出されます。

MYSQL_INITDB_SKIP_TZINFO

デフォルトでは、エントリポイントスクリプトは CONVERT_TZ() 関数に必要なタイムゾーンデータを自動的にロードします。
不要な場合、空でない値を指定するとタイムゾーンの読み込みが無効になります。

Docker Secrets

環境変数を介して機密情報を渡す代わりに、前述の環境変数に _FILEを追加して、初期化スクリプトがコンテナ内に存在するファイルからそれらの変数の値をロードするようにすることができます。
特にこれは /run/secrets/<secret_name> ファイルに保存されている Docker シークレットからパスワードをロードするために使用できます。
例えば:

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql-root -d mysql:tag

現在、これは以下の変数でのみサポートされています。

新しいインスタンスの初期化

コンテナが初めて起動されると、指定された名前の新しいデータベースが作成され、指定された構成変数で初期化されます。
さらに /docker-entrypoint-initdb.d にある拡張子 .sh.sql 、および .sql.gz のファイルを実行します。
ファイルはアルファベット順に実行されます。
SQL ダンプをそのディレクトリにマウントし、提供されたデータを含むカスタムイメージを提供することで、 mysql サービスに簡単にデータを取り込むことができます。
SQL ファイルは、デフォルトで MYSQL_DATABASE 変数で指定されたデータベースにインポートされます。

データの保存場所

重要な注意: Docker コンテナで実行されるアプリケーションが使用するデータを保存する方法はいくつかあります。
mysql イメージのユーザーは、次のような利用可能なオプションに慣れることをお勧めします。

Docker に独自の内部ボリューム管理を使用してホストシステム上のディスクにデータベースファイルを書き込むことにより、データベースデータのストレージを管理させます。
これはデフォルトであり、ユーザーにとって簡単でかなり透過的です。
欠点はホストシステム上で直接実行されるツールやアプリケーション、つまりコンテナの外部ではファイルを見つけるのが難しい場合があることです。
ホストシステム(コンテナの外側)にデータディレクトリを作成し、これをコンテナの内側から見えるディレクトリにマウントします。
これにより、データベースファイルがホストシステムの既知の場所に配置され、ホストシステム上のツールやアプリケーションがファイルに簡単にアクセスできるようになります。
欠点はユーザーがディレクトリが存在することを確認する必要があることと、ホストシステムのディレクトリ権限およびその他のセキュリティメカニズムが正しく設定されていることを確認する必要があることです。

Docker のドキュメントは、さまざまなストレージオプションとバリエーションを理解するための良い出発点であり、この分野で議論し、アドバイスを提供するブログやフォーラムの投稿が複数あります。
上記の後者のオプションの基本的な手順をここで簡単に示します。

ホストシステムの適切なボリュームに /my/own/datadir のようなデータディレクトリを作成します。
次のように mysql コンテナを起動します。

$ docker run --name some-mysql -v /my/own/datadir:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

コマンドの -v /my/own/datadir:/var/lib/mysql の部分は、基礎となるホストシステムから /my/own/datadir ディレクトリをコンテナ内の /var/lib/mysql としてマウントします。
MySQL はデフォルトでデータファイルを書き込みます。
MySQL の初期化が完了するまで接続はありません。
コンテナの起動時に初期化されたデータベースがない場合は、デフォルトのデータベースが作成されます。
これは予想される動作ですが、初期化が完了するまで着信接続を受け入れないことを意味します。
これにより、複数のコンテナーを同時に起動する docker-compose などの自動化ツールを使用するときに問題が発生する可能性があります。
MySQL に接続しようとしているアプリケーションが MySQL のダウンタイムを処理しない場合、または MySQL が正常に起動するのを待っている場合は、サービスが開始する前に接続と再試行のループを設定する必要があります。
公式イメージでのそのような実装の例については WordPress または Bonita を参照してください。

既存のデータベースに対する使用法

すでにデータベースが含まれているデータディレクトリ(具体的には mysql サブディレクトリ)で mysql コンテナインスタンスを起動する場合は、実行コマンドラインから $MYSQL_ROOT_PASSWORD 変数を省略してください。
上記変数を指定した場合でも無視され、既存のデータベースは変更されません。

任意のユーザーとして実行する方法

ディレクトリの権限がすでに適切に設定されていることがわかっている場合(上記のように既存のデータベースに対して実行する場合など)、または特定の UID/GID を使用して mysqld を実行する必要がある場合は、 --user を使用してこのイメージを呼び出すことができます。
目的の接続/構成を実現するには、任意の値(root/0 以外)に設定します。

$ mkdir data
$ ls -lnd data
drwxr-xr-x 2 1000 1000 4096 Aug 27 15:54 data
$ docker run -v "$PWD/data":/var/lib/mysql --user 1000:1000 --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

データベースダンプの作成

通常のツールのほとんどは機能しますが、 mysqld サーバーにアクセスできるようにするために、使用法が少し複雑になる場合があります。
これを確認する簡単な方法は docker exec を使用して、次のように同じコンテナーからツールを実行することです。

$ docker exec some-mysql sh -c 'exec mysqldump --all-databases -uroot -p"$MYSQL_ROOT_PASSWORD"' > /some/path/on/your/host/all-databases.sql

ダンプファイルからのデータリストア

データの復元用。
次のように -i フラグを指定して docker exec コマンドを使用できます。

$ docker exec -i some-mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"' < /some/path/on/your/host/all-databases.sql

ライセンス

このイメージに含まれているソフトウェアのライセンス情報を表示します。
すべての Docker イメージと同様に、これらには他のライセンスの下にある可能性のある他のソフトウェア(ベースディストリビューションの Bash など、プラ​​イマリソフトウェアに含まれている直接的または間接的な依存関係)も含まれている可能性があります。
自動検出できたいくつかの追加のライセンス情報は repo-info リポジトリの mysql/ ディレクトリにある可能性があります。
ビルド済みのイメージの使用に関しては、このイメージの使用がそれに含まれるすべてのソフトウェアの関連するライセンスに準拠していることを確認するのは、イメージユーザーの責任です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Docker mysql クイックリファレンス 日本語訳

Docker Hub の mysql のクイックリファレンス の日本語訳。

MySQL とは?

MySQL は世界で最も人気のあるオープンソースデータベースです。
実証済みのパフォーマンス、信頼性、使いやすさを備えた MySQL は、個人のプロジェクトや Web サイト、 e コマースや情報サービスなど、全範囲をカバーする Web ベースのアプリケーションの主要なデータベースの選択肢になりました。
Facebook、Twitter、YouTube、Yahoo! などの Web プロパティなどでも使用されています。
MySQL Server およびその他の MySQL 製品の詳細および関連するダウンロードについては www.mysql.com にアクセスしてください。

イメージの使い方

mysql サーバーインスタンスの開始

MySQL インスタンスの開始は簡単です。

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

some-mysql はコンテナに割り当てる名前、 my-secret-pw は MySQL root ユーザーに設定するパスワード、 tag は必要な MySQL バージョンを指定するタグです。
関連するタグについては、上記のリストを参照してください。
MySQL コマンドラインクライアントから MySQL に接続します。
次のコマンドは、別の mysql コンテナインスタンスを起動し、元の mysql コンテナに対して mysql コマンドラインクライアントを実行して、データベースインスタンスに対して SQL ステートメントを実行できるようにします。

$ docker run -it --network some-network --rm mysql mysql -hsome-mysql -uexample-user -p

some-mysql は、元の mysql コンテナ(Docker ネットワークに接続されている some-network )の名前です。
このイメージは Docker 以外のインスタンスまたはリモートインスタンスのクライアントとしても使用できます。

$ docker run -it --rm mysql mysql -hsome.mysql.host -usome-mysql-user -p

MySQL コマンドラインクライアントの詳細については MySQL のドキュメントを参照してください。

docker stack deploy または docker-compose を介した使用法

stack.yml記述例
# Use root/example as user/password credentials
version: '3.1'

services:

  db:
    image: mysql
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

docker stack deploy -c stack.yml mysql (または docker-compose -f stack.yml up)を実行し、完全に初期化されるのを待って http://swarm-ip:8080, http://localhost:8080, または http://host-ip:8080 へアクセスします。

コンテナシェルへのアクセスと MySQL ログの表示

docker exec コマンドを使用すると Docker コンテナ内でコマンドを実行できます。
次のコマンドラインは mysql コンテナ内に bash シェルを提供します。

$ docker exec -it some-mysql bash

ログは Docker のコンテナログから入手できます。

$ docker logs some-mysql

カスタム MySQL 構成ファイルの使用

MySQL のデフォルト設定は /etc/mysql/my.cnf にあります。
これには /etc/mysql/conf.d/etc/mysql/mysql.conf.d などの追加のディレクトリが含まれている場合があります。
詳細については mysql イメージ内の関連するファイルとディレクトリを調べてください。
/my/custom/config-file.cnf がカスタム構成ファイルのパスと名前である場合、次のように mysql コンテナーを開始できます(このコマンドでは、カスタム構成ファイルのディレクトリー・パスのみが使用されることに注意してください)。

$ docker run --name some-mysql -v /my/custom:/etc/mysql/conf.d -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

これにより MySQL インスタンスが /etc/mysql/my.cnf/etc/mysql/conf.d/config-file.cnf の起動設定を組み合わせて使用​​し、後者の設定が優先される新しいコンテナ some-mysql が起動します。

cnf ファイルを使用しない設定方法

多くの構成オプションをフラグとして mysqld に渡すことができます。これにより cnf ファイルを必要とせずにコンテナーをカスタマイズできる柔軟性が得られます。
たとえば、すべてのテーブルのデフォルトのエンコーディングと照合順序を変更して UTF-8(utf8mb4)を使用する場合は、次のコマンドを実行するだけです。

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

利用可能なオプションの完全なリストを確認したい場合は以下を実行してください。

$ docker run -it --rm mysql:tag --verbose --help

環境変数

mysql イメージを起動するときに docker run コマンドラインで 1 つ以上の環境変数を渡すことにより、 MySQL インスタンスの構成を調整できます。
すでにデータベースが含まれているデータディレクトリでコンテナを起動した場合、以下の変数はいずれも効果がないことに注意してください。
既存のデータベースは、コンテナの起動時に常に変更されません。
MySQL 自体が尊重する環境変数(特にこのイメージと一緒に使用すると問題を引き起こすことが知られている MYSQL_HOST のような変数)のドキュメントについては、https://dev.mysql.com/doc/refman/5.7/en/environment-variables.html も参照してください。

MYSQL_ROOT_PASSWORD

この変数は必須であり MySQL ルートスーパーユーザーアカウントに設定されるパスワードを指定します。
上記の例では my-secret-pw に設定されています。

MYSQL_DATABASE

この変数はオプションです。
イメージの起動時に作成されるデータベースの名前を指定できます。
ユーザー / パスワードが指定された場合(以下を参照)、そのユーザーには、このデータベースへのスーパーユーザーアクセス( GRANT ALL に対応)が付与されます。

MYSQL_USER, MYSQL_PASSWORD

これらの変数はオプションです。
新しいユーザーを作成し、そのユーザーのパスワードを設定するために組み合わせて使用​​されます。
このユーザーには MYSQL_DATABASE 変数で指定されたデータベースに対するスーパーユーザー権限(上記を参照)が付与されます。
ユーザーを作成するには、両方の変数が必要です。
ルートスーパーユーザーを作成するためにこの仕組みを使用する必要はないことに注意してください。
ルートスーパーユーザーは、デフォルトで MYSQL_ROOT_PASSWORD 変数で指定されたパスワードで作成されます。

MYSQL_ALLOW_EMPTY_PASSWORD

これはオプションの変数です。
yes のように空でない値に設定するとパスワードなしの root ユーザーでコンテナーを開始できます。

注: この変数を yes に設定することは、何をしているのかを本当に理解していない限りお勧めしません。
これにより MySQL インスタンスが完全に保護されなくなり、誰でも完全なスーパーユーザーアクセスを取得できるようになります。

MYSQL_RANDOM_ROOT_PASSWORD

これはオプションの変数です。
( pwgen を使用して) root ユーザーのランダムな初期パスワードを生成するには yes などの空でない値に設定します。
生成された root パスワードは stdout に出力されます( GENERATED ROOT PASSWORD: ...... )。

MYSQL_ONETIME_PASSWORD

初期化が完了すると root ユーザー( MYSQL_USER で指定されたユーザーではない)を期限切れとして設定し、最初のログイン時にパスワードの変更を強制します。
空でない値があると、この設定がアクティブになります。

注: この機能は MySQL 5.6 以降でのみサポートされています。
MySQL 5.5 でこのオプションを使用すると、初期化中にエラーが送出されます。

MYSQL_INITDB_SKIP_TZINFO

デフォルトでは、エントリポイントスクリプトは CONVERT_TZ() 関数に必要なタイムゾーンデータを自動的にロードします。
不要な場合、空でない値を指定するとタイムゾーンの読み込みが無効になります。

Docker Secrets

環境変数を介して機密情報を渡す代わりに、前述の環境変数に _FILEを追加して、初期化スクリプトがコンテナ内に存在するファイルからそれらの変数の値をロードするようにすることができます。
特にこれは /run/secrets/<secret_name> ファイルに保存されている Docker シークレットからパスワードをロードするために使用できます。
例えば:

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql-root -d mysql:tag

現在、これは以下の変数でのみサポートされています。

新しいインスタンスの初期化

コンテナが初めて起動されると、指定された名前の新しいデータベースが作成され、指定された構成変数で初期化されます。
さらに /docker-entrypoint-initdb.d にある拡張子 .sh.sql 、および .sql.gz のファイルを実行します。
ファイルはアルファベット順に実行されます。
SQL ダンプをそのディレクトリにマウントし、提供されたデータを含むカスタムイメージを提供することで、 mysql サービスに簡単にデータを取り込むことができます。
SQL ファイルは、デフォルトで MYSQL_DATABASE 変数で指定されたデータベースにインポートされます。

データの保存場所

重要な注意: Docker コンテナで実行されるアプリケーションが使用するデータを保存する方法はいくつかあります。
mysql イメージのユーザーは、次のような利用可能なオプションに慣れることをお勧めします。

Docker に独自の内部ボリューム管理を使用してホストシステム上のディスクにデータベースファイルを書き込むことにより、データベースデータのストレージを管理させます。
これはデフォルトであり、ユーザーにとって簡単でかなり透過的です。
欠点はホストシステム上で直接実行されるツールやアプリケーション、つまりコンテナの外部ではファイルを見つけるのが難しい場合があることです。
ホストシステム(コンテナの外側)にデータディレクトリを作成し、これをコンテナの内側から見えるディレクトリにマウントします。
これにより、データベースファイルがホストシステムの既知の場所に配置され、ホストシステム上のツールやアプリケーションがファイルに簡単にアクセスできるようになります。
欠点はユーザーがディレクトリが存在することを確認する必要があることと、ホストシステムのディレクトリ権限およびその他のセキュリティメカニズムが正しく設定されていることを確認する必要があることです。

Docker のドキュメントは、さまざまなストレージオプションとバリエーションを理解するための良い出発点であり、この分野で議論し、アドバイスを提供するブログやフォーラムの投稿が複数あります。
上記の後者のオプションの基本的な手順をここで簡単に示します。

ホストシステムの適切なボリュームに /my/own/datadir のようなデータディレクトリを作成します。
次のように mysql コンテナを起動します。

$ docker run --name some-mysql -v /my/own/datadir:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

コマンドの -v /my/own/datadir:/var/lib/mysql の部分は、基礎となるホストシステムから /my/own/datadir ディレクトリをコンテナ内の /var/lib/mysql としてマウントします。
MySQL はデフォルトでデータファイルを書き込みます。
MySQL の初期化が完了するまで接続はありません。
コンテナの起動時に初期化されたデータベースがない場合は、デフォルトのデータベースが作成されます。
これは予想される動作ですが、初期化が完了するまで着信接続を受け入れないことを意味します。
これにより、複数のコンテナーを同時に起動する docker-compose などの自動化ツールを使用するときに問題が発生する可能性があります。
MySQL に接続しようとしているアプリケーションが MySQL のダウンタイムを処理しない場合、または MySQL が正常に起動するのを待っている場合は、サービスが開始する前に接続と再試行のループを設定する必要があります。
公式イメージでのそのような実装の例については WordPress または Bonita を参照してください。

既存のデータベースに対する使用法

すでにデータベースが含まれているデータディレクトリ(具体的には mysql サブディレクトリ)で mysql コンテナインスタンスを起動する場合は、実行コマンドラインから $MYSQL_ROOT_PASSWORD 変数を省略してください。
上記変数を指定した場合でも無視され、既存のデータベースは変更されません。

任意のユーザーとして実行する方法

ディレクトリの権限がすでに適切に設定されていることがわかっている場合(上記のように既存のデータベースに対して実行する場合など)、または特定の UID/GID を使用して mysqld を実行する必要がある場合は、 --user を使用してこのイメージを呼び出すことができます。
目的の接続/構成を実現するには、任意の値(root/0 以外)に設定します。

$ mkdir data
$ ls -lnd data
drwxr-xr-x 2 1000 1000 4096 Aug 27 15:54 data
$ docker run -v "$PWD/data":/var/lib/mysql --user 1000:1000 --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

データベースダンプの作成

通常のツールのほとんどは機能しますが、 mysqld サーバーにアクセスできるようにするために、使用法が少し複雑になる場合があります。
これを確認する簡単な方法は docker exec を使用して、次のように同じコンテナーからツールを実行することです。

$ docker exec some-mysql sh -c 'exec mysqldump --all-databases -uroot -p"$MYSQL_ROOT_PASSWORD"' > /some/path/on/your/host/all-databases.sql

ダンプファイルからのデータリストア

データの復元用。
次のように -i フラグを指定して docker exec コマンドを使用できます。

$ docker exec -i some-mysql sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD"' < /some/path/on/your/host/all-databases.sql

ライセンス

このイメージに含まれているソフトウェアのライセンス情報を表示します。
すべての Docker イメージと同様に、これらには他のライセンスの下にある可能性のある他のソフトウェア(ベースディストリビューションの Bash など、プラ​​イマリソフトウェアに含まれている直接的または間接的な依存関係)も含まれている可能性があります。
自動検出できたいくつかの追加のライセンス情報は repo-info リポジトリの mysql/ ディレクトリにある可能性があります。
ビルド済みのイメージの使用に関しては、このイメージの使用がそれに含まれるすべてのソフトウェアの関連するライセンスに準拠していることを確認するのは、イメージユーザーの責任です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Nuxt.jsによるフロントエンド開発CI設定手順(2020/10/24バージョン)

各ツールのバージョン

Visual Studio Code, Docker を使用します。各バージョンは以下の通りです。
今回はWindows10を使用していますが、MacOSでも同様に操作できます。

  • Windows

image.png

  • Visual Studio Code

image.png

  • Docker

image.png

※以前Windowsのバージョンが1909だとDockerが入らなかったのですが、現在はインストール可能となったようです。

開発環境作成

Nuxt プロジェクトを作成し、GitHub で main ブランチに push したら自動で GitHub Pages に公開するよう準備します。

1) Visual Studio Code を起動します。
image.png

2) 作業フォルダを作成し開きます。(今回は C:\dev\nuxt20201024 としました)
image.png
image.png

3) 新規ファイル作成で Dockerfile という名前のファイルを作成します。
image.png
image.png

image.png がファイル作成ボタン

4) Dockerfile に Node.js 環境を作る内容を記載します。

Dockerfile
FROM node:lts-alpine
ENV CHOKIDAR_USEPOLLING=true NUXT_TELEMETRY_DISABLED=1
RUN apk update && apk add git

1行目:alpine OS の Node.js 入りイメージをベースにします。node:alpineだと Nuxt プロジェクトを作るときエラーとなったのでltsにしています。
2行目:開発時の自動更新を有効にし、Nuxt 実行時の統計確認を無効にします。
3行目:パッケージを更新し、gitをインストールします。

5) Visual Studio Code の Remote Development を使って Dockerコンテナを起動します。
image.png
image.png
image.png
※Remote Development 拡張機能が入っていない場合はインストールしてください。
image.png

6) Vuter 拡張機能をインストールする設定("octref.vetur")を追加し、Docker環境をリビルドします。

.devcontainer/devcontainer.json
{
(...省略)
    // Add the IDs of extensions you want installed when the container is created.
    "extensions": ["octref.vetur"]
(...省略)
}

image.png

image.png

7) Nuxtプロジェクトを作成します。
image.png

Terminal
yarn create nuxt-app my-nuxt

※my-nuxt はプロジェクト名なので自由に名前を付けられます。

? Project name: (my-nuxt)

プロジェクト名を聞かれますので、そのまま Enter

? Programming language: (Use arrow keys)
❯ JavaScript 
  TypeScript

使用する言語を聞かれますので JavaScript のまま Enter

? Package manager: (Use arrow keys)
❯ Yarn 
  Npm

パッケージマネージャを聞かれますので、Yarn のまま Enter

? UI framework: (Use arrow keys)
❯ None 
  Ant Design Vue 
  Bootstrap Vue 
  Buefy 
  Bulma 
  Chakra UI 
  Element 
  Framevuerk 
  iView 
  Tachyons 
  Tailwind CSS 
  Vuesax 
  Vuetify.js 

UI フレームワークを聞かれますがあとから追加可能なので None のまま Enter

? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◯ Axios
 ◯ Progressive Web App (PWA)
 ◯ Content

インストールするモジュールを聞かれますがあとから追加可能なのでそのまま Enter

? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◯ ESLint
 ◯ Prettier
 ◯ Lint staged files
 ◯ StyleLint
 ◯ Commitlint

インストールする構文チェックツールを聞かれますがあとから追加可能なのでそのまま Enter

? Testing framework: 
  None 
❯ Jest 
  AVA 
  WebdriverIO 

テストツールを聞かれますので、Jest を選択し Enter

? Rendering mode: (Use arrow keys)
  Universal (SSR / SSG) 
❯ Single Page App 

レンダリングモードを聞かれますので、Single Page App を選択し Enter

? Deployment target: (Use arrow keys)
  Server (Node.js hosting) 
❯ Static (Static/JAMStack hosting) 

デプロイ先を聞かれますが、今回 GitHub Pages を使うので Static (Static/JAMStack hosting) を選択し Enter

? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◯ jsconfig.json (Recommended for VS Code if you're not using typescript)
 ◯ Semantic Pull Requests
 ◯ Dependabot (For auto-updating dependencies, GitHub only)

インストールするデプロイ用ツールを聞かれますがあとから追加可能なのでそのまま Enter

? Continuous integration: (Use arrow keys)
  None 
❯ GitHub Actions (GitHub only) 

インストールする CI ツールを聞かれますが、今回 GitHub Actions を使うので GitHub Actions (GitHub only) を選択し Enter

? What is your GitHub username?

GitHub のユーザ名を聞かれますので自分のユーザ名を入力します。

? Version control system: (Use arrow keys)
❯ Git 
  None 

使用するバージョン管理ツールを聞かれますので、Git を選択し Enter

?  Successfully created project my-nuxt

Successfully が出れば作成完了です。

8) 実行してブラウザで確認します。
image.png

NPM SCRIPTS の dev - my-nuxt の右に出る三角ボタンで実行できます。

image.png

リモートエクスプローラタブに切り替えると FORWARDED PORTS がひょうじされ、3000 のところの地球儀のマークをクリックするとブラウザが起動します。
image.png
image.png

9) Git リポジトリ管理を開始します。

ソースコントロールタブに切り替えると変更ファイル一覧が表示されているので、Message 欄にコミットコメントとして first と入力してチェックボタンを押しコミットします。
image.png
image.png

10) GitHub にリポジトリを作成します。
image.png
image.png

Repository name を入力し、Public で作成します。(Private だと GitHub Pages を使うとき有料になります。)

11) リポジトリを GitHub に紐づけて Push します。

SOURCE CONTROL の・・・メニューから Remote / Add Remote... を選択し、Add Remote from GitHub を選択します。
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png
image.png

12) GitHub Actions で GitHub Pages にデプロイする設定を追加します。

my-nuxt/.github/workflows/ci.yml を開きます。
image.png

テスト用の設定は入っているので、そのあとに GitHub Pages にデプロイする処理を追加し保存します。

my-nuxt/.github/workflows/ci.yml
(...省略)
      - name: Run tests ?
        run: yarn test
(以下追加)
      - run: yarn generate

      - uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist

13) コミット、Push して GitHub Actions の動作ログを確認し、デプロイされたページも確認します。

image.png
image.png

GitHub の Actions ページを見て先ほど Push したコメントの workflow が完了していればデプロイ完了です。
image.png

デプロイ完了していれば gh-pages ブランチができます。
image.png

gh-pages ブランチに index.html があれば成功です。
image.png

GutHub の Settings ページの中にある GitHub Pages の設定に行きます。
image.png

Source を gh-pages に切り替えて Save ボタンを押します。
image.png

Your site is ready to be published at の後の URL が公開ページになります。

14) ベースフォルダを調整します。

公開 URL が https://~.github.io/my-nuxt/ となっていて、Nuxt は既定では / ベースのパス後世になっているので、 /my-nuxt/ がベースになるよう設定を追加します。

my-nuxt/nuxt.config.js変更前
(...省略)
  // Build Configuration (https://go.nuxtjs.dev/config-build)
  build: {
  }
}
my-nuxt/nuxt.config.js変更後
(...省略)
  // Build Configuration (https://go.nuxtjs.dev/config-build)
  build: {
  },

  router: {
    base: process.env.BASE
  }
}
my-nuxt/.github/workflows/ci.yml変更前
      - run: yarn generate
my-nuxt/.github/workflows/ci.yml変更後
      - run: BASE=/my-nuxt/ yarn generate

コミット、Push してブラウザで確認します。

image.png

image.png

以上で、Push すれば自動で公開される仕組みが完成しました。

おまけ:FTPを使ってサーバに公開する設定

レンタルサーバ等にデプロイする場合の GitHub Actions の設定を紹介します。

ci.yml に以下の内容を追加します。

my-nuxt/.github/workflows/ci.yml
(省略...)
      - run: yarn generate_ftp

      - name: FTP-Deploy-Action
        uses: SamKirkland/FTP-Deploy-Action@3.1.1
        with:
          ftp-server: ftp://FTPサーバのIPアドレスまたはDNS名/転送先のパス
          ftp-username: FTPユーザ名
          ftp-password: ${{ secrets.FTP_PASSWORD }}
          local-dir: dist/

今回は FTP-Deploy-Action を使っています。
FTPのパスワードは直接書いてしまうと公開されてしまうので、secrets という環境変数のようなもので指定します。
実際のパスワードは GitHub の Settings ページの Secrets に設定します。
image.png

New secret ボタンを押して、Name には ci.yml に記載した FTP_PASSWORD を、Value にはパスワードを入力し、Add Secret ボタンを押します。
image.png

以下のようになれば設定完了です。
image.png

また、GitHub Pages の yarn generate は /my-nuxt/ ベースになっているので、FTP ように yarn generate_ftp でビルドするようにします。

package.json にを編集します。

my-nuxt/package.json変更前
  "scripts": {
    "dev": "nuxt",
    "build": "nuxt build",
    "start": "nuxt start",
    "generate": "BASE=/my-nuxt/ nuxt generate",
    "test": "jest"
  },
my-nuxt/package.json変更後
  "scripts": {
    "dev": "nuxt",
    "build": "nuxt build",
    "start": "nuxt start",
    "generate": "BASE=/my-nuxt/ nuxt generate",
    "generate_ftp": "DIR=dist_ftp/ BASE=/user01/ nuxt generate",
    "test": "jest"
  },

ビルドしたファイルを dist_ftp フォルダに出力するよう DIR 環境変数を用意しています。
また、FTPユーザ名がサーバの公開パスになる場合は BASE 環境変数でそのパスを指定します。(上記では user01 で転送すると /user01 に入る想定)

DIRで指定したフォルダに出力されるよう nuxt.config.js も編集します。

my-nuxt/nuxt.config.js変更前
{
(省略...)
  router: {
    base: process.env.BASE
  }
}
my-nuxt/nuxt.config.js変更前
{
(省略...)
  router: {
    base: process.env.BASE
  },

  generate: {
    dir: process.env.DIR
  }
}

最後に dist_ftp フォルダは以下を git 管理から外すために .gitignore を編集します。

my-nuxt/.gitignore変更前
# Nuxt generate
dist
my-nuxt/.gitignore変更後
# Nuxt generate
dist
dist_ftp

これで、コミット・Push するとFTPサーバ経由でデプロイされるようになると思いましたが、実行するとファイルがアップロードされません。
調べると、アップロード対象ファイルは git 管理されているファイルの差分になるようです。

My files aren't uploading
V3+ uses github to determine when files have changes and only publish differences. This means files that aren't > committed to github will not upload by default.
To change this behavior please see .git-ftp-ignore documentation.

ビルドした dist_ftp 配下のフォルダは git 管理外のため、アップロード対象とするには、.git-ftp-ignore を作成します。

my-nuxt/.git-ftp-include
!dist_ftp/

これで、FTPで転送されるようになります。

image.png
image.png

この仕組みを使えばレンタルサーバさえあればWebサービスを公開できます。
これまで手動でホームページを管理していたものも GitHub 管理にでき、アップロードが自動化できます。
そして、GitHub Pages とは違って、PHP やデータベースを使ったバックエンドも合わせて作れるので個人で開発するときに有用かと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

7.Docker (Dockerfileのintruction)

Dockerfileの書き方

⭐️Docker imageのlayerの数は最小限にする。
・docker layerを作るinstructionは、RUN, COPY, ADDの3つ

例)
FROM ubuntu:latest
RUN apt-get update && apt-get install -y \
curl\
cvs\
nginx
CMD ["/bin/bash"]

解説

FROM [image]

Docker imageのベースとなるDocker imageを指定。

必要最低限の機能を備えたimageを指定するのが良い。
Docker hubに置いてあるイメージである場合が多い。
ubuntuなどOSそのままのDocker imageを使う場合が多い。

Run [command]

imageを構成するために必要なlinuxコマンドを実行する。
コマンドを実行(image layerを作成)
基本的にはRUNのdocker instructionでdocker imageを作っていく。

基本的な書き方は、上記の例なので、覚えるとよい。

ubuntuではapt-get(またはapt)というコマンドでパッケージを管理する。
常に新しいリストを取得してからインストールするのが一般的。

apt-get update ・・・ パッケージのリストを最新にして取得
apt-get install [package] ・・・ [package]をインストール

「-y」 ・・・ パッケージをインストールするときに「yes」で答える。
必要なパッケージをabc順に並べる。

「\」・・・改行した物も1行とみなす。

RUNはdockerのimage layerを作っていくので、RUNを複数業かくと、その数だけdocker image layerができ、docker imageが大きくなってしまう。
それを回避するために、コマンドを「&&」で繋ぎ、なるべくRUNの行数を減らす。

CMD["executable", "param1", "param2"]

Docker imageのデフォルトのコマンドを指定できる。

executable ・・・ 実行可能なコマンド

大抵DockerfileはCMDで終わる。

なくても良いが、つけた方が良い。
というのも、Dockerfileをみた時に、デフォルトでこのコマンドが動くことが色々な人にわかる。

※エントリポイントという別のDocker instructionを使う場合は、CMDの形と意味が変わる。

キャッシュを使いながらdocker buildをする

既にdocker layerがあった場合、改めてbuildする必要はない。
docker fileをメンテナンスしていく中では、RUNを複数業に分ける。

新たにパッケージを追加した場合に、RUNが一つにまとめている状態でbuildすると新たにimage layerが作られてしまう。

ex)
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y \   ←RUNを分ける
curl\
cvs\
git\             ←追加
nginx
CMD ["/bin/bash"]

※apt-getのupdateは既にdocker image layerがある。

最後に

この記事は、かめさん( https://twitter.com/usdatascientist?s=21 )のudemyのdocker講座の( https://www.udemy.com/share/103aTRAEMfeVhaTXoB/ )書き起こしです。

※あくまで、自分のための書き残しとして投稿しているので、講座と異なる部分を含んでいる可能性があります。

かめさんのブログ( https://datawokagaku.com/docker_lecture/ )

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Synology NAS DS218+ 奮戦記

DS-218+

SynologyのNASと格闘している。
なにせ、これまで何の情報もなく、手探りで進めてきたから。

先に結論を言うと、NASについているDOCKERを動かすことはあきらめた。
幸いながらNASには、仮想環境を作るアプリがあり、
そのチュートリアルを見つけることが出来たからである。

仮想環境

思えばDockerコンテナが出てくる前の、
クラウド技術は、VMwareによる仮想マシンである。

https://www.itmedia.co.jp/enterprise/articles/1612/19/news041.html

素人ITエンジニアによくある間違いは、
過去の経緯を知らず、最新の技術を使おうとすることである。

急がば回れというが、もっと手前の技術で出来ることがある。

先人に見習いながら、そういう熟成したものを
使いこなす方が、手っ取り早い場合があるのである。

How to install Ubuntu on Synology

そんな訳で海外のページを写経してみる。
https://blog.pavelsklenar.com/how-to-install-ubuntu-on-synology/

Linuxを選んだところまではすんなり行けたが、
途中でメモリが足りないことが発覚

一般的な設定
ステップ3は一般的な設定に関連しています。お気づきのように、あなたは新しい仮想マシンを作成しているので、CPU(2)、RAM(3)、ビデオカード(3)などを割り当てる必要があります。割り当てられた RAM の数は使用可能なメモリに関連していますが、DSM システムはその実行にもメモリを必要とするため、NAS に合計 4 GB がある場合に 4 台の仮想マシン (4 x 1 GB) を数えることはできないことを知っておく必要があります。

実際には、正確には1つの仮想マシンがあなたによって割り当てられたメモリに加えて256MB + 80MB(ハイパーバイジングのためのリソース)を消費します。各VMは256MBを必要とし、各vCPUは80MBを必要とします)なので、仮想マシンに割り当てられた1GBは合計で1360MBを必要とします。

仕方がなく、他の記事を見ると8Gを増設できたとのこと
詳細はここ
https://arakoki70.com/?p=2478

とりあえず、自宅のメモリを探したが、
PC用メモリ 2GB PC2-6400S-666-12は
型番が合わないようだ・・・

がっくし

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

dockerコンテナからホストにファイルをコピーする(docker cp)

コマンド

docker cp <コンテナIDまたはコンテナ名>:コピーしたいファイルのパス コピー先パス

でコピーできる。
レファレンスはこちら。

実例

dockerでoracleを動かしていて、接続情報を確認したくなったので、tnsnames.oraをコピーする。

コンテナID指定の場合
# コンテナID調べる(CONTAINER ID)
$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS                 PORTS                                            NAMES
d3e6c3fd1a85        oracle/database:19.3.0-ee   "/bin/sh -c 'exec $O…"   2 hours ago         Up 2 hours (healthy)   0.0.0.0:1521->1521/tcp, 0.0.0.0:5500->5500/tcp   orcl

# コピーする
$ docker cp d3e6c3fd1a85:/opt/oracle/oradata/dbconfig/ORCLCDB/tnsnames.ora ./

# コピーできたか確認
$ find tnsnames.ora
tnsnames.ora
コンテナ名指定の場合
# コンテナ名を調べる(NAMES) 
$ docker ps
CONTAINER ID        IMAGE                       COMMAND                  CREATED             STATUS                 PORTS                                            NAMES
d3e6c3fd1a85        oracle/database:19.3.0-ee   "/bin/sh -c 'exec $O…"   2 hours ago         Up 2 hours (healthy)   0.0.0.0:1521->1521/tcp, 0.0.0.0:5500->5500/tcp   orcl

# コピーする
$ docker cp orcl:/opt/oracle/oradata/dbconfig/ORCLCDB/tnsnames.ora ./

# コピーできたか確認
$ find tnsnames.ora
tnsnames.ora

環境情報

$ docker version
Client: Docker Engine - Community
 Cloud integration  0.1.18
 Version:           19.03.13
 API version:       1.40
 Go version:        go1.13.15
 Git commit:        4484c46d9d
 Built:             Wed Sep 16 16:58:31 2020
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.13
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       4484c46d9d
  Built:            Wed Sep 16 17:07:04 2020
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          v1.3.7
  GitCommit:        8fba4e9a7d01810a393d5d25a3621dc101981175
 runc:
  Version:          1.0.0-rc10
  GitCommit:        dc9208a3303feef5b3839f4323d9beb36df0a9dd
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H2

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初学者によるDocker理解まとめ⑤ 〜EC2インスタンスにdockerコンテナをデプロイするまで〜

はじめに

ようやくDockerを学び始めたので自分の理解をまとめておく。

やったこと

  • 前回の続き
  • githubを介して、EC2にローカルPCのコードをアップロード
  • そしてdocker-compose up!

Githubを介して、EC2にローカルPCのコードをアップロード

まずはEC2インスタンスにログイン

ローカルPCの~/.sshにキーペアがあるとして、

cd ~/.ssh
ssh -i "ec2-key.pem" ec2-user@<public-ip>

EC2インスタンス上で鍵を作る

ssh-keygen -t rsa

パスフレーズを聞かれるので、必要に応じてパスフレーズを入力する。
正しく鍵が作成されていると ~/.ssh 以下に、id_rsaid_rsa.pub に2つのファイルができる。
id_rsa.pub のほうが公開鍵なので、この中身を github の Deploy Key に登録することになる。
この公開鍵をコピーしておく。

cat ~/.ssh/id_rsa.pub

コピーした公開鍵をGithubに登録する

SSHキー登録ページで登録できる。
スクリーンショット 2020-10-24 12.42.23.png

Titleはキーの内容がわかる文面を記入し、Keyに先ほどコピーした公開鍵を貼り付ける。

登録できたかをEC2から確認

ssh -T git@github.com

EC2からgithubのレポジトリをクローンして終わり!

git clone git@github.com:yourname/yourrepo.git

スクリーンショット 2020-10-24 12.50.06.png

そしてdocker-compose up!

その前に、一応おまじない。

sudo service docker start

そして

docker-compose up

あれ、なぜかエラー。。。
Error: Cannot find module 'express'ってなんで? ちゃんとpackage.jsonに書いてあるじゃないですか。。。

Creating express-app ... done
Creating nginx-web   ... done
Attaching to express-app, nginx-web
express-app | node:internal/modules/cjs/loader:903
express-app |   throw err;
express-app |   ^
express-app | 
express-app | Error: Cannot find module 'express'
express-app | Require stack:
express-app | - /usr/src/app/index.js
express-app |     at Function.Module._resolveFilename (node:internal/modules/cjs/loader:900:15)
express-app |     at Function.Module._load (node:internal/modules/cjs/loader:745:27)
express-app |     at Module.require (node:internal/modules/cjs/loader:972:19)
express-app |     at require (node:internal/modules/cjs/helpers:88:18)
express-app |     at Object.<anonymous> (/usr/src/app/index.js:1:17)
express-app |     at Module._compile (node:internal/modules/cjs/loader:1083:30)
express-app |     at Object.Module._extensions..js (node:internal/modules/cjs/loader:1112:10)
express-app |     at Module.load (node:internal/modules/cjs/loader:948:32)
express-app |     at Function.Module._load (node:internal/modules/cjs/loader:789:14)
express-app |     at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:72:12) {
express-app |   code: 'MODULE_NOT_FOUND',
express-app |   requireStack: [ '/usr/src/app/index.js' ]
express-app | }
nginx-web | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
nginx-web | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
nginx-web | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
nginx-web | 10-listen-on-ipv6-by-default.sh: Getting the checksum of /etc/nginx/conf.d/default.conf
express-app exited with code 1
nginx-web | 10-listen-on-ipv6-by-default.sh: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
nginx-web | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
nginx-web | /docker-entrypoint.sh: Configuration complete; ready for start up
nginx-web | 2020/10/24 02:42:13 [emerg] 1#1: host not found in upstream "app" in /etc/nginx/nginx.conf:18
nginx-web | nginx: [emerg] host not found in upstream "app" in /etc/nginx/nginx.conf:18
nginx-web exited with code 1

いろいろ調べた結果、何やら難しいことが起きている模様。

アドバイスに従い、docker-compose.ymlを修正。

docker-compose.yml
     volumes:
      - './app:/usr/src/app'
      - usr/src/app/node_modules #追記

これでなんとかなりました!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DockerでVue3環境を作る!

はじめに

この記事は Docker を使って Vue3.0 環境の構築までを記します。
初心者のため間違い等ございましたらご指摘いただけると幸いです。

前提条件

Dockernodenpmをインストールしていること。
Dockerをインストールしていない場合は以下のリンクを参考にインストールしてみてください。
たぶん動くから!Docker始めてみよう!

環境

macOS Catalina : 10.15.7
Visual Studio Code : 1.42.1

ターミナル
$ docker --version
Docker version 19.03.13
$ node --version
v14.14.0
$ npm --version
6.14.8

Vueのバージョンアップ

PC上での Vue3.0 の環境を構築します。
古い Vue2.0 が入っていたため、一度アンインストールした後、最新のバージョンをインストールします。

旧バージョンの削除

旧バージョンのVueをグローバルインストールしている場合のみ行います。

ターミナル
$ sudo npm uninstall vue-cli -g

新バージョンのインストール

新しいバージョンをインストールし直します。

ターミナル
$ sudo npm install -g @vue/cli

()

$vue --version
@vue/cli 4.5.8

ローカルの環境構築

ローカルで Vue3.0 を使ったプロジェクトを動かしてみます。

ローカル環境でのVueプロジェクトの作成

作業用のディレクトリを作成します。
後々Vue以外のコンテナも追加予定のため、以下の様なディレクトリ構成を想定しています。

ディレクトリ構成
dockerApp
├──docker-compose.yml ←未作成
└──vue
    ├── Dockerfile   ←未作成
    └── vue_app       ←今からこのフォルダを作る。vueの作業場所。
        └── ・・・       ←ここにVueのソースとか入る。

vue_app用のフォルダを作成し、Vueプロジェクトを作成していきます。

ターミナル
$ mkdir vue_app        #ディレクトリ作成
$ cd vue_app           #ディレクトリの中へ移動
$ vue create vue_app   #Vueプロジェクトの作成

ディレクトリ名やプロジェクト名は各自好きな名前を付けてください。

コマンドを叩くと初期設定が始まります。
Vue 2Vue 3 どちらを使うか聞かれるので、もちろんVue 3 を選びます。
Default (Vue 3 Preview) ([Vue 3] babel, eslint) を選択します。(キーキーで選択し、Enterキーで決定)

ターミナル
? Please pick a preset: 
  Default ([Vue 2] babel, eslint) 
❯ Default (Vue 3 Preview) ([Vue 3] babel, eslint) 
  Manually select features 

しばらくしたら Vue3 のプロジェクトが作成されます。

一度動作の確認をしてみましょう。
npm run serve コマンドでサーバーを動かしてみます。

ターミナル
$ npm run serve

起動したら http://localhost:8080/ へアクセスしてみます。

スクリーンショット 2020-10-21 19.38.17.png

ちゃんと起動してますね!

ターミナル上で control + c でサーバーを停止させます。

コンテナの環境構築

次にコンテナで Vue 環境を構築していきます。

Dockerfileの作成

ディレクトリ構成で記した場所に、以下の様に Dockerfile を作成します。

Dockerfile
FROM node:14.14.0

WORKDIR /vue_app

RUN npm install

COPY ./vue_app/ .

CMD ["npm", "run", "serve"]
  • FROM node:14.14.0 : Docker イメージのベースとなるイメージを選択します。 : の後はバージョンを指定します。ローカル環境と揃えたほうが無難?
  • WORKDIR /vue_app : コンテナ内で作業するディレクトリを絶対パスで指定します。指定したディレクトリがない場合は自動的に生成してくれます。
  • RUN npm install : npmをインストールします。
  • COPY ./vue_app/ . : ソースをコピーします。[ローカル環境のソースのパス]、[コンテナ内に配置するパス]の順で記します。
  • CMD ["npm", "run", "serve"] : Vueサーバーの起動。

Dockerfileを用いたコンテナの起動

ターミナルで Dockerfile が配置されているディレクトリに移動してコンテナを起動してみましょう。
まずは docker build コマンドでイメージのビルドを行います。

ターミナル
cd {Dockerfileの配置されたパス}
docker build -t vueapp:0.0.1 .
  • -t myvueapp:0.0.1 : タグをつけることができます。以降の操作が楽になるので付けておきましょう。 : で区切ってバージョンを指定することができます。
  • . : Dockerfile が配置されているパスを指定します。

ビルドができたらイメージが作成されているか確認します。

ターミナル
$ docker images
REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
vueapp                  0.0.1               fc7cabc1fe35        22 minutes ago      1.06GB

作成されてますね!

ではいよいよ docker run コマンドで起動してみましょう。

ターミナル
$ docker run --name vue_app_container --rm -it -d -p 8080:8080 vueapp:0.0.1
  • --name vue_app_container : vue_app_container という名前で起動します。こちらもコンテナイメージをビルドするときと同じく、イメージ名を指定すると管理が楽になるので指定します。
  • --rm : コンテナを停止した際に自動的にコンテナを削除してくれます。
  • --it : ターミナルのコマンドをいい感じに入出力してくれます。
  • -d : コンテナをバックグラウンドで実行します。
  • -p 8080:8080 : ローカルのポートをコンテナのポートに転送します。
  • vueapp:0.0.1 : vueappイメージのバージョン 0.0.1 を起動します。

起動したら http://localhost:8080/ へアクセスして起動確認します。

ターミナル上での起動確認

ターミナル
$ docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                    NAMES
388c1dfe015b        vueapp:0.0.1        "docker-entrypoint.s…"   About a minute ago   Up About a minute   0.0.0.0:8080->8080/tcp   vue_app_container

docker stop コマンドで停止させます。

ターミナル
docker stop vue_app_container

docker-composeの作成

ディレクトリ構成で記した場所に、以下の様に docker-compose.yml を作成します。

docker-compose.yml
version: '3'
services:
  vue:
    build: ./vue
    image: vueapp:0.0.2
    container_name: "vue_app_container"
    ports:
      - "8080:8080"
  • build : dockerfile を配置したディレクトリを指定します。
  • images : イメージ名を指定します。 : 以降にバージョンの指定ができます。
  • container_name : コンテナ名を指定します。
  • ports : ポートの転送指定をします。

docker-composeを用いたコンテナの起動

docker-compose up コマンドで起動します。

ターミナル
$ docker-compose up -d
  • -d : バックグラウンドで起動します。

起動したら http://localhost:8080/ へアクセスして起動確認します。

スクリーンショット 2020-10-23 10.22.32.png

きちんと動いてますね!

コンテナを終了させるときは docker-compose down コマンドを使います。

ターミナル
$ docker-compose down

ファイルの変更をコンテナに反映させるには docker-compose build コマンドを使います。
試しに Vue プロジェクトを自動生成したときに作成されている HelloWorld.vuetemplate タグ内の最後の行に追記してみます。

vue/vue_app/src/components/HelloWorld.vue
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <p>
      For a guide and recipes on how to configure / customize this project,<br>
      check out the
      <a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
    </p>
    <h3>Installed CLI Plugins</h3>
    <ul>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
      <li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
    </ul>
    <h3>Essential Links</h3>
    <ul>
      <li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
      <li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
      <li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
      <li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
      <li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
    </ul>
    <h3>Ecosystem</h3>
    <ul>
      <li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
      <li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
      <li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
      <li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
      <li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
    </ul>
    <div>
      追記!
    </div>
  </div>
</template>

docker-compose build コマンドを叩いた後、起動します。

ターミナル
$ docker-compose build

()

$ docker-compose up -d
スクリーンショット 2020-10-23 10.37.19.png

「追記!」の文字が見えますね!

終わりに

Vueを使ったコンテナの作成の参考になれば幸いです。
前回の記事の最後に複数のコンテナを追加したdocker-composeを書きたいとか言いつつ結局コンテナ一つの記事になってしまいました。
次回こそ、次回こそきっと…

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

DockerでPlantUML Serverの環境構築をやってみた

先日業務でクラス図を描くことが有りました。クラス図を描くのは応用情報受験以来。。。しかも応用情報は穴埋め問題でパターン暗記。。。その業務は無事終わりましたが、モデリングについて勉強してみることにしました。そこでプログラミング感覚でUMLを描くことが出来るPlantUMLの実行環境(PlantUML Server)の環境構築をDockerを使ってやってみました。

コンテナイメージのPull

Docker Hubより公式のイメージをダウンロードします。以下のコマンドで実行出来ます。

docker pull plantuml/plantuml-server

Docker Ver1.13.x以降で採用されているコマンド形式を採用すると以下のコマンドで実行できます。

docker image pull plantuml/plantuml-server

コンテナの起動

コンテナを起動します。--restart=alwaysオプションを渡すことで、ホストが再起動された場合に自動でコンテナも再起動されます。Jetty版かTomcat版を選べる様ですが、筆者は特に指定しませんでした。logを確認した所、Jetty版が標準では起動される様です。どちらにしてもJavaで実装されていることに変わりは有りません。

docker run -d -p 8080:8080 --restart=always plantuml/plantuml-server

以下のコマンドでも実行可能です。

docker container run -d -p 8080:8080 --restart=always plantuml/plantuml-server

起動確認

ホスト上で確認する場合、ブラウザを起動してlocalhost:8080にアクセスします。以下の画面に遷移すれば成功です。描画したUML図はSVGかASCII Artとしいて表示可能な様です。

plantumlsv

  • クラス図の例

plantuml

まとめ

PlantUML Serverの環境構築が出来たので、以下の書籍でモデリングの勉強をしたいと思います。

image.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

python:3.8-alpineでException: you need a C compiler to build uWSGIエラーへの対処

Dockerのイメージ作成の際に、
python:3.8-alpineで、Exception: you need a C compiler to build uWSGI エラーが出た場合の対処方を記載

1.以下の記述だとエラーになる
(Exception: you need a C compiler to build uWSGI)

dockerfile
FROM python:3.8-alpine

RUN addgroup -S uwsgi && adduser -S -G uwsgi uwsgi
RUN pip install Flask==0.10.1 uWSGI

2.以下の文でgccを追加する
RUN apk add gcc build-base linux-headers

dockerfile
FROM python:3.8-alpine

RUN apk add gcc build-base linux-headers
RUN addgroup -S uwsgi && adduser -S -G uwsgi uwsgi
RUN pip install Flask==0.10.1 uWSGI

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Golang】Alpine Docker で実行すると error: stdlib.h: No such file or directory. "stdlib.h" が足りないと言われる

golang:alpine の Docker イメージで go rungo test を実行すると stdlib.h: No such file or directoryfatal エラーで叱られる。

「"golang" "alpine" fatal error: stdlib.h: No such file or directory」とググっても日本語で情報が出てこなかったので。

$ go test ./...
Testing main package
go: downloading ...
...
# runtime/cgo
exec: "gcc": executable file not found in $PATH
  FAIL  github.com/KEINOS/Sample [build failed]
  FAIL  github.com/KEINOS/Sample/hoge [build failed]
  • go version: go version go1.15.3 linux/amd64
  • uname -a: Linux a7325e2dcf0f 4.19.76-linuxkit #1 SMP Tue May 26 11:42:35 UTC 2020 x86_64 Linux
  • cat /etc/os-release | grep PRETTY_NAME: PRETTY_NAME="Alpine Linux v3.12"

TL; DR

ミニマル・バージョン
# Minimum, at-least-to-install packages to run/build
apk add --no-cache gcc musl-dev
横着バージョン(上記も含まれています)
# Base meta-package to run/build (This includes the above packages as well)
apk add --no-cache build-base
Alpine上で開発するなら、とりま入れておけパック
# For Dev, better to be installed packages
apk add --no-cache alpine-sdk build-base

TS; DR(上記に至るまで勉強したこと)

とあるマシンで Go言語(以下 Golang)のコードを触ってコンパイる必要があったのですが、Golang がインストールされていませんでした。しかも別途インストールすることが許されず。。。

しかし、Docker はインストールされていたので golang:alpine のイメージで試そうと思いました。

ローカルのカレントディレクトリをコンテナの/goディレクトリにマウントしながら起動
$ # Docker でマウントを取ってみる
$ cd /path/to/the/repo
$ docker run --rm -it -v $(pwd):/go golang:alpine /bin/sh
...
/go #

$GOPATH/go.mod exists but should not エラー

まずはテストの実行です。動きません。

/go # go test main.go ./...
$GOPATH/go.mod exists but should not

/go # go mod download
$GOPATH/go.mod exists but should not
/go # go mod verify
$GOPATH/go.mod exists but should not
/go # go env
$GOPATH/go.mod exists but should not

/go # env
HOSTNAME=xxxxxxxxxxxx
SHLVL=1
HOME=/root
TERM=xterm
PATH=/go/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
GOPATH=/go
PWD=/go
GOLANG_VERSION=1.15.3

mod がインストールされていないからか、と思えば違うようです。どうやら上記エラーは GOPATH モードとモジュール・モードに関係しているようです。

確かに環境変数に GO111MODULE がありませんでした。そのため GOPATH モードになっているからなのかと思い GO111MODULE=on で「常にモジュール対応モードで動作する」ように設定して見たのですが、残念ながらダメでした。

GO111MODULE=onで起動
$ docker run --rm -it -v $(pwd):/go -e GO111MODULE=on golang:alpine /bin/sh
/go #
/go # echo $GO111MODULE
on
/go # go env
$GOPATH/go.mod exists but should not

GO v1.15 なので、そもそもデフォルトで GO111MODULEauto になっているようで、以下のリンクによれば回避策は2つ。

  1. /go 以外の別のディレクトリにソースコードをマウントして実行する。
  2. export GOPATH= と環境変数の設定値を「空」にセットしてユーザーディレクトリ下にキャッシュさせる。

原因はモジュール・モードなのに GOPATH で指定されたディレクトリに開発用の mod.go 付きのソースコードを置いたことでした。

モジュール・モードの場合は GOPATH で指定されたディレクトリの下にモジュールをキャッシュして行きます。そのため、そのキャッシュ・ディレクトリに go.mod などの余計なファイルがあったことがエラーの原因でした。確かに「$GOPATH/go.mod exists but should not」と「$GOPATH/go.mod があるけど禁則事項です」と言ってます。

とりあえず、/go でなく /app にマウントすることにしました。

ローカルのカレントディレクトリを/appにマウントしながら起動
$ cd /path/to/the/repo
$ docker run --rm -it -v $(pwd):/app golang:alpine /bin/sh
/go #
/go # # マウントしたディレクトリに移動
/go # cd /app
/app #
/app # # 今度は go env が表示された。GO111MODULEは空なので auto ∴ モジュールモード
/app # go env
GO111MODULE=""
GOARCH="amd64"
GOBIN=""
GOCACHE="/root/.cache/go-build"
GOENV="/root/.config/go/env"
GOEXE=""
GOFLAGS=""
GOHOSTARCH="amd64"
GOHOSTOS="linux"
GOINSECURE=""
GOMODCACHE="/go/pkg/mod"
GONOPROXY=""
GONOSUMDB=""
GOOS="linux"
GOPATH="/go"
GOPRIVATE=""
GOPROXY="https://proxy.golang.org,direct"
GOROOT="/usr/local/go"
GOSUMDB="sum.golang.org"
GOTMPDIR=""
GOTOOLDIR="/usr/local/go/pkg/tool/linux_amd64"
GCCGO="gccgo"
AR="ar"
CC="gcc"
CXX="g++"
CGO_ENABLED="1"
GOMOD=""
CGO_CFLAGS="-g -O2"
CGO_CPPFLAGS=""
CGO_CXXFLAGS="-g -O2"
CGO_FFLAGS="-g -O2"
CGO_LDFLAGS="-g -O2"
PKG_CONFIG="pkg-config"
GOGCCFLAGS="-fPIC -m64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -fdebug-prefix-map=/tmp/go-build030913204=/tmp/go-build -gno-record-gcc-switches"

"gcc": executable file not found in $PATH エラー

そして気を取り直して、再テストです。先へ進んだものの、動きません。

テストの実行
/app # go test ./...
go: downloading github.com/...
go: downloading github.com/...
...
# runtime/cgo
exec: "gcc": executable file not found in $PATH
FAIL    github.com/KEINOS/Hello-Cobra [build failed]
FAIL    github.com/KEINOS/Hello-Cobra/hoge [build failed]

なんか cgo が怒っています。gcc がない、と。えー cgo 使ってないんだけどなぁ。依存パッケージが使ってるのかしら。

とりあえず gcc をインストールして再度テストして見ます。

gccのインストール
/app # # gcc 入ってない
/app # gcc --version
/bin/sh: gcc: not found
/app #
/app # # gcc 入れる
/app # apk add --no-cache gcc
...
/app # # gcc 入った
/app # gcc --version
gcc (Alpine 9.3.0) 9.3.0
再テスト
/app # go test ./...
# runtime/cgo
_cgo_export.c:3:10: fatal error: stdlib.h: No such file or directory
    3 | #include <stdlib.h>
      |          ^~~~~~~~~~
compilation terminated.
FAIL    github.com/KEINOS/Hello-Cobra [build failed]
FAIL    github.com/KEINOS/Hello-Cobra/hoge [build failed]

やはり、どこかで cgo を使っているらしく、叱られます。今度は stdlib.h が足りないら C です。どうやら依存パッケージが cgo を使っているぽいです。

GitHub にある Golang の Docker のリポジトリに Issue が上がってました。Alpine の cgomusl-dev が必要らしいです。そういや、Alpine 系の Issue で良く見る。

try adding musl-dev
Not sure why gcc doesnt depend on it.
CGO does not seem to work on golang:1.6-alpine #86 @ GitHub より)

それでは、再度テストをしてみます。動きました。

/app # # musl-dev 入れる
/app # apk add --no-cache musl-dev
...
/app # # テストする
/app # go test ./...
ok      github.com/KEINOS/Sample        0.003s
ok      github.com/KEINOS/Sample/hoge   0.006s
/app # # ? 

Yeah, the alpine images are designed to be minimal.
CGO does not seem to work on golang:1.6-alpine #86 @ GitHub より)

「そうさ。Alpine イメージは必要最低限になるように設計されてるのさ」と、ドキュメントを嫁と言ってます。

The main caveat to note is that it does use musl libc instead of glibc and friends, which can lead to unexpected behavior.
...
To minimize image size, additional related tools (such as git, gcc, or bash) are not included in Alpine-based images. Using this image as a base, add the things you need in your own Dockerfile (see the alpine image description for examples of how to install packages if you are unfamiliar).
golang:-alpine | Quick reference | Golang | docker-library @ GitHub より)

「何はともあれ忠告しておくと、Alpine は、glibc とその愉快な仲間たちの代わりに、挙動が読めない musl libc を使っているからね。」と、あります。そして、サイズを極力小さくするため git とか gcc とか bash すら入れていない、と。

そういえば、そうでした。Alpine はコンテナ向けなので何も入っていないので、Dockerfile で必要な物を入れてあげて使う物だったのを失念しておりました。

「なんか、あれこれ apk パッケージを探してインストールするのも面倒だなぁ」と Ubuntu のとりあえず入れておけパック「build-essential」みたいなものがないかググったら、「ビルドによく使われるものを集めたパッケージ」と「開発によく使われるものを集めたパッケージ」があるらしい。

ビルドに必要な良くあるものパック
apk add build-base
開発に必要な良くあるものパック
apk add alpine-sdk

確かに、gccmusl-dev を入れなくても build-base でいけた。

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む