Beliebte Suchanfragen
//

Erstes Data Engineering Projekt mit Databricks Asset Bundles und GitLab CI/CD

15.6.2025 | 9 Minuten Lesezeit

Einleitung

In diesem Artikel geht es um das Aufsetzen eines Datenprojekts mit Databricks Asset Bundles. Wir schauen uns das Basis Setup an und eine typische Projektstruktur, gehen anschließend auf einige wichtige Details und mögliche Stolpersteine und zeigen wie eine CI/CD-Integration mit GitLab funktionieren könnte.

Was ist Databricks?

Databricks wurde 2013 von den Originalentwicklern von Apache Spark gegründet und hat sich seither zu einer führenden Data-Plattform für die moderne Datenverarbeitung entwickelt. Die Plattform vereint Funktionen zum Speichern, Verarbeiten, Abfragen und Visualisieren von Daten und unterstützt dabei das Konzept des Lakehouse – einer Architektur, die die Vorteile von Data Warehouses und Data Lakes kombiniert.

Databricks richtet sich an verschiedene Nutzergruppen: Data Engineers, Data Scientists, Data Analysts sowie Business-Anwender ohne tiefere technische Kenntnisse können unter einer einheitlichen Oberfläche zusammenarbeiten. Dafür stehen unter anderem interaktive Notebooks mit Multi-Language-Unterstützung (z. B. Python, SQL, Scala), ein leistungsstarker SQL-Editor, Dashboards, Workflows für Data Pipelines sowie integrierte Machine-Learning-Tools wie MLflow zur Verfügung.

Die Plattform ist als vollständig verwaltete PaaS-Lösung in allen großen Public Clouds verfügbar – Azure, AWS und Google Cloud – und erlaubt so eine nahtlose Integration in bestehende Cloud-Umgebungen.

Im weiteren Verlauf liegt der Fokus auf dem Bereich Data Engineering und CI/CD mit Databricks: Ich zeige anhand eines typischen Projektaufbaus, wie man mit Databricks Asset Bundles eine Datenpipeline erstellt und diese in einen CI/CD-Workflow integriert.

Was sind Databricks Asset Bundles?

Ein Databricks Bundle ist eine Sammlung von Coderessourcen und Metadaten, die als ein einziges Bundle in einem Databricks Workspace ausgerollt werden können. Ein Bundle wird lokal entwickelt und kann anschließend in ein Repository eingecheckt werden (wir verwenden GitLab). Alternativ kann man direkt per CLI ausrollen. Grob beinhaltet ein Bundle folgende Ressourcen:

  • Databricks Workspace- und Projektkonfiguration
  • Python-Code (z.B. Notebooks oder Module für ETL-Pipelines)
  • Definitionen und Einstellungen für Databricks Ressourcen, wie Job-Definitionen für Serverless Jobs
  • Unit-Tests and Integration-Tests

Da Businesslogik nicht im Fokus steht werden wir ein minimales Bundle Setup verwenden, das eines der von Databricks bereitgestellten samples-Datensätze (nyctaxi) einbezieht.

Wie sieht ein typischer Projektaufbau mit Databricks aus?

Im Folgenden wird davon ausgegangen, dass ein gültiger Cloud-Account und ein Databricks Workspace bereits existieren.

Basis-Setup für Python-Projekt:

  • Pyenv zu Verwaltung und lokaler Installation verschiedener Python Versionen
  • Poetry zur Erstellung von virtuellen Python-Umgebungen
  • Ruff als Tool zu Linting & Codeanalyse (Diese Tools erhöhen die Qualität und Wartbarkeit von Python-Pipelines)
  • Typer zur Erstellung von Kommandozeilenanwendungen (Typer CLI). Das ist optional, wie ich weiter unten ausführe.

Als Erstes installieren wir eine Python Version in unserem Projektverzeichnis:

pyenv local 3.11.10

Hinweis: hier sollte die gleiche Python Version verwendet werden wie im Databricks Workspace. Letztere kann man leicht herausfinden, wenn man in einem Notebook den Befehl: !python --version ausführt. Der Befehl erstellt eine .python-version Datei, die man normalerweise nicht ins Repository eincheckt. Alternativ kann man natürlich eine systemübergreifende Python Version installieren. Als Nächstes erstellen wir eine virtuelle Umgebung, die wir auf unser Projektverzeichnis beschränken:

poetry config virtualenvs.in-project true

Databricks Bundle Setup:

Unsere Ziel-Projektstruktur sieht in etwa so aus:

databricks_bundle_example/
├── .databricks/ # Bundle-spezifische Konfigurationen und Artefakte pro Umgebung
│
├── .venv/ # Virtuelle Umgebung (optional, lokal)
│
├── src/ # Der eigentliche Code (z.B. ETL-Logik, Helper-Module)
│
├── resources/ # Job-Definitionen für Pipelines
│ └── databricks_bundle_demo.job.yml # Beispielhafte Job-Definition im YAML-Format
│
├── tests/ # Pytest-basierte Unit- oder Integrationstests
│
├── databricks.yml # Hauptdefinition des Databricks Bundles
├── .gitlab-ci.yml # CI/CD-Konfiguration für GitLab
└── pyproject.toml # Zentrale Konfigurationsdatei für das Python-Projekt

Ich möchte nicht auf jede einzelne Datei eingehen, nur einige wichtige Anmerkungen:

databricks.yml:

FeldBedeutung
variablesKönnen in verschiedenen Stages verwendet werden, um Stage-spzifische Werte zu übergeben
syncSteuert, welche lokalen Dateien beim Deployment ins Databricks-Dateisystem (meist /Workspace/...) hochgeladen werden. Die exclude-Einträge überschreiben include-Einträge, d. h. Dateien in exclude werden nicht synchronisiert, selbst wenn sie in include stehen.
targetsDefiniert verschiedene Deployment-Umgebungen wie dev_local, dev, prod. Ermöglicht isolierte Entwicklungsumgebungen sowie zentralisierte Produktionsdeployments. Jede Umgebung kann eigene Parameter (z. B. Kataloge, Cluster-Modi) enthalten.
root_pathGibt den genauen Pfad im Databricks Workspace an, in dem die Bundle-Inhalte abgelegt werden. Das ist wichtig zur Trennung von Umgebungen und Nutzern und verhindert Ressourcenkollisionen. Wird root_path nicht definiert, kann die Pipeline scheitern, weil nötige Artefakte nicht gefunden werden.
hostWorkspace URL, hat die Form: https://<workspace-id>.azuredatabricks.net

Hier ist ein beispielhafter Aufbau einer databricks.yml:

bundle:
  name: databricks_bundle_demo
  uuid: 57dddb64-fdcd-45bd-b00c-70de62f98c72

variables:
  schema_prefix:
    description: Prefix for local development schema
    default: ""
  catalogue:
    description: Catalogue to store schema and tables
  branch_ref:
    description: Branch reference name used for naming test deployments
    default: ""
  schedule_pause_status:
    description: Status of schedule triggers 'UNPAUSED' = enabled, 'PAUSED' = disabled
    default: "UNPAUSED"
sync:
  include:
    - src/**/*.py 

  exclude:
    - "test/*"
    - "resources/*"
    - ".*"
    - "*.lock"
    - "*.md"
    - "*.toml"
    - "databricks.yml"

include:
  - resources/*.yml

permissions:
  - group_name: users
    level: CAN_VIEW
  - user_name: "${workspace.current_user.userName}"
    level: CAN_MANAGE

targets:
  dev_local:
    mode: development
    default: true
    artifacts:
      wheel:
        type: whl
        files:
          - source: ./dist/*.whl
    variables:
      catalogue: "demo_dev_local"
      schema_prefix: "${workspace.current_user.short_name}_"
      schedule_pause_status: "PAUSED"
    workspace:
      host: https://adb-185960349365378.18.azuredatabricks.net
  dev:
    mode: development
    default: false
    variables:
      catalogue: "demo_dev"
      schema_prefix: "${workspace.current_user.short_name}_"
      schedule_pause_status: "PAUSED"
    workspace:
      host: https://adb-185960349365378.18.azuredatabricks.net
      root_path: /Shared/.bundle/${bundle.name}/dev/${workspace.current_user.short_name}/

  prod:
    mode: production
    workspace:
      host: https://adb-185960349365378.18.azuredatabricks.net
      root_path: /Workspace/Users/${workspace.current_user.name}/.bundle/${bundle.name}/${bundle.target}
    permissions:
      - user_name: ${workspace.current_user.name}
        level: CAN_MANAGE

databricks_bundle_demo.job.yml:

FeldBedeutung
environmentsDefiniert, mit welchen Libraries/Settings ein Task ausgeführt wird. Wir nutzen Serverless
./../dist/*.whlZeigt auf das gebaute Python-Wheel mit deinem Code. Wichtig für Python-Wheel-Jobs.
entry_pointName des Python-Moduls bzw. Scripts im Paket, das gestartet werden soll.
pause_status"UNPAUSED" bedeutet, dass der Schedule aktiv ist. In dev meist "PAUSED".
tasksHier werden einzelne Job-Schritte, benötigte Variablen und Abhängigkeiten definiert. Im Databricks Portal kann das als Prozessdiagramm visualisiert werden

Hier ist ein Beispiel für eine databricks_bundle_demo.job.yml:

resources:
  jobs:
    databricks_bundle_demo_job:
      name: databricks_bundle_demo_job
      max_concurrent_runs: 1
      schedule:
        timezone_id: "UTC"
        quartz_cron_expression: "0 15 0 ? * *"
        pause_status: "UNPAUSED"
      environments:
        - environment_key: Serverless
          spec:
            client: "2"
            dependencies:
              - ./../dist/*.whl

      tasks:
        - task_key: task_get_taxis
          python_wheel_task:
            package_name: databricks_bundle_example
            entry_point: demo_script
          environment_key: Serverless

main.py:

Ich möchte nicht näher auf den Inhalt des Codes eingehen, sondern nur ein Beispiel geben:

from loguru import logger
from pyspark.sql import SparkSession, DataFrame

def task_get_taxis():
    """
    Request taxis from databricks example data sample.
    """
    logger.info("running task_retrieve_roads_raw_data")

    get_taxis(get_spark()).show(5)

    logger.info("loaded roads data successful!")


def get_taxis(spark: SparkSession) -> DataFrame:
    return spark.read.table("samples.nyctaxi.trips")

def get_spark() -> SparkSession:
    try:
        from databricks.connect import DatabricksSession

        return DatabricksSession.builder.getOrCreate()
    except ImportError:
        return SparkSession.builder.getOrCreate()

In diesem Beispiel werden keine Parameter in der Funktion übergeben, weshalb es nicht notwendig ist, diese als CLI-Befehl zu kennzeichen. Anders sieht es aus, wenn man Parameter übergeben möchte, wie das folgende Beispiel zeigt:

import typer
from loguru import logger
from pyspark.sql import SparkSession, DataFrame

cli = typer.Typer()

def main():
    cli(standalone_mode=False)

@cli.command()
def task_get_taxis(schema_path: str = typer.Option(...)):
    """
    Request taxis from databricks example data sample.
    """
    logger.info("running task_get_taxis")

    taxies_data = get_taxis(get_spark())

    logger.info("loaded taxies data successful!")
    
    write_taxies_data(taxies_data, schema_path)
   
...

Für dieses Beispiel würde der entsprechende Task-Abschnitt in der Job-Definition so aussehen:

...
      tasks:
        - task_key: task_get_taxis
          python_wheel_task:
            package_name: databricks_bundle_example
            entry_point: demo_script
            parameters: [ "task-get-taxis", "--schema-path", "${var.catalogue}.${var.schema_prefix}" ]
          environment_key: Serverless
...

Die Annotation @cli.command() ist wichtig. Databricks ruft das Script über CLI auf, d.h. wenn man in der Task-Definition Parameter einträgt, dann sind das CLI Parameter und keine Python Parameter. Deswegen muss man im Python-Code die Aufrufargumente parsen. Typer-CLI macht an dieser Stelle eine CLI-Applikation aus unserem Code, sodass dieser mit den übergebenen Parametern in der Pipeline laufen kann.

pyproject.toml:

Das ist die zentrale Konfigurationsdatei für unser Projekt und wird bei einem Poetry-Setup vorausgesetzt. Hier ist ein Beispiel, wie die Datei in unserem Projektkontext aussehen könnte:

[tool.poetry]
name = "databricks_bundle_example"
version = "0.1.0"
packages = [{ include = "src" }]
authors = ["denis.khaskin@codecentric.de"]
[tool.poetry.scripts]
demo_script = 'src.databricks_bundle_demo.main:main'

[tool.poetry.dependencies]
python = "^3.11"
loguru = "^0.7.2"
databricks-connect = "15.4.2"

[tool.poetry.group.dev.dependencies]
pre-commit = "^3.6.2"
freezegun = "1.5.1"
ruff = "^0.3.7"

[tool.ruff]
line-length = 120
exclude = ["*.ipynb"]

[tool.ruff.format]
quote-style = "single"
indent-style = "space"
docstring-code-format = false

[tool.ruff.lint]
unfixable = ["F401"] # E.g. disable fix for unused imports (`F401`).

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Der Eintrag demo_script korrespondiert zu dem Eintrag entry_point in dem entsprechenden Task in der Jobdefinition, sodass der auszuführende Code zur Laufzeit gefunden wird.

Wie richte ich eine GitLab CI/CD-Pipeline für Databricks ein?

Bevor wir eine Pipeline in GitLab aufsetzen ist es wichtig eine Authentifizierung Methode festzulegen, mit der sich die Pipeline gegenüber Databricks authentifizieren soll, um den Code auszurollen und den Job zu starten. Wir verwenden dazu OAuth2-konform ein Service Principal, der für automatisierte Deployments mit GitLab CI/CD genutzt wird. Wichtig ist, dass dieses direkt im Databricks Portal und nicht im Portal des jeweiligen Cloud-Anbieters erstellt wird. Dazu wechselt man in Databricks zu den Einstellungen (rechts oben auf den Nutzer klicken). Dann navigiert man zu "Identity and access-Service principals-Manage". Dort kann man ein neues Service principal erstellen. Ist es erstellt, wechselt man zu "Secrets" und erstellt ein neues secret. Die erzeugten Secret-Daten sollte man sich sofort speichern, sie werden später nicht mehr angezeigt. Anschließend müssen die erzeugten Secret-Daten in GitLab (oder einem anderen Anbieter der Wahl) als Variablen hinterlegt werden. Folgend ein Beispiel wie es in GitLab aussehen könnte:

KeyValueMaskedExpandedEnvironments
DATABRICKS_CLIENT_ID*****NeinJaAll (default)
DATABRICKS_CLIENT_SECRET*****JaJaAll (default)
DATABRICKS_HOST*****NeinJaAll (default)
DATABRICKS_SERVERLESS_WAREHOUSE_ID*****NeinJaAll (default)

Die ersten beiden Variablen stammen direkt aus dem erzeugten secret. Databricks Host kann bei dem jeweiligen Cloud-Anbieter oder direkt in der URL des Portals eingesehen werden. Für die Warehouse-ID navigiert man im Portal zu SQL-warehouses, klickt auf das genutzte (sofern bereits erstellt, sonst ein neues erstellen), die ID steht direkt hinter dem Namen.

Sind die Variablen gesetzt können wir die CI/CD-Pipeline aufbauen. Nachfolgend das Beispiel einer .gitlab-ci.yml (ich gehe bewusst nicht auf alle Details ein, sondern setze hier ein Grundwissen voraus):

image: gitlab.codecentric.de:4567/data_ml_ai/databricks_pipeline_build_image:latest

stages:
  - check
  - unittest
  - build
  - deploy_dev
  - deploy_prod

python-lint-and-format:
  stage: check
  before_script:
    - poetry install
  script:
    - poetry run pre-commit run --all-files
  rules:
    - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "merge_request_event"'

validate-bundle:
  stage: check
  script:
    - databricks bundle validate
  rules:
    - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "merge_request_event"'

unit-tests:
  stage: unittest
  before_script:
    - poetry install
  script:
    - poetry run pytest --capture=no .
  rules:
    - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "merge_request_event"'

build:
  stage: build
  before_script:
    - poetry install
  script:
    - poetry build
  artifacts:
    paths:
      - dist
  rules:
    - if: '$CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"'

debug-auth:
  stage: check
  script:
    - echo $DATABRICKS_HOST
    - echo $DATABRICKS_CLIENT_ID
    - echo ${#DATABRICKS_CLIENT_SECRET} # nur Länge anzeigen
    - echo $DATABRICKS_TENANT_ID
  rules:
    - when: always

rollout-dev:
  stage: deploy_dev
  environment:
    name: dev
  script:
    - databricks version
    - databricks auth describe
    - databricks bundle deploy --target dev --var="branch_ref=$CI_COMMIT_REF_SLUG" --force --auto-approve

  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"'

run-dev:
  stage: deploy_dev
  needs:
    - rollout-dev
  environment:
    name: dev
  script:
    - databricks bundle run databricks_bundle_demo_job --target dev --var="branch_ref=$CI_COMMIT_REF_SLUG"
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"'

deploy-prod:
  stage: deploy_prod
  environment:
    name: prod
  script:
    - databricks bundle deploy --target prod
  rules:
    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $CI_PIPELINE_SOURCE == "push"'

Man beachte, dass hier ein selbst gebautes Docker image verwendet wird, in dem Databricks-CLI und andere Abhängigkeiten bereits installiert sind. Man erkennt, dass die Pipeline nicht nur das Projekt baut, sondern auch im Databricks Workspace ausrollt und den Job startet. Dazu ist das erstellte Service Principal notwendig. Nach einem erfolgreichen Lauf sollte man im Portal unter "Workflows" den entsprechenden Eintrag mit dem Tag gitlab_deployment (oder je nachdem was man verwendet) sehen können. Geht man weiter in die Jobdetails können einzelne Tasks und ihre Ergebnisse eingesehen werden.

Fazit

An dem hier vorgestellten Aufbau sieht man, dass es relativ einfach ist, ein Datenprojekt mit Databricks aufzusetzen. In Kombination mit den Portalwerkzeugen wie Notebooks oder SQL-Editor ist es sehr einfach eine Funktionalität zu entwickeln: Man kann nahezu alles ausprobieren, bevor man es ins Repository eincheckt. Auf der anderen Seite bieten Databricks Asset Bundles die Möglichkeit, Code unter modernen Softwarearchitektur-Prinzipien zu entwickeln – mit klarer Trennung von Umgebungen, CI/CD-Automatisierung und reproduzierbaren Serverless Workflows.

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//
Jetzt für unseren Newsletter anmelden

Alles Wissenswerte auf einen Klick:
Unser Newsletter bietet dir die Möglichkeit, dich ohne großen Aufwand über die aktuellen Themen bei codecentric zu informieren.