Kubernetes - Consumo de memória elevado em aplicações que escrevem em disco
Operações de entrada e saída no disco são custosas, a maioria dos sistemas operacionais implementam estratégias de caching
na escrita e leitura de dados no sistema de arquivos. No caso do Kernel Linux, ele utiliza algumas estratégias, como por exemplo o Page Cache, cujo objetivo principal é armazenar os dados lidos pelo sistema de arquivos em cache
, para que a próxima operação de leitura esse dado esteja disponível em memória
.
Ao analisar as métricas coletadas pelo Prometheus de uma aplicação Go rodando em um pod do Kubernetes, identificamos um consumo de memória muito superior ao esperado, dado que a aplicação não fazia nada além de escrever dados aleatórios em poucos arquivos. Através de uma série de analises e pesquisas conseguimos relacionar o comportamento de page caching
ao problema em mencionado.
Aplicação
A aplicação usada como objeto de estudo esta no repositório: go-disk-writer. Ela é simples, e sua única função é escrever um buffer
em arquivo, repetidas vezes até um certo limite:
func writeLoop(path string, maxFileSize int, count int) error {
buffer := make([]byte, 10*1024)
for i := 0; i < len(buffer); i++ {
buffer[i] = byte(i)
}
currentFile := 0
for {
file, err := openFile(path, currentFile)
if err != nil {
return err
}
var fileSize uint64 = 0
for {
written, err := file.Write(buffer)
if err != nil {
panic(err)
}
fileSize += uint64(written)
if fileSize >= uint64(maxFileSize) {
currentFile++
if currentFile >= (count) {
return nil
}
break
}
}
}
}
O trecho que código acima esta utilizando o pacote os. O sistema operacional realiza a chamada SYS_CALL write.
Kubectl top
O repositória da aplicação contém o arquivo de definição pod.yaml
, do qual utilizei para rodar o pod
através do comando kubectl apply -f pod.yaml
.
Para monitorar o consumo de recursos do pod
, utilizei o comando watch kubectl top pod
:
NAME CPU(cores) MEMORY(bytes)
disk-writer 1m 408Mi
Após o termino da escrita em disco, a coluna MEMORY
estabilizou na casa dos 400Mi
. Este comportamento é inesperado, pois além do consumo alto de memória durante o processamento, ele continua acima do esperado com a aplicação em repouso.
De onde vem a informação do comando kubectl top pod
? Esse foi o questionamento que levantei após me deparar com esse comportamento.
A resposta está no pipeline de métricas do Kubernetes:
-
cAdvisor: DaemonSet para coletar, agregar e expor métricas de contêineres incluído no
Kubelet
. -
kubelet: Agente para gerenciar recursos de contêineres. As métricas de recursos são acessíveis usando os endpoints
/metrics/resource
e/stats
da API dokubelet
. -
node level resource metrics: API fornecida pelo
kubelet
para descobrir e recuperar estatísticas resumidas por nodes disponíveis através do endpoint/metrics/resource
. -
metrics-server: Componente adicional do cluster que coleta e agrega métricas de recursos obtidas de cada
kubelet
. Fornece as métricas para uso pelo HPA, VPA e pelo comandokubectl top
. -
API de Métricas:
API
doKubernetes
que suporta o acesso aCPU
e memória usados para escalonamento automático.
O comando kubectl top
acessa as métricas de CPU
e memória através da API de Métricas, que inicia uma cadeia de comunicação entre componentes chegando até o container runtime.
Working Set
Working set é a métrica em bytes que indica a quantidade de memória consumida por um pod
. A documentação aponta que esse métrica é uma estimativa calculada pelo sistema operacional.
Trecho retirado da documentação oficial: "Em um mundo ideal, o
working set
é a quantidade de memória em uso que não pode ser liberada sob pressão de memória. No entanto, o cálculo varia de acordo com o sistema operacional do host e geralmente faz uso intensivo de heurísticas para produzir uma estimativa."
cAdvisor
O cAdvisor é o componente mais próximo do container runtime, e ele é o responsável por coletar o working set, atualmente ele está na versão v1.3
.
Na função setMemoryStats
do arquivo cadvisor/blob/master/container/libcontainer/handler.go, o seguinte calculo é realizado:
inactiveFileKeyName := "total_inactive_file"
if cgroups.IsCgroup2UnifiedMode() {
inactiveFileKeyName = "inactive_file"
}
workingSet := ret.Memory.Usage
if v, ok := s.MemoryStats.Stats[inactiveFileKeyName]; ok {
ret.Memory.TotalInactiveFile = v
if workingSet < v {
workingSet = 0
} else {
workingSet -= v
}
}
ret.Memory.WorkingSet = workingSet
Este trecho é importante, pois aqui é onde o cAdvisor
captura as estatísticas do cgroup, e calcula o working_set
. Repare que ele subtrai a estatística inactive_file
.
Cgroups
Cgroup é um recurso do kernel
do Linux
que permite agrupar processos de forma hierárquica e controlar a alocação de recursos do sistema para esses grupos de maneira configurável. Com cgroups
, é possível gerenciar e limitar o uso de recursos como:
CPU
: Definir quanto tempo de processamento cada grupo de processos pode utilizar.Memória
: Limitar a quantidade de memória que cada grupo de processos pode usar.I/O de Disco
: Controlar a quantidade de operações de entrada e saída que cada grupo pode realizar em dispositivos de armazenamento.Rede
: Gerenciar a largura de banda de rede disponível para cada grupo de processos.
Podemos inspecionar as estáticas de memória do cgroup
do pod
que usamos como exemplo, através do passo a passo abaixo.
Conectar no pod
: kubectl exec disk-writer -it -- bash
.
Navegar até o diretório das estatísticas do cgroup
: cd /sys/fs/cgroup
.
Listar todos os arquivos relacionados à memória: ls | grep -e memory.stat -e memory.current
.
memory.current
memory.stat
O valor de memory.current
representa a quantidade total de memória usada pelo cgroup, enquanto memory.stat
fornece uma visão detalhada sobre como essa memória está distribuída e gerida.
Verificar o arquivo memory.current
que é a quantidade total de memória alocado pelo cgroup
: cat memory.current
.
13124435968 # aproximadamente 12512 MB
Este valor é muito maior que os 408Mi
que o comando kubectl top pod
resultou anteriormente.
Verifique o inactive_file
no arquivo memory.stat
: cat memory.stat | grep inactive_file
inactive_file 12692201472 # aproximadamente 12108 MB
No parágrafo anterior, verificamos que o cAdvisor
realiza a subtração do inactive_file
, para calcular o working_set
, se fizermos o mesmo, o resultado seria: 12512MB−12108MB=404MB
.
O pod
que utilizamos como exemplo consume aproximadamente 408Mi
, valor muito acima do esperado para uma aplicação que um baixo nível de alocação de memória.
Por dentro do arquivo memory.stat
, realizei um calculo de distribuição percentual das maiores estatisticas em relação ao memory.current
, e o resultado foi: slab_reclaimable: 421,722,560 bytes (94.2%)
.
Conclusão
A análise detalhada sobre o consumo de memória em aplicações que realizam operações de escrita em disco no Kubernetes revelou um comportamento específico do sistema de arquivos e do kernel Linux
. O uso intensivo do Page Cache
, para otimizar as operações de leitura e escrita em disco pode resultar em um alto consumo de memória, mesmo após a conclusão das operações de escrita. Esse comportamento é refletido na métrica working_set
, que exclui a memória cacheada inativa, resultando em discrepâncias entre o consumo real e o reportado pelo comando kubectl top
.
O estudo demonstrou que uma parcela significativa da memória alocada estava relacionada a objetos slab reclaimable, que são estruturas de dados cacheadas pelo kernel para otimizar a alocação de memória. Esses dados, embora ainda armazenados em cache, podem ser liberados quando o sistema estiver sob pressão de memória, o que explica a diferença entre o consumo de memória observado diretamente no cgroup
e o valor reportado pelas métricas de Kubernetes.
Portanto, o comportamento de alto consumo de memória em aplicações que escrevem em disco pode ser atribuído a essa estratégia de caching do sistema operacional. Embora não represente necessariamente um problema de desempenho, é crucial entender como o Kubernetes e o kernel Linux gerenciam memória para otimizar e monitorar o uso de recursos adequadamente em ambientes de produção.