Kompilierte verteilte Systemkonfiguration

Ich möchte Ihnen einen interessanten Mechanismus für die Arbeit mit der Konfiguration eines verteilten Systems vorstellen. Die Konfiguration wird mithilfe sicherer Typen direkt in einer kompilierten Sprache (Scala) dargestellt. Dieser Beitrag bietet ein Beispiel für eine solche Konfiguration und erörtert verschiedene Aspekte der Implementierung einer kompilierten Konfiguration in den gesamten Entwicklungsprozess.

Kompilierte verteilte Systemkonfiguration

(englisch)

Einführung

Der Aufbau eines zuverlässigen verteilten Systems bedeutet, dass alle Knoten die richtige Konfiguration verwenden und mit anderen Knoten synchronisiert sind. DevOps-Technologien (Terraform, Ansible oder ähnliches) werden normalerweise verwendet, um automatisch Konfigurationsdateien zu generieren (oft spezifisch für jeden Knoten). Wir möchten außerdem sicherstellen, dass alle kommunizierenden Knoten identische Protokolle (einschließlich derselben Version) verwenden. Andernfalls kommt es zu Inkompatibilitäten in unserem verteilten System. In der JVM-Welt hat diese Anforderung unter anderem zur Folge, dass überall die gleiche Version der Bibliothek mit den Protokollnachrichten verwendet werden muss.

Wie wäre es mit dem Testen eines verteilten Systems? Wir gehen natürlich davon aus, dass alle Komponenten Komponententests durchlaufen, bevor wir mit dem Integrationstest fortfahren. (Damit wir Testergebnisse zur Laufzeit extrapolieren können, müssen wir auch in der Testphase und zur Laufzeit einen identischen Satz von Bibliotheken bereitstellen.)

Bei der Arbeit mit Integrationstests ist es oft einfacher, überall auf allen Knoten denselben Klassenpfad zu verwenden. Wir müssen lediglich sicherstellen, dass zur Laufzeit derselbe Klassenpfad verwendet wird. (Obwohl es durchaus möglich ist, verschiedene Knoten mit unterschiedlichen Klassenpfaden auszuführen, erhöht dies die Komplexität der Gesamtkonfiguration und erschwert die Bereitstellung und Integrationstests.) Für die Zwecke dieses Beitrags gehen wir davon aus, dass alle Knoten denselben Klassenpfad verwenden.

Die Konfiguration entwickelt sich mit der Anwendung. Wir verwenden Versionen, um verschiedene Phasen der Programmentwicklung zu identifizieren. Es erscheint logisch, auch verschiedene Versionen von Konfigurationen zu identifizieren. Und platzieren Sie die Konfiguration selbst im Versionskontrollsystem. Wenn nur eine Konfiguration in Produktion ist, können wir einfach die Versionsnummer verwenden. Wenn wir viele Produktionsinstanzen verwenden, benötigen wir mehrere
Konfigurationszweige und eine zusätzliche Bezeichnung zusätzlich zur Version (z. B. der Name des Zweigs). Auf diese Weise können wir die genaue Konfiguration eindeutig identifizieren. Jeder Konfigurationsbezeichner entspricht eindeutig einer bestimmten Kombination aus verteilten Knoten, Ports, externen Ressourcen und Bibliotheksversionen. Für die Zwecke dieses Beitrags gehen wir davon aus, dass es nur einen Zweig gibt und können die Konfiguration auf die übliche Weise anhand von drei durch einen Punkt getrennten Zahlen identifizieren (1.2.3).

In modernen Umgebungen werden Konfigurationsdateien selten manuell erstellt. Häufiger entstehen sie während des Einsatzes und werden nicht mehr berührt (so dass Mach nichts kaputt). Es stellt sich natürlich die Frage: Warum verwenden wir immer noch das Textformat zum Speichern der Konfiguration? Eine praktikable Alternative scheint die Möglichkeit zu sein, regulären Code für die Konfiguration zu verwenden und von Überprüfungen zur Kompilierungszeit zu profitieren.

In diesem Beitrag werden wir die Idee untersuchen, eine Konfiguration innerhalb eines kompilierten Artefakts darzustellen.

Kompilierte Konfiguration

Dieser Abschnitt enthält ein Beispiel für eine statisch kompilierte Konfiguration. Es werden zwei einfache Dienste implementiert – der Echo-Dienst und der Echo-Dienst-Client. Basierend auf diesen beiden Diensten werden zwei Systemoptionen zusammengestellt. Bei einer Option befinden sich beide Dienste auf demselben Knoten, bei einer anderen Option auf unterschiedlichen Knoten.

Typischerweise enthält ein verteiltes System mehrere Knoten. Sie können Knoten anhand bestimmter Wertetypen identifizieren NodeId:

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

oder

case class NodeId(hostName: String)

oder

object Singleton
type NodeId = Singleton.type

Knoten übernehmen verschiedene Rollen, sie führen Dienste aus und zwischen ihnen können TCP/HTTP-Verbindungen hergestellt werden.

Um eine TCP-Verbindung zu beschreiben, benötigen wir mindestens eine Portnummer. Wir möchten auch das Protokoll widerspiegeln, das an diesem Port unterstützt wird, um sicherzustellen, dass sowohl der Client als auch der Server dasselbe Protokoll verwenden. Wir werden den Zusammenhang mit der folgenden Klasse beschreiben:

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

wo Port - nur eine ganze Zahl Int Angabe des Bereichs akzeptabler Werte:

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

Raffinierte Typen

Siehe Bibliothek raffiniert и meine Bericht. Kurz gesagt: Mit der Bibliothek können Sie Einschränkungen zu Typen hinzufügen, die zur Kompilierzeit überprüft werden. In diesem Fall sind gültige Portnummernwerte 16-Bit-Ganzzahlen. Für eine kompilierte Konfiguration ist die Verwendung der verfeinerten Bibliothek nicht zwingend erforderlich, verbessert aber die Fähigkeit des Compilers, die Konfiguration zu überprüfen.

Für HTTP (REST)-Protokolle benötigen wir neben der Portnummer ggf. auch den Pfad zum Dienst:

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

Phantomtypen

Um das Protokoll zur Kompilierungszeit zu identifizieren, verwenden wir einen Typparameter, der in der Klasse nicht verwendet wird. Diese Entscheidung ist darauf zurückzuführen, dass wir zur Laufzeit keine Protokollinstanz verwenden, sondern möchten, dass der Compiler die Protokollkompatibilität prüft. Durch die Angabe des Protokolls können wir keinen ungeeigneten Dienst als Abhängigkeit weitergeben.

Eines der gängigen Protokolle ist die REST-API mit Json-Serialisierung:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

wo RequestMessage - Anfragetyp, ResponseMessage — Antworttyp.
Natürlich können wir auch andere Protokollbeschreibungen verwenden, die die von uns benötigte Genauigkeit der Beschreibung bieten.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Hier ist die Anfrage eine an die URL angehängte Zeichenfolge und die Antwort ist die zurückgegebene Zeichenfolge im Hauptteil der HTTP-Antwort.

Die Dienstkonfiguration wird durch den Dienstnamen, die Ports und die Abhängigkeiten beschrieben. Diese Elemente können in Scala auf verschiedene Arten dargestellt werden (z. B. HList-s, algebraische Datentypen). Für die Zwecke dieses Beitrags werden wir das Kuchenmuster verwenden und Module mit darstellen trait'ov. (Das Kuchenmuster ist kein erforderliches Element dieses Ansatzes. Es ist lediglich eine mögliche Implementierung.)

Abhängigkeiten zwischen Diensten können als Methoden dargestellt werden, die Ports zurückgeben EndPointder anderen Knoten:

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

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

Um einen Echo-Dienst zu erstellen, benötigen Sie lediglich eine Portnummer und einen Hinweis, dass der Port das Echo-Protokoll unterstützt. Wir geben möglicherweise keinen bestimmten Port an, weil... Mit Merkmalen können Sie Methoden ohne Implementierung deklarieren (abstrakte Methoden). In diesem Fall würde der Compiler beim Erstellen einer konkreten Konfiguration von uns verlangen, dass wir eine Implementierung der abstrakten Methode und eine Portnummer bereitstellen. Da wir die Methode implementiert haben, dürfen wir beim Erstellen einer bestimmten Konfiguration keinen anderen Port angeben. Der Standardwert wird verwendet.

In der Client-Konfiguration deklarieren wir eine Abhängigkeit vom Echo-Dienst:

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

Die Abhängigkeit ist vom gleichen Typ wie der exportierte Dienst echoService. Insbesondere im Echo-Client benötigen wir dasselbe Protokoll. Daher können wir bei der Verbindung zweier Dienste sicher sein, dass alles korrekt funktioniert.

Implementierung von Dienstleistungen

Zum Starten und Stoppen des Dienstes ist eine Funktion erforderlich. (Die Fähigkeit, den Dienst zu stoppen, ist für Tests von entscheidender Bedeutung.) Auch hier gibt es mehrere Optionen für die Implementierung einer solchen Funktion (beispielsweise könnten wir Typklassen verwenden, die auf dem Konfigurationstyp basieren). Für die Zwecke dieses Beitrags verwenden wir das Kuchenmuster. Wir werden den Dienst mithilfe einer Klasse darstellen cats.Resource, Weil Diese Klasse bietet bereits Mittel, um bei Problemen die Freigabe von Ressourcen sicher zu gewährleisten. Um eine Ressource zu erhalten, müssen wir eine Konfiguration und einen vorgefertigten Laufzeitkontext bereitstellen. Die Dienststartfunktion kann wie folgt aussehen:

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

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

wo

  • Config – Konfigurationstyp für diesen Dienst
  • AddressResolver — ein Laufzeitobjekt, mit dem Sie die Adressen anderer Knoten herausfinden können (siehe unten)

und andere Typen aus der Bibliothek cats:

  • F[_] — Art des Effekts (im einfachsten Fall F[A] könnte einfach eine Funktion sein () => A. In diesem Beitrag werden wir verwenden cats.IO.)
  • Reader[A,B] - mehr oder weniger gleichbedeutend mit Funktion A => B
  • cats.Resource - eine Ressource, die beschafft und freigegeben werden kann
  • Timer — Timer (ermöglicht Ihnen, eine Weile einzuschlafen und Zeitintervalle zu messen)
  • ContextShift - analog ExecutionContext
  • Applicative – eine Effekttypklasse, die es Ihnen ermöglicht, einzelne Effekte zu kombinieren (fast eine Monade). Bei komplexeren Anwendungen scheint die Verwendung besser zu sein Monad/ConcurrentEffect.

Mit dieser Funktionssignatur können wir mehrere 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](()))
  }

(Cm. Quellcode, in dem andere Dienste implementiert sind - Echo-Dienst, Echo-Client
и lebenslange Controller.)

Ein Knoten ist ein Objekt, das mehrere Dienste starten kann (der Start einer Ressourcenkette wird durch das Cake Pattern sichergestellt):

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 den genauen Konfigurationstyp angeben, der für diesen Knoten erforderlich ist. Wenn wir vergessen, einen der für einen bestimmten Dienst erforderlichen Konfigurationstypen anzugeben, tritt ein Kompilierungsfehler auf. Außerdem können wir keinen Knoten starten, es sei denn, wir stellen ein Objekt des entsprechenden Typs mit allen erforderlichen Daten bereit.

Auflösung von Hostnamen

Um eine Verbindung zu einem Remote-Host herzustellen, benötigen wir eine echte IP-Adresse. Es ist möglich, dass die Adresse später als die restliche Konfiguration bekannt wird. Wir brauchen also eine Funktion, die die Knoten-ID einer Adresse zuordnet:

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

Es gibt mehrere Möglichkeiten, diese Funktion zu implementieren:

  1. Wenn uns die Adressen vor der Bereitstellung bekannt werden, können wir damit Scala-Code generieren
    Adressen und führen Sie dann den Build aus. Dadurch werden Tests kompiliert und ausgeführt.
    In diesem Fall ist die Funktion statisch bekannt und kann im Code als Mapping dargestellt werden Map[NodeId, NodeAddress].
  2. In manchen Fällen ist die tatsächliche Adresse erst nach dem Start des Knotens bekannt.
    In diesem Fall können wir einen „Erkennungsdienst“ implementieren, der vor anderen Knoten ausgeführt wird und alle Knoten sich bei diesem Dienst registrieren und die Adressen anderer Knoten anfordern.
  3. Wenn wir etwas ändern können /etc/hosts, dann können Sie vordefinierte Hostnamen verwenden (wie my-project-main-node и echo-backend) und verknüpfen Sie einfach diese Namen
    mit IP-Adressen während der Bereitstellung.

Auf diese Fälle gehen wir in diesem Beitrag nicht näher ein. Für unser
In einem Spielzeugbeispiel haben alle Knoten dieselbe IP-Adresse - 127.0.0.1.

Als nächstes betrachten wir zwei Optionen für ein verteiltes System:

  1. Alle Dienste auf einem Knoten platzieren.
  2. Und Hosten des Echo-Dienstes und des Echo-Clients auf verschiedenen Knoten.

Konfiguration für ein Knoten:

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

Das Objekt implementiert die Konfiguration sowohl des Clients als auch des Servers. Es wird auch eine Time-to-Live-Konfiguration verwendet, sodass nach dem Intervall lifetime Beenden Sie das Programm. (Strg-C funktioniert auch und gibt alle Ressourcen korrekt frei.)

Derselbe Satz von Konfigurations- und Implementierungsmerkmalen kann verwendet werden, um ein System bestehend aus zu erstellen zwei separate Knoten:

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

Wichtig! Beachten Sie, wie die Dienste verknüpft sind. Wir geben einen von einem Knoten implementierten Dienst als Implementierung der Abhängigkeitsmethode eines anderen Knotens an. Der Abhängigkeitstyp wird vom Compiler überprüft, weil enthält den Protokolltyp. Bei der Ausführung enthält die Abhängigkeit die richtige Zielknoten-ID. Dank dieses Schemas geben wir die Portnummer genau einmal an und verweisen garantiert immer auf den richtigen Port.

Implementierung von zwei Systemknoten

Für diese Konfiguration verwenden wir dieselben Service-Implementierungen ohne Änderungen. Der einzige Unterschied besteht darin, dass wir jetzt zwei Objekte haben, die unterschiedliche Sätze von Diensten implementieren:

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

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

Der erste Knoten implementiert den Server und benötigt nur eine Serverkonfiguration. Der zweite Knoten implementiert den Client und verwendet einen anderen Teil der Konfiguration. Außerdem benötigen beide Knoten eine lebenslange Verwaltung. Der Serverknoten läuft auf unbestimmte Zeit, bis er gestoppt wird SIGTERM'om, und der Client-Knoten wird nach einiger Zeit beendet. Cm. Launcher-App.

Allgemeiner Entwicklungsprozess

Sehen wir uns an, wie sich dieser Konfigurationsansatz auf den gesamten Entwicklungsprozess auswirkt.

Die Konfiguration wird zusammen mit dem Rest des Codes kompiliert und ein Artefakt (.jar) generiert. Es erscheint sinnvoll, die Konfiguration in einem separaten Artefakt unterzubringen. Dies liegt daran, dass wir mehrere Konfigurationen basierend auf demselben Code haben können. Auch hier ist es möglich, Artefakte zu generieren, die verschiedenen Konfigurationszweigen entsprechen. Abhängigkeiten von bestimmten Bibliotheksversionen werden zusammen mit der Konfiguration gespeichert, und diese Versionen werden für immer gespeichert, wenn wir uns entscheiden, diese Version der Konfiguration bereitzustellen.

Jede Konfigurationsänderung wird zu einer Codeänderung. Und deshalb jeder
Die Änderung wird durch den normalen Qualitätssicherungsprozess abgedeckt:

Ticket im Bugtracker -> PR -> Review -> mit relevanten Branches zusammenführen ->
Integration -> Bereitstellung

Die wichtigsten Konsequenzen der Implementierung einer kompilierten Konfiguration sind:

  1. Die Konfiguration ist auf allen Knoten des verteilten Systems konsistent. Aufgrund der Tatsache, dass alle Knoten die gleiche Konfiguration aus einer Hand erhalten.

  2. Es ist problematisch, die Konfiguration nur in einem der Knoten zu ändern. Daher ist eine „Konfigurationsdrift“ unwahrscheinlich.

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

  4. Die meisten Konfigurationsänderungen erfolgen im Rahmen des gesamten Entwicklungsprozesses und unterliegen einer Überprüfung.

Benötige ich ein separates Repository zum Speichern der Produktionskonfiguration? Diese Konfiguration kann Passwörter und andere vertrauliche Informationen enthalten, auf die wir den Zugriff beschränken möchten. Vor diesem Hintergrund erscheint es sinnvoll, die endgültige Konfiguration in einem separaten Repository zu speichern. Sie können die Konfiguration in zwei Teile aufteilen – einen mit öffentlich zugänglichen Konfigurationseinstellungen und einen mit eingeschränkten Einstellungen. Dadurch haben die meisten Entwickler Zugriff auf allgemeine Einstellungen. Diese Trennung lässt sich leicht erreichen, indem man Zwischenmerkmale verwendet, die Standardwerte enthalten.

Mögliche Variationen

Versuchen wir, die kompilierte Konfiguration mit einigen gängigen Alternativen zu vergleichen:

  1. Textdatei auf dem Zielcomputer.
  2. Zentralisierter Schlüsselwertspeicher (etcd/zookeeper).
  3. Prozesskomponenten, die ohne Neustart des Prozesses neu konfiguriert/neu gestartet werden können.
  4. Speichern der Konfiguration außerhalb der Artefakt- und Versionskontrolle.

Textdateien bieten erhebliche Flexibilität im Hinblick auf kleine Änderungen. Der Systemadministrator kann sich beim Remote-Knoten anmelden, Änderungen an den entsprechenden Dateien vornehmen und den Dienst neu starten. Für große Systeme ist eine solche Flexibilität jedoch möglicherweise nicht wünschenswert. Die vorgenommenen Änderungen hinterlassen keine Spuren in anderen Systemen. Niemand überprüft die Änderungen. Es ist schwierig festzustellen, wer genau die Änderungen vorgenommen hat und aus welchem ​​Grund. Änderungen werden nicht getestet. Wenn das System verteilt ist, vergisst der Administrator möglicherweise, die entsprechende Änderung auf anderen Knoten vorzunehmen.

(Es sollte auch beachtet werden, dass die Verwendung einer kompilierten Konfiguration die Möglichkeit der zukünftigen Verwendung von Textdateien nicht ausschließt. Es reicht aus, einen Parser und Validator hinzuzufügen, der denselben Typ als Ausgabe erzeugt Config, und Sie können Textdateien verwenden. Daraus folgt sofort, dass die Komplexität eines Systems mit einer kompilierten Konfiguration etwas geringer ist als die Komplexität eines Systems mit Textdateien, weil Textdateien erfordern zusätzlichen Code.)

Ein zentraler Schlüsselwertspeicher ist ein guter Mechanismus zum Verteilen von Metaparametern einer verteilten Anwendung. Wir müssen entscheiden, was Konfigurationsparameter und was nur Daten sind. Lassen Sie uns eine Funktion haben C => A => B, und die Parameter C ändert sich selten und Daten A - oft. In diesem Fall können wir das sagen C - Konfigurationsparameter und A - Daten. Es scheint, dass sich Konfigurationsparameter von Daten dadurch unterscheiden, dass sie sich im Allgemeinen seltener ändern als Daten. Außerdem stammen Daten normalerweise aus einer Quelle (vom Benutzer) und Konfigurationsparameter aus einer anderen (vom Systemadministrator).

Wenn sich selten ändernde Parameter aktualisiert werden müssen, ohne das Programm neu zu starten, kann dies häufig zu einer Komplikation des Programms führen, da wir Parameter irgendwie liefern, speichern, analysieren und überprüfen und falsche Werte verarbeiten müssen. Unter dem Gesichtspunkt der Reduzierung der Komplexität des Programms ist es daher sinnvoll, die Anzahl der Parameter zu reduzieren, die sich während des Programmbetriebs ändern können (oder solche Parameter überhaupt nicht unterstützen).

Für die Zwecke dieses Beitrags werden wir zwischen statischen und dynamischen Parametern unterscheiden. Wenn die Logik des Dienstes eine Änderung der Parameter während des Programmbetriebs erfordert, nennen wir diese Parameter dynamisch. Ansonsten sind die Optionen statisch und können mithilfe der kompilierten Konfiguration konfiguriert werden. Für die dynamische Neukonfiguration benötigen wir möglicherweise einen Mechanismus, um Teile des Programms mit neuen Parametern neu zu starten, ähnlich wie Betriebssystemprozesse neu gestartet werden. (Unserer Meinung nach ist es ratsam, eine Neukonfiguration in Echtzeit zu vermeiden, da dies die Komplexität des Systems erhöht. Wenn möglich, ist es besser, die Standardfunktionen des Betriebssystems für den Neustart von Prozessen zu verwenden.)

Ein wichtiger Aspekt bei der Verwendung der statischen Konfiguration, der Menschen dazu veranlasst, eine dynamische Neukonfiguration in Betracht zu ziehen, ist die Zeit, die das System nach einer Konfigurationsaktualisierung für den Neustart benötigt (Ausfallzeit). 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. Das Ausfallzeitproblem ist je nach System unterschiedlich schwerwiegend. In einigen Fällen können Sie einen Neustart zu einem Zeitpunkt planen, an dem die Auslastung minimal ist. Wenn Sie einen kontinuierlichen Service bereitstellen müssen, können Sie dies implementieren Entleeren der AWS ELB-Verbindung. Wenn wir das System neu starten müssen, starten wir gleichzeitig eine parallele Instanz dieses Systems, schalten den Balancer darauf um und warten, bis die alten Verbindungen hergestellt sind. Nachdem alle alten Verbindungen beendet wurden, fahren wir die alte Instanz des Systems herunter.

Betrachten wir nun die Frage der Speicherung der Konfiguration innerhalb oder außerhalb des Artefakts. Wenn wir die Konfiguration in einem Artefakt speichern, hatten wir zumindest die Möglichkeit, die Richtigkeit der Konfiguration während der Zusammenstellung des Artefakts zu überprüfen. Wenn die Konfiguration außerhalb des kontrollierten Artefakts liegt, ist es schwierig nachzuverfolgen, wer Änderungen an dieser Datei vorgenommen hat und warum. Wie wichtig ist es? Unserer Meinung nach ist es für viele Produktionssysteme wichtig, eine stabile und qualitativ hochwertige Konfiguration zu haben.

Anhand der Version eines Artefakts können Sie bestimmen, wann es erstellt wurde, welche Werte es enthält, welche Funktionen aktiviert/deaktiviert sind und wer für etwaige Änderungen in der Konfiguration verantwortlich ist. Natürlich ist das Speichern der Konfiguration in einem Artefakt mit einigem Aufwand verbunden, sodass Sie eine fundierte Entscheidung treffen müssen.

Dafür und dagegen

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

Vorteile

Nachfolgend finden Sie eine Liste der Hauptmerkmale einer kompilierten verteilten Systemkonfiguration:

  1. Statische Konfigurationsprüfung. So können Sie sicher sein
    Die Konfiguration ist korrekt.
  2. Umfangreiche Konfigurationssprache. Normalerweise beschränken sich andere Konfigurationsmethoden höchstens auf die Ersetzung von Zeichenfolgenvariablen. Bei Verwendung von Scala stehen zahlreiche Sprachfunktionen zur Verfügung, um Ihre Konfiguration zu verbessern. Zum Beispiel können wir verwenden
    Merkmale für Standardwerte verwenden, Objekte zum Gruppieren von Parametern verwenden, können wir auf Werte verweisen, die nur einmal (DRY) im umschließenden Bereich deklariert wurden. Sie können beliebige Klassen direkt innerhalb der Konfiguration instanziieren (Seq, Map, benutzerdefinierte Klassen).
  3. DSL. Scala verfügt über eine Reihe von Sprachfunktionen, die die Erstellung einer DSL erleichtern. Es ist möglich, diese Funktionen zu nutzen und eine für die Zielgruppe der Benutzer komfortablere Konfigurationssprache zu implementieren, sodass die Konfiguration zumindest für Domänenexperten lesbar ist. Spezialisten können beispielsweise am Konfigurationsüberprüfungsprozess teilnehmen.
  4. Integrität und Synchronität zwischen Knoten. Einer der Vorteile der Speicherung der Konfiguration eines gesamten verteilten Systems an einem einzigen Punkt besteht darin, dass alle Werte genau einmal deklariert und dann überall dort wiederverwendet werden, wo sie benötigt werden. Durch die Verwendung von Phantomtypen zum Deklarieren von Ports wird sichergestellt, dass Knoten in allen korrekten Systemkonfigurationen kompatible Protokolle verwenden. Durch explizite obligatorische Abhängigkeiten zwischen Knoten wird sichergestellt, dass alle Dienste verbunden sind.
  5. Hochwertige Änderungen. Durch Änderungen an der Konfiguration über einen gemeinsamen Entwicklungsprozess ist es möglich, auch bei der Konfiguration hohe Qualitätsstandards zu erreichen.
  6. Gleichzeitiges Konfigurationsupdate. Durch die automatische Systembereitstellung nach Konfigurationsänderungen wird sichergestellt, dass alle Knoten aktualisiert werden.
  7. Vereinfachung der Anwendung. Die Anwendung benötigt kein Parsing, keine Konfigurationsprüfung oder die Verarbeitung falscher Werte. Dies reduziert die Komplexität der Anwendung. (Ein Teil der in unserem Beispiel beobachteten Konfigurationskomplexität ist kein Attribut der kompilierten Konfiguration, sondern lediglich eine bewusste Entscheidung, die auf dem Wunsch beruht, mehr Typsicherheit bereitzustellen.) Es ist ganz einfach, zur üblichen Konfiguration zurückzukehren – implementieren Sie einfach das Fehlende Teile. Daher können Sie beispielsweise mit einer kompilierten Konfiguration beginnen und die Implementierung unnötiger Teile auf den Zeitpunkt verschieben, an dem sie wirklich benötigt wird.
  8. Verifizierte Konfiguration. Da Konfigurationsänderungen dem üblichen Schicksal aller anderen Änderungen folgen, ist die Ausgabe, die wir erhalten, ein Artefakt mit einer eindeutigen Version. Dadurch können wir beispielsweise bei Bedarf zu einer früheren Version der Konfiguration zurückkehren. Wir können sogar die Konfiguration von vor einem Jahr verwenden und das System wird genauso funktionieren. Eine stabile Konfiguration verbessert die Vorhersagbarkeit und Zuverlässigkeit eines verteilten Systems. Da die Konfiguration in der Kompilierungsphase festgelegt wird, ist es ziemlich schwierig, sie in der Produktion zu fälschen.
  9. Modularität. Das vorgeschlagene Framework ist modular aufgebaut und die Module können auf unterschiedliche Weise kombiniert werden, um unterschiedliche Systeme zu erstellen. Insbesondere können Sie das System so konfigurieren, dass es in einer Ausführungsform auf einem einzelnen Knoten und in einer anderen auf mehreren Knoten läuft. Sie können mehrere Konfigurationen für Produktionsinstanzen des Systems erstellen.
  10. Testen. Indem Sie einzelne Dienste durch Scheinobjekte ersetzen, können Sie mehrere Versionen des Systems erhalten, die zum Testen geeignet sind.
  11. Integrationstests. Dank einer einzigen Konfiguration für das gesamte verteilte System können alle Komponenten im Rahmen von Integrationstests in einer kontrollierten Umgebung ausgeführt werden. Es ist beispielsweise einfach, eine Situation zu emulieren, in der einige Knoten zugänglich werden.

Nachteile und Einschränkungen

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

  1. Statische Konfiguration. Manchmal ist es notwendig, die Konfiguration in der Produktion schnell zu korrigieren und dabei alle Schutzmechanismen zu umgehen. Bei diesem Ansatz kann es schwieriger sein. Zumindest sind weiterhin Kompilierung und automatische Bereitstellung erforderlich. Dies ist sowohl ein nützliches Merkmal des Ansatzes als auch in manchen Fällen ein Nachteil.
  2. Konfigurationsgenerierung. Falls die Konfigurationsdatei von einem automatischen Tool generiert wird, sind möglicherweise zusätzliche Anstrengungen erforderlich, um das Build-Skript zu integrieren.
  3. Werkzeuge. Derzeit basieren Dienstprogramme und Techniken zur Arbeit mit der Konfiguration auf Textdateien. Nicht alle dieser Dienstprogramme/Techniken sind in einer kompilierten Konfiguration verfügbar.
  4. Es bedarf einer Einstellungsänderung. Entwickler und DevOps sind an Textdateien gewöhnt. Die bloße Idee, eine Konfiguration zu kompilieren, kann etwas unerwartet und ungewöhnlich sein und zur Ablehnung führen.
  5. Es ist ein qualitativ hochwertiger Entwicklungsprozess erforderlich. Um die kompilierte Konfiguration komfortabel nutzen zu können, ist eine vollständige Automatisierung des Erstellungs- und Bereitstellungsprozesses der Anwendung (CI/CD) erforderlich. Sonst wird es ziemlich unpraktisch.

Lassen Sie uns auch auf eine Reihe von Einschränkungen des betrachteten Beispiels eingehen, die nicht mit der Idee einer kompilierten Konfiguration zusammenhängen:

  1. Wenn wir unnötige Konfigurationsinformationen bereitstellen, die vom Knoten nicht verwendet werden, hilft uns der Compiler nicht dabei, die fehlende Implementierung zu erkennen. Dieses Problem lässt sich lösen, indem man auf das Kuchenmuster verzichtet und starrere Typen verwendet, zum Beispiel HList oder algebraische Datentypen (Fallklassen) zur Darstellung der Konfiguration.
  2. Es gibt Zeilen in der Konfigurationsdatei, die nichts mit der Konfiguration selbst zu tun haben: (package, import,Objektdeklarationen; override defist für Parameter, die Standardwerte haben). Dies kann teilweise vermieden werden, wenn Sie Ihr eigenes DSL implementieren. Darüber hinaus erlegen auch andere Konfigurationsarten (z. B. XML) bestimmte Einschränkungen der Dateistruktur auf.
  3. Für die Zwecke dieses Beitrags betrachten wir nicht die dynamische Neukonfiguration eines Clusters ähnlicher Knoten.

Abschluss

In diesem Beitrag haben wir die Idee untersucht, die Konfiguration im Quellcode mithilfe der erweiterten Funktionen des Scala-Typsystems darzustellen. Dieser Ansatz kann in verschiedenen Anwendungen als Ersatz für herkömmliche Konfigurationsmethoden auf Basis von XML- oder Textdateien verwendet werden. Auch wenn unser Beispiel in Scala implementiert ist, lassen sich die gleichen Ideen auf andere kompilierte Sprachen (wie Kotlin, C#, Swift, ...) übertragen. Sie können diesen Ansatz in einem der folgenden Projekte ausprobieren. Wenn er nicht funktioniert, fahren Sie mit der Textdatei fort und fügen Sie die fehlenden Teile hinzu.

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

Der betrachtete Ansatz kann erweitert werden:

  1. Sie können Makros verwenden, um Überprüfungen zur Kompilierungszeit durchzuführen.
  2. Sie können eine DSL implementieren, um die Konfiguration so darzustellen, dass sie für Endbenutzer zugänglich ist.
  3. Sie können ein dynamisches Ressourcenmanagement mit automatischer Konfigurationsanpassung implementieren. Um beispielsweise die Anzahl der Knoten in einem Cluster zu ändern, muss (1) jeder Knoten eine leicht unterschiedliche Konfiguration erhalten; (2) Der Cluster-Manager hat Informationen über neue Knoten erhalten.

Danksagung

Ich möchte Andrei Saksonov, Pavel Popov und Anton Nekhaev für ihre konstruktive Kritik am Artikelentwurf danken.

Source: habr.com

Kommentar hinzufügen