Capítulo 4. Rastreo con BPF
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
En ingeniería de software, el trazado es un método para recopilar datos para la creación de perfiles y la depuración. El objetivo es proporcionar información útil en tiempo de ejecución para futuros análisis. La principal ventaja de utilizar BPF para el rastreo es que puedes acceder a casi cualquier información del núcleo de Linux y de tus aplicaciones. BPF añade una cantidad mínima de sobrecarga al rendimiento y la latencia del sistema en comparación con otras tecnologías de rastreo, y no requiere que los desarrolladores modifiquen sus aplicaciones con el único fin de recopilar datos de ellas.
El núcleo Linux proporciona varias capacidades de instrumentación que pueden utilizarse junto con BPF. En este capítulo hablamos de estas distintas capacidades. Te mostramos cómo el núcleo expone esas capacidades en tu sistema operativo para que sepas cómo encontrar la información disponible para tus programas BPF.
El objetivo final del rastreo es proporcionarte una comprensión profunda de cualquier sistema, tomando todos los datos disponibles y presentándotelos de forma útil. Vamos a hablar de algunas representaciones de datos diferentes y de cómo puedes utilizarlas en distintos escenarios.
A partir de este capítulo, vamos a utilizar un potente conjunto de herramientas para escribir programas BPF, la Colección de Compiladores BPF (BCC). BCC es un conjunto de componentes que hace más predecible la construcción de programas BPF. Aunque domines Clang y LLVM, probablemente no querrás pasar más tiempo del necesario construyendo las mismas utilidades y asegurándote de que el verificador de BPF no rechace tus programas. BCC proporciona componentes reutilizables para estructuras comunes, como los mapas de eventos Perf, e integración con el backend de LLVM para ofrecer mejores opciones de depuración. Además, BCC incluye enlaces para varios lenguajes de programación; vamos a utilizar Python en nuestros ejemplos. Estos enlaces te permiten escribir la parte del espacio de usuario de tus programas BPF en un lenguaje de alto nivel, lo que da lugar a programas más útiles. También utilizaremos BCC en los capítulos siguientes para que nuestros ejemplos sean más concisos.
El primer paso para poder rastrear programas en el núcleo de Linux es identificar los puntos de extensión que proporciona para que puedas adjuntar programas BPF. Esos puntos de extensión se denominan comúnmente sondas.
Sondas
Una de las definiciones del diccionario inglés para la palabra sonda es la siguiente:
Nave espacial exploratoria no tripulada diseñada para transmitir información sobre su entorno.
Esta definición evoca recuerdos de películas de ciencia ficción y misiones épicas de la NASA en nuestra mente, y probablemente también en la tuya. Cuando hablamos de sondas de rastreo, podemos utilizar una definición muy similar.
Las sondas de rastreo son programas exploratorios diseñados para transmitir información sobre el entorno en el que se ejecutan.
Recogen datos en tu sistema y los ponen a tu disposición para que los explores y analices. Tradicionalmente, utilizar sondas en Linux implicaba escribir programas que se compilaban en módulos del núcleo, lo que podía causar problemas catastróficos en los sistemas de producción. A lo largo de los años, evolucionaron para ser más seguras de ejecutar, pero aún engorrosas de escribir y probar. Herramientas como SystemTap establecieron nuevos protocolos para escribir sondas y allanaron el camino para obtener información mucho más rica del núcleo de Linux y de todos los programas que se ejecutan en el espacio de usuario.
BPF se apoya en las sondas de rastreo para recopilar información para la depuración y el análisis. La naturaleza de seguridad de los programas BPF hace que su uso sea más atractivo que el de las herramientas que siguen dependiendo de la recompilación del núcleo. Volver a compilar el núcleo para incluir módulos externos puede introducir el riesgo de que se produzcan fallos en debido a código que no se comporta correctamente. El verificador BPF elimina este riesgo analizando el programa antes de cargarlo en el núcleo. Los desarrolladores de BPF aprovecharon las definiciones de sonda y modificaron el núcleo para que ejecutara programas BPF en lugar de módulos del núcleo cuando una ejecución de código encontrara una de esas definiciones.
Comprender los distintos tipos de sondas que puedes definir es fundamental para explorar lo que ocurre en tu sistema. En esta sección, clasificamos las distintas definiciones de sonda, cómo descubrirlas en tu sistema y cómo adjuntarles programas BPF.
En este capítulo tratamos cuatro tipos diferentes de sondas:
- Sondas del núcleo
-
Te dan acceso dinámico a los componentes internos del núcleo.
- Tracepuntos
-
Proporcionan acceso estático a los componentes internos del núcleo.
- Sondas del espacio de usuario
-
Te dan acceso dinámico a los programas que se ejecutan en el espacio de usuario.
- Tracepuntos definidos estáticamente por el usuario
-
Permiten el acceso estático a los programas que se ejecutan en el espacio de usuario.
Empecemos con las sondas del núcleo.
Sondas del núcleo
Las sondas del kernel te permiten establecer banderas dinámicas, o pausas, en casi cualquier instrucción del kernel con un mínimo de sobrecarga. Cuando el núcleo alcanza una de estas banderas, ejecuta el código adjunto a la sonda, y luego reanuda su rutina habitual. Las sondas del núcleo pueden darte información sobre cualquier cosa que ocurra en tu sistema, como los archivos que se abren en él y los binarios que se ejecutan. Una cosa importante a tener en cuenta sobre las sondas del núcleo es que no tienen una interfaz binaria de aplicación (ABI) estable, lo que significa que pueden cambiar entre versiones del núcleo. El mismo código podría dejar de funcionar si intentas conectar la misma sonda a dos sistemas con dos versiones distintas del núcleo.
Las sondas del núcleo se dividen en dos categorías: kprobes y kretprobes. Su uso depende del punto del ciclo de ejecución en el que puedas insertar tu programa BPF. Esta sección te guía sobre cómo utilizar cada una de ellas para adjuntar programas BPF a esas sondas y extraer información del núcleo.
Kprobes
Las Kprobes te permiten introducir programas BPF antes de que se ejecute cualquier instrucción del núcleo. Necesitas conocer la firma de la función en la que quieres introducirte y, como hemos mencionado antes, no se trata de una ABI estable, por lo que deberás tener cuidado al establecer estas sondas si vas a ejecutar el mismo programa en diferentes versiones del núcleo. Cuando la ejecución del núcleo llega a la instrucción en la que has fijado tu sonda, se introduce en tu código, ejecuta tu programa BPF y devuelve la ejecución a la instrucción original.
Para mostrarte cómo utilizar kprobes, vamos a escribir un programa BPF que imprima el nombre de cualquier binario que se ejecute en tu sistema. En este ejemplo vamos a utilizar la interfaz de Python para las herramientas BCC,, pero puedes escribirlo con cualquier otra herramienta BPF:
from
bcc
import
BPF
bpf_source
=
"""
int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) {
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_trace_printk(
"
executing program:
%s
"
, comm);
return 0;
}
"""
bpf
=
BPF
(
text
=
bpf_source
)
execve_function
=
bpf
.
get_syscall_fnname
(
"
execve
"
)
bpf
.
attach_kprobe
(
event
=
execve_function
,
fn_name
=
"
do_sys_execve
"
)
bpf
.
trace_print
(
)
Se inicia nuestro programa BPF. El ayudante
bpf_get_current_comm
va a obtener el nombre del comando actual que está ejecutando el núcleo y lo almacenará en nuestra variablecomm
. La hemos definido como una matriz de longitud fija porque el núcleo tiene un límite de 16 caracteres para los nombres de los comandos. Tras obtener el nombre del comando, lo imprimimos en nuestra traza de depuración, para que la persona que ejecute el script de Python pueda ver todos los comandos capturados por BPF.Carga el programa BPF en el núcleo.
Asocia el programa a la llamada al sistema
execve
. El nombre de esta llamada al sistema ha cambiado en diferentes versiones del núcleo, y BCC proporciona una función para recuperar este nombre sin que tengas que recordar qué versión del núcleo estás ejecutando.El código muestra el registro de rastreo, para que puedas ver todos los comandos que estás rastreando con este programa.
Kretprobes
Las kretprobes van a insertar tu programa BPF cuando una instrucción del núcleo devuelva un valor después de ser ejecutada. Normalmente, querrás combinar tanto kprobes como kretprobes en un único programa BPF para tener una visión completa del comportamiento de la instrucción.
Vamos a utilizar un ejemplo similar al de la sección anterior para mostrarte cómo funcionan las kretprobes:
from
bcc
import
BPF
bpf_source
=
"""
int ret_sys_execve(struct pt_regs *ctx) {
int return_value;
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
return_value = PT_REGS_RC(ctx);
bpf_trace_printk(
"
program:
%s
, return:
%d
"
, comm, return_value);
return 0;
}
"""
bpf
=
BPF
(
text
=
bpf_source
)
execve_function
=
bpf
.
get_syscall_fnname
(
"
execve
"
)
bpf
.
attach_kretprobe
(
event
=
execve_function
,
fn_name
=
"
ret_sys_execve
"
)
bpf
.
trace_print
(
)
Define la función que implementa el programa BPF. El núcleo la ejecutará inmediatamente después de que finalice la llamada al sistema
execve
.PT_REGS_RC
es una macro que va a leer el valor devuelto del registro BPF para este contexto específico. También utilizamosbpf_trace_printk
para imprimir el comando y su valor devuelto en nuestro registro de depuración.Inicializa el programa BPF y cárgalo en el núcleo.
Cambia la función de fijación a
attach_kretprobe
.
Las sondas del kernel son una forma potente de acceder al kernel. Pero, como hemos mencionado antes, pueden ser inestables porque te estás adhiriendo a puntos dinámicos del código fuente del kernel que pueden cambiar o desaparecer de una versión a otra. Ahora verás un método diferente para adjuntar programas al núcleo que es más seguro.
Tracepuntos
Los tracepoints son marcadores estáticos en el código del núcleo que puedes utilizar para adjuntar código en un núcleo en ejecución. La principal diferencia con las kprobes es que las codifican los desarrolladores del núcleo cuando implementan cambios en éste; por eso nos referimos a ellas como estáticas. Al ser estáticos, la ABI de los tracepoints es más estable; el núcleo siempre garantiza que un tracepoint de una versión antigua va a existir en las nuevas versiones. Sin embargo, dado que los desarrolladores tienen que añadirlos al núcleo, es posible que no cubran todos los subsistemas que lo forman.
Como mencionamos en el Capítulo 2, puedes ver todos los tracepoints disponibles en tu sistema enumerando todos los archivos en /sys/kernel/debug/tracing/events. Por ejemplo, puedes encontrar todos los puntos de seguimiento de BPF enumerando los eventos definidos en /sys/kernel/debug/tracing/events/bpf:
sudo ls -la /sys/kernel/debug/tracing/events/bpf total 0 drwxr-xr-x 14 root root 0 Feb 4 16:13 . drwxr-xr-x 106 root root 0 Feb 4 16:14 .. drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_create drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_delete_elem drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_lookup_elem drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_next_key drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_map_update_elem drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_get_map drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_get_prog drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_pin_map drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_obj_pin_prog drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_prog_get_type drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_prog_load drwxr-xr-x 2 root root 0 Feb 4 16:13 bpf_prog_put_rcu -rw-r--r-- 1 root root 0 Feb 4 16:13 enable -rw-r--r-- 1 root root 0 Feb 4 16:13 filter
Cada subdirectorio que aparece en esa salida corresponde a un tracepoint al que podemos adjuntar programas BPF. Pero allí hay dos archivos adicionales. El primer archivo, enable
, te permite activar y desactivar todos los tracepoints del subsistema BPF. Si el contenido del archivo es 0, los tracepoints están desactivados; si el contenido del archivo es 1, los tracepoints están activados. El archivo de filtro te permite escribir expresiones que el subsistema Rastreo del núcleo utilizará para filtrar eventos. BPF no utiliza este archivo; lee más en la documentación de rastreo del núcleo.
Escribir programas BPF para aprovechar los tracepoints es similar al rastreo con kprobes. He aquí un ejemplo que utiliza un programa BPF para rastrear todas las aplicaciones de tu sistema que cargan otros programas BPF:
from
bcc
import
BPF
bpf_source
=
"""
int trace_bpf_prog_load(void ctx) {
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
bpf_trace_printk(
"
%s
is loading a BPF program
"
, comm);
return 0;
}
"""
bpf
=
BPF
(
text
=
bpf_source
)
bpf
.
attach_tracepoint
(
tp
=
"
bpf:bpf_prog_load
"
,
fn_name
=
"
trace_bpf_prog_load
"
)
bpf
.
trace_print
(
)
Declara la función que define el programa BPF. Este código ya debe resultarte familiar; sólo hay unos pocos cambios sintácticos respecto al primer ejemplo que viste cuando hablamos de kprobes.
La principal diferencia en este programa: en lugar de adjuntar el programa a una kprobe, lo adjuntamos a un tracepoint. BCC sigue una convención para nombrar los tracepoints; primero se especifica el subsistema a rastrear -
bpf
en este caso- seguido de dos puntos, seguido del tracepoint en el subsistema,pbf_prog_load
. Esto significa que cada vez que el núcleo ejecute la funciónbpf_prog_load
, este programa recibirá el evento, e imprimirá el nombre de la aplicación que está ejecutando esa instrucciónbpf_prog_load
.
Las sondas del núcleo y los tracepoints te van a dar acceso completo al núcleo. Te recomendamos que utilices tracepoints siempre que sea posible, pero no te sientas obligado a ceñirte a los tracepoints sólo porque son más seguros. Aprovecha la naturaleza dinámica de las sondas del núcleo. En la siguiente sección discutiremos cómo obtener un nivel similar de visibilidad en los programas que se ejecutan en el espacio de usuario.
Sondas del espacio de usuario
Las sondas de espacio de usuario te permiten establecer banderas dinámicas en programas que se ejecutan en espacio de usuario. Son el equivalente a las sondas del núcleo para instrumentar programas que se ejecutan fuera del núcleo. Cuando defines una sonda ascendente, el núcleo crea una trampa alrededor de la instrucción adjunta. Cuando tu aplicación llega a esa instrucción, el núcleo desencadena un evento que tiene tu función de sonda como llamada de retorno. Las uprobes también te dan acceso a cualquier biblioteca a la que esté enlazado tu programa, y puedes rastrear esas llamadas si conoces el nombre correcto de la instrucción.
Al igual que las sondas del núcleo, las sondas del espacio de usuario también se dividen en dos categorías, las sondas ascendentes y las sondas ascendentes, dependiendo del punto del ciclo de ejecución en el que puedas insertar tu programa BPF. Entremos de lleno con algunos ejemplos.
Uprobes
En términos generales, los uprobes son ganchos que el núcleo inserta en el conjunto de instrucciones de un programa antes de que se ejecute una instrucción concreta. Debes tener cuidado cuando adjuntes uprobes a diferentes versiones del mismo programa, porque las firmas de las funciones pueden cambiar internamente entre esas versiones. La única forma de garantizar que un programa BPF se va a ejecutar en dos versiones diferentes es asegurarse de que la firma no ha cambiado. Puedes utilizar el comando nm
en Linux para listar todos los símbolos incluidos en un archivo objeto ELF, que es una buena forma de comprobar si la instrucción que estás rastreando sigue existiendo en tu programa, por ejemplo:
package
main
import
"fmt"
func
main
()
{
fmt
.
Println
(
"Hello, BPF"
)
}
Puedes compilar este programa Go utilizando go build -o hello-bpf main.go
. Puedes utilizar el comando nm
para obtener información sobre todos los puntos de instrucción que incluye el archivo binario. nm
es un programa incluido en las Herramientas de Desarrollo GNU que enumera los símbolos de los archivos objeto. Si filtras los símbolos con main
en su nombre, obtendrás una lista similar a ésta:
nm hello-bpf | grep main 0000000004850b0 T main.init 00000000567f06 B main.initdone. 00000000485040 T main.main 000000004c84a0 R main.statictmp_0 00000000428660 T runtime.main 0000000044da30 T runtime.main.func1 00000000044da80 T runtime.main.func2 000000000054b928 B runtime.main_init_done 00000000004c8180 R runtime.mainPC 0000000000567f1a B runtime.mainStarted
Ahora que tienes una lista de símbolos, puedes rastrear cuándo se ejecutan, incluso entre distintos procesos que ejecutan el mismo binario.
Para rastrear cuándo se ejecuta la función principal de nuestro ejemplo Go anterior, vamos a escribir un programa BPF, y vamos a adjuntarlo a un uprobe que se romperá antes de que cualquier proceso invoque esa instrucción:
from
bcc
import
BPF
bpf_source
=
"""
int trace_go_main(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
bpf_trace_printk(
"
New hello-bpf process running with PID:
%d
"
, pid);
}
"""
bpf
=
BPF
(
text
=
bpf_source
)
bpf
.
attach_uprobe
(
name
=
"
hello-bpf
"
,
sym
=
"
main.main
"
,
fn_name
=
"
trace_go_main
"
)
bpf
.
trace_print
(
)
Utiliza la función
bpf_get_current_pid_tgid
para obtener el identificador de proceso (PID) del proceso que está ejecutando nuestro programahello-bpf
.Adjunta este programa a un uprobe. Esta llamada necesita saber que el objeto que queremos rastrear,
hello-bpf
, es la ruta absoluta al archivo del objeto. También necesita el símbolo que estamos rastreando dentro del objeto,main.main
en este caso, y el programa BPF que queremos ejecutar. Con esto, cada vez que alguien ejecutehello-bpf
en nuestro sistema, obtendremos un nuevo registro en nuestra tubería de rastreo.
Uretprobes
Las Uretprobes son la sonda paralela a las kretprobes, pero para programas de espacio de usuario. Adjuntan programas BPF a instrucciones que devuelven valores, y te dan acceso a esos valores devueltos accediendo a los registros desde tu código BPF.
La combinación de uprobes y uretprobes te permite escribir programas BPF más complejos. Pueden darte una visión más holística de las aplicaciones que se ejecutan en tu sistema. Cuando puedes inyectar código de rastreo antes de que se ejecute una función e inmediatamente después de que finalice, puedes empezar a recopilar más datos y medir los comportamientos de la aplicación. Un caso de uso común es medir cuánto tarda en ejecutarse una función, sin tener que cambiar ni una sola línea de código de tu aplicación.
Vamos a reutilizar el programa Go que escribimos en "Uprobes" para medir cuánto tarda en ejecutarse la función principal. Este ejemplo de BPF es más largo que los ejemplos anteriores que has visto, así que lo hemos dividido en diferentes bloques de código:
bpf_source
=
"""
int trace_go_main(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
bpf_trace_printk(
"
New hello-bpf process running with PID:
%d
"
, pid);
}
"""
bpf
=
BPF
(
text
=
bpf_source
)
bpf
.
attach_uprobe
(
name
=
"
hello-bpf
"
,
sym
=
"
main.main
"
,
fn_name
=
"
trace_go_main
"
)
bpf
.
trace_print
(
)
Crea un mapa hash BPF. Esta tabla nos permite compartir datos entre las funciones uprobe y uretprobe. En este caso utilizamos el PID de la aplicación como clave de la tabla, y almacenamos la hora de inicio de la función como valor. Las dos operaciones más interesantes de nuestra función uprobe ocurren como se describe a continuación.
Captura el tiempo actual del sistema en nanosegundos, tal y como lo ve el núcleo.
Crea una entrada en nuestra caché con el PID del programa y la hora actual. Podemos suponer que esta hora es la hora de inicio de la función de la aplicación. Declaremos ahora nuestra función uretprobe:
Implementa la función para adjuntarla cuando termine tu instrucción. Esta función uretprobe es similar a otras que viste en "Kretprobes":
bpf_source
+
=
"""
static int print_duration(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
u64 start_time_ns = cache.lookup(&pid);
if (start_time_ns == 0) {
return 0;
}
u64 duration_ns = bpf_ktime_get_ns() - start_time_ns;
bpf_trace_printk(
"
Function call duration:
%d
"
, duration_ns);
return 0;
}
"""
Obtén el PID de nuestra aplicación; lo necesitamos para encontrar su hora de inicio. Utilizamos la función de mapa
lookup
para obtener esa hora del mapa donde la almacenamos antes de ejecutar la función.Calcula la duración de la función restando ese tiempo del tiempo actual.
Imprime la latencia en nuestro registro de seguimiento para que podamos visualizarla en el terminal.
Ahora, el resto del programa tiene que adjuntar estas dos funciones BPF a las sondas adecuadas:
bpf
=
BPF
(
text
=
bpf_source
)
bpf
.
attach_uprobe
(
name
=
"hello-bpf"
,
sym
=
"main.main"
,
fn_name
=
"trace_start_time"
)
bpf
.
attach_uretprobe
(
name
=
"hello-bpf"
,
sym
=
"main.main"
,
fn_name
=
"print_duration"
)
bpf
.
trace_print
()
Hemos añadido una línea a nuestro ejemplo original de uprobe en la que adjuntamos nuestra función de impresión a la uretprobe de nuestra aplicación.
En esta sección has visto cómo rastrear operaciones que ocurren en el espacio de usuario con BPF. Combinando funciones de BPF que se ejecutan en distintos puntos del ciclo de vida de tu aplicación, puedes empezar a extraer de ella información mucho más rica. Sin embargo, como hemos mencionado al principio de esta sección, las sondas del espacio de usuario son potentes, pero también inestables. Nuestros ejemplos de BPF pueden dejar de funcionar sólo porque alguien decida cambiar el nombre de una función de la aplicación. Veamos ahora una forma más estable de rastrear programas en el espacio de usuario.
Tracepuntos definidos estáticamente por el usuario
Los puntos de seguimiento definidos estáticamente por el usuario (USDT) proporcionan puntos de seguimiento estáticos para aplicaciones en el espacio de usuario. Se trata de una forma cómoda de instrumentar las aplicaciones, ya que te proporcionan un punto de entrada de baja sobrecarga a las capacidades de rastreo que ofrece BPF. También puedes utilizarlos como una convención para rastrear aplicaciones en producción, independientemente del lenguaje de programación con el que estén escritas estas aplicaciones.
Los USDT fueron popularizados por DTrace, una herramienta desarrollada originalmente en Sun Microsystems para la instrumentación dinámica de sistemas Unix. DTrace no estuvo disponible en Linux hasta hace poco debido a problemas de licencia; sin embargo, los desarrolladores del núcleo Linux se inspiraron mucho en el trabajo original de DTrace para implementar las USDT.
Al igual que los tracepoints estáticos del kernel que has visto antes, los USDT requieren que los desarrolladores instrumenten su código con instrucciones que el kernel utilizará como trampas para ejecutar programas BPF. La versión Hello World de los USDT sólo tiene unas pocas líneas de código:
#include <sys/sdt.h>
int
main
()
{
DTRACE_PROBE
(
"hello-usdt"
,
"probe-main"
);
}
En este ejemplo, vamos a utilizar una macro que proporciona Linux para definir nuestro primer USDT. Ya puedes ver en qué se inspira el kernel. DTRACE_PROBE
va a registrar el tracepoint que el kernel utilizará para inyectar la llamada de retorno de nuestra función BPF. El primer argumento de esta macro es el programa que está informando de la traza. El segundo es el nombre de la traza que estamos informando.
Muchas aplicaciones que puedes tener instaladas en tu sistema utilizan este tipo de sonda para darte acceso a los datos de rastreo en tiempo de ejecución de forma predecible. La popular base de datos MySQL, por ejemplo, expone todo tipo de información mediante tracepoints definidos estáticamente. Puedes recopilar información de consultas ejecutadas en el servidor, así como de muchas otras operaciones del usuario. Node.js, el tiempo de ejecución de JavaScript construido sobre el motor V8 de Chrome, también proporciona tracepoints que puedes utilizar para extraer información en tiempo de ejecución.
Antes de mostrarte cómo adjuntar programas BPF a tracepoint definidos por el usuario, tenemos que hablar de la descubribilidad. Como estos tracepoints se definen en formato binario dentro de los archivos ejecutables, necesitamos una forma de listar las sondas definidas por un programa sin tener que escarbar en el código fuente. Una forma de extraer esta información es leyendo directamente el binario ELF. En primer lugar, vamos a compilar nuestro ejemplo anterior Hola Mundo USDT; para ello podemos utilizar GCC:
gcc -o hello_usdt hello_usdt.c
Este comando va a generar un archivo binario llamado hello_usdt que podemos utilizar para empezar a jugar con varias herramientas para descubrir los tracepoints que define. Linux proporciona una utilidad llamada readelf
para mostrarte información sobre los archivos ELF. Puedes utilizarla con nuestro ejemplo compilado:
readelf -n ./hello_usdt
Puedes ver el USDT que hemos definido en la salida de este comando:
Displaying notes found in: .note.stapsdt Owner Data size Description stapsdt 0x00000033 NT_STAPSDT (SystemTap probe descriptors) Provider: "hello-usdt" Name: "probe-main"
readelf
puede darte mucha información sobre un archivo binario; en nuestro pequeño ejemplo, sólo muestra unas pocas líneas de información, pero su salida se vuelve engorrosa de analizar para binarios más complicados.
Una opción mejor para descubrir los tracepoints definidos en un archivo binario es utilizar la herramienta tplist
de BCC, que puede mostrar tanto los tracepoints del núcleo como los USDT. La ventaja de esta herramienta es la simplicidad de su salida; sólo te muestra las definiciones de los tracepoints, sin ninguna información adicional sobre el ejecutable. Su uso es similar a readelf
:
tplist -l ./hello_usdt
Enumera cada tracepoint que definas en líneas individuales. En nuestro ejemplo, sólo muestra una única línea con nuestra definición probe-main
:
./hello_usdt "hello-usdt":"probe-main"
Una vez que conozcas los tracepoints admitidos en tu binario, puedes adjuntarles programas BPF de forma similar a lo que has visto en ejemplos anteriores:
from
bcc
import
BPF
,
USDT
bpf_source
=
"""
#include <uapi/linux/ptrace.h>
int trace_binary_exec(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
bpf_trace_printk(
"
New hello_usdt process running with PID:
%d
"
, pid);
}
"""
usdt
=
USDT
(
path
=
"
./hello_usdt
"
)
usdt
.
enable_probe
(
probe
=
"
probe-main
"
,
fn_name
=
"
trace_binary_exec
"
)
bpf
=
BPF
(
text
=
bpf_source
,
usdt
=
usdt
)
bpf
.
trace_print
(
)
Hay un cambio importante en este ejemplo que requiere alguna explicación.
Crea un objeto USDT; no lo hemos hecho en nuestros ejemplos anteriores. Los USDT no forman parte de BPF, en el sentido de que puedes utilizarlos sin tener que interactuar con la VM de BPF. Como son independientes entre sí, tiene sentido que su uso sea independiente del código de BPF.
Adjunta la función BPF para rastrear las ejecuciones del programa a la sonda de nuestra aplicación.
Inicializa nuestro entorno BPF con la definición de tracepunto que acabamos de crear. Esto informará a BCC de que necesita generar el código para conectar nuestro programa BPF con la definición de sonda de nuestro archivo binario. Cuando ambos estén conectados, podremos imprimir las trazas generadas por nuestro programa BPF para descubrir nuevas ejecuciones de nuestro ejemplo binario.
Enlaces USDTs para otros idiomas
También puedes utilizar USDTs para rastrear aplicaciones escritas con lenguajes de programación distintos de C. En GitHub podrás encontrar bindings para Python, Ruby, Go, Node.js y muchos otros lenguajes. Los enlaces a Ruby son unos de nuestros favoritos por su sencillez e interoperabilidad con frameworks como Rails. Dale Hamel, que actualmente trabaja en Shopify, escribió un excelente informe sobre el uso de USDTs en su blog. También mantiene una biblioteca llamada ruby-static-tracing que hace que rastrear aplicaciones Ruby y Rails sea aún más sencillo.
La biblioteca de rastreo estático de Hamel te permite inyectar capacidades de rastreo a nivel de clase sin necesidad de añadir la lógica de rastreo a cada método de esa clase. En situaciones complejas, también te proporciona métodos prácticos para que tú mismo registres puntos finales de seguimiento dedicados.
Para utilizar ruby-static-tracing
en tus aplicaciones, primero tienes que configurar cuándo se van a activar los tracepoints. Puedes activarlos por defecto cuando se inicie la aplicación, pero si quieres evitar la sobrecarga de recoger datos todo el tiempo, puedes activarlos utilizando una señal syscall. Hamel recomienda utilizar PROF
como esta señal:
require
'ruby-static-tracing'
StaticTracing
.
configure
do
|
config
|
config
.
mode
=
StaticTracing
:
:Configuration
::
Modes
:
:SIGNAL
config
.
signal
=
StaticTracing
:
:Configuration
::
Modes
:
:SIGNALS
::
SIGPROF
end
Con esta configuración, puedes utilizar el comando kill
para activar los tracepoints estáticos de tu aplicación bajo demanda. En el siguiente ejemplo, supondremos que sólo hay un proceso Ruby ejecutándose en nuestra máquina, y podemos obtener su identificador de proceso utilizando pgrep
:
kill
-SIGPROF`
pgrep -nx ruby`
Además de configurar cuándo están activos los tracepoints, puede que quieras utilizar algunos de los mecanismos de rastreo incorporados que proporciona ruby-static-tracing. En el momento de escribir esto, la biblioteca incorpora tracepoints para medir la latencia y recoger trazas de pila. Nos gusta mucho cómo una tarea tan tediosa como medir la latencia de una función se convierte en algo casi trivial utilizando este módulo incorporado. En primer lugar, tienes que añadir el rastreador de latencia a tu configuración inicial:
require
'ruby-static-tracing'
require
'ruby-static-tracing/tracer/concerns/latency_tracer'
StaticTracing
.
configure
do
|
config
|
config
.
add_tracer
(
StaticTracing
:
:Tracer
::
Latency
)
end
Después, cada clase que incluya el módulo de latencia genera tracepoints estáticos para cada método público definido. Cuando el rastreo está activado, puedes consultar esos tracepoints para recopilar datos de temporización. En nuestro siguiente ejemplo, ruby-static-tracing
genera un tracepoint estático llamado usdt:/proc/X/fd/Y:user_model:find
, siguiendo la convención de utilizar el nombre de la clase como espacio de nombres para el tracepoint y utilizar el nombre del método como nombre del tracepoint:
class
UserModel
def
find
(
id
)
end
include
StaticTracing
:
:Tracer
::
Concerns
:
:Latency
end
Ahora podemos utilizar BCC para extraer la información de latencia de cada llamada a nuestro método find
. Para ello, utilizamos las funciones integradas de BCC bpf_usdt_readarg
y bpf_usdt_readarg_p
. Estas funciones leen los argumentos establecidos cada vez que se ejecuta el código de nuestra aplicación. ruby-static-tracing
siempre establece el nombre del método como primer argumento para el tracepoint, mientras que establece el valor calculado como segundo argumento. El siguiente fragmento implementa el programa BPF que obtiene la información del tracepoint y la imprime en el registro de seguimiento:
bpf_source
=
"""
#include <uapi/linux/ptrace.h>
int trace_latency(struct pt_regs *ctx) {
char method[64];
u64 latency;
bpf_usdt_readarg_p(1, ctx, &method, sizeof(method));
bpf_usdt_readarg(2, ctx, &latency);
bpf_trace_printk("method
%s
took
%d
ms", method, latency);
}
"""
También tenemos que cargar el programa BPF anterior en el núcleo. Como estamos rastreando una aplicación concreta que ya se está ejecutando en nuestra máquina, podemos adjuntar el programa al identificador del proceso concreto:
parser
=
argparse
.
ArgumentParser
(
)
parser
.
add_argument
(
"
-p
"
,
"
--pid
"
,
type
=
int
,
help
=
"
Process ID
"
)
args
=
parser
.
parse_args
(
)
usdt
=
USDT
(
pid
=
int
(
args
.
pid
)
)
usdt
.
enable_probe
(
probe
=
"
latency
"
,
fn_name
=
"
trace_latency
"
)
bpf
=
BPF
(
text
=
bpf_source
,
usdt
=
usdt
)
bpf
.
trace_print
(
)
Especifica ese PID.
Activa la sonda, carga el programa en el núcleo e imprime el registro de seguimiento. (Esta sección es muy similar a la que has visto anteriormente).
En esta sección, te hemos mostrado cómo introspeccionar aplicaciones que definen tracepoints estáticamente. Muchas bibliotecas y lenguajes de programación conocidos incluyen estas sondas para ayudarte a depurar las aplicaciones en ejecución, obteniendo más visibilidad cuando se ejecutan en entornos de producción. Esto es sólo la punta del iceberg; después de tener los datos, necesitas darles sentido. Esto es lo que exploraremos a continuación.
Visualización de los datos de seguimiento
Hasta ahora, hemos mostrado ejemplos que imprimen datos en nuestra salida de depuración. Esto no es muy útil en entornos de producción. Quieres dar sentido a esos datos, pero a nadie le gusta dar sentido a registros largos y complicados. Si queremos monitorizar los cambios en la latencia y la utilización de la CPU, es más fácil hacerlo mirando gráficos a lo largo de un periodo de tiempo que agregando números de un flujo de archivos.
Esta sección explora distintas formas de presentar los datos de rastreo de BPF. Por un lado, te mostraremos cómo los programas BPF pueden estructurar la información en agregados para ti. Por otro, aprenderás a exportar esa información en una representación portátil y a utilizar herramientas estándar para acceder a una representación más rica y compartir tus hallazgos con otras personas.
Gráficos de llama
Los gráficos de llama son diagramas que te ayudan a visualizar cómo gasta el tiempo tu sistema. Pueden darte una representación clara de qué código de una aplicación se ejecuta más a menudo. Brendan Gregg, el creador de los gráficos de llama, mantiene en GitHub un conjunto de scripts para generar fácilmente estos formatos de visualización. Más adelante, en esta sección, utilizaremos esos scripts para generar gráficos de llama a partir de los datos recopilados con BPF. Puedes ver el aspecto de estos gráficos en la Figura 4-1.
Hay dos cosas importantes que debes recordar sobre lo que muestra un gráfico de llama:
-
El eje x está ordenado alfabéticamente. La anchura de cada pila representa la frecuencia con la que aparece en los datos recogidos, lo que puede correlacionarse con la frecuencia con la que se ha visitado esa ruta de código mientras el perfilador estaba activado.
-
El eje y muestra las trazas de pila ordenadas según las lee el perfilador, conservando la jerarquía de las trazas.
Los gráficos de llama más conocidos representan el código que consume CPU con más frecuencia en tu sistema; se denominan gráficos on-CPU. Otra visualización interesante de los gráficos de llama son los gráficos off-CPU; representan el tiempo que una CPU dedica a otras tareas que no están relacionadas con tu aplicación. Combinando los gráficos on-CPU y off-CPU, puedes obtener una visión completa de en qué gasta ciclos de CPU tu sistema.
Tanto los gráficos dentro como fuera de la CPU utilizan trazas de pila para indicar en qué emplea el tiempo el sistema. Algunos lenguajes de programación, como Go, siempre incluyen información de rastreo en sus binarios, pero otros, como C++ y Java, requieren algo de trabajo extra para que los rastreos de pila sean legibles. Después de que tu aplicación incluya información de rastreo de pila, los programas BPF pueden utilizarla para agregar las rutas de código más frecuentes vistas por el núcleo.
Nota
Existen ventajas e inconvenientes a la agregación de trazas de pila en el núcleo. Por un lado, es una forma eficiente de contar la frecuencia de las trazas de pila porque ocurre en el núcleo, evitando enviar toda la información de la pila al espacio de usuario y reduciendo el intercambio de datos entre el núcleo y el espacio de usuario. Por otro lado, el número de eventos a procesar para los gráficos fuera de la CPU puede ser significativamente alto, porque estás llevando un registro de cada evento que se produce durante el cambio de contexto de tu aplicación. Esto puede crear una sobrecarga significativa en tu sistema si intentas perfilarlo durante demasiado tiempo. Ten esto en cuenta cuando trabajes con gráficos de llama.
BCC proporciona varias utilidades para ayudarte a agregar y visualizar trazas de pila, pero la principal es la macro BPF_STACK_TRACE
. Esta macro genera un mapa BPF de tipo BPF_MAP_TYPE_STACK_TRACE
para almacenar las pilas que acumula tu programa BPF. Además, este mapa BPF está mejorado con métodos para extraer la información de la pila del contexto del programa y recorrer las trazas de pila acumuladas cuando quieras utilizarlas después de agregarlas.
En el siguiente ejemplo, construimos un perfilador BPF sencillo que imprime las trazas de pila recogidas de las aplicaciones del espacio de usuario. Generamos gráficos de llamas en la CPU con las trazas que recoge nuestro perfilador. Para probar este perfilador, vamos a escribir un programa Go mínimo que genere carga en la CPU. Éste es el código de esa aplicación mínima:
package
main
import
"time"
func
main
()
{
j
:=
3
for
time
.
Since
(
time
.
Now
())
<
time
.
Second
{
for
i
:=
1
;
i
<
1000000
;
i
++
{
j
*=
i
}
}
}
Si guardas este código en un archivo llamado main.go y lo ejecutas con go run main.go
, verás que la utilización de la CPU de tu sistema aumenta significativamente. Puedes detener la ejecución pulsando Ctrl-C en tu teclado, y la utilización de la CPU volverá a la normalidad.
La primera parte de nuestro programa BPF va a inicializar las estructuras del perfilador:
bpf_source
=
"""
#include <uapi/linux/ptrace.h>
#include <uapi/linux/bpf_perf_event.h>
#include <linux/sched.h>
struct trace_t {
int stack_id;
}
BPF_HASH(cache, struct trace_t);
BPF_STACK_TRACE(traces, 10000);
"""
Inicializa una estructura que almacenará el identificador de referencia de cada uno de los marcos de pila que reciba nuestro perfilador. Utilizaremos estos identificadores más adelante para averiguar qué ruta de código se estaba ejecutando en ese momento.
Inicializa un mapa hash BPF que utilizamos para agregar la frecuencia con la que vemos el mismo fotograma strack. Los scripts del gráfico de llamas utilizan este valor agregado para determinar la frecuencia con la que se ejecuta el mismo código.
Inicializa nuestro mapa de seguimiento de pila BPF. Estamos estableciendo un tamaño máximo para este mapa, pero puede variar en función de la cantidad de datos que quieras procesar. Sería mejor tener este valor como variable, pero sabemos que nuestra aplicación Go no es muy grande, así que con 10.000 elementos es suficiente.
A continuación, en implementamos la función que agrega las trazas de pila en nuestro perfilador:
bpf_source
+
=
"""
int collect_stack_traces(struct bpf_perf_event_data *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
if (pid != PROGRAM_PID)
return 0;
struct trace_t trace = {
.stack_id = traces.get_stackid(&ctx->regs, BPF_F_USER_STACK)
};
cache.increment(trace);
return 0;
}
"""
Comprueba que el identificador de proceso del programa en el contexto BPF actual es el de nuestra aplicación Go; de lo contrario, ignoramos el evento. De momento no hemos definido el valor de
PROGRAM_PID
. Reemplacemos esta cadena en la parte Python del perfilador antes de inicializar el programa BPF. Se trata de una limitación actual en la forma en que BCC inicializa el programa BPF; no podemos pasar ninguna variable desde el espacio de usuario y, como práctica habitual, estas cadenas se sustituyen en el código antes de la inicialización.Crea una traza para agregar su uso. Obtenemos el ID de pila del contexto del programa con la función incorporada
get_stackid
. Este es uno de los ayudantes que BCC añade a nuestro mapa de trazas de pila. Utilizamos la banderaBPF_F_USER_STACK
para indicar a que queremos obtener el ID de pila de la aplicación en el espacio de usuario, y que no nos importa lo que ocurra dentro del núcleo.Incrementa el contador de nuestra traza para llevar la cuenta de la frecuencia con la que se ejercita el mismo código.
A continuación, vamos a adjuntar nuestro recolector de trazas de pila a todos los eventos Perf del núcleo:
program_pid
=
int
(
sys
.
argv
[
0
]
)
bpf_source
=
bpf_source
.
replace
(
'
PROGRAM_PID
'
,
program_pid
)
bpf
=
BPF
(
text
=
bpf_source
)
bpf
.
attach_perf_event
(
ev_type
=
PerfType
.
SOFTWARE
,
ev_config
=
PerfSWConfig
.
CPU_CLOCK
,
fn_name
=
'
collect_stack_traces
'
)
El primer argumento de nuestro programa Python. Es el identificador de proceso de la aplicación Go que estamos perfilando.
Utiliza la función
replace
incorporada en Python para intercambiar la cadenaPROGRAM_ID
de nuestro código fuente BPF con el argumento proporcionado al perfilador.Adjunta el programa BPF a todos los eventos de Perfeccionamiento del Software, esto ignorará cualquier otro evento, como los eventos de Hardware. También vamos a configurar nuestro programa BPF para que utilice el reloj de la CPU como fuente de tiempo, de modo que podamos medir el tiempo de ejecución.
Por último, tenemos que implementar el código que volcará las trazas de pila en nuestra salida estándar cuando se interrumpa el perfilador:
try
:
sleep
(
99999999
)
except
KeyboardInterrupt
:
signal
.
signal
(
signal
.
SIGINT
,
signal_ignore
)
for
trace
,
acc
in
sorted
(
cache
.
items
(
)
,
key
=
lambda
cache
:
cache
[
1
]
.
value
)
:
line
=
[
]
if
trace
.
stack_id
<
0
and
trace
.
stack_id
==
-
errno
.
EFAULT
line
=
[
'
Unknown stack
'
]
else
stack_trace
=
list
(
traces
.
walk
(
trace
.
stack_id
)
)
for
stack_address
in
reversed
(
stack_trace
)
line
.
extend
(
bpf
.
sym
(
stack_address
,
program_pid
)
)
frame
=
b
"
;
"
.
join
(
line
)
.
decode
(
'
utf-8
'
,
'
replace
'
)
(
"
%s
%d
"
%
(
frame
,
acc
.
value
)
)
Iterar sobre todas las trazas que hemos recogido para poder imprimirlas en orden.
Valida que tenemos identificadores de pila que podemos correlacionar con líneas de código específicas más adelante. Si obtenemos un valor no válido, utilizaremos un marcador de posición en nuestro gráfico de llama.
Iterar sobre todas las entradas de la traza de pila en orden inverso. Hacemos esto porque queremos ver la primera ruta de código ejecutada más recientemente en la parte superior, como esperarías en cualquier rastro de pila.
Utiliza el ayudante BCC
sym
para traducir la dirección de memoria del marco de pila en un nombre de función en nuestro código fuente.Formatea la línea de seguimiento de pila separada por punto y coma. Este es el formato que esperan posteriormente los scripts del gráfico de llamas para poder generar nuestra visualización.
Con nuestro perfilador BPF completo, podemos ejecutarlo como sudo
para recoger las trazas de pila de nuestro ocupado programa Go. Tenemos que pasar el ID de proceso del programa Go a nuestro perfilador para asegurarnos de que sólo recogemos trazas de esta aplicación; podemos encontrar ese PID utilizando pgrep
. Así es como se ejecuta el perfilador si lo guardas en un archivo llamado perfilador.py:
./profiler.py`
pgrep -nx go`
> /tmp/profile.out
pgrep
buscará en el PID un proceso en ejecución en tu sistema cuyo nombre coincida con go
. Enviamos la salida de nuestro perfilador a un archivo temporal para poder generar la visualización del gráfico de llamas.
Como hemos mencionado antes, vamos a utilizar los scripts FlameGraph de Brendan Gregg para generar un archivo SVG para nuestro gráfico; puedes encontrar esos scripts en su repositorio de GitHub. Una vez que hayas descargado ese repositorio, puedes utilizar flamegraph.pl
para generar el gráfico. Puedes abrir el gráfico con tu navegador favorito; en este ejemplo utilizamos Firefox:
./flamegraph.pl /tmp/profile.out > /tmp/flamegraph.svg && \ firefox /tmp/flamefraph.svg
Este tipo de perfilador es útil para rastrear problemas de rendimiento en tu sistema. BCC ya incluye un perfilador más avanzado que el de nuestro ejemplo, que puedes utilizar directamente en tus entornos de producción. Además del perfilador, BCC incluye herramientas que te ayudan a generar gráficos de llamas fuera de la CPU y muchas otras visualizaciones para analizar sistemas.
Los gráficos de llama son útiles para analizar el rendimiento. Los utilizamos con frecuencia en nuestro trabajo diario. En muchos escenarios, además de visualizar rutas de código calientes, querrás medir la frecuencia con la que se producen eventos en tus sistemas. Nos centraremos en ello a continuación.
Histogramas
Los histogramas son diagramas que muestran la frecuencia con la que se producen varios rangos de valores. Los datos numéricos que lo representan se dividen en cubos, y cada cubo contiene el número de ocurrencias de cualquier punto de datos dentro del cubo. La frecuencia que miden los histogramas es la combinación de la altura y la anchura de cada cubo. Si los cubos están divididos en rangos iguales, esta frecuencia coincide con la altura del histograma, pero si los rangos no están divididos por igual, tienes que multiplicar cada altura por cada anchura para hallar la frecuencia correcta.
Los histogramas son un componente fundamental para hacer análisis del rendimiento de los sistemas. Son una gran herramienta para representar la distribución de eventos medibles, como la latencia de instrucciones, porque te muestran información más correcta que la que puedes obtener con otras mediciones, como las medias.
Los programas BPF pueden crear histogramas basados en muchas métricas. Puedes utilizar mapas BPF para recoger la información, clasificarla en cubos y, a continuación, generar la representación en histogramas de tus datos. Implementar esta lógica no es complicado, pero se vuelve tedioso si quieres imprimir histogramas cada vez que necesitas analizar la salida de un programa. BCC incluye una implementación fuera de la caja que puedes reutilizar en todos los programas, sin tener que calcular el bucketing y la frecuencia manualmente cada vez. Sin embargo, el código fuente del núcleo tiene una fantástica implementación que te animamos a comprobar en los ejemplos de BPF.
Como experimento divertido, vamos a mostrarte cómo utilizar los histogramas de BCC para visualizar la latencia introducida por la carga de programas BPF cuando una aplicación llama a la instrucción bpf_prog_load
. Utilizaremos kprobes para recoger el tiempo que tarda en completarse esa instrucción, y acumularemos los resultados en un histograma que visualizaremos más tarde. Hemos dividido este ejemplo en varias partes para que sea más fácil de seguir.
Esta primera parte incluye la fuente inicial de nuestro programa BPF:
bpf_source
=
"""
#include <uapi/linux/ptrace.h>
BPF_HASH(cache, u64, u64);
BPF_HISTOGRAM(histogram);
int trace_bpf_prog_load_start(void ctx) {
u64 pid = bpf_get_current_pid_tgid();
u64 start_time_ns = bpf_ktime_get_ns();
cache.update(&pid, &start_time_ns);
return 0;
}
"""
Utiliza una macro para crear un mapa hash BPF para almacenar el momento inicial en que se activa la instrucción
bpf_prog_load
.Utiliza una nueva macro para crear un mapa de histograma BPF. No se trata de un mapa BPF nativo; BCC incluye esta macro para facilitarte la creación de estas visualizaciones. Bajo el capó, este histograma BPF utiliza mapas de matrices para almacenar la información. También tiene varios ayudantes para realizar el agrupamiento y crear el gráfico final.
Utiliza el PID del programa para almacenar cuándo la aplicación activa la instrucción que queremos rastrear. (Esta función te resultará familiar: la tomamos del ejemplo anterior de Uprobes),
Veamos cómo calculamos el delta de la latencia y lo almacenamos en nuestro histograma. Las líneas iniciales de este nuevo bloque de código también te resultarán familiares porque seguimos el ejemplo del que hablamos en "Sondas ascendentes".
bpf_source
+
=
"""
int trace_bpf_prog_load_return(void ctx) {
u64 *start_time_ns, delta;
u64 pid = bpf_get_current_pid_tgid();
start_time_ns = cache.lookup(&pid);
if (start_time_ns == 0)
return 0;
delta = bpf_ktime_get_ns() - *start_time_ns;
histogram.increment(bpf_log2l(delta));
return 0;
}
"""
Calcula el delta entre el momento en que se invocó la instrucción y el tiempo que tardó nuestro programa en llegar aquí; podemos suponer que también es el momento en que se completó la instrucción.
Guarda ese delta en nuestro histograma. En esta línea realizamos dos operaciones. En primer lugar, utilizamos la función incorporada
bpf_log2l
para generar el identificador de cubo para el valor del delta. Esta función crea una distribución estable de valores a lo largo del tiempo. Después, utilizamos la funciónincrement
para añadir un nuevo elemento a este cubo. Por defecto,increment
añade 1 al valor si el cubo existía en el histograma, o inicia un nuevo cubo con el valor 1, por lo que no tienes que preocuparte de antemano de si el valor existe.
El último trozo de código que tenemos que escribir adjunta estas dos funciones a las kprobes válidas e imprime el histograma en la pantalla para que podamos ver la distribución de la latencia. En esta sección es donde inicializamos nuestro programa BPF y esperamos los eventos para generar el histograma:
bpf
=
BPF
(
text
=
bpf_source
)
bpf
.
attach_kprobe
(
event
=
"
bpf_prog_load
"
,
fn_name
=
"
trace_bpf_prog_load_start
"
)
bpf
.
attach_kretprobe
(
event
=
"
bpf_prog_load
"
,
fn_name
=
"
trace_bpf_prog_load_return
"
)
try
:
sleep
(
99999999
)
except
KeyboardInterrupt
:
(
)
bpf
[
"
histogram
"
]
.
print_log2_hist
(
"
msecs
"
)
Inicializa BPF y adjunta nuestras funciones a kprobes.
Haz que nuestro programa espere para que podamos recoger tantos eventos como necesitemos de nuestro sistema.
Imprime el mapa del histograma en nuestro terminal con la distribución trazada de los eventos: esta es otra macro BCC que nos permite obtener el mapa del histograma.
Como hemos mencionado al principio de esta sección, los histogramas pueden ser útiles para observar anomalías en tu sistema. Las herramientas BCC incluyen numerosos scripts que utilizan histogramas para representar datos; te recomendamos encarecidamente que les eches un vistazo cuando necesites inspiración para sumergirte en tu sistema.
Eventos Perf
Creemos que los eventos Perf son probablemente el método de comunicación más importante que debes dominar para utilizar con éxito el rastreo BPF. Ya hablamos de los mapas de array de eventos Perf de BPF en el capítulo anterior. Te permiten colocar datos en un anillo de búferes que se sincroniza en tiempo real con los programas del espacio de usuario. Esto es ideal cuando estás recogiendo una gran cantidad de datos en tu programa BPF y quieres descargar el procesamiento y la visualización a un programa de espacio de usuario. Eso te permitirá tener más control sobre la capa de presentación, porque no estás restringido por la BPF VM en cuanto a capacidades de programación. La mayoría de los programas de rastreo de BPF que puedes encontrar utilizan eventos Perf sólo con este fin.
Aquí te mostramos cómo utilizarlos para extraer información sobre la ejecución de binarios y clasificar esa información para imprimir qué binarios son los más ejecutados en tu sistema. Hemos dividido este ejemplo en dos bloques de código para que puedas seguirlo fácilmente. En el primer bloque, definimos nuestro programa BPF y lo adjuntamos a una kprobe, como hicimos en "Sondas":
bpf_source
=
"""
#include <uapi/linux/ptrace.h>
BPF_PERF_OUTPUT(events);
int do_sys_execve(struct pt_regs *ctx, void filename, void argv, void envp) {
char comm[16];
bpf_get_current_comm(&comm, sizeof(comm));
events.perf_submit(ctx, &comm, sizeof(comm));
return 0;
}
"""
bpf
=
BPF
(
text
=
bpf_source
)
execve_function
=
bpf
.
get_syscall_fnname
(
"
execve
"
)
bpf
.
attach_kprobe
(
event
=
execve_function
,
fn_name
=
"
do_sys_execve
"
)
En la primera línea de este ejemplo, estamos importando una biblioteca de la biblioteca estándar de Python. Vamos a utilizar un contador de Python para agregar los eventos que recibimos de nuestro programa BPF.
Utiliza
BPF_PERF_OUTPUT
para declarar un mapa de eventos Perf. Se trata de una práctica macro que BCC proporciona para declarar este tipo de mapa. A este mapa le daremos el nombre de eventos.Envíalo al espacio de usuario para que lo agregue después de que tengamos el nombre del programa que ha ejecutado el núcleo. Lo hacemos con
perf_submit
. Esta función actualiza el mapa de eventos Perf con nuestra nueva información.Inicializa el programa BPF y adjúntalo a la kprobe para que se active cuando se ejecute un nuevo programa en nuestro sistema.
Ahora que hemos escrito el código para recopilar todos los programas que se ejecutan en nuestro sistema, tenemos que agregarlos en el espacio de usuario. Hay mucha información en el siguiente fragmento de código, así que vamos a guiarte por las líneas más importantes:
from
collections
import
Counter
aggregates
=
Counter
(
)
def
aggregate_programs
(
cpu
,
data
,
size
)
:
comm
=
bpf
[
"
events
"
]
.
event
(
data
)
aggregates
[
comm
]
+
=
1
bpf
[
"
events
"
]
.
open_perf_buffer
(
aggregate_programs
)
while
True
:
try
:
bpf
.
perf_buffer_poll
(
)
except
KeyboardInterrupt
:
break
for
(
comm
,
times
)
in
aggregates
.
most_common
(
)
:
(
"
Program {} executed {} times
"
.
format
(
comm
,
times
)
)
Declara un contador para almacenar la información de nuestro programa. Utilizamos el nombre del programa como clave, y los valores serán contadores. Utilizamos la función
aggregate_programs
para recoger los datos del mapa de eventos Perf. En este ejemplo, puedes ver cómo utilizamos la macro BCC para acceder al mapa y extraer el siguiente evento de datos entrantes de la parte superior de la pila.Incrementa el número de veces que hemos recibido un evento con el mismo nombre de programa.
Utiliza la función
open_perf_buffer
para indicar a BCC que debe ejecutar la funciónaggregate_programs
cada vez que reciba un evento del mapa de eventos Perf.BCC sondea los eventos después de abrir la memoria cíclica hasta que interrumpimos este programa Python. Cuanto más espere, más información procesará. Puedes ver cómo utilizamos
perf_buffer_poll
para este fin.Utiliza la función
most_common
para obtener la lista de elementos del contador y el bucle para imprimir primero los programas más ejecutados de tu sistema.
Los eventos Perf pueden abrir la puerta al procesamiento de todos los datos que BPF expone de formas novedosas e inesperadas. Te hemos mostrado un ejemplo para inspirar tu imaginación cuando necesites recoger algún tipo de dato arbitrario del núcleo; puedes encontrar muchos otros ejemplos en las herramientas que BCC proporciona para el rastreo.
Conclusión
En este capítulo sólo hemos arañado la superficie del rastreo con BPF. El núcleo de Linux te da acceso a información que es más difícil de obtener con otras herramientas. BPF hace que este proceso sea más predecible porque proporciona una interfaz común para acceder a estos datos. En capítulos posteriores verás más ejemplos que utilizan algunas de las técnicas descritas aquí, como adjuntar funciones a tracepoints. Te ayudarán a consolidar lo que has aprendido aquí.
En este capítulo hemos utilizado el framework BCC para escribir la mayoría de los ejemplos. Puedes implementar los mismos ejemplos en C, como hicimos en capítulos anteriores, pero BCC proporciona varias funciones integradas que hacen que escribir programas de trazado sea mucho más accesible que en C. Si te apetece un reto divertido, intenta reescribir estos ejemplos en C.
En el siguiente capítulo, te mostramos algunas herramientas que la comunidad de sistemas ha construido sobre BPF para realizar análisis de rendimiento y rastreo. Escribir tus propios programas es potente, pero estas herramientas dedicadas te dan acceso a gran parte de la información que hemos visto aquí en formato empaquetado. De este modo, no necesitas reescribir herramientas que ya existen.
Get Observabilidad de Linux con BPF 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.