Gomezh, creando un service mesh desde cero

Ya hemos hablado en el blog de qué es el Service Mesh y cómo puede ayudar a nuestras arquitecturas de microservicios. En este post nos embarcamos en la creación de un pequeño service mesh que potencie nuestra arquitectura de microservicios (MSA) haciendo uso del patrón sidecar.

Los objetivos que tenemos con este desarrollo son, por un lado, conocer los mecanismos usados por este tipo de componentes para hacer que las aplicaciones y los desarrolladores se centren en la lógica de negocio y se puedan olvidar de temas complejos inherentes a los microservicios.

Por otro, ofrecer ayuda a equipos de desarrollo centrados en funcionalidad y no en características técnicas y adentrarnos en uno de los lenguajes de moda para entornos cloud, Golang.

La elección del lenguaje

La elección del lenguaje es importante, el patrón sidecar coloca un contenedor de “utilidad” al lado de cada microservicio interceptando sus comunicaciones de entrada/salida, por lo que este software debe ser liviano e “inocuo”.

Golang se está postulando como uno de los lenguajes más usados en los entornos cloud. Kubernetes, Openshift o los mismos Istio y Conduit tienen componentes desarrollados en Golang, por lo que es el elegido para nuestro estudio.

La elección de la plataforma cloud

Kubernetes es ya un estándar de facto en la orquestación de contenedores. Tanto es así que Docker ya lo incluye en sus aplicaciones de escritorio.

Para esta prueba de concepto, el tiempo es limitado. Kubernetes ofrece ayudas en la construcción de microservicios como el servicio de descubrimiento y el balanceo entre contenedores.

Sin embargo, Openshift añade una consola web, que nos ayudará con la monitorización del sistema, así que usaremos el producto de Red Hat.

La elección del nombre

Todo experimento necesita ser un nombre! Para el nuestro hemos metido en la coctelera el lenguaje Go, el  patrón “Service Mesh” y una cucharada de jugo latinizador… El resultado, un nombre fresco y divertido: Gomezh ;-)

Diseño y características

Manos a la obra, en nuestro caso, evitaremos características como la gestión de reintentos o circuit breaking y nos centraremos en tres de las muchas funcionalidades que puede ofrecer un service mesh:

  • Seguridad: nuestro proxy/sidecar se encargará de validar las cabeceras de seguridad. En este caso, debe leer la cabecera http con nombre Authorization, extraer el “token de autenticación” y validarlo. Si el microservicio llama a otros servicios, se debería propagar esa misma cabecera o el contexto de seguridad.
  • Métricas: el proxy se encargará de sacar por logs los tiempos que tardan las peticiones en ser procesadas.
  • Trazabilidad: queremos mantener una trazabilidad entre las llamadas a los microservicios.

Crearemos una aplicación de ejemplo basada en arquitecturas de microservicios para testear nuestro componente y la aplicación tendrá un API REST muy simple para consultar información de libros.

Consta de un microservicio (books) que devuelve los datos de libros (título, autor, año) y un segundo microservicio (stars) que permite obtener la clasificación del libro.

El microservicio books llama, mediante HTTP, al microservicio stars para completar la información devuelta. Los servicios estarán desarrollados con Spring-Boot en lenguaje Java y Groovy.

Representación de la Arquitectura de microservicios junto a los proxy sidecar.

Nota: para evitar complejidad, daremos por hecho que los microservicios residen en el puerto 8081 y nuestro proxy escuchará en el 8080 (entrada) y el 8082 (salida).

Desarrollo

El componente que vamos a desarrollar, y que se instalará como contenedor sidecar, no deja de ser un proxy que intercepta el tráfico entrante y saliente del microservicio. Lo primero que debemos hacer es publicar dos controladores: uno de entrada (ingress) y otro de salida (egress).

ingressServer: = & http.Server {
    Addr: ":8080",
    Handler: ingressHandler,
    ReadTimeout: 5 * time.Second,
    WriteTimeout: 5 * time.Second
}

egressServer: = & http.Server {
    Addr: ":8082",
    Handler: egressHandler,
    ReadTimeout: 5 * time.Second,
    WriteTimeout: 5 * time.Second
}

go egressServer.ListenAndServe()
ingressServer.ListenAndServe()

Canal de entrada

El controlador de entrada escuchará en el puerto 8080 y deberá hacer un reenvío de peticiones al microservicio. Este es el código de reenvío de peticiones de entrada:

func forwardRequest(w http.ResponseWriter, req *http.Request) (*http.Response, error) {
   body, err := ioutil.ReadAll(req.Body)
   if err != nil {
      return nil, err
   }

   url := fmt.Sprintf("%s://%s%s", "http", "localhost:8081", req.RequestURI)

   proxyReq, err := http.NewRequest(req.Method, url, bytes.NewReader(body))

   proxyReq.Header = req.Header

   httpClient := http.Client{}
   resp, err := httpClient.Do(proxyReq)
   if err != nil {
      return nil, err
   }

   return resp, nil
}

Como podéis apreciar, tomamos como convención que el microservicio escuche en el puerto 8081 del pod. Kubernetes permite instanciar dos contenedores en un mismo POD compartiendo red, lo que evita la necesidad de usar un service discovery.

Usaremos esta funcionalidad de manera que podamos contactar entre los dos contenedores mediante el interfaz loopback (localhost).

Validación de la autenticación

El controlador de entrada debe validar la autenticación de la petición. Un caso de uso muy típico de implementación de seguridad en este tipo de arquitecturas es la inyección del contexto de seguridad en formato JWT en la cabecera estándar Authorization.

La validación de la seguridad la realizaremos mediante un middleware que deniegue el paso a las peticiones no autenticadas o con autenticación incorrecta y lo añadiremos al flujo de entrada (ingress).

func SecurityMiddleware(next http.Handler) http.Handler {
   return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
      log.Println("Executing security middleware")
      if (!isValidCredential(r.Header.Get("Authorization"))) {
         http.Error(w, "invalid credentials", http.StatusForbidden)
         return
      }
      next.ServeHTTP(w, r)
   })
}

Canal de salida (Egress)

De igual manera, las comunicaciones de salida también deben ser interceptadas por nuestro proxy. Se crea un segundo controlador en el puerto 8082 que intercepte las llamadas salientes. De nuevo, haremos uso del api net/http para reenviar peticiones.

Cuando el servicio books llame a http://stars:8081/stars/{bookId} la petición será interceptada antes de que el servicio de resolución de Kubernetes traduzca el DNS stars en una ruta final.

NOTA: El proxy diseñado es transparente, interceptará las comunicaciones de tal manera que el cliente invocante no tiene porque conocer la existencia del mismo, más adelante explicaremos cómo se consigue esto.

Propagación del contexto de seguridad

Para propagar las credenciales entre los microservicios (books -> stars), el proxy necesita crear un contexto compartido entre la entrada y la salida. Para ello usaremos una memoria compartida con exclusión mutua.

type AuthMap struct {
   sync.RWMutex
   values map[uint64]string
}

A la entrada, guardaremos el JWT en nuestra memoria y a la salida lo recogeremos para propagarlo. Pero… ¿cómo relacionamos la petición de entrada con la de salida aunque ingress y egress se ejecutan en distintos “threads”?

Opentracing y su implementación Zipkin nos ayudará en nuestra empresa. Opentracing permite generar un id a una request de entrada, este id viajará al microservicio en una cabecera http y éste debe propagarla en sus llamadas salientes, de esta manera también lo recibirá el controlador de salida del proxy.

collector := new(zipkin.NopCollector)
tracer, _ = zipkin.NewTracer(
   zipkin.NewRecorder(collector, false, "127.0.0.1:0", "mesh"))
...
//Creating a new trace context
span := s.Tracer.StartSpan("request")
traceId := span.Context().(zipkin.SpanContext).TraceID.Low
...
//Getting a trace context from the wire
wireContext, _ := s.Tracer.Extract(
   opentracing.HTTPHeaders,
   opentracing.HTTPHeadersCarrier(r.Header))
traceId := wireContext.(zipkin.SpanContext).TraceID.Low

Métricas

Con los objetivos de seguridad y trazabilidad cumplidos pasamos a sacar por salida estándar unas métricas simples del tráfico de red. Usaremos el mismo enfoque que para propagar la seguridad, una memoria compartida entre la entrada y la salida. Esto nos permitirá recoger tiempos totales y parciales en los diferentes puntos del proxy y sacarlos por pantalla.

Interceptando las peticiones

Para interceptar el tráfico entrante/saliente y que nuestro proxy sea transparente, usaremos iptables de Linux. Iptables nos da la magia necesaria que hace que el código fuente de los microservicios no tenga que ser modificado al incluir el proxy.

Aquí se listan las reglas de networking creadas en los contenedores Docker para interceptar:

  1. El tráfico de entrada en el puerto 8081 y redirigirlo al 8080 de nuestro proxy.
  2. El tráfico de salida hacia el puerto 8081 y redirigirlo al 8082, que es el canal de salida de nuestro proxy.
# all 8081 input traffic is intercepted and redirected to 8080, except local requests
iptables -t nat -A PREROUTING  ! -d 127.0.0.1 -p tcp --dport 8081 -j REDIRECT --to 8080
# all output traffic in 8081 is intercepted and redirected to 8082, except proxy requests
iptables -t nat -A OUTPUT      -m owner ! --uid-owner 666 -p tcp --dport 8081 -j REDIRECT --to 8082
iptables-save

Nota: para modificar estos parámetros de red serán necesarios permisos especiales (NET_ADMIN), más información en la documentación de Docker.

Instalación y validación

La instalación se realiza en la plataforma Openshift, el proxy acompañará tanto al contenedor books como al contenedor stars. Por lo tanto, se desplegará usando el patrón de despliegue proxy-sidecar.

En el código fuente se pueden ver los descriptores Docker, Docker-Compose y Openshift, aunque no es el objetivo de este post ahondar en estos temas.

Para inyectar el proxy en un POD añadiremos el nuevo contenedor a la definición del despliegue de Openshift:

- containers
...
      - image: luismoramedina/gomezh
        imagePullPolicy: Always
        name: sidecar
        securityContext:
          capabilities:
            add:
            - NET_ADMIN
...

Para la validación del sistema usaremos dos programas en linea de comandos curl y ab.  Con el siguiente comando lanzaremos una petición al microservicio books. En la petición añadiremos la cabecera de seguridad con un token de autenticación válido:

curl -i http://$books-route-url/ -H "Authorization: Bearer $JWT"

La respuesta sería similar a la siguiente:

{
	"id": 1,
	"title": "Enders game",
	"year": "1985",
	"author": "orson scott card",
	"stars": 5
}

Una vez hecha la prueba individual, pasamos a lanzar una batería de peticiones de un total de 2000 peticiones con 10 hilos concurrentes de cara a ver cómo se comporta nuestro sistema en condiciones de mayor carga.

ab -c 10 -n 2000 -H "Authorization: Bearer $JWT" http://$books-route-url/

Después de lanzar los tests, accedemos a la consola de Openshift donde veremos los recursos usados en las pruebas de carga. En la siguiente imagen se aprecia cómo el proxy está gastando del orden de 20 megas de memoria, que satisface nuestros requisitos sobre el uso de recursos.

Uso de memoria (footprint) de nuestro proxy.

Uso de memoria (footprint) del microservicio Java.

La instalación del contenedor proxy requiere ciertos permisos de seguridad sobre el proyecto Openshift. Estos permisos dan acceso a la ejecución de comandos de control de red (iptables entre otros). Se recomienda el uso de minishift o tener permisos de administración sobre el cluster.

Estos comandos deberán ser ejecutados por un usuario con los permisos adecuados con el cliente de comandos openshift (oc).

oc adm policy add-scc-to-user anyuid -z default
oc adm policy add-scc-to-user privileged -z default
oc patch scc/privileged --patch {\"allowedCapabilities\":[\"NET_ADMIN\"]}

Conclusión

Con este desarrollo hemos conseguido de una manera sencilla mover a la capa de infraestructura una pequeña cantidad de características que normalmente residen en los microservicios.

Esto ayuda a que los equipos de desarrollo puedan centrarse en la funcionalidad, dejar un poco de lado la tediosa instrumentación implícita en el desarrollo de una arquitectura de microservicios y permite crear arquitecturas políglotas de microservicios de una manera más sencilla.

Como contrapartida, la arquitectura se vuelve más compleja y utiliza técnicas que hacen que sean necesarios permisos especiales sobre el entorno.

Actualmente existen varias implementaciones de Service Mesh en el mercado, pretender crear un service mesh completo desde cero con soporte a múltiples protocolos y herramientas de terceros puede ser exagerado.

Por el contrario, en ciertos proyectos en los que no se quiera lidiar con la complejidad de un sistema de service mesh completo o en los que se necesiten funcionalidades muy específicas, una solución ad hoc mediante el patrón sidecar puede ser una buena alternativa.

Por su parte, Golang tiene un balance adecuado entre el nivel de complejidad de desarrollo y rendimiento. Sus características de networking son avanzadas y su “containerización” es trivial. Todo esto hace que sea una opción muy recomendable para implementar el patrón sidecar.

Podéis ver el código fuente en el siguiente repositorio. No somos expertos en el lenguaje Go, por lo que entendemos que será muy mejorable, se aceptan pull requests ;-)

En siguientes posts evaluaremos y probaremos algunos de los productos de service mesh que hay en el mercado, ¡os mantendremos informados!

Referencias

Ingeniero de Sistemas por la universidad de Zaragoza con 15 años de experiencia. Especializado en DevOps, cloud y seguridad. Actualmente desempeño tareas como Arquitecto Software en Paradigma Digital. Mi principal objetivo es participar a nivel técnico en proyectos relacionados con las nuevas tecnologías.

Ver toda la actividad de Luis Mora