Nginx + Lua + Redis. Efficiently processing sessions and delivering data

Suppose you have data you want to cache and serve without using heavy languages like PHP, while still ensuring the user is authenticated and authorized to access the data.
Today, I’ll show you how to achieve this using Nginx, Lua, and Redis. This approach can offload the server and boost the response speed by several times.

First, you’ll need to build Nginx with the nginx_lua_module.

Installation Guide

Install the Lua compiler (version 2.0 or 2.1).

Download and build LuaJIT

make && sudo make install

To compile Nginx with the Nginx devel kit, you’ll need the http_rewrite_module, which, in turn, requires the PCRE library. So, let’s install that first.

sudo apt-get update
sudo apt-get install libpcre3 libpcre3-dev

Download the required modules along with Nginx:

Let’s configure and install Nginx

export LUAJIT_LIB=/usr/local/lib // path to  lua
export LUAJIT_INC=/usr/local/include/luajit-2.1 // path to luaJit

./configure
--prefix=/etc/nginx
--sbin-path=/usr/sbin/nginx
--conf-path=/etc/nginx/nginx.conf
--error-log-path=/var/log/nginx/error.log
--http-log-path=/var/log/nginx/access.log
--pid-path=/var/run/nginx.pid
--lock-path=/var/run/nginx.lock
--http-client-body-temp-path=/var/cache/nginx/client_temp
--http-proxy-temp-path=/var/cache/nginx/proxy_temp
--http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp
--http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp
--http-scgi-temp-path=/var/cache/nginx/scgi_temp
--user=nginx
--group=nginx  
--with-ld-opt="-Wl,-rpath,/path/to/lua/lib" // path to Lua library
--add-module=/path/to/ngx_devel_kit // path to nginx devel kit
--add-module=/path/to/lua-nginx-module // path to nginx lua module
--without-http_gzip_module

make -j2
sudo make install

Download the Lua library for working with Redis from lua redis lib and copy it to the Lua library folder using the command:

sudo make install

Include the Lua Redis library in the Nginx configuration.

http {
...
    lua_package_path lua_package_path "/path/to/lib/lua/resty/redis.lua;;"; // path to lua redis library
...
}

That's it! Now you can write Lua scripts that are executed by Nginx.

Lua script

To quickly and efficiently serve cached data, we'll preload the most frequently used data into Redis during cache warming, while less frequently used data will be added on demand. We'll use Lua on the Nginx side to serve the data. This approach eliminates the need for PHP, significantly speeding up data delivery and reducing server memory usage.

Here's how we'll write the Lua script for this.
search.lua

local string = ngx.var.arg_string
if string == nil then
    ngx.exec("/")
end

local path = "/?string=" .. string

local redis = require "resty.redis"
local red = redis:new()

red:set_timeout(1000) -- 1 sec

local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.exec(path)
end

res, err = red:get("search:" .. string);

if res == ngx.null then
ngx.exec(path)
else
ngx.header.content_type = 'application/json'
ngx.say(res)
end

Include this file in nginx.conf and reload Nginx.

location /search-by-string {
   content_by_lua_file lua/search.lua;
}

Now, when a request is made to /search-by-string?string=smth, Lua will connect to Redis and try to find data with the key search:smth. If no data is found, the request will be handled by PHP. However, if the data is already cached in Redis, it will be immediately returned to the user.

What if we only want to serve data to authenticated users with a specific role?
In that case, we can store the session in Redis and check the user's role based on the session data before serving the content.

Since I work with the Symfony2 framework, I created a small bundle, nginx-session-handler, which allows storing sessions in Redis in a way that suits our needs.

In Redis, the session data will be stored as a hash value:

Now, we need to write a Lua script to handle this data:

session.lua

local redis = require "resty.redis"
local red = redis:new()

red:set_timeout(1000) -- 1 sec

local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
  ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end

local phpsession = ngx.var.cookie_PHPSESSID
local ROLE_ADMIN = "ROLE_ADMIN"

if phpsession == ngx.null then
  ngx.exit(ngx.HTTP_FORBIDDEN)
end

local res, err = red:hget("phpsession:" .. phpsession, "user-role")

if res == ngx.null or res ~= ROLE_ADMIN then
  ngx.exit(ngx.HTTP_FORBIDDEN)
end

We retrieve the user's session ID from the cookie and attempt to get their role from Redis using the query HGET phpsession:id user-role. If the user's session has expired, they are not authenticated, or they do not have the ROLE_ADMIN role, the server will return a 403 status code.
We then add this session-handling script before our data retrieval script. This ensures that only authenticated users with the ROLE_ADMIN role can access the data.
In practice, the session-handling script is required for multiple Nginx locations. To avoid duplicating code, we’ll include this script file wherever needed.

First, let’s slightly revise our session-handling script:

session.lua

local _M = {}

function _M.handle()

    local redis = require "resty.redis"
    local red = redis:new()

    red:set_timeout(1000) -- 1 sec

    local ok = red:connect("127.0.0.1", 6379)
    if not ok then
        ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
    end

    local phpsession = ngx.var.cookie_PHPSESSID
    local ROLE_ADMIN = "ROLE_ADMIN"

    if phpsession == ngx.null then
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end

    local res = red:hget("phpsession:" .. phpsession, "user-role")

    if res == ngx.null or res ~= ROLE_ADMIN then
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end

end

return _M

Next, we need to compile the session.lua file into a session.o object file using the LuaJIT compiler and build Nginx with this file.

Compile the session.o file by running the Lua compiler command:

/path/to/luajit/bin/luajit -bg session.lua session.o

Add the following line to the Nginx build configuration:

--with-ld-opt="/path/to/session.o"

Build Nginx (refer to the steps above for building Nginx).

Once this is done, you can include the compiled file in any Lua script and call the handle() function to process the user's session.

local session = require "session"
session.handle()

Tests

Machine configuration
Processor: Intel® Xeon(R) CPU X3440 @ 2.53GHz × 8
Memory: 7.9 GiB

ab -n 100 -c 100 php

Server Software:        nginx/1.9.4

Concurrency Level:      100
Time taken for tests:   3.869 seconds
Complete requests:      100
Failed requests:        0
<b>Requests per second:    25.85 [#/sec] (mean)</b>
Time per request:       3868.776 [ms] (mean)
Time per request:       38.688 [ms] (mean, across all concurrent requests)
Transfer rate:          6.66 [Kbytes/sec] received

Connection Times (ms)
min  mean[+/-sd] median   max
Connect:        1    3   1.1      3       5
Processing:   155 2116 1053.7   2191    3863
Waiting:      155 2116 1053.7   2191    3863
Total:        160 2119 1052.6   2194    3864

Percentage of the requests served within a certain time (ms)
50%   2194
66%   2697
75%   3015
80%   3159
90%   3504
95%   3684
98%   3861
99%   3864
100%   3864 (longest request)

ab -n 100 -c 100 lua

Server Software:        nginx/1.9.4

Concurrency Level:      100
Time taken for tests:   0.022 seconds
Complete requests:      100
Failed requests:        0
<b>Requests per second:    4549.59 [#/sec] (mean)</b>
Time per request:       21.980 [ms] (mean)
Time per request:       0.220 [ms] (mean, across all concurrent requests)
Transfer rate:          688.66 [Kbytes/sec] received

Connection Times (ms)
min  mean[+/-sd] median   max
Connect:        2    4   0.9      4       6
Processing:     3   13   1.6     13      14
Waiting:        3   13   1.6     13      14
Total:          9   17   1.3     18      18

Percentage of the requests served within a certain time (ms)
50%     18
66%     18
75%     18
80%     18
90%     18
95%     18
98%     18
99%     18
100%     18 (longest request)
</spoiler>
Разница "количества запросов в секунду" в 175 раз.

Такой же тест с другими парметрами

*ab -n 10000 -c 100 php*
Server Software:        nginx/1.9.4

Concurrency Level:      100
Time taken for tests:   343.082 seconds
Complete requests:      10000
Failed requests:        0
<b>Requests per second:    29.15 [#/sec] (mean)</b>
Time per request:       3430.821 [ms] (mean)
Time per request:       34.308 [ms] (mean, across all concurrent requests)
Transfer rate:          7.51 [Kbytes/sec] received

Connection Times (ms)
min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       4
Processing:   167 3414 197.5   3408    4054
Waiting:      167 3413 197.5   3408    4054
Total:        171 3414 197.3   3408    4055

Percentage of the requests served within a certain time (ms)
50%   3408
66%   3438
75%   3458
80%   3474
90%   3533
95%   3633
98%   3714
99%   3866
100%   4055 (longest request)

ab -n 10000 -c 100 lua

Server Software:        nginx/1.9.4

Concurrency Level:      100
Time taken for tests:   0.899 seconds
Complete requests:      10000
Failed requests:        0
<b>Requests per second:    11118.29 [#/sec] (mean)</b>
Time per request:       8.994 [ms] (mean)
Time per request:       0.090 [ms] (mean, across all concurrent requests)
Transfer rate:          1682.94 [Kbytes/sec] received

Connection Times (ms)
min  mean[+/-sd] median   max
Connect:        0    0   0.4      0       5
Processing:     1    9   3.4      7      19
Waiting:        1    9   3.5      7      18
Total:          2    9   3.4      7      21

Percentage of the requests served within a certain time (ms)
50%      7
66%     13
75%     13
80%     13
90%     13
95%     13
98%     13
99%     15
100%     21 (longest request)

The difference in "requests per second" is 381 times.
I hope my article was helpful. If you have any suggestions, comments, or know of any improvements, feel free to reach out.