目的
以下のようなシステムを作成したい。
- Docker composeを使用してイメージAとイメージBからそれぞれ
A1, A2, ..., AM, B1, B2, ..., BN
と複数のコンテナを立ち上げたい - 各イメージの設定は各Dockerfileに記載
Docker-composeの処理の流れ
Docker-composeを使用した際にどのような流れで処理が行われるかを復習。
-
docker compose up
コマンドを実行 - プロジェクト設定の読み込み
カレントディレクトリのdocker-compose.yml
を読み込む - ネットワークの作成
- ボリュームの作成
volumes セクションで定義された名前付きボリュームを作成(ただし、この時点では、ファイルのマウントは行われていないことに注意) - イメージのビルドまたは取得
Docker ファイルが指定されている場合は、それに従ってイメージをビルド。Dockerファイルに記載されていれば、この段階で必要なパッケージのインストールなどを行う - コンテナの作成
サービスごとにコンテナを作成するが、この時点ではまだ起動しない。ポート番号を管理するports
、環境変数を管理する'environment'などはこのタイミングで準備される - ボリュームのマウント
ここでローカルファイルがマウントされ使用できるようになる。逆にこのタイミング以前では、ローカルファイルの内容をDockerfileの中から使用することができない(Dockerファイルはビルド時に使用されるので)。これ以前にDockerfile
でコンテナにローカルからファイルをコピーしていたとしてもvolume
を設定していたならば上書きされる - 環境変数の適用
environment セクションや .env ファイルに記載された環境変数をコンテナに設定 - デフォルトコマンドまたは command の適用
Dockerfile内で指定されたCMDがデフォルトのコマンドとして適用される。例えば、python app.py
などで実際のアプリを実行。ただし、Dockerfile
とdocker-compose.yml
で共にコマンドが記載されている場合、docker-compose.ymlが優先される - コンテナの起動
コンテナを起動し、ネットワークやボリュームの設定を適用した状態で、サービスのプロセスを開始 - ログのストリーミング
開発時のファイルの内容
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)
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を試すにあたり、少し詰まった箇所であるためである。実際に必要なのは、コメントアウトされていない部分である。
# ベースイメージを指定
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
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と同じ
# ベースイメージを指定
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
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を再掲。
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
フィールドに マウントしたい対象のローカルマシンのディレクトリ:コンテナ内ディレクトリ
というように記載することで、ローカルマシンのディレクトリをマウントすることができる。
volumes:
- ./appA:/usr/src/app # ボリュームマウント。ホストの `./appA` ディレクトリをコンテナの `/usr/src/app` にマウント。
上の例だと、コンテナ内で/usr/src/app
というディレクトリにアクセスすれば、ローカルマシンの./app
ディレクトリにアクセスできる。このようにすることで、ローカルの変更が反映されるため、開発時に便利らしい。
似たような挙動としてDockerfileのCOPY
が挙げられる。今回の例では、DockerfileではCOPY
が2箇所で記載されており、そのうち後半のものは、コメントアウトされている。つまり、2つのCOPY
の前半は何らかの役割を果たしている一方、後半は役割がないと言うことである。コメントアウトを消して再掲。
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を実行した際の流れは以下の通り。
- docker compose upコマンドを実行
- プロジェクト設定の読み込み
- ボリュームの作成
- イメージのビルドまたは取得
→ ここでCOPY ./ /usr/src/app
とCOPY requirements.lock .
が実行され、ローカルマシンの内容がコンテナにコピー - コンテナの作成
- ボリュームのマウント
→ ここでコンテナ内の/usr/src/app
がローカルのappA
をマウント - 環境変数の適用
- デフォルトコマンドまたは command の適用
- コンテナの起動
コピーとマウントの順番に注目すると、コンテナの/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に一つずつ存在する。
command: python app_a_main.py # コンテナ起動時に実行するコマンド
CMD ["python", "./app1_main.py"]
このように2箇所に記載されている場合、docker-compose.ymlの方が優先され、Dockerfileの方は実行されていない。
docker composeの起動
実際にこれらのシステムを起動する際に以下のコマンドをルートディレクトリのdocker-compose-study
で実行。
docker compose up --build
localhost:5001
にブラウザでアクセスし、以下の画面が表示されれば、app_a1
が起動できていることを確認できる。
localhost:5002
にブラウザでアクセスし、以下の画面が表示されれば、app_a2
が起動できていることを確認できる。app_a1と同様のディレクトリappAから建てたコンテナのアプリだが、numpyで生成した乱数の値が異なり、独立に動いていることが確認できる。
localhost:6001
にブラウザでアクセスし、以下の画面が表示されれば、app_b1
が起動できていることを確認できる。このコンテナはappBディレクトリから生成しているので、appAから生成したapp_a1とapp_a2とは出力される文字列が異なることが確認できる。
内容は以上になります。
(追記)全てのコンテナのネットワークをローカルにする
開発を行う中で3つのコンテナをすべて1つのローカルネットワークで繋ぐ必要性が出てきて少し詰まったので、やり方を備忘録として追記。
Docker Compose のネットワーク仕様
Docker が提供するネットワークモードの一つでブリッジネットワークというモードが存在する。これは、コンテナ同士を隔離しつつ、必要に応じて通信できるようにする仮想ネットワークの仕組みである。ブリッジネットワークの特徴は以下の通り。
- コンテナ間通信
- 同じブリッジネットワーク内に属するコンテナ同士は、コンテナ名 or ホスト名で通信可能
- 例: コンテナAとコンテナBが同じブリッジネットワークにあるなら、コンテナAから
ping コンテナB
をすることで通信可能
- 外部ネットワークとの通信
- ブリッジネットワークに属するコンテナは、ホストマシンのネットワークやインターネットにアクセス可能
- 外部からの接続を許可する場合は、ポートフォワーディング(特定のネットワークポートを別のシステムやプロセスに転送すること)を設定する必要がある(例:
-p 8080:80
)
- 名前解決
- 名前解決とは、ホスト名やサービス名を対応するIPアドレスに置換すること
- Docker は内部DNSを使用して、同じネットワーク内のコンテナ名をIPアドレスに解決する
- 例:
my_database
という名前のコンテナがある場合、他のコンテナからHOST="my_database"
というようにしてアクセスする際、HOST=172.18.0.2
のようにコンテナIPに置換される
- デフォルトの分離
- デフォルトでは、異なるブリッジ間の通信は不可能。これにより意図しないネットワーク干渉を帽子
- 例: コンテナAとBでそれぞれ
localhost
に接続しても通信は干渉しない - 不必要な通信がなくなるためセキュリティ上良い
例えば以下のようにdocker-compose.yml
を作るとコンテナ同士が楽に通信可能。
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アドレスの範囲を制限したい。コンテナ群ごとに異なるネットワークにしたいといった場合は、カスタムブリッジネットワークを明示的に定義する必要がある