Sob o capô, o hh.ru contém um grande número de serviços Java em execução nos contêineres do docker. Durante sua operação, encontramos muitos problemas não triviais. Em muitos casos, para chegar ao fundo da solução, tive que pesquisar no Google por um longo tempo, ler as fontes do OpenJDK e até mesmo criar um perfil dos serviços na produção. Neste artigo, tentarei transmitir a quintessência do conhecimento adquirido no processo.
Limites de CPU
Costumávamos viver em máquinas virtuais kvm com limitações de CPU e memória e, mudando para o Docker, definimos restrições semelhantes nos cgroups. E o primeiro problema que encontramos foi precisamente os limites da CPU. Devo dizer imediatamente que esse problema não é mais relevante para as versões recentes do Java 8 e Java ≥ 10. Se você acompanhar os horários, poderá ignorar esta seção com segurança.
Portanto, iniciamos um pequeno serviço no contêiner e vemos que ele produz um grande número de threads. Ou a CPU consome muito mais do que o esperado, o tempo limite é em vão. Ou aqui está outra situação real: em uma máquina, o serviço é iniciado normalmente e, em outra, com as mesmas configurações, ele falha, pregado por um assassino da OOM.
A solução acaba sendo muito simples - apenas o Java não vê as limitações do
--cpus
definidas na janela de encaixe e acredita que todos os kernels da máquina host estão acessíveis a ela. E pode haver muitos deles (em nossa configuração padrão - 80).
As bibliotecas ajustam o tamanho dos conjuntos de encadeamentos ao número de processadores disponíveis - daí o grande número de encadeamentos.
O próprio Java dimensiona o número de encadeamentos da GC da mesma maneira, daí o consumo e os tempos limites da CPU - o serviço começa a gastar uma grande quantidade de recursos na coleta de lixo, usando a maior parte da cota alocada a ele.
Além disso, as bibliotecas (em particular a Netty) podem, em certos casos, ajustar o tamanho da memória fora do quadril ao número de CPUs, o que leva a uma alta probabilidade de exceder os limites definidos para o contêiner ao executar em um hardware mais poderoso.
Inicialmente, como esse problema se manifestou, tentamos usar as seguintes rodadas de trabalho:
- tentou usar alguns serviços
libnumcpus - uma biblioteca que permite "enganar" o Java, definindo um número diferente de processadores disponíveis;
- indicou explicitamente o número de threads do GC,
- definir explicitamente limites para o uso de buffers diretos de bytes.
Mas, é claro, mover-se com essas muletas não é muito conveniente, e a mudança para o Java 10 (e depois o Java 11), em que todos esses problemas estão
ausentes , foi uma solução real. Para ser justo, vale dizer que nos oito também tudo correu bem com a
atualização 191 , lançada em outubro de 2018. Naquele momento, já era irrelevante para nós, o que também desejo para você.
Este é um exemplo em que a atualização da versão Java oferece não apenas satisfação moral, mas também um lucro real tangível na forma de operação simplificada e aumento do desempenho do serviço.
Docker e máquina de classe de servidor
Portanto, no Java 10, as opções
-XX:ActiveProcessorCount
e
-XX:+UseContainerSupport
apareceram (e foram portadas para o Java 8), levando em consideração os limites padrão do cgroups. Agora tudo estava maravilhoso. Ou não?
Algum tempo depois de mudarmos para o Java 10/11, começamos a notar algumas esquisitices. Por alguma razão, em alguns serviços, os gráficos do GC pareciam não usar o G1:
Isso foi, para dizer o mínimo, um pouco inesperado, pois sabíamos com certeza que o G1 é o coletor padrão, começando com o Java 9. Ao mesmo tempo, não há esse problema em alguns serviços - o G1 está ativado, conforme o esperado.
Começamos a entender e tropeçar em
algo interessante . Acontece que, se o Java estiver sendo executado em menos de 3 processadores e com um limite de memória inferior a 2 GB, ele se considerará cliente e não permitirá o uso de outro que não o SerialGC.
A propósito, isso afeta apenas a
escolha do GC e não tem nada a ver com as opções de compilação -client / -server e JIT.
Obviamente, quando usamos o Java 8, ele não levou em consideração os limites do docker e achou que tinha muitos processadores e memória. Após a atualização para o Java 10, muitos serviços com limites mais baixos começaram subitamente a usar o SerialGC. Felizmente, isso é tratado com muita simplicidade - configurando explicitamente a opção
-XX:+AlwaysActAsServerClassMachine
.
Limites de CPU (sim, novamente) e fragmentação de memória
Observando os gráficos no monitoramento, de alguma forma percebemos que o tamanho do conjunto residente do contêiner é muito grande - até três vezes o tamanho máximo do quadril. Esse poderia ser o caso de algum próximo mecanismo complicado escalonado de acordo com o número de processadores no sistema e não sabe as limitações da janela de encaixe?
Acontece que o mecanismo não é nada complicado - é o malloc bem conhecido da glibc. Em resumo, a glibc usa as chamadas arenas para alocar memória. Ao criar, cada segmento recebe uma das arenas. Quando um encadeamento usando glibc deseja alocar uma certa quantidade de memória no heap nativo para suas necessidades e chama malloc, a memória é alocada na arena atribuída a ele. Se a arena atender a vários tópicos, eles competirão por ele. Quanto mais arenas, menos concorrência, mas mais fragmentação, pois cada arena tem sua própria lista de áreas livres.
Em sistemas de 64 bits, o número padrão de arenas é definido como 8 * o número de CPUs. Obviamente, isso representa uma enorme sobrecarga para nós, porque nem todas as CPUs estão disponíveis para o contêiner. Além disso, para aplicativos baseados em Java, a competição por arenas não é tão relevante, pois a maioria das alocações é feita no heap Java, cuja memória pode ser completamente alocada na inicialização.
Esse recurso do malloc é conhecido há
muito tempo , bem como sua solução - para usar a variável de ambiente
MALLOC_ARENA_MAX
para indicar explicitamente o número de arenas. É muito fácil de fazer para qualquer contêiner. Aqui está o efeito de especificar
MALLOC_ARENA_MAX = 4
para o nosso back-end principal:
Existem duas instâncias no gráfico RSS: em uma (azul), ligamos
MALLOC_ARENA_MAX
, na outra (vermelha), apenas reiniciamos. A diferença é óbvia.
Mas depois disso, existe um desejo razoável de descobrir em que Java geralmente gasta memória. É possível executar um microsserviço em Java com um limite de memória de 300 a 400 megabytes e não ter medo de que ele caia do Java-OOM ou não seja morto por um assassino de OOM do sistema?
Processamos Java-OOM
Primeiro de tudo, você precisa se preparar para o fato de que as OOMs são inevitáveis e precisa lidar com elas corretamente - pelo menos salve os quadris. Curiosamente, mesmo este simples empreendimento tem suas próprias nuances. Por exemplo, despejos de quadril não são substituídos - se um despejo de quadril com o mesmo nome já estiver salvo, um novo simplesmente não será criado.
Java pode
adicionar automaticamente o número de série
do dump e a identificação do processo ao nome do arquivo, mas isso não nos ajudará. O número de série não é útil, porque esse é o OOM, e não o despejo de quadril solicitado regularmente - o aplicativo é reiniciado depois dele, redefinindo o contador. E a identificação do processo não é adequada, pois na janela de encaixe é sempre a mesma (geralmente 1).
Portanto, chegamos a esta opção:
-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:HeapDumpPath=/var/crash/java.hprof
-XX:OnOutOfMemoryError="mv /var/crash/java.hprof /var/crash/heapdump.hprof"
É bastante simples e, com algumas melhorias, você pode até ensinar a armazená-lo não apenas no mais recente despejo de quadril, mas, para nossas necessidades, isso é mais que suficiente.
Java OOM não é a única coisa que temos que enfrentar. Cada contêiner tem um limite na memória que ocupa e pode ser excedido. Se isso acontecer, o contêiner será
restart_policy: always
pelo killer do OOM do sistema e reiniciado (usamos
restart_policy: always
). Naturalmente, isso é indesejável e queremos aprender como definir corretamente os limites dos recursos utilizados pela JVM.
Otimizando o consumo de memória
Mas antes de definir limites, é necessário garantir que a JVM não esteja desperdiçando recursos. Já conseguimos reduzir o consumo de memória usando um limite no número de CPUs e na variável
MALLOC_ARENA_MAX
. Existem outras maneiras "quase gratuitas" de fazer isso?
Acontece que existem mais alguns truques que economizarão um pouco de memória.
O primeiro é o uso da opção
-Xss
(ou
-XX:ThreadStackSize
), que controla o tamanho da pilha dos encadeamentos. O padrão para uma JVM de 64 bits é 1 MB. Descobrimos que 512 KB é suficiente para nós. Por esse motivo, um StackOverflowException nunca foi detectado antes, mas admito que isso não é adequado para todos. E o lucro disso é muito pequeno.
O segundo é o
-XX:+UseStringDeduplication
(com o G1 GC ativado). Permite economizar memória recolhendo linhas duplicadas devido à carga adicional do processador. A troca entre a memória e a CPU depende apenas do aplicativo específico e das configurações do próprio mecanismo de deduplicação. Leia o
dock e teste em seus serviços, temos essa opção ainda não encontrou sua aplicação.
E, finalmente, um método que não é adequado para todos (mas nos convém) é usar
jemalloc em vez do malloc nativo. Essa implementação é voltada para reduzir a fragmentação da memória e melhor suporte a multithreading comparado ao malloc da glibc. Para nossos serviços, o jemalloc proporcionou um pouco mais de memória que o malloc com
MALLOC_ARENA_MAX=4
, sem afetar significativamente o desempenho.
Outras opções, incluindo as descritas por Alexei Shipilev no
JVM Anatomy Quark # 12: Native Memory Tracking , pareciam bastante perigosas ou levavam a uma degradação perceptível no desempenho. No entanto, para fins educacionais, recomendo a leitura deste artigo.
Enquanto isso, vamos para o próximo tópico e, finalmente, tente aprender como limitar o consumo de memória e selecionar os limites corretos.
Limitando o consumo de memória: heap, non-heap, memória direta
Para fazer tudo certo, você precisa lembrar em que consiste a memória em geral em Java. Primeiro, vamos olhar para os conjuntos cujo status pode ser monitorado através do JMX.
O primeiro, é claro, é
moderno . É simples:
-Xmx
, mas como fazer isso certo? Infelizmente, não existe uma receita universal aqui, tudo depende da aplicação e do perfil de carga. Para novos serviços, começamos com um tamanho de heap relativamente razoável (128 MB) e, se necessário, aumentamos ou diminuímos. Para dar suporte aos já existentes, há monitoramento com gráficos de consumo de memória e métricas de GC.
Ao mesmo tempo que
-Xmx
, configuramos
-Xms == -Xmx
. Não temos memória excedente; portanto, é do nosso interesse que o serviço use os recursos que fornecemos ao máximo. Além disso, em serviços comuns, incluímos
-XX:+AlwaysPreTouch
e o mecanismo Transparent Huge Pages:
-XX:+UseTransparentHugePages -XX:+UseLargePagesInMetaspace
. No entanto, antes de ativar o THP, leia atentamente a
documentação e teste como os serviços se comportam com essa opção por um longo tempo. Não são descartadas surpresas em máquinas com RAM insuficiente (por exemplo, tivemos que desativar o THP em bancos de teste).
O próximo é
não-heap . A memória não heap inclui:
- Metaspace e espaço de classe compactado,
- Cache de código.
Considere esses conjuntos em ordem.
Claro, todo mundo já ouviu falar sobre o
Metaspace , não vou falar sobre isso em detalhes. Ele armazena metadados de classe, método bytecode e assim por diante. De fato, o uso do Metaspace depende diretamente do número e tamanho das classes carregadas, e você pode determiná-lo, como hip, apenas iniciando o aplicativo e removendo as métricas via JMX. Por padrão, o Metaspace não é limitado por nada, mas é muito fácil fazer isso com a
-XX:MaxMetaspaceSize
.
O espaço de classe compactado faz parte do Metaspace e aparece quando a opção
-XX:+UseCompressedClassPointers
está ativada (ativada por padrão para montes inferiores a 32 GB, ou seja, quando pode proporcionar um ganho real de memória). O tamanho desse pool pode ser limitado pela opção
-XX:CompressedClassSpaceSize
, mas não faz muito sentido, pois o Compressed Class Space está incluído no Metaspace e a quantidade total de memória bloqueada para o Metaspace e o Compressed Class Space é finalmente limitada a uma
-XX:MaxMetaspaceSize
.
A propósito, se você observar as leituras JMX, a quantidade de memória não-heap será sempre calculada como a
soma do Metaspace, do Espaço de Classe Compactado e do Cache de Código. Na verdade, você só precisa resumir o Metaspace e o CodeCache.
Portanto, no não heap, apenas o
Code Cache permaneceu - o repositório de código compilado pelo compilador JIT. Por padrão, seu tamanho máximo é definido como 240 MB e, para serviços pequenos, é várias vezes maior que o necessário. O tamanho do cache de código pode ser definido com a opção
-XX:ReservedCodeCacheSize
. O tamanho correto só pode ser determinado executando o aplicativo e seguindo-o em um perfil de carga típico.
É importante não cometer um erro aqui, uma vez que o Cache de Código insuficiente exclui o código frio e antigo do cache (a opção
-XX:+UseCodeCacheFlushing
ativada por padrão) e isso, por sua vez, pode levar a um maior consumo da CPU e degradação do desempenho . Seria ótimo se você pudesse lançar o OOM quando o cache de código
-XX:+ExitOnFullCodeCache
, pois existe até o
-XX:+ExitOnFullCodeCache
, mas, infelizmente, ele está disponível apenas na
versão de desenvolvimento da JVM.
O último conjunto sobre o qual há informações no JMX é
a memória direta . Por padrão, seu tamanho não é limitado, portanto, é importante definir algum tipo de limite para ele - pelo menos bibliotecas como o Netty, que usam ativamente buffers de byte direto, serão orientadas por ele. Não é difícil definir um limite usando o
-XX:MaxDirectMemorySize
e, novamente, apenas o monitoramento nos ajudará a determinar o valor correto.
Então, o que chegamos até agora?
Memória de processo Java =
Heap + Metaspace + Cache de código + Memória direta =
-Xmx +
-XX: MaxMetaspaceSize
-XX: ReservedCodeCacheSize
-XX: MaxDirectMemorySize
Vamos tentar desenhar tudo no gráfico e compará-lo com o contêiner da janela de encaixe RSS.
A linha acima é o RSS do contêiner e é uma vez e meia mais que o consumo de memória da JVM, que podemos monitorar através da JMX.
Cavando mais!
Limitando o consumo de memória: Rastreamento de Memória Nativa
Obviamente, além da memória heap, não heap e direta, a JVM usa muitos outros conjuntos de memórias. O sinalizador
-XX:NativeMemoryTracking=summary
nos ajudará a
-XX:NativeMemoryTracking=summary
com eles
-XX:NativeMemoryTracking=summary
. Ao ativar esta opção, poderemos obter informações sobre conjuntos conhecidos pela JVM, mas não disponíveis na JMX. Você pode ler mais sobre como usar esta opção na
documentação .
Vamos começar com o mais óbvio - a memória ocupada pelas
pilhas de threads . A NMT produz algo parecido com o seguinte para o nosso serviço:
Encadeamento (reservado = 32166 KB, confirmado = 5358 KB)
(segmento # 52)
(pilha: reservada = 31920 KB, confirmada = 5112 KB)
(malloc = 185 KB # 270)
(arena = 61KB # 102)
A propósito, seu tamanho também pode ser encontrado sem o Native Memory Tracking, usando o jstack e cavando um pouco em
/proc/<pid>/smaps
. Andrey Pangin estabeleceu um
utilitário especial para isso.
O tamanho do
espaço de classe compartilhado é ainda mais fácil de avaliar:
Espaço de classe compartilhado (reservado = 17084KB, confirmado = 17084KB)
(mmap: reservado = 17084 KB, confirmado = 17084 KB)
Este é o mecanismo de compartilhamento de dados de classe,
-Xshare
e
-XX:+UseAppCDS
. No Java 11, a opção
-Xshare
é definida como automática por padrão, o que significa que se você tiver o
$JAVA_HOME/lib/server/classes.jsa
(ele está na imagem oficial do dockJDK), ele carregará o mapa de memória- Ohm na inicialização da JVM, acelerando o tempo de inicialização. Assim, é fácil determinar o tamanho do Espaço de Classe Compartilhado se você souber o tamanho dos arquivos jsa.
A seguir estão as estruturas nativas do
coletor de lixo :
GC (reservado = 42137 KB, confirmado = 41801 KB)
(malloc = 5705 KB # 9460)
(mmap: reservado = 36432 KB, confirmado = 36096 KB)
Alexey Shipilev no manual já mencionado no Native Memory Tracking
diz que eles ocupam cerca de 4-5% do tamanho do heap, mas em nossa configuração para heap pequeno (até várias centenas de megabytes) a sobrecarga atingiu 50% do tamanho do heap.
Muito espaço pode ser ocupado por
tabelas de símbolos :
Símbolo (reservado = 16421KB, confirmado = 16421KB)
(malloc = 15261KB # 203089)
(arena = 1159 KB # 1)
Eles armazenam os nomes dos métodos, assinaturas e links para cadeias estendidas. Infelizmente, parece possível estimar o tamanho da tabela de símbolos somente após factum usando o Native Memory Tracking.
O que resta? De acordo com o Native Memory Tracking, muitas coisas:
Compilador (reservado = 509KB, confirmado = 509KB)
Interno (reservado = 1647 KB, confirmado = 1647 KB)
Outro (reservado = 2110 KB, confirmado = 2110 KB)
Pedaço da arena (reservado = 1712 KB, confirmado = 1712 KB)
Log (reservado = 6 KB, confirmado = 6 KB)
Argumentos (reservados = 19KB, confirmados = 19KB)
Módulo (reservado = 227KB, confirmado = 227KB)
Desconhecido (reservado = 32 KB, confirmado = 32 KB)
Mas tudo isso ocupa bastante espaço.
Infelizmente, muitas das áreas mencionadas da memória não podem ser limitadas nem controladas e, se pudesse, a configuração se tornaria um inferno. Mesmo monitorando seu status não é uma tarefa trivial, uma vez que a inclusão do Native Memory Tracking drena levemente o desempenho do aplicativo e habilitá-lo na produção em um serviço crítico não é uma boa idéia.
No entanto, por uma questão de interesse, vamos tentar refletir no gráfico tudo o que o Native Memory Tracking relata:
Nada mal! A diferença restante é uma sobrecarga para fragmentação / alocação de memória (é bastante pequena, pois usamos jemalloc) ou a memória que as bibliotecas nativas alocaram. Apenas usamos um deles para armazenamento eficiente da árvore de prefixos.
Portanto, para nossas necessidades, basta limitar o que podemos: Heap, Metaspace, Cache de código, Memória direta. Para todo o resto, deixamos algumas bases razoáveis, determinadas pelos resultados de medições práticas.
Tendo lidado com a CPU e a memória, passamos para o próximo recurso pelo qual os aplicativos podem competir - para os discos.
Java e unidades
E com eles, tudo está muito ruim: eles são lentos e podem levar a um embotamento tangível do aplicativo. Portanto, desvinculamos o Java dos discos o máximo possível:
- Escrevemos todos os logs de aplicativos no syslog local via UDP. Isso deixa algumas chances de que os logs necessários sejam perdidos em algum lugar ao longo do caminho, mas, como a prática demonstrou, esses casos são muito raros.
- Escreveremos logs da JVM em tmpfs; para isso, basta montar a janela de encaixe no local desejado com o
/dev/shm
.
Se escrevermos logs no syslog ou no tmpfs, e o próprio aplicativo gravar nada além de despejos de memória no disco, então o histórico de discos pode ser considerado fechado nisso?
Claro que não.
Prestamos atenção ao gráfico da duração das pausas do tipo "pare o mundo" e vemos uma imagem triste - as pausas do tipo "Pare o mundo" nos hosts são centenas de milissegundos e, em um host, elas podem chegar a um segundo:
Escusado será dizer que isso afeta negativamente a aplicação? Aqui, por exemplo, está um gráfico que reflete o tempo de resposta do serviço de acordo com os clientes:
Este é um serviço muito simples, geralmente fornecendo respostas em cache; portanto, de onde são esses intervalos proibitivos, começando com o percentil 95? Outros serviços têm uma imagem semelhante; além disso, os tempos limite estão chovendo com constância invejável ao levar a conexão do pool de conexões ao banco de dados, ao executar solicitações e assim por diante.
O que a unidade tem a ver com isso? - você pergunta. Acontece muito a ver com isso.
Uma análise detalhada do problema mostrou que longas pausas no STW surgem devido ao fato de os encadeamentos permanecerem no ponto seguro por um longo tempo. Após ler o código da JVM, percebemos que durante a sincronização de encadeamentos no ponto seguro, a JVM pode gravar o arquivo
/tmp/hsperfdata*
através do mapa de memória, para o qual exporta algumas estatísticas. Utilitários como
jstat
e
jps
usam
jstat
jps
.
Desative-o na mesma máquina com a opção
-XX:+PerfDisableSharedMem
e ...
As métricas do cais de esteiras estabilizam:
(, ):
, , , .
?
Java- , , , .
Nuts and Bolts , . , . , , JMX.
, . .
statsd JVM, (heap, non-heap ):
, , .
— , , , , ? . () -, , RPS .
: , . . ammo-
. . . :
.
, . , , - , , .
Em conclusão
, Java Docker — , . .