Da alta latência do Ceph ao patch do kernel com o eBPF / BCC



Existem muitas ferramentas para depurar programas do kernel e do espaço do usuário no Linux. A maioria deles tem impacto no desempenho e não pode ser executada facilmente em ambientes de produção. Alguns anos atrás, o eBPF foi desenvolvido , o que fornece a capacidade de rastrear o kernel e o espaço do usuário com pouca sobrecarga, sem a necessidade de recompilar programas ou carregar módulos do kernel.

Agora existem muitas ferramentas que usam o eBPF e, neste artigo, explicaremos como escrever sua própria ferramenta de criação de perfil usando a biblioteca PythonBCC . Este artigo é baseado em um problema real do ambiente de produção. Orientaremos você na solução do problema e mostraremos como as ferramentas Cco existentes podem ser usadas em alguns casos.

Ceph é lento


Uma nova plataforma foi adicionada a um cluster ceph. Depois de migrar alguns dados para a plataforma, a latência para solicitações de gravação foi maior que nos outros servidores.



Essa plataforma possui um novo dispositivo virtual de armazenamento em cache - bcache, que não usamos neste cluster antes - e um novo kernel - 4.15, que ainda não é usado em nenhum outro lugar nesse cluster. A raiz do problema pode estar em qualquer lugar, então vamos dar uma olhada mais profunda.

Investigando o Host


Vejamos o que está acontecendo dentro do processo ceph-osd. Usamos a ferramenta de rastreamento perf e o flamescope para criar gráficos de flama:



Como podemos ver no flamegraph, fdatasync () passou muito tempo enviando biografia na função generic_make_request () . Portanto, a raiz do nosso problema está em algum lugar fora do daemon ceph. Pode ser um problema de kernel, bcache ou disco. A saída do iostat mostrou alta latência para dispositivos bcache.

Outra descoberta suspeita é que o daemon systemd-udevd está consumindo CPU; cerca de 20% em várias CPUs. Esse é um comportamento estranho, por isso temos que descobrir o que está acontecendo. Como systemd-udevd trabalha com uevents, temos que usar o udevadm monitor para descobrir se há algum evento no sistema. Após a verificação, vimos que muitos eventos de "alteração" estavam sendo gerados para cada dispositivo de bloco no sistema.

Isso é incomum, então vamos descobrir o que está causando o envio de todos esses eventos.

Usando o BCC Toolkit


Como já sabemos, o kernel (e ceph daemon) está gastando muito tempo executando funções generic_make_requst () . Vamos medir sua latência usando a funclatência do kit de ferramentas BCC , apenas para garantir que estamos no caminho certo. Rastrearemos o PID do daemon ceph (argumento -p) em intervalos de 1 segundo (-i) e imprimiremos a latência em milissegundos (-m).



Essa função geralmente funciona muito rápido. Tudo o que faz é enviar a estrutura biológica para a fila do driver de dispositivo.

Bcache é um dispositivo complexo; de fato, consiste em 3 dispositivos: um dispositivo de suporte, que é um HDD lento no nosso caso; um dispositivo de armazenamento em cache, que é a partição da unidade NVMe; e um dispositivo virtual bcache, usado pelo aplicativo. Sabemos que o envio é lento, mas para qual dispositivo? Isso é algo que veremos um pouco mais tarde.

Por enquanto, sabemos que os eventos causam problemas nos ceph daemons e precisamos encontrar o software que os desencadeia. Não é fácil encontrar o que faz com que os eventos sejam gerados. Assumimos que é um software que roda apenas periodicamente. Para ver o que está sendo executado no sistema, usamos execsnoop do kit de ferramentas BCC. Podemos executá-lo e redirecionar o stdout para um arquivo.

Por exemplo:

/usr/share/bcc/tools/execsnoop | tee ./execdump 

Não forneceremos a saída completa do execsnoop aqui, mas uma string interessante que encontramos lá foi:

 sh 1764905 5802 0 sudo arcconf getconfig 1 AD | grep Temperature | awk -F '[:/]' '{print $2}' | sed 's/^ \([0-9]*\) C.*/\1/' 

A terceira coluna é o PPID do processo. Verificamos o que era o 5802 e vimos que é um dos nossos threads do daemon de monitoramento. Olhando mais para a configuração do sistema de monitoramento, encontramos um parâmetro com defeito. A temperatura do HBA estava sendo recuperada a cada 30 segundos, o que é muito frequente. Depois de alterar o intervalo de verificação para um valor mais apropriado, vimos que nossa latência de ceph correspondia às outras plataformas.

Mas ainda não sabemos por que a latência do bcache foi alta. Montamos uma plataforma de teste com a mesma configuração e tentamos reproduzir o problema com o fio no dispositivo bcache enquanto acionamos simultaneamente o udev com o comando udevadm trigger.

Escrevendo ferramentas baseadas em Cco


O que vamos fazer aqui é escrever uma ferramenta simples que rastreie as chamadas mais lentas de generic_make_request () e imprima o nome do disco para o qual a função foi solicitada.

O plano é simples:

  • Registre o kprobe em generic_make_request () :
    • Salve o nome do disco disponível no argumento da função
    • Salve o carimbo de data / hora atual
  • Registre o kretprobe na instrução de retorno generic_make_request () :
    • Recuperar o carimbo de data / hora atual
    • Procure carimbos de data e hora salvos anteriormente e compare-os com os atuais
    • Se o resultado for maior que o limite, procure nomes de discos salvos anteriormente e imprima-os no terminal com informações adicionais

Kprobes e kretprobes usam pontos de interrupção para alterar o código de uma função em tempo de execução. Você pode encontrar documentação e um bom artigo sobre isso. Se você der uma olhada no código para diferentes ferramentas de Cco , verá que todas elas têm uma estrutura idêntica. Ignoraremos a análise de argumentos e focaremos no próprio programa BPF.

O texto do nosso programa será definido em python da seguinte maneira:

 bpf_text = “”” # Here will be the bpf program code “”” 

Os programas BPF usam hashmaps para compartilhar dados entre diferentes funções. Usaremos o PID como uma chave e a estrutura auto-definida como um valor.

 struct data_t { u64 pid; u64 ts; char comm[TASK_COMM_LEN]; u64 lat; char disk[DISK_NAME_LEN]; }; BPF_HASH(p, u64, struct data_t); BPF_PERF_OUTPUT(events); 

Aqui, registramos um hashmap chamado p com um tipo de chave u64 e um tipo de valor struct data_t . Este mapa pode ser acessado em nosso contexto de programa BPF. A macro BPF_PERF_OUTPUT registra outro mapa chamado events , que é usado para enviar dados para o espaço do usuário.

Ao medir a latência entre a chamada de função e seu retorno ou entre uma chamada de função e outra, você deve ter certeza de que os dados que você salvou e acessou posteriormente se relacionam ao mesmo contexto. Em outras palavras, você deve estar ciente de quaisquer outras execuções paralelas da mesma função. É possível rastrear a latência entre a chamada de função de um processo e os retornos da mesma função de outro processo, mas isso não nos ajuda. Um bom exemplo é a ferramenta de biolatência em que a solicitação de ponteiro para estrutura é usada como uma chave de hashmap.

Em seguida, precisamos escrever um código que será executado nas chamadas de função por meio de um mecanismo kprobe:

 void start(struct pt_regs *ctx, struct bio *bio) { u64 pid = bpf_get_current_pid_tgid(); struct data_t data = {}; u64 ts = bpf_ktime_get_ns(); data.pid = pid; data.ts = ts; bpf_probe_read_str(&data.disk, sizeof(data.disk), (void*)bio->bi_disk->disk_name); p.update(&pid, &data); } 

Aqui temos o primeiro argumento generic_make_request () como o segundo argumento da nossa função. Em seguida, obtemos o PID e o registro de data e hora atuais em nanossegundos e os gravamos nos dados struct data_t recentemente alocados. Nós obtemos o nome do disco da estrutura bio, que é passada para generic_make_request () , e o salvamos em nossos dados . A última etapa é adicionar uma entrada ao hashmap que descrevemos anteriormente.

Esta função será executada nos retornos generic_make_request () :

 void stop(struct pt_regs *ctx) { u64 pid = bpf_get_current_pid_tgid(); u64 ts = bpf_ktime_get_ns(); struct data_t* data = p.lookup(&pid); if (data != 0 && data->ts > 0) { bpf_get_current_comm(&data->comm, sizeof(data->comm)); data->lat = (ts - data->ts)/1000; if (data->lat > MIN_US) { FACTOR data->pid >>= 32; events.perf_submit(ctx, data, sizeof(struct data_t)); } p.delete(&pid); } } 

Obtemos o PID e o carimbo de data e hora da saída anterior e procuramos no mapa de hash o valor where key == PID atual . Se for encontrado, obtemos o nome do processo em execução e o adicionamos à estrutura de dados . O que fazemos com data-> pid aqui nos fornece o ID do grupo de threads. A função anteriormente chamada bpf_get_current_pid_tgid () retorna o thread GID e PID do processo no mesmo valor de 64 bits.

Não estamos interessados ​​no ID de cada segmento, mas queremos saber o PID do segmento principal. Após verificar se a latência está acima do limite, enviamos nossa estrutura de dados para o espaço do usuário por meio do mapa de eventos e excluímos a entrada de hashmap no final.

Em nosso script python, precisamos substituir MIN_US e FACTOR de acordo com o limite que queremos e a unidade de tempo que queremos ver em nosso resultado:

 bpf_text = bpf_text.replace('MIN_US',str(min_usec)) if args.milliseconds: bpf_text = bpf_text.replace('FACTOR','data->lat /= 1000;') label = "msec" else: bpf_text = bpf_text.replace('FACTOR','') label = "usec" 

Em seguida, precisamos preparar o programa BPF com uma macro BPF () e registrar probes:

 b = BPF(text=bpf_text) b.attach_kprobe(event="generic_make_request",fn_name="start") b.attach_kretprobe(event="generic_make_request",fn_name="stop") 

Também precisamos definir a mesma estrutura que struct data_t em nosso script para ler os dados do programa BPF:

 TASK_COMM_LEN = 16 # linux/sched.h DISK_NAME_LEN = 32 # linux/genhd.h class Data(ct.Structure): _fields_ = [("pid", ct.c_ulonglong), ("ts", ct.c_ulonglong), ("comm", ct.c_char * TASK_COMM_LEN), ("lat", ct.c_ulonglong), ("disk",ct.c_char * DISK_NAME_LEN)] 

O último passo é imprimir os dados que queremos:

 def print_event(cpu, data, size): global start event = ct.cast(data, ct.POINTER(Data)).contents if start == 0: start = event.ts time_s = (float(event.ts - start)) / 1000000000 print("%-18.9f %-16s %-6d %-1s %s %s" % (time_s, event.comm, event.pid, event.lat, label, event.disk)) b["events"].open_perf_buffer(print_event) # format output start = 0 while 1: try: b.perf_buffer_poll() except KeyboardInterrupt: exit() 

O script completo está disponível no GitHub . Vamos executar o script e disparar eventos do udev enquanto o fio grava em um dispositivo bcache:



Sucesso! Agora vemos que o que parecia alta latência para o bcache é realmente a latência generic_make_request () para seu dispositivo de backup.

Cavar no kernel


O que se arrasta ao enviar solicitações? Vimos que ocorreu um pico de latência antes mesmo que a contabilidade da solicitação fosse iniciada. Isso pode ser facilmente verificado executando o iostat durante o problema ou o script BCC da biolatência , que se baseia no início da solicitação de contabilidade, para que nenhuma ferramenta mostre o problema do disco.

Se dermos uma olhada em generic_make_request () , veremos que há duas funções em execução antes do início da contabilidade. O primeiro é generic_make_request_checks () , que é leve e verifica a biografia de acordo com as configurações do dispositivo, etc. O segundo é blk_queue_enter () , que possui uma chamada wait_event_interruptible () :

 ret = wait_event_interruptible(q->mq_freeze_wq, (atomic_read(&q->mq_freeze_depth) == 0 && (preempt || !blk_queue_preempt_only(q))) || blk_queue_dying(q)); 

Aqui o kernel espera até que a fila seja descongelada. Vamos medir a latência de blk_queue_enter ():

 ~# /usr/share/bcc/tools/funclatency blk_queue_enter -i 1 -m Tracing 1 functions for "blk_queue_enter"... Hit Ctrl-C to end. msecs : count distribution 0 -> 1 : 341 |****************************************| msecs : count distribution 0 -> 1 : 316 |****************************************| msecs : count distribution 0 -> 1 : 255 |****************************************| 2 -> 3 : 0 | | 4 -> 7 : 0 | | 8 -> 15 : 1 | | 

Parece que estamos perto. As funções usadas para congelar / descongelar a fila são blk_mq_freeze_queue e blk_mq_unfreeze_queue . Eles são usados ​​para alterar as configurações da fila, o que pode afetar as solicitações io atualmente em andamento. Quando blk_mq_freeze_queue () é chamado, q-> mq_freeze_depth é incrementado em blk_freeze_queue_start () . Depois disso, o kernel espera que a fila fique vazia em blk_mq_freeze_queue_wait () .

Esse tempo de espera é igual à latência do disco, porque o kernel precisa aguardar a conclusão de todas as operações io. Quando a fila está vazia, as alterações podem ser feitas. A etapa final é chamar blk_mq_unfreeze_queue () , que diminui o contador freeze_depth .

Agora sabemos o suficiente para corrigir o problema. O comando udevadm trigger altera as configurações dos dispositivos de bloco. Essas configurações são descritas nas regras do udev. Podemos descobrir quais configurações congelam a fila alterando-as via sysfs ou olhando o código-fonte do kernel. Como alternativa, podemos chamar o trace do kit de ferramentas BCC para imprimir pilhas de kernel e usuário para cada chamada blk_freeze_queue :

 ~# /usr/share/bcc/tools/trace blk_freeze_queue -K -U PID TID COMM FUNC 3809642 3809642 systemd-udevd blk_freeze_queue blk_freeze_queue+0x1 [kernel] elevator_switch+0x29 [kernel] elv_iosched_store+0x197 [kernel] queue_attr_store+0x5c [kernel] sysfs_kf_write+0x3c [kernel] kernfs_fop_write+0x125 [kernel] __vfs_write+0x1b [kernel] vfs_write+0xb8 [kernel] sys_write+0x55 [kernel] do_syscall_64+0x73 [kernel] entry_SYSCALL_64_after_hwframe+0x3d [kernel] __write_nocancel+0x7 [libc-2.23.so] [unknown] 3809631 3809631 systemd-udevd blk_freeze_queue blk_freeze_queue+0x1 [kernel] queue_requests_store+0xb6 [kernel] queue_attr_store+0x5c [kernel] sysfs_kf_write+0x3c [kernel] kernfs_fop_write+0x125 [kernel] __vfs_write+0x1b [kernel] vfs_write+0xb8 [kernel] sys_write+0x55 [kernel] do_syscall_64+0x73 [kernel] entry_SYSCALL_64_after_hwframe+0x3d [kernel] __write_nocancel+0x7 [libc-2.23.so] [unknown] 

As regras do Udev não mudam frequentemente, portanto, mesmo atribuir valores já existentes a determinados parâmetros causa um aumento na latência de envio do aplicativo. É claro que gerar eventos do udev quando não há alterações na configuração de um dispositivo (nenhum dispositivo está conectado ou desconectado) não é uma boa prática. Ainda assim, podemos impedir que o kernel congele a fila se não houver motivo para fazer isso. Três pequenas confirmações corrigem o problema.

Conclusão


O eBPF é um instrumento altamente flexível e poderoso. Neste artigo, analisamos apenas um caso e demonstramos um pouco do que ele é capaz. Se você estiver interessado em desenvolver ferramentas baseadas em Cco, dê uma olhada no tutorial oficial , que descreve seus conceitos fundamentais.

Existem também outras ferramentas interessantes baseadas em eBPF disponíveis para criação de perfil e depuração. Um deles é o bpftrace , que permite escrever oneliners poderosos e pequenos programas em uma linguagem do tipo awk. Outro é o ebpf_exporter , que pode coletar métricas de alta resolução e baixo nível para o servidor prometheus, com suas excelentes habilidades de visualização e alerta.

Source: https://habr.com/ru/post/pt450818/


All Articles