12/08/2018, 16:28

Hiệu chỉnh Puma, Unicorn and Passenger để được hiệu quả tốt nhất.

Trong Ruby, các application server web giống như xăng trong xe hơi: những thứ "tốt" sẽ không làm cho chiếc xe của bạn đi nhanh hơn, nhưng những thứ "khó chịu" sẽ khiến xe của bạn bị mài mòn. Các Application servers không thể thực sự làm cho ứng dụng của bạn nhanh hơn, tất cả chúng đều giống nhau và ...

Trong Ruby, các application server web giống như xăng trong xe hơi: những thứ "tốt" sẽ không làm cho chiếc xe của bạn đi nhanh hơn, nhưng những thứ "khó chịu" sẽ khiến xe của bạn bị mài mòn. Các Application servers không thể thực sự làm cho ứng dụng của bạn nhanh hơn, tất cả chúng đều giống nhau và thay đổi từ ứng dụng này sang ứng dụng khác sẽ không cải thiện được thời gian truyền hoặc phản ứng trong ứng dụng của bạn nhiều. Nhưng thật dễ dàng để tự phá nát ứng dụng với một thiết lập xấu hoặc máy chủ cấu hình sai. Đó là một trong những vấn đề phổ biến nhất mình thấy trên client application.
Bài này sẽ giới thiệu về việc tối ưu hóa việc sử dụng tài nguyên (bộ nhớ và CPU) và tối đa hoá thông lượng (có nghĩa là yêu cầu mỗi giây) từ ba máy chủ ứng dụng của Ruby: Puma, Unicorn và Passenger. Mình sẽ sử dụng thuật ngữ "server" và "container" thay thế cho nhau, bởi vì không có gì ở đây đặc trưng cho môi trường ảo hóa.
Mình sẽ nói bao gồm cả ba máy chủ ứng dụng phổ biến trong một hướng dẫn vì tất cả đều sử dụng về cơ bản đều cùng một thiết kế. Với the fork system call, các application servers này tạo ra một số tiến trình con, sau đó thực hiện công việc phục vụ yêu cầu. Hầu hết các sự khác biệt giữa các máy chủ này nằm ở các chi tiết tốt hơn (mà mình cũng sẽ đề cập ở đây, điều rất quan trọng cho hiệu năng tối đa).
Trong suốt hướng dẫn này, mình sẽ cố gắng tối đa hóa công suất thông qua mỗi máy chủ. Mình muốn phục vụ hầu hết số request mỗi giây cho số lượng máy chủ thấp nhất.

Có 4 cài đặt cơ bản trên application server của bạn xác định hiệu suất và mức tiêu thụ tài nguyên của nó:

  • Số lượng các tiến trình con
  • Số lượng threads
  • Copy-on-write.
  • Kích thước của Container (Server)

Hãy bắt đầu đi qua từng phần nào.

Số lượng các tiến trình con

Cả 3 Unicorn, Puma và Passenger đều sử dụng forking design. Điều này có nghĩa là họ tạo ra một quy trình ứng dụng (application process) và gọi fork một số lần để tạo ra bản sao của quá trình đăng ký đó. Mình gọi những quy trình con này là bản sao. Số lượng các tiến trình con chúng ta có trên mỗi máy chủ có lẽ là thiết lập quan trọng nhất để tối đa hóa thông lượng cho mỗi server. Chúng ta đều muốn chạy nhiều process nhất cho mỗi máy chủ có thể mà không vượt quá nguồn lực của máy chủ.
Mình có tìm hiểu được lời khuyên khá hay tất cả các ứng dụng web Ruby chạy ít nhất 3 quy trình cho mỗi máy chủ hoặc vùng chứa. Điều này tối đa hóa hiệu suất định tuyến. Puma và Unicorn đều sử dụng thiết kế để tiến trình con lắng nghe trực tiếp trên một socket duy nhất, và để cho hệ điều hành cân bằng tải giữa các tiến trình. Passenger sử dụng một proxy ngược (nginx hoặc Apache) để định tuyến các yêu cầu cho quá trình con. Cả hai phương pháp đều hiệu quả và có nghĩa là một yêu cầu sẽ được chuyển tới một worker rảnh. Việc định tuyến ở các lớp cao hơn (tức là tại bộ cân bằng tải hoặc lưới HTTP của Heroku) khó thực hiện hơn rất nhiều bởi vì bộ cân bằng tải thường không có ý tưởng xem các máy chủ định tuyến có đang bận hay không

Nào cùng xem xét một thiết lập với 3 máy chủ, mỗi lần chạy 1 quy trình (vì vậy tổng cộng là 3 quy trình). Bộ cân bằng tải tối ưu hóa một yêu cầu tới một trong ba máy chủ như thế nào? . . . . . Nó có thể làm điều đó ngẫu nhiên hoặc theo kiểu vòng tròn, nhưng điều này không đảm bảo rằng yêu cầu sẽ được chuyển đến một máy chủ với một quy trình nhàn rỗi, chờ đợi. Ví dụ: với chiến lược round-robin, giả sử yêu cầu A được định tuyến đến máy chủ số 1. Yêu cầu B sau đó được chuyển đến máy chủ số 2, và Yêu cầu C đến máy chủ số 3. Bây giờ yêu cầu thứ tư yêu cầu D. Điều gì sẽ xảy ra nếu yêu cầu B và C đã được phục vụ thành công và các máy chủ (2 và 3) không hoạt động, nhưng Yêu cầu A là xuất khẩu CSV của ai đó và sẽ mất 20 giây để hoàn thành? Trình cân bằng tải sẽ tiếp tục đưa ra yêu cầu đến máy chủ số 1 ngay cả khi nó bận và sẽ không xử lý chúng cho đến khi nó được thực hiện với yêu cầu A. Từ vấn đề này, tất cả các cân bằng tải có cách để biết máy chủ đã "chết" hoàn toàn hay không nhưng phần lớn các phương pháp này có thời gian trễ dài (tức là 30 giây trở lên chậm trễ). Chạy nhiều tiến trình cho mỗi máy chủ sẽ bảo vệ chúng ta khỏi nguy cơ requests lâu dài "hogging" bởi phần lớn các quá trình con của máy chủ, bởi vì ở cấp độ máy chủ, các yêu cầu (request) sẽ không bao giờ được trao cho quá trình (process) đã quá bận. Thay vào đó, họ sẽ sao lưu ở cấp socket hoặc ngược proxy cho đến khi một worker được free. Mình đọc thấy rằng 3 quy trình cho mỗi máy chủ là một tối thiểu tốt để đạt được điều này. Nếu bạn không thể chạy ít nhất 3 quy trình cho mỗi máy chủ do hạn chế nguồn lực, hãy có được một máy chủ lớn hơn (nhiều hơn về sau).

Vì vậy, chúng ta nên chạy ít nhất 3 tiến trình con cho mỗi container (server). Nhưng tối đa là gì? Đó là hạn chế bởi bộ nhớ và tài nguyên CPU của chúng ta.
Hãy bắt đầu với bộ nhớ. Mỗi quá trình con sử dụng một bộ nhớ nhất định. Rõ ràng, chúng ta không nên thêm nhiều tiến trình con hơn RAM của máy chủ có thể hỗ trợ! Việc đo lường bộ nhớ thực tế của một process trong ứng dụng Ruby duy nhất có thể rất phức tạp. Vì một số lý do, application process web của Ruby tăng quá trình sử dụng bộ nhớ theo thời gian, thậm chí tăng gấp đôi hoặc gấp ba lần sử dụng bộ nhớ từ khi chúng được sinh ra. Để có được một phép đo chính xác số lượng bộ nhớ của bạn quá trình ứng dụng Ruby đang sử dụng, vô hiệu hóa tất cả các quá trình khởi động lại (kill tất cả process) và chờ 12-24 giờ để đo với ps. Nếu bạn đang sử dụng Heroku, bạn có thể sử dụng Heroku Exec mới để sử dụng ps trên một dyno chạy, hoặc chỉ đơn giản chia số liệu sử dụng bộ nhớ của Heroku với số lượng các quy trình bạn đang chạy trên một dyno. Hầu hết các ứng dụng Ruby sẽ sử dụng từ 200 đến 400 MB mỗi quá trình, nhưng một số có thể sử dụng đến 1GB.

Hãy chắc chắn để cho mình một số headroom về số lượng bộ nhớ - nếu bạn muốn một phương trình để đếm số lượng tiến trình con nó có thể như sau: TOTAL_RAM /// (RAM_PER_PROCESS ∗* 1,2)

Vượt quá dung lượng bộ nhớ sẵn có của máy chủ / vùng chứa có thể gây ra sự chậm chạp lớn khi bộ nhớ bị tràn và việc trao đổi bắt đầu xảy ra. Đây là lý do tại sao bạn muốn việc sử dụng bộ nhớ của ứng dụng của bạn có thể dự đoán được và không có đột biến đột ngột. Tăng đột ngột mức sử dụng bộ nhớ là một điều kiện mà tôi gọi là bộ nhớ sưng lên. Giải quyết vấn đề này là một chủ đề cho một ngày hoặc bài đăng khác, tuy nhiên chủ đề này được để cập trong bài viết sau: The Complete Guide to Rails Performance.
Thứ hai, chúng ta sẽ không muốn vượt quá dung lượng CPU hiện có của máy chủ. Ta không nên dành nhiều hơn 5% tổng thời gian triển khai của mình ở mức sử dụng CPU 100% - nhiều hơn nghĩa là chúng tôi đang bị nghẽn bởi dung lượng CPU hiện có. Hầu hết các ứng dụng Ruby và Rails đều có khuynh hướng bị "nghẽn" bộ nhớ trên hầu hết các nhà cung cấp đám mây, nhưng đôi khi CPU cũng có thể là nguyên nhân"nghẽn cổ chai". Làm sao bạn biết? Chỉ cần sử dụng công cụ theo dõi máy chủ yêu thích của bạn - các công cụ tích hợp sẵn của AWS có thể đủ để xác định xem việc sử dụng CPU có thường xuyên bị loại ra hay không.

Người ta thường nói rằng bạn không nên có nhiều tiến trình con trên mỗi máy chủ hơn là các CPU. Điều này chỉ đúng một phần. Đó là một điểm khởi đầu tốt, nhưng việc sử dụng CPU thật sự là số liệu bạn nên xem và tối ưu hóa. Trong thực tế, hầu hết các ứng dụng có lẽ sẽ ổn định ở một quá trình đếm 1,25-1,5x số hyperthreads có sẵn.

Trên Heroku, sử dụng log-runtime-metrics để có được số liệu tải CPU ghi vào nhật ký của bạn. Mình sẽ xem xét các trung bình 5 và 15 phút tải - nếu họ có liên tục gần hoặc cao hơn 1, bạn đang maxing ra CPU và cần phải giảm quá trình con quá trình

Việc thiết lập số lượng quá trình con là khá dễ dàng trong mọi máy chủ ứng dụng:

# Puma
$ puma -w 3 # Command-line option
workers 3 # in your config/puma.rb

# Unicorn
worker_processes 3 # config/unicorn.rb

# Passenger (nginx/Standalone)
# Passenger can automatically scale workers up and down - I don't find this
# super useful. Instead, just run a constant number by setting the max and min:
passenger_max_pool_size 3;
passenger_min_instances 3;

Thay vì đặt giá trị này thành số cứng, bạn có thể đặt nó vào một biến môi trường như WEB_CONCURRENCY:

workers Integer(ENV["WEB_CONCURRENCY"] || 3)

Nói tóm lại, hầu hết các ứng dụng sẽ muốn sử dụng 3-8 tiến trình cho mỗi máy chủ, tùy thuộc vào các tài nguyên có sẵn. Ứng dụng hoặc ứng dụng bị hạn chế bộ nhớ cao có tỷ lệ 95% (5-10 giây trở lên) có thể muốn chạy số tiến trình cao hơn, tối đa 4 lần số lượng hyperthread sẵn có. Còn lại hầu hết các quy trình con của ứng dụng không được vượt quá 1,5 lần lượng hyperthreads sẵn có.

Cài đặt số lượng Thread

Puma và Passenger Enterprise hỗ trợ đa luồng ứng dụng của bạn, vì vậy phần bài viết này nhằm vào các máy chủ đó.
Các threads có thể là một cách tiết kiệm tài nguyên để cải thiện sự đồng thời trong các tính năng của ứng dụng. Rails đã là threadsafe, và hầu hết các ứng dụng đều không làm những điều kỳ quái như tạo ra các thread của riêng họ hoặc sử dụng globals để truy cập vào tài nguyên chia sẻ, như các kết nối cơ sở dữ liệu. Vì vậy, hầu hết các ứng dụng web Ruby đều an toàn cho thread . Cách duy nhất để biết là thực sự cho nó một shot. Các ứng dụng Ruby có khuynh hướng thể hiện các luồng lỗi một cách to lớn, ngoại lệ, vì vậy bạn có thể dễ dàng thực hiện nó và xem kết quả.

Vậy chúng ta nên sử dụng bao nhiêu thread? Việc tăng tốc bạn có thể đạt được từ tính song song - bổ sung sẽ phụ thuộc vào phần thực hiện của chương trình mà bạn có thể thực hiện song song. Điều này được gọi là Amdahl's Law

Trong MRI / C Ruby, chúng ta chỉ có thể đợi song song IO (ví dụ như chờ kết quả cơ sở dữ liệu). Đối với hầu hết các ứng dụng web, đây có thể là 10-25% tổng thời gian của họ. Bạn có thể kiểm tra đơn của riêng bạn bằng cách xem xét thời gian bạn dành "trong cơ sở dữ liệu" cho mỗi yêu cầu. Thật không may, Amdahl's Law cho thấy rằng đối với các chương trình có phần nhỏ xử lý song song (ít hơn 50%), sẽ có rất ít lợi ích khi vượt qua một ngưỡng threads. Mình có tìm hiểu: trên ứng dụng khách hàng, cài đặt thread lớn hơn 5 không có hiệu lực. Noah Gibbs cũng đã kiểm tra điều này dựa trên tiêu chuẩn homepage benchmark và xác định số thread hiệu quả nhất là 6.

Không giống với tiến trình (process) - nơi mà phần trước mình có khuyến cáo các bạn nên thường xuyên kiểm tra các số liệu thì thread mình nghĩ chỉ cần set nó là 5 thread cho mỗi ứng dụng và quên nó đi.

Trong MRI / C Ruby, các threads có thể có tác động đáng kể đến bộ nhớ. Điều này là do một loạt các lý do phức tạp. Hãy chắc chắn kiểm tra việc sử dụng bộ nhớ trước và sau khi thêm thread vào ứng dụng. Đừng mong rằng mỗi thread sẽ chỉ tiêu thụ thêm 8MB dung lượng stack, chúng sẽ làm tăng tổng bộ nhớ sử dụng nhiều hơn thế.
Dưới đây là cách thiết lập số lượng thread:

# Puma. Again, I don't really use the "automatic" spin-up/spin-down features, so
# I set the max and min to the same number.
$ puma -t 5:5 # Command-line option
threads 5, 5 # in your config/puma.rb

# Passenger (nginx/Standalone)
passenger_concurrency_model thread;
passenger_thread_count 5;

Hành vi copy-on-write

Tất cả các hệ điều hành dựa trên Unix đều thực hiện hành vi copy-on-write. Nó khá đơn giản: khi một quá trình forks và tạo ra một đối tượng con, đó quá trình con, bộ nhớ được chia sẻ, hoàn toàn, với quá trình cha mẹ. Tất cả bộ nhớ đọc từ quá trình con sẽ đơn giản đọc từ bộ nhớ của cha mẹ. Tuy nhiên, việc sửa đổi một vị trí bộ nhớ tạo ra một bản sao, chỉ để sử dụng cá nhân của quá trình con. Nó rất hữu ích cho việc giảm sử dụng bộ nhớ của forking server web, vì các quy trình con, trên lý thuyết, có thể chia sẻ những thứ như thư viện chia sẻ và bộ nhớ khác "chỉ đọc" với cha mẹ, chứ không phải là tạo bản sao của riêng mình.
Copy-on-write chỉ là sự việc xảy ra, nó ko thể bị "tắt" bởi bất cứ gì khác nhưng bạn có thể làm cho nó trở nên hiệu quả hơn. Về cơ bản, chúng ta muốn tải tất cả các ứng dụng của chúng ta trước khi forking. Hầu hết các server web của Ruby đều gọi đây là "preloading". Tất cả là việc thay đổi khi gọi là fork - trước hoặc sau khi ứng dụng của bạn được khởi tạo.

Bạn cũng cần phải kết nối lại với bất kỳ cơ sở dữ liệu nào bạn đang sử dụng sau khi forking. Ví dụ, với ActiveRecord:

# Puma
preload_app!
on_worker_boot do
  # Valid on Rails 4.1+ using the `config/database.yml` method of setting `pool` size
  ActiveRecord::Base.establish_connection
end

# Unicorn
preload_app true
after_fork do |server, worker|
	ActiveRecord::Base.establish_connection
end

# Passenger uses preloading by default, so no need to turn it on.
# Passenger automatically establishes connections to ActiveRecord,
# but for other DBs, you will have to:
PhusionPassenger.on_event(:starting_worker_process) do |forked|
  if forked
    reestablish_connection_to_database # depends on the DB
  end
end

Về lý thuyết, bạn phải làm điều này cho mọi cơ sở dữ liệu mà ứng dụng của bạn sử dụng. Tuy nhiên, trong thực tế, Sidekiq không cố kết nối với Redis cho đến khi bạn thực sự cố gắng làm điều gì đó, do đó, trừ khi bạn đang chạy các công việc của Sidekiq trong quá trình khởi động ứng dụng, bạn không phải kết nối lại sau khi fork.
Thật không may, có những giới hạn đối với lợi ích của việc copy-on-write. Các trang khổng lồ trong suốt có thể gây ra những việc như sửa đổi bộ nhớ 1-bit có thể dẫn đến sao chép toàn bộ trang 2MB và phân mảnh cũng có thể hạn chế tiết kiệm. Nhưng nó ko làm tổn thương quá nhiều nên hãy bật preloading bằng mọi cách nhé.

Kích thước container(server)

Nhìn chung, chúng tôi muốn đảm bảo rằng chúng tôi đang sử dụng 70-80% CPU và bộ nhớ của máy chủ. Những nhu cầu này sẽ khác nhau giữa các ứng dụng, và tỷ lệ giữa lõi CPU và GB bộ nhớ sẽ lần lượt khác nhau. Một ứng dụng có thể là hạnh phúc nhất trên một máy chủ RAM 4 vCPU / 4 GB với 6 quy trình Ruby, trong khi ứng dụng khác ít bộ nhớ hơn và CPU nặng hơn có thể làm tốt với 8 vCPU và 2 GB RAM. Không có kích thước container hoàn hảo nào, nhưng bạn nên chọn tỷ lệ giữa CPU và bộ nhớ dựa trên số liệu sản xuất thực tế của bạn.
Số lượng bộ nhớ có sẵn cho máy chủ hoặc vùng chứa của chúng ta có lẽ là một trong những tài nguyên quan trọng nhất mà chúng ta có thể điều chỉnh. Đối với nhiều nhà cung cấp, số này là cực kỳ thấp - ví dụ, 512MB trên tiêu chuẩn Heroku dyno.
ĐI vào thực tế với ứng dụng của Rails. Bởi vì hầu hết các ứng dụng Rails sử dụng ~ 300MB bộ nhớ RAM cho một tiến trình và mình nghĩ rằng tất cả mọi người nên chạy ít nhất 3 tiến trình cho mỗi máy chủ, vì vậy nên hầu hết các ứng dụng Rails sẽ cần một máy chủ với ít nhất 1 GB RAM.

Tài nguyên CPU của máy chủ là một đòn bẩy quan trọng khác mà chúng ta có thể điều chỉnh. Chúng ta cần phải biết có bao nhiêu lõi CPU có sẵn, và bao nhiêu luồng mà chúng ta có thể thực hiện tại một thời điểm duy nhất (về cơ bản, máy chủ này có hỗ trợ Hyper-Threading hay không?)

Như tôi đã đề cập trong cuộc thảo luận về số lượng quy trình con, container(server) của bạn nên hỗ trợ ít nhất 3 tiến trình con. Thậm chí tốt hơn sẽ là 8 hoặc nhiều quy trình cho mỗi server / container. Số lượng quy trình cao hơn trên mỗi container cải thiện yêu cầu định tuyến và giảm độ trễ một cách đáng kể.

Tổng kết

Đây là một tổng quan mà mình tìm được trên mạng về làm thế nào để tối đa hóa thông lượng của các application server web Ruby của bạn. Đây là các bước:

  1. Xác định xem 1 worker với 5 threads sử dụng sẽ tốn bao nhiêu dung lượng bộ nhớ. Nếu bạn đang sử dụng Unicorn, rõ ràng là không có thread nào cần dùng. Chỉ chạy một vài worker trên một server duy nhất dưới nền production trong ít nhất 12 giờ mà không cần khởi động lại. Sử dụng ps để có được việc sử dụng bộ nhớ của một worker điển hình.
  2. Chọn kích thước container(server) với bộ nhớ bằng ít nhất 3 lần số đó. Hầu hết các ứng dụng Rails sẽ sử dụng ~ 300-400MB RAM cho mỗi worker. Vì vậy, hầu hết các ứng dụng Rails sẽ cần ít nhất 1 GB (container / server). Điều này cho phép chúng ta đủ bộ nhớ để chạy ít nhất 3 tiến trình cho mỗi máy chủ.
  3. Kiểm tra số CPU core/hyperthread. Nếu container của bạn có ít hơn hyperthreads (vCPUs trên AWS) so với bộ nhớ có thể hỗ trợ, bạn có thể chọn kích thước vùng chứa với bộ nhớ ít hơn hoặc nhiều CPU hơn.
  4. Deploy sau đo xem CPU và bộ nhớ tiêu thụ thế nào. Điều chỉnh quy trình con và kích thước container phù hợp để tối đa hóa việc sử dụng.

Mình cũng chưa có nhiều kinh nghiệm deploy, trên đây là các tìm hiểu và dịch lại của mình. Mong mọi người góp ý để bài viết hữu ích hơn. Xin cảm ơn mọi người đã chú ý bài viết.

0