Adding Concurrency to Rails Apps with Unicorn
blog.heroku.com
blog.heroku.com
One interesting thing about the post is the mention of Puma[2] as the webserver for Rails app. Puma is thread based, so, its memory footprint is significantly less than unicorn. So, in theory, you should be able to handle more concurrent requests(more threads) per dyno with Puma.
Btw, don't just blindly follow the post. Make sure you test your app and see whether your app use up more memory each dyno allows. If your dyno runs out of memory, Heroku will just silently drop all your requests. So, you might end up having a worse problem.
[1]: In general, Rails uses up quite a lot of memory on cold start. [2]: http://puma.io/
I've done some stuff with routes, but I feel there are other wins we could get. The good news is we have `ObjectSpace.memsize_of_all`:
irb(main):001:0> require 'objspace'
=> true
irb(main):002:0> ObjectSpace.memsize_of_all(String)
=> 169318
irb(main):003:0> x = 'x' * 1024; nil
=> nil
irb(main):004:0> ObjectSpace.memsize_of_all(String)
=> 171817
irb(main):005:0>
What's cool is that in MRI we can get size of the instruction sequences too: irb(main):005:0> ObjectSpace.memsize_of_all(RubyVM::InstructionSequence)
=> 1600800
irb(main):006:0> 100.times { |i| eval("def x_#{i}; end") }
=> 100
irb(main):007:0> ObjectSpace.memsize_of_all(RubyVM::InstructionSequence)
=> 1711904
Also we can see the delta between methods defined with eval vs define_method: irb(main):011:0> ObjectSpace.memsize_of_all(RubyVM::InstructionSequence)
=> 1717984
irb(main):012:0> 100.times { |i| define_method("x_#{i}") { } }
=> 100
irb(main):013:0> ObjectSpace.memsize_of_all(RubyVM::InstructionSequence)
=> 1722416
:-)EDIT: stop saying "cool" so much. :-/
Thanks to copy on write, the amount of memory needed for 2 processes is strictly less than 2. Just make sure to preload as much data as you can before you fork. And in many use cases memory slowly comes unstuck (change anything, and a whole page of data comes unshared) so it can make sense to kill workers every so often and let them refork with everything properly shared.
Another issue with using the brightbox packages is that if you happen to run into a bug, you'll have to figure out if the bug was caused by something non-standard in your ruby installation or if it's actually a bug in ruby.
One last thing to note is that it looks like the latest brightbox release targets patchlevel 327, while ruby core is at 392 (not counting 2.0.0-p0), so you're missing a lot bug fixes until the brightbox team gets around to building against the latest release.
Your other two points are true though. We consider the trade-off worth it for the memory gains.
after_fork do |server, worker| size_in_bytes = 60010241024 # megabytes Process.setrlimit(Process::RLIMIT_AS, size_in_bytes)
www-data 21714 0.0 5.6 102928 87164 ? Sl Feb26 0:30 unicorn_rails master -c production.conf.rb -E production -D
www-data 21762 0.2 8.5 150672 131996 ? Sl Feb26 1:58 unicorn_rails worker[0] -c production.conf.rb -E production -D
www-data 21768 0.2 8.5 152036 132352 ? Sl Feb26 1:58 unicorn_rails worker[1] -c production.conf.rb -E production -D
www-data 21774 0.2 8.1 145248 125668 ? Sl Feb26 1:56 unicorn_rails worker[2] -c production.conf.rb -E production -D
www-data 21780 0.2 8.3 148792 129736 ? Sl Feb26 2:01 unicorn_rails worker[3] -c production.conf.rb -E production -D
www-data 21786 0.1 8.3 148384 129260 ? Sl Feb26 1:48 unicorn_rails worker[4] -c production.conf.rb -E production -D
www-data 21792 0.1 8.6 152156 133880 ? Sl Feb26 1:52 unicorn_rails worker[5] -c production.conf.rb -E production -DI can confirm that. In my previous company, we had a lot of H12 (request timeout) on heroku. We thought this should be our codebase fault, without being able to isolate why some usually fast requests were sometime so long to complete. As a workaround, we decided to temporary switch to unicorn to add more concurrency to our dynos.
H12 went down, but we were flooded by R14 (memory exceeded).
Even with two workers, it was too much. Granted, our app was eating too much memory and we were refactoring it to split it into several distinct apps.
We also had the problem that heroku router seemed to send requests to dyno while master unicorn process was starting (and thus, could not process it yet), which was a major problem since we used hirefire, which keep starting new dynos(and thus, new unicorn masters).
ps -o rss,pid= -U username | sort -k 1 -nr | head -2 | awk -F: '{FS=" ";if ($1>limit*1000) print $2}' limit=`jot -r 1 144 192` | xargs kill -QUIT
It will gracefully kill 2 most memory-consuming unicorn workers above random range of 144-192MB of memory. It proved to be extremely efficient and simple solution to our needs.
It is probably about the same as buying more dynos -- I would think that unicorn set to fork three times is actually about the same as three dynos with thin in standard configuration -- as you say, either way it's three processes, each of which can only handle one request at a time.
It is _probably_ not a solution to the bad worst-end latencies that can occur when you have random (or round-robin) routing, and relatively high variation in request durations.
Using a multi-threaded request dispatch (NOT what unicorn does, but what they start hinting at towards the end) _may_ be a solution to that though. Although multi-threaded concurrency is STILL not "at all the same kind of node.js-style concurrency", as node.js is evented, not multi-threaded. But multi-threaded concurrency seems likely to make sense with random or round robin routing too. Although more research and data is called for (at least more than I have, which is zero).
But there's no reason to assume that node.js-style evented concurrency is the only thing that can possibly make sense with random routing. And if it is, that's very inconvenient. Trying to do node.js-style evented concurrency with a Rails app... or any app that wasn't built from the ground-up (including all it's dependencies) for evented-style concurrency... or even if it WAS... it's non-trivial.
In contrast, multi-threaded request dispatch requires not a lot of change to your code -- basically just avoiding shared in-memory state between requests (basically, global/class variable access).
In addition to puma, thin in fact does have a multi-threaded mode, although it's poorly documented.
I think they are actually passing the buck suggesting "We can't tell you which way to do multi-threaded request handling, it depends on your app!" Well, sure, it always depends on your app, but that applies to their suggestions to use unicorn as well, but that didn't stop them. There are still often general best practices for typical web apps.
I think it's true that they can't tell us the right way to do multi-threaded request handling -- because it requires more research and analysis and experimentation (and possibly a patch or two) to figure out the best practices here, the community hasn't put enough into it yet. But if anyone's got the resources to put into it, it's heroku, and it would be rather good for their business to figure this out and educate the community. Heroku became so respected because they seemed to really know what they were doing, to be at the top of the game -- if they can't manage to figure this out either, it lowers our trust in them.
Problem is, Heroku doesn't run a reverse proxy anymore on its Cedar stack. Rainbows[1] is a server based on Unicorn but designed to also handle longer requests/slow clients.
We use Nginx in front of Unicorn as suggested, however we also do synchronous file uploads and thumbnailing, the process takes a while (maybe 20 seconds) and I would like to understand whether we should be using Rainbows for this.
Quite some time ago I wrote (mostly for my own entertainment) a little example project to try to cut Dyno use for Python programs, although many of the precepts are exactly the same: https://github.com/fdr/double-boiler
For instance, our app which I'd say is a medium sized rails app, generally consumes between 175mb and 210mb of memory. So we /should/ be safe running 2 workers per dyno which would effectively double our processing power.
HOWEVER, if you hover over the memory graph it'll tell you the min/max/avg consumption over that particular time period. Looking at that I see we sometimes spike the max over 350mb. The question is, what is that spike going to look like running unicorn. Unfortunately, I can't think of many ways to accurately test/predict that without simply giving it a try.
One other thing that was mentioned in the comments of the article is that you can set the unicorn backlog to a very low number (default appears to be 1024) which will apparently tell the heroku router that a particular dyno is "full" and to look somewhere else to process the request. One comment recommends setting the backlog to 25. I wonder what setting the backlog to something like #workers*2 would do.
I see no open issues, 2 closed issues, and 5 forks, in which two or three configuration settings are tuned.
I'm not sure that actually means it's handling slow-clients, though.