[Docker]依存関係にあるサービス間のコンテナ起動タイミングを調整する[python]

## この記事について
この記事は、**[Django + MySQLの環境構築を通じてDockerの使い方を学ぶ](https://qiita.com/toffe_hy/items/f17d78abd92f0b4272f6)**に関連する記事群の **Part.5** にあたります。

1. [venvを利用してPythonの仮想環境を構築する](https://qiita.com/toffe_hy/items/8c62f2bc7b14f1ce2ae0)
2. [Dockerfileの記述を考える](https://qiita.com/toffe_hy/items/0429a95bfe1786955c2a)
3. [docker-compose.ymlの記述を考える](https://qiita.com/toffe_hy/items/f2e6ecc516fdf593c777)
4. [設定ファイルを編集し docker-compose up を実行する](https://qiita.com/toffe_hy/items/0571b84faa3e6b834c6e)
5. **依存関係にあるサービス間のコンテナ起動タイミングを調整する ( 当記事 )**

## はじめに

この記事では、`docker-compose`を利用して依存関係にある複数のサービスを動かすにあたって起こることのある、**起動順序による接続の失敗**に関する問題の解決を目指していきます。

[前回](https://qiita.com/toffe_hy/items/0571b84faa3e6b834c6e)までの記事にて、docker-composeを利用した`Django` + `MySQL`の環境構築設定を行いました。
基本的な設定が終わり`$ docker-compose up`でDjangoサーバを起動しようとすると、
**`MySQL`側の準備が終わっていないのにもかかわらず、`Django`が`DB`への接続を試みてしまうことで、接続エラーを吐いたまま状況が進行しない**状態に陥ってしまうことがあります。

現時点では、初期実行時のみに起こる問題で、`MySQL`側の準備を待ってから一度手動でキャンセルしてもう一度`$ docker-compose up`を実行すれば接続には成功するのですが、今後作業の過程で変更を加える際に同じ問題が起こり煩わされることのないように、**予め対応策を組み込めないか**考えてみます。

## エラーの内容
DBとの接続に失敗すると、以下のようなエラーが送りだされ、Django側のコンテナが硬直してしまいます。

```shell
# (抜粋)
djst_django | MySQLdb._exceptions.OperationalError: (2002, "Can't connect to MySQL server on 'db' (115)")
djst_django | django.db.utils.OperationalError: (2002, "Can't connect to MySQL server on 'db' (115)")
```
存在しないDBへの接続を試みた場合に送出されるエラーのようです。
つまり、今回の場合は**まだDBの準備が出来ていない**ことが問題なので、何かしらの手段で**出来るまで待つことによって順番を調整**出来ないか考えてみます。

ちなみに[Dockerの公式においてはシェルスクリプトによる解決が推奨](https://docs.docker.com/compose/startup-order/)されていますが、今回は`Python`のコーディングと`mysqlclient`の接続練習もかねて、**専用の`Python`のファイルを準備して解決を試みる**ことにします。

## ファイルの内容

### 全体
以下のようなファイルを`config`ディレクトリ下に用意しました。

```python:config/wait_for_db.py
import os
import MySQLdb
from time import sleep
from pathlib import Path

os.chdir(Path(__file__).parent)
from local_settings import DB_NAME, DB_USER, DB_PASSWORD


count_to_try = 0
LIMIT_OF_COUNT = 20 # 値は必要に応じて調整


def check_connection(count, limit):
    """
    docker-compose up実行時用、時間調整のための関数。
    """
    try:
        conn = MySQLdb.connect(
            unix_socket = "/var/run/mysqld/mysqld.sock",
            user=DB_USER,
            passwd=DB_PASSWORD,
            host="db",
            port=3306,
            db=DB_NAME,
        )
    except MySQLdb._exceptions.OperationalError as e:
        count += 1
        print("Waiting for MySQL... (", count, "/ 20 )")
        sleep(3)
        if count < limit:
            check_connection(count, limit)
        else:
            print(e)
            print("Failed to connect mySQL.")
    else:
        print("Connected!\n")
        conn.close()
        exit()


if __name__ == "__main__":
    check_connection(count_to_try, LIMIT_OF_COUNT)
```

### 記述について
続いて、コードの内容について書いていきます。

まずは必要なライブラリやパッケージをインストールします。

```python:config/wait_for_db.py
import os
import MySQLdb
from time import sleep
from pathlib import Path

os.chdir(Path(__file__).parent)
from local_settings import DB_NAME, DB_USER, DB_PASSWORD
```
`local_settings.py`に関しては`os.chdir(Path(__file__).parent)`でディレクトリを移動してからimportしています。

次に変数を定義します。

```python:config/wait_for_db.py
count_to_try = 0
LIMIT_OF_COUNT = 20 # 値は必要に応じて調整
```
`count_to_try`は後述する関数が呼び出された回数をカウントするためのもので、`LIMIT_OF_COUNT`はその回数制限です。20回繰り返したのちにファイルを終了するように関数内で条件分岐をします。この値は必要に応じて調整して使います。

メインとなる関数`check_connection`の内容については、
1. `try`文にてMySQLへの接続を試し、
2. 失敗した場合は`except`文にて接続試行回数をカウントして同一関数へ再帰させ、
3. 成功した場合は`else`文にて接続を終了しこのファイルを閉じる
といった流れになります。

```python:config/wait_for_db.py
try:
    conn = MySQLdb.connect(
        unix_socket = "/var/run/mysqld/mysqld.sock",
        user=DB_USER,
        passwd=DB_PASSWORD,
        host="db",
        port=3306,
        db=DB_NAME,
    )
```
`try`文の`unix_socket`については接続に利用するソケットですが、これは`$ docker-compose up`実行時のログに表示があるので、それを書き写せばOKです。あとは前回`Django`から設定したものと同様です。
.

```python:config/wait_for_db.py
except MySQLdb._exceptions.OperationalError as e:
    count += 1
    print("Waiting for MySQL... (", count, "/ 20 )")
    sleep(3)
    if count < limit:
        check_connection(count, limit)
    else:
        print(e)
        print("Failed to connect mySQL.")
```
`except`文については、試行回数をカウント、出力してから3秒待機し、20回に到達したらエラー内容を出力してファイルを終了します。上限を設けたのは、実際に接続エラーがある場合の無限ループを防ぐためです。
.

```python:config/wait_for_db.py
else:
    print("Connected!\n")
    conn.close()
    exit()
```
`try`文において試行した接続が成功した時 ( => **MySQLの準備が出来た時** ) は`else`文で接続成功を宣言し、接続を閉じたあと、ファイルを終了します。

`else`文を通過することが出来た場合は、次に控える`runserver`コマンドが滞りなく実行されるはずです。
.

```python:config/wait_for_db.py
if __name__ == "__main__":
    check_connection(count_to_try, LIMIT_OF_COUNT)
```
末尾の部分は、`if __name__ == "__main__":`、つまり**「このファイルが直接実行された時は」**、関数`check_connection`を読み込むという記述です。引数は冒頭で定義した変数です。

最後に、サーバー実行前にこのファイルが実行されるよう、`docker-compose.yml`の`command`内に記述を追加します。

```yml:docker-compose.yml
command: >
    bash -c "
        pip install -r requirements.txt &&
        python config/wait_for_db.py &&
        python manage.py runserver 0.0.0.0:8000
    "
```

以上で、**runserverコマンドの実行をMySQLのセットアップまで待機させるプログラム**の準備が出来ました。

実際に`$ docker-compose up`を実行してみると・・・

```shell
# (抜粋)
djst_django | Waiting for MySQL... ( 1 / 20 )
# (中略...)
djst_mysql | Version: '5.7.31'  socket: '/var/run/mysqld/mysqld.sock'  port: 3306  MySQL Community Server (GPL)
djst_django | Connected!
# (中略...)
djst_django | Starting development server at http://0.0.0.0:8000/
djst_django | Quit the server with CONTROL-C.
```
待機ののち、接続確認後に無事`Django`サーバーを起動できました。
( 前回の記事から繰り返しになりますが、設定の都合上、実際の動作確認は表示されている`http://0.0.0.0:8000/`ではなく、**`http://127.0.0.1:8000/`か`http://localhost:8000/`から行う**ことになります。 )

## 終わりに
以上で、**依存関係にあるサービス間のコンテナ起動タイミングを調整する**という当初の目的は達成できました。
何かご指摘などありましたら、コメントいただけますと幸いです。

今回についてはトレーニングもかねてpythonで記述しましたが、シェルスクリプトの方も自在に使いこなせるよう挑戦していきたいです。

(こちら↓から最初のページに戻れます。)
[Django + MySQLの環境構築を通じてDockerの使い方を学ぶ](https://qiita.com/toffe_hy/items/f17d78abd92f0b4272f6)

ご覧いただきありがとうございました。