Cuando Prefork y Docker no se llevan bien
En el desarrollo de software, los errores inesperados suelen aparecer cuando menos lo esperamos, y es ahí donde nos toca arremangarnos y resolverlos. Hace poco, mientras trabajaba en una aplicación Go con Fiber, ejecutada dentro de un entorno de contenedores Docker, me encontré con un problema interesante relacionado con el uso de Prefork
. Lo que parecía una buena idea para optimizar el rendimiento acabó causando algunos problemas difíciles de rastrear. En este artículo te voy a contar cómo sucedió, qué pruebas realicé, cómo lo corregí, y, lo más importante, cuándo sí y cuándo no usar Prefork
en tu aplicación.
El Entorno de Programación
Para ponerte en contexto, estoy trabajando en una aplicación escrita en Go usando el framework Fiber. La aplicación corre dentro de un contenedor Docker y estoy usando Docker Swarm para manejar múltiples instancias en producción. La idea es aprovechar al máximo los recursos de CPU de los servidores, y para ello decidi activar Prefork
en Fiber (mi primer error).
¿Qué es Prefork
?
Prefork
es una funcionalidad que permite que un proceso principal (en este caso, nuestra aplicación Go) genere múltiples procesos hijos. Cada uno de estos procesos hijos puede manejar las solicitudes de manera independiente, lo que es ideal para aprovechar todos los núcleos de CPU disponibles en un servidor. La lógica detrás de Prefork
es simple: si un servidor tiene, por ejemplo, 4 CPUs, en lugar de ejecutar un solo proceso de la aplicación, podrías ejecutar 4 procesos hijos, cada uno ocupando un núcleo, y así aumentar el rendimiento.
El Problema Inicial
Todo parecía estar bien configurado: la aplicación corría perfectamente en mi entorno local y en pruebas con Prefork activado. Sin embargo, cuando empecé a desplegarla en Docker Swarm con múltiples instancias de la aplicación, las cosas se complicaron. Los logs mostraban errores poco claros, la aplicación se comportaba de manera inestable, y en algunas instancias ni siquiera arrancaba correctamente.
Los errores que veía mostraban lo siguiente:
Error: unexpected shutdown
Connection issues with external services
Parecía que algo no estaba funcionando bien con Prefork
y Docker, pero no sabía exactamente por qué.
El Diagnóstico
Sabía que Prefork
debería permitir que la aplicación usara todos los núcleos de CPU disponibles en cada contenedor Docker. Sin embargo, al profundizar en el funcionamiento de Docker y de Prefork
, me di cuenta de algo importante: los procesos hijos que Prefork
crea comparten ciertos recursos del proceso principal. En otras palabras, algunas conexiones a la base de datos o a servicios externos (como AWS S3 en nuestro caso) no estaban siendo manejadas correctamente por los procesos hijos.
Además, observaba que, en servidores con un solo núcleo de CPU, Prefork
en realidad no ofrecía ningún beneficio. De hecho, generaba un overhead innecesario porque los procesos hijos competían entre sí por los recursos limitados.
Pruebas y Ajustes
1. Desactivando Prefork
La primera prueba fue simple: desactivar Prefork
. En vez de dejar que Fiber creara procesos hijos, ejecute la aplicación en su configuración básica, donde solo se ejecuta un proceso por instancia Docker. Esto eliminó los errores, y la aplicación funcionó sin problemas. Sin embargo, al hacerlo, perdi la capacidad de aprovechar múltiples núcleos de CPU en servidores con más de un CPU.
app := fiber.New(fiber.Config{
Prefork: false, // Desactivamos Prefork
})
Esto solucionó los errores, pero no me gustaba la idea de no usar todo el potencial de los servidores más potentes, así que seguí investigando.
2. Ajustando la Conexión a la Base de Datos
Descubrí que los procesos hijos creados por Prefork
heredan la conexión a la base de datos del proceso principal, lo que puede causar problemas de concurrencia o incluso fallos al acceder a la base de datos. Para solucionarlo, modifiqué el código para que cada proceso hijo creara su propia conexión después de que se creara el fork.
La solución fue crear la conexión a la base de datos dentro de los procesos hijos, asegurándome de que cada proceso manejara su propia conexión de manera independiente.
app := fiber.New(fiber.Config{
Prefork: true, // Prefork activado
})
func setupResources() {
db, err := database.Connect()
if err != nil {
log.Fatalf("Error al conectar a la base de datos: %v", err)
}
// Configurar otros recursos aquí
}
app.Listen(":3000")
setupResources() // Llamamos a la configuración después de iniciar los procesos hijos
3. Múltiples Instancias Docker y Prefork
Aquí es donde las cosas se ponen interesantes. En Docker Swarm, estoy usando múltiples instancias de la aplicación, lo que significa que ya estoy escalando horizontalmente. Cada instancia Docker tiene su propio proceso y recursos aislados. Si activo Prefork
dentro de cada instancia, podría aprovechar todos los núcleos de CPU disponibles en cada servidor.
Sin embargo, hay un aspecto crucial: Prefork
solo es útil en servidores con más de un núcleo de CPU. Si el servidor tiene un solo núcleo, activar Prefork
no aportará ningún beneficio y, de hecho, puede reducir el rendimiento.
¿Cuándo Usar Prefork
?
Después de algunas pruebas, aquí está mi conclusión sobre cuándo usar Prefork
:
Usar Prefork
cuando:
- Estás ejecutando tu aplicación en un servidor con múltiples núcleos de CPU.
- Quieres maximizar el uso de los recursos de un solo servidor.
- Los recursos como las conexiones a la base de datos o servicios externos se manejan correctamente dentro de cada proceso hijo (es decir, no compartes conexiones entre procesos).
No usar Prefork
cuando:
- Estás ejecutando la aplicación en un servidor con un solo núcleo de CPU. En este caso,
Prefork
solo genera sobrecarga y no mejora el rendimiento. - Tu aplicación depende de servicios o librerías que no manejan bien el forking (como algunas conexiones persistentes o servicios que no están diseñados para ser utilizados por múltiples procesos).
Diferencias con Múltiples Instancias Docker
Similitudes:
- Tanto
Prefork
como tener múltiples instancias Docker permiten manejar más solicitudes simultáneas mediante el uso de más procesos en paralelo. - Ambas técnicas permiten escalar horizontalmente, pero en contextos diferentes (dentro de un servidor vs. entre servidores).
Diferencias:
- Aislamiento: En Docker, cada instancia está completamente aislada, lo que significa que los errores en una instancia no afectan a las demás. Con
Prefork
, los procesos hijos comparten algunos recursos del proceso principal. - Escalabilidad: Las múltiples instancias Docker permiten escalar a través de varios servidores, mientras que
Prefork
se limita a un solo servidor. - Redundancia: Con Docker y múltiples instancias, si una instancia falla, Docker Swarm puede reiniciar otra, lo que ofrece mayor redundancia. En
Prefork
, si el proceso maestro falla, todos los procesos hijos se ven afectados.
Conclusión
Al final, aprendí que Prefork
es una herramienta poderosa, pero debe ser usada con cuidado. En mi caso, desactivarlo en servidores con un solo CPU y activarlo en aquellos con múltiples núcleos me permitió optimizar los recursos. Además, combinar Prefork
con múltiples instancias Docker me dio lo mejor de ambos mundos: uso eficiente de la CPU dentro de cada servidor y escalabilidad horizontal en todo el clúster.
Si te encuentras en una situación similar, te recomiendo probar ambos enfoques y ver cuál se adapta mejor a tu entorno y necesidades de escalabilidad. ¡No olvides siempre ajustar las configuraciones según el hardware y las librerías que uses!
Photo by [Kutan Ural](https://unsplaPhoto by Annie Spratt on Unsplash
Written with StackEdit.