0

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:

  1. Phase A
    1. Client A sends a request to the proxy
    2. Proxy doesn't have in cache so asks the backend
    3. Backend replies 200 with an ETag
    4. Proxy replies 200 with an ETag
  2. Phase B
    1. Client B sends the same request to the proxy
    2. Proxy has in cache but must revalidate (because no-cache and must-revalidate and ETag)
    3. Backend replies with 304 (because the revalidation request includes the If-None-Match header with the cached ETag)
    4. Proxy replies 200 with an Etag
  3. Phase C
    1. Client A sends the same request again, this time with If-None-Match
    2. The proxy asks the backend with the provided If-None-Match header (not the cached one)
    3. The backend server replies 304
    4. 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.

Xowap
  • 153

1 Answers1

3

Set a TTL

As stated in the built-in VCL: Varnish treats the no-cache directive in the same way as private & 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:

Cache-Control: public, s-maxage=3, must-revalidate

Make Varnish understand must-revalidate

Another issue is that Varnish doesn't understand the must-revalidate directive, whereas it does support the stale-while-revalidate directive.

Adding must-revalidate has the same effect as stale-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:

sub vcl_backend_response {
    if(beresp.http.Cache-Control ~ "must-revalidate") {
        set beresp.grace = 0s;
    }
}

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 the keep timer:

sub vcl_backend_response {
    set beresp.keep = 1d;
    if(beresp.http.Cache-Control ~ "must-revalidate") {
        set beresp.grace = 0s;
    }
}

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 an Authorization header or a Cookie 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 run varnishtest etag.vtc to validate the test case:

varnishtest "Testing ETag & 304 status codes in Varnish"

server s1 { # First backend request is not a conditional one # Return 200 with the ETag rxreq expect req.method == "GET" expect req.http.If-None-Match == <undef> txresp -status 200
-hdr "Cache-Control: public, s-maxage=3, must-revalidate"
-hdr "ETag: abc123" -bodylen 7

# Second backend request is a conditional one with a matching ETag
# Return a 304
rxreq
expect req.method == &quot;GET&quot;
expect req.http.If-None-Match == &quot;abc123&quot;
txresp -status 304 \
 -hdr &quot;Cache-Control: public, s-maxage=3, must-revalidate&quot; \
 -hdr &quot;ETag: abc123&quot;

# Third backend request is a conditional where the Etag doesn't match
# The content has been updated
# Return a 200 with  the updated content and the new Etag
rxreq
expect req.method == &quot;GET&quot;
expect req.http.If-None-Match == &quot;abc123&quot;
txresp -status 200 \
 -hdr &quot;Cache-Control: public, s-maxage=3, must-revalidate&quot; \
 -hdr &quot;ETag: xyz456&quot; -bodylen 8

} -start

varnish v1 -vcl+backend { sub vcl_backend_response { set beresp.keep = 1d; if(beresp.http.Cache-Control ~ "must-revalidate") { set beresp.grace = 0s; } } } -start

client c1 { # Send a regular request # Expect a cache miss # Trigger the first backend response # Return 200 with a response body txreq -url "/" rxresp expect resp.status == 200 expect resp.http.Age == "0" expect resp.http.ETag == "abc123" expect resp.http.Cache-Control == "public, s-maxage=3, must-revalidate" expect resp.bodylen == 7

# Wait for 1 second
delay 1

# Send a conditional request with an If-None-Match header
# Should be a cache hit
# Should return a 304 without a response body
# Varnish is responsible for this 304
txreq -url &quot;/&quot; -hdr &quot;If-None-Match: abc123&quot;
rxresp
expect resp.status == 304
expect resp.http.Age != &quot;0&quot;
expect resp.http.ETag == &quot;abc123&quot;
expect resp.http.Cache-Control == &quot;public, s-maxage=3, must-revalidate&quot;
expect resp.bodylen == 0

# Wait for 3 seconds
delay 3

# Send a conditional request with an If-None-Match header
# Should be a cache miss
# Trigger the second backend response
# Should return a 304 without a response body
# The server is responsible for this 304
txreq -url &quot;/&quot; -hdr &quot;If-None-Match: abc123&quot;
rxresp
expect resp.status == 304
expect resp.http.Age == &quot;0&quot;
expect resp.http.ETag == &quot;abc123&quot;
expect resp.http.Cache-Control == &quot;public, s-maxage=3, must-revalidate&quot;
expect resp.bodylen == 0

# Wait for 4 seconds
delay 4

# Send a conditional request with an If-None-Match header
# Should be a cache miss
# Trigger the third backend response
# Should return a 200 with a response body
# Content has been updated: a new Etag is returned
txreq -url &quot;/&quot; -hdr &quot;If-None-Match: abc123&quot;
rxresp
expect resp.status == 200
expect resp.http.Age == &quot;0&quot;
expect resp.http.ETag == &quot;xyz456&quot;
expect resp.http.Cache-Control == &quot;public, s-maxage=3, must-revalidate&quot;
expect resp.bodylen == 8

} -run

Conclusion

If you follow the steps I described, your Varnish setup will support ETag revalidation in 2 ways:

  • From the client to the cached object in Varnish while the object is still fresh
  • From Varnish to the origin server when the object has expired, but while it's still kept around in the cache due to the keep timer

Please 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 the keep value.

Thijs Feryn
  • 1,331