0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Docker Compose で作る MLflow + RustFS + Nginx の ML 実験管理基盤

0
Posted at

はじめに

  • 対象: ML プロジェクトで実験管理・データセット管理を自前で構築したい人
  • 前提: Docker Compose の基本操作ができる
  • ゴール: MLflow + RustFS + Nginx を Docker Compose で立ち上げ、DVC と連携できる状態にする

機械学習プロジェクトで「実験の再現性が取れない」「データセットのバージョンがわからなくなる」問題に対応するためチーム共通のデータセットおよび実験の共有基盤を構築しました。

使用する OSS は以下の 4 つです。

コンポーネント 役割
MLflow 実験トラッキング・artifact 管理
PostgreSQL 15 MLflow のメタデータ永続化
RustFS S3 互換オブジェクトストレージ(artifact / データセット)
Nginx リバースプロキシ・Basic 認証ゲート

構築しながらいくつかハマりポイントがあったので、設計の全体像と一緒に記録します。

環境: WSL2 (Ubuntu 22.04) + Docker Engine 26.x + Docker Compose v2.x

AWS は一切使いません

本構成には amazon/aws-clidvc[s3]AWS_ACCESS_KEY_ID といった "AWS" という名前のツール・変数が登場しますが、AWS(クラウド)には一切接続しません

これらはすべて S3 互換 API のクライアント実装です。Amazon S3 が公開している REST API 仕様は事実上の標準になっており、RustFS・MinIO などのオンプレミスストレージもこの仕様を実装しています。--endpoint-url http://rustfs:9000 のように接続先を差し替えるだけで、同じクライアントがローカルストレージに対して動作します。

ツール/変数 実態
amazon/aws-cli S3 互換 API クライアント。--endpoint-url で任意のエンドポイントに向けられる
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY S3 API の認証仕様で定められた変数名。AWS 固有ではない
s3://bucket-name S3 互換 API の URL スキーム。ローカルストレージでも使用される
dvc[s3] 内部で boto3 を使う S3 互換クライアント。endpointurl で RustFS に向ける

システム全体像

ポイント: proxied artifact access

MLflow には artifact へのアクセス方式が 2 種類あります。

  • direct access: クライアントが S3 に直接接続する
  • proxied access (--serve-artifacts): クライアントは Tracking Server 経由で artifact を送受信し、S3 への直接接続は不要

今回は proxied access を採用しました。クライアントが知る必要がある URL は MLFLOW_TRACKING_URI ひとつだけになり、S3 エンドポイントの設定を各端末に配る必要がありません。


ディレクトリ構成

dataenv/
├── docker-compose.yml
├── .env                    # 認証情報(.gitignore 済み)
├── .env.example            # テンプレート(Git 管理)
├── nginx/
│   └── conf.d/
│       ├── mlflow.conf
│       ├── rustfs-dvc.conf
│       └── rustfs-admin.conf
└── scripts/
    ├── smoke-test.sh       # 全 8 ステップの疎通確認
    └── init-rustfs.sh      # バケット・IAM ユーザー初期化

docker-compose.yml

services:
  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro
      - ./nginx/.htpasswd-admin:/etc/nginx/.htpasswd-admin:ro
    depends_on:
      mlflow:
        condition: service_started
      rustfs:
        condition: service_healthy
    restart: unless-stopped

  mlflow:
    image: python:3.10-slim
    command: >
      bash -c "
      pip install --quiet 'mlflow[extras]' psycopg2-binary &&
      mlflow server
      --backend-store-uri postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/mlflow
      --artifacts-destination s3://mlflow-artifacts
      --serve-artifacts
      --host 0.0.0.0
      --port 5000
      --allowed-hosts '*'
      "
    environment:
      MLFLOW_S3_ENDPOINT_URL: http://rustfs:9000
      AWS_ACCESS_KEY_ID: ${RUSTFS_ROOT_USER}
      AWS_SECRET_ACCESS_KEY: ${RUSTFS_ROOT_PASSWORD}
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15
    environment:
      POSTGRES_DB: mlflow
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d mlflow"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped

  rustfs:
    image: rustfs/rustfs:latest
    environment:
      RUSTFS_ACCESS_KEY: ${RUSTFS_ROOT_USER}
      RUSTFS_SECRET_KEY: ${RUSTFS_ROOT_PASSWORD}
      RUSTFS_CONSOLE_ENABLE: "true"
    volumes:
      - rustfs_data:/data
    command: /data
    healthcheck:
      test: ["CMD-SHELL", "netstat -tuln 2>/dev/null | grep -q 9000 || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s
    restart: unless-stopped

volumes:
  postgres_data:
  rustfs_data:

.env テンプレート

# PostgreSQL
POSTGRES_USER=mlflow
POSTGRES_PASSWORD=your-pg-password

# RustFS 管理者(MLflow・DVC 共用の S3 認証情報)
RUSTFS_ROOT_USER=admin
RUSTFS_ROOT_PASSWORD=your-rustfs-password

MLflow・DVC の両方が root 認証情報を使います。IAM ユーザー分離を行わない理由は後述します。


なぜ Nginx(リバースプロキシ)を入れるのか

「Web サーバーを前に置くと便利だから」だけでは動機が曖昧なので整理しておきます。

1. 複数端末から接続させたい

MLflow Tracking Server は --host 0.0.0.0 で外部接続を受けないと複数端末から利用できません。一方 MLflow 2.x 以降はセキュリティミドルウェアが有効になっており、接続元の制御が必要です。入口を Nginx の 1 か所に絞ることで、内部サービスを守りながらチームでの共有を実現できます。

2. 「利用者に見せる URL」と「内部サービスの実体」を分離する

利用者には http://mlflow.example.localhttp://s3.example.local だけ見せ、裏で mlflow:5000rustfs:9000 に転送します。内部構成を変更しても利用者側の設定変更が不要になります。

3. RustFS を直接公開しない

RustFS のセキュリティガイドラインは S3 API ポートへの直接アクセスを制限し、リバースプロキシ経由での提供を推奨しています。DVC に使わせたいのは「エンドポイント」であって、ストレージノードそのものを露出する必要はありません。

4. 認証・アクセス制御・ログを入口でまとめる

Basic 認証・IP 制限・アクセスログ収集を Nginx 側で一元管理できます。サービスごとに個別実装するより運用コストが低く、将来的な TLS 終端や認証連携の追加も容易です。

5. 将来の構成変更に備える

MLflow や RustFS の裏側を変えても、利用者向けの入口(URL)をそのままにできます。スケールアップ時にも影響範囲を Nginx 設定の変更だけに閉じ込められます。


Nginx 設定

MLflow エンドポイント(mlflow.conf

server {
    listen 80;
    server_name mlflow.example.local;

    auth_basic "MLflow";
    auth_basic_user_file /etc/nginx/.htpasswd;

    client_max_body_size 5G;

    location / {
        proxy_pass http://mlflow:5000;
        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_read_timeout 300s;
    }

    # smoke-test 用に Basic 認証なしでヘルスチェックを通す
    location /health {
        auth_basic off;
        proxy_pass http://mlflow:5000/health;
    }
}

RustFS S3 API エンドポイント(rustfs-dvc.conf

server {
    listen 80;
    server_name s3.example.local;
    merge_slashes off;             # S3 オブジェクトキーのパスを変形させない

    client_max_body_size 5G;      # 大容量データセット転送に対応

    location / {
        proxy_pass http://rustfs:9000;
        proxy_set_header Host $host;           # S3 署名保護のため $proxy_host ではなく $host
        proxy_set_header X-Real-IP $remote_addr;
        proxy_pass_request_headers on;         # S3 署名(Authorization)をそのまま通す
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }
}

proxy_set_header Host $hostmerge_slashes off が S3 署名保護に関わる設定です。
詳細はハマりポイント 5・6 を参照してください。

RustFS Console(rustfs-admin.conf

server {
    listen 80;
    server_name s3-admin.example.local;

    # 管理端末のサブネットだけ許可(環境に合わせて変更)
    allow 192.168.1.0/24;
    deny all;

    auth_basic "Admin Only";
    auth_basic_user_file /etc/nginx/.htpasswd-admin;

    location / {
        proxy_pass http://rustfs:9001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        # Console は WebSocket を使うので Upgrade ヘッダーを渡す
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

ネットワーク設計

Docker 内部通信

全サービスは Docker Compose が自動生成する <project>_default ネットワークに接続します。コンテナ間はサービス名(mlflowrustfspostgres)で名前解決できます。

外部に公開するポートは Nginx の 80 番のみ。MLflow・RustFS・PostgreSQL へのポートは ports: に記載しないことで外部から直接アクセスできない構成にしています。

/etc/hosts の設定(クライアント端末)

127.0.0.1 mlflow.example.local s3.example.local s3-admin.example.local

ローカル DNS サーバーがある場合はそこに登録、ない場合は各端末の /etc/hosts に追記します。


セットアップ手順

1. Basic 認証ファイルの生成

# 一般ユーザー向け(MLflow UI 用)
htpasswd -cB nginx/.htpasswd <username>

# 管理者向け(RustFS Console 用)
htpasswd -cB nginx/.htpasswd-admin <admin-username>

2. 全サービス起動

cp .env.example .env
# .env を編集して認証情報を設定

docker compose up -d

3. RustFS の初期化(初回のみ)

コンテナ起動直後はバケットが存在しません。init-rustfs.sh でバケットを作成します。

bash scripts/init-rustfs.sh

内部では amazon/aws-cli(Apache 2.0)を docker run で使い、バケット作成のみを行います。IAM ユーザー分離は行わず、全サービスが root 認証情報を共用します(後述の技術選定の注意点参照)。

4. 疎通確認

bash scripts/smoke-test.sh

全 8 ステップ PASS で構築完了です。


DVC クライアントの設定

利用者端末側では以下の設定を行います。

# .dvc/config(Git 管理)
[core]
    remote = myremote

['remote "myremote"']
    url = s3://dvc-remote
    endpointurl = http://s3.example.local
# 認証情報はローカル設定(Git 管理外)
dvc remote modify --local myremote access_key_id <RUSTFS_ROOT_USER>
dvc remote modify --local myremote secret_access_key <RUSTFS_ROOT_PASSWORD>

ハマったポイント 4 選

1. RustFS のヘルスエンドポイントが 403 を返す

状況: smoke-test のステップ 2 が失敗し続ける。

# 失敗したチェック
curl -sf http://rustfs:9000/minio/health/live  # → exit 1

原因: MinIO は /minio/health/live に認証なしで 200 OK を返しますが、RustFS は 403 Forbidden を返します。curl -f は 4xx / 5xx で失敗扱いにするため、サーバーが正常起動していてもテストが落ちていました。

修正: HTTP ステータスコードを取得して 1xx4xx を「サーバーが応答している=正常」と判定するよう変更しました。5xx や接続失敗(コード 000)が実際の障害を示します。

# 修正後
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 \
  http://rustfs:9000/minio/health/live 2>/dev/null || true)

if [[ "${code}" =~ ^[1234][0-9]{2}$ ]]; then
  echo "healthy (HTTP ${code})"
else
  echo "unreachable (HTTP ${code})"
fi

2. minio/mc は使えない — aws-cli で代替する

状況: バケット初期化に minio/mc を使おうとしたが 2 つの問題があった。

問題①: RustFS コンテナ内に mc が存在しない

# NG: rustfs コンテナ内に mc は存在しない
docker compose exec rustfs mc mb local/dvc-remote
# → mc: not found

rustfs/rustfs イメージには mc CLI が含まれていません。

問題②: minio/mc 自体が技術選定として不適切

調査した結果、minio/mc には 2 つの問題があります。

  • ライセンス: AGPL-3.0。ポリシーで AGPL を禁止している組織では使えない
  • Docker Hub イメージがアーカイブ済み: 更新が止まっており、セキュリティパッチも期待できない

修正: バケット作成は amazon/aws-cli(Apache 2.0)で完全に代替できます。IAM ユーザー分離は mc admin が必要なため廃止し、全サービスが root 認証情報を共用する設計に変更しました。ローカル内部環境での利用であれば許容できるトレードオフです。

NETWORK=$(docker network ls --format '{{.Name}}' | grep "^${PROJECT}_" | head -1)

aws_run() {
  docker run --rm --network "${NETWORK}" \
    -e AWS_ACCESS_KEY_ID="${RUSTFS_ROOT_USER}" \
    -e AWS_SECRET_ACCESS_KEY="${RUSTFS_ROOT_PASSWORD}" \
    -e AWS_DEFAULT_REGION="us-east-1" \
    amazon/aws-cli \
    --endpoint-url http://rustfs:9000 \
    "$@"
}

aws_run s3 mb s3://dvc-remote
aws_run s3 mb s3://mlflow-artifacts

amazon/aws-cli は S3 互換 API に対応しており、RustFS でも問題なく動作します。


3. MLflow 2.x のセキュリティミドルウェアが Docker 内部通信を拒否する

状況: smoke-test の STEP 1〜7 が全部 PASS なのに、STEP 8(SDK エンドツーエンド)だけ失敗する。エラーログを確認すると:

mlflow.exceptions.MlflowException:
API request to endpoint /api/2.0/mlflow/experiments/create failed with error code 403 != 200.
Response body: 'Invalid Host header - possible DNS rebinding attack detected'

原因: MLflow 2.x からデフォルトで DNS rebinding 対策のセキュリティミドルウェアが有効になっています。Docker ネットワーク内から http://mlflow:5000 にアクセスすると、HTTP リクエストの Host ヘッダーが mlflow:5000 になります。これをミドルウェアが「localhost 以外のホスト名=怪しい」と判断して 403 を返していました。

MLflow のログに起動時のヒントが出ていました:

[MLflow] Security middleware enabled with default settings (localhost-only).
To allow connections from other hosts, use --host 0.0.0.0 and configure
--allowed-hosts and --cors-allowed-origins.

修正: --allowed-hosts '*' を追加します。

# docker-compose.yml
command: >
  bash -c "
  pip install --quiet 'mlflow[extras]' psycopg2-binary &&
  mlflow server
  --backend-store-uri postgresql://...
  --artifacts-destination s3://mlflow-artifacts
  --serve-artifacts
  --host 0.0.0.0
  --port 5000
  --allowed-hosts '*'   # ← 追加
  "

注意: '*' はすべてのホスト名を許可します。プロダクション環境では実際に使うホスト名を列挙してください(例: --allowed-hosts 'mlflow.example.com,localhost')。


4. docker run で heredoc が stdin に渡らない

状況: smoke-test STEP 8 の Python スクリプトが出力を何も返さず、「E2E OK」が得られない。

# NG: docker run に -i がないと heredoc が stdin に渡らない
result=$(docker run --rm --network "${network}" \
  python:3.10-slim bash -c "pip install -q mlflow && python3 -" <<'PYEOF'
import mlflow
print("E2E OK")
PYEOF
)
echo "${result}"  # 空

原因: docker run はデフォルトで stdin を接続しません。python3 - は stdin から Python コードを読むよう指示していますが、stdin が接続されていないため即座に EOF を受け取り、何も実行せず終了します。

修正: -i--interactive)フラグを追加します。

# OK: -i で stdin を接続する
result=$(docker run --rm -i --network "${network}" \
  python:3.10-slim bash -c "pip install -q mlflow && python3 -" <<'PYEOF'
import mlflow
print("E2E OK")
PYEOF
)
echo "${result}"  # E2E OK

smoke-test.sh の構成

全サービスを起動したあとに実行する疎通確認スクリプトです。コンポーネントの変更後は必ず実行します。

bash scripts/smoke-test.sh
[INFO]  Docker ネットワーク: dataenv_default

STEP 1: PostgreSQL 接続確認...
  ✅ STEP 1: PostgreSQL 接続 OK
STEP 2: RustFS S3 API ヘルスチェック...
  ✅ STEP 2: RustFS S3 API ヘルスチェック OK (HTTP 403)
STEP 3: MLflow Tracking Server 起動確認...
  ✅ STEP 3: MLflow Tracking Server 起動 OK
STEP 4: Nginx → MLflow 転送確認...
  ✅ STEP 4: Nginx → MLflow 転送 OK
STEP 5: Nginx → RustFS S3 転送確認...
  ✅ STEP 5: Nginx → RustFS S3 転送 OK (HTTP 403)
STEP 6: DVC バケット存在確認...
  ✅ STEP 6: DVC バケット (dvc-remote) 存在確認 OK
STEP 7: MLflow artifact バケット存在確認...
  ✅ STEP 7: MLflow artifact バケット (mlflow-artifacts) 存在確認 OK
STEP 8: MLflow SDK エンドツーエンド確認...
  ✅ STEP 8: MLflow SDK エンドツーエンド OK

  PASS: 8 / FAIL: 0
[OK]    全 8 ステップ PASS — MVP 完了条件を満たしています

スクリプト内のネットワーク名取得はハードコードせず動的に解決しています。Docker Compose のデフォルトネットワーク名は <ディレクトリ名>_default になるため、ディレクトリ名を変えてもそのまま動作します。

PROJECT=$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | tr -cd '[:alnum:]-')
NETWORK=$(docker network ls --format '{{.Name}}' | grep "^${PROJECT}_" | head -1)

セキュリティ設計

認証・認可の多層構造

S3 認証情報の共用について

本構成では MLflow と DVC が同じ root 認証情報(RUSTFS_ROOT_USER / RUSTFS_ROOT_PASSWORD)を使います。IAM ユーザーを分離すれば「MLflow は mlflow-artifacts のみ」「DVC は dvc-remote のみ」というバケット単位の権限分離が可能ですが、そのためには minio/mc の admin API が必要です。

minio/mc は AGPL-3.0 かつ Docker Hub イメージがアーカイブ済みのため採用しない判断をしました(詳細はハマりポイント参照)。代替として amazon/aws-cli(Apache 2.0)を使いますが、こちらは標準 S3 API のみ対応でユーザー管理 API は持っていません。

ローカル内部環境での許容判断: 外部からは Nginx(ポート 80)経由でしかアクセスできず、S3 ポート(9000)は非公開です。Docker 内部通信のみの環境でバケット間のアクセス分離がないことは許容できるトレードオフです。

認証情報の管理

  • .env.gitignore に追加。Git に含めない
  • echo $SECRET_KEY のような認証情報の標準出力は禁止(bash history / CI ログへの漏洩防止)
  • 認証情報の存在確認は [ -n "$VAR" ] && echo "set" || echo "unset" で実施

まとめ

詰まった点 原因 対策
RustFS ヘルスチェックが常に失敗 MinIO と異なり /minio/health/live が 403 を返す curl -f を外して HTTP コードで判定
バケット初期化に minio/mc が使えない AGPL-3.0 + Docker Hub イメージがアーカイブ済み amazon/aws-cli(Apache 2.0)で代替、IAM 分離を廃止
Docker 内部通信が 403 になる MLflow 2.x の DNS rebinding 対策ミドルウェア --allowed-hosts '*' を追加
heredoc がコンテナに渡らない docker run の stdin デフォルト非接続 -i フラグを追加

RustFS は MinIO より軽量で動作が速く、Apache 2.0 のエコシステムで完結できました。MLflow のセキュリティミドルウェアは新しい挙動でドキュメントに気づきにくかったので、同じ構成を試す方の参考になれば幸いです。


参考リンク

MLflow

RustFS

DVC

amazon/aws-cli

Nginx

Docker Compose

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?