2 puntos por GN⁺ 2023-11-09 | 1 comentarios | Compartir por WhatsApp
  • Aunque se apliquen límites de CPU a un contenedor, el runtime de Go por defecto no los conoce, por lo que puede crear threads tomando como referencia todos los cores del host y aumentar la latencia
  • El GC de Go se ejecuta en su mayor parte de forma concurrente con la aplicación, pero en Sweep Termination y Mark Termination necesita tramos stop-the-world (STW) en los que se detienen todas las goroutines
  • El CFS de Linux reparte los cores como tiempo de CPU por segundo, y --cpus=4 le da al contenedor 4 segundos de tiempo de CPU por cada segundo
  • Si se ejecuta un contenedor limitado a 4 cores en un host de 16 cores, Go puede montar goroutines sobre 16 threads del SO, por lo que, tras agotar la CPU quota, el STW puede alargarse
  • Al ajustar GOMAXPROCS al límite de CPU del contenedor, en el ejemplo el ciclo de GC bajó de menos de 2.5 ms a menos de 1 ms, y el STW se redujo hasta unos 26 μs

Desajuste entre los límites de CPU de los contenedores y el runtime de Go

  • Al ejecutar una aplicación Go en un contenedor, el límite de CPU es un mecanismo para impedir que consuma toda la CPU del host
  • El problema es que el runtime de Go, por defecto, no reconoce el límite de CPU del contenedor
  • Por este desajuste, el runtime asume que puede usar más CPU que la quota real, lo que puede derivar en mayor latencia

Dónde ocurre STW en el GC de Go

  • El garbage collector de Go se ejecuta concurrentemente con la aplicación durante la mayor parte del tiempo
  • Sin embargo, dentro del proceso de GC hay dos tramos en los que se deben detener todas las goroutines
    • La etapa en la que se detiene para aplicar el write barrier antes de la Mark Phase es Sweep Termination
    • La etapa en la que se detiene de nuevo para quitar el write barrier después de la Mark Phase es Mark Termination
  • Los tramos STW suelen estar en el orden de decenas de microsegundos
  • La aplicación de ejemplo es una aplicación web simple que asigna mucha memoria, y el código fuente está en go-cfs-blog
  • El contenedor se ejecuta con un límite de 4 CPU
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
  • Se puede recolectar una trace con el paquete runtime/trace y analizarla con go tool trace
  • En esta ejecución, el ciclo de GC fue de menos de 2.5 ms, pero casi el 10% de ese tiempo correspondió a tramos STW
  • En aplicaciones sensibles a la latencia, incluso una proporción de este tamaño puede ser problemática

Límites de CPU de Docker y cómo funciona Linux CFS

  • El límite de CPU --cpus de Docker es un hard limit
  • También se puede configurar --cpu-shares, pero solo se impone cuando el host está bajo restricción de CPU
    • Si el host tiene capacidad libre, el contenedor puede usar más que los cores de CPU asignados
    • Cuando el host queda en estado de restricción, la aplicación se ve limitada
  • Linux Completely Fair Scheduler (CFS) se introdujo en Linux 2.6.23 y fue el scheduler predeterminado hasta antes de Linux 6.6
  • CFS es un proportional share scheduler, y asigna el weight de un proceso en proporción a la cantidad de cores de CPU que puede usar
    • El weight de un proceso que puede usar 4 cores de CPU es 4
    • El weight de un proceso que puede usar 2 cores de CPU es 2
  • CFS reparte el tiempo de CPU en porciones
    • Un sistema de 4 cores puede repartir 4 segundos de tiempo de CPU por cada segundo
    • Asignar una cantidad de cores de CPU a un contenedor equivale a pedirle al scheduler de Linux tiempo equivalente a n CPU
    • --cpus=4 significa que el contenedor recibe 4 segundos de tiempo de CPU por cada segundo

Por qué se alarga el STW

  • Al iniciar, el runtime de Go crea un thread del SO por cada core de CPU
  • En una máquina de 16 cores puede crear 16 threads del SO, independientemente del límite de CPU de CGroup
  • El runtime agenda goroutines sobre esos threads del SO
  • Aunque el límite de CPU del contenedor sea de 4 cores, Go puede colocar goroutines en los 16 threads del SO
  • En ese estado, el runtime espera poder usar 16 segundos de tiempo de CPU por cada segundo
  • Los tiempos STW largos ocurren porque se deben detener también las goroutines que están sobre threads que esperan a que el scheduler de Linux los vuelva a ejecutar
  • Después de que el contenedor ya usó su CPU quota, esos threads no son agendados

Ajustar GOMAXPROCS a la CPU quota

  • Go permite limitar la cantidad de threads de CPU que usará el runtime mediante la variable de entorno GOMAXPROCS
  • En un contenedor con una CPU quota de 4, se especifica también GOMAXPROCS=4
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
  • Con la misma aplicación y la misma carga, al alinear GOMAXPROCS con la CPU quota, el tiempo de GC se redujo
  • En la trace, el ciclo de GC bajó a menos de 1 ms y el tramo STW fue de 26 μs
  • Esto equivale aproximadamente a 1/10 del tiempo STW en comparación con no limitar GOMAXPROCS
  • GOMAXPROCS debe configurarse con la cantidad de cores de CPU que el contenedor puede usar
    • Al asignar CPU fraccionales, se redondea hacia abajo
    • Al asignar menos de 1 CPU, se redondea hacia arriba
    • La fórmula es GOMAXPROCS=max(1, floor(CPUs))
  • automaxprocs de Uber es una biblioteca open source que calcula automáticamente este valor a partir de los cgroups del contenedor
  • Hay un GitHub Issue abierto para que el runtime de Go lo soporte de forma predeterminada

Qué revisar en servicios Go contenerizados

  • No basta con configurar solo el límite de CPU: también hay que ajustar GOMAXPROCS para que el runtime de Go refleje ese límite
  • Si calcularlo manualmente es difícil, se puede usar una biblioteca como automaxprocs para configurar automáticamente el valor basado en cgroups
  • Los servicios Go sensibles a la latencia deberían revisar el tiempo STW en las GC traces para comprobar que la CPU quota y la configuración del runtime no estén desalineadas

1 comentarios

 
GN⁺ 2023-11-09
Opiniones en Hacker News
  • Un problema común en varios lenguajes es que las aplicaciones miran /proc/cpuinfo para detectar la cantidad de núcleos de la máquina.
    Pero dentro de un contenedor Docker u otras tecnologías de contenedores, ese archivo se ve igual que en el host del contenedor y lista todos los núcleos, sin importar cuántos se le hayan asignado realmente al contenedor.
    Durante un tiempo pensé que quizá Docker podría crear un /proc/cpuinfo falso que listara solo las “CPU de Docker” asignadas a la tarea, pero pensándolo de nuevo, creo que no funcionaría bien por varias razones.

    • Cuando se usan límites basados en quota, el contenedor puede usar todos los núcleos de CPU del host.
      Lo que se limita es por cuánto tiempo puede usar esos núcleos.
      Hay excepciones, y la documentación está aquí: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
    • Solo uso nproc, y he visto que en otros contenedores también lo usan, como bundle install -j $(nproc).
      Esto respeta la asignación de CPU, así que ofrece la función que buscabas.
      No sé si las aplicaciones arbitrarias usan nproc cuando pueden.
      “Imprime la cantidad de unidades de procesamiento disponibles para el proceso actual, que puede ser menor que la cantidad de procesadores en línea. Si no se puede obtener esta información, imprime la cantidad de procesadores instalados”.
      https://www.gnu.org/software/coreutils/manual/html_node/npro...
      https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
    • Go no hace eso.
      Go mira la cantidad en la máscara de CPU al iniciar, y después no vuelve a mirarla.
      En Kubernetes esto se vuelve un problema porque las CPU visibles pueden cambiar mientras el proceso está en ejecución.
    • Un /proc/cpuinfo falso ya existe: https://github.com/lxc/lxcfs
      lxcfs es un sistema de archivos FUSE que infiere valores de cgroup y emula /proc, para que las aplicaciones y bibliotecas no tengan que preocuparse por si están corriendo dentro de un contenedor.
      Por ejemplo, /proc/uptime debería reflejar el tiempo de actividad del contenedor y no el del host, y /proc/cpuinfo refleja como cantidad de CPU el límite más bajo entre la combinación de cpu.max y cpuset.cpus.
      La cantidad de CPU también puede inferirse con la llamada al sistema sched_getaffinity, y este método no depende de /proc/cpuinfo.
      Así que, según la biblioteca que uses, podrías terminar en una situación incómoda.
    • Al ver esto, uno concluye que los contenedores son una abstracción endeble y que VMware dejó pasar una oportunidad.
  • Esta explicación es sutilmente incorrecta.
    Desde la perspectiva de Docker, en la extensión CFS de cgroup hay varias perillas ajustables: cfs_quota_us, cfs_period_us (el valor predeterminado habitual no es 1 segundo sino 100 ms) y shares.
    Si configuras shares, se aplica una planificación proporcional basada en pesos, pero solo importa cuando hay contención.
    Los dos valores anteriores imponen una quota estricta.
    Es mejor usar --cpu-shares en lugar de la bandera --cpu de Docker para evitar la imposición de quotas, que en general es inútil.
    Según la documentación de Linux, cpu.shares es el peso de cada grupo del mismo nivel, y cpu.cfs_period_us es el período del scheduler para evaluar el ancho de banda; su valor predeterminado es 100000 us, o 100 ms.
    cpu.cfs_quota_us es el tiempo máximo durante el cual el grupo actual puede ejecutarse en cada cfs_period_us, y este valor es tiempo acumulado sobre todas las CPU del sistema, así que para usar por completo 2 CPU hay que configurarlo al doble de cfs_period_us.

    • La frase “no uses la bandera --cpu de Docker, en su lugar…” es demasiado tajante sin más contexto.
      De ninguna manera puede considerarse “en general inútil”.
      shares y quota son para casos de uso distintos, así que hay que entender el propio caso de uso y elegir en consecuencia.
    • Una cosa a tener en cuenta es que, si usas --cpu, la aplicación puede detectarlo.
      Probablemente sea porque usa cpuset.
      Si usas quota, no puede detectarlo, por lo que es muy probable que se creen más hilos de los necesarios.
    • Soy el autor del blog; gracias por el feedback.
      Voy a intentar aclarar más esta parte.
      Creo que los síntomas aparecen de esta forma, pero debería expresarlo con más claridad.
    • Quienes usan Kubernetes no ajustan ni cambian directamente estas configuraciones.
      La aplicación debe comportarse correctamente.
  • Si se usan reservas de CPU en lugar de límites de CPU, este ajuste no hace falta: https://home.robusta.dev/blog/stop-using-cpu-limits
    Las reservas de CPU también son, en la práctica, límites, pero se declaran como un límite implícito y una garantía.
    Así que basta con dejar que el runtime de Go use todas las CPU disponibles, y si aparece contención de CPU, dejar que el planificador de Linux lo limite según las reservas declaradas.

    • La razón para configurar límites no es que un pod pueda afectar a otros pods.
      Es porque no quieres acostumbrarte a poder usar CPU excedente no garantizada.
      A medida que el nodo se va llenando con otros pods, un pod que hasta hace un momento funcionaba bien puede volverse lento de repente.
      Usar límites permite simular ese mismo comportamiento y prepararse con una planificación de capacidad correcta.
      No es la única forma, pero sí la más simple.
    • En una configuración de 128 núcleos ejecutamos varias cosas, y ponemos los límites de CPU mucho más altos que los requests, pero aun así los configuramos para que nada se descontrole.
      Me interesa más esta discusión, pero el artículo enlazado parece tratar solo de que la gente cree que necesita un límite para garantizar CPU a todos los pods.
    • En la comunidad de Kubernetes siento que tenemos esta discusión cada dos semanas.
      El artículo en sí no está mal, y en general se parece más a marketing de contenidos, pero hace afirmaciones amplias e ignora varias buenas razones para configurar límites.
      Hay artículos del mismo sitio que simplemente están equivocados: https://home.robusta.dev/blog/containers-dont-use-chroot
      Hay workloads que consumen toda la capacidad de ráfaga para obtener apenas una pequeña ganancia, y a veces hay que priorizar la capacidad de ráfaga de un servidor HTTP por encima de un cronjob que solo tiene que terminar dentro de un tiempo determinado.
      También he visto incidentes porque los desarrolladores no actualizaron los requests cuando crecieron los requerimientos de la app, y luego el tiempo de CPU libre de pronto dejó de estar disponible.
    • Las reservas no son límites, sino restricciones de uso mínimo garantizado de CPU.
      En teoría son recursos mínimos garantizados, pero cuando contenedores ocupados se ejecutan juntos en el mismo host, la latencia de cola y la latencia promedio pueden aumentar de forma anormal.
      La latencia en una instancia EC2 de 4 núcleos con 50% de uso de CPU es bastante distinta a la de una con 90%.
      Con las reservas pasa algo parecido: aunque cada contenedor reciba su propia reserva garantizada, otros procesos ocupados en el mismo host hacen que el uso relativo de CPU sea muy alto.
    • Es interesante, pero ¿no aplica a la memoria?
      El OOMKiller puede llevárselo.
      Si no tienes límites de CPU ni de memoria, no puedes obtener la clase QoS Guaranteed, así que en algún momento el pod podría ser desalojado.
  • Usando contenedores y cgroups, el planificador CFS me ha golpeado varias veces.
    Me da curiosidad saber qué es el nuevo planificador.
    ¿Alguien aquí lo ha usado en clústeres de producción?
    Llevamos casi 20 años desperdiciando núcleos: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf

    • El problema aquí no es el planificador.
      El problema es que el contenedor impone límites de recursos, pero Go, que es el proceso dentro del contenedor, no revisa las funciones del sistema operativo usadas para esos límites al calcular la cantidad de paralelismo disponible.
    • https://kernelnewbies.org/Linux_6.6#New_task_scheduler:_EEVD...
  • Además de GOMAXPROCS, las versiones recientes de Go tienen GOMEMLIMIT.
    Usar https://github.com/KimMachineGun/automemlimit permite configurar automáticamente este límite de forma similar a https://github.com/uber-go/automaxprocs.

  • El año pasado, en mi trabajo anterior, siendo ingeniero de plataforma y administrando clústeres Kubernetes on-premise e infraestructura de pipelines de CI/CD, descubrí esto.
    Vi que la discrepancia entre la CPU real y la CPU asignada causaba problemas, especialmente throttling de CPU, pero era difícil encontrar una solución escalable que afectara a todos los despliegues en Go del clúster.
    Hacer que todos los desarrolladores de cientos de proyectos agregaran una dependencia de autoprocs no era una opción.
    La alternativa de ajustar todos los CPU request/limit a números enteros y poner ese valor como variable de entorno GOMAXPROCS en los manifiestos de Kubernetes también era engorrosa y poco viable.
    Al final, aplicamos la variable GOMAXPROCS solo a algunas aplicaciones que usaban mucho multithreading y obtuvimos mejoras, pero todavía no encontramos una solución aplicable a todos los despliegues de una arquitectura de microservicios donde los requerimientos de CPU varían mucho de un proyecto a otro.

    • No hay una única respuesta correcta para esto.
      Limitar GOMAXPROCS puede causar problemas graves de latencia cuando el proceso recibe mucho tráfico y el encolamiento es simple.
      Independientemente de la idea que tengas sobre cuánto tiempo usará el proceso en promedio, en la práctica lo mejor es configurar GOMAXPROCS con el valor que proporciona el hardware.
    • Puedes definir un mutating webhook que inyecte GOMAXPROCS en todos los contenedores de los pods.
  • Como alguien que no está familiarizado con Docker ni Go, me pregunto si este comportamiento es intencional.
    ¿Se puede hacer que el equipo de Go reconozca los límites de CGroups?
    ¿Otros runtimes se comportan de forma similar?

    • Estoy bastante seguro de que .NET también tuvo que lidiar con este problema, y recuerdo que Java tuvo, o quizá todavía tiene, problemas.
      ¿O te referías a runtimes como containerd?
    • Pasé por el mismo problema en la JVM.
      Fue en Scala.
  • También hay técnicas de GC que hacen que las pausas sean más cortas
    Por ejemplo, consisten en realizar en paralelo el trabajo que se haría durante la pausa y luego repetirlo en un punto seguro
    La expectativa es que, gracias al trabajo concurrente, lo que se haga en el punto seguro se convierta en una simple verificación de que “no hay nada por hacer”
    Si se duplica el trabajo, el throughput del GC puede empeorar

  • Aunque este artículo habla de contenedores, parece que el problema aparece siempre que Go solo puede acceder a menos tiempo de CPU del esperado
    ¿No pasaría lo mismo si se ejecuta Go en un sistema donde hay otros procesos usando CPU?
    ¿Incluso bastaría con ejecutar dos programas en Go al mismo tiempo?