Dockerで開発環境を統一する – コンテナ化の基礎から実践的なdocker-compose活用まで

Dockerで開発環境を統一する Docker
Dockerで開発環境を統一する
この記事は約26分で読めます。

こんにちは、JS2IIUです。
チーム開発において、「私の環境では動くのに、他の人の環境では動かない」という問題に遭遇したことはありませんか?この問題は、開発者のローカル環境の差異(OSのバージョン、インストールされているライブラリのバージョン、環境変数の設定など)によって引き起こされます。

特に機械学習プロジェクトでは、PyTorchやTensorFlowなどのライブラリが特定のCUDAバージョンに依存していたり、データ処理用のツールが複雑な依存関係を持っていたりするため、環境構築の難易度が高くなります。

Dockerは、この問題を根本から解決するコンテナ化技術です。本記事では、Dockerの基礎から実践的なdocker-composeの活用まで、機械学習エンジニアの視点で解説していきます。今回もよろしくお願いします。

1. Dockerの基本概念を理解する

1.1 コンテナとは何か

コンテナは、アプリケーションとその実行に必要なすべての依存関係を1つのパッケージにまとめたものです。仮想マシン(VM)と似ていますが、より軽量で高速に起動できます。

仮想マシンとコンテナの違い:

  • 仮想マシン:各VMが完全なOSを含むため、起動に時間がかかり、リソースを多く消費します
  • コンテナ:ホストOSのカーネルを共有するため、軽量で高速です。秒単位で起動できます

1.2 Dockerの3つの基本要素

  1. Dockerイメージ:アプリケーションの設計図。読み取り専用のテンプレート
  2. Dockerコンテナ:イメージから作成された実行可能なインスタンス
  3. Dockerボリューム:コンテナが削除されてもデータを永続化する仕組み

2. 実践:PythonアプリケーションをDockerコンテナ化する

2.1 シンプルな機械学習APIのコンテナ化

まず、scikit-learnを使ったシンプルな予測APIをコンテナ化してみましょう。

プロジェクト構成:

Plaintext
ml-api/
├── app.py
├── requirements.txt
├── Dockerfile
└── model.pkl

app.py(簡単な予測API):

Python
from flask import Flask, request, jsonify
import pickle
import numpy as np

app = Flask(__name__)

# 事前に学習済みのモデルを読み込む
with open('model.pkl', 'rb') as f:
    model = pickle.load(f)

@app.route('/predict', methods=['POST'])
def predict():
    """
    入力データを受け取り、予測結果を返すエンドポイント
    """
    try:
        # JSON形式でデータを受け取る
        data = request.get_json()
        features = np.array(data['features']).reshape(1, -1)

        # 予測を実行
        prediction = model.predict(features)

        return jsonify({
            'prediction': prediction.tolist(),
            'status': 'success'
        })
    except Exception as e:
        return jsonify({
            'error': str(e),
            'status': 'error'
        }), 400

if __name__ == '__main__':
    # 本番環境では0.0.0.0でリッスンする
    app.run(host='0.0.0.0', port=5000, debug=False)

requirements.txt:

Plaintext
flask==2.3.0
scikit-learn==1.3.0
numpy==1.24.0

2.2 効率的なDockerfileの書き方

Dockerfileは、イメージをビルドするための設計図です。以下は、ベストプラクティスを反映したDockerfileです。

Dockerfile
# ベースイメージには軽量なPythonイメージを使用
FROM python:3.10-slim

# 作業ディレクトリを設定
WORKDIR /app

# 依存関係ファイルを先にコピー(レイヤーキャッシュを活用)
# requirements.txtが変更されない限り、この層は再利用される
COPY requirements.txt .

# 依存関係をインストール
# --no-cache-dirでキャッシュファイルを保存しないことでイメージサイズを削減
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションコードをコピー
# requirements.txtより後にコピーすることで、コード変更時の再ビルドを高速化
COPY . .

# コンテナが使用するポートを明示
EXPOSE 5000

# 非rootユーザーで実行(セキュリティのベストプラクティス)
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser

# コンテナ起動時のコマンド
CMD ["python", "app.py"]

重要なポイント:

  1. レイヤーキャッシュの活用:変更頻度の低いファイル(requirements.txt)を先にコピーすることで、ビルド時間を短縮できます
  2. イメージサイズの最適化-slimイメージの使用や--no-cache-dirオプションでサイズを削減
  3. セキュリティ:非rootユーザーでアプリケーションを実行

2.3 Dockerイメージのビルドと実行

Bash
# イメージをビルド
docker build -t ml-api:v1 .

# コンテナを起動
docker run -d -p 5000:5000 --name ml-api-container ml-api:v1

# APIをテスト
curl -X POST http://localhost:5000/predict \
  -H "Content-Type: application/json" \
  -d '{"features": [5.1, 3.5, 1.4, 0.2]}'

3. マルチステージビルドで本番環境を最適化

機械学習プロジェクトでは、開発時にJupyter Notebookやデバッグツールが必要ですが、本番環境では不要です。マルチステージビルドを使えば、開発ツールを含まない軽量な本番イメージを作成できます。

Dockerfile
# ステージ1: ビルド環境(開発ツールを含む)
FROM python:3.10 as builder

WORKDIR /app

COPY requirements.txt .
# ビルド時に必要なツールをインストール
RUN pip install --user --no-cache-dir -r requirements.txt

# ステージ2: 本番環境(最小限の構成)
FROM python:3.10-slim

WORKDIR /app

# ビルドステージからインストール済みのパッケージをコピー
COPY --from=builder /root/.local /root/.local

# アプリケーションコードをコピー
COPY app.py .
COPY model.pkl .

# パスを通す
ENV PATH=/root/.local/bin:$PATH

EXPOSE 5000

CMD ["python", "app.py"]

このアプローチにより、最終的なイメージサイズを大幅に削減できます(約1GB → 300MB程度)。

4. docker-composeで複数サービスを管理する

実際の機械学習プロジェクトでは、APIサーバー、データベース、キャッシュサーバーなど複数のサービスが連携します。docker-composeを使えば、これらを一括で管理できます。

4.1 機械学習プロジェクトの典型的な構成

以下は、FastAPI + PostgreSQL + Redisを使った構成例です。

プロジェクト構成:

Plaintext
ml-project/
├── api/
│   ├── Dockerfile
│   ├── main.py
│   └── requirements.txt
├── docker-compose.yml
├── .env
└── init.sql

docker-compose.yml:

YAML
version: '3.8'

services:
  # FastAPIアプリケーション
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    environment:
      # 環境変数をファイルから読み込む
      - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      - REDIS_URL=redis://redis:6379
    depends_on:
      - db
      - redis
    volumes:
      # 開発時にコード変更を即座に反映
      - ./api:/app
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload

  # PostgreSQLデータベース
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      # データの永続化
      - postgres_data:/var/lib/postgresql/data
      # 初期化SQLスクリプト
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"

  # Redisキャッシュ
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

# 名前付きボリュームの定義
volumes:
  postgres_data:
  redis_data:

.env ファイル(機密情報の管理):

Plaintext
POSTGRES_USER=mluser
POSTGRES_PASSWORD=secure_password_here
POSTGRES_DB=mldb

4.2 FastAPIアプリケーションの実装

api/main.py:

Python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import redis
import psycopg2
from psycopg2.extras import RealDictCursor
import json
import os

app = FastAPI(title="ML API with Cache")

# Redisクライアントの初期化
redis_client = redis.from_url(os.getenv('REDIS_URL'))

# データベース接続の取得
def get_db_connection():
    """
    PostgreSQLへの接続を取得する
    """
    return psycopg2.connect(
        os.getenv('DATABASE_URL'),
        cursor_factory=RealDictCursor
    )

class PredictionRequest(BaseModel):
    """
    予測リクエストのスキーマ
    """
    user_id: int
    features: list[float]

@app.post("/predict")
async def predict(request: PredictionRequest):
    """
    予測を実行し、結果をキャッシュとDBに保存
    """
    cache_key = f"prediction:{request.user_id}"

    # キャッシュを確認
    cached_result = redis_client.get(cache_key)
    if cached_result:
        return {
            "prediction": json.loads(cached_result),
            "source": "cache"
        }

    # 予測を実行(ここでは簡略化)
    prediction = sum(request.features) / len(request.features)

    # 結果をキャッシュに保存(有効期限:1時間)
    redis_client.setex(
        cache_key,
        3600,
        json.dumps(prediction)
    )

    # 結果をデータベースに保存
    try:
        with get_db_connection() as conn:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    INSERT INTO predictions (user_id, features, result)
                    VALUES (%s, %s, %s)
                    """,
                    (request.user_id, request.features, prediction)
                )
                conn.commit()
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

    return {
        "prediction": prediction,
        "source": "computed"
    }

@app.get("/health")
async def health_check():
    """
    ヘルスチェックエンドポイント
    """
    # Redisとデータベースの接続を確認
    try:
        redis_client.ping()
        with get_db_connection() as conn:
            with conn.cursor() as cur:
                cur.execute("SELECT 1")
        return {"status": "healthy"}
    except Exception as e:
        return {"status": "unhealthy", "error": str(e)}

4.3 docker-composeの基本操作

Bash
# すべてのサービスを起動
docker-compose up -d

# ログを確認
docker-compose logs -f api

# 特定のサービスを再起動
docker-compose restart api

# すべてのサービスを停止・削除
docker-compose down

# ボリュームも含めて完全に削除
docker-compose down -v

5. 開発環境と本番環境の切り替え

docker-composeでは、環境ごとに異なる設定を適用できます。

docker-compose.override.yml(開発環境用):

YAML
version: '3.8'

services:
  api:
    volumes:
      # ソースコードをマウント(ホットリロード)
      - ./api:/app
    environment:
      - DEBUG=1
    command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload

docker-compose.prod.yml(本番環境用):

YAML
version: '3.8'

services:
  api:
    # 本番環境ではコードをマウントしない
    volumes: []
    environment:
      - DEBUG=0
    # 複数のワーカーで起動
    command: gunicorn main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000

使い分け:

Bash
# 開発環境(自動的にoverride.ymlが適用される)
docker-compose up -d

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

6. チーム開発での運用Tips

6.1 Makefileでコマンドを簡略化

複雑なdockerコマンドをMakefileにまとめると、チームメンバーが使いやすくなります。

Makefile:

Makefile
.PHONY: build up down logs test clean

build:
    docker-compose build

up:
    docker-compose up -d

down:
    docker-compose down

logs:
    docker-compose logs -f api

test:
    docker-compose exec api pytest tests/

clean:
    docker-compose down -v
    docker system prune -f

使い方:

Bash
make build  # イメージをビルド
make up     # サービスを起動
make logs   # ログを表示

6.2 よくあるトラブルシューティング

問題1:ポートがすでに使用されている

Bash
# 使用中のポートを確認
lsof -i :5000

# docker-compose.ymlでポート番号を変更
ports:
  - "5001:5000"

問題2:ボリュームのパーミッションエラー

Dockerfile
# Dockerfile内でユーザーIDを指定
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN groupadd -g ${GROUP_ID} appuser && \
    useradd -u ${USER_ID} -g appuser -m appuser

問題3:イメージのビルドキャッシュが原因で古いコードが実行される

Bash
# キャッシュを使わずにビルド
docker-compose build --no-cache

まとめ

本記事では、Dockerとdocker-composeを使った開発環境の統一について解説しました。重要なポイントをまとめます:

  1. Dockerの基本:コンテナは軽量で高速、環境の差異を解消できる
  2. Dockerfileのベストプラクティス:レイヤーキャッシュの活用、マルチステージビルド、セキュリティ対策
  3. docker-composeの活用:複数サービスの一括管理、環境変数管理、ボリュームによるデータ永続化
  4. 環境の切り替え:開発環境と本番環境で異なる設定を適用

機械学習プロジェクトでは、依存関係が複雑になりがちですが、Dockerを活用することで「私の環境では動くのに」問題から解放されます。チーム全体で同じ環境を共有できるため、デバッグやトラブルシューティングの時間を大幅に削減できます。

次のステップとして、KubernetesやDocker Swarmを使ったコンテナオーケストレーション、CI/CDパイプラインへの統合などに挑戦してみてください。

最後まで読んでいただきありがとうございました。

コメント

タイトルとURLをコピーしました