Banking on AWS - Part 1: Amazon ECS

Im Rahmen unseres Innovation Labs "Bank in the Cloud" haben wir uns mit dem Open Source Kernbankensystem Apache Fineract CN beschäftigt. In einem einleitenden Beitrag sind bereits die Motivation und die Historie des Projekts erläutert worden. In diesem und den kommenden Beiträgen werden wir uns damit beschäftigen, wie die Anwendung auf AWS in Betrieb genommen werden kann. Als Schwerpunkt in diesem Teil wird der Amazon Elastic Container Service (ECS) betrachtet.

Fineracts Architektur

Bevor der Deep Dive in ECS kommt, müssen wir erst einmal auf die Architektur von Apache Fineract CN schauen.

Apache Fineract CN besitzt eine Microservice-Architektur. Die Anwendung besteht zum Zeitpunkt der Betrachtung aus sechs fachlichen Microservices. Zwei dieser Microservices sind zum Beispiel der Office und der Customer-Service. Der Office-Service verwaltet die Filialstruktur einer Bank, der Customer-Service verwaltet die Kunden dazu.

Ein dritter Microservices ist der Provisioner. Die Aufgabe des Provisioners ist eine ganz besondere. Dieser Service ermöglicht zur Laufzeit das Einpflegen neuer Mandanten in das System. Die Architektur von Apache Fineract CN ermöglicht dabei sogar eine physikalische Trennung der Mandanten auf Datenbankebene, was sich positiv auf Sicherheit und Skalierbarkeit auswirkt

Hinzu kommen noch diverse selbstgeschriebene Libraries, die alle unter der Apache Fineract CN GitHub Organization eingesehen werden können. Derzeit findet man die Services noch unter dem Namen "Mifos I/O", es gibt jedoch schon eine Roadmap für die Intagration der neuen Architektur in Apache Fineract. Zur Kommunikation zwischen den Microservices kommt Eureka als Service Discovery zum Einsatz (was später noch für einige Herausforderungen sorgen wird).

...Step 1: Dockerize Services

Um sowohl das lokale Starten als auch das Deployment (vermeintlich) zu vereinfachen, haben wir uns entschieden für jeden Microservice einen Docker-Container zu bauen und lokal über Docker-Compose zu starten. Zusätzliche Container wurden für Eureka und Cassandra erstellt.

Folgender Ausschnitt zeigt exemplarisch die Definition eines Services innerhalb der Dockers-Compose-Datei:

identity:
    image: openjdk:8-jre-alpine
    container_name: identity
    volumes:
      - "$HOME/path/to/app:/app"
    working_dir: /app
    command: java -Djava.security.egd=file:/dev/./urandom -jar service-0.1.0-BUILD-SNAPSHOT-boot.jar
    ports:
      - "8081:8081"
    depends_on:
        - cassandra
        - discovery
    networks:
        - fineract

[...]
networks:
    fineract:

Am interessantesten hierbei ist das networks-Attribut. Docker erzeugt ein Netzwerk innerhalb des selben Hosts und die einzelnen Services können diesem Netzwerk beitreten. Die Dokumentation von Docker schreibt dazu:

By default Compose sets up a single network for your app. Each container for a service joins the default network and is both reachable by other containers on that network, and discoverable by them at a hostname identical to the container name.

Bedeutet, dass sich die Container innerhalb dieses Netzwerks unter dem Hostnamen finden, der identisch zum angegeben Containernamen ist. Der identity-Container ist also im Netwerk auch unter http://identity:8081 zu erreichen.

Die vollständige Docker-Compose Datei für das lokale Deployment kann in diesem Gist eingesehen werden.

Soweit so gut...

...Step 2: Basics von Amazon ECS

Nachdem wir alles "dockerisiert" haben, sollte das Deployment auf AWS ja kein Problem sein. Dafür sind die Container schließlich da. Da AWS einen extra Service zum Betreiben von Container bereitstellt, war es naheliegend sich diesen Service anzuschauen.

Bevor wir uns in die architekturellen Feinheiten stürzen, müssen wir uns natürlich erst einmal die Grundlagen von ECS aneignen.

Wer mit ECS arbeitet, wird mit den Begriffen Repository, Cluster, TaskDefinition, Task und Service konfrontiert werden. Diese werden wir kurz erläutern.

Repository

Das Repository ist das Pendant von AWS zur Docker Registry auf Docker-Hub. Im Prinzip ist es eine Ablage für die Docker-Images, um diese online zur Verfügung zu stellen.
Dieser Service firmiert auch unter der Bezeichnung AWS ECR und ist ein Bestandteil von ECS.

Cluster

Amazon schreibt dazu:

An Amazon EC2 Container Service (Amazon ECS) cluster is a logical grouping of container instances [...]

Bedeutet im Klartext, dass der Anwender einfach mehrere EC2-Instanzen definiert, die zum Cluster gehören.

TaskDefinition

Die TaskDefinition ist so ähnlich wie ein Docker-Compose-File mit "Amazon-Flavour". Sie unterstützt viele Docker-Attribute (manchmal unter anderem Namen...) und fügt Amazon eigene Attribute zur Steuerung hinzu.

Task

Der Task ist die Ausführung einer Task-Definition auf einer der Cluster-Instanzen. Auch hier ist es vereinfacht gesagt eine laufende Docker-Instanz. Hierbei können aus einer Task-Definition auch mehrere Tasks gestartet werden.

Service

Der Service im ECS Kontext kümmert sich darum, dass Tasks automatisch gestartet und neugestartet werden, wenn diese ausfallen. Wenn ich mir zum Beispiel wünsche, dass der Identity-Service ausfallsicher immer zwei Tasks ausführen soll, dann ist es die Aufgabe des Service dies sicherzustellen. Dabei können verschiedene Platzierungsstrategien ausgewählt werden wie diese im Cluster verteilt werden.

Nachdem wir nun verstanden haben, was die Begriffe bedeuten und wie diese zusammenspielen, widmen wir uns ...

...Step 3: Porting to AWS

Als ersten Schritt müssen wir die Docker Images in ECR zur Verfügung stellen. Das geht mit folgenden Befehlen:

docker tag <image-id> 604370441254.dkr.ecr.eu-central-1.amazonaws.com/identity

docker push 604370441254.dkr.ecr.eu-central-1.amazonaws.com/identity

Der erste Befehl erzeugt zu einem bestehenden Image ein neues Tag mit URL-Prefix. Der zweite Befehl pusht das Image mit dem Tag in das ECR-Repository, da Docker den Prefix als URL erkennt und diesen als Ziel benutzt. Befinden sich alle Docker Images auf AWS ECR, können wir nun die TaskDefinitions für unsere Services anlegen.

Die Grundstruktur einer TaskDefinition [1] sieht wie folgt aus:

Die beiden Attribute networkMode und volumes verhalten sich genauso wie bei Docker.

Die Attribute taskRole, placementConstraints und family sind AWS ECS spezifische Attribute. taskRole ist ein Verweis auf eine Rechterolle innerhalb von AWS, welche die erlaubte Aktionen (z.B. Steuern anderer AWS Services) eines Tasks definiert. Mit placementConstraints [2] kann feingranular gesteuert werden, auf welchen Instanzen der Task ausgeführt werden soll. family ist die Bezeichnung der TaskDefinition. Jede Änderung an der TaskDefinition führt zu einer neuen Revision. In unserem Beispiel wäre fineract-all-in-one:4 also die vierte Revision unserer Definition.

In dem Attribut containerDefinitions werden nun die einzelnen Services definiert wie zum Beispiel der identity-Service.

Dieses Code Snippet zeigt vollumfänglich alle möglichen Attribute einer Container Definition. Viele Attribute verhalten sich wie bei Docker, haben aber eventuell einen etwas anderen Namen (z.B. portMappings).

Interessant hierbei ist, dass es im Vergleich zu Docker-Compose ein Attribut nicht gibt und zwar das networks-Attribut (auch nicht unter anderem Namen). Der ECS-Agent, der auf allen Cluster-Instanzen von AWS installiert ist, unterstützt dies nicht [3].
Unterstützt wird hingegen das links-Attribut, welches auch in Docker existiert. Allerdings ist dieses Attribut bereits deprecated [4] und kann mit kommenden Docker-Releases entfernt werden. Für unsere Zwecke war dies erst einmal nicht so wichtig, führte aber zu folgenden Problemen:

Verlinkungen über links müssen alle in der selben TaskDefinition sein:
Dies führt dazu, dass alle Services in einem Task entweder hoch oder heruntergefahren werden. Eine feingranulare Steuerung ist nicht möglich, da Tasks nur aus einer TaskDefinition entstehen. Da alle Services in die gleiche TaskDefinition gepackt sind, müssen wir, wenn wir eine zweite Instanz eines Services innerhalb der Definition benötigen, alle anderen Services mitskalieren. Dies setzt gleichzeitig auch voraus, dass der Arbeitsspeicher für alle Services vorhanden sein muss.

Pro TaskDefinition sind nur maximal zehn ContainerDefinitions erlaubt:
In einem Microservices-Ansatz wie ihn auch Apache Fineract CN verfolgt, kommen wir da schnell an das Limit. Hinzufügen weiterer Services ist dann nicht mehr möglich.

Service-Kommunikation mit links gilt nur innerhalb des selben Tasks: Wenn von einer TaskDefinition nun zwei Tasks laufen, wissen diese faktisch nichts voneinander; unabhängig davon, ob die Tasks auf dem selben Host laufen. Führt zum Beispiel dazu, dass zwei Eureka Instanzen sich nicht kennen und alle Services sich nur am eigenen Eureka anmelden. Eine Service Discovery wäre hier somit gar nicht nötig, da der Service nur Services aus der eigenen TaskDefinition finden würde.

Daraus ergeben sich folgende Rückschlüsse:

  • Unterstützende Services wie Eureka und Service Discovery auslagern.
  • Für jeden Microservice eine eigene TaskDefinition erstellen, welche die URL auf eine Eureka Instanz bekommt, an der sich ein Service anmelden kann.
  • Keine Verlinkung der Microservices über links-Attribute, sondern mit DNS-Hostnamen über externe Eureka...

... Step 4: Which ip/hostname to send?

Da die Services alle in Docker-Containern laufen, senden diese eine IP an Eureka, die von anderen Hosts überhaupt nicht erreicht werden kann.

In diesem Fall sendet der Service die Container-IP 172.17.0.2 statt der Host-IP 192.168.0.2. Dies ist der Fall, wenn wir in der TaskDefinition das Attribut networkMode auf "bridge" gesetzt haben.
Eine mögliche Lösung wäre den networkMode auf "host" zu setzen, sodass der Netzwerkstack des Hosts genutzt wird. Nachteil dieser Lösung ist ein implizites Port-Binding, welches dazu führt, dass wir definitiv nur noch einen Tasks eines Services auf einem Host laufen lassen können. Wollen wir zum Beispiel den identity-Service mit vier Tasks laufen lassen, benötigen wir auch vier unterschiedliche Cluster-Instanzen (Achtung: Dieses Problem existiert auch bei explizitem Port Binding), obwohl eventuell noch genügend Rechenkapazität auf dem selben Server verfügbar wäre.

Auch das Senden eines Hostnamens statt einer IP ist nicht möglich. Zwar kann man in der TaskDefinition angeben, welcher Hostname für den Container gesetzt werden soll, ist damit aber statisch an eine Cluster-Instanz gebunden. Jeder neue Tasks würde den angegeben Hostnamen kommunizieren. Eine automatische Skalierung auf mehreren Servern ist somit nicht möglich.

Was für eine Lösung suchen wir also? Wir wollen in der Lage sein ohne Portbinding dynamisch mehrere Tasks eines Service zu starten, die in Docker-Containern laufen und sich gleichzeitig aber auch mit dem richtigen Hostnamen anmelden.

Da der "direkte Weg" wie geschildert nicht zum Ziel führt, musste ein indirekter Weg her, der allerdings Eureka quasi überflüssig macht. Dieser Weg funktioniert wie folgt:

Dynamic Portbinding: Geben wir bei der ContainerDefinition beim Portmapping als Host-Port eine "0" an, sucht sich der Docker-Container einen freien Host-Port und belegt diesen. Dadurch können auch mehrere Tasks auf einem Server laufen.

Application LoadBalancer: Der Service in ECS meldet die Container an einem Application LoadBalancer an. Die ECS-Agents auf den Cluster-Instanzen sorgen dafür, dass der LoadBalancer alle laufenden Tasks und damit die Docker-Container mit ihren Ports kennt (und das instanzübergreifend). Eine genaue Anleitung findet sich im AWS Blog.

Faking Hostname: Die Microservices melden sich nun bei Eureka mit einem Hostnamen an. Der Hostname ist aber nicht der Hostname der Cluster-Instanz, sondern es ist der Hostname des Application LoadBalancers.

Wenn jetzt zum Beispiel wie in folgender Abbildung der Portfolio-Microservice die Adresse eines Identity-Microservices anfragt, bekommt dieser immer die Adresse des Identity-Application-LoadBalancers zurück (http://identity.bitc). Der Request vom Portfolio-Client geht damit über den LoadBalancer und verteilt die Anfragen wieder.

Der LoadBalancer ist hinter der Adresse http://identity.bitc versteckt. Diese Adresse wird von Route53 aufgelöst, die wir in unserem Szenario als DNS-Server nutzen. Weitere Informationen dazu gibt es im nächsten Teil der Blogreihe.

Wie bereits erwähnt ist Eureka mit dieser Lösung im Prinzip obsolet. In diesem AWS-Blog-Artikel wird der Weg über Route53 als Service Discovery als Ersatz für Eureka und als Referenzarchitektur skizziert. Da wir nicht für das Deployment in den Open Source Code eingreifen wollten, haben wir die Eureka nicht ausgebaut.

Fazit

Die in diesem Blogpost geschilderten Herausforderungen treten erstmal nur in der speziellen Konstellation AWS ECS mit Eureka auf. Laufen die Microservices nicht in Docker-Containern, gäbe es diese Probleme nicht.

Die zwei konkreten Probleme lauten: Wie können die Container sinnvoll skaliert werden und wie finden sich die Container innerhalb der Cluster-Infrastruktur?

Die von uns gewählte Lösung beinhaltet:

  • Pro TaskDefinition nur ein Microservice.
  • Dynamic Port Binding für die Docker Container, die sich hinter dem Application Load Balancer befinden.
  • Microservices kommunizieren nur die Adresse des LoadBalancers.

Die Lösung ist unter dem Hintergrund entstanden, dass keine Anpassungen am Code vorgenommen werden sollten.

Wenn man den Code anpassen kann, gibt es andere Alternativen:

  • Ausbau Eureka
  • Explizites Setzen des Hostnames im Code über AWS SDK.
Verweise

  1. Erklärung aller Task-Definition Attribute: http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definitions.html ↩︎

  2. Placement Strategien:
    http://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-placement-constraints.html ↩︎

  3. ECS Agent und das "networks" Attribut:
    https://github.com/aws/amazon-ecs-agent/issues/437 ↩︎

  4. Docker "links":
    https://docs.docker.com/engine/userguide/networking/default_network/dockerlinks/ ↩︎