April 14, 2026

GCP Data-Science-Projekt anlegen mit Terraform

Durch Nutzung von Terraform kann man in wenigen Befehlen eine Data Science Projektumgebung hochziehen und sie auch wieder sauber entfernen. Unten ist ein kompletter, “einfach aber realistisch” gehaltener Blueprint für ein kleines Data-Science-Projekt in Google Cloud Platform (GCP), das du vollständig mit Terraform aufsetzt – inkl. Makefile, typischer Projektstruktur, allen nötigen Terraform-Ressourcen.

Wie man grundsätzlich mit Terraform arbeiten kann wird hier gezeigt, wie man Modelle auf GCP deployed wird hier erklärt.

Architektur

In diesem Beitrag baust du ein komplett reproduzierbares Setup:

  • Deployment: Modell wird auf einem Vertex AI Endpoint deployed und ist online abfragbar.
  • Infrastruktur mit Terraform: Projekt-Setup (APIs), Service Account + IAM, Artifact-Bucket, (optional) Vertex AI Endpoint.
  • Datenquelle BigQuery: Wir nutzen einen Public Dataset (Standarddatensatz) aus bigquery-public-data.
  • Training mit Vertex AI: Einfaches scikit-learn Modell per Custom Training Job.
  • Model Registry: Modell wird hochgeladen/registriert.

Voraussetzungen & Setup

Voraussetzung ist ein GCP Account, GCloud und eine Terraform Installation (siehe hier).

GCP Projekt erstellen

Wie immer müssen wir zunächst ein Projekt auf GCP erstellen und die Abrechnung aktivieren:

gcloud projects create my-terraform-2026 --set-as-default
gcloud auth application-default login
gcloud auth application-default set-quota-project my-terraform-2026

Für das Projekt muss außerdem Billing aktiviert werden:

gcloud billing accounts list
gcloud billing projects link my-terraform-2026 --billing-account=xxxx-xxxx-xxxx

Außerdem muss man die Compute Engine API aktivieren:

gcloud services enable compute.googleapis.com --project=my-terraform-2026

Ordnerstruktur

Wir verwenden folgende Ordnerstruktur:

gcp-vertex-terraform-ml/
├─ terraform/
  ├─ versions.tf
  ├─ provider.tf
  ├─ variables.tf
  ├─ main.tf
  ├─ outputs.tf
├─ src/
  ├─ config.py
  ├─ train.py
  ├─ submit_job.py
  ├─ predict_online.py
├─ config.yaml
├─ pyproject.toml

Terraform

Terraform einrichten

Zunächst wechseln wir in den Unterordner terraform und initialisieren wir Terraform:

terraform init

Im Ordner Terraform erstellen wir nun eine Datei namens „versions.tf“:

  • Hier wird die Version von Terraform festgelegt
  • Und der Provider für Google eingerichtet
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 5.30.0"
    }
  }
}

In der Datei variables.tf halten wir fest, welche Variablen beim ausführen von Terraform vom User festgelegt werden:

variable "project_id" {
  type        = string
  description = "GCP Project ID (existierend oder neu)."
}

variable "region" {
  type        = string
  description = "GCP Region, z.B. europe-west1"
  default     = "europe-west1"
}

variable "artifact_bucket_name" {
  type        = string
  description = "Globally unique GCS bucket name for ML artifacts."
}

variable "vertex_endpoint_display_name" {
  type        = string
  default     = "ds-demo-endpoint"
}

Um die Variablen festzulegen, erzeugen wir eine Datei names „terraform.tfvars“. Darin halten wir unsere Konfiguration fest:

project_id             = "my-gcp-project-123"
region                 = "europe-west1"
artifact_bucket_name   = "my-gcp-project-vertex-artifacts"
vertex_endpoint_display_name = "ds-demo-endpoint"

Wichtig: Diese Datei sollte nicht nach git commited werden und wird daher in .gitignore festgelegt:

# Terraform files
.terraform/
.terraform.lock.hcl
terraform.tfstate
terraform.tfstate.backup

# Terraform local vars
terraform.tfvars
*.tfvars

In „provider.tf“ werden ProjektID und Projekt-Region anhand dieser Variablen festgelegt:

provider "google" {
  project = var.project_id
  region  = var.region
}

Die tatsächliche Konfiguration des Projekts findet dann in „main.tf“ statt. Zum einen werden die notwendigen APIs aktiviert:

############################################
# APIs aktivieren
############################################
resource "google_project_service" "aiplatform" {
  project = var.project_id
  service = "aiplatform.googleapis.com"
  disable_on_destroy = false
}

resource "google_project_service" "bigquery" {
  project = var.project_id
  service = "bigquery.googleapis.com"
  disable_on_destroy = false
}

resource "google_project_service" "storage" {
  project = var.project_id
  service = "storage.googleapis.com"
  disable_on_destroy = false
}

resource "google_project_service" "iam" {
  project = var.project_id
  service = "iam.googleapis.com"
  disable_on_destroy = false
}

# (Optional hilfreich) Artifact Registry, Logging etc. – je nach Setup
resource "google_project_service" "artifactregistry" {
  project = var.project_id
  service = "artifactregistry.googleapis.com"
  disable_on_destroy = false
}
############################################
# GCS Bucket für Training & Modelle
############################################
resource "google_storage_bucket" "artifacts" {
  name                        = var.artifact_bucket_name
  location                    = var.region
  uniform_bucket_level_access = true
  force_destroy               = var.bucket_force_destroy # Erzwingt löschen, auch wenn Daten vorhanden

  # Für Demos ok; für Produktion Lifecycle/Retention definieren
  versioning {
    enabled = true
  }

  depends_on = [google_project_service.storage]
}
############################################
# Service Account für Vertex Training/Deploy
############################################
resource "google_service_account" "vertex_sa" {
  account_id   = "vertex-train-deploy"
  display_name = "Vertex AI Training & Deployment SA"

  depends_on = [google_project_service.iam]
}

# Rollen – für Demo bewusst pragmatisch.
# Produktion: enger zuschneiden (Least Privilege).
resource "google_project_iam_member" "vertex_sa_aiplatform_admin" {
  project = var.project_id
  role    = "roles/aiplatform.admin"
  member  = "serviceAccount:${google_service_account.vertex_sa.email}"
}

resource "google_project_iam_member" "vertex_sa_bigquery_jobuser" {
  project = var.project_id
  role    = "roles/bigquery.jobUser"
  member  = "serviceAccount:${google_service_account.vertex_sa.email}"
}

resource "google_project_iam_member" "vertex_sa_bigquery_dataviewer" {
  project = var.project_id
  role    = "roles/bigquery.dataViewer"
  member  = "serviceAccount:${google_service_account.vertex_sa.email}"
}

resource "google_project_iam_member" "vertex_sa_storage_admin" {
  project = var.project_id
  role    = "roles/storage.admin"
  member  = "serviceAccount:${google_service_account.vertex_sa.email}"
}

resource "google_service_account_iam_member" "vertex_sa_user" {
  service_account_id = google_service_account.vertex_sa.name
  role               = "roles/iam.serviceAccountUser"
  member             = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-aiplatform.iam.gserviceaccount.com"
}
############################################
# Vertex AI Endpoint (Terraform)
############################################
resource "google_vertex_ai_endpoint" "endpoint" {
  name         = var.vertex_endpoint_display_name
  display_name = var.vertex_endpoint_display_name
  location     = var.region

  depends_on = [google_project_service.aiplatform]
}

Die letzte Datei die wir erzeugen heißt „outputs.tf“ und legt die Outputs fest:

output "artifact_bucket" {
  value = google_storage_bucket.artifacts.name
}

output "vertex_service_account_email" {
  value = google_service_account.vertex_sa.email
}

output "vertex_endpoint_id" {
  value = google_vertex_ai_endpoint.endpoint.id
}

output "vertex_endpoint_name" {
  value = google_vertex_ai_endpoint.endpoint.name
}

Zum Ausführen von Terraform verwenden wir folgende Befehle:

terraform fmt
terraform validate
terraform plan
terraform apply
terraform show

Es empfiehlt sich, nicht allen Code der „main.tf“ auf einmal auszuführen, sondern Schritt für Schritt einzelne Komponenten hinzuzufügen, um mögliche Fehler beheben zu können.

Gerade der letzte Schritt zur Erstellung des Endpunkts dauert länger, ca. 10-15 Minuten.

Outputs in config.yaml

Am Ende gibt Terraform die Outputs aus für Artifakt Bucket, Endpoint ID usw. Die Outputs kopieren wir in die Datei „config/config.yaml“:

project:
  id: projectid
  region: europe-west1

vertex:
  service_account: service_account@projectid.iam.gserviceaccount.com
  endpoint:
    id: projects/projectid/locations/europe-west1/endpoints/projectid-endpoint
    display_name: projectid-endpoint

storage:
  artifact_bucket: projectid-artifacts

Modell training

Im Gegensatz zum Artikel hier werden wir das Modelltraining nicht lokal durchführen, sondern als CustomTraining Job auf VertexAI ausführen.

Folgende Pakete sind erforderlich (requirements.txt):

google-cloud-aiplatform>=1.50.0
google-cloud-bigquery>=3.20.0
pandas>=2.0.0
scikit-learn>=1.3.0
pyarrow>=14.0.0
joblib>=1.3.0

Das Training sieht man in VertexAI unter Trainings (Europe-West1).

Das Training selbst wird über die Datei train.py erstellt, in der mehrere Schritte verarbeitet werden.

Daten aus Big Query laden

Zunächst müssen die Daten aus Big Query geladen werden. Wichtig ist es die Projekt-ID im Client explizit zu setzen, da der Trainingsjob später in einem eigenen Container läuft:

BQ_TABLE = "bigquery-public-data.ml_datasets.penguins"

def load_data_from_bigquery(limit: int = 5000) -> pd.DataFrame:
    # Lese die Project ID aus den Environment Variables
    # Fallback auf None, falls man es lokal testet und gcloud config gesetzt ist
    project_id = os.environ.get("PROJECT_ID")
    
    # WICHTIG: Project explizit setzen!
    client = bigquery.Client(project=project_id)
    
    query = f"""
      SELECT
        species, island, sex,
        culmen_length_mm, culmen_depth_mm,
        flipper_length_mm, body_mass_g
      FROM `{BQ_TABLE}`
      WHERE body_mass_g IS NOT NULL
      LIMIT {limit}
    """
    return client.query(query).to_dataframe()

Model auf GCS laden

Fürs Upload des Models auf GCS benötigen wir eine Hilfsfunktion:

def upload_to_gcs(local_path: str, gcs_path: str):
    """Lädt eine lokale Datei in den GCS Pfad hoch."""
    if not gcs_path.startswith("gs://"):
        return

    client = storage.Client()
    
    # Pfad zerlegen: gs://bucket_name/path/to/blob
    path_parts = gcs_path.replace("gs://", "").split("/", 1)
    bucket_name = path_parts[0]
    blob_name = path_parts[1] if len(path_parts) > 1 else ""
    
    # Den Dateinamen an den Blob-Pfad anhängen
    filename = os.path.basename(local_path)
    # Verhindern von doppelten Slashes, falls blob_name leer ist oder mit / endet
    if blob_name and not blob_name.endswith("/"):
        blob_path = f"{blob_name}/{filename}"
    elif blob_name:
        blob_path = f"{blob_name}{filename}"
    else:
        blob_path = filename

    bucket = client.bucket(bucket_name)
    blob = bucket.blob(blob_path)
    blob.upload_from_filename(local_path)
    print(f"Uploaded {local_path} to gs://{bucket_name}/{blob_path}")

Training selbst

Das Training selbst sieht so aus:

def main():
    df = load_data_from_bigquery()

    y = df["body_mass_g"].astype(float)
    X = df.drop(columns=["body_mass_g"])

    cat_cols = ["species", "island", "sex"]
    num_cols = ["culmen_length_mm", "culmen_depth_mm", "flipper_length_mm"]

    preprocessor = ColumnTransformer(
        transformers=[
            ("cat", Pipeline(steps=[
                ("imputer", SimpleImputer(strategy="most_frequent")),
                ("ohe", OneHotEncoder(handle_unknown="ignore")),
            ]), cat_cols),
            ("num", Pipeline(steps=[
                ("imputer", SimpleImputer(strategy="median")),
            ]), num_cols),
        ]
    )

    model = RandomForestRegressor(
        n_estimators=200,
        random_state=42,
        n_jobs=-1
    )

    clf = Pipeline(steps=[("prep", preprocessor), ("model", model)])

    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )

    clf.fit(X_train, y_train)
    preds = clf.predict(X_test)
    mae = float(mean_absolute_error(y_test, preds))

    # 1. Lokal speichern (im Container Filesystem)
    local_model_name = "model.joblib"
    joblib.dump(clf, local_model_name)
    
    metrics = {"mae": mae}
    with open("metrics.json", "w") as f:
        json.dump(metrics, f)

    # 2. Prüfen, ob wir in Vertex AI sind (AIP_MODEL_DIR ist gesetzt)
    model_dir = os.environ.get("AIP_MODEL_DIR")
    
    if model_dir and model_dir.startswith("gs://"):
        print(f"Vertex AI Environment detected. Uploading to: {model_dir}")
        upload_to_gcs(local_model_name, model_dir)
        # Optional: Metriken auch hochladen, falls gewünscht
        # upload_to_gcs("metrics.json", model_dir)
    else:
        print(f"Local run. Model saved locally as {local_model_name}")

    print(f"Metrics: {metrics}")

Training-Job auf GCP submitten

Um den Trainings-Job auf GCP zu comitten verwenden wir folgenden Code:

import sys
from pathlib import Path

# Add the project root to the Python path so that the 'config' package can be found
root_dir = str(Path(__file__).parent.parent)
if root_dir not in sys.path:
    sys.path.append(root_dir)

from config.config import load_config
from google.cloud import aiplatform

cfg = load_config()
PROJECT_ID = cfg["project"]["id"]
REGION = cfg["project"]["region"]
BUCKET = cfg["storage"]["artifact_bucket"]
SA = cfg["vertex"]["service_account"]
ENDPOINT_ID = cfg["vertex"]["endpoint"]["id"]

aiplatform.init(
    project=PROJECT_ID,
    location=REGION,
    staging_bucket=f"gs://{BUCKET}" if not BUCKET.startswith("gs://") else BUCKET
)

job = aiplatform.CustomTrainingJob(
    display_name="penguins-train",
    script_path="src/train.py",
    # Use a TensorFlow container to get Python 3.10+ support (required for scikit-learn 1.2+)
    # standard scikit-learn-cpu.0-23 uses Python 3.7 which is too old
    container_uri="europe-docker.pkg.dev/vertex-ai/training/tf-cpu.2-11:latest",
    requirements=[
        "google-cloud-bigquery",
        "google-cloud-storage",
        "pandas",
        "pyarrow",
        "scikit-learn==1.2.2",
        "joblib",
        "db-dtypes", #Pandas benötigt inzwischen das Paket db-dtypes, um BigQuery-Ergebnisse korrekt zu verarbeiten.
    ],
    model_serving_container_image_uri="europe-docker.pkg.dev/vertex-ai/prediction/sklearn-cpu.1-2:latest",
)

model = job.run(
    replica_count=1,
    machine_type="n1-standard-4",
    service_account=SA,
    environment_variables={
        "PROJECT_ID": PROJECT_ID
    },
    model_display_name="penguins-rf-regressor",
    sync=True,
)


endpoint = aiplatform.Endpoint(ENDPOINT_ID)
model.deploy(
    endpoint=endpoint,
    machine_type="n1-standard-2",
    traffic_percentage=100,
    sync=True
)

print("Done:", model.resource_name, endpoint.resource_name)

Endpunkt testen

from google.cloud import aiplatform
from config import load_config

cfg = load_config()
PROJECT_ID = cfg["project"]["id"]
ENDPOINT_ID = cfg["vertex"]["endpoint"]["id"]
ENDPOINT_NAME = cfg["vertex"]["endpoint"]["display_name"]
REGION = cfg["project"]["region"]

# ENDPOINT_NAME = os.environ["VERTEX_ENDPOINT_NAME"]

def main():
    aiplatform.init(project=PROJECT_ID, location=REGION)

    endpoint = aiplatform.Endpoint(endpoint_name=ENDPOINT_NAME)

    # Muss zur Feature-Reihenfolge passen, wie im Training verwendet:
    instances = [
        {
            "species": "Adelie",
            "island": "Torgersen",
            "sex": "MALE",
            "culmen_length_mm": 39.1,
            "culmen_depth_mm": 18.7,
            "flipper_length_mm": 181
        }
    ]

    preds = endpoint.predict(instances=instances)
    print(preds)

if __name__ == "__main__":
    main()

Aufräumen

Möchte man am Ende alles wieder entfernen, dann löscht Terraform alle Ressourcen mit einem Befehl – wichtig, um ungewollte Kosten zu vermeiden:

terraform destroy

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert