livenessProbe e deadlocks em APIs de Machine Learning no Kubernetes

livenessProbe 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:

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

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.