Docker Composeによるローカル開発環境構築:MySQL・Redis・MinIO

Docker Composeを使ってMySQL、Redis、MinIOを含むローカル開発環境を構築する方法を、docker-compose.ymlの書き方からデータ永続化、ヘルスチェックまで実践的に解説します。

はじめに

ローカル開発でMySQL、Redis、オブジェクトストレージなどのミドルウェアを手動でインストール・設定するのは、環境差異やバージョン管理の面で問題が生じがちです。Docker Composeを使えば、docker compose up の一コマンドで再現可能な開発環境を即座に構築できます。

本記事では、MySQL・Redis・MinIO(S3互換オブジェクトストレージ)を含む実用的な開発環境をDocker Composeで構築する方法を解説します。

全体アーキテクチャ

本記事で構築する開発環境は、アプリケーションコンテナ(app)がMySQL・Redis・MinIOの3つのミドルウェアに依存する構成です。

Docker Composeのサービストポロジー図。app・mysql・redis・minioがデフォルトブリッジネットワーク上に配置され、app→mysql・app→redisはdepends_onのヘルスチェックゲート付き矢印、app→minioは実行時のみのS3 API呼び出しとして描かれている

すべてのサービスはComposeがプロジェクトごとに自動生成する単一のデフォルトネットワーク(<プロジェクト名>_default)上に配置され、コンテナ同士はサービス名でDNS解決できます(詳細は後述の「ネットワーキング」節)。appmysqlredisに対してはdepends_onのヘルスチェックゲートを通過してから起動しますが、minioprofiles: ["storage"]で分離されたオプションサービスであり、起動順序の制御対象ではなく実行時にS3 APIとして呼び出されるだけです。各ミドルウェアにはNamed Volumeが割り当てられており、docker compose downでコンテナを削除してもデータは保持されます(-vを付けない限り)。以降の節では、この図に描かれた要素——依存関係の解決順序、サービスディスカバリ、データ永続化——を順に詳しく見ていきます。

docker-compose.ymlの基本構造

Docker Composeの設定ファイルは主に3つのセクションで構成されます。

services: # コンテナの定義
  web:
    image: nginx
    ports:
      - "8080:80"

volumes: # データ永続化のためのボリューム
  db-data:

networks: # カスタムネットワーク
  backend:

各サービスの設定

MySQL

services:
  mysql:
    image: mysql:8.0
    container_name: dev-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpass}
      MYSQL_DATABASE: ${MYSQL_DATABASE:-devdb}
      MYSQL_USER: ${MYSQL_USER:-devuser}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-devpass}
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    command: >
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_unicode_ci
      --default-time-zone=+09:00

default-time-zone=+09:00 を設定することで、JSTでの日時管理が可能です。

Redis

redis:
  image: redis:7-alpine
  container_name: dev-redis
  ports:
    - "6379:6379"
  volumes:
    - redis-data:/data
  healthcheck:
    test: ["CMD", "redis-cli", "ping"]
    interval: 10s
    timeout: 5s
    retries: 5
  command: redis-server --appendonly yes

--appendonly yes でAOF(Append Only File)永続化を有効にし、データの耐久性を確保します。

MinIO(S3互換ストレージ)

minio:
  image: minio/minio:latest
  container_name: dev-minio
  environment:
    MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
    MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
  ports:
    - "9000:9000" # API
    - "9001:9001" # Console
  volumes:
    - minio-data:/data
  healthcheck:
    test: ["CMD", "mc", "ready", "local"]
    interval: 10s
    timeout: 5s
    retries: 5
  command: server /data --console-address ":9001"

MinIOはAWS S3互換のAPIを提供するため、本番でS3を使うアプリケーションのローカル開発に最適です。

完成版 docker-compose.yml

services:
  mysql:
    image: mysql:8.0
    container_name: dev-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpass}
      MYSQL_DATABASE: ${MYSQL_DATABASE:-devdb}
      MYSQL_USER: ${MYSQL_USER:-devuser}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:-devpass}
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    command: >
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_unicode_ci
      --default-time-zone=+09:00

  redis:
    image: redis:7-alpine
    container_name: dev-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    command: redis-server --appendonly yes

  minio:
    image: minio/minio:latest
    container_name: dev-minio
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - minio-data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 10s
      timeout: 5s
      retries: 5
    command: server /data --console-address ":9001"

volumes:
  mysql-data:
  redis-data:
  minio-data:

よく使うコマンド

コマンド説明
docker compose up -dバックグラウンドで起動
docker compose down停止・コンテナ削除
docker compose down -v停止・ボリュームも削除
docker compose logs -f mysqlMySQLのログを追跡
docker compose exec mysql mysql -u devuser -p devdbMySQLに接続
docker compose exec redis redis-cliRedis CLIに接続
docker compose psコンテナの状態確認
docker compose restart redis特定サービスの再起動

データ永続化

Named Volumes vs Bind Mounts

方式用途
Named VolumeDB等のデータ永続化mysql-data:/var/lib/mysql
Bind Mountソースコードの同期./src:/app/src

Named Volumeはdocker compose down後もデータが残りますが、-v フラグを付けると削除されます。

ライフサイクル:down と down -v の違い

Named Volumeとデータの生死を、コマンド列に沿って確認します(実際にDockerデーモンを起動して取得したログではなく、Dockerが公式に文書化しているボリュームの挙動に基づく想定シーケンスです)。

docker compose up -d mysql          # 初回起動。mysql-dataボリュームが作成され、初期化される
docker compose exec mysql mysql -u devuser -p devdb -e \
  "CREATE TABLE t (id INT); INSERT INTO t VALUES (1);"

docker compose down                 # コンテナとネットワークは削除されるが、mysql-dataは残る
docker compose up -d mysql          # 同じmysql-dataを再アタッチ
docker compose exec mysql mysql -u devuser -p devdb -e "SELECT * FROM t;"
# => 1 が返る。データは失われていない

docker compose down -v              # -v でNamed Volumeも削除
docker compose up -d mysql          # mysql-dataが新規作成され、MySQLは初期状態から再初期化される
docker compose exec mysql mysql -u devuser -p devdb -e "SELECT * FROM t;"
# => ERROR 1146: Table 'devdb.t' doesn't exist(初期化し直されたため)

docker compose downは「コンテナ・ネットワーク・(Composeファイルで匿名定義された)匿名ボリューム」を削除しますが、volumes:セクションで宣言したNamed Volumeそのものは明示的に-vを付けない限り削除されません。誤って-vを付けたままCIスクリプトを使い回すと、意図せずローカル開発用DBの中身を消してしまう事故につながるため、down -vをエイリアスやスクリプトに安易に固定しないことを推奨します。

Bind Mountの注意点

ソースコード同期など開発体験の向上にはBind Mountが便利ですが、ステートフルなサービス(DB等)に使う場合は次の落とし穴があります。

  • UID/GIDの不一致によるパーミッションエラー: 多くの公式DBイメージはコンテナ内の専用ユーザー(例: MySQLはmysqlユーザー、多くはUID 999前後)でデータディレクトリを所有します。ホスト側のディレクトリを直接Bind Mountすると、ホストユーザーとコンテナ内ユーザーのUID/GIDが一致せず、初期化時にPermission deniedで失敗することがあります。Named Volumeであれば、Docker自身がボリュームの所有権をコンテナ内ユーザーに合わせて初期化するため、この問題を回避できます。
  • Docker Desktop(macOS/Windows)でのI/O性能劣化: Docker DesktopはホストのファイルシステムをVM経由でコンテナに共有するため、Bind Mountされたディレクトリへの大量の細かいI/O(DBのファイルI/Oはまさにこれに該当)は、VM内部に閉じたNamed Volumeと比べて明確に遅くなります。ファイル共有方式は世代を追って改善されており(gRPC-FUSEからVirtioFSへの移行等)体感速度は大きく向上していますが、2025年時点の計測でもBind MountはネイティブI/O比でおおよそ3倍程度遅いという報告があります( Docker on MacOS is still slow? )。Linux上でDocker Engineを直接使う場合はホストのファイルシステムをそのまま利用するため、この問題自体が生じません。

以上から、DBのデータファイルには常にNamed Volumeを使い、Bind Mountはソースコードや設定ファイルの同期用途に限定するのが実務上の指針です。

ヘルスチェックとdepends_on

依存関係の解決順序:「起動した」と「準備ができた」は別物

Docker Composeはdepends_onで宣言された依存関係からDAG(有向非巡回グラフ)を構築し、トポロジカルソートした順に依存サービスを先に起動します。ただしdepends_onを単純なリスト形式(conditionを省略した形式)で書いた場合、デフォルトの条件はservice_startedです。

services:
  app:
    build: .
    depends_on:
      - mysql # condition省略時は service_started と同じ意味
      - redis

service_startedが保証するのは「依存コンテナのエントリーポイントプロセス(PID 1)がexecされたこと」だけであり、そのプロセスが接続を受け付けられる状態になったことは一切保証しません。MySQLを例に取ると、mysqldプロセスが起動してから実際に3306番ポートでLISTENを開始するまでには次のような内部処理が挟まります。

  1. データディレクトリの存在確認(初回起動時は初期化処理一式)
  2. InnoDBバッファプール・REDOログの初期化
  3. 権限テーブルのロードとネットワークソケットのbind

特に初回起動(ボリュームが空の状態)では、MySQL公式イメージのエントリーポイントは初期化用の一時サーバーを内部で起動してから一旦停止し、改めて本来のサーバープロセスを起動するという2段階の手順を踏みます。この間、コンテナ自体は「起動済み(Up)」の状態にもかかわらず、3306番ポートへの接続はまだ確立できません。したがってconditionを指定しない、あるいはservice_startedのままにしていると、appコンテナは「MySQLコンテナが起動した直後」に接続を試み、Connection refusedCan't connect to MySQL serverのようなエラーで失敗する可能性があります。これが、Docker Composeにおける典型的な**起動順序の競合状態(race condition)**です。

service_healthy:ヘルスチェックによるゲート

この問題を解決するのがcondition: service_healthyです。

services:
  app:
    build: .
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy

service_healthyを指定すると、Composeは依存サービスのコンテナステータスがstartingからhealthyに遷移するまで、依存元サービスの起動を保留します。ヘルスチェックの状態遷移はhealthcheckで指定した4つのパラメータで制御されます。

パラメータ意味
test正常性を判定するコマンド。終了コード0で成功、それ以外は失敗
intervalヘルスチェックを実行する間隔
timeout1回のチェックがこの時間内に完了しない場合、失敗として扱う
retries連続してこの回数失敗すると、コンテナ状態がunhealthyになる
start_periodコンテナ起動直後の猶予期間。この間の失敗はretriesのカウントに含まれない(起動が遅いサービスを誤ってunhealthyと判定しないための緩和措置)

MySQLの例(mysqladmin ping)ではinterval: 10stimeout: 5sretries: 5start_period: 30sとしているため、起動から最大30秒間は失敗してもカウントされず、その後は10秒ごとのチェックが5回連続で失敗して初めてunhealthyと判定されます。condition: service_healthyを指定した依存元サービスは、この状態がhealthyになるまで起動を待機するため、「ポートは開いているがまだ初期化中」という中間状態を安全に読み飛ばせます。

なお、service_healthyが保証するのはあくまで起動順序であり、実行中に依存サービスが再起動して一時的にunhealthyになっても、Composeはappを自動的に止めたり再起動したりしません。稼働中の依存関係を保証したい場合は、Compose Specのdepends_on.restart: true(依存サービス再起動時に依存元も再起動する)を併用するか、アプリケーション側に接続リトライロジックを実装する必要があります。

検証:naive設定とヘルスチェックゲート設定の比較

実際に両方の設定を比較してみます。次の2つは、appmysqlredisの3サービスのみを抜き出した最小構成です(両方ともYAML構文としては妥当で、docker compose configで変数展開・スキーマの検証が可能です)。

naive版(起動順序のみ、ヘルスチェックなし):

services:
  app:
    build: .
    depends_on:
      - mysql
      - redis
    environment:
      DATABASE_URL: mysql://devuser:devpass@mysql:3306/devdb
      REDIS_URL: redis://redis:6379
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: devdb
      MYSQL_USER: devuser
      MYSQL_PASSWORD: devpass
  redis:
    image: redis:7-alpine

ヘルスチェックゲート版:

services:
  app:
    build: .
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    environment:
      DATABASE_URL: mysql://devuser:devpass@mysql:3306/devdb
      REDIS_URL: redis://redis:6379
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: devdb
      MYSQL_USER: devuser
      MYSQL_PASSWORD: devpass
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

両者の差分はdepends_onの形式(リスト vs condition付きマップ)と、mysqlredisへのhealthcheckブロックの有無だけです。docker compose up実行時の挙動は次のように異なります。

経過naive版ヘルスチェックゲート版
t=0smysqlredisappがほぼ同時にコンテナ作成・起動開始mysqlredisが起動開始、appはまだ作成されない
t=1〜数秒MySQLコンテナ内部で初期化処理が進行中。appは並行してDB接続を試みうるComposeがstart_period内でヘルスチェックをポーリング
接続タイミングが重なった場合appECONNREFUSED等で起動時に失敗しうる(アプリ側にリトライがなければクラッシュ)appはまだ起動していないため接続自体が発生しない
MySQLが接続を受け付け可能になった時点(既にappが失敗して終了している場合、再起動ポリシー任せになる)ヘルスチェックがhealthyと判定された直後にappが起動を開始し、初回接続から成功する

この表は一般的に観測される挙動を整理したものであり、実際の失敗確率はホストマシンの性能やDockerイメージのキャッシュ状態(初回pull直後かどうか)によって変動します。CI環境やホストの負荷が高いタイミングほどMySQLの初期化に時間がかかりやすく、naive版でのレースが顕在化しやすくなります。ローカル開発で一度動いたからといって、CI環境でも同じように動くとは限らない点に注意してください。

ネットワーキング:サービスディスカバリと「localhost」の罠

デフォルトブリッジネットワークとDNSによる名前解決

docker-compose.ymlに明示的なnetworksを定義しない場合、Composeはプロジェクトごとに<プロジェクト名>_defaultという名前のブリッジネットワークを自動的に作成し、すべてのサービスをそこに接続します。このネットワーク上には127.0.0.11で待ち受ける組み込みDNSサーバーが存在し、サービス名がそのままコンテナのホスト名として解決されます。つまりappコンテナの中からは、mysqlredisminioという名前でそれぞれのサービスに到達できます。

environment:
  DATABASE_URL: mysql://devuser:devpass@mysql:3306/devdb # ホスト名は "mysql"
  REDIS_URL: redis://redis:6379 # ホスト名は "redis"

これはIPアドレスをハードコードする必要がないという利点に加え、コンテナが再作成されてIPアドレスが変わってもサービス名による名前解決は影響を受けないという実務上の利点があります。

よくある間違い:コンテナ内の「localhost」は自分自身を指す

最も頻繁に発生するハマりどころは、接続先ホストにlocalhost(または127.0.0.1)を指定してしまうことです。

# NG例
environment:
  DATABASE_URL: mysql://devuser:devpass@localhost:3306/devdb

Dockerのコンテナはそれぞれ独立したネットワーク名前空間を持っています。appコンテナ内でlocalhostと書いた場合、それは**appコンテナ自身のループバックインターフェース**を指し、mysqlコンテナのネットワーク名前空間には一切到達しません。appコンテナの3306番ポートには(そのコンテナ自身がリッスンしていない限り)何もいないため、接続はConnection refusedで失敗します。

紛らわしいのは、ホストマシンからlocalhost:3306にアクセスした場合はこれが機能してしまう点です。ports: ["3306:3306"]のようにポートを公開していると、Docker(docker-proxyやiptablesのDNAT)がホストのlocalhost:3306宛の通信をmysqlコンテナの3306番ポートへ転送するためです。つまり「ホストのlocalhostは効くが、コンテナ内部のlocalhostは効かない」という非対称性があり、動作確認をホストマシンのCLIツール(mysql -h 127.0.0.1など)で行っていると、アプリケーションコンテナに同じ接続文字列を設定したときに初めて問題が表面化します。

原則: コンテナ間通信の接続先ホストには常にサービス名を使い、localhostはコンテナ自身のプロセス間通信(同一コンテナ内の別プロセス間)にのみ使うようにします。

名前解決の確認方法

疑わしい場合は、稼働中のコンテナの中から直接名前解決を確認できます。

docker compose exec app getent hosts mysql
# 例: 172.19.0.3      mysql

docker compose exec app ping -c 1 redis

getent hosts <サービス名>でIPアドレスが返ってくれば、DNSによる名前解決自体は機能しています。それでも接続できない場合は、対象サービスがそのポートで実際にLISTENしているか(healthcheckhealthyになっているか)を疑うべきです。

環境変数の管理

.env ファイルを docker-compose.yml と同じディレクトリに置くと、自動的に読み込まれます。

# .env
MYSQL_ROOT_PASSWORD=my_secure_password
MYSQL_DATABASE=myapp
MYSQL_USER=appuser
MYSQL_PASSWORD=app_secure_password
MINIO_ROOT_USER=minio_admin
MINIO_ROOT_PASSWORD=minio_secure_password

.env ファイルは .gitignore に追加し、バージョン管理には .env.example(値なし)を含めてください。

開発体験の改善:Docker Compose Watch(2024〜2025年の変更)

Docker Composeは2023年9月リリースのv2.22でdocker compose watchコマンドとdevelop.watch設定を導入し、2024〜2025年にかけて本番投入可能な機能として定着しました。ソースコードの変更を検知して、コンテナを再作成せずにファイルを直接同期したり、必要な場合のみイメージを再ビルドしたりできます。

services:
  app:
    build: .
    develop:
      watch:
        - action: sync # 変更を検知したらファイルをそのままコンテナへ同期
          path: ./src
          target: /app/src
        - action: rebuild # 依存関係ファイルの変更時はイメージを再ビルド
          path: ./package.json
        - action: sync+restart # 設定ファイル変更時は同期してプロセスを再起動
          path: ./config
          target: /app/config
docker compose watch

action: syncはホスト側の変更をコンテナに直接コピーするだけなので、Bind Mountのように常時ファイルシステムを共有し続けるのと異なり、変更のたびに明示的な同期イベントとして扱われます。action: rebuildpackage.jsonのような、コンテナの再ビルドが必須なファイルの変更に使い、action: sync+restartは設定ファイルの変更後にプロセスの再起動だけで済むケースに向いています。2025年9月には、docker compose watch起動時に既存の全ファイルを即座に同期するinitial_syncが追加され、監視開始前の状態差分によるすれ違いが起きにくくなりました( Use Compose Watch )。

Tips

プロファイルによるサービスの選択的起動

すべてのサービスが常に必要とは限りません。プロファイルを使えば選択的に起動できます。

services:
  minio:
    profiles: ["storage"]
    image: minio/minio:latest
    # ...
docker compose up -d                    # mysql, redisのみ
docker compose --profile storage up -d  # minioも含む

ボリュームのクリーンアップ

不要なボリュームが溜まると、ディスクを圧迫します。

docker volume ls                  # ボリューム一覧
docker volume prune               # 未使用ボリュームを削除
docker system df                  # Docker全体のディスク使用量

関連記事

参考文献