Profiling en producción con net/http/pprof

Cuando un servicio en producción empieza a presentar latencias erráticas o un consumo de memoria que no deja de subir, no puedes limitarte a mirar métricas genéricas de CPU o RAM de Kubernetes. Necesitas saber exactamente qué línea de código está reservando memoria o qué goroutine está bloqueada esperando un mutex. Para eso usamos net/http/pprof.

Este paquete permite inspeccionar el estado interno del runtime de Go mediante endpoints HTTP. Funciona mediante una importación con efecto secundario (side-effect import) de _ "net/http/pprof", lo que registra automáticamente varios handlers en el http.DefaultServeMux. El mecanismo interno aprovecha las capacidades de runtime/pprof para capturar muestras (sampling) del stack de ejecución, la asignación de objetos en el heap y la contención de sincronización.

Solo debes usarlo cuando necesitas un diagnóstico profundo y real sobre la carga de trabajo actual. No es para monitorización continua de métricas (eso es para Prometheus), sino para análisis de incidentes o debugging de performance. Si lo usas mal —por ejemplo, ejecutando un perfil de CPU de 30 segundos en un nodo que ya está al 95% de CPU— puedes exacerbar el problema debido al overhead que genera el muestreo de señales para capturar los stacks. Además, el perfil de CPU es significativamente más costoso que un volcado de goroutines o un perfil del heap, ya que requiere interrumpir la ejecución del programa para tomar las muestras.

Lo que realmente te va a romper el sistema es la seguridad. Los endpoints de pprof exponen información extremadamente sensible sobre tu arquitectura interna y la gestión de memoria; si dejas el http.DefaultServeMux expuesto a internet, cualquier atacante puede realizar un DoS pidiendo perfiles pesados o entender la estructura de tu memoria para planificar un exploit.

package main

import (
	"fmt"
	"math/big"
	"net/http"
	_ "net/http/pprof" // Registra los handlers en http.DefaultServeMux
	"runtime"
	"time"
)

// simulaCargaCPU realiza cálculos intensivos para generar actividad en el CPU profile
func simulaCargaCPU() {
	for {
		// Operaciones aritméticas con números grandes para mantener el uso de CPU
		n, _ := new(big.Int).SetString("123456789012345678901234567890", 10)
		n.Mul(n, n)
		_ = n.String()
	}
}

// simulaLeakMemoria simula una fuga de memoria asignando slices que nunca se liberan
func simulaLeakMemoria() {
	var leak [][]byte
	for {
		// Cada iteración crea un bloque de memoria que se mantiene vivo en el heap
		block := make([]byte, 1024*1024) // 1 MB
		for i := range block {
			block[i] = 1
		}
		leak = append(leak, block)
		time.Sleep(500 * time.Millisecond)
	}
}

func main() {
	// Iniciamos procesos en background para tener algo que profilear
	go simulaCargaCPU()
	go simulaLeakMemoria()

	fmt.Println("Servidor de diagnóstico corriendo en :8080")
	fmt.Println("Endpoints disponibles:")
	fmt.Println("  CPU:     http://localhost:8080/debug/pprof/profile")
	fmt.Println("  Heap:    http://localhost:8080/debug/pprof/heap")
	fmt.Println("  Goroutines: http://localhost:8080/debug/goroutine")

	// IMPORTANTE: Estamos usando nil para usar http.DefaultServeMux
	// Esto incluye automáticamente los handlers de pprof importados arriba.
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(fmt.Sprintf("Error al iniciar el servidor: %v", err))
	}
}

Análisis del ejemplo

En el código, la línea _ "net/http/pprof" es la clave de todo. Al usar el guion bajo, le decimos al compilador que no queremos usar las funciones de ese paquete directamente, pero sí queremos que se ejecute su función init(). Esa función init() es la que hace el http.HandleFunc necesario para registrar los endpoints.

En el main, al pasar nil a http.ListenAndServe, le estamos diciendo al servidor que use el http.DefaultServeMux. Esta es una decisión crítica: el DefaultServeMux es un registro global. Si en cualquier parte de tu aplicación (o en una dependencia de una dependencia) se importa pprof, esos endpoints estarán vivos y escuchando.

El programa levanta dos goroutines que actúan como “patógenos”:
1. simulaCargaCPU: Al ejecutar go tool pprof http://localhost:8080/debug/pprof/profile, verás que el tiempo se concentra en la librería math/big. El perfil de CPU es una captura de muestras de tiempo de ejecución; te dirá exactamente qué función estaba en el stack en esos instantes.
2. simulaLeakMemoria: Si ejecutas go tool pprof http://localhost:8080/debug/pprof/heap, podrás ver la acumulación de slices de bytes en el heap. El perfil de heap es mucho más ligero porque solo captura las asignaciones de memoria en momentos específicos, no requiere la interrupción constante del CPU.

Para analizar los datos, usa la herramienta de línea de comandos:
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap
Esto te abrirá una interfaz web con un grafo de llamadas (flame graph) que te permitirá ver visualmente qué parte de tu código está reteniendo los objetos en memoria.

El error frecuente

El error más común y peligroso es exponer el http.DefaultServeMux en un servidor que ya tiene un router configurado para recibir tráfico público (como un servidor Gin o Echo) sin aislar los endpoints de diagnóstico.

// ERROR: Esto expone pprof a todo internet si el servidor es público
r := gin.Default()
r.GET("/api/v1/user", getUser)
// Si en algún lugar del proyecto hiciste import _ "net/http/pprof"
// ... el servidor de Gin también tendrá expuestos /debug/pprof/
r.Run(":80") 

Si tu servicio es una API pública, nunca uses http.ListenAndServe(":80", nil). La forma correcta en producción es registrar manualmente los handlers de pprof en un router específico o, mejor aún, levantar un segundo servidor en un puerto distinto (ej. :6060) que solo esté accesible desde la red interna de tu clúster o mediante un túnel SSH.

Si vas a exponer estos endpoints, asegúrate de que el firewall de tu VPC solo permita el acceso desde tu pod de debug o un túnel SSH.

165

Dejar un comentario

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

Scroll al inicio