Über unsMediaKontaktImpressum
Uwe Friedrichsen 31. Mai 2016

Resilient Software Design – Robuste Software entwickeln

Bei Resilient Software Design geht es wie im Titel bereits angedeutet darum, robuste Software zu entwickeln. Aber warum braucht man das? Reichen die altbewährten Maßnahmen wie Clustering und hochverfügbare Hardware nicht mehr aus? Wie macht man es und wie führt man es nachhaltig bei sich ein? In diesem Artikel werden diese und noch einige weitere Fragen beantwortet.

Vor zwei oder drei Jahren noch erhielt man hauptsächlich ratlose Blicke, wenn man von "Resilient Software Design" gesprochen hat. Mittlerweile ist die Situation etwas besser, aber von IT-Standardwissen ist das Thema immer noch weit entfernt. Beginnen wir daher mit einer kleinen semi-formalen Begriffsdefinition:

Resilient Software Design beschreibt die Gestaltung und Umsetzung einer Software-basierten Lösung auf eine Weise, dass im Falle einer unerwarteten Fehlersituation

  • der Nutzer im besten Fall überhaupt nichts davon bemerkt,
  • die Lösung anderenfalls in einem definierten, reduzierten Service-Level weiterarbeitet.

Niemand würde widersprechen, dass das nach einer sehr erstrebenswerten Systemeigenschaft klingt. Aber streben wir das nicht bereits seit vielen Jahren an? Was ist anders an Resilient Software Design?

Warum Resilient Software Design?

Zum Beantworten dieser Fragen muss man etwas weiter ausholen: Software wird in der Regel nicht zum Selbstzweck entwickelt, sondern um bestimmte Ziele zu erreichen. Im Unternehmensumfeld sind diese Ziele häufig an das Verdienen von Geld und/oder Kundenzufriedenheit gebunden.

Damit Software zur Erreichung dieser Ziele beitragen kann, muss sie zwei Voraussetzungen erfüllen:

  • Sie muss in Produktion laufen. Software in Entwicklung oder Test erzielt keinen Geschäftswert.
  • Sie muss darüber hinaus ordnungsgemäß funktionieren, also verfügbar sein. Nur verfügbare Software erzielt Geschäftswert.

Formal ist Verfügbarkeit als MTTF / (MTTF + MTTR) definiert [1]. Dabei bedeuten:

  • MTTF (Mean Time To Failure) – die durchschnittliche Zeit vom ordnungsgemäßen Beginn der Arbeit des Systems bis zum Auftreten des ersten an den Systemgrenzen sichtbaren Fehlers.
  • MTTR (Mean Time To Recovery) – die durchschnittliche Zeit vom Auftreten des Fehlers bis das System wieder ordnungsgemäß arbeitet.

Verfügbarkeit beschreibt also den Anteil der Zeit, in der das System ordnungsgemäß, also innerhalb des spezifizierten Systemverhaltens, arbeitet. Dabei geht es nicht nur um Absturzfehler, sondern z. B. auch um Latenzfehler, d. h. ein langsames Antwortverhalten oder Antwortfehler, also falsche Antworten (eine detaillierte Beschreibung der möglichen Fehlerklassen siehe [2]).

Betrachtet man die Formel für Verfügbarkeit, so sieht man, dass es zwei Ansätze gibt, die Verfügbarkeit zu erhöhen:

  • Vergrößern von MTTF, d. h. die Wahrscheinlichkeit reduzieren, dass überhaupt ein Fehler auftritt.
  • Reduzieren von MTTR, d. h. die Zeit für die Wiederherstellung nach Auftreten eines Fehlers verkürzen.

In der Vergangenheit hat man sich primär auf den ersten Ansatz fokussiert. Man hat versucht, den Zeitpunkt, an dem ein Fehler auftritt, so lange wie möglich hinauszuzögern. Das hat man primär auf Infrastrukturebene getan, z. B. durch High-Availability-Hardware, Software-Clustering und ähnliche Maßnahmen. Die Anwendungen selber haben nichts beigetragen – sie sind so programmiert worden, als wären alle Bausteine, die sie zum ordnungsgemäßen Funktionieren benötigen, stets zu 100% verfügbar.

Dieser Ansatz funktioniert auch recht gut, solange die betroffene Anwendung isoliert läuft, d. h. keine oder kaum entfernte Ressourcen für ihr ordnungsgemäßes Funktionieren benötigt und es nur wenige Fehlerquellen gibt, gegen die man die Anwendung schützen muss (typischerweise den Ausfall von Hardware oder den Absturz eines Prozesses).

Diese Voraussetzungen gelten heute aber nicht mehr. Praktisch jede Anwendung in einem normalen Unternehmensumfeld ist ein verteiltes System. Selbst einfache Web-Anwendungen bestehen in der Regel aus mindestens einem Web-Server, einem Anwendungsserver und einer Datenbank (SSO-Server, Caching Infrastrukturen und andere übliche Komponenten nicht mitgezählt). Und nicht wenige Anwendungen in größeren Unternehmen kommunizieren mit Dutzenden bis hin zu hunderten anderen Anwendungen, um ihre Leistung zu erbringen.

Setzt man sich mit verteilten Systemen auseinander, stellt man schnell fest, dass es darin nahezu beliebig viele Fehlerquellen gibt und man die wenigsten davon deterministisch antizipieren kann. Berühmt sind in diesem Zusammenhang die "Acht Irrtümer verteilten Rechnens" von Peter Deutsch [3] geworden, die populäre Fehlannahmen in Bezug auf das Netzwerk beschreiben.

Und da geht es nur um das Netzwerk – Fehler in verteilter Hardware, Betriebssystemen und anderen Anwendungskomponenten sind dabei überhaupt nicht berücksichtigt. Das führt so weit, dass Leslie Lamport, einer der führenden Köpfe im Bereich verteilter Systeme einmal etwas flapsig, aber zutreffend sagte: "A distributed system is one in which the failure of a computer you didn't even know existed can render your own computer unusable" ( [4]).

Kurzum: Fehler in den heutigen verteilten, komplexen und hochgradig vernetzten Anwendungslandschaften sind weder vermeidbar noch vorhersehbar, sondern der Normalfall. Das hat zwei Konsequenzen:

  • MTTF ist nur sehr eingeschränkt beeinflussbar. Um hohe Verfügbarkeit zu gewährleisten, muss man stattdessen MTTR minimieren.
  • Die Fehlerquellen, die traditionelle HA-Maßnahmen auf Infrastrukturebene adressiert haben, reichen nicht mehr aus, um eine hohe Verfügbarkeit zu gewährleisten. Insbesondere ist es in Umgebungen, in denen Fehler der Normalfall sind, keine Option mehr, dass Anwendungen so implementiert werden, als gäbe es keine Fehler. Stattdessen müssen Fehlerszenarien auch aktiv auf Anwendungsebene adressiert werden.

Genau diese zwei Punkte machen Resilient Software Design aus.

Wie entwirft man eine Anwendung resilient?

Nachdem geklärt ist, warum Resilient Software Design benötigt wird, stellt sich als nächstes die Frage, wie man eine Anwendung resilient entwirft. Diese Tätigkeit zerfällt in zwei Kernaspekte:

  • Design der sogenannten Bulkheads.
  • Die Anwendung durch Einsatz geeigneter Resilience-Muster robust machen.

"Bulkhead" ist ein aus dem Schiffsbau übernommener Begriff (zu deutsch in etwa "Schottwand") und beschreibt das elementarste Muster aus Resilient Software Design. In einfachen Worten besagt das Muster: Eine Anwendung sollte niemals als Ganzes ausfallen. Deshalb zerlege die Anwendung in Teile (die Bulkheads) und isoliere diese so voneinander, dass keine kaskadierenden Fehler auftreten. Ein kaskadierender Fehler ist ein Fehler, der in einem Anwendungsteil auftritt und sich in andere Anwendungsteile fortpflanzt, so dass immer mehr Anwendungsteile von dem ursprünglichen Fehler in Mitleidenschaft gezogen werden.

Das Bulkhead-Muster ist wahrscheinlich das schwierigste Muster von allen, weil es einen auf die Grundlagen guten Anwendungsdesigns zurückwirft.

Das Bulkhead-Muster klingt zunächst einfach, weshalb es häufig massiv unterschätzt wird. Tatsächlich ist es in der Praxis aber wahrscheinlich das schwierigste Muster von allen, weil es keine konkrete Implementierungsanweisung enthält, sondern einen auf die Grundlagen guten Anwendungsdesigns zurückwirft. Um nämlich Bulkheads sinnvoll voneinander isolieren zu können, ist es zunächst einmal wichtig, dass die Bulkheads auf fachlicher Ebene entkoppelt sind. Benötigt etwa ein Bulkhead A immer einen anderen Bulkhead B, um seinen Service zu erbringen, kann von Isolation keine Rede sein, egal wie sehr man sich auf technischer Ebene anstrengt. Ein Fehler in Bulkhead B wird sich immer zu Bulkhead A fortpflanzen.

Das wirft einen auf die bis heute nicht gut verstandenen Grundprinzipien guter Modularisierung zurück, insbesondere Separation of Concerns und High cohesion/Low coupling. Um die Aussage zu präzisieren: Die meisten Leute haben verstanden, was diese Prinzipien bedeuten, aber kaum jemand hat ein Verständnis davon, wie man diese Prinzipien in der Praxis umsetzt.

Ein wenig Unterstützung bieten die Muster aus Domain Driven Design (s. z. B. [5]). Aber auch hier gilt: Die Muster zu kennen, bedeutet noch lange nicht, dass man sie auch gut umsetzen kann. Nicht umsonst betont Eric Evans in seinem Buch [5] immer wieder, dass es viel Aufwand und Energie erfordert, eine Domäne so gut zu durchdringen, dass man in der Lage ist, dafür eine gute Zerlegung zu finden.

Hat man die "Design-Hürde" genommen, kommt der zweite Teil, nämlich die Anwendung durch Nutzung geeigneter Resilience-Muster robust zu machen.

Die richtigen Muster wählen

Schaut man auf die Muster, die es im Kontext von Resilient Software Design gibt (s. z. B.  für eine Einführung [6] sowie für einen – unvollständigen – Überblick [7,8]), fühlt man sich zunächst einmal erschlagen. Zwar kommen einem diverse Muster nicht unvertraut vor und man hat das ein oder andere wahrscheinlich auch schon einmal eingesetzt, ohne einen Namen dafür zu haben, aber es bleibt die Frage, wie viele Muster man tatsächlich nutzen muss oder soll. Eine generelle Antwort auf die Frage gibt es nicht, weil es dabei sehr stark auf die Komplexität des fachlichen Use Cases und die konkreten Robustheitsanforderungen ankommt.

Beispielsweise implementiert Erlang im Kern nur sehr wenige Resilience-Muster (etwa ein halbes Dutzend) und erreicht damit in Telefonie-Switches, für deren Steuerung Erlang eingesetzt wird, phänomenale Verfügbarkeiten mit Ausfallzeiten von unter einer Sekunde pro Jahr [9] . Allerdings ist der Use Case auch relativ eingeschränkt und klar definiert.

Auf der anderen Seite implementieren Netflix wesentlich mehr Muster (deutlich über ein Dutzend) und erreichen damit zwar auch eine sehr hohe Verfügbarkeit, die aber definitiv nicht in die Nähe der zuvor beschriebenen Verfügbarkeiten in die Telefonie-Switches kommt. Allerdings sind die Use Cases bei Netflix auch wesentlich komplexer und die Verfügbarkeitsanforderungen etwas geringer.

In den meisten Fällen wird man sich wahrscheinlich irgendwo zwischen einem halben Dutzend bis Dutzend Mustern einpendeln, in Ausnahmefällen auch weniger oder mehr. Es bleibt dann aber immer noch die Frage, welche Muster man konkret verwenden sollte. Für diese Auswahl gibt es zwei Hilfsmittel:

  • Eine Muster-Taxonomie
  • Die wichtigsten Fehlerszenarien

Eine einfache Muster-Taxonomie (s. Abb.1) kann die Auswahl erleichtern, weil sie einem hilft zu erkennen, ob man alle relevanten Resilience-Domänen abgedeckt hat. Die gezeigte Taxonomie beinhaltet die folgenden Teildomänen:

  • Error Detection – Die Erkennung eines internen Fehlers, bevor er außerhalb der Systemgrenzen sichtbar wird. Das ist immer der erste Schritt des Fehlerbehandlungs-Flows.
  • Error Recovery – Das vollständige Beheben eines erkannten Fehlers unter der Annahme, dass man dafür genügend Wissen und Zeit hat.
  • Error Mitigation – Das Eindämmen bzw. Abmildern der Folgen eines erkannten Fehlers, ohne ihn zu beheben, weil einem entweder das Wissen oder die Zeit dafür fehlen.
  • Error Prevention – Die Wahrscheinlichkeit reduzieren, dass ein Fehler auftritt.
  • Fault Treatment – Die Ursache für einen entstandenen Fehler beheben.
  • Architecture – Ergänzende strukturelle und verhaltensorientierte Muster, die die Umsetzung von Mustern aus den zuvor genannten Domänen ermöglichen bzw. unterstützen.

Das zweite Hilfsmittel sind die wichtigsten Fehlerszenarien. Auf welche Arten von Fehlern soll die Anwendung auf jeden Fall ohne externe Unterstützung reagieren können und wie soll die Reaktion aussehen? Zur Ermittlung der Szenarien sollte man seine fachlichen Use Cases und die möglichen Fehlerklassen (s. [2]) untersuchen und daraus die wichtigsten Fehlerszenarien ableiten, auf die man auf jeden Fall automatisiert in der Anwendung reagieren können will.

Man sollte hier auf keinen Fall versuchen, alle relevanten Szenarien zu finden. Verteilte Systeme sind zu komplex, als dass man alle Szenarien à priori entdecken könnte. Stattdessen sollte man versuchen, zunächst nur die wichtigsten und offensichtlichsten Szenarien zu identifizieren und über die Zeit mehr über das Fehlerverhalten seiner Anwendung zu lernen und darauf basierend die Behandlungsstrategien evolutionär anzupassen.

Für jedes Fehlerszenario überlegt man sich zunächst, wie man den Fehler erkennen will (Error Detection). Danach entscheidet man, ob man in der Lage ist, den Fehler direkt zu beheben (Error Recovery) oder man ihn nur abmildern kann (Error Mitigation). Letztlich entscheidet man noch, ob man die Wahrscheinlichkeit des Auftretens des Fehlers reduzieren will (Error Prevention).

Hat man das für alle Szenarien gemacht, hat man sein initiales Muster-Set an der Hand. Dann überlegt man noch, ob man diese Muster durch geeignete Architekturmuster unterstützt und ggf. gezielte Maßnahmen für die Fehlerbeseitigung (Fault Treatment) benötigt. Letztere sind speziell für Fehler relevant, die man zur Laufzeit nicht abschließend beheben, sondern nur eindämmen konnte.

Auf diese Weise erhält man normalerweise zwischen einem halben Dutzend und einem Dutzend Muster. Sollte man deutlich mehr Muster ausgewählt haben, ist es aus mehreren Gründen sinnvoll, die Auswahl noch einmal kritisch zu hinterfragen:

  • Viel hilft nicht automatisch viel. Eine inflationäre Menge an Resilience-Mustern macht eine Anwendung nicht automatisch robuster. Alleine schon die damit verbundene deutlich erhöhte Komplexität der Anwendung kann den erhofften Effekt zunichte machen.
  • Es ist wie bereits geschrieben nicht sinnvoll möglich, alle vorstellbaren Fehlersituationen zu erheben. Es ist wesentlich besser, mit wenigen Kernszenarien zu starten und die Muster mit zunehmendem Wissen evolutionär weiterzuentwickeln.
  • Resilience kostet Geld. Es kostet Design- und Entwicklungsaufwände, Testaufwände und fast immer auch Geld aufgrund zusätzlicher Ressourcen, die in Produktion gebraucht werden (viele Muster basieren auf zusätzlichen bzw. redundanten Ressourcen, die zur Laufzeit benötigt werden). Daher ist Resilient Software Design wie jede Architekturentscheidung eine Abwägung: Wie viel Resilience benötige ich, um meine geschäftlichen Ziele zu erreichen? Wie viel kostet es? Ist es mir das wert?

In Abb.2 ist der resiliente Anwendungsentwurf noch einmal zusammenfassend dargestellt. Bei der Implementierung kann man mittlerweile häufig auf existierende Bibliotheken zurückgreifen. Ein gutes Beispiel dafür ist Hystrix [10], eine von Netflix entwickelte und als OSS freigegebene Bibliothek, deren Kern eine Implementierung des Circuit Breaker-Musters (s. [6] für eine detaillierte Beschreibung) ist und die recht vielseitig im Resilience-Umfeld einsetzbar ist.

Wie führt man Resilient Software Design ein

Hat man verstanden, warum man Resilient Software Design benötigt und wie man es inhaltlich macht, stellt sich häufig als nächste Frage, wie man Resilient Software Design am besten in seiner Organisation einführt.

Der erste Reflex ist, alle Entwickler auf eine entsprechende Schulung zu schicken (oder ein Schneeballprinzip zu versuchen). Aber das hilft in der Regel nicht viel. Die meisten Entwickler finden die Schulung zwar gut und einen Moment lang sieht man auch einen kleinen Effekt, aber der verpufft in der Regel nach kurzer Zeit wieder.

Das Problem ist, dass der Nutzen von Resilient Software Design in Produktion sichtbar wird. Nur sehen Entwickler so gut wie nie, welche Effekte ihr Handeln in Produktion hat. In den meisten Unternehmen gibt es immer noch die "große Mauer" zwischen Entwicklung und Betrieb und solange es keine katastrophalen Probleme in Produktion gibt, erhalten Entwickler in der Regel kaum Feedback bzgl. der von ihnen entwickelten Software aus Produktion. Erschwerend kommt noch hinzu, dass Entwickler in der Regel nicht für die Robustheit ihrer Software belohnt werden, sondern für die Menge der Fachanforderungen, die sie umsetzen. Das geht hin bis zu Zielvereinbarungen, in denen Teile des Gehalts an die Menge der umgesetzten Anforderungen gebunden werden.

Zusätzlich muss man sicherstellen, dass Entwicklung und Produktion auf gemeinsame Ziele hinarbeiten.

Da die Anforderer meistens viel von der Fachdomäne, aber wenig von Anwendungsbetrieb verstehen, werden in so einem Szenario Themen wie Robustheit typischerweise nachrangig behandelt. Nicht selten geht das so weit, dass ein Entwickler letztlich dafür abgestraft wird, wenn er sich um die Robustheit der Anwendung kümmert, weil er dann weniger Fachanforderungen umsetzen kann. Um es ganz klar zu sagen: Das ist kein Versäumnis der Entwickler. Das ist ein grundlegender Fehler im System, den die meisten Unternehmen leider nicht einmal ansatzweise verstanden haben.

Will man etwas an dieser Situation verbessern, muss man dafür sorgen, dass die Entwickler (sowie die anderen am Entwicklungsprozess beteiligten Parteien) verstehen, welche Konsequenzen ihr Handeln in Produktion hat – also dort, wo der Geschäftswert erzielt wird, solange die Anwendung verfügbar ist. Dafür benötigt man eine ständige Feedback-Schleife von Produktion zur Entwicklung. Zusätzlich muss man sicherstellen, dass Entwicklung und Produktion auf gemeinsame Ziele hinarbeiten.

Mit diesen Herausforderungen setzt sich DevOps auseinander. DevOps hier im Detail vorzustellen würde den Artikel bei weitem sprengen. Deshalb sei hier nur auf das Buch "The Phoenix Project" [11] verwiesen, in dem die Treiber und Ideen von DevOps sehr anschaulich in Form einer kurzweiligen Novelle dargestellt werden und in dem man viele Ideen findet, wie man die benötigten Feedback-Schleifen aufbauen kann. Hat man die Feedback-Schleifen und gemeinsamen Ziele erfolgreich etabliert, dann kann man die Trainings und ggf. Coachings aufsetzen. In der Regel rennt man dann bei den Entwicklern auch bereits offene Türen ein, weil sie den Bedarf bereits verstanden haben – und weil sie jetzt dafür belohnt werden, wenn sie Robustheit in eine Anwendung einbauen.

Nachhaltige Resilience

Eine weitere Frage ist, wie man Resilient Software Design nachhaltig macht. Hat man eine Anwendung robust gemacht, möchte man nicht, dass die Robustheit durch zukünftige Änderungen kompromittiert wird. In der Regel ist es aber sehr schwer, das mit Hilfe von Tests während der Entwicklung sicherzustellen. Deshalb nutzen Firmen, die sich schon länger Resilient Software Design auf die Fahnen geschrieben haben, in der Regel eine Kombination aus detailliertem Monitoring und synthetischer Fehlererzeugung in ihren Produktionssystemen.

Das Monitoring benötigt man, um zu sehen, ob sich die Anwendung erwartungskonform verhält. Dann provoziert man explizite Fehlersituationen, z. B. durch Abschießen von Prozessen oder Servern, Hinzufügen von Latenzen auf dem Netzwerk oder fehlerhafte Anfragen und beobachtet, ob sich das System erwartungskonform verhält.

Rollt man einen neuen Software-Stand aus, dann vergleicht man das Verhalten der Software vor dem Ausrollen und danach und stellt sicher, dass sich das Verhalten nicht verschlechtert hat. Ansonsten nimmt man das Update zurück und verbessert die Software, bevor man sie erneut ausrollt. Vielen Personen bereitet der Gedanken Unbehagen, Robustheit durch "Ausprobieren" in Produktion zu testen. Man sollte dabei aber bedenken, dass es unmöglich ist, die komplexen Fehlermuster mit vertretbarem Aufwand vorab vollständig zu prüfen. Deshalb hat man in der Praxis keine Alternative, als die Robustheit seiner Anwendung regelmäßig in Produktion (oder zumindest einer praktisch produktionsidentischen Umgebung mit vergleichbaren Last- und Kommunikationsmustern) zu testen, wenn man sie nachhaltig sicherstellen will.

Netflix setzt dafür z. B. seine "Simian Army" [12] ein, eine Reihe von Programmen, die ständig in ihrer Produktionsumgebung laufen und dort Fehler provozieren. Beispielsweise schießt der "Chaos Monkey" regelmäßig Prozesse und Server ab und der "Latency Monkey" verzögert die Kommunikation zwischen Prozessen. Auf diese Weise stellt Netflix sicher, dass ihre Anwendungen nachhaltig robust bleiben. Natürlich verwendet Netflix auch ein schrittweises Ausrollen neuer Software-Stände, um sicherzustellen, dass evtl. Probleme in den neuen Ständen nicht ihre Produktion lahmlegen. So viel Vorsicht muss dann doch sein.

Fazit

Resilient Software Design ist in den heutigen verteilten, komplexen und hochgradig vernetzten Software-Landschaften keine Option mehr, sondern ein Muss, da die früher üblichen isolierten Maßnahmen auf Infrastrukturebene nicht mehr hinreichend sind, um die geforderte Robustheit sicherzustellen.

Zuerst muss die "Mauer" zwischen Entwicklung und Produktion eingerissen werden.

Das Umsetzung zerfällt in zwei Hauptblöcke:

  • Die Zerlegung einer Anwendung in geeignete Teile (Bulkheads), die man gegeneinander isolieren kann.
  • Die Isolation der Bulkheads durch das Implementieren geeigneter Muster zur Erkennung und Behebung (bzw. Eindämmung) von Fehlern.

Beides sind evolutionäre Prozesse, da sowohl das Finden einer geeigneten Zerlegung als auch das Entdecken der möglichen Fehlerszenarien in der Regel mit langen und intensiven Lernprozessen verbunden ist.

Um Resilient Software Design nachhaltig einzuführen, muss zuerst die "Mauer" zwischen Entwicklung und Produktion eingerissen werden, die in den meisten Unternehmen noch besteht und die eine effektive Feedback-Schleife von Produktion zur Entwicklung verhindert. Das ist wahrscheinlich das größte Hindernis beim Einführen von Resilient Software Design. Alles andere kann man lernen.

Quellen
  1. R. S. Hanmer, 2007: Patterns for Fault Tolerant Software, Wiley
  2. A. Tanenbaum, M. v. Steen, 2006: Distributed Systems – Principles and Paradigms, Prentice Hall
  3. P. Deutsch: The Eight Fallacies of Distributed Computing
  4. L. Lamport: A distributed system is ...
  5. E. Evans, 2004: Domain Driven Design, Addison-Wesley
  6. M. T. Nygard, 2007: Release It!, Pragmatic Bookshelf
  7. U. Friedrichsen: Patterns of Resilience
  8. U. Friedrichsen: Resilience reloaded - more resilience patterns
  9. J. Armstrong: What’s all this fuss about Erlang?
  10. Github: Hystrix
  11. G. Kim, K. Behr, G. Spafford, 2014: The Phoenix Project, Astronaut Projects
  12. The Netflix Simian Army

Autor

Uwe Friedrichsen

Uwe Friedrichsen ist ein langjähriger Reisender in der IT-Welt. Als CTO der codecentric AG ist er stets auf der Suche nach innovativen Ideen und Konzepten.
>> Weiterlesen
botMessage_toctoc_comments_9210