Go, contenedores y el planificador de Linux

Los servicios Go en contenedores a menudo calculan mal cuánta CPU tienen realmente, lo que lleva al runtime de Go a crear demasiados hilos y a sufrir un throttling severo bajo el planificador CFS de Linux. Los comentaristas comparan quotas vs. shares de CPU, debaten si conviene confiar en límites de Kubernetes/Docker o en ajustes a nivel de aplicación, y señalan herramientas como `automaxprocs` y runtimes conscientes de cgroups (como en Java y .NET) como soluciones prácticas. En términos más amplios, el intercambio destaca las ventajas y desventajas entre límites estrictos de CPU, sobrecompromiso para una mejor utilización y latencia predecible en clústeres grandes multiusuario.

Go, CFS y los límites de CPU de los contenedores

  • Problema principal: el runtime de Go dimensiona GOMAXPROCS a partir de los núcleos visibles, ignorando las cuotas de CPU de cgroup, por lo que en contenedores a menudo crea muchos más hilos del sistema operativo de los que el tiempo de CPU realmente disponible permite.
  • Esto lleva a un fuerte throttling de CFS, degradación del rendimiento y la latencia, y un comportamiento de “núcleos desperdiciados” observado durante muchos años.
  • Algunos sostienen que el fallo central está en el runtime de Go (usar la métrica incorrecta), no en el planificador de Linux.

Docker/Kubernetes: cuotas vs shares y banderas

  • --cpus en Docker se asigna a quota/period de CFS, no a cpuset, y puede provocar throttling duro incluso en hosts mayormente inactivos.
  • cpu.shares es una planificación proporcional/relativa que solo se usa bajo contención; las cuotas (cpu.cfs_quota_us) son límites duros de tiempo.
  • En Kubernetes: request de CPU → shares; limit de CPU → quota. Algunos dicen “no pongas limits, solo requests”; otros ven la quota como esencial para un aislamiento fuerte.
  • Hay desacuerdo sobre cuándo realmente throttling de CFS: algunos afirman “solo bajo contención”; otros señalan documentación y experimentos que muestran que es un límite duro sin importar eso.

Soluciones: GOMAXPROCS y sondeo de cgroup

  • Patrón popular: configurar GOMAXPROCS a partir de datos de cgroup (por ejemplo, cpu.cfs_quota_us / cpu.cfs_period_us) o usar bibliotecas como automaxprocs.
  • Hay varios informes de mejoras sustanciales de latencia y rendimiento tras hacer esto, frente tanto a “sin límite” como a “con quota pero sin ajuste”.
  • Otros advierten que limitar GOMAXPROCS a la quota puede perjudicar el manejo de picos y aumentar la latencia de cola; “el paralelismo máximo no debería equivaler al presupuesto de CPU a largo plazo”.
  • Las sugerencias incluyen: webhooks mutadores para inyectar GOMAXPROCS, usar nproc/sched_getaffinity, o lxcfs para falsificar /proc y ofrecer vistas conscientes del contenedor.

Debate: ¿deberías usar límites de CPU en absoluto?

  • Un bando: “deja de usar límites de CPU; usa solo reservas/requests”. Argumento: los límites desperdician CPU, causan throttling mientras el host está ocioso y hacen impredecible el comportamiento de capacidad.
  • Bando contrario: los límites son necesarios para:
    • Evitar que vecinos ruidosos afecten a servicios críticos.
    • No depender de “CPU libre en ráfaga” que puede desaparecer a medida que se llenan los nodos.
    • Simular condiciones de peor caso para una planificación de capacidad realista.
  • Varios señalan que el sobrecompromiso y la mezcla de cargas sensibles a la latencia con cargas batch son intrínsecamente complejos; las abstracciones de K8s pueden inducir a error a equipos de operaciones con menos experiencia.

Otros runtimes y conciencia de contenedores

  • Java, .NET y Rust han añadido lógica consciente de contenedores, usando límites de cgroup para dimensionar pools de hilos, hilos de GC, etc.
  • El comportamiento de Java ha evolucionado desde usar shares hasta usar quotas; hay flags para controlar si la quota del contenedor influye en el recuento de CPU.
  • Algunos sugieren que mecanismos similares o shims cargables podrían aplicarse a Go.

Contenedores vs VMs/unikernels para Go

  • Pregunta recurrente: dado que Go produce binarios estáticos, ¿qué valor aportan los contenedores?
  • Puntos a favor de los contenedores: empaquetado estandarizado, aislamiento de red/sistema de archivos/procesos, controles de recursos, compatibilidad con orquestación (Kubernetes) y entornos CI/CD y configuraciones multi-servicio más sencillos.
  • Visión escéptica: para servicios Go individuales, los contenedores pueden ser “bloat” redundante; los unikernels junto con binarios Go podrían encajar mejor, aunque las herramientas y el despliegue siguen siendo ásperos.
  • Consenso: el ecosistema y la estandarización del flujo de trabajo son razones importantes por las que las aplicaciones Go siguen acabando en contenedores.

Varios / puntos poco claros

  • Algo de discusión sobre las políticas del CPU manager de Kubernetes (static vs none) y cómo cambian las máscaras de CPU visibles; el comportamiento difiere según la configuración.
  • Mención de planificadores de Linux emergentes (EEVDF), pero no se informó de experiencia concreta en producción en el hilo.
  • En general, los participantes coinciden en que la interacción entre runtimes de lenguaje, CFS, cgroups y orquestadores sigue siendo sutil y fácil de configurar mal.