In questo post vorremmo condividere un modo interessante di gestire la configurazione di un sistema distribuito.
La configurazione è rappresentata direttamente nel linguaggio Scala in modo sicuro dal punto di vista del tipo. Un'implementazione esemplificativa è descritta nei dettagli. Vari aspetti della proposta sono discussi, inclusa l'influenza sul processo di sviluppo generale.

()
Introduction
Costruire sistemi distribuiti robusti richiede l'uso di una configurazione corretta e coerente su tutti i nodi. Una soluzione tipica è utilizzare una descrizione testuale del deployment (terraform, ansible o qualcosa di simile) e file di configurazione generati automaticamente (spesso dedicati per ogni nodo/ruolo). Vogliamo anche utilizzare gli stessi protocolli delle stesse versioni su ciascun nodo comunicante (altrimenti potremmo riscontrare problemi di incompatibilità). Nel mondo JVM ciò significa che almeno la libreria di messaggistica dovrebbe essere della stessa versione su tutti i nodi comunicanti.
E per quanto riguarda il testing del sistema? Ovviamente, dovremmo avere test unitari per tutti i componenti prima di arrivare ai test di integrazione. Per poter generalizzare i risultati dei test in fase di runtime, dobbiamo assicurarci che le versioni di tutte le librerie siano mantenute identiche sia negli ambienti di runtime che in quelli di testing.
Quando eseguiamo i test di integrazione, è spesso molto più facile avere lo stesso classpath su tutti i nodi. Dobbiamo solo assicurarci che lo stesso classpath sia utilizzato nel deployment. (È possibile utilizzare classpath diversi su nodi diversi, ma è più difficile rappresentare questa configurazione e distribuirla correttamente.) Quindi, per mantenere le cose semplici, considereremo solo classpath identici su tutti i nodi.
La configurazione tende a evolversi insieme al software. Di solito utilizziamo le versioni per identificare i vari
stadi dell'evoluzione del software. Sembra ragionevole coprire la configurazione sotto la gestione delle versioni e identificare diverse configurazioni con alcune etichette. Se c'è solo una configurazione in produzione, possiamo utilizzare una singola versione come identificatore. A volte potremmo avere più ambienti di produzione. E per ciascun ambiente potremmo aver bisogno di un ramo separato di configurazione. Le configurazioni potrebbero quindi essere etichettate con ramo e versione per identificare univocamente configurazioni diverse. Ogni etichetta di ramo e versione corrisponde a una singola combinazione di nodi distribuiti, porte, risorse esterne, versioni delle librerie nel classpath su ogni nodo. Qui considereremo solo un singolo ramo 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 successivamente. Quindi ci si potrebbe chiedere perché utilizziamo ancora il formato di testo per i file di configurazione? Un'opzione valida è posizionare la configurazione all'interno di un'unità di compilazione e beneficiare della validazione della configurazione in fase 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. Vengono configurati e implementati due semplici servizi: il servizio echo e il client del servizio echo. Successivamente, 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 sistema distribuito tipico è composto da alcuni nodi. I nodi potrebbero essere identificati utilizzando un certo tipo:
sealed trait NodeId
case object Backend extends NodeId
case object Frontend extends NodeIdo semplicemente
case class NodeId(hostName: String)o addirittura
object Singleton
type NodeId = Singleton.typeQuesti nodi eseguono vari ruoli, gestiscono alcuni servizi e dovrebbero essere in grado di comunicare con gli altri nodi tramite connessioni TCP/HTTP.
Per una connessione TCP è necessario almeno un numero di porta. Vogliamo anche assicurarci che client e server stiano utilizzando lo stesso protocollo. Per modellare una connessione tra nodi, dichiariamo la seguente classe:
case class TcpEndPoint[Protocol](node: NodeId, port: Port[Protocol])where Porta è semplicemente un Int all'interno dell'intervallo consentito:
type PortNumber = Refined[Int, Closed[_0, W.`65535`.T]]Tipi raffinati
Vedi libreria. In breve, consente di aggiungere vincoli di compilazione ad altri tipi. In questo caso Int è consentito utilizzare solo valori a 16 bit che possono rappresentare il numero di porta. Non è obbligatorio utilizzare questa libreria per questo approccio di configurazione. Sembra semplicemente adattarsi molto bene.
Per HTTP (REST) potremmo avere anche 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 phantom
Per identificare il protocollo durante la compilazione stiamo utilizzando la funzionalità di Scala di dichiarare un argomento di tipo Protocollo che non viene utilizzato nella classe. Si tratta di un tipo phantom. A runtime, raramente abbiamo bisogno di un'istanza dell'identificatore di protocollo, ecco perché non lo memorizziamo. Durante la compilazione, questo tipo phantom offre una sicurezza di tipo aggiuntiva. Non possiamo passare una porta con un protocollo errato.
Uno dei protocolli più utilizzati è REST API con serializzazione Json:
sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]where RequestMessage è il tipo base dei messaggi che il client può inviare al server e ResponseMessage è il messaggio di risposta dal server. Certamente, 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 è aggiunto all'url e il messaggio di risposta è restituito come stringa semplice.
Una configurazione del servizio potrebbe essere descritta dal nome del servizio, una raccolta di porte e alcune dipendenze. Ci sono alcuni modi possibili per rappresentare tutti questi elementi in Scala (ad esempio, HList, tipi di dati algebrici). Ai fini di questo post utilizzeremo il Cake Pattern e rappresenteremo i pezzi combinabili (moduli) come trait. (Il Cake Pattern non è un requisito per questo approccio di configurazione compilabile. È solo una possibile implementazione dell'idea.)
Le dipendenze potrebbero 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 bisogno solo 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é il trait consente la dichiarazione 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 la 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 colliamo queste due dipendenze funzioneranno correttamente.
Implementazione dei servizi
Un servizio ha bisogno di una funzione per avviarsi e chiudersi in modo controllato. (La capacità di chiudere un servizio è fondamentale per il testing.) Ancora una volta ci sono alcune opzioni per specificare tale funzione per una configurazione data (ad esempio, potremmo utilizzare classi di tipo). Per questo post useremo di nuovo il Cake Pattern. Possiamo rappresentare un servizio usando cats.Resource che già fornisce la delimitazione e il 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]
}where
Configurazione— tipo di configurazione richiesto da questo avviatore di serviziAddressResolver— un oggetto di runtime che ha la capacità di ottenere gli indirizzi reali di altri nodi (continua a leggere per dettagli).
gli altri tipi derivano da cats:
F[_]— tipo di effetto (nel caso più sempliceF[A]potrebbe essere solo() => A. In questo post utilizzeremocats.IO.)Reader[A,B]— è più o meno un sinonimo per una funzioneA => Bcats.Resource— ha modi per acquisire e rilasciareTimer— consente di dormire/misurare il tempoContextShift— analogo diExecutionContextApplicative— wrapper di funzioni in 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 per altre implementazioni di servizi — ,
and .)
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
}Si noti che nel nodo specifichiamo il tipo esatto di configurazione di cui ha bisogno questo nodo. Il compilatore non ci permetterà di costruire l'oggetto (Cake) con un tipo insufficiente, perché 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 vero indirizzo host per ogni nodo. Questo 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 alcuni modi possibili per implementare tale funzione.
- Se conosciamo gli indirizzi effettivi prima del deployment, durante l'instanziazione degli host del nodo, allora possiamo generare codice Scala con gli indirizzi effettivi e eseguire la build successivamente (che esegue controlli in tempo di compilazione e poi esegue 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]. - 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 ancora stati 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.
- Se possiamo modificare
/etc/hosts, possiamo utilizzare nomi host predefiniti (comemy-project-main-nodeandecho-backend) e semplicemente associare questo nome con l'indirizzo IP al momento del deployment.
In questo post non trattiamo questi casi in modo più dettagliato. Infatti, nel nostro esempio teorico, tutti i nodi avranno lo stesso indirizzo IP — 127.0.0.1.
In questo post considereremo due layout di sistema distribuito:
- Layout di nodo singolo, dove tutti i servizi sono collocati su un singolo nodo.
- Layout a due nodi, dove servizio e client sono su nodi diversi.
La configurazione per un è la seguente:
Configurazione del nodo singolo
object SingleNodeConfig extends EchoConfig[String]
with EchoClientConfig[String] with FiniteDurationLifecycleConfig
{
case object Singleton
type NodeId = Singleton.type
def nodeId = Singleton
override def portNumber: PortNumber = 8088
def echoServiceDependency = echoService
override def testMessage: UrlPathElement = "hello"
def pollInterval: FiniteDuration = 1.second
def lifetime: FiniteDuration = 10500.milliseconds
}Qui creiamo una singola configurazione che estende sia la configurazione del server che quella del client. Inoltre, configuriamo un controllore del ciclo di vita che normalmente terminerà client e server dopo che lifetime l'intervallo passa.
Lo stesso insieme di implementazioni e configurazioni dei servizi può essere utilizzato per creare un layout del sistema con due nodi separati. Abbiamo solo bisogno di creare con i servizi appropriati:
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
{
def echoServiceDependency = NodeServerConfig.echoService
def pollInterval: FiniteDuration = 1.second
def lifetime: FiniteDuration = 10500.milliseconds
def testMessage: String = "dolly"
}Vedi come specifichiamo la dipendenza. Menzioniamo il servizio fornito dall'altro nodo come dipendenza del nodo corrente. Il tipo di dipendenza viene verificato perché contiene un tipo fantasma che descrive il protocollo. E a runtime avremo l'id del nodo corretto. Questo è uno degli aspetti importanti dell'approccio di configurazione proposto. Ci offre la possibilità di impostare la porta solo una volta e assicurarci che stiamo facendo riferimento alla porta corretta.
Implementazione a due nodi
Per questa configurazione utilizziamo esattamente le stesse implementazioni dei servizi. Nessuna modifica. Tuttavia, creiamo due implementazioni di nodo diverse che contengono un insieme diverso 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 ha bisogno solo della configurazione lato server. Il secondo nodo implementa il client e necessita di un'altra parte della configurazione. Entrambi i nodi richiedono specifiche di durata. Ai fini di questo post, il nodo del servizio avrà una durata infinita che potrebbe essere terminata utilizzando SIGTERM, mentre il client echo terminerà dopo la durata finita configurata. Vedi l' per i dettagli.
Processo di sviluppo generale
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 da altri artefatti del 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 particolari di librerie e questo rimarrà costante ogni volta che distribuiamo questa configurazione.
Una modifica della configurazione diventa una modifica del codice. Quindi dovrebbe essere coperta dallo stesso processo di assicurazione della qualità:
Ticket -> PR -> revisione -> fusione -> integrazione continua -> distribuzione continua
Ci sono le seguenti conseguenze dell'approccio:
- La configurazione è coerente per un'istanza di sistema specifica. Sembra che non ci sia modo di avere una connessione errata tra i nodi.
- Non è facile cambiare la configurazione in un solo nodo. Sembra irragionevole effettuare il login e modificare alcuni file di testo. Quindi, il drift di configurazione diventa meno probabile.
- Piccole modifiche alla configurazione non sono facili da apportare.
- La maggior parte delle modifiche di configurazione seguirà lo stesso processo di sviluppo e passerà attraverso una revisione.
Abbiamo bisogno di un repository separato per la configurazione di produzione? La configurazione di produzione potrebbe contenere informazioni sensibili che vorremmo mantenere lontano dalla vista di molte persone. Quindi 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 per la stragrande maggioranza dei parametri, mantenendo al contempo l'accesso a cose veramente sensibili sotto controllo. È facile raggiungere questo obiettivo utilizzando tratti intermedi con valori di parametro predefiniti.
Variazioni
Vediamo i pro e i contro dell'approccio proposto rispetto ad altre tecniche di gestione della configurazione.
Prima di tutto, elencheremo alcune alternative agli aspetti differenti del modo proposto di gestire la configurazione:
- File di testo sulla macchina target.
- Archiviazione centralizzata di chiavi e valori (come
sono stati resi/zookeeper). - Componenti subprocess che possono essere riconfigurati/riavviati senza riavviare il processo.
- Configurazione esterna all'artifact e controllata dalla versione.
Un file di testo offre una certa flessibilità in termini di correzioni ad hoc. Un amministratore di sistema può accedere al nodo target, apportare una modifica e semplicemente riavviare il servizio. Questo potrebbe non essere altrettanto efficace per sistemi più grandi. Non rimangono tracce dietro la modifica. La modifica non viene esaminata da un'altra persona. Potrebbe essere difficile scoprire cosa ha causato la modifica. Non è stata testata. Dal punto di vista di un sistema distribuito, un amministratore può facilmente dimenticare di aggiornare la configurazione in uno degli altri nodi.
(A proposito, se eventualmente ci sarà bisogno di iniziare a utilizzare file di configurazione di testo, dovremo solo aggiungere un parser + un validatore che possa produrre lo stesso Configurazione tipo e questo sarebbe sufficiente per iniziare a utilizzare configurazioni di testo. Ciò dimostra anche che la complessità della configurazione a tempo di compilazione è leggermente inferiore alla complessità delle configurazioni basate su testo, perché nella versione basata su testo abbiamo bisogno di un codice aggiuntivo.)
L'archiviazione centralizzata di chiavi e valori è un buon meccanismo per distribuire parametri meta dell'applicazione. Qui dobbiamo pensare a cosa consideriamo valori di configurazione e cosa è solo dati. Data una funzione C => A => B di solito chiamiamo valori che cambiano raramente C «configurazione», mentre i dati frequentemente modificati A — solo dati di input. La configurazione dovrebbe essere fornita alla funzione prima dei dati. AData questa idea, possiamo dire che è la frequenza attesa delle modifiche ciò che potrebbe essere usato 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, parsing e validazione, gestendo valori errati. Pertanto, al fine di ridurre la complessità del programma, è meglio ridurre il numero di parametri che possono cambiare a tempo di esecuzione (o addirittura 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 cambiamenti rari di alcuni parametri durante il runtime, allora possiamo definirli parametri dinamici. Altrimenti, sono statici e possono essere configurati utilizzando l'approccio proposto. Per la riconfigurazione dinamica potrebbero essere necessari altri approcci. Ad 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 affidarsi semplicemente al supporto del sistema operativo per il riavvio dei processi. Anche se, potrebbe non essere sempre possibile.)
Un aspetto importante dell'utilizzo della configurazione statica che a volte fa sì che le persone considerino 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 efficaci. I requisiti per il downtime variano da un sistema all'altro, quindi potrebbe non essere così critico. Se è critico, dobbiamo pianificare in anticipo qualsiasi riavvio del sistema. Ad esempio, potremmo implementare . In questo scenario, ogni volta che dobbiamo riavviare il sistema, avviamo una nuova istanza del sistema in parallelo, quindi passiamo l'ELB a essa, mentre lasciamo al vecchio sistema il tempo di completare il servizio delle connessioni esistenti.
Cosa ne pensi di mantenere la configurazione all'interno di un artefatto versionato o all'esterno? Mantenere la configurazione all'interno di un artefatto significa che, nella maggior parte dei casi, questa configurazione ha superato lo stesso processo di assicurazione qualità degli altri artefatti. Quindi si potrebbe essere certi che la configurazione sia di buona qualità e affidabile. Al contrario, una 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 è stato creato, quali valori contiene, quali funzionalità sono abilitate/disabilitate e chi era responsabile delle modifiche apportate alla configurazione. Potrebbe richiedere un certo sforzo mantenere la configurazione all'interno di un artefatto ed è una scelta progettuale da effettuare.
Pro e contro
Qui vorremmo evidenziare alcuni vantaggi e discutere alcuni svantaggi dell'approccio proposto.
Vantaggi
Caratteristiche della configurazione compilabile di un sistema distribuito completo:
- Controllo statico della configurazione. Questo offre un alto livello di fiducia che la configurazione è corretta date le restrizioni sui tipi.
- Linguaggio ricco di configurazione. Tipicamente, altri approcci di configurazione sono limitati al massimo alla sostituzione di variabili.
Utilizzando Scala, si possono utilizzare un'ampia gamma di funzionalità del linguaggio per migliorare la configurazione. Ad esempio, possiamo usare le traits per fornire valori predefiniti, oggetti per impostare ambiti diversi, possiamo fare riferimento avals definiti solo una volta nell'ambito esterno (DRY). È possibile utilizzare sequenze letterali o istanze di determinate classi (Seq,Mappa, ecc.). - DSL. Scala ha un buon supporto per i creatori di DSL. Si possono utilizzare queste funzionalità per stabilire un linguaggio di configurazione che sia più conveniente e user-friendly, affinché la configurazione finale sia almeno leggibile dagli utenti del dominio.
- Integrità e coerenza tra i nodi. Uno dei vantaggi di avere la configurazione per l'intero sistema distribuito in un unico luogo è 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 i tipi garantiscono che in tutte le configurazioni possibili corrette i nodi del sistema parleranno la stessa lingua. Ci sono dipendenze esplicite tra i nodi che rendono difficile dimenticare di fornire alcuni servizi.
- Alta qualità delle modifiche. L'approccio complessivo di far passare le modifiche di configurazione attraverso il normale processo di PR stabilisce alti standard di qualità anche in configurazione.
- Modifiche di configurazione simultanee. Ogni volta che apportiamo modifiche alla configurazione, il deployment automatico garantisce che tutti i nodi vengano aggiornati.
- Semplificazione dell'applicazione. L'applicazione non ha bisogno di analizzare e convalidare la configurazione e gestire valori di configurazione errati. Questo semplifica l’applicazione nel suo complesso. (Un certo aumento della complessità è nella configurazione stessa, ma è un compromesso consapevole verso la sicurezza.) È abbastanza semplice tornare alla configurazione ordinaria: basta aggiungere i pezzi mancanti. È più facile iniziare con una configurazione compilata e rimandare l'implementazione di ulteriori pezzi a un momento successivo.
- Configurazione versionata. Poiché le modifiche alla configurazione seguono lo stesso processo di sviluppo, otteniamo un artefatto con un versioning unico. Questo ci consente di tornare alla configurazione precedente, se necessario. Possiamo anche distribuire una configurazione utilizzata un anno fa e funzionerà esattamente nello 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.
- Modularità. Il framework proposto è modulare e i moduli possono essere combinati in vari modi per
supportare diverse configurazioni (disposizioni/layout). In particolare, è possibile avere una disposizione su un singolo nodo su piccola scala e una configurazione multi-nodo su larga scala. È ragionevole avere più layout di produzione. - Testing. Per scopi di test, si potrebbe implementare un servizio fittizio e usarlo come dipendenza in modo tipo-sicuro. Si potrebbero mantenere simultaneamente diversi layout di test con varie parti sostituite da mock.
- Test di integrazione. A volte nei sistemi distribuiti è difficile eseguire test di integrazione. Utilizzando l’approccio descritto per la configurazione tipo-sicura 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 della configurazione compilata è diverso dalla configurazione «normale» e potrebbe non soddisfare tutte le esigenze. Ecco alcuni degli svantaggi della configurazione compilata:
- Configurazione statica. Potrebbe non essere adatto a tutte le applicazioni. In alcuni casi è necessario riparare rapidamente la configurazione in produzione bypassando tutte le misure di sicurezza. Questo approccio rende le cose più difficili. Richiede compilazione e ridistribuzione dopo aver apportato qualsiasi modifica alla configurazione. Questa è sia una caratteristica che un onere.
- Generazione di configurazione. Quando la configurazione è generata da uno strumento di automazione, questo approccio richiede successiva compilazione (che a sua volta potrebbe fallire). Potrebbe richiedere uno sforzo aggiuntivo per integrare questo passaggio aggiuntivo nel sistema di build.
- Strumenti. Oggi ci sono molti strumenti in uso che si basano su configurazioni basate su testo. Alcuni di essi
non saranno applicabili quando la configurazione è compilata. - È necessario un cambiamento di mentalità. Gli sviluppatori e i DevOps sono familiari con i file di configurazione di testo. L'idea di compilare la configurazione potrebbe sembrare strana per loro.
- Prima di introdurre una configurazione compilabile, è necessario un processo di sviluppo software di alta qualità.
Ci sono alcune limitazioni dell'esempio implementato:
- Se forniamo una configurazione extra non richiesta dall'implementazione del nodo, il compilatore non ci aiuterà a rilevare l'implementazione assente. Questo potrebbe essere affrontato utilizzando
HListo ADT (case class) per la configurazione del nodo, anziché trait e Cake Pattern. - Dobbiamo fornire del boilerplate nel file di configurazione: (
pacchetto,importa,oggettodichiarazioni;
override def‘s per parametri che hanno valori predefiniti). Questo potrebbe essere parzialmente affrontato utilizzando un DSL. - In questo post non trattiamo la riconfigurazione dinamica dei cluster di nodi simili.
Conclusione
In questo post abbiamo discusso l'idea di rappresentare la configurazione direttamente nel codice sorgente in modo type-safe. L'approccio potrebbe essere utilizzato in molte applicazioni come sostituzione di configurazioni in xml e altri tipi di file di testo. Anche se il nostro esempio è 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 una configurazione robusta di pari qualità.
Questo approccio potrebbe essere esteso in vari modi:
- Si potrebbero utilizzare macro per eseguire la validazione della configurazione e fallire in fase di compilazione in caso di eventuali contratti logica aziendale non rispettati.
- Potrebbe essere implementato un DSL per rappresentare la configurazione in modo user-friendly per il dominio.
- Gestione dinamica delle risorse con regolazioni automatiche della configurazione. Ad esempio, quando regoliamo il numero di nodi del cluster possiamo voler (1) che i nodi ottengano una configurazione leggermente modificata; (2) che il gestore del cluster riceva le informazioni sui nuovi nodi.
Grazie
Vorrei ringraziare Andrey Saksonov, Pavel Popov e Anton Nehaev per i feedback ispiratori sul draft di questo post che mi hanno aiutato a renderlo più chiaro.
Fonte: habr.com
