การเขียน Logstash Pipeline แบบถูกวิธี

Nutmos
KBTG Life
Published in
4 min readMay 12, 2020

ก่อนหน้านี้เราได้พูดถึงการใช้งาน Elasticsearch เพื่อซัพพอร์ตงาน Infrastructure ของ KBTG ไปแล้ว (ตอนที่ 1, ตอนที่ 2) ในบทความนี้เราจะมาอธิบายเกี่ยวกับส่วนประกอบส่วนถัดไปใน Elastic Stack กัน

ทวนกันอีกครั้งก่อน Elastic Stack หรือชื่อเดิมคือ ELK Stack มีทั้งหมด 4 อย่าง คือ

  • Elasticsearch เป็นที่เก็บข้อมูลและทำ Index เพื่อการเสิร์ช
  • Kibana หน้า UI ไว้สำหรับการเสิร์ชและจัดการ Elasticsearch
  • Logstash เป็นอุปกรณ์ที่เอาไว้ใช้ประมวลผลข้อมูลก่อนส่งเข้า Elasticsearch
  • Beats เป็น Lightweight Data Shipper สำหรับการส่งข้อมูลมาที่ Logstash หรือ Elasticsearch

สำหรับส่วนประกอบที่เราจะมาทำความรู้จักกันบน Elastic Stack รอบนี้คือ Logstash เป็นอุปกรณ์ไว้ใช้สำหรับประมวลผลข้อมูลก่อนที่จะส่งเข้าไปเก็บใน Elasticsearch

ทำไมต้อง Logstash

จริง ๆ แล้ว Elasticsearch ก็สามารถประมวลผลข้อมูลในตัวเองได้ระดับหนึ่งผ่านความสามารถ Ingest Pipeline รวมถึง Beats เองก็สามารถประมวลผลได้เช่นกัน แต่การประมวลผลของ 2 ส่วนนี้ก็ยังมีข้อจำกัดอยู่มากคือ

Elasticsearch สามารถประมวลผลข้อมูลได้ในรูปแบบง่ายๆ เช่น ข้อมูลที่มาในรูปแบบ JSON, การเขียน Regular Expression แบบตรงๆ หรือการคำนวณในรูปแบบพื้นฐาน แต่ถ้าข้อมูลไม่สามารถเขียนด้วย Regular Expression ได้ หรือต้องใช้การคำนวณที่ซับซ้อนขึ้นอีกเล็กน้อย Elasticsearch ก็ทำไม่ได้แล้ว

ส่วนฝั่ง Beats นั้น แม้จะมีความสามารถในการประมวลผลข้อมูลระดับหนึ่ง แต่ความสามารถดังกล่าวนั้นจำกัดมากๆ ซึ่งการเป็น Lightweight Data Shipper ตามคอนเซ็ปต์แล้วสิ่งที่ Beats ควรจะทำจึงมีแค่เก็บข้อมูลและส่งออกมาเท่านั้น และการประมวลผลใด ๆ ที่เกิดขึ้นใน Beats ควรเป็นการประมวลผลเบื้องต้น เช่น เพิ่มฟิลด์ หรือ Drop Event เป็นต้น

จึงเป็นเหตุผลที่เราต้องมี Logstash ไว้ตรงกลางอีกชั้นหนึ่งระหว่าง Beats กับ Elasticsearch เพราะ Logstash สามารถตอบโจทย์การประมวลผลได้มาก มีปลั๊กอินสำหรับประมวลผลขั้นสูงมากมาย ถ้าปลั๊กอินที่มีให้ไม่พอ จะเขียนโปรแกรมเป็นภาษา Ruby เข้าไปได้เลยก็ได้ เจ๋งมาก!

นอกจากนี้ Logstash ไม่ใช่เครื่องมือที่จำกัดไว้ใช้งานเฉพาะ Beats กับ Elasticsearch เพราะตัว Logstash เองก็เป็น Open Source ที่มีปลั๊กอินสำหรับเชื่อมต่อเยอะมาก ไม่ว่าจะเป็น Kafka, Redis, RabbitMQ, SQS, Google Pub-Sub, Azure Event Hub, JDBC, Syslog, TCP, UDP, STDIN, S3 และอื่นๆ

ด้วยความที่ตัว Logstash เป็น Java จึงกินทรัพยากรดุมาก และการมี Logstash ก็เพิ่มตัวกลางที่ทำให้ทำงานช้าลงไปอีก ดังนั้นถ้าไม่จำเป็นก็สามารถใช้ Beats หรือ Elasticsearch แทนได้

แต่ถ้ายังจำเป็นที่จะต้องใช้ Pipeline ของ Logstash บทความนี้ก็สามารถอธิบายได้ว่าเราควรจะเขียน Pipeline อย่างไรให้ทำงานได้ประสิทธิภาพสูงสุด

ภาพจาก Elastic

Logstash จะมี Pipeline อยู่ 3 ส่วน คือ

  • อินพุต (Input) เป็นส่วนรับข้อมูลเข้ามาประมวลผล
  • ฟิลเตอร์ (Filter) เป็นส่วนการประมวลผลข้อมูล
  • เอาต์พุต (Output) เป็นส่วนการส่งข้อมูลออก

ในบทความนี้จะเน้นเรื่องฟิลเตอร์ เนื่องจากเป็นส่วนหลักในการประมวลผลข้อมูล ซึ่งจะเป็นส่วนที่ใช้ทรัพยากรสูงมากที่สุด

เขียน Regular Expression ให้ชัดเจนที่สุด

ข้อนี้ถือเป็นข้อพื้นฐานและเป็นพระเอกของการเขียน Logstash เพราะการใช้ Logstash แทบทุกครั้งมักจะใช้ Grok หรือฟิลเตอร์ที่เอาไว้ใช้ตัด Log ซึ่งจะเป็นการใช้ Regular Expression (ต่อไปนี้ขอเรียกว่า Regex) ที่อธิบายรูปแบบของ Log ที่ต้องการตัด

สำหรับบทความนี้จะอธิบายเฉพาะการเขียน Regex ให้ได้ประสิทธิภาพที่ดีเท่านั้น ส่วนวิธีการเขียน Regex แบบละเอียดสามารถอ่านได้จาก Elastic

Regex เป็นการอธิบายรูปแบบของ Log ซึ่ง Logstash ได้เตรียมแพทเทิร์นของ Regex มาให้ระดับหนึ่งแล้ว ตามใน GitHub ซึ่งจะมีแพทเทิร์นพื้นฐานให้ระดับหนึ่ง ตั้งแต่คำ ตัวเลข วัน/เดือน/ปี ไปจนถึงแพทเทิร์น Apache Log พื้นฐานก็มีให้เช่นกัน

เมื่อเราต้องเริ่มเขียน Regex เราจะต้องเริ่มพิจารณาฟอร์แมตของ Log ก่อน ยกตัวอย่างเช่น ถ้าเราต้องตัด Log ที่มีฟอร์แมตเป็น GET=50msเราอาจจะมองง่ายๆ เป็น อะไรก็ได้=อะไรก็ได้ms จากนั้นก็ตัด Log โดยใช้ Regex แบบนี้

%{GREEDYDATA:method}=%{GREEDYDATA:responsetime}ms

GREEDYDATA เขียนเป็น Regex Pattern คือ.* หรือถ้าอธิบายเป็นภาษาง่ายๆ คือ “อะไรก็ได้”

แบบนี้คือตัวอย่างที่ไม่สมควรทำเป็นอย่างยิ่งครับ ถ้ามองเผินๆเราอาจจะไม่คิดอะไร แต่จริงๆแล้วส่งผลต่อประสิทธิภาพของ Logstash มากครับ หากลองเทียบกับตัวนี้

(?<method>[A-Z]+)=(?<responsetime>[0-9]+)ms

เมื่อเราเปรียบเทียบ 2 แบบเป็น Finite Automata

หมายเหตุ: ใน Non-deterministic Finite Automata (NFA) ที่ผมเขียนขึ้นมานี้ จะใช้นิยามว่า Σ จะใช้แทนตัวอักษรตัวใดก็ได้

อธิบายง่ายๆ สำหรับคนที่ไม่รู้จัก Finite Automata คือเราจะเริ่มต้นที่โหนด A และเมื่ออ่านตัวอักษรจากสตริง 1 ตัว เราจะเดินตามเส้น 1 ครั้ง ถ้าไปถึงจุดที่มีวงกลมซ้อน 2 ชั้นได้ หมายความว่า Finite Automata นี้ Accept String ซึ่งแปลว่า Regex นั้น match กับสตริง

จากตัวอย่างด้านบน GET=50msหากเรากำลังอ่านตัวอักษรที่ 4 ก็คือ = หรือเครื่องหมายเท่ากับ จะเป็นดังรูปด้านล่าง

สังเกตว่ากรณีแรก เราจะเห็น “ทางไป” ของ Regex แบบแรกทั้งหมดสองทาง ส่วนแบบที่ 2 จะมีเพียงทางเดียว

กรณีของ 2 ทาง ถ้าเราไม่รู้ว่าจะเลือกเดินไปทางไหน แต่เนื่องจากคอมพิวเตอร์ไม่สามารถตัดสินใจตรงนี้ได้ Regex จึงกำหนดเงื่อนไขไปในตัวว่าจะเลือกทางเดินไหนก่อน ซึ่งเงื่อนไขของ Greedydata จะเลือกตัวอักษรของทั้งหมดก่อน หมายความว่าเลือกทางเดินที่ 1 ถ้าไม่ได้จึง Backtrack หรือย้อนกลับมา แล้วเลือกทางเดินที่ 2 และถ้าทางเลือกที่ 2 ไปไม่ได้อีกก็จะได้ผลลัพธ์ว่า Match Fail

ในขณะที่กรณีสอง จะระบุที่ตัวอักษรชัดไปเลย หมายความว่าเรามีทางเลือกให้เดินเพียงทางเดียวเท่านั้น ซึ่งจะเห็นความแตกต่างในการทำงานดังรูปด้านล่าง

แบบที่ 1 (ซ้าย), แบบที่ 2 (ขวา)

จะเห็นว่าแพทเทิร์นแบบที่ 1 จะมีการ Backtrack หลายรอบ ส่วนแบบที่ 2 ไม่มีการ Backtrack เลย ดังนั้นจะเห็นได้ว่า Regex ที่ดี จะต้องลดจำนวนครั้งในการ Backtrack ย้อนกลับมาให้มากที่สุด และผลลัพธ์สุดท้ายก็ยังคงสามารถ Match ได้ผลลัพธ์ตามต้องการก็คือ

method = GET
responsetime = 50

จากตัวอย่างที่ยกมานี้เป็นเพียงสตริงเพียงแค่ 8 ตัวอักษรเท่านั้น ลองคิดดูว่าถ้าเป็นสตริงขนาดใหญ่มากๆ การเขียน Regex ที่ดีจะช่วยประหยัดเวลาในการ Match ได้มากสักแค่ไหน

แม้ว่าจริง ๆ แล้ว ตามทฤษฎี Non-deterministic Finite Automata (NFA) สามารถเขียนเป็น Deterministic Finite Automata (DFA) ที่ทำให้เลือกทางเดินได้เพียงทางเดียวก็จริง แต่สำหรับ Grok รวมถึงเอนจินอื่น ๆ ที่ใช้ Regex ส่วนใหญ่ไม่มีความสามารถนี้ หรือหมายความว่าจะต้องเกิดการลองผิดลองถูกเลือกทางเดินเกิดขึ้นแน่นอน

ดังนั้นทางที่ดีที่สุดคือ เขียน Regex ให้ชัดเจนและลดความกำกวมให้ได้มากที่สุด หรือถ้าอธิบายตาม Finite Automata ก็คือทำให้มีทางเลือกเดินเพียงทางเดียว ลดโอกาสลองเลือกเดิน รวมถึงถ้า Match Fail ก็ควรจะรู้ตัวเพื่อ Fail ให้ไวที่สุดครับ

ลบฟิลด์ที่ไม่จำเป็นทิ้ง

โดยปกติแล้ว การคำนวณใน Logstash อาจทำให้เราต้องทิ้งตัวแปรหรือฟิลด์ไว้เยอะ เช่น อาจจะทำ Grok ครั้งแรก แล้วทำ Grok ครั้งที่สองโดยอ้างอิงสตริงจากครั้งแรก จึงอาจจะเกิดฟิลด์ที่ไม่ได้ใช้จริง แต่เอาไว้ใช้แค่ชั่วคราว

{
"message": "127.0.0.1 - frank [22/Mar/2020:13:40:37 +0700] "GET /image.jpg HTTP/1.0" 200 2326",
"ip": "127.0.0.1",
"timestamp": "22/Mar/2020:13:40:37 +0700",
"method": "GET",
"context_route": "/image.jpg",
"status": "200",
"bytes": 2326
}

อย่างอีเว้นท์ด้านบนนี้ เมื่อ Extract ข้อมูลออกมาแล้ว Message ก็ไม่จำเป็นต้องมีอีกต่อไป

เมื่อฟิลด์เหล่านี้ปรากฏขึ้น ถ้าไม่ลบทิ้งสิ่งที่จะตามมาคือ Logstash จะส่งฟิลด์ทั้งหมดเข้า Elasticsearch และ Elasticsearch ก็จะทำ Index ฟิลด์ทั้งหมด ซึ่งเปลืองพลังงานและพื้นที่มาก

mutate {
remove_field => [ "message" ]
}

ดังนั้น ทางที่ดี ควรจะลบฟิลด์ที่ไม่ใช้ออกให้หมดก่อนส่งเข้า Elasticsearch โดยใช้ฟิลเตอร์ Mutate คำสั่ง remove_field ดังตัวอย่างด้านบนนี้ครับ

ข้อมูลอะไรไม่จำเป็น อย่าส่งเข้า Elasticsearch

ทุกครั้งของการสร้าง Pipeline ของ Logstash สิ่งที่ควรทำอีกอย่างคือการกำหนดว่าข้อมูลอะไรที่จะส่งเข้า Elasticsearch และอะไรที่ไม่จำเป็นก็ไม่ควรส่งเข้าไปเก็บเพราะจะเปลืองทรัพยากรโดยเปล่าประโยชน์ ใช้ฟิลเตอร์ Drop เพื่อสั่งจบอีเว้นท์นี้ซะ

{
"message": "127.0.0.1 - probe [22/Mar/2020:13:40:37 +0700] "GET /health HTTP/1.0" 200 20",
"ip": "127.0.0.1",
"timestamp": "22/Mar/2020:13:40:37 +0700",
"method": "GET",
"context_route": "/health",
"status": "200",
"bytes": 20
}

อย่างอีเว้นท์ด้านบนนี้เป็น Healthcheck ไม่ใช่ข้อมูลจากผู้เข้าใช้งานจริง ไม่จำเป็นต้องเก็บก็ได้

โดยปกติแล้ว ข้อมูลที่จะเข้า Logstash จะมาเป็นอีเว้นท์ ดังนั้นถ้าเรา Drop อีเว้นท์ใด ก็จะไม่มีผลกระทบกับอีเว้นท์อื่นอยู่แล้ว

นอกจากการสั่ง Drop ที่ส่วนฟิลเตอร์แล้ว จะดักเป็น If ที่ฝั่งเอาต์พุตก่อนส่งเข้า Elasticsearch ก็ได้เช่นกัน

ใส่ ID ให้ข้อมูล

จากพฤติกรรมของ Elastic Stack ที่หลายๆ ตัวจะมีการ Confirm ว่าข้อมูลต้องถึงปลายทางจึงจะหยุดส่ง มิฉะนั้นจะส่งไปเรื่อยๆ (เรียกว่า At least once เพื่อให้มั่นใจว่าข้อมูลจะถึงปลายทาง แต่อาจจะมีการส่งซ้ำได้) แต่ถ้าคลัสเตอร์เกิดปัญหากระตุก เราอาจพบข้อมูลเดียวกันเก็บซ้ำ ๆ กันนับสิบนับร้อยครั้ง

Elasticsearch จะมีตัวที่บ่งบอก ID ของข้อมูล เป็นฟิลด์ชื่อว่า _id คือข้อมูลแต่ละ record ของ Elasticsearch ที่อยู่ใน index เดียวกัน จะไม่มีค่า _id ที่ซ้ำกัน ถ้าเจอค่า _id ซ้ำกับของเก่า Elasticsearch จะนำข้อมูลที่รับเข้ามาใหม่ไปวางทับข้อมูลเก่าแทน ซึ่งถ้าฟิลด์ _id ไม่ได้ใส่เข้ามาในข้อมูลด้วย Elasticsearch จะกำหนดให้เป็นค่าที่ไม่ซ้ำใครใน Index (แม้ว่าข้อมูลจะเหมือนกันก็ตามที)

{
"message": "127.0.0.1 - frank [22/Mar/2020:13:40:37 +0700] "GET /image.jpg HTTP/1.0" 200 2326",
"fingerprint": "09dbdc1ce75b656dbff11878d4326d34"
}

ดังนั้นเพื่อป้องกันพฤติกรรมข้อมูลเดียวกันไปปรากฏซ้ำ ๆ กันบน Elasticsearch สิ่งที่เราทำได้เบื้องต้นคือการกำหนด ID ให้ข้อมูลเอง โดยเราจะต้องมั่นใจว่า ID นั้น ๆ จะไม่เหมือนกับข้อมูล Record อื่นภายใน Index เดียวกันด้วย

วิธีที่ผมทำเพื่อให้มั่นใจว่า ID จะเป็นค่าที่เหมือนกันเสมอและไม่ซ้ำกับ Record อื่น คือการแฮชข้อความทั้งหมด (ซึ่งก็คือข้อความทั้งหมดในตัว Log ที่รับเข้ามา โดยมากจะเก็บไว้ในฟิลด์ที่ชื่อว่า Message) เนื่องจากตามสภาพของ Log มักจะมีเวลาแปะเข้ามา เท่ากับว่าข้อมูลแต่ละตัวก็จะไม่เหมือนกันอยู่แล้ว

...
filter {
fingerprint {
method => "MD5",
source => "message",
target => "fingerprint"
}
}
elasticsearch {
index => "apache-%{+YYYY.MM.dd}"
document_id => "%{fingerprint}"
}

Logstash จะมีฟิลเตอร์ปลั๊กอินตัวหนึ่งที่ชื่อว่า Fingerprint จะเป็นฟิลเตอร์ที่เอาไว้คำนวณค่าแฮช เราสามารถเลือกแฮชได้หลายรูปแบบตามที่เราต้องการ แล้วก่อนจะส่งข้อมูลเข้า Elasticsearch เราก็ใส่ document_id เป็นฟิลด์ที่แฮชออกมา เพียงเท่านี้ก็จบปัญหา Elasticsearch เก็บข้อความแบบซ้ำ ๆ กันแล้ว

เลือกใช้ฟิลเตอร์ที่มีอยู่ ก่อนจะใช้ Ruby

Logstash มีฟิลเตอร์มาให้เลือกเป็นจำนวนมาก โดยฟิลเตอร์นั้นก็ถือว่าครอบคลุมการใช้งานในหลากหลายรูปแบบ และมีฟิลเตอร์ที่น่าสนใจคือ Ruby ที่เรียกได้ว่าเป็นฟิลเตอร์ไม้ตายสุดท้าย เพราะว่าถ้าเราไม่มีฟิลเตอร์ใด ๆ เราก็ยังเขียนโปรแกรมลงไปได้ด้วยภาษา Ruby

จริง ๆ แล้วการที่เราเขียนโปรแกรม การจัดการตัว Pipeline จะยากกว่าการใช้ฟิลเตอร์ที่เป็นปลั๊กอินมาก เพราะการใช้ฟิลเตอร์ผู้ที่มาอ่านต่อจะทราบทันทีว่าฟิลเตอร์นั้นๆใช้ทำอะไร และมีเอกสารให้อ่านบนเว็บไซต์ของ Elastic ในขณะที่การเขียนโปรแกรมนั้นผู้อ่านจะต้องมาตีความว่าผู้เขียนต้องการทำอะไรกันแน่

เพื่อความง่ายในการจัดการ Pipeline ในอนาคต ผมจึงแนะนำให้ใช้ Ruby เท่าที่จำเป็น และพยายามเลือกใช้ปลั๊กอินของ Logstash ให้ได้มากที่สุดครับ

สรุป

Logstash เป็นเครื่องมือที่สามารถโปรเซสงาน Log ได้มาก และแน่นอนว่ามันช่วยลดงานประมวลผลของ Elasticsearch รวมถึง Beats ได้ด้วย แต่เนื่องจากความใหญ่ของ Logstash การเขียน Pipeline ให้ถูกวิธีก็สามารถลดการใช้แรงประมวลผลอย่างไม่จำเป็นลงได้มาก

ดังนั้น คิดสักนิดในการเขียน Pipeline ให้ Logstash ก็จะช่วยให้ Logstash ทำงานได้อย่างมีประสิทธิภาพมากขึ้นครับ

--

--