- 投稿日:2020-10-24T23:33:19+09:00
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
- 投稿日:2020-10-24T23:32:09+09:00
FastAPI + SQLAlchemy(postgresql)でAPI実装してみた(Dockerやミドルウェア, テストコードなども含む)
アーキテクチャ
python: v3.8.5 postgresql: v12.4 fastapi: v0.60.2 SQLAlchemy: v1.3.18マイグレーションツール
alembic: v1.4.2FastAPIとは
詳細は既に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_samplerequirements.txt作成/インストール
$ touch requirements.txtrequirements.txtにfastapiを追記
fastapi_sample/requirements.txtfastapi==0.60.2 uvicorn==0.11.8仮装環境にインストール
$ pip3 install -r requirements.txtエントリーポイント(main.py)を作成して、Swagger-UIを表示してみる
エントリーポイント(main.py)作成
$ touch main.pymain.pyを編集
fastapi_sample/main.pyfrom 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.ymlversion: '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: localNginxコンテナの設定
confファイルを用意する
$ mkdir -p nginx/conf.d $ touch nginx/conf.d/app.conffastapi_sample/docker/nginx/conf.d/app.confupstream 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/sslOpenSSLを使って秘密鍵(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サーバ証明書を信頼する
「この証明書を使用するとき」のプルダウンから「常に信頼する」を選択する
最終的にこうなっていればNginxの設定は完了です
fastapi_sample ├── docker │ └── nginx │ ├── conf.d │ │ └── app.conf │ └── ssl │ ├── server.crt │ ├── server.csr │ └── server.key ├── docker-compose.yml ├── main.py └── requirements.txtapp(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.txtwait-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 $cmdrundevserver.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_samplehttps://localhostにアクセスして、
{"message":"Hello World"}
が表示されれば完了です。ライブラリ「pydantic」を使用して環境変数(.env)を扱う
.env
ファイル作成$ touch .envfastapi_sample/.envDEBUG=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.txtpydantic[email]==1.6.1 # 追記したらpip3 installフォルダ「core」を作成、その直下に「config.py」を作成
$ mkdir core $ touch core/config.pyfastapi_sample/core/config.pyimport 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.txtalembic==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.pyfastapi_sample/migrations/models/base.pyfrom 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.pyfrom 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__.pyfrom 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.pyfrom 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:driveralembic.iniを直接書き換えるのもいいですが、開発環境やステージング、本番環境でそれぞれ異なるalembic.iniファイルができてしまい気持ち悪いです。
なので、マイグレーションファイル生成時やマイグレート実行時は、alembic.iniのデータベースURLを一時的に.env
のDATABASE_URL
で書き換えるようにしてみます。
python-dotenv
をインストールfastapi_sample/requirements.txtpython-dotenv==0.14.0 # 追記したらインストールする
env.py
を修正fastapi_sample/migrations/env.pyimport 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がバージョン管理に使用するテーブルで、自動生成されます)
データアクセスクラス作成
ベースのデータアクセスクラスを作成
単純な全件取得や1件取得、登録、更新、削除などを記載します。
base.pyに記載しているget_db_session()
は後述するリクエストミドルウェアやテストコード実行時などで大変重要な役割を果たします、今は気にしなくて良いです。mkdir crud touch crud/base.pyfastapi_sample/crud/base.pyfrom 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.pyfrom crud.base import BaseCRUD from migrations.models.user import User class CRUDUser(BaseCRUD): """ ユーザーデータアクセスクラスのベース """ model = UserAPI実装
お待たせしました、ようやくAPI実装編です。
API実装前準備
リクエスト情報にDBセッションを格納する依存性注入用(Depends)の関数を実装します。
このFastAPIの依存性注入システムがとても強力で、パラメーターをまとめたりもすることができます。
完全に理解しているわけではないので、申し訳ないのですが詳細は公式ドキュメントを参照してください。mkdir dependencies touch dependencies/__init__.pydependencies/__init__.pyfrom 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_sessionAPIを実装していくフォルダ作成
mkdir -p api/v1ユーザーの一覧取得API実装
touch api/v1/user.py
fastapi_sample/api/v1/user.pyfrom 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.pyfastapi_sample/api/endpoints/v1/user.pyfrom 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__.pyfrom 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.pyfrom 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が表示されているはずです。
登録/更新用にスキーマ(フォーム?)を定義する
mkdir api/schemas touch api/schemas/user.pyfastapi_sample/api/schemas/user.pyfrom 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.pyfrom 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.pyfrom 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)ブラウザからアクセスして確認
routerに
response_model
を指定すると、DBから取得したオブジェクトのJSONシリアライズを開発者が明示的に実装する必要がなくなります。(jsonable_encoder()
を使わなくて良くなったのはこれのおかげ)
また、swagger-UI上にも反映してくれます。
※一覧取得のレスポンス
リクエストミドルウェアを実装する
さて、APIは実行できましたが、DBセッションのコミット実行していないので登録/更新/削除を実行してもDBが変更されません。
変更をDBに反映させるため、以下のようなリクエストミドルウェアを実装して適用します。
・リクエストが完了したらDBセッションコミットしてからDBセッション破棄
・エラー発生時はロールバックしてからDBセッション破棄mkdir middleware touch middleware/__init__.pyfastapi_sample/middleware/__init__.pyfrom 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.pyfrom 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実行で確認
テストコード実装編
CRUDを実装できたので、次はテストコードを実装していきます。
とはいえ、単純にテストコードからAPIを実行してしまうと、DBに登録・更新・削除が実行されるため、テスト実行ごとに結果が変わってしまう可能性があります。
それを防ぐため、テスト関数ごとにクリーンなDBを作成し、そのDBを使用してテストするようにしたいと思います。pytestをインストール
fastapi_sample/requirements.txtpytest==6.1.0 # 追記したらrequirements.txtをインストールし直すのを忘れずにテスト用のDB接続情報を環境変数に追加
fastapi_sample/.envDEBUG=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.pyclass Environment(BaseSettings): """ 環境変数を読み込むファイル """ debug: bool database_url: str test_database_url: str # 追加テストコード実装用のフォルダ作成
mkdir tests
APIのベーステストクラスを作成
touch tests/base.py
fastapi_sample/tests/base.pyimport 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こうすることで、APIやミドルウェアで使用している「request.state.db_session」もテストDBに切り替えることができます。
一覧取得のテストコードを実装してみる
requirements.txtrequests==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.pyimport 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に切り替えてのテスト実行は成功したようです!ローカル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__.pyfrom 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.pyimport 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.pyfrom 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.pyfrom 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のエラーが解消され、表示されるようになりました。
終わり
FastAPIでCRUDを実装してみました。
フルスタックフレームワークのDjango(RestはDRF)ばかり使っていたせいで、初め「なんだこの使いづらいフレームワークは・・・」とか思っていましたが、今ではもうDjangoよりFastAPI派になってしまいました。
ガシガシ環境周りのコードを自分で実装できて楽しいですし、何より成長に繋がりました。
公式ドキュメントを読みきれていないので、他にもいい方法はあるかと思いますので、
コメントでこっそり教えてもらえると嬉しいです( ´ノω`)
私が所属しているFabeee株式会社はお仕事 と 一緒に働くお仲間を随時募集しております!!!!(宣伝)
- 投稿日:2020-10-24T23:32:09+09:00
FastAPI + SQLAlchemy(postgresql)でCRUD APIを実装してみた(Dockerやミドルウェア, テストコードなども含む)
アーキテクチャ
python: v3.8.5 postgresql: v12.4 fastapi: v0.60.2 SQLAlchemy: v1.3.18マイグレーションツール
alembic: v1.4.2FastAPIとは
詳細は既に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_samplerequirements.txt作成/インストール
$ touch requirements.txtrequirements.txtにfastapiを追記
fastapi_sample/requirements.txtfastapi==0.60.2 uvicorn==0.11.8仮装環境にインストール
$ pip3 install -r requirements.txtエントリーポイント(main.py)を作成して、Swagger-UIを表示してみる
エントリーポイント(main.py)作成
$ touch main.pymain.pyを編集
fastapi_sample/main.pyfrom 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.ymlversion: '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: localNginxコンテナの設定
confファイルを用意する
$ mkdir -p nginx/conf.d $ touch nginx/conf.d/app.conffastapi_sample/docker/nginx/conf.d/app.confupstream 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/sslOpenSSLを使って秘密鍵(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サーバ証明書を信頼する
「この証明書を使用するとき」のプルダウンから「常に信頼する」を選択する
最終的にこうなっていればNginxの設定は完了です
fastapi_sample ├── docker │ └── nginx │ ├── conf.d │ │ └── app.conf │ └── ssl │ ├── server.crt │ ├── server.csr │ └── server.key ├── docker-compose.yml ├── main.py └── requirements.txtapp(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.txtwait-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 $cmdrundevserver.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_samplehttps://localhostにアクセスして、
{"message":"Hello World"}
が表示されれば完了です。ライブラリ「pydantic」を使用して環境変数(.env)を扱う
.env
ファイル作成$ touch .envfastapi_sample/.envDEBUG=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.txtpydantic[email]==1.6.1 # 追記したらpip3 installフォルダ「core」を作成、その直下に「config.py」を作成
$ mkdir core $ touch core/config.pyfastapi_sample/core/config.pyimport 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.txtalembic==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.pyfrom 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.pyfrom 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.pyfrom 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:driveralembic.iniを直接書き換えるのもいいですが、開発環境やステージング、本番環境でそれぞれ異なるalembic.iniファイルができてしまい気持ち悪いです。
なので、マイグレーションファイル生成時やマイグレート実行時は、alembic.iniのデータベースURLを一時的に.env
のDATABASE_URL
で書き換えるようにしてみます。
python-dotenv
をインストールfastapi_sample/requirements.txtpython-dotenv==0.14.0 # 追記したらインストールする
env.py
を修正fastapi_sample/migrations/env.pyimport 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がバージョン管理に使用するテーブルで、自動生成されます)
コンテナ起動時にマイグレートが実行されるようにする
これでコンテナを起動するたびにマイグレートが実行されるようになります。
fastapi_sample/docker/rundevserver.shpip3 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.pyfastapi_sample/crud/base.pyfrom 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.pyfrom crud.base import BaseCRUD from migrations.models import User class CRUDUser(BaseCRUD): """ ユーザーデータアクセスクラスのベース """ model = UserAPI実装
お待たせしました、ようやくAPI実装編です。
API実装前準備
リクエスト情報にDBセッションを格納する依存性注入用(Depends)の関数を実装します。
このFastAPIの依存性注入システムがとても強力で、パラメーターをまとめたりもすることができます。
完全に理解しているわけではないので、申し訳ないのですが詳細は公式ドキュメントを参照してください。mkdir dependencies touch dependencies/__init__.pydependencies/__init__.pyfrom 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_sessionAPIを実装していくフォルダ作成
mkdir -p api/v1ユーザーの一覧取得API実装
touch api/v1/user.py
fastapi_sample/api/v1/user.pyfrom 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.pyfastapi_sample/api/endpoints/v1/user.pyfrom 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__.pyfrom 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.pyfrom 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が表示されているはずです。
登録/更新用にスキーマ(フォーム?)を定義する
mkdir api/schemas touch api/schemas/user.pyfastapi_sample/api/schemas/user.pyfrom 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.pyfrom 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.pyfrom 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)ブラウザからアクセスして確認
routerに
response_model
を指定すると、DBから取得したオブジェクトのJSONシリアライズを開発者が明示的に実装する必要がなくなります。(jsonable_encoder()
を使わなくて良くなったのはこれのおかげ)
また、swagger-UI上にも反映してくれます。
※一覧取得のレスポンス
リクエストミドルウェアを実装する
さて、APIは実行できましたが、DBセッションのコミット実行していないので登録/更新/削除を実行してもDBが変更されません。
変更をDBに反映させるため、以下のようなリクエストミドルウェアを実装して適用します。
・リクエストが完了したらDBセッションコミットしてからDBセッション破棄
・エラー発生時はロールバックしてからDBセッション破棄mkdir middleware touch middleware/__init__.pyfastapi_sample/middleware/__init__.pyfrom 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.pyfrom 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実行で確認
テストコード実装編
CRUDを実装できたので、次はテストコードを実装していきます。
とはいえ、単純にテストコードからAPIを実行してしまうと、DBに登録・更新・削除が実行されるため、テスト実行ごとに結果が変わってしまう可能性があります。
それを防ぐため、テスト関数ごとにクリーンなDBを作成し、そのDBを使用してテストするようにしたいと思います。pytestをインストール
fastapi_sample/requirements.txtpytest==6.1.0 # 追記したらrequirements.txtをインストールし直すのを忘れずにテスト用のDB接続情報を環境変数に追加
fastapi_sample/.envDEBUG=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.pyclass Environment(BaseSettings): """ 環境変数を読み込むファイル """ debug: bool database_url: str test_database_url: str # 追加テストコード実装用のフォルダ作成
mkdir tests
APIのベーステストクラスを作成
touch tests/base.py
fastapi_sample/tests/base.pyimport 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こうすることで、APIやミドルウェアで使用している「request.state.db_session」もテストDBに切り替えることができます。
一覧取得のテストコードを実装してみる
requirements.txtrequests==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.pyimport 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に切り替えてのテスト実行は成功したようです!ローカル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__.pyfrom 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.pyimport 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.pyfrom 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.pyfrom 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のエラーが解消され、表示されるようになりました。
【オマケ①】CORS問題を回避
このままだと、フロントエンドからのAPI呼び出し時にCORSエラー発生してしまいます。
この問題を回避するため、CORSミドルウェアを実装します。
.env
と環境変数を扱うクラス
を編集fastapi_sample/.envALLOW_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_samplefastapi_sample/core/config.pyclass Environment(BaseSettings): """ 環境変数を読み込むファイル """ allow_headers: list # 追加 allow_origins: list # 追加 ... .. .CORSミドルウェアを実装・エントリポイントに適用
fastapi_sample/middleware/__init__.pyfrom 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.pyfrom 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を作成してフロントエンドから実行)
【オマケ②】カスタム例外
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.pyimport 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__.pyfrom 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.pyfrom 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__.pyfrom 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.jsaxios.interceptors.response.use( response => { return response; }, error => { // エラーレスポンスをコンソール表示 console.log(error.response); ... .. .カスタム例外がスローされ、フロント側でエラーレスポンスを確認することができました。
システムエラーについて
アプリが意図的に返す例外は問題ないですが、意図せぬ例外(システムエラー)はどうでしょうか。
fastapi_sample/api/v1/auth.pyfrom fastapi import Request class AuthAPI: """ 認証に関するAPI """ @classmethod def login(cls, request: Request): """ ログインAPI """ result = 1 / 0 # 0除算でエラー return result
結果はCORSエラーが返却された上に、エラーレスポンス(error.response)の中身がundefined
です。
(CORSエラーは多分エラーの形式が不正だから・・・??)
システム例外用のカスタムExceptionを作成
fastapi_sample/exception/__init__.pyimport 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__.pyfrom 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()フロント側でシステムエラーのエラーレスポンスを受け取ることができました。
終わり
FastAPIでCRUDを実装してみました。
フルスタックフレームワークのDjango(RestはDRF)ばかり使っていたせいで、初め「なんだこの使いづらいフレームワークは・・・」とか思っていましたが、今ではもうDjangoよりFastAPI派になってしまいました。
ガシガシ環境周りのコードを自分で実装できて楽しいですし、何より成長に繋がりました。
公式ドキュメントを読みきれていないので、他にもいい方法はあるかと思いますので、
コメントでこっそり教えてもらえると嬉しいです( ´ノω`)
私が所属しているFabeee株式会社はお仕事 と 一緒に働くお仲間を随時募集しております!!!!(宣伝)
- 投稿日:2020-10-24T23:32:09+09:00
FastAPI + SQLAlchemy(postgresql)によるCRUD API実装ハンズオン(Dockerやミドルウェア, テストコードなども含む)
アーキテクチャ
python: v3.8.5 postgresql: v12.4 fastapi: v0.60.2 SQLAlchemy: v1.3.18マイグレーションツール
alembic: v1.4.2FastAPIとは
詳細は既に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_samplerequirements.txt作成/インストール
$ touch requirements.txtrequirements.txtにfastapiを追記
fastapi_sample/requirements.txtfastapi==0.60.2 uvicorn==0.11.8仮装環境にインストール
$ pip3 install -r requirements.txtエントリーポイント(main.py)を作成して、Swagger-UIを表示してみる
エントリーポイント(main.py)作成
$ touch main.pymain.pyを編集
fastapi_sample/main.pyfrom 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.ymlversion: '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: localNginxコンテナの設定
confファイルを用意する
$ mkdir -p nginx/conf.d $ touch nginx/conf.d/app.conffastapi_sample/docker/nginx/conf.d/app.confupstream 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/sslOpenSSLを使って秘密鍵(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サーバ証明書を信頼する
「この証明書を使用するとき」のプルダウンから「常に信頼する」を選択する
最終的にこうなっていればNginxの設定は完了です
fastapi_sample ├── docker │ └── nginx │ ├── conf.d │ │ └── app.conf │ └── ssl │ ├── server.crt │ ├── server.csr │ └── server.key ├── docker-compose.yml ├── main.py └── requirements.txtapp(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.txtwait-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 $cmdrundevserver.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_samplehttps://localhostにアクセスして、
{"message":"Hello World"}
が表示されれば完了です。ライブラリ「pydantic」を使用して環境変数(.env)を扱う
.env
ファイル作成$ touch .envfastapi_sample/.envDEBUG=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.txtpydantic[email]==1.6.1 # 追記したらpip3 installフォルダ「core」を作成、その直下に「config.py」を作成
$ mkdir core $ touch core/config.pyfastapi_sample/core/config.pyimport 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.txtalembic==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.pyfrom 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.pyfrom 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.pyfrom 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:driveralembic.iniを直接書き換えるのもいいですが、開発環境やステージング、本番環境でそれぞれ異なるalembic.iniファイルができてしまい気持ち悪いです。
なので、マイグレーションファイル生成時やマイグレート実行時は、alembic.iniのデータベースURLを一時的に.env
のDATABASE_URL
で書き換えるようにしてみます。
python-dotenv
をインストールfastapi_sample/requirements.txtpython-dotenv==0.14.0 # 追記したらインストールする
env.py
を修正fastapi_sample/migrations/env.pyimport 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がバージョン管理に使用するテーブルで、自動生成されます)
コンテナ起動時にマイグレートが実行されるようにする
これでコンテナを起動するたびにマイグレートが実行されるようになります。
fastapi_sample/docker/rundevserver.shpip3 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.pyfastapi_sample/crud/base.pyfrom 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.pyfrom crud.base import BaseCRUD from migrations.models import User class CRUDUser(BaseCRUD): """ ユーザーデータアクセスクラスのベース """ model = UserAPI実装
お待たせしました、ようやくAPI実装編です。
API実装前準備
リクエスト情報にDBセッションを格納する依存性注入用(Depends)の関数を実装します。
このFastAPIの依存性注入システムがとても強力で、パラメーターをまとめたりもすることができます。
完全に理解しているわけではないので、申し訳ないのですが詳細は公式ドキュメントを参照してください。mkdir dependencies touch dependencies/__init__.pydependencies/__init__.pyfrom 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_sessionAPIを実装していくフォルダ作成
mkdir -p api/v1ユーザーの一覧取得API実装
touch api/v1/user.py
fastapi_sample/api/v1/user.pyfrom 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.pyfastapi_sample/api/endpoints/v1/user.pyfrom 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__.pyfrom 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.pyfrom 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が表示されているはずです。
登録/更新用にスキーマ(フォーム?)を定義する
mkdir api/schemas touch api/schemas/user.pyfastapi_sample/api/schemas/user.pyfrom 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.pyfrom 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.pyfrom 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)ブラウザからアクセスして確認
routerに
response_model
を指定すると、DBから取得したオブジェクトのJSONシリアライズを開発者が明示的に実装する必要がなくなります。(jsonable_encoder()
を使わなくて良くなったのはこれのおかげ)
また、swagger-UI上にも反映してくれます。
※一覧取得のレスポンス
リクエストミドルウェアを実装する
さて、APIは実行できましたが、DBセッションのコミット実行していないので登録/更新/削除を実行してもDBが変更されません。
変更をDBに反映させるため、以下のようなリクエストミドルウェアを実装して適用します。
・リクエストが完了したらDBセッションコミットしてからDBセッション破棄
・エラー発生時はロールバックしてからDBセッション破棄mkdir middleware touch middleware/__init__.pyfastapi_sample/middleware/__init__.pyfrom 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.pyfrom 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実行で確認
テストコード実装編
CRUDを実装できたので、次はテストコードを実装していきます。
とはいえ、単純にテストコードからAPIを実行してしまうと、DBに登録・更新・削除が実行されるため、テスト実行ごとに結果が変わってしまう可能性があります。
それを防ぐため、テスト関数ごとにクリーンなDBを作成し、そのDBを使用してテストするようにしたいと思います。pytestをインストール
fastapi_sample/requirements.txtpytest==6.1.0 # 追記したらrequirements.txtをインストールし直すのを忘れずにテスト用のDB接続情報を環境変数に追加
fastapi_sample/.envDEBUG=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.pyclass Environment(BaseSettings): """ 環境変数を読み込むファイル """ debug: bool database_url: str test_database_url: str # 追加テストコード実装用のフォルダ作成
mkdir tests
APIのベーステストクラスを作成
touch tests/base.py
fastapi_sample/tests/base.pyimport 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こうすることで、APIやミドルウェアで使用している「request.state.db_session」もテストDBに切り替えることができます。
一覧取得のテストコードを実装してみる
requirements.txtrequests==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.pyimport 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に切り替えてのテスト実行は成功したようです!ローカル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__.pyfrom 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.pyimport 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.pyfrom 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.pyfrom 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のエラーが解消され、表示されるようになりました。
【オマケ①】CORS問題を回避
このままだと、フロントエンドからのAPI呼び出し時にCORSエラー発生してしまいます。
この問題を回避するため、CORSミドルウェアを実装します。
.env
と環境変数を扱うクラス
を編集fastapi_sample/.envALLOW_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_samplefastapi_sample/core/config.pyclass Environment(BaseSettings): """ 環境変数を読み込むファイル """ allow_headers: list # 追加 allow_origins: list # 追加 ... .. .CORSミドルウェアを実装・エントリポイントに適用
fastapi_sample/middleware/__init__.pyfrom 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.pyfrom 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を作成してフロントエンドから実行)
【オマケ②】カスタム例外
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.pyimport 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__.pyfrom 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.pyfrom 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__.pyfrom 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.jsaxios.interceptors.response.use( response => { return response; }, error => { // エラーレスポンスをコンソール表示 console.log(error.response); ... .. .カスタム例外がスローされ、フロント側でエラーレスポンスを確認することができました。
システムエラーについて
アプリが意図的に返す例外は問題ないですが、意図せぬ例外(システムエラー)はどうでしょうか。
fastapi_sample/api/v1/auth.pyfrom fastapi import Request class AuthAPI: """ 認証に関するAPI """ @classmethod def login(cls, request: Request): """ ログインAPI """ result = 1 / 0 # 0除算でエラー return result
結果はCORSエラーが返却された上に、エラーレスポンス(error.response)の中身がundefined
です。
(CORSエラーは多分エラーの形式が不正だから・・・??)
システム例外用のカスタムExceptionを作成
fastapi_sample/exception/__init__.pyimport 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__.pyfrom 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()フロント側でシステムエラーのエラーレスポンスを受け取ることができました。
終わり
FastAPIでCRUDを実装してみました。
フルスタックフレームワークのDjango(RestはDRF)ばかり使っていたせいで、初め「なんだこの使いづらいフレームワークは・・・」とか思っていましたが、今ではもうDjangoよりFastAPI派になってしまいました。
ガシガシ環境周りのコードを自分で実装できて楽しいですし、何より成長に繋がりました。
公式ドキュメントを読みきれていないので、他にもいい方法はあるかと思いますので、
コメントでこっそり教えてもらえると嬉しいです( ´ノω`)
私が所属しているFabeee株式会社はお仕事 と 一緒に働くお仲間を随時募集しております!!!!(宣伝)
- 投稿日:2020-10-24T22:00:02+09:00
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.confhttp { (中略) proxy_cache_path /data/nginx/cache keys_zone=cachezone:10m max_size=200m; include /etc/nginx/conf.d/*.conf; (中略) }/etc/nginx/conf.d/default.confserver { 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.confhttp { (中略) 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"といった感じで、内部通信のログと、キャッシュヒット有無が表示されるプロキシ機能のログが出力される。
性能差はどれくらいか?
キャッシュなし
まだまだ余裕そうな雰囲気だが、性能ツール側を止めてしまったので一旦ここまで。
キャッシュあり
あれ?キャッシュなしの方が性能が良いように見える。
たぶん、今回の負荷モデルは「それほど大きくないコンテンツに集中的にアクセス」なので、そもそもOSのバッファキャッシュに乗ってしまって、かえってキャッシュありの方がオーバーヘッドになっているのではないかという気がする。
厳密に差分を確認するには、キャッシュの恩恵を得られるような「大きいコンテンツに分散アクセス」しないと分からないかもしれない。
- 投稿日:2020-10-24T21:29:59+09:00
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.ymlservices: locust-master: volumes: - ./log:/locust/logとか定義しておこう。
これで
test_stats_history.csvTimestamp,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お、なんかスッキリした出力になった!
- 投稿日:2020-10-24T19:49:18+09:00
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 -pMySQL コマンドラインクライアントの詳細については 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現在、これは以下の変数でのみサポートされています。
- MYSQL_ROOT_PASSWORD
- MYSQL_ROOT_HOST
- MYSQL_DATABASE
- MYSQL_USER
- MYSQL_PASSWORD
新しいインスタンスの初期化
コンテナが初めて起動されると、指定された名前の新しいデータベースが作成され、指定された構成変数で初期化されます。
さらに/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/
ディレクトリにある可能性があります。
ビルド済みのイメージの使用に関しては、このイメージの使用がそれに含まれるすべてのソフトウェアの関連するライセンスに準拠していることを確認するのは、イメージユーザーの責任です。
- 投稿日:2020-10-24T19:49:18+09:00
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 -pMySQL コマンドラインクライアントの詳細については 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現在、これは以下の変数でのみサポートされています。
- MYSQL_ROOT_PASSWORD
- MYSQL_ROOT_HOST
- MYSQL_DATABASE
- MYSQL_USER
- MYSQL_PASSWORD
新しいインスタンスの初期化
コンテナが初めて起動されると、指定された名前の新しいデータベースが作成され、指定された構成変数で初期化されます。
さらに/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/
ディレクトリにある可能性があります。
ビルド済みのイメージの使用に関しては、このイメージの使用がそれに含まれるすべてのソフトウェアの関連するライセンスに準拠していることを確認するのは、イメージユーザーの責任です。
- 投稿日:2020-10-24T17:32:59+09:00
Nuxt.jsによるフロントエンド開発CI設定手順(2020/10/24バージョン)
各ツールのバージョン
Visual Studio Code, Docker を使用します。各バージョンは以下の通りです。
今回はWindows10を使用していますが、MacOSでも同様に操作できます。
- Windows
- Visual Studio Code
- Docker
※以前Windowsのバージョンが1909だとDockerが入らなかったのですが、現在はインストール可能となったようです。
開発環境作成
Nuxt プロジェクトを作成し、GitHub で main ブランチに push したら自動で GitHub Pages に公開するよう準備します。
2) 作業フォルダを作成し開きます。(今回は C:\dev\nuxt20201024 としました)
3) 新規ファイル作成で Dockerfile という名前のファイルを作成します。
4) Dockerfile に Node.js 環境を作る内容を記載します。
DockerfileFROM node:lts-alpine ENV CHOKIDAR_USEPOLLING=true NUXT_TELEMETRY_DISABLED=1 RUN apk update && apk add git1行目:alpine OS の Node.js 入りイメージをベースにします。node:alpineだと Nuxt プロジェクトを作るときエラーとなったのでltsにしています。
2行目:開発時の自動更新を有効にし、Nuxt 実行時の統計確認を無効にします。
3行目:パッケージを更新し、gitをインストールします。5) Visual Studio Code の Remote Development を使って Dockerコンテナを起動します。
※Remote Development 拡張機能が入っていない場合はインストールしてください。
6) Vuter 拡張機能をインストールする設定("octref.vetur")を追加し、Docker環境をリビルドします。
.devcontainer/devcontainer.json{ (...省略) // Add the IDs of extensions you want installed when the container is created. "extensions": ["octref.vetur"] (...省略) }Terminalyarn 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.jsUI フレームワークを聞かれますがあとから追加可能なので 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-nuxtSuccessfully が出れば作成完了です。
NPM SCRIPTS の dev - my-nuxt の右に出る三角ボタンで実行できます。
リモートエクスプローラタブに切り替えると FORWARDED PORTS がひょうじされ、3000 のところの地球儀のマークをクリックするとブラウザが起動します。
9) Git リポジトリ管理を開始します。
ソースコントロールタブに切り替えると変更ファイル一覧が表示されているので、Message 欄にコミットコメントとして first と入力してチェックボタンを押しコミットします。
Repository name を入力し、Public で作成します。(Private だと GitHub Pages を使うとき有料になります。)
11) リポジトリを GitHub に紐づけて Push します。
SOURCE CONTROL の・・・メニューから Remote / Add Remote... を選択し、Add Remote from GitHub を選択します。
12) GitHub Actions で GitHub Pages にデプロイする設定を追加します。
my-nuxt/.github/workflows/ci.yml を開きます。
テスト用の設定は入っているので、そのあとに 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: ./dist13) コミット、Push して GitHub Actions の動作ログを確認し、デプロイされたページも確認します。
GitHub の Actions ページを見て先ほど Push したコメントの workflow が完了していればデプロイ完了です。
デプロイ完了していれば gh-pages ブランチができます。
gh-pages ブランチに index.html があれば成功です。
GutHub の Settings ページの中にある GitHub Pages の設定に行きます。
Source を gh-pages に切り替えて Save ボタンを押します。
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 generatemy-nuxt/.github/workflows/ci.yml変更後- run: BASE=/my-nuxt/ yarn generateコミット、Push してブラウザで確認します。
以上で、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 に設定します。
New secret ボタンを押して、Name には ci.yml に記載した FTP_PASSWORD を、Value にはパスワードを入力し、Add Secret ボタンを押します。
また、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で転送されるようになります。
この仕組みを使えばレンタルサーバさえあればWebサービスを公開できます。
これまで手動でホームページを管理していたものも GitHub 管理にでき、アップロードが自動化できます。
そして、GitHub Pages とは違って、PHP やデータベースを使ったバックエンドも合わせて作れるので個人で開発するときに有用かと思います。
- 投稿日:2020-10-24T16:41:44+09:00
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/ )
- 投稿日:2020-10-24T15:53:58+09:00
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は
型番が合わないようだ・・・がっくし
- 投稿日:2020-10-24T15:21:59+09:00
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参考
- 投稿日:2020-10-24T13:10:58+09:00
初学者による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_rsa
とid_rsa.pub
に2つのファイルができる。
id_rsa.pub
のほうが公開鍵なので、この中身を github の Deploy Key に登録することになる。
この公開鍵をコピーしておく。cat ~/.ssh/id_rsa.pubコピーした公開鍵をGithubに登録する
SSHキー登録ページで登録できる。
Titleはキーの内容がわかる文面を記入し、Keyに先ほどコピーした公開鍵を貼り付ける。
登録できたかをEC2から確認
ssh -T git@github.comEC2からgithubのレポジトリをクローンして終わり!
git clone git@github.com:yourname/yourrepo.gitそして
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.ymlvolumes: - './app:/usr/src/app' - usr/src/app/node_modules #追記これでなんとかなりました!
- 投稿日:2020-10-24T11:11:54+09:00
DockerでVue3環境を作る!
はじめに
この記事は
Docker
を使ってVue3.0
環境の構築までを記します。
初心者のため間違い等ございましたらご指摘いただけると幸いです。前提条件
Docker
、node
、npm
をインストールしていること。
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.8Vueのバージョンアップ
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 2
かVue 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/ へアクセスしてみます。
ちゃんと起動してますね!
ターミナル上で
control
+c
でサーバーを停止させます。コンテナの環境構築
次にコンテナで
Vue
環境を構築していきます。Dockerfileの作成
ディレクトリ構成で記した場所に、以下の様に
Dockerfile
を作成します。DockerfileFROM 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_containerdocker-composeの作成
ディレクトリ構成で記した場所に、以下の様に
docker-compose.yml
を作成します。docker-compose.ymlversion: '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/ へアクセスして起動確認します。
きちんと動いてますね!
コンテナを終了させるときは
docker-compose down
コマンドを使います。ターミナル$ docker-compose down
ファイルの変更をコンテナに反映させるには
docker-compose build
コマンドを使います。
試しにVue
プロジェクトを自動生成したときに作成されているHelloWorld.vue
のtemplate
タグ内の最後の行に追記してみます。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
「追記!」の文字が見えますね!
終わりに
Vue
を使ったコンテナの作成の参考になれば幸いです。
前回の記事の最後に複数のコンテナを追加したdocker-compose
を書きたいとか言いつつ結局コンテナ一つの記事になってしまいました。
次回こそ、次回こそきっと…
- 投稿日:2020-10-24T09:33:10+09:00
DockerでPlantUML Serverの環境構築をやってみた
先日業務でクラス図を描くことが有りました。クラス図を描くのは応用情報受験以来。。。しかも応用情報は穴埋め問題でパターン暗記。。。その業務は無事終わりましたが、モデリングについて勉強してみることにしました。そこでプログラミング感覚でUMLを描くことが出来るPlantUMLの実行環境(PlantUML Server)の環境構築をDockerを使ってやってみました。
コンテナイメージのPull
Docker Hubより公式のイメージをダウンロードします。以下のコマンドで実行出来ます。
docker pull plantuml/plantuml-serverDocker 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としいて表示可能な様です。
- クラス図の例
まとめ
PlantUML Serverの環境構築が出来たので、以下の書籍でモデリングの勉強をしたいと思います。
- 投稿日:2020-10-24T01:11:51+09:00
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)dockerfileFROM python:3.8-alpine RUN addgroup -S uwsgi && adduser -S -G uwsgi uwsgi RUN pip install Flask==0.10.1 uWSGI2.以下の文でgccを追加する
RUN apk add gcc build-base linux-headersdockerfileFROM 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以上
- 投稿日:2020-10-24T00:12:42+09:00
【Golang】Alpine Docker で実行すると error: stdlib.h: No such file or directory. "stdlib.h" が足りないと言われる
golang:alpine
の Docker イメージでgo run
やgo test
を実行するとstdlib.h: No such file or directory
のfatal
エラーで叱られる。「"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/amd64uname -a
: Linux a7325e2dcf0f 4.19.76-linuxkit #1 SMP Tue May 26 11:42:35 UTC 2020 x86_64 Linuxcat /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-baseAlpine上で開発するなら、とりま入れておけパック# For Dev, better to be installed packages apk add --no-cache alpine-sdk build-baseTS; 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
モードとモジュール・モードに関係しているようです。
- 参考文献
- Go: DepからGo Modulesへの移行 @ Qiita
確かに環境変数に
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 notGO v1.15 なので、そもそもデフォルトで
GO111MODULE
はauto
になっているようで、以下のリンクによれば回避策は2つ。
- 参考文献
/go
以外の別のディレクトリにソースコードをマウントして実行する。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
使ってないんだけどなぁ。依存パッケージが使ってるのかしら。
- cgoを使ったCとGoのリンクの裏側 (1) @ Qiita
とりあえず
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 の
cgo
はmusl-dev
が必要らしいです。そういや、Alpine 系の Issue で良く見る。try adding
musl-dev
Not sure whygcc
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 ofglibc
and friends, which can lead to unexpected behavior.
...
To minimize image size, additional related tools (such asgit
,gcc
, orbash
) are not included in Alpine-based images. Using this image as a base, add the things you need in your own Dockerfile (see thealpine
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
- What is the alpine equivalent to build-essential? #24 | docker-alpine | gliderlabs @ GitHub
確かに、
gcc
やmusl-dev
を入れなくてもbuild-base
でいけた。参考文献
- GOPATH モードからモジュール対応モードへ移行せよ @ Qiita
- cgoを使ったCとGoのリンクの裏側 (1) @ Qiita
- [Golang]$GOPATH/go.mod exists but should notを回避する @ Selfnote
- When trying to build docker image, I get “gcc“: executable file not found in $PATH” @ StackOverflow
- CGO does not seem to work on golang:1.6-alpine #86 @ GitHub
- What is the alpine equivalent to build-essential? #24 | docker-alpine | gliderlabs @ GitHub
- Getting GOPATH error “go: cannot use path@version syntax in GOPATH mode” in Ubuntu 16.04 @ StackOverflow