Niet beschikbaar voor vaste aanstelling — alleen inzetbaar via Rubicon. Neem contact met mij als je geintresseerd bent.

Infrastructure as Code voor Kubernetes: Terraform, Bicep en GitOps vergeleken

Infrastructure as Code voor Kubernetes: Terraform, Bicep en GitOps vergeleken

Er is een veelgemaakte denkfout bij het beginnen met Infrastructure as Code voor Kubernetes: men denkt dat IaC en Kubernetes hetzelfde zijn, of dat een van de twee de ander vervangt. Dat is niet zo.

Kubernetes beheert je workloads — de applicaties, services en configuraties die draaien in een cluster. Infrastructure as Code beheert de infrastructuur zelf — het cluster, de netwerken, de databases, de identiteiten, de firewall-regels. Beide zijn nodig, en ze vullen elkaar aan.

Dit artikel legt uit hoe je die lagen kunt verdelen, welke tools ervoor bestaan en hoe je ze combineert in een werkende aanpak voor AKS.

De twee lagen van “alles als code”

Het helpt om een helder onderscheid te maken:

Laag 1: Clusterinfrastructuur Het AKS-cluster zelf, de bijbehorende Azure-resources (VNet, subnets, NSG’s, ACR, Key Vault, managed identities, Log Analytics workspace), en de node pools. Dit zijn Azure-resources die je aanmaakt en beheert via Azure-API’s.

Tools voor deze laag: Terraform, Bicep, OpenTofu, Pulumi.

Laag 2: Cluster-inhoud (workloads) Namespaces, Deployments, Services, ConfigMaps, Ingresses, RBAC-objecten. Dit zijn Kubernetes-objecten die je beheert via de Kubernetes API.

Tools voor deze laag: Helm, Kustomize, Flux, Argo CD — en ook Terraform via de Kubernetes- en Helm-providers.

De grens tussen de lagen is niet altijd scherp. Sommige teams beheren alles in Terraform, andere splitsen strikt. Verderop in dit artikel kom je de trade-offs tegen.

Terraform voor AKS-infrastructuur

Terraform is de meest gebruikte IaC-tool voor Azure-infrastructuur in organisaties die multi-cloud of cloud-agnostisch willen werken. De azurerm-provider is volwassen en goed gedocumenteerd.

Een basismodule voor een AKS-cluster:

resource "azurerm_resource_group" "aks" {
  name     = "rg-${var.cluster_name}-${var.environment}"
  location = var.location
}

resource "azurerm_kubernetes_cluster" "main" {
  name                = "aks-${var.cluster_name}-${var.environment}"
  location            = azurerm_resource_group.aks.location
  resource_group_name = azurerm_resource_group.aks.name
  dns_prefix          = var.cluster_name
  kubernetes_version  = var.kubernetes_version

  default_node_pool {
    name                = "system"
    node_count          = 2
    vm_size             = "Standard_D2s_v5"
    os_disk_size_gb     = 50
    type                = "VirtualMachineScaleSets"
    zones               = ["1", "2", "3"]
    enable_auto_scaling = true
    min_count           = 2
    max_count           = 5

    upgrade_settings {
      max_surge = "33%"
    }
  }

  identity {
    type = "SystemAssigned"
  }

  network_profile {
    network_plugin    = "azure"
    network_policy    = "calico"
    load_balancer_sku = "standard"
    outbound_type     = "loadBalancer"
  }

  oms_agent {
    log_analytics_workspace_id = azurerm_log_analytics_workspace.aks.id
  }

  tags = {
    environment = var.environment
    team        = var.team
    managed_by  = "terraform"
  }
}

# Separate node pool voor workloads
resource "azurerm_kubernetes_cluster_node_pool" "workload" {
  name                  = "workload"
  kubernetes_cluster_id = azurerm_kubernetes_cluster.main.id
  vm_size               = "Standard_D4s_v5"
  enable_auto_scaling   = true
  min_count             = 1
  max_count             = 20
  zones                 = ["1", "2", "3"]

  node_taints = ["workload=true:NoSchedule"]
  node_labels = {
    "nodepool" = "workload"
  }
}

De variabelen staan in een apart bestand:

# variables.tf
variable "cluster_name" {
  description = "Naam van het AKS-cluster"
  type        = string
}

variable "environment" {
  description = "Omgeving (dev, staging, prod)"
  type        = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Omgeving moet dev, staging of prod zijn."
  }
}

variable "kubernetes_version" {
  description = "Kubernetes-versie"
  type        = string
  default     = "1.30"
}

Per omgeving heb je een terraform.tfvars-bestand:

# environments/prod.tfvars
cluster_name       = "mijnbedrijf"
environment        = "prod"
location           = "westeurope"
kubernetes_version = "1.30"
team               = "platform"

Terraform state beheren

Terraform slaat de huidige toestand op in een state-bestand. Dit mag nooit lokaal staan — gebruik altijd een remote backend:

terraform {
  backend "azurerm" {
    resource_group_name  = "rg-terraform-state"
    storage_account_name = "stterraformstate"
    container_name       = "tfstate"
    key                  = "aks-prod.tfstate"
  }
}

Gebruik state-locking (ingebouwd in de Azure backend) en bewaar de state-storage buiten de clusters die erin worden beschreven.

Bicep voor Azure-teams

Bicep is Microsoft’s eigen IaC-taal voor Azure. Het compileert naar ARM-templates en is diep geintegreerd in Azure DevOps en GitHub Actions. Als je puur op Azure werkt en geen multi-cloud behoefte hebt, is Bicep een serieuze keuze.

Hetzelfde AKS-cluster in Bicep:

param clusterName string
param location string = resourceGroup().location
param kubernetesVersion string = '1.30'
param environment string

resource aksCluster 'Microsoft.ContainerService/managedClusters@2024-02-01' = {
  name: 'aks-${clusterName}-${environment}'
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    kubernetesVersion: kubernetesVersion
    dnsPrefix: clusterName
    agentPoolProfiles: [
      {
        name: 'system'
        count: 2
        vmSize: 'Standard_D2s_v5'
        mode: 'System'
        enableAutoScaling: true
        minCount: 2
        maxCount: 5
        availabilityZones: ['1', '2', '3']
      }
    ]
    networkProfile: {
      networkPlugin: 'azure'
      networkPolicy: 'calico'
      loadBalancerSku: 'standard'
    }
  }
  tags: {
    environment: environment
    managedBy: 'bicep'
  }
}

output clusterName string = aksCluster.name
output clusterId string = aksCluster.id

Terraform versus Bicep — wanneer wat?

  Terraform Bicep
Multi-cloud Ja Nee, alleen Azure
Azure-diepte Goed Uitstekend
Community Groot Groter voor Azure-teams
Leesbaarheid Goed Goed
State-beheer Eigen state-file Via ARM, geen eigen state
Ecosysteem Uitgebreid (modules, providers) Groeiend (Azure Verified Modules)

Als je organisatie puur Azure is en het platform-team sterke Azure DevOps-integraties wil, is Bicep de betere keuze. Als je multi-cloud werkt of de brede Terraform-community wilt benutten, kies dan Terraform.

GitOps voor cluster-inhoud

Nadat je cluster staat, wil je de inhoud — namespaces, deployments, RBAC — ook als code beheren. Hier komt GitOps in beeld.

GitOps is een aanpak waarbij Git de enige bron van waarheid is voor de gewenste cluster-toestand. Een operator in het cluster (Flux of Argo CD) synchroniseert continu de cluster-toestand met wat in Git staat. Als iemand handmatig iets aanpast in het cluster, trekt de operator het terug.

Flux is de meest gebruikte GitOps-operator voor Kubernetes. Het is een CNCF-project en integreert goed met Helm.

Een Flux-setup voor een namespace met Helm releases:

# GitOps repository structuur
clusters/
  prod/
    flux-system/          # Flux eigen configuratie
    apps/
      kustomization.yaml  # Welke apps worden gemonitored
namespaces/
  payments-prod.yaml
apps/
  payments-api/
    helmrelease.yaml
    values-prod.yaml
# namespaces/payments-prod.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: payments-prod
  labels:
    team: payments
    environment: production
# apps/payments-api/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
  name: payments-api
  namespace: payments-prod
spec:
  interval: 5m
  chart:
    spec:
      chart: ./helm/payments-api
      sourceRef:
        kind: GitRepository
        name: payments-api-repo
        namespace: flux-system
  valuesFrom:
  - kind: ConfigMap
    name: payments-api-values-prod

Elke wijziging in Git — een nieuwe image-tag, een aangepaste resource-limiet, een extra environment variable — wordt automatisch toegepast in het cluster. Rollback is een git revert.

De combinatie: Terraform + Flux

De aanbevolen aanpak voor de meeste teams:

  • Terraform voor de clusterinfrastructuur: het cluster zelf, de Azure-resources, de node pools, de managed identities
  • Flux voor de cluster-inhoud: namespaces, Helm releases, RBAC, monitoring
Git Repository (infrastractuur)          Git Repository (workloads)
+---------------------------+            +---------------------------+
|  terraform/               |            |  clusters/                |
|    main.tf                |            |    prod/                  |
|    variables.tf           |  Cluster   |      apps/                |
|    environments/          +----------->|        kustomization.yaml |
|      prod.tfvars          |  staat     |  namespaces/              |
+---------------------------+            |  apps/                    |
         |                               +---------------------------+
         | terraform apply                          |
         v                                          | Flux sync
   AKS-cluster                              Cluster-inhoud
   VNet, ACR, etc.

Sommige teams houden beide repositories gescheiden. Andere combineren ze. Dat is een keuze die afhangt van hoe je teams zijn georganiseerd en wie eigenaar is van welke laag.

Veelgemaakte fouten en hoe je ze voorkomt

Secrets in Git

De grootste fout bij IaC: wachtwoorden, connection strings of API-sleutels opslaan in je Git-repository, al dan niet in een Terraform-variabele of YAML-bestand.

Oplossing: gebruik de External Secrets Operator om secrets vanuit Azure Key Vault te injecteren in Kubernetes, en gebruik Key Vault-referenties in Terraform:

resource "azurerm_key_vault_secret" "db_password" {
  name         = "db-password-prod"
  value        = var.db_password   # Wordt nooit in Git opgeslagen
  key_vault_id = azurerm_key_vault.main.id
}

De variabele var.db_password geef je mee als omgevingsvariabele in de pipeline (TF_VAR_db_password), nooit als hardcoded waarde.

Handmatige wijzigingen naast IaC

Als je een resource aanmaakt via de Azure Portal naast Terraform, raakt de state uit sync. De volgende keer dat Terraform runt, weet het niet wat het moet doen met de handmatig aangemaakte resource.

Oplossing: zorg dat iedereen in het team de afspraak kent — niets in productie dat niet via IaC loopt. Gebruik Azure Policy om handmatige wijzigingen te detecteren en te rapporteren.

Geen module-structuur

Een enkel groot main.tf bestand met alle resources voor alle omgevingen wordt snel onbeheersbaar.

Geef je Terraform-repository structuur:

terraform/
  modules/
    aks-cluster/     # Herbruikbare AKS-module
    networking/      # VNet, subnets, NSG's
    monitoring/      # Log Analytics, dashboards
  environments/
    dev/
      main.tf        # Roept modules aan
      terraform.tfvars
    prod/
      main.tf
      terraform.tfvars

Modules maken je configuratie herbruikbaar en testbaar. De omgevingsspecifieke configuratie bevat alleen de parameters, niet de implementatiedetails.

State-bestanden in Git

Soms zie je dit in repositories van beginners: het terraform.tfstate-bestand is gecommit. Dit bestand bevat alle resource-IDs, outputs en soms gevoelige waarden. Het hoort nooit in Git.

Zorg dat .gitignore dit afdekt:

# .gitignore
*.tfstate
*.tfstate.*
.terraform/
*.tfvars.json     # Kan gevoelige waarden bevatten

IaC in een CI/CD-pipeline

Terraform-wijzigingen moeten via een pipeline lopen, niet handmatig via de laptop van een engineer. Een basisopzet in Azure DevOps:

# azure-pipelines.yml
stages:
- stage: Validate
  jobs:
  - job: TerraformValidate
    steps:
    - task: TerraformInstaller@1
      inputs:
        terraformVersion: '1.8.0'
    - task: TerraformTaskV4@4
      inputs:
        provider: 'azurerm'
        command: 'init'
        backendServiceArm: 'terraform-state-service-connection'
    - task: TerraformTaskV4@4
      inputs:
        command: 'validate'
    - task: TerraformTaskV4@4
      inputs:
        command: 'plan'
        commandOptions: '-var-file=environments/prod.tfvars -out=tfplan'

- stage: Apply
  dependsOn: Validate
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - deployment: TerraformApply
    environment: 'production'     # Vereist handmatige goedkeuring
    strategy:
      runOnce:
        deploy:
          steps:
          - task: TerraformTaskV4@4
            inputs:
              command: 'apply'
              commandOptions: 'tfplan'

De environment: 'production' stap in Azure DevOps vereist dat een goedkeurder de apply bevestigt. Niemand past productie-infrastructuur aan zonder een tweede paar ogen.

Conclusie

Infrastructure as Code voor Kubernetes is geen keuze tussen tools, maar een bewuste indeling van verantwoordelijkheden:

  • Terraform of Bicep voor de Azure-laag (het cluster en de omliggende resources)
  • Flux of Argo CD voor de Kubernetes-laag (de workloads en configuraties)
  • Git als de enige bron van waarheid voor beide lagen
  • Een CI/CD-pipeline die wijzigingen valideert en toepast met goedkeurings-stappen

Begin klein: zet je bestaande AKS-cluster om naar Terraform met behulp van terraform import, en voeg daarna Flux toe voor de workloads. Je hoeft niet alles tegelijk te migreren.

De eerste keer IaC opzetten kost tijd. De tweede keer een omgeving aanmaken kost minuten. Daar zit de winst.

Volgende stappen

  • Nog niet begonnen met AKS? Lees

Snel aan de slag met AKS


Jean-Paul van Houten-Bos is DevOps Engineer gespecialiseerd in AKS en cloud-native architecturen. Hij begeleidt enterprise-teams bij de overgang van handmatige infrastructuur naar volledig geautomatiseerde IaC-pipelines.