PHP Session Storage for Load Balanced Applications

In our previous article Building a Load Balanced LAMP Cluster we illustrated how to construct a simple scale-out architecture for a LAMP application with multiple backend servers. For simplicity, one of the important considerations for a production deployment which had not been addressed was the necessity for shared session storage. This follow-up article describes the advantages & drawbacks of common forms of PHP session storage, and briefly how to configure them for your applications.

In the aforementioned example, we configured session stickiness on the load balancer instead of setting up a session store for PHP sessions.

Web applications use sessions to keep authenticated users logged-in until they close their browser, by recording their Session ID. By default, the session IDs are stored as text files in a temporary directory (usually /tmp or /var/lib/php/session) on the server’s file system. This presents a problem in a distributed architecture, where the load balancer will probably assign a different web server to serve the user’s request on the next page load. Only web server A where the user logged in would have locally stored a record of their session. Therefore, the user is treated as unauthenticated by web server B, causing them to see an error message and be prompted to login again. This happens repeatedly as the user is bounced between different web servers by the load balancer, until they give up on using the application.

Session stickiness is a simple, but crude way to handle session management. Enabling “session stickiness” on the load balancer means it places a cookie on each user’s computer who visits the application. For the cookie’s lifetime, each user’s requests are guaranteed to be served by the same backend server. With this time-based approach, there is no need for a shared session store. Authenticated users will be recognized by the server’s local session store — but only for as long as the cookie lasts. After the cookie expires, the load balancer may assign the user a different web server, prompting them to login again.

Another disadvantage of session stickiness is it can cause the load to be disproportionately greater on some web servers from time-to-time, as users’ requests are not allocated based on a Round-Robin or Least Connections algorithm.

Implementing shared session storage is the most robust solution for PHP session management in a distributed application. Different approaches for session redundancy include storing sessions as:

  • Files in an NFS mount
  • Objects in a Memcached in-memory cache
  • Key-value pairs in a Redis database

Storing sessions within a table of a traditional OLTP database such as MySQL is also common with many web applications, but it is not one of the native session save handlers supported by PHP. If your application is based on certain PHP frameworks, such as Symfony, there might be a built-in solution for database session storage such as PdoSessionHandler.

Pay attention to security: Note that none of the below session stores require authentication by default, so they should be secured by a firewall granting only access to the IPs of the application servers on the local network. Redis has the option of additionally requiring a password to connect, using the requirepass directive in redis.conf. If opting for this option, the password must be passed through the auth=parameter of the PHP session.save_path directive like this:

session.save_path = “tcp://10.132.xxx.xxx:6379?auth=foobarred&database=10”

Files in an NFS mount

Moving the session store from a local directory such as /var/lib/php/session to an NFS mount is the simplest way to share session data across multiple application nodes in a cluster. This can be accomplished by exporting a share on the NFS server, mounting it across all the app nodes, and updating php.ini on each of the servers.

On the nfs server

mkdir /var/sessions_share
chmod 777 /var/sessions_share

– Add the following line to the /etc/exports file, where 10.132.0.0/16 is the private subnet which the nodes in your cluster belong to. The CIDR block on this line of the configuration refers to the IP range of hosts which should have access to the NFS share.

/var/sessions_share/     10.132.0.0/16(rw,sync,no_root_squash,no_all_squash)

– Restart the nfs service.

systemctl restart nfs-server

On the application servers

Where 10.132.xxx.xxx is the private IP address of the nfs server:

mkdir -p /var/lib/php/session/ # in case the PHP sessions directory doesn’t exist
mount -t nfs 10.132.xxx.xxx:/var/sessions_share/ /var/lib/php/session/

– Add the following line to /etc/fstab to mount the NFS share at boot.

10.132.xxx.xxx:/var/sessions_share/ /var/lib/php/session/ nfs rw,sync,hard,intr 0 0

– Update the following parameters in /etc/php.ini (or the location of your php.ini file).

session.save_handler = file
session.save_path = /var/lib/php/session

– Restart Apache (if using mod_php) or the PHP-FPM service (if using FPM).

systemctl restart httpd
OR systemctl restart php-fpm

The major drawback of this approach is the performance hit your applications will experience writing session files to an NFS mount, rather than local storage. It is the least riskiest way to share session data across multiple app nodes in a cluster, given that the mechanism is identical to the default — which is to store session IDs in flat files. The storage is as reliable as your NFS server/cluster; sessions won’t be lost even if the NFS server must be rebooted, as they are persisted on disk.

Once implemented, you can check that sessions are being written to the NFS mount using this command on the nfs server.

watch ls /var/sessions_share

Objects in a Memcached in-memory cache

Memcached is a much speedier way to store sessions than by using an NFS share, with one major caveat. As an in-memory cache, any data “cached” to Memcached will be lost if the server is rebooted. To manage memory, Memcached also automatically evicts (deletes) unused objects as soon as the system reaches about 75% memory usage.

You should only consider using Memcached as a session store if performance is the most important consideration, and users have very short-lived sessions that wouldn’t be affected if they were lost.

Even in such cases where high-performance is crucial, we recommend eschewing Memcached for Redis (covered in the next section), a more modern solution which similarly runs in-memory but has far better options for persisting long-lived sessions to disk.

Support for using Memcached as a session save handler can be easily added by installing the php-memcached module on the application servers with PHP installed.

On the session storage server

Provision a session storage server on the same private network as your cluster.

On DigitalOcean, you might use the following parameters to spin up a Droplet – as a continuation of our previous guide on setting up a load-balanced LAMP cluster.

Image: CentOS 7.6 x64
Plan: Standard 1 GB / 1 CPU
Backups: Optional
Datacenter region: Same as datacenter for your cluster
Additional options: Private networking
SSH Keys: Select your SSH key.
Number of Droplets: 1
Hostname: sessions
Project: Name of project for your cluster

– Install Memcached, enable the service on boot, and start the Memcached server.

yum install epel-release
yum update
yum install memcached

systemctl enable memcached
systemctl start memcached

– Edit the Memcached configuration file located at /etc/sysconfig/memcached

The OPTIONS= line will initially be blank. Edit it as follows, providing a comma-separated list of IP addresses which the Memcached server should listen on, after the -l flag. As a minimum, include 127.0.0.1 (localhost) and substitute the private IP address of the sessions server for 10.132.xxx.xxx.

PORT="11211"
USER="memcached"
MAXCONN="1024"
CACHESIZE="64"
OPTIONS="-U 0 -l 127.0.0.1,10.132.xxx.xxx"

– Restart the memcached service to have the config changes take effect.

systemctl restart memcached

On the application servers

– Install the php-memcached PHP module.

yum install php-memcached

– Update the following parameters in /etc/php.ini (or the location of your php.ini file).

Where 10.132.xxx.xxx is the private IP address of the sessions server, with Memcached listening on port 11211.

session.save_handler = memcached
session.save_path = “10.132.xxx.xxx:11211”

– Restart Apache (if using mod_php) or the PHP-FPM service (if using FPM).

systemctl restart httpd
OR systemctl restart php-fpm

Once implemented, you can check that sessions are being written to Memcached using this command on the sessions server.

yum install net-cat
watch "echo stats | nc 127.0.0.1 11211"

Key-value pairs in a Redis database

Out of the choices listed above, storing PHP sessions in a Redis database provides the best of both worlds. The performance is comparably good to Memcached as Redis also stores its keys in-memory, but you can set up Redis so that it regularly persists its keys to disk. If the Redis server crashes, or is restarted for any reason, the keys can be automatically recovered from the disk.

Support for using Redis as a session save handler can be easily added by installing the phpredis module on the application servers with PHP installed.

On the session storage server

Provision a session storage server on the same private network as your cluster.

On DigitalOcean, you might use the following parameters to spin up a Droplet – as a continuation of our previous guide on setting up a load-balanced LAMP cluster.

Image: CentOS 7.6 x64
Plan: Standard 1 GB / 1 CPU
Backups: Optional
Datacenter region: Same as datacenter for your cluster
Additional options: Private networking
SSH Keys: Select your SSH key.
Number of Droplets: 1
Hostname: sessions
Project: Name of project for your cluster

– Install Redis, enable the service on boot, and start the Redis server.

yum install epel-release
yum update
yum install redis

systemctl enable redis
systemctl start redis

– Edit the following parameters in the Redis configuration file located at /etc/redis.conf

Where 10.132.xxx.xxx is the private IP address of the sessions server.

The bind directive lists the IP addresses corresponding to the interfaces which the Redis server should listen on. By listing both the loopback interface 127.0.0.1 and the LAN interface 10.132.xxx.xxx, the Redis server will be accessible from localhost and the private network.

The save 60 1 directive means that Redis will write the keys to disk every 60 seconds, when at least 1 key has been changed. By default, the Redis DBs are dumped to a file named dump.rdb, but this can be modified with the dbfilename directive, if desired.

Naturally, the trade-off for writing the Redis keys to disk too often is performance, but the more often Redis persists data to disk, the fewer keys you will lose in the event that Redis runs out of memory or crashes.

bind 127.0.0.1 10.132.xxx.xxx
save 60 1

– Restart the Redis service to have the config changes take effect.

systemctl restart redis

On the application servers

– Install the php-redis PHP module.

yum install php-pecl-redis

– Update the following parameters in /etc/php.ini (or the location of your php.ini file).

Where 10.132.xxx.xxx is the private IP address of the sessions server, with Redis listening on port 6379.

session.save.handler = redis
session.save_path = “tcp://10.132.xxx.xxx:6379”

If you plan to use the same Redis instance for non-session storage purposes, such as database caching, then it’s essential to make sure you specify the Redis database ID exclusively for PHP session storage. In this scenario, add this line instead:

session.save_path = “tcp://10.132.xxx.xxx:6379?database=10”

This ensures the PHPREDIS_SESSION keys are not overwritten by other usage of Redis.

– Restart Apache (if using mod_php) or the PHP-FPM service (if using FPM).

systemctl restart httpd
OR systemctl restart php-fpm

Once implemented, you can check that sessions are being written to Redis using this command on the sessions server. keys * lists all of the keys which are stored in the Redis DB.

redis-cli
127.0.0.1:6379> keys *

After some users have logged into the applications through the load balancer, you should see some entries with the PHPREDIS_SESSION key, followed by the session ID — similar to the following:

1) "PHPREDIS_SESSION:787oi01na5uv**************”
2) "PHPREDIS_SESSION:jfpv8dj0pcml**************"
3) "PHPREDIS_SESSION:rn0b7bn8c0b**************"
4) "PHPREDIS_SESSION:gqlb67bmn5db**************"
5) "PHPREDIS_SESSION:k8ehadfn0glb**************"
6) "PHPREDIS_SESSION:646o5jhamd47**************"

Testing and Troubleshooting

Assuming you successfully configured one of the options above for PHP session storage, you should no longer get spontaneously logged out of your web applications — even without session stickiness on the load balancer. On the other hand, if you encounter errors logging in or messages such as “Invalid CSRF token”, refer to the troubleshooting advice below.

If you don’t notice any session data being written to the NFS, Memcached, or Redis session store after configuring it in php.ini on the application servers, first ensure that your app servers are able to communicate with the shared session store.

NFS

Check whether the NFS mount appears in the list of mounts, using the mount command on the application server. Then, try creating an empty text file to the NFS mount and see whether it appears in the corresponding share directory on the NFS server.

The output of mount should contain a line similar to this.

10.132.14.xxx:/var/sessions_share on /var/lib/php/session type nfs4

Memcached or Redis

Using nmap, a port scanning utility, on the app servers, you can ensure that port 6379 is accessible. Where 10.132.xxx.xxx is the private IP address of the sessions server.

yum install nmap
For Memcached: nmap -p 11211 10.132.xxx.xxx
For Redis: nmap -p 6379 10.132.xxx.xxx

The output should state that the port is open:

PORT   STATE SERVICE
6379/tcp open  unknown

All: check the local php.ini value is not overriding the master php.ini value

With the default configuration files from a fresh install of Apache through the CentOS yum repository, the config file located at /etc/httpd/conf.d/php.conf (the local php.ini value) can override the session.save_handler and session.save_path values you’ve defined in /etc/php.ini (the master php.ini value).

To allow your directives in php.ini to take effect, comment out the following directives in /etc/httpd/conf.d/php.conf by adding a “#” preceding each line.

# php_value session.save_handler "files"
# php_value session.save_path    "/var/lib/php/session"
# php_value soap.wsdl_cache_dir  "/var/lib/php/wsdlcache"