Make your backend more reliable using Nginx caching proxy
Most of us are familiar with Nginx - it’s a very popular Web Server and Reverse Proxy. But do you know that you can also use it as a Caching Proxy?
Now, you’re probably wondering why would anyone want to do something like that - can’t you just update your service to cache data in Redis or Memcached? What are the upsides of externalizing the cache to a separate layer outside your service?
These are some scenarios where this might be useful:
- You want to serve cached data even when your service goes down
- You want to serve cached data when your service takes too long to respond
- You want to protect your service when there’s a lot of load
- You have a legacy system that you want to be made more reliable and performant but you can’t change the code
- You want to make a external 3rd party service more reliable and performant
- You’re using a polyglot microservices architecture and want a standard way of caching requests
Now that you’re interested, let’s go through the implementation one step at a time.
Simple proxy
Let’s start off with a simple implementation. You have a Nginx server that just proxies all the calls to your backend service without any caching.
This is what the Nginx config would look like:
events {
worker_connections 1024;
}
http {
server {
listen 3000;
location / {
proxy_set_header Host $host;
proxy_pass http://my-backend-service/;
}
}
}
This runs the Nginx server in port 3000
and proxies all requests to http://my-backend-service/
Read more: proxy_pass, proxy_set_header
Simple cache proxy
Now let’s extend the above example with caching.
events {
worker_connections 1024;
}
http {
+ proxy_cache_path /var/cache/nginx keys_zone=my_cache:10m;
server {
listen 3000;
+ proxy_cache my_cache;
location / {
proxy_set_header Host $host;
proxy_pass http://my-backend-service/;
+ proxy_cache_key $scheme://$host$uri$is_args$query_string;
+ proxy_cache_valid 200 10m;
}
}
}
Some new directives are added, let’s go through them one by one. Nginx caches the responses in the disk, proxy_cache_path
specifies the path where the responses are to be stored. proxy_cache
defines the shared memory zone used for storing the cache keys and other metadata. proxy_cache_key
defines the caching key. proxy_cache_valid
specifies cache expiry, which can also be configured dynamically by sending cache headers from your backend service. Once the cache expires, the responses are no longer considered “fresh” and become “stale”.
Nginx doesn’t immediately delete the stale responses and keeps them in disk. The reason why it doesn’t delete them would become more apparent in next few sections. We can use the parameter called inactive
in the proxy_cache_path
directive to control how long to keep the stale responses in disk before deleting them. It might be initially confusing how the proxy_cache_valid
and inactive
configuration work together as they seem to be doing similar things, let’s use the following example to understand this clearly:
If the proxy_cache_valid
is set to 5m and inactive
is set to 10m. If the first request comes at time T0 minutes and next request comes at T6 minutes, the second request would need to fetched from the backend service even though the data would still be in the disk as the cache has expired. If instead, the proxy_cache_valid
is set to 10m and inactive
is set to 5m, and the first request comes at time T0 minutes and next request comes at T6 minutes, even though the cache has not expired, the data is deleted from the disk so it needs to be fetched from backend service again.
Read more: proxy_cache_path, proxy_cache, proxy_cache_key, proxy_cache_valid
Temporarily bypass the cache
Sometimes you would need to temporarily bypass the cache and directly hit the backend service.
events {
worker_connections 1024;
}
http {
proxy_cache_path /var/cache/nginx keys_zone=my_cache:10m;
server {
listen 3000;
proxy_cache my_cache;
location / {
proxy_set_header Host $host;
proxy_pass http://my-backend-service/;
proxy_cache_key $scheme://$host$uri$is_args$query_string;
proxy_cache_valid 200 10m;
+ proxy_cache_bypass $arg_should_bypass_cache;
}
}
}
Here, we’ve added the proxy_cache_bypass
directive with $arg_should_bypass_cache
variable. If the request is sent with ?should_bypass_cache=true
query param, then the cache would be bypassed. You can also use cookies or HTTP headers instead of query params.
Read more: proxy_cache_bypass
Serve cached data when backend is down or slow
This is the killer application for using a separate caching proxy. When the backend service is down or takes too long to respond, we can configure Nginx to serve stale responses instead.
events {
worker_connections 1024;
}
http {
- proxy_cache_path /var/cache/nginx keys_zone=my_cache:10m;
+ proxy_cache_path /var/cache/nginx keys_zone=my_cache:10m inactive=1w;
server {
listen 3000;
proxy_cache my_cache;
location / {
proxy_set_header Host $host;
proxy_pass http://my-backend-service/;
proxy_cache_key $scheme://$host$uri$is_args$query_string;
proxy_cache_valid 200 10m;
proxy_cache_bypass $arg_should_bypass_cache;
+ proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504 http_429;
}
}
}
We’ve updated proxy_cache_path
with the inactive
parameter mentioned before and have added proxy_cache_use_stale
directive with the following params:
error
- use stale if backend can’t be reached
timeout
- use stale if backend takes too long to respond
http_xxx
- use stale if backend returns these status codes
The timeout is set to 60s
by default and can be configured using proxy_connect_timeout
directive.
Read more: proxy_cache_use_stale, proxy_next_upstream, proxy_connect_timeout
Prevent cache stampede
If you have an endpoint that’s slow and computationally expensive, and if there are many concurrent requests to that endpoint, the cache won’t be very helpful in reducing load to your backend service. This is called as “cache stampede” and in order to prevent this, we can use the proxy_cache_lock
directive.
events {
worker_connections 1024;
}
http {
proxy_cache_path /var/cache/nginx keys_zone=my_cache:10m inactive=1w;
server {
listen 3000;
proxy_cache my_cache;
location / {
proxy_set_header Host $host;
proxy_pass http://my-backend-service/;
proxy_cache_key $scheme://$host$uri$is_args$query_string;
proxy_cache_valid 200 10m;
proxy_cache_bypass $arg_should_bypass_cache;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504 http_429;
+ proxy_cache_lock on;
}
}
}
This will ensure that only one request goes to your backend service and others wait for it to come back. One downside of this directive is that it adds extra 500ms
latency to the requests that have been waiting.
Read more: proxy_cache_lock
Conclusion
As you can see, with a few lines of code, we can easily add caching to your services. While the cache only works at the public endpoint level, it should act as a good starting point and later Redis etc can be added for caching intermediate calculations within your service. Since it’s a separate proxy, it can also be added between your backend and legacy or external services.
Thanks for reading! Feel free to follow me in Twitter for more posts like this :)