Handling Geographical Cache Using Nginx and Lua

Handling Geographical Cache Using Nginx and Lua

The following approach uses Nginx to serve the user already cached data based on their geographical location.

Assume we have an application, which gets the latitude and the longitude of the user, searches through a database which in this sample application, saves "stores" based on their location, and responds to the user with the nearest store to them. Now imagine that in a reasonable time in the future, we get another request from another user based in a similar geographical location, e.g. in a 10km radius. In this case, we can use the same data we served to the first user.

To reach this goal we can have a cache database, in this case, Redis, to save the response alongside the location of the user and use it for the upcoming requests nearby the original user.

This approach aims to move the caching part of the mentioned application above to the Nginx. This way if the incoming request already has cached data, there's no need to bother the application about it. Nginx just serves the user with cached data and this could have a huge performance impact when we have a reasonable portion of HITs on our caching mechanism.

Let's take a look at how I've done it.

The application has three services:

  • A web application, in this case, a simple Flask app, to generate responses.
  • A Nginx to get the request, check for any existing data, and serving to the user.
  • A database, in this case, Redis, to save the cached data.
version: '3'
services:
  nginx:
    build:
      context: ./nginx
      dockerfile: deploy/Dockerfile
    ports:
      - 8080:80
  app:
    build:
      context: ./app
      dockerfile: deploy/Dockerfile
  redis:
    image: redis:6-alpine

1. Application

As mentioned above, the application is a simple Flask app, which gets the request from the user and serves an arbitrary response. The request in this case simply contains a lat for the latitude and a long for the longitude of the user. Then it simply calculates a store id, saves it to the Redis, and serves the user.

from flask import Flask, make_response, request
import redis
import json

app = Flask(__name__)
r = redis.Redis(host='redis', port=6379, db=0)

@app.route('/', methods=["GET"])
def index():
    lat = float(request.args.get("lat"))
    lng = float(request.args.get("long"))
    store_id = int(((lat+lng)%10)*10000)
    store = json.dumps({
        "address": "SAMPLE STORE ADDRESS",
        "name": "SAMPLE STORE NAME",
        "id": store_id
    })
    r.geoadd("stores", lng, lat, store)
    return make_response(store, 200)

2. Nginx

This is the place that all the magic happens. Let start with the Dockerfile.

FROM openresty/openresty:alpine-fat

RUN luarocks install redis-lua

COPY ./src/conf.d /etc/nginx/conf.d
COPY ./src/modules /usr/local/openresty/nginx

The reason I've chosen openresty is that it already has the lua builtin. But it'd be more reasonable to compile an Nginx from scratch with additional lua functionalities.

We also need the lua to interact with Redis, so we need to install it using luarocks install redis-lua.

And at last, we need to copy our Nginx configuration files and written lua modules to the container. We'll get to them next.

Now for the Nginx configuration we simply need a location to handle the caching, in this case, the root endpoint.

location = / {
    content_by_lua_file cache.lua;
}

The content of the response is produced by a lua file, named cache.lua. We'll get to that later. We also need an internal location, which cannot be accessible from the outside of Nginx, to handle requests to the Flask application. We'll get to that later too.

location = /app {
        internal;
        proxy_pass http://app:80/;
    }
}

Now it comes to the main part of the whole application, the cache.lua file that handles the caching. This file gets parameters from the request, searches through the redis database for any near records, serves the user if anything is found, and otherwise requests to the upstream application.

local redis = require 'redis'

redis.commands.georadius = redis.command('GEORADIUS')

local client = redis.connect('redis', 6379)
local lat = ngx.var.arg_lat
local long = ngx.var.arg_long

if not lat or not long then
    ngx.status = 400
    ngx.say("please insert lat and long parameters")
    ngx.exit(ngx.OK)
end

local cache_radius = 10
local cache_radius_unit = "km"

local cached_value = client:georadius("stores", long, lat, cache_radius, cache_radius_unit, "ASC")[1]
if cached_value then
    ngx.header["Content-Type"] = "application/json"
    return ngx.say(cached_value)
end
local res = ngx.location.capture("/app", {
    args = {
        lat = lat,
        long = long
    }
})
ngx.status = res.status  
return ngx.say(res.body)

3. redis

The redis part is completely straightforward. There's no customization or anything special happening, it just runs a container from a redis standard docker repository and handles the save and the load requests.

You can access the completed application here .