1 điểm bởi GN⁺ 2024-05-12 | 1 bình luận | Chia sẻ qua WhatsApp
  • Bộ nhớ của Dyno web trong một ứng dụng Rails production 10 năm tuổi tăng vọt trong lúc triển khai, và vì đây là dịch vụ xử lý tải liên tục 400~500 req/s và lên tới hàng nghìn req/s ở giờ cao điểm nên cần giảm thiểu rất nhanh
  • Trên Heroku, đã khởi động lại các Dyno gần chạm giới hạn bộ nhớ và hoàn tác các thay đổi mã nguồn/chỉ số trong 3 ngày gần nhất, nhưng rò rỉ bộ nhớ vẫn tiếp diễn
  • Trong khi Sidekiq và Delayed::Job hoạt động bình thường, chỉ một số Puma worker phình ra, cho thấy nghi ngờ có liên quan tới một loại traffic cụ thể
  • Kết quả theo dõi heap bằng rbtrace, ObjectSpace, heapy, sheap, reap cho thấy thread xử lý request của Puma đang giữ 32.067 object và 1.9GiB bộ nhớ thông qua mảng @children của ActiveSupport::Notifications::Event
  • Query parameter bị thao túng đã gây ra URI::InvalidURIError trong quá trình Bugsnag dọn dẹp URL, và cách xử lý ngắn hạn là nâng cấp Bugsnag, còn dài hạn là nâng cấp Rails

Rò rỉ bắt đầu trong ứng dụng Rails đang vận hành

  • Đối tượng là một ứng dụng Rails 10 năm tuổi, là dịch vụ production tạo ra doanh thu thực tế
  • Tải duy trì bình thường là 400~500 req/s, còn đỉnh điểm có thể lên tới hàng nghìn request mỗi giây
  • Trong luồng triển khai thông thường, memory spike bắt đầu xuất hiện và pager phát cảnh báo
  • Do chạy trên Heroku nên tình trạng được theo dõi dựa trên chỉ số bộ nhớ của từng Dyno

Giảm thiểu sự cố bắt đầu bằng việc khởi động lại Dyno

  • Hiện tượng trông không giống memory bloat đơn thuần mà giống rò rỉ hơn, và biện pháp tạm thời là khởi động lại process
  • Bình thường nhiều lần deploy mỗi ngày đã khởi động lại các instance web, nhưng những Dyno tiến sát giới hạn bộ nhớ thì được khởi động lại thủ công

Hoàn tác các thay đổi đáng ngờ nhưng rò rỉ vẫn còn

  • Đã rà soát ngược lại 3 ngày thay đổi mã nguồn kể từ trước đợt spike lớn đầu tiên
  • Có ba thay đổi trông có khả năng liên quan
    • Một thay đổi gây rò rỉ bộ nhớ do Rails code reloading trong chế độ development
    • Một thay đổi khiến số lần gọi Redis tăng nhiều hơn dự kiến khi lọc một số request nhất định
    • Một thay đổi kiểu N+1 làm phát sinh nhiều truy vấn cơ sở dữ liệu hơn và tải thêm các instance ActiveRecord
  • Sau khi sửa hai thay đổi đầu và rollback thay đổi thứ ba rồi deploy từng cái một, rò rỉ vẫn tiếp diễn
  • Ngay cả việc hoàn tác thay đổi công cụ dùng để thu thập metric ngôn ngữ Ruby và metric sử dụng Puma pool cũng không ngăn được mức tăng bộ nhớ

Mẫu rò rỉ chỉ về một loại traffic cụ thể

  • Rò rỉ chỉ xảy ra trên Dyno web, còn các Dyno Sidekiq và Delayed::Job trông vẫn bình thường
  • Không phải mọi Dyno web đều luôn bị rò rỉ
    • Trong vài giờ, mức dùng bộ nhớ khá phẳng như một web process chạy lâu thông thường
    • Sau đó đến một thời điểm nào đó, một, một số, hoặc toàn bộ Dyno bắt đầu rò rỉ
  • Puma chạy ở cluster mode, và mỗi Dyno dùng 12 worker process cho 8 vCPU
  • Ngay trong một Dyno, cũng có trường hợp chỉ một số trong 12 worker dùng gần như toàn bộ bộ nhớ
  • OpenTelemetry Traces bị sampling mạnh nên khó nối loại request cụ thể với Dyno cụ thể, và việc tương quan với log không bị sampling cũng không dễ với công cụ hiện có

Quy trình thu thập heap dump

  • Để attach vào process Ruby đang chạy, đã dùng rbtrace
  • rbtrace phải được load sẵn trong process nên nó được thêm vào Gemfile, và việc load được điều khiển bằng biến môi trường
gem "rbtrace", require: String(ENV.fetch("FEATURE_ENABLE_MEMORY_DUMPS", false)) == "true"
  • Trên Heroku, dùng heroku ps:exec để mở SSH tunnel tới Dyno đang rò rỉ, rồi dùng ps để sắp xếp các process Ruby theo RSS
ps -eo pid,ppid,comm,rss,vsz --sort -rss | grep ruby
  • Trên Dyno web, các process có cùng PPID là Puma worker, và PID của worker dùng nhiều bộ nhớ nhất được chọn làm mục tiêu
  • Việc theo dõi memory allocation được bật bằng ObjectSpace.trace_object_allocations_start, có thể ảnh hưởng tới hiệu năng, bộ nhớ và CPU
DUMP_PID=<pid>
rbtrace --pid="${DUMP_PID}" --eval="Thread.new{require 'objspace';ObjectSpace.trace_object_allocations_start}.join"
  • Heap dump được tạo vào /tmp bằng ObjectSpace.dump_all, và với process rò rỉ đã chạy vài giờ, file JSON có thể phình tới 5~6GiB
rbtrace --pid="${DUMP_PID}" --eval="Thread.new{require 'objspace'; GC.start(); io=File.open('/tmp/heap-${DUMP_PID}.json', 'w'); ObjectSpace.dump_all(output: io); io.close}.join" --timeout=600
gzip "/tmp/heap-${DUMP_PID}.json"
  • Trên Heroku, dump được kéo về máy local bằng heroku ps:copy, và để xem retained memory bằng heapy thì thu thập ít nhất khoảng ba dump
  • Sau khi xong việc, allocation tracking được tắt và dump bị xóa hoặc Dyno được khởi động lại

Phân tích heap cho thấy một Thread đang giữ 1.9GiB

  • Chỉ với báo cáo retained memory của heapy và diff của sheap thì khó tìm được điểm bắt đầu
  • Đã tạo flame graph bằng reap, công cụ phân tích và trực quan hóa đồ thị tham chiếu của Ruby heap dump
  • Flame graph hiển thị các tham chiếu từ root theo góc nhìn của Ruby GC xuống các object phía dưới, và object giữ càng nhiều bộ nhớ thì ô càng rộng
  • Trong heap dump thứ ba, một Thread đang giữ 1.9GiB bộ nhớ
  • Thực tế, Array ở phía dưới đang tham chiếu 32.067 object và giữ lại 1.9GiB

Lần theo đường tham chiếu bằng sheap

  • Dùng sheap ở nhánh main mới nhất để so sánh dump thứ hai và thứ ba
  • Vì kích thước dump gần 6GiB nên việc parse mất khá nhiều thời gian
  • Kết quả find_path cho thấy Thread có vấn đề không phải là background thread của công cụ telemetry hay metric mà là Puma thread xử lý request
  • ActiveSupport::SubscriberQueueRegistry trong Rails 6.1 hoạt động như một Hash theo thread, lưu danh sách ActiveSupport::Subscriber theo tên event
  • Registry đó tham chiếu tới một Hash, và một trong các Array bên trong nó đang giữ ActiveSupport::Notifications::Event
  • Event đó lại tham chiếu hơn 32.067 object Event con thông qua mảng @children
  • Tên của Event con đầu tiên là redirect_to.action_controller, và bên trong có object ActionDispatch::Request

Request bất thường trở thành manh mối để tái hiện

  • ActionDispatch::Request trong heap có route thực tế và ID tài nguyên công khai hợp lệ, nhưng query parameter lại ở dạng bị thao túng
  • Trong đường dẫn request có password=[FILTERED], cho thấy quá trình làm sạch dữ liệu nhạy cảm đã can thiệp
  • Khi gửi request cùng đường dẫn và parameter đó tới ứng dụng production bằng cửa sổ trình duyệt ẩn danh, đã xuất hiện 500 server error
  • Trong log có URI::InvalidURIError, và cũng xác định được Dyno đã nhận request đó
  • Dyno đó khi ấy vẫn có mức dùng bộ nhớ bình thường, nhưng khi tạm dừng deploy và tiếp tục quan sát thì xu hướng rò rỉ xuất hiện
  • Ở local, đã chèn binding.pry và debug bằng puts vào gem activesupport để tái hiện cùng tình huống và backtrace

Nguyên nhân thực sự là sự kết hợp giữa thay đổi ở Rails và Bugsnag

  • Error backtrace chỉ tới gem uri trong thư viện chuẩn của Ruby, và nó đang được dùng trong Bugsnag.cleaner.clean_url của Bugsnag
  • Đoạn mã này nằm trong quá trình làm sạch Rails breadcrumb URL bên trong block ActiveSupport::Notifications.subscribe
  • Vấn đề là sự kết hợp của hai yếu tố
    • ActiveSupport::Subscriber của Rails 6.1 theo dõi event bằng Event#children và một Array dùng chung
    • Thay đổi ở Bugsnag dùng URI để làm sạch Rails breadcrumb URL, và có thể phát sinh exception với URI không hợp lệ
  • Khi URI ném lỗi với invalid URI, block subscribe của Bugsnag phát sinh exception trong lúc xử lý ActiveSupport::Notifications::Event
  • Vì exception đó, Event cha không bị pop khỏi Subscriber#event_stack, nên Event cha còn sót lại và gây rò rỉ bộ nhớ
  • Event cha tiếp tục tham chiếu các Event con qua mảng #children, từ đó giữ lại ngày càng nhiều bộ nhớ
  • Bản sửa trong Rails 7.1 của John Hawthorn đã loại bỏ khái niệm Event#childrenArray dùng chung để theo dõi event, qua đó loại bỏ đồng thời hai nguyên nhân rò rỉ này

Cách khắc phục là nâng cấp Bugsnag và nâng cấp Rails

  • Ở phiên bản Rails mới nhất, vấn đề này không còn xảy ra nữa nhờ bản sửa của John Hawthorn
  • Khi đó ứng dụng vẫn đang ở Rails 6.1 nên chưa thể nhận ngay hiệu quả từ bản sửa của Rails
  • Bugsnag thì đã sửa để Bugsnag.cleaner.clean_url không còn ném exception với invalid URI
  • Cách xử lý ngắn hạn là nâng cấp lên phiên bản Bugsnag Gem đã bao gồm bản sửa đó
  • Cách xử lý dài hạn là nâng cấp phiên bản Rails
  • Thay đổi trùng với thời điểm memory spike đầu tiên là nâng cấp Bugsnag từ v6.26.0 lên v6.26.1, với mục đích sửa deprecation warning của một dependency khác

1 bình luận

 
GN⁺ 2024-05-12
Ý kiến trên Hacker News
  • Tôi không hiểu vì sao quản lý bộ nhớ thủ công lại đáng sợ đến vậy. Chỉ cần RAII và các quy tắc sở hữu rõ ràng thì quản lý bộ nhớ là một công việc kỹ thuật dễ dàng
    Ngược lại, các framework ép dùng đếm tham chiếu hoặc con trỏ chia sẻ còn khiến tôi thấy khó hơn, vì quyền sở hữu trở nên mờ nhạt
    Tự tạo thì tự giải phóng; đã chuyển giao thì không bận tâm nữa. Các tài nguyên OS như handle, socket cũng được quản lý thủ công mà không cần trình quản lý tài nguyên tự động, nên tôi không thấy lý do gì phải làm thiết kế phức tạp hơn bằng quản lý bộ nhớ tự động

    • Quản lý bộ nhớ thủ công làm tăng tải nhận thức khi suy luận về phần mềm. Dung lượng bộ nhớ làm việc khác nhau rất lớn giữa mỗi người, và trở thành yếu tố giới hạn hiệu năng khi thiết kế các hệ thống phức tạp
      Sau nhiều năm phát triển, tôi dần thấy rằng phần lớn lập trình viên không có đủ khoảng trống trong bộ nhớ làm việc để đồng thời suy luận cả về quản lý bộ nhớ. Dù có biết cách làm một cách máy móc, nếu phải tung hứng quá nhiều thứ trong đầu thì sẽ bỏ sót
      Ngược lại, cũng có một số ít người hầu như lần nào cũng làm đúng quản lý bộ nhớ thủ công mà không mấy khó khăn. Với họ, việc đó thực sự dễ, nên họ khó cảm nhận được vì sao nó khó với người khác. Với những người như vậy, quản lý bộ nhớ tự động có thể trông như lợi ích thì không rõ ràng mà nhược điểm lại lớn
    • Tôi xem lỗi bộ nhớ gần như là một loại lỗi đã được giải quyết. Nếu dùng ngôn ngữ có garbage collector hiện đại xử lý được tham chiếu vòng, rất có khả năng bạn sẽ không gặp lỗi bộ nhớ nào trong suốt dự án
      Nói đại khái, không phải các lỗi kiểu này bị thay thế bằng lỗi khác, mà chúng đơn giản là biến mất. Nó cũng không đòi hỏi lập trình viên làm nhiều việc hơn; trái lại còn giảm việc phải làm so với quản lý bộ nhớ thủ công
      Tất nhiên garbage collection không phải lúc nào cũng thắng và nó có nhược điểm thực sự. Nhưng với đa số chương trình, garbage collector hiện đại đủ tốt để các nhược điểm đó không thành vấn đề lớn
    • Vấn đề không hẳn là bản thân quản lý bộ nhớ khó, mà là lập trình viên không hoàn hảo, nên rất khó viết chương trình hoàn toàn không có hành vi không xác định và rò rỉ. Chỉ một sai sót cũng có thể tạo ra CVE, mức tăng bộ nhớ dần dần trong chương trình chạy lâu, hoặc lỗi cứ 1000 lần mới nổ một lần
      Lỗi logic cũng có vấn đề tương tự và ngay cả trong các ngôn ngữ như Java đôi khi vẫn có thể rò rỉ bộ nhớ, nhưng ngôn ngữ an toàn bộ nhớ là một cải tiến. Nó tương tự việc TypeScript tốt hơn JavaScript. Khi đã có tự động hóa có thể giảm lỗi bộ nhớ từ 1% xuống 0,01%, tôi không hiểu vì sao việc ngăn rò rỉ và hành vi không xác định vẫn phải tiếp tục là mối bận tâm thủ công
      Bạn có thể dùng ngôn ngữ có garbage collection dễ như Java nhưng có overhead, hoặc dùng ngôn ngữ cưỡng chế quyền sở hữu như Rust, có đường cong học tập nhưng không overhead. Lỗi logic cũng đau đầu, nhưng lỗi bộ nhớ đặc biệt khét tiếng vì có khi không đưa ra thông báo lỗi rõ ràng, hoặc dù xảy ra chương trình vẫn không dừng
      Nhân tiện, kiểm chứng hình thức cũng là một cách gần như loại bỏ hẳn một nhóm lỗi. Hiện giờ nó chỉ xuất hiện trong các hệ thống mà tính đúng đắn là quan trọng nhất, vì khác với quản lý bộ nhớ, nhược điểm của nó quá lớn. Mã cực kỳ dài dòng, khó viết và bị ép theo cấu trúc nhất định. Nhưng nếu kiểm chứng hình thức trở nên tốt hơn, tôi nghĩ nó cũng sẽ trở nên phổ biến hơn
    • Tôi đã làm quản lý bộ nhớ thủ công trong các hệ thống 24/7 suốt 10 năm, nhưng không thấy nhớ nó. Bản thân việc đó không khó hay đáng sợ, nhưng nếu là cấu trúc có thể sinh vòng tham chiếu hoặc kiến trúc dựa trên event handler thường xuyên chuyển tham chiếu qua lại, thì thay vì chỉ tập trung vào miền bài toán, bạn phải thiết kế việc quản lý bộ nhớ rất cẩn thận
    • Việc 35% lỗ hổng ở các công ty công nghệ lớn là do lỗi use-after-free là một phần của câu trả lời. Hơn 90% lỗ hổng nghiêm trọng xuất phát từ lỗi bộ nhớ vốn không thể xảy ra trong ngôn ngữ an toàn bộ nhớ
  • “Tôi không phải là một lập trình viên thực thụ. Tôi chắp vá đủ thứ sao cho có vẻ chạy được rồi bỏ qua. Các lập trình viên thực thụ sẽ nói: ‘Nó có chạy, nhưng bộ nhớ đang rò rỉ khắp nơi. Có lẽ nên sửa chứ?’ Còn tôi thì cứ mỗi 10 request lại khởi động lại Apache.” — Rasmus Lerdorf, PHP Non-Designer
    https://en.wikiquote.org/wiki/Rasmus_Lerdorf

    • Nếu biết chính xác vòng đời của process, không bao giờ gọi free() cũng là một chiến lược quản lý bộ nhớ hợp lệ
  • Chỗ tôi từng làm trước đây xứng đáng nhận giải cách ngu ngốc nhất để mất 5 triệu đô la vì rò rỉ bộ nhớ
    Driver máy in trên Solaris thập niên 90 có rò rỉ bộ nhớ[1]. Khi đó tôi làm nhà thầu cho một ngân hàng lớn; thời ấy, trong việc xác nhận hợp đồng, địa vị pháp lý của fax chưa được kiểm chứng đầy đủ tại tòa, nên các ngân hàng ghi lại giao dịch bằng fax. Hệ thống gửi fax cũng gửi tài liệu đến một máy in cụ thể để in xác nhận giao dịch, rồi có người cầm bản xác nhận đó đọc qua điện thoại cho bên kia, lưu lại trong bản ghi âm cuộc gọi[2] và xác nhận về mặt pháp lý
    Một ngày nọ, vì rò rỉ bộ nhớ, driver máy in chết, khiến một bản xác nhận không được in ra, và người phụ trách không thể đọc qua điện thoại. Thị trường biến động mạnh và bên kia xử lý giao dịch đó là DK[3]. Dù các lãnh đạo ngân hàng có làm ầm ĩ thế nào cũng vô ích; sau khi ghi nhận khoản lỗ 5 triệu đô la vào sổ sách, họ lập chính sách không bao giờ giao dịch với ngân hàng đó nữa[4]. Công việc in fax được chuyển sang Windows NT
    [1] Theo cuốn sách xuất sắc “Expert C Programming”, vấn đề này được sửa sau khi Scott McNealy, CEO của Sun Microsystems lúc bấy giờ, dù là CEO nhưng nhận một workstation hiệu năng thấp nên thường xuyên gặp sự cố, và sau khi phàn nàn đủ nhiều thì cuối cùng các lập trình viên đã sửa nó https://progforperf.github.io/Expert_C_Programming.pdf
    [2] Các cuộc gọi của bộ phận chứng khoán trong ngân hàng hầu như luôn được ghi âm vì lý do pháp lý và tuân thủ
    [3] DK là viết tắt của “Don’t know”. Khi bên kia nói họ “không biết” giao dịch, tức là họ tranh chấp việc hợp đồng đã được hình thành
    [4] Bên kia chỉ cần giao dịch ở nơi khác và trả phí cho ngân hàng khác, nên có lẽ phía chúng tôi mới là bên thiệt hơn

    • Có thể tôi quá yếm thế, nhưng tôi tự hỏi liệu có nhiều doanh nghiệp sẽ thừa nhận sau đó một giao dịch gây thiệt hại khổng lồ cho chính họ hay không. Nếu quy trình yêu cầu xác nhận bằng văn bản và xác nhận qua điện thoại, mà cuộc gọi đó không diễn ra, thì tôi thắc mắc vì sao bên này chứ không phải bên kia phải chịu lỗ
      Citi cũng từng vướng kiện tụng vì trả khoản vay quá sớm. Trong giới tài chính, tôi cho rằng nếu có lợi cho mình thì ai cũng sẽ bám chặt vào hợp đồng bằng văn bản
  • Trong C, nhờ Valgrind nên việc tìm rò rỉ rất dễ
    Sửa thì khó hơn, nhưng nếu thiết kế đúng thì thường cũng dễ. Thông thường, trừ khi đó là hàm cấp phát cho caller, ta cấp phát và giải phóng trong cùng một hàm. Nếu là hàm cấp phát cho caller thì bản thân lời gọi đó được xem như phần cấp phát ở phía caller

    • Cái khó là tái hiện lỗi
      Khi phân tích tĩnh codebase, các đường xử lý lỗi là nguyên nhân phổ biến nhất của vấn đề
    • Trong C cũng làm tương tự, nhưng nghĩ về nó như các mức scope khác nhau bên trong abstraction
      Giống như có block scope, function scope, file scope, global scope, trong các model là abstraction của miền vấn đề hoặc lời giải cũng có nhiều mức scope. Chỉ là tôi chưa từng thấy điều này được dạy
      Nếu một scope nào đó lấy tài nguyên ở $SCOPE::foo() mà không giải phóng ở $SCOPE::cleanup() thì nhìn bằng mắt cũng khá dễ phát hiện. Khả năng mô hình hóa miền vấn đề và lời giải được đề xuất trước khi lao vào code là hữu ích
  • Tôi nhớ đến một câu chuyện từng nghe về Yahoo. Ad server của họ bị rò rỉ bộ nhớ, nên sau khoảng 10000 request thì bị hết bộ nhớ
    Cách giải quyết là khởi động lại server sau 8000 request. Cách này hiệu quả trong 1–2 năm, nhưng về sau ngay cả sau 8000 request cũng bắt đầu hết bộ nhớ
    Cách giải quyết tiếp theo là khởi động lại server sau 6000 request

    • Trên một ad server trung bình, 8000 request vào khoảng 500 mili giây
      Muốn cách đó hiệu quả thì việc restart phải cực kỳ nhanh
  • Khi còn là Rails developer, việc ném thêm phần cứng vào những vấn đề như thế này được xem là một đánh đổi hợp lý cho năng suất. Không khí chung là nếu bạn bận tâm đến các vấn đề kiểu này thì cứ dùng công cụ nghiêm ngặt hơn
    Cá nhân tôi khó chấp nhận cách tiếp cận đó vì có xu hướng cầu toàn, nhưng khó phủ nhận là nó thật sự chạy được

    • Thay vì thừa nhận rằng reboot server mỗi 10 phút để xóa rò rỉ bộ nhớ, cứ gọi nó là chiến lược cấp phát arena theo giai đoạn thì nghe ổn hơn
  • Tôi đã dùng cả ngôn ngữ có garbage collection lẫn không có. Thường thì quản lý thủ công khó viết hơn, còn quản lý tự động thì khó xử lý sự cố hơn
    Tôi muốn dùng một ngôn ngữ có thể làm được cả hai. Khi viết code thăm dò thì quản lý bộ nhớ tự động tiện hơn, còn với một số loại code thì quản lý bộ nhớ thủ công có lợi hơn
    Thật bực khi không tìm được điểm giữa giữa cấm đoán và bắt buộc

    • V mặc định dùng garbage collector, nhưng có thể dễ dàng tắt theo từng hàm hoặc module bằng thuộc tính @[manualfree], và cũng có thể tắt cho toàn bộ project bằng v -gc none
      https://vlang.io
    • Ngôn ngữ đó là C++. Hầu như không cần quản lý bộ nhớ thủ công, nhưng nếu muốn thì vẫn làm được
  • “Đã có rất nhiều bài viết về các công cụ profiling rò rỉ, cách hiểu heap dump, và các nguyên nhân rò rỉ thường gặp”
    Ừm, rò rỉ với heap dump cơ đấy. Có vẻ ai đó cần một chế độ ăn lành mạnh hơn