🐳

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になります。

compose.yml
services:
  web:
    extends:
      file: compose-services.yml
      service: webapp
# 他にもサービスを用意し、extendsすることができる
compose-services.yml
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を含めることで解消できます

コンフリクトがないパターン

compose.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とかを再定義しなくて良い
./db/db-service.yml
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しつつ差分をマージする

compose.yml
include:
  - path:
      - ./db/db-service.yml
      - override.yml
# 以下、先ほどと同様のため省略

./db/db-service.yml は先ほどと同様のため省略

override.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つを読み込みますが、自身で設定したファイルをオーバーライドすることもできます。

compose.yml
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
compose.test.yml
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 configup と同じ -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または空配列が勧められています)
compose.yml
services:
  web:
    image: python:3.13.6-slim
    ports:
      - "8000:443"
compose.override.yml
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ポートで上書きする
compose.yml
services:
  web:
    image: python:3.13.6-slim
    ports:
      - "8000:443"
compose.override.yml
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

compose.yml
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

compose.yml
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

マージのルールは適宜確認しておきたい。。。

参考

GitHubで編集を提案

Discussion