Procesos distribuidos en Elixir

Juan Carlos Galvis
Bancolombia Tech
Published in
6 min readAug 30, 2022

En la búsqueda de tener sistemas altamente disponibles, nos enfrentamos a cómo resolver 3 desafíos:

  • La recuperación y tolerancia a fallos, buscando minimizar y aislar de la manera más eficiente los fallos que tenemos.
  • Cómo escalar el sistema de una manera elástica a las cargas, pudiendo incrementar o disminuir los recursos asignados ajustándose a los requisitos del momento dado.
  • Tener un sistema distribuido que pueda correrse en múltiples máquinas para que la carga de trabajo pueda repartirse entre las mismas y tener un mecanismo de recuperación en caso de que alguna falle.

Al usar un lenguaje como Erlang o abstracciones de este como Elixir, el lenguaje desde su construcción se preocupa por resolver estos 3 retos; por lo que, desde sus primitivas, ya se tienen las herramientas para diseñar sistemas altamente concurrentes.

La unidad básica de concurrencia en la BEAM es un proceso, este es diferente de los procesos que se tienen a nivel de sistema operativo. Se caracteriza por ser mucho mas ligero y tener menos CPU asignada, tan solo toma unos cuantos microsegundos crear un nuevo proceso y al ser cada uno un nuevo hilo de ejecución, se puede tener millones de procesos ejecutando tareas en paralelo.

Los procesos de la BEAM están aislados, no comparten memoria entre ellos y no son dependientes entre ellos. De esta manera, en caso de que uno de los procesos falle, no va a comprometer la ejecución ni el estado de los otros procesos y al tener segmentadas las unidades de ejecución, facilita la detección y mitigación de errores, pudiendo incluso llegar a reiniciar un único proceso.

Al no compartir estado, los procesos requieren un mecanismo para poder comunicarse entre ellos, esto se soluciona mediante el uso de mailbox, en el que un proceso 1 puede enviar mensajes asíncronos a un proceso 2 y este los almacena para su posterior procesamiento. Para lograr esto, cada proceso tiene una cola FIFO con todos los mensajes que ha recibido, estos siempre serán consumidos en orden de llegada y para eliminar un mensaje de la cola solo puede hacerse después de haberlo consumido.

Conceptualmente en Elixir contamos con un módulo Registry, el cual se encarga de almacenar los procesos asociados a un identificador definido por el desarrollador. Este concepto permite que los procesos sean iniciados dinámicamente, a su vez que estos eventualmente puedan terminar; teniendo siempre actualizado en el registry el pid del proceso mientras está en ejecución, esta entrada es eliminada cuando el proceso termina de forma normal, o se actualiza cuando el proceso es reiniciado tras una terminación de forma abrupta; y el registro contendrá el pid del nuevo proceso.

case Registry.lookup(Registry.MyRegistry, "some-name") do
[{pid, _val}] -> GenServer.call(pid, {:do_anything})
[] -> {:error, :process_not_found}
end

Las cargas de trabajo varían en el tiempo, no solo en periodos de meses o días, si no también de horas. Para lograr costo eficiencia en los sistemas diseñados se debe buscar que estos se puedan ajustar de manera dinámica a las cargas transaccionales entrantes, logrando así que la cantidad de recursos asignados sea la óptima. Una estrategia para lograr esto es escalar horizontalmente las aplicaciones, de esta manera se pueden tener múltiples réplicas de la aplicación y que la cantidad de replicas sea la que se ajuste a la necesidad del momento. Tecnologías como Kubernetes facilitan la configuración de este escalamiento a través de un recurso llamado horizontal pod autoescaling que permite definir reglas, máximos y mínimos para para realizar el escalamiento.

Horizontal Pod Autoscaling (HPA) escala el número de réplicas de pod. La mayoría de sus implementaciones utilizan la CPU y la memoria como factores para decidir si se debe aumentar o disminuir la cantidad de réplicas.

El modelo de escalamiento horizontal funciona muy bien para aplicaciones stateless, y que no tienen la necesidad de compartir el estado entre sus diferentes replicas, pero cuando se tienen la necesidad de tener procesos distribuidos y de una comunicación constante entre las distintas replicas, hay que buscar mecanismos que permitan esta interconexión. Elixir/Erlang, al ser un leguaje diseñado desde su origen para llevar acabo procesos distribuidos, desde sus primitivas se puede realizar la conexión de múltiples nodos en un clúster, cada nodo sería una réplica de la aplicación, luego de que los nodos estén conectados se pueden comunicar a través de procesos usando el sistema de mailbox.

De manera local se podría simular el escalado horizontal a partir de varios nodos, realizando su interconexión. Para iniciar un nodo se puede lograr con el siguiente comando:

$iex --sname node1@localhost

Usar el — same convierte la replica en un nodo con un identificador, si luego se tiene otra replica para conectar se hace de la siguiente manera:

$ iex --sname node2@localhost
iex(node2@localhost)1> Node.connect(:node1@localhost)
true

Con esto, ya ambos nodos quedan conectados y pueden consumir los diferentes procesos presentes en cada uno de ellos.

iex(node1@localhost)2> Node.list()
[:node2@localhost]
iex(node2@localhost)2> Node.list()
[:node1@localhost]

Esta conexión se debe realizar de una manera automática, ya que el Horizontal Pod Autoescaling de Kubernetes va a estar aumentado o disminuyendo de manera continua, por lo tanto se debe usar una librería que nos ayude con esta interconexión.

libcluster es una librería que ayuda con la formación de Clusters en Erlang, esto se puede lograr de manera dinámica usando Kubernetes, el se apalanca en las direcciones IP internas generadas por Kubernetes, para asignar los nombres de los nodos

A partir del escalamiento horizontal surge la necesidad de tener procesos distribuidos en los diferentes nodos, esto implica que un proceso pueda ser encontrado independiente del nodo que reciba la petición y del nodo donde se encuentre el proceso. El módulo Registry se queda corto para realizar ese registro de procesos dinámicos entre nodos, por lo cual surgen capacidades nativas y librerías que implementan el mismo concepto de registry pero esta vez distribuido. Algunas de éstas pueden ser horde o swarm. De esta forma se resuelve el problema de ubicar un proceso en un entorno distribuido, con horde se puede lograr de la siguiente manera:

  1. En la Application se debe configurar el registry en los children:
children = [
...
{Horde.Registry, [name: MyApp.MyRegistry, keys: :unique]}
...
]

2. De esta forma se puede registrar un proceso en el registry:

GenServer.start_link(__MODULE__, args, name: via_tuple(identifier))
...
defp via_tuple(identifier) do
{:via, Horde.Registry, {MyApp.MyRegistry, identifier}}
end

3. Finalmente en cualquier nodo del clúster se puede buscar y llamar al proceso registrado:

case Horde.Registry.lookup(via_tuple(identifier)) do
[{pid, _val}] -> GenServer.call(pid, {:do_anything})
[] -> {:error, :process_not_found}
end

Aplicar este tipo de implementaciones técnicas, dan pie a lo que se conoce como el principio de Location Transparecy; el cual permite el acceso a los recursos independientemente de su ubicación física o de red, en este caso los recursos son los procesos distribuidos.

Contar con tecnologías como Elixir facilitan la implementación de sistemas distribuidos, ya que es una característica inmersa en su diseño, si bien se usa una librería como registry distribuido, la acción de llamar un proceso con base en su pid si es nativo del lenguaje, no solo eso también se construyen sistemas responsivos por su capacidad de asignar ventanas de procesamiento a diversos procesos, sistemas tolerables a fallos gracias a su árbol de supervisión y la capacidad de reiniciar los procesos ante errores.

co-autor: alejobtc

--

--