Automatisiertes Testen von Mikrodiensten in Docker für kontinuierliche Integration

In Projekten, die mit der Entwicklung von Mikrodiensten verbunden sind, wird CI/CD von einer nützlichen Möglichkeit zu einer dringenden Notwendigkeit. Automatisiertes Testen ist ein unverzichtbarer Teil der kontinuierlichen Integration, und ein durchdachter Ansatz kann dem Team viele angenehme Abende mit Familie und Freunden bescheren. Andernfalls riskiert das Projekt, niemals abgeschlossen zu werden.

Es ist möglich, den gesamten Code des Mikrodienstes mit Unit-Tests und Mock-Objekten abzudecken, aber dies löst das Problem nur teilweise und lässt viele Fragen und Schwierigkeiten offen, insbesondere beim Testen der Datenverarbeitung. Wie immer sind die schwierigsten Punkte: das Testen der Konsistenz der Daten in relationalen Datenbanken, das Testen der Interaktion mit Cloud-Diensten und falsche Annahmen beim Schreiben von Mock-Objekten.

All dies und noch mehr kann durch das Testen des gesamten Mikrodienstes in einem Docker-Container gelöst werden. Ein unbestreitbarer Vorteil zur Gewährleistung der Validität der Tests ist, dass die gleichen Docker-Images getestet werden, die in die Produktionsumgebung gehen.

Die Automatisierung dieses Ansatzes stellt eine Reihe von Problemen dar, deren Lösungen weiter unten beschrieben werden:

  • Konflikte paralleler Aufgaben auf demselben Docker-Host;
  • Konflikte von Identifikatoren in der Datenbank während der Testiteration;
  • Warten auf die Verfügbarkeit von Mikrodiensten;
  • Zusammenführen und Ausgeben von Protokollen an externe Systeme;
  • Testen von ausgehenden HTTP-Anfragen;
  • Testen von WebSockets (mit SignalR);
  • Testen der Authentifizierung und Autorisierung mit OAuth.

Dies ist ein Artikel inspiriert von meinem Vortrag auf der SECR 2019. Also für diejenigen, die keine Lust zum Lesen haben, hier ist die Aufnahme des Vortrags..

Automatisiertes Testen von Mikrodiensten in Docker für kontinuierliche Integration

In dem Artikel werde ich erklären, wie man mit einem Skript den zu testenden Dienst, die Datenbank und die AWS-Dienste in Docker startet, dann Tests mit Postman ausführt und nach deren Abschluss die erstellten Container stoppt und entfernt. Die Tests werden bei jeder Codeänderung durchgeführt. So stellen wir sicher, dass jede Version korrekt mit der Datenbank und den AWS-Diensten funktioniert.

Das gleiche Skript wird sowohl von den Entwicklern auf ihren Windows-Desktops als auch vom Gitlab CI-Server unter Linux gestartet.

Damit die Einführung neuer Tests gerechtfertigt ist, darf sie keine zusätzlichen Werkzeuge weder auf dem Computer des Entwicklers noch auf dem Server, auf dem die Tests bei einem Commit ausgeführt werden, erfordern. Docker löst dieses Problem.

Der Test sollte aus folgenden Gründen auf einem lokalen Server laufen:

  • Ein Netzwerk ist niemals vollständig zuverlässig. Von tausend Anfragen kann eine nicht durchgehen;
    Ein automatischer Test wird in diesem Fall nicht bestehen, die Arbeit wird gestoppt, und es muss nach der Ursache in den Logs gesucht werden;
  • Zu häufige Anfragen sind von einigen Drittanbieterdiensten nicht erlaubt.

Darüber hinaus ist es unerwünscht, eine Testumgebung zu verwenden, weil:

  • Die Testumgebung kann nicht nur durch fehlerhaften Code, der darauf läuft, sondern auch durch Daten, die von korrektem Code nicht verarbeitet werden können, beschädigt werden;
  • So sehr wir auch versuchen, alle durch den Test vorgenommenen Änderungen zurückzusetzen, kann während des Tests etwas schiefgehen (sonst, warum gibt es die Tests?).

Über das Projekt und den Prozess der Organisation

Unser Unternehmen hat eine mikroservicebasierte Webanwendung entwickelt, die in Docker in der Amazon AWS-Cloud läuft. Im Projekt wurden bereits Unit-Tests verwendet, jedoch traten häufig Fehler auf, die die Unit-Tests nicht erkannten. Es war notwendig, den gesamten Mikroservice zusammen mit der Datenbank und den Amazon-Diensten zu testen.

Im Projekt wird ein standardisierter Prozess der kontinuierlichen Integration verwendet, der das Testen des Mikroservices bei jedem Commit beinhaltet. Nachdem die Aufgabe zugewiesen wurde, nimmt der Entwickler Änderungen am Mikroservice vor, testet ihn manuell und führt alle vorhandenen automatischen Tests aus. Wenn nötig, ändert der Entwickler die Tests. Wenn keine Probleme festgestellt werden, wird ein Commit in den Branch dieser Aufgabe durchgeführt. Nach jedem Commit werden die Tests automatisch auf dem Server gestartet. Das Mergen in den gemeinsamen Branch und das Ausführen automatischer Tests dort erfolgt nach erfolgreichem Review. Wenn die Tests im gemeinsamen Branch bestanden werden, wird der Service automatisch in der Testumgebung auf Amazon Elastic Container Service (Teststand) aktualisiert. Der Teststand ist für alle Entwickler und Tester notwendig und sollte nicht beschädigt werden. Tester überprüfen in dieser Umgebung einen Fix oder ein neues Feature, indem sie manuelle Tests durchführen.

Architektur des Projekts

Automatisiertes Testen von Mikrodiensten in Docker für kontinuierliche Integration

Die Anwendung besteht aus mehr als zehn Diensten. Einige davon sind in .NET Core, andere in NodeJs geschrieben. Jeder Dienst läuft in einem Docker-Container im Amazon Elastic Container Service. Jeder hat seine eigene Postgres-Datenbank, und einige haben auch Redis. Es gibt keine gemeinsamen Datenbanken. Wenn mehrere Dienste die gleichen Daten benötigen, werden diese Daten zum Zeitpunkt ihrer Änderung über SNS (Simple Notification Service) und SQS (Amazon Simple Queue Service) an jeden dieser Dienste übermittelt, und die Dienste speichern sie in ihren separaten Datenbanken.

SQS und SNS

SQS ermöglicht es, Nachrichten über das HTTPS-Protokoll in die Warteschlange zu stellen und Nachrichten aus der Warteschlange zu lesen.

Wenn mehrere Dienste eine Warteschlange lesen, erhält jede Nachricht nur einen dieser Dienste. Dies ist nützlich, wenn mehrere Instanzen eines Dienstes gestartet werden, um die Last zwischen ihnen zu verteilen.

Wenn eine Nachricht an mehrere Dienste zugestellt werden soll, benötigt jeder Empfänger seine eigene Warteschlange, und zur Duplizierung von Nachrichten in mehrere Warteschlangen wird SNS benötigt.

In SNS erstellen Sie ein Topic und abonnieren beispielsweise eine SQS-Warteschlange darauf. Man kann Nachrichten an das Topic senden. Bei diesem Vorgang wird die Nachricht an jede Warteschlange gesendet, die auf dieses Topic abonniert ist. In SNS gibt es keine Methode zum Lesen von Nachrichten. Wenn während der Fehlersuche oder des Testens festgestellt werden muss, was an SNS gesendet wird, kann eine SQS-Warteschlange erstellt, auf das betreffende Topic abonniert und die Warteschlange gelesen werden.

Automatisiertes Testen von Mikrodiensten in Docker für kontinuierliche Integration

API Gateway

Die meisten Dienste sind nicht direkt aus dem Internet zugänglich. Der Zugriff erfolgt über das API Gateway, das die Zugriffsrechte überprüft. Dies ist auch unser Dienst, und auch dafür gibt es Tests.

Echtzeitbenachrichtigungen

Die Anwendung verwendet SignalR, um dem Benutzer Echtzeitbenachrichtigungen anzuzeigen. Dies ist im Benachrichtigungsdienst implementiert. Er ist direkt aus dem Internet zugänglich und arbeitet selbst mit OAuth, da es sich als nicht sinnvoll erwies, die Unterstützung von Web-Sockets in das Gateway zu integrieren, verglichen mit der Integration von OAuth und dem Benachrichtigungsdienst.

Bekannter Ansatz zum Testen

Unit-Tests ersetzen Dinge wie Datenbanken durch Mock-Objekte. Wenn ein Mikrodienst beispielsweise versucht, einen Datensatz in einer Tabelle mit einem Fremdschlüssel zu erstellen und der Datensatz, auf den dieser Schlüssel verweist, nicht existiert, kann die Anfrage nicht ausgeführt werden. Unit-Tests können dies nicht erkennen.

Im Artikel von Microsoft empfiehlt, eine In-Memory-Datenbank zu verwenden und Mock-Objekte einzufügen.

Die In-Memory-Datenbank ist eine der Datenbanken, die vom Entity Framework unterstützt wird. Sie wurde speziell für Tests entwickelt. Daten in einer solchen Datenbank werden nur bis zum Ende des Prozesses gespeichert, der sie verwendet. Es müssen keine Tabellen erstellt werden, und die Datenintegrität wird nicht überprüft.

Mock-Objekte modellieren die ersetzbare Klasse nur so weit, wie es der Entwickler des Tests versteht.

Wie man den automatischen Start von Postgres und die Ausführung von Migrationen beim Teststart erreicht, wird in dem Artikel von Microsoft nicht angegeben. Meine Lösung tut dies und fügt keinen speziellen Code für Tests in den Microservice ein.

Kommen wir zur Lösung

Im Laufe der Entwicklung wurde klar, dass Unit-Tests nicht ausreichen, um alle Probleme rechtzeitig zu finden, weshalb beschlossen wurde, die Angelegenheit anders anzugehen.

Einrichten der Testumgebung

Die erste Aufgabe besteht darin, die Testumgebung bereitzustellen. Die Schritte, die erforderlich sind, um den Microservice zu starten, sind:

  • Den zu testenden Service auf die lokale Umgebung konfigurieren, in den Umgebungsvariablen werden die Anmeldedaten für die Verbindung zur Datenbank und zu AWS angegeben;
  • Postgres starten und die Migration durchführen, indem Liquibase ausgeführt wird.
    In relationalen Datenbanksystemen müssen, bevor Daten in die Datenbank geschrieben werden, die Datenstruktur, einfacher gesagt, die Tabellen, erstellt werden. Bei der Aktualisierung einer Anwendung müssen die Tabellen auf das von der neuen Version verwendete Format gebracht werden, und das idealerweise ohne Datenverlust. Dies wird als Migration bezeichnet. Das Erstellen von Tabellen in einer ursprünglich leeren Datenbank ist ein Sonderfall der Migration. Die Migration kann in die Anwendung selbst integriert werden. Sowohl in .NET als auch in NodeJS gibt es Frameworks für Migrationen. In unserem Fall haben Microservices aus Sicherheitsgründen kein Recht, die Datenstruktur zu ändern, und die Migration erfolgt mit Liquibase.
  • Amazon LocalStack starten. Dies ist eine Implementierung von AWS-Diensten, die lokal betrieben wird. Für LocalStack gibt es ein fertiges Image im Docker Hub.
  • Ein Skript ausführen, um die erforderlichen Entitäten in LocalStack zu erstellen. Shell-Skripte verwenden AWS CLI.

Für das Testen des Projekts wird Postmanverwendet. Er war schon früher da, wurde aber manuell gestartet und die Anwendung wurde getestet, nachdem sie auf der Umgebung bereitgestellt wurde. Dieses Tool ermöglicht es, beliebige HTTP(S)-Anfragen zu stellen und die Antworten mit den Erwartungen zu überprüfen. Die Anfragen werden in einer Sammlung zusammengefasst, und die gesamte Sammlung kann auf einmal gestartet werden.

Automatisiertes Testen von Mikrodiensten in Docker für kontinuierliche Integration

Wie funktioniert der automatisierte Test

Während des Tests funktioniert alles in Docker: der zu testende Dienst, Postgres, das Migrationswerkzeug und Postman, genauer gesagt, seine Konsolenversion – Newman.

Docker löst eine Reihe von Problemen:

  • Unabhängigkeit von der Konfiguration des Hosts;
  • Installation von Abhängigkeiten: Docker lädt Images von Docker Hub herunter;
  • Rückkehr des Systems in den Ausgangszustand: einfach die Container löschen.

Docker-compose vereint Container in ein virtuelles Netzwerk, das vom Internet isoliert ist, in dem Container sich gegenseitig über Domainnamen finden.

Der Test wird von einem Shell-Skript gesteuert. Um den Test unter Windows auszuführen, verwenden wir git-bash. Somit genügt ein Skript sowohl für Windows als auch für Linux. Git und Docker sind bei allen Entwicklern im Projekt installiert. Bei der Installation von Git unter Windows wird git-bash installiert, so dass auch jeder ihn hat.

Das Skript führt die folgenden Schritte aus:

  • Erstellung von Docker-Images
    docker-compose build
  • Starten von DB und LocalStack
    docker-compose up -d
  • Migration der DB und Vorbereitung von LocalStack
    docker-compose run
  • Start des zu testenden Dienstes
    docker-compose up -d
  • Start des Tests (Newman)
  • Stoppen aller Container
    docker-compose down
  • Posten der Ergebnisse in Slack
    Wir haben einen Chat, in den Nachrichten mit grünem Häkchen oder rotem Kreuz und einem Link zum Log gelangen.

In diesen Schritten kommen folgende Docker-Images zum Einsatz:

  • Der zu testende Dienst – dasselbe Image wie für die Produktion. Die Konfiguration für den Test erfolgt über Umgebungsvariablen.
  • Für Postgres, Redis und LocalStack werden fertige Images von Docker Hub verwendet. Auch für Liquibase und Newman gibt es fertige Images. Wir erstellen unsere eigenen auf deren Grundlage, indem wir unsere Dateien hinzufügen.
  • Für die Vorbereitung von LocalStack wird ein fertiges Image des AWS CLI verwendet, auf dessen Grundlage ein Image mit dem Skript erstellt wird.

Durch die Nutzung Volumes, es ist nicht notwendig, ein Docker-Image nur für die Hinzufügung von Dateien in den Container zu erstellen. Volumes sind jedoch für unsere Umgebung ungeeignet, da die Aufgaben von GitLab CI selbst in Containern ausgeführt werden. Von einem solchen Container aus kann Docker gesteuert werden, aber Volumes montieren nur Ordner von der Host-System und nicht aus einem anderen Container.

Probleme, auf die man stoßen kann

Warten auf die Bereitschaft

Wenn der Container mit dem Dienst gestartet ist, bedeutet das noch nicht, dass er bereit ist, Verbindungen entgegenzunehmen. Man muss auf die Verbindung warten, um fortzufahren.

Dieses Problem wird manchmal mit Hilfe eines Skripts gelöst wait-for-it.sh, die darauf wartet, eine TCP-Verbindung herzustellen. LocalStack kann jedoch einen Fehler 502 Bad Gateway ausgeben. Zudem besteht es aus vielen Diensten; wenn einer von ihnen bereit ist, sagt das nichts über die anderen aus.

Lösung: Vorbereitungs-Skripte für LocalStack, die auf eine 200-Antwort sowohl von SQS als auch von SNS warten.

Konflikte bei parallelen Aufgaben

Mehrere Tests können gleichzeitig auf einem Docker-Host laufen, daher müssen die Namen der Container und Netzwerke eindeutig sein. Darüber hinaus können Tests aus verschiedenen Zweigen eines Dienstes ebenfalls gleichzeitig laufen, sodass es nicht ausreicht, in jeder Compose-Datei eigene Namen zu vergeben.

Lösung: das Skript stellt einen einzigartigen Wert für die Variable COMPOSE_PROJECT_NAME ein.

Besonderheiten von Windows

Bei der Verwendung von Docker auf Windows gibt es einige Punkte, auf die ich Ihre Aufmerksamkeit lenken möchte, da diese Erfahrungen wichtig sind, um die Ursachen von Fehlern zu verstehen.

  1. Shell-Skripte im Container müssen Linux-Zeilenenden haben.
    Das CR-Symbol für die Shell ist ein Syntaxfehler. Aus der Fehlermeldung ist es schwierig zu erkennen, dass dies der Grund ist. Beim Bearbeiten solcher Skripte unter Windows ist ein geeigneter Texteditor erforderlich. Zudem muss das Versionskontrollsystem richtig konfiguriert sein.

So wird git konfiguriert:

git config core.autocrlf input

  1. Git-Bash emuliert die Standardverzeichnisse von Linux und ersetzt beim Aufruf von exe-Dateien (einschließlich docker.exe) absolute Linux-Pfade durch Windows-Pfade. Dies macht jedoch für Pfade, die sich nicht auf der lokalen Maschine befinden (oder Pfade im Container), keinen Sinn. Dieses Verhalten kann nicht deaktiviert werden.

Lösung: einen zusätzlichen Slash am Anfang des Pfades hinzuzufügen: //bin anstelle von /bin. Linux versteht solche Pfade, für ihn ist es gleich, ob mehrere Slashes vorhanden sind oder nicht. Aber git-bash erkennt solche Pfade nicht und unternimmt keine Versuche zur Umwandlung.

Protokollausgaben

Beim Ausführen von Tests möchte ich die Protokolle sowohl von Newman als auch vom zu testenden Dienst sehen. Da die Ereignisse dieser Protokolle miteinander verbunden sind, ist es viel praktischer, sie in einer Konsole zu kombinieren, als in zwei separaten Dateien. Newman wird über docker-compose run, und daher gelangt seine Ausgabe in die Konsole. Es bleibt, dafür zu sorgen, dass auch die Ausgabe des Dienstes dorthin gelangt.

Die ursprüngliche Lösung bestand darin, zu tun docker-compose up ohne das Flag -d, jedoch durch die Möglichkeiten der Shell diesen Prozess im Hintergrund laufen zu lassen:

docker-compose up  &

Das funktionierte, bis es nötig wurde, Protokolle aus Docker an einen externen Dienst zu senden. docker-compose up hörte auf, Protokolle in die Konsole auszugeben. Das Kommando funktionierte jedoch docker attach.

Lösung:

docker attach --no-stdin ${COMPOSE_PROJECT_NAME}__1 &

Identifikatorenkonflikt bei den Testiterationen

Die Tests werden in mehreren Iterationen ausgeführt. Die Datenbank wird dabei nicht geleert. Die Einträge in der Datenbank haben eindeutige IDs. Wenn konkrete IDs in den Anfragen angegeben werden, kommt es in der zweiten Iteration zu einem Konflikt.

Um dies zu vermeiden, müssen entweder die IDs eindeutig sein oder alle vom Test erstellten Objekte müssen gelöscht werden. Bestimmte Objekte dürfen nicht gelöscht werden, gemäß den Anforderungen.

Lösung: GUIDs mit Skripten in Postman generieren.

var uuid = require('uuid');
var myid = uuid.v4();
pm.environment.set('myUUID', myid);

Dann im Request das Zeichen {{myUUID}}, das durch den Wert der Variablen ersetzt wird.

Interaktion über LocalStack

Wenn der getestete Dienst eine SQS-Warteschlange liest oder in sie schreibt, muss der Test ebenfalls mit dieser Warteschlange arbeiten.

Lösung: Anfragen von Postman an LocalStack.

Die API von AWS-Diensten ist dokumentiert, was es ermöglicht, Anfragen ohne SDK zu stellen.

Wenn der Dienst in die Warteschlange schreibt, lesen wir sie und überprüfen den Inhalt der Nachricht.

Wenn der Dienst Nachrichten an SNS sendet, wird in der Vorbereitungsphase von LocalStack ebenfalls eine Warteschlange erstellt und auf dieses SNS-Thema abonniert. Danach läuft alles wie oben beschrieben ab.

Wenn der Dienst eine Nachricht aus der Warteschlange lesen muss, schreiben wir in dem vorherigen Schritt des Tests diese Nachricht in die Warteschlange.

Testen von HTTP-Anfragen, die vom getesteten Mikrodienst ausgehen

Einige Dienste arbeiten über HTTP mit etwas anderem als AWS, und einige AWS-Funktionen sind in LocalStack nicht implementiert.

Lösung: In diesen Fällen kann MockServerhelfen, der einen fertigen Container im Docker Hub. Erwartete Anfragen und Antworten darauf werden mit einer HTTP-Anfrage konfiguriert. Die API ist dokumentiert, daher stellen wir Anfragen von Postman.

Testen der Authentifizierung und Autorisierung mit OAuth

Wir verwenden OAuth und JSON Web Tokens (JWT). Für den Test benötigt man einen OAuth-Anbieter, den wir lokal ausführen können.

Die gesamte Interaktion des Dienstes mit dem OAuth-Anbieter umfasst zwei Anfragen: Zuerst wird die Konfiguration angefragt /.well-known/openid-configuration, danach wird der öffentliche Schlüssel (JWKS) an der Adresse aus der Konfiguration angefragt. All dies ist statischer Inhalt.

Lösung: Unser Test-OAuth-Anbieter ist ein Server für statischen Inhalt mit zwei Dateien darauf. Der Token wurde einmal generiert und in Git committiert.

Besonderheiten des SignalR-Tests

Mit WebSockets funktioniert Postman nicht. Ein spezielles Tool wurde zum Testen von SignalR erstellt.

Ein Client von SignalR kann nicht nur ein Browser sein. Es gibt eine Client-Bibliothek für .NET Core. Der in .NET Core geschriebene Client stellt die Verbindung her, authentifiziert sich und wartet auf eine bestimmte Abfolge von Nachrichten. Wenn eine unerwartete Nachricht empfangen wird oder die Verbindung abbricht, beendet sich der Client mit dem Code 1. Bei Erhalt der letzten erwarteten Nachricht beendet er sich mit dem Code 0.

Neben dem Client läuft Newman. Es werden mehrere Clients gestartet, um zu überprüfen, dass die Nachrichten an alle Empfänger geliefert werden.

Automatisiertes Testen von Mikrodiensten in Docker für kontinuierliche Integration

Zum Starten mehrerer Clients wird die Option —scale in der Befehlszeile von docker-compose verwendet.

Vor dem Start wartet das Postman-Skript darauf, dass alle Clients die Verbindung herstellen.
Das Problem, auf das wir beim Warten auf die Verbindung gestoßen sind, hatten wir schon einmal. Damals gab es Server, hier jedoch einen Client. Ein anderer Ansatz ist erforderlich.

Lösung: Der Client im Container verwendet den Mechanismus HealthCheck, um dem Skript auf dem Host seinen Status mitzuteilen. Der Client erstellt eine Datei auf einem bestimmten Pfad, sagen wir, /healthcheck, sobald die Verbindung hergestellt ist. Das HealthCheck-Skript in der Docker-Datei sieht folgendermaßen aus:

HEALTHCHECK --interval=3s CMD if [ ! -e /healthcheck ]; then false; fi

Team docker inspect zeigt für den Container den normalen Status, den Gesundheitsstatus und den Exit-Code an.

Nach dem Abschluss von Newman überprüft das Skript, ob alle Container mit dem Client beendet wurden, und zwar mit Code 0.

Es gibt Glück

Nachdem wir die oben beschriebenen Schwierigkeiten überwunden haben, verfügen wir jetzt über eine Reihe stabil laufender Tests. In den Tests funktioniert jeder Service als Ganzes, interagiert mit der Datenbank und mit Amazon LocalStack.

Diese Tests schützen ein Team von über 30 Entwicklern vor Fehlern in der Anwendung mit komplexen Interaktionen zwischen über 10 Mikrodiensten bei häufigen Deployments.

Quelle: habr.com

60GB SSD 8Gb DDR4