- 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#children và Array 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
Ý 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
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
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
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 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
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
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
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 đề
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 íchTô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
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
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
@[manualfree], và cũng có thể tắt cho toàn bộ project bằngv -gc nonehttps://vlang.io
“Đã 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