Configurazione del sistema distribuito compilato

Vorrei raccontarvi un meccanismo interessante per lavorare con la configurazione di un sistema distribuito. La configurazione è rappresentata direttamente in un linguaggio compilato (Scala) utilizzando tipi sicuri. Questo post fornisce un esempio di tale configurazione e discute vari aspetti dell'implementazione di una configurazione compilata nel processo di sviluppo complessivo.

Configurazione del sistema distribuito compilato

(Inglese)

Introduzione

Costruire un sistema distribuito affidabile significa che tutti i nodi utilizzano la configurazione corretta, sincronizzata con gli altri nodi. Le tecnologie DevOps (terraform, ansible o qualcosa del genere) vengono solitamente utilizzate per generare automaticamente file di configurazione (spesso specifici per ciascun nodo). Vorremmo anche essere sicuri che tutti i nodi comunicanti utilizzino protocolli identici (inclusa la stessa versione). Altrimenti, l'incompatibilità verrà incorporata nel nostro sistema distribuito. Nel mondo JVM, una conseguenza di questo requisito è che la stessa versione della libreria contenente i messaggi del protocollo deve essere utilizzata ovunque.

Che ne dici di testare un sistema distribuito? Naturalmente, presupponiamo che tutti i componenti abbiano test unitari prima di passare ai test di integrazione. (Per poter estrapolare i risultati del test in fase di esecuzione, dobbiamo anche fornire un set identico di librerie in fase di test e in fase di esecuzione.)

Quando si lavora con i test di integrazione, spesso è più semplice utilizzare lo stesso classpath ovunque su tutti i nodi. Tutto quello che dobbiamo fare è assicurarci che lo stesso classpath venga utilizzato in fase di runtime. (Sebbene sia del tutto possibile eseguire nodi diversi con percorsi di classe diversi, ciò aggiunge complessità alla configurazione generale e difficoltà con i test di distribuzione e integrazione.) Ai fini di questo post, presupponiamo che tutti i nodi utilizzino lo stesso percorso di classe.

La configurazione evolve con l'applicazione. Utilizziamo le versioni per identificare le diverse fasi dell'evoluzione del programma. Sembra logico identificare anche diverse versioni di configurazioni. E posiziona la configurazione stessa nel sistema di controllo della versione. Se è presente una sola configurazione in produzione, possiamo semplicemente utilizzare il numero di versione. Se utilizziamo molte istanze di produzione, ne avremo bisogno di diverse
rami di configurazione e un'etichetta aggiuntiva oltre alla versione (ad esempio il nome del ramo). In questo modo possiamo identificare chiaramente la configurazione esatta. Ogni identificatore di configurazione corrisponde in modo univoco a una combinazione specifica di nodi distribuiti, porte, risorse esterne e versioni della libreria. Ai fini di questo post assumeremo che esista un solo ramo e possiamo identificare la configurazione nel solito modo utilizzando tre numeri separati da un punto (1.2.3).

Negli ambienti moderni, i file di configurazione vengono raramente creati manualmente. Più spesso vengono generati durante la distribuzione e non vengono più toccati (quindi non rompere nulla). Sorge spontanea una domanda: perché utilizziamo ancora il formato testo per memorizzare la configurazione? Una valida alternativa sembra essere la possibilità di utilizzare codice normale per la configurazione e trarre vantaggio dai controlli in fase di compilazione.

In questo post esploreremo l'idea di rappresentare una configurazione all'interno di un artefatto compilato.

Configurazione compilata

Questa sezione fornisce un esempio di configurazione compilata statica. Vengono implementati due semplici servizi: il servizio echo e il client del servizio echo. Sulla base di questi due servizi vengono assemblate due opzioni di sistema. In un'opzione, entrambi i servizi si trovano sullo stesso nodo, in un'altra opzione - su nodi diversi.

Tipicamente un sistema distribuito contiene diversi nodi. Puoi identificare i nodi utilizzando valori di qualche tipo NodeId:

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

o

case class NodeId(hostName: String)

o

object Singleton
type NodeId = Singleton.type

I nodi svolgono vari ruoli, eseguono servizi e tra loro possono essere stabilite connessioni TCP/HTTP.

Per descrivere una connessione TCP abbiamo bisogno almeno di un numero di porta. Vorremmo anche riflettere il protocollo supportato su quella porta per garantire che sia il client che il server utilizzino lo stesso protocollo. Descriveremo la connessione utilizzando la seguente classe:

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

dove Port - solo un numero intero Int indicando l'intervallo di valori accettabili:

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

Tipi raffinati

Vedi biblioteca raffinato и il mio доклад. In breve, la libreria consente di aggiungere vincoli ai tipi controllati in fase di compilazione. In questo caso, i valori del numero di porta validi sono numeri interi a 16 bit. Per una configurazione compilata, l'utilizzo della libreria perfezionata non è obbligatorio, ma migliora la capacità del compilatore di controllare la configurazione.

Per i protocolli HTTP (REST), oltre al numero di porta, potremmo aver bisogno anche del percorso del servizio:

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

Tipi fantasma

Per identificare il protocollo in fase di compilazione, utilizziamo un parametro di tipo che non viene utilizzato all'interno della classe. Questa decisione è dovuta al fatto che non utilizziamo un'istanza di protocollo in fase di runtime, ma vorremmo che il compilatore verificasse la compatibilità del protocollo. Specificando il protocollo, non saremo in grado di passare un servizio inappropriato come dipendenza.

Uno dei protocolli comuni è l'API REST con serializzazione Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

dove RequestMessage - Tipo di richiesta, ResponseMessage — tipo di risposta.
Naturalmente possiamo utilizzare altre descrizioni di protocollo che forniscano l'accuratezza della descrizione richiesta.

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

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Qui la richiesta è una stringa aggiunta all'URL e la risposta è la stringa restituita nel corpo della risposta HTTP.

La configurazione del servizio è descritta dal nome del servizio, dalle porte e dalle dipendenze. Questi elementi possono essere rappresentati in Scala in diversi modi (ad esempio, HList-s, tipi di dati algebrici). Ai fini di questo post, utilizzeremo il Cake Pattern e rappresenteremo i moduli utilizzando trait'ovv. (Il Cake Pattern non è un elemento richiesto di questo approccio. È semplicemente una possibile implementazione.)

Le dipendenze tra i servizi possono essere rappresentate come metodi che restituiscono porte EndPointdi 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)
  }

Per creare un servizio echo, tutto ciò di cui hai bisogno è un numero di porta e l'indicazione che la porta supporta il protocollo echo. Potremmo non specificare una porta specifica, perché... i tratti consentono di dichiarare metodi senza implementazione (metodi astratti). In questo caso, quando si crea una configurazione concreta, il compilatore ci richiederebbe di fornire un'implementazione del metodo astratto e fornire un numero di porta. Poiché abbiamo implementato il metodo, quando creiamo una configurazione specifica, potremmo non specificare una porta diversa. Verrà utilizzato il valore predefinito.

Nella configurazione del client dichiariamo una dipendenza dal servizio echo:

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

La dipendenza è dello stesso tipo del servizio esportato echoService. In particolare, nell'echo client richiediamo lo stesso protocollo. Pertanto, quando colleghiamo due servizi, possiamo essere sicuri che tutto funzionerà correttamente.

Implementazione dei servizi

È necessaria una funzione per avviare e interrompere il servizio. (La capacità di interrompere un servizio è fondamentale per il test.) Ancora una volta, ci sono diverse opzioni per implementare tale funzionalità (ad esempio, potremmo utilizzare classi di tipo basate sul tipo di configurazione). Per gli scopi di questo post utilizzeremo il modello torta. Rappresenteremo il servizio utilizzando una classe cats.Resource, Perché Questa classe fornisce già i mezzi per garantire in sicurezza il rilascio delle risorse in caso di problemi. Per ottenere una risorsa, dobbiamo fornire la configurazione e un contesto runtime già pronto. La funzione di avvio del servizio può assomigliare a questa:

  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

  • Config — tipo di configurazione per questo servizio
  • AddressResolver — un oggetto runtime che ti consente di scoprire gli indirizzi di altri nodi (vedi sotto)

e altri tipi dalla biblioteca cats:

  • F[_] — tipo di effetto (nel caso più semplice F[A] potrebbe essere solo una funzione () => A. In questo post utilizzeremo cats.IO.)
  • Reader[A,B] - più o meno sinonimo di funzione A => B
  • cats.Resource - una risorsa che può essere ottenuta e rilasciata
  • Timer — timer (ti permette di addormentarti per un po' e di misurare gli intervalli di tempo)
  • ContextShift - analogico ExecutionContext
  • Applicative — una classe di tipi di effetti che ti consente di combinare effetti individuali (quasi una monade). Nelle applicazioni più complesse sembra preferibile utilizzare Monad/ConcurrentEffect.

Utilizzando questa firma di funzione possiamo implementare diversi servizi. Ad esempio, un servizio che non fa nulla:

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

(Cm. codice sorgente, in cui sono implementati altri servizi - servizio eco, cliente dell'eco
и controllori a vita.)

Un nodo è un oggetto che può lanciare più servizi (il lancio di una catena di risorse è assicurato dal Cake Pattern):

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

Tieni presente che stiamo specificando l'esatto tipo di configurazione richiesta per questo nodo. Se dimentichiamo di specificare uno dei tipi di configurazione richiesti da un particolare servizio, si verificherà un errore di compilazione. Inoltre, non saremo in grado di avviare un nodo a meno che non forniamo un oggetto del tipo appropriato con tutti i dati necessari.

Risoluzione del nome host

Per connetterci a un host remoto, abbiamo bisogno di un indirizzo IP reale. È possibile che l'indirizzo venga reso noto più tardi rispetto al resto della configurazione. Quindi abbiamo bisogno di una funzione che mappi l'ID del nodo su un indirizzo:

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

Esistono diversi modi per implementare questa funzione:

  1. Se gli indirizzi ci vengono resi noti prima della distribuzione, possiamo generare il codice Scala con
    indirizzi e quindi eseguire il build. Questo compilerà ed eseguirà i test.
    In questo caso la funzione sarà nota staticamente e potrà essere rappresentata nel codice come mappatura Map[NodeId, NodeAddress].
  2. In alcuni casi, l'indirizzo effettivo è noto solo dopo l'avvio del nodo.
    In questo caso, possiamo implementare un “servizio di rilevamento” che viene eseguito prima degli altri nodi e tutti i nodi si registreranno con questo servizio e richiederanno gli indirizzi di altri nodi.
  3. Se possiamo modificare /etc/hosts, quindi puoi utilizzare nomi host predefiniti (come my-project-main-node и echo-backend) e collega semplicemente questi nomi
    con indirizzi IP durante la distribuzione.

In questo post non considereremo questi casi in modo più dettagliato. Per noi
in un esempio giocattolo, tutti i nodi avranno lo stesso indirizzo IP - 127.0.0.1.

Successivamente, consideriamo due opzioni per un sistema distribuito:

  1. Posizionamento di tutti i servizi su un nodo.
  2. E ospitando il servizio echo e il client echo su nodi diversi.

Configurazione per un nodo:

Configurazione a nodo singolo

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

L'oggetto implementa la configurazione sia del client che del server. Viene utilizzata anche una configurazione time-to-live in modo che dopo l'intervallo lifetime terminare il programma. (Anche Ctrl-C funziona e libera correttamente tutte le risorse.)

Lo stesso insieme di caratteristiche di configurazione e implementazione può essere utilizzato per creare un sistema composto da due nodi separati:

Configurazione a due nodi

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

Importante! Notare come sono collegati i servizi. Specifichiamo un servizio implementato da un nodo come implementazione del metodo di dipendenza di un altro nodo. Il tipo di dipendenza viene controllato dal compilatore, perché contiene il tipo di protocollo. Una volta eseguita, la dipendenza conterrà l'ID del nodo di destinazione corretto. Grazie a questo schema specifichiamo il numero di porta esattamente una volta e abbiamo sempre la garanzia di fare riferimento alla porta corretta.

Realizzazione di due nodi del sistema

Per questa configurazione utilizziamo le stesse implementazioni del servizio senza modifiche. L'unica differenza è che ora abbiamo due oggetti che implementano diversi set di servizi:

  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
  }

Il primo nodo implementa il server e necessita solo della configurazione del server. Il secondo nodo implementa il client e utilizza una parte diversa della configurazione. Inoltre entrambi i nodi necessitano di gestione della durata. Il nodo del server viene eseguito indefinitamente finché non viene arrestato SIGTERM'om e il nodo client termina dopo un po' di tempo. Cm. applicazione di avvio.

Processo generale di sviluppo

Vediamo come questo approccio di configurazione influisce sul processo di sviluppo complessivo.

La configurazione verrà compilata insieme al resto del codice e verrà generato un artefatto (.jar). Sembra abbia senso inserire la configurazione in un artefatto separato. Questo perché possiamo avere più configurazioni basate sullo stesso codice. Anche in questo caso è possibile generare artefatti corrispondenti a diversi rami di configurazione. Le dipendenze su versioni specifiche delle librerie vengono salvate insieme alla configurazione e queste versioni vengono salvate per sempre ogni volta che decidiamo di distribuire quella versione della configurazione.

Qualsiasi modifica alla configurazione si trasforma in una modifica del codice. E quindi, ciascuno
la modifica sarà coperta dal normale processo di garanzia della qualità:

Ticket nel bug tracker -> PR -> revisione -> unisci con i rami rilevanti ->
integrazione -> distribuzione

Le principali conseguenze dell'implementazione di una configurazione compilata sono:

  1. La configurazione sarà coerente su tutti i nodi del sistema distribuito. Poiché tutti i nodi ricevono la stessa configurazione da un'unica fonte.

  2. È problematico modificare la configurazione solo in uno dei nodi. Pertanto, la “deriva della configurazione” è improbabile.

  3. Diventa più difficile apportare piccole modifiche alla configurazione.

  4. La maggior parte delle modifiche alla configurazione verranno apportate come parte del processo di sviluppo complessivo e saranno soggette a revisione.

Ho bisogno di un repository separato per archiviare la configurazione di produzione? Questa configurazione può contenere password e altre informazioni sensibili a cui vorremmo limitare l'accesso. Sulla base di ciò, sembra avere senso archiviare la configurazione finale in un repository separato. Puoi dividere la configurazione in due parti: una contenente le impostazioni di configurazione accessibili pubblicamente e l'altra contenente le impostazioni limitate. Ciò consentirà alla maggior parte degli sviluppatori di avere accesso alle impostazioni comuni. Questa separazione è facile da ottenere utilizzando tratti intermedi contenenti valori predefiniti.

Possibili variazioni

Proviamo a confrontare la configurazione compilata con alcune alternative comuni:

  1. File di testo sul computer di destinazione.
  2. Archivio centralizzato di valori-chiave (etcd/zookeeper).
  3. Componenti del processo che possono essere riconfigurati/riavviati senza riavviare il processo.
  4. Memorizzazione della configurazione al di fuori del controllo degli artefatti e della versione.

I file di testo offrono una notevole flessibilità in termini di piccole modifiche. L'amministratore di sistema può accedere al nodo remoto, apportare modifiche ai file appropriati e riavviare il servizio. Per i sistemi di grandi dimensioni, tuttavia, tale flessibilità potrebbe non essere auspicabile. Le modifiche apportate non lasciano tracce in altri sistemi. Nessuno esamina le modifiche. È difficile determinare chi abbia apportato esattamente le modifiche e per quale motivo. Le modifiche non vengono testate. Se il sistema è distribuito, l'amministratore potrebbe dimenticare di apportare la modifica corrispondente su altri nodi.

(Va inoltre notato che l'utilizzo di una configurazione compilata non preclude la possibilità di utilizzare file di testo in futuro. Sarà sufficiente aggiungere un parser e un validatore che produca come output lo stesso tipo Configed è possibile utilizzare file di testo. Ne consegue immediatamente che la complessità di un sistema con una configurazione compilata è leggermente inferiore alla complessità di un sistema che utilizza file di testo, perché i file di testo richiedono codice aggiuntivo.)

Un archivio chiave-valore centralizzato è un buon meccanismo per distribuire i metaparametri di un'applicazione distribuita. Dobbiamo decidere quali sono i parametri di configurazione e quali sono solo i dati. Diamo una funzione C => A => Be i parametri C cambia raramente e dati A - Spesso. In questo caso possiamo dirlo C - parametri di configurazione e A - dati. Sembra che i parametri di configurazione differiscano dai dati in quanto generalmente cambiano meno frequentemente dei dati. Inoltre, i dati provengono solitamente da una fonte (dall'utente) e i parametri di configurazione da un'altra (dall'amministratore di sistema).

Se i parametri che cambiano raramente devono essere aggiornati senza riavviare il programma, ciò può spesso portare a complicazioni del programma, perché dovremo in qualche modo fornire parametri, archiviare, analizzare, controllare ed elaborare valori errati. Pertanto, dal punto di vista della riduzione della complessità del programma, ha senso ridurre il numero di parametri che possono cambiare durante il funzionamento del programma (o non supportare affatto tali parametri).

Ai fini di questo post, distingueremo tra parametri statici e dinamici. Se la logica del servizio richiede la modifica dei parametri durante il funzionamento del programma, chiameremo tali parametri dinamici. Altrimenti le opzioni sono statiche e possono essere configurate utilizzando la configurazione compilata. Per la riconfigurazione dinamica, potremmo aver bisogno di un meccanismo per riavviare parti del programma con nuovi parametri, in modo simile a come vengono riavviati i processi del sistema operativo. (A nostro avviso, è consigliabile evitare la riconfigurazione in tempo reale, poiché ciò aumenta la complessità del sistema. Se possibile, è meglio utilizzare le funzionalità standard del sistema operativo per riavviare i processi.)

Un aspetto importante dell'utilizzo della configurazione statica che induce le persone a considerare la riconfigurazione dinamica è il tempo necessario al riavvio del sistema dopo un aggiornamento della configurazione (tempo di inattività). Infatti, se avremo bisogno di apportare modifiche alla configurazione statica, dovremo riavviare il sistema affinché i nuovi valori abbiano effetto. Il problema dei tempi di inattività varia in gravità a seconda dei diversi sistemi. In alcuni casi, è possibile pianificare un riavvio in un momento in cui il carico è minimo. Se è necessario fornire un servizio continuo, è possibile implementarlo Drenaggio della connessione AWS ELB. Allo stesso tempo, quando dobbiamo riavviare il sistema, lanciamo un'istanza parallela di questo sistema, trasferiamo il bilanciatore su di essa e aspettiamo che le vecchie connessioni vengano completate. Dopo che tutte le vecchie connessioni sono state terminate, chiudiamo la vecchia istanza del sistema.

Consideriamo ora il problema della memorizzazione della configurazione all'interno o all'esterno dell'artefatto. Se memorizziamo la configurazione all'interno di un artefatto, almeno abbiamo avuto l'opportunità di verificare la correttezza della configurazione durante l'assemblaggio dell'artefatto. Se la configurazione è esterna all'elemento controllato, è difficile tenere traccia di chi ha apportato modifiche a questo file e perché. Quanto è importante? A nostro avviso, per molti sistemi di produzione è importante avere una configurazione stabile e di alta qualità.

La versione di un artefatto consente di determinare quando è stato creato, quali valori contiene, quali funzioni sono abilitate/disabilitate e chi è responsabile di qualsiasi modifica nella configurazione. Naturalmente, archiviare la configurazione all'interno di un artefatto richiede un certo impegno, quindi è necessario prendere una decisione informata.

Pro e contro

Vorrei soffermarmi sui pro e contro della tecnologia proposta.

Vantaggi

Di seguito è riportato un elenco delle principali caratteristiche di una configurazione di sistema distribuito compilata:

  1. Controllo della configurazione statica. Ti permette di esserne sicuro
    la configurazione è corretta.
  2. Linguaggio di configurazione ricco. In genere, gli altri metodi di configurazione si limitano al massimo alla sostituzione delle variabili stringa. Quando si utilizza Scala, è disponibile un'ampia gamma di funzionalità linguistiche per migliorare la configurazione. Ad esempio possiamo usare
    tratti per i valori predefiniti, utilizzando oggetti per raggruppare parametri, possiamo fare riferimento a vals dichiarati solo una volta (DRY) nell'ambito allegato. Puoi istanziare qualsiasi classe direttamente all'interno della configurazione (Seq, Map, classi personalizzate).
  3. DSL. Scala ha una serie di funzionalità linguistiche che semplificano la creazione di un DSL. È possibile sfruttare queste funzionalità e implementare un linguaggio di configurazione più conveniente per il gruppo target di utenti, in modo che la configurazione sia almeno leggibile dagli esperti del settore. Gli specialisti possono, ad esempio, partecipare al processo di revisione della configurazione.
  4. Integrità e sincronia tra i nodi. Uno dei vantaggi di avere la configurazione di un intero sistema distribuito archiviata in un unico punto è che tutti i valori vengono dichiarati esattamente una volta e poi riutilizzati ovunque siano necessari. L'utilizzo di tipi fantasma per dichiarare le porte garantisce che i nodi utilizzino protocolli compatibili in tutte le configurazioni di sistema corrette. Avere dipendenze obbligatorie esplicite tra i nodi garantisce che tutti i servizi siano connessi.
  5. Cambiamenti di alta qualità. Apportare modifiche alla configurazione utilizzando un processo di sviluppo comune consente di raggiungere elevati standard di qualità anche per la configurazione.
  6. Aggiornamento simultaneo della configurazione. La distribuzione automatica del sistema dopo le modifiche alla configurazione garantisce che tutti i nodi vengano aggiornati.
  7. Semplificare l'applicazione. L'applicazione non necessita di analisi, controllo della configurazione o gestione di valori errati. Ciò riduce la complessità dell'applicazione. (Parte della complessità di configurazione osservata nel nostro esempio non è un attributo della configurazione compilata, ma solo una decisione consapevole guidata dal desiderio di fornire una maggiore sicurezza dei tipi.) È abbastanza semplice tornare alla configurazione abituale: basta implementare le parti mancanti parti. Pertanto è possibile, ad esempio, iniziare con una configurazione compilata, rinviando l'implementazione delle parti non necessarie al momento in cui sarà realmente necessaria.
  8. Configurazione verificata. Poiché le modifiche alla configurazione seguono il normale destino di qualsiasi altra modifica, l'output che otteniamo è un artefatto con una versione univoca. Ciò ci consente, ad esempio, di tornare, se necessario, a una versione precedente della configurazione. Possiamo anche utilizzare la configurazione di un anno fa e il sistema funzionerà esattamente allo stesso modo. Una configurazione stabile migliora la prevedibilità e l'affidabilità di un sistema distribuito. Poiché la configurazione è fissa in fase di compilazione, è abbastanza difficile falsificarla in produzione.
  9. Modularità. La struttura proposta è modulare e i moduli possono essere combinati in diversi modi per creare sistemi diversi. In particolare, è possibile configurare il sistema per funzionare su un singolo nodo in una forma di realizzazione, e su più nodi in un'altra. È possibile creare diverse configurazioni per le istanze di produzione del sistema.
  10. Test. Sostituendo i singoli servizi con oggetti fittizi, è possibile ottenere diverse versioni del sistema utili per i test.
  11. Test d'integrazione. Avere un'unica configurazione per l'intero sistema distribuito rende possibile eseguire tutti i componenti in un ambiente controllato come parte dei test di integrazione. È facile emulare, ad esempio, una situazione in cui alcuni nodi diventano accessibili.

Svantaggi e limiti

La configurazione compilata differisce da altri approcci di configurazione e potrebbe non essere adatta per alcune applicazioni. Di seguito sono riportati alcuni svantaggi:

  1. Configurazione statica. A volte è necessario correggere rapidamente la configurazione in produzione, aggirando tutti i meccanismi di protezione. Con questo approccio può essere più difficile. Come minimo, saranno ancora necessarie la compilazione e la distribuzione automatica. Questa è sia una caratteristica utile dell’approccio che uno svantaggio in alcuni casi.
  2. Generazione della configurazione. Nel caso in cui il file di configurazione venga generato da uno strumento automatico, potrebbero essere necessari ulteriori sforzi per integrare lo script di compilazione.
  3. Utensili. Attualmente, le utilità e le tecniche progettate per funzionare con la configurazione si basano su file di testo. Non tutte queste utilità/tecniche saranno disponibili in una configurazione compilata.
  4. È necessario un cambiamento di atteggiamenti. Gli sviluppatori e i DevOps sono abituati ai file di testo. L'idea stessa di compilare una configurazione può essere alquanto inaspettata e insolita e causare il rifiuto.
  5. È necessario un processo di sviluppo di alta qualità. Per poter utilizzare comodamente la configurazione compilata, è necessaria la completa automazione del processo di creazione e distribuzione dell'applicazione (CI/CD). Altrimenti sarà abbastanza scomodo.

Soffermiamoci anche su una serie di limitazioni dell'esempio considerato che non sono legate all'idea di configurazione compilata:

  1. Se forniamo informazioni di configurazione non necessarie che non vengono utilizzate dal nodo, il compilatore non ci aiuterà a rilevare l'implementazione mancante. Questo problema può essere risolto abbandonando il Cake Pattern e utilizzando tipi più rigidi, ad esempio, HList o tipi di dati algebrici (classi di casi) per rappresentare la configurazione.
  2. Nel file di configurazione sono presenti righe che non sono correlate alla configurazione stessa: (package, import,dichiarazioni di oggetti; override defper i parametri che hanno valori predefiniti). Questo può essere parzialmente evitato se si implementa la propria DSL. Inoltre anche altri tipi di configurazione (ad esempio XML) impongono determinate restrizioni sulla struttura dei file.
  3. Ai fini di questo post, non stiamo considerando la riconfigurazione dinamica di un cluster di nodi simili.

conclusione

In questo post abbiamo esplorato l'idea di rappresentare la configurazione nel codice sorgente utilizzando le funzionalità avanzate del sistema di tipi Scala. Questo approccio può essere utilizzato in varie applicazioni in sostituzione dei tradizionali metodi di configurazione basati su file xml o di testo. Anche se il nostro esempio è implementato in Scala, le stesse idee possono essere trasferite ad altri linguaggi compilati (come Kotlin, C#, Swift, ...). Puoi provare questo approccio in uno dei seguenti progetti e, se non funziona, passare al file di testo, aggiungendo le parti mancanti.

Naturalmente una configurazione compilata richiede un processo di sviluppo di alta qualità. In cambio, è garantita l'alta qualità e l'affidabilità delle configurazioni.

L’approccio considerato può essere ampliato:

  1. È possibile utilizzare le macro per eseguire controlli in fase di compilazione.
  2. È possibile implementare un DSL per presentare la configurazione in modo accessibile agli utenti finali.
  3. È possibile implementare la gestione dinamica delle risorse con la regolazione automatica della configurazione. Ad esempio, la modifica del numero di nodi in un cluster richiede che (1) ciascun nodo riceva una configurazione leggermente diversa; (2) il gestore cluster ha ricevuto informazioni sui nuovi nodi.

Ringraziamenti

Vorrei ringraziare Andrei Saksonov, Pavel Popov e Anton Nekhaev per la loro critica costruttiva alla bozza dell'articolo.

Fonte: habr.com

Aggiungi un commento