ELK to AWS: gestione dei log con meno problemi

Pubblicato: 2022-03-11

Elasticsearch è una potente soluzione software progettata per cercare rapidamente informazioni in una vasta gamma di dati. Combinato con Logstash e Kibana, questo forma lo "stack ELK" chiamato in modo informale e viene spesso utilizzato per raccogliere, archiviare temporaneamente, analizzare e visualizzare i dati di registro. Di solito sono necessari alcuni altri software, come Filebeat per inviare i log dal server a Logstash ed Elastalert per generare avvisi basati sul risultato di alcune analisi eseguite sui dati archiviati in Elasticsearch.

Lo stack ELK è potente, ma...

La mia esperienza con l'utilizzo di ELK per la gestione dei registri è piuttosto mista. Da un lato, è molto potente e la gamma delle sue capacità è piuttosto impressionante. D'altra parte, è difficile da configurare e può essere un mal di testa da mantenere.

Il fatto è che Elasticsearch è molto buono in generale e può essere utilizzato in un'ampia varietà di scenari; può anche essere usato come motore di ricerca! Poiché non è specializzato per la gestione dei dati di registro, ciò richiede più lavoro di configurazione per personalizzarne il comportamento per le esigenze specifiche di gestione di tali dati.

La configurazione del cluster ELK è stata piuttosto complicata e mi ha richiesto di giocare con una serie di parametri per renderlo finalmente operativo. Poi è arrivato il lavoro di configurarlo. Nel mio caso, avevo cinque diversi software da configurare (Filebeat, Logstash, Elasticsearch, Kibana ed Elastalert). Questo può essere un lavoro piuttosto noioso, poiché ho dovuto leggere la documentazione ed eseguire il debug di un elemento della catena che non comunica con il successivo. Anche dopo aver finalmente messo in funzione il cluster, è comunque necessario eseguire operazioni di manutenzione ordinaria su di esso: patch, aggiornamento dei pacchetti del sistema operativo, controllo dell'utilizzo di CPU, RAM e disco, piccoli aggiustamenti secondo necessità, ecc.

Il mio intero stack ELK ha smesso di funzionare dopo un aggiornamento di Logstash. Dopo un esame più attento, si è scoperto che, per qualche ragione, gli sviluppatori di ELK hanno deciso di cambiare una parola chiave nel loro file di configurazione e di pluralizzarla. Quella è stata l'ultima goccia e ho deciso di cercare una soluzione migliore (almeno una soluzione migliore per le mie esigenze particolari).

Volevo archiviare i registri generati da Apache e varie app PHP e nodi e analizzarli per trovare schemi indicativi di bug nel software. La soluzione che ho trovato è stata la seguente:

  • Installa CloudWatch Agent sulla destinazione.
  • Configura CloudWatch Agent per spedire i log ai log di CloudWatch.
  • Attiva il richiamo delle funzioni Lambda per elaborare i log.
  • La funzione Lambda invierà messaggi a un canale Slack se viene trovato un pattern.
  • Ove possibile, applica un filtro ai gruppi di log di CloudWatch per evitare di chiamare la funzione Lambda per ogni singolo log (che potrebbe aumentare i costi molto rapidamente).

E, ad alto livello, il gioco è fatto! Una soluzione serverless al 100% che funzionerà bene senza bisogno di manutenzione e che si adatterebbe bene senza alcuno sforzo aggiuntivo. I vantaggi di tali soluzioni serverless su un cluster di server sono numerosi:

  • In sostanza, tutte le operazioni di manutenzione ordinaria che eseguiresti periodicamente sui server del cluster sono ora responsabilità del provider di servizi cloud. Qualsiasi server sottostante verrà aggiornato, aggiornato e mantenuto per te senza che tu te ne accorga.
  • Non è più necessario monitorare il tuo cluster e deleghi tutti i problemi di ridimensionamento al provider cloud. In effetti, una configurazione serverless come quella sopra descritta si ridimensionerà automaticamente senza che tu debba fare nulla!
  • La soluzione sopra descritta richiede una configurazione inferiore ed è molto improbabile che venga apportata una modifica sostanziale ai formati di configurazione dal provider di servizi cloud.
  • Infine, è abbastanza facile scrivere alcuni modelli di CloudFormation per inserire tutto ciò come infrastruttura come codice. Fare lo stesso per configurare un intero cluster ELK richiederebbe molto lavoro.

Configurazione degli avvisi di rallentamento

Quindi ora entriamo nei dettagli! Esaminiamo come sarebbe un modello CloudFormation per una tale configurazione, completo di webhook Slack per allertare gli ingegneri. Dobbiamo prima configurare tutte le impostazioni di Slack, quindi tuffiamoci dentro.

 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

Dovresti configurare il tuo spazio di lavoro Slack per questo, dai un'occhiata a questa guida WebHooks for Slack per ulteriori informazioni.

Dopo aver creato la tua app Slack e configurato un hook in entrata, l'URL hook diventerà un parametro del tuo stack 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

Qui abbiamo creato due gruppi di log: uno per i log di accesso di Apache , l'altro per i log di errore di Apache .

Non ho configurato alcun meccanismo del ciclo di vita per i dati di registro perché non rientra nell'ambito di questo articolo. In pratica, probabilmente vorrai avere una finestra di conservazione ridotta e progettare policy del ciclo di vita S3 per spostarle su Glacier dopo un certo periodo di tempo.

Funzione Lambda per elaborare i log di accesso

Ora implementiamo la funzione Lambda che elaborerà i log di accesso di 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

Qui abbiamo creato un ruolo IAM che sarà collegato alle funzioni Lambda, per consentire loro di svolgere i propri compiti. In effetti, AWSLambdaBasicExecutionRole è (nonostante il nome) una policy IAM fornita da AWS. Consente semplicemente alla funzione Lambda di creare un gruppo di log e flussi di log all'interno di tale gruppo, quindi di inviare i propri log a 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

Quindi qui stiamo definendo una funzione Lambda per elaborare i log di accesso di Apache. Si prega di notare che non sto usando il formato di registro comune che è l'impostazione predefinita su Apache. Ho configurato il formato del registro di accesso in questo modo (e noterai che essenzialmente genera registri formattati come JSON, il che rende molto più semplice l'elaborazione più avanti):

 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

Questa funzione Lambda è scritta in Python 3. Prende la riga di log inviata da CloudWatch e può cercare pattern. Nell'esempio sopra, rileva semplicemente le richieste HTTP che hanno prodotto un codice di stato 5XX e invia un messaggio a un canale Slack.

Puoi fare tutto ciò che vuoi in termini di rilevamento dei pattern e il fatto che sia un vero linguaggio di programmazione (Python), al contrario dei soli pattern regex in un file di configurazione Logstash o Elastalert, ti dà molte opportunità per implementare il riconoscimento di pattern complessi .

Controllo di revisione

Una breve parola sul controllo delle revisioni: ho scoperto che avere il codice inline nei modelli CloudFormation per funzioni Lambda di piccole utilità come questa è abbastanza accettabile e conveniente. Naturalmente, per un progetto di grandi dimensioni che coinvolge molte funzioni e livelli Lambda, molto probabilmente sarebbe scomodo e sarebbe necessario utilizzare 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:*

Quanto sopra autorizza CloudWatch Logs a chiamare la tua funzione Lambda. Un avvertimento: ho scoperto che l'uso della proprietà SourceAccount può portare a conflitti con SourceArn .

In generale, suggerirei di non includerlo quando il servizio che sta chiamando la funzione Lambda si trova nello stesso account AWS. SourceArn comunque ad altri account di chiamare la funzione Lambda.

 ApacheAccessLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheAccessLogFunctionPermission Properties: LogGroupName: !Ref ApacheAccessLogGroup DestinationArn: !GetAtt ProcessApacheAccessLogFunction.Arn FilterPattern: "{$.status = 5*}"

La risorsa filtro sottoscrizione è il collegamento tra CloudWatch Logs e Lambda. Qui, i log inviati ad ApacheAccessLogGroup verranno inoltrati alla funzione Lambda definita sopra, ma solo quei log che superano il pattern di filtro. Qui, il modello di filtro prevede alcuni JSON come input (i modelli di filtro iniziano con '{' e terminano con '}') e corrisponderanno alla voce di registro solo se ha uno status del campo che inizia con "5".

Ciò significa che chiamiamo la funzione Lambda solo quando il codice di stato HTTP restituito da Apache è un codice 500, il che di solito significa che sta succedendo qualcosa di piuttosto brutto. Ciò garantisce che non chiamiamo troppo la funzione Lambda e quindi eviti costi inutili.

Ulteriori informazioni sui modelli di filtro sono disponibili nella documentazione di Amazon CloudWatch. I pattern di filtro di CloudWatch sono abbastanza buoni, anche se ovviamente non così potenti come Grok.

Nota il campo DependsOn , che garantisce che CloudWatch Logs possa effettivamente chiamare la funzione Lambda prima che venga creata la sottoscrizione. Questa è solo una ciliegina sulla torta, molto probabilmente non è necessaria poiché in uno scenario reale, Apache probabilmente non riceverà richieste prima di almeno alcuni secondi (ad esempio: per collegare l'istanza EC2 con un sistema di bilanciamento del carico e ottenere il carico bilanciatore per riconoscere lo stato dell'istanza EC2 come integro).

Funzione Lambda per elaborare i log degli errori

Ora diamo un'occhiata alla funzione Lambda che elaborerà i log degli errori di 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

Questa seconda funzione Lambda elabora i log degli errori di Apache e invierà un messaggio a Slack solo quando si verifica un errore grave. In questo caso, i messaggi di avviso e di avviso PHP non sono considerati sufficientemente gravi da attivare un avviso.

Anche in questo caso, questa funzione prevede che il log degli errori di Apache sia formattato in JSON. Quindi ecco la stringa di formato del registro degli errori che ho utilizzato:

 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

Questa risorsa concede le autorizzazioni a CloudWatch Logs per chiamare la tua funzione Lambda.

 ApacheErrorLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheErrorLogFunctionPermission Properties: LogGroupName: !Ref ApacheErrorLogGroup DestinationArn: !GetAtt ProcessApacheErrorLogFunction.Arn FilterPattern: '{$.msg != "PHP Warning*" && $.msg != "PHP Notice*"}'

Infine, colleghiamo CloudWatch Logs alla funzione Lambda utilizzando un filtro di sottoscrizione per il gruppo di log degli errori di Apache. Notare il modello di filtro, che garantisce che i registri con un messaggio che inizia con "Avviso PHP" o "Avviso PHP" non attivino una chiamata alla funzione Lambda.

Considerazioni finali, prezzi e disponibilità

Un'ultima parola sui costi: questa soluzione è molto più economica rispetto alla gestione di un cluster ELK. I log archiviati in CloudWatch hanno un prezzo allo stesso livello di S3 e Lambda consente un milione di chiamate al mese come parte del suo piano gratuito. Questo probabilmente sarebbe sufficiente per un sito web con traffico da moderato a intenso (a patto che tu abbia utilizzato i filtri CloudWatch Logs), soprattutto se lo hai codificato bene e non ha troppi errori!

Inoltre, tieni presente che le funzioni Lambda supportano fino a 1.000 chiamate simultanee. Al momento della scrittura, questo è un limite rigido in AWS che non può essere modificato. Tuttavia, puoi aspettarti che la chiamata per le funzioni di cui sopra duri per circa 30-40 ms. Questo dovrebbe essere abbastanza veloce da gestire un traffico piuttosto intenso. Se il tuo carico di lavoro è così intenso da raggiungere questo limite, probabilmente hai bisogno di una soluzione più complessa basata su Kinesis, che potrei trattare in un prossimo articolo.


Ulteriori letture sul blog di Toptal Engineering:

  • Registrazione SSH e gestione delle sessioni tramite AWS SSM