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


概要

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')