Configuración del sistema distribuido compilado

Me gustaría contarles un mecanismo interesante para trabajar con la configuración de un sistema distribuido. La configuración se representa directamente en un lenguaje compilado (Scala) utilizando tipos seguros. Esta publicación proporciona un ejemplo de dicha configuración y analiza varios aspectos de la implementación de una configuración compilada en el proceso de desarrollo general.

Configuración del sistema distribuido compilado

(Inglés)

introducción

Construir un sistema distribuido confiable significa que todos los nodos utilicen la configuración correcta, sincronizados con otros nodos. Las tecnologías DevOps (terraform, ansible o algo así) se suelen utilizar para generar automáticamente archivos de configuración (a menudo específicos para cada nodo). También nos gustaría asegurarnos de que todos los nodos de comunicación utilicen protocolos idénticos (incluida la misma versión). De lo contrario, la incompatibilidad se incorporará a nuestro sistema distribuido. En el mundo JVM, una consecuencia de este requisito es que se debe utilizar en todas partes la misma versión de la biblioteca que contiene los mensajes de protocolo.

¿Qué pasa con las pruebas de un sistema distribuido? Por supuesto, asumimos que todos los componentes tienen pruebas unitarias antes de pasar a las pruebas de integración. (Para que podamos extrapolar los resultados de las pruebas al tiempo de ejecución, también debemos proporcionar un conjunto idéntico de bibliotecas en la etapa de prueba y en tiempo de ejecución).

Cuando se trabaja con pruebas de integración, suele ser más fácil utilizar el mismo classpath en todos los nodos. Todo lo que tenemos que hacer es asegurarnos de que se utilice el mismo classpath en tiempo de ejecución. (Si bien es completamente posible ejecutar diferentes nodos con diferentes classpaths, esto agrega complejidad a la configuración general y dificultades con las pruebas de implementación e integración). Para los propósitos de esta publicación, asumimos que todos los nodos usarán la misma classpath.

La configuración evoluciona con la aplicación. Utilizamos versiones para identificar diferentes etapas de evolución del programa. Parece lógico identificar también diferentes versiones de configuraciones. Y coloque la configuración en sí en el sistema de control de versiones. Si solo hay una configuración en producción, simplemente podemos usar el número de versión. Si utilizamos muchas instancias de producción, entonces necesitaremos varias
ramas de configuración y una etiqueta adicional además de la versión (por ejemplo, el nombre de la rama). De esta manera podemos identificar claramente la configuración exacta. Cada identificador de configuración corresponde de forma única a una combinación específica de nodos distribuidos, puertos, recursos externos y versiones de biblioteca. A efectos de este post asumiremos que existe una sola rama y podemos identificar la configuración de la forma habitual utilizando tres números separados por un punto (1.2.3).

En entornos modernos, los archivos de configuración rara vez se crean manualmente. Lo más frecuente es que se generen durante el despliegue y ya no se toquen (para que no rompas nada). Surge una pregunta natural: ¿por qué seguimos usando el formato de texto para almacenar la configuración? Una alternativa viable parece ser la capacidad de utilizar código normal para la configuración y beneficiarse de las comprobaciones en tiempo de compilación.

En esta publicación exploraremos la idea de representar una configuración dentro de un artefacto compilado.

Configuración compilada

Esta sección proporciona un ejemplo de una configuración compilada estática. Se implementan dos servicios simples: el servicio de eco y el cliente del servicio de eco. A partir de estos dos servicios se ensamblan dos opciones de sistema. En una opción, ambos servicios están ubicados en el mismo nodo, en otra opción, en nodos diferentes.

Normalmente un sistema distribuido contiene varios nodos. Puedes identificar nodos usando valores de algún 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

Los nodos desempeñan varias funciones, ejecutan servicios y se pueden establecer conexiones TCP/HTTP entre ellos.

Para describir una conexión TCP necesitamos al menos un número de puerto. También nos gustaría reflejar el protocolo admitido en ese puerto para garantizar que tanto el cliente como el servidor utilicen el mismo protocolo. Describiremos la conexión usando la siguiente clase:

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

donde Port - solo un número entero Int indicando el rango de valores aceptables:

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

tipos refinados

Ver biblioteca refinado и mi reportar. En resumen, la biblioteca le permite agregar restricciones a los tipos que se verifican en el momento de la compilación. En este caso, los valores de número de puerto válidos son números enteros de 16 bits. Para una configuración compilada, el uso de la biblioteca refinada no es obligatorio, pero mejora la capacidad del compilador para verificar la configuración.

Para los protocolos HTTP (REST), además del número de puerto, es posible que también necesitemos la ruta al servicio:

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

Tipos fantasma

Para identificar el protocolo en tiempo de compilación, utilizamos un parámetro de tipo que no se utiliza dentro de la clase. Esta decisión se debe al hecho de que no utilizamos una instancia de protocolo en tiempo de ejecución, pero nos gustaría que el compilador verificara la compatibilidad del protocolo. Al especificar el protocolo, no podremos pasar un servicio inapropiado como dependencia.

Uno de los protocolos comunes es la API REST con serialización Json:

sealed trait JsonHttpRestProtocol[RequestMessage, ResponseMessage]

donde RequestMessage - tipo de solicitud, ResponseMessage — tipo de respuesta.
Por supuesto, podemos utilizar otras descripciones de protocolo que proporcionen la precisión de descripción que necesitamos.

Para los fines de esta publicación, utilizaremos una versión simplificada del protocolo:

sealed trait SimpleHttpGetRest[RequestMessage, ResponseMessage]

Aquí la solicitud es una cadena agregada a la URL y la respuesta es la cadena devuelta en el cuerpo de la respuesta HTTP.

La configuración del servicio se describe mediante el nombre del servicio, los puertos y las dependencias. Estos elementos se pueden representar en Scala de varias maneras (por ejemplo, HList-s, tipos de datos algebraicos). Para los propósitos de esta publicación, usaremos Cake Pattern y representaremos módulos usando trait'ov. (El patrón de pastel no es un elemento obligatorio de este enfoque. Es simplemente una posible implementación).

Las dependencias entre servicios se pueden representar como métodos que devuelven puertos. EndPointde otros nodos:

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

Para crear un servicio de eco, todo lo que necesita es un número de puerto y una indicación de que el puerto admite el protocolo de eco. Es posible que no especifiquemos un puerto específico, porque... Los rasgos le permiten declarar métodos sin implementación (métodos abstractos). En este caso, al crear una configuración concreta, el compilador nos pediría que proporcionemos una implementación del método abstracto y un número de puerto. Dado que hemos implementado el método, al crear una configuración específica, no podemos especificar un puerto diferente. Se utilizará el valor predeterminado.

En la configuración del cliente declaramos una dependencia del servicio echo:

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

La dependencia es del mismo tipo que el servicio exportado. echoService. En particular, en el cliente echo requerimos el mismo protocolo. Por tanto, al conectar dos servicios, podemos estar seguros de que todo funcionará correctamente.

Implementación de servicios

Se requiere una función para iniciar y detener el servicio. (La capacidad de detener el servicio es fundamental para las pruebas). Nuevamente, hay varias opciones para implementar dicha característica (por ejemplo, podríamos usar clases de tipos basadas en el tipo de configuración). Para los propósitos de esta publicación usaremos el patrón de pastel. Representaremos el servicio usando una clase. cats.Resource, porque Esta clase ya proporciona medios para garantizar de forma segura la liberación de recursos en caso de problemas. Para obtener un recurso, debemos proporcionar configuración y un contexto de tiempo de ejecución listo para usar. La función de inicio del servicio puede verse así:

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

donde

  • Config — tipo de configuración para este servicio
  • AddressResolver — un objeto de tiempo de ejecución que le permite conocer las direcciones de otros nodos (ver más abajo)

y otros tipos de la biblioteca cats:

  • F[_] — tipo de efecto (en el caso más simple F[A] podría ser simplemente una función () => A. En esta publicación usaremos cats.IO.)
  • Reader[A,B] - más o menos sinónimo de función A => B
  • cats.Resource - un recurso que se puede obtener y liberar
  • Timer — temporizador (te permite quedarte dormido un rato y medir intervalos de tiempo)
  • ContextShift - analógico ExecutionContext
  • Applicative — una clase de tipo de efecto que te permite combinar efectos individuales (casi una mónada). En aplicaciones más complejas parece mejor usar Monad/ConcurrentEffect.

Usando esta firma de función podemos implementar varios servicios. Por ejemplo, un servicio que no hace nada:

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

(Cm. codigo fuente, en el que se implementan otros servicios - servicio de eco, cliente de eco
и controladores de por vida.)

Un nodo es un objeto que puede lanzar varios servicios (el lanzamiento de una cadena de recursos está garantizado por Cake Pattern):

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

Tenga en cuenta que estamos especificando el tipo exacto de configuración que se requiere para este nodo. Si olvidamos especificar uno de los tipos de configuración requeridos por un servicio en particular, habrá un error de compilación. Además, no podremos iniciar un nodo a menos que proporcionemos algún objeto del tipo apropiado con todos los datos necesarios.

Resolución de nombre de host

Para conectarnos a un host remoto, necesitamos una dirección IP real. Es posible que la dirección se conozca más tarde que el resto de la configuración. Entonces necesitamos una función que asigne el ID del nodo a una dirección:

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

Hay varias formas de implementar esta función:

  1. Si conocemos las direcciones antes de la implementación, entonces podemos generar código Scala con
    direcciones y luego ejecutar la compilación. Esto compilará y ejecutará pruebas.
    En este caso, la función se conocerá estáticamente y se podrá representar en código como un mapeo. Map[NodeId, NodeAddress].
  2. En algunos casos, la dirección real sólo se conoce después de que se ha iniciado el nodo.
    En este caso, podemos implementar un "servicio de descubrimiento" que se ejecute antes que otros nodos y todos los nodos se registrarán en este servicio y solicitarán las direcciones de otros nodos.
  3. Si podemos modificar /etc/hosts, entonces puedes usar nombres de host predefinidos (como my-project-main-node и echo-backend) y simplemente vincular estos nombres
    con direcciones IP durante la implementación.

En esta publicación no consideraremos estos casos con más detalle. Para nuestro
En un ejemplo de juguete, todos los nodos tendrán la misma dirección IP. 127.0.0.1.

A continuación, consideramos dos opciones para un sistema distribuido:

  1. Colocar todos los servicios en un nodo.
  2. Y alojar el servicio de eco y el cliente de eco en diferentes nodos.

Configuración para un nodo:

Configuración de un solo nodo

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

El objeto implementa la configuración tanto del cliente como del servidor. También se utiliza una configuración de tiempo de vida para que después del intervalo lifetime terminar el programa. (Ctrl-C también funciona y libera todos los recursos correctamente).

El mismo conjunto de características de configuración e implementación se puede utilizar para crear un sistema que consta de dos nodos separados:

Configuración de dos nodos

  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! Observe cómo están vinculados los servicios. Especificamos un servicio implementado por un nodo como una implementación del método de dependencia de otro nodo. El compilador verifica el tipo de dependencia, porque contiene el tipo de protocolo. Cuando se ejecute, la dependencia contendrá el ID del nodo de destino correcto. Gracias a este esquema, especificamos el número de puerto exactamente una vez y siempre tenemos la garantía de referirnos al puerto correcto.

Implementación de dos nodos del sistema.

Para esta configuración, utilizamos las mismas implementaciones de servicios sin cambios. La única diferencia es que ahora tenemos dos objetos que implementan diferentes conjuntos de servicios:

  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
  }

El primer nodo implementa el servidor y solo necesita la configuración del servidor. El segundo nodo implementa el cliente y utiliza una parte diferente de la configuración. Además, ambos nodos necesitan gestión de por vida. El nodo del servidor se ejecuta indefinidamente hasta que se detiene. SIGTERM'om, y el nodo cliente finaliza después de un tiempo. Cm. aplicación de inicio.

Proceso de desarrollo general

Veamos cómo este enfoque de configuración afecta el proceso de desarrollo general.

La configuración se compilará junto con el resto del código y se generará un artefacto (.jar). Parece tener sentido poner la configuración en un artefacto separado. Esto se debe a que podemos tener múltiples configuraciones basadas en el mismo código. Nuevamente, es posible generar artefactos correspondientes a diferentes ramas de configuración. Las dependencias de versiones específicas de bibliotecas se guardan junto con la configuración, y estas versiones se guardan para siempre cada vez que decidimos implementar esa versión de la configuración.

Cualquier cambio de configuración se convierte en un cambio de código. Y por lo tanto, cada
el cambio estará cubierto por el proceso normal de garantía de calidad:

Ticket en el rastreador de errores -> PR -> revisar -> fusionar con ramas relevantes ->
integración -> implementación

Las principales consecuencias de implementar una configuración compilada son:

  1. La configuración será coherente en todos los nodos del sistema distribuido. Debido a que todos los nodos reciben la misma configuración de una única fuente.

  2. Es problemático cambiar la configuración en solo uno de los nodos. Por lo tanto, es poco probable que se produzca una "desviación de la configuración".

  3. Se vuelve más difícil realizar pequeños cambios en la configuración.

  4. La mayoría de los cambios de configuración se producirán como parte del proceso de desarrollo general y estarán sujetos a revisión.

¿Necesito un repositorio separado para almacenar la configuración de producción? Esta configuración puede contener contraseñas y otra información confidencial a la que nos gustaría restringir el acceso. En base a esto, parece tener sentido almacenar la configuración final en un repositorio separado. Puede dividir la configuración en dos partes: una que contenga ajustes de configuración accesibles públicamente y otra que contenga ajustes restringidos. Esto permitirá que la mayoría de los desarrolladores tengan acceso a configuraciones comunes. Esta separación es fácil de lograr utilizando rasgos intermedios que contienen valores predeterminados.

Posibles variaciones

Intentemos comparar la configuración compilada con algunas alternativas comunes:

  1. Archivo de texto en la máquina de destino.
  2. Almacén centralizado de valores clave (etcd/zookeeper).
  3. Componentes del proceso que se pueden reconfigurar/reiniciar sin reiniciar el proceso.
  4. Almacenar la configuración fuera del control de versiones y artefactos.

Los archivos de texto brindan una flexibilidad significativa en términos de pequeños cambios. El administrador del sistema puede iniciar sesión en el nodo remoto, realizar cambios en los archivos apropiados y reiniciar el servicio. Sin embargo, para sistemas grandes, tal flexibilidad puede no ser deseable. Los cambios realizados no dejan huellas en otros sistemas. Nadie revisa los cambios. Es difícil determinar quién realizó exactamente los cambios y por qué motivo. Los cambios no se prueban. Si el sistema está distribuido, es posible que el administrador se olvide de realizar el cambio correspondiente en otros nodos.

(También cabe señalar que usar una configuración compilada no cierra la posibilidad de usar archivos de texto en el futuro. Será suficiente agregar un analizador y validador que produzca el mismo tipo como salida Config, y puedes usar archivos de texto. De ello se deduce inmediatamente que la complejidad de un sistema con una configuración compilada es algo menor que la complejidad de un sistema que utiliza archivos de texto, porque Los archivos de texto requieren código adicional.)

Un almacén centralizado de valores clave es un buen mecanismo para distribuir metaparámetros de una aplicación distribuida. Necesitamos decidir cuáles son los parámetros de configuración y cuáles son solo datos. Tengamos una función C => A => By los parámetros C rara vez cambia, y los datos A - a menudo. En este caso podemos decir que C - parámetros de configuración, y A - datos. Parece que los parámetros de configuración difieren de los datos en que generalmente cambian con menos frecuencia que los datos. Además, los datos suelen proceder de una fuente (del usuario) y los parámetros de configuración de otra (del administrador del sistema).

Si es necesario actualizar parámetros que rara vez cambian sin reiniciar el programa, esto a menudo puede complicar el programa, porque de alguna manera necesitaremos entregar parámetros, almacenar, analizar y verificar, y procesar valores incorrectos. Por lo tanto, desde el punto de vista de reducir la complejidad del programa, tiene sentido reducir la cantidad de parámetros que pueden cambiar durante la ejecución del programa (o no admitir dichos parámetros en absoluto).

Para los propósitos de esta publicación, diferenciaremos entre parámetros estáticos y dinámicos. Si la lógica del servicio requiere cambiar parámetros durante la operación del programa, entonces llamaremos a dichos parámetros dinámicos. De lo contrario, las opciones son estáticas y se pueden configurar utilizando la configuración compilada. Para la reconfiguración dinámica, es posible que necesitemos un mecanismo para reiniciar partes del programa con nuevos parámetros, similar a cómo se reinician los procesos del sistema operativo. (En nuestra opinión, es aconsejable evitar la reconfiguración en tiempo real, ya que esto aumenta la complejidad del sistema. Si es posible, es mejor utilizar las capacidades estándar del sistema operativo para reiniciar procesos).

Un aspecto importante del uso de la configuración estática que hace que las personas consideren la reconfiguración dinámica es el tiempo que tarda el sistema en reiniciarse después de una actualización de la configuración (tiempo de inactividad). De hecho, si necesitamos realizar cambios en la configuración estática, tendremos que reiniciar el sistema para que los nuevos valores surtan efecto. El problema del tiempo de inactividad varía en gravedad según los diferentes sistemas. En algunos casos, puede programar un reinicio en un momento en que la carga sea mínima. Si necesita brindar un servicio continuo, puede implementar Drenaje de conexión AWS ELB. Al mismo tiempo, cuando necesitamos reiniciar el sistema, iniciamos una instancia paralela de este sistema, le cambiamos el balanceador y esperamos a que se completen las conexiones anteriores. Una vez finalizadas todas las conexiones antiguas, cerramos la instancia anterior del sistema.

Consideremos ahora la cuestión de almacenar la configuración dentro o fuera del artefacto. Si almacenamos la configuración dentro de un artefacto, al menos tuvimos la oportunidad de verificar la corrección de la configuración durante el ensamblaje del artefacto. Si la configuración está fuera del artefacto controlado, es difícil rastrear quién realizó cambios en este archivo y por qué. ¿Qué tan importante es? En nuestra opinión, para muchos sistemas de producción es importante disponer de una configuración estable y de alta calidad.

La versión de un artefacto permite determinar cuándo se creó, qué valores contiene, qué funciones están habilitadas/deshabilitadas y quién es responsable de cualquier cambio en la configuración. Por supuesto, almacenar la configuración dentro de un artefacto requiere cierto esfuerzo, por lo que es necesario tomar una decisión informada.

Pros y contras

Me gustaría detenerme en los pros y los contras de la tecnología propuesta.

Ventajas

A continuación se muestra una lista de las características principales de una configuración de sistema distribuido compilado:

  1. Comprobación de configuración estática. Le permite estar seguro de que
    la configuración es correcta.
  2. Lenguaje de configuración rico. Normalmente, otros métodos de configuración se limitan como máximo a la sustitución de variables de cadena. Al utilizar Scala, hay disponible una amplia gama de funciones de idioma para mejorar su configuración. Por ejemplo podemos usar
    rasgos para los valores predeterminados, usando objetos para agrupar parámetros, podemos referirnos a los valores declarados solo una vez (DRY) en el ámbito adjunto. Puede crear instancias de cualquier clase directamente dentro de la configuración (Seq, Map, clases personalizadas).
  3. DSL. Scala tiene una serie de características de lenguaje que facilitan la creación de un DSL. Es posible aprovechar estas características e implementar un lenguaje de configuración que sea más conveniente para el grupo objetivo de usuarios, de modo que la configuración sea al menos legible por expertos en el dominio. Los especialistas pueden, por ejemplo, participar en el proceso de revisión de la configuración.
  4. Integridad y sincronía entre nodos. Una de las ventajas de tener la configuración de un sistema distribuido completo almacenada en un único punto es que todos los valores se declaran exactamente una vez y luego se reutilizan donde sean necesarios. El uso de tipos fantasma para declarar puertos garantiza que los nodos utilicen protocolos compatibles en todas las configuraciones correctas del sistema. Tener dependencias obligatorias explícitas entre nodos garantiza que todos los servicios estén conectados.
  5. Cambios de alta calidad. Realizar cambios en la configuración utilizando un proceso de desarrollo común permite alcanzar altos estándares de calidad también para la configuración.
  6. Actualización de configuración simultánea. La implementación automática del sistema después de los cambios de configuración garantiza que todos los nodos estén actualizados.
  7. Simplificando la aplicación. La aplicación no necesita análisis, verificación de configuración ni manejo de valores incorrectos. Esto reduce la complejidad de la aplicación. (Parte de la complejidad de la configuración observada en nuestro ejemplo no es un atributo de la configuración compilada, sino solo una decisión consciente impulsada por el deseo de proporcionar una mayor seguridad de tipos). Es bastante fácil volver a la configuración habitual: simplemente implemente lo que falta. partes. Por lo tanto, puede, por ejemplo, comenzar con una configuración compilada, posponiendo la implementación de partes innecesarias hasta el momento en que realmente sea necesario.
  8. Configuración verificada. Dado que los cambios de configuración siguen el destino habitual de cualquier otro cambio, el resultado que obtenemos es un artefacto con una versión única. Esto nos permite, por ejemplo, volver a una versión anterior de la configuración si fuera necesario. Incluso podemos usar la configuración de hace un año y el sistema funcionará exactamente igual. Una configuración estable mejora la previsibilidad y confiabilidad de un sistema distribuido. Dado que la configuración se fija en la etapa de compilación, es bastante difícil falsificarla en producción.
  9. Modularidad. El marco propuesto es modular y los módulos se pueden combinar de diferentes maneras para crear diferentes sistemas. En particular, puede configurar el sistema para que se ejecute en un único nodo en una realización y en varios nodos en otra. Puede crear varias configuraciones para instancias de producción del sistema.
  10. Pruebas. Al reemplazar servicios individuales con objetos simulados, puede obtener varias versiones del sistema que son convenientes para realizar pruebas.
  11. Pruebas de integración. Tener una configuración única para todo el sistema distribuido hace posible ejecutar todos los componentes en un entorno controlado como parte de las pruebas de integración. Es fácil emular, por ejemplo, una situación en la que algunos nodos se vuelven accesibles.

Desventajas y limitaciones

La configuración compilada difiere de otros enfoques de configuración y puede no ser adecuada para algunas aplicaciones. A continuación se presentan algunas desventajas:

  1. Configuración estática. A veces es necesario corregir rápidamente la configuración en producción, sin pasar por todos los mecanismos de protección. Con este enfoque puede resultar más difícil. Como mínimo, seguirá siendo necesaria la compilación y la implementación automática. Esta es a la vez una característica útil del enfoque y una desventaja en algunos casos.
  2. Generación de configuración. En caso de que el archivo de configuración sea generado por una herramienta automática, es posible que se requieran esfuerzos adicionales para integrar el script de compilación.
  3. Herramientas. Actualmente, las utilidades y técnicas diseñadas para trabajar con la configuración se basan en archivos de texto. No todas estas utilidades/técnicas estarán disponibles en una configuración compilada.
  4. Es necesario un cambio de actitudes. Los desarrolladores y DevOps están acostumbrados a los archivos de texto. La sola idea de compilar una configuración puede resultar algo inesperada e inusual y provocar rechazo.
  5. Se requiere un proceso de desarrollo de alta calidad. Para utilizar cómodamente la configuración compilada, es necesaria la automatización completa del proceso de creación e implementación de la aplicación (CI/CD). De lo contrario, será bastante inconveniente.

Detengámonos también en una serie de limitaciones del ejemplo considerado que no están relacionados con la idea de una configuración compilada:

  1. Si proporcionamos información de configuración innecesaria que no utiliza el nodo, entonces el compilador no nos ayudará a detectar la implementación faltante. Este problema se puede resolver abandonando el patrón Cake y utilizando tipos más rígidos, por ejemplo, HList o tipos de datos algebraicos (clases de casos) para representar la configuración.
  2. Hay líneas en el archivo de configuración que no están relacionadas con la configuración en sí: (package, import, declaraciones de objetos; override def's para parámetros que tienen valores predeterminados). Esto se puede evitar en parte si implementas tu propio DSL. Además, otros tipos de configuración (por ejemplo, XML) también imponen ciertas restricciones en la estructura del archivo.
  3. Para los propósitos de esta publicación, no estamos considerando la reconfiguración dinámica de un grupo de nodos similares.

Conclusión

En esta publicación, exploramos la idea de representar la configuración en el código fuente utilizando las capacidades avanzadas del sistema de tipos Scala. Este enfoque se puede utilizar en varias aplicaciones como reemplazo de los métodos de configuración tradicionales basados ​​en archivos xml o de texto. Aunque nuestro ejemplo está implementado en Scala, las mismas ideas se pueden transferir a otros lenguajes compilados (como Kotlin, C#, Swift,...). Puede probar este enfoque en uno de los siguientes proyectos y, si no funciona, pasar al archivo de texto y agregar las partes que faltan.

Naturalmente, una configuración compilada requiere un proceso de desarrollo de alta calidad. A cambio, se garantiza una alta calidad y fiabilidad de las configuraciones.

El enfoque considerado se puede ampliar:

  1. Puede utilizar macros para realizar comprobaciones en tiempo de compilación.
  2. Puede implementar un DSL para presentar la configuración de manera que sea accesible para los usuarios finales.
  3. Puede implementar una gestión dinámica de recursos con ajuste automático de configuración. Por ejemplo, cambiar la cantidad de nodos en un clúster requiere que (1) cada nodo reciba una configuración ligeramente diferente; (2) el administrador del clúster recibió información sobre nuevos nodos.

Agradecimientos

Quisiera agradecer a Andrei Saksonov, Pavel Popov y Anton Nekhaev por su crítica constructiva al proyecto de artículo.

Fuente: habr.com

Añadir un comentario