Varnish: How do You Know Your Backend Responses Properly?

You might use varnishstat to monitor backend healthy status, but that is not so directly, instead you can do this:

# varnishlog -g session -C -q 'ReqHeader:Host ~ "example.com"' | grep Status
--  RespStatus     200
--  RespStatus     200
--  RespStatus     200
--  RespStatus     200
--- BerespStatus   200
--- ObjStatus      200
--  RespStatus     200
--  RespStatus     200
--  RespStatus     200
--  RespStatus     200
--- BerespStatus   200
--- ObjStatus      200
--  RespStatus     200
--- BerespStatus   200
--- ObjStatus      200
--  RespStatus     200
--- BerespStatus   200
--- ObjStatus      200
--  RespStatus     200
--- BerespStatus   200
...

Convenient, right? you can tail and grep whatever you want of varnishlog’s output.

Whitelisting IPs in Limiting Request Rate of Nginx and Varnish

Sometimes we want to exclude IP blocks from limited request rate zone of web servers, here is how we can do it in Nginx and Varnish, the Nginx way needs crappy hacks, on the other hand, Varnish handles it really elegant.

The Nginx way:

# in the http block, we define a zone, and use the geoip
# module to map IP addresses to variable
http {
    ...
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
    # geo directive maps $remote_addr to $block variable.
    # limited as default
    geo $block {
        default          limited;
        10.10.10.0/24    blacklist;
        192.168.1.0/24   whitelist;
        include more_geoip_map.conf;
    }
}

# server block
server {
    ...
    location /wherever {
        if ($block = whitelist) { return 600; }
        if ($block = limited)   { return 601; }
        if ($block = blacklist) { return 403; }
        # error code 600 and 601 goes to each's internal location.
        error_page 600 = @whitelist;
        error_page 601 = @limited;
    }

    # @whitelist have no limiting, it just passes
    # the requests to backend.
    location @whitelist {
        proxy_pass http://backend;
        # feel free to log into other file.
        #access_log /var/log/nginx/$host_whitelist.access.log;
    }

    # insert limit_req here.
    location @limited {
        limit_req zone=one burst=1 nodelay;
        proxy_pass http://backend;
        # feel free to log into other file.
        #access_log /var/log/nginx/$host_limited.access.log;
    }
    ...
}

The Varnish way:

vcl 4.0;

import vsthrottle;

acl blacklist {
    "10.10.10.0/24";
}

acl whitelist {
    192.168.1.0/24;
}

sub vcl_recv {
    # take client.ip as identify to distinguish clients.
    set client.identity = client.ip;
    if (client.ip ~ blacklist) {
        return (synth(403, "Forbidden"));
    }
    if ((client.ip !~ whitelist) && vsthrottle.is_denied(client.identity, 25, 10s)) {
        return (synth(429, "Too Many Requests"));
    }
}

As you can see, unlike Nginx, Varnish has the powerful if directive, it works just like you’d expect.

Varnish 位于负载均衡后端的缓存清理

假设有如下的架构:
LB proxy pass to Varnish
我们有时清理缓存会遇到问题:虽然 Varnish 上面的缓存清理机制配置好了,但是使用客户端访问到的那个 URL 却无法清理缓存!具体一点来说是比如客户端使用”http://www.example.com/some/url” 访问到最前端的负载均衡(比如是使用 Nginx),那么当我们使用 PURGE 方法在 Varnish 服务器上面清除”http://www.example.com/some/url”这个 URL 的时候,会发现尽管 ban 规则添加了,但是客户端访问到的仍然是旧的资源;不过使用 Ctrl+f5 却能清除缓存,但这种方法在有多个 Varnish 缓存服务器的情况下不是一个好的清除缓存的方法。
出现这种情况的原因可能是负载均衡上面把 URL 做了修改了,比如 URL 重写、Host 头替换等。这个时候客户端发送过来的”http://www.example.com/some/url”到了 Varnish 服务器可能就变成了访问的是”http://www.example.com/some/other/url”或者”http://other.example.com/some/url”。这个时候自然是无法再使用客户端可见的 URL 来清理缓存,因为缓存服务器上面存在的是另外一个 URL 的缓存!
不过这个问题还是比较容易解决的。思路是是一定要把客户端访问的那个 URL 通过某种方式传递给缓存服务器,然后缓存服务器可以识别到那个前端 URL,并且使用那个客户端 URL 作为清除缓存的标志。
假设最前端是 Nginx 在做调度,我们可以通过以下步骤实现:
1. Nginx proxy pass 到后端的时候添加两个 HTTP 头,比如 X-Forwarded-Host 和 X-Forwarded-Url,分别保存用户请求的原始 Host 头和 URL,这两个会在 Nginx 请求后端的时候被发送。

proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Url  $request_uri;

(注意:proxy_set_header 指令如果在低级别位置定义,则不会继承高级别位置已经定义的。也就是说如果你在 http 这个配置段加了那两条配置指令,但是 location 配置段有其它的 proxy_set_header 指令存在了,那么 location 段不会继承到这两个 HTTP 头的配置,必须要单独在这里再配置一次。坑爹的 Nginx!)
2. Varnish 上面读取 X-Forwarded-Host 和 X-Forwarded-Url 这两个 HTTP 头,并且把它们作为清除缓存的标志。

sub vcl_backend_response {
        if (bereq.http.X-Forwarded-Host) {
                set beresp.http.X-PURGE-Host = bereq.http.X-Forwarded-Host;
        } else {
                set beresp.http.X-PURGE-Host = bereq.http.Host;
        }

        if (bereq.http.X-Forwarded-Url) {
                set beresp.http.X-PURGE-Url = bereq.http.X-Forwarded-Url;
        } else {
                set beresp.http.X-PURGE-Url = bereq.url;
        }
}

清理缓存的是这条命令:

ban ("obj.http.X-PURGE-Host == " + req.http.Host + " && obj.http.X-PURGE-Url ~ ^" + req.url + "$");

最终我们就都可以用和客户端一样的 URL 去清理缓存了。

Varnish 限制访问频率

Varnish 4 仍然没有内置的访问频率限制功能,要达到这一目的需要安装 vsthrottle VMOD。

这个 VMOD 可以从这里获取:https://github.com/varnish/libvmod-vsthrottle

可以使用源码目录中的 autogen.sh 利用 autotools 先生成 configure 脚本,然后再按照“./configure; make; make install”步骤安装好这个 VMOD(如果是使用官方 rpm 安装 Varnish 的话,还需要 varnish-libs-devel 这个包才能编译)。

安装好之后就可以在 vcl 配置里面使用它了。

vcl 4.0;

import vsthrottle;

sub vcl_recv {
        set client.identity = client.ip;
        if (vsthrottle.is_denied(client.identity, 15, 10s)) {
                # Client has exceeded 15 reqs per 10s
                return (synth(429, "Too Many Requests"));
        }
}

vsthrottle 的使用比较简单,只有一个“is_denied()”函数。函数原型为:

is_denied(STRING key, INT limit, DURATION period)

第一个 key 参数和 nginx 里面的 limit_req_zone 指令里面的 key 差不多,就是作为限制频率的依据。上面的配置里面我们按照客户端地址进行了访问限制,10s 内如果超过 15 次访问则给它返回 429 状态码——Too Many Requests。但是可以看到并没有直接将 client.ip 传递给 vsthrottle.is_denied,这是因为 key 参数必须是字符串类型,而 client.ip 则是个 IP 地址类型,只能将它先用 client.identity 保存起来后才能进行传递。key 的定义很灵活,我们可以把 req.http.User-Agent 等作为key,也可以利用类型转换,或者是 regsub 函数修改字符来作为 key。

Varnish: 让客户端强制刷新更新服务器缓存

从 RFC2616 的要求来看,缓存服务器是需要对客户端发过来的”Cache-Control”头作出回应的

Sometimes a user agent might want or need to insist that a cache revalidate its cache entry with the origin server (and not just with the next cache along the path to the origin server), or to reload its cache entry from the origin server. End-to-end revalidation might be necessary if either the cache or the origin server has overestimated the expiration time of the cached response. End-to-end reload may be necessary if the cache entry has become corrupted for some reason.

http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.4
其中说到了一种叫做”End-to-end reload”的:

End-to-end reload
The request includes a “no-cache” cache-control directive or, for compatibility with HTTP/1.0 clients, “Pragma: no-cache”. Field names MUST NOT be included with the no-cache directive in a request. The server MUST NOT use a cached copy when responding to such a request.

也就是说,当客户端(通常来说是浏览器)发过来”Cache-Control: no-cache”头(通常当我们按 Ctrl+f5 或者 Shift+鼠标点击刷新按钮的时候就会发出这个 HTTP 头)的时候,缓存服务器绝不能响应之前被缓存的拷贝,而是要重新去从源服务器获取一份发送给客户端。
但是在现实中往往不是这样:当我们在浏览器进行强制刷新的时候,只是将浏览器本地的缓存给清除掉了,缓存服务器的可能并没有更新。造成这个现象的原因主要有两个:

  1. 缓存服务器的性能可能满足不了这样的要求。
    在一个繁忙的缓存服务器,尤其是以磁盘作为主要缓存存储设备的服务器上面,限于磁盘IO,如果每次都必须将缓存对象彻底清除(实际上应该是可以讨巧的不去碰旧的缓存对象,然后去后端请求一个新的,然后替代旧的),即使再好的算法恐怕也无法应对大量的刷新缓存需求。
  2. 忽略客户端发过来的”Cache-Control”头,可以达到更高的缓存命中率。
    之所以要做缓存服务器,就是要让缓存率越高越好,开放让用户端去刷新缓存会降低缓存命中率。

但是现在也许该用新一点的思维来思考这个问题了:如果我们的服务器可以承受这样的刷新量,我们也许就可以让用户拥有刷新缓存服务器的能力。毕竟这是个信息迅速过期的时代,”Cache-Control: max-age=31536000″ 的作用也许已并不重要了。
我们可以用 Varnish 缓存服务器来做到这点:
前提要求是最好用纯内存作为缓存。添加如下的配置

vcl 4.0;

acl purge {
    "127.0.0.1";
}

sub vcl_recv {
    if (req.http.Cache-Control ~ "no-cache" && client.ip ~ purge) {
        # Force a cache miss
        set req.hash_always_miss = true;
    }
}

req.hash_always_miss 让 Varnish 去后端取新的内容,在 hash 里面替代旧的缓存对象,但是旧的并不会被立即清除,要等待它的 ttl 到了或者其它方法才会清除掉。这个做法的缺点是会在内存里面留下大量的旧的无用的缓存对象拷贝。
另一个方法是使用 ban 规则去清除缓存:

vcl 4.0;

acl purge {
    "127.0.0.1";
}

sub vcl_backend_response {
    # 后端响应添加到缓存时,把 Host 和 Url 添加到缓存里面,以方便后续可以根据 obj. 对象进行缓存清除。
    set beresp.http.X-Cache-Host = bereq.http.Host;
    set beresp.http.X-Cache-Url = bereq.url;
}

sub vcl_recv {
    # purge 地址内的客户端发过来"Cache-Control: no-cache"头,则添加一条 ban 规则,清除缓存
    if (req.http.Cache-Control ~ "no-cache" && client.ip ~ purge) {
        ban ("obj.http.X-Cache-Host == " + req.http.Host + " && obj.http.X-Cache-Url == " + req.url);
    }
}

sub vcl_backend_response {
        # 设置 Varnish 最长缓存保留时间为一天
        if (beresp.ttl > 1d) {
                set beresp.ttl = 1d;
        }
}

sub vcl_deliver {
    # 在发送给客户端之前,我们可以将只用于清除缓存的头去掉,没必要发给客户。
    unset resp.http.X-Cache-Host;
    unset resp.http.X-Cache-Url;
}

可以根据需求修改要不要对可以进行刷缓存的客户端 IP 进行限制。
最后,为什么要设置 Varnish 的最长保留缓存时间为一天?这是因为如果刷新请求量一旦比较大的话,会累积大量的 ban 规则(ban 规则只有当缓存里面最老的缓存对象都比他要新的时候才会被删除,否则就会一直累积着。因此,即使只有一个缓存对象保存时间为一年的话,都会导致 Varnish 累积一年的 ban 规则),这仍然还是会对性能产生很大的影响,所以为了不至于累积过多的 ban 规则,拖累 ban lurker 线程和整个 Varnish 的性能,直接设置最多保存一天就够了。(实际上,需要被缓存一天以上的东西本来就不多,可以通过 varnishtop -i TTL 查看到。)
更新:后面使用 ban 规则的方法似乎设置过最长 ttl 为一天后,还是会出现大量 ban 规则累积的情况,暂时没时间研究了。。。 🙁