Nuestro mundo esta lleno de palabrejos para definir aquello que hacemos, a veces parecemos médicos, pues el artículo de hoy va sobre observabilidad e instrumentación (observability and instrumenting) en nuestros microservicios. Seguro que muchos de vosotros estáis hartos de crear microservicios, ya sea para despedazar ese antiguo monolito, como por aislar ciertas funcionalidades concretas, etc. y seguramente os habréis encontrado con que muchas veces seguir el flujo de comunicación y que sucede en nuestros microservicios no es algo sencillo.
Un problema que nos encontramos con los microservicios es que, a diferencia de los monolitos, en un microservicio nada es propiedad entera de un solo equipo, sino que varios equipos se encargan de varias partes del sistema, por ejemplo la parte de usuarios, carrito, el catálogo… ¿entonces como podemos obtener toda la información que ha sucedido en cada microservicio, cuando un usuario realiza una acción? Ahí entra la observabilidad.
La observabilidad es la actividad que implica medir, recolectar y analizar varios tipos de señales desde un sistema. Estas señales, incluyen métricas, trazas, logs, eventos, profiles y más.
Hoy nos centraremos, en instrumentación, para obtener trazas de todo lo que sucede en uno de nuestros microservicios de punta a punta. Para ello utilizaremos Zipkin, Zipkin es un sistema de trazas distribuido o como lo llamaremos a partir de ahora, distributed tracing, ayuda a recopilar datos sobre tiempos, para poder solucionar problemas de latencia en nuestros servicios, obviamente además de recopilar datos también nos ofrece la posibilidad de buscar estos datos en el sistema.
Con lo cual, volviendo a nuestra famosa GopherApi veremos como implementar Zipkin en Go utilizando para ello la librería, zipkin-go.
Instrumentación con Zipkin
Para empezar a jugar con Zipkin, lo primero que deberemos hacer es preparar una imagen de docker y dejarla corriendo:
docker run -d -p 9411:9411 openzipkin/zipkin
Luego crearemos un zipkin.Tracer
el cual será el nodo principal que identificará todo lo que vaya sucediendo durante nuestra operación. Para ello vamos a crear un fichero nuevo pkg/tracer/tracer.go
, donde encapsularemos dicha implementación:
// pkg/tracer/tracer.go
package tracer
import (
"fmt"
"github.com/openzipkin/zipkin-go"
"github.com/openzipkin/zipkin-go/model"
"github.com/openzipkin/zipkin-go/reporter/http"
)
// NewTracer creates a new tracer with the necessary dependencies
func NewTracer(serviceName string, reporterURL string) (*zipkin.Tracer, error) {
reporter := http.NewReporter(fmt.Sprintf("%s/api/v2/spans", reporterURL))
endpoint := &model.Endpoint{
ServiceName: serviceName,
}
// sampler indicate the range of how many traces are going to be sampled
sampler, err := zipkin.NewCountingSampler(1)
if err != nil {
return nil, err
}
t, err := zipkin.NewTracer(reporter, zipkin.WithSampler(sampler), zipkin.WithLocalEndpoint(endpoint))
if err != nil {
return nil, err
}
return t, nil
}
Si desglosamos la creación línea a línea, veremos que primeramente que nos encontramos con reporter := http.NewReporter(fmt.Sprintf("%s/api/v2/spans", reporterURL))
, este reporter
, nos servirá para comunicarnos con la API de Zipkin y poder hacer las operaciones necesarias. Posteriormente crearemos un enpoint
, que será la identificación de nuestro servicio, en este caso nos basta con informar el serviceName
, que será el nombre que le hayamos dado a nuestra aplicación, como vimos en el artículo sobre contextos. Por último inicializaremos nuestro tracer
, el cual además de lo anterior requiere de un sampler
, que no es más que la frecuencia de datos que quieres que sean almacenado, siendo 1.0
todo y 0.0
nada.
Lo siguiente que haremos es modificar nuestro main.go
para crear el tracer
y además el fichero cmd/server/server.go
, donde tendremos que aceptar en el New
una instancia de zipkin.Tracer
.
Gracias a esto podremos agregar un nuevo middleware:
func router(s *server) {
r := mux.NewRouter()
r.Use(
newServerMiddleware(s.serverID),
zipkinhttp.NewServerMiddleware(s.tracer, zipkinhttp.SpanName("request")),
)
...
s.router = r
}
La propia librería de Zipkin, nos ofrece un middleware
, con el cual no tendremos que preocuparnos, más que de inicializarlo, de esta manera se encargará de coger nuestro anterior context
y utilizarlo para posteriormente poder asociar todas las trazas, más adelante veremos como se organiza la información y tendrá más sentido.
Como podéis ver cuando hemos creado el middleware
, con un nombre de span
nada distintivo, tranquilos, más adelante explicamos que son los span
, en detalles, de momento quedaos con que estaría bien darle un nombre que identifique a nuestra request
. Así que lo que vamos a hacer es modificar nuestro ya existente middleware, para que tenga un nombre mejor.
Para ello crearemos una función dentro de nuestro middleware
, tal que así:
func zipkinSpanHttpName(ctx context.Context, req *http.Request) {
if span := zipkin.SpanFromContext(ctx); span != nil {
if currentRoute := mux.CurrentRoute(req); currentRoute != nil {
if routePath, err := currentRoute.GetPathTemplate(); err == nil {
zipkin.TagHTTPRoute.Set(span, routePath)
span.SetName(fmt.Sprintf("%s %s", req.Method, routePath))
}
}
}
}
Esta función lo que hará es dado nuestro contexto, setear el nombre al span
, por el método y endpoint
al que atacaremos, pero no el endpoint
final, sino el endpoint
tal y como lo hemos configurado, por ejemplo, get /gophers/{id:[a-za-z0-9_]+}
, esto nos permitirá indentificar más rápidamente nuestras requests
.
Para usar la función, basta con llamarla dentro de nuestro middleware
:
func (h handler) createRequestContext(req *http.Request) context.Context {
...
zipkinSpanHttpName(ctx, req)
return ctx
}
Sí no sabéis de que middleware
estamos hablando, echad un vistazo al artículo sobre contextos, que hicimos recientemente en el blog.
Llegados a este punto me veo en la obligación de hablaros de B3-Propagation
, es una especificación de el header
, b3
, es decir todo aquel header que empiece por x-b3-
. Estos headers
se utilizarán para propagar el contexto entre servicios. Como dijimos al principio, muchas veces queremos saber que pasa de principio a fin cuando un usuario hace una llamada a por ejemplo, “registrarse en la web”, aquí puede haber varios microservicios implicados, y queremos poder tener toda la información anidada de este proceso, por ello se utilizan este tipo de cabeceras pa ir propagando la información de cada uno.
Básicamente el proceso es el siguiente:
Client Tracer Server Tracer
┌───────────────────────┐ ┌───────────────────────┐
│ │ │ │
│ TraceContext │ Http Request Headers │ TraceContext │
│ ┌───────────────────┐ │ ┌───────────────────┐ │ ┌───────────────────┐ │
│ │ TraceId │ │ │ X-B3-TraceId │ │ │ TraceId │ │
│ │ │ │ │ │ │ │ │ │
│ │ ParentSpanId │ │ Inject │ X-B3-ParentSpanId │ Extract │ │ ParentSpanId │ │
│ │ ├─┼────────>│ ├─────-───┼>│ │ │
│ │ SpanId │ │ │ X-B3-SpanId │ │ │ SpanId │ │
│ │ │ │ │ │ │ │ │ │
│ │ Sampling decision │ │ │ X-B3-Sampled │ │ │ Sampling decision │ │
│ └───────────────────┘ │ └───────────────────┘ │ └───────────────────┘ │
│ │ │ │
└───────────────────────┘ └───────────────────────┘
Así pues los headers
que nos podemos encontrar son los siguientes:
- X-B3-TraceId
- X-B3-ParentSpanId
- X-B3-SpanId
- X-B3-Sampled
Si queréis información más detallada sobre B3-Propagation
, os recomendamos el siguiente artículo
Por suerte para nosotros, el middleware
que acabamos de añadir ya trae la extracción de toda esta información de manera automática.
Realmente con esto ya estaríamos traceando toda nuestra request
, pero vamos a hilar un poco más finos y vamos a tracear también que sucede en nuestro repositorio, sabemos que es un repositorio in memory
así que los tiempos que nos darán son ridículos pero el ejemplo sirve igual y queda todo más visual.
Para ello vamos a volver a usar de ejemplo el FetchGopherByID
. Lo primero que haremos es añadir el tracer
a la implementación de nuestro repositorio
type gopherRepository struct {
mtx sync.RWMutex
tracer *zipkin.Tracer
gophers map[string]gopher.Gopher
}
// NewRepository creates a inmem repository with the necessary dependencies
func NewRepository(gophers map[string]gopher.Gopher, tracer *zipkin.Tracer) gopher.Repository {
if gophers == nil {
gophers = make(map[string]gopher.Gopher)
}
return &gopherRepository{
gophers: gophers,
tracer: tracer,
}
}
Ahora ya tendremos en nuestros métodos el tracer disponible para poder añadir las trazas que necesitemos, vamos a ver como hacerlo en el método que hemos mencionado:
func (r *gopherRepository) FetchGopherByID(ctx context.Context, ID string) (*gopher.Gopher, error) {
span, _ := r.tracer.StartSpanFromContext(ctx, "FetchGopherByID")
span.Tag("Repository", "in memory")
span.Annotate(time.Now(), "Transaction Start")
defer func() {
span.Annotate(time.Now(), "Transaction End")
span.Finish()
}()
...
}
Un span
es una vista única de una operación, a esta operación podemos añadirle tantas anotaciones como queremos, que serán sucesos que ocurran en dicho momento, por eso vienen indicadas con un time.Time
, en este caso hemos añadido una anotación de Transaction Start
y Transaction End
, normalmente no es necesario tracear el tiempo de nuestra operación entera, porque siempre nos vendrá informada la duración total del proceso, y se anotarán normalmente sucesos que pasen durante dicha operación, pero en nuestro caso nos vamos a mantener simples para que entendáis bien el ejemplo. Finalmente, acabaremos con span.Finish()
.
Probando nuestra instrumentación
Bien, ya hemos terminado de adaptar todo nuestro código para que empiece a almacenar trazas de todo lo que ocurre durante el proceso de pedir un Gopher
, ahora sólo tendremos que realizar una petición GET
al endpoint /gophers
, recordad que podemos pasar las cabeceras:
- X-B3-TraceId
- X-B3-ParentSpanId
- X-B3-SpanId
- X-B3-Sampled
Para probar la propagación del header B3
.
Una vez hecha la petición querremos ver nuestros datos, así que podemos acceder a http://localhost:9411
y deberíamos de poder ver algo similar a esto
Conclusión
Ya sabéis cómo instrumentar vuestro microservicio con Zipkin, ahora es momento de que le echéis imaginación y lo pongáis en practica en vuestro día a día, seguro que os salva de más de un quebradero de cabeza.
Si queréis seguir todo lo que hemos hecho durante el artículo, recordad que tenemos el repositorio de GopherAPI a vuestra entera disposición, además esta implementación será tageada en la versión v0.3.3
, para que siempre este disponible.
Ya sabéis que si tenéis cualquier duda o comentario podéis dejarlo en los comentarios o en nuestro Twitter @FriendsofGoTech.