Capítulo 4. Buenas prácticas de instrumentación
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
El primer paso de cualquier viaje es el más difícil, incluido el viaje de instrumentar tus aplicaciones para el rastreo distribuido. Las preguntas se amontonan sobre las preguntas: ¿Qué debo hacer primero? ¿Cómo sé que estoy haciendo las cosas bien? ¿Cuándo termino? Cada aplicación es diferente, pero este capítulo ofrece algunos consejos y estrategias generales para crear buenas prácticas de instrumentación de aplicaciones.
Las buenas prácticas no existen en el vacío. Los datos que genera tu instrumentación serán recogidos por un sistema de análisis de trazas, que los analizará y procesará. Como instrumentador, ¡es fundamental que le proporciones los mejores datos posibles!
Primero hablaremos de una aplicación que carece de instrumentación para fundamentar nuestro debate. A continuación, hablaremos de los primeros pasos para instrumentar una aplicación existente -observando los nodos y perímetros- y de algunas formas habituales de conseguirlo. Repasaremos las buenas prácticas para crear tramos y el tipo de información que querrás añadirles. Hablaremos de cómo utilizar el rastreo como parte del desarrollo de la aplicación para validar que tu arquitectura funciona como esperas que funcione. Por último, te daremos algunas señales para que sepas cuándo has llegado a "demasiada" instrumentación.
Rastreo con ejemplos
Es un tópico que la mejor forma de aprender es haciendo. Para ayudarte a comprender cómo debes instrumentar una aplicación de microservicios para el rastreo distribuido, es lógico que primero tengas una aplicación de microservicios. Hemos creado una aplicación de ejemplo que utilizaremos para ilustrar algunas técnicas y buenas prácticas. En esta sección, describiremos cómo puedes ejecutar el servicio en tu ordenador para seguir los ejemplos proporcionados, y demostraremos algunos principios básicos de instrumentación que pueden aplicarse de forma más general para instrumentar tus propios servicios.
Instalar la aplicación de ejemplo
Hemos desarrollado una pequeña aplicación de microservicio para demostrar los conceptos importantes necesarios para instrumentar una aplicación. Para ejecutarla, necesitarás una versión actualizada del runtime Go y Node.JS instalados en tu ordenador. También necesitarás descargar una copia del código fuente de la aplicación, que puedes encontrar en este repositorio de GitHub;puedes comprobarlo utilizando el software de control de versiones Git, o descargar y extraer un archivo zip de los archivos. Una vez que tengas una copia local de los archivos fuente, ejecutar el software es bastante sencillo: en una ventana de terminal, ejecuta go run cmd/<binary>/main.go
desde el directorio microcalc
para ejecutar cada servicio. Para ejecutar la aplicación cliente, tendrás que ejecutar npm install
en el subdirectorio web
, y luego npm start
.
La aplicación en sí es una calculadora básica con tres componentes. El cliente es una aplicación web para el navegador escrita en HTML y JavaScript que proporciona una interfaz con el servicio backend. El siguiente componente principal es un proxy de la API que recibe solicitudes del cliente y las envía al servicio trabajador apropiado. El componente final, los trabajadores operadores, son servicios que reciben una lista de operandos, realizan la operación matemática adecuada con esos operandos y devuelven el resultado.
Añadir rastreo distribuido básico
Antes de añadir el rastreo, examina el propio código y cómo funciona. Veremos el código en orden: primero, el cliente web, luego el servicio API y, por último, los trabajadores. Una vez que comprendas lo que hace cada parte del código, te resultará más fácil entender no sólo cómo instrumentar el servicio, sino por qué (ver Figura 4-1).
El servicio cliente es muy sencillo: un simple frontend HTML y JavaScript. El HTML presenta un formulario, que interceptamos en JavaScript y creamos un XMLHttpRequest
que transmite datos a los servicios backend. La versión no instrumentada de este código puede verse en el Ejemplo 4-1. Como puedes ver, no estamos haciendo nada terriblemente complicado aquí: creamos un gancho en el elemento del formulario y escuchamos el evento onClick
que se emite cuando se pulsa el botón Enviar.
Ejemplo 4-1. Servicio al cliente no instrumentado
const
handleForm
=
()
=>
{
const
endpoint
=
'http://localhost:3000/calculate'
let
form
=
document
.
getElementById
(
'calc'
)
const
onClick
=
(
event
)
=>
{
event
.
preventDefault
();
let
fd
=
new
FormData
(
form
);
let
requestPayload
=
{
method
:
fd
.
get
(
'calcMethod'
),
operands
:
tokenizeOperands
(
fd
.
get
(
'values'
))
};
calculate
(
endpoint
,
requestPayload
).
then
((
res
)
=>
{
updateResult
(
res
);
});
}
form
.
addEventListener
(
'submit'
,
onClick
)
}
const
calculate
=
(
endpoint
,
payload
)
=>
{
return
new
Promise
(
async
(
resolve
,
reject
)
=>
{
const
req
=
new
XMLHttpRequest
();
req
.
open
(
'POST'
,
endpoint
,
true
);
req
.
setRequestHeader
(
'Content-Type'
,
'application/json'
);
req
.
setRequestHeader
(
'Accept'
,
'application/json'
);
req
.
send
(
JSON
.
stringify
(
payload
))
req
.
onload
=
function
()
{
resolve
(
req
.
response
);
};
});
};
Tu primer paso al instrumentar esto debería ser rastrear la interacción entre este servicio y nuestros servicios backend. OpenTelemetry proporciona un útil complemento de instrumentación para rastrear XMLHttpRequest
, por lo que deberás utilizarlo para tu instrumentación básica. Tras importar los paquetes de OpenTelemetry, tendrás que configurar tu rastreador y tus complementos. Una vez que lo hayas hecho, envuelve tus llamadas a métodos de XMLHttpRequest
con algo de código de rastreo, como se ve en el Ejemplo 4-2.
Ejemplo 4-2. Crear y configurar tu trazador
// After importing dependencies, create a tracer and configure it
const
webTracerWithZone
=
new
WebTracer
(
{
scopeManager
:
new
ZoneScopeManager
(
)
,
plugins
:
[
new
XMLHttpRequestPlugin
(
{
ignoreUrls
:
[
/localhost:8090\/sockjs-node/
]
,
propagateTraceHeaderCorsUrls
:
[
'http://localhost:3000/calculate'
]
}
)
]
}
)
;
webTracerWithZone
.
addSpanProcessor
(
new
SimpleSpanProcessor
(
new
ConsoleSpanExporter
(
)
)
)
;
const
handleForm
=
(
)
=>
{
const
endpoint
=
'http://localhost:3000/calculate'
let
form
=
document
.
getElementById
(
'calc'
)
const
onClick
=
(
event
)
=>
{
event
.
preventDefault
(
)
;
const
span
=
webTracerWithZone
.
startSpan
(
'calc-request'
,
{
parent
:
webTracerWithZone
.
getCurrentSpan
(
)
}
)
;
let
fd
=
new
FormData
(
form
)
;
let
requestPayload
=
{
method
:
fd
.
get
(
'calcMethod'
)
,
operands
:
tokenizeOperands
(
fd
.
get
(
'values'
)
)
}
;
webTracerWithZone
.
withSpan
(
span
,
(
)
=>
{
calculate
(
endpoint
,
requestPayload
)
.
then
(
(
res
)
=>
{
webTracerWithZone
.
getCurrentSpan
(
)
.
addEvent
(
'request-complete'
)
;
span
.
end
(
)
;
updateResult
(
res
)
;
}
)
;
}
)
;
}
form
.
addEventListener
(
'submit'
,
onClick
)
}
Ejecuta la página en web
con npm start
y haz clic en Enviar con la consola del navegador abierta: deberías ver cómo se escriben los spans en la salida de la consola. Ya has añadido un rastreo básico a tu servicio cliente.
Ahora veremos los servicios backend: la API y los trabajadores. El servicio proveedor de la API utiliza la biblioteca Go net/http
para proporcionar un marco HTTP que utilizamos como marco RPC para pasar mensajes entre el cliente, el servicio API y los trabajadores. Como se ve en la Figura 4-1, la API recibe mensajes en formato JSON del cliente, busca el trabajador apropiado en su configuración, envía los operandos al servicio trabajador apropiado y devuelve el resultado al cliente.
El servicio API tiene dos métodos principales que nos interesan: Run
y calcHandler
. El método Run
del Ejemplo 4-3 inicializa el enrutador HTTP y configura el servidor HTTP. calcHandler
realiza la lógica de gestión de las solicitudes entrantes analizando el cuerpo JSON del cliente, asociándolo a un trabajador y creando una solicitud bien formada al servicio trabajador.
Ejemplo 4-3. Método de ejecución
func
Run
()
{
mux
:=
http
.
NewServeMux
()
mux
.
Handle
(
"/"
,
http
.
HandlerFunc
(
rootHandler
))
mux
.
Handle
(
"/calculate"
,
http
.
HandlerFunc
(
calcHandler
))
services
=
GetServices
()
log
.
Println
(
"Initializing server..."
)
err
:=
http
.
ListenAndServe
(
":3000"
,
mux
)
if
err
!=
nil
{
log
.
Fatalf
(
"Could not initialize server: %s"
,
err
)
}
}
func
calcHandler
(
w
http
.
ResponseWriter
,
req
*
http
.
Request
)
{
calcRequest
,
err
:=
ParseCalcRequest
(
req
.
Body
)
if
err
!=
nil
{
http
.
Error
(
w
,
err
.
Error
(),
http
.
StatusBadRequest
)
return
}
var
url
string
for
_
,
n
:=
range
services
.
Services
{
if
strings
.
ToLower
(
calcRequest
.
Method
)
==
strings
.
ToLower
(
n
.
Name
)
{
j
,
_
:=
json
.
Marshal
(
calcRequest
.
Operands
)
url
=
fmt
.
Sprintf
(
"http://%s:%d/%s?o=%s"
,
n
.
Host
,
n
.
Port
,
strings
.
ToLower
(
n
.
Name
),
strings
.
Trim
(
string
(
j
),
"[]"
))
}
}
if
url
==
""
{
http
.
Error
(
w
,
"could not find requested calculation method"
,
http
.
StatusBadRequest
)
}
client
:=
http
.
DefaultClient
request
,
_
:=
http
.
NewRequest
(
"GET"
,
url
,
nil
)
res
,
err
:=
client
.
Do
(
request
)
if
err
!=
nil
{
http
.
Error
(
w
,
err
.
Error
(),
http
.
StatusInternalServerError
)
return
}
body
,
err
:=
ioutil
.
ReadAll
(
res
.
Body
)
res
.
Body
.
Close
()
if
err
!=
nil
{
http
.
Error
(
w
,
err
.
Error
(),
http
.
StatusInternalServerError
)
return
}
resp
,
err
:=
strconv
.
Atoi
(
string
(
body
))
if
err
!=
nil
{
http
.
Error
(
w
,
err
.
Error
(),
http
.
StatusInternalServerError
)
return
}
fmt
.
Fprintf
(
w
,
"%d"
,
resp
)
}
Empecemos por el perímetro de este servicio y busquemos instrumentación para el framework RPC. En el Ejemplo 4-4, como estamos utilizando HTTP para comunicarnos entre servicios, querrás instrumentar el código del framework HTTP. Ahora bien, podrías escribirlo tú mismo, pero generalmente es mejor buscar instrumentación de código abierto para estos componentes comunes. En este caso, podemos utilizar el paquete othttp
existente en el proyecto OpenTelemetry para envolver nuestras rutas HTTP con instrumentación de seguimiento.
Ejemplo 4-4. Utilizar el paquete othttp
existente en el proyecto OpenTelemetry para envolver nuestras rutas HTTP con instrumentación de rastreo
std
,
err
:=
stdout
.
NewExporter
(
stdout
.
Options
{
PrettyPrint
:
true
}
)
traceProvider
,
err
:=
sdktrace
.
NewProvider
(
sdktrace
.
WithConfig
(
sdktrace
.
Config
{
DefaultSampler
:
sdktrace
.
AlwaysSample
(
)
}
)
,
sdktrace
.
WithSyncer
(
std
)
)
mux
.
Handle
(
"/"
,
othttp
.
NewHandler
(
http
.
HandlerFunc
(
rootHandler
)
,
"root"
,
othttp
.
WithPublicEndpoint
(
)
)
)
mux
.
Handle
(
"/calculate"
,
othttp
.
NewHandler
(
http
.
HandlerFunc
(
calcHandler
)
,
"calculate"
,
othttp
.
WithPublicEndpoint
(
)
)
)
Gestiona adecuadamente los errores y demás. Se ha eliminado parte del código para mayor claridad.
En primer lugar, tenemos que registrar un exportador para ver realmente la salida de telemetría; también podría ser un backend de análisis externo, pero de momento utilizaremos
stdout
.A continuación, registra el exportador con el proveedor de trazas y configúralo para que muestree el 100% de los tramos.
¿Qué hace esto por nosotros? El complemento de instrumentación se encargará de bastantes tareas "prácticas" por nosotros, como propagar los intervalos de las solicitudes entrantes y añadir algunos atributos útiles (vistos en el Ejemplo 4-5), como el tipo de método HTTP, el código de respuesta, etc. Simplemente añadiendo esto, podemos empezar a rastrear las peticiones a nuestro sistema backend. Presta especial atención al parámetro que hemos pasado a nuestro manejador de instrumentación, othttp.WithPublicEndpoint
-esto modificará ligeramente cómo fluye el contexto de rastreo desde el cliente a nuestros servicios backend. En lugar de persistir el mismo TraceID del cliente, el contexto entrante se asociará a una nueva traza como enlace.
Ejemplo 4-5. Salida JSON span
{
"SpanContext"
:
{
"TraceID"
:
"060a61155cc12b0a83b625aa1808203a"
,
"SpanID"
:
"a6ff374ec6ed5c64"
,
"TraceFlags"
:
1
},
"ParentSpanID"
:
"0000000000000000"
,
"SpanKind"
:
2
,
"Name"
:
"go.opentelemetry.io/plugin/othttp/add"
,
"StartTime"
:
"2020-01-02T17:34:01.52675-05:00"
,
"EndTime"
:
"2020-01-02T17:34:01.526805742-05:00"
,
"Attributes"
:
[
{
"Key"
:
"http.host"
,
"Value"
:
{
"Type"
:
"STRING"
,
"Value"
:
"localhost:3000"
}
},
{
"Key"
:
"http.method"
,
"Value"
:
{
"Type"
:
"STRING"
,
"Value"
:
"GET"
}
},
{
"Key"
:
"http.path"
,
"Value"
:
{
"Type"
:
"STRING"
,
"Value"
:
"/"
}
},
{
"Key"
:
"http.url"
,
"Value"
:
{
"Type"
:
"STRING"
,
"Value"
:
"/"
}
},
{
"Key"
:
"http.user_agent"
,
"Value"
:
{
"Type"
:
"STRING"
,
"Value"
:
"HTTPie/1.0.2"
}
},
{
"Key"
:
"http.wrote_bytes"
,
"Value"
:
{
"Type"
:
"INT64"
,
"Value"
:
27
}
},
{
"Key"
:
"http.status_code"
,
"Value"
:
{
"Type"
:
"INT64"
,
"Value"
:
200
}
}
],
"MessageEvents"
:
null
,
"Links"
:
null
,
"Status"
:
0
,
"HasRemoteParent"
:
false
,
"DroppedAttributeCount"
:
0
,
"DroppedMessageEventCount"
:
0
,
"DroppedLinkCount"
:
0
,
"ChildSpanCount"
:
0
}
En calcHandler
, querremos hacer algo similar para instrumentar nuestra RPC saliente al servicio trabajador. De nuevo, OpenTelemetry contiene un complemento de instrumentación para el cliente HTTP de Go que podemos utilizar (ver Ejemplo 4-6).
Ejemplo 4-6. Manejador API
client
:=
http
.
DefaultClient
// Get the context from the request in order to pass it to the instrumentation plug-in
ctx
:=
req
.
Context
()
request
,
_
:=
http
.
NewRequestWithContext
(
ctx
,
"GET"
,
url
,
nil
)
// Create a new outgoing trace
ctx
,
request
=
httptrace
.
W3C
(
ctx
,
request
)
// Inject the context into the outgoing request
httptrace
.
Inject
(
ctx
,
request
)
// Send the request
res
,
err
:=
client
.
Do
(
request
)
Esto añadirá cabeceras de rastreo W3C a la solicitud saliente, que podrán ser recogidas por el trabajador, propagando el contexto de rastreo a través del cable. Esto nos permite visualizar muy fácilmente la relación entre nuestros servicios, ya que los vanos creados en el servicio trabajador tendrán el mismo identificador de rastreo que el/los padre(s).
Añadir el rastreo a los servicios de los trabajadores es igual de sencillo, porque simplemente estamos envolviendo el método del enrutador con el controlador de rastreo de OpenTelemetry, como se muestra en el Ejemplo 4-7.
Ejemplo 4-7. Añadir el controlador
// You also need to add an exporter and register it with the trace provider,
// as in the API server, but the code is the same
mux
.
Handle
(
"/"
,
othttp
.
NewHandler
(
http
.
HandlerFunc
(
addHandler
),
"add"
,
othttp
.
WithPublicEndpoint
())
)
Los plug-ins de instrumentación se encargan de gran parte de las tareas repetitivas de las que tenemos que ocuparnos en este y otros lenguajes, como extraer el contexto del span de la solicitud entrante, crear un nuevo span hijo (o un nuevo span raíz, si procede) y añadir ese span al contexto de la solicitud. En la siguiente sección, veremos cómo podemos ampliar esta instrumentación básica con eventos y atributos personalizados de nuestra lógica empresarial para mejorar la utilidad de nuestros spans y trazas.
Instrumentación a medida
Llegados a este punto, tenemos las partes críticas del rastreo configuradas en nuestros servicios; se rastrea cada RPC, lo que nos permite ver una única solicitud mientras viaja desde nuestro servicio cliente a todos nuestros servicios backend. Además, tenemos un tramo de disponible en nuestra lógica de negocio, transportado a lo largo del contexto de solicitud, que podemos mejorar con atributos o eventos personalizados. ¿Qué hacemos entonces? En general, esto depende realmente de ti, el instrumentador. Hablaremos de ello con más detalle en "Etiquetado eficaz", pero es útil añadir instrumentación personalizada para algunas cosas de tu lógica empresarial: capturar y registrar estados de error, por ejemplo, o crear child spans que describan con más detalle el funcionamiento de un servicio. En nuestro servicio API , hemos implementado un ejemplo de esto pasando el contexto local a un método diferente (ParseCalcRequest
), donde creamos un nuevo span y lo mejoramos con eventos personalizados, como se muestra en el Ejemplo 4-8.
Ejemplo 4-8. Mejorar un span con eventos personalizados
var
calcRequest
CalcRequest
err
=
tr
.
WithSpan
(
ctx
,
"generateRequest"
,
func
(
ctx
context
.
Context
)
error
{
calcRequest
,
err
=
ParseCalcRequest
(
ctx
,
b
)
return
err
})
En el Ejemplo 4-9, puedes ver lo que hacemos con el contexto pasado: obtenemos el span actual del contexto y le añadimos eventos. En este caso, hemos añadido algunos eventos informativos sobre lo que hace realmente la función (analizar el cuerpo de nuestra solicitud entrante en un objeto) y cambiar el estado del span si la operación falla.
Ejemplo 4-9. Añadir eventos al span
func
ParseCalcRequest
(
ctx
context
.
Context
,
body
[]
byte
)
(
CalcRequest
,
error
)
{
var
parsedRequest
CalcRequest
trace
.
CurrentSpan
(
ctx
).
AddEvent
(
ctx
,
"attempting to parse body"
)
trace
.
CurrentSpan
(
ctx
).
AddEvent
(
ctx
,
fmt
.
Sprintf
(
"%s"
,
body
))
err
:=
json
.
Unmarshal
(
body
,
&
parsedRequest
)
if
err
!=
nil
{
trace
.
CurrentSpan
(
ctx
).
SetStatus
(
codes
.
InvalidArgument
)
trace
.
CurrentSpan
(
ctx
).
AddEvent
(
ctx
,
err
.
Error
())
trace
.
CurrentSpan
(
ctx
).
End
()
return
parsedRequest
,
err
}
trace
.
CurrentSpan
(
ctx
).
End
()
return
parsedRequest
,
nil
}
Ahora que ya sabes cómo añadir instrumentación a una aplicación, retrocedamos un poco. Puede que estés pensando que las aplicaciones "reales" son obviamente más complejas e intrincadas que una muestra creada al efecto. La buena noticia, sin embargo, es que los principios básicos que hemos aprendido e implementado aquí son generalmente aplicables a la instrumentación de software de cualquier tamaño o complejidad. Echemos un vistazo al software de instrumentación y a cómo aplicar estos principios básicos a las aplicaciones de microservicios.
Dónde empezar-Nodos y perímetros
La gente tiende a empezar por fuera cuando resuelve problemas, ya sean organizativos, financieros, de cálculo o incluso culinarios. El lugar más fácil para empezar es el que está más cerca de ti. El mismo enfoque se aplica a los servicios de instrumentación para el rastreo distribuido.
En la práctica, empezar por el exterior es eficaz por tres razones principales. La primera es que los perímetros de tu servicio son los más fáciles de ver y, por tanto, de manipular. Es bastante sencillo añadir cosas que rodeen a un servicio, aunque sea difícil modificar el propio servicio. En segundo lugar, empezar desde fuera tiende a ser eficiente desde el punto de vista organizativo. Puede ser difícil convencer a equipos dispares de que adopten el rastreo distribuido, sobre todo si el valor de ese rastreo puede ser difícil de ver de forma aislada. Por último, el rastreo distribuido requiere la propagación del contexto: necesitamos que cada servicio conozca el rastreo del llamante, y cada servicio al que llamamos necesita saber también que está incluido en un rastreo. Por estas razones, es muy útil empezar a instrumentar cualquier tipo de aplicación existente empezando desde fuera y moviéndose hacia dentro. Esto puede adoptar la forma de instrumentación del marco o de instrumentación de la malla de servicios (o componente equivalente).
Instrumentación del marco
En cualquier aplicación distribuida, los servicios necesitan comunicarse entre sí. Este tráfico RPC puede adoptar diversos protocolos y métodos de transporte: datos estructurados sobre HTTP, búferes de protocolo sobre gRPC, Apache Thrift, protocolos personalizados sobre sockets TCP, etc. Debe haber cierta equivalencia a ambos lados de esta conexión. Tus servicios deben hablar el mismo idioma cuando se comunican.
Hay dos componentes críticos en lo que se refiere a la instrumentación a nivel de marco. En primer lugar, nuestros marcos deben permitirnos realizarla propagación de contextos , la transmisión de identificadores de rastreo a través de la red. En segundo lugar, nuestros marcos deben ayudarnos a crear tramos para cada servicio.
La propagación del contexto es quizá el reto más fácil de resolver. Echemos otro vistazo a MicroCalc para discutirlo. Como se muestra en la Figura 4-2, sólo estamos utilizando un método de transporte (HTTP), pero dos formas distintas de pasar mensajes: JSON y parámetros de consulta. Puedes imaginar que algunos de estos enlaces podrían hacerse de otra manera; por ejemplo, podríamos refactorizar la comunicación entre nuestro servicio API y los servicios de trabajador para utilizar gRPC, Thrift o incluso graphQL. El transporte en sí es en gran medida irrelevante, el requisito es simplemente que seamos capaces de pasar el contexto de rastreo al siguiente servicio.
Una vez que identifiques los protocolos de transporte que utilizan tus servicios para comunicarse, considera la ruta crítica de las llamadas a tus servicios. En pocas palabras, identifica la ruta de las llamadas a medida que una petición se desplaza por tus servicios. En esta fase del análisis, querrás centrarte en los componentes que actúan como centro de las peticiones. ¿Por qué? Generalmente, estos componentes van a encapsular lógicamente operaciones en el backend y proporcionar una API para múltiples clientes (como clientes web basados en navegador o aplicaciones nativas en un dispositivo móvil). Por lo tanto, instrumentar primero estos componentes permite obtener valor del rastreo en un plazo más breve. En el ejemplo anterior, nuestro servicio proxy de API cumple estos criterios: nuestro cliente se comunica directamente a través de él para todas las acciones descendentes.
Tras identificar el servicio que vas a instrumentar, debes considerar el método de transporte utilizado para las solicitudes que entran y salen del servicio. Nuestro servicio proxy API se comunica exclusivamente a través de datos estructurados utilizando HTTP, pero esto es simplemente un ejemplo en aras de la brevedad: en el mundo real, a menudo encontrarás servicios que pueden aceptar múltiples transportes y también enviar solicitudes salientes a través de múltiples transportes. Tendrás que ser muy consciente de estas complicaciones a la hora de instrumentar tus propias aplicaciones.
Dicho esto, veremos la mecánica real de instrumentar nuestro servicio. En la instrumentación del marco, querrás instrumentar el propio marco de transporte de tu servicio. A menudo, esto puede implementarse como una especie de middleware en tu ruta de solicitud: código que se ejecuta para cada solicitud entrante. Este es un patrón común para añadir registro a tus peticiones, por ejemplo. ¿Qué middlewares querrías implementar para este servicio? Lógicamente, necesitarás realizar lo siguiente:
-
Comprueba si una solicitud entrante incluye un contexto de rastreo, que indicaría que la solicitud está siendo rastreada. Si es así, añade este contexto a la petición.
-
Comprueba si existe un contexto en la solicitud. Si el contexto existe, crea un nuevo span como hijo del contexto fluido. Si no, crea un nuevo span raíz. Añade este span a la solicitud.
-
Comprueba si existe un span en la solicitud. Si existe un span, añádele otra información pertinente disponible en el contexto de la solicitud, como la ruta, los identificadores de usuario, etc. En caso contrario, no hagas nada y continúa.
Estas tres acciones lógicas pueden combinarse en una única pieza de middleware mediante el uso de librerías de instrumentación como las que comentamos en el Capítulo 3. Podemos implementar una versión sencilla de este middleware en Golang utilizando la biblioteca OpenTracing, como muestra el Ejemplo 4-10, o utilizando complementos de instrumentación incluidos en marcos como OpenTelemetry, como demostramos en "Seguimiento por ejemplo".
Ejemplo 4-10. Rastrear middleware
func
TracingMiddleware
(
t
opentracing
.
Tracer
,
h
http
.
HandlerFunc
)
http
.
HandlerFunc
{
return
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
spanContext
,
_
:=
t
.
Extract
(
opentracing
.
HTTPHeaders
,
opentracing
.
HTTPHeadersCarrier
(
r
.
Header
))
span
:=
t
.
StartSpan
(
r
.
Method
,
opentracing
.
ChildOf
(
spanContext
))
span
.
SetTag
(
"route"
,
r
.
URL
.
EscapedPath
())
r
=
r
.
WithContext
(
opentracing
.
ContextWithSpan
(
r
.
Context
(),
span
.
Context
()))
defer
span
.
Finish
()
h
(
w
,
r
)
span
.
SetTag
(
"status"
,
w
.
ResponseCode
)
}
)
}
Este fragmento cumple los objetivos expuestos anteriormente: primero intentamos extraer un contexto span de las cabeceras de la solicitud. En el ejemplo anterior, hacemos algunas suposiciones, a saber, que nuestro contexto span se propagará utilizando las cabeceras HTTP y no ningún tipo de formato binario. OpenTracing, en general, define estas cabeceras con los siguientes formatos:
ot-span-id
-
Un entero sin signo de 64 o 128 bits
ot-trace-id
-
Un entero sin signo de 64 o 128 bits
ot-sampled
-
Un valor booleano que indica si el servicio ascendente ha muestreado la traza
Ten en cuenta que éstos no son los únicos tipos de cabeceras que pueden contener un contexto span. Puedes obtener más información sobre otros formatos de cabecera populares en "OpenTracing y OpenCensus".
Como aprendimos en el Capítulo 2, el contexto span de es fundamental para propagar un rastreo a través de nuestros servicios, razón por la cual primero lo extraemos de la solicitud entrante. Después de extraer las cabeceras entrantes, nuestro middleware crea un nuevo span, con el nombre de la operación HTTP que se está realizando (GET, POST, PUT, etc.), añade una etiqueta que indica la ruta que se está solicitando y, a continuación, añade el nuevo span al objeto de contexto Go. Por último, el middleware continúa la cadena de peticiones. A medida que la solicitud se resuelve, añade el código de respuesta de la solicitud al span, que se cierra implícitamente a través de nuestra llamada a defer
.
Imaginemos que nos detuviéramos aquí. Si añadieras este middleware al servicio proxy de la API junto con un trazador y un analizador de trazas, ¿qué verías? Bueno, para empezar, se rastrearían todas y cada una de las solicitudes HTTP entrantes. Esto te daría la posibilidad de monitorizar la latencia de los puntos finales de tu API en cada solicitud entrante, un valioso primer paso a la hora de monitorizar tu aplicación. La otra ventaja es que ahora has propagado tu rastreo en el contexto, lo que permite que otras llamadas a funciones o RPC añadan información o creen nuevos intervalos basados en él. Mientras tanto, seguirás pudiendo acceder a la información de latencia, por ruta API, y utilizarla para informarte de problemas de rendimiento y posibles puntos conflictivos en tu código base.
Sin embargo, instrumentar el marco de trabajo tiene sus contrapartidas. La instrumentación del marco depende en gran medida de la capacidad de realizar cambios en el código de los propios servicios. Si no puedes modificar el código del servicio, no puedes instrumentar realmente el marco de transporte. La instrumentación del marco puede resultarte difícil si tu proxy API actúa simplemente como una capa de traducción -por ejemplo, una envoltura fina que traduce JSON sobre HTTP a un transporte propietario o interno-; en este caso, se aplicaría el principio general, pero es posible que carezcas de la capacidad de enriquecer un tramo con tantos datos como quisieras. Por último, la instrumentación del marco puede ser difícil si no tienes componentes que centralicen las peticiones -por ejemplo, un cliente que llame a varios servicios directamente, en lugar de a través de alguna capa proxy-. En este caso, podrías utilizar el cliente como punto de centralización, y añadir allí tu instrumentación inicial.
Instrumentación de la Malla de Servicio
Al hablar de las ventajas y desventajas de la instrumentación del marco de trabajo, la primera consideración que mencionamos fue: "¿Y si no puedes cambiar el código?". No se trata de una hipótesis irracional o descabellada. Hay varias razones por las que la persona que instrumenta el software no puede modificar el servicio que intenta instrumentar. Lo más habitual es que esto sea un reto para las grandes organizaciones, en las que las personas que monitorean la aplicación están separadas de las que la crean por motivos geográficos, zonas horarias, etc.
Entonces, ¿cómo instrumentar código que no puedes tocar? En pocas palabras, instrumentas la parte del código que puedes tocar y sigues a partir de ahí.
Primero debes entender qué es una malla de servicios; si ya lo sabes, puedes saltarte un párrafo. Una malla de servicios es una capa de infraestructura configurable diseñada para soportar la comunicación interprocesos entre servicios. Generalmente, lo hace a través deproxies sidecar , procesos que viven junto a cada instancia de servicio y gestionan toda la comunicación entre procesos de su servicio asociado. Además de las comunicaciones entre servicios, la malla de servicios y sus "sidecars" pueden encargarse del monitoreo, la seguridad, el descubrimiento de servicios, el equilibrio de carga, la encriptación, etc. En esencia, la malla de servicios permite separar las preocupaciones de los desarrolladores de las de las operaciones, lo que permite a los equipos especializarse y centrarse en escribir software de alto rendimiento, seguro y fiable.
Ahora que estamos en la misma página, hablemos de cómo es la instrumentación de la malla de servicios. Como hemos indicado antes, una de las características fundamentales del proxy sidecar es que toda la comunicación entre procesos fluye a través del proxy. Esto nos permite añadir rastreo al propio proxy. Sucede que esta funcionalidad funciona de forma inmediata en muchos proyectos modernos de malla de servicios, como Istio,, pero a un nivel más hipotético, la mecánica se parece notablemente a cómo funciona la instrumentación del marco. En las solicitudes entrantes, extrae el contexto span de las cabeceras, crea un nuevo span utilizando este contexto, y añade etiquetas que describan la operación-finaliza el span cuando se resuelve la solicitud.
La mayor ventaja de este estilo de instrumentación es que puedes obtener una imagen completa de tu aplicación. Recuerda nuestra discusión sobre la instrumentación del marco de trabajo: empezamos en un punto de centralización y, a partir de ahí, continuamos hacia fuera. Al instrumentar en la malla de servicios, todos los servicios gestionados por la malla de servicios formarán parte de la traza, lo que te dará una visión mucho más amplia de toda tu aplicación. Además, la instrumentación de la malla de servicios de es agnóstica respecto a la capa de transporte de cada servicio. Mientras el tráfico pase por la capa de transporte, será rastreado.
Dicho esto, la instrumentación de malla de servicios tiene ventajas e inconvenientes. Principalmente, la instrumentación de malla de servicios actúa como una forma de instrumentación de caja negra. No tienes ni idea de lo que ocurre dentro del código, y no puedes enriquecer tus tramos con datos ajenos a los que ya están ahí. Siendo realistas, esto significa que puedes lograr algunos hallazgos implícitos útiles -etiquetar los spans con códigos de respuesta HTTP, por ejemplo, y suponer que cualquier código de estado que represente una solicitud fallida (como HTTP 500) será un error-, pero requiere un análisis o manejo especializado para obtener información explícita en un span. El otro defecto de la instrumentación de la malla de servicios es que resulta difícil para los servicios enriquecer los spans procedentes de la malla de servicios. Tu sidecar pasará cabeceras de rastreo a tu proceso, sí, pero aún tendrás que extraer esas cabeceras, crear un contexto de span, etc. Si cada servicio está creando sus propios spans hijos, puedes llegar muy rápidamente a un estado en el que tus trazas se hayan vuelto extremadamente grandes y empiecen a tener un coste real de almacenamiento o procesamiento.
En última instancia, la instrumentación de la malla de servicios y la instrumentación del marco no son una decisión de uno u otro. Funcionan mejor juntas. Siendo realistas, no todos tus servicios necesitarán ser instrumentados desde el principio, o potencialmente nunca. Hablemos de por qué.
Crear tu gráfico de servicios
Independientemente de la metodología que utilices para empezar a instrumentar tu aplicación, debes plantearte el primer hito que te gustaría alcanzar. ¿Qué quieres medir? Diríamos que el rastreo es principalmente una forma de medir el rendimiento y la salud de los servicios individuales en el contexto de una aplicación más amplia. Para entender ese contexto, sin embargo, necesitas tener alguna idea de las conexiones entre tus servicios y de cómo fluyen las peticiones a través del sistema. Así pues, un buen primer hito sería construir un gráfico de servicios para tu aplicación completa o algún subconjunto significativo de ella, como ilustra la Figura 4-3.
Esta comparación debería demostrar la necesidad de comprender tu gráfico de servicios. Incluso cuando los servicios son sencillos, con pocas dependencias, comprender tu gráfico de servicio puede ser un componente crítico para mejorar tu MTTR (tiempo medio de recuperación) de incidencias en . Dado que gran parte de esto está ligado a factores no relacionados, como el tiempo que se tarda en implementar una nueva versión de un servicio, reducir el tiempo dedicado al diagnóstico es la mejor manera de reducir el MTTR general. Una ventaja clave del rastreo distribuido es que te permite mapear implícitamente tus servicios y las relaciones entre ellos, lo que te permite identificar errores en otros servicios que contribuyen a la latencia de una solicitud concreta. Cuando las aplicaciones se vuelven más complicadas e interconectadas, comprender estas relaciones deja de ser opcional y empieza a ser fundamental.
En la aplicación de ejemplo, puedes ver que las dependencias entre servicios son bastante sencillas y fáciles de entender. Incluso en esta sencilla aplicación, poder construir el gráfico completo es muy valioso. Imaginemos que utilizas una combinación de técnicas para instrumentar cada uno de nuestros servicios (proxy API, servicio de autenticación, servicios de trabajador, etc.) y disponer de un analizador de trazas que pueda leer y procesar los spans generados desde nuestra aplicación. Con esto, puedes responder a preguntas que serían difíciles si no tuvieras acceso a estas relaciones de servicio. Éstas pueden ir desde lo mundano ("¿Qué servicios contribuyen más a la latencia de esta operación concreta?") hasta lo específico ("Para este ID de cliente concreto, para esta transacción concreta, ¿qué servicio está fallando?"). Sin embargo, si te limitas a rastrear los perímetros de tus servicios, estás en un pequeño aprieto. Sólo puedes identificar los fallos de forma muy aproximada, por ejemplo, si una solicitud ha fallado o ha tenido éxito.
Entonces, ¿cómo solucionarlo? Tienes varias opciones. Sin duda, una es empezar a añadir instrumentación al propio código del servicio. Como discutiremos en la siguiente sección, hay un arte y una ciencia en crear tramos que sean útiles para perfilar y depurar el código trazado. La otra consiste en aprovechar los perímetros que has trazado y minarlos para obtener más datos. Presentaremos tres mecanismos más avanzados que utilizan los conceptos de marco e instrumentación de malla para rellenar los huecos de tu malla de servicios.
El primer método consiste en aumentar el nivel de detalle de nuestros tramos proporcionados por el marco. En nuestro ejemplo de middleware HTTP, sólo registramos una pequeña cantidad de detalles sobre la solicitud, como el método HTTP, la ruta y el código de estado. En realidad, cada solicitud tendría potencialmente registrados muchos más datos. ¿Tus solicitudes entrantes están vinculadas a un usuario? Considera la posibilidad de adjuntar el identificador de usuario a cada solicitud como una etiqueta. Las solicitudes de servicio a servicio deben identificarse con algunos identificadores semánticos proporcionados por tu biblioteca de rastreo, como los atributos SpanKind
de OpenTelemetry o etiquetas específicas que te permitan identificar el tipo de servicio (caché, base de datos, etc.). Para las llamadas a bases de datos, instrumentar el cliente de base de datos te permite capturar una amplia variedad de información, como la instancia de base de datos real que se está utilizando, la consulta a la base de datos, etc. Todos estos enriquecimientos ayudan a construir tu grafo de servicios en una representación semántica de tu aplicación y de las conexiones entre ella.
El segundo método consiste en aprovechar la instrumentación y las integraciones existentes para tus servicios. Existe una variedad de plug-ins para OpenTelemetry, OpenTracing y OpenCensus que permiten a las bibliotecas comunes de código abierto emitir spans como parte de tu traza existente. Si te enfrentas a un viaje de instrumentación desalentador, con una gran cantidad de código existente, puedes utilizar estos plug-ins para instrumentar los marcos y clientes existentes junto con la instrumentación de nivel superior en la capa de malla/marco de servicio. Enumeramos una muestra de estos plug-ins en el Apéndice A.
El tercer método es a través de la instrumentación manual , que cubrimos en "Instrumentación personalizada", y se aplican los mismos principios. Querrás asegurarte de que un span raíz se propaga a cada servicio a partir del cual puedes crear spans hijos. Dependiendo del nivel de detalle que requiera un servicio, puede que no necesites varios span hijos para un solo servicio; considera el pseudocódigo del Ejemplo 4-11.
Ejemplo 4-11. Un método en pseudocódigo para gestionar el cambio de tamaño y el almacenamiento de imágenes
func uploadHandler(request) { image = imageHelper.ParseFile(request.Body()) resizedImage = imageHelper.Resize(image) uploadResponse = uploadToBucket(resizedImage) return uploadResponse }
En este caso, ¿qué nos importa rastrear? La respuesta variará en función de tus requisitos. Existe un argumento a favor de que la mayoría de los métodos que se llaman aquí tengan sus propios vanos hijos, pero la verdadera delimitación aquí sería restringir las llamadas hijas a los métodos que están fuera del ámbito de responsabilidad de un equipo determinado. Puedes imaginarte una situación en la que, a medida que nuestro servicio crezca, podamos factorizar las funciones que analizan y redimensionan las imágenes fuera de este en otro servicio. Como hemos escrito, probablemente querrás simplemente encerrar todo este método en un único span y añadir etiquetas y registros basados en las respuestas a las llamadas a tu método, algo como el Ejemplo 4-12.
Ejemplo 4-12. Instrumentar manualmente un método
func uploadHandler(context, request) { span = getTracer().startSpanFromContext(context) image = imageHelper.ParseFile(request.Body()) if image == error { span.setTag("error", true) span.log(image.error) } // Etc. }
Cualquiera de estos métodos, o todos ellos, pueden entremezclarse para construir un gráfico de servicios más eficaz y representativo, que no sólo describa con precisión las dependencias de servicios de tu aplicación, sino que represente semánticamente la naturaleza de esas dependencias. Ya hemos hablado de añadir o enriquecer tramos; a continuación, veremos cómo crear estos tramos y cómo determinar la información más importante y valiosa que debes añadir a un tramo.
¿Qué hay en una Span?
Los tramos son los componentes básicos del rastreo distribuido, pero ¿qué significa eso en realidad? Un lapso representa dos cosas: el periodo de tiempo en que tu servicio estuvo funcionando y el mecanismo por el que los datos se transportan desde tu servicio hasta algún sistema de análisis capaz de procesarlos e interpretarlos. Crear intervalos eficaces que revelen información sobre el comportamiento de tu servicio es en parte arte y en parte ciencia. Implica comprender las buenas prácticas en torno a la asignación de nombres a tus spans, asegurarte de que estás etiquetando spans con información semánticamente útil y registrar datos estructurados.
Nombres eficaces
¿Qué hay en un nombre? Cuando se trata de un span, ¡ésta es una muy buena pregunta! El nombre de un span, también conocido comonombre de la operación , es un valor obligatorio en las bibliotecas de rastreo de código abierto; de hecho, es uno de los únicos valores obligatorios. ¿Por qué es así? Como hemos aludido, los spans son una abstracción sobre el trabajo de un servicio. Se trata de una diferencia significativa respecto a la forma en que podrías pensar en una cadena de peticiones, o en una pila de llamadas. No debes tener una correspondencia de uno a uno entre el nombre de la función y el nombre del span.
Dicho esto, ¿qué hay en un nombre de palmo?
En primer lugar, los nombres deben ser agregables. En resumen, debes evitar los nombres span que sean únicos para cada ejecución de un servicio. Un antipatrón que vemos, especialmente en los servicios HTTP, es que los implementadores hacen que el nombre del span sea el mismo que el de la ruta que coincide completamente (como GET /api/v2/users/1532492
). Este patrón dificulta la agregación de operaciones a través de miles o millones de ejecuciones, reduciendo gravemente la utilidad de tus datos. En su lugar, haz que la ruta sea más genérica y traslada los parámetros a etiquetas, como GET /api/v2/users/{id}
con una etiqueta asociada de userId: 1532492
.
Nuestro segundo consejo es que los nombres deben describir acciones, no recursos. Por poner un ejemplo, pensemos en MicroCalc. Podríamos añadir un almacén de datos, que podría ser un almacenamiento blob, podría ser SQL, podría ser cualquier cosa para cualquier número de propósitos, como una base de datos de usuarios o un historial de resultados anteriores. En lugar de nombrar un span basándote en el recurso al que accede, muta o consume de alguna otra forma, será mucho mejor que describas la acción y etiquetes el span con el tipo de recurso. Esto permite realizar consultas contra tus tramos a través de múltiples tipos, permitiendo interesantes perspectivas analíticas. Un ejemplo sería la diferencia entre los nombres WriteUserToSQL
y WriteUser
. Puedes imaginar una situación en la que estos componentes independientes se cambian para hacer pruebas (supongamos que queremos probar un almacén de datos NoSQL o en la nube para nuestros usuarios); tener este nombre menos proscriptivo permitiría hacer comparaciones entre cada almacén de respaldo. Si sigues estos dos consejos, te asegurarás de que tus spans sean más útiles a la hora de analizarlos.
Etiquetado eficaz
No estás obligado a etiquetar tus tramos, pero deberías hacerlo. Las etiquetas son la principal forma de enriquecer un span con más información sobre lo que ocurre en una operación determinada, y desbloquean mucho poder para el análisis. Mientras que los nombres te ayudarán a agregar a un alto nivel (para que puedas hacer preguntas como "¿Cuál es mi tasa de error al buscar usuarios en todos los servicios?"), las etiquetas te permitirán trocear esa información para comprender mejor el porqué de tu consulta. Los datos con una cardinalidad alta deben exponerse en tu span como una etiqueta, en lugar de otra cosa: colocar datos de alta cardinalidad en un nombre reduce tu capacidad de agregar operaciones, y colocarlos dentro de sentencias de registro suele reducir su indexabilidad.
Entonces, ¿qué hace que una etiqueta sea eficaz? Las etiquetas deben tener importancia externa, es decir, deben tener significado para otros consumidores de tus datos de rastreo. Aunque hay formas de utilizar etiquetas y trazas en el desarrollo, las etiquetas que emitas en un sistema de trazas de producción deben ser útiles en general para cualquiera que intente comprender lo que hace tu servicio.
Las etiquetas también deben ser coherentes internamente: deben utilizar las mismas claves en varios servicios. En nuestra aplicación simulada, teóricamente podríamos hacer que cada servicio informara sobre el mismo dato (un ID de usuario, por ejemplo) utilizando diferentes claves de etiqueta -userId
, UserId
, User_ID
, USERID
, etc. - pero sería difícil crear consultas sobre ello en sistemas externos. Considera la posibilidad de crear bibliotecas de ayuda que estandaricen estas claves, o establece un formato que se ajuste a las normas de codificación de tu organización.
Además de la coherencia de las claves de las etiquetas, asegúrate de que los datos de las etiquetas se mantienen lo más coherentes posible dentro de una clave de etiqueta. Si algunos servicios informan de la clave userId
como un valor de cadena, y otros como un valor entero, podrían surgir problemas en tu sistema de análisis. Además, asegúrate de que si estás rastreando algún valor numérico, añades la unidad de la etiqueta a la clave. Por ejemplo, si estás midiendo los bytes devueltos en una solicitud, message_size_kb
es más útil que message_size
. Las etiquetas deben ser sucintas en lugar de verbosas; por ejemplo, no pongas trazas de pila en las etiquetas. Recuerda que las etiquetas son fundamentales para consultar tus datos de rastreo y obtener información, ¡así que no las descuides!
Registro eficaz
Tanto la denominación como el etiquetado de los tramos te ayudan a obtener información de tus rastros. Te ayudan a construir una especie de gráfico relacional, mostrándote qué ocurrió (a través de los nombres) y por qué ocurrió (a través de las etiquetas). Los registros podrían considerarse la pieza "cómo ocurrió" de este rompecabezas, ya que ofrecen a los desarrolladores la posibilidad de adjuntar cadenas de texto estructuradas o no estructuradas a un tramo determinado.
Un registro eficaz con las expansiones tiene dos componentes centrales. En primer lugar, pregúntate qué debes registrar realmente. Los span nombrados y etiquetados pueden reducir significativamente la cantidad de sentencias de registro que requiere tu código. En caso de duda, crea un nuevo tramo en lugar de una nueva sentencia de registro. Por ejemplo, considera el pseudocódigo del Ejemplo 4-13.
Ejemplo 4-13. Tramos nombrados y etiquetados
func getAPI(context, request) { value = request.Body() outgoingRequest = new HttpRequest() outgoingRequest.Body = new ValueQuery(value) response = await HttpClient().Get(outgoingRequest) if response != Http.OK { request.error = true return } resValue = response.Body() // Go off and do more work }
Sin rastreo, querrías registrar bastante aquí: por ejemplo, los parámetros entrantes, especialmente el valor que te interesa inspeccionar. Posiblemente sería interesante registrar el cuerpo de la solicitud saliente. El código de respuesta sería definitivamente algo que te interesaría registrar, especialmente si se trata de un caso excepcional o de error. Con un span, sin embargo, hay mucho menos que sea valioso como declaración de registro: el valor del parámetro entrante, si es útil en general, podría ser una etiqueta como value:foo
, el código de respuesta sería sin duda uno, y así sucesivamente. Dicho esto, puede que aún te interese registrar el caso de error exacto que se está produciendo allí. En esta situación, considera la posibilidad de crear un nuevo span para esa petición externa. La razón es doble: se trata de un perímetro del código de tu aplicación y, como ya hemos dicho, es una buena práctica rastrear los perímetros.
Otra razón es que una declaración de registro sería menos interesante en términos de datos que otro tramo. HTTP GET puede parecer una operación sencilla, y a menudo lo es cuando pensamos en utilizarla. Pero piensa en lo que ocurre entre bastidores: búsquedas de DNS, enrutamiento a través de quién sabe cuántos saltos, tiempo de espera en la lectura de sockets, etcétera. Esta información, si se pone a disposición en un span a través de etiquetas, puede proporcionar una visión más detallada de los problemas de rendimiento y, por lo tanto, es mejor que sea un nuevo span en lugar de una operación de registro discreta.
El segundo aspecto para un registro eficaz en tus spans es, cuando sea posible, escribir registros estructurados y asegurarte de que tu sistema de análisis es capaz de entenderlos. Se trata más de garantizar la usabilidad de tus spans en el futuro que de otra cosa: un sistema de análisis puede convertir los datos de registro estructurados en algo más legible en una GUI, y proporciona opciones para realizar consultas complejas (por ejemplo, "muéstrame todos los registros del Servicio X en los que se registró un evento con un tipo concreto de excepción" o "¿alguno de mis servicios emite registros con nivel INFO?").
Comprender las consideraciones de rendimiento
El efecto secundario indeseable de crear estos spans ricos y detallados es que todos tienen que ir a alguna parte, y eso lleva tiempo. Consideremos una representación de texto de un span típico para una solicitud HTTP (ver Ejemplo 4-14).
Ejemplo 4-14. Tramo típico de una solicitud HTTP
{ context: { TraceID: 9322f7b2-2435-4f36-acec-f9750e5bd9b7, SpanID: b84da0c2-5b5f-4ecf-90d5-0772c0b5cc18 } name: "/api/v1/getCat", startTime: 1559595918, finishTime: 1559595920, endTime: tags: [ { key: "userId", value: 9876546 }, { key: "srcImagePath", value: "s3://cat-objects/19/06/01/catten-arr-djinn.jpg" }, { key: "voteTotalPositive", value: 9872658 }, { key: "voteTotalNegative", value: 72 }, { key: "http.status_code", value: 200 }, { key: "env", value: "prod" }, { key: "cache.hit", value: true } ] }
Esto es menos de 1 KB de datos, unos 600 bytes. Si lo codificamos en base64, serán unos 800 bytes. Seguimos estando por debajo de 1 KB, así que está bien, pero esto es sólo un tramo. ¿Qué aspecto tendría en caso de error? Un rastreo de la pila probablemente nos llevaría de menos de 1 KiB a unos 3-4 KiB. La codificación de un solo tramo es, de nuevo, fracciones de segundo(time openssl base64
informa cpu 0.006 total
), lo que no es mucho cuando te pones a ello.
Ahora multiplícalo por mil, diez mil, cien mil... al final, suma y sigue. Nunca vas a conseguir nada de esto gratis, pero no temas, no es tan malo como pueda parecer. Lo primero que debes tener en cuenta es que no lo sabrás hasta que lo sepas: no hay una regla única que podamos darte para que todo funcione mejor por arte de magia. La cantidad de sobrecarga que estás dispuesto a presupuestar y aceptar en el rendimiento de tu aplicación va a variar en función de una gran cantidad de factores que incluyen:
-
Lenguaje y tiempo de ejecución
-
Perfil de Implementación
-
Utilización de los recursos
-
Escalabilidad horizontal
Teniendo esto en cuenta, debes considerar estos factores cuidadosamente cuando empieces a instrumentar tu aplicación. Ten en cuenta que el caso de uso estable y el perfil de rendimiento del peor caso serán a menudo extremadamente diferentes. Más de un desarrollador se ha encontrado en una situación peliaguda en la que algún recurso externo estaba de repente disponible de forma inesperada durante un largo periodo de tiempo, lo que provocaba bucles de caída o cuelgues del servicio extremadamente poco graciosos y que consumían muchos recursos. Una estrategia que puedes utilizar para combatir esto es incorporar válvulas de seguridad a tu marco de trazado interno. Dependiendo de tu estrategia de muestreo, esta "válvula de seguridad de rastreo" podría ser un corte en la creación de nuevos span si la aplicación se encuentra en un estado de fallo persistente, o una reducción gradual de la creación de span hasta un punto asintótico.
Además, considera la posibilidad de incorporar algún método para desactivar remotamente el rastreador en el código de tu aplicación. Esto puede ser útil en una variedad de escenarios más allá de la mencionada pérdida inesperada de recursos externos; también puede ser útil cuando quieras perfilar el rendimiento de tu servicio con el rastreo activado frente al rastreo desactivado.
En última instancia, el mayor coste de recursos en el rastreo no es realmente el coste a nivel de servicio de crear y enviar tramos. Lo más probable es que un solo tramo sea una fracción de los datos que se manejan en cualquier RPC en términos de tamaño. Deberías experimentar con los tramos que creas, la cantidad de datos que les añades y el ritmo al que los creas, para encontrar el equilibrio adecuado entre rendimiento e información necesaria para que el rastreo tenga valor.
Desarrollo basado en trazas
Cuando se habla del seguimiento como parte de una aplicación o servicio, se tiende a "posponerlo", por así decirlo. De hecho, existe casi una jerarquía de monitoreo que se aplica, por orden, a los servicios a medida que se desarrollan. Empezarás sin nada, pero rápidamente empezarás a añadir declaraciones de registro a tu código, para poder ver qué ocurre en un método concreto o qué parámetros se pasan. Muy a menudo, ésta es la mayor parte del monitoreo que se aplica a un nuevo servicio hasta que está a punto de ser lanzado, momento en el que volverás a identificar algunas métricas que te interesen (tasa de error, por ejemplo) y las introducirás, justo antes de que todo el engendro pase a tus Implementaciones de producción.
¿Por qué se hace así? Por varias razones, algunas de ellas buenas. Puede ser muy difícil escribir código de monitoreo cuando el código que estás monitoreando cambia y se agita bajo tus pies -piensa en lo rápido que se pueden añadir, eliminar o refactorizar líneas de código mientras un proyecto está en desarrollo-, así que es algo que los desarrolladores tienden a no hacer, a menos que exista una práctica de observabilidad muy sólida en su equipo.
Pero hay otra razón, y quizá sea la más interesante. Es difícil escribir código de monitoreo en desarrollo porque no sabes realmente qué monitorear. Las cosas por las que sí sabes preocuparte, como la tasa de errores, no son realmente tan interesantes de monitorizar y a menudo pueden observarse a través de otra fuente, como mediante un proxy o una pasarela API. Las métricas a nivel de máquina, como el consumo de memoria de tu proceso, no son algo de lo que la mayoría de los desarrolladores tengan que preocuparse, y si lo hacen, esas métricas serán monitorizadas por un componente diferente y no por su propia aplicación.
Ni las métricas ni los registros hacen un buen trabajo a la hora de capturar las cosas que sí sabes al principio del desarrollo de tu servicio, como con qué servicios debería comunicarse o cómo debería llamar a funciones internamente. El rastreo ofrece una opción, permitiendo el desarrollo de trazas a medida que desarrollas tu aplicación, que ofrecen tanto el contexto necesario mientras desarrollas y pruebas tu código, como un conjunto de herramientas listas para la observabilidad dentro del código de tu aplicación. En esta sección, cubriremos las dos partes de alto nivel de este concepto: desarrollar utilizando trazas y probar utilizando trazas.
Desarrollar con trazas
Independientemente del lenguaje, la plataforma o el estilo de servicio que escribas, probablemente todos empezarán en el mismo lugar: una pizarra. Es esta superficie la que utilizarás para crear el modelo de las funciones de tu servicio, y dibujar líneas que representen las conexiones entre él y otros servicios. Tiene mucho sentido, sobre todo en las primeras fases de prototipado del desarrollo, empezar en un lugar tan maleable.
El problema viene cuando llega el momento de tomar tu modelo y traducirlo a código. ¿Cómo te aseguras de que lo que has escrito en la pizarra coincide con tu código? Tradicionalmente se recomienda el uso de funciones de prueba, pero quizás sea un objetivo demasiado pequeño para decirte realmente algo útil. Una prueba unitaria, por diseño, debe validar el comportamiento de unidades muy pequeñas de funcionalidad -una sola llamada a un método, por ejemplo-. Por supuesto, puedes escribir pruebas unitarias más grandes que empiecen a validar otras suposiciones sobre tus funciones, como asegurarte de que el método A llama al método B y llama al método C... pero, al final, sólo estás escribiendo una prueba que ejercita cada ruta de código por razones espurias.
Cuando intentas probar la relación que tiene tu servicio con los servicios antecedentes y dependientes, la cosa se complica aún más. Generalmente, éstas se considerarían pruebas de integración. Sin embargo, el problema de las pruebas de integración para verificar tu modelo es doble. El primer problema es que si empiezas a simular servicios, no estás realizando pruebas contra el servicio real, sino contra un simulacro de sustitución que sigue alguna orden preestablecida. El segundo problema, y quizás el mayor, es que las pruebas de integración van a estar necesariamente limitadas a tu entorno de pruebas y tienen poco soporte para comunicarse a través de los límites del proceso (al menos, sin pasar por un montón de aros para configurar un marco de pruebas de integración o escribir el tuyo propio).
Si las pruebas unitarias y las pruebas de integración no funcionan, ¿entonces qué lo hará? Bueno, volvamos al punto original: es importante tener una forma de validar el modelo mental de tu aplicación. Eso significa que debes ser capaz de escribir tu código de forma que te permita asegurarte de que tanto los métodos internos como los servicios externos se llaman en el orden correcto, con los parámetros correctos, y que los errores se gestionan sanamente. Un error común que hemos observado, especialmente en código con importantes dependencias de servicios externos, es lo que ocurre en caso de fallo persistente del servicio.
Puedes ver ejemplos de esto en el mundo real todo el tiempo. Piensa en las interrupciones que se produjeron como resultado de la indisponibilidad persistente de los buckets S3 de AWS durante horas y horas hace varios años. Disponer de datos de rastreo, tanto en pruebas como en producción, te permite escribir herramientas que comparen rápidamente el estado deseado de tu sistema con la realidad. También tiene un valor incalculable cuando intentas construir sistemas caóticos como parte de tu integración continua/entrega continua (CI/CD): ser capaz de encontrar las diferencias entre tu sistema en estado estacionario y el sistema bajo el caos mejorará drásticamente tu capacidad para construir sistemas más resistentes.
El rastreo como parte de tu proceso de desarrollo funciona de forma similar al rastreo en cualquier otra parte de tu código base, con algunas ventajas notables. En primer lugar, recuerda lo que hemos dicho antes sobre cómo empezar a rastrear un servicio ("Dónde empezar: nodos y perímetros"). El mismo principio se aplica a la escritura de un nuevo servicio y a la instrumentación de uno existente. Debes aplicar a cada solicitud entrante un middleware que compruebe los datos del span y, si existe, crear un nuevo span hijo. Cuando tu nuevo servicio emita solicitudes salientes, también deberá inyectar tu contexto span actual en la solicitud saliente, de modo que los servicios descendentes que sean conscientes del rastreo puedan participar en él. Los cambios en el proceso tienden a producirse entre estos puntos, porque te enfrentarás a retos sobre cuánto rastrear.
Como discutiremos al final de este capítulo, existe algo llamado demasiado rastreo. Especialmente en producción, querrás limitar tus trazas a los datos que importan a los observadores y usuarios externos cuando ven una traza de extremo a extremo. Entonces, ¿cómo modelar con precisión un único servicio con múltiples llamadas internas? Querrás crear algún tipo de concepto de verbosidad para tu rastreador. Esto es muy común en el registro , donde existen niveles de registro como info, debug, warning y error. Cada verbosidad especifica un mínimo que debe cumplir la sentencia de registro para ser impresa. El mismo concepto puede aplicarse también a las trazas. El Ejemplo 4-15 muestra un método en Golang para crear trazas verbose, configurables mediante una variable de entorno.
Ejemplo 4-15. Crear trazas detalladas
var
traceVerbose
=
os
.
Getenv
(
"TRACE_LEVEL"
)
==
"verbose"
...
func
withLocalSpan
(
ctx
context
.
Context
)
(
context
.
Context
,
opentracing
.
Span
)
{
if
traceVerbose
{
pc
,
_
,
_
,
ok
:=
runtime
.
Caller
(
1
)
callerFn
:=
runtime
.
FuncForPC
(
pc
)
if
ok
&&
callerFn
!=
nil
{
span
,
ctx
:=
opentracing
.
StartSpanFromContext
(
ctx
,
callerFn
.
Name
()
)
return
ctx
,
span
}
}
return
ctx
,
opentracing
.
SpanFromContext
(
ctx
)
}
func
finishLocalSpan
(
span
opentracing
.
Span
)
{
if
traceVerbose
{
span
.
Finish
()
}
}
Establecer la verbosidad de las trazas no se limita a los aspectos de Go: se pueden utilizar atributos u otras técnicas dinámicas o de metaprogramación en los lenguajes que las admitan. No obstante, la idea básica es la presentada. Primero, asegúrate de que el nivel de verbosidad está ajustado adecuadamente. Después, determina la función de llamada e inicia un nuevo span como hijo del actual. Por último, devuelve el span y el objeto de contexto lingüístico según corresponda. Ten en cuenta que, en este caso, sólo estamos proporcionando un método de inicio/fin, lo que significa que cualquier registro o etiqueta que introduzcamos no se añadirá necesariamente al hijo verborreico, sino que podría añadirse al padre si el hijo no existe. Si esto no es deseable, considera la posibilidad de crear funciones de ayuda para registrar o etiquetar a través de ellas para evitar este comportamiento. Utilizar nuestras trazas verbose también es bastante sencillo (ver Ejemplo 4-16).
Ejemplo 4-16. Utilizar trazas detalladas
import
(
"github.com/opentracing-contrib/go-stdlib/nethttp"
"github.com/opentracing/opentracing-go"
)
func
main
()
{
// Create and register tracer
mux
:=
http
.
NewServeMux
()
fs
:=
http
.
FileServer
(
http
.
Dir
(
"../static"
))
mux
.
HandleFunc
(
"/getFoo"
,
getFooHandler
)
mux
.
Handle
(
"/"
,
fs
)
mw
:=
nethttp
.
Middleware
(
tracer
,
mux
)
log
.
Printf
(
"Server listening on port %s"
,
serverPort
)
http
.
ListenAndServe
(
serverPort
,
mw
)
}
func
getFooHandler
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
foo
:=
getFoo
(
r
.
Context
())
// Handle response
}
func
getFoo
(
ctx
context
.
Context
)
{
ctx
,
localSpan
:=
withLocalSpan
(
ctx
)
// Do stuff
finishLocalSpan
(
localSpan
)
}
En este ejemplo, estamos creando un sencillo servidor HTTP en Golang y rastreándolo con el paquete go-stdlib
. Esto analizará las solicitudes HTTP entrantes en busca de cabeceras de rastreo y creará spans de forma adecuada, de modo que los perímetros de nuestro servicio se estén gestionando. Añadiendo los métodos withLocalSpan
y finishLocalSpan
, podemos crear un span que sea local a una función y que sólo exista cuando nuestra verbosidad de rastreo esté configurada adecuadamente.
Estos intervalos podrían visualizarse en un analizador de trazas mientras realizas el desarrollo local, lo que te permitiría evaluar con precisión que las llamadas se están produciendo de la misma forma que crees que deberían, asegurándote de que puedes observar tu servicio mientras llama a otros servicios (o es llamado por ellos), y como extra te permite utilizar frameworks de código abierto como opción predeterminada para preguntas como "¿Qué API de registro/métricas/rastreo debería utilizar?", ya que éstas pueden realizarse a través de tu API de telemetría. Al fin y al cabo, ¡no reinventes la rueda si no es necesario!
Pruebas con trazas
Los datos de rastreo pueden representarse como un gráfico acíclico direccional. Aunque normalmente se representa como un grafo de llamas, las trazas son simplemente grafos acíclicos dirigidos de una petición, como se ilustra en la Figura 4-4. Un grafo acíclico dirigido, o DAG, puede resultarte muy familiar si tienes conocimientos de informática o matemáticas; tiene varias propiedades que son extremadamente útiles. Los DAG son finitos (tienen un final) y no tienen ciclos dirigidos (no forman bucles sobre sí mismos; los que lo hacen se denominan referencias cíclicas). Otra propiedad útil de los DAG es que son bastante fáciles de comparar entre sí.
Sabiendo esto, ¿cuáles son las posibilidades? En primer lugar, puede que te preguntes: "¿Y qué?". Como ya hemos dicho, las pruebas de integración y otras formas de pruebas de nivel superior son suficientes y necesarias para garantizar el funcionamiento de nuestro servicio a medida que se despliega. Dicho esto, hay varias razones por las que podrías considerar añadir comparaciones de trazas a tu repertorio de pruebas. La forma más fácil de pensar en los datos de rastreo aplicados como una forma de prueba es a través de simples diferencias entre entornos. Considera un escenario en el que desplegamos una versión de nuestra aplicación en un entorno de preparación o preproducción después de probarla localmente. Considera además que exporta nuestros datos de rastreo en algún tipo de archivo plano, adecuado para su procesamiento, como se muestra en el Ejemplo 4-17.
Ejemplo 4-17. Exportar datos de traza
[ { name: "getFoo", spanContext: { SpanID: 1, TraceID: 1 }, parent: nil }, { name: "computeFoo", spanContext: { SpanID: 2, TraceID: 1 }, parent: spanContext{ SpanID: 1, TraceID: 1 } }, ... ]
En un sistema real, podríamos esperar que estuvieran desordenados o que no existieran en un estado ordenado, pero deberíamos esperar que el gráfico de llamadas de un único punto final de la API fuera el mismo entre ellos.
Una posible aplicación, por tanto, es realizar una ordenación topográfica en cada conjunto de datos de traza, y luego comparar por longitud o mediante algún otro proceso de diferenciación. Si nuestras trazas difieren, sabremos que tenemos algún tipo de problema porque los resultados no coinciden con nuestras expectativas.
Otra aplicación de esto sería identificar, de forma proactiva, cuándo los servicios empiezan a tomar dependencias de tu servicio. Consideremos una situación en la que nuestro servicio de autenticación, o de búsqueda, se diera a conocer más ampliamente a otros equipos. Sin que nosotros lo sepamos, empiezan a tomar una dependencia de él en sus nuevos servicios. La difusión automatizada de trazas nos daría una visión proactiva de estos nuevos consumidores, especialmente si existe algún tipo de marco centralizado que genere y compare estas trazas a lo largo del tiempo.
Otra aplicación es simplemente utilizar el rastreo como columna vertebral de la recopilación de indicadores de nivel de servicio y objetivos para tu servicio, y compararlos automáticamente a medida que despliegas nuevas versiones. Dado que las trazas son inherentemente capaces de comprender la temporización de tu servicio, son una forma estupenda de hacer un seguimiento de los cambios de rendimiento en una amplia variedad de peticiones a medida que iteras y desarrollas más los servicios.
En última instancia, gran parte de esto es especulativo: no sabemos de nadie que utilice intensamente el rastreo distribuido como parte de sus conjuntos de pruebas. Eso no significa que no sea útil, pero al ser una tecnología nueva, aún no se han explorado ni explotado todas sus facetas. Quizá tú seas el primero.
Creación de un plan de instrumentación
Para bien o para mal, la mayoría de la gente llega al seguimiento y monitoreo distribuidos tarde en el desarrollo de una aplicación o pieza de software. Parte de esto tiene que ver con la naturaleza del desarrollo iterativo: cuando estás creando un producto o servicio, puede ser difícil comprender qué es lo que necesitas saber hasta que hayas pasado algún tiempo construyéndolo y ejecutándolo. El rastreo distribuido también añade una arruga a esto, porque los desarrolladores a menudo acuden a él como solución a problemas que surgen debido a la escala y el crecimiento, ya sea en términos de recuento de servicios o de complejidad organizativa. En ambos casos, a menudo tendrás un gran conjunto de servicios, presumiblemente complicados, ya implementados y en funcionamiento, y necesitarás saber cómo puedes aprovechar el rastreo distribuido para mejorar la fiabilidad y la salud no sólo de tu software, sino también de tu equipo. Tal vez estés empezando el desarrollo de una nueva pieza de software y estés añadiendo el rastreo distribuido desde el principio: tendrás que crear un plan para añadir y hacer crecer el rastreo en todo tu equipo y organización. En esta sección, hablaremos de cómo puedes argumentar eficazmente a favor de la instrumentación de servicios nuevos o existentes en tu organización y cómo conseguir la aceptación de tus equipos (y de otros), las señales que indican cuándo has instrumentado lo suficiente y, por último, cómo hacer crecer de forma sostenible la instrumentación en todos tus servicios.
Argumentar a favor de la instrumentación
Supongamos que ya te ha convencido la idea del rastreo distribuido por el hecho de haber leído este libro. El reto, entonces, es convencer a tus colegas de que es tan buena idea como tú crees, porque ellos también van a tener que trabajar para asegurarse de que sus servicios son compatibles con el rastreo.
Cuando defiendas ante otros equipos las ventajas y los costes del rastreo distribuido, es importante que tengas en cuenta muchas de las lecciones sobre instrumentación que hemos tratado en este capítulo. En resumen, la instrumentación puede ser valiosa aunque sea bastante básica. Si cada servicio emite un span con algunos atributos básicos que no requieren sobrecarga en tiempo de ejecución (es decir, valores de cadena que se pueden precalcular en la inicialización del servicio), entonces la sobrecarga total añadida a cada solicitud es simplemente la propagación de las cabeceras de contexto de rastreo, una tarea que añade 25 bytes en el cable y una cantidad insignificante de ciclos para decodificar después.
El beneficio -rastreo de extremo a extremo de una solicitud- es extremadamente útil por un precio tan pequeño. Este estilo de trazado distribuido centrado en las solicitudes de ha encontrado adoptantes en empresas como Google, que ha utilizado Dapper para diagnosticar anomalías y problemas de rendimiento en estado estacionario, además de la atribución de la utilización de recursos.1 Muchos otros equipos de ingeniería y organizaciones, grandes y pequeñas, han adoptado el trazado distribuido para reducir el MTTR de incidencias y otros tiempos de inactividad de la producción. Además, el trazado distribuido es extremadamente valioso como parte de una práctica más amplia de monitoreo y observabilidad, donde te permite reducir el espacio de búsqueda de datos que necesitas investigar para diagnosticar incidentes, perfilar el rendimiento y restaurar tus servicios a un estado saludable.
Puede ser útil pensar en el rastreo distribuido como un "campo de juego nivelado" cuando se trata del rendimiento del servicio. Especialmente cuando se interactúa en un entorno políglota, o en una empresa distribuida globalmente, puede haber retos a la hora de garantizar que todo el mundo está en la misma página en cuanto a los datos de rendimiento. Algunos de estos retos son técnicos, pero muchos son políticos. La proliferación de métricas de vanidad es particularmente notable aquí; puedes medir bastantes cosas sobre el rendimiento de tu software que no importan, y puede que ya lo estés haciendo para alcanzar nebulosos objetivos de "calidad" establecidos por razones que escapan a nuestro conocimiento. Los datos de rastreo distribuidos, sin embargo, proporcionan señales críticas por defecto de forma estandarizada para todos tus servicios y lo hacen sin requerir puntos finales sintéticos o enfoques para garantizar la salud del servicio. Estos datos de rastreo pueden utilizarse para aportar algo de paz y cordura a un proceso posiblemente roto. Por supuesto, el primer paso para proporcionar esos datos de rastreo es la instrumentación del servicio, así que tendrás que empezar por ahí.
Instrumentar tus servicios no tiene por qué ser difícil. Las buenas herramientas -de código abierto y propietarias- aliviarán la carga de la instrumentación. Las detallamos en el Apéndice A, con ejemplos de instrumentación automática, así como integraciones de bibliotecas para marcos de trabajo populares que permiten el rastreo, a veces sin necesidad de cambiar el código. Debes tener en cuenta tus marcos de trabajo y el código compartido a la hora de plantear la instrumentación, de modo que puedas aprovechar estas herramientas existentes. Según nuestra experiencia, uno de los argumentos más persuasivos para el rastreo distribuido es simplemente instrumentar algún marco de microservicios existente ya utilizado por tu organización y demostrar cómo se pueden rastrear los servicios que lo utilizan simplemente actualizando una dependencia. Si tienes un hackathon interno o un hack day, ¡este puede ser un proyecto divertido e interesante que abordar!
Independientemente de cómo lo hagas, los argumentos a favor de la instrumentación se reducen en última instancia a los argumentos a favor del rastreo distribuido en general. Como hemos mencionado, hay muchas aplicaciones interesantes para el seguimiento fuera del monitoreo del rendimiento: seguimiento como parte de tu ciclo de desarrollo, seguimiento al probar otras aplicaciones. Podrías utilizar el rastreo distribuido como parte de tu marco de trabajo de CI y CD, cronometrando cuánto tardan determinadas partes de tus compilaciones e implementaciones. El seguimiento podría integrarse en los ejecutores de tareas para crear máquinas virtuales o aprovisionar contenedores, permitiéndote comprender qué partes de tu ciclo de vida de construcción e implementación llevan más tiempo. El rastreo puede utilizarse como valor añadido para los servicios que proporcionan algún tipo de API como servicio: si ya rastreas el tiempo de ejecución de tu backend, podrías poner alguna versión de esos datos de rastreo a disposición de tus clientes para ayudarles también a perfilar su software. Las posibilidades de rastreo son ilimitadas, y los argumentos para instrumentar tu software deberían reflejarlo.
Lista de comprobación de la calidad de la instrumentación
Al instrumentar un servicio existente o crear directrices sobre cómo instrumentar nuevos servicios, puede ser útil disponer de una lista de comprobación de los elementos que son importantes para garantizar una instrumentación de calidad en toda tu aplicación. Hemos incluido una recomendada en el repositorio del libro, pero puedes utilizarla como punto de partida para la tuya propia.
Gran parte de lo que hay en nuestra lista de comprobación de instrumentación está sacado de otras partes de este capítulo, así que no nos extenderemos demasiado. Algunas notas a destacar:
-
Muchas bibliotecas de instrumentación de código abierto o bibliotecas de instrumentación de frameworks instrumentarán, por defecto, cada solicitud entrante o punto final definido en el código de tu servicio, incluidos los puntos finales de diagnóstico. Generalmente, querrás implementar un filtro o muestreador en tu servicio para evitar que se creen tramos desde estos puntos finales, a menos que tengas alguna necesidad apremiante de ello.
-
Ten mucho cuidado con exponer la IIP en tus atributos y eventos; los costes por incumplimiento pueden ser graves, especialmente si transmites datos de rastreo a un tercero para su análisis y almacenamiento.
-
Los atributos de versión son muy valiosos, sobre todo cuando se hacen comparaciones de trazas, ya que te permiten diferenciar fácilmente una petición entre dos o más versiones de un servicio para descubrir regresiones o mejoras en el rendimiento.
-
Integrar tus indicadores de características y otros experimentos con tus datos de rastreo es una forma útil de comprender cómo esos experimentos están cambiando el rendimiento y la fiabilidad de tu servicio.
No dudes en adaptar esta lista de comprobación con información específica que la haga útil para tu equipo, e inclúyela en las listas de comprobación de implantación de servicios.
Saber cuándo dejar de instrumentar
Ya hemos hablado varias veces de los costes de la instrumentación en este capítulo; echemos un vistazo más profundo. A un alto nivel, la instrumentación es una compensación como cualquier otra cosa en software. Estás cambiando cierta medida de rendimiento por, con suerte, un nivel mucho mayor de conocimiento del funcionamiento de tu servicio a un nivel que sea fácil de comunicar a otros miembros de tu equipo u organización. En esta sección señalamos algunos antipatrones notables a tener en cuenta cuando instrumentas. Existe el riesgo de que las compensaciones resulten demasiado costosas y te lleven a dejar de instrumentar, o a sobremuestrear y perder resolución en tus trazas.
Un antipatrón es implementar una resolución por defecto demasiado alta. Una buena regla es que tu servicio emita tantos intervalos como operaciones lógicas realice. ¿Tu servicio gestiona la autenticación y autorización de los usuarios? Desglosa lógicamente esta función: gestiona una solicitud entrante, realiza algunas búsquedas en un almacén de datos, transforma el resultado y lo devuelve. Aquí hay dos operaciones lógicas: gestionar la solicitud/respuesta y buscar los datos. Siempre es útil separar las llamadas a servicios externos; en este ejemplo, puede que sólo tengas un span si el almacén de datos es algún tipo de base de datos local), pero puede que no necesites emitir un span para transformar la respuesta al nuevo formato que espera la persona que llama.
Si tu servicio es más complicado, añadir más spans puede estar bien, pero tienes que considerar cómo los consumidores de tus datos de rastreo los encontrarán valiosos, y si son colapsables en menos spans. El corolario de este punto es que tal vez quieras tener la posibilidad de aumentar la verbosidad de los spans emitidos por un servicio -vuelve a consultar "Desarrollo orientado al rastreo" para obtener ideas sobre cómo aumentar o disminuir la resolución de tus spans. Por eso decimos resolución por defecto; quieres asegurarte de que la cantidad de información emitida por defecto es lo suficientemente pequeña como para integrarse bien en un rastreo mayor, pero lo suficientemente grande como para garantizar que contiene información útil para consumidores que podrían no estar en tu equipo (¡pero que podrían verse afectados por problemas con tu servicio!).
Otro antipatrón es no estandarizar tu formato de propagación. Esto puede suponer un reto, sobre todo cuando se integran servicios heredados o servicios escritos por diversos equipos. El valor clave de una traza está en su naturaleza interconectada. Si tienes 20, 50, 200 o más servicios que utilizan un batiburrillo de formatos de rastreo , lo vas a pasar mal intentando sacar valor de tus rastreos. Evítalo normalizando tus prácticas de trazado en la medida de lo posible, y proporcionando calzos para los sistemas heredados o entre diferentes formatos.
Un método para combatir los formatos de propagación no estándar es crear una pila de propagadores de rastreo que puedan conocer las distintas cabeceras (como X-B3
o opentracing
) y seleccionar la adecuada en función de cada solicitud. Puede que te resulte menos trabajoso actualizar los sistemas existentes al nuevo formato en lugar de crear capas de compatibilidad: utiliza tu mejor criterio y las normas y prácticas existentes en tu organización para averiguar qué es lo más adecuado para ti.
El último consejo, volviendo al título de la sección, es saber cuándo debes parar. Por desgracia, no hay una respuesta sencilla, pero sí algunas señales a las que debes prestar atención. En general, debes considerar cuál es el punto de ruptura de tu servicio sin muestrear ninguno de tus datos de traza.
El muestreo es una práctica en la que un determinado porcentaje de tus trazas no se registran para el análisis, con el fin de reducir la carga total de tu sistema. En "Muestreo" aparece una discusión sobre el muestreo , pero te aconsejamos que no tengas en cuenta la frecuencia de muestreo al escribir la instrumentación. Si te preocupa la cantidad de tramos creados por tu servicio, considera la posibilidad de utilizar indicadores de verbosidad para ajustar dinámicamente cuántos tramos se están creando, o considera enfoques de muestreo "basados en la cola" que analicen toda la traza antes de tomar una decisión de muestreo. Esto es importante porque el muestreo es la mejor forma de desechar accidentalmente datos potencialmente críticos que podrían ser útiles al depurar o diagnosticar un problema en producción. Por el contrario, un enfoque de muestreo tradicional tomará la decisión al principio de la traza, por lo que no hay razón para optimizar en torno a "se muestreará o no esto": tu traza se desechará en su totalidad si se muestrea.
Una señal de que necesitas seguir adelante es si la resolución interservicios de tu traza es demasiado baja. Por ejemplo, si estás elidiendo varios servicios dependientes en un solo tramo, deberías seguir instrumentando hasta que esos servicios sean tramos independientes. No necesitas necesariamente instrumentar los servicios dependientes, pero tus llamadas RPC a cada uno de ellos deben instrumentarse, especialmente si cada una de esas llamadas es terminal en tu cadena de peticiones. Para ser más concretos, imagina un servicio de trabajador que se comunica con varios envoltorios de almacén de datos: puede que no sea necesario instrumentar esos envoltorios de almacén de datos, pero deberías tener spans independientes para cada una de las llamadas de tu servicio a ellos, a fin de comprender mejor la latencia y los errores (¿estoy fallando al leer, o al escribir?).
Deja de trazar si el número de tramos que emites por defecto empieza a parecerse a la pila de llamadas real de un servicio. Sigue instrumentando si tienes casos de error no gestionados en el código de tu servicio: poder categorizar los spans con errores frente a los spans sin errores es vital. Por último, debes seguir instrumentando si encuentras cosas nuevas que instrumentar. Considera la posibilidad de modificar tu proceso estándar de gestión de errores para incluir no sólo la escritura de nuevas pruebas que cubran la corrección, sino también la escritura de nueva instrumentación para asegurarte de que puedes detectarla en el futuro.
Crecimiento inteligente y sostenible de la instrumentación
Una cosa es instrumentar un único servicio, o una aplicación de demostración que pretende enseñarte algunos conceptos sobre el rastreo. Otra cosa, mucho más difícil, es saber adónde ir a partir de ahí. Dependiendo de cómo empieces tu viaje de instrumentación, puedes encontrarte rápidamente en aguas no probadas, luchando por averiguar cómo proporcionar valor a partir del rastreo y, al mismo tiempo, hacer crecer su adopción dentro de tu organización o equipo.
Hay varias estrategias que puedes emplear para hacer crecer la instrumentación dentro de tu aplicación. Estas estrategias, a grandes rasgos, pueden agruparse en soluciones técnicas y organizativas. Primero abordaremos las estrategias técnicas y luego hablaremos de las organizativas. Hay cierto solapamiento entre ambas: como cabría esperar, las soluciones técnicas y organizativas trabajan codo con codo para potenciarse mutuamente.
Técnicamente, la mejor forma de hacer crecer la instrumentación en toda tu aplicación es facilitar su uso. Proporcionar bibliotecas que hagan el trabajo pesado necesario para configurar el rastreo e integrarlo en tus marcos RPC u otro código compartido facilita a los servicios la integración del rastreo. Del mismo modo, crear etiquetas, atributos y otros metadatos estándar para tu organización es una forma estupenda de garantizar que los nuevos equipos y servicios que adopten el rastreo tengan una hoja de ruta para comprender rápidamente y obtener valor del rastreo a medida que lo habilitan. Por último, considera la adopción del rastreo como parte de tu proceso de desarrollo y pruebas: si los equipos son capaces de empezar a utilizar el rastreo en el día a día, se convertirá en parte de su flujo de trabajo, y estará disponible una vez que desplieguen sus servicios en producción.
En última instancia, el objetivo del crecimiento de la instrumentación debe estar ligado a la facilidad de adopción de la instrumentación. Te resultará difícil hacer crecer la adopción del rastreo si su implementación supone mucho trabajo para los desarrolladores individuales. Todas las grandes organizaciones de ingeniería que han adoptado el rastreo distribuido (incluidas Google y Uber) han hecho del rastreo un componente de primera clase de su arquitectura de microservicios, envolviendo sus bibliotecas de infraestructura en código de rastreo. Esta estrategia permite que la instrumentación crezca de forma natural: a medida que se implementen o migren nuevos servicios, obtendrán automáticamente la instrumentación.
Desde el punto de vista organizativo, hay algo más de lo que hablar. Todas las soluciones técnicas presentadas anteriormente no servirán de mucho sin la aceptación de la organización. Entonces, ¿cómo debes desarrollar esa aceptación? La opción más sencilla, y que hemos visto que ha tenido un éxito increíble, es simplemente un mandato de arriba abajo para utilizar el rastreo distribuido. Ahora bien, esto no significa necesariamente que debas empezar a enviar correos electrónicos a tu vicepresidente de ingeniería y, en muchos casos, no es la estrategia más eficaz. Si tienes un equipo de plataforma, un equipo SRE, un equipo DevOps u otros ingenieros de infraestructura, estos equipos pueden ser un lugar adecuado para buscar el impulso para hacer crecer el rastreo en todo tu software. Considera cómo se comunican y gestionan los problemas en tu organización de ingeniería. ¿Quién tiene la gestión del rendimiento como parte de su cartera? Estos pueden ser aliados y defensores de la implantación inicial del rastreo en todos tus servicios.
Si tu equipo de SRE utiliza herramientas como listas de comprobación de lanzamientos, añade la compatibilidad del rastreo a la lista de comprobación y empieza a desplegarlo de esa forma. También debes tener en cuenta el rendimiento de tu rastreo cuando realices postmortems de incidentes en : ¿hubo servicios que no se rastrearon y que deberían haberse rastreado? ¿Había datos críticos para resolver el incidente que no estaban presentes en los spans? La instrumentación más allá de lo básico también puede ser un objetivo definido para tus equipos que se mide como cualquier otro aspecto de la calidad del código. También es útil hacer un seguimiento de las mejoras en la instrumentación, en lugar de limitarse a añadir nuevos servicios: una instrumentación eficaz es tan importante como una instrumentación ubicua.
Asegúrate de que existe un proceso para que los usuarios finales de tus trazas puedan sugerir mejoras, especialmente en las bibliotecas compartidas, con el fin de impulsar la mejora continua. Presta mucha atención al código de instrumentación existente durante las refactorizaciones, especialmente las refactorizaciones que modifican la propia instrumentación. ¡No querrás perder resolución en tus trazas porque alguien haya eliminado tramos accidentalmente! Esta es un área en la que resulta valioso construir pruebas en torno a tu instrumentación, ya que puedes comparar fácilmente el estado de tus trazas antes y después de los cambios, y advertir o notificar automáticamente a los desarrolladores de las diferencias inesperadas.
En última instancia, la instrumentación es una parte fundamental del rastreo distribuido, pero sólo es el primer paso. Sin instrumentación, no vas a tener los datos de rastreo necesarios para observar y comprender realmente las peticiones a medida que se mueven por tu sistema. Una vez que hayas instrumentado tus servicios, de repente te encontrarás con una manguera de fuego de datos. ¿Cómo puedes recopilar y analizar esos datos para descubrir perspectivas e información sobre el rendimiento de tus servicios en conjunto? En los próximos capítulos, hablaremos del arte de recopilar y almacenar datos de rastreo.
Get Rastreo distribuido en la práctica now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.