データテクノロジーセンター 兼 3D円グラフ撲滅委員の 吉野祥 です。

 

Dockerによる設定は一度設定が完了して、しばらく触らないと、なぜそのように設定したのかを個人的に忘れがちになります。

本記事では、備忘録のための車輪の再開発をしつつ、Gunicornによるプロセス監視など使ったことがなかったパッケージも使って、FastAPIを動かしてみようと思います。

ディレクトリ構成

├ backend
│ ├ router
│ │ ├ __init__.py
│ │ └ main.py
│ ├ schemas
│ │ ├ __init__.py
│ │ └ main.py
│ ├ gunicorn.conf.py
│ └ main.py
├ docker
│ ├ DockerFile
│ ├ Pipfile
│ └ Pipfile.lock
├ scripts
│ └ make_key.sh
├ logs
├ docker-compose.override.yml
├ docker-compose.prod.yml
└ docker-compose.yml

Pipfile

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
fastapi = "==0.63.0"
uvicorn = {extras = ["standard"], version = "==0.13.4"}
Gunicorn = "==20.0.4"
orjson = "==3.5.1"
python-multipart = "==0.0.5"
aiofiles = "==0.6.0"
[dev-packages]
pytest = "==6.2.2"
pytest-asyncio = "==0.14.0"
requests = "==2.25.1"
[requires]
python_version = "3.8"

パッケージのインストールにはpipenvを使用しました。

FastAPIを動かすのに最低限必要なのはfastapi, uvicornです。 uvicornstandardでインストールしないと、gunicornを動かすとき、uvloopがないぞ、と怒られます。

uvicornはワーカー数の指定はできますが、落ちたことを感知して再起動するなどのプロセス監視はできないため、Gunicornをインストールしています。

プロセスを監視するパッケージとして、FastAPI – Using a process managerでは、Gunicorn, Supervisor, Circusによる監視方法を提示しています。

Uvicorn provides a lightweight way to run multiple worker processes, for example –workers 4, but does not provide any process monitoring.

Gunicorn is probably the simplest way to run and manage Uvicorn in a production setting. Uvicorn includes a gunicorn worker class that means you can get set up with very little configuration.

orjsonは、より高速にJSONを取り扱うために使用しています。(参考: FastAPI – Custom Response – HTML, Stream, File, others – Use ORJSONResponse)

For example, if you are squeezing performance, you can install and use orjson and set the response to be ORJSONResponse.

下記パッケージは本記事では使用していませんが、利用することが多いので、ついでにインストールしています。

  • python-multipart
  • aiofiles
  • pytest
  • requests
  • pytest-asyncio
    • 非同期のテストに必要

    Docker

    FROM python:3.8-slim as base
    WORKDIR /usr/src/fastapi_sample
    COPY docker/Pipfile .
    RUN pip install pipenv
    # 開発用
    FROM base as dev
    RUN pipenv install --dev
    # 本番用
    FROM base as prod
    RUN pipenv install
    COPY backend backend

    本番用と開発用にマルチステージビルドを分けています。

    本番と開発で分けると、ポート番号や環境変数などの設定の違いがあるときや、テスト時にしか使わないパッケージを除く場合に便利です。 また、パッケージのダウンロードやインストール時に残った不要なファイルの削除もできます。

    • 開発用
      • ソースコードを永続ボリュームで共有
        • 後述のdocker-compose.override.yml にて設定
  • テストパッケージインストール
  • 本番用
    • ソースコードをイメージにCOPY
  • 本番用はソースコードをイメージに埋め込むことが多いですが、開発時は埋め込みだとコードの変更があるたびにイメージを作り直さなくて不便なので、開発時は永続ボリュームを利用しています。

    docker-compose.yml

    version: '3.9'
    services:
    fastapi-sample:
    build:
    context: ./
    dockerfile: docker/Dockerfile
    image: fastapi-sample
    working_dir: /usr/src/fastapi_sample
    command: >
    pipenv run gunicorn backend.main:app
    --config './backend/gunicorn.conf.py'
    volumes:
    - ./logs:/usr/src/fastapi_sample/logs
    environment:
    - TZ=Asia/Tokyo
    restart: always
    secrets:
    - source: ssh_key
    - source: ssh_crt
    secrets:
    ssh_key:
    file: ./scripts/server.key
    ssh_crt:
    file: ./scripts/server.crt

    永続ボリュームの logs は、Gunicorn で設定するアクセスログとエラーログを配置する場所です。

    httpsで起動させるために必要な鍵と証明書は、secrets機能を使って読み込ませています。 鍵と証明書は後述するシェルで作成しています。

    開発時と本番時のdocker-compose

    docker-compose.ymlの他に本番用と開発用を分けるため、ふたつのファイルを作成しています。

    • 開発用
      • docker-compose.override.yml
  • 引数なしで読み込み可能
  • 本番用
    • docker-compose.prod.yml
  • ポート番号など開発時と本番時に設定が異なる場合に利用します。

    docker-compose.override.ymlは引数なしで読み込ませることができます。

    個人的にコマンドを打つ機会が多い開発用にdocker-compose.override.ymlを設定しましたが、環境によっては本番用にdocker-compose.override.ymlを設定してもいいとともいます。

    開発用 docker-compose.override.yml

    version: '3.9'
    services:
    fastapi-sample:
    build:
    target: dev
    volumes:
    - ./backend:/usr/src/fastapi_sample/backend
    ports:
    - 8080:8080
    environment:
    - PORT=8080

    docker-compose.override.ymlはデフォルトで読み込まむため、特にdocker-compose.override.ymlをコマンドで指定しなくても読み込ませることができます。

    # イメージ作成
    docker-compose build
    # コンテナ立ち上げ
    docker-compose up -d
    # コンテナ落とす
    docker-compose down -v

    本番用 docker-compose.prod.yml

    version: '3.9'
    services:
    fastapi-sample:
    build:
    target: prod
    ports:
    - "8080"
    environment:
    - PORT=8080

    開発用のdocker-compose.override.ymlを読み込ませず、任意のファイルを読み込ますには-fオプションを使います。

    # イメージ作成
    docker-compose -f docker-compose.yml -f docker-compose.prod.yml build
    # コンテナ立ち上げ
    docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
    # コンテナ立ち上げ
    docker-compose -f docker-compose.yml -f docker-compose.prod.yml down -v

    Gunicorn設定 (backend/gunicorn.conf.py)

    import os
    # Server Socket
    bind = '0.0.0.0:' + os.getenv('PORT')
    # Worker Processes
    workers = 2 * os.cpu_count() + 1
    threads = 2 * os.cpu_count() + 1
    worker_class = 'uvicorn.workers.UvicornWorker'
    # Debugging
    reload = True
    # SSL
    keyfile = '/run/secrets/ssh_key'
    certfile = '/run/secrets/ssh_crt'
    ciphers = 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256'
    # Logging
    accesslog = './logs/access.log'
    errorlog = './logs/error.log'
    loglevel = 'info'

    ワーカー数とスレッド数

    ワーカー数とスレッド数は、Better performance by optimizing Gunicorn configを参考にすると、CPUの数 * 2 + 1 が最適とのことなので、2 * os.cpu_count() + 1を設定しています。

    Each of the workers is a UNIX process that loads the Python application. There is no shared memory between the workers. The suggested number of workers is (2*CPU)+1.

    The suggested maximum concurrent requests when using workers and threads is still(2*CPU)+1.

    secrets

    docker-compose.ymlのsecretsにて設定した鍵と証明書はここで設定しています。

    secretsで設定すると/run/secrets/に配置され、名前はホスト側の名前ではなく、docker-compose.ymlで記載した名称で配置されます。

    暗号スイート (Cipher suite)

    Gunicorn – settings – SSLによると、python3.6以降において、デフォルトだと、クライアントサーバー間で最も高いバージョンのTLSを使うそうです。

    Negotiate highest possible version between client/server. Can yield SSL. (Python 3.6+)

    TLSv1.2が使えない場合、TLSv1.1を使うといったことがありそうなので、暗号スイートを明示的にしています。

    OWASPからTLS Cipher String Cheat SheetのCipher-String Aを参考に設定しました。 Racoon Attack対策から、鍵交換がDHEの暗号スイート(暗号スイートの頭がDHE)は除いています。

    自己証明書(オレオレ証明書)の作成

    cd `dirname $0`
    COUNTRY=JP
    STATE=Tokyo
    LOCATION=Minato-Ku
    ORGANIZATION=Cresco
    ORGANIZATION_UNIT=DataTechnorogyCenter
    COMMON_NAME=ShoYOSHINO
    KEY_PATH=server.key
    CSR_PATH=server.csr
    SAN_PATH=san.txt
    CRT_PATH=server.crt
    IP_ADDR=`curl -s ifconfig.me`
    CRT_DAYS=825
    openssl ecparam -name prime256v1 -genkey -out ${KEY_PATH}
    openssl req -new -sha256 -key ${KEY_PATH} -out ${CSR_PATH} \
    -subj "/C=${COUNTRY}/ST=${STATE}/L=${LOCATION}/O=${ORGANIZATION}/OU=${ORGANIZATION_UNIT}/CN=${COMMON_NAME}"
    echo subjectAltName=IP.1:${IP_ADDR} > ${SAN_PATH}
    openssl x509 -req -days ${CRT_DAYS} -in ${CSR_PATH} -signkey ${KEY_PATH} -out ${CRT_PATH} -extfile ${SAN_PATH}
    chmod 600 ${KEY_PATH}
    chmod 600 ${CRT_PATH}

    Chrome58以降、SAN (Subject Alternative Name) 拡張領域を設定しないと、エラーになるので、subjectAltName=に実行しているサーバーのIPアドレスを設定しています。(参考:Chrome58になると自己署名の証明書がエラーになるので、OpenSSLに詳しくなった話)

    また、iOS 13 および macOS 10.15 における信頼済み証明書の要件によると、iOS 13 および macOS 10.15以降で、2019年7月1日以降に発行される証明書の発行期限825日以下でないといけないそうなので、期限を825日に設定しています。

    メインプログラム (backend/main.py)

    import sys
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    from fastapi.responses import ORJSONResponse
    sys.path.append('../')
    import backend.schemas.main as schemas
    app = FastAPI()
    app.add_middleware(
    CORSMiddleware,
    allow_origins=['*'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
    )
    @app.get(
    '/',
    response_class=ORJSONResponse,
    response_model=schemas.MainResponse,
    summary='main get',
    tags=['main']
    )
    async def main():
    return {'message': 'Hello World'}

    FastAPIを起動するためのメインプログラムです。

    CORSMiddlewareは、CORSへの対応です。参考 : FastAPI – CORS (Cross-Origin Resource Sharing)

    上記で設定したAPIでは@app.getとしているのでGETメソッドです。 メソッドに応じて、後ろの単語が変わります。

    • @app.get
  • @app.post
  • @app.patch
  • @app.delete
  • @app.getの引数では、'/' としているので、https://IPアドレス:ポート番号 にGETリクエストを投げると、レスポンスで、{'message': 'Hello World'}が返ってきます。 もし'/sample' としていれば、アクセス先は、https://IPアドレス:ポート番号/sample になります。

    response_class=ORJSONResponseは、前述したJSONを高速に取り扱うためのパッケージorjsonを使いますという宣言です。

    response_model=schemas.MainResponse,は、レスポンスの形を定義しています。 定義しなくても動きますが、レスポンス定義のAPIドキュメントへの反映や予期していないレスポンスへのバリデーションエラーを設定できます。

    summary='main get'は、記載したAPIへの説明です。 APIごとに設定できます。

    tags=['main']は、APIごとに設定できるタグです。 後述するAPIドキュメントにおいて、同じタグでAPIをまとめることができます。

    Response (backend/schema/main.py)

    APIで返すレスポンスの形を定義できます。

    定義することで、後述するAPIドキュメントに定義が反映されたり、定義以外で返却しようとしたときバリデーションエラーになるように設定されます。

    from pydantic import BaseModel
    class MainResponse(BaseModel):
    message: str

    APIドキュメント

    FastAPIでは、APIサーバーを立ち上げると、ドキュメントも自動で2種類作成されます。

    • https://IPアドレス:ポート番号/docs
    • Swagger UI形式
  • https://IPアドレス:ポート番号/redoc
    • ReDoc形式

    前述したレスポンスの定義をきちんと定義すると、APIドキュメントにも自動で反映されて、とても便利です。

    おわりに

    再開発してみると、案外忘れてたり、よくドキュメント読んでいなかったり、ポカミスも多かったりで、非常に勉強になりました。。。 また、新たにFastAPIを調べてみて、1年以上前とくらべて、FastAPIの記事が世の中に量産されてて非常に嬉しい限りです。

    今回はテストやDBといった事項までいかなかったので、次の機会に書こうと思います