ELK to AWS: การจัดการบันทึกด้วยความยุ่งยากน้อยลง
เผยแพร่แล้ว: 2022-03-11Elasticsearch เป็นโซลูชันซอฟต์แวร์ที่ทรงพลังที่ออกแบบมาเพื่อค้นหาข้อมูลอย่างรวดเร็วในข้อมูลที่หลากหลาย เมื่อรวมกับ Logstash และ Kibana แล้ว สิ่งเหล่านี้จะสร้างชื่ออย่างไม่เป็นทางการว่า “ELK stack” และมักใช้เพื่อรวบรวม จัดเก็บ วิเคราะห์ และแสดงภาพข้อมูลบันทึก โดยปกติแล้วจำเป็นต้องใช้ซอฟต์แวร์อื่นๆ อีกสองสามชิ้น เช่น Filebeat เพื่อส่งบันทึกจากเซิร์ฟเวอร์ไปยัง Logstash และ Elastalert เพื่อสร้างการแจ้งเตือนตามผลการวิเคราะห์ที่รันบนข้อมูลที่เก็บไว้ใน Elasticsearch
ELK Stack นั้นทรงพลัง แต่…
ประสบการณ์ของฉันกับการใช้ ELK ในการจัดการบันทึกค่อนข้างหลากหลาย ในด้านหนึ่ง มันทรงพลังมากและความสามารถของมันค่อนข้างน่าประทับใจ ในทางกลับกัน การตั้งค่าค่อนข้างยากและอาจทำให้ปวดหัวได้
ความจริงก็คือว่า Elasticsearch นั้นดีมากโดยทั่วไปและสามารถใช้ได้ในสถานการณ์ที่หลากหลาย มันสามารถใช้เป็นเครื่องมือค้นหาได้! เนื่องจากไม่ได้เชี่ยวชาญในการจัดการข้อมูลบันทึก จึงต้องมีการกำหนดค่าเพิ่มเติมเพื่อปรับแต่งลักษณะการทำงานสำหรับความต้องการเฉพาะในการจัดการข้อมูลดังกล่าว
การตั้งค่าคลัสเตอร์ ELK นั้นค่อนข้างยุ่งยากและทำให้ฉันต้องเล่นกับพารามิเตอร์จำนวนหนึ่งเพื่อที่จะเริ่มใช้งานได้ในที่สุด จากนั้นงานของการกำหนดค่าก็มาถึง ในกรณีของฉัน ฉันมีซอฟต์แวร์ห้าชิ้นที่จะกำหนดค่า (Filebeat, Logstash, Elasticsearch, Kibana และ Elastalert) นี่อาจเป็นงานที่น่าเบื่อหน่าย เนื่องจากฉันต้องอ่านเอกสารประกอบและดีบักองค์ประกอบหนึ่งของ chain ที่ไม่พูดถึงอีกอันหนึ่ง แม้หลังจากที่คุณทำให้คลัสเตอร์ของคุณใช้งานได้แล้ว คุณยังต้องดำเนินการบำรุงรักษาตามปกติ: การแพตช์ การอัปเกรดแพ็คเกจระบบปฏิบัติการ การตรวจสอบ CPU, RAM และการใช้ดิสก์ ทำการปรับเปลี่ยนเล็กน้อยตามต้องการ ฯลฯ
สแต็ค ELK ทั้งหมดของฉันหยุดทำงานหลังจากอัปเดต Logstash จากการตรวจสอบอย่างละเอียดถี่ถ้วน ปรากฏว่าด้วยเหตุผลบางอย่าง นักพัฒนา ELK ตัดสินใจเปลี่ยนคำสำคัญในไฟล์ปรับแต่งของตนและทำให้เป็นพหูพจน์ นั่นคือฟางเส้นสุดท้ายและตัดสินใจที่จะมองหาวิธีแก้ปัญหาที่ดีกว่า (อย่างน้อยก็ทางออกที่ดีกว่าสำหรับความต้องการเฉพาะของฉัน)
ฉันต้องการจัดเก็บบันทึกที่สร้างโดย Apache และ PHP และแอพโหนดต่างๆ และเพื่อแยกวิเคราะห์เพื่อค้นหารูปแบบที่บ่งบอกถึงข้อบกพร่องในซอฟต์แวร์ วิธีแก้ปัญหาที่ฉันพบมีดังต่อไปนี้:
- ติดตั้ง CloudWatch Agent บนเป้าหมาย
- กำหนดค่า CloudWatch Agent เพื่อจัดส่งบันทึกไปยังบันทึกของ CloudWatch
- ทริกเกอร์การเรียกใช้ฟังก์ชัน Lambda เพื่อประมวลผลบันทึก
- ฟังก์ชันแลมบ์ดาจะโพสต์ข้อความไปยังแชนเนล Slack หากพบรูปแบบ
- หากเป็นไปได้ ให้ใช้ตัวกรองกับกลุ่มบันทึก CloudWatch เพื่อหลีกเลี่ยงการเรียกใช้ฟังก์ชัน Lambda สำหรับบันทึกทุกรายการ (ซึ่งอาจทำให้ต้นทุนเพิ่มขึ้นอย่างรวดเร็ว)
และในระดับสูง แค่นั้นแหละ! โซลูชันไร้เซิร์ฟเวอร์ 100% ที่จะทำงานได้ดีโดยไม่ต้องมีการบำรุงรักษาใดๆ และปรับขนาดได้ดีโดยไม่ต้องใช้ความพยายามเพิ่มเติมใดๆ ข้อดีของโซลูชันแบบไร้เซิร์ฟเวอร์บนคลัสเตอร์ของเซิร์ฟเวอร์มีมากมาย:
- โดยพื้นฐานแล้ว การดำเนินการบำรุงรักษาตามปกติทั้งหมดที่คุณดำเนินการเป็นระยะๆ บนเซิร์ฟเวอร์คลัสเตอร์ของคุณเป็นความรับผิดชอบของผู้ให้บริการระบบคลาวด์ เซิร์ฟเวอร์พื้นฐานใดๆ จะถูกแพตช์ อัปเกรด และบำรุงรักษาให้คุณโดยที่คุณไม่รู้ตัว
- คุณไม่จำเป็นต้องตรวจสอบคลัสเตอร์ของคุณอีกต่อไป และมอบหมายปัญหาการปรับขนาดทั้งหมดให้กับผู้ให้บริการระบบคลาวด์ อันที่จริง การตั้งค่าแบบไร้เซิร์ฟเวอร์ดังที่อธิบายไว้ข้างต้นจะปรับขนาดโดยอัตโนมัติโดยที่คุณไม่ต้องดำเนินการใดๆ
- โซลูชันที่อธิบายข้างต้นต้องการการกำหนดค่าน้อยกว่า และไม่น่าเป็นไปได้มากที่ผู้ให้บริการระบบคลาวด์จะนำการเปลี่ยนแปลงที่เสียหายมาสู่รูปแบบการกำหนดค่า
- สุดท้าย มันค่อนข้างง่ายในการเขียนเทมเพลต CloudFormation เพื่อใส่ทั้งหมดนี้เป็นโครงสร้างพื้นฐานเหมือนโค้ด การทำเช่นเดียวกันนี้เพื่อตั้งค่าคลัสเตอร์ ELK ทั้งหมดจะต้องดำเนินการอย่างมาก
การกำหนดค่า Slack Alerts
ทีนี้มาดูรายละเอียดกันเลย! มาดูกันว่าเทมเพลต CloudFormation จะเป็นอย่างไรสำหรับการตั้งค่าดังกล่าว พร้อมด้วย Slack webhooks สำหรับวิศวกรแจ้งเตือน เราต้องกำหนดค่าการตั้งค่า Slack ทั้งหมดก่อน มาดูรายละเอียดกันเลย
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
คุณจะต้องตั้งค่าพื้นที่ทำงาน Slack ของคุณ โปรดดูข้อมูลเพิ่มเติมในคู่มือ WebHooks for Slack
เมื่อคุณสร้างแอป Slack และกำหนดค่า hook ขาเข้า URL ของ hook จะกลายเป็นพารามิเตอร์ของ CloudFormation stack ของคุณ
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
ที่นี่เราสร้างกลุ่มบันทึกสองกลุ่ม: กลุ่มหนึ่งสำหรับ บันทึกการเข้าถึง Apache และอีกกลุ่มสำหรับ บันทึกข้อผิดพลาด Apache
ฉันไม่ได้กำหนดค่ากลไกวงจรชีวิตใดๆ สำหรับข้อมูลบันทึก เนื่องจากอยู่นอกขอบเขตของบทความนี้ ในทางปฏิบัติ คุณอาจต้องการให้มีระยะเวลาเก็บรักษาสั้นลง และออกแบบนโยบายวงจรการใช้งาน S3 เพื่อย้ายไปยัง Glacier หลังจากช่วงระยะเวลาหนึ่ง
ฟังก์ชันแลมบ์ดาเพื่อประมวลผลบันทึกการเข้าถึง
ตอนนี้ เรามาใช้งานฟังก์ชัน Lambda ที่จะประมวลผลบันทึกการเข้าถึง 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
ที่นี่ เราสร้างบทบาท IAM ที่จะแนบมากับฟังก์ชันของแลมบ์ดา เพื่อให้พวกเขาสามารถปฏิบัติหน้าที่ได้ ตามจริงแล้ว AWSLambdaBasicExecutionRole
คือ (แม้จะมีชื่อ) นโยบาย IAM ที่ AWS จัดหาให้ เพียงแค่อนุญาตให้ฟังก์ชัน Lambda สร้างกลุ่มบันทึกและสตรีมบันทึกภายในกลุ่มนั้น จากนั้นจึงส่งบันทึกของตัวเองไปยัง 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
ดังนั้น เรากำลังกำหนดฟังก์ชัน Lambda เพื่อประมวลผลบันทึกการเข้าถึง Apache โปรดทราบว่าฉันไม่ได้ใช้รูปแบบบันทึกทั่วไปซึ่งเป็นค่าเริ่มต้นใน Apache ฉันกำหนดค่ารูปแบบบันทึกการเข้าถึงเช่นนั้น (และคุณจะสังเกตเห็นว่าโดยพื้นฐานแล้วมันสร้างบันทึกที่จัดรูปแบบเป็น JSON ซึ่งทำให้การประมวลผลต่อไปง่ายขึ้นมาก):
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
ฟังก์ชัน Lambda นี้เขียนด้วย Python 3 โดยจะใช้บรรทัดบันทึกที่ส่งจาก CloudWatch และสามารถค้นหารูปแบบได้ ในตัวอย่างข้างต้น ตรวจพบเพียงคำขอ HTTP ที่ทำให้เกิดรหัสสถานะ 5XX และโพสต์ข้อความไปยังช่อง Slack

คุณสามารถทำอะไรก็ได้ที่คุณชอบในแง่ของการตรวจจับรูปแบบ และความจริงที่ว่ามันเป็นภาษาโปรแกรมที่แท้จริง (Python) แทนที่จะใช้แค่รูปแบบ regex ในไฟล์กำหนดค่า Logstash หรือ Elastalert ทำให้คุณมีโอกาสมากมายในการนำการจดจำรูปแบบที่ซับซ้อนไปใช้ .
การควบคุมการแก้ไข
คำสั้นๆ เกี่ยวกับการควบคุมการแก้ไข: ฉันพบว่าการมีโค้ดแบบอินไลน์ในเทมเพลต CloudFormation สำหรับฟังก์ชันยูทิลิตี้ขนาดเล็กของ Lambda เช่นอันนี้เป็นที่ยอมรับและสะดวก แน่นอน สำหรับโปรเจ็กต์ขนาดใหญ่ที่เกี่ยวข้องกับฟังก์ชันและเลเยอร์ของแลมบ์ดาจำนวนมาก การดำเนินการนี้อาจไม่สะดวกนักและคุณจะต้องใช้ 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:*
ด้านบนให้สิทธิ์ CloudWatch Logs เพื่อเรียกใช้ฟังก์ชัน Lambda ของคุณ คำเตือนหนึ่งคำ: ฉันพบว่าการใช้คุณสมบัติ SourceAccount
สามารถนำไปสู่ความขัดแย้งกับ SourceArn
โดยทั่วไป ฉันไม่แนะนำให้รวมไว้เมื่อบริการที่เรียกใช้ฟังก์ชัน Lambda อยู่ในบัญชี AWS เดียวกัน SourceArn
จะห้ามไม่ให้บัญชีอื่นเรียกใช้ฟังก์ชัน Lambda อยู่ดี
ApacheAccessLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheAccessLogFunctionPermission Properties: LogGroupName: !Ref ApacheAccessLogGroup DestinationArn: !GetAtt ProcessApacheAccessLogFunction.Arn FilterPattern: "{$.status = 5*}"
ทรัพยากรตัวกรองการสมัครเป็นลิงค์ระหว่าง CloudWatch Logs และ Lambda ที่นี่ บันทึกที่ส่งไปยัง ApacheAccessLogGroup
จะถูกส่งต่อไปยังฟังก์ชัน Lambda ที่เรากำหนดไว้ข้างต้น แต่เฉพาะบันทึกที่ผ่านรูปแบบตัวกรองเท่านั้น ที่นี่ รูปแบบตัวกรองคาดว่า JSON บางส่วนจะเป็นอินพุต (รูปแบบตัวกรองเริ่มต้นด้วย '{' และลงท้ายด้วย '}') และจะจับคู่รายการบันทึกเฉพาะเมื่อมี status
ช่องที่ขึ้นต้นด้วย "5"
ซึ่งหมายความว่าเราเรียกใช้ฟังก์ชันแลมบ์ดาก็ต่อเมื่อรหัสสถานะ HTTP ที่ส่งกลับโดย Apache เป็นรหัส 500 เท่านั้น ซึ่งมักจะหมายถึงมีบางอย่างที่ไม่ดีเกิดขึ้น เพื่อให้แน่ใจว่าเราไม่เรียกใช้ฟังก์ชัน Lambda มากเกินไป และด้วยเหตุนี้จึงหลีกเลี่ยงค่าใช้จ่ายที่ไม่จำเป็น
ดูข้อมูลเพิ่มเติมเกี่ยวกับรูปแบบตัวกรองได้ในเอกสารประกอบของ Amazon CloudWatch รูปแบบตัวกรอง CloudWatch ค่อนข้างดี แม้ว่าจะไม่ได้มีประสิทธิภาพเท่ากับ Grok ก็ตาม
โปรดสังเกตช่อง DependsOn
ซึ่งช่วยให้มั่นใจว่า CloudWatch Logs สามารถเรียกใช้ฟังก์ชัน Lambda ได้จริงก่อนที่จะสร้างการสมัครรับข้อมูล นี่เป็นเพียงเชอร์รี่บนเค้ก อาจไม่จำเป็นที่สุดในสถานการณ์จริง Apache อาจไม่ได้รับคำขอก่อนอย่างน้อยสองสามวินาที (เช่น: เพื่อเชื่อมโยงอินสแตนซ์ EC2 กับโหลดบาลานเซอร์ และรับโหลด บาลานเซอร์เพื่อรับรู้สถานะของอินสแตนซ์ EC2 ว่ามีประสิทธิภาพ)
ฟังก์ชัน Lambda เพื่อประมวลผลบันทึกข้อผิดพลาด
ตอนนี้ มาดูฟังก์ชันแลมบ์ดาที่จะประมวลผลบันทึกข้อผิดพลาดของ 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
ฟังก์ชันที่สองของ Lambda จะประมวลผลบันทึกข้อผิดพลาดของ Apache และจะโพสต์ข้อความไปยัง Slack เมื่อพบข้อผิดพลาดร้ายแรงเท่านั้น ในกรณีนี้ ข้อความแจ้งเตือนและข้อความเตือน PHP ไม่ถือว่าร้ายแรงพอที่จะทำให้เกิดการแจ้งเตือน
อีกครั้ง ฟังก์ชันนี้คาดว่าบันทึกข้อผิดพลาด Apache จะอยู่ในรูปแบบ JSON นี่คือสตริงรูปแบบบันทึกข้อผิดพลาดที่ฉันใช้:
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
ทรัพยากรนี้ให้สิทธิ์กับ CloudWatch Logs เพื่อเรียกใช้ฟังก์ชัน Lambda ของคุณ
ApacheErrorLogSubscriptionFilter: Type: AWS::Logs::SubscriptionFilter DependsOn: ApacheErrorLogFunctionPermission Properties: LogGroupName: !Ref ApacheErrorLogGroup DestinationArn: !GetAtt ProcessApacheErrorLogFunction.Arn FilterPattern: '{$.msg != "PHP Warning*" && $.msg != "PHP Notice*"}'
สุดท้าย เราเชื่อมโยง CloudWatch Logs กับฟังก์ชัน Lambda โดยใช้ตัวกรองการสมัครสำหรับกลุ่มบันทึกข้อผิดพลาด Apache สังเกตรูปแบบตัวกรอง ซึ่งช่วยให้มั่นใจว่าบันทึกที่มีข้อความที่ขึ้นต้นด้วย "คำเตือน PHP" หรือ "การแจ้งเตือน PHP" จะไม่เรียกให้เรียกใช้ฟังก์ชัน Lambda
ความคิดสุดท้าย ราคา และการวางจำหน่าย
คำพูดสุดท้ายเกี่ยวกับค่าใช้จ่าย: โซลูชันนี้ถูกกว่าการใช้งานคลัสเตอร์ ELK มาก บันทึกที่เก็บไว้ใน CloudWatch มีราคาเท่ากับ S3 และ Lambda อนุญาตให้โทรได้หนึ่งล้านครั้งต่อเดือนโดยเป็นส่วนหนึ่งของระดับฟรี นี่อาจจะเพียงพอสำหรับเว็บไซต์ที่มีปริมาณการใช้งานปานกลางถึงมาก (หากคุณใช้ตัวกรอง CloudWatch Logs) โดยเฉพาะอย่างยิ่งหากคุณเขียนโค้ดได้ดีและไม่มีข้อผิดพลาดมากเกินไป!
นอกจากนี้ โปรดทราบว่าฟังก์ชัน Lambda รองรับการโทรพร้อมกันสูงสุด 1,000 ครั้ง ในขณะที่เขียน นี่เป็นข้อจำกัดที่เข้มงวดใน AWS ที่ไม่สามารถเปลี่ยนแปลงได้ อย่างไรก็ตาม คุณสามารถคาดหวังให้ฟังก์ชันข้างต้นใช้งานได้ประมาณ 30-40 มิลลิวินาที ซึ่งควรจะเร็วพอที่จะรองรับการจราจรที่ค่อนข้างหนาแน่น หากภาระงานของคุณรุนแรงมากจนถึงขีดจำกัดนี้ คุณอาจต้องการโซลูชันที่ซับซ้อนมากขึ้นตาม Kinesis ซึ่งฉันจะกล่าวถึงในบทความต่อๆ ไป
อ่านเพิ่มเติมในบล็อก Toptal Engineering:
- การบันทึก SSH และการจัดการเซสชันโดยใช้ AWS SSM