Imperativ vs. Deklarativ

Dieser Beitrag ist meine Zusammenfassung (in meinem eigenen Interesse) des ursprünglichen Beitrags in voller Länge: https://leebriggs.co.uk/blog/2022/07/20/nobody-knows-what-declarative-is

Table of Contents

Immer wieder stösst man im Bereich Infrastructure as Code (IaC) auf die Begriffe imperativ und deklarativ. Doch was bedeuten diese Begriffe? Ist Terraform wirklich deklarativ und Ansible nicht? Diese beiden Begriffe sind nicht einfach Buzzwords, sondern wesentliche Design-Patterns von IaC.

Glossar: Imperativ / Deklarativ

Beim deklarativen Ansatz definiert der Entwickler den Endzustand und lässt das IaC Tool ermitteln, wie dieser Zustand erreicht werden soll. Im Gegensatz dazu definiert beim imperativen Ansatz der Entwickler den Prozess, mit welchem vom Ist-Zustand aus der Soll-Zustand erreicht wird. Der Hauptunterschied ist, dass imperative Programme einen Control-Flow haben und deklarative nicht. Das lässt sich einfach herausfinden indem man die IaC Anweisungen. Verfügen diese über Error-Checks und Überprüfungen ob etwas korrekt ausgeführt wurde, dann handelt es sich um einen Control-Flow.

Ein einfaches Beispiel einer imperativen Anweisung:

if cluster_exists:
  echo "you already created that cluster"
else:
  echo "I'm creating a cluster for you"

Auf der anderen Seite verfügt der deklarative Ansatz über keinen Control-Flow, das braucht es dort gar nicht, weil sich Terraform selbst um die Logik und den Control-Flow kümmert, deshalb muss sich der Anwender keine Gedanken darüber machen wie der Soll-Zustand erreicht wird, er muss nur definieren wie der Soll-Zustand am Ende sein soll.

Ein einfaches Beispiel einer deklarativen Anweisung:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.16"
    }
  }

  required_version = ">= 1.2.0"
}

provider "aws" {
  region  = "us-west-2"
}

resource "aws_instance" "app_server" {
  ami           = "ami-830c94e3"
  instance_type = "t2.micro"

  tags = {
    Name = "ExampleAppServerInstance"
  }
}

Der deklarative Ansatz reduziert den Code Umfang erheblich und vermindert damit auch die Fehleranfälligkeit. Zudem muss man sich nicht selbst mit dauern ändernden Cloud APIs herumschlagen.

Erwartung und Vorurteil

Die meisten Leute mit denen man über IaC spricht haben die Tendenz zu glauben das ein IaC Tool deklarativ sein muss. Was sehr komisch ist, sind aussagen wie:

Terraform ist deklarativ, Pulumi, Ansible und AWS CDK nicht, deshalb ist Terraform besser als die anderen.

Leute mit solchen Aussagen verraten das sie keine Ahnung haben wovon sie sprechen.

Der Irrtum

Das Hauptproblem in der ganzen imperativ vs. deklarativ Debatte besteht darin, das die meisten Leute meinen, wenn man eine Configurations-Sprache, wie zum Beispiel die DSL Sprachen von Terraform, HCL verwendet das automatisch etwas deklarativ macht.

IaC Tools wie Terraform, Ansible, Pulumi, AWS CDK usw. machen sich alle DAG zu nutzen. DAG steht für Directed acyclic graph:

Ein Directed Acyclic Graph (DAG) ist eine abstrakte Struktur, die aus Knoten und Kanten besteht. Die Kanten bilden die Verbindungen zwischen den Knoten und besitzen eine Richtung. Schleifen sind in der Struktur ausgeschlossen. Folgt man der Richtung der Kanten, gelangt man von einem Startpunkt (Startknoten) zu einem Zielknoten und niemals zurück an den Ausgangsknoten. Es entsteht eine topologische Ordnung. Mit DAGs lassen sich beispielsweise kausale Zusammenhänge gut darstellen.  

Pulumi, und Terraform erstellen nicht nur ein DAG, sondern ermöglichen es auch, das erstellte Diagramm zu untersuchen! Pulumi verfügt über den Pulumi-Stack-Graph-Befehl und Terraform über den Terraform-Graph-Befehl.

Dieses Diagramm wird erstellt, wenn man ein Terraform- oder Pulumi-Programm erstellt, und wird dann von den Engines des jeweiligen Tools ausgeführt, die dieses Diagramm bei jeder Tool-Instanziierung idempotent ausführen.

Idempotent bedeutet, dass man dieselben Ergebnisse erwarten kann, wenn man immer wieder dasselbe ausführt. Jedes Infrastruktur als Code-Tool ist (wenn man es korrekt einsetzt) deklarativ und idempotent. Konfigurationsmanagement-Tools sind im Allgemeinen idempotent, aber nicht unbedingt deklarativ.

Wieso glauben aber so viele Leute das Pulumi, Ansible und CDK imperativ sind? Die Antwort ist, das die meisten Leute dabei nicht an das eigentliche Tool denken, sondern nur an die Sprache welche verwendet wird. Konfigurationssprachen machen deklarative Zustände leicht verständlich, da Sie keine Bedingungen in Konfigurationssprachen verwenden können, ohne eine Templating Sprache zu verwenden, oder grosse Änderungen an einer DSL vorzunehmen.

Mit Ansible, Pulumi und AWS CDK kann man jedoch Bedingungen nach Herzenslust verwenden, da sie imperative Sprachen als primäre Authoring-Erfahrung verwenden.

Nehmen wir das folgende Snippet von Ansible code als Beispiel:

  - name: Set super_group variable for RedHat
    set_fact:
      super_group: wheel
    when: ansible_os_family == "RedHat"  
  - name: Set super_group variable for Debian
    set_fact:
      super_group: sudo
    when: ansible_os_family == "Debian"
  - name: Add local linux user {{ username }}
    user:
      name: "{{ username }}"
      groups: "{{ super_group }}"
      append: true
      password_lock: true
      shell: /bin/bash
      create_home: true
      generate_ssh_key: true
      state: present

Hier kann man sehen, das eine when Bedingung definiert ist und dort entschieden wird, ob die Variable super_group: sudo oder wheel gesetzt wird, oder garnicht. Anschliessend wird ein neuer Linux User erstellt. Es handelt sich in offensichtlich insgesamt um eine imperative Operation, zuerst wird anhand der OS Family eine Variable gesetzt und dann der Benutzer erstellt. Aber damit nicht genug: das Resultat, dieser imerpativen Operation ist deklarativ. Damit versuchen wir den Irrtum nun aufzulösen: Pulumi und Ansible und wie die Tools alle heissen sind als solches, (als Tool) alle deklarativ. Die Sprache jedoch, in welcher man die Infrastruktur beschreibt wird bei Pulumi und Ansible in einer imperativen Form geschrieben.

Das Problem mit imperativen Anweisungen

Das Problem bei manchen Ansible Playbooks ist, dass diese nicht idempotent geschrieben wurden. Was bitte schön ist Idempotenz? Idempotenz bezeichnet die Unveränderbarkeit des Ergebnisses bei einer mehrfachen Verknüpfung oder Funktionsanwendung. Idempotenz ist erreicht, wenn ein IaC-Task nach wiederholtem Ausführen immer dasselbe Resultat erzeugt. Erst durch idempotente Tasks wird ein Gesamtautomatisierungsprozess auch für mehrere Ausführungen robust und zuverlässig.

Wenn man unsorgfältige Ansible Tasks verfasst, kann das dazu führen, dass diese bei wiederholtem ausführen nicht dieselben Resultate erzeugen, im Vergleich zu Terraform, wo dies aufgrund der strikten DSL nicht passieren kann.

Das Problem mit deklarativen Anweisungen

Im Gegensatz dazu gibt es bei Terraform keine wirklich ausgereifte Möglichkeit den Control-Flow zu bearbeiten. Es gibt zwar mechanismen wie das count argument oder ein for_each, das führt dann aber beispielsweise zu folgendem Code:

count = var.enabled ? 1 : length([some list of resources or datasources])

Diese Anweisung ist nichts anderes als die when oder state Anweisung im oberen Ansible Beispielcode. Nur ist dieser Code hier deutlich schwerer lesbar und wenige verständlich.

Meine Ansicht

Anstatt imperativ und deklarativ mit falsch, richtig, besser oder schlechter zu bewerten, sollten wir uns bewusst werden, das wir in den meisten Fällen im IaC Bereich immer mit beidem zu tun haben. Je nach Tool oder Configurations Sprache direkter oder indirekter. Letztendlich erlauben Tools wie Ansible und Pulumi aus meiner Sicht höchste flexibilität und Anpassbarkeit des Control-Flows. Tools wie Terraform hingegen sind ungeeignet für mofizierungen des Control-Flows. Deshalb macht es meiner Meinung nach am meisten Sinn, das Tool oder die Konfigurations-Sprache zu verwenden, welche für den jeweiligen Use-Case am besten geeignet ist. Grundsätzlich lässt sich jede Aufgabe mit entweder dem imperativen oder deklarativen Ansatz lösen. Es gibt aber Aufgaben, die deutlich einfacher mit dem einen oder dem anderen Ansatz gelöst werden können. Es gilt daher situativ zu entscheiden, welcher Ansatz sich für welche Aufgaben am besten eignet und diese dann geschickt zu kombinieren. Beispielsweise kann man Terraform einsetzen, um Basis-VMs direkt von einem Template ausgehend zu erstellen. Nachfolgend kann man dann z.b. je nach OS Familie Ansible einsetzen, um Software-Installationen und Konfigurationen auf dem VMs vorzunehmen.