Versionierung und Routing in Microservice-Welten

Versionierung ist sowas wie das ungeliebte Kind der Softwareentwicklung. Jeder hat es, aber niemand macht sich wirklich Gedanken drum. Ist bisher ja auch immer gut gegangen, aber sobald wir den tollen neuen Abenteuerspielplatz "Microservices" entdecken sollten wir etwas mehr Acht auf unsere X.Y.Zs geben.

Mal ehrlich. Wie viele Gedanken machst du dir um Versionsnummern?

... 2.3.0-Patch1-1.0.0 ...

Zumindest in einem klassischen Enterprise-Java-Umfeld vermutlich eher wenig: Es gibt eine Deploymenteinheit mit Major.Minor.0 Versionsnummer, Major wird eigentlich nie hochgezogen, Minor bei jedem "major release" *(merkst'e was?)*, fertig. Wenn ein Patch oder Hotfix notwendig sein sollte wird nicht etwa aus der 0 eine 1 sondern man baut enterprisy Konstrukte wie 2.3.0-Patch1-1.0.0.

... Wir könnten auch ♥✯✯♥✓ reinschreiben ...

Ich hoffe sehr, dass du derartige Konstrukte nur aus Erzählungen böser Menschen kennst. Dennoch gibt es viele Systeme, die derart versioniert sind, ich spreche da aus leidvoller Erfahrung. Das überraschende daran: Sie funktionieren. Warum? Weil die Versionsnummer komplett egal für den Client ist. Im Regelfall bekommt der Client eine Schnittstellenbeschreibung (z.B. ein Interface, ein DocType, eine XSD,...) zusammen mit der Bitte Empfehlung Anforderung "nutze dieses Interface in Version x.y.z, und alles wird gut". Damit wird es vollkommen egal, ob und wenn ja welche Bedeutung dieser "Versionsstring" tatsächlich hat. Wir könnten auch ♥✯✯♥✓ reinschreiben.

Enter Microservices.

90%[quotation needed] aller Microservice-Architekturen nutzen REST als Client-Kommunikations-Technologie. Eine typische Architektur sieht dann so aus

Der Client ruft http://umgebung.endpunkt.tld/Service/v1/Ressource auf, der reverse proxy routet auf den passenden internen Host weiter. Nun ist die Schnittstellenversion nicht mehr egal, in der URL steht die Major-Versionsnummer des aufgerufenen Services [^1]. Und zwar nur die Major-Versionsnummer. Zur Laufzeit. Das hat weitreichende Konsequenzen: Der Service-Entwickler muss sich nun Gedanken machen, ob und wann er die Major-Versionsnummer anpassen muss. Letztendlich darf er keine Änderungen machen, die die Kompatibilität zu früheren "v1"-Versionen brechen würde, will er seine neue Version immer noch "v1" nennen.

... eine Pflichtlektüre für jeden (jeden!) Softwareentwickler ...

Diese Vorschrift ist eine der Kernpunkte von semantic versioning [^2] (www.semver.org). Wer mit diesem Begriff noch überhaupt nichts anfangen kann sollte unbedingt die Spezifikation lesen, eine Pflichtlektüre für jeden (jeden!) Softwarenentwickler. Nochmals in der Kurzfassung als Auffrischung:

Ein Softwarepaket, dass eine API bereitstellt, hat eine Versionsnummer der Form `X.Y.Z` oder `X.Y.Z-branch.P`, mit
  • X (major) wird genau dann um 1 erhöht, wenn es inkompatible Änderungen an der APi gab
  • .
  • Y (minor) genau dann, wenn es Änderungen an der Schnittstelle gab, die kompatibel sind.
  • .
  • Z (patch) wird genau dann erhöht, wenn ein neues Paket gebaut wird, das nur interne Änderungen beinhaltet, aber keine Änderung an der Schnittstelle.

Wie mit Branches umzugehen ist (der optionale Teil `-branch.P`) wird auch definiert, ist für diesen Artikel aber erstmal egal.

... wir halten uns strikt an semantic versioning, wie hilft uns das nun? ...

Gehen wir nun davon aus, wir halten uns strikt an semantic versioning, wie hilft uns das nun?
Zunächst mal kann der Service dem Client gewisse Garantien geben: *Wenn du einmal erfolgreich gegen eine /v1/ URL getestet hast, wird das was du tust auch bei allen zukünftigen Serviceversionen funktionieren. Oder du bekommst einen einfach zu erkennenden und behandelnden 404* (genau dann, wenn kein /v1/ Service im Netzwerk verfügbar ist, z.B. weil alle Dienste nur noch in /v2/ vorliegen).

Dieses Vorgehen funktioniert. Aber nur unter 2 Annahmen:
  1. Ich werde nie (nie!) 2 unterschiedliche Minor-Versionen eines Services im Netz haben (z.B. niemals 1.2.x und 1.4.x gleichzeitig)
  2. Alle Clients sind "gleichartig": Sie haben alle ungefähr den gleichen Lebenszyklus, sie können mit kompatiblen Änderungen an der Schnittstelle umgehen,...

Diese Annahmen treffen auf viele Projekte zu. Es gibt aber mindestens genau so viele im großen Enterprise-Suppentopf, in der mindestens eine dieser Annahmen nicht zutrifft.

Gerade in großen Systemlandschaften haben viele Clients unterschiedliche Lebenszyklen: Ein Client C, der Microservice S verwendet, wird im Jahr T angefasst - dann aber die nächsten 3 Jahre nicht mehr. Unglücklicher weise ist C derart "ungeschickt" entwickelt, dass er selbst mit kompatiblen Änderungen nicht richtig umgehen kann. S wird aber auch von anderen Clients genutzt, und die brauchen ganz dringend in 2 Monaten ein neues Feature in S...

Ein anderes Beispiel: In Produktion stelle ich fest, dass Service 1.4.0 ein Performanceproblem für 1.3.x Funktionalität hereinbringt. Kann ich nun gefahrlos auf 1.3.x zurückrollen? Kann ich nicht beantworten, da ich nicht weiß, ob und falls ja welche Clients die neuen 1.4.x Features benötigen.

... Wir ändern bzw. erweitern die Semantik im Versionsanteil der URL ...

Glücklicher weise ist die Lösung für all diese Probleme relativ simpel: Wir ändern bzw. erweitern die Semantik im Versionsanteil der URL.Wir versteifen uns nicht mehr auf ein starres /v1/ in der URL, sondern nutzen ein Schema wie dieses:

  • endpunkt.services.com/min2.3/servicename
    • Routet auf den neusten Service >= 2.3, also zB auch 2.5.43
    • Semantik bei Branches 2.3.5 > 2.3.4-irgendwas.2 > 2.3.4-irgendwas.1 > 2.3.4
    • Diese Variante sollte von allen Clients bevorzugt verwendet werden, die mit kompatiblen Änderungen umgehen können!
  • endpunkt.services.com/v2.3/servicename
    • Routet auf neusten 2.3.x
    • Wirft 404 falls nur 1.x oder 2.4.x oder 2.2.x verfügbar!
    • Das ist die Variante für unseren Problem-Client C von oben
  • endpunkt.services.com/v2/servicename
    • Routet auf neusten Service mit 2.x.x
  • endpunkt.services.com/2.5.4-wartung.5/servicename
    • Routet genau auf die angegebene Version
    • Zur Verwendung z.B. in Smoketests, Debugging etc.
  • endpunkt.services.com/servicename/
    • Routet automatisch auf 'LATEST'
    • Hilfreich beim lokalen Entwickeln und Testen, sollte in Produktion nicht verwendet werden
.

Diese simple Erweiterung des "Namensraumes" erledigt all unsere Probleme: Wir bleiben weiterhin kompatibel zum klassischen /v1/-Schema, bieten "schwierigen" Clients jedoch deutlich mehr Möglichkeiten, ohne unsere Servicelandschaft einschränken zu müssen. Wie man dieses Verfahren nun technisch umsetzen kann, und warum jeder Client wenn möglich nur noch /min1.3/ und nicht mehr /v1/ verwenden sollte, gibt es dann demnächst mal...)

... yay!.

p.S.: In vorherigen Versionen dieses Posts hatte ich eine Serie angekündigt. Wie das Leben so spielt kommt man nicht immer zu Dingen die man tun will, also müsst ihr euch leider noch ein wenig gedulden - oder mich einfach direkt kontaktieren!