Docker ComposeをDRY にする4つのテクニック
背景
Docker Composeでサービスが重複して書かれているのを見たりするシーンがあったり、自分が書いてしまっていたり...。
そこで、Docker Composeでも共通モジュール化したりできないのかなぁと思っていたことが始まりです。
環境
sw_vers
ProductName: macOS
ProductVersion: 15.6
BuildVersion: 24G84
docker -v
Docker version 28.3.2, build 578ccf6
docker compose version
Docker Compose version v2.39.1-desktop.1
DRY にする4つのテクニック
パターン早見表
| 方法 | 利用場面 | 利点 | 注意点 |
|---|---|---|---|
| extends | 既存サービスの定義を異なるファイルやプロジェクト間で共有したい | 記述量が減る | ・volumes等の相対パスの基準が呼び出し元ファイルになる ・元のサービス自体は起動しない |
| include | サービス単位でファイルを分割し、パスの基準を維持したまま取り込みたい | ・記述量が減る ・各サービスのパスを崩さなくて済む |
同じサービス名の場合マージされずコンフリクトするため、override が必要になる |
| merge(-f) | 開発/テスト/本番など、環境ごとに設定を切り替えたい | ・環境差分を柔軟に管理できる ・同名サービスを意図的にマージできる |
・-f 指定時は自動読込されない ・マージルール(置換や連結など)の理解が必要 |
| Extensions/Fragments | 単一の Compose ファイル内で、共通の設定ブロックを再利用したい | ・単一ファイル完結 ・依存関係がわかりやすい ・柔軟な定義が可能 |
大規模になるとコードが長くなる可能性がある |
複数のcomposeファイルをextendする
extends属性を使用することで、異なるファイル/異なるプロジェクト間での設定を共有できます。
複数のサービスで共通の設定を再利用する際に便利です。
ただし、注意点として、volumesなどで使われる相対パスは呼び出し元composeファイルを基準とします。
以下の場合、compose.ymlから見たファイルパスを基準にするため、
volumesがcompose-services.ymlから見たパスになっている場合正しい参照になりません。
これの問題を解消するのが次のセクションに出てくるincludeになります。
services:
web:
extends:
file: compose-services.yml
service: webapp
# 他にもサービスを用意し、extendsすることができる
services:
webapp:
image: python:3.13.6-slim
working_dir: /app
volumes:
- .:/app
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PIP_NO_CACHE_DIR: "1"
TZ: Asia/Tokyo
command: python app/main.py
実際に立ち上げてみる
docker compose up
データモデルを見てみる
# docker compose config
name: memo
services:
web:
command:
- python
- app/main.py
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
networks:
default: null
volumes:
- type: bind
source: /path/to/your/project
target: /app
bind:
create_host_path: true
working_dir: /app
networks:
default:
name: memo_default
実際に環境変数も設定されていることが確認できます
# docker compose exec web sh
# env
HOSTNAME=89b9360a13f1
HOME=/root
PYTHONUNBUFFERED=1
GPG_KEY=7169605F62C751356D054A26A821E680E5FA6305
PYTHON_SHA256=17ba5508819d8736a14fbfc47d36e184946a877851b2e9c4b6c43acb44a3b104
PYTHONDONTWRITEBYTECODE=1
TERM=xterm
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PIP_NO_CACHE_DIR=1
PYTHON_VERSION=3.13.6
PWD=/app
TZ=Asia/Tokyo
サービス全体をextendsした例ですが、情報がしっかりと反映されていることがわかります。
一部の属性をまとめたファイルを用意し、読み込むこともできます。
また、extendsされたサービスは最終的には含まれません。compose.yml側でwebとして使用されます。
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
a59bfceba9f7 python:3.13.6-slim "python app/main.py" 14 seconds ago Up 13 seconds memo-web-1
複数の compose ファイルを include する
extends 属性との違いは以下です。
- Top レベルに記載する必要があります
- 各ファイルを基準にし実行するため、extendsとmergeで発生する相対パスの問題を解決できます
- extendsで言及した通りです
- 再帰的に適用されるため、include したファイル内でも include を使用している場合それらも読みこまれます
- また、同じ名称のサービスがある場合はコンフリクトするため注意が必要です(
services.〇〇 conflicts with imported resourceになる) - コンフリクトを回避するには、compose.ymlと同じ階層にcompose.override.ymlを配置するか、includeにoverride.ymlを含めることで解消できます
- また、同じ名称のサービスがある場合はコンフリクトするため注意が必要です(
コンフリクトがないパターン
include:
- path: ./db/db-service.yml
services:
web:
image: python:3.13.6-slim
working_dir: /app
volumes:
- .:/app:ro
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PIP_NO_CACHE_DIR: "1"
TZ: Asia/Tokyo
command: python app/main.py
depends_on:
db:
condition: service_healthy
# ここにDBとかを再定義しなくて良い
services:
# compose.ymlにあるサービス名と同じものを定義した場合、コンフリクトする
db:
image: mysql:8.4
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: sampledb
MYSQL_USER: appuser
MYSQL_PASSWORD: apppass
TZ: Asia/Tokyo
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 3
start_period: 40s
volumes:
- db-data:/var/lib/mysql
volumes:
db-data:
実際に立ち上げてみる
docker compose up
データモデルを見てみる(docker compose config)
# docker compose config
name: memo
services:
db:
environment:
MYSQL_DATABASE: sampledb
MYSQL_PASSWORD: apppass
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: appuser
TZ: Asia/Tokyo
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
timeout: 5s
interval: 10s
retries: 3
start_period: 40s
image: mysql:8.4
networks:
default: null
ports:
- mode: ingress
target: 3306
published: "3306"
protocol: tcp
restart: unless-stopped
volumes:
- type: volume
source: db-data
target: /var/lib/mysql
volume: {}
web:
command:
- python
- app/main.py
depends_on:
db:
condition: service_healthy
required: true
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
networks:
default: null
volumes:
- type: bind
source: /path/to/your/project
target: /app
read_only: true
bind:
create_host_path: true
working_dir: /app
networks:
default:
name: memo_default
volumes:
db-data:
name: memo_db-data
コンフリクトがあるパターンを解消する
dbをテスト用にしたい場合、overrideしつつ差分をマージする
include:
- path:
- ./db/db-service.yml
- override.yml
# 以下、先ほどと同様のため省略
./db/db-service.yml は先ほどと同様のため省略
services:
# ./db/db-service.ymlにあるサービス名と同じものを定義した場合、通常コンフリクトするがoverrideすることでコンフリクトを防ぐ
db:
environment:
MYSQL_ROOT_PASSWORD: testpass
MYSQL_DATABASE: testdb
MYSQL_USER: testuser
MYSQL_PASSWORD: testpass
TZ: Asia/Tokyo
ports: !override # 追加ではなく、上書き
- "3307:3306"
!overrideを書いている理由は記述ルールによるもので、後述Mergeセクションをご覧ください。
データモデルを見てみる(docker compose config)
# docker compose config
name: memo
services:
db:
environment: # 上書きされていることがわかる
MYSQL_DATABASE: testdb
MYSQL_PASSWORD: testpass
MYSQL_ROOT_PASSWORD: testpass
MYSQL_USER: testuser
TZ: Asia/Tokyo
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
timeout: 5s
interval: 10s
retries: 3
start_period: 40s
image: mysql:8.4
networks:
default: null
ports:
- mode: ingress
target: 3306
published: "3307" # overrideされていることがわかる
protocol: tcp
restart: unless-stopped
volumes:
- type: volume
source: db-data
target: /var/lib/mysql
volume: {}
web:
command:
- python
- app/main.py
depends_on:
db:
condition: service_healthy
required: true
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
networks:
default: null
volumes:
- type: bind
source: /path/to/your/project
target: /app
read_only: true
bind:
create_host_path: true
working_dir: /app
networks:
default:
name: memo_default
volumes:
db-data:
name: memo_db-data
複数の compose ファイルを Merge する
複数のcomposeファイルをまとめて merge・overrideし、composeファイルを作成します。
デフォルトは、compose.yamlとオプションでcompose.override.yamlの2つを読み込みますが、自身で設定したファイルをオーバーライドすることもできます。
services:
web:
image: python:3.13.6-slim
working_dir: /app
volumes:
- .:/app
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PIP_NO_CACHE_DIR: "1"
APP_ENV: dev
TZ: Asia/Tokyo
command: python app/main.py
services:
web:
ports: ["8000:8000"] # マージされることを確認するめに記載
environment:
APP_ENV: test
command: python app/test.py
実行方法
-
テスト
docker compose -f compose.yml -f compose.test.yml up -
開発
docker compose up
※補足: docker compose config も up と同じ -f の順序で実行してください。
データモデルを見る(docker compose -f compose.yml -f compose.test.yml config)
# docker compose -f compose.yml -f compose.test.yml config
name: memo
services:
web:
command: # 上書きされている
- python
- app/test.py
environment:
APP_ENV: test # 上書きされている
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
networks:
default: null
ports: # 追加されている
- mode: ingress
target: 8000
published: "8000"
protocol: tcp
volumes:
- type: bind
source: /path/to/your/project
target: /app
bind:
create_host_path: true
working_dir: /app
networks:
default:
name: memo_default
上記に加えて、オーバーライドする要素を削除・マージルールを回避して完全に置き換えることもできます。
- 要素を削除する
- compose.ymlのportsを完全に削除する(結果の属性がクリアされることが明確になるようにnullまたは空配列が勧められています)
services:
web:
image: python:3.13.6-slim
ports:
- "8000:443"
services:
web:
image: python:3.13.6-slim
ports: !reset []
compose.override.ymlがない場合のデータモデル
# docker compose config
name: memo
services:
web:
image: python:3.13.6-slim
networks:
default: null
ports:
- mode: ingress
target: 443
published: "8000"
protocol: tcp
networks:
default:
name: memo_default
compose.override.ymlを置いた場合のデータモデル
# docker compose config
name: memo
services:
web:
image: python:3.13.6-slim
networks:
default: null
networks:
default:
name: memo_default
- 完全に置き換える(上書き)場合
- compose.ymlを完全に80ポートで上書きする
services:
web:
image: python:3.13.6-slim
ports:
- "8000:443"
services:
web:
image: python:3.13.6-slim
ports: !override
- "8000:80"
compose.override.ymlがない場合のconfig
# docker compose config
name: memo
services:
web:
image: python:3.13.6-slim
networks:
default: null
ports:
- mode: ingress
target: 443
published: "8000"
protocol: tcp
networks:
default:
name: memo_default
compose.override.ymlを置いた docker compose upのconfig(compose.override.ymlが同じ階層にある場合自動で読み込まれる)
# docker compose config
name: memo
services:
web:
image: python:3.13.6-slim
networks:
default: null
ports:
- mode: ingress
target: 80
published: "8000"
protocol: tcp
networks:
default:
name: memo_default
単一ファイルでExtensions、Fragments を使用する
単一ファイル内で再利用したい設定をモジュールにする場合は、serviceから切り離しTopレベルに「x-」プレフィックスを付け記載します。
composeは「x-」で始まるフィールドを無視するようです。
また、composeには必要がないけれどもデプロイのメタ情報として読み取らせたい場合にも使用ができるようです。
以下サンプルの通り、モジュール化しただけではただセパレートされただけで参照ができないため、 Fragmentsであるアンカー(&)とエイリアス(*)を使用し参照できるようにしてあげる必要があります。
before
services:
web:
image: python:3.13.6-slim
profiles: ["web"]
working_dir: /app
volumes:
- .:/app
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PIP_NO_CACHE_DIR: "1"
TZ: Asia/Tokyo
command: python app/main.py
depends_on:
db:
condition: service_healthy
test:
image: python:3.13.6-slim
profiles: ["test"]
working_dir: /app
volumes:
- .:/app
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PIP_NO_CACHE_DIR: "1"
TZ: Asia/Tokyo
command: python app/test.py
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.4
restart: unless-stopped
profiles: [web, test]
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: sampledb
MYSQL_USER: appuser
MYSQL_PASSWORD: apppass
TZ: Asia/Tokyo
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 3
start_period: 40s
volumes:
- db-data:/var/lib/mysql
volumes:
db-data:
# ...他にもdbなど同じような書きっぷりでサービスをいっぱい定義しているとする
after
x-base-web: &base-web
image: python:3.13.6-slim
working_dir: /app
volumes:
- .:/app
environment:
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
PIP_NO_CACHE_DIR: "1"
TZ: Asia/Tokyo
depends_on:
db:
condition: service_healthy
services:
web:
<<: *base-web
profiles: ["web"]
command: python app/main.py
test:
<<: *base-web
profiles: ["test"]
command: python app/test.py
db:
image: mysql:8.4
restart: unless-stopped
profiles: [web, test]
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: sampledb
MYSQL_USER: appuser
MYSQL_PASSWORD: apppass
TZ: Asia/Tokyo
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 3
start_period: 40s
volumes:
- db-data:/var/lib/mysql
volumes:
db-data:
Extensions/Fragmentsを使用したモジュール化のおかげでweb関連スッキリ定義できました。
docker compose --profile web up
or
docker compose --profile test up
docker compose --profile web upのデータモデル
# docker compose --profile web config
name: memo
services:
db:
profiles:
- web
- test
environment:
MYSQL_DATABASE: sampledb
MYSQL_PASSWORD: apppass
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: appuser
TZ: Asia/Tokyo
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
timeout: 5s
interval: 10s
retries: 3
start_period: 40s
image: mysql:8.4
networks:
default: null
ports:
- mode: ingress
target: 3306
published: "3306"
protocol: tcp
restart: unless-stopped
volumes:
- type: volume
source: db-data
target: /var/lib/mysql
volume: {}
web:
profiles:
- web
command:
- python
- app/main.py
depends_on:
db:
condition: service_healthy
required: true
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
networks:
default: null
volumes:
- type: bind
source: /path/to/your/project
target: /app
bind:
create_host_path: true
working_dir: /app
networks:
default:
name: memo_default
volumes:
db-data:
name: memo_db-data
x-base-web: # 「x-」で書いた部分はconfigにも出力されるが実際のサービスには含まれない
depends_on:
db:
condition: service_healthy
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
volumes:
- .:/app
working_dir: /app
docker compose --profile test upのデータモデル
# docker compose --profile test config
name: memo
services:
db:
profiles:
- web
- test
environment:
MYSQL_DATABASE: sampledb
MYSQL_PASSWORD: apppass
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_USER: appuser
TZ: Asia/Tokyo
healthcheck:
test:
- CMD
- mysqladmin
- ping
- -h
- localhost
timeout: 5s
interval: 10s
retries: 3
start_period: 40s
image: mysql:8.4
networks:
default: null
ports:
- mode: ingress
target: 3306
published: "3306"
protocol: tcp
restart: unless-stopped
volumes:
- type: volume
source: db-data
target: /var/lib/mysql
volume: {}
test:
profiles:
- test
command:
- python
- app/test.py
depends_on:
db:
condition: service_healthy
required: true
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
networks:
default: null
volumes:
- type: bind
source: /path/to/your/project
target: /app
bind:
create_host_path: true
working_dir: /app
networks:
default:
name: memo_default
volumes:
db-data:
name: memo_db-data
x-base-web: # 「x-」で書いた部分はconfigにも出力されるが実際のサービスには含まれない
depends_on:
db:
condition: service_healthy
environment:
PIP_NO_CACHE_DIR: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TZ: Asia/Tokyo
image: python:3.13.6-slim
volumes:
- .:/app
working_dir: /app
まとめ
- 既存のサービスの定義を異なるファイル・プロジェクトで共有したい -> extends
- サービス単位でファイルを分割し、パスの基準を維持しておきたい -> include
- 開発・テスト・本番など環境差異で設定切り替えをしたい -> merge(-f)
- 単一のファイル内で共通のブロックを再利用したい -> Extensions/Fragments
マージのルールは適宜確認しておきたい。。。
Discussion