Kompilierbare Konfiguration eines verteilten Systems

In diesem Beitrag möchten wir eine interessante Möglichkeit teilen, mit der Konfiguration eines verteilten Systems umzugehen.
Die Konfiguration wird direkt in der Programmiersprache Scala auf eine typsichere Weise dargestellt. Eine Beispielimplementierung wird im Detail beschrieben. Verschiedene Aspekte des Vorschlags werden diskutiert, einschließlich der Auswirkungen auf den gesamten Entwicklungsprozess.

Kompilierbare Konfiguration eines verteilten Systems

(in Russisch)

Einführung

Der Aufbau robuster verteilter Systeme erfordert die Verwendung einer korrekten und kohärenten Konfiguration auf allen Knoten. Eine typische Lösung ist die Verwendung einer textuellen Bereitbeschreibung (Terraform, Ansible oder Ähnliches) und automatisch generierten Konfigurationsdateien (häufig — speziell für jeden Knoten/Rolle). Wir möchten auch sicherstellen, dass dieselben Protokolle der gleichen Versionen bei den kommunizierenden Knoten verwendet werden (ansonsten würden wir Kompatibilitätsprobleme erleben). In der JVM-Welt bedeutet dies, dass die Messaging-Bibliothek auf allen kommunizierenden Knoten mindestens in der gleichen Version vorliegen sollte.

Wie sieht es mit dem Testen des Systems aus? Natürlich sollten wir für alle Komponenten vor den Integrationstests Unit-Tests haben. Um die Testergebnisse zur Laufzeit extrapolieren zu können, müssen wir sicherstellen, dass die Versionen aller Bibliotheken sowohl in der Laufzeit- als auch in der Testumgebung identisch sind.

Beim Ausführen von Integrationstests ist es oft viel einfacher, den gleichen Klassenpfad auf allen Knoten zu haben. Wir müssen nur sicherstellen, dass derselbe Klassenpfad bei der Bereitstellung verwendet wird. (Es ist möglich, unterschiedliche Klassenpfade auf verschiedenen Knoten zu verwenden, aber es ist schwieriger, diese Konfiguration darzustellen und korrekt bereitzustellen.) Um die Dinge einfach zu halten, werden wir nur identische Klassenpfade auf allen Knoten betrachten.

Die Konfiguration entwickelt sich in der Regel zusammen mit der Software. Wir verwenden normalerweise Versionen, um verschiedene
Phasen der Softwareentwicklung zu identifizieren. Es scheint sinnvoll, die Konfiguration unter Versionsverwaltung zu stellen und verschiedene Konfigurationen mit einigen Etiketten zu kennzeichnen. Wenn es nur eine Konfiguration in der Produktion gibt, können wir eine einzelne Version als Identifikator verwenden. Manchmal haben wir möglicherweise mehrere Produktionsumgebungen. Und für jede Umgebung benötigen wir möglicherweise einen separaten Branch der Konfiguration. Daher können Konfigurationen mit Branch und Version gekennzeichnet werden, um verschiedene Konfigurationen eindeutig zu identifizieren. Jede Branch-Bezeichnung und Version entspricht einer einzigen Kombination von verteilten Knoten, Ports, externen Ressourcen und Versionsbibliotheken auf jedem Knoten. Hier werden wir nur den einzelnen Branch behandeln und Konfigurationen durch eine dreiteilige dezimale Version (1.2.3) identifizieren, auf dieselbe Weise wie andere Artefakte.

In modernen Umgebungen werden Konfigurationsdateien nicht mehr manuell bearbeitet. Typischerweise generieren wir
Konfigurationsdateien zum Zeitpunkt der Bereitstellung und berühren sie danach nicht mehr. Daher könnte man sich fragen, warum wir immer noch das Textformat für Konfigurationsdateien verwenden? Eine gangbare Option ist es, die Konfiguration innerhalb einer Kompilierungseinheit zu platzieren und von der Validierung der Konfigurationsüberprüfung zur Kompilierzeit zu profitieren.

In diesem Beitrag werden wir die Idee untersuchen, die Konfiguration im kompilierten Artefakt zu halten.

Kompilierbare Konfiguration

In diesem Abschnitt werden wir ein Beispiel für eine statische Konfiguration diskutieren. Zwei einfache Dienste — der Echo-Dienst und der Client des Echo-Dienstes — werden konfiguriert und implementiert. Dann werden zwei verschiedene verteilte Systeme mit beiden Diensten instanziiert. Eines ist für eine Einzelknoten-Konfiguration und das andere für eine Zwei-Knoten-Konfiguration.

Ein typisches verteiltes System besteht aus einigen Knoten. Die Knoten können durch einen bestimmten Typ identifiziert werden:

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

oder einfach

case class NodeId(hostName: String)

oder sogar

object Singleton
type NodeId = Singleton.type

Diese Knoten erfüllen verschiedene Rollen, führen einige Dienste aus und sollten in der Lage sein, mit anderen Knoten über TCP/HTTP-Verbindungen zu kommunizieren.

Für eine TCP-Verbindung ist mindestens eine Portnummer erforderlich. Wir möchten auch sicherstellen, dass Client und Server dasselbe Protokoll verwenden. Um eine Verbindung zwischen den Knoten zu modellieren, lassen Sie uns die folgende Klasse deklarieren:

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

where Port ist einfach ein Int innerhalb des zulässigen Bereichs:

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

Verfeinerte Typen

Siehe refined Bibliothek. Kurz gesagt, sie ermöglicht es, zur Kompilierzeit Einschränkungen zu anderen Typen hinzuzufügen. In diesem Fall Int darf nur 16-Bit-Werte haben, die eine Portnummer repräsentieren können. Es gibt keine Anforderung, diese Bibliothek für diesen Konfigurationsansatz zu verwenden. Sie scheint einfach sehr gut zu passen.

Für HTTP (REST) benötigen wir möglicherweise auch einen Pfad des Dienstes:

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

Phantom-Typ

Um das Protokoll während der Kompilierung zu identifizieren, verwenden wir das Scala-Feature zur Deklaration des Typarguments Protokoll , das in der Klasse nicht verwendet wird. Es wird so genannt phantom type. Zur Laufzeit benötigen wir selten eine Instanz des Protokollidentifikators, weshalb wir ihn nicht speichern. Während der Kompilierung bietet dieser Phantom-Typ zusätzliche Typensicherheit. Wir können keinen Port mit einem falschen Protokoll übergeben.

Eines der am häufigsten verwendeten Protokolle ist REST API mit JSON-Serialisierung:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

where RequestMessage ist der Basistyp von Nachrichten, die der Client an den Server senden kann und ResponseMessage ist die Antwortnachricht vom Server. Natürlich können wir auch andere Protokollbeschreibungen erstellen, die das Kommunikationsprotokoll mit der gewünschten Präzision spezifizieren.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

In diesem Protokoll wird die Anforderungsnachricht an die URL angehängt und die Antwortnachricht als einfacher String zurückgegeben.

Eine Dienstkonfiguration könnte durch den Dienstnamen, eine Sammlung von Ports und einige Abhängigkeiten beschrieben werden. Es gibt verschiedene Möglichkeiten, wie all diese Elemente in Scala dargestellt werden können (zum Beispiel HList, algebraische Datentypen). Für die Zwecke dieses Beitrags verwenden wir das Cake-Muster und repräsentieren kombinierbare Teile (Module) als Traits. (Das Cake-Muster ist kein Erfordernis für diesen kompilierbaren Konfigurationsansatz. Es ist nur eine mögliche Implementierung der Idee.)

Abhängigkeiten könnten mithilfe des Cake-Musters als Endpunkte anderer Knoten dargestellt werden:

  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)
  }

Der Echo-Dienst benötigt nur einen konfigurierten Port. Und wir erklären, dass dieser Port das Echo-Protokoll unterstützt. Beachten Sie, dass wir zu diesem Zeitpunkt keinen bestimmten Port angeben müssen, da die Traits abstrakte Methodendeklarationen ermöglichen. Wenn wir abstrakte Methoden verwenden, wird der Compiler eine Implementierung in einer Konfigurationsinstanz verlangen. Hier haben wir die Implementierung bereitgestellt (8081) und sie wird als Standardwert verwendet, wenn wir sie in einer konkreten Konfiguration auslassen.

Wir können eine Abhängigkeit in der Konfiguration des Echo-Dienst-Clients deklarieren:

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

Die Abhängigkeit hat denselben Typ wie der echoService. Insbesondere erfordert es dasselbe Protokoll. Daher können wir sicher sein, dass diese beiden Abhängigkeiten korrekt funktionieren, wenn wir sie verbinden.

Implementierung der Dienste

Ein Dienst benötigt eine Funktion zum Starten und zum geordneten Herunterfahren. (Die Möglichkeit, einen Dienst herunterzufahren, ist entscheidend für Tests.) Auch hier gibt es einige Optionen für die Spezifizierung einer solchen Funktion für eine gegebene Konfiguration (zum Beispiel könnten wir Typklassen verwenden). Für diesen Beitrag verwenden wir erneut das Cake-Muster. Wir können einen Dienst unter Verwendung von cats.Resource darstellen, das bereits die Rahmenbildung und Ressourcenschließung bereitstellt. Um eine Ressource zu erwerben, müssen wir eine Konfiguration und einen Laufzeitkontext bereitstellen. Daher könnte die Funktion zum Starten des Dienstes so 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]
  }

where

  • Konfiguration — der Typ der Konfiguration, die von diesem Dienststarter benötigt wird
  • AddressResolver — ein Laufzeitobjekt, das in der Lage ist, die realen Adressen anderer Knoten zu erhalten (lesen Sie weiter für Details).

die anderen Typen stammen von cats:

  • F[_] — Effekt-Typ (im einfachsten Fall könnte F[A] einfach nur sein () => A. In diesem Beitrag verwenden wir cats.IO.)
  • Reader[A,B] — ist mehr oder weniger ein Synonym für eine Funktion A => B
  • cats.Resource — hat Möglichkeiten, Ressourcen zu erwerben und freizugeben
  • Timer — ermöglicht Schlafen/Messen von Zeit
  • ContextShift — Analogon von ExecutionContext
  • Applicative — Wrapper von Funktionen im Effekt (fast ein Monad) (wir könnten es schließlich durch etwas anderes ersetzen)

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

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

(Siehe Quellcode für andere Implementierungen von Diensten — Echodienst,
Echo-Client and Lebenszyklus-Controller.)

Ein Knoten ist ein einzelnes Objekt, das einige Dienste ausführt (das Starten einer Kette von Ressourcen wird durch das Cake-Muster ermöglicht):

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

Beachten Sie, dass im Knoten der genaue Typ der Konfiguration angegeben wird, die von diesem Knoten benötigt wird. Der Compiler wird es uns nicht erlauben, das Objekt (Cake) mit unzureichendem Typ zu erstellen, da jeder Dienst-Trait eine Einschränkung für den Konfiguration Typ. Außerdem können wir den Knoten nicht starten, ohne die vollständige Konfiguration bereitzustellen.

Knotenadressauflösung

Um eine Verbindung herzustellen, benötigen wir eine echte Hostadresse für jeden Knoten. Diese könnte später als andere Teile der Konfiguration bekannt sein. Daher benötigen wir eine Möglichkeit, eine Zuordnung zwischen Knoten-ID und der tatsächlichen Adresse bereitzustellen. Diese Zuordnung ist eine Funktion:

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

Es gibt einige mögliche Wege, eine solche Funktion zu implementieren.

  1. Wenn wir die tatsächlichen Adressen vor dem Einsatz kennen, können wir während der Instanziierung der Knoten-Hosts Scala-Code mit den tatsächlichen Adressen generieren und den Build danach ausführen (der zur Kompilierzeitprüfungen durchführt und anschließend die Integrationstest-Suite ausführt). In diesem Fall ist unsere Zuordnungsfunktion statisch bekannt und kann vereinfacht werden zu etwas wie einer Map[NodeId, NodeAddress].
  2. Manchmal erhalten wir die tatsächlichen Adressen erst zu einem späteren Zeitpunkt, wenn der Knoten tatsächlich gestartet wird, oder wir haben keine Adressen von Knoten, die noch nicht gestartet sind. In diesem Fall könnten wir einen Entdeckungsdienst haben, der vor allen anderen Knoten gestartet wird, und jeder Knoten könnte seine Adresse in diesem Dienst ankündigen und sich für Abhängigkeiten anmelden.
  3. Wenn wir ändern können /etc/hosts, können wir vordefinierte Hostnamen (wie my-project-main-node and echo-backend) verwenden und diesen Namen einfach zur Bereitstellungszeit mit einer IP-Adresse verknüpfen.

In diesem Beitrag behandeln wir diese Fälle nicht näher. Tatsächlich haben in unserem Spielbeispiel alle Knoten dieselbe IP-Adresse — 127.0.0.1.

In diesem Beitrag betrachten wir zwei Layouts für verteilte Systeme:

  1. Einzelknoten-Layout, bei dem alle Dienste auf dem einzelnen Knoten platziert sind.
  2. Zwei-Knoten-Layout, bei dem Dienst und Client auf verschiedenen Knoten sind.

Die Konfiguration für ein Einzelknoten- Layout ist wie folgt:

Einzelknoten-Konfiguration

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.
}

Hier erstellen wir eine einzelne Konfiguration, die sowohl die Server- als auch die Clientkonfiguration erweitert. Außerdem konfigurieren wir einen Lebenszyklus-Controller, der normalerweise den Client und den Server nach Ablauf des lifetime Intervalls beendet.

Das gleiche Set an Dienstimplementierungen und -konfigurationen kann verwendet werden, um ein Layout des Systems mit zwei separaten Knoten zu erstellen. Wir müssen nur zwei separate Knoten-Konfigurationen mit den entsprechenden Diensten erstellen:

Zwei Knoten-Konfiguration

  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"
  }

Siehe, wie wir die Abhängigkeit angeben. Wir erwähnen den vom anderen Knoten bereitgestellten Dienst als Abhängigkeit des aktuellen Knotens. Die Art der Abhängigkeit wird überprüft, da sie einen Phantomtyp enthält, der das Protokoll beschreibt. Und zur Laufzeit haben wir die korrekte Knoten-ID. Dies ist einer der wichtigen Aspekte des vorgeschlagenen Konfigurationsansatzes. Er ermöglicht es uns, den Port nur einmal festzulegen und sicherzustellen, dass wir auf den korrekten Port verweisen.

Zwei Knoten-Implementierung

Für diese Konfiguration verwenden wir genau die gleichen Dienstimplementierungen. Es gibt keine Änderungen. Wir erstellen jedoch zwei unterschiedliche Knotenimplementierungen, die unterschiedliche Dienste enthalten:

  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 serverseitige Konfiguration. Der zweite Knoten implementiert den Client und benötigt einen anderen Teil der Konfiguration. Beide Knoten benötigen eine Lebensdauer-Spezifikation. Für die Zwecke dieses Beitrags hat der Dienstknoten eine unendliche Lebensdauer, die durch SIGTERMbeendet werden könnte, während der Echo-Client nach der konfigurierten endlichen Dauer beendet wird. Siehe die Starter-Anwendung für Details.

Allgemeiner Entwicklungsprozess

Lassen Sie uns sehen, wie dieser Ansatz die Art und Weise verändert, wie wir mit der Konfiguration arbeiten.

Die Konfiguration als Code wird kompiliert und erzeugt ein Artefakt. Es erscheint sinnvoll, das Konfigurationsartefakt von anderen Codeartefakten zu trennen. Oft können wir eine Vielzahl von Konfigurationen im selben Code-Basis haben. Und natürlich können wir mehrere Versionen verschiedener Konfigurationszweige haben. In einer Konfiguration können wir bestimmte Versionen von Bibliotheken auswählen, und dies bleibt konstant, wann immer wir diese Konfiguration bereitstellen.

Eine Änderung der Konfiguration wird zu einer Änderung des Codes. Daher sollte sie durch denselben Qualitätssicherungsprozess abgedeckt werden:

Ticket -> PR -> Überprüfung -> Zusammenführen -> Kontinuierliche Integration -> Kontinuierliche Bereitstellung

Es ergeben sich folgende Konsequenzen aus dem Ansatz:

  1. Die Konfiguration ist kohärent für eine bestimmte Instanz des Systems. Es scheint, dass es keine Möglichkeit gibt, eine falsche Verbindung zwischen Knoten herzustellen.
  2. Es ist nicht einfach, die Konfiguration nur bei einem Knoten zu ändern. Es erscheint unvernünftig, sich einzuloggen und einige Textdateien zu ändern. Daher wird die Konfigurationsabweichung weniger wahrscheinlich.
  3. Kleine Konfigurationsänderungen sind nicht einfach vorzunehmen.
  4. Die meisten Konfigurationsänderungen folgen dem gleichen Entwicklungsprozess und werden einer Überprüfung unterzogen.

Brauchen wir ein separates Repository für die Produktionskonfiguration? Die Produktionskonfiguration könnte sensible Informationen enthalten, die wir aus der Reichweite vieler Menschen halten möchten. Daher könnte es sinnvoll sein, ein separates Repository mit eingeschränktem Zugriff zu halten, das die Produktionskonfiguration enthält. Wir könnten die Konfiguration in zwei Teile aufteilen — einen, der die meisten offenen Parameter der Produktion enthält, und einen, der den geheimen Teil der Konfiguration enthält. Dies würde den meisten Entwicklern Zugang zu den meisten Parametern ermöglichen und gleichzeitig den Zugriff auf wirklich sensible Dinge einschränken. Es ist einfach, dies mithilfe von Zwischenwerten mit Standardparametern zu erreichen.

Variationen

Lassen Sie uns die Vor- und Nachteile des vorgeschlagenen Ansatzes im Vergleich zu anderen Techniken des Konfigurationsmanagements ansehen.

Zunächst einmal listen wir einige Alternativen zu den verschiedenen Aspekten der vorgeschlagenen Vorgehensweise beim Umgang mit Konfiguration auf:

  1. Textdatei auf der Zielmaschine.
  2. Zentraler Key-Value-Speicher (wie im entsprechenden Docker-Image/zookeeper).
  3. Subprozesskomponenten, die ohne Neustart des Prozesses neu konfiguriert/neugestartet werden können.
  4. Konfiguration außerhalb von Artefakten und Versionskontrolle.

Eine Textdatei bietet eine gewisse Flexibilität in Bezug auf Ad-hoc-Reparaturen. Ein Systemadministrator kann sich in den Zielknoten einloggen, eine Änderung vornehmen und einfach den Dienst neu starten. Das könnte für größere Systeme nicht so gut sein. Es bleiben keine Spuren der Änderung zurück. Die Änderung wird nicht von einem anderen Paar Augen überprüft. Es könnte schwierig sein herauszufinden, was die Änderung verursacht hat. Sie wurde nicht getestet. Aus der Perspektive eines verteilten Systems kann ein Administrator einfach vergessen, die Konfiguration in einem der anderen Knoten zu aktualisieren.

(Übrigens, wenn es schließlich erforderlich sein wird, Textkonfigurationsdateien zu verwenden, müssen wir nur einen Parser + Validator hinzufügen, der den gleichen Konfiguration Typ erzeugen könnte, und das wäre ausreichend, um Textkonfigurationen zu verwenden. Dies zeigt auch, dass die Komplexität der zur Compile-Zeit erstellten Konfiguration etwas kleiner ist als die Komplexität der textbasierten Konfigurationen, da wir in der textbasierten Version zusätzlichen Code benötigen.)

Zentraler Key-Value-Speicher ist ein gutes Mechanismus zur Verteilung von Meta-Parametern der Anwendung. Hier müssen wir darüber nachdenken, was wir als Konfigurationswerte betrachten und was nur Daten sind. Gegeben einer Funktion C => A => B nennen wir in der Regel selten wechselnde Werte C «Konfiguration», während häufig geänderte Daten A — einfach Eingabedaten sind. Die Konfiguration sollte der Funktion vor den Daten bereitgestellt werden. AVor diesem Hintergrund können wir sagen, dass die erwartete Änderungsfrequenz verwendet werden kann, um Konfigurationsdaten von einfachen Daten zu unterscheiden. Auch Daten stammen typischerweise aus einer Quelle (Benutzer) und die Konfiguration kommt aus einer anderen Quelle (Administrator). Der Umgang mit Parametern, die nach der Prozessinitialisierung geändert werden können, führt zu einer Erhöhung der Anwendungscomplexität. Für solche Parameter müssen wir ihren Übermittlungsmechanismus, Parsing und Validierung, sowie den Umgang mit falschen Werten verwalten. Daher sollten wir, um die Programmkomplexität zu reduzieren, die Anzahl der Parameter, die zur Laufzeit geändert werden können, besser verringern (oder sie ganz eliminieren).

Aus der Perspektive dieses Beitrags sollten wir zwischen statischen und dynamischen Parametern unterscheiden. Wenn die Servicelogik seltene Änderungen einiger Parameter zur Laufzeit erfordert, können wir sie als dynamische Parameter bezeichnen. Andernfalls sind sie statisch und können mit dem vorgeschlagenen Ansatz konfiguriert werden. Für die dynamische Neukonfiguration könnten andere Ansätze erforderlich sein. Beispielsweise könnten Teile des Systems mit den neuen Konfigurationsparametern neu gestartet werden, ähnlich wie bei der Neugestaltung separater Prozesse eines verteilten Systems.
(Meiner bescheidenen Meinung nach sollte man die Neukonfiguration zur Laufzeit vermeiden, da sie die Komplexität des Systems erhöht.
Es könnte einfacher sein, sich einfach auf die Unterstützung des Betriebssystems beim Neustarten von Prozessen zu verlassen. Dies ist jedoch möglicherweise nicht immer möglich.)

Ein wichtiger Aspekt der Verwendung statischer Konfiguration, der manchmal dazu führt, dass Menschen die dynamische Konfiguration in Betracht ziehen (ohne andere Gründe), ist die Ausfallzeit des Dienstes während des Updates der Konfiguration. Tatsächlich müssen wir, wenn wir Änderungen an der statischen Konfiguration vornehmen müssen, das System neu starten, damit die neuen Werte wirksam werden. Die Anforderungen an die Ausfallzeit variieren je nach System, sodass dies möglicherweise nicht so kritisch ist. Wenn es kritisch ist, müssen wir im Voraus für irgendeinen Neustart des Systems planen. Zum Beispiel könnten wir implementieren AWS ELB-Verbindungseinhäutungen. In diesem Szenario, wann immer wir das System neu starten müssen, starten wir eine neue Instanz des Systems parallel, wechseln dann zu ELB, während wir das alte System die bestehenden Verbindungen bedienen lassen.

Wie sieht es damit aus, die Konfiguration innerhalb eines versionierten Artefakts oder außerhalb zu halten? Die Beibehaltung der Konfiguration innerhalb eines Artefakts bedeutet in den meisten Fällen, dass diese Konfiguration denselben Qualitätssicherungsprozess durchlaufen hat wie andere Artefakte. Man kann also sicher sein, dass die Konfiguration von guter Qualität und vertrauenswürdig ist. Im Gegensatz dazu bedeutet die Konfiguration in einer separaten Datei, dass es keine Spuren gibt, wer und warum Änderungen an dieser Datei vorgenommen hat. Ist das wichtig? Wir glauben, dass es für die meisten Produktionssysteme besser ist, eine stabile und qualitativ hochwertige Konfiguration zu haben.

Die Version des Artefakts ermöglicht es, herauszufinden, wann es erstellt wurde, welche Werte es enthält, welche Funktionen aktiviert/deaktiviert sind und wer für jede Änderung in der Konfiguration verantwortlich war. Es könnte erforderlich sein, einige Anstrengungen zu unternehmen, um die Konfiguration in einem Artefakt zu halten, und es ist eine Designentscheidung, dies zu tun.

Vor- und Nachteile

Hier möchten wir einige Vorteile hervorheben und einige Nachteile des vorgeschlagenen Ansatzes diskutieren.

Vorteile

Eigenschaften der kompilierbaren Konfiguration eines vollständigen verteilten Systems:

  1. Statische Überprüfung der Konfiguration. Dies gibt ein hohes Maß an Vertrauen, dass die Konfiguration korrekt ist, gegebenen Typschranken.
  2. Reiche Sprache der Konfiguration. Typischerweise sind andere Konfigurationsansätze auf maximal Variablenaustausch beschränkt.
    Mit Scala kann man eine Vielzahl von Sprachfeatures nutzen, um die Konfiguration zu verbessern. Zum Beispiel können wir Traits verwenden, um Standardwerte bereitzustellen, Objekte, um unterschiedliche Kontexte festzulegen, und wir können auf vals zugreifen, die nur einmal im äußeren Gültigkeitsbereich definiert sind (DRY). Es ist möglich, literale Sequenzen oder Instanzen bestimmter Klassen (Seq, Karte, usw.) zu verwenden.
  3. DSL. Scala hat eine anständige Unterstützung für DSL-Autoren. Man kann diese Funktionen nutzen, um eine Konfiguration Sprache zu etablieren, die bequemer und benutzerfreundlicher ist, sodass die endgültige Konfiguration mindestens von Domänenbenutzern lesbar ist.
  4. Integrität und Kohärenz über Knoten hinweg. Ein Vorteil der zentralen Konfiguration für das gesamte verteilte System ist, dass alle Werte streng einmal definiert und dann an allen Stellen wiederverwendet werden, wo wir sie benötigen. Auch die typensicheren Portdeklarationen gewährleisten, dass in allen möglichen korrekten Konfigurationen die Knoten des Systems dieselbe Sprache sprechen. Es gibt explizite Abhängigkeiten zwischen Knoten, was es schwer macht, zu vergessen, bestimmte Dienste bereitzustellen.
  5. Hohe Qualität der Änderungen. Der allgemeine Ansatz, Änderungen an der Konfiguration durch den normalen PR-Prozess zu leiten, legt hohe Qualitätsstandards auch in der Konfiguration fest.
  6. Simultane Änderungen der Konfiguration. Immer wenn wir Änderungen an der Konfiguration vornehmen, sorgt das automatische Deployment dafür, dass alle Knoten aktualisiert werden.
  7. Vereinfachung der Anwendung. Die Anwendung muss die Konfiguration nicht parsen und validieren oder mit inkorrekten Konfigurationswerten umgehen. Dies vereinfacht die gesamte Anwendung. (Ein gewisser Anstieg der Komplexität liegt in der Konfiguration selbst, aber es ist ein bewusster Kompromiss zugunsten der Sicherheit.) Es ist ziemlich einfach, zur normalen Konfiguration zurückzukehren — man muss nur die fehlenden Teile hinzufügen. Es ist einfacher, mit kompilierten Konfigurationen zu beginnen und die Implementierung zusätzlicher Teile auf später zu verschieben.
  8. Versionierte Konfiguration. Da die Änderungen der Konfiguration den gleichen Entwicklungsprozess durchlaufen, erhalten wir ein Artefakt mit einer einzigartigen Version. Dies ermöglicht es uns, die Konfiguration bei Bedarf zurückzuschalten. Wir können sogar eine Konfiguration bereitstellen, die vor einem Jahr genutzt wurde, und sie wird genau gleich funktionieren. Stabile Konfiguration verbessert die Vorhersehbarkeit und Zuverlässigkeit des verteilten Systems. Die Konfiguration wird zur Kompilierzeit festgelegt und kann in einem Produktionssystem nicht einfach manipuliert werden.
  9. Modularität. Das vorgeschlagene Framework ist modular und die Module können auf verschiedene Weisen kombiniert werden, um
    unterstützt unterschiedliche Konfigurationen (Setups/Layouts). Insbesondere ist es möglich, ein kleines, skalierbares Layout mit einem einzelnen Knoten und ein großes, skalierbares Layout mit mehreren Knoten zu haben. Es ist sinnvoll, mehrere Produktionslayouts zu haben.
  10. Testen. Für Testzwecke könnte man einen Mock-Service implementieren und ihn auf typensichere Weise als Abhängigkeit verwenden. Es könnten gleichzeitig mehrere verschiedene Test-Layouts mit verschiedenen Teilen, die durch Mocks ersetzt wurden, verwaltet werden.
  11. Integrationstests. Manchmal ist es in verteilten Systemen schwierig, Integrationstests durchzuführen. Mit dem beschriebenen Ansatz zur typensicheren Konfiguration des gesamten verteilten Systems können wir alle verteilten Teile auf einem einzelnen Server kontrollierbar ausführen. Es ist einfach, die Situation zu emulieren
    wenn einer der Dienste nicht verfügbar ist.

Nachteile

Der Ansatz mit kompilierten Konfigurationen unterscheidet sich von „normalen“ Konfigurationen und ist möglicherweise nicht für alle Bedürfnisse geeignet. Hier sind einige der Nachteile der kompilierten Konfiguration:

  1. Statische Konfiguration. Diese ist möglicherweise nicht für alle Anwendungen geeignet. In manchen Fällen besteht die Notwendigkeit, die Konfiguration in der Produktion schnell zu beheben und dabei alle Sicherheitsmaßnahmen zu umgehen. Dieser Ansatz macht dies schwieriger. Die Kompilierung und das erneute Deployment sind nach jeder Änderung an der Konfiguration erforderlich. Dies ist sowohl ein Merkmal als auch eine Belastung.
  2. Konfigurationsgenerierung. Wenn die Konfiguration von einem Automatisierungstool generiert wird, erfordert dieser Ansatz die nachfolgende Kompilierung (die wiederum fehlschlagen könnte). Es könnte zusätzliche Anstrengungen erfordern, diesen zusätzlichen Schritt in das Build-System zu integrieren.
  3. Instrumente. Es gibt heute viele Werkzeuge, die auf textbasierten Konfigurationen basieren. Einige davon
    werden nicht anwendbar sein, wenn die Konfiguration kompiliert wird.
  4. Ein Umdenken ist nötig. Entwickler und DevOps sind mit Textkonfigurationsdateien vertraut. Die Idee, die Konfiguration zu kompilieren, könnte ihnen seltsam erscheinen.
  5. Vor der Einführung einer kompilierbaren Konfiguration ist ein qualitativ hochwertiger Softwareentwicklungsprozess erforderlich.

Es gibt einige Einschränkungen des implementierten Beispiels:

  1. Wenn wir eine zusätzliche Konfiguration bereitstellen, die von der Knoteneimplementation nicht gefordert wird, wird uns der Compiler nicht helfen, die fehlende Implementierung zu erkennen. Dies könnte durch die Verwendung HList oder ADTs (Fallklassen) für die Knotenkonfiguration anstelle von Traits und dem Cake-Pattern adressiert werden.
  2. Wir müssen einige Boilerplate im Konfigurationsfile bereitstellen: (package, import, object Deklarationen;
    override def‘s für Parameter, die Standardwerte haben). Dies könnte teilweise durch die Verwendung einer DSL adressiert werden.
  3. In diesem Beitrag behandeln wir nicht die dynamische Neukonfiguration von Clustern ähnlicher Knoten.

Fazit

In diesem Beitrag haben wir die Idee diskutiert, Konfigurationen direkt im Quellcode auf typensichere Weise darzustellen. Dieser Ansatz könnte in vielen Anwendungen als Ersatz für XML- und andere textbasierte Konfigurationen genutzt werden. Obwohl unser Beispiel in Scala implementiert wurde, könnte es auch in andere kompilierbare Sprachen (wie Kotlin, C#, Swift usw.) übersetzt werden. Man könnte diesen Ansatz in einem neuen Projekt ausprobieren und, falls er nicht gut passt, zum traditionellen Weg wechseln.

Natürlich erfordert kompilierbare Konfiguration einen hochwertigen Entwicklungsprozess. Im Gegenzug verspricht sie, eine ebenso qualitativ hochwertige und robuste Konfiguration bereitzustellen.

Dieser Ansatz könnte auf verschiedene Weise erweitert werden:

  1. Man könnte Makros verwenden, um die Konfiguration zu validieren und im Falle von Geschäftsanwendungsfehlern zur Kompilierzeit fehlschlagen.
  2. Eine domänenspezifische Sprache (DSL) könnte implementiert werden, um die Konfiguration auf eine benutzerfreundliche Weise darzustellen.
  3. Dynamisches Ressourcenmanagement mit automatischen Konfigurationsanpassungen. Wenn wir beispielsweise die Anzahl der Clusterknoten anpassen, möchten wir, dass (1) die Knoten eine leicht modifizierte Konfiguration erhalten; (2) der Cluster-Manager neue Knoteninformationen erhält.

Danke

Ich möchte mich bei Andrey Saksonov, Pavel Popov und Anton Nehaev bedanken, die mir inspirierendes Feedback zum Entwurf dieses Beitrags gegeben haben, das mir geholfen hat, ihn klarer zu gestalten.

Quelle: habr.com

60GB SSD 8Gb DDR4