ELK para AWS: gerenciando logs com menos complicações
Publicados: 2022-03-11O Elasticsearch é uma solução de software poderosa projetada para pesquisar informações rapidamente em uma vasta gama de dados. Combinado com Logstash e Kibana, isso forma a informalmente denominada “pilha ELK” e é frequentemente usada para coletar, armazenar temporariamente, analisar e visualizar dados de log. Alguns outros softwares geralmente são necessários, como o Filebeat para enviar os logs do servidor para o Logstash e o Elatalert para gerar alertas com base no resultado de algumas análises executadas nos dados armazenados no Elasticsearch.
A pilha ELK é poderosa, mas…
Minha experiência com o uso do ELK para gerenciar logs é bastante variada. Por um lado, é muito poderoso e o alcance de suas capacidades é bastante impressionante. Por outro lado, é complicado de configurar e pode ser uma dor de cabeça para manter.
O fato é que o Elasticsearch é muito bom em geral e pode ser usado em uma ampla variedade de cenários; pode até ser usado como um motor de busca! Como não é especializado para gerenciar dados de log, isso requer mais trabalho de configuração para personalizar seu comportamento para as necessidades específicas de gerenciamento desses dados.
Configurar o cluster ELK foi bastante complicado e exigiu que eu brincasse com vários parâmetros para finalmente colocá-lo em funcionamento. Depois veio o trabalho de configurá-lo. No meu caso, eu tinha cinco softwares diferentes para configurar (Filebeat, Logstash, Elasticsearch, Kibana e Elatalert). Isso pode ser um trabalho bastante tedioso, pois tive que ler a documentação e depurar um elemento da cadeia que não fala com o próximo. Mesmo depois de finalmente colocar seu cluster em funcionamento, você ainda precisa realizar operações de manutenção de rotina nele: patching, atualização dos pacotes do SO, verificação da CPU, RAM e uso do disco, fazendo pequenos ajustes conforme necessário, etc.
Toda a minha pilha ELK parou de funcionar após uma atualização do Logstash. Após uma análise mais detalhada, descobriu-se que, por algum motivo, os desenvolvedores do ELK decidiram alterar uma palavra-chave em seu arquivo de configuração e pluralizá-la. Essa foi a gota d'água e decidi procurar uma solução melhor (pelo menos uma solução melhor para minhas necessidades específicas).
Eu queria armazenar logs gerados pelo Apache e vários aplicativos PHP e de nó e analisá-los para encontrar padrões indicativos de bugs no software. A solução que encontrei foi a seguinte:
- Instale o Agente do CloudWatch no destino.
- Configure o Agente do CloudWatch para enviar os logs aos logs do CloudWatch.
- Acione a invocação de funções do Lambda para processar os logs.
- A função do Lambda postaria mensagens em um canal do Slack se um padrão fosse encontrado.
- Sempre que possível, aplique um filtro aos grupos de logs do CloudWatch para evitar chamar a função Lambda para cada log (o que pode aumentar os custos muito rapidamente).
E, em alto nível, é isso! Uma solução 100% sem servidor que funcionará bem sem qualquer necessidade de manutenção e que escalaria bem sem nenhum esforço adicional. As vantagens de tais soluções sem servidor sobre um cluster de servidores são inúmeras:
- Em essência, todas as operações de manutenção de rotina que você realizaria periodicamente em seus servidores de cluster agora são de responsabilidade do provedor de nuvem. Qualquer servidor subjacente será corrigido, atualizado e mantido para você sem que você saiba.
- Você não precisa mais monitorar seu cluster e delega todos os problemas de dimensionamento ao provedor de nuvem. De fato, uma configuração sem servidor como a descrita acima será dimensionada automaticamente sem que você precise fazer nada!
- A solução descrita acima requer menos configuração e é muito improvável que uma alteração importante seja trazida para os formatos de configuração pelo provedor de nuvem.
- Por fim, é muito fácil escrever alguns modelos do CloudFormation para colocar tudo isso como infraestrutura como código. Fazer o mesmo para configurar um cluster ELK inteiro exigiria muito trabalho.
Como configurar alertas do Slack
Então agora vamos aos detalhes! Vamos explorar como seria um modelo do CloudFormation para essa configuração, completo com webhooks do Slack para alertar engenheiros. Precisamos configurar toda a configuração do Slack primeiro, então vamos mergulhar nele.
AWSTemplateFormatVersion: 2010-09-09 Description: Setup log processing Parameters: SlackWebhookHost: Type: String Description: Host name for Slack web hooks Default: hooks.slack.com SlackWebhookPath: Type: String Description: Path part of the Slack webhook URL Default: /services/YOUR/SLACK/WEBHOOK
Você precisaria configurar seu espaço de trabalho do Slack para isso, confira este guia WebHooks for Slack para obter informações adicionais.
Depois de criar seu aplicativo Slack e configurar um gancho de entrada, o URL do gancho se tornará um parâmetro da sua pilha do CloudFormation.
Resources: ApacheAccessLogGroup: Type: AWS::Logs::LogGroup Properties: RetentionInDays: 100 # Or whatever is good for you ApacheErrorLogGroup: Type: AWS::Logs::LogGroup Properties: RetentionInDays: 100 # Or whatever is good for you
Aqui criamos dois grupos de logs: um para os logs de acesso do Apache e outro para os logs de erros do Apache .
Não configurei nenhum mecanismo de ciclo de vida para os dados de log porque está fora do escopo deste artigo. Na prática, você provavelmente gostaria de ter uma janela de retenção reduzida e projetar políticas de ciclo de vida do S3 para movê-las para o Glacier após um determinado período de tempo.
Função Lambda para processar logs de acesso
Agora vamos implementar a função Lambda que processará os logs de acesso do Apache.
BasicLambdaExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
Aqui criamos uma função do IAM que será anexada às funções do Lambda, para permitir que elas desempenhem suas funções. Com efeito, o AWSLambdaBasicExecutionRole
é (apesar do nome) uma política do IAM fornecida pela AWS. Ele apenas permite que a função do Lambda crie um grupo de logs e fluxos de log dentro desse grupo e, em seguida, envie seus próprios logs para o CloudWatch Logs.
ProcessApacheAccessLogFunction: Type: AWS::Lambda::Function Properties: Handler: index.handler Role: !GetAtt BasicLambdaExecutionRole.Arn Runtime: python3.7 Timeout: 10 Environment: Variables: SLACK_WEBHOOK_HOST: !Ref SlackWebHookHost SLACK_WEBHOOK_PATH: !Ref SlackWebHookPath Code: ZipFile: | import base64 import gzip import json import os from http.client import HTTPSConnection def handler(event, context): tmp = event['awslogs']['data'] # `awslogs.data` is base64-encoded gzip'ed JSON tmp = base64.b64decode(tmp) tmp = gzip.decompress(tmp) tmp = json.loads(tmp) events = tmp['logEvents'] for event in events: raw_log = event['message'] log = json.loads(raw_log) if log['status'][0] == "5": # This is a 5XX status code print(f"Received an Apache access log with a 5XX status code: {raw_log}") slack_host = os.getenv('SLACK_WEBHOOK_HOST') slack_path = os.getenv('SLACK_WEBHOOK_PATH') print(f"Sending Slack post to: host={slack_host}, path={slack_path}, url={url}, content={raw_log}") cnx = HTTPSConnection(slack_host, timeout=5) cnx.request("POST", slack_path, json.dumps({'text': raw_log})) # It's important to read the response; if the cnx is closed too quickly, Slack might not post the msg resp = cnx.getresponse() resp_content = resp.read() resp_code = resp.status assert resp_code == 200
Então, aqui estamos definindo uma função do Lambda para processar os logs de acesso do Apache. Observe que não estou usando o formato de log comum que é o padrão no Apache. Configurei o formato de log de acesso assim (e você notará que ele essencialmente gera logs formatados como JSON, o que facilita muito o processamento mais adiante):
LogFormat "{\"vhost\": \"%v:%p\", \"client\": \"%a\", \"user\": \"%u\", \"timestamp\": \"%{%Y-%m-%dT%H:%M:%S}t\", \"request\": \"%r\", \"status\": \"%>s\", \"size\": \"%O\", \"referer\": \"%{Referer}i\", \"useragent\": \"%{User-Agent}i\"}" json
Essa função do Lambda é escrita em Python 3. Ela usa a linha de log enviada do CloudWatch e pode pesquisar padrões. No exemplo acima, ele apenas detecta solicitações HTTP que resultaram em um código de status 5XX e publica uma mensagem em um canal do Slack.

Você pode fazer o que quiser em termos de detecção de padrões e o fato de ser uma verdadeira linguagem de programação (Python), em vez de apenas padrões regex em um arquivo de configuração do Logstash ou Elatalert, oferece muitas oportunidades para implementar o reconhecimento de padrões complexos .
Controle de Revisão
Uma palavra rápida sobre controle de revisão: descobri que ter o código embutido nos modelos do CloudFormation para pequenas funções utilitárias do Lambda como esta é bastante aceitável e conveniente. É claro que, para um projeto grande envolvendo muitas funções e camadas do Lambda, isso provavelmente seria inconveniente e você precisaria usar o SAM.
ApacheAccessLogFunctionPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref ProcessApacheAccessLogFunction Action: lambda:InvokeFunction Principal: logs.amazonaws.com SourceArn: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:*
O acima dá permissão ao CloudWatch Logs para chamar sua função Lambda. Uma palavra de cautela: descobri que usar a propriedade SourceAccount
pode levar a conflitos com o SourceArn
.
De um modo geral, sugiro não incluí-lo quando o serviço que está chamando a função Lambda estiver na mesma conta da AWS. O SourceArn
proibirá outras contas de chamar a função Lambda de qualquer maneira.
ApacheAccessLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheAccessLogFunctionPermission Properties: LogGroupName: !Ref ApacheAccessLogGroup DestinationArn: !GetAtt ProcessApacheAccessLogFunction.Arn FilterPattern: "{$.status = 5*}"
O recurso de filtro de assinatura é o link entre o CloudWatch Logs e o Lambda. Aqui, os logs enviados para o ApacheAccessLogGroup
serão encaminhados para a função Lambda que definimos acima, mas apenas os logs que passarem pelo padrão de filtro. Aqui, o padrão de filtro está esperando algum JSON como entrada (os padrões de filtro começam com '{' e terminam com '}') e corresponderão à entrada de log apenas se tiver um status
de campo que comece com “5”.
Isso significa que chamamos a função Lambda apenas quando o código de status HTTP retornado pelo Apache é um código 500, o que geralmente significa que algo muito ruim está acontecendo. Isso garante que não chamemos demais a função Lambda e, assim, evitemos custos desnecessários.
Mais informações sobre padrões de filtro podem ser encontradas na documentação do Amazon CloudWatch. Os padrões de filtro do CloudWatch são muito bons, embora obviamente não tão poderosos quanto o Grok.
Observe o campo DependsOn
, que garante que o CloudWatch Logs possa realmente chamar a função do Lambda antes que a assinatura seja criada. Isso é apenas a cereja do bolo, provavelmente desnecessário, pois em um cenário de caso real, o Apache provavelmente não receberia solicitações antes de pelo menos alguns segundos (por exemplo: vincular a instância do EC2 a um balanceador de carga e obter a carga balanceador para reconhecer o status da instância do EC2 como íntegra).
Função Lambda para processar logs de erros
Agora vamos dar uma olhada na função do Lambda que processará os logs de erro do Apache.
ProcessApacheErrorLogFunction: Type: AWS::Lambda::Function Properties: Handler: index.handler Role: !GetAtt BasicLambdaExecutionRole.Arn Runtime: python3.7 Timeout: 10 Environment: Variables: SLACK_WEBHOOK_HOST: !Ref SlackWebHookHost SLACK_WEBHOOK_PATH: !Ref SlackWebHookPath Code: ZipFile: | import base64 import gzip import json import os from http.client import HTTPSConnection def handler(event, context): tmp = event['awslogs']['data'] # `awslogs.data` is base64-encoded gzip'ed JSON tmp = base64.b64decode(tmp) tmp = gzip.decompress(tmp) tmp = json.loads(tmp) events = tmp['logEvents'] for event in events: raw_log = event['message'] log = json.loads(raw_log) if log['level'] in ["error", "crit", "alert", "emerg"]: # This is a serious error message msg = log['msg'] if msg.startswith("PHP Notice") or msg.startswith("PHP Warning"): print(f"Ignoring PHP notices and warnings: {raw_log}") else: print(f"Received a serious Apache error log: {raw_log}") slack_host = os.getenv('SLACK_WEBHOOK_HOST') slack_path = os.getenv('SLACK_WEBHOOK_PATH') print(f"Sending Slack post to: host={slack_host}, path={slack_path}, url={url}, content={raw_log}") cnx = HTTPSConnection(slack_host, timeout=5) cnx.request("POST", slack_path, json.dumps({'text': raw_log})) # It's important to read the response; if the cnx is closed too quickly, Slack might not post the msg resp = cnx.getresponse() resp_content = resp.read() resp_code = resp.status assert resp_code == 200
Essa segunda função do Lambda processa os logs de erros do Apache e postará uma mensagem no Slack somente quando um erro grave for encontrado. Nesse caso, as mensagens de aviso e aviso do PHP não são consideradas sérias o suficiente para acionar um alerta.
Novamente, essa função espera que o log de erros do Apache seja formatado em JSON. Então, aqui está a string de formato de log de erros que tenho usado:
ErrorLogFormat "{\"vhost\": \"%v\", \"timestamp\": \"%{cu}t\", \"module\": \"%-m\", \"level\": \"%l\", \"pid\": \"%-P\", \"tid\": \"%-T\", \"oserror\": \"%-E\", \"client\": \"%-a\", \"msg\": \"%M\"}"
ApacheErrorLogFunctionPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref ProcessApacheErrorLogFunction Action: lambda:InvokeFunction Principal: logs.amazonaws.com SourceArn: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:* SourceAccount: !Ref AWS::AccountId
Esse recurso concede permissões ao CloudWatch Logs para chamar sua função do Lambda.
ApacheErrorLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheErrorLogFunctionPermission Properties: LogGroupName: !Ref ApacheErrorLogGroup DestinationArn: !GetAtt ProcessApacheErrorLogFunction.Arn FilterPattern: '{$.msg != "PHP Warning*" && $.msg != "PHP Notice*"}'
Por fim, vinculamos o CloudWatch Logs à função Lambda usando um filtro de assinatura para o grupo de logs de erros do Apache. Observe o padrão de filtro, que garante que os logs com uma mensagem começando com “PHP Warning” ou “PHP Notice” não acionem uma chamada para a função Lambda.
Considerações finais, preços e disponibilidade
Uma última palavra sobre custos: esta solução é muito mais barata do que operar um cluster ELK. Os logs armazenados no CloudWatch têm o mesmo preço do S3, e o Lambda permite um milhão de chamadas por mês como parte de seu nível gratuito. Isso provavelmente seria suficiente para um site com tráfego moderado a intenso (desde que você tenha usado os filtros do CloudWatch Logs), especialmente se você o codificou bem e não tem muitos erros!
Além disso, observe que as funções do Lambda suportam até 1.000 chamadas simultâneas. No momento da redação deste artigo, esse é um limite rígido na AWS que não pode ser alterado. No entanto, você pode esperar que a chamada para as funções acima dure cerca de 30-40ms. Isso deve ser rápido o suficiente para lidar com tráfego bastante pesado. Se sua carga de trabalho for tão intensa que você atingiu esse limite, provavelmente precisará de uma solução mais complexa baseada no Kinesis, que posso abordar em um artigo futuro.
Leitura adicional no Blog da Toptal Engineering:
- Registro SSH e gerenciamento de sessões usando o AWS SSM