ELK do AWS: mniej kłopotliwe zarządzanie dziennikami
Opublikowany: 2022-03-11Elasticsearch to potężne rozwiązanie programowe przeznaczone do szybkiego wyszukiwania informacji w szerokim zakresie danych. W połączeniu z Logstash i Kibana tworzy to nieformalnie nazwany „stos ELK” i jest często używany do zbierania, tymczasowego przechowywania, analizowania i wizualizacji danych dziennika. Zwykle potrzebne jest kilka innych programów, takich jak Filebeat do wysyłania logów z serwera do Logstash oraz Elastalert do generowania alertów w oparciu o wyniki analizy przeprowadzonej na danych przechowywanych w Elasticsearch.
Stos ELK jest potężny, ale…
Moje doświadczenia z używaniem ELK do zarządzania logami są dość mieszane. Z jednej strony jest bardzo potężny, a zakres jego możliwości jest imponujący. Z drugiej strony konfiguracja jest trudna i może powodować ból głowy.
Faktem jest, że Elasticsearch jest ogólnie bardzo dobry i może być używany w wielu różnych scenariuszach; może być nawet używany jako wyszukiwarka! Ponieważ nie jest wyspecjalizowany w zarządzaniu danymi dziennika, wymaga to więcej pracy konfiguracyjnej, aby dostosować jego zachowanie do konkretnych potrzeb związanych z zarządzaniem takimi danymi.
Konfiguracja klastra ELK była dość trudna i wymagała ode mnie zabawy z wieloma parametrami, aby w końcu go uruchomić. Potem przyszła praca nad jego konfiguracją. W moim przypadku miałem do skonfigurowania pięć różnych programów (Filebeat, Logstash, Elasticsearch, Kibana i Elastalert). Może to być dość żmudna praca, ponieważ musiałem przeczytać dokumentację i debugować jeden element łańcucha, który nie komunikuje się z następnym. Nawet po tym, jak w końcu uruchomisz klaster, nadal musisz wykonywać na nim rutynowe operacje konserwacyjne: łatanie, aktualizowanie pakietów systemu operacyjnego, sprawdzanie wykorzystania procesora, pamięci RAM i dysku, wprowadzanie drobnych korekt w razie potrzeby itp.
Cały mój stos ELK przestał działać po aktualizacji Logstasha. Po bliższym przyjrzeniu się okazało się, że z jakiegoś powodu programiści ELK zdecydowali się zmienić słowo kluczowe w swoim pliku konfiguracyjnym i umieścić je w liczbie mnogiej. To była ostatnia kropla i postanowiłem poszukać lepszego rozwiązania (przynajmniej lepszego rozwiązania dla moich konkretnych potrzeb).
Chciałem przechowywać logi generowane przez Apache i różne aplikacje PHP i node, a także analizować je w celu znalezienia wzorców wskazujących na błędy w oprogramowaniu. Rozwiązanie, które znalazłem, było następujące:
- Zainstaluj CloudWatch Agent na miejscu docelowym.
- Skonfiguruj CloudWatch Agent, aby wysyłał logi do logów CloudWatch.
- Uruchom wywołanie funkcji Lambda w celu przetworzenia dzienników.
- Funkcja Lambda wysyła wiadomości do kanału Slack, jeśli zostanie znaleziony wzorzec.
- Jeśli to możliwe, zastosuj filtr do grup logów CloudWatch, aby uniknąć wywoływania funkcji Lambda dla każdego pojedynczego logu (co może bardzo szybko zwiększyć koszty).
I na wysokim poziomie to wszystko! Rozwiązanie w 100% bezserwerowe, które będzie działać dobrze bez potrzeby konserwacji i które będzie się dobrze skalować bez dodatkowego wysiłku. Przewagi takich rozwiązań bezserwerowych nad klastrem serwerów są liczne:
- Zasadniczo za wszystkie rutynowe operacje konserwacyjne, które należy okresowo wykonywać na serwerach klastra, odpowiada teraz dostawca chmury. Każdy serwer bazowy zostanie załatany, zaktualizowany i utrzymywany bez Twojej wiedzy.
- Nie musisz już monitorować swojego klastra i delegujesz wszystkie problemy związane ze skalowaniem do dostawcy chmury. Rzeczywiście, konfiguracja bezserwerowa, taka jak opisana powyżej, skaluje się automatycznie, bez konieczności robienia czegokolwiek!
- Opisane powyżej rozwiązanie wymaga mniejszej konfiguracji i jest bardzo mało prawdopodobne, że przełomowa zmiana zostanie wprowadzona do formatów konfiguracyjnych przez dostawcę chmury.
- Wreszcie dość łatwo jest napisać kilka szablonów CloudFormation, aby umieścić to wszystko jako infrastrukturę jako kod. Wykonanie tego samego w celu utworzenia całego klastra ELK wymagałoby wiele pracy.
Konfigurowanie alertów dotyczących luzu
Przejdźmy teraz do szczegółów! Przyjrzyjmy się, jak wyglądałby szablon CloudFormation dla takiej konfiguracji, wraz z webhookami Slack do ostrzegania inżynierów. Najpierw musimy skonfigurować wszystkie ustawienia Slacka, więc zagłębimy się w to.
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
W tym celu musisz skonfigurować swój obszar roboczy Slack, zapoznaj się z tym przewodnikiem WebHooks for Slack, aby uzyskać dodatkowe informacje.
Po utworzeniu aplikacji Slack i skonfigurowaniu podpięcia przychodzącego adres URL podpięcia stanie się parametrem stosu 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
Tutaj utworzyliśmy dwie grupy dzienników: jedną dla dzienników dostępu Apache , drugą dla dzienników błędów Apache .
Nie skonfigurowałem żadnego mechanizmu cyklu życia dla danych dziennika, ponieważ jest to poza zakresem tego artykułu. W praktyce prawdopodobnie chciałbyś mieć skrócone okno przechowywania i zaprojektować zasady cyklu życia S3, aby przenieść je do Glacier po pewnym czasie.
Funkcja lambda do przetwarzania dzienników dostępu
Zaimplementujmy teraz funkcję Lambda, która będzie przetwarzać logi dostępu 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
Tutaj stworzyliśmy rolę IAM, która zostanie przypisana do funkcji Lambda, aby umożliwić im wykonywanie swoich obowiązków. W efekcie AWSLambdaBasicExecutionRole
jest (pomimo swojej nazwy) polityką uprawnień dostarczaną przez AWS. Pozwala tylko funkcji Lambda na utworzenie własnej grupy logów i strumieni logów w ramach tej grupy, a następnie wysyłanie własnych logów do 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
Więc tutaj definiujemy funkcję Lambda do przetwarzania dzienników dostępu Apache. Należy pamiętać, że nie używam wspólnego formatu dziennika, który jest domyślny na Apache. Skonfigurowałem format dziennika dostępu w ten sposób (i zauważysz, że zasadniczo generuje dzienniki sformatowane jako JSON, co znacznie ułatwia przetwarzanie w dalszej części):
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
Ta funkcja Lambda jest napisana w Pythonie 3. Pobiera wiersz dziennika wysłany z CloudWatch i może wyszukiwać wzorce. W powyższym przykładzie wykrywa tylko żądania HTTP, które spowodowały kod stanu 5XX i wysyła wiadomość do kanału Slack.

Możesz zrobić wszystko, co chcesz, jeśli chodzi o wykrywanie wzorców, a fakt, że jest to prawdziwy język programowania (Python), w przeciwieństwie do wzorców wyrażeń regularnych w pliku konfiguracyjnym Logstash lub Elastalert, daje wiele możliwości wdrożenia złożonego rozpoznawania wzorców .
Kontrola rewizji
Krótkie słowo na temat kontroli wersji: stwierdziłem, że posiadanie kodu wbudowanego w szablonach CloudFormation dla małych funkcji Lambda, takich jak ta, jest całkiem akceptowalne i wygodne. Oczywiście w przypadku dużego projektu obejmującego wiele funkcji i warstw Lambda byłoby to prawdopodobnie niewygodne i konieczne byłoby użycie 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:*
Powyższe daje uprawnienia CloudWatch Logs do wywołania funkcji Lambda. Jedno słowo ostrzeżenia: stwierdziłem, że użycie właściwości SourceAccount
może prowadzić do konfliktów z SourceArn
.
Generalnie sugerowałbym nie uwzględniać go, gdy usługa wywołująca funkcję Lambda znajduje się na tym samym koncie AWS. SourceArn
i tak zabroni innym kontom wywoływania funkcji Lambda.
ApacheAccessLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheAccessLogFunctionPermission Properties: LogGroupName: !Ref ApacheAccessLogGroup DestinationArn: !GetAtt ProcessApacheAccessLogFunction.Arn FilterPattern: "{$.status = 5*}"
Zasób filtra subskrypcji jest łączem między CloudWatch Logs a Lambdą. W tym przypadku logi wysyłane do ApacheAccessLogGroup
zostaną przekazane do funkcji Lambda, którą zdefiniowaliśmy powyżej, ale tylko te logi, które przejdą wzorzec filtra. Tutaj wzorzec filtra oczekuje na dane wejściowe jakiegoś kodu JSON (wzorce filtrów zaczynają się od „{” i kończą na „}”) i będą pasować do wpisu w dzienniku tylko wtedy, gdy status
pola zaczyna się od „5”.
Oznacza to, że wywołujemy funkcję Lambda tylko wtedy, gdy kod statusu HTTP zwrócony przez Apache to kod 500, co zwykle oznacza, że dzieje się coś bardzo złego. Gwarantuje to, że nie będziemy zbyt często wywoływać funkcji Lambda, a tym samym unikniemy niepotrzebnych kosztów.
Więcej informacji na temat wzorców filtrów można znaleźć w dokumentacji Amazon CloudWatch. Wzorce filtrów CloudWatch są całkiem dobre, choć oczywiście nie tak potężne jak Grok.
Zwróć uwagę na pole DependsOn
, które zapewnia, że CloudWatch Logs może faktycznie wywołać funkcję Lambda przed utworzeniem subskrypcji. To tylko wisienka na torcie, prawdopodobnie jest to niepotrzebne, ponieważ w prawdziwym scenariuszu Apache prawdopodobnie nie odbierze żądań przed upływem kilku sekund (np.: połączyć instancję EC2 z load balancerem i pobrać obciążenie balansera, aby rozpoznał stan instancji EC2 jako zdrowy).
Funkcja lambda do przetwarzania dzienników błędów
Przyjrzyjmy się teraz funkcji Lambda, która będzie przetwarzać logi błędów 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
Ta druga funkcja Lambda przetwarza logi błędów Apache i wyśle wiadomość do Slacka tylko wtedy, gdy napotka poważny błąd. W tym przypadku powiadomienia i ostrzeżenia PHP nie są uważane za wystarczająco poważne, aby wywołać alert.
Ta funkcja ponownie oczekuje, że dziennik błędów Apache będzie sformatowany w formacie JSON. Oto ciąg formatu dziennika błędów, którego używałem:
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
Ten zasób nadaje uprawnienia CloudWatch Logs do wywoływania funkcji Lambda.
ApacheErrorLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheErrorLogFunctionPermission Properties: LogGroupName: !Ref ApacheErrorLogGroup DestinationArn: !GetAtt ProcessApacheErrorLogFunction.Arn FilterPattern: '{$.msg != "PHP Warning*" && $.msg != "PHP Notice*"}'
Na koniec łączymy CloudWatch Logs z funkcją Lambda za pomocą filtra subskrypcji dla grupy dzienników błędów Apache. Zwróć uwagę na wzorzec filtra, który zapewnia, że dzienniki z komunikatem zaczynającym się od „PHP Warning” lub „PHP Notice” nie wywołują wywołania funkcji Lambda.
Ostatnie przemyślenia, ceny i dostępność
Ostatnie słowo o kosztach: to rozwiązanie jest znacznie tańsze niż prowadzenie klastra ELK. Logi przechowywane w CloudWatch są wyceniane na tym samym poziomie co S3, a Lambda pozwala na milion połączeń miesięcznie w ramach swojej bezpłatnej warstwy. To prawdopodobnie wystarczyłoby w przypadku witryny o średnim lub dużym natężeniu ruchu (pod warunkiem, że używałeś filtrów CloudWatch Logs), zwłaszcza jeśli dobrze ją zakodowałeś i nie ma zbyt wielu błędów!
Należy również pamiętać, że funkcje Lambda obsługują do 1000 jednoczesnych wywołań. W chwili pisania tego tekstu jest to sztywny limit w AWS, którego nie można zmienić. Można jednak oczekiwać, że wywołanie powyższych funkcji będzie trwało około 30-40 ms. Powinno to być wystarczająco szybkie, aby obsłużyć dość duży ruch. Jeśli Twoje obciążenie pracą jest tak intensywne, że osiągnąłeś ten limit, prawdopodobnie potrzebujesz bardziej złożonego rozwiązania opartego na Kinesis, o czym być może napiszę w przyszłym artykule.
Dalsza lektura na blogu Toptal Engineering:
- Logowanie SSH i zarządzanie sesją za pomocą AWS SSM