My CMS generates pretty complex pages and thus takes a little while to do so (about 2 seconds), which is well above my time budget to serve pages to the client.
However it's very cheap for me to know the current version of a given page and such it's very easy for me to say if a given version is still up-to-date. As such, I would like to be able to put a ETag-based strategy where every single request to a page needs to be revalidated but the server will reply in 10ms tops if the content didn't change.
For th is to be effective, I need to share this cache between all my clients. As long as the ETag gets revalidated, all my pages will stay identical for all users, so I can safely share their content.
In order to do so, my page emits a:
Cache-Control: public, no-cache, must-revalidate
ETag: W/"xxx"
When testing from a browser it works great: the page stays in cache and simply revalidates against the server every time I refresh the page, getting a 304 most of the time or a 200 when I change the content version.
All I need now is to share this cache between clients. Essentially:
- Phase A
- Client A sends a request to the proxy
- Proxy doesn't have in cache so asks the backend
- Backend replies 200 with an ETag
- Proxy replies 200 with an ETag
- Phase B
- Client B sends the same request to the proxy
- Proxy has in cache but must revalidate (because no-cache and must-revalidate and ETag)
- Backend replies with 304 (because the revalidation request includes the If-None-Match header with the cached ETag)
- Proxy replies 200 with an Etag
- Phase C
- Client A sends the same request again, this time with If-None-Match
- The proxy asks the backend with the provided If-None-Match header (not the cached one)
- The backend server replies 304
- The proxy replies 304
I've tried nginx but it requires lots of tweaking to even get it remotely working. Then I've tried Traefik before realizing that the caching middleware is part of the enterprise version. Then I've figured that Varnish seems to implement what I want.
So here I go with my Varnish configuration:
vcl 4.0;
backend default {
.host = "localhost";
.port = "3000";
}
backend api {
.host = "localhost";
.port = "8000";
}
sub vcl_recv {
if (req.url ~ "^/back/" || req.url ~ "^/_/") {
set req.backend_hint = api;
} else {
set req.backend_hint = default;
}
}
And of course... It didn't work.
When varying the Cache-Control
headers I either get the result from a shared cache but which isn't revalidated or just a pass-through to the client but never does it seem to keep the content in cache as I'd like it to.
What am I missing to get this shared cache/ETag re-validation logic in place? I suppose that I'm missing something obvious but can't figure out what.
Set a TTL
As stated in the built-in VCL: Varnish treats the
no-cache
directive in the same way asprivate
&no-store
: the content will not end up in the cache.For ETag-based revalidation that poses a problem, because there is nothing to compare.
My advice would be to set a low TTL to ensure it ends up in the cache. I would recommend using the following
Cache-Control
header:Make Varnish understand must-revalidate
Another issue is that Varnish doesn't understand the
must-revalidate
directive, whereas it does support thestale-while-revalidate
directive.Adding
must-revalidate
has the same effect asstale-while-revalidate=0
: we cannot serve stale content when the object expires and require immediate synchronous validation.This sets the internal
grace
timer to zero.However, with the following VCL code, you can make Varnish respect the
must-revalidate
directive:Increase the keep time
By setting the grace value to zero and assigning a low TTL, the objects will expire fairly quickly and won't be around long enough for revalidation.
As explained in https://stackoverflow.com/questions/68177623/varnish-default-grace-behavior/68207764#68207764 Varnish has a set of timers it uses to decide on expiration, revalidation & tolerated staleness.
My suggestion would be to increase the
keep
timer in VCL. This timer ensures that expired and out-of-grace objects are kept around, without running the risk of serving stale content.The only reason why the
keep
timer exists is for ETag revalidation, so that's exactly what you need.I suggest using the following VCL to support
must-revalidate
& increase thekeep
timer:This snippet increases the
keep
timer to a day, allowing expired content to stay in the cache for a day while revalidation takes place.Watch out with cookies (& authorization headers)
Despite having all the bits and pieces in place to support ETag revalidation and store objects in the cache, it's important that relevant requests don't bypass the cache. That has nothing to do with
Cache-Control
headers, but with request headers.If you take a look at the built-in VCL for the
vcl_recv
subroutine, you'll notice that by default Varnish bypasses the cache for requests containing anAuthorization
header or aCookie
header.If your website uses cookies, please read the following tutorial to learn how to remove tracking cookies that could mess up your hit rate: https://www.varnish-software.com/developers/tutorials/removing-cookies-varnish/
Varnish test case as a proof of concept
Store the following VTC content in
etag.vtc
and runvarnishtest etag.vtc
to validate the test case:Conclusion
If you follow the steps I described, your Varnish setup will support ETag revalidation in 2 ways:
keep
timerPlease also consider increasing the TTL of the cache if your content allows it. This all depends on how much pages/resources change.
Please also realize that keeping objects around longer (due to an increased
keep
value), will fill up the cache. When the cache is full an LRU strategy is applied to remove the least popular object from the cache.If
set beresp.keep = 1d;
is too much and Varnish starts removing objects from the cache because it's full, consider lowering thekeep
value.