ELK la AWS: gestionarea jurnalelor cu mai puține bătăi de cap

Publicat: 2022-03-11

Elasticsearch este o soluție software puternică, concepută pentru a căuta rapid informații într-o gamă largă de date. Combinat cu Logstash și Kibana, acesta formează „stiva ELK” denumită informal și este adesea folosit pentru a colecta, stoca temporar, analiza și vizualiza datele de jurnal. De obicei, sunt necesare alte câteva componente de software, cum ar fi Filebeat pentru a trimite jurnalele de pe server către Logstash și Elastalert pentru a genera alerte bazate pe rezultatul unor analize efectuate pe datele stocate în Elasticsearch.

Stiva ELK este puternică, dar...

Experiența mea cu utilizarea ELK pentru gestionarea jurnalelor este destul de amestecată. Pe de o parte, este foarte puternic și gama de capacități este destul de impresionantă. Pe de altă parte, este dificil de configurat și poate fi o durere de cap de întreținut.

Cert este că Elasticsearch este foarte bun în general și poate fi folosit într-o mare varietate de scenarii; poate fi folosit chiar și ca motor de căutare! Deoarece nu este specializat în gestionarea datelor de jurnal, acest lucru necesită mai multă muncă de configurare pentru a-și personaliza comportamentul pentru nevoile specifice de gestionare a acestor date.

Configurarea cluster-ului ELK a fost destul de dificilă și mi-a cerut să mă joc cu o serie de parametri pentru a-l pune în funcțiune în sfârșit. Apoi a venit munca de configurare. În cazul meu, aveam de configurat cinci programe diferite (Filebeat, Logstash, Elasticsearch, Kibana și Elastalert). Aceasta poate fi o muncă destul de plictisitoare, deoarece a trebuit să citesc documentația și să depanez un element al lanțului care nu vorbește cu următorul. Chiar și după ce în sfârșit vă puneți în funcțiune clusterul, mai trebuie să efectuați operațiuni de întreținere de rutină asupra acestuia: corecție, actualizarea pachetelor de sistem de operare, verificarea utilizării CPU, RAM și a discului, efectuarea de ajustări minore, după cum este necesar, etc.

Întreaga mea stivă ELK a încetat să funcționeze după o actualizare Logstash. La o examinare mai atentă, S-a dovedit că, din anumite motive, dezvoltatorii ELK au decis să schimbe un cuvânt cheie în fișierul lor de configurare și să-l pluralizeze. Acesta a fost ultima picătură și am decis să caut o soluție mai bună (cel puțin o soluție mai bună pentru nevoile mele speciale).

Am vrut să stochez jurnalele generate de Apache și diverse aplicații PHP și noduri și să le analizez pentru a găsi modele care indică erorile din software. Soluția pe care am găsit-o a fost următoarea:

  • Instalați CloudWatch Agent pe țintă.
  • Configurați agentul CloudWatch pentru a expedia jurnalele către jurnalele CloudWatch.
  • Declanșați invocarea funcțiilor Lambda pentru a procesa jurnalele.
  • Funcția Lambda ar posta mesaje pe un canal Slack dacă este găsit un model.
  • Acolo unde este posibil, aplicați un filtru pentru grupurile de jurnal CloudWatch pentru a evita apelarea funcției Lambda pentru fiecare jurnal (care ar putea crește costurile foarte repede).

Și, la un nivel înalt, asta este! O soluție 100% fără server care va funcționa bine, fără a fi nevoie de întreținere și care s-ar scala bine fără niciun efort suplimentar. Avantajele unor astfel de soluții fără server față de un cluster de servere sunt numeroase:

  • În esență, toate operațiunile de întreținere de rutină pe care le-ați efectua periodic pe serverele dvs. de cluster sunt acum responsabilitatea furnizorului de cloud. Orice server de bază va fi corectat, actualizat și întreținut pentru tine fără ca tu să știi.
  • Nu mai trebuie să vă monitorizați clusterul și delegeți toate problemele de scalare furnizorului de cloud. Într-adevăr, o configurație fără server precum cea descrisă mai sus se va scala automat, fără ca tu să fii nevoit să faci nimic!
  • Soluția descrisă mai sus necesită mai puțină configurare și este foarte puțin probabil ca furnizorul de cloud să aducă o schimbare radicală în formatele de configurare.
  • În cele din urmă, este destul de ușor să scrieți niște șabloane CloudFormation pentru a pune toate acestea ca infrastructură-ca-cod. A face același lucru pentru a configura un întreg cluster ELK ar necesita multă muncă.

Configurarea alertelor Slack

Așa că acum să intrăm în detalii! Să explorăm cum ar arăta un șablon CloudFormation pentru o astfel de configurare, complet cu webhook-uri Slack pentru alertarea inginerilor. Mai întâi trebuie să configuram toate configurațiile Slack, așa că haideți să ne aprofundăm.

 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

Ar trebui să vă configurați spațiul de lucru Slack pentru aceasta, consultați acest ghid WebHooks for Slack pentru informații suplimentare.

Odată ce ați creat aplicația Slack și ați configurat un hook de intrare, adresa URL a hook-ului va deveni un parametru al stivei dvs. 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

Aici am creat două grupuri de jurnal: unul pentru jurnalele de acces Apache , celălalt pentru jurnalele de erori Apache .

Nu am configurat niciun mecanism de ciclu de viață pentru datele de jurnal, deoarece nu intră în domeniul de aplicare al acestui articol. În practică, probabil că ați dori să aveți o fereastră de reținere scurtă și să proiectați politici de ciclu de viață S3 pentru a le muta în Glacier după o anumită perioadă de timp.

Funcția Lambda pentru procesarea jurnalelor de acces

Acum să implementăm funcția Lambda care va procesa jurnalele de acces 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

Aici am creat un rol IAM care va fi atașat funcțiilor Lambda, pentru a le permite să-și îndeplinească atribuțiile. De fapt, AWSLambdaBasicExecutionRole este (în ciuda numelui său) o politică IAM furnizată de AWS. Acesta permite doar funcției Lambda să își creeze un grup de jurnal și să înregistreze fluxuri în cadrul acelui grup, apoi să trimită propriile jurnale către 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

Deci, aici definim o funcție Lambda pentru a procesa jurnalele de acces Apache. Vă rugăm să rețineți că nu folosesc formatul de jurnal comun, care este implicit pe Apache. Am configurat astfel formatul jurnalului de acces (și veți observa că generează, în esență, jurnalele formatate ca JSON, ceea ce face procesarea mult mai ușoară):

 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

Această funcție Lambda este scrisă în Python 3. Preia linia de jurnal trimisă de CloudWatch și poate căuta modele. În exemplul de mai sus, detectează doar solicitările HTTP care au dus la un cod de stare 5XX și postează un mesaj pe un canal Slack.

Puteți face orice doriți în ceea ce privește detectarea modelelor, iar faptul că este un adevărat limbaj de programare (Python), spre deosebire de modelele regex dintr-un fișier de configurare Logstash sau Elastalert, vă oferă o mulțime de oportunități de a implementa recunoașterea modelelor complexe. .

Controlul revizuirii

Un cuvânt scurt despre controlul revizuirii: am constatat că a avea codul în linie în șabloanele CloudFormation pentru funcții Lambda utilitare mici, cum ar fi aceasta, este destul de acceptabil și convenabil. Desigur, pentru un proiect mare care implică multe funcții și straturi Lambda, acest lucru ar fi cel mai probabil incomod și ar trebui să utilizați 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:*

Cele de mai sus dă permisiunea CloudWatch Logs să apeleze funcția Lambda. Un cuvânt de precauție: am descoperit că utilizarea proprietății SourceAccount poate duce la conflicte cu SourceArn .

În general, aș sugera să nu îl includeți atunci când serviciul care apelează funcția Lambda este în același cont AWS. SourceArn va interzice oricum altor conturi să apeleze funcția Lambda.

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

Resursa de filtru de abonament este legătura dintre CloudWatch Logs și Lambda. Aici, jurnalele trimise către ApacheAccessLogGroup vor fi redirecționate către funcția Lambda pe care am definit-o mai sus, dar numai acele jurnale care trec modelul de filtrare. Aici, modelul de filtru se așteaptă ca intrare JSON (modelele de filtru începe cu „{” și se termină cu „}”) și se va potrivi cu intrarea de jurnal numai dacă are o status de câmp care începe cu „5”.

Aceasta înseamnă că numim funcția Lambda numai atunci când codul de stare HTTP returnat de Apache este un cod 500, ceea ce înseamnă de obicei că se întâmplă ceva destul de rău. Acest lucru asigură că nu apelăm prea mult la funcția Lambda și, prin urmare, evităm costurile inutile.

Mai multe informații despre modelele de filtrare pot fi găsite în documentația Amazon CloudWatch. Modelele de filtrare CloudWatch sunt destul de bune, deși evident că nu sunt la fel de puternice ca Grok.

Rețineți câmpul DependsOn , care asigură că CloudWatch Logs poate apela de fapt funcția Lambda înainte de crearea abonamentului. Aceasta este doar o cireașă pe tort, cel mai probabil este inutil, deoarece într-un scenariu real, Apache probabil nu ar primi cereri înainte de cel puțin câteva secunde (de exemplu: pentru a conecta instanța EC2 cu un echilibrator de încărcare și pentru a obține încărcarea echilibrant să recunoască starea instanței EC2 ca fiind sănătoasă).

Funcția Lambda pentru procesarea jurnalelor de erori

Acum să aruncăm o privire la funcția Lambda care va procesa jurnalele de erori 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

Această a doua funcție Lambda procesează jurnalele de erori Apache și va posta un mesaj către Slack numai atunci când se întâlnește o eroare gravă. În acest caz, mesajele de avertizare și de avertizare PHP nu sunt considerate suficient de grave pentru a declanșa o alertă.

Din nou, această funcție se așteaptă ca jurnalul de erori Apache să fie format JSON. Deci, iată șirul de format al jurnalului de erori pe care l-am folosit:

 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

Această resursă acordă permisiuni CloudWatch Logs pentru a vă apela funcția Lambda.

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

În cele din urmă, legăm CloudWatch Logs cu funcția Lambda folosind un filtru de abonament pentru grupul de jurnal de erori Apache. Rețineți modelul de filtrare, care asigură că jurnalele cu un mesaj care începe fie cu „Avertisment PHP”, fie cu „Notă PHP” nu declanșează un apel la funcția Lambda.

Gânduri finale, prețuri și disponibilitate

Un ultim cuvânt despre costuri: această soluție este mult mai ieftină decât operarea unui cluster ELK. Jurnalele stocate în CloudWatch au prețuri la același nivel ca S3, iar Lambda permite un milion de apeluri pe lună ca parte a nivelului său gratuit. Acest lucru ar fi probabil suficient pentru un site web cu trafic moderat spre intens (cu condiția să utilizați filtre CloudWatch Logs), mai ales dacă l-ați codificat bine și nu are prea multe erori!

De asemenea, rețineți că funcțiile Lambda acceptă până la 1.000 de apeluri simultane. La momentul scrierii, aceasta este o limită strictă în AWS care nu poate fi schimbată. Cu toate acestea, vă puteți aștepta ca apelul pentru funcțiile de mai sus să dureze aproximativ 30-40 ms. Acest lucru ar trebui să fie suficient de rapid pentru a face față unui trafic destul de intens. Dacă volumul de muncă este atât de intens încât atingeți această limită, probabil că aveți nevoie de o soluție mai complexă bazată pe Kinesis, pe care s-ar putea să o acopăr într-un articol viitor.


Citiți suplimentare pe blogul Toptal Engineering:

  • Înregistrare SSH și gestionare a sesiunilor folosind AWS SSM