Traducir entre gRPC y HTTP/JSON

Como comenté en mi post anterior sobre gRPC, la traducción entre gRPC y JSON es estándard. Esto nos permite tener nuestra comunicación interna en gRPC y exponer una fachada en HTTP con JSON para aquellos clientes que (todavía) no pueden usar gRPC.

En este post os voy a mostrar como podemos crear dicha fachada usando Envoy ejecutándose en un contenedor Docker. ¡Vamos allá!

Dado que Envoy es una pieza clave en este post, dejadme que os lo presente. Envoy es un reverse proxy de alto rendimiento escrito en C++ y que tiene soporte nativo para HTTP1.1, HTTP/2 y gRPC. En el caso de este post me interesa contaros como usando Envoy podemos simplificar la traducción de gRPC a HTTP/JSON. Por favor, tened presente que eso no traduce gRPC a REST. Eso es extremadamente difícil porque RPC y REST parten de paradigmas de diseño opuestos. Cierto es que sí «restificas» tu API gRPC, cuando la traduzcas a HTTP/JSON se parecerá más»a una API REST, pero para eso debes diseñar tu API gRPC teniendo esto en mente.

Vale, como en el post anterior voy a presuponer que tienes el «Hello World» de Microsoft funcionando. Nuestro amado GreeterService. Lo único es que mi cliente no es una aplicación de consola, si no una API que expone un controlador MVC que al llamarlo, llama al servidor por gRPCPara levantar el ejemplo simplemente usad «docker-compose up server client» y el cliente se expondrá en localhost:9002. Navegad a localhost:9002/hello?name=xxxx y el cliente llamará al servidor via gRPC y mostrará la respuesta. El servidor muestra un log conforme se ha llamado al servicio gRPC.

Terminal donde se ve el log del servidor gRPC y como al usar cURL, el cliente llama al sevicio gRPC y aparece el log

En este caso, el servidor solo expone un endpoint gRPC, pero vamos a usar Envoy para hacer este traslado.

Lo primero será añadir el contenedor de Envoy a nuestro docker-compose:

envoy:
  image: envoyproxy/envoy

El siguiente punto es configurar Envoy. Para ello necesitamos compilar el fichero proto a su formato binario (.rb). Esta compilación se realiza usando protoc, que está desarrollado en Go…. pero antes de usar protoc debemos hacer algunas modificaciones en nuestro fichero .proto.

Vayamos por partes.

Configurar Envoy – Parte 1: Modificar el fichero .proto

El fichero proto que hemos usado no es válido ya que le falta información. En efecto, le falta la información de que endpoints de gRPC deben ser traducidos a HTTP/JSON y como se mapean las rutas. Esto, aunque podría ser configuración propia de Envoy, se pone en el fichero .proto, porque hay una extensión de gRPC lo define así. Tiene lógica: el propio servicio gRPC define en su proto «como se transforma a HTTP». Envoy usará esa información. En mi caso la definición del servicio en el .proto queda así (pongo solo la descripción del servicio):

service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {
    option (google.api.http) = {
      get: "/api/v1/hello/{name}"
    };
  }
}

Enrutamos el método SayHello a la ruta «/api/v1/hello/{name}», donde  {name} se enlaza a la propiedad name de la HelloRequest.

Para que eso funcione hay que importar el fichero annotations.proto, para ello en el fichero .proto debes también añadir la línea:

import "google/api/annotations.proto";

Puedes añadirla al principio, justo después de la línea «syntax».

¡No recompiles el servidor de gRPC! Lo que hemos agregado no afecta al servidor, pero si lo intentas compilar vas a recibir errores. Luego hablamos de eso. Ahora toca generar el fichero proto compilado (.pb) a partir del .proto. Para ello vamos a crearnos un contenedor de Docker.

Configurar Envoy – Parte 2: Crear un contenedor para compilar el fichero .proto

Esto es fácil. Yo he creado una carpeta ProtoCompiler y he metido un Dockerfile en ella:

FROM golang:1.13beta1-buster as ProtoCompilerBase
WORKDIR /protoc
RUN apt-get update && apt-get install unzip
RUN curl -OL https://github.com/google/protobuf/releases/download/v3.2.0/protoc-3.2.0-linux-x86_64.zip
RUN unzip protoc-3.2.0-linux-x86_64.zip -d protoc3 && mv protoc3/bin/* /usr/local/bin/ &&  mv protoc3/include/* /usr/local/include/

WORKDIR /gapis
RUN git clone https://github.com/googleapis/googleapis
ENV GOOGLEAPIS_DIR=/gapis/googleapis

FROM ProtoCompilerBase
WORKDIR /app
ENTRYPOINT protoc -I${GOOGLEAPIS_DIR} -I/app --include_imports --include_source_info --descriptor_set_out=/out/${PROTO_NAME}.pb /app/${PROTO_NAME}.proto

Este Dockerfile parte de una image de Go a la que le instala todas las dependencias y luego invoca «protoc». El nombre del fichero proto se le pasa en la variable de entorno PROTO_NAME, y debe estar ubicado en el directorio /app del contenedor.

Luego, solo queda modificar el fichero compose:

protoc:
  image: protoc-compiler
  build:
    context: ProtoCompiler
    dockerfile: Dockerfile
  environment: 
    - PROTO_NAME=${PROTO_NAME:-proto}
  volumes:
    - "${PROTO_DIR}/${PROTO_NAME:-proto}.proto:/app/${PROTO_NAME:-proto}.proto"
    - "./ProtoCompiler/_out:/out"

Le pasamos la variable de entorno «PROTO_NAME» al contenedor y definimos dos bind mounts:

  1. El de entrada, para dejar el fichero proto en el /app del contenedor. La ubicación del fichero proto en nuestro sistema local la define la variable PROTO_DIR
  2. El de salida, para poder recojer el fichero .pb generado.

Si ahora ejecutas los comandos (por supuesto si estás en Linux usa export en lugar de set):

set PROTO_DIR=./DemoService/Protos
set PROTO_NAME=greet
docker-compose build protoc
docker-compose run protoc

Deberías tener en el directorio ProtoCompiler/_out el fichero .pb generado. La siguiente imagen muestra el proceso. Observa como en _out no hay nada y luego de ejecutar el contenedor con «docker-compose run» nos aparece el fichero .pb.

Línea de comandos donde se ve que el directorio _out está vacío, se establecen las variables de entorno (PROTO_NAME y PROTO_DIR) y se ejecuta el "docker-compose run protoc". Luego se muestra como el directorio _out contiene el binario (.sb)

Ahora que ya tenemos el fichero .pb, ya podemos crear la configuración de Envoy.

Configurar Envoy – Parte 3: Generar el yaml de Envoy

Envoy se configura mediante un fichero yaml. Contar como se configura Envoy queda fuera del alcance de este post, así que voy a poner el código de un fichero de configuración y comentaré las partes más relevantes. Por supuesto, habría otras maneras, Envoy es muy potente y flexible:

admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
  - name: listener1
    address:
      socket_address: { address: 0.0.0.0, port_value: 51051 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
          stat_prefix: grpc_json
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route: { cluster: grpc, timeout: { seconds: 60 } }
          http_filters:
          - name: envoy.grpc_json_transcoder
            config:
              proto_descriptor: "/etc/envoy/greet.pb"
              services: ["Greet.Greete"]
              print_options:
                add_whitespace: true
                always_print_primitive_fields: true
                always_print_enums_as_ints: false
                preserve_proto_field_names: false
          - name: envoy.router

  clusters:
  - name: grpc
    connect_timeout: 1.25s
    type: logical_dns
    lb_policy: round_robin
    dns_lookup_family: V4_ONLY
    http2_protocol_options: {}
    load_assignment:
      cluster_name: grpc
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: server
                port_value: 80

Vale, parece muy largo y complejo, pero en esencia estamos configurando el filtro de envoy.grpc_json_transcoder que es el encargado de traducir entre HTTP/JSON y gRPC:

http_filters:
- name: envoy.grpc_json_transcoder
  config:
    proto_descriptor: "/etc/envoy/greet.pb"
    services: ["Greet.Greeter"]

Con esto indicamos que fichero .pb usar y qué servicio (en formato package.nombreServicio).

- match: { prefix: "/" }
  route: { cluster: grpc, timeout: { seconds: 60 } }

Enrutamos todas las peticiones al cluster grpc. Un cluster en Envoy es (más o menos) un servicio virtual.

clusters:
- name: grpc
  connect_timeout: 1.25s
  type: logical_dns
  lb_policy: round_robin
  dns_lookup_family: V4_ONLY
  http2_protocol_options: {}
  load_assignment:
    cluster_name: grpc
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: server
              port_value: 80

Configuramos el cluster grpc para que termine llamando por HTTP puerto 80 al servicio llamado «server».

Con esto, cuando nosotros llamemos a Envoy, usando HTTP/JSON, este enrutará la petición al servicio llamado server por el puerto 80, usando gRPC.

Configurar Envoy – Parte 4: Todo junto en docker compose

Debemos añadir la configuración de Envoy en el fichero compose. Para ello le debemos pasar (usando un bind mount), el fichero .pb y el fichero envoy.yaml. En mi caso el fichero envoy.yaml lo tengo en una carpeta llamada Envoy. Así que agrego eso al fichero docker-compose.override.yml:

envoy:
  volumes:
    - "./ProtoCompiler/_out/${PROTO_NAME:-proto}.pb:/etc/envoy/${PROTO_NAME:-proto}.pb"
    - "./Envoy/envoy.yaml:/etc/envoy/envoy.yaml"
  ports:
    - 9100:51051

Observa que Envoy se expone por el puerto 9100. Así que ahora ya solo queda hacer un «docker-compose up server envoy» y verificar que usando el puerto 9100 podemos acceder via HTTP/JSON a nuestro servicio gRPC. Podemos usar curl para ello, tecleando p. ej. curl http://localhost:9100/api/v1/hello/eiximenis y observando como se invoca el servicio gRPC y recibimos la respuesta en HTTP/JSON.

Dos consolas donde en una hay el curl mencionado (con la respuesta en json) y en la otra los logs del servidor, donde se observa como se llama al servicio grpc

¡Perfecto! ¡Ya has traducido de gRPC a HTTP/JSON usando Envoy! ¿Qué te parece? ¡Más fácil imposible!

Los errores al compilar el servidor gRPC

Si usas el fichero .proto que hemos modificado en el servidor y lo intentas recompilar obtendrás errores:

Los errores que aparecen al compilar el proto modificado (captura pantalla errores de VS)

Los errores son un «File not found» y un error que el Import falla. Eso es porque en local no tengo el fichero annotations.proto que estamos importando.

Si usas protoc directamente la solución es clonar el repositorio https://github.com/googleapis/googleapis y establecer la variable de entorno GOOGLEAPIS_DIR al directorio donde has clonado el repo. Pero eso no funciona en dotnet build/Visual Studio. Eso es porque el paquete Grpc.Tools establece determinados directorios como ficheros para importar cuando invoca protoc.exe. Hay una issue al respecto, donde se comenta que la solución pasa por incorporar el fichero annotations.proto al proyecto. Pero eso dista de ser lo deseable (yo no lo he probado). Es posible hacer una compilación invocando a mano (usando <Exec>) protoc, y quizá hay alguna configuración de Grpc.Tools que lo permita. No lo he investigado y lo desconozco. Si tengo novedades al respecto ya lo publicaré en el blog.

Bueno, lo dejamos aquí por hoy. En el próximo post veremos como desplegar eso en un Kubernetes usando envoy como side car container 🙂

PD: Os dejo un fichero zip con el código fuente final. IMPORTANTE: En este fichero .zip, el .proto tiene las líneas adicionales comentadas. La idea es que una vez hayáis compilado el servidor y el cliente y antes de generar el .pb, descomentéis esas líneas. Si las dejo descomentadas os encontraréis con los errores de VS que comentaba al final.

El fichero zip lo tenéis aquí. ¡Saludos!

 

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *