The Ultimate DevOps Playbook: Tuning Nginx, Gunicorn/FPM, and PostgreSQL on AWS for Ruby
Nginx as a High-Performance Frontend for Ruby Applications
When deploying Ruby applications on AWS, Nginx serves as an indispensable frontend, efficiently handling static assets, SSL termination, and request routing to your application servers (Gunicorn or Puma for Python/Ruby, or PHP-FPM for PHP). Optimizing Nginx is crucial for maximizing throughput and minimizing latency.
Nginx Configuration Tuning
A robust Nginx configuration starts with tuning worker processes and connection limits. For a typical AWS EC2 instance, setting worker_processes to the number of available CPU cores is a good starting point. worker_connections dictates the maximum number of simultaneous connections a worker can handle; this should be set high enough to accommodate your expected traffic, considering that each connection consumes a file descriptor.
Core Nginx Directives
Here’s a sample Nginx configuration snippet focusing on performance:
worker_processes auto; # Or set to the number of CPU cores
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024; # Adjust based on expected load and system limits
multi_accept on;
}
http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off; # Hide Nginx version for security
# Gzip compression for text-based assets
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Caching for static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Proxy to your application server (e.g., Gunicorn/Puma)
location / {
proxy_pass http://your_app_backend; # e.g., http://127.0.0.1:8000
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # Increase timeout for long-running requests
proxy_connect_timeout 75s;
}
# Include other configurations
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Explanation of Key Directives:
worker_processes auto;: Automatically scales Nginx workers to match CPU cores.worker_connections 1024;: Sets the maximum concurrent connections per worker. This value, multiplied byworker_processes, gives the total maximum connections.sendfile on;: Enables efficient transfer of files from disk to network socket without user-space buffering.tcp_nopush on;: Instructs Nginx to send headers in one packet and the rest of the data in subsequent packets, optimizing TCP performance.tcp_nodelay on;: Disables the Nagle algorithm, which can reduce latency for small packets.keepalive_timeout 65;: Sets the timeout for persistent connections.server_tokens off;: Hides the Nginx version in HTTP headers, a minor security enhancement.gzip on;: Enables Gzip compression for supported content types.gzip_types ...;: Specifies MIME types to be compressed.location ~* \.(jpg|...)$ { expires 30d; ... }: Configures browser caching for static assets.proxy_pass http://your_app_backend;: Directs requests to your application server. Replaceyour_app_backendwith the actual upstream server definition (e.g., an IP address and port, or a load balancer DNS name).proxy_set_header ...;: Forwards essential client information to the backend application.proxy_read_timeout 300s;: Increases the timeout for reading a response from the upstream server, crucial for potentially long-running Ruby tasks.proxy_connect_timeout 75s;: Sets the timeout for establishing a connection with the upstream server.
Gunicorn/Puma Tuning for Ruby Applications
For Ruby applications, Puma is the de facto standard application server. If you’re using a Python framework with Ruby deployment (less common but possible), Gunicorn would be the choice. We’ll focus on Puma, as it’s more idiomatic for Ruby.
Puma Configuration Best Practices
Puma’s performance is heavily influenced by its worker and thread configuration. The optimal settings depend on your application’s I/O-bound vs. CPU-bound nature and the available server resources.
Worker and Thread Model
Puma operates with a master process that spawns multiple worker processes. Each worker process can then manage multiple threads. A common strategy is to set the number of workers to (CPU cores * 2) + 1 and then tune the number of threads per worker.
Example Puma Configuration (via config/puma.rb)
This configuration assumes you’re running Puma in clustered mode, often managed by a process supervisor like systemd or foreman.
# config/puma.rb
# Set the environment
environment ENV.fetch('RAILS_ENV') { 'production' }
# Number of workers. A good starting point is (CPU cores * 2) + 1.
# For a 4-core instance, this would be 9.
# Adjust based on your application's memory footprint and concurrency needs.
workers ENV.fetch('WEB_CONCURRENCY') { 4 }.to_i
# Number of threads per worker.
# This is where you balance I/O-bound vs. CPU-bound tasks.
# For I/O-bound apps, higher threads are good. For CPU-bound, fewer threads are better.
threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 }.to_i
threads threads_count, threads_count
# Bind to a TCP socket or a Unix socket.
# For Nginx proxying, a TCP socket is common.
# If Nginx and Puma are on the same host, use localhost.
# If using a load balancer, this might be a private IP.
bind "tcp://0.0.0.0:3000" # Or "unix:///path/to/puma.sock"
# Set the maximum number of requests that a worker will process before restarting.
# This helps prevent memory leaks and keeps the application fresh.
max_threads_count = ENV.fetch('RAILS_MIN_THREADS') { threads_count }.to_i
plugin :tmp_restart
# Specifies the maximum number of requests that a worker will process before
# restarting. This is a good way to prevent memory leaks.
prune_bundler_gems
# Logging
stdout_redirect "#{__dir__}/log/#{environment}.log", "#{__dir__}/log/#{environment}.log", true
# State file for Puma
state_path "#{__dir__}/tmp/pids/puma.state"
# Daemonize the process (run in background)
# daemonize true # Typically managed by systemd/foreman, so often false here
# Preload the application code
preload_app!
# Callbacks
on_worker_boot do
# Worker specific setup for Rails.
# This is called after the worker has forked.
ActiveRecord::Base.establish_connection if defined?(ActiveRecord::Base)
end
on_worker_shutdown do
# Worker specific cleanup.
end
# Allow Puma to be restarted by `rails restart` command.
plugin :restart
# Configure the worker timeout.
worker_timeout 60 # seconds
Tuning Considerations:
workers: The number of Puma worker processes. Each worker is a separate Ruby process.threads: The range of threads each worker can use.threads 5, 5means each worker will use exactly 5 threads.preload_app!: This directive loads your application code into memory before forking worker processes. This significantly speeds up worker startup and reduces memory duplication.worker_timeout: The maximum time a worker will wait for a request to complete. If a request exceeds this, the worker is restarted.max_threads_count: Used in conjunction withthreadsto define a range.threads 5, 10means a worker can scale from 5 to 10 threads based on load.
PostgreSQL Performance Tuning on AWS RDS
PostgreSQL performance is critical for most Ruby applications. AWS Relational Database Service (RDS) simplifies management, but tuning is still essential.
Key PostgreSQL Parameters
Most PostgreSQL tuning involves adjusting parameters within the postgresql.conf file. On AWS RDS, these parameters are managed via DB Parameter Groups.
Essential Parameters to Tune
Create a custom DB Parameter Group based on the default PostgreSQL version you are using. Then, modify these parameters:
# Example parameters in a custom RDS DB Parameter Group # Memory Management shared_buffers = 25% of total RAM # e.g., for 32GB RAM, set to 8GB (8192MB) work_mem = 10MB # Start small, increase if specific queries show sorting issues maintenance_work_mem = 256MB # For VACUUM, CREATE INDEX, etc. effective_cache_size = 50% of total RAM # e.g., for 32GB RAM, set to 16GB (16384MB) # Connection Management max_connections = 100 # Adjust based on application needs and instance size # Consider using RDS Proxy for better connection pooling if max_connections is a bottleneck # WAL (Write-Ahead Logging) wal_buffers = 16MB # Default is often too small wal_writer_delay = 200ms # Default is 200ms, can be tuned checkpoint_timeout = 5min # Default is 5min, can be increased to reduce frequency max_wal_size = 4GB # Default is 1GB, increase to reduce checkpoint frequency # Autovacuum autovacuum = on autovacuum_max_workers = 3 # Adjust based on CPU cores autovacuum_naptime = 15s # How often autovacuum runs autovacuum_vacuum_threshold = 50 # Minimum number of rows to trigger vacuum autovacuum_analyze_threshold = 50 # Minimum number of rows to trigger analyze # Query Planning random_page_cost = 1.1 # Default is 4.0, lower for SSDs seq_page_cost = 1.0 # Default is 1.0
Parameter Explanations:
shared_buffers: The amount of memory dedicated to PostgreSQL for caching data pages. A common recommendation is 25% of instance RAM, but avoid exceeding 40% to leave memory for the OS and other processes.work_mem: Memory used for internal sort operations and hash tables before writing to disk. Tune this based on specific query performance. Too high can lead to OOM errors.maintenance_work_mem: Memory used for maintenance operations likeVACUUM,CREATE INDEX, andALTER TABLE.effective_cache_size: An estimate of how much memory is available for disk caching by the operating system and PostgreSQL. Helps the query planner make better decisions.max_connections: The maximum number of concurrent connections. Ensure this is sufficient for your application but not so high that it exhausts server memory. AWS RDS Proxy can be a better solution for managing connections at scale.wal_buffers: Memory for WAL data before writing to disk. Increasing this can improve write performance.checkpoint_timeoutandmax_wal_size: These control how often PostgreSQL performs checkpoints, which flush dirty buffers to disk. Increasing these reduces the frequency of checkpoints, which can improve write performance but may increase recovery time after a crash.autovacuum: Essential for reclaiming space from dead tuples and preventing table bloat. Tuneautovacuum_max_workers,autovacuum_naptime, and thresholds based on your workload.random_page_cost: The planner’s estimated cost for a non-sequentially-accessed disk page. Lowering this for SSDs (common on AWS) makes the planner favor index scans more.
Monitoring and Indexing
Beyond parameter tuning, continuous monitoring and proper indexing are paramount.
Essential Monitoring Tools
- AWS CloudWatch Metrics: Monitor CPU Utilization, Database Connections, Read/Write IOPS, Network In/Out, and Freeable Memory.
- RDS Performance Insights: Provides a visual dashboard of database load, identifying top SQL queries, wait events, and hosts.
pg_stat_statementsextension: Enable this extension to track execution statistics of all SQL statements executed by the server.
To enable pg_stat_statements on RDS:
-- Connect to your RDS PostgreSQL instance ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements'; SELECT pg_reload_conf(); CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
Then, query it:
SELECT
query,
calls,
total_time,
rows,
mean_time,
stddev_time
FROM
pg_stat_statements
ORDER BY
total_time DESC
LIMIT 10;
Indexing Strategy
Regularly review your application’s queries (using Performance Insights or pg_stat_statements) and ensure appropriate indexes are in place. Avoid over-indexing, as it incurs write overhead and storage costs. Use tools like pg_qualstats to identify queries with missing or unused indexes.
Putting It All Together: A Holistic Approach
Optimizing a Ruby application stack on AWS is an iterative process. Start with sensible defaults, monitor performance closely, and make incremental adjustments. Nginx handles the edge, Puma/Gunicorn processes the requests, and PostgreSQL stores the data. Each layer needs to be tuned in concert with the others. Remember to test all configuration changes in a staging environment before deploying to production.