본문으로 이동

A MediaWiki cache built on Caddy: caddy-mwcache

이 문서는 페미위키:운영팀/개발팀/기술 블로그/28 문서를 번역한 것이며 번역은 100% 완료했습니다.
.
A squirrel sitting on a rock in the woods, holding a nut in its front paws and eating it.
© su neko, CC BY 2.0

Hello, this is Leslie from the Femiwiki development team. Femiwiki keeps the pages it shows to logged-out visitors in a cache we built ourselves, called caddy-mwcache. In this post I would like to introduce how this cache was made, and a bug we recently fixed that kept it from holding more than 17 pages. This post is written for readers who already know about web servers and HTTP caching.

Background

When a page changes, MediaWiki sends a PURGE request to that page's address to tell the cache in front of it to drop the old copy. Wikipedia receives these requests with Varnish,[1] but Femiwiki had only one server, which made a separate cache server hard to justify, and it was already using Caddy as its web server. So when CPU usage suddenly rose in December 2020, we decided to cache requests from logged-out visitors,[2] built it ourselves as a Caddy module, and released the first version in April 2021.[3][4]

How the cache is built

Responses are stored in Ristretto, an in-memory cache library written in Go, and whether to cache something is decided by rules similar to Wikimedia's Varnish. For example, a request with a session or token cookie, or an Authorization header, may come from a logged-in user, so it bypasses the cache;[5] a response carrying private, no-cache, no-store or Set-Cookie is not cached; and how long a response is kept follows the s-maxage that MediaWiki sends.

sessionCookiePattern = regexp.MustCompile(`([sS]ession|Token)=`)

func hasSessionCookie(r *http.Request) bool {
	cookie := r.Header.Get("Cookie")
	return sessionCookiePattern.MatchString(cookie)
}

By the way, when we first built it, PURGE requests never reached the cache. When $wgInternalServer is empty, MediaWiki sends PURGE to $wgServer, which is an https://… address, and Caddy took that as a proxy tunnel (CONNECT) request and passed it on over HTTPS.[6] We fixed this by setting $wgInternalServer to http://127.0.0.1:80.

The cache that held only 17 pages

In September 2026 we noticed that the main page was always served from the cache, while an ordinary page was rendered anew no matter how many times it was reopened. Opening the same address twice and comparing the x-request-id attached to each response told them apart, because a response from the cache carries the value it had when it was cached.[7]

/w/페미위키:대문  x-request-id: 539669cff53359cf8874d69d
/w/페미위키:대문  x-request-id: 539669cff53359cf8874d69d
/w/설경구        x-request-id: b32ef97ff7d12cf14c30ce70
/w/설경구        x-request-id: fd9e121cfaf1ee53b06a97ca

Two causes overlapped. First, every entry was given a cost of 1 when it was stored, so the max_cost setting counted entries rather than bytes. On top of that, unless IgnoreInternalCost is turned on, Ristretto adds 56, the size of its internal struct, to the cost of every entry,[8] so an entry with a cost of 1 actually took up 57, and the cache could only hold max_cost divided by 57. Putting 5,000 entries into a cache with a MaxCost of 1,000 gives this:

held: 17
hit: 17 miss: 4983 keys-added: 5000 keys-evicted: 4983 cost-added: 285000 ... hit-ratio: 0.00

So we added a new setting, max_cost_bytes, which costs each entry at the response's size in bytes. Since someone somewhere might be using caddy-mwcache, which is open source, we left the existing max_cost as it was for backward compatibility, and when counting in bytes, the internal struct size is counted too, because it is memory actually in use.[9]

cost := int64(1)
if m.costInBytes {
	cost = int64(len(val))
}

Hit rate

Even after the cache grew from 17 entries to 1,000, and then to 64 MiB, the number of requests served from it stayed at about 155 to 195 every five minutes. To find out why, we counted the page requests that came in during one hour from 11 a.m. on September 25 (KST).[10]

Requests 1,691
Distinct addresses 1,545
Repeat requests for the same address 146 (8.6%)
Requests served from the cache 96
Cache usage 19.7 MB of 64 MiB

In other words, with only 146 repeat requests for the same address, no amount of extra cache size looked able to get more hits than that. 1,498 addresses were requested exactly once, which is just what a crawler sweeping a wiki from start to finish looks like; for example, one IP address made 247 requests at intervals of 3.5 to 5.7 seconds.

Other improvements

We could not raise the hit rate, but along the way we made the following improvements.

  • Caddy was compressing responses taken from the cache with gzip all over again every time, and about 60% of the CPU Caddy used on the production server went to compression. Now a response is compressed once when it is stored and sent out as it is.[11]
  • Because load.php does not use sessions (MW_NO_SESSION), it is now served from the cache even when a session cookie is present. A crawler that picked up a cookie on the account creation page had been making the server rebuild a stylesheet every few seconds.[12]
  • Following Wikimedia's static.php,[13] static files whose address carries a version hash, such as skin icons, are now marked as cacheable for a year when the hash matches the file on disk.
  • The host is now part of the cache key, so pages from different hosts cannot get mixed up even if different hosts serve different pages someday.[14][15]
  • When several requests ask for the same page at once, MediaWiki is asked only once,[16] and when the browser's copy is still current, the cache answers 304 without a body and adds an Age header.[17]
  • Pages are stored separately for each visitor language, so the cache works with the automatic interface language selection we recently turned on.[18]

Closing

When we first introduced the cache, I thought that for a wiki a bigger cache was always better, and vaguely assumed it would be doing something, but recently, with crawlers making up most of Femiwiki's traffic, I saw in numbers for the first time that it does very little. Now Femiwiki also has CloudFront in front of it, so requests such as load.php are mostly served by CloudFront and caddy-mwcache takes the remaining pages behind it, among many other improvements.

caddy-mwcache is published under the AGPL, so if you run MediaWiki on a single server, we would be glad if you considered giving it a try.

References

  1. ↑ “Manual:Varnish caching”. 《MediaWiki》 (영어). 2026년 11월 1일에 확인함. 
  2. ↑ “Cache anonymous request · Issue #219 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  3. ↑ “Implement cache · Pull Request #3 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  4. ↑ “Deploy caddy-mwcache · Pull Request #491 · femiwiki/docker-mediawiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  5. ↑ “caddy-mwcache/mwcache.go at main · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  6. ↑ “테스트 서버 상에서 caddy-mwcache 사용 중 purge가 안 됨 · Issue #239 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  7. ↑ “caddy-mwcache: make max_cost a byte budget · Issue #452 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  8. ↑ “ristretto/cache.go at v2.4.2 · dgraph-io/ristretto”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  9. ↑ “feat: add max_cost_bytes, a budget spent in bytes · Pull Request #141 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  10. ↑ “caddy-mwcache: caddy reload drops the whole cache · Issue #454 · femiwiki/femiwiki”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  11. ↑ “feat: store pages gzipped · Pull Request #164 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  12. ↑ “feat: serve load.php from the cache to requests with a session cookie · Pull Request #167 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  13. ↑ “feat: add a static block that caches static files by their version hash · Pull Request #160 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  14. ↑ “The cache key has no host · Issue #170 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  15. ↑ “feat!: put the request's host in the cache key · Pull Request #171 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  16. ↑ “feat: coalesce concurrent misses for one page · Pull Request #197 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  17. ↑ “feat: answer conditional requests on hits and send Age · Pull Request #198 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함. 
  18. ↑ “feat: store a variant per Accept-Language with vary_accept_language · Pull Request #207 · femiwiki/caddy-mwcache”. 《GitHub》 (영어). 2026년 11월 1일에 확인함.