Azure DevOps in zes lessen
Gericht op data engineering, niet op applicatieontwikkeling. Van Repos en branching tot YAML-pipelines die dbt, Databricks en Fabric uitrollen, met omgevingen, approvals en veilig omgaan met geheimen.
Les 1: De onderdelen van Azure DevOps
Vijf diensten onder één naam
Azure DevOps is geen enkele tool maar een verzameling diensten die je los kunt gebruiken. Voor data engineering heb je er in de praktijk drie van nodig, en de andere twee kom je vooral tegen in teams die er al mee werken.
- Repos — Git-repositories met pull requests en branch policies. Hier staat je dbt-project, je notebooks of je Terraform.
- Pipelines — bouwen, testen en uitrollen. Dit is waar je de meeste tijd doorbrengt.
- Artifacts — pakketten en build-uitvoer bewaren. Voor data vooral handig om Python-wheels of dbt-artefacten tussen pipelines door te geven.
- Boards — werk plannen met backlogs en sprints.
- Test Plans — handmatig testbeheer. Bij datawerk zelden gebruikt.
Waarom dit bij datawerk hoort
Een datawarehouse is software. Transformaties zijn code, een pipeline-definitie is code, en de inrichting van je platform kan code zijn. Zodra dat zo is, horen dezelfde gewoonten erbij als bij elke andere codebase: reviews, geautomatiseerde tests en een uitrol die reproduceerbaar is.
Organisatie, project en repository
De hiërarchie is drie lagen diep. Een organisatie is je bedrijf. Daarbinnen maak je projecten, en elk project heeft zijn eigen repositories, pipelines en boards. Rechten regel je op projectniveau.
Voor een datateam werkt één project met meerdere repositories meestal prettiger dan een project per repository: je pipelines kunnen dan makkelijk templates en variabelengroepen delen.
Let op de parallelle jobs
Een gratis organisatie krijgt één door Microsoft gehoste parallelle job, en nieuwe organisaties moeten die zelfs eerst aanvragen. Draaien er twee pipelines tegelijk, dan staat de tweede te wachten. Bij een datateam dat 's nachts meerdere laadprocessen draait, loop je daar snel tegenaan; dan is een self-hosted agent of een betaalde parallelle job de uitweg. Les 6 gaat daarover.
Oefening: wat heb je nodig?
Situatie: een datateam van vier mensen werkt met een dbt-project op Snowflake en een handvol Databricks-notebooks. Nu staat alles in een gedeelde netwerkmap en wordt er met de hand gedeployd.
Opdracht:
- Welke van de vijf diensten heeft dit team echt nodig, en welke kan het overslaan?
- Maak je één project of twee? Waar hangt dat van af?
- Zet je dbt en de notebooks in één repository of in twee?
- Wat is het eerste dat je inricht, nog vóór enige pipeline?
Richting: Repos en Pipelines zijn onmisbaar, Artifacts komt later van pas, Boards alleen als ze hun werk nu nergens anders plannen, Test Plans niet. Eén project met twee repositories: dbt en notebooks hebben een eigen levenscyclus en een eigen pipeline, maar delen wel variabelengroepen en service connections. Begin met de repository en een branch policy op main — zonder dat laatste kan iedereen alsnog rechtstreeks naar main duwen en heb je alleen een duurdere netwerkmap.
Les 2: Repos, branching en pull requests
Een vertakkingsmodel dat bij datawerk past
De meeste datateams zijn klein en leveren vaak. Daar past trunk-based development beter bij dan een zwaar model met release- en hotfixbranches: je werkt op korte takken vanaf main en voegt dagelijks samen.
Wat je vooral wilt vermijden is een tak die weken openstaat. Bij transformatiecode leidt dat gegarandeerd tot conflicten, want iedereen raakt dezelfde modellen aan.
Naamgeving die zichzelf uitlegt
Gebruik een voorvoegsel plus het werkitemnummer: feature/1234-omzet-per-segment of fix/1240-dubbele-orders. Azure DevOps koppelt de tak dan automatisch aan het werkitem, en in je historie zie je later terug waaróm iets veranderde.
Branch policies: de knop die het verschil maakt
Een repository zonder beleid op main is een netwerkmap met versiegeschiedenis. Met branch policies dwing je af dat er niemand rechtstreeks naar main duwt en dat elke wijziging langs een pull request gaat.
Zet minimaal deze drie aan: minstens één reviewer, een build die moet slagen, en gekoppelde werkitems. De eerste zorgt dat iemand meekijkt, de tweede dat kapotte code main niet haalt, de derde dat je over een jaar nog kunt achterhalen waarom een model eruitziet zoals het eruitziet.
# Altijd vanaf een actuele main beginnen
git checkout main
git pull
git checkout -b feature/1234-omzet-per-segment
# ... je wijzigingen ...
git add models/marts/fct_omzet_segment.sql
git commit -m "Omzet per klantsegment toegevoegd aan de gold-laag"
git push -u origin feature/1234-omzet-per-segment
# Pull request aanmaken vanaf de opdrachtregel
az repos pr create \
--title "Omzet per klantsegment" \
--description "Nieuwe mart voor de kwartaalrapportage. Tests toegevoegd." \
--source-branch feature/1234-omzet-per-segment \
--target-branch main \
--work-items 1234
Kies squash merge
Bij squash merge wordt je hele tak één commit op main. Je historie blijft daarmee leesbaar: één regel per wijziging in plaats van vijftien tussencommits met berichten als "fix" en "nog een poging". Je kunt dat per repository afdwingen in de policies.
Wat hoort niet in je repository
Bij datawerk is de verleiding groot om even een CSV of een exportje mee te committen. Doe dat niet: Git is er niet voor gemaakt en je krijgt het er nooit meer uit. Een .gitignore die profielen, omgevingsbestanden en data buitensluit, hoort bij het aanmaken van de repository.
# Inloggegevens
profiles.yml
.env
# Door dbt gegenereerd
target/
dbt_packages/
logs/
# Data hoort niet in git
*.csv
*.parquet
*.xlsx
!seeds/*.csv # behalve seeds: die zijn klein en bewust beheerd
# Python
__pycache__/
.venv/
Oefening: beleid inrichten
Situatie: een team van vier duwt rechtstreeks naar main. Vorige week ging er een model live met een syntaxfout, en niemand wist wie het had gedaan of waarom. Er staan inmiddels ook drie CSV-bestanden van 80 MB in de repository.
Opdracht:
- Welke drie branch policies zet je als eerste aan, en welk probleem lost elk op?
- Hoe voorkom je dat er nog meer CSV's in belanden?
- De drie bestaande CSV's zijn al gecommit. Waarom is
git rmniet genoeg? - Een teamlid klaagt dat pull requests het werk vertragen. Wat is je antwoord?
Richting: een verplichte reviewer vangt de menselijke blik, een verplichte build vangt de syntaxfout, en gekoppelde werkitems maken achteraf navolgbaar waarom iets veranderde. Nieuwe CSV's hou je buiten met .gitignore. Bestaande bestanden verwijderen met git rm haalt ze uit de huidige stand maar niet uit de historie — de repository blijft even groot en de data is nog steeds terug te halen; daarvoor moet je de historie herschrijven, wat afstemming met het hele team vraagt. Op de laatste vraag: een pull request die een dag blijft liggen vertraagt inderdaad, dus spreek af dat reviews binnen een paar uur gebeuren en houd takken klein. De vertraging zit in grote takken, niet in het reviewen zelf.
Les 3: YAML-pipelines
Pipelines als code
Azure DevOps kent twee soorten pipelines. De klassieke stel je samen in de webinterface; de YAML-variant staat als bestand in je repository. Kies altijd YAML. Je pipeline gaat dan mee in pull requests, je ziet in de historie wie wat wijzigde, en je kunt hem terugdraaien zoals elke andere code.
De opbouw
Een pipeline bestaat uit stages die jobs bevatten, en elke job bestaat uit steps. Stages draaien standaard na elkaar, jobs binnen een stage parallel. Elke job krijgt een schone machine, dus wat de ene job neerzet, is in de volgende weg tenzij je het als artefact doorgeeft.
trigger:
branches:
include: [main]
paths:
include: [models/**, macros/**] # alleen starten als dit wijzigt
pr:
branches:
include: [main]
pool:
vmImage: ubuntu-latest
variables:
- group: dbt-gedeeld # variabelengroep uit Library
- name: pythonVersie
value: '3.11'
stages:
- stage: Controleren
displayName: Bouwen en testen
jobs:
- job: dbt_build
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: $(pythonVersie)
- script: pip install dbt-snowflake==1.8.3
displayName: dbt installeren
- script: dbt deps
displayName: Pakketten ophalen
- script: dbt build --target ci
displayName: Modellen bouwen en testen
env:
SNOWFLAKE_PASSWORD: $(snowflakeWachtwoord)
Gebruik padfilters
Zonder paths start je pipeline ook bij een wijziging in de README. Bij één parallelle job betekent dat wachten voor niets. Beperk de trigger tot de mappen waar echte code staat.
Templates tegen kopieerwerk
Heb je drie repositories die bijna dezelfde stappen draaien, zet die stappen dan in een template. Wijzig je de dbt-versie, dan doe je dat op één plek in plaats van drie.
# In het template
parameters:
- name: doelomgeving
type: string
- name: selectie
type: string
default: ''
steps:
- script: pip install dbt-snowflake==1.8.3
displayName: dbt installeren
- script: dbt deps
displayName: Pakketten ophalen
- script: |
dbt build --target ${{ parameters.doelomgeving }} ${{ parameters.selectie }}
displayName: Bouwen en testen
# In de pipeline die het template gebruikt
steps:
- template: templates/dbt-stappen.yml
parameters:
doelomgeving: ci
selectie: '--select state:modified+'
Oefening: een pipeline opzetten
Situatie: je hebt een dbt-project in Azure Repos. Je wilt dat bij elke pull request wordt gebouwd en getest, en dat er na een merge naar main niets automatisch naar productie gaat — dat moet nog een aparte stap worden.
Opdracht:
- Welke triggers zet je aan, en welke bewust niet?
- Hoe zorg je dat een wijziging in de documentatie geen pipeline start?
- Waarom zet je het wachtwoord niet gewoon in het YAML-bestand?
- Het team heeft ook een Databricks-repository met bijna dezelfde stappen. Wat doe je?
Richting: zet pr aan op main zodat elke pull request wordt gecontroleerd, en trigger op main voor de bouwstap — maar laat de uitrol naar productie in een aparte stage met een approval, zodat er niets vanzelf live gaat. Beperk met paths tot je code-mappen. Het wachtwoord hoort in een variabelengroep of Key Vault: alles in YAML staat in je repository en is daarmee zichtbaar voor iedereen met leesrechten, en het staat voorgoed in de historie. Voor de tweede repository maak je een template met de gedeelde stappen.
Les 4: CI voor dataprojecten
Wat valt er te testen aan data?
Bij applicatiecode draai je unittests. Bij transformatiecode ligt het anders: de logica zit in SQL en het resultaat hangt af van de data. Toch is er genoeg te controleren voordat iets main haalt.
- Compileert het? Draait
dbt compilezonder fouten, dan klopt in elk geval je Jinja en je verwijzingen. - Slagen de tests? Je
unique,not_nullenrelationshipsuit dbt draaien hier. - Is de stijl consistent? Een linter als SQLFluff voorkomt discussies over opmaak in reviews.
- Blijft het bestaande werken? Door alleen wat je wijzigde plus alles erachter te bouwen, zie je of je iets stuk hebt gemaakt.
Alleen bouwen wat veranderde
Een volledig project met tweehonderd modellen bouwen bij elke pull request duurt te lang; dan wacht niemand meer op de uitkomst. Met state:modified+ bouw je alleen de gewijzigde modellen en wat daarvan afhangt. Daar heb je de artefacten van de laatste productierun voor nodig, en die haal je als artefact binnen.
stages:
- stage: CI
jobs:
- job: controleren
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: '3.11'
- script: pip install dbt-snowflake==1.8.3 sqlfluff
displayName: Gereedschap installeren
- script: dbt deps
displayName: Pakketten ophalen
# Artefacten van de laatste productierun binnenhalen,
# zodat we weten wat er gewijzigd is ten opzichte van productie
- task: DownloadPipelineArtifact@2
inputs:
buildType: specific
project: $(System.TeamProject)
definition: $(prodPipelineId)
artifactName: dbt-artifacts
targetPath: $(Pipeline.Workspace)/prod-artifacts
continueOnError: true
- script: dbt compile --target ci
displayName: Compileren
- script: sqlfluff lint models/ --dialect snowflake
displayName: Stijlcontrole
continueOnError: true # waarschuwen, niet blokkeren
- script: |
dbt build --target ci \
--select state:modified+ \
--defer --state $(Pipeline.Workspace)/prod-artifacts
displayName: Gewijzigde modellen bouwen en testen
env:
SNOWFLAKE_PASSWORD: $(snowflakeWachtwoord)
- task: PublishTestResults@2
condition: always() # ook publiceren als de stap faalde
inputs:
testResultsFormat: JUnit
testResultsFiles: target/*.xml
Gebruik condition: always() bij resultaten
Faalt een stap, dan slaat Azure DevOps standaard de rest over. Juist bij een mislukte build wil je de testresultaten zien. Zonder condition: always() krijg je alleen "de pipeline is rood" zonder te weten welke test faalde.
Een schone omgeving per pull request
Laat je CI niet in hetzelfde schema bouwen als je collega's. Geef elke pipelinerun een eigen schema op basis van het build-id, en ruim het achteraf op. Dan kunnen twee pull requests tegelijk draaien zonder elkaar te overschrijven.
variables:
- name: ciSchema
value: 'ci_pr_$(System.PullRequest.PullRequestId)'
steps:
- script: dbt build --target ci
env:
DBT_SCHEMA: $(ciSchema)
# Opruimen, ook als de build faalde
- script: |
echo "DROP SCHEMA IF EXISTS $(ciSchema) CASCADE;" | snowsql -f -
displayName: CI-schema opruimen
condition: always()
Oefening: een CI die niemand gebruikt
Situatie: de CI-pipeline draait bij elke pull request en duurt 38 minuten, want hij bouwt het hele project van 210 modellen. Twee ontwikkelaars die tegelijk een pull request openen, overschrijven elkaars tabellen in het gedeelde CI-schema. Niemand wacht nog op de uitkomst; er wordt gemerged zodra er een reviewer is.
Opdracht:
- Welke twee oorzaken maken deze pipeline onbruikbaar?
- Hoe breng je die 38 minuten omlaag, en wat heb je daarvoor nodig?
- Hoe voorkom je dat twee runs elkaar in de weg zitten?
- Welke policy zorgt dat er niet gemerged kan worden voordat de build groen is?
- De stijlcontrole faalt vaak op oude code. Wat doe je daarmee?
Richting: te traag en niet geïsoleerd. De duur pak je aan met state:modified+ en --defer, waarvoor je de artefacten van de productierun als artefact moet publiceren en binnenhalen. De botsing los je op met een schema per pull request, opgeruimd met condition: always(). Een verplichte build in de branch policy zorgt dat groen een voorwaarde wordt in plaats van een advies. En zet de linter op continueOnError: true zolang de oude code nog niet is bijgewerkt: een waarschuwing die je negeert is beter dan een blokkade die mensen leren omzeilen.
Les 5: Omgevingen, approvals en releases
Van merge naar productie
Een merge naar main hoort niet automatisch productie te raken. Tussen die twee zit een uitrol die je wilt kunnen tegenhouden, inzien en terugdraaien. Daarvoor gebruik je environments met approvals.
Een environment in Azure DevOps is meer dan een naam: het is de plek waar je vastlegt wie moet goedkeuren, op welke momenten er niet uitgerold mag worden, en wat de geschiedenis van uitrollen is.
stages:
- stage: Acceptatie
dependsOn: CI
condition: succeeded()
jobs:
- deployment: naar_acc
environment: acceptatie
strategy:
runOnce:
deploy:
steps:
- checkout: self
- script: dbt build --target acc
env:
SNOWFLAKE_PASSWORD: $(accWachtwoord)
- stage: Productie
dependsOn: Acceptatie
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: naar_prod
environment: productie # hier hangt de approval aan
strategy:
runOnce:
deploy:
steps:
- checkout: self
- script: dbt build --target prod
env:
SNOWFLAKE_PASSWORD: $(prodWachtwoord)
# Artefacten bewaren, zodat de volgende CI kan vergelijken
- publish: target/
artifact: dbt-artifacts
Die condition is belangrijk
Zonder de controle op Build.SourceBranch kan een pipeline die vanaf een feature-branch draait ook de productiestage bereiken. Leg expliciet vast dat alleen main naar productie mag.
Approvals en checks
Op een environment hang je meer dan alleen een goedkeuring. De nuttigste voor datateams:
- Approvals — een of meer mensen moeten akkoord geven. Zet er een toelichting bij zodat de goedkeurder weet waar hij ja op zegt.
- Business hours — alleen uitrollen binnen een venster. Handig om te voorkomen dat er vrijdagmiddag om vijf uur iets live gaat.
- Exclusive lock — maar één uitrol tegelijk naar deze omgeving. Dit voorkomt dat twee runs elkaars tabellen overschrijven.
- Invoke REST API — bijvoorbeeld eerst controleren of het nachtelijke laadproces klaar is.
Terugdraaien vraagt om een plan
Bij applicaties rol je terug door de vorige versie opnieuw uit te rollen. Bij data werkt dat niet altijd: een model dat je tabel heeft overschreven, draai je niet terug door oude code te deployen. Denk vooraf na over wat herstel betekent — Time Travel in Snowflake, een clone vóór de uitrol, of een incrementeel model dat je gericht kunt herbouwen.
Oefening: een uitrol inrichten
Situatie: het team wil van handmatig deployen af. Er is een acceptatieomgeving en een productieomgeving. De financiële afdeling wil dat er niets verandert tijdens de maandafsluiting, de eerste drie werkdagen van de maand. Twee keer is er een uitrol misgegaan doordat er tegelijk een nachtelijk laadproces liep.
Opdracht:
- Welke stages maak je, en welke voorwaarde zet je tussen acceptatie en productie?
- Welke check lost het probleem met het nachtelijke laadproces op?
- Hoe regel je de maandafsluiting? Er is geen check die "niet de eerste drie werkdagen" kent.
- Wat publiceer je als artefact, en waarom heb je dat later nodig?
- Hoe ziet terugdraaien eruit als een model de productietabel al heeft overschreven?
Richting: CI, dan acceptatie, dan productie, met op de productiestage een voorwaarde op main plus een approval. De botsing met het laadproces los je op met een exclusive lock op de omgeving, eventueel aangevuld met een REST-controle of het laadproces klaar is. Voor de maandafsluiting bestaat geen kant-en-klare check: gebruik een business-hours-venster plus een handmatige approval, en leg de afspraak vast bij de goedkeurder — of laat de pipeline zelf een variabele controleren die je tijdens de afsluiting op "nee" zet. Publiceer de map target/ als artefact; die heeft je volgende CI nodig voor --defer. En terugdraaien regel je vooraf: maak een clone van de betrokken tabellen vlak voor de uitrol, zodat herstel een hernoeming is in plaats van een herbouw.
Les 6: Geheimen, verbindingen en agents
Waar wachtwoorden horen
Nooit in je YAML. Alles in je repository is zichtbaar voor iedereen met leesrechten en blijft voorgoed in de historie staan, ook nadat je het eruit haalt. Er zijn drie betere plekken.
- Variabelengroepen in Library, met de waarde als secret gemarkeerd. Prima voor losse wachtwoorden.
- Azure Key Vault, gekoppeld aan een variabelengroep. De waarde staat dan op één plek voor je hele organisatie en je kunt rouleren zonder je pipelines aan te raken.
- Service connections met een workload identity. Dan is er helemaal geen wachtwoord meer.
variables:
- group: snowflake-prod # gekoppeld aan Key Vault
steps:
# GOED: als omgevingsvariabele doorgeven
- script: dbt build --target prod
env:
SNOWFLAKE_PASSWORD: $(snowflakeWachtwoord)
# FOUT: in de opdrachtregel zelf. Dat belandt in de logs
# en is zichtbaar voor iedereen die de run kan bekijken.
# - script: dbt build --password $(snowflakeWachtwoord)
Waarom env en niet de opdrachtregel
Azure DevOps maskeert bekende geheimen in logs, maar dat werkt niet waterdicht: zodra een waarde door een script wordt bewerkt of in een foutmelding terechtkomt, kan hij alsnog zichtbaar worden. Via env komt hij niet in het commando terecht en loop je dat risico niet.
Service connections
Een service connection is de brug tussen je pipeline en een externe dienst: een Azure-abonnement, een Databricks-workspace, een container registry. Voor Azure gebruik je bij voorkeur workload identity federation: je pipeline wisselt dan een kortlevend token in en er is geen geheim meer om te beheren of te lekken.
Beperk wie een verbinding mag gebruiken
Een service connection staat standaard open voor alle pipelines in het project. Zet dat uit voor je productieverbinding en geef hem alleen vrij aan de pipeline die hem nodig heeft. Anders kan iedereen met rechten op een willekeurige pipeline bij je productieomgeving.
Door Microsoft gehost of zelf beheerd
Een door Microsoft gehoste agent is een schone machine die na afloop verdwijnt. Dat is prettig: geen onderhoud, geen resten van een vorige run. Er zijn twee redenen om toch een eigen agent te draaien.
De eerste is netwerktoegang. Staat je bron achter een firewall of in een virtueel netwerk, dan komt een gehoste agent er niet bij. De tweede is wachttijd: met één parallelle job staan je pipelines in de rij, en een eigen agent lost dat op.
# Door Microsoft gehost
pool:
vmImage: ubuntu-latest
# Eigen agent, bijvoorbeeld binnen je netwerk
pool:
name: eigen-agents
demands:
- agent.os -equals Linux
- dbt # zelf gezette eigenschap
# Per stage verschillen kan ook: CI gehost, productie op eigen agent
stages:
- stage: CI
pool:
vmImage: ubuntu-latest
- stage: Productie
pool:
name: eigen-agents
Een eigen agent is onderhoud
Je bent zelf verantwoordelijk voor de machine, de updates en het opruimen tussen runs. Werkruimtes die niet worden opgeschoond, zijn de meest voorkomende oorzaak van een pipeline die "op mijn agent wel werkt". Zet workspace: clean: all in je job als je daar last van hebt.
Oefening: veilig inrichten
Situatie: in het YAML-bestand staat het Snowflake-wachtwoord hardgecodeerd, al negen maanden. De pipeline moet ook een on-premises SQL Server benaderen die achter een firewall staat. De productieverbinding met Azure is beschikbaar voor alle zestien pipelines in het project.
Opdracht:
- Het wachtwoord uit het bestand halen is niet genoeg. Wat moet er nog meer gebeuren?
- Waar zet je het in plaats daarvan, en wat is het verschil tussen de twee opties?
- Hoe bereikt de pipeline die SQL Server achter de firewall?
- Wat doe je met die productieverbinding die voor alles openstaat?
- Hoe voorkom je dat het wachtwoord alsnog in de logs verschijnt?
Richting: het wachtwoord staat negen maanden in je historie, dus het is gecompromitteerd: roteer het, want verwijderen uit het bestand haalt het niet uit eerdere commits. Zet het nieuwe in een variabelengroep, of beter in Key Vault zodat je voortaan kunt rouleren zonder je pipelines te raken. Voor de SQL Server heb je een self-hosted agent binnen dat netwerk nodig; een gehoste agent komt er niet bij. Beperk de productieverbinding tot de pipelines die hem echt nodig hebben. En geef geheimen altijd via env door in plaats van in de opdrachtregel, zodat ze niet in een commando of foutmelding terechtkomen.