ในฐานะ APM แบบ all-in-one ที่เติบโตต่อเนื่อง เราใช้เวลามากมายในการทำให้ AppSignal รองรับการเพิ่มขึ้นของ traffic ปกติเราไม่คุยเรื่องว่าทำอย่างไร บล็อกของเราเต็มไปด้วยบทความเกี่ยวกับสิ่งยอดเยี่ยมภายใต้ฮู้ดของ Ruby หรือการทำสิ่งบ้าบั่นกับ Elixir แต่ไม่ใช่เกี่ยวกับสิ่งที่ทำให้ AppSignal ทำงาน
ครั้งนี้อย่างไรก็ตาม เราอยากแชร์การเปลี่ยนแปลงใหญ่ๆ ใน stack ของเราที่ทำมาเป็นปีๆ เพื่อให้เราสามารถ process double-digit billions ของ requests ที่ส่งมาทุกเดือนได้ แบบ real-time วันนี้เราใช้ประสบการณ์การ scale ของเราเพื่อ discuss stack ของตัวเองและช่วยคุณด้วยวิธีนั้น
จาก Standard Rails Setup สู่ Custom Parts มากขึ้น
AppSignal เริ่มต้นเป็น standard Rails setup ค่อนข้างมาก เราใช้ Rails app ที่ collect data ผ่าน API endpoint ซึ่งสร้าง Sidekiq jobs เพื่อ process ใน background
หลังจากผ่านไปพักหนึ่ง เราแทนที่ Rails API ด้วย Rack middleware เพื่อได้ speed เล็กน้อย และทีหลังมันถูกแทนที่ด้วย Go web server ที่ push Sidekiq-compatible jobs ไปยัง Redis
App State และ Increments/Updates
ในขณะที่ setup นี้ทำงานได้ดีเป็นเวลานาน เราเริ่มเจอปัญหาที่ databases ไม่ follow up กับจำนวน queries ที่รันติดได้ ณ จุดนี้เรากำลัง process tens of billions ของ requests แล้ว สาเหตุหลักคือแต่ละ Sidekiq process ต้อง get entire app state จาก database เพื่อ increment counters ที่ถูกต้องและ update documents ที่ถูกต้อง
เรา mitigate ได้บ้างด้วย local caching ของข้อมูล แต่เพราะ nature แบบ round-robin ของ setup ของเรายังหมายความว่าแต่ละ server ต้องมี full cache ของข้อมูลทั้งหมด เพราะเราไม่แน่ใจว่า payload จะไปตกที่ server ไหน เราตระหนักว่าด้วยการเติบโตของข้อมูลที่เรากำลังประสบ setup นี้จะกลายเป็น impossible ในอนาคต
Enter Kafka
ในการหาวิธีที่ดีกว่าจัดการข้อมูล เราตัดสินใจใช้ Kafka เป็น data processing pipeline แทนที่จะ aggregate metrics ใน database เราตอนนี้ aggregate metrics ใน Kafka processors เป้าหมายคือ Kafka pipeline ของเราไม่ควร query database จนกว่าข้อมูลที่ aggregated จะต้อง flush นี่ทำให้จำนวน queries ต่อ payload ลดลงจากสูงสุดสิบ reads และ writes เหลือเพียง one write ที่ท้าย pipeline
เราระบุ key สำหรับแต่ละ Kafka message และ Kafka รับประกันว่า keys เดียวกันจะไปอยู่ partition เดียวกัน ที่ถูก consume โดย server เดียวกัน เราใช้ app ID เป็น key สำหรับ messages หมายความว่าแทนที่จะมี cache สำหรับลูกค้าทั้งหมดบน server เราต้อง cache ข้อมูลสำหรับ apps ที่ server ได้รับจาก Kafka เท่านั้น ไม่ใช่ทั้งหมด
Kafka เป็นระบบที่ยอดเยี่ยม และเรา migrate มาในสองปีที่ผ่านมา ตอนนี้เกือบการ process ทั้งหมดทำใน Rust ผ่าน Kafka แต่ยังมีบางอย่างที่ทำง่ายกว่าใน Ruby เช่นการส่ง Notifications และงาน database-heavy อื่นๆ นี่หมายความว่าเราต้องการวิธีบางอย่างเพื่อ get data จาก Kafka ไปยัง Rails stack ของเรา
เชื่อมต่อ Kafka และ Ruby/Rails
เมื่อเราเริ่ม transition นี้ มี Kafka Ruby gems หลายตัว แต่ไม่มีตัวไหนทำงานกับ latest release ของ Kafka (ตอนนั้น 0.10.x) และส่วนใหญ่ unmaintained
เราคิดจะเขียน gem เอง (ซึ่งเราทำในที่สุด) เราจะเขียนเพิ่มเติมในบทความอื่น แตการมี driver ที่ดีเป็นเพียงส่วนหนึ่งของ requirements เรายังต้องการระบบ consume data และ execute tasks ใน Ruby และ spawn workers ใหม่เมื่อเก่าตาย
ในที่สุดเรามาถึง solution ที่ต่างออกไป Stack Kafka ของเราสร้างใน Rust และเราเขียน binary เล็กน้อยที่ consume topic sidekiq_out และสร้าง Sidekiq-compatible jobs ใน Redis วิธีนี้เราสามารถ deploy binary นี้บน worker machines และมันจะ feed jobs ใหม่เข้า Sidekiq เหมือนที่คุณทำภายใน Rails เอง
Binary มี options เล็กน้อย เช่น จำกัด amount ของ data ใน Redis เพื่อหยุด consume Kafka topic จนกว่า threshold จะ clear วิธีนี้ข้อมูลทั้งหมดจาก Kafka จะไม่ไปตกใน memory ของ Redis บน workers ถ้ามี backlog
จากมุมมองของ Ruby ไม่มีความแตกต่างเลยระหว่าง jobs ที่ generate ใน Rails และ jobs ที่มาจาก Kafka มันทำให้เรา prototype workers ใหม่ที่ได้ data จาก Kafka และ process ใน Rails — ส่ง notifications และ update database — โดยไม่ต้องรู้อะไรเกี่ยวกับ Kafka
มันทำให้ migration ไป Kafka ง่ายขึ้น เพราะเราสามารถ switch ไป Kafka และกลับมาโดยไม่ต้อง deploy Ruby code ใหม่ มันยังทำให้ testing ง่ายมาก เพราะคุณ generate jobs ใน test suite เพื่อ consumed โดย Ruby ได้โดยไม่ต้อง setup Kafka stack ทั้งหมด locally
เราใช้ Protobuf define ทุก (internal) messages ของเรา วิธีนี้เราแน่ใจได้ค่อนข้างมากว่าถ้า test pass worker จะ process jobs จาก Kafka ถูกต้อง
ในที่สุด solution นี้ประหยัดเวลาและพลังงานมาก และทำให้ชีวิตง่ายขึ้นมากสำหรับ Ruby team ของเรา
Pros and Cons
เหมือนทุกอย่างมี pros และ cons สำหรับ setup นี้:
Pros:
- ไม่ต้องเปลี่ยนแปลงใน Ruby, API compatible
- Deploy และ revert ง่าย
- Switch ระหว่าง Kafka และ Ruby ง่าย
- Redis ไม่ overloaded ด้วย messages เมื่อใช้ limiter ประหยัด memory บน server เก็บ messages ไว้ใน Kafka แทน
- Horizontal scaling นำไปสู่ smaller caches บนแต่ละ server เพราะ keyed messages
Cons:
- ยังมีปัญหาที่แต่ละ Sidekiq thread ต้องเข้าถึง cache ของข้อมูลทั้งหมดสำหรับ apps จาก partitions ที่ server consume (เช่น Memcache)
- Separate process running บน server
- Rust processor commit message offset เมื่อ message ถูก flush ไป Redis หมายความว่า guaranteed จะอยู่ใน Redis แต่ไม่มี guarantee ว่าจะถูก process โดย Ruby หมายความว่ากรณี server crash มีโอกาสบาง messages ที่อยู่ใน Redis แตยังไม่ process จะไม่ถูก process
Sidekiq และ Kafka
การใช้ Sidekiq ช่วยเรามากขณะ migrate processing pipeline ไป Kafka เราตอนนี้ move away จาก Sidekiq เกือบหมดแล้ว และจัดการทุกอย่างผ่าน Kafka driver โดยตรง แต่เป็นเรื่องสำหรับบทความอื่น
Happy ending นี้ปิดท้ายเรื่องราวรัก เราหวังว่าคุณเพลิดเพลินกับมุมมองนี้เกี่ยวกับ performance และ scaling และประสบการณ์ scaling AppSignal ของเรา เราหวังว่าตัวอย่างเรื่องราวเกี่ยวกับ decisions ที่เราทำรอบ stack ของเรา จะช่วยคุณได้
ดูบทความอื่นๆ ในบล็อกหรือ follow เราเพื่อดูตอนต่อไปเกี่ยวกับ Kafka setup ของเรา และถ้าคุณหา all-in-one APM ที่เป็นจริงโดย developers สำหรับ developers มาหาเราได้เลย