はじめに
近年はIaC(Infrastructure as Code)が当たり前になり、手作業での環境構築は再現性・可監査性・スピードの面で限界があります。特にMLOpsでは、
以下の複数コンポーネントを一貫した構成で立ち上げ直せることが重要です。
- 実験(学習環境)
- トラッキング(メトリクス・アーティファクト)
- レジストリ(モデルのライフサイクル)
- サービング(推論API)
本記事では、筆者が以前まとめた構成(MLflow × FastAPIを用いた最小限のMLOpsの構成でモデルを管理・提供してみた)を、Terraformで表現し直して一発起動・一発撤収できる形に落とし込みます。
1. Terraformの要点(概念・仕組み・他ツールとの違い)
1.1 Terraformとは
Terraformは、インフラの構成をコードで宣言・定義ができる IaC ツールです。
コードの書式は HCL(HashiCorp Configuration Language) を使います。
1.2 コア概念
-
Terraform本体:HCLを読み、グラフ(依存関係)を作って Plan(差分) を出し、ユーザーが承認すると Apply で実際に変更する。
変更や実体の対応関係は State(tfstate) という台帳に保存する。 -
Provider(プラグイン):Docker/AWS/GCPなど各サービスのAPIに対してCRUDを実行する役割。
Terraform本体が計算した計画に従い、Providerが実際のリソース作成・更新・削除を行う。 -
Resource / Data Source:
-
Resource は「作るもの」(例:
docker_container、aws_s3_bucket)。 - Data Source は「既存のものを参照」(例:既存ネットワークIDの取得)。
-
Resource は「作るもの」(例:
- Module:Resource群の再利用単位。環境ごとの差を変数で切り替えられます。
-
State(tfstate)
今の実体と宣言の対応表。差分計算・破壊影響の可視化の中枢。(チームでは必ずリモート化 & ロック) -
Graph / Dependency
依存関係を有向グラフで解決し、並列安全に適用。depends_onで明示することも可能。 -
Plan → Apply
planでどう変えるかを可視化、applyで実行。Planfileを経由するとヒューマンレビューがしやすい。
1.3 他手段との違い・使い分け
- Docker Compose: ローカル向けに速い。差分計算やState管理が弱い。クラウド移行の見通しはTerraformが上。
- Ansible: 構成管理(手順型)。宣言的な最終状態を扱うTerraformと補完関係。
- Kubernetes Manifests: K8s専用の宣言。周辺(VPC/DB/S3/LB等)まで横断するならTerraformが便利。
1.4 メリット(実務視点で一歩深く)
- 再現性/検証容易:同じHCLから同じ環境を短時間で再構築。
-
差分の透明性:
plan結果がレビュー対象。危険変更を事前に把握。 - 監査性:Git履歴=変更履歴。誰がいつ何をが追える。
- 拡張性:ローカル→クラウドにProvider差し替えでスケール。
- ドリフト検出:手作業変更との差異を早期検知。
- モジュール化:環境ごとの差分は変数/ワークスペース/モジュールで整理し、コピペ設計から卒業。
2. 構成ゴールと全体アーキテクチャ
2.1 目標
- Jupyterで学習
- MLflow Registryに登録(Productionへ昇格)
- FastAPIがProductionモデルをロードして推論
- これらをTerraform一式で起動・撤収
2.2 図
ポイント:Jupyter/MLflow/FastAPIが同じ物理ボリューム(/mlruns)を見ます。これがズレるとアーティファクトの実体が食い違い、以降すべてが連鎖的に失敗します。
3. Terraform(main.tfの要点付きコード)
設計意図
-
単一ネットワークで名前解決を安定化(
mlflow:5010)。 -
共有ボリューム
/mlrunsを3コンテナへ同パスでマウント。 - SQLiteはローカルMVPに最適。将来はRDS/CloudSQL等へ置換。
terraform {
required_version = ">= 1.6.0"
required_providers {
docker = { source = "kreuzwerker/docker", version = "~> 3.0" }
}
}
provider "docker" {}
resource "docker_network" "mlops" { name = "mlops_net" }
resource "docker_volume" "mlruns" { name = "mlruns_vol" } # 共有アーティファクト
resource "docker_volume" "mlflowdb" { name = "mlflow_db_vol" } # SQLite
# --- MLflow ---
resource "docker_image" "mlflow" {
name = "mlops-mlflow:local"
build { context = "${path.module}/.."; dockerfile = "Dockerfile.mlflow" }
keep_locally = true
}
resource "docker_container" "mlflow" {
name = "mlflow"
image = docker_image.mlflow.name
user = "1000:1000"
restart = "unless-stopped"
networks_advanced { name = docker_network.mlops.name }
env = ["MLFLOW_LOGGING_LEVEL=DEBUG"]
command = [
"mlflow","server",
"--host","0.0.0.0","--port","5010",
"--backend-store-uri","sqlite:////data/mlflow.db",
"--registry-store-uri","sqlite:////data/mlflow.db",
"--serve-artifacts",
"--artifacts-destination","file:///mlruns",
"--default-artifact-root","file:///mlruns",
"--workers","1"
]
ports { internal = 5010, external = var.port_mlflow, ip = "0.0.0.0" }
volumes { volume_name = docker_volume.mlflowdb.name, container_path = "/data" }
volumes { volume_name = docker_volume.mlruns.name, container_path = "/mlruns" }
}
# --- FastAPI ---
resource "docker_image" "fastapi" {
name = "mlops-fastapi:local"
build { context = "${path.module}/../fastapi"; dockerfile = "Dockerfile" }
keep_locally = true
}
resource "docker_container" "fastapi" {
name = "fastapi"
image = docker_image.fastapi.name
restart = "unless-stopped"
depends_on = [docker_container.mlflow]
networks_advanced { name = docker_network.mlops.name }
env = [
"MLFLOW_TRACKING_URI=http://mlflow:5010",
"MODEL_URI=models:/CaliforniaHousingModel/Production"
]
# models:/ が file:///mlruns/... に解決されても整合性が保てるよう RO で共有
volumes { volume_name = docker_volume.mlruns.name, container_path = "/mlruns", read_only = true }
ports { internal = 8000, external = var.port_fastapi }
}
# --- Jupyter ---
resource "docker_image" "jupyter" {
name = "mlops-jupyter:local"
build { context = "${path.module}/.."; dockerfile = "Dockerfile.jupyter" }
keep_locally = true
}
resource "docker_container" "jupyter" {
name = "jupyter"
image = docker_image.jupyter.name
restart = "unless-stopped"
depends_on = [docker_container.mlflow]
networks_advanced { name = docker_network.mlops.name }
env = ["MLFLOW_TRACKING_URI=http://mlflow:5010"]
volumes { host_path = abspath("${path.module}/../notebooks"), container_path = "/home/jovyan/work" }
volumes { volume_name = docker_volume.mlruns.name, container_path = "/mlruns" }
ports { internal = 8888, external = var.port_jupyter, ip = "0.0.0.0" }
command = ["jupyter","lab","--ip=0.0.0.0","--no-browser","--ServerApp.token="]
}
# 予防接種:/data, /mlruns の所有権を1000:1000に
resource "docker_container" "fix_mlflowdb_perm" {
name = "fix-mlflowdb-perm"
image = docker_image.mlflow.name
user = "0:0"
command = ["sh","-c","chown -R 1000:1000 /data || true"]
mounts { type="volume", source=docker_volume.mlflowdb.name, target="/data" }
must_run=false; restart="no"; start=true
}
resource "docker_container" "fix_mlruns_perm" {
name = "fix-mlruns-perm"
image = docker_image.mlflow.name
user = "0:0"
command = ["sh","-c","chown -R 1000:1000 /mlruns || true"]
mounts { type="volume", source=docker_volume.mlruns.name, target="/mlruns" }
must_run=false; restart="no"; start=true
}
var.port_mlflow / var.port_fastapi / var.port_jupyter は variables.tf で定義 or -var で注入。
4. Dockerfile
- バージョン固定で再現性を担保
- Jupyterは学習用途の最低限のみ
# Dockerfile.mlflow
FROM python:3.11-slim
RUN pip install --no-cache-dir mlflow==2.15.0 scikit-learn==1.5.1 pandas==2.2.2 uvicorn
RUN useradd -m -u 1000 app
USER 1000:1000
WORKDIR /app
# Dockerfile.jupyter
FROM jupyter/scipy-notebook:python-3.11
USER root
RUN pip install --no-cache-dir mlflow==2.15.0 scikit-learn==1.5.1 pandas==2.2.2
USER $NB_UID
# fastapi/Dockerfile
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn mlflow==2.15.0 scikit-learn pandas
COPY app.py /app/app.py
CMD ["uvicorn","app:app","--host","0.0.0.0","--port","8000"]
5. 学習→Registry登録(Jupyterスクリプト・解説)
Run の artifacts/model/MLmodel が存在しないと、以降すべて失敗します。
# notebooks/train_and_register.py
import time, mlflow, mlflow.sklearn
from mlflow import MlflowClient
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import PolynomialFeatures, FunctionTransformer
from mlflow.models.signature import infer_signature
import pandas as pd
mlflow.set_tracking_uri("http://mlflow:5010")
EXPERIMENT_NAME="california-demo"; MODEL_NAME="CaliforniaHousingModel"
client = MlflowClient()
exp = client.get_experiment_by_name(EXPERIMENT_NAME)
exp_id = exp.experiment_id if exp else client.create_experiment(
EXPERIMENT_NAME, artifact_location="file:///mlruns"
)
# data
df = fetch_california_housing(as_frame=True).frame
X, y = df.drop(columns=["MedHouseVal"]), df["MedHouseVal"]
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.2, random_state=42)
# 軽い特徴量追加
def _add_ratio_features(X: pd.DataFrame) -> pd.DataFrame:
X = X.copy(); eps=1e-6
X["rooms_per_person"] = X["AveRooms"] / (X["AveOccup"] + eps)
X["bedrooms_per_room"] = X["AveBedrms"] / (X["AveRooms"] + eps)
X["pop_per_occup"] = X["Population"]/ (X["AveOccup"] + eps)
return X
preproc = ColumnTransformer(
[("geo_poly", PolynomialFeatures(2, include_bias=False), ["Latitude","Longitude"])],
remainder="passthrough", verbose_feature_names_out=False,
)
pipe = Pipeline([
("feat", FunctionTransformer(_add_ratio_features, validate=False, feature_names_out="one-to-one")),
("geo", preproc),
("model", RandomForestRegressor(n_estimators=400, random_state=42, n_jobs=-1)),
])
with mlflow.start_run(experiment_id=exp_id) as run:
pipe.fit(Xtr, ytr)
r2 = pipe.score(Xte, yte)
mlflow.log_metric("r2", r2)
sig = infer_signature(Xtr, pipe.predict(Xtr))
mlflow.sklearn.log_model(pipe, artifact_path="model",
signature=sig, input_example=Xtr.head(2))
# 再発防止:本当に model/ ができたか
arts = [a.path for a in MlflowClient().list_artifacts(run.info.run_id, "model")]
assert "model/MLmodel" in arts, f"model/MLmodel not found: {arts}"
# Registry 登録(runs:/ → models:/)
mv = mlflow.register_model(
model_uri=f"runs:/{run.info.run_id}/model",
name=MODEL_NAME,
await_registration_for=60
)
time.sleep(1.0)
client.transition_model_version_stage(
MODEL_NAME, mv.version, stage="Production", archive_existing_versions=False
)
print("Run UI:", f"http://mlflow:5010/#/experiments/{exp_id}/runs/{run.info.run_id}")
print("Models UI:", f"http://mlflow:5010/#/models/{MODEL_NAME}")
6. FastAPI(Productionモデルを読む)
# fastapi/app.py
import os, mlflow, pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
TRACKING_URI = os.getenv("MLFLOW_TRACKING_URI","http://mlflow:5010")
MODEL_URI = os.getenv("MODEL_URI","models:/CaliforniaHousingModel/Production")
mlflow.set_tracking_uri(TRACKING_URI)
app = FastAPI(title="California Housing API")
model = None
@app.on_event("startup")
def _load_on_startup():
global model
try:
model = mlflow.pyfunc.load_model(MODEL_URI)
print(f"Loaded: {MODEL_URI}")
except Exception as e:
print(f"Could not load model from {MODEL_URI}: {type(e).__name__}: {e}")
@app.post("/reload")
def reload():
global model
try:
model = mlflow.pyfunc.load_model(MODEL_URI)
return {"detail": f"Model reloaded from {MODEL_URI}"}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Model reload failed: {type(e).__name__}: {e}")
class Features(BaseModel):
MedInc: float; HouseAge: float; AveRooms: float; AveBedrms: float
Population: float; AveOccup: float; Latitude: float; Longitude: float
@app.post("/predict")
def predict(x: Features):
if model is None:
raise HTTPException(status_code=503, detail="Model not loaded. Call /reload.")
y = model.predict(pd.DataFrame([x.dict()]))
return {"prediction": float(y[0])}
7. チーム運用でのTerraformコマンド&運用ルール
7.1 コマンド(頻出&チームで効くやつ)
# 1) 初期化・整形・静的検証
terraform init # プロバイダ取得/バックエンド初期化
terraform fmt -recursive # HCLのコード整形(PR前の必須儀式)
terraform validate # 基本的な構文/参照チェック
# 2) 差分の厳格運用
terraform plan -out=tfplan.bin # 差分をファイル出力(審査用)
terraform show tfplan.bin # Planfileの中身を確認
terraform apply tfplan.bin # 承認済みPlanのみ適用(本番はこれ)
# 3) 状態/可視化・デバッグ
terraform graph | dot -Tpng > g.png # 依存グラフを可視化
terraform state list # 管理下リソースの一覧
terraform state show <address> # その1件の詳細
terraform show # state全体の可読表示
# 4) 状態メンテ(慎重に)
terraform import <addr> <id> # 既存資産をstateに取り込み
terraform state mv A B # アドレスの移動(モジュール再編時)
terraform state rm <address> # 管理対象から切り離す(破壊はしない)
# 5) ワークスペース(環境分離)
terraform workspace new staging
terraform workspace select staging
terraform workspace list
# 6) ターゲット適用(応急処置。常用は非推奨)
terraform apply -target=resource.docker_container.mlflow
7.2 ルール(壊れないチーム開発の約束)
-
Stateはリモート管理(S3+DynamoDBロックやGCSロック等)
→ ロックで同時実行事故を防止。個人ローカルstateは禁止。 -
Plan→レビュー→Applyを分離
→ Planfile(-out)を成果物としてレビュー・承認後にのみ適用。 -
-auto-approveは開発環境限定。本番は必ず人的承認。 - バージョン固定:Terraform本体・Provider・Moduleはversionピン。
-
環境分離:
workspaceor 環境ディレクトリ分割。
変数はtfvars/ CIの環境変数(TF_VAR_xxx)で注入。 -
SecretsはHCLへ直書きしない:
TF_VAR_・Vault・CIのSecretで渡す。 -
破壊的変更の検知:
planで-destroyが出たら一旦中止して合意形成。 -
-targetの乱用禁止:依存整合性を壊しやすい。緊急時のみに限定。
8. 構築するのにハマった箇所(エラー→パターン→原因→対策)
色々ハマった箇所があったため、エラーごとに「失敗パターン→原因→対策」の順で整理します。
エラーA:Run has no artifacts at artifact path 'model'
-
失敗パターン:Jupyterで学習は成功表示だが、
artifacts/modelが空または別の場所にできている。 -
主原因:
-
artifact_path="model"を未指定/typo。 -
/mlrunsがコンテナ間で別物(ボリューム名やマウント先が不一致)。 - 権限不整合で書けず、空ディレクトリ化。
-
-
対策:
- ① 学習スクリプトで
mlflow.sklearn.log_model(..., artifact_path="model")を明示。 - ② Terraformで同一
docker_volume.mlrunsを3者に/mlrunsでマウント。 - ③ 起動時
chown -R 1000:1000 /mlrunsを実行(サンプル同梱の fix_*_perm)。 - ④ 登録前チェック:
arts = [a.path for a in MlflowClient().list_artifacts(run_id,"model")] assert "model/MLmodel" in arts
- ① 学習スクリプトで
エラーB:FastAPI /reload が No such file or directory: '/mlruns/<run>/artifacts/model'
- 失敗パターン:FastAPIだけ別の/m lrunsを見ている/マウント先がROではなく未マウント。
-
主原因:FastAPIコンテナに
/mlrunsをマウントしていない、またはパス名が違う。 -
対策:FastAPIにも 同じボリュームを
/mlrunsでROマウント。MODEL_URI=models:/...を利用しつつ、ローカル解決も耐える構造に。
エラーC:/api/2.0/mlflow/model-versions/create が 500(MaxRetryError など)
- 失敗パターン:Register APIがタイムアウト/内部500。
-
主原因:
- Registry/Backend DB(SQLite)のロック衝突、同時処理。
- MLflow サーバの起動引数ミス(
--registry-store-uri未設定等)。
-
対策:
- 学習→登録は直列で実行。
- MLflowサーバ引数を
docker inspect mlflow --format '{{.Path}} {{.Args}}'で確認。 - 将来はPostgres等に移行し同時性を確保。
エラーD:models:/... が解決できない/別環境のMLflowに向いている
-
失敗パターン:FastAPI 起動時ログで別ホストに向いている、または
MLFLOW_TRACKING_URI未設定。 - 主原因:環境変数の設定漏れ。
-
対策:FastAPI/Jupyterに
MLFLOW_TRACKING_URI=http://mlflow:5010を必ず注入。Terraform のenvを見直す。
エラーE:権限エラー(Permission denied)
-
失敗パターン:Jupyterから
/mlrunsに書けない。 - 主原因:UID/GID不一致。
- 対策:サンプル同梱のchownワンショットコンテナを使う/DockerfileでユーザIDを揃える。
9. まとめと次の一歩
- 学習→Registry→Servingの最短ラインを、Terraform一発で回せるようにした。
- 成功の鍵は**/mlrunsの単一ボリューム共有とMLflowサーバ引数の明示**。
参考:元記事 MLflow × FastAPIを用いた最小限のMLOpsの構成でモデルを管理・提供してみた をTerraform化しました。