Interestingly, at the same time, another high-traffic Rails web app called Penny Arcade was doing just fine. Why? Because it had no funky overly-custom code, had clearly mapped dependencies, and hailed well with connected databases.
Remember: Ruby supports multi-processing within apps. In some cases, multi-process apps can perform better than multi-thread ones. But the trick with processes is that they consume more memory and have more complex dependencies. If you inadvertently kill a parent process, children processes will not get informed about the termination and thus, turn into sluggish “zombie” processes. This means they’ll keep running and consume resources. So watch out for those!
Suboptimal Database Setup
In the early days, Twitter had intensive write workloads and poorly organized read patterns, which were non-compatible with database sharding.
At present, a lot of Rails developers are still skimming on coding proper database indexes and triple-checking all queries for redundant requests. Slow database queries, lack of caching, and tangled database indexes can throw any good Rails app off the rails (pun intended).
Sometimes, complex database design is also part of deliberate decisions, as was the case with one of our clients, PennyPop. To store app data, the team set up an API request to the Rails application. The app itself then stores the data inside DynamoDB and sends a response back to the app. Instead of ActiveRecord, the team created their own data storage layer to enable communication between the app and DynamoDB.
But the issue they ran into is that DynamoDB has limits on how much information can be stored in one key. This was a technical deal-breaker, but the dev team came up with an interesting workaround — compressing the value of the key to a payload of base64 encoded data. Doing so has allowed the team to exchange bigger records between the app and the database without compromising the user experience or app performance.
Sure, the above operation requires more CPU. But since they are using Engine Yard to help manage and optimize other infrastructure, these costs remain manageable.
How to Cope
Granted, there are many approaches to improving Rails database performance. Deliberate caching and database partitioning (sharding) is one of the common routes as your app grows more complex.
What’s even better is that you have a ton of great solutions for resolving RoR database issues, such as:
- Redis — an open-source in-memory data structure store for Rails apps.
- ActiveRecord — a database querying tool standardizing access to popular databases with built-in caching capabilities.
- Memcached — distributed memory caching system for Ruby on Rails.
The above three tools can help you sufficiently shape up your databases to tolerate extra-high loads.
Moreover, you can:
- Switch to UUIDs over standard IDs for principle keys as your databases grow more complex.
- Try other ORM alternatives to ActiveRecord when your DBs get extra-large. Some good ones include Sequel, DataMapper, and ORM Adapter.
- Use database profiling gems to diagnose and detect speed and performance issues early on. Popular ones are rack-mini-profiler, bullet, rails_panel, etc.
Insufficient Server Bandwidth
The last problem is basic but still pervasive. You can’t accelerate your Rails apps to millions of RPMs if you lack resources.
Granted, with cloud computing, provisioning extra instances is a matter of several clicks. Yet, you still need to understand and account for:
- Specific apps/subsystems requirements for extra resources
- Cloud computing costs (aka the monetary tradeoff for speed)
Ideally, you need tools to constantly scan your systems and identify cases of slow performance, resources under (and over)-provisioning, as well as overall performance benchmarks for different apps.
Not having such is like driving without a speedometer: You rely on a hunch to determine if you are going too slow or deadly fast.
How to Cope
One of the lessons we learned when building and scaling Engine Yard on Kubernetes was that the container platform sets no default resource limits for hosted containers. Respectively, your apps can consume unlimited CPU and memory, which can create “noisy neighbor” situations, where some apps rack up too many resources and drag down the performance of others.
The solution: Orchestrate your containers from the get-go. Use Kubernetes Scheduler to right-size nodes for the pods, limit maximum resource allocation, plus define pod preemption behavior.
Moreover, if you are running containers, always set up your own logging and monitoring since there are no out-of-the-box solutions available. Adding Log Aggregation to Kubernetes provides extra visibility into your apps’ behavior.
In our case, we use:
- Fluent Bit for distributed log collection
- Kibana + Elasticsearch for log analysis
- Prometheus + Grafana for metrics alerting and visualization
To sum up: The key to ensuring scalability is weeding out the lagging modules and optimizing different infrastructure and architecture elements individually for a greater cumulative good.
Scaling Rails Apps: Two Main Approaches
Similar to others, Rails apps scale in two ways — vertically and horizontally.
Both approaches have their merit in respective cases.
Vertical Scaling
Vertical scaling, i.e., provisioning more server resources to an app, can increase the number of RPMs. The baseline premises are the same as for other frameworks. You add extra processors, RAM, etc., until it is technically feasible and makes financial sense. Understandably, vertical scaling is a temp “patch” solution.
Scaling Rails apps vertically makes sense to accommodate linear or predictable growth since cost control will be easy too. Also, vertical scaling is a good option for upgrading database servers. After all, slow databases can be majorly accelerated when placed on better hardware.
Hardware is the obvious limitation to vertical scaling. But even if you are using cloud resources, still scaling Rails apps vertically can be challenging.
For example, if you plan to implement Vertical Pod Autoscaling (VPA) on Kubernetes, it accounts for several limitations.
During our experiments with scaling Ruby apps, we found that:
- VPA is a rather disruptive method since it busts the original pod and then recreates its vertically scaled version. This can cause much havoc.
- You cannot pair VPA with Horizontal Pod Autoscaling.
So it’s best to prioritize horizontal scaling whenever you can.
Horizontal Scaling
Horizontal scaling, i.e., redistributing your workloads across multiple servers, is a more future-proof approach to scaling Rails apps.
In essence, you convert your apps in a three-tier architecture featuring:
- Web server and load balancer for connected apps
- Rails app instances (on-premises or in the cloud)
- Database instances (also local or cloud-based)
The main idea is to distribute loads across different machines to obtain optimal performance equitably.
To effectively reroute Rails processes across server instances, you must select the optimal web server and load balancing solution. Then right-size instances to the newly decoupled workloads.
Load balancing
Load balancers are the key structural element for scale-out architecture. Essentially, they perform a routing function and help optimally distribute incoming traffic across connected instances.
Most cloud computing services come with native software load balancing solutions (think Elastic Load Balancing on AWS). Such solutions also support dynamic host port mapping. This helps establish a seamless pairing between registered web balancers and container instances.
When it comes to Rails apps, the two most common options are using a combo of web servers and app servers (or a fusion service) to ensure optimal performance.
- Web servers transfer the user request to your website and then pass it to a Rails app (if applicable). Essentially, they filter out unnecessary requests for CSS, SSL, or JavaScript components (which the server can handle itself), thus reducing the number of requests to the Rails app to bare essentials.
- Examples of Rails web servers: Ngnix and Apache.
- App servers are programs that maintain your app in memory. So that when an incoming request from a web server apps appears, it gets routed straight to the app for handling. Then the response is bounced back to the web server and, subsequently, the user. When paired with a web server in production, such a setup lets you render requests to multiple apps faster.
- Examples of app servers for Rails: Unicorn, Puma, Thin, Rainbows.
Finally, there are also “fusion” services such as Passenger App (Phusion Passenger). This service integrates with popular web servers (Ngnix and Apache) and brings in an app server layer — available for standalone and combo use with web servers.