Tecnologia e Segurança

GO e Kubernetes: entendendo throttling de CPU

Na Quanto, o Go se tornou uma das principais linguagens de programação do nosso stack, principalmente por ser uma ferramenta pronta para produção, rápida, de fácil manutenção e versátil. Apesar de eficiente, essa linguagem e as suas facilidades não são tão conhecidas. Você sabe para que serve a variável GOMAXPROCS e como ela pode impactar a sua aplicação Golang no Kubernetes? Confira a explicação preparada por Felipe Romani, Software Engineer na Quanto.

 

Aplicações em Kubernetes

Em Golang, podemos usar a variável de ambiente GOMAXPROCS para definir o limite de Threads do sistema operacional que serão provisionadas para executar nosso binário de forma simultânea. Por padrão, o GOMAXPROCS considera todos os núcleos lógicos da máquina onde o binário é executado, ou seja, será o valor da função runtime.NumCPU()

 

Mas de que forma isso pode afetar nossa aplicação dentro de um cluster de Kubernetes?

 

Para facilitar nosso exemplo, considere que temos um cluster de Kubernetes com um único nó e que nos oferece oito núcleos de processamento.

 

Quando fazemos o deploy do container de nossa aplicação no Kubernetes, nós devemos definir o limite de CPU e Memória que nossa aplicação terá disponível. Isso nos ajuda a evitar que nossa aplicação sozinha não consuma todo o recursos do cluster, afetando outras aplicações.

 

Então, vamos supor que especificamos um limite de 4000m (4 mil milicpu/milicores), ou seja, colocamos 4 núcleos da CPU como limite. Abaixo, exemplo de configuração do container:

 

Kubernetes

Esperamos que o GOMAXPROCS respeite os limites impostos pelo Kubernetes após a aplicação destes no deploy, tendo seu valor padrão alterado para 4 - mas na prática, não é isso que acontece.

 

Se você executar algo próximo do código abaixo dentro do Kubernetes em nosso nó, mesmo com os limites, ainda terá o resultado de 8. Ou seja, ele considera todos os núcleos que o nó do Kubernetes tem disponível.

 

Kubernetes2

O Kubernetes usa como agendador de processos o CFS (Completely Fair Scheduler), ele  também é usado para forçar o limite de CPU para os recursos do Pod. Para ajudar em nosso exemplo, no nosso cluster de Kubernetes podemos configurar dois principais parâmetros do CFS:

 

  • - cpu.cfs_period_us: Define um período (configuração global)
  • - cpu.cfs_quota_us: Define uma quota (configuração por Pod)

 

Por padrão, o cpu.cfs_period_us é configurado para 100ms enquanto o cpu.cfs_quota_us padrão para os Pods é a quantidade de tempo de CPU que uma aplicação pode consumir em 100ms, ou seja, se o limite for definido em 4 núcleos (4000 milicore), significa que será 400ms (4 x 100ms).

 

O problema

Agora, vamos imaginar um cenário onde múltiplas goroutines são executadas de forma concorrente em 4 diferentes Threads e cada Thread será executada em um núcleo diferente. As 4 threads serão executadas nos núcleos 1, 3, 4 e 8 de nosso nó do cluster, conforme mostra a imagem abaixo:

Kubernetes

 

Durante o primeiro período de 100ms, as 4 Threads foram ocupadas pelo o aplicativo, então ele consumiu totalmente as 400ms disponíveis. Já no segundo período, o aplicativo consumiu 360ms do total de 400ms, e assim segue. Por enquanto, tudo está em conformidade, já que a aplicação consome menos do que a cota limite.

 

Se voltarmos ao exemplo, vamos lembrar que o nó de kubernetes tem 8 núcleos e a aplicação configurou de forma padrão o GOMAXPROCS para 8 também, pois como explicado, o runtime do Golang irá sempre considerar o valor total de núcleos virtuais que existem no nó e não respeitará os limites do Kubernetes. Portanto no pior cenário, poderemos ter 8 Threads executando as goroutines e cada uma ocupando um núcleo diferente, como mostra a imagem abaixo:

Kubernetes

 

Para cada período de 100ms, a cota configurada é de 400ms. Então, se temos 8 Threads sendo ocupadas executando as goroutines de nossa aplicação, depois de 50ms, nós atingimos a cota de 400ms (8 x 50ms = 400ms). Qual será a consequência disso?

 

O CFS irá limitar o recurso de CPU (throttling) e, consequentemente, não será possível alocar recursos de CPU até começar um novo período. Em outras palavras, nossa aplicação precisará esperar de forma ociosa por 50ms.

 

Por exemplo, se nossa aplicação possui normalmente uma latência de 50ms, ela pode levar até 150ms para responder devido a esse problema, ou seja, uma penalidade de 300% na latência.

 

A solução

Então, qual a solução? Primeiramente devemos acompanhar a Issue-33803, talvez em um futuro próximo o runtime da Golang por padrão cuidará dos limites do CFS.

 

Além disso, a solução é usar o projeto da Uber. Essa biblioteca irá automaticamente ajustar os limites da configuração GOMAXPROCS para respeitar o limite de CPU especificado para o container no Kubernetes. Se usássemos essa biblioteca no nosso exemplo, o GOMAXPROCS seria configurado corretamente para 4 ao invés dos 8 núcleos, evitando o throttling de CPU.

 

Uma dica importante: o GOMAXPROCS possui o valor mínimo de 1, então, para evitar o erro abaixo, use no mínimo 1000m (1000 milicores) na sua configuração de limite de CPU nos recursos do container no Kubernetes:

 

Kubernetes3

Se esse conteúdo foi útil, também recomendamos o livro 100 Go Mistakes and How to Avoid Them, que ensina Golang de forma mais aprofundada. 

 

#VempraQuanto

Se você quer saber mais sobre a Quanto, nossa stack, cultura e benefícios, visite a nossa página de carreiras

 

Leia também: