A crescente popularidade dos contêineres e seu uso em conjunto com os grupos de controle revelaram um sério problema de escalabilidade, o que leva a uma queda significativa no desempenho de grandes máquinas. O problema é que o tempo de desvio dos caches SLAB depende quadraticamente do número de contêineres e o consumo ativo de grandes quantidades de memória em um curto período pode fazer com que o sistema entre em um loop ocupado, consumindo 100% do tempo do processador. Hoje eu gostaria de contar como resolvemos esse problema alterando o algoritmo de contabilidade para usar o grupo de controle memcg para usar objetos de cache SLAB e otimizar a função shrink_slab ().

Por que surgiu a questão da otimização de processos no kernel? Tudo começou com o fato de que um de nossos clientes, usando ativamente contêineres e grupos de controle de memória (memcg), chamou a atenção para os estranhos picos de consumo de CPU que ocorrem de tempos em tempos. A carga normal do sistema era de cerca de 50% e, nos horários de pico, 100% do tempo do processador era gasto, e quase todo era consumido pelo kernel (tempo do sistema).
O nó em si era multiusuário e cerca de 200 contêineres OpenVZ foram lançados nele. A análise mostrou que um grande número de usuários criou contêineres Docker aninhados e hierarquias de vários níveis de grupos de controle de memória. Cada contêiner de nível superior de nível de usuário continha cerca de 20 pontos de montagem e 20 grupos de memória de controle (memcg) criados pelo systemd. Além disso, havia pontos de montagem e grupos de controle criados pelo Docker mencionado acima. Simplificando, o nó estava muito carregado e a carga nele era muito maior que a média para todos os outros clientes. Estávamos interessados em descobrir o motivo da aparência desses picos, pois o mesmo problema poderia aparecer em máquinas menos ocupadas, onde era quase imperceptível (por exemplo, fornecer picos com + 5% de tempo do sistema, o que prejudica o desempenho).
Ao manipular o perf, consegui pegar o pico e remover a trilha. Aconteceu que a maior parte do tempo do processador é gasta na limpeza de caches SLAB, a saber, caches de super bloco:
- 100,00% 0,00% kswapd0 [kernel.vmlinux] [k] kthread - 99,31% balance_pgdat - 82,11% shrink_zone - 61,69% shrink_slab - 58,29% super_cache_count + 54,56% list_lru_count_one
Aqui vale a pena fazer uma explicação e me debruçar sobre esse assunto com mais detalhes. Todo mundo sabe que o kernel armazena em cache dados não utilizados por um tempo antes de finalmente liberar memória. O kernel faz uso extensivo desse princípio. Por exemplo, o cache da página contém páginas de dados relacionados ao arquivo, o que acelera bastante o acesso repetido a eles durante a leitura (porque você não precisa acessar o disco novamente). No nosso caso, o problema surgiu com o cache de metadados do superbloco contido em duas listas de LRU: s_dentry_lru e s_inode_lru.
LRU (menos usado recentemente)
struct lru_list aponta para uma matriz de listas vinculadas e cada memcg ativo corresponde a um elemento (list_lru_one) nessa matriz. Quando um determinado objeto SLAB não é mais usado pelo kernel, o kernel o adiciona a uma das listas vinculadas da matriz (dependendo de qual memcg o objeto pertence ou, grosso modo, qual memcg o processo usado quando ele criou esse objeto). A própria matriz é descrita da seguinte forma (lru_list :: node :: memcg_lrus):
struct list_lru_memcg { struct rcu_head rcu; struct list_lru_one *lru[0]; }; struct list_lru_one { struct list_head list; long nr_items; };
lru [0] indica uma lista de objetos relacionados ao memcg com o ID 0;
lru [1] indica uma lista de objetos relacionados ao memcg com o ID 1;
...
lru [n] indica uma lista de objetos relacionados ao memcg com o ID n;
As listas LRU s_dentry_lru e s_inode_lru aparecem no nosso problema e, como o nome sugere, elas contêm objetos não utilizados do sistema de arquivos dentry e inode.
No futuro, se não houver memória suficiente no sistema ou em um memcg específico, alguns dos itens da lista serão finalmente liberados e um mecanismo especial chamado shrinker fará isso.
Encolhedor
Quando o kernel precisa alocar páginas de memória, mas não há memória livre no nó NUMA ou no sistema, o mecanismo para limpá-lo é iniciado. Ele está tentando jogar ou descartar uma certa quantidade de disco: 1) páginas do conteúdo dos arquivos do cache da página; 2) páginas relacionadas à memória anônima em uma troca e 3) objetos SLAB em cache (o problema que encontramos está relacionado a eles).
A eliminação de parte dos objetos SLAB em cache não afeta diretamente o lançamento das páginas: seu tamanho, em regra, é significativamente menor que o tamanho da página e uma página contém centenas de objetos. Quando parte dos objetos é liberada, as lacunas de memória livre são exibidas nas páginas SLAB, que podem ser usadas para criar outros objetos SLAB. Este algoritmo é aceito no kernel intencionalmente: é simples e bastante eficiente. Um leitor interessado pode ver a fórmula para selecionar uma parte dos objetos para limpeza na função do_shrink_slab ().
Esta função executa a limpeza real de parte dos objetos, guiada pela descrição passada a ela no encolhedor de estrutura:
static unsigned long do_shrink_slab(struct shrink_control *shrinkctl, struct shrinker *shrinker, int priority) { … freeable = shrinker->count_objects(shrinker, shrinkctl); if (freeable == 0) return 0; total_scan = _(freeable); while (total_scan >= batch_size) { ret = shrinker->scan_objects(shrinker, shrinkctl); total_scan -= shrinkctl->nr_scanned; } ... }
Em relação ao superbloco de retração, essas funções são implementadas da seguinte maneira. Cada superbloco mantém suas próprias listas s_dentry_lru e s_inode_lru de objetos não utilizados relacionados a ele:
struct super_block { ... struct shrinker s_shrink; ... struct list_lru s_dentry_lru; struct list_lru s_inode_lru; … };
O método .count_objects retorna o número de objetos:
static unsigned long super_cache_count(struct shrinker *shrink, struct shrink_control *sc) { total_objects += list_lru_shrink_count(&sb->s_dentry_lru, sc); total_objects += list_lru_shrink_count(&sb->s_inode_lru, sc); total_objects = vfs_pressure_ratio(total_objects); return total_objects; }
O método .scan_objects realmente libera objetos:
static unsigned long super_cache_scan(struct shrinker *shrink, struct shrink_control *sc) { prune_dcache_sb(sb, sc); prune_icache_sb(sb, sc); }
O número de objetos a serem liberados é passado no parâmetro sc. Além disso, o memcg é indicado lá, cujos objetos devem ser jogados para fora da LRU:
struct shrink_control { int nid; unsigned long nr_to_scan; struct mem_cgroup *memcg; };
Portanto, prune_dcache_sb () seleciona uma lista vinculada da estrutura struct list_lru_memcg :: lru [] e trabalha com ela. Prune_icache_sb () faz o mesmo.
Algoritmo velho do desvio do shrinker
Com a abordagem padrão, “ejetar” objetos do SLAB com falta de memória no
sc-> target_mem_cgroup acontece da seguinte maneira:
shrink_node() { … struct mem_cgroup *root = sc->target_mem_cgroup; memcg = mem_cgroup_iter(root, NULL, &reclaim); do { … shrink_slab(memcg, ...); … } while ((memcg = mem_cgroup_iter(root, memcg, &reclaim))); ... }
Examinamos todo o memcg filho e chamamos shrink_slab () para cada um deles. Em seguida, na função shrink_slab (), examinamos todos os encolhedores e, para cada um deles, chamamos do_shrink_slab ():
static unsigned long shrink_slab(gfp_t gfp_mask, int nid, struct mem_cgroup *memcg, int priority) { list_for_each_entry(shrinker, &shrinker_list, list) { struct shrink_control sc = { .nid = nid, .memcg = memcg, }; ret = do_shrink_slab(&sc, shrinker, ...); } }
Lembre-se de que para cada superbloco, seu próprio encolhedor é adicionado a esta lista. Vamos contar quantas vezes o do_shrink_slab () será chamado para o caso com 200 contêineres de 20 memcg e 20 pontos de montagem em cada um. No total, temos 200 * 20 pontos de montagem e 200 * 20 grupos de controle. Se não houver memória suficiente no memcg superior, seremos forçados a ignorar todo o memcg filho (isto é, tudo em geral) e, para cada um deles, chamar cada um dos shrinkers da shrinker_list. Assim, o kernel fará 200 * 20 * 200 * 20 = 16000000 chamadas para a função do_shrink_slab ().
Além disso, o grande número de chamadas para essa função será inútil: os contêineres geralmente são isolados entre si, e a probabilidade de o CT1 usar o super_block2 criado no CT2 é geralmente baixa. Ou, o que é o mesmo, se memcg1 for um grupo de controle do CT1, o elemento correspondente da matriz super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id] será uma lista vazia e não há sentido em chamar do_shrink_slab ().
Esse problema pode ser modelado usando um script bash simples (dados do patchset, que foram posteriormente passados para o kernel, são usados aqui):
$echo 1 > /sys/fs/cgroup/memory/memory.use_hierarchy $mkdir /sys/fs/cgroup/memory/ct $echo 4000M > /sys/fs/cgroup/memory/ct/memory.kmem.limit_in_bytes $for i in `seq 0 4000`; do mkdir /sys/fs/cgroup/memory/ct/$i; echo $$ > /sys/fs/cgroup/memory/ct/$i/cgroup.procs; mkdir -ps/$i; mount -t tmpfs $is/$i; touch s/$i/file; done
Vamos ver o que acontece se você chamar o procedimento de redefinição de cache 5 vezes seguidas:
$time echo 3 > /proc/sys/vm/drop_caches
A primeira iteração dura 14 segundos, porque os objetos em cache realmente estão na memória:
0,00 usuário 13,78 system 0: 13.78 decorrido 99% da CPU.A segunda iteração leva 5 segundos, embora não haja mais objetos:
0.00user 5.59system 0: 05.60capsed 99% CPU.A terceira iteração leva 5 segundos:
0.00user 5.48system 0: 05.48elapse CPU 99%A quarta iteração leva 8 segundos:
0.00user 8.35system 0: 08.35elapsed 99% CPUA quinta iteração leva 8 segundos:
0.00user 8.34system 0: 08.35elapsed 99% CPUTornou-se óbvio que o algoritmo de desvio de encolhimento usado pelo núcleo de baunilha não é ideal, e precisamos alterá-lo para melhor em termos de escalabilidade.
Novo algoritmo de desvio de encolhimento
Com o novo algoritmo, eu queria obter o seguinte:
- libertá-lo das falhas dos antigos e
- Não adicione novos bloqueios. Chame do_shrink_slab () apenas quando fizer sentido (ou seja, a lista vinculada correspondente da matriz s_dentry_lru ou da matriz s_inode_lru não estiver vazia), mas não acesse diretamente a memória da lista vinculada.
Ficou claro que isso poderia ser fornecido apenas por uma nova estrutura de dados sobre encolhedores heterogêneos (existem encolhedores não apenas do superbloco, mas também de outros objetos de dados não descritos neste artigo. O leitor pode se familiarizar com eles pesquisando a palavra-chave prealloc_shrinker () no código do kernel). A nova estrutura de dados deve permitir a codificação de dois estados: “faz sentido chamar do_shrink_slab ()” e “não faz sentido chamar do_shrink_slab ()”.
As estruturas de dados do tipo IDA foram rejeitadas porque eles usam bloqueios dentro de si. A estrutura de dados do campo de bits é totalmente adequada para essa função: permite a modificação atômica de bits individuais e, em combinação com barreiras de memória, permite a criação de um algoritmo eficiente sem o uso de bloqueios.
Cada shrinker obtém seu próprio ID exclusivo (shrinker :: id) e cada memcg obtém um bitmap capaz de conter o maior ID dos atualmente registrados. Quando o primeiro elemento é adicionado à lista s_dentry_lru-> node-> memcg_lrus-> lru [memcg_id], o bitmap memcg correspondente é definido como 1 bit com o número shrinker-> id. A mesma coisa com s_inode_id.
Agora o loop em shrink_slab () pode ser otimizado para ignorar apenas os shrinkers necessários:
unsigned long shrink_slab() { … for_each_set_bit(i, map, shrinker_nr_max) { … shrinker = idr_find(&shrinker_idr, i); … do_shrink_slab(&sc, shrinker, priority); … } }
(A limpeza de bits também é implementada quando o shrinker entra no estado "não faz sentido chamar do_shrink_slab (). Consulte o
commit do Github para obter mais detalhes.
Se você repetir o teste de redefinição de cache, usando o novo algoritmo, ele mostra resultados significativamente melhores:
$time echo 3 > /proc/sys/vm/drop_caches
Primeira iteração:
0.00user 1.10system 0: 01.10 CPU decorrida de 99%
Segunda iteração:
0.00user 0.00system 0: 00.01apps 64% CPU
Terceira iteração:
0.00user 0.01system 0: 00.01elapse 82% CPU
Quarta iteração:
0.00user 0.00system 0: 00.01apps 64% CPU
Quinta iteração:
0.00user 0.01system 0: 00.01elapse 82% CPUA duração da segunda à quinta iterações é de 0,01 segundos,
548 vezes mais rápido que antes.Como ações semelhantes para redefinir os caches ocorrem com cada falta de memória na máquina, essa otimização melhora significativamente a operação de máquinas com um grande número de contêineres e grupos de controle de memória.
Um conjunto de patches (17 peças) foi aceito no núcleo da baunilha e você pode encontrá-lo lá a partir da versão 4.19.
No processo de revisão dos patches, um funcionário do Google apareceu, e descobriu-se que eles tinham o mesmo problema. Portanto, os patches foram testados em um tipo diferente de carga.
Como resultado, o patchset foi adotado na 9ª iteração; e sua entrada no núcleo da baunilha levou cerca de 4 meses. Ainda hoje, o patchset está incluído em nosso próprio kernel Virtuozzo 7, começando com a versão vz7.71.9