In einer verteilten Systemlandschaft gilt es irgendwann, das Problem zu lösen, wie Services sich gegenseitig im Netz finden. Der einfachste Ansatz ist es, in den betreffenden Services eine feste Konfiguration zu hinterlegen. Meist finden sich dann hinter einem Servicenamen eine IP-Adresse und der dazugehörige Port wieder. Eine etwas dynamischere Lösung ist es, anstatt IP-Adressen einen DNS-Eintrag zu verwenden. Dabei können Adresse und Port z. B. in einem DNS SRV Record hinterlegt werden. Auch ist es möglich, mehreren Instanzen der gleichen Services zur Verfügung zu stellen, indem man ein Round-Robin-Verfahren nutzt. Vereinfacht ausgedrückt wird bei jeder Anfrage die IP-Adresse einer anderen Instanz zurückgegeben.

Welche Probleme löst dann eigentlich noch Consul? Zum reinen Auflösen von Namen und Ports kommen in einem moderneren verteilten System schnell weitere Anforderungen dazu: Services sollen sich oft an einem zentralen API registrieren können. Bereits registrierte Dienste müssen per Health Check auf ihre Erreichbarkeit geprüft werden, um sie im Fehlerfall automatisiert aus der Registry zu entfernen. Außerdem ist die Discovery ein fester Teil der Infrastruktur und muss daher natürlich auch eine entsprechende Robustheit und Ausfallsicherheit bieten. Kann ein Service die anderen Systeme nicht finden, ist das oft mit einem Netzwerkausfall gleichzusetzen. Außerdem benötigt man häufig eine zentrale Stelle, um systemweite Konfiguration wie „Feature Toggles“ ablegen zu können, die bestimmte Features an- oder abschalten. Traditionelle DNS-Server sind mit anderen Zielen entwickelt worden, und eine selbstgebaute Lösung entspricht vom Aufwand eher einem eigenen Projekt. Aus diesem Grund lohnt sich ein Blick auf Consul.

Die Servicedefinition ist der erste Schritt

Um einen Service registrieren und abfragen zu können, ist es wichtig, wie die Discovery einen Service definiert und welche Informationen verfügbar sind und gespeichert werden. Bei Consul besteht eine Servicedefinition grundlegend aus ID, Name, IP-Adresse und Port. Optional können noch das Rechenzentrum, beliebige Tags und Health Checks angegeben werden. Das Rechenzentrum ist nicht nur eine reine Zusatzinformation, sondern dient zur Strukturierung und Aufbau des Clusters. Der Name muss nicht eindeutig sein. Damit können mehrere Instanzen der gleichen Services unterstützt werden. Die Unterscheidung nach Instanzen ist bewusst gewählt und ermöglicht so z. B. die Konfiguration von unterschiedlichen Health Checks oder Tags per Instanz. Soll Consul schon mit einer Servicedefinitionen vorprovisioniert werden, können diese im JSON-Format nach einem festen Schema als Konfiguration hinterlegt werden (Listing 1).

{ "service": {
    "name": "web-app",
    "tags": ["core"],
    "address": "203.0.113.23",
    "port": 8000,
    "checks": [
      {
        "http": "http://localhost:5000/health",
        "interval": "10s",
        "timeout": "1s"
}]}}
Listing 1: Beispiel Servicedefinition

Über Discovery-Schnittstellen Services finden

Services können über zwei unterschiedliche Schnittstellen abgefragt werden: DNS und ein HTTP-API. Mit der Standardkonfiguration ist das DNS-Interface über UDP auf Port 8600 erreichbar und beantwortet DNS-Anfragen unter der .consul-Domain. Der Eintrag zum Service web-app aus Listing 1 lässt sich dann z. B. über webapp.service.consul abfragen. Dezidiertere Abfragen sind nach folgendem Schema möglich:

[tag.].service[.datacenter].consul

Wenn nichts weiter spezifiziert wurde, antwortet Consul mit einem A-Record, also einer IP-Adresse. Werden zusätzliche Informationen benötigt, beispielsweise der Port des Service, kann explizit ein SRV-Record angefragt werden (Listing 2).

$ dig @127.0.0.1 -p 8600 consul.service.consul SRV

; <<>> DiG 9.8.3-P1 <<>> @127.0.0.1 -p 8600 consul.service.consul ANY
[..]
;; QUESTION SECTION:
;consul.service.consul. IN SRV

;; ANSWER SECTION:
consul.service.consul. 0 IN SRV 1 1 8300 foobar.node.dc1.consul.

;; ADDITIONAL SECTION:
foobar.node.dc1.consul. 0 IN A 203.0.113.42
Listing 2: Serviceabfrage via DNS

Weitere Beispiele finden sich in der Consul-Dokumentation zum DNS-Interface [1]. Eine Abfrage über das HTTP-API ist über einen GET-Request an /v1/catalog/service/ möglich. Auch hier kann die Suche auf das Rechenzentrum oder bestimmte Tags mit einem Query-Parameter eingeschränkt werden und z. B. aussehen wie in Listing 3.

$ curl -XGET “http://my.consul.service:8500/v1/catalog/service/web-app?
dc=dc1&tag=core”
[{
    "Node": "foobar",
    "Address": "203.0.113.23",
    "ServiceID": "web-app1",
    "ServiceName": "web-app",
    "ServiceTags": ["core"],
    "ServiceAddress": "",
    "ServicePort": 8000
}]
Listing 3: Serviceabfrage via HTTP-API

Das CAP-Theorem: Man kann nicht alles haben

Um die Architektur von Consul zu verstehen, muss man einen Blick auf die Eigenschaften von verteilten Systemen werfen. Service Discovery ist kritische Infrastruktur und muss, wenn sie ausfallsicher sein soll, auf mehreren Knoten betrieben werden. Interessant sind hier Konsistenz (Daten sind überall aktuell), Verfügbarkeit (Anfragen werden beantwortet) und Partitionstoleranz (Verlust einzelner Knoten wird toleriert).

Eric Brewer stellte 2000 die inzwischen bewiesene Vermutung auf (CAP- oder Brewer-Theorem), dass nur zwei dieser drei Anforderungen an ein verteiltes System gleichzeitig erfüllt werden können. Soll ein System zu 100 Prozent verfügbar und die Daten immer konsistent sein, darf kein Knoten ausfallen. In der Realität kann es aber immer passieren, dass ein Knoten ausfällt. Sogar hochverfügbare Systeme können ausfallen. Kann ein Knoten ausfallen, muss man entweder auf die Verfügbarkeit verzichten oder einen inkonsistenten Datenbestand akzeptieren. Durch den Ausfall des Knoten können die anderen Knoten nicht mehr wissen, welchen Datenstand dieser Knoten hatte. Wenn sie Anfragen aus ihrem Stand der Daten beantworten, sind die Antworten gegebenenfalls inkonsistent mit den Antworten, die der ausgefallene Knoten gegeben hätten. Sind solche Inkonsistenzen nicht akzeptabel, können die anderen Knoten nur noch keine Antworten nach dem Ausfall geben – dann sind sie nicht mehr verfügbar.

Consul versucht eine praktikable Lösung zu finden und ist in erster Linie streng konsistent und toleriert den Ausfall einzelner Knoten. Dabei wird akzeptiert, dass Anfragen nicht immer beantwortet werden können.

Abb. 1 CAP-Theorem
Abb. 1 CAP-Theorem

Um trotzdem bei Clusterproblemen zwischen den Consul-Knoten nicht das gesamte System zu blockieren oder um die Performance zu steigern, kann der Client so genannte stale-Anfragen stellen. Damit bekommt er unter Umständen auch veraltete Antworten. Im Normalbetrieb liegen diese Antworten jedoch in der Regel nicht weiter als 50 Millisekunden zum Cluster zurück. Eine weitere Möglichkeit besteht darin, die Infrastruktur zu erweitern und einen zusätzlichen DNS-Cache vor Consul zu betreiben. Entschärft wird dieses Problem außerdem durch die Partition des Consul-Clusters in einzelne datacenter. Diese müssen nicht zwangsläufig physische Rechenzentren sein, sondern können auch unterschiedliche Availability Zones in der Amazon-Cloud sein. Tritt ein Problem mit dem Clustering in Rechenzentrum A auf, ist die Service Registry in Rechenzentrum B nicht betroffen.

Die Consul-Architektur: Server und Agents

Die Architektur von Consul sieht mindestens einen Server vor, um Ausfallsicherheit zu erreichen; idealerweise drei oder fünf Server. Dabei wird ein Server als Cluster Leader bestimmt, der alle Änderungen an der Registry ausführt und anschließend diese an die anderen Knoten repliziert. Ein Cluster bestehend aus drei Knoten erzeugt man, indem man drei Consul-Server-Instanzen startet und den letzten Server anweist einen Cluster mit den vorherigen Servern zu bilden. Der Befehl zum Starten sieht wie folgt aus:

$ consul agent -server -data-dir data --bootstrap-expect 3
$ consul join -server <NODE_A> <NODE_B>

Aus Performancegründen erfüllt nicht jede laufende Consul-Instanz die Rolle eines Servers, z. B. wegen Abstimmungen im Cluster. Wird das Konfigurations-Flag -server nicht angegeben, läuft Consul im Clientmodus.

Clients besitzen keinen eigenen Zustand und leiten alle Anfragen an verfügbare Server weiter. Eine laufende Consul-Instanz wird als Consul Agent bezeichnet, unabhängig davon, ob sie als Client oder Server gestartet wurde. Eine typische Systemlandschaft für Consul besteht aus mehreren Servern und einem Consul Client pro Host (Abb. 2).

Abb. 2 Consul Infrastruktur
Abb. 2 Consul Infrastruktur

Health Checks durchführen

Zu einem Service können bei Bedarf verschiedene Health Checks definiert werden. Health Checks werden nicht von Servern im Cluster ausgeführt, sondern ebenfalls aus Performancegründen von den lokalen Consul Clients. Ist ein Service nicht mehr erreichbar, wird er erst als kritisch markiert und letztendlich aus dem Servicekatalog entfernt. Ist der Health Check nicht für einen einzelnen Service definiert, sondern Teil der Konfiguration eines Consul-Agent-Knotens, werden bei einem fehlgeschlagenen Check alle Services, die auf diesem Knoten laufen, ebenfalls als kritisch markiert und im nächsten Schritt aus der Registry entfernt.

Es gibt unterschiedliche Möglichkeiten, die Tests auszuführen. Consul kann eine Socket-Verbindung zu einem Port aufbauen, ein auf dem Knoten abgelegtes Skript starten und den Rückgabewert interpretieren oder eine HTTP-Verbindung zu einem definierten URI aufbauen. Ist der HTTP-Status-Code 2xx, wird der Service als gesund betrachtet und bleibt in der Registry eingetragen.

Alternativ kann ein Service sich als Health Check auch aktiv beim Consul Agent melden. Dazu wird der HTTP-API-Endpoint /v1/agent/check/pass/ oder /v1/agent/check/fail/ aufgerufen. Meldet sich der Service nicht innerhalb einer vorgegebenen Time-to-Live, wird der Test ebenfalls als fehlgeschlagen markiert. Inzwischen kann Consul außerdem nativ in Docker-Containern Skripte starten, um auch hier einfacher die Verfügbarkeit zu testen (Listing 3).

{"check": {
    "id": "mem-util",
    "name": "Memory utilization",
    "docker_container_id": "f972c95ebf0e",
    "shell": "/bin/bash",
    "script": "/usr/local/bin/check_mem.py",
    "interval": "10s"
}}
Listing 3: Beispiel Docker-Health-Check

Services registrieren

Natürlich ist es nicht immer möglich, feste Servicedefinitionen im Vorfeld als Konfigurationsdatei zur Verfügung zu stellen. Ein verteiltes System verändert sich dynamisch, Services fallen kurzzeitig aus, es kommen neue Instanzen hinzu etc. Die wichtigste Schnittstelle zu Consul ist das HTTP-API mit dem /v1/agent/service/register-Endpoint. Erwartet wird als JSON Payload dabei die gleiche Definition, wie bei einer festen Konfiguration aus unserem ersten Beispiel (Listing 4).

$ curl -XPUT “http://my.consul.service:8500/” -d{ "service": {
    "name": "web-app",
    "tags": ["core"],
    "address": "203.0.113.23",
    "port": 8000,
    "checks": [
      {
        "http": "http://localhost:5000/health",
        "interval": "10s",
        "timeout": "1s"
}]}}
Listing 4: Serviceregistrierung mit Curl

Möchte man keine explizite Abhängigkeit zwischen einem eigenen Service und der Registry schaffen, z. B. im Docker-Umfeld, gibt es verschiedene Bridges. Eine Service Registry Bridge würde beispielsweise alle neu gestarteten Container automatisch bei Consul registrieren. Gliderlabs bietet mit Registrator [2] ein Docker-Image an, das mit der Minimalkonfiguration in Listing 5 einfach gestartet werden kann und neue Container am lokalen Consul Agent anmeldet.

$ docker run -d \
  --name=registrator \
  --net=host \
  --volume=/var/run/docker.sock:/tmp/docker.sock \
gliderlabs/registrator:latest \
consul://localhost:8500
Listing 5: Registrator als Docker Sidekick

Key-Value Store mit Extras

Consul bietet einen Key-Value Store, der eine ganze Reihe an weiteren Features ermöglicht. Darunter fallen Semaphore [3], ein Session-Mechanismus [4] und die Option mit Watches [5] auf die Änderung bestimmter Werte zu reagieren, ohne diese Daten konstant abfragen zu müssen. Außerdem sind Transaktionen für das Lesen und Schreiben von Daten möglich. Hier betrachten wir allerdings nur die Basisfunktionalität: Das Speichern und Lesen von Werten unter einem bestimmten Schlüssel. Einzelne Keys können über den API-Endpoint /v1/kv/<key> verändert oder gelesen werden. Dabei kann der Key durchaus eine Hierarchie abbilden, wie /web/frontend/reverseproxy.

Ein GET-Request liefert ein JSON-Objekt mit einem Value und zusätzlichen Metadaten zurück. Die Obergrenze für den Value liegt bei 512 kB. Der Query-Parameter ?recurse sorgt dafür, dass der angeforderte Key als Präfix interpretiert wird und erweitert damit die Abfrage auf beispielsweise alle Keys, die mit /web beginnen. Diese können auch mittels PUT oder DELETE Requests manipuliert werden. Als Body des PUT Requests wird allerdings nur der Value und kein JSON erwartet. Metadaten werden durch entsprechende Query-Parameter gesetzt.

Integration mit Templates

Erwähnenswert in Zusammenhang mit Consul ist das von HashiCorp gepflegte Consul-Templateprojekt [6]. Vorbereitete Templates können durch einem Daemon mit Daten aus Consul provisioniert werden. Dadurch lässt sich z. B. eine nginx-Reverse-Proxy-Konfiguration erweitern, sobald eine neue Serviceinstanz im Backend hochgefahren wurde. Nach dieser Konfigurationsänderung kann der Template Daemon den Proxy neustarten. Als Template-Engine kommt die Standard Go-Bibliothek text/template zum Einsatz.

Fazit

Über die genannten Features hinaus existieren noch eine Vielzahl an API-Endpunkten, ACLs und Securityfeatures. Außerdem kann Consul auch mit strenger Authentifizierung und TLS betrieben werden. Interessierte Leser finden in der Dokumentation dazu weitere Informationen, unter anderem unter dem Punkt Encryption und Security Model.

Consul steht in Konkurrenz zu einer ganzen Reihe an weiteren Lösungen, mit denen eine Service Discovery aufgebaut werden kann. Verbreitet sind CoreOS etcd, Netflix Eureka, SkyDNS, Doozer oder Zookeeper. Oft sind diese Lösungen allerdings nur Key-Value Stores und müssen entweder durch eigene Services erweitert oder mit anderen Projekten ergänzt werden, um eine vollständige Discovery-Lösung zu bieten. Der Ratschlag, sich Anforderungen genau anzusehen und nach diesen eine passende Lösung auszusuchen, gilt hier besonders.

  1. DNS Interface: https://developer.hashicorp.com/consul/docs/services/discovery/dns-overview  ↩

  2. Registrator: https://github.com/gliderlabs/registrator  ↩

  3. Semaphore: https://www.consul.io/docs/guides/semaphore.html  ↩

  4. Sessions: https://www.consul.io/docs/internals/sessions.html  ↩

  5. Watches: https://www.consul.io/docs/agent/watches.html  ↩

  6. Consul–Template: https://github.com/hashicorp/consul-template  ↩