Blog Post

Desenvolvedores BR
6 MIN READ

Kubernetes - Consumo de memória elevado em aplicações que escrevem em disco

ClaudioGodoy's avatar
ClaudioGodoy
Icon for Microsoft rankMicrosoft
Oct 14, 2024

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:

metrics api

  • 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 do kubelet.

  • 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 comando kubectl top.

  • API de Métricas: API do Kubernetes que suporta o acesso a CPU 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%).

slab_reclaimable

 

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.

Updated Oct 14, 2024
Version 1.0
No CommentsBe the first to comment