Kompilierte Konfiguration des verteilten Systems

Ich möchte einen interessanten Mechanismus zur Arbeit mit der Konfiguration eines verteilten Systems vorstellen. Die Konfiguration wird direkt in der kompilierbaren Sprache (Scala) unter Verwendung sicherer Typen dargestellt. In diesem Beitrag wird ein Beispiel für eine solche Konfiguration behandelt und verschiedene Aspekte der Implementierung konfigurierbarer Sprachversionen im gesamten Entwicklungsprozess betrachtet.

Kompilierte Konfiguration des verteilten Systems

(english)

Einführung

Der Aufbau eines zuverlässigen verteilten Systems setzt voraus, dass an allen Knoten eine korrekte Konfiguration verwendet wird, die mit anderen Knoten synchronisiert ist. Üblicherweise kommen DevOps-Technologien (Terraform, Ansible oder etwas Ähnliches) zur automatischen Erstellung von Konfigurationsdateien zum Einsatz (häufig spezifisch für jeden Knoten). Wir möchten auch sicherstellen, dass an allen interagierenden Knoten identische Protokolle verwendet werden (einschließlich derselben Version). Andernfalls würde unsere verteilte Systemarchitektur Inkompatibilitäten aufweisen. In der JVM-Welt ist eine der Folgen dieser Anforderung die Notwendigkeit, überall dieselbe Version der Bibliothek zu verwenden, die die Protokollnachrichten enthält.

Wie sieht es mit dem Testen eines verteilten Systems aus? Natürlich gehen wir davon aus, dass für alle Komponenten Unit-Tests vorhanden sind, bevor wir zum Integrationstest übergehen. (Damit wir die Testergebnisse auf die Laufzeit extrapolieren können, müssen wir auch sicherstellen, dass während der Testphase und der Laufzeit dasselbe Set an Bibliotheken verwendet wird.)

Bei der Arbeit mit Integrationstests ist es oft einfacher, überall dieselben Classpaths an allen Knoten zu verwenden. Wir müssen nur sicherstellen, dass derselbe Classpath auch in der Laufzeit verwendet wird. (Obwohl es durchaus möglich ist, verschiedene Knoten mit unterschiedlichen Classpaths zu starten, führt dies zu einer Komplexität der gesamten Konfiguration und Schwierigkeiten bei der Bereitstellung sowie den Integrationstests.) In diesem Beitrag gehen wir davon aus, dass an allen Knoten derselbe Classpath verwendet wird.

Die Konfiguration entwickelt sich gemeinsam mit der Anwendung. Zur Identifizierung verschiedener Evolutionsstufen der Software verwenden wir Versionen. Es scheint auch sinnvoll, verschiedene Versionen der Konfigurationen zu identifizieren. Darüber hinaus sollte die Konfiguration in ein Versionskontrollsystem eingebettet werden. Wenn es in der Produktion nur eine Konfiguration gibt, können wir einfach die Versionsnummer verwenden. Gibt es jedoch mehrere Produktionsinstanzen, benötigen wir mehrere
Konfigurationszweige und ein zusätzliches Label neben der Version (zum Beispiel den Namen des Zweigs). Auf diese Weise können wir die exakte Konfiguration eindeutig identifizieren. Jede Konfigurations-ID entspricht eindeutig einer bestimmten Kombination aus verteilten Knoten, Ports, externen Ressourcen und Bibliotheksversionen. In diesem Beitrag gehen wir davon aus, dass es nur einen einzigen Zweig gibt, und wir die Konfiguration auf übliche Weise mit drei durch Punkte getrennten Zahlen identifizieren können (1.2.3).

In modernen Umgebungen werden Konfigurationsdateien nur selten manuell erstellt. Häufig werden sie während der Bereitstellung generiert und danach nicht mehr berührt (um nichts zu beschädigen). Es stellt sich die berechtigte Frage, warum wir immer noch das Textformat zur Speicherung der Konfiguration verwenden. Eine durchaus lebensfähige Alternative wäre die Verwendung von normalem Code für die Konfiguration, um Vorteile durch Prüfungen zur Kompilierzeit zu erzielen.

In diesem Beitrag untersuchen wir genau die Idee, die Konfiguration innerhalb des kompilierten Artefakts darzustellen.

Kompilierte Konfiguration

In diesem Abschnitt wird ein Beispiel für eine statische, kompilierbare Konfiguration betrachtet. Es werden zwei einfache Dienste implementiert – ein Echo-Dienst und ein Echo-Dienst-Client. Auf der Grundlage dieser beiden Dienste werden zwei Systemvarianten zusammengestellt. In einer Variante befinden sich beide Dienste auf einem Knoten, in der anderen Variante auf verschiedenen Knoten.

Eine verteilte Systemumgebung besteht in der Regel aus mehreren Knoten. Knoten können anhand von Werten eines bestimmten Typs identifiziert werden. NodeId:

sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeId

oder

case class NodeId(hostName: String)

oder sogar

object Singleton
type NodeId = Singleton.type

Knoten übernehmen verschiedene Rollen, auf ihnen laufen Dienste und zwischen ihnen können TCP/HTTP-Verbindungen eingerichtet werden.

Zur Beschreibung der TCP-Verbindung benötigen wir mindestens eine Portnummer. Wir möchten auch das Protokoll angeben, das an diesem Port unterstützt wird, um sicherzustellen, dass sowohl der Client als auch der Server dasselbe Protokoll verwenden. Wir beschreiben die Verbindung mit einer solchen Klasse:

case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])

wo Port — eine ganze Zahl Int mit Angaben zum zulässigen Wertebereich:

type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]

Verfeinerte Typen

Siehe Bibliothek refined und mein Bericht. Kurz gesagt, die Bibliothek ermöglicht das Hinzufügen von Einschränkungen zu Typen, die zur Kompilierungszeit überprüft werden. In diesem Fall sind die zulässigen Werte für die Portnummer ganze 16-Bit-Zahlen. Die Verwendung der Bibliothek refined ist für die kompilierte Konfiguration nicht obligatorisch, verbessert jedoch die Möglichkeiten des Compilers zur Überprüfung der Konfiguration.

Für HTTP (REST) Protokolle benötigen wir neben der Portnummer möglicherweise auch den Pfad zum Service:

type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9\/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)

Phantomtypen

Zur Identifizierung des Protokolls zur Kompilierungszeit verwenden wir einen Typparameter, der innerhalb der Klasse nicht verwendet wird. Diese Lösung hängt damit zusammen, dass wir zur Laufzeit keine Instanz des Protokolls verwenden, wir jedoch möchten, dass der Compiler die Protokollkompatibilität überprüft. Durch die Angabe des Protokolls können wir keinen ungeeigneten Service als Abhängigkeit übergeben.

Eines der verbreitetsten Protokolle ist das REST API mit JSON-Serialisierung:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

wo RequestMessage — der Anfrage-Typ, ResponseMessage — der Antwort-Typ.
Natürlich können auch andere Protokollbeschreibungen verwendet werden, die die erforderliche Genauigkeit der Beschreibung gewährleisten.

Für die Zwecke dieses Beitrags verwenden wir eine vereinfachte Version des Protokolls:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Hier stellt die Anfrage einen an die URL angehängten String dar, während die Antwort der zurückgegebene String im Körper der HTTP-Antwort ist.

Die Konfiguration des Services wird durch den Servicenamen, die Ports und Abhängigkeiten beschrieben. Diese Elemente können in Scala auf verschiedene Arten dargestellt werden (z.B. HList- und algebraische Datentypen). Für die Zwecke dieses Beitrags verwenden wir das Cake Pattern und stellen die Module durch traitDas Cake Pattern ist kein obligatorisches Element des beschriebenen Ansatzes. Es ist lediglich eine der möglichen Implementierungen.

Die Abhängigkeiten zwischen den Diensten können in Form von Methoden dargestellt werden, die Ports zurückgeben EndPointvon anderen Knoten:

  type EchoProtocol[A] = SimpleHttpGetRest[A, A]

  trait EchoConfig[A] extends ServiceConfig {
    def portNumber: PortNumber = 8081
    def echoPort: PortWithPrefix[EchoProtocol[A]] = PortWithPrefix[EchoProtocol[A]](portNumber, "echo")
    def echoService: HttpSimpleGetEndPoint[NodeId, EchoProtocol[A]] = providedSimpleService(echoPort)
  }

Um einen Echo-Dienst zu erstellen, benötigen wir nur die Portnummer und die Angabe, dass dieser Port das Echo-Protokoll unterstützt. Wir könnten den spezifischen Port auch nicht angeben, da Traits es ermöglichen, Methoden ohne Implementierung zu deklarieren (abstrakte Methoden). In diesem Fall würde der Compiler bei der Erstellung einer konkreten Konfiguration von uns verlangen, die Implementierung der abstrakten Methode bereitzustellen und die Portnummer anzugeben. Da wir die Methode implementiert haben, können wir bei der Erstellung einer konkreten Konfiguration einen anderen Port weglassen. Der Standardwert wird verwendet.

In der Konfiguration des Clients deklarieren wir die Abhängigkeit vom Echo-Dienst:

  trait EchoClientConfig[A] {
    def testMessage: String = "test"
    def pollInterval: FiniteDuration
    def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
  }

Die Abhängigkeit hat denselben Typ wie der exportierte Dienst echoService. Im Echo-Client fordern wir daher dasselbe Protokoll an. Wenn wir zwei Dienste verbinden, können wir sicher sein, dass alles korrekt funktioniert.

Implementierung der Dienste

Zum Starten und Stoppen eines Dienstes ist eine Funktion erforderlich. (Die Möglichkeit, den Dienst zu stoppen, ist für Tests von entscheidender Bedeutung.) Es gibt wiederum mehrere Möglichkeiten, eine solche Funktion zu implementieren (zum Beispiel könnten wir Typklassen basierend auf dem Typ der Konfiguration verwenden). Für die Zwecke dieses Beitrags verwenden wir das Cake Pattern. Wir werden den Dienst durch die Klasse cats.Resourcerepräsentieren, da diese Klasse bereits Mittel für eine sichere, garantierte Freigabe von Ressourcen im Falle von Problemen bereitstellt. Um die Ressource zu erhalten, müssen wir eine Konfiguration und den entsprechenden Runtime-Kontext bereitstellen. Die Funktion zum Starten des Dienstes könnte wie folgt aussehen:

  type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]

  trait ServiceImpl[F[_]] {
    type Config
    def resource(
      implicit
      resolver: AddressResolver[F],
      timer: Timer[F],
      contextShift: ContextShift[F],
      ec: ExecutionContext,
      applicative: Applicative[F]
    ): ResourceReader[F, Config, Unit]
  }

wo

  • Konfiguration — Konfigurationstyp für diesen Dienst
  • AddressResolver — Ausführungsobjekt, das es ermöglicht, die Adressen anderer Knoten zu erfahren (siehe unten)

und andere Typen aus der Bibliothek cats:

  • F[_] — Effekt-Typ (im einfachsten Fall F[A] kann einfach eine Funktion sein () => A. In diesem Beitrag werden wir verwenden cats.IO.)
  • Reader[A,B] — mehr oder weniger ein Synonym für die Funktion A => B
  • cats.Resource — Ressource, die abgerufen und freigegeben werden kann
  • Timer — Timer (erlaubt es, für eine gewisse Zeit in den Schlafzustand zu gehen und Zeitintervalle zu messen)
  • ContextShift — ähnlich ExecutionContext
  • Applicative — Effekt-Typklasse, die es erlaubt, einzelne Effekte zu kombinieren (fast eine Monade). In komplexeren Anwendungen ist es offenbar besser, Monad/ConcurrentEffect.

Mit dieser Funktionssignatur können wir mehrere Dienste implementieren. Zum Beispiel einen Dienst, der nichts tut:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config  Resource.pure[F, Unit](()))
  }

(Siehe den Quellcodein dem andere Dienste implementiert sind — Echo-Dienst, Echo-Client
und Lebenszyklus-Controller.)

Ein Knoten ist ein Objekt, das mehrere Dienste starten kann (die Ketten von Ressourcen wird durch das Cake-Pattern bereitgestellt):

object SingleNodeImpl extends ZeroServiceImpl[IO]
  with EchoServiceService
  with EchoClientService
  with FiniteDurationLifecycleServiceImpl
{
  type Config = EchoConfig[String] with EchoClientConfig[String] with FiniteDurationLifecycleConfig
}

Beachten Sie, dass wir den genauen Typ der Konfiguration angeben, die für diesen Knoten erforderlich ist. Wenn wir vergessen, einen der von einem Dienst benötigten Konfigurationstypen anzugeben, wird es einen Kompilierungsfehler geben. Außerdem können wir den Knoten nicht starten, wenn wir kein Objekt bereitstellen, das den passenden Typ mit allen erforderlichen Daten hat.

Namensauflösung von Knoten

Um sich mit einem entfernten Knoten zu verbinden, benötigen wir eine tatsächliche IP-Adresse. Es ist durchaus möglich, dass die Adresse später bekannt wird als die anderen Teile der Konfiguration. Daher benötigen wir eine Funktion, die die Knotennummer in eine Adresse abbildet:

case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
  def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}

Es können mehrere Implementierungsmöglichkeiten für eine solche Funktion vorgeschlagen werden:

  1. Wenn uns die Adressen vor der Bereitstellung bekannt werden, können wir Scala-Code mit
    Adressen generieren und dann den Build starten. Dabei werden Kompilierung und Tests durchgeführt.
    In diesem Fall ist die Funktion statisch bekannt und kann im Code als Darstellung dargestellt werden. Map[NodeId, NodeAddress].
  2. In einigen Fällen wird die tatsächliche Adresse erst nach dem Start des Knotens bekannt.
    In diesem Fall können wir einen „Entdeckungsdienst“ (discovery) implementieren, der vor den anderen Knoten gestartet wird, und alle Knoten in diesem Dienst registrieren und Adressen anderer Knoten anfordern.
  3. Wenn wir modifizieren können /etc/hosts, können vordefinierte Hostnamen verwendet werden (ähnlich wie my-project-main-node und echo-backend) und diese Namen einfach während der Bereitstellung
    mit IP-Adressen verknüpfen.

Im Rahmen dieses Beitrags werden wir diese Fälle nicht weiter behandeln. Für unser
Spielbeispiel werden alle Knoten die gleiche IP-Adresse haben — 127.0.0.1.

Lassen Sie uns zwei Varianten eines verteilten Systems betrachten:

  1. Die Bereitstellung aller Dienste auf einem Knoten.
  2. Und die Bereitstellung des Echo-Servers und des Echo-Clients auf verschiedenen Knoten.

Konfiguration für einen Knoten:

Konfiguration für einen Knoten

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton \/\/ identifier des einzelnen Knotens 
  \/\/ Konfiguration des Servers
  type NodeId = Singleton.type
  def nodeId = Singleton

  \/** Typensichere Spezifikation des Dienstport.
  *\/ 
  override def portNumber: PortNumber = 8088

  \/\/ Konfiguration des Clients

  \/** Wir verwenden den Dienst des gleichen Hosts. *\/ 
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hallo"

  def pollInterval: FiniteDuration = 1.second

  \/\/ Lebenszykluscontroller-Konfiguration
  def lifetime: FiniteDuration = 10500.milliseconds \/\/ zusätzliche 0,5 Sekunden, damit es 10 Anfragen und nicht 9 gibt.
}

Das Objekt implementiert die Konfiguration sowohl für den Client als auch für den Server. Es wird auch eine Lebensdauer-Konfiguration verwendet, um nach Ablauf des Intervalls lifetime das Programm zu beenden. (Strg-C funktioniert ebenfalls und gibt alle Ressourcen ordnungsgemäß frei.)

Das gleiche Set an Traits für die Konfiguration und Implementierungen kann verwendet werden, um ein System zu erstellen, das aus zwei separaten Knoten:

Konfiguration für zwei Knoten

  object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
  {
    type NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
  {
    \/\/ NB! Abhängigkeitsspezifikation
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds \/\/ zusätzliche 0,5 Sekunden, damit es 10 Anfragen und nicht 9 gibt.

    def testMessage: String = "dolly"
  }

Wichtig! Bitte beachten Sie, wie die Bindung von Diensten durchgeführt wird. Wir geben den Dienst an, der von einem Knoten als Implementierung der Abhängigkeitsmethode eines anderen Knotens bereitgestellt wird. Der Typ der Abhängigkeit wird vom Compiler geprüft, da er den Typ des Protokolls enthält. Beim Start wird die Abhängigkeit die korrekte Kennung des Zielknotens enthalten. Mit diesem Schema geben wir die Portnummer genau einmal an und verweisen immer garantiert auf den richtigen Port.

Implementierung von zwei Knoten im System

Für diese Konfiguration verwenden wir die gleichen Implementierungen von Diensten ohne Änderungen. Der einzige Unterschied besteht darin, dass wir jetzt zwei Objekte haben, die unterschiedliche Dienstebenen implementieren:

  object TwoJvmNodeServerImpl extends ZeroServiceImpl[IO] with EchoServiceService with SigIntLifecycleServiceImpl {
    type Config = EchoConfig[String] with SigTermLifecycleConfig
  }

  object TwoJvmNodeClientImpl extends ZeroServiceImpl[IO] with EchoClientService with FiniteDurationLifecycleServiceImpl {
    type Config = EchoClientConfig[String] with FiniteDurationLifecycleConfig
  }

Der erste Knoten implementiert den Server und benötigt nur die Serverkonfiguration. Der zweite Knoten implementiert den Client und verwendet einen anderen Teil der Konfiguration. Außerdem benötigen beide Knoten ein Lebenszyklusmanagement. Der Serverknoten läuft unbegrenzt, bis er gestoppt wird. SIGTERMDer Clientknoten wird nach einer bestimmten Zeit beendet. Siehe. Anwendungsstart.

Der gesamte Entwicklungsprozess

Lassen Sie uns sehen, wie dieser Ansatz zur Konfiguration den gesamten Entwicklungsprozess beeinflusst.

Die Konfiguration wird zusammen mit dem restlichen Code kompiliert und es wird ein Artefakt (.jar) generiert. Es scheint sinnvoll zu sein, die Konfiguration in ein separates Artefakt zu stellen. Das liegt daran, dass wir viele Konfigurationen basierend auf demselben Code haben können. Wiederum können Artefakte generiert werden, die verschiedenen Konfigurationszweigen entsprechen. Zusammen mit der Konfiguration werden Abhängigkeiten von bestimmten Versionen von Bibliotheken gespeichert, und diese Versionen bleiben für immer gespeichert, wann immer wir uns entscheiden, diese Version der Konfiguration bereitzustellen.

Jede Änderung der Konfiguration wird zu einer Codeänderung. Daher wird jede solche
Änderung von dem üblichen Qualitätsprozess erfasst:

Ticket im Bugtracker -> PR -> Überprüfung -> Zusammenführung mit den entsprechenden Zweigen ->
Integration -> Bereitstellung

Die Hauptfolgen der Implementierung einer kompilierbaren Konfiguration:

  1. Die Konfiguration wird an allen Knoten des verteilten Systems abgestimmt. Da alle Knoten die gleiche Konfiguration aus einer einzigen Quelle erhalten, ist dies gegeben.

  2. Es ist problematisch, die Konfiguration nur an einem der Knoten zu ändern. Daher ist eine "Konfigurationsabweichung" (configuration drift) unwahrscheinlich.

  3. Es wird schwieriger, kleine Änderungen an der Konfiguration vorzunehmen.

  4. Die meisten Änderungen an der Konfiguration erfolgen im Rahmen eines allgemeinen Entwicklungsprozesses und unterliegen einer Prüfung.

Ist ein separates Repository zur Speicherung der Produktionskonfiguration erforderlich? In einer solchen Konfiguration könnten Passwörter und andere vertrauliche Informationen enthalten sein, auf die wir den Zugriff einschränken möchten. Daher macht es offensichtlich Sinn, die endgültige Konfiguration in einem separaten Repository zu speichern. Die Konfiguration kann in zwei Teile unterteilt werden – einen, der öffentliche Konfigurationsparameter enthält, und einen anderen, der parameter mit eingeschränktem Zugriff enthält. Dadurch haben die meisten Entwickler Zugriff auf die allgemeinen Parameter. Eine solche Trennung lässt sich einfach erreichen, indem man Zwischentraits verwendet, die Standardwerte enthalten.

Mögliche Variationen

Vergleichen wir die kompilierte Konfiguration mit einigen gängigen Alternativen:

  1. Textdatei auf der Zielmaschine.
  2. Zentralisiertes Schlüssel-Wert-Speicher (im entsprechenden Docker-Image/zookeeper).
  3. Prozesskomponenten, die ohne Neustart des Prozesses rekonstruiert oder neu gestartet werden können.
  4. Speicherung der Konfiguration außerhalb des Artefakts und der Versionskontrolle.

Textdateien bieten erhebliche Flexibilität hinsichtlich kleiner Änderungen. Ein Systemadministrator kann sich auf einen entfernten Knoten einloggen, Änderungen in den entsprechenden Dateien vornehmen und den Dienst neu starten. Für große Systeme kann jedoch eine solche Flexibilität unerwünscht sein. Es bleiben keine Spuren der vorgenommenen Änderungen in anderen Systemen. Niemand führt eine Prüfung der Änderungen durch. Es ist schwer festzustellen, wer genau Änderungen vorgenommen hat und aus welchem Grund. Änderungen werden nicht getestet. Wenn das System verteilt ist, kann der Administrator vergessen, die entsprechenden Änderungen an anderen Knoten vorzunehmen.

(Es ist auch anzumerken, dass die Verwendung einer kompilierten Konfiguration die Möglichkeit, in Zukunft Textdateien zu verwenden, nicht ausschließt. Es genügt, einen Parser und Validator hinzuzufügen, die denselben Typ ausgeben, Konfiguration, und man kann Textdateien verwenden. Daraus folgt direkt, dass die Komplexität des Systems mit kompilierten Konfigurationen etwas geringer ist als die Komplexität des Systems, das Textdateien verwendet, da für Textdateien zusätzlicher Code erforderlich ist.)

Ein zentrales Schlüssel-Wert-Speicher ist ein gutes Mechanismus zur Verteilung von Meta-Parametern einer verteilten Anwendung. Wir müssen klären, was Konfigurationsparameter sind und was einfach Daten sind. Angenommen, wir haben eine Funktion C => A => B, wobei die Parameter C selten geändert werden, während die Daten A häufig sind. In diesem Fall können wir sagen, dass C Konfigurationsparameter sind, während A Daten sind. Es scheint, dass sich Konfigurationsparameter in der Regel darin von Daten unterscheiden, dass sie seltener geändert werden als Daten. Außerdem stammen Daten normalerweise aus einer Quelle (vom Benutzer), während Konfigurationsparameter aus einer anderen (vom Systemadministrator) stammen.

Wenn parameter, die selten geändert werden, ohne Neustart des Programms aktualisiert werden müssen, kann das häufig zu einer Komplexität des Programms führen, da wir auf irgendeine Weise die Parameter bereitstellen, speichern, parsen und überprüfen, sowie ungültige Werte verarbeiten müssen. Daher ist es aus der Sicht der Reduzierung der Komplexität des Programms sinnvoll, die Anzahl der Parameter zu minimieren, die während des Betriebs des Programms geändert werden können (oder solche Parameter gar nicht zu unterstützen).

In Bezug auf den aktuellen Beitrag werden wir zwischen statischen und dynamischen Parametern unterscheiden. Wenn die Logik der Dienstleistung erfordert, dass Parameter während der Programmausführung geändert werden, bezeichnen wir solche Parameter als dynamisch. Andernfalls sind die Parameter statisch und können unter Verwendung einer kompilierbaren Konfiguration konfiguriert werden. Für die dynamische Rekonfiguration benötigen wir möglicherweise einen Mechanismus zum Neustart von Programmteilen mit neuen Parametern, ähnlich wie es beim Neustart von Prozessen im Betriebssystem der Fall ist. (Unserer Meinung nach ist es wünschenswert, Rekonfigurationen in Echtzeit zu vermeiden, da dies die Komplexität des Systems erhöht. Wenn möglich, sollte man die standardmäßigen Möglichkeiten des Betriebssystems zum Neustarten von Prozessen nutzen.)

Ein wichtiger Aspekt der Verwendung statischer Konfigurationen, der die Menschen dazu bringt, dynamische Rekonfigurationen in Betracht zu ziehen, ist die Zeit, die das System benötigt, um nach einer Aktualisierung der Konfiguration neu zu starten (Downtime). In der Tat müssen wir, wenn wir Änderungen an der statischen Konfiguration vornehmen müssen, das System neu starten, damit die neuen Werte in Kraft treten. Das Problem der Downtime hat für verschiedene Systeme unterschiedliche Dringlichkeit. In einigen Fällen kann der Neustart so geplant werden, dass er zu einem Zeitpunkt erfolgt, zu dem die Last minimal ist. Wenn ein kontinuierlicher Service gewährleistet werden muss, kann man „Verbindungsdrainage“ (AWS ELB connection draining). Wenn wir das System neu starten müssen, starten wir eine parallele Instanz dieses Systems, schalten den Lastenausgleich darauf um und warten, bis die alten Verbindungen abgeschlossen sind. Nachdem alle alten Verbindungen beendet sind, schalten wir die alte Instanz des Systems aus.

Betrachten wir nun die Frage der Speicherung der Konfiguration innerhalb des Artefakts oder außerhalb davon. Wenn wir die Konfiguration innerhalb des Artefakts speichern, haben wir zumindest während des Baus des Artefakts die Möglichkeit, die Korrektheit der Konfiguration zu überprüfen. Wenn sich die Konfiguration außerhalb des kontrollierten Artefakts befindet, ist es schwierig nachzuvollziehen, wer und warum Änderungen an dieser Datei vorgenommen hat. Wie wichtig ist das? Unserer Meinung nach ist es für viele Produktionssysteme wichtig, eine stabile und qualitativ hochwertige Konfiguration zu haben.

Die Version des Artefakts ermöglicht es, den Zeitpunkt seiner Erstellung zu bestimmen, welche Werte es enthält, welche Funktionen aktiviert/deaktiviert sind und wer für Änderungen an der Konfiguration verantwortlich ist. Selbstverständlich erfordert die Speicherung der Konfiguration im Artefakt einige Anstrengungen, daher ist eine bewusste Entscheidung notwendig.

Für und Wider

Ich möchte auf die Vor- und Nachteile der vorgeschlagenen Technologie eingehen.

Vorteile

Nachfolgend finden Sie eine Liste der Hauptfunktionen der kompilierten Konfiguration eines verteilten Systems:

  1. Statische Konfigurationsprüfung. Sie gewährleistet, dass
    die Konfiguration korrekt ist.
  2. Reiche Konfigurationssprache. Üblicherweise sind andere Konfigurationsmethoden auf das Ersetzen von Variablen in Strings beschränkt. Bei Verwendung von Scala steht eine breite Palette an Sprachmöglichkeiten zur Verfügung, um die Konfiguration zu verbessern. Zum Beispiel können wir
    Traits für Standardwerte verwenden, Parameter mit Objekten gruppieren und können auf einmal deklarierte Werte (DRY) im übergeordneten Gültigkeitsbereich verweisen. Wir können direkt innerhalb der Konfiguration beliebige Klassen instanziieren (Seq, Karte, benutzerdefinierte Klassen).
  3. DSL. In Scala gibt es eine Reihe von Sprachmöglichkeiten, die die Erstellung von DSL erleichtern. Diese Möglichkeiten können genutzt werden, um eine Konfigurationssprache zu implementieren, die für die Zielgruppe benutzerfreundlicher wäre, sodass die Konfiguration zumindest für Fachleute lesbar wäre. Experten können beispielsweise am Überprüfungsprozess der Konfiguration teilnehmen.
  4. Integrität und Synchronizität zwischen den Knoten. Ein Vorteil der Speicherung der gesamten Konfiguration eines verteilten Systems an einem zentralen Ort besteht darin, dass alle Werte genau einmal deklariert und dann überall dort wiederverwendet werden, wo sie benötigt werden. Der Einsatz von Phantomtypen zur Deklaration von Ports garantiert, dass in allen korrekten Konfigurationen des Systems die Knoten kompatible Protokolle verwenden. Das Vorhandensein expliziter erforderlicher Abhängigkeiten zwischen den Knoten gewährleistet, dass alle Dienste miteinander verbunden sind.
  5. Hohe Qualität der Änderungen. Änderungen an der Konfiguration unter Verwendung des allgemeinen Entwicklungsprozesses machen hohe Qualitätsstandards auch für die Konfiguration verfügbar.
  6. Gleichzeitige Aktualisierung der Konfiguration. Die automatische Bereitstellung des Systems nach Änderungen an der Konfiguration gewährleistet, dass alle Knoten aktualisiert werden.
  7. Vereinfachung der Anwendung. Die Anwendung benötigt keine Parser, keine Überprüfung der Konfiguration und keine Verarbeitung fehlerhafter Werte. Dadurch wird die Komplexität der Anwendung reduziert. (Ein gewisser Anstieg der Komplexität der Konfiguration, der in unserem Beispiel zu beobachten ist, ist nicht das Attribut einer kompilierbaren Konfiguration, sondern lediglich eine bewusste Entscheidung, die durch den Wunsch nach größerer Typsicherheit ausgelöst wurde.) Es ist ziemlich einfach, zu einer regulären Konfiguration zurückzukehren - man muss lediglich die fehlenden Teile implementieren. Daher kann man zum Beispiel mit einer kompilierbaren Konfiguration beginnen und die Implementierung der zusätzlichen Teile auf einen späteren Zeitpunkt verschieben, wenn dies wirklich erforderlich ist.
  8. Versionierte Konfiguration. Da die Konfigurationsänderungen dem gewöhnlichen Schicksal aller anderen Änderungen folgen, erhalten wir am Ende ein Artefakt mit einer einzigartigen Version. Das ermöglicht es uns, beispielsweise im Bedarfsfall zu einer vorherigen Version der Konfiguration zurückzukehren. Wir können sogar die Konfiguration von vor einem Jahr nutzen und das System wird genau gleich funktionieren. Eine stabile Konfiguration verbessert die Vorhersagbarkeit und Zuverlässigkeit des verteilten Systems. Da die Konfiguration in der Kompilierungsphase festgelegt wird, ist es ziemlich schwierig, sie in der Produktionsumgebung zu manipulieren.
  9. Modularität. Das vorgeschlagene Framework ist modular, und die Module können in verschiedenen Varianten kombiniert werden, um verschiedene Systeme zu erstellen. Insbesondere kann das System in einer Variante für den Betrieb auf einem einzigen Knoten und in einer anderen Variante für den Betrieb auf mehreren Knoten konfiguriert werden. Es können mehrere Konfigurationen für Produktionsinstanzen des Systems erstellt werden.
  10. Testing. Durch den Austausch einzelner Dienste gegen Mock-Objekte können mehrere Versionen des Systems erstellt werden, die für Tests geeignet sind.
  11. Integrationstest. Das Vorhandensein einer einheitlichen Konfiguration des gesamten verteilten Systems ermöglicht den Start aller Komponenten in einer kontrollierten Umgebung im Rahmen des Integrationstests. Es ist einfach, zum Beispiel eine Situation zu simulieren, in der einige Knoten nicht verfügbar sind.

Nachteile und Einschränkungen

Die kompilierte Konfiguration unterscheidet sich von anderen Ansätzen zur Konfiguration und ist möglicherweise nicht für bestimmte Anwendungen geeignet. Hier sind einige Nachteile aufgeführt:

  1. Statische Konfiguration. Manchmal ist es erforderlich, die Konfiguration schnell im Produktionsumfeld zu ändern und dabei alle Schutzmechanismen zu umgehen. Mit diesem Ansatz kann es schwieriger sein. Mindestens Kompilierung und automatisierte Bereitstellung werden dennoch benötigt. Dies ist gleichzeitig eine nützliche Eigenschaft des Ansatzes und hat in einigen Fällen Nachteile.
  2. Konfigurationsgenerierung. Wenn die Konfigurationsdatei von einem automatisierten Tool generiert wird, können zusätzliche Anstrengungen zur Integration des Build-Skripts erforderlich sein.
  3. Werkzeug. Derzeit basieren die zur Arbeit mit Konfigurationen verwendeten Hilfsprogramme und Methoden auf Textdateien. Nicht alle solchen Hilfsprogramme/Methoden werden im Falle einer kompilierten Konfiguration verfügbar sein.
  4. Änderung der Perspektiven erforderlich. Entwickler und DevOps sind an Textdateien gewöhnt. Die Idee, die Konfiguration zu kompilieren, kann daher etwas überraschend und ungewohnt sein und Abneigungen hervorrufen.
  5. Ein hochqualitativ gestalteter Entwicklungsprozess ist erforderlich. Um eine komfortable Nutzung der kompilieren Konfiguration zu gewährleisten, ist eine vollständige Automatisierung des Build- und Bereitstellungsprozesses (CI/CD) notwendig. Andernfalls wird es recht unangenehm sein.

Wir werden auch einige Einschränkungen des betrachteten Beispiels ansprechen, die nicht mit der Idee der kompilierten Konfiguration zusammenhängen:

  1. Wenn wir zu viele Konfigurationsinformationen bereitstellen, die von einem Knoten nicht verwendet werden, wird uns der Compiler nicht helfen, das Fehlen einer Implementierung zu erkennen. Dieses Problem kann gelöst werden, wenn man auf das Cake-Muster verzichtet und festere Typen verwendet, wie zum Beispiel HList oder algebraische Datentypen (Case-Klassen) zur Darstellung der Konfiguration.
  2. In der Konfigurationsdatei gibt es Zeilen, die nicht direkt zur Konfiguration gehören: (package, import, Objekt-Deklarationen; override defEs gibt Einschränkungen für Parameter mit Standardwerten. Teilweise kann man dies vermeiden, indem man DSL implementiert. Außerdem stellen andere Konfigurationsarten (wie XML) bestimmte Anforderungen an die Struktur der Datei.
  3. In diesem Beitrag betrachten wir nicht die dynamische Rekonfiguration eines Clusters ähnlicher Knoten.

Fazit

In diesem Beitrag haben wir die Idee untersucht, Konfigurationen im Quellcode darzustellen, indem wir die erweiterten Funktionen des Typsystems von Scala nutzen. Dieser Ansatz kann in verschiedenen Anwendungen eingesetzt werden, um traditionelle Konfigurationsmethoden auf Basis von XML- oder Textdateien zu ersetzen. Obwohl unser Beispiel in Scala implementiert ist, lassen sich die gleichen Ideen auch auf andere kompilierte Sprachen (wie Kotlin, C#, Swift usw.) übertragen. Dieser Ansatz kann in einem der folgenden Projekte getestet werden, und sollte er nicht geeignet sein, kann man auf Textdateien umsteigen und die fehlenden Details hinzufügen.

Natürlich erfordert die kompilierte Konfiguration einen hochwertigen Entwicklungsprozess. Im Gegenzug wird eine hohe Qualität und Zuverlässigkeit der Konfigurationen gewährleistet.

Der betrachtete Ansatz kann erweitert werden:

  1. Es können Makros verwendet werden, um Prüfungen während der Kompilierung durchzuführen.
  2. Es kann ein DSL implementiert werden, um die Konfiguration in einer für Endbenutzer zugänglichen Form darzustellen.
  3. Es kann ein dynamisches Ressourcenmanagement mit automatischer Anpassung der Konfiguration realisiert werden. Beispielsweise ist es erforderlich, dass (1) jeder Knoten eine leicht abweichende Konfiguration erhält, wenn sich die Anzahl der Knoten im Cluster ändert; (2) der Cluster-Manager Informationen über neue Knoten erhält.

Dank

Ich möchte mich bei Andrey Saxonov, Pavel Popov und Anton Nekhaev für das konstruktive Feedback zum Entwurf des Artikels bedanken.

Quelle: habr.com

60GB SSD 8Gb DDR4