Cuando Prefork y Docker no se llevan bien

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 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.

Jack Fiallos

Jack Fiallos

Te gustó este artículo?