Scaling with Rails
I have spent much of the past decade now building web applications professionally. Though not exclusively so, a large part of this work has ended up being with the Ruby on Rails framework. There is a widespread notion that monolithic Rails applications don’t scale, but in my experience I have found such applications often the most readily scalable of all that I have encountered.
I wanted to share some high-level insights and personally-acquired knowledge on this subject, in the hopes that such things may be taken into consideration by teams or developers weighing their options in architecting a web application for scale. There are quite a few pitfalls, and these often contribute to an intuitive (if not very deeply introspected) sense that Rails codebases scale poorly, so I will attempt to highlight these pitfalls as well.
Background
To me one of the most overwhelming benefits of Rails is a low complexity surface area. Even a very comprehensive, featureful, mature Rails application can, at scale, consist of just a few infrastructural bits:
- A database. Typically an RDBMS such as PostgreSQL or MySQL
- Web application servers (passing requests through the Ruby + Rails application code). Load-balanced at scale (with something like HAProxy or a proprietary load balancer such as Amazon ELB)
- Queue servers (processing background/out-of-band tasks)
- Application cache (such as Redis or Memcached)
- Object storage (typically delegated to a hosted solution like Amazon S3, Wasabi, etc.)
- Email/SMS etc. transport (typically delegated to a hosted provider like Sendgrid, Mailgun, Twilio etc.)
The low default complexity and relative uniformity of Rails applications makes it possible to focus on the big wins when it comes to infrastructure and scaling.
Infrastructure
One of the great virtues of the relative structural simplicity of Rails applications is that you generally have a very clear roadmap to scaling. Most of the constituent parts of a production Rails deployment scale naturally in a horizontal way. Web request processing, for instance, is stateless and the web application tier can scale out horizontally to as many physical servers and application processes as the database (and cache, etc.) can handle connections for. An identical horizontal scalability can be achieved for job servers.
One big win/common pitfall at this tier is the selection of an application server and load balancing scheme. Today I commonly recommend Phusion Passenger as an application server because it has shown itself to be, in my opinion, since version 5 (2014) at least, far more evolved than competing offerings, while being packaged in a way that makes it relatively painless to manage in production.
In my opinion, Passenger is particularly distinguished in its inclusion of a built-in buffering reverse proxy that itself uses evented I/O which prevents it from suffering the slow-client problem or forcing you to engineer around it in the way that many other Ruby application servers do.
On the matter of application load balancing, while you can provision and manage your own HAProxy nodes with a very precisely tuned configuration, in my experience it often proves more practical to just use the proprietary load balancers offered by your infrastructure provider. I.e. ELB on Amazon AWS, Linode’s NodeBalancers, DigitalOcean’s plainly named Load Balancers, etc. A decent ops solution (see next section) will be able to manage these resources as a standard part of your stack.
Scaling of the database tier usually follows a slightly different protocol from scaling of the application tier, but is still fairly straightforward.
A first step in database scaling is typically just the provisioning of better hardware. With fully managed solutions like Amazon RDS or DigitalOcean’s Managed Databases this can be managed through a simple web interface. A degree of further horizontal scalability can also be achieved (barring extremely write-heavy workloads) with Read Replicas, which, as of framework version 6, Rails now includes native support for.
Ops
Because most Ruby on Rails deployments have a relatively uniform shape, with relatively uniform dependencies, there is now a broad ecosystem of high-level tools for managing Rails applications in production.
One that I continue to recommend, and that I have found to be constantly improving over the 4 years or so that I have been using it, is Cloud66. Cloud66 offers a Platform as a Service-like (PaaS) experience with hardware of your choosing. This means that it assumes responsibility for provisioning all of your resources, firewalling them appropriately (i.e. behind a bastion server), managing access for your team members (ssh, etc.), managing logs, deployment, scheduled jobs and more. Cloud66 will technically allow you work with servers at any provider (so long as you install a Cloud66 daemon on the machines), but deep integration with the most popular cloud providers (AWS, DigitalOcean, Linode, Google Cloud, Azure) means that you get a particularly seamless experience when working with those.
Logic optimizations and caching
Rails is, today, a highly mature framework. The course of its development has been shaped by a characteristic, ruthless pragmatism that I still to this day haven’t seen to the same extent in any other framework.
Many of Rails’ more pragmatic innovations concern application-level caching to reduce database hits and content rendering and consequently lower response times. One of the central and caching faculties of Rails is key-based fragment caching with template digests – an innovation that still, many years later, does not exist in quite the same form in any other popular framework that I am aware of.
The essential point here is that the relatively low complexity surface area of Rails applications leaves the mental space for higher-leverage optimizations like straightforward and robust application-level caching in the places where it makes sense. This means that, while a framework like Phoenix (for the Elixir language running atop the Erlang VM) may offer faster code execution than Rails running atop a Ruby interpreter, in practice, many applications written in such a framework end up having poorer real-world request performance just because they will less broadly leverage higher-level optimizations like application-level caching to avoid unnecessary database hits.
Conclusion
Ruby on Rails, approaching a decade and a half of existence now, is something of an elder statesman among web frameworks. It is not, by default, always the framework that comes to mind when one thinks of scalability. However, such a substantial number of hugely popular, high traffic sites today run atop Rails, and this is no accident: Shopify, Github, Zendesk, Kickstarter, Couchsurfing, Fiverr and many, many more. I hope that this article was able to provide insight into how, in fact, there are very natural and proven paths to scaling with Rails, and that it may be a helpful resource when making framework/infrastructure decisions for yourself or your team.
No Comments