ELK vers AWS : gérer les journaux avec moins de tracas
Publié: 2022-03-11Elasticsearch est une solution logicielle puissante conçue pour rechercher rapidement des informations dans une vaste gamme de données. Combiné avec Logstash et Kibana, cela forme la "pile ELK" officieusement nommée, et est souvent utilisée pour collecter, stocker temporairement, analyser et visualiser les données de journal. Quelques autres logiciels sont généralement nécessaires, tels que Filebeat pour envoyer les journaux du serveur à Logstash, et Elastalert pour générer des alertes basées sur le résultat d'une analyse effectuée sur les données stockées dans Elasticsearch.
La pile ELK est puissante, mais…
Mon expérience avec l'utilisation d'ELK pour la gestion des journaux est assez mitigée. D'une part, il est très puissant et l'éventail de ses capacités est assez impressionnant. En revanche, il est délicat à mettre en place et peut être un casse-tête à entretenir.
Le fait est qu'Elasticsearch est très bon en général et peut être utilisé dans une grande variété de scénarios ; il peut même être utilisé comme moteur de recherche ! Comme il n'est pas spécialisé dans la gestion des données de journal, cela nécessite plus de travail de configuration pour personnaliser son comportement en fonction des besoins spécifiques de gestion de ces données.
La configuration du cluster ELK était assez délicate et m'a obligé à jouer avec un certain nombre de paramètres afin de le rendre enfin opérationnel. Puis vint le travail de configuration. Dans mon cas, j'avais cinq logiciels différents à configurer (Filebeat, Logstash, Elasticsearch, Kibana et Elastalert). Cela peut être un travail assez fastidieux, car j'ai dû lire la documentation et déboguer un élément de la chaîne qui ne parle pas au suivant. Même après avoir enfin mis en place votre cluster, vous devez toujours effectuer des opérations de maintenance de routine sur celui-ci : patcher, mettre à niveau les packages du système d'exploitation, vérifier l'utilisation du processeur, de la RAM et du disque, effectuer des ajustements mineurs si nécessaire, etc.
Toute ma pile ELK a cessé de fonctionner après une mise à jour de Logstash. Après un examen plus approfondi, il s'est avéré que, pour une raison quelconque, les développeurs ELK ont décidé de modifier un mot-clé dans leur fichier de configuration et de le pluraliser. C'était la dernière goutte et j'ai décidé de chercher une meilleure solution (au moins une meilleure solution pour mes besoins particuliers).
Je voulais stocker les journaux générés par Apache et diverses applications PHP et nœuds, et les analyser pour trouver des modèles indiquant des bogues dans le logiciel. La solution que j'ai trouvé était la suivante :
- Installez l'agent CloudWatch sur la cible.
- Configurez l'agent CloudWatch pour expédier les journaux aux journaux CloudWatch.
- Déclenchez l'appel des fonctions Lambda pour traiter les journaux.
- La fonction Lambda publierait des messages sur un canal Slack si un modèle est trouvé.
- Dans la mesure du possible, appliquez un filtre aux groupes de journaux CloudWatch pour éviter d'appeler la fonction Lambda pour chaque journal (ce qui pourrait augmenter les coûts très rapidement).
Et, à haut niveau, c'est tout ! Une solution 100% sans serveur qui fonctionnera bien sans aucun besoin de maintenance et qui évoluera bien sans aucun effort supplémentaire. Les avantages de telles solutions sans serveur par rapport à un cluster de serveurs sont nombreux :
- Essentiellement, toutes les opérations de maintenance de routine que vous effectueriez périodiquement sur vos serveurs de cluster relèvent désormais de la responsabilité du fournisseur de cloud. Tout serveur sous-jacent sera corrigé, mis à jour et maintenu pour vous sans même que vous le sachiez.
- Vous n'avez plus besoin de surveiller votre cluster et vous déléguez tous les problèmes de mise à l'échelle au fournisseur de cloud. En effet, une configuration sans serveur telle que celle décrite ci-dessus évoluera automatiquement sans que vous ayez à faire quoi que ce soit !
- La solution décrite ci-dessus nécessite moins de configuration et il est très peu probable qu'un changement radical soit apporté aux formats de configuration par le fournisseur de cloud.
- Enfin, il est assez facile d'écrire des modèles CloudFormation pour mettre tout cela sous forme d'infrastructure en tant que code. Faire de même pour mettre en place tout un cluster ELK demanderait beaucoup de travail.
Configuration des alertes Slack
Alors maintenant, entrons dans les détails ! Explorons à quoi ressemblerait un modèle CloudFormation pour une telle configuration, avec des webhooks Slack pour alerter les ingénieurs. Nous devons d'abord configurer toute la configuration de Slack, alors plongeons-y.
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
Vous auriez besoin de configurer votre espace de travail Slack pour cela, consultez ce guide WebHooks pour Slack pour plus d'informations.
Une fois que vous avez créé votre application Slack et configuré un crochet entrant, l'URL du crochet deviendra un paramètre de votre pile 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
Ici, nous avons créé deux groupes de journaux : un pour les journaux d'accès Apache , l'autre pour les journaux d'erreurs Apache .
Je n'ai configuré aucun mécanisme de cycle de vie pour les données de journal, car cela sort du cadre de cet article. En pratique, vous souhaiterez probablement avoir une fenêtre de rétention raccourcie et concevoir des politiques de cycle de vie S3 pour les déplacer vers Glacier après une certaine période de temps.
Fonction Lambda pour traiter les journaux d'accès
Implémentons maintenant la fonction Lambda qui traitera les journaux d'accès 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
Ici, nous avons créé un rôle IAM qui sera attaché aux fonctions Lambda, pour leur permettre d'accomplir leurs tâches. En effet, AWSLambdaBasicExecutionRole
est (malgré son nom) une stratégie IAM fournie par AWS. Il permet simplement à la fonction Lambda de créer son groupe de journaux et ses flux de journaux au sein de ce groupe, puis d'envoyer ses propres journaux à 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
Nous définissons donc ici une fonction Lambda pour traiter les journaux d'accès Apache. Veuillez noter que je n'utilise pas le format de journal commun qui est la valeur par défaut sur Apache. J'ai configuré le format du journal d'accès comme suit (et vous remarquerez qu'il génère essentiellement des journaux au format JSON, ce qui facilite beaucoup le traitement ultérieur):
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
Cette fonction Lambda est écrite en Python 3. Elle prend la ligne de journal envoyée par CloudWatch et peut rechercher des modèles. Dans l'exemple ci-dessus, il détecte simplement les requêtes HTTP qui ont abouti à un code d'état 5XX et publie un message sur un canal Slack.

Vous pouvez faire tout ce que vous voulez en termes de détection de modèles, et le fait qu'il s'agisse d'un véritable langage de programmation (Python), par opposition aux modèles regex dans un fichier de configuration Logstash ou Elastalert, vous offre de nombreuses possibilités d'implémenter une reconnaissance de modèle complexe. .
Contrôle de révision
Un mot rapide sur le contrôle des révisions : j'ai trouvé que le fait d'avoir le code en ligne dans les modèles CloudFormation pour les petites fonctions utilitaires Lambda telles que celle-ci était tout à fait acceptable et pratique. Bien sûr, pour un grand projet impliquant de nombreuses fonctions et couches Lambda, cela serait très probablement gênant et vous auriez besoin d'utiliser 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:*
Ce qui précède autorise CloudWatch Logs à appeler votre fonction Lambda. Un mot d'avertissement : j'ai trouvé que l'utilisation de la propriété SourceAccount
peut entraîner des conflits avec le SourceArn
.
De manière générale, je suggérerais de ne pas l'inclure lorsque le service qui appelle la fonction Lambda se trouve dans le même compte AWS. Le SourceArn
interdira de toute façon aux autres comptes d'appeler la fonction Lambda.
ApacheAccessLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheAccessLogFunctionPermission Properties: LogGroupName: !Ref ApacheAccessLogGroup DestinationArn: !GetAtt ProcessApacheAccessLogFunction.Arn FilterPattern: "{$.status = 5*}"
La ressource de filtre d'abonnement est le lien entre CloudWatch Logs et Lambda. Ici, les journaux envoyés à ApacheAccessLogGroup
seront transmis à la fonction Lambda que nous avons définie ci-dessus, mais uniquement les journaux qui passent le modèle de filtre. Ici, le modèle de filtre attend du JSON en entrée (les modèles de filtre commencent par '{' et se terminent par '}'), et ne correspondront à l'entrée de journal que s'il a un status
de champ qui commence par "5".
Cela signifie que nous appelons la fonction Lambda uniquement lorsque le code d'état HTTP renvoyé par Apache est un code 500, ce qui signifie généralement que quelque chose d'assez mauvais se passe. Cela garantit que nous n'appelons pas trop la fonction Lambda et évitons ainsi des coûts inutiles.
Vous trouverez plus d'informations sur les modèles de filtre dans la documentation Amazon CloudWatch. Les modèles de filtre CloudWatch sont assez bons, bien qu'évidemment pas aussi puissants que Grok.
Notez le champ DependsOn
, qui garantit que CloudWatch Logs peut réellement appeler la fonction Lambda avant la création de l'abonnement. Ce n'est qu'une cerise sur le gâteau, c'est probablement inutile car dans un scénario réel, Apache ne recevrait probablement pas de requêtes avant au moins quelques secondes (par exemple : pour lier l'instance EC2 à un équilibreur de charge, et obtenir la charge équilibreur pour reconnaître le statut de l'instance EC2 comme saine).
Fonction Lambda pour traiter les journaux d'erreurs
Examinons maintenant la fonction Lambda qui traitera les journaux d'erreurs 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
Cette deuxième fonction Lambda traite les journaux d'erreurs Apache et envoie un message à Slack uniquement lorsqu'une erreur grave est rencontrée. Dans ce cas, les avis PHP et les messages d'avertissement ne sont pas considérés comme suffisamment sérieux pour déclencher une alerte.
Encore une fois, cette fonction s'attend à ce que le journal des erreurs Apache soit au format JSON. Voici donc la chaîne de format du journal des erreurs que j'ai utilisée :
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
Cette ressource accorde des autorisations à CloudWatch Logs pour appeler votre fonction Lambda.
ApacheErrorLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheErrorLogFunctionPermission Properties: LogGroupName: !Ref ApacheErrorLogGroup DestinationArn: !GetAtt ProcessApacheErrorLogFunction.Arn FilterPattern: '{$.msg != "PHP Warning*" && $.msg != "PHP Notice*"}'
Enfin, nous relions CloudWatch Logs à la fonction Lambda à l'aide d'un filtre d'abonnement pour le groupe de journaux d'erreurs Apache. Notez le modèle de filtre, qui garantit que les journaux avec un message commençant par « PHP Warning » ou « PHP Notice » ne déclenchent pas d'appel à la fonction Lambda.
Réflexions finales, prix et disponibilité
Un dernier mot sur les coûts : cette solution est beaucoup moins chère que l'exploitation d'un cluster ELK. Les journaux stockés dans CloudWatch sont facturés au même niveau que S3, et Lambda autorise un million d'appels par mois dans le cadre de son offre gratuite. Cela suffirait probablement pour un site Web avec un trafic modéré à important (à condition que vous utilisiez les filtres CloudWatch Logs), surtout si vous l'avez bien codé et qu'il n'y a pas trop d'erreurs !
Veuillez également noter que les fonctions Lambda prennent en charge jusqu'à 1 000 appels simultanés. Au moment de la rédaction de cet article, il s'agit d'une limite stricte dans AWS qui ne peut pas être modifiée. Cependant, vous pouvez vous attendre à ce que l'appel des fonctions ci-dessus dure environ 30 à 40 ms. Cela devrait être assez rapide pour gérer un trafic plutôt lourd. Si votre charge de travail est si intense que vous atteignez cette limite, vous avez probablement besoin d'une solution plus complexe basée sur Kinesis, que je pourrais aborder dans un prochain article.
Lectures complémentaires sur le blog Toptal Engineering :
- Journalisation SSH et gestion des sessions à l'aide d'AWS SSM