[備忘録] Docker compose で複数のコンテナを立ち上げる

# 目的
以下のようなシステムを作成したい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2718862/39274b34-4f30-5bfb-fd93-74ba6fb7726a.png)


* Docker composeを使用してイメージAとイメージBからそれぞれ `A1, A2, ..., AM, B1, B2, ..., BN` と複数のコンテナを立ち上げたい
* 各イメージの設定は各Dockerfileに記載



# Docker-composeの処理の流れ
Docker-composeを使用した際にどのような流れで処理が行われるかを復習。

1. `docker compose up`コマンドを実行
1. プロジェクト設定の読み込み
カレントディレクトリの`docker-compose.yml`を読み込む
1. ネットワークの作成
1. ボリュームの作成
volumes セクションで定義された名前付きボリュームを作成（ただし、この時点では、ファイルのマウントは行われていないことに注意）
1. イメージのビルドまたは取得
Docker ファイルが指定されている場合は、それに従ってイメージをビルド。Dockerファイルに記載されていれば、この段階で必要なパッケージのインストールなどを行う
1. コンテナの作成
サービスごとにコンテナを作成するが、この時点ではまだ起動しない。ポート番号を管理する`ports`、環境変数を管理する'environment'などはこのタイミングで準備される
1. ボリュームのマウント
ここでローカルファイルがマウントされ使用できるようになる。逆にこのタイミング以前では、ローカルファイルの内容をDockerfileの中から使用することができない（Dockerファイルはビルド時に使用されるので）。これ以前に`Dockerfile`でコンテナにローカルからファイルをコピーしていたとしても`volume`を設定していたならば上書きされる
1. 環境変数の適用
environment セクションや .env ファイルに記載された環境変数をコンテナに設定
1. デフォルトコマンドまたは command の適用
Dockerfile内で指定されたCMDがデフォルトのコマンドとして適用される。例えば、`python app.py`などで実際のアプリを実行。ただし、`Dockerfile`と`docker-compose.yml`で共にコマンドが記載されている場合、docker-compose.ymlが優先される
1. コンテナの起動
コンテナを起動し、ネットワークやボリュームの設定を適用した状態で、サービスのプロセスを開始
1. ログのストリーミング

# 開発時のファイルの内容
Docker-compose.yml や Dockerfileの設定の仕方は、開発と本番で異なる。ここで、ローカルとはDockerを起動しているホストを指し、コンテナはホストによって建てられたコンテナを指す。

* 開発時：docker-compose.ymlの `volume`を使用してローカルファイルをマウントして使用
* 本番時：DockerfileのCOPYを使用してローカルのファイルをコピー

このようにすることで、開発時はローカルファイルを動的に変えつつ、開発やテストを行うことができ、本番で使用する際は、ファイルを動的に変更できないようにすることで安定して動作させる。

## ディレクトリ構成
今回の記事は、以下のディレクトリ内容を前提に進めていく。

```
docker-compose-study
├── README.md
├── appA
│   ├── Dockerfile
│   ├── appA_main.py
│   └── requirements.lock
├── appB
│   ├── Dockerfile
│   ├── appB_main.py
│   └── requirements.lock
└── docker-compose.yml
```
* appA: アプリAが含まれたディレクトリ
    * Dockerfile: Dockerイメージを作成するための設定ファイル
    * appA_main.py: 実行するとFlaskのスクリプトが走り、Webブラウザに文字を表示
    * requirements.lock: appA_main.py で実行されるスクリプトで必要とされるライブラリが記載されたファイル
* appB: アプリBが含まれたディレクトリ
    * Dockerfile: Dockerイメージを作成するための設定ファイル
    * appA_main.py: 実行するとFlaskのスクリプトが走り、Webブラウザに文字を表示
    * requirements.lock: appB_main.py で実行されるスクリプトで必要とされるライブラリが記載されたファイル。コンテナの検証のためアプリAと使用するライブラリが少し異なる
## docker-compose.yml
アプリAとアプリBのDockerfileからイメージをbuildし、コンテナをcreateし、起動する際に使用するファイル。
今回は以下の3つのコンテナを起動する。起動後は、ホストマシンのブラウザのlocalhostでアクセスすることができる。
* app_a1_container: アプリAのイメージからcreateしたコンテナ(localhost:5001)
* app_a1_container: 同じくアプリAのイメージからcreateした2つ目のコンテナ(localhost:5002)
* app_a2_container: アプリBのイメージからcreateしたコンテナ(localhost:6001)



```yaml
services:
  app_a1:
    # サービスの定義。ここでは 'app_a1' という名前のサービスを定義。
    build:
      # Dockerイメージのビルド設定。
      context: ./appA  # ビルドに使用するDockerfileがあるディレクトリのパス。
      dockerfile: Dockerfile  # ビルドに使用するDockerfileの名前。
    container_name: app_a1_container  # コンテナに割り当てる名前。指定しない場合はランダムな名前が生成される。
    ports:
      - "5001:5000"  # ポートのマッピング。ホストの5001番ポートをコンテナの5000番ポートに転送。
    volumes:
      - ./appA:/usr/src/app  # ボリュームマウント。ホストの `./appA` ディレクトリをコンテナの `/usr/src/app` にマウント。
    environment:
      - ENV_VAR="app_a1"  # コンテナ内の環境変数の設定。ここでは 'ENV_VAR' に 'app_a1' を設定。
    command: python app_a_main.py  # コンテナ起動時に実行するコマンド。ここでは Python スクリプトを実行。


  app_a2:
    build:
      context: ./appA
      dockerfile: Dockerfile
    container_name: app_a2_container
    ports:
      - "5002:5000"  # ホストの5002をコンテナの5000にマッピング（ホストマシン:コンテナ）
    volumes:
      - ./appA:/usr/src/app
    environment:
      - ENV_VAR="app_a2"
    command: python app_a_main.py  # コンテナ起動時に実行するコマンド

  app_b1:
    build:
      context: ./appB
      dockerfile: Dockerfile
    container_name: app_b1_container
    ports:
      - "6001:6000"  # ホストの6001をコンテナの6000にマッピング
    volumes:
      - ./appB:/usr/src/app
    environment:
      - ENV_VAR="app_b1"
    command: python appB_main.py

```

## appA
今回はDockerfileの書き方や、Flaskの書き方にはあまり触れない。

### Dockerfile
あえてコメントアウト部分を残してあるのは、今回Docker composeを試すにあたり、少し詰まった箇所であるためである。実際に必要なのは、コメントアウトされていない部分である。
```Dockerfile
# ベースイメージを指定
FROM python:3.12

# 作業ディレクトリを設定
WORKDIR /usr/src/app

# requirements.lockをコンテナにコピー
# 開発時はdocker-compose.ymlで指定したvolumeを使うため、このCOPYは不要
# 逆に本番時は、docker-compose.ymlで指定したvolumeを使わず、このCOPYを使う
COPY requirements.lock .

# 依存関係をインストール
RUN pip install --no-cache-dir -r requirements.lock

# アプリケーションのソースコードをコピー
# 開発時はdocker-compose.ymlで指定したvolumeを使うため、このCOPYは不要
# 逆に本番時は、docker-compose.ymlで指定したvolumeを使わず、このCOPYを使う
# COPY ./ /usr/src/app

# コンテナ起動時の実行コマンド
# コメントアウトを外してもdocker-compose.ymlで指定したコマンドが実行されるため無視される
# つまり、このDockerfileにコマンドを書く必要はない
# CMD ["python", "./app1_main.py"]
```

### appA_main.py

```python
from flask import Flask
import numpy as np
import os

app = Flask(__name__)

@app.route('/')
def hello_world():
    test = np.random.randint(0, 10000)
    env_var = os.getenv('ENV_VAR', 'ENV_VAR not set')
    text = f"Hello World this is appA and the random number is: {test}. The value of the environment variable ENV_VAR is: {env_var}. Working dir:{os.getcwd()}"
    return text

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

```
注目すべき点
* appA_main.pyでのみnumpyをimportしており、appB_main.py(後述）ではしない。appA_main.pyでnumpyがうまく動くかを確認することでコンテナごとに環境が作られているかをチェック
* numpyを使用してインスタンスごとにランダムな数値を出力させることでappAから作成したコンテナがそれぞれ異なるものであるかを確認
* `os.get_env`でdocker-compose.ymlで指定した環境変数が拾えているかを確認
* `app.run(host='0.0.0.0', port=5000)`でポート番号5000としてFlaskアプリを起動しているが、これはコンテナ内だけの話でありローカルマシンでポート番号5000にブラウザで接続してもアプリは開けないことに注意。つまり、**ローカルのポート番号とコンテナ内のポート番号とのマッピングが必要**
* 逆に言えばコンテナに使用したポート番号がすでにローカルマシンで使用されていても問題ないということ（正しくマッピングされていれば）

### requirements.lock
今回アプリAで使用しているライブラリは少ないので非常にシンプル。
```
Flask
numpy
```

## appB
基本的な構成はほぼappAと同様なので、違う部分だけ解説。

### Dockerfile
appAと同じ
```Dockerfile
# ベースイメージを指定
FROM python:3.12

# 作業ディレクトリを設定
WORKDIR /usr/src/app

# requirements.lockをコンテナにコピー
# 開発時はdocker-compose.ymlで指定したvolumeを使うため、このCOPYは不要
# 逆に本番時は、docker-compose.ymlで指定したvolumeを使わず、このCOPYを使う
COPY requirements.lock .

# 依存関係をインストール
RUN pip install --no-cache-dir -r requirements.lock

# アプリケーションのソースコードをコピー
# 開発時はdocker-compose.ymlで指定したvolumeを使うため、このCOPYは不要
# 逆に本番時は、docker-compose.ymlで指定したvolumeを使わず、このCOPYを使う
# COPY ./ ./app

# コンテナ起動時の実行コマンド
# CMD ["python", "appB_main.py"]
```

### appB_main.py

```python
from flask import Flask
import os

app = Flask(__name__)

@app.route('/')
def hello_world():
    env_var = os.getenv('ENV_VAR', 'ENV_VAR not set')
    text = f"Hello World this is appB. The value of the environment variable ENV_VAR is: {env_var}"
    return text

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=6000)

```
appAと違う点は以下の通り。
* numpyを使用してない
* コンテナのポート番号が6000(何となく変えてみた）

### requirements.lock
numpyを使用していないので一つだけ。
```
Flask
```

## docker-compose.ymlとDockerfileを併用する際のポイント
docker-compose.ymlファイルからDockerfileを使用して複数コンテナを立てる際に、わかりづらかった箇所がいくつかあったので、appAを例として、記録に残しておく。(docker-compose.ymlやDockerfileにおいて、各要素が何を意味しているかは前のセクションを参照してください。)

appAのDockerfileを再掲。
```Dockerfile
FROM python:3.12

# 作業ディレクトリを設定
WORKDIR /usr/src/app

# requirements.lockをコンテナにコピー
# 開発時はdocker-compose.ymlで指定したvolumeを使うため、このCOPYは不要
# 逆に本番時は、docker-compose.ymlで指定したvolumeを使わず、このCOPYを使う
COPY requirements.lock .

# 依存関係をインストール
RUN pip install --no-cache-dir -r requirements.lock

# アプリケーションのソースコードをコピー
# 開発時はdocker-compose.ymlで指定したvolumeを使うため、このCOPYは不要
# 逆に本番時は、docker-compose.ymlで指定したvolumeを使わず、このCOPYを使う
# COPY ./ /usr/src/app

# コンテナ起動時の実行コマンド
# コメントアウトを外してもdocker-compose.ymlで指定したコマンドが実行されるため無視される
# つまり、このDockerfileにコマンドを書く必要はない
# CMD ["python", "./app1_main.py"]

```

### volumes
docker-compose.ymlの`volumes`フィールドに `マウントしたい対象のローカルマシンのディレクトリ:コンテナ内ディレクトリ`というように記載することで、ローカルマシンのディレクトリをマウントすることができる。

```yaml
    volumes:
      - ./appA:/usr/src/app  # ボリュームマウント。ホストの `./appA` ディレクトリをコンテナの `/usr/src/app` にマウント。

```

上の例だと、コンテナ内で`/usr/src/app`というディレクトリにアクセスすれば、ローカルマシンの`./app`ディレクトリにアクセスできる。このようにすることで、ローカルの変更が反映されるため、開発時に便利らしい。

似たような挙動としてDockerfileの`COPY`が挙げられる。今回の例では、Dockerfileでは`COPY`が2箇所で記載されており、そのうち後半のものは、コメントアウトされている。つまり、2つの`COPY`の前半は何らかの役割を果たしている一方、後半は役割がないと言うことである。コメントアウトを消して再掲。

```Dockerfile
FROM python:3.12
WORKDIR /usr/src/app
# 消すとエラーが起こる
COPY requirements.lock .  
RUN pip install --no-cache-dir -r requirements.lock
# 消してもエラーが起こらない
COPY ./ /usr/src/app  
CMD ["python", "./app1_main.py"]
```

なぜ、`COPY ./ /usr/src/app  `が必要ないのかというと、docker-composeで`volumes`を使用しているためである。docker composeを実行した際の流れは以下の通り。

1. docker compose upコマンドを実行
1. プロジェクト設定の読み込み
1. ボリュームの作成
1. イメージのビルドまたは取得 
→ ここで`COPY ./ /usr/src/app `と `COPY requirements.lock . ` が実行され、ローカルマシンの内容がコンテナにコピー
1. コンテナの作成
1. ボリュームのマウント
→ ここでコンテナ内の`/usr/src/app`がローカルの`appA`をマウント
1. 環境変数の適用
1. デフォルトコマンドまたは command の適用
1. コンテナの起動

コピーとマウントの順番に注目すると、コンテナの`/usr/src/app`にコピーした後に、再度マウントしなおしていることが分かる。この場合、マウントが優先されるため、実質`COPY ./ /usr/src/app `は何の役割も果たしていない。

一方、`COPY requirements.lock . `が役割を果たしているのは、マウント前に`RUN pip install --no-cache-dir -r requirements.lock`を実行する必要があるためである。結果として、`COPY requirements.lock . `によってコピーしたファイルは失われてしまうが、pip install のタイミングでだけ存在していれば良いため、意味をなしている。

### command: python app_a_main.py
コンテナが起動した際に実行するコマンドを指定するフィールドがdocker-compose.ymlとDockerfileに一つずつ存在する。

```yaml
command: python app_a_main.py  # コンテナ起動時に実行するコマンド
```

```dockerfile
CMD ["python", "./app1_main.py"]
```

このように2箇所に記載されている場合、docker-compose.ymlの方が優先され、Dockerfileの方は実行されていない。

# docker composeの起動
実際にこれらのシステムを起動する際に以下のコマンドをルートディレクトリの`docker-compose-study`で実行。

```
docker compose up --build 
```

`localhost:5001`にブラウザでアクセスし、以下の画面が表示されれば、`app_a1`が起動できていることを確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2718862/14f1e5ea-0c6b-d1d7-15c3-1900416947ec.png)

`localhost:5002`にブラウザでアクセスし、以下の画面が表示されれば、`app_a2`が起動できていることを確認できる。app_a1と同様のディレクトリappAから建てたコンテナのアプリだが、numpyで生成した乱数の値が異なり、独立に動いていることが確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2718862/4d899fb6-8d80-6c54-b9b7-14ab09567d69.png)

`localhost:6001`にブラウザでアクセスし、以下の画面が表示されれば、`app_b1`が起動できていることを確認できる。このコンテナはappBディレクトリから生成しているので、appAから生成したapp_a1とapp_a2とは出力される文字列が異なることが確認できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2718862/5a6367c9-814f-e331-47b8-1528e43e691d.png)

内容は以上になります。

# (追記)全てのコンテナのネットワークをローカルにする
開発を行う中で3つのコンテナをすべて1つのローカルネットワークで繋ぐ必要性が出てきて少し詰まったので、やり方を備忘録として追記。

## Docker Compose のネットワーク仕様
Docker が提供するネットワークモードの一つでブリッジネットワークというモードが存在する。これは、コンテナ同士を隔離しつつ、必要に応じて通信できるようにする仮想ネットワークの仕組みである。ブリッジネットワークの特徴は以下の通り。

1. コンテナ間通信
    * 同じブリッジネットワーク内に属するコンテナ同士は、コンテナ名 or ホスト名で通信可能
    * 例: コンテナAとコンテナBが同じブリッジネットワークにあるなら、コンテナAから`ping コンテナB`をすることで通信可能
2. 外部ネットワークとの通信
    * ブリッジネットワークに属するコンテナは、ホストマシンのネットワークやインターネットにアクセス可能
    * 外部からの接続を許可する場合は、ポートフォワーディング（特定のネットワークポートを別のシステムやプロセスに転送すること）を設定する必要がある（例: `-p 8080:80`）
3. 名前解決
    * 名前解決とは、ホスト名やサービス名を対応するIPアドレスに置換すること
    * Docker は内部DNSを使用して、同じネットワーク内のコンテナ名をIPアドレスに解決する
    * 例: `my_database`という名前のコンテナがある場合、他のコンテナから `HOST="my_database"`というようにしてアクセスする際、`HOST=172.18.0.2`のようにコンテナIPに置換される
4. デフォルトの分離
    * デフォルトでは、異なるブリッジ間の通信は不可能。これにより意図しないネットワーク干渉を帽子
    * 例: コンテナAとBでそれぞれ`localhost`に接続しても通信は干渉しない
    * 不必要な通信がなくなるためセキュリティ上良い

例えば以下のように`docker-compose.yml`を作るとコンテナ同士が楽に通信可能。
```yaml
version: "3.8"

services:
  container_a:
    build:
      context: ./container_a
      dockerfile: Dockerfile
    container_name: container_a
    environment:
      - MQTT_HOST=container_c  # ブローカーのコンテナ名を指定
      - MQTT_PORT=1883
    networks:
      - custom_bridge_network
    command: python main.py  # コンテナAの起動スクリプト

  container_b:
    build:
      context: ./container_b
      dockerfile: Dockerfile
    container_name: container_b
    environment:
      - MQTT_HOST=container_c  # ブローカーのコンテナ名を指定
      - MQTT_PORT=1883
    networks:
      - custom_bridge_network
    command: python main.py  # コンテナBの起動スクリプト

  container_c:  # MQTTブローカー
    image: eclipse-mosquitto
    container_name: container_c
    ports:
      - 1883:1883  # 外部から接続するためのポートフォワーディング
    networks:
      - custom_bridge_network

# カスタムブリッジネットワーク
networks:
  custom_bridge_network:
    driver: bridge

```
上記のようにすることでコンテナCとA、Bがそれぞれコンテナ名により通信することができる。ここではカスタムブリッジネットワークを定義している。カスタムブリッジネットワークの説明は以下の通り。

* ユーザーが独自に作成、認定できる仮想ネットワーク
* 複数のコンテナを接続して名前解決や通信の制御を可能に
* 標準のデフォルトブリッジネットワーク（名前: bridge）とは異なり、ユーザーがカスタムネットワークを明示的に作成
* Docker Compose はnetworkセクションを明示的に定義しなくてもプロジェクトごとにカスタムブリッジネットワークを自動作成（つまり上例の場合、network以下はなくても動く）
* ただしDocker Composeを使用した場合、全てのコンテナがデフォルトネットワークに接続される。つまり、IPアドレスの範囲を制限したい。コンテナ群ごとに異なるネットワークにしたいといった場合は、カスタムブリッジネットワークを明示的に定義する必要がある