diff --git a/api/.env b/api/.env index 10629fa72..b156e4d3b 100644 --- a/api/.env +++ b/api/.env @@ -59,3 +59,5 @@ MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=https://localhost ###< symfony/routing ### + +CACHE_INVALIDATION_URL=http://localhost:2019/souin-api/souin diff --git a/api/Dockerfile b/api/Dockerfile index 4320f33c7..4e71929a8 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,9 +12,35 @@ FROM docker.io/dunglas/frankenphp:1-php8.5 AS frankenphp_upstream # https://docs.docker.com/compose/compose-file/#target +# Build FrankenPHP with Mercure, Vulcain and Souin (HTTP cache) modules +FROM docker.io/dunglas/frankenphp:1-builder AS builder + +# Install xcaddy to build a custom FrankenPHP binary with additional Caddy modules +# CGO_ENABLED=0: xcaddy is a pure Go tool and must not be compiled with CGO +# hadolint ignore=DL3062 +RUN CGO_ENABLED=0 go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest + +# hadolint ignore=DL3062 +RUN CGO_ENABLED=1 \ + CGO_CFLAGS="$(php-config --includes)" \ + CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \ + XCADDY_GO_BUILD_FLAGS="-tags=nobadger,nomysql,nopgx" \ + "$(go env GOPATH)/bin/xcaddy" build \ + --output /usr/local/bin/frankenphp \ + --with github.com/dunglas/caddy-cbrotli \ + --with github.com/dunglas/frankenphp/caddy \ + --with github.com/dunglas/mercure/caddy \ + --with github.com/dunglas/vulcain/caddy \ + --with github.com/darkweak/souin/plugins/caddy \ + --with github.com/darkweak/storages/otter/caddy \ + --with github.com/darkweak/storages/redis/caddy + + # Base FrankenPHP image FROM frankenphp_upstream AS frankenphp_base +COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp + WORKDIR /app # persistent / runtime deps diff --git a/api/config/packages/api_platform.yaml b/api/config/packages/api_platform.yaml index 769d460a0..a2a574aab 100644 --- a/api/config/packages/api_platform.yaml +++ b/api/config/packages/api_platform.yaml @@ -71,3 +71,19 @@ services: $nameConverter: '@?api_platform.name_converter' $properties: { name: 'ipartial' } tags: [ 'api_platform.filter' ] + +when@prod: + api_platform: + http_cache: + invalidation: + enabled: true + purger: 'app.http_cache.purger.fault_tolerant_souin' + urls: + - '%env(CACHE_INVALIDATION_URL)%' + + services: + app.http_cache.purger.fault_tolerant_souin: + class: App\HttpCache\FaultTolerantSouinPurger + arguments: + $inner: '@api_platform.http_cache.purger.souin' + $logger: '@logger' diff --git a/api/frankenphp/Caddyfile b/api/frankenphp/Caddyfile index 96bc936d6..beb89428e 100644 --- a/api/frankenphp/Caddyfile +++ b/api/frankenphp/Caddyfile @@ -52,6 +52,8 @@ header ?Permissions-Policy "browsing-topics=()" route { + {$CADDY_SERVER_CACHE} + # Matches requests for OIDC routes @oidc expression path('/oidc/*') diff --git a/api/src/HttpCache/FaultTolerantSouinPurger.php b/api/src/HttpCache/FaultTolerantSouinPurger.php new file mode 100644 index 000000000..9e1bcefb8 --- /dev/null +++ b/api/src/HttpCache/FaultTolerantSouinPurger.php @@ -0,0 +1,40 @@ +inner->purge($iris); + } catch (\Throwable $throwable) { + $this->logger->warning('Failed to purge HTTP cache: {message}', [ + 'message' => $throwable->getMessage(), + 'exception' => $throwable, + ]); + } + } + + public function getResponseHeaders(array $iris): array + { + return $this->inner->getResponseHeaders($iris); + } +} diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 78a6d9465..e77c76968 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -13,6 +13,10 @@ services: - gutendex.com php: + environment: + # Disable HTTP cache during E2E tests: Souin would cache empty API responses + # during service startup (before fixtures are loaded) and serve them during tests + CADDY_SERVER_CACHE: "" volumes: - ./e2e/mock-server/cert.pem:/usr/local/share/ca-certificates/mock-server.crt:ro depends_on: diff --git a/compose.prod.yaml b/compose.prod.yaml index 5455a3db3..46d00ff44 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -10,6 +10,21 @@ services: APP_SECRET: ${APP_SECRET} MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET} + CADDY_GLOBAL_OPTIONS: | + order cache before rewrite + cache { + ttl 3600s + stale 86400s + default_cache_control "public, s-maxage=3600" + redis { + url redis:6379 + } + otter + api { + souin + } + } + CADDY_SERVER_CACHE: "cache" pwa: image: ${PWA_DOCKER_IMAGE} diff --git a/compose.yaml b/compose.yaml index 33faa6720..1851d8452 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,8 +8,11 @@ services: condition: service_started keycloak: condition: service_started + redis: + condition: service_healthy restart: unless-stopped environment: + CACHE_INVALIDATION_URL: "http://localhost:2019/souin-api/souin" PWA_UPSTREAM: pwa:3000 OIDC_UPSTREAM: keycloak:8080 SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 @@ -91,6 +94,17 @@ services: ###> symfony/mercure-bundle ### ###< symfony/mercure-bundle ### + redis: + image: docker.io/redis:8-alpine + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + volumes: + - redis_data:/data + keycloak-database: image: docker.io/postgres:${KEYCLOAK_POSTGRES_VERSION:-16}-alpine environment: @@ -144,4 +158,5 @@ volumes: ###< doctrine/doctrine-bundle ### ###> symfony/mercure-bundle ### ###< symfony/mercure-bundle ### + redis_data: keycloak_db_data: diff --git a/docs/adr/0005-http-cache-with-souin.md b/docs/adr/0005-http-cache-with-souin.md new file mode 100644 index 000000000..1a3fe2d6a --- /dev/null +++ b/docs/adr/0005-http-cache-with-souin.md @@ -0,0 +1,31 @@ +# HTTP Cache with Souin + +* Status: accepted +* Deciders: @vincentchalamon + +## Context and Problem Statement + +The demo application exposes public HTTP cache headers (`Cache-Control: public`) on API responses, but no cache layer actually stores and serves cached responses. Every request hits the PHP backend, even for unchanged resources. The demo needs an HTTP cache that integrates natively with FrankenPHP (Caddy) and API Platform's automatic cache invalidation, while also serving as an example for distributed deployments. + +## Considered Options + +**Varnish** is the traditional HTTP cache for API Platform projects. It is battle-tested and well-documented. However, it requires a separate service (reverse proxy) in front of the application, adding operational complexity. It does not integrate natively with Caddy/FrankenPHP — the two processes must be orchestrated separately, and Varnish uses its own configuration language (VCL). + +**Souin** is an HTTP cache module written in Go that compiles directly into Caddy as a plugin. Since FrankenPHP is built on Caddy, Souin runs in the same process — no additional service, no network hop for cache lookups. It is RFC 7234 compliant, supports surrogate key invalidation (which API Platform uses natively via `SouinPurger`), and offers pluggable storage backends. The trade-off is that it requires a custom FrankenPHP build via `xcaddy`. + +**CloudFlare / CDN** would offload caching entirely to an external provider. This works well in production but cannot be demonstrated locally, and ties the demo to a specific vendor. + +## Decision Outcome + +We chose **Souin** because it integrates natively with the existing FrankenPHP/Caddy stack — no extra service to manage, no network hop, and built-in support in API Platform via `api_platform.http_cache.purger.souin`. This makes the demo a realistic showcase of HTTP caching with automatic invalidation. + +For storage, we use a **two-tier architecture: Otter (L1) + Redis (L2)**. Otter is an in-memory cache local to each process, providing the fastest possible read path. Redis is a shared, persistent store that serves two purposes: distributing the cache across multiple pods in a Kubernetes deployment, and surviving process restarts so the cache is not lost on every deploy. This combination demonstrates a production-ready pattern for distributed projects, which is a key goal of the demo application. + +The cache is compiled into all images (dev and prod) but **only activated in production** via environment variables. This keeps the developer experience unchanged while ensuring the production setup is always tested with the same binary. + +## Links + +* [Souin — HTTP cache module for Caddy](https://github.com/darkweak/souin) +* [Souin Caddy plugin documentation](https://docs.souin.io/docs/middlewares/caddy/) +* [API Platform HTTP Cache documentation](https://api-platform.com/docs/core/performance/) +* [FrankenPHP custom builds](https://frankenphp.dev/docs/compile/) diff --git a/helm/api-platform/templates/configmap.yaml b/helm/api-platform/templates/configmap.yaml index 172447865..afb5e2379 100644 --- a/helm/api-platform/templates/configmap.yaml +++ b/helm/api-platform/templates/configmap.yaml @@ -13,7 +13,9 @@ data: mercure-url: "http://{{ include "api-platform.fullname" . }}/.well-known/mercure" mercure-public-url: {{ .Values.mercure.publicUrl | default "http://127.0.0.1/.well-known/mercure" | quote }} mercure-extra-directives: {{ .Values.mercure.extraDirectives | quote }} - caddy-global-options: {{ .Values.php.caddyGlobalOptions | quote }} + caddy-global-options: {{ tpl .Values.php.caddyGlobalOptions . | quote }} + caddy-server-cache: {{ .Values.php.caddyServerCache | default "" | quote }} + cache-invalidation-url: "http://localhost:2019/souin-api/souin" oidc-server-url: "https://{{ (first .Values.ingress.hosts).host }}/oidc/realms/demo" oidc-server-url-internal: "http://{{ include "api-platform.fullname" . }}-keycloak/oidc/realms/demo" better-auth-url: "https://{{ (first .Values.ingress.hosts).host }}/api/auth" diff --git a/helm/api-platform/templates/deployment.yaml b/helm/api-platform/templates/deployment.yaml index 88700128e..1c31b2988 100644 --- a/helm/api-platform/templates/deployment.yaml +++ b/helm/api-platform/templates/deployment.yaml @@ -120,6 +120,16 @@ spec: secretKeyRef: name: {{ include "api-platform.fullname" . }} key: mercure-jwt-secret + - name: CADDY_SERVER_CACHE + valueFrom: + configMapKeyRef: + name: {{ include "api-platform.fullname" . }} + key: caddy-server-cache + - name: CACHE_INVALIDATION_URL + valueFrom: + configMapKeyRef: + name: {{ include "api-platform.fullname" . }} + key: cache-invalidation-url ports: - name: http containerPort: 80 diff --git a/helm/api-platform/templates/redis-deployment.yaml b/helm/api-platform/templates/redis-deployment.yaml new file mode 100644 index 000000000..e8ca41b9d --- /dev/null +++ b/helm/api-platform/templates/redis-deployment.yaml @@ -0,0 +1,45 @@ +{{- if .Values.redis.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "api-platform.fullname" . }}-redis + labels: + {{- include "api-platform.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + replicas: 1 + selector: + matchLabels: + {{- include "api-platform.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: redis + template: + metadata: + labels: + {{- include "api-platform.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: redis + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: redis + image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}" + imagePullPolicy: {{ .Values.redis.image.pullPolicy }} + ports: + - name: redis + containerPort: 6379 + protocol: TCP + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.redis.resources | nindent 12 }} +{{- end }} diff --git a/helm/api-platform/templates/redis-service.yaml b/helm/api-platform/templates/redis-service.yaml new file mode 100644 index 000000000..a63f81c27 --- /dev/null +++ b/helm/api-platform/templates/redis-service.yaml @@ -0,0 +1,19 @@ +{{- if .Values.redis.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "api-platform.fullname" . }}-redis + labels: + {{- include "api-platform.labels" . | nindent 4 }} + app.kubernetes.io/component: redis +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: redis + protocol: TCP + name: redis + selector: + {{- include "api-platform.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: redis +{{- end }} diff --git a/helm/api-platform/values.yaml b/helm/api-platform/values.yaml index 7c9234d0f..80654c87c 100644 --- a/helm/api-platform/values.yaml +++ b/helm/api-platform/values.yaml @@ -19,18 +19,26 @@ php: - "10.0.0.0/8" - "172.16.0.0/12" - "192.168.0.0/16" + caddyServerCache: "cache" caddyGlobalOptions: | debug servers { metrics trusted_proxies static private_ranges } -# order cache before rewrite -# cache { -# api { -# souin -# } -# } + order cache before rewrite + cache { + ttl 3600s + stale 86400s + default_cache_control "public, s-maxage=3600" + redis { + url {{ include "api-platform.fullname" . }}-redis:6379 + } + otter + api { + souin + } + } resources: requests: memory: 100Mi @@ -89,6 +97,19 @@ postgresql: memory: 50Mi cpu: 1m +redis: + enabled: true + image: + repository: "docker.io/redis" + tag: "8-alpine" + pullPolicy: IfNotPresent + resources: + requests: + memory: 50Mi + cpu: 1m + limits: + memory: 128Mi + keycloak: enabled: true image: