The Ultimate DevOps Playbook: Tuning Nginx, Gunicorn/FPM, and Redis on AWS for C
Nginx as a High-Performance Frontend Proxy
Nginx is the de facto standard for serving static assets and acting as a reverse proxy for dynamic applications. For optimal performance, especially under heavy load, fine-tuning Nginx’s worker processes, connection handling, and caching mechanisms is crucial. We’ll focus on a common AWS setup where Nginx sits in front of Gunicorn (for Python apps) or PHP-FPM (for PHP apps) and Redis.
Worker Processes and Connections
The `worker_processes` directive controls how many worker processes Nginx spawns. A common recommendation is to set this to the number of CPU cores available to the Nginx instance. The `worker_connections` directive limits the number of simultaneous connections a single worker process can handle. The total maximum connections will be `worker_processes * worker_connections`.
On an EC2 instance, you can determine the number of CPU cores using the `nproc` command or by inspecting the instance type’s specifications. For example, a `t3.medium` instance typically has 2 vCPUs.
Tuning `nginx.conf`
Locate your `nginx.conf` file (often at `/etc/nginx/nginx.conf` or `/usr/local/nginx/conf/nginx.conf`). Adjust the `events` and `http` blocks as follows:
user www-data;
worker_processes auto; # Or set to the number of CPU cores, e.g., 2
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024; # Adjust based on expected load and server resources
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_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Include other configurations
include /etc/nginx/mime.types;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Explanation:
worker_processes auto;: Lets Nginx automatically determine the number of worker processes based on CPU cores.worker_connections 1024;: A reasonable starting point. Monitor your server’s connection usage and adjust upwards if necessary, but be mindful of system limits (e.g., `/proc/sys/net/core/somaxconn`).sendfile on;: Efficiently transfers files from disk to network socket without user-space buffering.tcp_nopush on;: Instructs Nginx to send header and file in one packet if possible.tcp_nodelay on;: Disables the Nagle algorithm, which can reduce latency for small packets.keepalive_timeout 65;: Keeps connections open for a specified duration, reducing overhead for subsequent requests.server_tokens off;: Hides the Nginx version in HTTP headers, a minor security hardening step.gzip on; ... gzip_types ...;: Enables and configures Gzip compression for text-based responses, significantly reducing bandwidth and improving load times.
Caching Strategies
Nginx can serve static assets directly from disk or cache them in memory or on disk for faster retrieval. For dynamic content, it can cache responses from backend applications.
Static Asset Caching
Configure browser caching and Nginx’s own caching for static files. In your site’s Nginx configuration (e.g., `/etc/nginx/sites-available/your_app`):
server {
listen 80;
server_name example.com;
root /var/www/your_app/public;
index index.php index.html index.htm;
# Serve static files directly
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 30d; # Cache for 30 days in browser
add_header Cache-Control "public, no-transform";
access_log off; # Don't log static file access
try_files $uri =404;
}
# Proxy to backend application
location / {
proxy_pass http://unix:/run/gunicorn.sock; # Or 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;
}
# ... other configurations like PHP-FPM if applicable
}
Backend Response Caching (with Redis)
For frequently accessed, non-user-specific dynamic content, Nginx can cache responses from your backend. This is particularly effective when combined with Redis for storing cache keys and invalidation signals.
http {
# ... other http configurations
# Define a cache zone
proxy_cache_path /var/cache/nginx/my_app levels=1:2 keys_zone=my_app_cache:10m inactive=60m max_size=1g;
# keys_zone=name:size - name for the zone, size of shared memory
# inactive=time - how long an item can be unused before being removed
# max_size=size - maximum size of the cache on disk
# ... other http configurations
server {
listen 80;
server_name example.com;
# ... other server configurations
location /api/ { # Cache API responses
proxy_pass http://unix:/run/gunicorn.sock;
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;
# Cache settings
proxy_cache my_app_cache; # Use the defined cache zone
proxy_cache_valid 200 302 10m; # Cache successful responses for 10 minutes
proxy_cache_valid 404 1m; # Cache 404s for 1 minute
proxy_cache_key "$scheme$request_method$host$request_uri"; # Unique cache key
add_header X-Cache-Status $upstream_cache_status; # Useful for debugging
# Bypass cache for authenticated users or specific requests
proxy_cache_bypass $http_cookie; # Example: bypass if cookie is present
proxy_no_cache $http_cookie; # Example: don't store if cookie is present
# If using Redis for cache invalidation, you'd typically handle this
# within your application logic, sending a signal to Nginx or
# directly invalidating cache entries via Nginx's cache manager module
# or by setting appropriate Cache-Control headers.
# For advanced invalidation, consider Nginx's Lua module or external tools.
}
# ... other location blocks
}
}
Note on Redis Integration: Nginx itself doesn’t directly integrate with Redis for cache *storage* in the same way it uses its file-based cache. However, Redis is invaluable for:
- Storing cache keys and metadata.
- Implementing cache invalidation logic within your application. Your application can update Redis, and Nginx (or a custom module) can check Redis to determine if a cached item is stale or should be bypassed.
- Rate limiting and session management, which indirectly affect caching decisions.
Gunicorn/PHP-FPM Tuning for Backend Performance
The application server (Gunicorn for Python, PHP-FPM for PHP) is where your application code executes. Optimizing its worker processes, memory usage, and request handling is critical.
Gunicorn (Python WSGI HTTP Server)
Gunicorn is a popular choice for deploying Python web applications. Its performance is heavily influenced by the number of worker processes and the worker type.
Worker Types
Gunicorn supports several worker types:
- Sync (Synchronous): The default. Each worker handles one request at a time. Simple but can be a bottleneck under high concurrency.
- Gevent/Eventlet: Asynchronous workers using green threads. Can handle many concurrent connections efficiently, especially for I/O-bound tasks. Requires installing `gevent` or `eventlet`.
- Async (e.g., `aiohttp`): For applications built with `asyncio`.
For most CPU-bound or mixed workloads, a combination of multiple sync workers or gevent workers is effective. The number of workers is often set to `(2 * number_of_cores) + 1` as a starting point, but this can vary significantly based on application behavior.
Gunicorn Configuration (Command Line or `gunicorn.conf.py`)
You can configure Gunicorn via command-line arguments or a configuration file. Using a configuration file is cleaner for production.
# gunicorn.conf.py
import multiprocessing
# Number of worker processes. A common starting point is (2 * number_of_cores) + 1.
# For I/O bound tasks, consider using gevent workers and a higher number.
workers = multiprocessing.cpu_count() * 2 + 1
# workers = 3 # Example for a 1-core CPU
# Worker class. 'sync' is default. 'gevent' is good for I/O bound.
worker_class = 'sync' # or 'gevent'
# Bind to a Unix socket for Nginx to connect to (more efficient than TCP loopback)
# Ensure the user running Nginx has permissions to access this socket.
bind = "unix:/run/gunicorn.sock"
# Or for TCP: bind = "127.0.0.1:8000"
# Maximum number of requests a worker will handle before restarting.
# Helps prevent memory leaks.
max_requests = 1000
# Maximum number of requests a worker will handle before it's recycled.
# A value of 0 means never recycle.
# max_requests_jitter = 50 # Add some randomness to max_requests
# Timeout for worker requests.
# Increase this for long-running operations, but be mindful of Nginx proxy_read_timeout.
# Default is 30 seconds.
# timeout = 120
# Logging configuration
loglevel = 'info' # 'debug', 'info', 'warning', 'error', 'critical'
accesslog = '/var/log/gunicorn/access.log'
errorlog = '/var/log/gunicorn/error.log'
# PID file
pidfile = '/run/gunicorn.pid'
# User and group to run as (if not running as root initially)
# user = 'your_app_user'
# group = 'your_app_group'
# Threads for async workers (if using 'gthread' worker class, not common)
# threads = 2
# Enable graceful reload
# reload = True # Use with caution in production, better managed by systemd/supervisor
# reload_engine = 'inotify' # or 'poll'
# Enable access log for requests that return an error
# log_syslog = True # Send logs to syslog
# syslog_handler = 'socket' # or 'udp' or 'tcp'
# syslog_address = '/dev/log' # or ('localhost', 514)
# Enable worker heartbeat
# worker_heartbeat = 30 # seconds
Deployment Example (using systemd):
# /etc/systemd/system/gunicorn.service
[Unit]
Description=Gunicorn instance to serve myapp
After=network.target
[Service]
User=your_app_user
Group=your_app_group
WorkingDirectory=/path/to/your/app
ExecStart=/path/to/your/venv/bin/gunicorn \
--workers 3 \
--bind unix:/run/gunicorn.sock \
--access-logfile /var/log/gunicorn/access.log \
--error-logfile /var/log/gunicorn/error.log \
--pid /run/gunicorn.pid \
your_app.wsgi:application # Replace with your actual WSGI application entry point
# Optional: If using gevent workers, ensure gevent is installed and specify worker_class
# ExecStart=/path/to/your/venv/bin/gunicorn -k gevent -w 3 --bind unix:/run/gunicorn.sock ...
Restart=always
RestartSec=10
[Install]
[Install]
WantedBy=multi-user.target
After creating/modifying the service file, run:
sudo systemctl daemon-reload sudo systemctl start gunicorn sudo systemctl enable gunicorn sudo systemctl status gunicorn
PHP-FPM (FastCGI Process Manager)
PHP-FPM manages a pool of PHP processes that handle requests from the web server (Nginx). Its performance tuning involves configuring the process manager and worker pool.
Process Manager Settings
PHP-FPM offers different process management strategies:
- Static: A fixed number of child processes are spawned when FPM starts. Best for predictable workloads and consistent performance.
- Dynamic: FPM starts a few processes and spawns more as needed, up to a defined maximum. It can also kill idle processes to save resources. Good for variable workloads.
- On-demand: Processes are spawned only when a request comes in and are killed after a timeout. Can save memory but might introduce latency for the first request.
For production, ‘static’ or ‘dynamic’ are generally preferred. ‘Static’ offers the most predictable performance. The number of processes should be tuned based on server CPU and memory, and the application’s resource consumption.
PHP-FPM Configuration (`php-fpm.conf` and Pool Configuration)**
The main configuration is typically in `/etc/php/X.Y/fpm/php-fpm.conf` (where X.Y is your PHP version), and pool-specific settings are in `/etc/php/X.Y/fpm/pool.d/www.conf` (or a custom pool file).
; /etc/php/X.Y/fpm/pool.d/www.conf [www] ; Process management settings ; Choose one of the following process management modes: ; static, dynamic or ondemand pm = dynamic ; If pm = dynamic, these are the values to use: ; pm.max_children: The maximum number of children that can be started. ; pm.start_servers: The number of children that will be started at FPM startup. ; pm.min_spare_servers: The minimum number of children that should be always available. ; pm.max_spare_servers: The maximum number of children that should be always available. ; pm.max_requests: The number of requests each child process should execute before re. ; pm.process_idle_timeout: The number of seconds after which an idle process will be killed. pm.max_children = 50 ; Adjust based on RAM and expected load pm.start_servers = 5 ; Initial number of workers pm.min_spare_servers = 2 ; Minimum idle workers pm.max_spare_servers = 10; Maximum idle workers pm.max_requests = 500 ; Recycle processes after this many requests ; If pm = static, this is the value to use: ; pm.max_children = 50 ; Fixed number of children ; If pm = ondemand, this is the value to use: ; pm.max_children = 50 ; pm.min_spare_servers = 1 ; pm.max_spare_servers = 1 ; pm.process_idle_timeout = 10s ; Idle processes are killed after 10 seconds ; Listen socket. ; Use a TCP socket for remote connections, or a Unix socket for local connections. ; For Nginx, a Unix socket is generally preferred for performance. listen = /run/php/phpX.Y-fpm.sock ; Ensure Nginx user has read/write access ; If you prefer TCP: ; listen = 127.0.0.1:9000 ; Set permissions for the socket ; listen.owner = www-data ; listen.group = www-data ; listen.mode = 0660 ; Set user and group for the pool user = www-data group = www-data ; Set the default timezone ; date.timezone = UTC ; Other useful settings: ; request_terminate_timeout = 0 ; Set to a value (e.g., 60s) to limit script execution time ; request_slowlog_timeout = 10s ; Log scripts that take longer than this ; slowlog = /var/log/php/php-fpm-slow.log ; Error reporting and logging ; error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT ; log_level = notice ; Available levels: debug, notice, warn, error, critical ; access.log = /var/log/php/php-fpm.access.log ; access.format = "%R - %u %{%s}e \"%{REQUEST_METHOD}x %U%q%{%HTTP_PROTOCOL}x\" %s %O \"%{HTTP_REFERER}x\" \"%{HTTP_USER_AGENT}x\"" ; Security settings ; cgi.fix_pathinfo = 0 ; Recommended for security
After modifying the configuration, reload PHP-FPM:
sudo systemctl reload phpX.Y-fpm
Redis Performance Tuning
Redis is an in-memory data structure store, often used as a cache, message broker, and database. Optimizing Redis involves tuning its memory usage, persistence, and network configuration.
Memory Management
The `maxmemory` directive is crucial to prevent Redis from consuming all available RAM. Setting `maxmemory-policy` determines how Redis evicts keys when `maxmemory` is reached.
`redis.conf` Tuning
# redis.conf # Set the maximum memory Redis can use. # Example: 2GB. Adjust based on your EC2 instance's RAM and other processes. maxmemory 2gb # maxmemory 0 # No limit (not recommended in production) # Eviction policy when maxmemory is reached. # volatile-lru: Remove LRU keys among those with an expire set. # allkeys-lru: Remove LRU keys among all keys. # volatile-random: Remove random keys among those with an expire set. # allkeys-random: Remove random keys among all keys. # volatile-ttl: Remove keys with the shortest TTL among those with an expire set. # noeviction: Return errors on write operations when memory limit is reached. (Default) maxmemory-policy allkeys-lru # Recommended for caching scenarios # Save configuration (RDB snapshots) # save 900 1 # Save if 1 key changed in 900 seconds # save 300 10 # Save if 10 keys changed in 300 seconds # save 60 10000 # Save if 10000 keys changed in 60 seconds # For caching-heavy workloads, you might disable or reduce saving frequency # to avoid I/O overhead, especially if data loss is acceptable. save "" # Disable RDB persistence if data loss is acceptable # Append Only File (AOF) for durability. # If you disable RDB, AOF can provide better durability. # appendonly no # Set to 'yes' if you need durability and have disabled RDB # appendfilename "appendonly.aof" # appendfsync everysec # 'always' is too slow, 'no' is faster but less safe # Network configuration # bind 127.0.0.1 ::1 # Bind to localhost only for security if accessed only locally # bind 0.0.0.0 # Bind to all interfaces (use with caution and strong passwords/firewalls) port 6379 # Security # requirepass your_strong_password # Set a strong password # Client configuration # maxclients 10000 # Maximum number of concurrent clients # TCP keepalive tcp-keepalive 300 # Send TCP ACK to clients to keep connections alive
After modifying `redis.conf`, restart the Redis service:
sudo systemctl restart redis-server
Monitoring and Diagnostics
Continuous monitoring is key to identifying bottlenecks and validating tuning efforts. Use tools like:
- Nginx: `stub_status` module, `error.log`, `access.log`, `X-Cache-Status` header.
- Gunicorn: `status` command, logs, systemd service status.
- PHP-FPM: `status` page (if enabled), logs, systemd service status.
- Redis: `redis-cli INFO` command (especially `memory`, `stats`, `clients`), `redis-cli MONITOR`.
- System Metrics: `top`, `htop`, `vmstat`, `iostat`, CloudWatch metrics (CPU Utilization, Network In/Out, Disk I/O, Memory).
Example Redis `INFO` output snippet:
# redis-cli INFO memory used_memory:2147483648 used_memory_human:2.00G used_memory_rss:2200000000 used_memory_rss_human:2.05G used_memory_peak:2147483648 used_memory_peak_human:2.00G mem_fragmentation_ratio:1.02 # ... other sections like stats, clients, persistence ... # redis-cli INFO stats total_commands_processed:123456789 instantaneous_ops_per_sec:1500 keyspace_hits:100000000 keyspace_misses:23456789
Analyze these metrics to understand memory usage, hit/miss ratios for caches, command throughput, and identify potential resource contention. Adjust `maxmemory`, `maxmemory-policy`, worker counts, and timeouts based on observed behavior.