はじめに
- 対象: 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-cli、dvc[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.local や http://s3.example.local だけ見せ、裏で mlflow:5000・rustfs: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 $host と merge_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 ネットワークに接続します。コンテナ間はサービス名(mlflow、rustfs、postgres)で名前解決できます。
外部に公開するポートは 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 ステータスコードを取得して 1xx〜4xx を「サーバーが応答している=正常」と判定するよう変更しました。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
- MLflow Tracking — Artifact Stores — S3 互換ストレージの設定・proxied access の説明
-
mlflow server CLI リファレンス —
--serve-artifacts/--allowed-hostsオプション
RustFS
- RustFS 公式ドキュメント — セットアップ・設定ガイド
- RustFS — Amazon S3 Compatibility — サポートする S3 API 操作一覧
- rustfs/rustfs — GitHub — ソースコード・Issue トラッカー
DVC
-
DVC — Amazon S3 リモートストレージ — S3 互換ストレージへの接続設定(
endpointurlなど) - dvc remote modify — リモート設定パラメータ一覧
amazon/aws-cli
- amazon/aws-cli — Docker Hub — 公式 Docker イメージ(Apache 2.0)
-
AWS CLI — S3 コマンドリファレンス —
aws s3 mbなどの使い方 -
Service-specific endpoints — AWS SDKs and Tools —
endpoint_urlの設定方法・優先順位
Nginx
-
ngx_http_proxy_module — proxy_set_header —
Hostヘッダー転送設定 - ngx_http_core_module — merge_slashes — S3 オブジェクトキーのパス正規化制御
Docker Compose
-
Docker Compose ファイルリファレンス —
depends_on/healthcheckなどの設定仕様