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

# 概要

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

# 構成

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

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

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

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

![arch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/116209/b2a08b7a-ce68-0c89-21b3-ec53a3b1b3cf.png)

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

```mermaid
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を可視化するツール


## ディレクトリ構成

```bash: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``でコードのディレクトリを永続化させておく方が楽でよい。
**実際に使う場合は、``db``と``influxdb``のコンテナには``volumes``で永続化させる設定を記載すること**

```yaml: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の記載例

```bash:.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コンテナ**
    * 基本的にはコンフィグファイル（HTTPS化する場合は証明書と鍵も）を送るだけ
    * DjangoでWSGIを使うために``uwsgi_params``というファイルも送る
    * ただし、コンフィグファイルで環境変数を使うために下記の細工がいる
        * [envsubstを使ってDockerで設定ファイルに環境変数を埋め込めこむ汎用的なパターン](https://qiita.com/minamijoyo/items/63ae57b99d4a4c5d7987)
* **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を使うために``pip``で``influxdb``のインストールが必要。``flask-cors``も入れておいた方がいい。
あとは以下のようなコードでAPIを作成できる。

```python: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:bash
curl -POST http://apiserver:4000/add_influxdb -d "measurement=ipmgr&type=Hoge&value1=val1&value2=val2"  
```

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

```python: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ファイル。

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

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

* ``manage.py``と``wsgi.py``  
理由わからないが``DJANGO_SETTINGS_MODULE``という環境変数に値をsetしているので、該当部分を以下のように書き換える。

```python
# 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``  
主に以下の部分を書き換える。他に必要な部分があれば同様に書き換え可能。

```python: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を参照させるように書き換える。

```python: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するモジュールを決定させられる。

```python
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``を入れておくこと

```python: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``を入れておくこと

```python: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-compose``で``depend_on``を使えば起動順を設定できるが、あくまでコンテナの起動順のため、初期セットアップが終わるまでは待ってくれない。
そのため、``docker-entrypoint.sh``の最初でDBコンテナにアクセスできるようになるまで待つwhileループを入れておく。

```bash: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``  
該当部分のみ抜粋

```bash: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``  
存在チェックのスクリプトも埋め込めるが、流石に長いので別ファイルにしておく。

```python:check_admin.py
from django.contrib.auth.models import User

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