Configurazione compilabile di un sistema distribuito

In questo post vogliamo condividere un modo interessante per gestire la configurazione di un sistema distribuito.
La configurazione è rappresentata direttamente nel linguaggio Scala in modo sicuro dal punto di vista dei tipi. Un'implementazione di esempio è descritta in dettaglio. Vengono discussi vari aspetti della proposta, incluso l'impatto sul processo di sviluppo complessivo.

Configurazione compilabile di un sistema distribuito

(in russo)

Introduzione

Costruire sistemi distribuiti robusti richiede l'uso di configurazioni corrette e coerenti su tutti i nodi. Una soluzione tipica è utilizzare una descrizione di distribuzione testuale (terraform, ansible o simili) e file di configurazione generati automaticamente (spesso dedicati per ciascun nodo/ruolo). Dobbiamo anche assicurarci di utilizzare gli stessi protocolli delle stesse versioni su ciascun nodo comunicante (altrimenti potremmo sperimentare problemi di incompatibilità). Nel mondo JVM, questo significa che almeno la libreria di messaggistica dovrebbe essere della stessa versione su tutti i nodi comunicanti.

E per quanto riguarda il test del sistema? Certamente, dobbiamo avere test unitari per tutti i componenti prima di passare ai test di integrazione. Per poter estrapolare i risultati dei test in fase di esecuzione, dobbiamo assicurarci che le versioni di tutte le librerie siano mantenute identiche sia negli ambienti di runtime che di test.

Quando eseguiamo test di integrazione, è spesso molto più facile avere lo stesso classpath su tutti i nodi. Dobbiamo semplicemente assicurarci che venga utilizzato lo stesso classpath in fase di distribuzione. (È possibile utilizzare classpath diversi su nodi diversi, ma è più difficile rappresentare questa configurazione e distribuirla correttamente.) Quindi, per semplificare le cose, considereremo solo classpath identici su tutti i nodi.

La configurazione tende ad evolversi insieme al software. Di solito utilizziamo versioni per identificare diverse
fasi dell'evoluzione del software. Sembra ragionevole coprire la configurazione nella gestione delle versioni e identificare le diverse configurazioni con alcune etichette. Se c'è solo una configurazione in produzione, possiamo usare una versione singola come identificatore. A volte potremmo avere più ambienti di produzione. E per ogni ambiente potremmo aver bisogno di un ramo separate di configurazione. Quindi le configurazioni potrebbero essere etichettate con ramo e versione per identificare in modo univoco le diverse configurazioni. Ogni etichetta di ramo e versione corrisponde a una singola combinazione di nodi distribuiti, porte, risorse esterne, versioni delle librerie classpath su ciascun nodo. Qui copriremo solo il ramo singolo e identificheremo le configurazioni con una versione decimale a tre componenti (1.2.3), nello stesso modo degli altri artefatti.

Negli ambienti moderni, i file di configurazione non vengono più modificati manualmente. Tipicamente generiamo
file di configurazione al momento del deployment e non li tocchiamo successivamente. Quindi si potrebbe chiedere perché utilizziamo ancora un formato testuale per i file di configurazione? Un'opzione valida è inserire la configurazione all'interno di un'unità di compilazione e beneficiare della convalida della configurazione a tempo di compilazione.

In questo post esamineremo l'idea di mantenere la configurazione nell'artefatto compilato.

Configurazione compilabile

In questa sezione discuteremo un esempio di configurazione statica. Due semplici servizi — servizio echo e il client del servizio echo vengono configurati e implementati. Quindi vengono istanziati due diversi sistemi distribuiti con entrambi i servizi. Uno è per una configurazione a nodo singolo e l'altro per una configurazione a due nodi.

Un tipico sistema distribuito è composto da alcuni nodi. I nodi possono essere identificati utilizzando un tipo:

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

o semplicemente

case class NodeId(hostName: String)

o anche

object Singleton
type NodeId = Singleton.type

Questi nodi svolgono vari ruoli, eseguono alcuni servizi e dovrebbero essere in grado di comunicare con gli altri nodi tramite connessioni TCP/HTTP.

Per la connessione TCP è necessario almeno un numero di porta. Vogliamo anche assicurarci che client e server utilizzino lo stesso protocollo. Per modellare una connessione tra nodi, dichiariamo la seguente classe:

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

dove Porto è solo un Int all'interno dell'intervallo consentito:

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

Tipi raffinati

Vedi raffinato libro. In breve, consente di aggiungere vincoli di tempo di compilazione ad altri tipi. In questo caso Int è consentito avere solo valori a 16 bit che possono rappresentare il numero di porta. Non è necessario utilizzare questa libreria per questo approccio di configurazione. Sembra semplicemente adattarsi molto bene.

Per HTTP (REST) potremmo aver bisogno anche di un percorso del servizio:

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

Tipo fantasma

Per identificare il protocollo durante la compilazione utilizziamo la funzione di Scala di dichiarare un argomento di tipo Protocollo che non viene utilizzato nella classe. È un cosiddetto tipo fantasma. A runtime raramente abbiamo bisogno di un'istanza dell'identificatore di protocollo, ecco perché non lo memorizziamo. Durante la compilazione questo tipo fantasma fornisce una maggiore sicurezza di tipo. Non possiamo passare una porta con un protocollo errato.

Uno dei protocolli più utilizzati è l'API REST con serializzazione Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

dove RequestMessage è il tipo base di messaggi che il client può inviare al server e ResponseMessage è il messaggio di risposta dal server. Naturalmente, possiamo creare altre descrizioni di protocollo che specificano il protocollo di comunicazione con la precisione desiderata.

Ai fini di questo post utilizzeremo una versione semplificata del protocollo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

In questo protocollo, il messaggio di richiesta viene aggiunto all'URL e il messaggio di risposta viene restituito come una stringa semplice.

Una configurazione del servizio può essere descritta dal nome del servizio, da una collezione di porte e da alcune dipendenze. Ci sono diversi modi possibili per rappresentare tutti questi elementi in Scala (ad esempio, HList, tipi di dati algebrici). Per gli scopi di questo post utilizzeremo il Cake Pattern e rappresenteremo pezzi combinabili (moduli) come trait. (Il Cake Pattern non è un requisito per questo approccio di configurazione compilabile. È solo un'implementazione possibile dell'idea.)

Le dipendenze possono essere rappresentate utilizzando il Cake Pattern come endpoint di altri nodi:

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

Il servizio Echo ha solo bisogno di una porta configurata. E dichiariamo che questa porta supporta il protocollo echo. Si noti che non è necessario specificare una porta particolare in questo momento, poiché i trait consentono dichiarazioni di metodi astratti. Se utilizziamo metodi astratti, il compilatore richiederà un'implementazione in un'istanza di configurazione. Qui abbiamo fornito l'implementazione (8081) e sarà utilizzata come valore predefinito se lo saltiamo in una configurazione concreta.

Possiamo dichiarare una dipendenza nella configurazione del client del servizio echo:

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

La dipendenza ha lo stesso tipo del echoService. In particolare, richiede lo stesso protocollo. Pertanto, possiamo essere certi che se colleghiamo queste due dipendenze funzioneranno correttamente.

Implementazione dei servizi

Un servizio ha bisogno di una funzione per avviarsi e spegnersi in modo controllato. (La possibilità di spegnere un servizio è fondamentale per i test.) Ci sono diverse opzioni per specificare una funzione di questo tipo per una data configurazione (ad esempio, potremmo utilizzare classi di tipo). Per questo post utilizzeremo nuovamente il Cake Pattern. Possiamo rappresentare un servizio usando cats.Resource , che già fornisce incapsulamento e rilascio delle risorse. Per acquisire una risorsa, dobbiamo fornire una configurazione e un contesto di runtime. Quindi, la funzione di avvio del servizio potrebbe apparire così:

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

dove

  • Configurazione — tipo di configurazione richiesta da questo avviatore di servizio
  • AddressResolver — un oggetto di runtime che ha la capacità di ottenere indirizzi reali di altri nodi (continua a leggere per i dettagli).

gli altri tipi provengono da cats:

  • F[_] — tipo di effetto (nel caso più semplice F[A] potrebbe essere semplicemente () => A. In questo post utilizzeremo cats.IO.)
  • Reader[A,B] — è più o meno un sinonimo per una funzione A => B
  • cats.Resource — ha modi per acquisire e rilasciare
  • Timer — consente di mettere in pausa/misurare il tempo
  • ContextShift — analogo di ExecutionContext
  • Applicative — involucro di funzioni nell'effetto (quasi un monade) (potremmo eventualmente sostituirlo con qualcos'altro)

Utilizzando questa interfaccia possiamo implementare alcuni servizi. Ad esempio, un servizio che non fa nulla:

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

(Vedi Codice sorgente per altre implementazioni di servizi — servizio echo,
client echo and controllori di vita.)

Un nodo è un singolo oggetto che esegue alcuni servizi (l'avvio di una catena di risorse è abilitato dal Cake Pattern):

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

Nota che nel nodo specifichiamo il tipo esatto di configurazione richiesta per questo nodo. Il compilatore non ci permetterà di costruire l'oggetto (Cake) con un tipo insufficiente, poiché ogni trait di servizio dichiara una restrizione sul Configurazione tipo. Inoltre, non saremo in grado di avviare il nodo senza fornire una configurazione completa.

Risoluzione dell'indirizzo del nodo

Per stabilire una connessione, abbiamo bisogno di un indirizzo host reale per ogni nodo. Potrebbe essere noto più tardi rispetto ad altre parti della configurazione. Pertanto, abbiamo bisogno di un modo per fornire una mappatura tra l'id del nodo e il suo indirizzo effettivo. Questa mappatura è una funzione:

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

Ci sono diversi modi possibili per implementare tale funzione.

  1. Se conosciamo gli indirizzi effettivi prima del deployment, durante l'istanza degli host dei nodi, allora possiamo generare codice Scala con gli indirizzi effettivi e poi eseguire la build (che esegue controlli in fase di compilazione e poi avvia la suite di test di integrazione). In questo caso, la nostra funzione di mappatura è nota staticamente e può essere semplificata in qualcosa come un Map[NodeId, NodeAddress].
  2. A volte otteniamo gli indirizzi effettivi solo in un secondo momento, quando il nodo è effettivamente avviato, o non abbiamo gli indirizzi dei nodi che non sono stati ancora avviati. In questo caso, potremmo avere un servizio di scoperta che viene avviato prima di tutti gli altri nodi e ogni nodo potrebbe pubblicizzare il suo indirizzo in quel servizio e iscriversi alle dipendenze.
  3. Se possiamo modificare /etc/hosts, possiamo utilizzare nomi host predefiniti (come my-project-main-node and echo-backend) e collega semplicemente questo nome all'indirizzo IP al momento del deployment.

In questo post non approfondiamo questi casi in modo più dettagliato. Infatti, nel nostro esempio semplificato, tutti i nodi avranno lo stesso indirizzo IP — 127.0.0.1.

In questo post considereremo due layout di sistemi distribuiti:

  1. Layout a nodo singolo, dove tutti i servizi sono collocati su un unico nodo.
  2. Layout a due nodi, dove il servizio e il client sono su nodi diversi.

La configurazione per un nodo singolo è la seguente:

Configurazione del nodo singolo

object SingleNodeConfig extends EchoConfig[String] 
  with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
  case object Singleton // identificatore del nodo singolo 
  // configurazione del server
  type NodeId = Singleton.type
  def nodeId = Singleton

  /** Specifica del porto del servizio in modo type safe. */
  override def portNumber: PortNumber = 8088

  // configurazione del client

  /** Utilizzeremo il servizio fornito dallo stesso host. */
  def echoServiceDependency = echoService

  override def testMessage: UrlPathElement = "hello"

  def pollInterval: FiniteDuration = 1.second

  // configurazione del controllore del ciclo di vita
  def lifetime: FiniteDuration = 10500.milliseconds // 0.5 secondi aggiuntivi affinché ci siano 10 richieste, non 9.
}

Qui creiamo una configurazione singola che estende sia la configurazione del server che quella del client. Configuriamo anche un controllore del ciclo di vita che terminerà normalmente client e server dopo la durata dell'intervallo.

Lo stesso insieme di implementazioni e configurazioni dei servizi può essere utilizzato per creare un layout di sistema con due nodi separati. Dobbiamo semplicemente creare due configurazioni di nodo separate con i servizi appropriati:

Configurazione a due nodi

  oggetto NodeServerConfig estende EchoConfig[String] con SigTermLifecycleConfig
  {
    tipo NodeId = NodeIdImpl

    def nodeId = NodeServer

    override def portNumber: PortNumber = 8080
  }

  oggetto NodeClientConfig estende EchoClientConfig[String] con FiniteDurationLifecycleConfig
  {
    // NB! specifica delle dipendenze
    def echoServiceDependency = NodeServerConfig.echoService

    def pollInterval: FiniteDuration = 1.second

    def lifetime: FiniteDuration = 10500.milliseconds // ulteriori 0.5 secondi in modo da effettuare 10 richieste, non 9.

    def testMessage: String = "dolly"
  }

Guarda come specifichiamo la dipendenza. Menziamo il servizio fornito dall'altro nodo come una dipendenza del nodo corrente. Il tipo di dipendenza è verificato perché contiene un tipo fantasma che descrive il protocollo. E durante l'esecuzione avremo l'id del nodo corretto. Questo è uno degli aspetti importanti dell'approccio di configurazione proposto. Ci offre la possibilità di impostare la porta una sola volta e assicurarci di fare riferimento alla porta corretta.

Implementazione di due nodi

Per questa configurazione utilizziamo esattamente le stesse implementazioni dei servizi. Nessuna modifica. Tuttavia, creiamo due implementazioni di nodo diverse che contengono un diverso insieme di servizi:

  oggetto TwoJvmNodeServerImpl estende ZeroServiceImpl[IO] con EchoServiceService e SigIntLifecycleServiceImpl {
    tipo Config = EchoConfig[String] con SigTermLifecycleConfig
  }

  oggetto TwoJvmNodeClientImpl estende ZeroServiceImpl[IO] con EchoClientService e FiniteDurationLifecycleServiceImpl {
    tipo Config = EchoClientConfig[String] con FiniteDurationLifecycleConfig
  }

Il primo nodo implementa il server e ha bisogno solo della configurazione del lato server. Il secondo nodo implementa il client e necessita di un'altra parte della configurazione. Entrambi i nodi richiedono una specifica di durata. Per scopi di questo post, il nodo di servizio avrà una durata infinita che può essere terminata usando SIGTERM, mentre il client echo terminerà dopo la durata finita configurata. Vedi il applicazione di avvio per ulteriori dettagli.

Processo di sviluppo complessivo

Vediamo come questo approccio cambia il modo in cui lavoriamo con la configurazione.

La configurazione come codice sarà compilata e produrrà un artefatto. Sembra ragionevole separare l'artefatto di configurazione dagli altri artefatti di codice. Spesso possiamo avere una moltitudine di configurazioni sulla stessa base di codice. E naturalmente, possiamo avere più versioni di vari rami di configurazione. In una configurazione possiamo selezionare versioni specifiche di librerie e questo rimarrà costante ogni volta che distribuiamo questa configurazione.

Una modifica della configurazione diventa una modifica del codice. Pertanto, dovrebbe essere coperta dallo stesso processo di garanzia della qualità:

Ticket -> PR -> revisione -> unione -> integrazione continua -> distribuzione continua

Le conseguenze di questo approccio sono le seguenti:

  1. La configurazione è coerente per una particolare istanza di sistema. Sembra che non ci sia modo di avere una connessione errata tra i nodi.
  2. Non è facile cambiare la configurazione solo in un nodo. Sembra irragionevole accedere e modificare alcuni file di testo. Quindi, la deriva della configurazione diventa meno probabile.
  3. Piccole modifiche alla configurazione non sono facili da effettuare.
  4. La maggior parte delle modifiche di configurazione seguirà lo stesso processo di sviluppo e passerà attraverso una revisione.

È necessario un repository separato per la configurazione di produzione? La configurazione di produzione potrebbe contenere informazioni sensibili che vogliamo tenere lontane da molte persone. Pertanto, potrebbe valere la pena mantenere un repository separato con accesso ristretto che conterrà la configurazione di produzione. Possiamo suddividere la configurazione in due parti: una che contiene la maggior parte dei parametri aperti di produzione e una che contiene la parte segreta della configurazione. Questo consentirebbe l'accesso alla maggior parte degli sviluppatori alla stragrande maggioranza dei parametri, limitando al contempo l'accesso a questioni veramente sensibili. È facile realizzare ciò utilizzando tratti intermedi con valori di parametro predefiniti.

Variazioni

Esaminiamo i pro e i contro dell'approccio proposto rispetto ad altre tecniche di gestione della configurazione.

Innanzitutto, elencheremo alcune alternative ai diversi aspetti del modo proposto di gestire la configurazione:

  1. File di testo sulla macchina di destinazione.
  2. Archiviazione centralizzata chiave-valore (come etcd/zookeeper).
  3. Componenti subprocesso che possono essere riconfigurati/riavviati senza riavviare il processo.
  4. Configurazione esterna all'artifact e al controllo delle versioni.

Il file di testo offre una certa flessibilità in termini di correzioni ad hoc. Un amministratore di sistema può accedere al nodo di destinazione, apportare una modifica e semplicemente riavviare il servizio. Questo potrebbe non essere così vantaggioso per sistemi più grandi. Non rimangono tracce della modifica. La modifica non è esaminata da un'altra persona. Potrebbe essere difficile scoprire cosa ha causato la modifica. Non è stata testata. Da una prospettiva di sistema distribuito, un amministratore può semplicemente dimenticare di aggiornare la configurazione in uno degli altri nodi.

(A proposito, se alla fine ci sarà la necessità di iniziare a usare file di configurazione di testo, dovremo solo aggiungere un parser + validator che possa produrre lo stesso Configurazione digitare e sarebbe sufficiente per iniziare a utilizzare le configurazioni di testo. Questo mostra anche che la complessità della configurazione a tempo di compilazione è un po' più piccola rispetto alla complessità delle configurazioni basate su testo, perché nella versione basata su testo abbiamo bisogno di un po' di codice aggiuntivo.

Lo storage centralizzato chiave-valore è un buon meccanismo per distribuire i parametri meta dell'applicazione. Qui dobbiamo riflettere su ciò che consideriamo valori di configurazione e cosa è solo dato. Date una funzione C => A => B di solito chiamiamo valori che cambiano raramente C «configurazione», mentre i dati che cambiano frequentemente A — sono solo dati di input. La configurazione dovrebbe essere fornita alla funzione prima dei dati A. Data questa idea, possiamo dire che la frequenza prevista delle modifiche potrebbe essere usata per distinguere i dati di configurazione dai semplici dati. Inoltre, i dati provengono tipicamente da una sola fonte (utente) e la configurazione proviene da una fonte diversa (amministratore). Gestire parametri che possono essere cambiati dopo l'inizializzazione del processo porta a un aumento della complessità dell'applicazione. Per tali parametri dovremo gestire il loro meccanismo di consegna, analisi e validazione, gestendo valori non corretti. Pertanto, per ridurre la complessità del programma, sarebbe meglio ridurre il numero di parametri che possono cambiare durante il runtime (o anche eliminarli del tutto).

Dal punto di vista di questo post dovremmo fare una distinzione tra parametri statici e dinamici. Se la logica del servizio richiede un cambiamento raro di alcuni parametri durante il runtime, allora possiamo chiamarli parametri dinamici. Altrimenti, sono statici e potrebbero essere configurati utilizzando l'approccio proposto. Per la riconfigurazione dinamica potrebbero essere necessari altri approcci. Per esempio, parti del sistema potrebbero essere riavviate con i nuovi parametri di configurazione in modo simile al riavvio di processi separati di un sistema distribuito.
(La mia umile opinione è di evitare la riconfigurazione a runtime perché aumenta la complessità del sistema.
Potrebbe essere più semplice fare affidamento sul supporto del sistema operativo per riavviare i processi. Anche se, potrebbero non esserci sempre le condizioni per farlo.

Un aspetto importante dell'utilizzo della configurazione statica che a volte spinge le persone a considerare la configurazione dinamica (senza altre ragioni) è il downtime del servizio durante l'aggiornamento della configurazione. Infatti, se dobbiamo apportare modifiche alla configurazione statica, dobbiamo riavviare il sistema affinché i nuovi valori diventino effettivi. I requisiti per il downtime variano a seconda dei diversi sistemi, quindi potrebbe non essere così critico. Se è critico, allora dobbiamo pianificare in anticipo eventuali riavvii del sistema. Ad esempio, potremmo implementare il drain delle connessioni di AWS ELB. In questo scenario, ogni volta che dobbiamo riavviare il sistema, avviamo una nuova istanza del sistema in parallelo e poi commutiamo ELB su di essa, mentre permettiamo al vecchio sistema di completare il servizio delle connessioni esistenti.

E per quanto riguarda la conservazione della configurazione all'interno di un artefatto versionato o all'esterno? Mantenere la configurazione all'interno di un artefatto significa, nella maggior parte dei casi, che questa configurazione ha superato lo stesso processo di assicurazione della qualità degli altri artefatti. Quindi si potrebbe essere certi che la configurazione sia di buona qualità e affidabile. Al contrario, la configurazione in un file separato significa che non ci sono tracce di chi e perché ha apportato modifiche a quel file. È importante? Crediamo che per la maggior parte dei sistemi di produzione sia meglio avere una configurazione stabile e di alta qualità.

La versione dell'artefatto consente di scoprire quando è stata creata, quali valori contiene, quali funzionalità sono attivate/disattivate e chi è stato responsabile di ogni modifica nella configurazione. Potrebbe richiedere qualche sforzo mantenere la configurazione all'interno di un artefatto ed è una scelta di design da fare.

Pro e contro

Qui vorremmo mettere in evidenza alcuni vantaggi e discutere alcuni svantaggi dell'approccio proposto.

Vantaggi

Caratteristiche della configurazione compilabile di un sistema distribuito completo:

  1. Controllo statico della configurazione. Ciò offre un alto livello di fiducia che la configurazione sia corretta rispetto ai vincoli di tipo.
  2. Linguaggio ricco di configurazione. Tipicamente, altri approcci di configurazione sono limitati al massimo alla sostituzione di variabili.
    Utilizzando Scala, è possibile sfruttare un'ampia gamma di funzionalità linguistiche per migliorare la configurazione. Ad esempio, possiamo utilizzare i trait per fornire valori predefiniti, oggetti per impostare ambiti diversi e poter fare riferimento a vals definito solo una volta nell'ambito esterno (DRY). È possibile utilizzare sequenze letterali o istanze di determinate classi (Seq, Map, ecc.).
  3. DSL. Scala ha un buon supporto per gli scrittori di DSL. È possibile utilizzare queste funzionalità per stabilire un linguaggio di configurazione che sia più conveniente e user-friendly, in modo che la configurazione finale sia leggibile almeno dagli utenti di dominio.
  4. Integrità e coerenza tra i nodi. Uno dei vantaggi di avere la configurazione per l'intero sistema distribuito in un unico posto è che tutti i valori sono definiti rigorosamente una sola volta e poi riutilizzati in tutti i luoghi in cui ne abbiamo bisogno. Inoltre, le dichiarazioni di porta sicure per il tipo garantiscono che in tutte le configurazioni corrette possibili, i nodi del sistema parleranno la stessa lingua. Ci sono dipendenze esplicite tra i nodi che rendono difficile dimenticare di fornire alcuni servizi.
  5. Alta qualità dei cambiamenti. L'approccio generale di passare le modifiche di configurazione attraverso il normale processo di PR stabilisce elevati standard di qualità anche nella configurazione.
  6. Modifiche di configurazione simultanee. Ogni volta che apportiamo modifiche alla configurazione, il deployment automatico assicura che tutti i nodi vengano aggiornati.
  7. Semplificazione dell'applicazione. L'applicazione non ha bisogno di analizzare e validare la configurazione né gestire valori di configurazione errati. Questo semplifica l'applicazione nel suo complesso. (C'è un certo aumento della complessità nella configurazione stessa, ma è un compromesso consapevole per la sicurezza.) È piuttosto semplice tornare alla configurazione ordinaria: basta aggiungere i pezzi mancanti. È più facile iniziare con una configurazione compilata e rinviare l'implementazione di componenti aggiuntivi a un secondo momento.
  8. Configurazione versionata. Poiché le modifiche alla configurazione seguono lo stesso processo di sviluppo, ne consegue che otteniamo un artefatto con una versione unica. Ciò ci consente di ripristinare la configurazione se necessario. Possiamo persino distribuire una configurazione utilizzata un anno fa e funzionerà esattamente allo stesso modo. Una configurazione stabile migliora la prevedibilità e l'affidabilità del sistema distribuito. La configurazione è fissa al momento della compilazione e non può essere facilmente manomessa in un sistema di produzione.
  9. Modularità. Il framework proposto è modulare e i moduli possono essere combinati in vari modi per
    supportare diverse configurazioni (impostazioni/disposizioni). In particolare, è possibile avere una disposizione su singolo nodo a piccola scala e una configurazione multi-nodo a grande scala. È ragionevole avere più layout di produzione.
  10. Test. Per scopi di test, si potrebbe implementare un servizio simulato e usarlo come dipendenza in modo sicuro dal punto di vista dei tipi. Si potrebbero mantenere simultaneamente diversi layout di test con varie parti sostituite da mock.
  11. Test di integrazione. A volte, nei sistemi distribuiti, è difficile eseguire test di integrazione. Utilizzando l'approccio descritto per la configurazione sicura dal punto di vista dei tipi dell'intero sistema distribuito, possiamo eseguire tutte le parti distribuite su un singolo server in modo controllabile. È facile emulare la situazione
    quando uno dei servizi diventa non disponibile.

Svantaggi

L'approccio di configurazione compilata è diverso dalla configurazione «normale» e potrebbe non soddisfare tutte le esigenze. Ecco alcuni degli svantaggi della configurazione compilata:

  1. Configurazione statica. Potrebbe non essere adatta a tutte le applicazioni. In alcuni casi, è necessario correggere rapidamente la configurazione in produzione bypassando tutte le misure di sicurezza. Questo approccio rende le cose più difficili. È necessaria la compilazione e il ridistribuzione dopo aver apportato qualsiasi modifica nella configurazione. Questa è sia una caratteristica che un onere.
  2. Generazione della configurazione. Quando la configurazione è generata da uno strumento di automazione, questo approccio richiede una compilazione successiva (che potrebbe a sua volta fallire). Potrebbe richiedere uno sforzo aggiuntivo per integrare questo passaggio aggiuntivo nel sistema di build.
  3. Strumenti. Oggi ci sono molti strumenti in uso che si affidano a configurazioni basate su testo. Alcuni di essi
    non saranno applicabili quando la configurazione è compilata.
  4. È necessaria una modifica della mentalità. Gli sviluppatori e DevOps sono familiari con i file di configurazione testuali. L'idea di compilare la configurazione potrebbe sembrare strana per loro.
  5. Prima di introdurre una configurazione compilabile, è richiesto un processo di sviluppo software di alta qualità.

Ci sono alcune limitazioni dell'esempio implementato:

  1. Se forniamo una configurazione extra che non è richiesta dall'implementazione del nodo, il compilatore non ci aiuterà a rilevare l'implementazione assente. Questo potrebbe essere affrontato usando HList o ADT (classi di caso) per la configurazione del nodo invece di traits e Cake Pattern.
  2. Dobbiamo fornire un po' di boilerplate nel file di configurazione: (pacchetto, importa, oggetto dichiarazioni;
    override def‘s per parametri che hanno valori predefiniti). Questo potrebbe essere affrontato parzialmente utilizzando un DSL.
  3. In questo post non copriamo la riconfigurazione dinamica di cluster di nodi simili.

Conclusione

In questo post abbiamo discusso l'idea di rappresentare la configurazione direttamente nel codice sorgente in modo sicuro rispetto ai tipi. Questo approccio può essere utilizzato in molte applicazioni come sostituto delle configurazioni basate su XML e su altri testi. Sebbene il nostro esempio sia stato implementato in Scala, potrebbe essere tradotto anche in altri linguaggi compilabili (come Kotlin, C#, Swift, ecc.). Si potrebbe provare questo approccio in un nuovo progetto e, nel caso non si adatti bene, tornare al metodo tradizionale.

Naturalmente, la configurazione compilabile richiede un processo di sviluppo di alta qualità. In cambio, promette di fornire configurazioni robuste di qualità altrettanto elevata.

Questo approccio potrebbe essere esteso in vari modi:

  1. Si potrebbero utilizzare macro per eseguire la validazione della configurazione e fallire in fase di compilazione in caso di violazioni dei vincoli di logica aziendale.
  2. Potrebbe essere implementato un DSL per rappresentare la configurazione in un modo amichevole per l'utente del dominio.
  3. Gestione dinamica delle risorse con regolazioni automatiche della configurazione. Ad esempio, quando modifichiamo il numero di nodi del cluster potremmo voler (1) che i nodi ottengano una configurazione leggermente modificata; (2) che il gestore del cluster riceva nuove informazioni sui nodi.

Grazie

Vorrei ringraziare Andrey Saksonov, Pavel Popov e Anton Nehaev per aver fornito feedback ispiratori sul draft di questo post che mi hanno aiutato a renderlo più chiaro.

Fonte: habr.com

Acquista un hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista un hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster