Kubernetes-Native PHP Deployments: Optimizing Laravel Performance and Scalability with FPM, RoadRunner, and Advanced Caching Strategies
Leveraging PHP-FPM with Kubernetes for Scalable Web Applications
For many years, PHP-FPM has been the de facto standard for running PHP applications in production, especially within containerized environments like Kubernetes. Its ability to manage worker processes efficiently and integrate seamlessly with web servers like Nginx makes it a robust choice. When deploying PHP-FPM on Kubernetes, the key is to configure it for optimal performance and scalability, leveraging Kubernetes primitives for resource management and auto-scaling.
Kubernetes Deployment Configuration for PHP-FPM
A typical Kubernetes deployment for a PHP-FPM application involves a Deployment object managing multiple Pods, each running the PHP-FPM process. We’ll also use a Service to expose these Pods internally and potentially an Ingress for external access. The critical part is the PHP-FPM configuration itself, which needs to be tuned for a containerized, dynamic environment.
PHP-FPM Pool Configuration (`www.conf`)
The `php-fpm.d/www.conf` file is central to controlling PHP-FPM’s behavior. In a Kubernetes context, we want to dynamically adjust the number of worker processes based on available resources and application load. The `pm` (process manager) setting is crucial here. For Kubernetes, `dynamic` or `ondemand` are generally preferred over `static` to avoid over-provisioning or under-utilization.
Here’s an example of a `www.conf` tailored for Kubernetes:
[global]
pid = /run/php/php7.4-fpm.pid
error_log = /var/log/php/php-fpm.log
daemonize = no
[www]
user = www-data
group = www-data
listen = /run/php/php7.4-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 2
pm.max_spare_servers = 10
pm.process_idle_timeout = 10s
pm.max_requests = 500
request_terminate_timeout = 60s
request_slowlog_timeout = 10s
slowlog = /var/log/php/php-fpm.slow.log
catch_workers_output = yes
access.log = /var/log/php/access.log
access.format = "%R - %u %t \"%m %r%Q%qHttp/%H\" %s %O \"%{Referer}i\" \"%{User-Agent}i\""
clear_env = no
Key considerations for this configuration:
daemonize = no: Essential for running within a container. The container orchestrator (Kubernetes) manages the process lifecycle.listen: Using a Unix socket (`/run/php/php7.4-fpm.sock`) is generally faster than TCP/IP for communication between Nginx and PHP-FPM when they are on the same Pod.pm = dynamic: Allows PHP-FPM to scale the number of worker processes based on demand.pm.max_children: This should be carefully tuned. A common heuristic is to set it based on available memory. For example, if each PHP-FPM worker consumes ~30MB of RAM, and you have a 1GB Pod limit, you might setmax_childrento around 30. However, this is highly application-dependent.pm.start_servers,pm.min_spare_servers,pm.max_spare_servers: These parameters control how PHP-FPM scales dynamically. Tuning these can reduce latency during traffic spikes.pm.max_requests: Limits the number of requests a child process will serve before respawning. This helps prevent memory leaks from accumulating over time.request_terminate_timeoutandrequest_slowlog_timeout: Crucial for identifying and mitigating slow requests.catch_workers_output = yes: Directs worker output (like `echo` statements) to the main error log, simplifying debugging.
Kubernetes Deployment Manifest
Here’s a simplified Kubernetes Deployment manifest for a PHP-FPM application. This assumes you’re using a custom Docker image that includes your application code and the configured PHP-FPM.
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-php-app-fpm
labels:
app: php-fpm
spec:
replicas: 3
selector:
matchLabels:
app: php-fpm
template:
metadata:
labels:
app: php-fpm
spec:
containers:
- name: php-fpm
image: your-docker-registry/your-php-app:latest
ports:
- containerPort: 9000 # Default FPM port if using TCP/IP, or not needed if using socket
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
volumeMounts:
- name: php-fpm-config-volume
mountPath: /etc/php/7.4/fpm/pool.d/www.conf
subPath: www.conf # Mount only the www.conf file
- name: php-log-volume
mountPath: /var/log/php
volumes:
- name: php-fpm-config-volume
configMap:
name: php-fpm-configmap
items:
- key: www.conf
path: www.conf
- name: php-log-volume
emptyDir: {} # Temporary storage for logs within the Pod lifecycle
And the corresponding ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: php-fpm-configmap
data:
www.conf: |
[global]
pid = /run/php/php7.4-fpm.pid
error_log = /var/log/php/php-fpm.log
daemonize = no
[www]
user = www-data
group = www-data
listen = /run/php/php7.4-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 30
pm.start_servers = 3
pm.min_spare_servers = 1
pm.max_spare_servers = 5
pm.process_idle_timeout = 10s
pm.max_requests = 500
request_terminate_timeout = 60s
request_slowlog_timeout = 10s
slowlog = /var/log/php/php-fpm.slow.log
catch_workers_output = yes
access.log = /var/log/php/access.log
access.format = "%R - %u %t \"%m %r%Q%qHttp/%H\" %s %O \"%{Referer}i\" \"%{User-Agent}i\""
clear_env = no
Explanation:
- The
Deploymentmanages the lifecycle of your PHP-FPM Pods. resources.requestsandresources.limitsare crucial for Kubernetes to schedule Pods effectively and prevent resource starvation or noisy neighbor issues. Tune these based on your application’s actual resource consumption.- We mount the `www.conf` from a
ConfigMap. This allows us to update PHP-FPM settings without rebuilding the Docker image. subPathis used to mount only the `www.conf` file, not the entire directory.- An
emptyDirvolume is used for logs. These logs are ephemeral and will be lost when the Pod is deleted, which is typical for stateless applications in Kubernetes. For persistent logging, you’d integrate with a dedicated logging solution (e.g., EFK stack, Loki).
Integrating with Nginx and Horizontal Pod Autoscaler (HPA)
For a complete web application stack, you’ll typically pair your PHP-FPM deployment with an Nginx Ingress Controller or a dedicated Nginx Deployment. Nginx will proxy requests to the PHP-FPM service. To achieve true scalability, you’ll want to configure a Horizontal Pod Autoscaler (HPA) to automatically adjust the number of PHP-FPM replicas based on CPU or memory utilization.
Nginx Configuration Snippet
If you’re running Nginx in a separate Pod (e.g., as part of your application deployment or via an Ingress controller), here’s a relevant Nginx configuration snippet to proxy requests to PHP-FPM using a Unix socket:
server {
listen 80;
server_name your-domain.com;
root /var/www/html; # Your application's public directory
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass unix:/run/php/php7.4-fpm.sock; # Matches the listen directive in www.conf
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# Deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
location ~ /\.ht {
deny all;
}
}
Horizontal Pod Autoscaler (HPA) Configuration
The HPA will monitor metrics (like CPU utilization) of your PHP-FPM Pods and scale the my-php-app-fpm Deployment up or down. Ensure your Pods have resource requests defined for the HPA to work effectively.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-php-app-fpm-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-php-app-fpm
minReplicas: 3
maxReplicas: 15
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # Target 70% CPU utilization across all Pods
With this setup, Kubernetes will automatically adjust the number of PHP-FPM replicas between 3 and 15, aiming to keep the average CPU utilization at 70%. When CPU usage exceeds this threshold, new replicas are created; when it drops, replicas are terminated.
Advanced: RoadRunner for High-Performance PHP
While PHP-FPM is robust, it has limitations, particularly regarding request overhead and the lifecycle of PHP processes. For applications demanding extreme performance, lower latency, and better resource utilization, frameworks like RoadRunner offer a compelling alternative. RoadRunner acts as a high-performance PHP application server, managing a pool of PHP workers that are kept alive indefinitely, eliminating the overhead of starting a new PHP process for each request.
RoadRunner Architecture
RoadRunner typically runs as a standalone process (often in a container) that listens for HTTP requests. It then forwards these requests to a pool of long-lived PHP workers. These workers execute your PHP application code. RoadRunner supports various communication protocols, including gRPC and direct TCP/IP, and can be integrated with web servers like Nginx or Caddy.
Kubernetes Deployment with RoadRunner
Deploying RoadRunner on Kubernetes involves a similar pattern to PHP-FPM but with different configurations.
RoadRunner Configuration (`.rr.yaml`)
A basic RoadRunner configuration file:
version: "3"
rpc:
listen: tcp://127.0.0.1:6001 # RoadRunner's internal RPC port
server:
command: "php ./bin/rr serve -c .rr.yaml" # Command to start RoadRunner
relay: "tcp://127.0.0.1:6002" # RoadRunner's relay for worker communication
# http:
# address: ":8080" # If RoadRunner directly serves HTTP
# If using a web server like Nginx or Caddy to proxy to RoadRunner's workers
http:
address: ":8080" # The port RoadRunner will listen on for HTTP requests
max_request_size_bytes: 10485760 # 10MB
# Worker configuration
workers:
# Number of workers to keep alive
num_workers: 10
# How workers are managed
mode: "shared" # or "process"
# Command to start a PHP worker
# This command should execute your application's entry point (e.g., a Laravel/Symfony console command)
# that initializes RoadRunner's worker.
# For Laravel, this might be `php artisan serve` or a custom script.
# For Symfony, it might be `php bin/console server:run` or similar.
# The key is that this command must start a process that listens on the 'relay' address.
# A common pattern is to use a framework-specific command that integrates with RoadRunner.
# Example for Laravel with Laravel RoadRunner package:
command: "php ./vendor/bin/rr serve -c .rr.yaml" # Assuming you have a framework integration
# The directory where workers will be executed
directory: "./"
# Environment variables for workers
environment:
APP_ENV: "production"
APP_DEBUG: "false"
RR_MODE: "http" # Or "grpc" depending on your needs
RR_RELAY: "tcp://127.0.0.1:6002"
RR_RPC_LISTEN: "tcp://127.0.0.1:6001"
RR_HTTP_LISTEN: ":8080"
# Example for logging
logs:
mode: "native" # or "json"
level: "info"
output: "stderr"
file: "/var/log/roadrunner.log" # If mode is not stderr
Note: The command for workers is critical. For frameworks like Laravel or Symfony, you’ll typically use a dedicated package (e.g., spiral/roadrunner-laravel) that provides the correct command to initialize the worker and integrate with the framework’s request/response cycle.
Docker Image for RoadRunner
Your Dockerfile would need to install RoadRunner and your application dependencies. A simplified example:
FROM php:8.1-fpm
# Install RoadRunner binary (example for Linux x86_64)
RUN curl -s https://releases.roadrunner.dev/roadrunner -o /usr/local/bin/rr \
&& chmod +x /usr/local/bin/rr
# Install Composer and application dependencies
COPY --chown=www-data:www-data . /var/www/html
WORKDIR /var/www/html
RUN composer install --no-dev --optimize-autoloader
# Copy RoadRunner configuration
COPY .rr.yaml /var/www/html/.rr.yaml
# Expose the port RoadRunner will listen on
EXPOSE 8080
# Set user
USER www-data
# Command to run RoadRunner
CMD ["/usr/local/bin/rr", "serve", "-c", ".rr.yaml"]
Kubernetes Deployment for RoadRunner
The Kubernetes Deployment would be similar to the PHP-FPM one, but the container image would be your RoadRunner-enabled application, and the `CMD` would be set to start RoadRunner.
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-php-app-roadrunner
labels:
app: php-roadrunner
spec:
replicas: 3
selector:
matchLabels:
app: php-roadrunner
template:
metadata:
labels:
app: php-roadrunner
spec:
containers:
- name: php-roadrunner
image: your-docker-registry/your-php-app-roadrunner:latest
ports:
- containerPort: 8080 # The port RoadRunner listens on
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
# If using Nginx/Caddy as a separate ingress, you'd configure it to proxy to this service.
# If RoadRunner is handling HTTP directly, you might use a LoadBalancer Service.
Advanced Caching Strategies in Kubernetes
Effective caching is paramount for high-performance PHP applications, especially in a distributed Kubernetes environment. Beyond application-level caching (like object caching), consider infrastructure-level caching.
Redis for Object and Session Caching
Redis is a popular choice for distributed caching. Deploying Redis on Kubernetes can be done using a StatefulSet for persistent data or a simple Deployment for ephemeral cache instances. For production, consider using a managed Redis service or a robust Kubernetes operator.
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-cache
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:6.2-alpine
ports:
- containerPort: 6379
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
# For persistent storage, add volumeMounts and volumes with PersistentVolumeClaim
# For ephemeral cache, this is sufficient.
---
apiVersion: v1
kind: Service
metadata:
name: redis-cache-service
spec:
selector:
app: redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379
type: ClusterIP # Internal service, accessible by other Pods in the cluster
Your PHP application (Laravel, Symfony, etc.) can then be configured to use this Redis instance for object caching and session storage. For Laravel:
// config/cache.php
'stores' => [
// ... other stores
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
],
// ...
],
// config/session.php
'driver' => env('SESSION_DRIVER', 'file'), // Change to 'redis'
'store' => 'redis', // Use the Redis store defined above
'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => false,
'lottery' => [0, 100],
'cookie' => env('SESSION_COOKIE', 'myapp_session'),
'path' => '/',
'domain' => env('SESSION_DOMAIN', null),
'secure' => env('SESSION_SECURE_COOKIE', false),
'http_only' => true,
'same_site' => 'lax',
// config/database.php (for Redis connections)
'redis' => [
'client' => 'phpredis', // or 'predis'
'options' => [
'cluster' => 'redis',
'parameters' => [
'password' => env('REDIS_PASSWORD', null),
'scheme' => 'tcp',
'host' => env('REDIS_HOST', 'redis-cache-service'), // Kubernetes service name
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
],
],
],
Nginx Caching (Proxy Cache)
For static assets or even dynamic API responses that don’t change frequently, Nginx’s proxy cache can significantly reduce the load on your PHP application and backend services. This is configured within your Nginx deployment or Ingress controller.
http {
# Define cache path and parameters
proxy_cache_path /var/cache/nginx/my_cache levels=1:2 keys_zone=my_cache_zone:10m max_size=10g inactive=60m use_temp_path=off;
server {
listen 80;
server_name your-domain.com;
root /var/www/html;
index index.php index.html;
# ... other configurations ...
location / {
proxy_pass http://your-php-app-service; # Assuming a Kubernetes Service for your PHP app
proxy_cache my_cache_zone; # 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_use_stale error timeout updating http_500; # Serve stale content if backend is down
add_header X-Cache-Status $upstream_cache_status; # Useful for debugging cache hits/misses
}
# ... PHP-FPM or RoadRunner configuration ...
}
}
This Nginx configuration enables caching for all requests proxied to your PHP application service. The `X-Cache-Status` header is invaluable for debugging cache behavior.
Conclusion
Deploying PHP applications on Kubernetes offers immense scalability and resilience. By carefully configuring PHP-FPM or adopting high-performance solutions like RoadRunner, and by implementing robust caching strategies at both the application and infrastructure levels, you can build highly performant and scalable PHP applications that leverage the full power of Kubernetes.