How I made a powerful cache system using Go
Sylvain Combraque
Creator of Souin HTTP cache
Træfik helper/ambassador
Cache-handler maintainer
Open-source contributor
@darkweak
@darkweak_dev
Some context
Traefik
Emile Vauge
Written in GO
Easy to use
French Quality
Where is the cache ?
github.com/darkweak/Souin
Leave a ⭐️ on Github
Varnish vs Souin
[disclaimer trolling]
Demo
How does Go can help us to achieve performance
The power of
go func() {
_, _ = new(http.Client).Do(req)
}()
With channels
go func(rs http.ResponseWriter, rq *http.Request) {
if rc != nil && <-coalesceable {
rc.Temporize(req, rs, nextMiddleware)
} else {
errorBackendCh <- nextMiddleware(rs, rq)
return
}
errorBackendCh <- nil
}(res, req)
select {
case <-req.Context().Done():
switch req.Context().Err() {
case ctx.DeadlineExceeded:
cw := res.(*CustomWriter)
rfc.MissCache(cw.Header().Set, req, "DEADLINE-EXCEEDED")
cw.WriteHeader(http.StatusGatewayTimeout)
_, _ = cw.Rw.Write(serverTimeoutMessage)
return ctx.DeadlineExceeded
case ctx.Canceled:
return nil
default:
return nil
}
case v := <-errorBackendCh:
if v == nil {
_, _ = res.(souinWriterInterface).Send()
}
return v
}
Interfaces to the rescue
type Badger struct {}
func (provider *Badger) ListKeys() []string {}
func (provider *Badger) Prefix(key string, req *http.Request) *http.Response {}
func (provider *Badger) Get(key string) []byte {}
func (provider *Badger) Set(key string, value []byte) error {}
func (provider *Badger) Delete(key string) {}
func (provider *Badger) DeleteMany(key string) {}
func (provider *Badger) Init() error {}
func (provider *Badger) Name() string {}
func (provider *Badger) Reset() error {}
type Olric struct {}
func (provider *Olric) ListKeys() []string {}
func (provider *Olric) Prefix(key string, req *http.Request) *http.Response {}
func (provider *Olric) Get(key string) []byte {}
func (provider *Olric) Set(key string, value []byte) error {}
func (provider *Olric) Delete(key string) {}
func (provider *Olric) DeleteMany(key string) {}
func (provider *Olric) Init() error {}
func (provider *Olric) Name() string {}
func (provider *Olric) Reset() error {}
type Storer interface {
ListKeys() []string
Prefix(key string, req *http.Request) *http.Response
Get(key string) []byte
Set(key string, value []byte) error
Delete(key string)
DeleteMany(key string)
Init() error
Name() string
Reset() error
}
func NewStorages(c AbstractConfigurationInterface) map[string]Storer {
providers := make(map[string]AbstractProviderInterface)
olric, _ := OlricConnectionFactory(c)
providers["olric"] = olric
badger, _ := BadgerConnectionFactory(configuration)
providers["badger"] = badger
for _, p := range providers {
_ = p.Init()
}
return providers
}
Context
ctx := context.Background()
valCtx := ctx.Value("MY_CONTEXT_KEY")
if valCtx == nil {
return "not found"
}
val, ok := valCtx.(string)
// not found
ctx := context.Background()
ctx = context.WithValue(ctx, "MY_CONTEXT_KEY", "a string")
ctx := context.Background()
valCtx := ctx.Value("MY_CONTEXT_KEY")
if valCtx == nil {
return "not found"
}
val, ok := valCtx.(string)
// val: "a string"
// ok: true
const (
CacheName ctxKey = "souin_ctx.CACHE_NAME"
RequestCacheControl ctxKey = "souin_ctx.REQUEST_CACHE_CONTROL"
)
func (cc *cacheContext) SetContext(req *http.Request) *http.Request {
co, _ := cacheobject.ParseRequestCacheControl(req.Header.Get("Cache-Control"))
return req.WithContext(
context.WithValue(
context.WithValue(
req.Context(),
CacheName,
cc.cacheName
),
RequestCacheControl,
co
)
)
}
Cool features in Souin
default_cache:
ttl: 10s
reverse_proxy_url: 'http://reverse-proxy'
YAML configuration
func GetConfiguration() *Configuration {
data := readFile("./configuration/configuration.yml")
var config Configuration
if err := yaml.Unmarshal(data, config); err != nil {
log.Fatal(err)
}
return &config
}
Configuration as plugin
No configuration required
# souin/docker-compose.yml
version: '3.4'
x-networks: &networks
networks:
- your_network
services:
souin:
image: darkweak/souin:latest
ports:
- 80:80
- 443:443
environment:
GOPATH: /app
volumes:
- ./configuration.yml:/configuration/configuration.yml
<<: *networks
networks:
your_network:
external: true
# anywhere/docker-compose.yml
version: '3.4'
x-network:
&network
networks:
- your_network
services:
traefik:
image: traefik:latest
command: --providers.docker
ports:
- 81:80
- 444:443
- 8080:8080
volumes:
- /var/run/docker.sock:/var/run/docker.sock
<<: *network
networks:
your_network:
external: true
Container first
Middlewares
Middlewares
APP
Headers middleware
Middlewares
func (s *DummyCaddyPlugin) ServeHTTP(rw http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
req := s.Retriever.GetContext().SetBaseContext(r)
# do some stuff....
customWriter := newInternalCustomWriter(rw, bufPool)
getterCtx := getterContext{customWriter, req, next}
ctx := context.WithValue(req.Context(), getterContextCtxKey, getterCtx)
req = req.WithContext(ctx)
req.Header.Set("Date", time.Now().UTC().Format(time.RFC1123))
next.ServeHTTP(customWriter, r)
return nil
}
Request coalescing system support
var result singleflight.Result
select {
case <-timeout:
http.Error(rw, "Gateway Timeout", http.StatusGatewayTimeout)
return
case result = <-ch:
}
if result.Err != nil {
http.Error(rw, result.Err.Error(), http.StatusInternalServerError)
return
}
API management
Configuration
api:
basepath: /souin-api
prometheus:
basepath: /anything-for-prometheus-metrics
souin:
basepath: /anything-for-souin
Cache management API
Default base path: /souin
basePathAPIS := c.GetAPI().BasePath
if basePathAPIS == "" {
basePathAPIS = "/souin-api"
}
for _, endpoint := range api.Initialize(provider, c) {
if endpoint.IsEnabled() {
http.HandleFunc(
fmt.Sprintf(
"%s%s",
basePathAPIS,
endpoint.GetBasePath()
),
endpoint.HandleRequest
)
http.HandleFunc(
fmt.Sprintf(
"%s%s/",
basePathAPIS,
endpoint.GetBasePath()
),
endpoint.HandleRequest
)
}
}
How to code this?
Choose your storage
nutsdb
badger
redis
olric
etcd
Surrogate-Keys
First group
Second group
Third group
key 1
key 3
key 564
key 90
key ABC
key 3
key 789
key 1
key 789
Cache-Groups
CDN tag invalidation
CDN tag invalidation
Compatible with many softwares
Used in production by ETH.LIMO
It was funny parts to dev
Respect the RFC
such a pain
When you think it's simple, RFC tells you it is not
1. Introduction
HTTP is typically used for distributed information systems, where performance can be improved by the use of response caches. This document defines aspects of HTTP/1.1 related to caching and reusing response messages. An HTTP cache is a local store of response messages and the subsystem that controls storage, retrieval, and deletion of messages in it. A cache stores cacheable responses in order to reduce the response time and network bandwidth consumption on future, equivalent requests. Any client or server MAY employ a cache, though a cache cannot be used by a server that is acting as a tunnel. A shared cache is a cache that stores responses to be reused by more than one user; shared caches are usually (but not always) deployed as a part of an intermediary. A private cache, in contrast, is dedicated to a single user; often, they are deployed as a component of a user agent. The goal of caching in HTTP/1.1 is to significantly improve performance by reusing a prior response message to satisfy a current request. A stored response is considered "fresh", as defined in Section 4.2, if the response can be reused without "validation" (checking with the origin server to see if the cached response remains valid for this request). A fresh response can therefore reduce both latency and network overhead each time it is reused. When a cached response is not fresh, it might still be reusable if it can be freshened by validation (Section 4.3) or if the origin is unavailable (Section 4.2.4).
1.1. Conformance and Error Handling
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC2119]. Conformance criteria and considerations regarding error handling are defined in Section 2.5 of [RFC7230].
1.2. Syntax Notation
This specification uses the Augmented Backus-Naur Form (ABNF) notation of [RFC5234] with a list extension, defined in Section 7 of [RFC7230], that allows for compact definition of comma-separated lists using a '#' operator (similar to how the '*' operator indicates Fielding, et al. Standards Track [Page 4]
RFC-7234
if isResponseTransparent {
return transparent
}
if isResponseStale {
return stale
}
if isResponseFresh {
return fresh
}
expiresHeader := respHeaders.Get("Expires")
if expiresHeader != "" {
expires, err := time.Parse(time.RFC1123, expiresHeader)
if err != nil {
lifetime = zeroDuration
} else {
lifetime = expires.Sub(date)
}
}
RFC-9211
aka. Cache-Status RFC
Cache-Status
Souin; stored; fwd=uri-miss
Souin; hit; ttl=1234; key=GET-domain.com-/uri-another
Souin; fwd=uri-miss; key=GET-domain.com-/uri-another,
Caddy; hit; ttl=432; key=abcdef123
Cache-Status
res
.header
.Set(
"Cache-Status",
fmt.Sprintf(
"%s; fwd=uri-miss; key=%s; detail=UNCACHEABLE-STATUS-CODE",
rq.Context().Value(context.CacheName),
rfc.GetCacheKeyFromCtx(rq.Context()),
)
)
RFC-9213
aka. Targeted Cache-Control
CDN-Cache-Control
{name}-Cache-Control
Age: 1800
Cache-Control: max-age=600
Age: 1800
Cache-Control: max-age=600
CDN-Cache-Control: max-age=3600
Age: 1800
Cache-Control: max-age=600
CDN-Cache-Control: no-cache
Souin-Cache-Control: public; max-age=3600
LowLevelApp-Cache-Control: public; max-age=7200
https://www.youtube.com/watch?v=AaQxCzoT2X8
New version deployment
The workflow
Test
Build
Release
Testing
golangci-lint
go unit test
build plugins
E2E plugin tests
go unit test with services
jobs:
lint-validation:
name: Validate Go code linting
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
args: --timeout=240s
golangci-lint
jobs:
unit-test-golang:
needs: lint-validation
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: Run unit static tests
run: go test -v -race $(go list ./... | grep -v pkg/storage)
unit tests
jobs:
unit-test-golang-with-services:
needs: lint-validation
name: Unit tests with external services
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ${{ env.GO_VERSION }}
- name: Build and run the docker stack
run: |
docker network create your_network || true
docker-compose -f docker-compose.yml.test up \
-d --build --force-recreate --remove-orphans
- name: Run pkg storage tests
run: docker-compose -f docker-compose.yml.test exec -T souin \
go test -v -race ./pkg/storage
unit tests with services
jobs:
build-roadrunner-validator:
name: Check that Souin build as middleware
uses: ./.github/workflows/plugin_template.yml
secrets: inherit
with:
CAPITALIZED_NAME: Roadrunner
LOWER_NAME: roadrunner
GO_VERSION: '1.21'
build plugins
jobs:
plugin-test:
name: Check that Souin build as ${{ inputs.CAPITALIZED_NAME }} middleware
runs-on: ubuntu-latest
env:
GO_VERSION: ${{ inputs.GO_VERSION }}
# ...
steps:
- name: Check if the configuration is loaded to define if Souin is loaded too
uses: nick-invision/assert-action@v1
with:
expected: 'Souin configuration is now loaded.'
actual: ${{ env.MIDDLEWARE_RESULT }}
comparison: contains
- name: Run ${{ inputs.CAPITALIZED_NAME }} E2E tests
uses: anthonyvscode/newman-action@v1
with:
collection: "docs/e2e/Souin E2E.postman_collection.json"
folder: ${{ inputs.CAPITALIZED_NAME }}
reporters: cli
delayRequest: 5000
E2E plugin tests
jobs:
generate-souin-docker:
name: Generate souin docker
runs-on: ubuntu-latest
steps:
- name: Build & push Docker image containing only binary
id: docker_build
uses: docker/build-push-action@v4
with:
push: true
file: ./Dockerfile-prod
platforms: linux/arm64, # others...
build-args: |
"GO_VERSION=${{ env.GO_VERSION }}"
tags: |
darkweak/souin:latest
darkweak/souin:${{ env.RELEASE_VERSION }}
Build and Release
jobs:
generate-artifacts:
name: Deploy to goreleaser
runs-on: ubuntu-latest
steps:
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v3
with:
version: latest
args: release --clean
workdir: ./plugins/souin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GO_VERSION: ${{ secrets.GITHUB_TOKEN }}
Release
jobs:
generate-tyk-versions:
name: Generate Tyk plugin binaries
runs-on: ubuntu-latest
env:
LATEST_VERSION: v5.0
PREVIOUS_VERSION: v4.3
SECOND_TO_LAST_VERSION: v4.2
steps:
- name: Generate Tyk amd64 artifacts
run: cd plugins/tyk && make vendor && docker compose up
- name: Upload Tyk amd64 artifacts
uses: actions/upload-artifact@v3
with:
path: plugins/tyk/*.so
Release
My reaction when all jobs are green and the new version is available
The deployment is done
RESULTS
================================================================================
---- Global Information --------------------------------------------------------
> request count 101000 (OK=101000 KO=0 )
> min response time 0 (OK=0 KO=- )
> max response time 56 (OK=56 KO=- )
> mean response time 8 (OK=8 KO=- )
> std deviation 4 (OK=4 KO=- )
> response time 50th percentile 8 (OK=8 KO=- )
> response time 75th percentile 10 (OK=10 KO=- )
> response time 95th percentile 15 (OK=15 KO=- )
> response time 99th percentile 21 (OK=21 KO=- )
> mean requests/sec 3884.615 (OK=3884.615 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 101000 (100%)
> 800 ms < t < 1200 ms 0 ( 0%)
> t > 1200 ms 0 ( 0%)
> failed 0 ( 0%)
================================================================================
Roadmap
- 👷♂️ Stream every responses
- 🚀 Better performances
- 🛠 Managed version? (ask for the beta access)
Any idea ?
Open an issue at https://github.com/darkweak/souin/issues/new
Incoming conference 📣
Thank you for your attention
How I made a powerful cache system using Go
By darkweak
How I made a powerful cache system using Go
- 947