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-2026Für das Projekt muss außerdem Billing aktiviert werden:
gcloud billing accounts list
gcloud billing projects link my-terraform-2026 --billing-account=xxxx-xxxx-xxxxAußerdem muss man die Compute Engine API aktivieren:
gcloud services enable compute.googleapis.com --project=my-terraform-2026Ordnerstruktur
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 initIm 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
*.tfvarsIn „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 showEs 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-artifactsModell 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