Docker Compose本番環境構築ガイド - プロダクションレディな設定

advanced | 90分 で読める | 2025.12.02

Docker Composeは開発環境だけでなく、小〜中規模の本番環境でも十分に活用できます。本記事では、開発環境との違いを明確にしながら、セキュリティ、パフォーマンス、運用性を考慮したプロダクションレディな設定を構築します。

この記事で学ぶこと

  1. 開発環境と本番環境の設定分離
  2. マルチステージビルドによる最適化
  3. シークレット管理とセキュリティ
  4. ヘルスチェックと自動復旧
  5. ログ管理とモニタリング
  6. バックアップと復旧戦略

プロジェクト構成

project/
├── docker/
│   ├── app/
│   │   └── Dockerfile
│   ├── nginx/
│   │   ├── Dockerfile
│   │   └── nginx.conf
│   └── postgres/
│       └── init.sql
├── docker-compose.yml           # 基本設定
├── docker-compose.override.yml  # 開発環境(自動読み込み)
├── docker-compose.prod.yml      # 本番環境
├── docker-compose.staging.yml   # ステージング環境
├── .env.example                 # 環境変数テンプレート
└── src/
    └── ...

開発環境と本番環境の分離

基本設定(docker-compose.yml)

# docker-compose.yml - 共通設定
services:
  app:
    build:
      context: .
      dockerfile: docker/app/Dockerfile
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - backend

  db:
    image: postgres:16-alpine
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    networks:
      - backend

networks:
  backend:
    driver: bridge

volumes:
  postgres_data:

開発環境設定(docker-compose.override.yml)

# docker-compose.override.yml - 開発環境
# docker compose up で自動的に読み込まれる
services:
  app:
    build:
      target: development
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
      - DEBUG=app:*
    ports:
      - "3000:3000"
      - "9229:9229"  # デバッガ用
    command: npm run dev

  db:
    environment:
      - POSTGRES_USER=dev
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=app_dev
    ports:
      - "5432:5432"

  redis:
    ports:
      - "6379:6379"

本番環境設定(docker-compose.prod.yml)

# docker-compose.prod.yml - 本番環境
services:
  app:
    build:
      target: production
    environment:
      - NODE_ENV=production
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 2G
        reservations:
          cpus: '1'
          memory: 1G
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
    logging:
      driver: "json-file"
      options:
        max-size: "100m"
        max-file: "5"
    secrets:
      - db_password
      - app_secret

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./docker/nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - app
    networks:
      - backend
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 256M

  db:
    environment:
      - POSTGRES_USER_FILE=/run/secrets/db_user
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
      - POSTGRES_DB=app_production
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    deploy:
      resources:
        limits:
          cpus: '2'
          memory: 4G
    secrets:
      - db_user
      - db_password

  redis:
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M

secrets:
  db_user:
    file: ./secrets/db_user.txt
  db_password:
    file: ./secrets/db_password.txt
  app_secret:
    file: ./secrets/app_secret.txt

volumes:
  postgres_data:
  redis_data:

マルチステージビルド

Node.js アプリケーション

# docker/app/Dockerfile
# ========== Base Stage ==========
FROM node:20-alpine AS base
WORKDIR /app
RUN apk add --no-cache libc6-compat

# ========== Dependencies Stage ==========
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --only=production && \
    cp -R node_modules /tmp/prod_modules && \
    npm ci

# ========== Development Stage ==========
FROM base AS development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=development
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]

# ========== Builder Stage ==========
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ========== Production Stage ==========
FROM base AS production

# 非rootユーザーを作成
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

# 本番用の依存関係のみ
COPY --from=deps /tmp/prod_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./

# 所有権を変更
RUN chown -R nextjs:nodejs /app
USER nextjs

ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

# ヘルスチェック
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "dist/main.js"]

イメージサイズの比較

マルチステージビルドの効果

ステージサイズ削減率
開発用(全依存関係)1.2 GB-
ビルドステージ1.5 GB-
本番用(最終)180 MB85%

シークレット管理

Docker Secrets(推奨)

# シークレットファイルの作成
mkdir -p secrets
echo "app_user" > secrets/db_user.txt
echo "$(openssl rand -base64 32)" > secrets/db_password.txt
echo "$(openssl rand -base64 64)" > secrets/app_secret.txt

# ファイル権限の設定
chmod 600 secrets/*
# docker-compose.prod.yml
services:
  app:
    secrets:
      - db_password
      - app_secret
    environment:
      # シークレットはファイルから読み込み
      - DATABASE_PASSWORD_FILE=/run/secrets/db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
  app_secret:
    file: ./secrets/app_secret.txt
// アプリケーション側でのシークレット読み込み
import { readFileSync } from 'fs';

function getSecret(name: string): string {
  const filePath = `/run/secrets/${name}`;
  try {
    return readFileSync(filePath, 'utf8').trim();
  } catch {
    // 開発環境では環境変数から取得
    return process.env[name.toUpperCase()] || '';
  }
}

const dbPassword = getSecret('db_password');

環境変数の安全な管理

# .env.example(テンプレート)
NODE_ENV=production
DB_HOST=db
DB_PORT=5432
DB_NAME=app_production
# シークレットはファイルから読み込むため記載しない

# .env.prod(本番用 - .gitignoreに追加)
NODE_ENV=production
DB_HOST=db
DB_PORT=5432
DB_NAME=app_production
# docker-compose.prod.yml
services:
  app:
    env_file:
      - .env.prod

セキュリティ設定

Nginx リバースプロキシ

# docker/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
    use epoll;
    multi_accept on;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    # ログフォーマット
    log_format main_json escape=json '{'
        '"time": "$time_iso8601",'
        '"remote_addr": "$remote_addr",'
        '"method": "$request_method",'
        '"uri": "$request_uri",'
        '"status": $status,'
        '"body_bytes_sent": $body_bytes_sent,'
        '"request_time": $request_time,'
        '"upstream_response_time": "$upstream_response_time",'
        '"user_agent": "$http_user_agent"'
    '}';

    access_log /var/log/nginx/access.log main_json;

    # パフォーマンス設定
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 65;
    types_hash_max_size 2048;

    # セキュリティヘッダー
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;

    # サーバー情報を隠す
    server_tokens off;

    # Gzip圧縮
    gzip on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml application/json application/javascript application/xml;

    # レート制限
    limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
    limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;

    upstream app {
        server app:3000;
        keepalive 32;
    }

    server {
        listen 80;
        server_name _;
        return 301 https://$host$request_uri;
    }

    server {
        listen 443 ssl http2;
        server_name example.com;

        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
        ssl_prefer_server_ciphers off;
        ssl_session_cache shared:SSL:10m;
        ssl_session_timeout 1d;

        # HSTS
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

        location / {
            proxy_pass http://app;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_cache_bypass $http_upgrade;
        }

        location /api/ {
            limit_req zone=api burst=20 nodelay;
            proxy_pass http://app;
            # ... 他の設定
        }

        location /api/auth/login {
            limit_req zone=login burst=5 nodelay;
            proxy_pass http://app;
        }

        # 静的ファイル
        location /static/ {
            alias /app/static/;
            expires 30d;
            add_header Cache-Control "public, immutable";
        }

        # ヘルスチェック
        location /health {
            access_log off;
            proxy_pass http://app;
        }
    }
}

コンテナのセキュリティ強化

# docker-compose.prod.yml
services:
  app:
    # 読み取り専用ファイルシステム
    read_only: true
    tmpfs:
      - /tmp
      - /app/tmp

    # 権限の制限
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # 必要な権限のみ追加

    # セキュリティオプション
    security_opt:
      - no-new-privileges:true

    # 非rootユーザー(Dockerfileで設定済み)
    user: "1001:1001"

ヘルスチェックと自動復旧

ヘルスチェックの設定

# docker-compose.prod.yml
services:
  app:
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  db:
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d app_production"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  redis:
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

アプリケーション側のヘルスチェックエンドポイント

// src/health.ts
import { Router } from 'express';
import { Pool } from 'pg';
import Redis from 'ioredis';

const router = Router();
const pool = new Pool();
const redis = new Redis();

interface HealthStatus {
  status: 'healthy' | 'unhealthy';
  timestamp: string;
  checks: {
    database: { status: string; latency?: number };
    redis: { status: string; latency?: number };
    memory: { used: number; total: number };
  };
}

router.get('/health', async (req, res) => {
  const health: HealthStatus = {
    status: 'healthy',
    timestamp: new Date().toISOString(),
    checks: {
      database: { status: 'unknown' },
      redis: { status: 'unknown' },
      memory: {
        used: process.memoryUsage().heapUsed,
        total: process.memoryUsage().heapTotal,
      },
    },
  };

  try {
    // データベースチェック
    const dbStart = Date.now();
    await pool.query('SELECT 1');
    health.checks.database = {
      status: 'healthy',
      latency: Date.now() - dbStart,
    };
  } catch {
    health.checks.database = { status: 'unhealthy' };
    health.status = 'unhealthy';
  }

  try {
    // Redisチェック
    const redisStart = Date.now();
    await redis.ping();
    health.checks.redis = {
      status: 'healthy',
      latency: Date.now() - redisStart,
    };
  } catch {
    health.checks.redis = { status: 'unhealthy' };
    health.status = 'unhealthy';
  }

  const statusCode = health.status === 'healthy' ? 200 : 503;
  res.status(statusCode).json(health);
});

// Liveness probe(生存確認)
router.get('/health/live', (req, res) => {
  res.status(200).json({ status: 'alive' });
});

// Readiness probe(準備完了確認)
router.get('/health/ready', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    await redis.ping();
    res.status(200).json({ status: 'ready' });
  } catch {
    res.status(503).json({ status: 'not ready' });
  }
});

export default router;

ログ管理

構造化ログ

// src/logger.ts
import pino from 'pino';

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label }),
  },
  timestamp: () => `,"timestamp":"${new Date().toISOString()}"`,
  base: {
    service: 'app',
    version: process.env.APP_VERSION || '1.0.0',
  },
});

export default logger;

Fluentd/Loki との統合

# docker-compose.prod.yml
services:
  app:
    logging:
      driver: "fluentd"
      options:
        fluentd-address: "localhost:24224"
        tag: "app.{{.Name}}"
        fluentd-async-connect: "true"

  fluentd:
    image: fluent/fluentd:v1.16-debian
    volumes:
      - ./docker/fluentd/fluent.conf:/fluentd/etc/fluent.conf:ro
      - fluentd_logs:/fluentd/log
    ports:
      - "24224:24224"
    networks:
      - backend

volumes:
  fluentd_logs:
# docker/fluentd/fluent.conf
<source>
  @type forward
  port 24224
  bind 0.0.0.0
</source>

<filter app.**>
  @type parser
  key_name log
  reserve_data true
  <parse>
    @type json
  </parse>
</filter>

<match app.**>
  @type elasticsearch
  host elasticsearch
  port 9200
  logstash_format true
  logstash_prefix app
  <buffer>
    @type file
    path /fluentd/log/buffer
    flush_interval 5s
  </buffer>
</match>

バックアップと復旧

データベースバックアップスクリプト

#!/bin/bash
# scripts/backup-db.sh

set -euo pipefail

BACKUP_DIR="/backups/postgres"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/backup_${DATE}.sql.gz"
RETENTION_DAYS=7

# バックアップディレクトリ作成
mkdir -p "${BACKUP_DIR}"

# バックアップ実行
docker compose -f docker-compose.prod.yml exec -T db \
  pg_dump -U postgres -d app_production | gzip > "${BACKUP_FILE}"

# 古いバックアップの削除
find "${BACKUP_DIR}" -name "backup_*.sql.gz" -mtime +${RETENTION_DAYS} -delete

# バックアップの検証
if gzip -t "${BACKUP_FILE}"; then
  echo "Backup successful: ${BACKUP_FILE}"
  echo "Size: $(du -h ${BACKUP_FILE} | cut -f1)"
else
  echo "Backup verification failed!"
  exit 1
fi

復元スクリプト

#!/bin/bash
# scripts/restore-db.sh

set -euo pipefail

BACKUP_FILE=$1

if [ -z "${BACKUP_FILE}" ]; then
  echo "Usage: $0 <backup_file>"
  exit 1
fi

echo "Restoring from: ${BACKUP_FILE}"
echo "WARNING: This will overwrite the current database!"
read -p "Continue? (y/N): " confirm

if [ "${confirm}" != "y" ]; then
  echo "Aborted."
  exit 0
fi

# 復元実行
gunzip -c "${BACKUP_FILE}" | docker compose -f docker-compose.prod.yml exec -T db \
  psql -U postgres -d app_production

echo "Restore completed."

デプロイコマンド

デプロイスクリプト

#!/bin/bash
# scripts/deploy.sh

set -euo pipefail

echo "=== Starting deployment ==="

# 設定ファイルの検証
docker compose -f docker-compose.yml -f docker-compose.prod.yml config --quiet

# 最新イメージのビルド
echo "Building images..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml build --pull

# データベースバックアップ
echo "Creating database backup..."
./scripts/backup-db.sh

# ローリングアップデート
echo "Starting rolling update..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps app

# ヘルスチェック待機
echo "Waiting for health check..."
sleep 10

# ヘルスチェック確認
if curl -sf http://localhost/health > /dev/null; then
  echo "=== Deployment successful ==="
else
  echo "=== Health check failed! Rolling back... ==="
  docker compose -f docker-compose.yml -f docker-compose.prod.yml rollback
  exit 1
fi

# 未使用イメージの削除
docker image prune -f

echo "=== Deployment completed ==="

Makefileによる操作の標準化

# Makefile
.PHONY: dev prod build deploy logs backup restore clean

# 開発環境
dev:
	docker compose up -d

dev-logs:
	docker compose logs -f

# 本番環境
prod:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

prod-logs:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f

# ビルド
build:
	docker compose -f docker-compose.yml -f docker-compose.prod.yml build --no-cache

# デプロイ
deploy:
	./scripts/deploy.sh

# バックアップ
backup:
	./scripts/backup-db.sh

restore:
	./scripts/restore-db.sh $(FILE)

# クリーンアップ
clean:
	docker compose down -v --remove-orphans
	docker image prune -af
	docker volume prune -f

運用コマンド早見表

# 起動
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# 停止
docker compose -f docker-compose.yml -f docker-compose.prod.yml down

# ログ確認
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f app

# スケールアウト
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --scale app=3

# コンテナに入る
docker compose -f docker-compose.yml -f docker-compose.prod.yml exec app sh

# リソース使用状況
docker stats

# 設定の検証
docker compose -f docker-compose.yml -f docker-compose.prod.yml config

まとめ

Docker Composeを本番環境で使用する際のポイントをまとめます。

セキュリティ

  1. シークレット管理: Docker Secretsを使用
  2. 非rootユーザー: コンテナ内で特権を持たない
  3. 最小権限: 必要な権限のみ付与
  4. イメージ最適化: マルチステージビルドで攻撃面を削減

信頼性

  1. ヘルスチェック: 全サービスに設定
  2. 再起動ポリシー: 障害時の自動復旧
  3. リソース制限: OOMキラー対策
  4. バックアップ: 定期的な自動バックアップ

運用性

  1. ログ管理: 構造化ログと集約
  2. モニタリング: メトリクス収集
  3. デプロイ自動化: スクリプト化
  4. 設定分離: 環境ごとの設定ファイル

Docker Composeは、適切に設定すれば本番環境でも十分に活用できます。

参考リンク

← 一覧に戻る