LoginSignup
5
14

More than 3 years have passed since last update.

コンテナで良い感じにDjangoを動かす構成を考える

Posted at

概要

Djangoの環境をコンテナで作って動かしていたが、クラウドネイティブを目指してImmutableでマイクロサービスな構成を作ろうとすると一般的なDjangoの作り方ではいまいちに感じる部分が多かった。
いろいろ弄ってみて良い感じにできたので逆引き的な書き方で記事にしておく。
コード一式は以下のリポジトリ参照
https://github.com/sensq/container_django

構成

最終的な構成は以下の図を参照
一見難しそうだが、コンテナを使うと割と簡単に構築できる。
データ、キャッシュ、アプリログはすべて別々のDBに保存するようにし、アプリケーションには一切のデータを持たせないようにした。

StaticファイルはDjangoが生成するものをNginxで参照させる必要があるため、両方から見える場所に置くようにする。あらかじめ生成物をすべて用意しておけばNginxから見えるだけでもよいが、その場合は管理するファイルが増える。今回は単純にホストの適当なディレクトリにマウントさせているだけ。

API-ServerはDjangoに機能追加していくとごてごてして辛くなった経験から、依存性の低い機能を別出しできる場所として作った。一つのコンテナにAPIを追加していってもいいし、1機能ごとにコンテナを作ってもいいし、とりあえずあると便利だと思う。今回はアプリのログをInfluxDBに保存する部分だけ実装した。

コンテナの監視はやらない。

arch.png

上の画像のmermaid(Qiitaだと対応していなかった)

graph LR
User((User))

User -->|"アクセス"| Nginx
User -->|"アクセス"| Chronograf

subgraph フロント
  Nginx
end
Nginx -->|"Staticファイル参照"| static
uWSGI -->|"Staticファイル参照"| static
Nginx -->|"リバースプロキシ"| uWSGI

uWSGI -->|"データ"| MySQL
uWSGI -->|"キャッシュ"| memchached
uWSGI -->|"アプリログ"| Flask
Chronograf -->|"アクセス"| InfluxDB
Flask -->|"アプリログ"| InfluxDB

subgraph AP
  uWSGI["uWSGI+Django"]
end

subgraph API Server
  Flask
end

subgraph CacheDB
  memchached
end

subgraph DB
  MySQL
end

subgraph 時系列DB
  InfluxDB
end

subgraph ログ可視化
  Chronograf
end

static

使用するミドルウェアと用途

  • Docker(コンテナ管理)
    • 下記すべてを一つのサーバで良い感じに動かしてくれる凄いクジラ
    • Kubernetesの前座
  • Nginx(Webサーバ)
    • Webアクセスする入り口
    • URLでバックエンドにルーティングさせる
  • uWSGI+Django(アプリケーションサーバ)
    • アプリケーションが動くところ
    • Nginxがルーティングさせてくるところ
    • DjangoはPythonのWebアプリケーションフレームワーク
    • uWSGIはPythonのWebアプリを動かすためのアプリケーションサーバ
      • Gunicorn, Nginx Unitなどでも代替可
  • MySQL(RDBMS)
    • アプリのデータを保存するところ
    • どのRDBMSも使い方はほとんど変わらないので好きなものを使えばいい
  • memcached(KVS = Key-Value-Store)
    • アプリのキャッシュ(ログイン情報とか)を保存するところ
    • アプリのコンテナをステートレスにするために必要
    • Redisでもたぶん代替可
  • InfluxDB(TSDB = Time-Series-DataBase)
    • アプリのログを保存するところ
  • Chronograf
    • InfluxDBを可視化するツール

ディレクトリ構成

tree
.
├── .env  # docker-composeの環境変数を定義するファイル
├── .gitignore
├── README.md
├── docker-compose.yml
└── build/
    ├── api/  # API Serverコンテナ関連のファイル
    │   ├── Dockerfile
    │   ├── apiserver.py
    │   └── requirements.txt
    ├── db/  # MySQLコンテナ関連のファイル
    │   ├── Dockerfile
    │   └── my.cnf  # MySQLの設定ファイル
    ├── sample_app/  # AP(Django)コンテナ関連のファイル
    │   ├── Dockerfile
    │   ├── requirements.txt
    │   ├── check_admin.py  # 管理者ユーザの存在をチェックするスクリプト
    │   ├── docker-entrypoint.sh  # コンテナ起動時に毎回実行されるスクリプト
    │   └── project/  # Djangoプロジェクトディレクトリ
    │       ├── manage.py
    │       ├── prj/  # Djangoプロジェクト全体の設定ファイル
    │       │   ├── settings.py
    │       │   ├── urls.py
    │       │   └── wsgi.py
    │       └── app/  # Djangoアプリケーションディレクトリ
    │           ├── __init__.py
    │           ├── admin.py
    │           ├── apps.py
    │           ├── models.py
    │           ├── tests.py
    │           ├── urls.py
    │           ├── views.py
    │           └── templates/
    └── web/  # Nginxコンテナ関連のファイル
        ├── Dockerfile
        ├── default.conf.template  # Nginxの設定ファイル
        └── uwsgi_params

docker-compose.yml

環境変数で各種設定を行えるようにしておき、設定変更やコード変更した場合はコンテナを作り直す。とはいえ、コード変更の確認で毎回ビルドするのは面倒なので開発中はvolumesでコードのディレクトリを永続化させておく方が楽でよい。
実際に使う場合は、dbinfluxdbのコンテナにはvolumesで永続化させる設定を記載すること

docker-compose.yml
version: '3'

services:
  web:
    build: ./build/web
    depends_on:
      - sample_app
    volumes:
      - ./data/static:/codes/static:ro
    ports:
      - "80:80"
      - "443:443"
    command: >
      /bin/sh -c "envsubst '
      $$NGINX_LOCATION_SUBDIR
      $$WSGI_CONTAINER_NAME
      $$WSGI_PORT
      ' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
      && nginx -g 'daemon off;'"
    environment:
      NGINX_LOCATION_SUBDIR: ${NGINX_LOCATION_SUBDIR}  # Webアクセスするサブディレクトリ名
      WSGI_CONTAINER_NAME: ${WSGI_CONTAINER_NAME}      # WSGIを動かすコンテナの名前
      WSGI_PORT: ${WSGI_PORT}                          # WSGIを動かすポート

  sample_app:
    build:
      context: ./build/sample_app
      args:
        DJANGO_PROJECT_NAME: ${DJANGO_PROJECT_NAME}
    depends_on:
      - db
    volumes:
      - ./data/static:/static
#      - ./hoge-dir-path:/${DJANGO_PROJECT_NAME}
    environment:
      WSGI_PORT: ${WSGI_PORT}                              # WSGIを動かすポート
      WSGI_PROCESSES: ${WSGI_PROCESSES}                    # WSGIを動かすプロセス数
      WSGI_THREADS: ${WSGI_THREADS}                        # WSGIを動かすスレッド数
      NGINX_LOCATION_SUBDIR: ${NGINX_LOCATION_SUBDIR}      # Webアクセスするサブディレクトリ名
      DJANGO_DEBUG: ${DJANGO_DEBUG}                        # DjangoのDEBUGモードの有効化
      DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}        # Djangoに接続を許可するホスト名またはIP
      DJANGO_PROJECT_NAME: ${DJANGO_PROJECT_NAME}          # Djangoプロジェクトの名前(フォルダ名と合わせる)
      DJANGO_APPLICATION_NAME: ${DJANGO_APPLICATION_NAME}  # Djangoアプリケーションの名前(フォルダ名と合わせる)
      DJANGO_ADMIN_EMAIL: ${DJANGO_ADMIN_EMAIL}            # Django管理者ユーザのEMAIL
      DJANGO_ADMIN_PASSWORD: ${DJANGO_ADMIN_PASSWORD}      # Django管理者ユーザのパスワード
      DATABASE_CONTAINER_NAME: ${DATABASE_CONTAINER_NAME}  # Djangoで使用するDBコンテナの名前
      DATABASE_PORT: ${DATABASE_PORT}                      # Djangoで使用するDBコンテナの公開ポート
      MYSQL_DATABASE: ${MYSQL_DATABASE}                    # Djangoで使用するDBの名前
      MYSQL_USER: ${MYSQL_USER}                            # Djangoで使用するDBのログインユーザ
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}                    # Djangoで使用するDBのログインパスワード
      APISERVER_HOST: ${APISERVER_HOST}                    # API Serverが稼働しているホストやコンテナの名前

  db:
    build: ./build/db
#    volumes:
#      - ./hoge-dir-path:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_ROOT_HOST: ${MYSQL_ROOT_HOST}
      MYSQL_DATABASE: ${MYSQL_DATABASE}  # Djangoで使用するDBの名前
      MYSQL_USER: ${MYSQL_USER}          # Djangoで使用するDBのログインユーザ
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}  # Djangoで使用するDBのログインパスワード

  cache:
    image: memcached:alpine

  apiserver:
    build: ./build/api
    environment:
      INFLUXDB_HOST: ${INFLUXDB_HOST}      # InfluxDBが稼働しているホストやコンテナの名前
      INFLUXDB_PORT: ${INFLUXDB_PORT}      # InfluxDBが稼働しているポート番号
      INFLUXDB_DB: ${INFLUXDB_DB}          # InfluxDBに初期作成するDB名
      APISERVER_PORT: ${APISERVER_PORT}    # API Serverを稼働させるポート番号
      APISERVER_DEBUG: ${APISERVER_DEBUG}  # API ServerのDEBUGモードの有効化

  influxdb:
    image: influxdb:alpine
#    volumes:
#      - ./hoge-dir-path:/var/lib/influxdb
    environment:
      INFLUXDB_DB: ${INFLUXDB_DB}                          # InfluxDBに初期作成するDB名
      INFLUXDB_ADMIN_USER: ${INFLUXDB_ADMIN_USER}          # InfluxDBの管理者ユーザ名
      INFLUXDB_ADMIN_PASSWORD: ${INFLUXDB_ADMIN_PASSWORD}  # InfluxDBの管理者ユーザのパスワード

  chronograf:
    image: chronograf:alpine
    ports:
      - "${CHRONOGRAF_PORT}:8888"
    environment:
      INFLUXDB_URL: ${INFLUXDB_URL}            # InfluxDBのURL
      INFLUXDB_USERNAME: ${INFLUXDB_USERNAME}  # InfluxDBに接続するユーザ名
      INFLUXDB_PASSWORD: ${INFLUXDB_PASSWORD}  # InfluxDBに接続するユーザのパスワード

.envの記載例

.env
NGINX_LOCATION_SUBDIR=sample

WSGI_CONTAINER_NAME=sample_app
WSGI_PORT=5000
WSGI_PROCESSES=5
WSGI_THREADS=3

DJANGO_DEBUG=True
DJANGO_ALLOWED_HOSTS=192.168.1.100
DJANGO_PROJECT_NAME=prj
DJANGO_APPLICATION_NAME=sample
DJANGO_ADMIN_EMAIL=admin@localhost.com
DJANGO_ADMIN_PASSWORD=admin
DATABASE_CONTAINER_NAME=db
DATABASE_PORT=3306

MYSQL_ROOT_PASSWORD=P@ssw0rd
MYSQL_ROOT_HOST=%
MYSQL_DATABASE=django
MYSQL_USER=django
MYSQL_PASSWORD=dj@ng0

KEYCLOAK_USER=admin
KEYCLOAK_PASSWORD=admin

APISERVER_HOST=apiserver
APISERVER_PORT=4000
APISERVER_DEBUG=True

INFLUXDB_HOST=influxdb
INFLUXDB_PORT=8086
INFLUXDB_URL=http://influxdb:8086
INFLUXDB_DB=sample
INFLUXDB_ADMIN_USER=admin
INFLUXDB_ADMIN_PASSWORD=admin
INFLUXDB_USERNAME=admin
INFLUXDB_PASSWORD=admin

CHRONOGRAF_PORT=8888

ざっくりとした各コンテナの構築説明

  • Nginxコンテナ
  • Djangoコンテナ
    • コンテナで良い感じに使うためにいろいろと細工がいる(下記参照)
  • API-Serverコンテナ
    • Post用のメソッドを1つ作成してFlaskでWebサーバとして起動しておくだけ
    • 具体的には下記参照
  • MySQLコンテナ
    • 環境変数のみで設定が完結するため、ビルド不要だった
    • どこかのバージョンから以下の設定を行わないとDjangoから使えなくなったため、my.cnfを送るだけのDockerfileが必要
      • default_authentication_plugin=mysql_native_password
  • memcachedコンテナ
    • 環境変数のみで設定が完結するため、ビルド不要
  • InfluxDBコンテナ
    • 環境変数のみで設定が完結するため、ビルド不要
  • Chronografコンテナ
    • 環境変数のみで設定が完結するため、ビルド不要

API-Server補足

InfluxDBのClientを使うためにpipinfluxdbのインストールが必要。flask-corsも入れておいた方がいい。
あとは以下のようなコードでAPIを作成できる。

apiserver.py
from flask import Flask, jsonify, abort, make_response, render_template, request
from flask_cors import CORS
import os

api = Flask(__name__)
CORS(api)  # CORS有効化

@api.route('/add_influxdb', methods=['POST'])
def post():
    influxdb_host = os.environ.get("INFLUXDB_HOST")
    influxdb_port = os.environ.get("INFLUXDB_PORT")
    influxdb_database = os.environ.get("INFLUXDB_DB")

    from influxdb import InfluxDBClient
    client = InfluxDBClient(host=influxdb_host,
                            port=influxdb_port,
                            database=influxdb_database)

    # Postで渡されたパラメータを受け取る
    # 以下はタグ1つ、フィールド2つのパラメータを受け取るの場合の例
    measurement = request.form["measurement"]
    type = request.form["type"]
    value1 = request.form["value1"]
    value2 = request.form["value2"]
    # InfluxDBに書き込むデータを作成する
    # RDBMSのように事前にテーブル定義をしておかなくても、柔軟にデータを書き込める
    data = [{
        "measurement": measurement,
        "tags": {"type": type},
        "fields": {
            "value1": value1,
            "value2": value2
        }
    }]
    # InfluxDBに書き込む
    client.write_points(data)
    return make_response(data[0])

# Webサーバを起動する
if __name__ == '__main__':
    port = os.environ.get("APISERVER_PORT")
    from distutils.util import strtobool
    debug = strtobool(os.environ.get("APISERVER_DEBUG"))
    api.run(host='0.0.0.0', port=port, debug=debug)

curlでAPI実行する場合のコマンド例

bash
curl -POST http://apiserver:4000/add_influxdb -d "measurement=ipmgr&type=Hoge&value1=val1&value2=val2"  

PythonでAPI実行する場合のコード例

python
import os
import urllib.request
import urllib.parse
host = os.environ.get("APISERVER_HOST")
port = os.environ.get("APISERVER_PORT")
api_url = "http://" + host + ":" + port + "/add_influxdb"
data = {
    "measurement": measurement,
    "type": type,
    "value1": value1,
    "value2": value2
}
encoded_data = urllib.parse.urlencode(data).encode('utf-8')
req = urllib.request.Request(api_url, encoded_data)
res = urllib.request.urlopen(req)

逆引きDjango問題

プロジェクト名とアプリ名を環境変数化したい

変更の必要なファイルは以下に配置されている4つのpythonファイル。

tree
"<デフォルトがプロジェクト名だけど実はなんでもいい>/"
├── manage.py
├── "<プロジェクト名>/"
│   ├── settings.py
│   ├── urls.py  # 上部のコメント部分だけなので適当な文字列に書き換える
│   └── wsgi.py
└── "<アプリケーション名>/"
    └── # ここに配置されるファイルには該当する部分無し

どれもpythonスクリプトのため、os.environ.get("KEY_NAME")で環境変数から値を取得できる。また、取得した値を使って文字列結合することでプロジェクト名やアプリ名の含まれるパスなどを作成できる。

  • manage.pywsgi.py
    理由わからないがDJANGO_SETTINGS_MODULEという環境変数に値をsetしているので、該当部分を以下のように書き換える。
# DJANGO_PROJECT_NAMEはdocker-compose.ymlで定義した環境変数
prj_name = os.environ.get("DJANGO_PROJECT_NAME")
os.environ.setdefault('DJANGO_SETTINGS_MODULE', (prj_name + '.settings'))
  • settings.py
    主に以下の部分を書き換える。他に必要な部分があれば同様に書き換え可能。
settings.py
# DJANGO_PROJECT_NAMEとDJANGO_APPLICATION_NAMEはdocker-compose.ymlで定義した環境変数
PRJ_NAME = os.environ.get("DJANGO_PROJECT_NAME")
APP_NAME = os.environ.get("DJANGO_APPLICATION_NAME")

INSTALLED_APPS = [
    # 省略
    APP_NAME,
]
ROOT_URLCONF = (PRJ_NAME + '.urls')
WSGI_APPLICATION = (PRJ_NAME + '.wsgi.application')
  • urls.py
    アプリケーション内のurls.pyを参照させるように書き換える。
urls.py
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include(os.environ.get("DJANGO_APPLICATION_NAME") + '.urls'))
]

settings.pyをいろいろ環境変数化したい

os.environ.get("KEY_NAME")で環境変数から値を取得できるため、環境変数化したい部分をdocker-compose.ymlで定義することで好きなように環境変数化可能
他のファイルで環境変数の値を使いたい場合も同様

import文でアプリケーション名を書く必要がある

importlibを使うと動的にimportできる。以下のように書くことで環境変数の値からimportするモジュールを決定させられる。

import os
app_name = os.environ.get("DJANGO_APPLICATION_NAME")
module_path = app_name + ".models"
from importlib import import_module
module = import_module(module_path)
Hoge = getattr(module, "Hoge")

# これで以下を記載した場合と同様にimportされる
# from app_name.models import Hoge

DBにMySQLを使いたい

settings.pyに以下のように記載する。
一般的には各パラメータには固定値を記載するが、環境変数を参照させるようにすることでコンテナを汎用的に扱いやすくする。
※事前にpip installでpymysqlを入れておくこと

settings.py
import pymysql
pymysql.install_as_MySQLdb()
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': os.environ.get("MYSQL_DATABASE"),
        'USER': os.environ.get("MYSQL_USER"),
        'PASSWORD': os.environ.get("MYSQL_PASSWORD"),
        'HOST': os.environ.get("DATABASE_CONTAINER_NAME"),
        'PORT': os.environ.get("DATABASE_PORT", "3306"),
        'OPTIONS': {
            'charset': 'utf8mb4',
        },
    }
}

キャッシュにmemcachedを使いたい

settings.pyに以下のように記載する。
LOCATIONのサーバ名とポート番号は固定でよいと思っているが、環境変数化したかったらMySQLと同様にos.environ.getを使って環境変数を参照させるようにすればよい。
※事前にpip installでpython-memcachedを入れておくこと

settings.py
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
        'LOCATION': 'cache:11211',
    }
}

DBの初期セットアップが終わる前にDjangoが起動してしまう

DBに接続できない状態でDjangoが起動してしまうとMigrationが上手くいかず正常に起動しない。
docker-composedepend_onを使えば起動順を設定できるが、あくまでコンテナの起動順のため、初期セットアップが終わるまでは待ってくれない。
そのため、docker-entrypoint.shの最初でDBコンテナにアクセスできるようになるまで待つwhileループを入れておく。

docker-entrypoint.sh
DATABASE_HOSTNAME=${DATABASE_CONTAINER_NAME}
DATABASE_PORT=${DATABASE_PORT:-'3306'}

echo "Waiting for database"
while ! nc -zv $DATABASE_HOSTNAME $DATABASE_PORT
do
  sleep 0.1
done

DB作成するたびに管理者ユーザを作らないといけない

docker-entrypoint.shでadminユーザの存在をチェックし、存在していなかったら作成する。
一般的にはpython manage.py createsuperuserで作成するが、対話形式になってしまうのでDjango shellから作成する。

  • docker-entrypoint.sh
    該当部分のみ抜粋
docker-entrypoint.sh
PRJ_NAME=${DJANGO_PROJECT_NAME}

# Create Admin User
EXIST_ADMIN=`python manage.py shell < /check_admin.py`
if [ ${EXIST_ADMIN} = 'True' ]; then
  :
else
  echo "Does not exist admin user."
  /${PRJ_NAME}/manage.py shell -c "from django.contrib.auth.models import User; User.objects.create_superuser('admin', os.environ.get('DJANGO_ADMIN_EMAIL','admin@localhost.com'), os.environ.get('DJANGO_ADMIN_PASSWORD','admin'))"
  echo "Created admin user!! ('admin', ${DJANGO_ADMIN_EMAIL:-'admin@localhost.com'}, ${DJANGO_ADMIN_PASSWORD:-'admin'})"
fi
  • check_admin.py
    存在チェックのスクリプトも埋め込めるが、流石に長いので別ファイルにしておく。
check_admin.py
from django.contrib.auth.models import User

try:
    User.objects.get(username="admin")
    print('True')
except User.DoesNotExist:
    print('False')
5
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
14