livenessProbe e deadlocks em APIs de Machine Learning no Kubernetes
2022 Jan 01livenessProbe na prevenção de deadlocks em APIs de Machine Learning no Kubernetes
Semanas atrás durante a investigação de uma indisponibilidade em uma das nossas APIs de Machine Learning responsável por servir um modelo de classificação de textos para outras aplicações (i.e. model servicing), reparamos que após os contêineres estarem parados, a destruição e a reconstrução dos mesmos não estava acontecendo.
Com isso a API ficou em estado de indisponibilidade total e terminal, gerando uma tempestade de alertas, e mensagens de outras aplicações clientes que dependem desse serviço.
O erro de aplicação em si junto com a indisponibilidade nem foram os maiores problemas, mas sim o fato da reconstrução dos contêineres não ter acontecido de forma automática dentro do Kubernetes.
Isso ainda fica mais estranho dado o fato de que a empresa na qual eu trabalho tem um CLI específica com inúmeros templates para o Kubernetes justamente para simplificar o desenvolvimento nesse tipo de stack e evitar esse tipo de cenário aconteça com qualquer aplicação.
Sabendo que este estado de deadlock e/ou falha terminal sem reinicialização do pod poderia ser um problema de configuração (isso vai ficar mais claro na próxima seção), resolvemos investigar o que aconteceu e como dar uma solução permanente para este problema.
Mas primeiro tínhamos que saber o que causou esse “travamento” da nossa API que causou a falha do contêiner.
Gatilho e o real problema
O fator determinante que desencadeou a queda da API foi causado por uma aplicação cliente gerenciada por um outro time de desenvolvimento, que por um erro de configuração iniciou o disparo uma espécie de “teste de stress involuntário” em nosso serviço.
Isso fez com que essa aplicação cliente enviasse um volume de requisições que ultrapassaram mais de 1000% a nossa atual capacidade; o que levou primeiramente a um aumento da latência nas requisições dos pods, e posteriormente levou os contêineres a quebrarem.
De certa forma sempre esperamos que aconteçam alguns (poucos) erros de aplicação por inúmeros motivos, mas o que não esperávamos é que não houvesse a remediação através da destruição e substituição dos contêineres de forma preemptiva como esperamos com o Kubernetes, e foi aí que o nosso real problema.
Durante o troubleshooting um membro do time percebeu que a nossa aplicação
não tinha o livenessProbe
configurado. Ou seja, por conta falta deste parâmetro
no template de configuração de produção, não estávamos fazendo o uso desse mecanismo do Kubernetes
para o monitoramento da saúde da aplicação (service health).
O que é o livenessProbe
?
Essencialmente o livenessProbe
faz uma “prova de vida” em um contêiner de modo a saber
quando ele precisa realizar uma reinicialização.
Como a documentação coloca
“[…] o livenessProbe
pode ajudar a capturar uma situação de deadlock de uma aplicação
em execução […] e que reiniciar um contêiner em tal estado pode ajudar a aumentar a
disponibilidade da aplicação, apesar de bugs”.
Sem esta configuração, se o contêiner ficar em um estado de indisponibilidade por conta da aplicação (i.e. bugs), o K8s não vai tentar trazer o contêiner para um estado ativo (i.e. live) novamente via restart do contêiner. Em outras palavras a aplicação pode ficar em um estado de falha terminal ou em modo zumbi.
Conserto
A configuração em si é tranquila de se fazer no Kubernetes. No nosso caso usamos os seguintes valores:
Nas especificações da aplicação parametrizamos o livenessProbe
como mecanismo para
realizar as “provas de vida” do contêiner; e também usamos o readinessProbe
que em
tradução livre seria algo como “prova de prontidão”.[2]
A documentação
define o readinessProbe
como um mecanismo de verificação de prontidão de contêineres em
situações em que alguma aplicação esteja (i) temporariamente impossibilitada de receber
tráfego por inúmeros motivos (ii) e que ao mesmo tempo não desejamos que o
contêiner seja destruído de forma desnecessária.
Em outras palavras, são situações em que o contêiner está ativo mas não pronto, e que por conta disso não queremos que seja destruído por conta de outros fatores (e.g. uma situação externa).
Em APIs de Machine Learning em que um ou mais modelos são servidos, em determinadas situações não é recomendável o recebimento de tráfego ou a destruição do contêiner por conta de inúmeros fatores como:
-
Carga de modelos em memória tomando mais tempo do que o necessário. Por exemplo, dependendo do tamanho do corpora o FastText pode gerar modelos de até 3Gb;
-
Demora no download de pesos e/ou modelos que por ventura possam estar em outro repositório (ex: S3, model registry, ou modelos como o VGG que podem consumir quase 500+ Mb);
-
Indisponibilidade na Feature Store (e.g. se parte dos dados em tempo real depende de um nó do Cassandra que está indisponível por algum motivo isso pode causar uma latência adicional); e
-
Dependência de aplicações dentro do mesmo nó do K8s que estejam indisponíveis por conta de um
CrashLoopBackOff
por conta de um erro de setup depois do deployment.
A ideia aqui é ilustrar que apesar de serem um pouco mais simples do que aplicações padrão de engenharia de software, essas APIs de Machine Learning podem ter uma infinidade de motivos que essas APIs estão ativas, mas não prontas.
Para um entendimento e aprofundamento sobre estes parâmetros de configuração, eu sugiro a leitura dos links que estão nas referências ao final desse post.
Valores de configuração e outras considerações…
Longe de estabelecer qualquer tipo de “melhores práticas” dado que cada ML-API é única e tem uma série de especificidades e contexto, eu queria compartilhar alguns dos motivos para a escolha de parte das nossas configurações apenas como referência, e mostrar o que funcionou ou não no nosso caso:
Remoção do uso de tcpSocket
em porta estabelecida no livenessProbe
e readinessProbe
Eu particularmente uso o tcpSocket
apenas em casos que eu preciso de garantia de
execução na casa dos milissegundos. A vantagem é que este é um teste barato do ponto de vista de performance, de
implementação mais simples, e que não tem nenhum tipo de acoplamento com um endpoint
específico dado que checagem é somente no socket aberto dependendo apenas da disponibilidade uma porta.
Mesmo com essas vantagens eu uso o tcpSocket
apenas em casos que os endpoints
de health-check e afins ainda não estão estabelecidos em suas versões finais.
Digo isso porque apesar da simplicidade essa forma de sondagem informa apenas que a aplicação está com o socket disponível e responsivo, mas não se um endpoint específico está retornando o que deveria.
Eu admito que essa é uma forma ruim (anti-padrão) de acoplar comportamento de aplicação em uma prova que deveria ser somente de ambiente. Contudo, na maioria das vezes eu estou muito mais interessado na tríade {metal + ambiente + aplicação}, do que apenas nos dois primeiros.
readinessProbe
com requisição HTTP (HTTP request)
Essa é a configuração que eu gosto de usar para a maioria das aplicações padrão de serviços de Machine Learning, e ao menos ao meu ver trás um resultado que pode não ser o mais performático do ponto de vista de latência, ao menos trás algo muito mais informativo do ponto de vista de saúde da aplicação.
Eu sempre uso essa configuração juntamente com um endpoint de health-check
(eu gosto de manter o nome sempre como /health)
. Isso vai de cada
implementação e necessidade, mas ao menos nos casos em que eu tive a oportunidade de
trabalhar funcionou bem, pois, eu tenho sempre o mesmo lugar para checar em todas as
APIs (o que reduz muito a minha carga cognitiva por conta da padronização) e além do
mais, eu posso condicionar a uma resposta que simule similar a um fluxo de aplicação
que emule o comportamento da aplicação, desde que não tenha nenhum tipo de dependência
externa [3].
initialDelaySeconds
permissivo em caso de estratégia de rolling deployments
Dentro das mais diversas estratégias de deployment como Canary Deployment, Blue-Green, Feature Flags, entre outras, para as aplicações as quais estou trabalhando no momento, estou satisfeito com a estratégia de rolling deployment [1].
Essa estratégia de deployment apesar do feedback rápido com a produção tem um risco grande dado que um deployment ruim pode colocar toda a aplicação em situação de indisponibilidade.
No nosso caso mesmo com um rolling deployment (e rollback) extremamente rápido, de maneira geral
eu sou um pouco mais permissivo nos valores do initialDelaySeconds
no
livenessProbe
e também no readinessProbe
.
Isto devido ao fato de que se um dos dois entrar antes da aplicação estar ativa ou pronta um erro vai acontecer, e ambos vão reinicializar o contâiner (de produção).
Isso pode levar a uma potencial situação pós-deployment em que o contâiner anterior está sendo destruído (i.e. o produção atual e funcional) mas ao mesmo mesmo tempo o novo contâiner (i.e. código novo) está sofrendo um restart antecipado tudo isso, isto é, a aplicação potencialmente pode ficar travada em uma situação de indisponibilidade total.
Um dos mecanismos para evitar esse tipo de situação é a utilização da estratégia na
configuração via RollingUpdate
(Docs) ,
algo mais ou menos com a seguinte configuração:
A documentação oficial é bem interessante nesse aspecto e vale a pena a leitura para uma completa compreensão.
readinessProbe
> livenessProbe
Eu não sei se isso é uma boa prática geral do K8s ou não, mas empiricamente o que eu
vi é que a configuração ideal é que o readinessProbe
tenha o valor do
initialDelaySeconds
de no mínimo igual ou maior do que o do livenessProbe
.
Isso porque em caso de qualquer tipo de indisponibilidade de tráfego ou
dependência de outros serviços, carga de um modelo na API em que existe esse tempo
de buffer, se o readinessProbe
entrar antes do livenessProbe
pode acontecer um erro.
Em outras palavras a aplicação pode estar ativa mas não pronta para receber requisições,
por conta dos motivos já colocados anteriormente em que falamos sobre o readinessProbe
.
Se tem readinessProbe
eu coloco o livenessProbe
Em algumas aplicações por algum movito específico durante uma prova de prontidão via
readinessProbe
em que o timeout tinha que ser alto (e.g. download de um modelo do
S3 para a API, carga de pesos de um modelo pré-treinado em memória, acessar alguma
informação de outra API externa, etc.) mas que a prova de vida via livenessProbe
não estava configurada, acontecia a situação de o contâiner já ter sido destruído ou estar
indisponível e não acontecer o restart (e a falha terminal permanecer) e por conta
do longo tempo de timeout isso mascarava uma indisponibilidade.
Uma das formas de evitar isso foi usar o livenessProbe
para checar se o container estava
vivo primeiro e somente depois do container estar pronto aí sim deixar o readinessProbe
entrar e
analisar a prontidão. Esse inclusive é um dos conselhos desse blogpost
chamado Kubernetes Liveness and Readiness Probes: How to Avoid Shooting Yourself in the Foot
em que o autor usa essa mesma heurística para evitar que os pods sejam destruídos de maneira
desnecessária.
Evite o uso de aplicações externas ou inclusão de lógicas mais complexas no readinessProbe
Tanto o livenessProbe
e readinessProbe
são configurações que se a sua ausência pode
causar os problemas que eu coloquei anteriormente, o uso incorreto juntamente com
dependências externas pode causar uma miríade de erros.
Um exemplo clássico que como dependências externas podem ser danosas nesse tipo de configuração
foi colocado pelo Henning Jacobs em seu post chamado
Kubernetes livenessProbes are dangerous,
em que se a uma aplicação que utiliza um banco de dados que está enfrentando uma latência
maior do que o initialDelaySeconds
, isso pode provocar a entrada do readinessProbe
que
vai dar restart em todos os pods que têm essa dependência desencadeando
falhas em cascata em outros sistemas que dependem desse pod.
Um outro anti-exemplo [4] seria uma aplicação que tem como readinessProbe
uma checagem de
integridade do modelo em produção via MD5 por uma questão de compliance.
O maior problema de utilização do dependências externas no readinessProbe
é que por
conta da conveniência e facilidade, muita gente pode optar por incluir lógica de aplicação
como forma de estabelecer um mecanismo de monitoramento de saúde de serviço
, e muitas das vezes esquecemos que esse processo não roda somente na inicialização do pod, mas
em todo o seu ciclo de vida; isto é, essa checagem vai ocorrer enquanto o pod estiver
em pé dentro do intervalo especificado. O fator complicador nisso é que
toda a reinicialização de um pod de uma aplicação em produção fica a
mercê de aplicações que não são gerenciadas e estão sob todas as intempéries dos Deuses da
computação e da Lei de Murphy.
Na dúvida não coloque nenhum serviço externo no readinessProbe
, se for indispensável
tenha a certeza que no código existem formas de remediar o problema com o restart
como, por exemplo, circuit breakers, tratamento de erro, etc.
Considerações Finais
Por mais que o problema foi disparado por um fator externo e a causa raiz foi um problema de configuração por conta de ausência de padronização dessas configurações, foi uma # jornada interessante para descobrir o problema, e de quebra aprendi bastante durante esse troubleshooting. Como direção futura eu já coloquei essas configurações no nosso padrão de “chassis de serviços de ML” que é uma ideia totalmente copiada do Chris Richardson em seu “Pattern: Microservice chassis” e que vai virar um post para uma outra hora.
Referências
-
Kubernetes Liveness and Readiness Probes: How to Avoid Shooting Yourself in the Foot
-
Eliminate DB dependency from liveness & readiness endpoints in Kuma
Notas
[1] De forma simples um rolling deployment é uma substituição lenta e gradual de uma aplicação; no meu caso de cada um dos pods que serão parados, destruídos e substituídos por novos pods com a nova versão do código de produção. Mesmo com as desvantagens de lentidão para deployments pequenos (porque a aplicação toda tem que ser feita o deployment, mesmo se alterar um pedaço pequeno de código) e obviamente aumentando o risco; eu levo em consideração os testes que sempre fazemos e forma simplificada que temos em nossos processos de rollback em caso de algum problema.
No nosso caso específico, ter o feedback da produção o mais rápido possível e simplicidade de rollback e implementação estamos satisfeitos com a escolha. E como basicamente implementamos somente artefatos em memória dentro do endpoint não temos toda a complicação de outras aplicações com estruturas de dependência em outras classes ou componentes como engenharia de software tradicional.
Eu dei essa introdução sobre a nossa estratégia de deployment devido ao fato de que
como estamos em rolling deployment o initialDelaySeconds
afeta o tempo no qual a
aplicação vai estar disponível. Exemplo: Se eu sei que o tempo de um rolling
deployment vai ser baixo, eu posso manter em 10 segundos, isso é, a prova de vida
vai ser feita apenas depois de 10 segundos. Se eu sei que o meu deployment vai custar
mais tempo (e.g. por download de um artefato do S3, validação do SHA do artefato no
S3 de acordo com a versão da API, etc) eu deixo esse valor mais alto.
[2] A tradução literal seria algo como “sonda de vida” para livenessProbe
e
“sondas de prontidão” para readinessProbe
. Mantive a palavra “prova” apenas para simplificação
da linguagem, dado que geralmente usamos “sonda” em português muito mais em contextos
médicos ou em algumas indústrias específicas como a petrolífera e espacial.
[3] Novamente não é uma prática recomendada do ponto de vista de design de APIs mas ao menos em nossos casos de uso não basta a API retornar “algo” mas sim retornar este algo “em funcionamento”. Uma analogia seria como um médico que quer verificar uma função respiratória e ao invés de perguntar para o paciente se ele está respirando, ele usa o estetoscópio e coloca no peito do paciente e verifica através do som recebido do aparelho.
[4] Em casos que a API de Machine Learning que vai fazer o servicing tiver o seu
model registry desacoplado de gerenciador de experimentos/treinamento como o AWS SageMaker,
Algoritmia, MLFlow, e afins; idealmente no momento em que o modelo de produção é
persistido no registro extraí-se o MD5 do artefato e esse hash fica persistido na
aplicação (geralmente em um setup.py
) para referência.
Esse valor pode usar usado em
testes unitários em uma esteira de CI/CD para checagem de integridade como também
para readinessProbe
para saber se o artefato que está no registro é o mesmo que está
sendo servido na API.
Uma proposta de como fazer essa verificação pode ser algo mais ou menos como o abaixo:
Em um arquivo com o nome de check_integrity.py
No caso o readinessProbe
seria algo como:
É mais do que não recomendado a utilização de checagens externas no readinessProbe
por conta de todos os problemas já descritos anteriormente.