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

()
Einführung
Der Aufbau eines zuverlässigen verteilten Systems setzt voraus, dass an allen Knoten eine korrekte Konfiguration verwendet wird, die mit anderen Knoten synchronisiert ist. In der Regel werden DevOps-Technologien (Terraform, Ansible oder ähnliche) eingesetzt, um Konfigurationsdateien automatisch zu generieren (oft individuell für jeden Knoten). Zudem möchten wir sicherstellen, dass an allen interagierenden Knoten identische Protokolle (einschließlich derselben Version) verwendet werden. Andernfalls wird unsere verteilte Systemarchitektur Inkompatibilitäten aufweisen. In der JVM-Welt ist eine der Konsequenzen dieser Anforderung die Notwendigkeit, überall dieselbe Version der Bibliothek zu verwenden, die die Protokollnachrichten enthält.
Wie sieht es mit dem Testen eines verteilten Systems aus? Natürlich gehen wir davon aus, dass für alle Komponenten Unit-Tests vorgesehen sind, bevor wir zum Integrationstest übergehen. (Um die Testergebnisse auf die Laufzeit zu extrapolieren, müssen wir auch sicherstellen, dass während der Testphase und in der Laufzeit das gleiche Set an Bibliotheken verwendet wird.)
Bei der Arbeit mit Integrationstests ist es oft einfacher, überall denselben Classpath auf allen Knoten zu verwenden. Es bleibt uns nur noch sicherzustellen, dass derselbe Classpath auch zur Laufzeit eingesetzt wird. (Obwohl es durchaus möglich ist, verschiedene Knoten mit unterschiedlichen Classpaths zu starten, führt dies zu einer Komplexität in der gesamten Konfiguration und erschwert das Deployment sowie die Integrationstests.) In diesem Beitrag gehen wir davon aus, dass auf allen Knoten derselbe Classpath verwendet wird.
Die Konfiguration entwickelt sich parallel zur Anwendung. Zur Identifizierung der verschiedenen Entwicklungsphasen von Programmen nutzen wir Versionen. Es erscheint sinnvoll, auch unterschiedliche Versionen von Konfigurationen zu identifizieren. Zudem sollte die Konfiguration in ein Versionskontrollsystem eingeordnet werden. Wenn in der Produktionsumgebung nur eine einzige Konfiguration existiert, können wir einfach die Versionsnummer nutzen. Wenn jedoch mehrere Instanzen in der Produktion verwendet werden, benötigen wir mehrere.
Konfigurationszweige und ein zusätzliches Label neben der Version (z. B. der Name des Zweigs). Dadurch können wir die genaue Konfiguration eindeutig identifizieren. Jede Konfigurations-ID entspricht eindeutig einer bestimmten Kombination von verteilten Knoten, Ports, externen Ressourcen und Bibliotheksversionen. In diesem Beitrag gehen wir davon aus, dass es nur einen Zweig gibt und wir die Konfiguration auf die übliche Weise mit drei durch Punkte getrennten Zahlen (1.2.3) identifizieren können.
In modernen Umgebungen werden Konfigurationsdateien manuell nur selten erstellt. Häufig werden sie während der Bereitstellung generiert und danach nicht mehr verändert (um ). Es stellt sich die berechtigte Frage, warum wir immer noch ein Textformat zur Speicherung der Konfiguration verwenden? Eine durchaus praktikable Alternative wäre, normalen Code für die Konfiguration zu nutzen und von den Vorteilen der Kompilierungsprüfungen zu profitieren.
In diesem Beitrag untersuchen wir die Idee, die Konfiguration innerhalb eines kompilierten Artefakts darzustellen.
Kompilierte Konfiguration
In diesem Abschnitt wird ein Beispiel für eine statisch kompilierbare Konfiguration dargestellt. Es werden zwei einfache Dienste implementiert – ein Echo-Dienst und der Client dieses Echo-Dienstes. Aus diesen beiden Diensten entstehen zwei Varianten des Systems. In der einen Variante befinden sich beide Dienste auf einem Knoten, in der anderen Variante – auf verschiedenen Knoten.
In der Regel besteht ein verteiltes System aus mehreren Knoten. Knoten können mithilfe von Werten eines bestimmten Typs identifiziert werden. NodeId:
sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeIdoder
case class NodeId(hostName: String)oder sogar
object Singleton
type NodeId = Singleton.typeKnoten übernehmen unterschiedliche Rollen, auf ihnen laufen Dienste, und zwischen ihnen können TCP/HTTP-Verbindungen hergestellt werden.
Um eine TCP-Verbindung zu beschreiben, benötigen wir mindestens eine Portnummer. Darüber hinaus möchten wir das Protokoll angeben, das an diesem Port unterstützt wird, um sicherzustellen, dass sowohl der Client als auch der Server dasselbe Protokoll verwenden. Wir werden die Verbindung mithilfe dieser Klasse beschreiben:
case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])wobei Port – einfach eine ganze Zahl Int mit der Angabe eines zulässigen Wertebereichs:
type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]Verfeinerte Typen
Siehe Bibliothek und . Kurz gesagt, die Bibliothek ermöglicht das Hinzufügen von Einschränkungen zu Typen, die zur Kompilierungszeit überprüft werden. In diesem Fall sind die zulässigen Werte für die Portnummer ganze 16-Bit-Zahlen. Für die kompilierte Konfiguration ist die Verwendung der Bibliothek refined nicht zwingend erforderlich, verbessert jedoch die Möglichkeiten des Compilers bei der Überprüfung der Konfiguration.
Für HTTP (REST) Protokolle benötigen wir neben der Portnummer möglicherweise auch den Pfad zum Service:
type UrlPathPrefix = Refined[String, MatchesRegex[W.`"[a-zA-Z_0-9/]*"`.T]]
case class PortWithPrefix[Protocol](portNumber: PortNumber, pathPrefix: UrlPathPrefix)Phantomtypen
Zur Identifizierung des Protokolls zur Kompilierungszeit verwenden wir einen Typparameter, der innerhalb der Klasse nicht verwendet wird. Diese Entscheidung basiert darauf, dass wir zur Laufzeit kein Exemplar des Protokolls verwenden, wir jedoch möchten, dass der Compiler die Kompatibilität der Protokolle überprüft. Durch die Angabe des Protokolls können wir keinen inkompatiblen Service als Abhängigkeit übergeben.
Eines der verbreiteten Protokolle ist das REST API mit JSON-Serialisierung:
sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]wobei RequestMessage — Typ der Anfrage, ResponseMessage — Typ der Antwort.
Selbstverständlich können auch andere Protokollbeschreibungen verwendet werden, die die erforderliche Genauigkeit bieten.
Für die Zwecke dieses Beitrags werden wir eine vereinfachte Version des Protokolls verwenden:
sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]Hier stellt die Anfrage eine Zeichenkette dar, die an die URL angehängt wird, und die Antwort ist die zurückgegebene Zeichenkette im Körper der HTTP-Antwort.
Die Konfiguration des Dienstes wird durch den Dienstnamen, die Ports und Abhängigkeiten dargestellt. Diese Elemente können in Scala auf verschiedene Weise dargestellt werden (z. B. HList-en, algebraischen Datentypen). Für die Zwecke dieses Beitrags werden wir das Cake Pattern verwenden und die Module durch trait‘s darstellen. (Das Cake Pattern ist kein zwingendes Element des beschriebenen Ansatzes. Es ist lediglich eine der möglichen Implementierungen.)
Abhängigkeiten zwischen Diensten können als Methoden dargestellt werden, die Ports zurückgeben EndPoint‘e anderer 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-Service einzurichten, benötigen Sie lediglich die Portnummer und die Angabe, dass dieser Port das Echo-Protokoll unterstützt. Es wäre auch möglich, keinen spezifischen Port anzugeben, da Traits es erlauben, Methoden ohne Implementierung zu deklarieren (abstrakte Methoden). In diesem Fall würde der Compiler bei der Erstellung einer konkreten Konfiguration von uns verlangen, die Implementierung der abstrakten Methode und die Portnummer bereitzustellen. Da wir die Methode bereits implementiert haben, müssen wir bei der Erstellung einer konkreten Konfiguration keinen weiteren Port angeben. Der Standardwert wird verwendet.
In der Konfiguration des Clients deklarieren wir die Abhängigkeit vom Echo-Service:
trait EchoClientConfig[A] {
def testMessage: String = "test"
def pollInterval: FiniteDuration
def echoServiceDependency: HttpSimpleGetEndPoint[_, EchoProtocol[A]]
}Die Abhängigkeit hat den gleichen Typ wie der exportierte Service echoService. Insbesondere im Echo-Client verlangen wir dasselbe Protokoll. Daher können wir uns bei der Verbindung zweier Services sicher sein, dass alles korrekt funktioniert.
Implementierung der Services
Für den Start und die Beendigung des Dienstes ist eine Funktion erforderlich. (Die Möglichkeit, den Dienst zu stoppen, ist für Tests von entscheidender Bedeutung.) Es gibt mehrere Varianten, eine solche Funktion zu implementieren (zum Beispiel könnten wir Typklassen basierend auf dem Konfigurationstyp verwenden). In diesem Beitrag verwenden wir das Cake Pattern. Wir werden den Dienst mithilfe einer Klasse darstellen. cats.Resource, da in dieser Klasse bereits Mittel zur sicheren und garantierten Freigabe von Ressourcen im Falle von Problemen vorgesehen sind. Um auf eine Ressource zuzugreifen, müssen wir die Konfiguration und den vorbereiteten Runtime-Kontext bereitstellen. Die Funktion zum Starten des Dienstes könnte wie folgt aussehen:
type ResourceReader[F[_], Config, A] = Reader[Config, Resource[F, A]]
trait ServiceImpl[F[_]] {
type Config
def resource(
implicit
resolver: AddressResolver[F],
timer: Timer[F],
contextShift: ContextShift[F],
ec: ExecutionContext,
applicative: Applicative[F]
): ResourceReader[F, Config, Unit]
}wobei
Konfiguration— Konfigurationstyp für diesen Dienst.AddressResolver— Objekt zur Laufzeit, das es ermöglicht, die Adressen anderer Knoten zu erfahren (siehe unten).
und weitere Typen aus der Bibliothek cats:
F[_]— Typ des Effekts (im einfachsten FallF[A]kann einfach eine Funktion sein() => A. In diesem Beitrag verwenden wircats.IO.)Reader[A,B]— mehr oder weniger ein Synonym für eine FunktionA => Bcats.Resource— eine Ressource, die angefordert und wieder freigegeben werden kannTimer— Timer (ermöglicht es, für eine bestimmte Zeit zu schlafen und Zeitintervalle zu messen)ContextShift— ÄquivalentExecutionContextApplicative— eine Effekttypklasse, die es ermöglicht, einzelne Effekte zu kombinieren (fast eine Monade). In komplexeren Anwendungen ist es offensichtlich besser,Monad/ConcurrentEffect.
Mit dieser Funktionssignatur können wir mehrere Dienste implementieren. Zum Beispiel einen Dienst, der nichts tut:
trait ZeroServiceImpl[F[_]] extends ServiceImpl[F] {
type Config Resource.pure[F, Unit](()))
}(Siehe , in dem andere Dienste implementiert sind — ,
und .)
Ein Knoten ist ein Objekt, das mehrere Dienste starten kann (die Ausführung der Ressourcenfolge 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 den genauen Konfigurationstyp angeben, der für diesen Knoten erforderlich ist. Wenn wir vergessen, einen der für einen bestimmten Dienst erforderlichen Konfigurationstyp anzugeben, wird ein Kompilierungsfehler auftreten. Außerdem können wir den Knoten nicht starten, wenn wir kein Objekt bereitstellen, das vom passenden Typ mit allen erforderlichen Daten ist.
Auflösungen von Knoten-Namen
Um eine Verbindung zu einem Remote-Knoten herzustellen, benötigen wir eine reale IP-Adresse. Es ist durchaus möglich, dass die Adresse später bekannt wird als die anderen Teile der Konfiguration. Daher benötigen wir eine Funktion, die die Knoten-ID in eine Adresse umwandelt:
case class NodeAddress[NodeId](host: Uri.Host)
trait AddressResolver[F[_]] {
def resolve[NodeId](nodeId: NodeId): F[NodeAddress[NodeId]]
}Es können mehrere Implementierungsansätze für eine solche Funktion vorgeschlagen werden:
- Wenn uns die Adressen vor der Bereitstellung bekannt sind, können wir den Scala-Code mit
Adressen generieren und dann die Kompilierung starten. Dabei erfolgt die Kompilierung und die Tests werden ausgeführt.
In diesem Fall wird die Funktion statisch bekannt sein und kann im Code als Zuordnung dargestellt werdenMap[NodeId, NodeAddress]. - In manchen Fällen wird die tatsächliche Adresse erst nach dem Start des Knotens bekannt.
In diesem Fall können wir einen "Entdeckungsdienst" (Discovery) implementieren, der vor den anderen Knoten gestartet wird. Alle Knoten werden sich bei diesem Dienst registrieren und die Adressen der anderen Knoten anfordern. - Wenn wir modifizieren können
/etc/hosts, können vordefinierte Hostnamen verwendet werden (wie zum Beispielmy-project-main-nodeundecho-backend), und diese Namen einfach während der Bereitstellung mit IP-Adressen verknüpfen.
Im Rahmen dieses Beitrags werden wir diese Fälle nicht näher betrachten. Für unser
Spielbeispiel werden alle Knoten eine IP-Adresse haben —
Nun betrachten wir zwei Varianten eines verteilten Systems: 127.0.0.1.
Alle Dienste auf einem Knoten.
- Und die Platzierung des Echo-Dienstes und des Echo-Clients auf verschiedenen Knoten.
- Konfiguration für
einen Knoten. :
Конфигурация для одного узла
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.
}Das Objekt implementiert sowohl die Konfiguration als auch den Client und Server. Außerdem wird eine Lebensdauerkonfiguration verwendet, um nach Ablauf des Intervalls lebensdauer die Programmarbeit zu beenden. (Strg-C funktioniert ebenfalls und gibt alle Ressourcen korrekt frei.)
Das gleiche Set von Traits für Konfiguration und Implementierungen kann verwendet werden, um ein System zu erstellen, das aus :
Konfiguration für zwei Knoten
object NodeServerConfig extends EchoConfig[String] with SigTermLifecycleConfig
{
type NodeId = NodeIdImpl
def nodeId = NodeServer
override def portNumber: PortNumber = 8080
}
object NodeClientConfig extends EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
// NB! Abhä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"
}Wichtig! Achten Sie darauf, wie die Bindung von Diensten erfolgt. Wir geben den Dienst an, der von einem Knoten als Implementierung der Abhängigkeit eines anderen Knotens bereitgestellt wird. Der Typ der Abhängigkeit wird vom Compiler überprüft, da er den Protokolltyp enthält. Bei der Ausführung enthält die Abhängigkeit die korrekte ID des Zielknotens. Durch dieses Schema geben wir die Portnummer genau einmal an und verweisen stets garantiert auf den richtigen Port.
Implementierung von zwei Knoten im System
Für diese Konfiguration verwenden wir dieselben Dienstimplementierungen ohne Änderungen. Der einzige Unterschied besteht darin, dass wir jetzt zwei Objekte haben, die verschiedene Dienstsets 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 einen Server und benötigt lediglich eine Serverkonfiguration. Der zweite Knoten implementiert einen Client und verwendet einen anderen Teil der Konfiguration. Zudem benötigen beide Knoten eine Lebenszyklusverwaltung. Der Serverknoten läuft unbegrenzt, bis er gestoppt wird. SIGTERMwährend der Clientknoten nach einer bestimmten Zeit beendet wird. Siehe. .
Allgemeiner Entwicklungsprozess
Schauen wir uns an, wie sich dieser Ansatz zur Konfiguration auf den gesamten Entwicklungsprozess auswirkt.
Die Konfiguration wird zusammen mit dem restlichen Code kompiliert und ein Artefakt (.jar) wird generiert. Es macht offenbar Sinn, die Konfiguration in ein separates Artefakt zu packen, da wir mehrere Konfigurationen basierend auf demselben Code haben können. Darüber hinaus können Artefakte generiert werden, die verschiedenen Konfigurationszweigen entsprechen. Zusammen mit der Konfiguration werden die Abhängigkeiten von bestimmten Bibliotheksversionen gespeichert, und diese Versionen werden für immer gespeichert, wann immer wir uns entscheiden, diese Version der Konfiguration bereitzustellen.
Jede Änderung an der Konfiguration wird zu einer Änderung des Codes. Daher wird jede solche
Änderung durch den üblichen Qualitätssicherungsprozess abgedeckt:
Ticket im Bugtracker -> PR -> Review -> Zusammenführung mit den entsprechenden Zweigen ->
Integration -> Bereitstellung
Wesentliche Auswirkungen der Implementierung einer kompilierbaren Konfiguration:
Die Konfiguration wird auf allen Knoten des verteilten Systems konsistent sein, da alle Knoten dieselbe Konfiguration aus einer einheitlichen Quelle erhalten.
Es ist problematisch, die Konfiguration nur in einem der Knoten zu ändern. Daher ist eine "Konfigurationsdrift" unwahrscheinlich.
Es wird schwieriger, kleinere Änderungen an der Konfiguration vorzunehmen.
Die meisten Konfigurationsänderungen erfolgen im Rahmen des allgemeinen Entwicklungsprozesses und werden einer Überprüfung unterzogen.
Brauchen wir ein separates Repository zur Speicherung der Produktionskonfiguration? In einer solchen Konfiguration könnten Passwörter und andere geheime Informationen enthalten sein, auf die wir den Zugang einschränken möchten. Daher scheint es sinnvoll zu sein, die endgültige Konfiguration in einem separaten Repository zu speichern. Die Konfiguration kann in zwei Teile aufgeteilt werden: einen, der öffentliche Konfigurationsparameter enthält, und einen anderen, der Parameter mit eingeschränktem Zugang beinhaltet. Dies ermöglicht den meisten Entwicklern den Zugriff auf die öffentlichen Parameter. Eine solche Trennung ist leicht zu erreichen, indem Zwischen-Traits verwendet werden, die Standardwerte enthalten.
Mögliche Variationen
Lassen Sie uns die kompilierte Konfiguration mit einigen gängigen Alternativen vergleichen:
- Textdatei auf dem Zielsystem.
- Zentralisiertes Schlüssel-Wert-Speicher (
die Unterstützung für die Version etcd2 eingestellt./zookeeper). - Komponenten des Prozesses, die umkonfiguriert / neu gestartet werden können, ohne den Prozess neu zu starten.
- Speichern der Konfiguration außerhalb des Artefakts und der Versionskontrolle.
Textdateien bieten erhebliche Flexibilität in Bezug auf kleine Änderungen. Der Systemadministrator kann auf einen remote Knoten zugreifen, Änderungen an den entsprechenden Dateien vornehmen und den Dienst neu starten. Für große Systeme kann jedoch eine solche Flexibilität unerwünscht sein. Es bleiben keine Spuren der vorgenommenen Änderungen in anderen Systemen. Es gibt niemanden, der die Änderungen überprüft. Es ist schwierig festzustellen, wer genau die Änderungen vorgenommen hat und warum. Die Änderungen werden nicht getestet. Wenn das System verteilt ist, kann der Administrator vergessen, die entsprechenden Änderungen auf anderen Knoten vorzunehmen.
(Es ist auch zu beachten, dass die Verwendung einer kompilierbaren Konfiguration die zukünftige Nutzung von Textdateien nicht ausschließt. Es reicht aus, einen Parser und Validator hinzuzufügen, die den gleichen Typ ausgeben. Konfiguration, und es können Textdateien verwendet werden. Daraus folgt, dass die Komplexität eines Systems mit kompilierten Konfigurationen etwas geringer ist als die eines Systems, das Textdateien verwendet, da für Textdateien zusätzlicher Code erforderlich ist.)
Ein zentrales Schlüssel-Wert-Speicher ist ein gutes Mechanismus zur Verteilung von Meta-Parametern einer verteilten Anwendung. Wir müssen klären, was Konfigurationsparameter sind und was einfach Daten sind. Nehmen wir an, wir haben eine Funktion C => A => B, wobei die Parameter C selten geändert werden, während die Daten A — häufig sind. In diesem Fall können wir sagen, dass C — Konfigurationsparameter sind und A — Daten. Es scheint, dass sich Konfigurationsparameter von Daten dadurch unterscheiden, dass sie in der Regel seltener geändert werden als Daten. Außerdem stammen Daten normalerweise aus einer Quelle (vom Benutzer), während Konfigurationsparameter aus einer anderen Quelle (vom Systemadministrator) stammen.
Wenn selten änderbare Parameter ohne Neustart des Programms aktualisiert werden müssen, kann dies oft zu einer Komplexitätssteigerung des Programms führen. Denn es wird notwendig sein, die Parameter zu übermitteln, zu speichern, zu parsen und zu überprüfen sowie ungültige Werte zu verarbeiten. Aus der Sicht der Komplexitätsreduzierung des Programms macht es daher Sinn, die Anzahl der Parameter zu verringern, die während des Programmablaufs geändert werden können (oder solche Parameter überhaupt nicht zu unterstützen).
In diesem Beitrag werden wir zwischen statischen und dynamischen Parametern unterscheiden. Wenn die Logik eines Dienstes erfordert, dass die Parameter während der Ausführung des Programms geändert werden, bezeichnen wir diese als dynamische Parameter. Andernfalls sind die Parameter statisch und können mithilfe einer kompilierten Konfiguration eingestellt werden. Für die dynamische Rekonfiguration könnte ein Mechanismus erforderlich sein, um Teile des Programms mit neuen Parametern neu zu starten, ähnlich wie Prozesse im Betriebssystem neu gestartet werden. (Es ist unserer Meinung nach wünschenswert, die Rekonfiguration in Echtzeit zu vermeiden, da dadurch die Komplexität des Systems steigt. Wenn möglich, sollte man die standardmäßigen Funktionalitäten des Betriebssystems zum Neustart von Prozessen nutzen.)
Ein wichtiger Aspekt der Nutzung einer statischen Konfiguration, der die Menschen dazu bringt, dynamische Re-Konfigurationen in Betracht zu ziehen, ist die Zeit, die das System nach einer Aktualisierung der Konfiguration benötigt, um neu zu starten (Downtime). Tatsächlich müssen wir, wenn Änderungen an der statischen Konfiguration erforderlich sind, das System neu starten, damit die neuen Werte wirksam werden. Das Problem der Downtime hat für verschiedene Systeme unterschiedliche Schweregrade. In einigen Fällen kann ein Neustart zu einem Zeitpunkt geplant werden, an dem die Last minimal ist. Wenn jedoch ein kontinuierlicher Service gewährleistet werden muss, kann man . Wenn wir das System neu starten müssen, starten wir eine parallele Instanz dieses Systems, schalten den Lastenausgleich darauf um und warten, bis alle alten Verbindungen abgeschlossen sind. Sobald alle alten Verbindungen beendet sind, schalten wir die alte Instanz des Systems ab.
Betrachten wir nun die Frage, ob die Konfiguration innerhalb oder außerhalb des Artefakts gespeichert werden sollte. Wenn wir die Konfiguration innerhalb des Artefakts speichern, haben wir zumindest während des Erstellungsprozesses die Möglichkeit, die Richtigkeit der Konfiguration zu überprüfen. Befindet sich die Konfiguration jedoch außerhalb des kontrollierten Artefakts, ist es schwierig nachzuvollziehen, wer und warum Änderungen an dieser Datei vorgenommen hat. Wie wichtig ist das? Unserer Meinung nach ist es für viele Produktionssysteme entscheidend, eine stabile und qualitativ hochwertige Konfiguration zu haben.
Die Version des Artefakts ermöglicht es, zu bestimmen, wann es erstellt wurde, welche Werte es enthält und welche Funktionen aktiviert/deaktiviert sind sowie wer für Änderungen in der Konfiguration verantwortlich ist. Selbstverständlich erfordert die Speicherung der Konfiguration innerhalb des Artefakts einige Anstrengungen, weshalb eine bewusste Entscheidung getroffen werden sollte.
Für und Wider
Ich möchte auf die Vor- und Nachteile der vorgeschlagenen Technologie eingehen.
Vorteile
Nachfolgend finden Sie eine Liste der wichtigsten Funktionen der kompilierbaren Konfiguration in verteilten Systemen:
- Statische Überprüfung der Konfiguration. Damit kann sichergestellt werden, dass
die Konfiguration korrekt ist. - Eine vielseitige Konfigurationssprache. In der Regel sind andere Konfigurationsmethoden auf maximal das Ersetzen von String-Variablen beschränkt. Mit Scala eröffnet sich ein breites Spektrum an Sprachmöglichkeiten zur Verbesserung der Konfiguration. Zum Beispiel können wir
Traits für Standardwerte verwenden, Parameter in Objekten gruppieren und auf einmal deklarierte (DRY) Werte in ihrem Gültigkeitsbereich verweisen. Es ist möglich, direkt innerhalb der Konfiguration jegliche Klassen zu instanziieren (Seq,Map, benutzerdefinierte Klassen). - DSL. Scala bietet mehrere Sprachfeatures, die das Erstellen von DSL erleichtern. Diese Möglichkeiten können genutzt werden, um eine Konfigurationssprache zu erstellen, die benutzerfreundlicher für die Zielgruppe ist, damit die Konfiguration zumindest für Fachleute lesbar ist. Experten können beispielsweise am Review-Prozess der Konfiguration teilnehmen.
- Integrität und Synchronisierung zwischen den Knoten. Ein Vorteil der Speicherung der Konfiguration eines gesamten verteilten Systems an einem einzigen Ort besteht darin, dass alle Werte genau einmal deklariert und dann überall dort wiederverwendet werden, wo sie benötigt werden. Der Einsatz von phantomartigen Typen zur Deklaration von Ports gewährleistet, dass in allen korrekten Konfigurationen der Knoten kompatible Protokolle verwenden. Das Vorhandensein expliziter notwendiger Abhängigkeiten zwischen den Knoten stellt sicher, dass alle Dienste miteinander verbunden sind.
- Hohe Qualität bei Änderungen. Änderungen an der Konfiguration unter Verwendung eines gemeinsamen Entwicklungsprozesses ermöglicht es, hohe Qualitätsstandards auch für die Konfiguration verfügbar zu machen.
- Gleichzeitige Aktualisierung der Konfiguration. Die automatische Bereitstellung des Systems nach Änderungen an der Konfiguration stellt sicher, dass alle Knoten aktualisiert werden.
- Vereinfachung der Anwendung. Die Anwendung benötigt kein Parsing, keine Konfigurationsprüfung und keine Verarbeitung ungültiger Werte. Dadurch wird die Komplexität der Anwendung verringert. (Ein gewisses Maß an Komplexität in der Konfiguration, das in unserem Beispiel zu beobachten ist, ist keine Eigenschaft der kompilierbaren Konfiguration, sondern vielmehr eine bewusste Entscheidung, die aus dem Wunsch resultiert, eine größere Typsicherheit zu gewährleisten.) Es ist recht einfach, zu einer gewöhnlichen Konfiguration zurückzukehren – man muss lediglich die fehlenden Teile implementieren. Daher kann man beispielsweise mit einer kompilierbaren Konfiguration beginnen und die Implementierung überflüssiger Teile auf den Zeitpunkt verschieben, wenn dies tatsächlich erforderlich ist.
- Versionskonfiguration. Da Konfigurationsänderungen dem üblichen Schicksal aller anderen Änderungen folgen, erhalten wir am Ende ein Artefakt mit einer einzigartigen Version. Dies ermöglicht es uns beispielsweise, im Bedarfsfall zu einer vorherigen Version der Konfiguration zurückzukehren. Wir können sogar die Konfiguration von vor einem Jahr verwenden, und das System wird genau gleich funktionieren. Eine stabile Konfiguration verbessert die Vorhersehbarkeit und Zuverlässigkeit eines verteilten Systems. Da die Konfiguration in der Kompilierungsphase festgelegt wird, ist es ziemlich schwierig, sie im Produktionsumfeld zu manipulieren.
- Modularität. Das vorgeschlagene Framework ist modular, und die Module können in verschiedenen Varianten kombiniert werden, um unterschiedliche Systeme zu erhalten. Insbesondere kann in einer Variante das System zur Ausführung auf einem Knoten konfiguriert werden, während es in einer anderen auf mehreren Knoten läuft. Es können mehrere Konfigurationen für Produktionsinstanzen des Systems erstellt werden.
- Testen. Durch den Austausch einzelner Dienste gegen Mock-Objekte lassen sich mehrere Versionen des Systems generieren, die sich gut für Tests eignen.
- Integrationstests. Die Verfügbarkeit einer einheitlichen Konfiguration für das gesamte verteilte System ermöglicht den Betrieb aller Komponenten in einer kontrollierten Umgebung während der Integrationstests. Situationen, in denen bestimmte Knoten nicht mehr erreichbar sind, lassen sich leicht simulieren.
Nachteile und Einschränkungen
Die compilierte Konfiguration unterscheidet sich von anderen Konfigurationsansätzen und könnte für einige Anwendungen ungeeignet sein. Hier sind einige Nachteile aufgeführt:
- Statische Konfiguration. Manchmal ist es erforderlich, die Konfiguration in der Produktion schnell zu ändern, um alle Schutzmechanismen zu umgehen. Dieser Ansatz kann dies schwieriger machen. In jedem Fall sind Compilation und automatisierte Bereitstellung weiterhin erforderlich. Dies ist sowohl ein nützliches Merkmal des Ansatzes als auch in einigen Fällen ein Nachteil.
- Konfigurationsgenerierung. Wenn die Konfigurationsdatei von einem automatisierten Tool generiert wird, können zusätzliche Anstrengungen zur Integration des Build-Skripts erforderlich sein.
- Werkzeuge. Derzeit basieren die Hilfsprogramme und Methoden, die für die Arbeit mit Konfigurationen vorgesehen sind, auf Textdateien. Nicht alle diese Hilfsprogramme/Methoden werden bei kompilierbaren Konfigurationen verfügbar sein.
- Ein Perspektivwechsel ist erforderlich. Entwickler und DevOps-Teams sind an Textdateien gewöhnt. Die Vorstellung von kompilierbaren Konfigurationen kann etwas unerwartet und ungewohnt sein und auf Skepsis stoßen.
- Ein hochwertiger Entwicklungsprozess ist notwendig. Für eine komfortable Nutzung kompilierbarer Konfigurationen ist eine vollständige Automatisierung des Build- und Deployment-Prozesses (CI/CD) erforderlich. Andernfalls wird es unpraktisch.
Lassen Sie uns auch einige Einschränkungen des betrachteten Beispiels betrachten, die nicht mit der Idee kompilierbarer Konfigurationen zusammenhängen:
- Wenn wir überflüssige Konfigurationsinformationen bereitstellen, die vom Knoten nicht verwendet werden, wird der Compiler uns nicht helfen, die fehlende Implementierung zu erkennen. Dieses Problem kann gelöst werden, indem wir auf das Cake-Muster verzichten und striktere Typen verwenden, zum Beispiel:
HListoder algebraische Datentypen (Case Classes) zur Darstellung von Konfigurationen. - In der Konfigurationsdatei befinden sich Zeilen, die nicht direkt zur Konfiguration gehören: (
Paket,importieren, Objektdeklarationen;override def‘s für Parameter mit Standardwerten). Teilweise lässt sich das vermeiden, indem man eine eigene DSL implementiert. Darüber hinaus bringen auch andere Konfigurationsarten (zum Beispiel XML) bestimmte Einschränkungen für die Struktur der Datei mit sich. - In diesem Beitrag betrachten wir nicht die dynamische Rekonfiguration von ähnlichen Knoten im Cluster.
Fazit
In diesem Beitrag haben wir die Idee behandelt, die Konfiguration im Quellcode unter Verwendung der leistungsstarken Typensystemfunktionen von Scala darzustellen. Dieser Ansatz kann in verschiedenen Anwendungen als Ersatz für traditionelle Konfigurationsmethoden auf Basis von XML- oder Textdateien eingesetzt werden. Obwohl unser Beispiel in Scala umgesetzt wurde, lassen sich die gleichen Ideen auch auf andere kompilierte Sprachen (wie Kotlin, C#, Swift, ...) übertragen. Dieser Ansatz kann in einem der folgenden Projekte getestet werden, und sollte er sich als ungeeignet erweisen, kann man auf Textdateien zurückgreifen und die fehlenden Details hinzufügen.
Natürlich erfordert eine kompilierbare Konfiguration einen qualitativ hochwertigen Entwicklungsprozess. Im Gegenzug wird hohe Qualität und Zuverlässigkeit der Konfigurationen gewährleistet.
Der betrachtete Ansatz kann erweitert werden:
- Es können Makros verwendet werden, um Prüfungen während der Kompilierung durchzuführen.
- Es kann eine DSL implementiert werden, um die Konfiguration in einer für Endbenutzer zugänglichen Form darzustellen.
- Es kann eine dynamische Ressourcenverwaltung mit automatischer Anpassung der Konfiguration realisiert werden. Zum Beispiel muss bei einer Änderung der Anzahl der Knoten im Cluster sichergestellt werden, dass (1) jeder Knoten eine leicht unterschiedliche Konfiguration erhält; (2) der Cluster-Manager Informationen über die neuen Knoten erhält.
Danksagungen
Ich möchte Andrei Saksonov, Pavel Popov und Anton Nekhaev für die konstruktive Kritik des Entwurfs danken.
Quelle: habr.com
