- 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
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
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...
nproc, e já vi isso em outros contêineres também, comobundle 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 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
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/cpuinfoEntão, dependendo da biblioteca que você usa, pode ficar complicado
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 sharesAo 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
--cpudo Docker, é melhor usar--cpu-sharespara evitar a imposição de quota, que em geral é pouco útilSegundo 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 mscpu.cfs_quota_usé o tempo máximo durante o qual o grupo atual pode executar a cadacfs_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 decfs_period_us--cpudo Docker; em vez disso…” é forte demais sem mais contextoDe 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
--cpu, a aplicação consegue detectar issoProvavelmente 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
Vou tentar deixar essa parte mais clara
Acho que os sintomas aparecem desse jeito, mas preciso deixar a formulação mais precisa
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
É 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
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
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
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
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 é 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
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
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
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?
Ou você estava falando de runtimes como containerd?
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?