Sizing a Varnish or Nginx Cache Store
Problem Statement
A proxy cache is configured with whatever size seemed reasonable when it was deployed, and hit ratio has been drifting downward as the catalogue grows. The question is not “how big should a cache be” in the abstract but “how much of the demand is this store failing to hold”, and that is measurable.
Prerequisite Concepts
- Cache storage, eviction and capacity — the working-set idea this procedure operationalises.
- How to calculate cache freshness lifetime — the lifetime that defines the measurement window.
- Cache hit, miss and bypass mechanics — what a capacity-driven miss looks like from the outside.
Step-by-Step Resolution
Step 1 — Pick the measurement window
The store only needs to hold an object for as long as that object remains reusable. The correct window is therefore the longest freshness lifetime the store is expected to honour — if your longest s-maxage is 24 hours, measure 24 hours. Measuring a week over-sizes the store by counting objects that would have expired anyway.
Step 2 — Compute the working set
Count each distinct URL once and sum the response sizes:
# Access log fields: ... "$request" $status $body_bytes_sent ...
awk '$9 == 200 {size[$7] = $10} END {
for (u in size) { total += size[u]; n++ }
printf "%d distinct objects, %.1f GB working set\n", n, total/1073741824
}' /var/log/nginx/access.log
Two refinements matter. Exclude responses that are never stored — anything marked no-store, and any status the cache does not keep — because they consume no capacity. And count each variant separately if Vary is in play, since each is a full stored copy.
Step 3 — Configure the store
For Nginx the ceiling is max_size, and inactive is a second, independent eviction rule worth setting deliberately:
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=main:96m
max_size=130g inactive=24h use_temp_path=off;
The keys_zone size is separate from the content size and is often the forgotten constraint: it holds metadata, roughly one kilobyte per stored object, so 96 MB accommodates around a hundred thousand entries. A zone too small for the object count evicts entries regardless of how much disk remains.
For Varnish, storage is declared at startup and media is worth separating:
varnishd -s main=file,/var/lib/varnish/main.bin,130G \
-s media=file,/var/lib/varnish/media.bin,400G
Step 4 — Verify with the eviction counters
Configuration is a hypothesis; the counters test it. Varnish exposes evictions directly:
varnishstat -1 -f MAIN.n_lru_nuked -f MAIN.n_object -f MAIN.cache_hit -f MAIN.cache_miss
A steadily rising n_lru_nuked means the store is full and every admission costs an eviction. After a correct resize it should fall to near zero and stay there.
For Nginx there is no eviction counter, so measure the directory against the ceiling:
watch -n 60 'du -sh /var/cache/nginx'
A directory pinned at max_size is a store in continuous eviction.
Expected Output / Verification
Three signals together confirm the store is no longer the constraint: eviction count near zero, the store size settling below its ceiling rather than pinned at it, and hit ratio that no longer falls as the catalogue grows. If hit ratio stays low while all three hold, the problem is elsewhere — most often key fragmentation or a directive preventing storage.
MAIN.n_lru_nuked 3 0.00 /s
MAIN.n_object 84120 .
MAIN.cache_hit 918400 96.3 %
Step 5 — Decide whether more capacity is the right lever at all
A resize is worth doing only when the store is genuinely the constraint, and there are two common cases where it is not.
The first is key fragmentation. If a single page is stored under hundreds of keys because campaign parameters or an unsorted query string reach the cache key, the working set measurement is inflated by content that does not exist. Sizing to it buys disk for duplicates. The measurement in step 2 makes this visible: a distinct-object count far larger than the number of URLs the site actually publishes is the signature, and the fix is normalization rather than capacity.
The second is a periodic sweep. A crawler or a synthetic monitor that walks every URL once per night evicts the entire hot set in favour of objects nobody will request again, and the store recovers over the following hours. Hit ratio plotted by hour shows this immediately as a nightly cliff. Admission control — Nginx’s proxy_cache_min_uses, or an equivalent policy — solves it by refusing to store single-request objects, and no amount of extra disk will.
Where neither applies and the eviction counters are high, capacity is the answer and the measurement gives the number.
Edge Cases
- The metadata zone, not the disk, is the ceiling. An Nginx
keys_zonesized for fewer objects than the working set contains evicts entries while the disk sits half empty. Size it at roughly one kilobyte per expected object. inactiveshorter than the lifetime. Nginx removes entries untouched forinactiveregardless of freshness, silently discarding the rarely-requested remainder. Keep it at or above the longest lifetime you intend to honour.- A working set that grows discontinuously. A catalogue import or a new locale can double the distinct-object count overnight. Re-measure after any event that changes what is addressable, not on a fixed schedule.
- Filesystem overhead on small objects. A store full of two-kilobyte responses wastes a significant fraction of its disk on block granularity. Measure the directory size, not the summed content size, when checking against the ceiling.
Frequently Asked Questions
Should the store hold the whole catalogue?
Almost never. The catalogue can be orders of magnitude larger than the set of objects anyone requests within a lifetime, and capacity spent on objects nobody asks for returns nothing. Size to the working set and let the rarely-requested remainder miss.
What is a reasonable headroom factor?
Enough to absorb growth and traffic-mix changes without another resize — commonly 30 to 50 percent above the measured working set. Beyond that the returns are negligible, because hit ratio flattens once the working set fits.
Does a bigger store fix a low hit ratio?
Only when the store is the constraint. If hit ratio is low because the cache key fragments, or because a crawler flushes the store nightly, capacity postpones the symptom without touching the cause.
Should large media share a store with everything else?
No. A handful of large objects can evict thousands of small ones under a single LRU policy. Where the cache supports multiple stores, give media its own with its own ceiling.
Related
- Cache key normalization and query strings covers the fragmentation that inflates a working set beyond what the content justifies.
- Origin shielding and request collapsing is the topology answer to a working set that will not fit at every edge location.
- Range requests and partial content caching explains why media consumes store capacity in blocks rather than whole objects.