Kompilierbare Konfiguration eines verteilten Systems

In diesem Beitrag möchten wir einen interessanten Umgang mit der Konfiguration eines verteilten Systems vorstellen.
Die Konfiguration wird typsicher direkt in der Scala-Sprache dargestellt. Eine Beispielimplementierung wird ausführlich beschrieben. Es werden verschiedene Aspekte des Vorschlags diskutiert, darunter auch der Einfluss auf den gesamten Entwicklungsprozess.

Kompilierbare Konfiguration eines verteilten Systems

(in Englisch)

Einleitung

Der Aufbau robuster verteilter Systeme erfordert die Verwendung einer korrekten und kohärenten Konfiguration auf allen Knoten. Eine typische Lösung besteht darin, eine textuelle Bereitstellungsbeschreibung (Terraform, Ansible oder ähnliches) und automatisch generierte Konfigurationsdateien (häufig – speziell für jeden Knoten/jede Rolle) zu verwenden. Wir möchten außerdem auf allen kommunizierenden Knoten die gleichen Protokolle der gleichen Versionen verwenden (ansonsten würden Inkompatibilitätsprobleme auftreten). In der JVM-Welt bedeutet dies, dass zumindest die Messaging-Bibliothek auf allen kommunizierenden Knoten dieselbe Version haben sollte.

Wie wäre es mit dem Testen des Systems? Natürlich sollten wir Unit-Tests für alle Komponenten durchführen, bevor wir mit den Integrationstests beginnen. Um Testergebnisse zur Laufzeit extrapolieren zu können, sollten wir sicherstellen, dass die Versionen aller Bibliotheken sowohl in der Laufzeit- als auch in der Testumgebung identisch bleiben.

Beim Ausführen von Integrationstests ist es oft viel einfacher, auf allen Knoten denselben Klassenpfad zu haben. Wir müssen lediglich sicherstellen, dass bei der Bereitstellung derselbe Klassenpfad verwendet wird. (Es ist möglich, unterschiedliche Klassenpfade auf verschiedenen Knoten zu verwenden, es ist jedoch schwieriger, diese Konfiguration darzustellen und korrekt bereitzustellen.) Der Einfachheit halber berücksichtigen wir daher nur identische Klassenpfade auf allen Knoten.

Die Konfiguration entwickelt sich tendenziell zusammen mit der Software weiter. Normalerweise verwenden wir Versionen, um verschiedene zu identifizieren
Phasen der Softwareentwicklung. Es erscheint sinnvoll, die Konfiguration im Rahmen der Versionsverwaltung abzudecken und unterschiedliche Konfigurationen mit einigen Bezeichnungen zu kennzeichnen. Wenn in der Produktion nur eine Konfiguration vorhanden ist, verwenden wir möglicherweise eine einzelne Version als Kennung. Manchmal haben wir möglicherweise mehrere Produktionsumgebungen. Und für jede Umgebung benötigen wir möglicherweise einen separaten Konfigurationszweig. Daher können Konfigurationen mit Zweig und Version gekennzeichnet werden, um verschiedene Konfigurationen eindeutig zu identifizieren. Jede Zweigbezeichnung und Version entspricht einer einzelnen Kombination aus verteilten Knoten, Ports, externen Ressourcen und Klassenpfadbibliotheksversionen auf jedem Knoten. Hier behandeln wir nur den einzelnen Zweig und identifizieren Konfigurationen anhand einer dreikomponentigen Dezimalversion (1.2.3), genau wie andere Artefakte.

In modernen Umgebungen werden Konfigurationsdateien nicht mehr manuell geändert. Normalerweise generieren wir
Konfigurationsdateien zum Zeitpunkt der Bereitstellung und Fass sie niemals an nachher. Man könnte sich also fragen, warum wir immer noch das Textformat für Konfigurationsdateien verwenden? Eine praktikable Option besteht darin, die Konfiguration in einer Kompilierungseinheit zu platzieren und von der Konfigurationsvalidierung zur Kompilierungszeit zu profitieren.

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

Kompilierbare Konfiguration

In diesem Abschnitt besprechen wir ein Beispiel einer statischen Konfiguration. 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. Eine ist für eine Einzelknotenkonfiguration und eine andere für die Konfiguration mit zwei Knoten.

Ein typisches verteiltes System besteht aus wenigen Knoten. Die Knoten könnten anhand eines Typs identifiziert werden:

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

oder einfach nur

case class NodeId(hostName: String)

oder

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 den anderen Knoten zu kommunizieren.

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

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

woher Port ist nur ein Int im erlaubten Bereich:

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

Raffinierte Typen

See raffiniert Bibliothek. Kurz gesagt, es ermöglicht das Hinzufügen von Kompilierungszeitbeschränkungen zu anderen Typen. In diesem Fall Int darf nur 16-Bit-Werte haben, die die Portnummer darstellen können. Für diesen Konfigurationsansatz ist die Verwendung dieser Bibliothek nicht erforderlich. Es 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)

Phantomtyp

Um das Protokoll während der Kompilierung zu identifizieren, verwenden wir die Scala-Funktion zur Deklaration von Typargumenten Protocol das wird in der Klasse nicht verwendet. Es ist ein sogenanntes Phantomtyp. Zur Laufzeit benötigen wir selten eine Instanz einer Protokollkennung, deshalb speichern wir sie nicht. Bei der Kompilierung bietet dieser Phantomtyp zusätzliche Typsicherheit. Wir können den Port nicht mit einem falschen Protokoll weiterleiten.

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

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

woher RequestMessage ist der Basistyp von Nachrichten, die der Client an den Server senden kann ResponseMessage ist die Antwortnachricht vom Server. Selbstverständlich 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 wird als einfache Zeichenfolge zurückgegeben.

Eine Dienstkonfiguration könnte durch den Dienstnamen, eine Sammlung von Ports und einige Abhängigkeiten beschrieben werden. Es gibt einige Möglichkeiten, alle diese Elemente in Scala darzustellen (z. B. HList, algebraische Datentypen). Für die Zwecke dieses Beitrags verwenden wir Cake Pattern und stellen kombinierbare Teile (Module) als Merkmale dar. (Cake Pattern ist keine Voraussetzung für diesen kompilierbaren Konfigurationsansatz. Es ist nur eine mögliche Implementierung der Idee.)

Abhängigkeiten könnten mit dem Cake Pattern 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)
  }

Für den Echo-Dienst muss lediglich ein Port konfiguriert werden. 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 Traits die Deklaration abstrakter Methoden ermöglichen. Wenn wir abstrakte Methoden verwenden, erfordert der Compiler eine Implementierung in einer Konfigurationsinstanz. Hier haben wir die Implementierung bereitgestellt (8081) und wird als Standardwert verwendet, wenn wir ihn in einer konkreten Konfiguration überspringen.

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

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

Die Abhängigkeit hat den gleichen Typ wie die echoService. Insbesondere wird das gleiche Protokoll gefordert. Daher können wir sicher sein, dass diese beiden Abhängigkeiten ordnungsgemäß funktionieren, wenn wir sie verbinden.

Implementierung von Dienstleistungen

Ein Dienst benötigt eine Funktion zum Starten und ordnungsgemäßen Beenden. (Die Möglichkeit, einen Dienst herunterzufahren, ist für Tests von entscheidender Bedeutung.) Auch hier gibt es einige Möglichkeiten, eine solche Funktion für eine bestimmte Konfiguration anzugeben (wir könnten beispielsweise Typklassen verwenden). Für diesen Beitrag verwenden wir erneut Cake Pattern. Wir können einen Dienst mit darstellen cats.Resource die bereits Belichtungsreihen und Ressourcenfreigabe bietet. Um eine Ressource zu erhalten, sollten wir eine Konfiguration und einen Laufzeitkontext bereitstellen. Die Dienststartfunktion könnte also 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]
  }

woher

  • Config – Art der Konfiguration, die für diesen Dienststarter erforderlich ist
  • AddressResolver – ein Laufzeitobjekt, das die Fähigkeit hat, echte Adressen anderer Knoten abzurufen (weiterlesen für Details).

die anderen Typen stammen aus cats:

  • F[_] — Effekttyp (Im einfachsten Fall F[A] könnte gerecht 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 – verfügt über Möglichkeiten zum Erwerb und Freigeben
  • Timer – ermöglicht das Schlafen/Zeitmessen
  • ContextShift - Analogon von ExecutionContext
  • Applicative – tatsächlicher Wrapper von Funktionen (fast eine Monade) (wir könnten ihn irgendwann durch etwas anderes ersetzen)

Über diese Schnittstelle können wir einige Dienste implementieren. Zum Beispiel ein Dienst, der nichts tut:

  trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
    type Config <: Any
    def resource(...): ResourceReader[F, Config, Unit] =
      Reader(_ => Resource.pure[F, Unit](()))
  }

(Siehe Quellcode für andere Dienstimplementierungen – Echo-Dienst,
Echo-Client machen lebenslange Controller.)

Ein Knoten ist ein einzelnes Objekt, das einige Dienste ausführt (das Starten einer Ressourcenkette wird durch Cake Pattern 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 wir im Knoten genau den Konfigurationstyp angeben, der von diesem Knoten benötigt wird. Der Compiler erlaubt uns nicht, das Objekt (Cake) mit unzureichendem Typ zu erstellen, da jedes Dienstmerkmal eine Einschränkung für das Objekt deklariert Config Typ. Außerdem können wir den Knoten nicht starten, ohne die vollständige Konfiguration bereitzustellen.

Auflösung der Knotenadresse

Um eine Verbindung herzustellen, benötigen wir für jeden Knoten eine echte Hostadresse. Möglicherweise wird es später als andere Teile der Konfiguration bekannt. 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 verschiedene Möglichkeiten, eine solche Funktion zu implementieren.

  1. Wenn wir die tatsächlichen Adressen vor der Bereitstellung kennen, während der Instanziierung der Knotenhosts, können wir Scala-Code mit den tatsächlichen Adressen generieren und anschließend den Build ausführen (der Kompilierungszeitprüfungen durchführt und dann die Integrationstestsuite ausführt). In diesem Fall ist unsere Zuordnungsfunktion statisch bekannt und kann auf etwas wie a vereinfacht werden Map[NodeId, NodeAddress].
  2. Manchmal erhalten wir tatsächliche 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 verfügen wir möglicherweise über einen Erkennungsdienst, der vor allen anderen Knoten gestartet wird, und jeder Knoten gibt möglicherweise seine Adresse in diesem Dienst bekannt und abonniert Abhängigkeiten.
  3. Wenn wir etwas ändern können /etc/hostskönnen wir vordefinierte Hostnamen verwenden (wie my-project-main-node machen echo-backend) und verknüpfen Sie diesen Namen zum Zeitpunkt der Bereitstellung einfach mit der IP-Adresse.

In diesem Beitrag gehen wir nicht näher auf diese Fälle ein. Tatsächlich haben in unserem Spielzeugbeispiel alle Knoten dieselbe IP-Adresse – 127.0.0.1.

In diesem Beitrag betrachten wir zwei verteilte Systemlayouts:

  1. Einzelknoten-Layout, bei dem alle Dienste auf einem einzelnen Knoten platziert werden.
  2. Zwei-Knoten-Layout, bei dem sich Dienst und Client auf unterschiedlichen Knoten befinden.

Die Konfiguration für a einzelner Knoten Das Layout ist wie folgt:

Einzelknotenkonfiguration

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identifier of the single node 
  // configuration of server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Type safe service port specification. */
  override def portNumber: PortNumber = 8088

  // configuration of client

  /** We'll use the service provided by the same host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // lifecycle controller configuration
  def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 requests, not 9.
}

Hier erstellen wir eine einzelne Konfiguration, die sowohl die Server- als auch die Clientkonfiguration erweitert. Außerdem konfigurieren wir einen Lebenszyklus-Controller, der Client und Server normalerweise danach beendet lifetime Intervalldurchgänge.

Derselbe Satz an Dienstimplementierungen und -konfigurationen kann verwendet werden, um das Layout eines Systems mit zwei separaten Knoten zu erstellen. Wir müssen nur etwas erschaffen zwei separate Knotenkonfigurationen mit den passenden Leistungen:

Konfiguration mit 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! dependency specification
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // additional 0.5 seconds so that there are 10 request, not 9.

    def testMessage: String = "dolly"
  }

Sehen Sie, wie wir die Abhängigkeit angeben. Wir erwähnen den vom anderen Knoten bereitgestellten Dienst als Abhängigkeit vom aktuellen Knoten. Der Typ der Abhängigkeit wird überprüft, da er einen Phantomtyp enthält, der das Protokoll beschreibt. Und zur Laufzeit haben wir die richtige Knoten-ID. Dies ist einer der wichtigen Aspekte des vorgeschlagenen Konfigurationsansatzes. Dadurch haben wir die Möglichkeit, den Port nur einmal festzulegen und sicherzustellen, dass wir auf den richtigen Port verweisen.

Implementierung mit zwei Knoten

Für diese Konfiguration verwenden wir genau die gleichen Service-Implementierungen. Überhaupt keine Änderungen. Wir erstellen jedoch zwei verschiedene 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 eine serverseitige Konfiguration. Der zweite Knoten implementiert den Client und benötigt einen weiteren Teil der Konfiguration. Für beide Knoten ist eine Lebensdauerangabe erforderlich. Für die Zwecke dieses Beitrags hat der Dienstknoten eine unbegrenzte Lebensdauer, die mit beendet werden könnte SIGTERM, während der Echo-Client nach der konfigurierten endlichen Dauer beendet wird. Siehe die Starteranwendung für weitere Einzelheiten.

Gesamtentwicklungsprozess

Sehen wir uns an, 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, Konfigurationsartefakte von anderen Codeartefakten zu trennen. Oft können wir eine Vielzahl von Konfigurationen auf derselben Codebasis 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 bei jeder Bereitstellung dieser Konfiguration konstant.

Eine Konfigurationsänderung wird zur Codeänderung. Daher sollte es demselben Qualitätssicherungsprozess unterliegen:

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

Aus der Vorgehensweise ergeben sich folgende Konsequenzen:

  1. Die Konfiguration ist für die Instanz eines bestimmten Systems kohärent. Es scheint, dass es keine Möglichkeit gibt, eine falsche Verbindung zwischen Knoten herzustellen.
  2. Es ist nicht einfach, die Konfiguration nur in einem Knoten zu ändern. Es scheint unvernünftig, sich anzumelden und einige Textdateien zu ändern. Dadurch wird eine Konfigurationsdrift weniger möglich.
  3. Kleine Konfigurationsänderungen sind nicht einfach durchzuführen.
  4. Die meisten Konfigurationsänderungen werden demselben Entwicklungsprozess folgen und einige Prüfungen bestehen.

Benötigen wir ein separates Repository für die Produktionskonfiguration? Die Produktionskonfiguration enthält möglicherweise vertrauliche Informationen, die wir außerhalb der Reichweite vieler Personen halten möchten. Daher kann es sich lohnen, ein separates Repository mit eingeschränktem Zugriff zu führen, das die Produktionskonfiguration enthält. Wir können die Konfiguration in zwei Teile aufteilen – einen, der die offensten Parameter der Produktion enthält, und einen, der den geheimen Teil der Konfiguration enthält. Dies würde den meisten Entwicklern den Zugriff auf die überwiegende Mehrheit der Parameter ermöglichen und gleichzeitig den Zugriff auf wirklich sensible Dinge beschränken. Dies lässt sich leicht erreichen, indem man Zwischenmerkmale mit Standardparameterwerten verwendet.

Variationen

Sehen wir uns die Vor- und Nachteile des vorgeschlagenen Ansatzes im Vergleich zu den anderen Konfigurationsmanagementtechniken an.

Zunächst listen wir einige Alternativen zu den verschiedenen Aspekten des vorgeschlagenen Umgangs mit der Konfiguration auf:

  1. Textdatei auf dem Zielcomputer.
  2. Zentralisierte Schlüsselwertspeicherung (wie etcd/zookeeper).
  3. Unterprozesskomponenten, die ohne Neustart des Prozesses neu konfiguriert/neu gestartet werden könnten.
  4. Konfiguration außerhalb der Artefakt- und Versionskontrolle.

Die Textdatei bietet eine gewisse Flexibilität im Hinblick auf Ad-hoc-Korrekturen. Der Administrator eines Systems kann sich beim Zielknoten anmelden, eine Änderung vornehmen und den Dienst einfach neu starten. Für größere Systeme ist dies möglicherweise nicht so gut. Von der Veränderung bleiben keine Spuren zurück. Die Änderung wird nicht von einem anderen Augenpaar überprüft. Es könnte schwierig sein herauszufinden, was die Änderung verursacht hat. Es wurde nicht getestet. Aus der Sicht eines verteilten Systems kann ein Administrator einfach vergessen, die Konfiguration in einem der anderen Knoten zu aktualisieren.

(Übrigens, wenn irgendwann die Notwendigkeit besteht, Textkonfigurationsdateien zu verwenden, müssen wir nur einen Parser und einen Validator hinzufügen, die dasselbe erzeugen könnten Config Typ und das würde ausreichen, um mit der Verwendung von Textkonfigurationen zu beginnen. Dies zeigt auch, dass die Komplexität der Konfiguration zur Kompilierungszeit etwas geringer ist als die Komplexität textbasierter Konfigurationen, da wir in der textbasierten Version zusätzlichen Code benötigen.)

Die zentralisierte Schlüsselwertspeicherung ist ein guter Mechanismus zur Verteilung von Anwendungsmetaparametern. Hier müssen wir darüber nachdenken, was wir als Konfigurationswerte betrachten und was nur Daten sind. Gegeben eine Funktion C => A => B Wir nennen normalerweise selten sich ändernde Werte C „Konfiguration“, während häufig geänderte Daten A - einfach Daten eingeben. Die Konfiguration sollte der Funktion früher als den Daten bereitgestellt werden A. Angesichts dieser Idee können wir sagen, dass die erwartete Häufigkeit von Änderungen dazu verwendet werden könnte, Konfigurationsdaten von reinen Daten zu unterscheiden. Außerdem stammen Daten typischerweise aus einer Quelle (Benutzer) und die Konfiguration stammt aus einer anderen Quelle (Administrator). Der Umgang mit Parametern, die nach dem Initialisierungsprozess geändert werden können, führt zu einer Erhöhung der Anwendungskomplexität. Für solche Parameter müssen wir uns um den Übermittlungsmechanismus, das Parsen und die Validierung sowie den Umgang mit falschen Werten kümmern. Um die Komplexität des Programms zu verringern, sollten wir daher besser die Anzahl der Parameter reduzieren, die sich zur Laufzeit ändern können (oder sie sogar ganz eliminieren).

Aus Sicht dieses Beitrags sollten wir zwischen statischen und dynamischen Parametern unterscheiden. Wenn die Dienstlogik eine seltene Änderung einiger Parameter zur Laufzeit erfordert, können wir sie dynamische Parameter nennen. Andernfalls sind sie statisch und könnten mit dem vorgeschlagenen Ansatz konfiguriert werden. Für die dynamische Rekonfiguration sind möglicherweise andere Ansätze erforderlich. Beispielsweise könnten Teile des Systems mit den neuen Konfigurationsparametern neu gestartet werden, ähnlich wie beim Neustart einzelner Prozesse eines verteilten Systems.
(Meine bescheidene Meinung ist, eine Neukonfiguration zur Laufzeit zu vermeiden, da sie die Komplexität des Systems erhöht.
Es könnte einfacher sein, sich beim Neustarten von Prozessen einfach auf die Unterstützung des Betriebssystems zu verlassen. Allerdings ist dies möglicherweise nicht immer möglich.)

Ein wichtiger Aspekt bei der Verwendung der statischen Konfiguration, der manchmal (ohne andere Gründe) dazu führt, dass Menschen über eine dynamische Konfiguration nachdenken, ist die Ausfallzeit des Dienstes während der Konfigurationsaktualisierung. Wenn wir tatsächlich Änderungen an der statischen Konfiguration vornehmen müssen, müssen wir das System neu starten, damit die neuen Werte wirksam werden. Die Anforderungen an Ausfallzeiten variieren je nach System und sind daher möglicherweise nicht so kritisch. Wenn es kritisch ist, müssen wir etwaige Systemneustarts im Voraus planen. Wir könnten zum Beispiel umsetzen Entleeren der AWS ELB-Verbindung. In diesem Szenario starten wir jedes Mal, wenn wir das System neu starten müssen, parallel eine neue Instanz des Systems und schalten dann ELB darauf um, während das alte System die Wartung bestehender Verbindungen abschließen kann.

Wie wäre es, die Konfiguration innerhalb oder außerhalb eines versionierten Artefakts zu belassen? Das Behalten 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 Gegenteil bedeutet die Konfiguration in einer separaten Datei, dass es keine Spuren darüber 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.

Mit der Version des Artefakts können Sie herausfinden, wann es erstellt wurde, welche Werte es enthält, welche Funktionen aktiviert/deaktiviert sind und wer für die einzelnen Änderungen in der Konfiguration verantwortlich war. Es kann einige Anstrengungen erfordern, die Konfiguration innerhalb eines Artefakts beizubehalten, und es ist eine Designentscheidung, die getroffen werden muss.

Für und Wider

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

Vorteile

Merkmale der kompilierbaren Konfiguration eines vollständigen verteilten Systems:

  1. Statische Überprüfung der Konfiguration. Dies gibt ein hohes Maß an Sicherheit, dass die Konfiguration angesichts der Typbeschränkungen korrekt ist.
  2. Umfangreiche Konfigurationssprache. Typischerweise beschränken sich andere Konfigurationsansätze auf höchstens die Variablensubstitution.
    Mit Scala kann man eine Vielzahl von Sprachfunktionen nutzen, um die Konfiguration zu verbessern. Beispielsweise können wir Merkmale verwenden, um Standardwerte bereitzustellen, und Objekte, um unterschiedliche Bereiche festzulegen, auf die wir verweisen können vals werden nur einmal im äußeren Bereich (DRY) definiert. Es ist möglich, Literalsequenzen oder Instanzen bestimmter Klassen zu verwenden (Seq, Map, Etc.).
  3. DSL. Scala bietet gute Unterstützung für DSL-Autoren. Mithilfe dieser Funktionen kann eine komfortablere und benutzerfreundlichere Konfigurationssprache erstellt werden, sodass die endgültige Konfiguration zumindest für Domänenbenutzer lesbar ist.
  4. Integrität und Kohärenz über Knoten hinweg. Einer der Vorteile der Konfiguration für das gesamte verteilte System an einem Ort besteht darin, dass alle Werte genau einmal definiert und dann an allen Stellen wiederverwendet werden, an denen wir sie benötigen. Außerdem stellen typsichere Portdeklarationen sicher, dass die Knoten des Systems in allen möglichen korrekten Konfigurationen dieselbe Sprache sprechen. Es gibt explizite Abhängigkeiten zwischen Knoten, sodass man kaum vergisst, einige Dienste bereitzustellen.
  5. Hohe Qualität der Änderungen. Der Gesamtansatz, Konfigurationsänderungen durch den normalen PR-Prozess zu leiten, schafft hohe Qualitätsstandards auch in der Konfiguration.
  6. Gleichzeitige Konfigurationsänderungen. Wann immer wir Änderungen an der Konfiguration vornehmen, stellt die automatische Bereitstellung sicher, dass alle Knoten aktualisiert werden.
  7. Anwendungsvereinfachung. Die Anwendung muss die Konfiguration nicht analysieren und validieren und falsche Konfigurationswerte verarbeiten. Dies vereinfacht die Gesamtanwendung. (Ein gewisser Komplexitätszuwachs liegt in der Konfiguration selbst, aber es handelt sich dabei um einen bewussten Kompromiss im Hinblick auf die Sicherheit.) Es ist ziemlich einfach, zur normalen Konfiguration zurückzukehren – fügen Sie einfach die fehlenden Teile hinzu. Es ist einfacher, mit der kompilierten Konfiguration zu beginnen und die Implementierung zusätzlicher Teile auf einen späteren Zeitpunkt zu verschieben.
  8. Versionierte Konfiguration. Aufgrund der Tatsache, dass Konfigurationsänderungen demselben Entwicklungsprozess folgen, erhalten wir als Ergebnis ein Artefakt mit einer eindeutigen Version. Dadurch können wir die Konfiguration bei Bedarf zurückwechseln. Wir können sogar eine Konfiguration bereitstellen, die vor einem Jahr verwendet wurde, und sie funktioniert genauso. Eine stabile Konfiguration verbessert die Vorhersagbarkeit und Zuverlässigkeit des verteilten Systems. Die Konfiguration wird zur Kompilierungszeit festgelegt und kann auf einem Produktionssystem nicht einfach manipuliert werden.
  9. Modularität. Das vorgeschlagene Framework ist modular aufgebaut und Module können auf verschiedene Weise kombiniert werden
    unterstützen verschiedene Konfigurationen (Setups/Layouts). Insbesondere ist es möglich, ein Einzelknoten-Layout im kleinen Maßstab und eine Anordnung mit mehreren Knoten im großen Maßstab zu haben. Es ist sinnvoll, mehrere Produktionslayouts zu haben.
  10. Testen. Zu Testzwecken könnte man einen Scheindienst implementieren und ihn typsicher als Abhängigkeit verwenden. Einige unterschiedliche Testlayouts, bei denen verschiedene Teile durch Modelle ersetzt wurden, konnten gleichzeitig beibehalten werden.
  11. Integrationstests. In verteilten Systemen ist es manchmal schwierig, Integrationstests durchzuführen. Mit dem beschriebenen Ansatz zur typsicheren Konfiguration des gesamten verteilten Systems können wir alle verteilten Teile kontrollierbar auf einem einzigen Server ausführen. Es ist einfach, die Situation nachzuahmen
    wenn einer der Dienste nicht verfügbar ist.

Nachteile

Der kompilierte Konfigurationsansatz unterscheidet sich von der „normalen“ Konfiguration und ist möglicherweise nicht für alle Anforderungen geeignet. Hier sind einige der Nachteile der kompilierten Konfiguration:

  1. Statische Konfiguration. Es ist möglicherweise nicht für alle Anwendungen geeignet. In manchen Fällen besteht die Notwendigkeit, die Konfiguration in der Produktion unter Umgehung aller Sicherheitsmaßnahmen schnell zu reparieren. Dieser Ansatz macht es schwieriger. Die Kompilierung und erneute Bereitstellung sind erforderlich, nachdem Änderungen an der Konfiguration vorgenommen wurden. Dies ist sowohl das Merkmal als auch die Belastung.
  2. Konfigurationsgenerierung. Wenn die Konfiguration von einem Automatisierungstool generiert wird, erfordert dieser Ansatz eine anschließende Kompilierung (die wiederum fehlschlagen kann). Es könnte zusätzlichen Aufwand erfordern, diesen zusätzlichen Schritt in das Build-System zu integrieren.
  3. Instrumente. Heutzutage sind zahlreiche Tools im Einsatz, die auf textbasierten Konfigurationen basieren. Manche von ihnen
    ist nicht anwendbar, wenn die Konfiguration kompiliert wird.
  4. Ein Umdenken ist erforderlich. Entwickler und DevOps sind mit Textkonfigurationsdateien vertraut. Die Idee, Konfigurationen zu kompilieren, mag 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 zusätzliche Konfigurationen bereitstellen, die von der Knotenimplementierung nicht benötigt werden, hilft uns der Compiler nicht dabei, die fehlende Implementierung zu erkennen. Dies könnte durch die Verwendung behoben werden HList oder ADTs (Fallklassen) für die Knotenkonfiguration anstelle von Merkmalen und Kuchenmuster.
  2. Wir müssen einige Boilerplates in der Konfigurationsdatei bereitstellen: (package, import, object Erklärungen;
    override defist für Parameter, die Standardwerte haben). Dies kann teilweise mit einem DSL behoben werden.
  3. In diesem Beitrag behandeln wir nicht die dynamische Rekonfiguration von Clustern ähnlicher Knoten.

Zusammenfassung

In diesem Beitrag haben wir die Idee diskutiert, Konfiguration direkt im Quellcode typsicher 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, auf die altmodische Methode umsteigen.

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

Dieser Ansatz könnte auf verschiedene Arten erweitert werden:

  1. Man könnte Makros verwenden, um eine Konfigurationsvalidierung durchzuführen und zur Kompilierungszeit fehlzuschlagen, falls irgendwelche Geschäftslogik-Einschränkungen fehlschlagen.
  2. Ein DSL könnte implementiert werden, um die Konfiguration auf domänenfreundliche Weise darzustellen.
  3. Dynamisches Ressourcenmanagement mit automatischen Konfigurationsanpassungen. Wenn wir beispielsweise die Anzahl der Clusterknoten anpassen, möchten wir möglicherweise, dass (1) die Knoten eine leicht geänderte Konfiguration erhalten; (2) Cluster-Manager zum Empfangen neuer Knoteninformationen.

Vielen Dank

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

Source: habr.com