2 pontos por GN⁺ 2023-11-09 | 1 comentários | Compartilhar no WhatsApp
  • Mesmo com limite de CPU no contêiner, o runtime do Go não percebe isso por padrão, então pode criar threads com base em todos os núcleos do host e aumentar a latência
  • O GC do Go roda em sua maior parte ao mesmo tempo que a aplicação, mas precisa de trechos de stop-the-world (STW) em Sweep Termination e Mark Termination, quando todas as goroutines são paradas
  • O CFS do Linux distribui o tempo de CPU por segundo com base no número de núcleos, e --cpus=4 significa dar ao contêiner 4 segundos de tempo de CPU por segundo
  • Em um host de 16 núcleos, se um contêiner com limite de 4 núcleos for executado, o Go pode colocar goroutines em 16 threads do sistema operacional, o que pode alongar o STW após o esgotamento da quota de CPU
  • Ao ajustar GOMAXPROCS ao limite de CPU do contêiner, no exemplo o ciclo de GC caiu de menos de 2,5 ms para menos de 1 ms, e o STW foi reduzido para cerca de 26 μs

Descompasso entre o limite de CPU do contêiner e o runtime do Go

  • Ao executar uma aplicação Go em um contêiner, o limite de CPU serve para impedir que ela consuma todos os CPUs do host
  • O problema é que o runtime do Go, por padrão, não reconhece o limite de CPU do contêiner
  • Com esse descompasso, o runtime assume que pode usar mais CPU do que a quota real permite, o que pode levar a maior latência

Onde o STW acontece no GC do Go

  • O coletor de lixo do Go roda concorrentemente com a aplicação na maior parte do tempo
  • Ainda assim, há dois pontos no processo de GC em que todas as goroutines precisam ser paradas
    • A etapa em que ele para para aplicar a write barrier antes da Mark Phase é chamada de Sweep Termination
    • Depois da Mark Phase, ele para novamente para remover a write barrier na etapa de Mark Termination
  • Os trechos de STW normalmente ficam na casa de dezenas de microssegundos
  • A aplicação de exemplo é uma aplicação web simples que faz muitas alocações de memória, e o código-fonte está em go-cfs-blog
  • O contêiner foi executado com limite de 4 CPUs
docker run --cpus=4 -p 8080:8080 $(ko build -L main.go)
  • É possível coletar traces com o pacote runtime/trace e analisá-los com go tool trace
  • Nessa execução, o ciclo de GC ficou abaixo de 2,5 ms, mas quase 10% disso foi gasto em trechos de STW
  • Em aplicações sensíveis à latência, mesmo essa proporção pode ser problemática

Como funcionam o limite de CPU do Docker e o CFS do Linux

  • O limite de CPU --cpus do Docker é um hard limit
  • Também é possível configurar --cpu-shares, mas isso só é aplicado quando o host está sob restrição de CPU
    • Se houver folga no host, o contêiner pode usar mais CPU do que os núcleos que lhe foram atribuídos
    • Quando o host entra em estado de restrição, a limitação passa a ser aplicada à aplicação
  • O Completely Fair Scheduler (CFS) do Linux foi introduzido no Linux 2.6.23 e foi o escalonador padrão até o Linux 6.6
  • O CFS é um proportional share scheduler, em que o peso do processo é proporcional ao número de núcleos de CPU que ele pode usar
    • Um processo que pode usar 4 núcleos de CPU tem peso 4
    • Um processo que pode usar 2 núcleos de CPU tem peso 2
  • O CFS divide e distribui tempo de CPU
    • Um sistema com 4 núcleos pode distribuir 4 segundos de tempo de CPU por segundo
    • Atribuir uma quantidade de núcleos de CPU a um contêiner equivale a pedir ao escalonador do Linux tempo correspondente a n CPUs
    • --cpus=4 significa que o contêiner recebe 4 segundos de tempo de CPU por segundo

Por que o STW fica mais longo

  • Ao iniciar, o runtime do Go cria uma thread do sistema operacional para cada núcleo de CPU
  • Em uma máquina com 16 núcleos, ele pode criar 16 threads do sistema operacional, independentemente do limite de CPU do cgroup
  • O runtime agenda goroutines sobre essas threads do sistema operacional
  • Mesmo que o contêiner tenha limite de 4 núcleos, o Go pode colocar goroutines em todas as 16 threads do sistema operacional
  • Nessa situação, o runtime passa a operar como se pudesse usar 16 segundos de tempo de CPU por segundo
  • O STW mais longo acontece porque ele precisa parar até as goroutines que estão em threads aguardando o Linux voltar a escaloná-las
  • Depois que o contêiner já consumiu sua quota de CPU, essas threads deixam de ser escalonadas

Ajustando o GOMAXPROCS à quota de CPU

  • O Go permite limitar o número de threads de CPU usadas pelo runtime com a variável de ambiente GOMAXPROCS
  • Em um contêiner com quota de CPU 4, deve-se definir GOMAXPROCS=4
docker run --cpus=4 -e GOMAXPROCS=4 -p 8080:8080 $(ko build -L main.go)
  • Com a mesma aplicação e a mesma carga, ao alinhar GOMAXPROCS à quota de CPU, o tempo de GC diminuiu
  • No trace, o ciclo de GC caiu para menos de 1 ms, e o trecho de STW foi de 26 μs
  • Isso representa cerca de 1/10 do tempo de STW observado quando não havia limite de GOMAXPROCS
  • GOMAXPROCS deve ser configurado com a quantidade de núcleos de CPU disponíveis para o contêiner
    • Ao atribuir CPU fracionária, arredonde para baixo
    • Ao atribuir menos de 1 CPU, arredonde para cima
    • A fórmula é GOMAXPROCS=max(1, floor(CPUs))
  • O automaxprocs da Uber é uma biblioteca open source que calcula esse valor automaticamente a partir dos cgroups do contêiner
  • Há uma issue no GitHub aberta para que o runtime do Go passe a oferecer esse suporte por padrão

O que verificar em serviços Go containerizados

  • Não basta definir apenas o limite de CPU: também é preciso alinhar o GOMAXPROCS para que o runtime do Go respeite esse limite
  • Se for difícil calcular isso manualmente, bibliotecas como automaxprocs podem definir automaticamente o valor com base nos cgroups
  • Em serviços Go sensíveis à latência, vale conferir o tempo de STW no trace de GC para verificar se a quota de CPU e a configuração do runtime estão desalinhadas

1 comentários

 
GN⁺ 2023-11-09
Opiniões no Hacker News
  • Um problema comum em várias linguagens é que a aplicação detecta o número de núcleos da máquina olhando para /proc/cpuinfo
    Mas, dentro de um contêiner Docker ou de outras tecnologias de contêiner, esse arquivo aparece igual ao do host do contêiner e lista todos os núcleos, independentemente de quantos tenham sido realmente alocados ao contêiner
    Por um tempo achei que talvez o Docker pudesse criar um /proc/cpuinfo falso que listasse apenas as “CPUs Docker” alocadas à tarefa, mas, pensando melhor, isso provavelmente não funcionaria bem por vários motivos

    • Ao usar limites baseados em quota, o contêiner pode usar todos os núcleos de CPU do host
      O que é limitado é por quanto tempo ele pode usar esses núcleos
      Há exceções, e a documentação está aqui: https://kubernetes.io/docs/tasks/administer-cluster/cpu-mana...
    • Eu só uso nproc, e já vi isso em outros contêineres também, como bundle install -j $(nproc)
      Isso respeita a alocação de CPU, então oferece a funcionalidade que você estava procurando
      Não sei se aplicações arbitrárias usam nproc quando possível
      “Imprime o número de unidades de processamento disponíveis para o processo atual, que pode ser menor que o número de processadores online. Se essa informação não puder ser obtida, imprime o número de processadores instalados”
      https://www.gnu.org/software/coreutils/manual/html_node/npro...
      https://www.flamingspork.com/blog/2020/11/25/why-you-should-...
    • Go não faz isso
      Go olha, na inicialização, para a quantidade na máscara de CPU e depois não verifica novamente
      No Kubernetes, isso é um problema porque as CPUs visíveis podem mudar enquanto o processo está em execução
    • /proc/cpuinfo falso já existe: https://github.com/lxc/lxcfs
      lxcfs é um sistema de arquivos FUSE que infere valores de cgroup e imita /proc, para que aplicações e bibliotecas não precisem se preocupar se estão rodando dentro de um contêiner
      Por exemplo, /proc/uptime deve refletir o tempo de atividade do contêiner, não do host, e /proc/cpuinfo reflete como número de CPUs o limite mais baixo da combinação de cpu.max e cpuset.cpus
      A inferência do número de CPUs também pode ser feita pela chamada de sistema sched_getaffinity, e esse método não depende de /proc/cpuinfo
      Então, dependendo da biblioteca que você usa, pode ficar complicado
    • Vendo isso, chego à conclusão de que contêineres são uma abstração mal-ajambrada e que a VMware perdeu uma oportunidade
  • Essa explicação está sutilmente errada
    Do ponto de vista do Docker, há vários controles ajustáveis nas extensões de cgroup CFS: cfs_quota_us, cfs_period_us (o padrão comum é 100 ms, não 1 segundo) e shares
    Ao configurar shares, aplica-se escalonamento proporcional baseado em pesos, mas isso só faz sentido quando há contenção
    Os dois primeiros valores impõem uma quota rígida
    Em vez da flag --cpu do Docker, é melhor usar --cpu-shares para evitar a imposição de quota, que em geral é pouco útil
    Segundo a documentação do Linux, cpu.shares é o peso de cada grupo no mesmo nível, cpu.cfs_period_us é o período do escalonador para avaliação de largura de banda, e o padrão é 100000 us ou 100 ms
    cpu.cfs_quota_us é o tempo máximo durante o qual o grupo atual pode executar a cada cfs_period_us, e esse valor é o tempo somado em todas as CPUs do sistema; portanto, para usar completamente 2 CPUs, ele deve ser definido como o dobro de cfs_period_us

    • A expressão “não use a flag --cpu do Docker; em vez disso…” é forte demais sem mais contexto
      De forma alguma dá para dizer que ela é “em geral pouco útil”
      shares e quota servem a casos de uso diferentes, então é preciso entender seu caso de uso e escolher de acordo
    • Um ponto de atenção é que, ao usar --cpu, a aplicação consegue detectar isso
      Provavelmente porque usa cpuset
      Ao usar quota, ela não consegue detectar, então é muito provável que crie mais threads do que o necessário
    • Sou o autor do blog, obrigado pelo feedback
      Vou tentar deixar essa parte mais clara
      Acho que os sintomas aparecem desse jeito, mas preciso deixar a formulação mais precisa
    • Quem usa Kubernetes não ajusta nem altera essas configurações diretamente
      A aplicação precisa se comportar corretamente
  • Se você usar CPU reservations em vez de CPU limits, esse ajuste não é necessário: https://home.robusta.dev/blog/stop-using-cpu-limits
    CPU reservations também são, na prática, limits, mas declarados como um limite implícito e uma garantia
    Portanto, basta deixar o runtime do Go usar todas as CPUs disponíveis e, quando houver contenção de CPU, deixar o escalonador do Linux limitar conforme as reservations declaradas

    • O motivo para definir limits não é o medo de um pod afetar outros pods
      É para não se acostumar a um estado em que se pode usar CPU excedente não garantida
      À medida que o nó vai sendo preenchido com outros pods, um pod que rodava bem até há pouco pode ficar lento de repente
      Usar limits simula esse mesmo comportamento e permite se preparar com um planejamento de capacidade correto
      Não é a única forma, mas é a mais simples
    • Em uma configuração de 128 cores, rodo algumas coisas definindo CPU limits bem acima dos requests, mas ainda os configuro para impedir que algo saia do controle
      Tenho curiosidade sobre essa discussão, mas o artigo linkado parece tratar apenas da ideia de que as pessoas acham que um limit é necessário para garantir CPU a todos os pods
    • Na comunidade Kubernetes, parece que essa discussão acontece a cada duas semanas
      Esse artigo, por si só, não está errado e em geral se parece mais com marketing de conteúdo, mas faz afirmações amplas e ignora vários bons motivos para definir limits
      Há artigos do mesmo lugar que são simplesmente errados: https://home.robusta.dev/blog/containers-dont-use-chroot
      Existem workloads que consomem toda a capacidade de burst em troca de ganhos pequenos, e às vezes é preciso priorizar a capacidade de burst de um servidor HTTP em vez de um cronjob que precisa terminar dentro de um prazo definido
      Também já houve casos em que desenvolvedores não atualizaram os requests mesmo com o aumento da demanda da aplicação, e tiveram uma falha quando o tempo ocioso de CPU de repente ficou escasso
    • Reservations não são limits, mas sim uma restrição de uso mínimo garantido de CPU
      Em teoria, são recursos mínimos garantidos, mas, quando contêineres ocupados rodam juntos no mesmo host, a latência de cauda e a latência média podem aumentar de forma anormal
      Em uma instância EC2 de 4 cores, a latência com 50% de uso de CPU é bem diferente da latência com 90%
      Com reservations acontece algo parecido: mesmo que cada contêiner receba sua própria reservation garantida, o uso relativo de CPU fica muito alto por causa de outros processos ocupados no mesmo host
    • Interessante, mas isso não se aplica à memória, certo?
      O OOMKiller pode pegar o processo
      Sem limits de CPU e memória, não dá para obter a classe Guaranteed QoS, então em algum momento o pod pode ser despejado
  • Usando contêineres e cgroups, já apanhei várias vezes do escalonador CFS
    Fico curioso para saber qual é o novo escalonador
    Alguém aqui já usou isso em um cluster de produção?
    Estamos desperdiçando cores há quase 20 anos: https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf

    • O problema aqui não é o escalonador
      O problema é que o contêiner impôs limites de recursos, mas Go, o processo dentro do contêiner, não verifica o recurso do sistema operacional usado para esse limite ao calcular a quantidade de paralelismo disponível
    • https://kernelnewbies.org/Linux_6.6#New_task_scheduler:_EEVD...
  • Além de GOMAXPROCS, as versões recentes do Go têm GOMEMLIMIT
    Usando https://github.com/KimMachineGun/automemlimit, é possível configurar esse limite automaticamente, de forma parecida com https://github.com/uber-go/automaxprocs

  • No ano passado, no meu emprego anterior, trabalhando como engenheiro de plataforma e administrando clusters Kubernetes on-premises e a infraestrutura de pipelines de CI/CD, descobri isso
    Eu vi a divergência entre CPU real e CPU alocada causar problemas, especialmente CPU throttling, mas era difícil encontrar uma solução escalável que afetasse todos os deploys em Go do cluster
    Fazer todos os desenvolvedores de centenas de projetos adicionarem a dependência autoprocs não era uma opção
    Como alternativa, alinhar todos os CPU requests/limits para números inteiros e inserir esse valor na variável de ambiente GOMAXPROCS nos manifests do Kubernetes também era trabalhoso e inviável
    No fim, obtivemos melhorias aplicando a variável GOMAXPROCS apenas a algumas aplicações que usavam muito multithreading, mas ainda não encontramos uma solução aplicável a todos os deploys em uma arquitetura de microsserviços na qual os requisitos de CPU variam bastante de projeto para projeto

    • Não há uma única resposta certa aqui
      Limitar GOMAXPROCS pode causar problemas graves de latência quando o processo recebe muito tráfego e o enfileiramento é simples
      Independentemente da ideia de quanto tempo, em média, o processo vai usar, definir GOMAXPROCS para o valor fornecido pelo hardware é, na prática, o melhor
    • É possível definir um mutating webhook que injete GOMAXPROCS em todos os contêineres dos pods
  • Da perspectiva de alguém que não conhece bem Docker nem Go, fico me perguntando se esse comportamento é intencional
    Dá para fazer a equipe do Go reconhecer o CGroups limit?
    Outros runtimes se comportam de modo parecido?

    • Tenho quase certeza de que .NET também precisou lidar com esse problema, e me lembro de que Java também teve, ou talvez ainda tenha, problemas
      Ou você estava falando de runtimes como containerd?
    • Passei pelo mesmo problema na JVM
      Foi em Scala
  • Também existem técnicas de GC que tornam as pausas mais curtas
    Por exemplo, uma abordagem em que o trabalho a ser feito durante a pausa é executado simultaneamente e depois repetido novamente em um ponto seguro
    A expectativa é que, graças ao trabalho concorrente, o trabalho no ponto seguro se transforme em uma simples verificação de que “não há nada a fazer”
    Se o trabalho for duplicado, a taxa de transferência do GC pode piorar

  • Embora este texto fale de contêineres, o problema parece surgir sempre que Go tem acesso a menos tempo de CPU do que o esperado
    A mesma coisa não aconteceria ao executar Go em um sistema com outros processos usando CPU?
    Talvez até apenas executar dois programas Go ao mesmo tempo já cause isso?