En artículos anteriores hablamos del futuro de las arquitecturas de microservicios y de cómo service-mesh sería la tendencia clave en las mismas.

También analizamos a fondo Istio, la solución de service-mesh con plano de control más madura. En este post profundizaremos en Envoy, la solución de plano de datos y que además es internamente utilizada por Istio como sidecar-proxy.

*“Originally built at Lyft, Envoy is a high performance C++ distributed proxy designed for single services and applications, as well as a communication bus and “universal data plane” designed for large microservice “service mesh” architectures.” *Site de Envoy.

La primera versión opensource corresponde a Septiembre de 2016. A fecha de redacción del artículo están trabajando en la versión 1.7.0. La última estable, 1.6.0, corresponde al pasado mes de marzo.

El proyecto forma parte de la Cloud Native Computing Foundation y para que nos hagamos una idea de su capacidad:

*“Today, we run Envoy on thousands of nodes and over one hundred services, which in aggregate process over 2 million requests per second, powering every system at Lyft, either real time or otherwise.” *Announcing Envoy: C++ L7 proxy and communication bus.

Necesidad y objetivo

Una de las conclusiones de nuestro último post sobre Istio era que, aún siendo un producto robusto y estable, casi todas sus funcionalidades e integraciones se encontraban en fase alpha o beta.

Eso de por sí, hace que muchas empresas no estén dispuestas a arriesgarse a implantar Istio en proyectos productivos. Debíamos, por tanto, encontrar una alternativa y resulta que estaba justo delante de nuestras narices.

Si bien las soluciones de plano de control no están todavía lo suficientemente maduras, las de plano de datos sí. Y qué mejor solución de plano de datos que la que integra el propio Istio.

El proyecto está lo suficientemente maduro, es compatible con la tipología de entornos que utilizamos mayoritariamente (basados en Kubernetes) y, además, todo el conocimiento adquirido nos servirá para entender mejor cómo funcionan las tripas de Istio.

Así empezamos un viaje en el que construiremos una arquitectura de microservicios con Envoy como plano de datos buscando cubrir las necesidades típicas de dicha arquitectura (circuit breaking, balanceo de carga...).

Esto nos permitirá reemplazar las librerías de código destinadas a tal fin llegando a una arquitectura de microservicios políglota**.**

Por el camino, evaluaremos algunas de las funcionalidades de Envoy, veremos cómo configurarlo y entenderemos la necesidad de la implementación de un plano de control.

Requisitos mínimos

Para la POC se han utilizado diversas herramientas como Docker, Openshift, Gradle, Spring Boot…, de las cuales se suponen conocimientos previos. Queda por tanto fuera del alcance del post comentar sus detalles en profundidad, centrándonos en Envoy que es la finalidad de este artículo.

Introducción a Envoy

Antes de empezar a trastear con Envoy debemos entender qué podemos hacer con él y, sobre todo, cómo configurarlo. A grandes rasgos, podemos definir las siguientes áreas de funcionalidad:

A continuación podemos ver una configuración sencilla de Envoy de ejemplo en la cual hemos marcado las secciones en diferentes colores para poder explicarlas mejor.

Fuente

Lógicamente no podemos pararnos a explicar todos los elementos de configuración y todas las opciones posibles (para eso ya existe la documentación).

Pero sí podemos comentar las principales y/o más habituales. Eso debería darnos suficiente visión como para entender de qué va la cosa.

Aquí podremos ver las estadísticas de los diversos clusters, los diversos listeners existentes, resetear contadores, cambiar el nivel de log en caliente…

Ambas configuraciones son compatibles de forma que podemos proporcionar una configuración base durante el arranque y hacerle añadidos/cambios dinámicos durante la ejecución.

En nuestro caso, por simplicidad, utilizaremos principalmente configuraciones estáticas. Para el que esté interesado en las configuraciones dinámicas aquí se pueden encontrar diversos ejemplos de diferentes configuraciones.

En este caso estamos definiendo un listener en el puerto 10000 y que todas las llamadas a cualquier dominio (valor “*”) cuyo path empiece por “/” deben ser dirigidas al cluster *service_google. *Más adelante profundizaremos en estas configuraciones.

En este caso las peticiones a este cluster serán directamente redirigidas a la web de google.

En el site de Envoy tenemos la posibilidad de construir el proxy, pero la forma más sencilla de utilizarlo es usando la imagen docker proporcionada. Además nos proporciona una serie de configuraciones de ejemplo para poder levantar diversos entornos con Docker Compose.

Con esto ya tenemos unas nociones básicas para poder configurar Envoy y podemos empezar a montar nuestra ecosistema de microservicios. A lo largo de las diversas piezas iremos comentando el detalle de las diferentes configuraciones y opciones.

Arquitectura

La arquitectura final de nuestra aplicación se puede ver en la siguiente imagen:

Como vemos, en este caso la solución de orquestación que hemos elegido es Openshift (más en concreto minishift, que nos permite ejecutar un nodo de Openshift en local).

Las cajas azules representan los pods. Dentro de los pods tenemos los contenedores: los color crema para las aplicaciones y los color naranja para los proxies Envoy (como vemos tenemos varios pods dentro de los que se ejecutan dos contenedores).

Las flechas rojas representan el flujo de una petición de cliente mientras que las flechas verdes representan las comunicaciones para el descubrimiento de servicios. La flecha blanca representa las consultas al API de Kubernetes.

Estos son los diferentes pods de nuestro sistema:

Todas las aplicaciones escucharán en el puerto 8080, que será el que exponga el contenedor.

Mientras, en el caso de Envoy, expondremos 3 puertos (dependiendo de la configuración de cada Envoy):

Es importante remarcar que los microservicios (a excepción del servicio de descubrimiento que no incluye proxy Envoy) no comunican directamente con nadie, todas sus comunicaciones, tanto entrantes como salientes, se realizarán siempre a través del Envoy local.

Esto implica que las llamadas desde dentro de los microservicios serán siempre a *localhost:9900 *y que las llamadas a un pod serán siempre dirigidas al contenedor de Envoy, es decir, al puerto 10000.

Ahora que hemos visto los elementos que conformarán la arquitectura y cómo interactúan, vamos a recorrer el camino que realizamos para su construcción.

Consideraciones de los recursos

El código fuente de la POC se puede encontrar en el siguiente repositorio público. Si decidís montar el entorno podéis utilizar directamente las imágenes docker incluidas, ya que están disponibles de forma pública para su descarga en mi cuenta de dockerhub.

Si queréis construirlas, necesitaréis disponer de vuestra propia cuenta de dockerhub y adaptar la configuración (o publicarlas en el registry interno de openshift/kubernetes).

Dentro del repositorio podéis encontrar los siguientes subdirectorios:

Dentro de cada carpeta podemos encontrar los siguientes recursos comunes (no aplican a todos los casos):

Además, en el directorio raíz del repositorio podéis encontrar un script create_openshift.sh que crea la service account necesaria para sds e invoca la creación de los recursos de openshift de los diferente subdirectorios.

Con solo lanzar este script deberíais tener creado todo lo necesario en el entorno.

IMPORTANTE: para ejecutar este script es necesario tener permisos para creación de service accounts y asignación de roles y haber creado previamente el *namespace *envoy en el que ejecutaremos el script (la configuración asume en algunos puntos la ejecución en dicho namespace).

Imagen Docker Envoy

Como comentamos previamente, Envoy nos proporciona una imagen docker con la que poder trabajar. En nuestro caso, para no tener que reconstruir la imagen cada vez que hiciéramos un cambio de configuración, optamos por extender dicha imagen para que la configuración pueda ser cargada de forma externa utilizando un configmap. A continuación podéis ver el detalle del Dockerfile:

[code light="true"]
#Update envoy default image to load configuration dinamically from configmap

FROM envoyproxy/envoy:latest

#If not provided start the default envoy
ENV ENVOY_CONFIG_PATH /etc/envoy.yaml
ENV ENVOY_STARTUP_PARAMS ""

CMD /usr/local/bin/envoy -c $ENVOY_CONFIG_PATH $ENVOY_STARTUP_PARAMS
[/code]

Microservicio Pet

Empezaremos por el microservicio Pet porque es el más sencillo. Como comentamos en la introducción, no profundizaremos en las partes no relacionadas con Envoy para así mantener el objetivo del post.

A continuación vemos la configuración utilizada para el proxy Envoy:

[code light="true"]
static_resources:
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
codec_type: auto
stat_prefix: ingress_http
route_config:
name: ingress_route
virtual_hosts:
- name: service
domains:
- "*"
routes:
- match:
prefix: "/"
route:
cluster: local_service
http_filters:
- name: envoy.router
config: {}
clusters:
- name: local_service
connect_timeout: 0.50s
type: strict_dns
lb_policy: round_robin
hosts:
- socket_address:
address: 127.0.0.1
port_value: 8080
admin:
access_log_path: "/tmp/admin_access.log"
address:
socket_address:
address: 0.0.0.0
port_value: 8081
[/code]

Detalles importantes a remarcar:

Este listener será en el que se reciben las peticiones de los otros pods y que son derivadas al microservicio local pet. En este caso no se define un listener de salida porque el microservicio pet no genera llamadas salientes.

Microservicio Petstore

En este microservicio comentaremos más en detalle los diversos campos de cada uno de los elementos (cluster y listener) de la configuración, ya que encaja mejor aquí por el mayor nivel de complejidad de su configuración.

Para empezar, presentamos la configuración del Envoy de Petstore. Hemos marcado diferentes elementos con diversos colores para poder explicarlos mejor:

Puntos relevantes de esta configuración:

Lo que tenemos que saber aquí es que dicho microservicio nos permitirá conocer las instancias existentes para cada cluster. En el caso de los clusters local_service y sds, utilizando el tipo strict_dns*,* nosotros indicaremos explícitamente dónde encontrar dichas instancias.

Como dijimos previamente, para el primero indicamos la instancia local dentro del pod y para el segundo referenciamos la capa service de Openshift.

En el caso del microservicio pet es diferente, ya que utilizaremos el microservicio sds para obtener las instancias existentes. Para ello configuraremos el tipo de descubrimiento como eds y definiremos en la sección eds_cluster_config cómo utilizar el servicio de descubrimiento.

Debemos definir cuáles son los clusters del servicio de descubrimiento (cluster_names), con qué frecuencia actualizar el listado de instancias (refresh_delay) y qué API utilizar para la comunicación (api_type). El detalle de todos estos atributos se explica más en profundidad en la siguiente sección.

Microservicio SDS

Durante el proceso de configuración de Envoy, una de las problemáticas que surge es cómo descubrir las diferentes instancias que componen un cluster. Por las características de un sistema Cloud, sabemos que el número de instancias puede variar de forma dinámica y que sus IPs no son fijas.

La opción habitual para el descubrimiento en PaaS basados en Kubernetes (más allá del uso de librerías) es utilizar la capa service, cuyo nombre es resoluble en la red interna, cuenta con una IP fija y realiza balanceo entre las diversas instancias de una aplicación.

Esta opción no nos sirve si queremos controlar el algoritmo de balanceo desde Envoy entre otras cosas, ya que para Envoy habría una única instancia, que sería la capa service, y sería esta la que aplicaría el balanceo en destino.

Por su lado Envoy nos ofrece diversas formas de resolver el descubrimiento de instancias. No vamos a entrar en todas ellas, si queréis verlas en detalle podéis encontrarlas aquí. Lo más remarcable es la recomendación de utilizar SDS como solución:

“SDS is the preferred service discovery mechanism for a few reasons:

La solución basada en SDS para implementar un API REST que las instancias de Envoy invocarán para resolver las instancias que componen cluster. Lyft tiene una implementación en Python que utiliza DynamoDB.

Esta implementación se basa en que cada instancia, durante su arranque, se registre directamente en el servicio.

En nuestro caso, como utilizamos un orquestador basado en Kubernetes, y no queremos delegar el registro en la capa de aplicación, haremos nuestra propia implementación para que consulte las instancias existentes en el API de Kubernetes**.**

Actualmente Envoy tiene publicadas tres posibles APIs:

Esta API es la que permite definir la configuración de forma dinámica como comentamos en la sección de *Introducción a Envoy *y así mismo, es la que nos permite a través de su implementación generar un plano de control, que comentaremos posteriormente. Los elementos del modelo de datos están definidos utilizando la sintaxis de Protocol Buffers de Google.

Para quien no lo conozca Protocol Buffers nos permite definir un modelo de datos de forma agnóstica al lenguaje y luego, utilizando su compilador, generar el código fuente de dicha definición asociado el lenguaje que necesitemos.

Esta implementación incluye los típicos getters y setters*, *además de otros métodos habituales siguiendo buenas prácticas y patrones de diseño.

La principal ventaja de protocol buffers, más allá de una definición agnóstica del lenguaje, es que estas implementaciones están pensadas para optimizar la comunicación de red, reduciendo la cantidad de datos a su mínima expresión y optimizando el proceso de serialización.

Volviendo al tema que nos ocupa, la definición del modelo de datos la podéis encontrar en el siguiente repositorio y, dentro del mismo, aquí podéis encontrar la documentación con diversos diagramas de secuencia explicando la comunicación para las actualizaciones de recursos.

Por suerte para nosotros, la gente de Envoy ya ha subido a su repositorio la compilación para Java, con lo cual solo tendríamos que descargar este repositorio y construir el artefacto.

Una vez realizado este proceso, lo que tendremos que hacer es implementar una serie de métodos definidos por sus interfaces en los que devolveremos la información correspondiente a cada recurso en cada endpoint obteniendo esta información de la fuente que deseemos.

Existen dos formatos de comunicación para implementar dicha interfaz:

En nuestro caso, implementamos la versión 1, ya que al comienzo no teníamos constancia de la versión 2 y desconocíamos que la v1 estaba ya deprecada.

Posteriormente evaluamos implementar la v2, pero debido a que su complejidad excedía el alcance de una POC, optamos por mantener la v1. De todas formas, cualquier aplicación susceptible de desplegarse en producción deberá implementar la v2.

Actualmente, la mayoría de implementaciones que se pueden encontrar a través de la red son para la v1 ya deprecada. Es el caso de la anteriormente mencionada de Lyft, la desarrollada por Datawire para su producto de API Gateway basado en Envoy que podemos encontrar aquí. Existe, eso sí, una implementación de Envoy de la v2 hecha en go.

Como comentamos al inicio, nosotros no queremos delegar a la capa de aplicación el registro de la instancia durante el arranque, sino que queremos localizar esta responsabilidad en las capas inferiores aprovechando el registro de Kubernetes.

Por tanto, nuestro servicio de descubrimiento lo que hará es, cada vez que se soliciten las instancias correspondientes a un cluster, consultar dichas instancias en Kubernetes adaptando la respuesta para derivarla al proxy Envoy.

Para hacer todo esto necesitaremos que el contenedor tenga acceso al API de Kubernetes, que lógicamente está securizada.

La ejecución de cada contenedor está asociada a una serviceaccount. Los contenedores incorporan dentro del mismo el token correspondiente a dicha serviceaccount típicamente en /var/run/secrets/kubernetes.io/serviceaccount/token. Este token es el que usan para identificarse internamente en Kubernetes. Esto es el comportamiento habitual.

En este caso lo que haremos será aprovechar este token para autenticarnos contra el API de Kubernetes y así poder consultar la lista de instancias que corresponden a un servicio.

La serviceaccount asociada a los contenedores no tiene permisos para acceder al API por defecto, por eso lo que haremos será crear una serviceaccount con permisos de acceso al API e indicar en la ejecución de sds que deber ser con dicha serviceaccount.

Los siguientes comandos nos permiten crear la serviceaccount apisa con estos permisos:

[code light="true"]
oc create sa apisa -n envoy
oc adm policy add-role-to-user view -z apisa -n envoy
[/code]

Una vez tenemos la serviceaccount creada, bastará con referenciarla en el DeploymentConfig a través del parámetro serviceaccount.

De esta forma la aplicación puede leer el token interno del contenedor y usarlo para autenticarse contra el API. Lo bueno de esta aproximación es que no es necesario configurar ni usuarios ni contraseñas.

Atacaremos el siguiente endpoint, que proporcionando el nombre de un servicio y namespace nos devuelve las instancias correspondientes (endpoints en terminología del API).

[code light="true"]
GET /api/v1/namespaces/{namespace}/endpoints/{name}
[/code]

El detalle de este endpoint lo podemos encontrar aquí.

Un detalle importante es que tendremos que sobreescribir el puerto devuelto para indicar en el que se estará ejecutando la instancia Envoy (en nuestro caso el 10000).

De otra forma la petición no pasaría a través del proxy. Hemos sacado muchos de estos valores a la configuración de la aplicación que hemos externalizado en un configmap. Este es el detalle de la configuración:

[code light="true"]
kubernetes:
namespace: envoy
api:
host: https://192.168.42.69:8443
token:
path: /var/run/secrets/kubernetes.io/serviceaccount/token
envoy:
port: 10000
[/code]

IMPORTANTE: si decidís ejecutar la POC, tendréis que ajustar la propiedad kubernetes.api.host a la IP en la que se están ejecutando vuestro cluster.

La siguiente captura muestra un ejemplo de consulta al servicio SDS para el cluster petstore*:*

Como vemos, para cada instancia se incluyen valores como az, canary, load_balancing_weight*, *que en nuestro caso rellenamos con el mismo valor siempre ya que no tenemos interés en utilizarlos.

Es importante rellenarlos porque si no la instancia de Envoy no es capaz de interpretar el JSON de respuesta por lo que no registrará la instancia.

Para que las instancias Envoy utilicen este servicio es necesario especificar la configuración que indicamos en el anterior apartado en la sección clusters para petstore y que podemos ver a continuación:

[code light="true"]
clusters
- name: pet
connect_timeout: 0.50s
type: eds
lb_policy: round_robin
eds_cluster_config:
eds_config:
api_config_source:
api_type: REST_LEGACY
cluster_names: sds
refresh_delay: 60s
[/code]

El parámetro api_type referencia que versión de la API y formato de comunicación utilizar, para la v1 de la API corresponde el valor REST_LEGACY*.*

En el servicio de descubrimiento no se incluye instancia Envoy dentro del Pod porque no tiene sentido ya que no forma parte de nuestro ecosistema de microservicios; es solo un servicio asociado a Envoy que los propios proxies envoy consultarán. Referenciamos la capa service ya que si que puede resultar de interés levantar varias instancias.

Una posible mejora en el servicio, ya que cada instancia de Envoy va a lanzar peticiones de forma periódica para descubrir varios clusters, es utilizar algún tipo de caché y que sea el propio servicio de registro el que pregunte de forma periódica al API de Kubernetes y no ante cada petición externa.

Con esto ya tenemos resuelto e implementado un servicio de descubrimiento para Envoy que nos permite conocer las diversas instancias que componen un cluster utilizando para ello el registro interno de Kubernetes.

¡Wow, esto sí que ha sido un viaje intenso! En un momento nos hemos metido entre pecho y espalda toda una configuración de Envoy y hemos entrado a fondo en el servicio de descubrimiento, que no es para nada trivial.

Ahora ya tenemos lo necesario para empezar, pero no os equivoqueis, aún quedan muchas cosas interesantes por venir. En la segunda parte profundizaremos en el ingress, la gestión del fallo y la trazabilidad distribuida. Pero por ahora démonos un respiro para asentar conceptos.

Cuéntanos qué te parece.

Enviar.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.