delhi09の勉強日記

技術トピック専用のブログです。自分用のメモ書きの投稿が多いです。あくまで「勉強日記」なので記事の内容は鵜呑みにしないでください。

Django Girls TutorialのアプリをDocker環境(Django × MySQL × Redis)で動かすまで

Django Girls TutorialのアプリをDocker環境(Django × MySQL × Redis)で動くようにしてみた。
その際にポイントだと思ったこと・ハマったことを備忘録として残しておく。

githubリポジトリは以下
https://github.com/kamatimaru/djangogirls-tutorial-docker

環境

開発用PC

  • OS : MacOS Catalina
  • バージョン : 10.15.4

Docker

  • バージョン : 19.03.8

アプリケーションサーバー(Dockerコンテナ)

  • OS : Amazon Linux2
  • バージョン : amzn2-container-raw-2.0.20200406.0-x86_64
  • 使用したイメージ : amazonlinux:2
  • Python
    • バージョン : 3.7.6
  • Django
    • バージョン : 2.2.4

DBサーバー(Dockerコンテナ)

公式のmysql:5.7のイメージに自分なりに日本語設定を追加したもの。
githubリポジトリは以下。
https://github.com/kamatimaru/docker-mysql57-ja

キャッシュサーバー (Dockerコンテナ)

  • キャッシュサーバー : Redis
  • バージョン : 6.0.0
  • 使用したイメージ : redis:6

目的

  • Docker/Docker Composeの勉強
  • DjangoSqlite以外のDBを使う際の設定方法の勉強
  • Djangoでキャッシュサーバーを使う際の設定方法の勉強
  • アプリケーションサーバー × DBサーバー × キャッシュサーバーの構成はよくあるので、一度Dockerでの開発環境を構築する実績を作っておくことで、他のプロジェクトに展開できるようにする。

ポイントだと思ったこと・ハマったこと

イメージ作成時

mysqlclientのインストールに失敗する

Macの時とは別のエラーでpipでのmysqlclientのインストールに失敗した。
結論としては、以下のモジュール全てをyumでインストールしておく必要があった。

yum install -y python3-devel mysql mysql-devel gcc
コンテナ起動時にコマンドを実行したい。

毎回コンテナ起動後にコンテナにログインして

を行うのは効率が悪いので、コンテナ立ち上げ時にコマンドを実行する方法がないか調べたところ、「ENTRYPOINT」という命令で実現できた。

ENTRYPOINT /bin/bash ${DEPLOY_DIR}/docker-entrypoint.sh

※ docker-entrypoint.shの中身については後の項に記載

ENTRYPOINTで環境変数が展開されない

ENTRYPOINT記述する際に、最初は

ENTRYPOINT ["/bin/bash", "${DEPLOY_DIR}/docker-entrypoint.sh"]

と記載していたが、環境変数が展開されなくて少しハマった。

stackoverflowに同じような質問があったので、その回答を参考に

ENTRYPOINT /bin/bash ${DEPLOY_DIR}/docker-entrypoint.sh

と書いたら解決。
stackoverflow.com

Docker Composeでカレントディレクトリのパスを取得する

Docker ComposeでホストOS側のカレントディレクトリをコンテナに同期したくて、カレントディレクトリのパスをdocker-compose.yml上で展開する方法を調べたところ、以下で実現できた。

volumes:
    - $PWD:/root/djangogirls-tutorial

コンテナ起動時

Django Girls Tutorialアプリのコンテナが起動後すぐに停止してしまう

「dockser-compose up -d」でそれぞれのコンテナが立ち上がるようにはなったが、Django Girls Tutorialアプリのコンテナだけ、起動後すぐに停止してしまうという事象が発生した。

調べていると以下の記事を発見
qiita.com

記事の通り、docker-compose.ymlに

my-djangogirls-tutorial:
    ...
    tty: true

を付けるとコンテナが停止しなくなった。

ホストOS側からブラウザで「http://localhost:8000」にアクセスすると「ERR_EMPTY_RESPONSE」が返ってくる

Django Girls Tutorialアプリのコンテナにログインして、

python3 manage.py migrate
python3 manage.py runserver

を実行した後、ホストOS側からブラウザで「http://localhost:8000」にアクセスすると、以下のように「ERR_EMPTY_RESPONSE」が返ってきた。
f:id:kamatimaru:20200502191300p:plain

こちらも、調べていると以下の記事を発見
qiita.com

IPアドレス「0.0.0.0」でアプリケーションサーバーを起動すると解決するらしい。

Djangoではrunserver時にオプションで以下のようにIPアドレスとポートを指定できるようなので、以下のコマンドを実行してから再度ホストOS側のブラウザで閲覧したところ、無事DjangoのTOPページを表示できた。

python3 manage.py runserver 0.0.0.0:8000

※ネットワークの知識に疎いので、上記のQiitaの解説記事を完全には理解できなかったが、ここで止まっていると先に進めないので、いったん宿題として積むことにする。

コンテナ起動時にDBを作成する。

毎回コンテナ起動後にMySQLのコンテナにログインして、「CREATE DATABASE」を実行するのは効率が悪いので、コンテナ起動時にDBを作成したいと思った。

最初はちょっと面倒だなと思いつつも、docker-entrypoint.sh内で「CREATE DATABASE」を実行しようと思っていたが、公式のMySQLのイメージはコンテナ起動時に「MYSQL_DATABASE」という環境変数を渡せば、起動時にデータベースを作成してくれるということを知った。
hub.docker.com

実際にdocker-compose.ymlに以下のように記載すると、「djangogirls」というデータベースがコンテナ起動時に作成されるようになった。

db_server:
    ...
    environment: 
        ...
        MYSQL_DATABASE: djangogirls
DBサーバー・キャッシュサーバーのIPアドレスの取得方法

Docker Composeでは各コンテナのプライベートIPアドレスは動的に割り当てられるため、固定ではない。

他方でもちろんDjangoのsettings.pyにはMySQLとRedisのIPアドレスを設定する必要がある。

解決方法としては、docker-compose.ymlに連携したいコンテナを設定しておくと、環境変数から連携先のコンテナのIPアドレスやポート番号を取得することができる。

docker-compose.ymlには以下のように記述する。

my-djangogirls-tutorial:
    ....
    links:
        - "db_server:mysql"
        - "cache_server:redis"
    ....

db_server:
    ....
    ports: 
        - "3306:3306"
    ....

cache_server:
    ....
    ports:
        - "6379:6379"
    ....


すると、以下のように「my-djangogirls-tutorial」のコンテナの環境変数に情報が設定される。

$ printenv
...
MYSQL_PORT_3306_TCP_ADDR=172.17.0.3
...
REDIS_PORT_6379_TCP_ADDR=172.17.0.2
...
$

Django側で環境変数を読み込んでsetteings.pyに設定する方法については、後の項で記載する。

コンテナ起動時にマイグレーションの実行・サーバーの起動を行う

docker-entrypoint.shに以下を記載すればOK

python3 ${DEPLOY_DIR}/manage.py migrate
python3 ${DEPLOY_DIR}/manage.py runserver 0.0.0.0:8000

※ ${DEPLOY_DIR}はDockerfileでENVで宣言しているので、docker-entrypoint.sh内でも使うことができる。

コンテナ起動時にsuperuserの作成を行う

createsuperuserは対話モードで実行されるため、自動化するためにはexpectコマンドが必要になる。

以下をDockerfileに追記して、イメージ作成時にexpectをインストールする。

RUN yum install -y expect

expectコマンドの使い方については、以下の記事を参考にさせて頂いた。

qiita.com

また、コンテナのロケールを日本語に設定しないと、expectが日本語を判定できないので、以下もDockerfileに追記する。

RUN yum install -y glibc-langpack-ja
ENV LANG ja_JP.UTF-8

以下を参考にさせて頂いた。
qiita.com


結論としては、以上をDockerfileに記述した上でdocker-entrypoint.shに以下のように記述すると、admin/passwordでsuperuserをコンテナ起動時に自動で作成することができる。

expect -c "
spawn python3 ${DEPLOY_DIR}/manage.py createsuperuser
expect \"ユーザー名 (leave blank to use 'root'):\"
send \"admin\n\"
expect \"メールアドレス:\"
send \"admin@example.com\n\"
expect \"Password:\"
send \"password\n\"
expect \"Password (again):\"
send \"password\n\"
expect \"Bypass password validation and create user anyway? \\\\\[y/N\\\\\]:\"
send \"y\n\"
expect \"Superuser created successfully.\"
exit 0
"


※ 尚、以下のブラケット([])の部分は、expectで使用されているtclという構文の特殊文字なのでエスケープが必要だが、なぜ5個必要なのかは分からなかった。1〜4個で試しみていずれもうまくいかなかったので、5個で試してみたらうまくいった。
(ちなみに、expectは部分一致でも判定してくれるので、「Bypass password validation and create user anyway? 」まででもよい。)

expect \"Bypass password validation and create user anyway? \\\\\[y/N\\\\\]:\"
send \"y\n\"
MySQLの起動を待ってからマイグレーションを実行する

コンテナ起動時にマイグレーションを実行するに当たって、MySQLが起動する前にマイグレーションコマンドを実行して、エラーでコンテナ起動が失敗することがありうるのか?というのが気になった。

ドキュメントを確認したところ、

  • docker-composeはデフォルトではコンテナが起動する順番を保証しない。
  • 「depends_on」というオプションを使えばコンテナが起動する順番は制御できるが、アプリケーション起動までのWAITは行わない

とのことであった。

docs.docker.jp

従って、以下のようにdocker-entrypoint.shの中にWAIT処理を記述した。

conn_established=0
for _ in `seq 1 60`
do
    mysql -uroot -ppassword -h ${MYSQL_PORT_3306_TCP_ADDR} -e "SELECT 1 FROM dual;" > /dev/null 2>&1
    if [ $? -eq 0 ]; then
        conn_established=1
        break
    fi
    sleep 1
done

if [ $conn_established -eq 1 ]; then
    # MySQLへの接続が確立できた場合に実行する処理
else
    # MySQLに接続できなかった場合に実行する処理
fi
最終的なdocker-entrypoint.sh

docker-entrypoint.shは最終的に以下のようになった。
https://github.com/kamatimaru/djangogirls-tutorial-docker/blob/master/docker-entrypoint.sh

Djangoの設定

環境変数からの値の読み込み

今回の用途ではPythonの標準ライブラリのos.environで十分だが、試験的にakiyokoさんの『現場で使える Django の教科書《基礎編》』で紹介されている「django-environ」を使ってみる。

・インストール方法
requirements.txtに以下を記述

django-environ

・使い方(例)

import environ
env = environ.Env()
mysql_ip = env("REDIS_PORT_6379_TCP_ADDR")
MySQLへの接続の設定方法

同じくakiyokoさんの本のp117〜p118を参考にさせて頂いた。

requirements.txtに以下を記述

mysqlclient

・以下のように設定する。

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": "djangogirls",
        "USER": "root",
        "PASSWORD": "password",
        "HOST": env("MYSQL_PORT_3306_TCP_ADDR"),
        "PORT": "3306"
    }
}

以上で接続できた

Redisへの接続の設定方法

調べてみたところ、DjangoでRedisを使う場合はdjango-redisというサードパーティーのライブラリを使用するのがスタンダードらしい。
github.com

Djangoの公式ドキュメントにはMemcachedの設定方法しか記載されていない。
docs.djangoproject.com

従って、Redisの場合は設定方法に関してもdjango-redisのドキュメントを参照する。

結論としてはドキュメント通りにsettings.pyに以下を追記すればOK

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://" + env("REDIS_PORT_6379_TCP_ADDR") + ":6379",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient",
        }
    }
}

SESSION_ENGINE = "django.contrib.sessions.backends.cache"

動作確認

MySQL

管理画面からコンテンツを登録した後、DBをSELECTしてデータが保存されていることを確認する。

mysql> select * from blog_post;
+----+-----------+--------------------------------+----------------------------+----------------------------+-----------+
| id | title     | text                           | created_date               | published_date             | author_id |
+----+-----------+--------------------------------+----------------------------+----------------------------+-----------+
|  1 | テスト    | テスト用の記事です。           | 2020-05-03 02:18:34.000000 | 2020-05-03 02:18:48.000000 |         1 |
+----+-----------+--------------------------------+----------------------------+----------------------------+-----------+
1 row in set (0.00 sec)

Redis

管理画面にログインした後、Redisを検索してセッションが保存されていることを確認する。

root@690eda5c0010:/data# redis-cli
127.0.0.1:6379> keys *
1) ":1:django.contrib.sessions.cachegz3e1parfbzg4h5g2k8zdcapmpjqs5bt"
127.0.0.1:6379> get :1:django.contrib.sessions.cachegz3e1parfbzg4h5g2k8zdcapmpjqs5bt
"\x80\x04\x95\x97\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\r_auth_user_id\x94\x8c\x011\x94\x8c\x12_auth_user_backend\x94\x8c)django.contrib.auth.backends.ModelBackend\x94\x8c\x0f_auth_user_hash\x94\x8c(ce3757e18e11fe9e307c39921281fcf0a9039b95\x94u."
127.0.0.1:6379>

以上