Kompilierbare Konfiguration eines verteilten Systems

In diesem Beitrag möchten wir eine interessante Methode zur Konfiguration eines verteilten Systems vorstellen.
Die Konfiguration wird direkt in der sicheren Scala-Sprache dargestellt. Eine Beispielimplementierung wird im Detail beschrieben. Verschiedene Aspekte des Vorschlags werden erörtert, einschließlich des Einflusses auf den gesamten Entwicklungsprozess.

Kompilierbare Konfiguration eines verteilten Systems

(in Russisch)

Einführung

Der Aufbau robuster verteilter Systeme erfordert die Verwendung korrekter und konsistenter Konfigurationen auf allen Knoten. Eine typische Lösung besteht darin, eine textuelle Bereitstellungsbeschreibung (Terraform, Ansible oder Ähnliches) sowie automatisch generierte Konfigurationsdateien (häufig jeweils für jeden Knoten/Rolle) zu verwenden. Außerdem sollten wir sicherstellen, dass dieselben Protokolle der gleichen Version auf allen kommunizierenden Knoten verwendet werden (ansonsten würden wir auf Kompatibilitätsprobleme stoßen). In der JVM-Welt bedeutet dies, dass zumindest die Messaging-Bibliothek auf allen kommunizierenden Knoten dieselbe Version haben sollte.

Wie steht es um das Testen des Systems? Natürlich sollten wir für alle Komponenten Unit-Tests haben, bevor wir zu den Integrationstests kommen. Um Testresultate zur Laufzeit extrapolieren zu können, müssen wir sicherstellen, dass die Versionen aller Bibliotheken in beiden Umgebungen (Laufzeit und Test) identisch bleiben.

Beim Ausführen von Integrationstests ist es oft viel einfacher, denselben Classpath auf allen Knoten zu haben. Wir müssen lediglich sicherstellen, dass derselbe Classpath bei der Bereitstellung verwendet wird. (Es ist zwar möglich, unterschiedliche Classpaths auf verschiedenen Knoten zu verwenden, doch es ist schwieriger, diese Konfiguration darzustellen und korrekt bereitzustellen.) Um es einfach zu halten, betrachten wir daher nur identische Classpaths auf allen Knoten.

Konfigurationen neigen dazu, sich gemeinsam mit der Software weiterzuentwickeln. Wir verwenden in der Regel Versionen, um verschiedene
Phasen der Softwareentwicklung. Es erscheint sinnvoll, die Konfiguration im Rahmen des Versionsmanagements abzudecken und verschiedene Konfigurationen mit einigen Labels zu kennzeichnen. Wenn es nur eine Konfiguration in der Produktion gibt, können wir eine Einzelversion als Identifier verwenden. Manchmal haben wir mehrere Produktionsumgebungen. Für jede Umgebung benötigen wir möglicherweise einen separaten Konfigurationszweig. Daher könnten Konfigurationen mit Zweig und Version gekennzeichnet werden, um verschiedene Konfigurationen eindeutig zu identifizieren. Jedes Zweiglabel und jede Version entspricht einer einzelnen Kombination aus verteilten Knoten, Ports, externen Ressourcen und den Versionen der Klassenbibliotheken auf jedem Knoten. Hier betrachten wir nur den einzelnen Zweig und identifizieren Konfigurationen durch eine drei Komponenten dezimale Version (1.2.3), ähnlich wie bei anderen Artefakten.

In modernen Umgebungen werden Konfigurationsdateien nicht mehr manuell geändert. Typischerweise generieren wir
Konfigurationsdateien zur Bereitstellungszeit und berühren sie danach nie wieder. Daher könnte man sich fragen, warum wir immer noch das Textformat für Konfigurationsdateien verwenden? Eine praktikable Option ist es, die Konfiguration innerhalb einer Kompiliereinheit zu platzieren und von der Validierung der Konfiguration zur Kompilierzeit zu profitieren.

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

Kompilierbare Konfiguration

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

Ein typisches verteiltes System besteht aus mehreren Knoten. Die Knoten könnten mit einer Art 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 übernehmen verschiedene Rollen, führen einige Dienste aus und sollten in der Lage sein, über TCP/HTTP-Verbindungen mit anderen Knoten zu kommunizieren.

Für eine TCP-Verbindung ist mindestens eine Portnummer erforderlich. Wir möchten zudem sicherstellen, dass Client und Server das gleiche Protokoll verwenden. Um eine Verbindung zwischen Knoten zu modellieren, deklarieren wir die folgende Klasse:

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

wo 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 Kompilierungszeit Einschränkungen für andere Typen hinzuzufügen. In diesem Fall Int darf nur 16-Bit-Werte haben, die eine Portnummer darstellen können. Es gibt keine Verpflichtung, diese Bibliothek für diesen Konfigurationsansatz zu verwenden. Es scheint jedoch 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)

Phantomtyp

Um das Protokoll während der Kompilierung zu identifizieren, nutzen wir das Scala-Feature zur Deklaration eines Typ-Arguments, Protokoll das in der Klasse nicht verwendet wird. Es handelt sich um einen sogenannten Phantomtyp.Zur Laufzeit benötigen wir selten eine Instanz des Protokollidentifikators, weshalb wir ihn nicht speichern. Während der Kompilierung bietet dieser Phantomtyp zusätzliche Typensicherheit. Wir können keinen Port mit einem falschen Protokoll übergeben.

Eines der am weitesten verbreiteten Protokolle ist die REST API mit JSON-Serialisierung:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

wo 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 Protokolldefinitionen erstellen, die das Kommunikationsprotokoll mit der gewünschten Genauigkeit angeben.

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 wird als einfacher Text 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 man all diese Elemente in Scala darstellen kann (zum Beispiel, HList, algebraische Datentypen). Für die Zwecke dieses Beitrags verwenden wir das Cake-Muster und stellen kombinierbare Teile (Module) als Traits dar. (Das Cake-Muster ist kein Muss für diesen kompilierbaren Konfigurationsansatz. Es ist nur eine mögliche Implementierung der Idee.)

Abhängigkeiten können mit dem Cake-Muster 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. 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 zulassen. Wenn wir abstrakte Methoden verwenden, fordert der Compiler eine Implementierung in einer Konfigurationsinstanz. 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-Dienstclients deklarieren:

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

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

Implementierung von Diensten

Ein Dienst benötigt eine Funktion zum Starten und zum ordnungsgemäßen Herunterfahren. (Die Fähigkeit, einen Dienst herunterzufahren, ist für Tests entscheidend.) Es gibt erneut einige Optionen zur 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 mit cats.Resource darstellen, der bereits eine Klammerung und Ressourcenfreigabe bietet. Um eine Ressource zu erwerben, sollten wir eine Konfiguration und einen gewissen Laufzeitkontext bereitstellen. Die Startfunktion des Dienstes könnte 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]
  }

wo

  • Konfiguration — Art der Konfiguration, die von diesem Dienststarter benötigt wird
  • AddressResolver — ein Laufzeitobjekt, das die Fähigkeit hat, die realen Adressen anderer Knoten zu erhalten (lesen Sie weiter für Details).

Die anderen Typen stammen von cats:

  • F[_] — Effekttyp (im einfachsten Fall F[A] könnte einfach 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 Wege, um zu erwerben und freizugeben
  • Timer — erlaubt es zu schlafen/Zeit zu messen
  • ContextShift — Analogon von ExecutionContext
  • Applicative — Wrapper von Funktionen im Effekt (fast ein Monad) (wir könnten es letztendlich durch etwas anderes ersetzen)

Mit diesem Interface 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 die Implementierung anderer Dienstleistungen — Echo-Dienst,
Echo-Client und Lebenszeit-Controller.)

Ein Knoten ist ein einzelnes Objekt, das einige Dienste ausführt (das Starten einer Ressourcenkette 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
}

Bitte beachten Sie, dass wir im Knoten den genauen Typ der Konfiguration angeben, die für diesen Knoten benötigt wird. Der Compiler lässt uns das Objekt (Cake) nicht mit einem unzureichenden Typ erstellen, da jede Dienstmerkmale eine Einschränkung auf den Konfiguration Typ erklärt. Außerdem können wir den Knoten nicht starten, ohne eine vollständige Konfiguration bereitzustellen.

Auflösung der Knotenadresse

Um eine Verbindung herzustellen, benötigen wir eine echte Hostadresse für jeden Knoten. Diese könnte später bekannt sein als andere Teile der Konfiguration. Daher benötigen wir eine Möglichkeit, eine Zuordnung zwischen der 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 mehrere mögliche Methoden zur Implementierung einer solchen Funktion.

  1. Wenn wir die tatsächlichen Adressen vor der Bereitstellung kennen, während die Knotenhosts instanziiert werden, können wir Scala-Code mit den tatsächlichen Adressen generieren und danach den Build ausführen (der zur Compile-Zeit Überprüfungen durchführt und dann die Testsuite für die Integration ausführt). In diesem Fall ist unsere Zuordnungsfunktion statisch bekannt und kann auf etwas wie ein 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 wurden. In diesem Fall könnte es einen Entdeckungsdienst geben, der vor allen anderen Knoten gestartet wird, und jeder Knoten könnte seine Adresse in diesem Dienst bekannt geben und Abhängigkeiten abonnieren.
  3. Wenn wir ändern können /etc/hosts, können wir vordefinierte Hostnamen verwenden (wie my-project-main-node und echo-backend) und diesen Namen zur Bereitstellungszeit einfach mit der IP-Adresse verknüpfen.

In diesem Beitrag behandeln wir diese Fälle nicht im Detail. Tatsächlich wird in unserem Spielbeispiel jeder Knoten die gleiche IP-Adresse haben — 127.0.0.1.

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

  1. Einzelknotenlayout, bei dem alle Dienste auf einem einzigen Knoten platziert sind.
  2. Zwei-Knoten-Layout, bei dem Dienst und Client auf verschiedenen Knoten sind.

Die Konfiguration für eine einzelne Instanz gestaltet sich wie folgt:

Einzelinstanzkonfiguration

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

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

  // Konfiguration des Clients

  /** Wir verwenden den Dienst, der vom gleichen Host bereitgestellt wird. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // Konfiguration des Lebenszyklusmanagers
  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. Zudem konfigurieren wir einen Lifecycle-Controller, der normalerweise den Client und Server nach lebensdauer Ablauf des Intervalls beendet.

Das gleiche Set an Service-Implementierungen und Konfigurationen kann verwendet werden, um ein Systemlayout mit zwei separaten Instanzen zu erstellen. Wir müssen nur zwei separate Instanzkonfigurationen mit den entsprechenden Diensten erstellen:

Zwei Instanzenkonfiguration

  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ängigkeitsangabe
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

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

    def testMessage: String = "dolly"
  }

Siehe, wie wir die Abhängigkeit spezifizieren. Wir erwähnen den vom anderen Knoten bereitgestellten Dienst als Abhängigkeit des aktuellen Knotens. Die Art der Abhängigkeit wird geprüft, da sie einen phantomhafte Typ enthält, der das Protokoll beschreibt. Und zur Laufzeit haben wir die korrekte Knoten-ID. Dies ist einer der wichtigen Aspekte des vorgeschlagenen Konfigurationsansatzes. Es gibt uns die Möglichkeit, den Port nur einmal festzulegen und sicherzustellen, dass wir auf den richtigen Port verweisen.

Zwei Instanzenimplementierung

Für diese Konfiguration verwenden wir genau die gleichen Service-Implementierungen. Keine Änderungen whatsoever. Wir erstellen jedoch zwei verschiedene Instanzenimplementierungen, die unterschiedliche Sets an Services 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 erfordern eine Spezifikation der Lebensdauer. Für die Zwecke dieses Beitrags wird der Service-Knoten eine unbegrenzte Lebensdauer haben, die mittels SIGTERM, während der Echo-Client nach der konfigurierten Enddauer beendet wird. Siehe die Starteranwendung für weitere Details.

Allgemeiner Entwicklungsprozess

Sehen wir uns an, wie sich dieser Ansatz auf unsere Arbeit mit Konfiguration auswirkt.

Die Konfiguration als Code wird kompiliert und erzeugt ein Artefakt. Es erscheint sinnvoll, das Konfigurationsartefakt von anderen Codeartefakten zu trennen. Oft haben wir eine Vielzahl von Konfigurationen auf demselben Code-Basis. 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 Konfigurationsänderung wird zur Codeänderung. Sie sollte daher denselben Qualitätssicherungsprozess durchlaufen:

Ticket -> PR -> Überprüfung -> Zusammenführung -> kontinuierliche Integration -> kontinuierliche Bereitstellung

Die folgenden Konsequenzen ergeben sich aus dem Ansatz:

  1. Die Konfiguration ist konsistent für eine bestimmte Instanz des Systems. Es scheint keine Möglichkeit zu geben, eine falsche Verbindung zwischen Knoten zu haben.
  2. Es ist nicht einfach, die Konfiguration nur in einem Knoten zu ändern. Es erscheint unangemessen, sich einzuloggen und einige Textdateien zu ändern. Somit wird Konfigurationsdrift weniger wahrscheinlich.
  3. Kleine Konfigurationsänderungen sind nicht leicht vorzunehmen.
  4. Die meisten Konfigurationsänderungen folgen demselben Entwicklungsprozess und werden eine Überprüfung durchlaufen.

Brauchen wir ein separates Repository für die Produktionskonfiguration? Die Produktionskonfiguration könnte sensible Informationen enthalten, die wir vielen Personen vorenthalten möchten. Daher könnte es sinnvoll sein, ein separates Repository mit eingeschränktem Zugriff zu führen, 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, während der Zugriff auf wirklich sensible Informationen eingeschränkt bleibt. Es ist einfach zu erreichen, dies mit Hilfe von Zwischenmerkmalen mit Standardwerten zu realisieren.

Variationen

Schauen wir uns die Vor- und Nachteile des vorgeschlagenen Ansatzes im Vergleich zu anderen Konfigurationsverwaltungstechniken an.

Zunächst einmal werden wir einige Alternativen zu den verschiedenen Aspekten der vorgeschlagenen Vorgehensweise bei der Handhabung von Konfigurationen auflisten:

  1. Textdatei auf der Zielmaschine.
  2. Zentralisierte Schlüssel-Wert-Speicherung (wie die Unterstützung für die Version etcd2 eingestellt./zookeeper).
  3. Unterprozesskomponenten, die umkonfiguriert oder neu gestartet werden könnten, ohne den Prozess neu zu starten.
  4. Konfiguration außerhalb von Artefakten und Versionskontrolle.

Eine Textdatei bietet gewisse Flexibilität in Bezug auf Ad-hoc-Änderungen. Ein Systemadministrator kann sich in den Zielknoten einloggen, eine Änderung vornehmen und einfach den Dienst neu starten. Dies könnte für größere Systeme weniger geeignet sein. Es bleiben keine Spuren der Änderung zurück. Die Änderung wird nicht von einem anderen Auge ü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, falls letztendlich die Notwendigkeit besteht, Textkonfigurationsdateien zu verwenden, müssen wir nur einen Parser + Validator hinzufügen, der denselben Konfiguration Typ erzeugen kann, und das würde ausreichen, um mit der Verwendung von Textkonfigurationen zu beginnen. Dies zeigt auch, dass die Komplexität der Konfiguration zur Compile-Zeit etwas geringer ist als die Komplexität von textbasierten Konfigurationen, da wir in der textbasierten Version zusätzlichen Code benötigen.)

Zentralisierte Schlüssel-Wert-Speicherung ist ein gutes Mechanismus zur Verteilung von Anwendungs-Meta-Parametern. Hier müssen wir darüber nachdenken, was wir als Konfigurationswerte und was nur als Daten betrachten. C => A => B Gegeben einer Funktion, C nennen wir selten ändernde Werte A „Konfiguration“, während häufig geänderte Daten A— einfach Eingabedaten sind. Die Konfiguration sollte der Funktion früher bereitgestellt werden als die Daten.

In diesem Beitrag sollten wir zwischen statischen und dynamischen Parametern unterscheiden. Wenn die Logik des Dienstes nur selten eine Änderung einiger Parameter zur Laufzeit erfordert, können wir diese 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 Systemkomplexität erhöht.
Es könnte einfacher sein, sich einfach auf die Unterstützung des Betriebssystems beim Neustart von Prozessen zu verlassen. Zwar ist dies nicht immer möglich.)

Ein wichtiger Aspekt der Verwendung statischer Konfiguration, der manchmal die Menschen dazu bringt, dynamische Konfiguration in Betracht zu ziehen (ohne weitere Gründe), ist die Dienstunterbrechung während des Konfigurationsupdates. 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 kritisch ist. Wenn es kritisch ist, müssen wir im Voraus für alle Systemneustarts planen. Beispielsweise könnten wir AWS ELB-Verbindungsentleerung implementieren. In diesem Szenario starten wir jedes Mal, wenn wir das System neu starten müssen, eine neue Instanz des Systems parallel und schalten dann den ELB um, während das alte System weiterhin bestehende Verbindungen bearbeitet.

Wie steht es mit der Konfiguration: Soll diese in einem versionierten Artefakt oder extern gehalten werden? Eine Konfiguration innerhalb eines Artefakts bedeutet in den meisten Fällen, dass diese Konfiguration den gleichen Qualitäts­sicherungs­prozess durchlaufen hat wie andere Artefakte. Daher kann man sicher sein, dass die Konfiguration von guter Qualität und vertrauenswürdig ist. Im Gegensatz dazu bedeutet eine Konfiguration in einer separaten Datei, dass es keine Hinweise darauf gibt, wer und warum Änderungen an dieser Datei vorgenommen hat. Ist das wichtig? Wir glauben, dass es für die meisten Produktions­systeme besser ist, eine stabile und hochwertige Konfiguration zu haben.

Die Version des Artefakts ermöglicht es herauszufinden, wann es erstellt wurde, welche Werte es enthält, welche Features aktiviert bzw. deaktiviert sind und wer für jede Änderung in der Konfiguration verantwortlich war. Es kann einige Mühe kosten, die Konfiguration in einem Artefakt zu halten, und es ist eine Design­entscheidung, die zu treffen ist.

Vor- und Nachteile

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

Vorteile

Merkmale der kompilierbaren Konfiguration eines kompletten verteilten Systems:

  1. Statische Überprüfung der Konfiguration. Dies gibt ein hohes Maß an Vertrauen, dass die Konfiguration korrekt ist, gegeben die Typbeschränkungen.
  2. Reiche Sprache der Konfiguration. Typischerweise sind andere Konfigurations­ansätze auf maximal Variablen­ersetzung beschränkt.
    Mit Scala kann man eine breite Palette an Sprachfeatures nutzen, um die Konfiguration zu verbessern. Zum Beispiel können wir Traits verwenden, um Standardwerte bereitzustellen, Objekte, um unterschiedliche Geltungsbereiche festzulegen, und wir können auf vals verweisen, das nur einmal im äußeren Geltungsbereich definiert ist (DRY). Es ist möglich, literale Sequenzen oder Instanzen bestimmter Klassen zu verwenden (Seq, Map, etc.).
  3. DSL. Scala bietet eine angemessene Unterstützung für DSL-Autoren. Diese Funktionen können genutzt werden, um eine Konfigurationssprache zu schaffen, die benutzerfreundlicher ist, sodass die endgültige Konfiguration zumindest für Fachanwender lesbar ist.
  4. Integrität und Kohärenz über Knoten hinweg. Ein Vorteil, die Konfiguration für das gesamte verteilte System an einem Ort zu haben, ist, dass alle Werte streng einmal definiert und dann an allen Stellen wiederverwendet werden, wo wir sie benötigen. Auch die typsicheren Portdeklarationen stellen sicher, dass in allen möglichen korrekten Konfigurationen die Knoten des Systems dieselbe Sprache sprechen. Es gibt explizite Abhängigkeiten zwischen Knoten, was es schwierig macht, einige Dienste zu vergessen.
  5. Hohe Qualität der Änderungen. Der Gesamtherangehensweise, Konfigurationsänderungen durch den normalen PR-Prozess zu leiten, setzt hohe Qualitätsstandards auch in der Konfiguration.
  6. Gleichzeitige Konfigurationsänderungen. Wann immer wir Änderungen an der Konfiguration vornehmen, sorgt die automatische Bereitstellung dafür, dass alle Knoten aktualisiert werden.
  7. Vereinfachung der Anwendung. Die Anwendung muss die Konfiguration nicht parsen und validieren oder mit falschen Konfigurationswerten umgehen. Dies vereinfacht die gesamte Anwendung. (Einige Komplexitätserhöhungen liegen in der Konfiguration selbst, aber dies ist ein bewusster Kompromiss zugunsten der Sicherheit.) Es ist recht einfach, zu einer normalen Konfiguration zurückzukehren — man fügt einfach die fehlenden Teile hinzu. Es ist einfacher, mit kompilierter Konfiguration zu beginnen und die Implementierung zusätzlicher Teile auf später zu verschieben.
  8. Versionierte Konfiguration. Da Konfigurationsänderungen dem gleichen Entwicklungsprozess folgen, erhalten wir ein Artefakt mit einer einzigartigen Version. Dies ermöglicht es uns, die Konfiguration bei Bedarf zurückzusetzen. Wir können sogar eine Konfiguration bereitstellen, die vor einem Jahr verwendet wurde, und sie wird genau gleich funktionieren. Eine stabile Konfiguration verbessert die Vorhersagbarkeit und Zuverlässigkeit des verteilten Systems. Die Konfiguration ist zur Compile-Zeit festgelegt und kann auf einem Produktionssystem nicht leicht manipuliert werden.
  9. Modularität. Das vorgeschlagene Framework ist modular und Module können auf verschiedene Weise kombiniert werden, um
    unterschiedliche Konfigurationen (Setups / Layouts) zu unterstützen. Insbesondere ist es möglich, ein kleines Setup mit einem einzelnen Knoten und ein großes Setup mit mehreren Knoten zu haben. Es ist sinnvoll, mehrere Produktionslayouts zu haben.
  10. Tests. Zu Testzwecken könnte man einen Mock-Service implementieren und ihn auf sichere Weise als Abhängigkeit verwenden. Mehrere verschiedene Testlayouts mit verschiedenen Teilen, die durch Mocks ersetzt wurden, könnten gleichzeitig gepflegt werden.
  11. Integrationstests. In verteilten Systemen ist es manchmal schwierig, Integrationstests durchzuführen. Mithilfe des beschriebenen Ansatzes zur typsicheren Konfiguration des gesamten verteilten Systems können wir alle verteilten Teile auf einem einzigen Server kontrolliert ausführen. Es ist einfach, die Situation zu emulieren
    in der einer der Dienste nicht verfügbar ist.

Nachteile

Der Ansatz der kompilierten Konfiguration unterscheidet sich von der «normalen» Konfiguration und könnte nicht für alle Bedürfnisse geeignet sein. Hier sind einige der Nachteile der kompilierten Konfiguration:

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

Es gibt einige Einschränkungen des implementierten Beispiels:

  1. Wenn wir zusätzliche Konfigurationen bereitstellen, die von der Knotenimplementierung nicht gefordert werden, wird uns der Compiler nicht helfen, die fehlende Implementierung zu erkennen. Dies könnte angegangen werden durch HList oder ADTs (Fallklassen) für die Knoten-Konfiguration anstelle von Traits und dem Cake-Muster.
  2. Wir müssen einige Boilerplate im Konfigurationsdatei bereitstellen: (Paket, importieren, Objekt 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, Konfiguration direkt im Quellcode sicher und typisiert darzustellen. Der Ansatz könnte in vielen Anwendungen als Ersatz für XML- und andere textbasierte Konfigurationen verwendet 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, zur herkömmlichen Methode wechseln.

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

Dieser Ansatz könnte auf verschiedene Weise erweitert werden:

  1. Man könnte Makros nutzen, um die Konfigurationsvalidierung durchzuführen und im Falle von Fehlassertionen bei Geschäftslogik-Beschränkungen während der Kompilierung zu scheitern.
  2. Eine DSL könnte implementiert werden, um die Konfiguration auf eine benutzerfreundliche Art und Weise für das Fachgebiet darzustellen.
  3. Dynamisches Ressourcenmanagement mit automatischen Anpassungen der Konfiguration. Wenn wir beispielsweise die Anzahl der Cluster-Knoten anpassen, könnten wir (1) wünschen, dass die Knoten eine leicht modifizierte Konfiguration erhalten; (2) dass der Cluster-Manager die Informationen über die neuen Knoten erhält.

Danke

Ich möchte Andrey Saksonov, Pavel Popov und Anton Nehaev für ihr inspirierendes Feedback zum Entwurf dieses Beitrags danken, das mir geholfen hat, ihn klarer zu gestalten.

Quelle: habr.com

Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server 🔥 Kaufen Sie zuverlässiges Hosting für Websites mit DDoS-Schutz, VPS VDS-Server | ProHoster