0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Terraformで“最小構成”のMLOps環境を作ってみた

Last updated at Posted at 2025-10-18

はじめに

近年は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_containeraws_s3_bucket)。
    • Data Source は「既存のものを参照」(例:既存ネットワークIDの取得)。
  • 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 目標

  1. Jupyterで学習
  2. MLflow Registryに登録(Productionへ昇格)
  3. FastAPIProductionモデルをロードして推論
  4. これらを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_jupytervariables.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ピン
  • 環境分離workspace or 環境ディレクトリ分割
    変数は 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または別の場所にできている。
  • 主原因
    1. artifact_path="model"を未指定/typo。
    2. /mlrunsがコンテナ間で別物(ボリューム名やマウント先が不一致)。
    3. 権限不整合で書けず、空ディレクトリ化。
  • 対策
    • ① 学習スクリプトで 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 /reloadNo 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化しました。

0
1
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
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?