Docker Compose:マルチコンテナアプリを管理する、もう混乱しない方法

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

スタンドアップ前にすべてが壊れた日

想像してみてほしい:チームに新しい開発者が加わったばかり。リポジトリをクローンして、READMEの手順に従ったのに、30分後もポートの競合、環境変数の不足、正しい順序で起動しないデータベースと格闘し続けている。スタンドアップまであと5分。どのチームもいつかはこの状況に直面する。

これは約2年前の私たちの話だ。Node.jsのAPI、PostgreSQLデータベース、Redisキャッシュ、Nginxリバースプロキシ——4つのコンテナが、誰も触りたがらないシェルスクリプトに詰め込まれた大量の個別のdocker runコマンドで管理されていた。

コンテナ自体は問題ではなかった。問題はオーケストレーションだった。正しい順序で起動させ、正しいネットワーク上で互いに通信させ、ローカル開発環境とステージング環境の間で一貫性を保つ——そこですべてが崩れ落ちた。

個別のdocker runコマンドが機能しなくなる理由

コンテナを1つずつ管理すると、毎回同じ問題を付け焼き刃で解決することになる:

  • 起動順序:データベースがまだ準備できていないためAPIコンテナがクラッシュする。
  • ネットワーク:手動でネットワークを作成して接続しない限り、コンテナ同士が見つけられない。
  • 環境の乖離:開発環境とステージング環境でポートや認証情報が異なり、誰もそれを記録していない。
  • 削除:クリーンアップはコンテナID、ボリューム、ネットワークを手動で探し出すことを意味する。

bashスクリプトを書くこともできる。そうしているチームもある。最初の1週間はきれいに見える——しかし誰かがサービスを追加すると、突然ネットワークがすでに存在するかどうかを確認する条件分岐、部分的な失敗のエラーハンドリング、誰も覚えていないフラグが増えていく。スクリプトは腐る。

docker runは単一のコンテナ向けに設計されている。マルチコンテナアプリには別のツールが必要だ。

Docker Composeの本質(そして本質でないもの)

Docker Composeを使えば、マルチコンテナアプリケーション全体を単一のYAMLファイルに定義し、シンプルなコマンドですべてを管理できる。ネットワーク、起動順序、ボリュームマウント、環境変数——すべて宣言的に、1か所にまとめられる。

Kubernetesの代替ではない。オートスケーリングが必要な複数ノードにまたがる数百のサービスを運用するなら、もっと重厚なツールが必要だ。しかしローカル開発、小規模な本番デプロイ、CI環境では、Composeはちょうど適切な複雑さのレベルに収まる。

Docker Desktop v3.3以降、Compose V2はDockerに直接バンドルされている。docker compose(ハイフンなし)として呼び出せるが、別途インストールしていれば従来のdocker-compose CLIも引き続き動作する。

実際のcompose.yamlを構築する

典型的なWebアプリスタックで私たちが使っている構成を紹介しよう:APIサービス、PostgreSQLデータベース、Redisキャッシュだ。これは本番環境で実際に動かしているものに近く、例示のために認証情報を簡略化している。

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/appdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

このファイルには、盲目的にコピーする前に理解しておく価値のある3つの設計判断がある。

depends_onhealthcheckの組み合わせ

depends_on: dbだけでは、コンテナが起動するのを待つだけで、PostgreSQLが実際に接続を受け付ける準備ができるまでは待たない。healthcheckを追加してcondition: service_healthyを設定すると、HealthプローブをPostgresが通過するまでAPIコンテナは起動しない。起動時の競合状態で何時間も無駄にしてきた。この組み合わせがそれを解消する。

名前付きボリュームとバインドマウントの違い

名前付きボリューム(pgdataredisdata)はDockerが管理し、コンテナの再起動をまたいで永続する。データベースは常に名前付きボリュームを使うべきだ。バインドマウント——./data:/var/lib/postgresql/dataのようなローカルディレクトリのマッピング——は開発中にファイルへの直接アクセスが必要な場合に便利だが、Linux上でパーミッションの問題が発生しやすく、本番データベースには向いていない。

DNSによるサービスディスカバリー

APIのDATABASE_URLではdbをホスト名として使っている。Composeはプロジェクト用のネットワークを自動的に作成し、各サービスをその名前で登録する——つまりコンテナはサービス名で互いを見つけられ、手動のネットワーク設定は不要だ。

実際に使う日常のコマンド

# デタッチモードですべてを起動
docker compose up -d

# イメージを再ビルドして起動(コード変更後)
docker compose up -d --build

# 実行中のサービスを確認
docker compose ps

# 特定サービスのログをリアルタイム表示
docker compose logs -f api

# サービスコンテナ内でコマンドを実行
docker compose exec db psql -U app -d appdb

# すべて停止(ボリュームは保持)
docker compose down

# 完全削除:コンテナ、ネットワーク、ボリュームをすべて削除
docker compose down -v

最後のdown -vはデータベースのデータを削除する。開発中に完全にきれいな状態から始めたいときに便利だが、間違った環境で考えなしに実行すると危険だ。

環境別設定の管理

環境の違いは、開発・ステージング・本番間の摩擦のよくある原因の一つだ。最もすっきりしたアプローチは、ベースのcompose.yamlとオーバーライドファイルを組み合わせることだ:

# 開発環境(compose.yamlとcompose.override.yamlを自動的に使用)
docker compose up -d

# ステージングまたは本番環境(明示的なファイル選択)
docker compose -f compose.yaml -f compose.prod.yaml up -d

開発用のcompose.override.yamlでは、ホットリロードを有効にしてデバッグポートを公開することができる:

services:
  api:
    volumes:
      - ./api/src:/app/src
    environment:
      NODE_ENV: development
      DEBUG: "*"

本番用のオーバーライドはセキュアに保つ——ボリュームマウントなし、デバッグフラグなし、リソース制限を設定する。

シークレットは専用ファイルに記述する。compose.yamlと同じディレクトリに.envを置けば、Composeが自動的に読み込む:

# .env
POSTGRES_PASSWORD=actualstrongpassword
API_SECRET_KEY=yoursecretkey
# compose.yaml
services:
  db:
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

.env.exampleをプレースホルダー値でリポジトリにコミットし、.env.gitignoreに追加する。これで「認証情報はどこにある?」という問題をバージョン管理に漏らすことなく解決できる。

本番環境で実際に動かしているもの

この構成を3つのプロジェクトで18か月間本番環境で運用してきた。デプロイパイプラインはシンプルだ:mainにプッシュし、サーバーにSSH接続して、最新イメージをプルし、docker compose up -dを実行する。Kubernetesのオーバーヘッドもなく、クラウドネイティブの複雑さもない。

エンジニア2〜5人、月間リクエスト数が数十万件のトラフィック——この構成で十分対応できる。本番環境での安定性に差をつける3つの追加設定がある:

  • restart: unless-stoppedをすべてのサービスに——クラッシュやサーバー再起動後にコンテナが自動的に再起動する。
  • deploy.resources.limitsによるリソース制限——暴走プロセスが他のコンテナのリソースを奪うのを防ぐ。
  • ログドライバー——Dockerのデフォルトのjson-fileドライバーはサイズ上限なしでログを保存する。max-sizemax-fileを設定しないと、いずれディスクがいっぱいになる
services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    deploy:
      resources:
        limits:
          memory: 512m
          cpus: '0.5'

結論:Composeが適切な選択肢となる場面

複数のプロジェクトで1年以上運用してきた経験から、Composeが真価を発揮する場面を紹介しよう:

  • ローカル開発環境——新しいチームメンバーを数時間ではなく数分で立ち上げられる。
  • 単一ホストまたは小規模クラスター上の小〜中規模の本番デプロイ
  • CIパイプライン——統合テスト用にフルスタックを起動し、テスト後にきれいに削除する。
  • 完全なオーケストレーションのコストをかけずに本番環境をミラーリングする必要があるステージング環境。

Composeを卒業するときも、そのスキルはKubernetesに直接活かせる。サービス、ネットワーク、ボリューム、ヘルスチェックというコンセプトは同じだ。Composeは複雑さのコストを払わずにそれらを学べる場所だ。

セットアップに30分かかっていた新しい開発者?compose.yamlをリポジトリに追加してから、オンボーディングは5分以内に短縮された。コマンド1つで、すべてが起動して作業準備完了。それが本当の価値だ。

Share: