4544: POC for using FrankenPHP behind Traefik - #59
Draft
turegjorup wants to merge 16 commits into
Draft
Conversation
…-poc # Conflicts: # composer.json # composer.lock # docker-compose.yml
Bring the POC compose config in line with develop: required-variable syntax, mariadb healthcheck dependency, protected /health/detail route, and the markdownlint/prettier dev services.
Replace the phpfpm and nginx pair with a single FrankenPHP container, added in the per-environment override files, and move the stack to PHP 8.5. Changes - Add the `frankenphp` service in `docker-compose.override.yml` and `docker-compose.server.override.yml`, and park `phpfpm` and `nginx` in a profile that is never enabled - Port the nginx configuration to `.docker/Caddyfile` and the PHP settings the fpm image derives from `PHP_*` variables to `.docker/php.ini` - Keep TLS termination in Traefik: `auto_https` is off and Caddy serves plain HTTP on 8080 - Build on the published `dunglas/frankenphp:1.12-php8.5` image, adding the extensions it omits: pdo_mysql, amqp and intl - Move `itkdev/php8.5-fpm`, `itkdev/supervisor-php8.5` and the composer platform requirement to PHP 8.5 - Point Taskfile, workflows, Woodpecker, the staging and redirect overrides and the docs at the `frankenphp` service Why The POC was a year behind develop and pinned to Symfony 7. Putting the service in the override files keeps the base compose files as the template ships them, so the swap is one file per environment rather than a rewrite.
API Specification - Non-breaking changesNo changelog changes |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #59 +/- ##
=============================================
+ Coverage 37.14% 42.20% +5.05%
- Complexity 948 1107 +159
=============================================
Files 133 148 +15
Lines 2972 3535 +563
=============================================
+ Hits 1104 1492 +388
- Misses 1868 2043 +175
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The populated-database job checks out the base branch before the pull request, so it sees two revisions of docker-compose.yml and cannot assume either service name.
Changes
- Expose Caddy's Prometheus endpoint at `/metrics`, behind the `ITKMetricsAuth`
middleware `/cron-metrics` used
- Log requests as JSON from Caddy and keep PHP's `error_log` on `${PHP_LOGS}`,
where php-fpm sent it
- Mirror the itkdev/php8.5-fpm ini templates in `.docker/php.ini` using the
image's own `PHP_*` variable names, defaulted in the Dockerfile
- Trust `private_ranges` instead of `172.16.0.0/16`
Why
nginx exported no metrics at all: `stub_status` is compiled into the image but
the template never enabled it, php-fpm's `pm.status_path` was never routed, and
the supercronic behind `/cron-metrics` only starts when `/app/crontab` exists,
which this project has no. Caddy has a real exporter, so the endpoint finally
has something behind it.
`172.16.0.0/16` covers neither the `frontend` network (172.18/16) nor the client
(172.22/16), so `set_real_ip_from` never matched and real-IP resolution silently
did nothing. `private_ranges` is what a `/12` in the template would have meant.
The ini file previously hardcoded values the fpm image derives from environment
variables, and invented two variable names. Mirroring the image's templates
keeps the same overrides working.
symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4, selected automatically off the FRANKENPHP_WORKER=1 that FrankenPHP sets for a worker script. runtime/frankenphp-symfony is only for older Symfony, so its lack of a Symfony 8 release never blocked anything — dropping it was right, but redundancy was the reason, not incompatibility. What actually holds worker mode back is application state, not the runtime.
Changes - `PackageVersionFactory` and `ModuleVersionFactory` keep their deduplication buffers in locals threaded through the private helpers, not in properties - Key the version buffers on object identity rather than on the entity id, so they hold before Doctrine has assigned one - `LeantimeService` implements `ResetInterface`; autoconfigure tags it `kernel.reset` - Add tests for all three; the factories had none Why The contract advises statelessness over `ResetInterface` where it is possible, and for the factories it is: the buffers exist only to stand in for the repositories between `persist()` and `flush()` within one call, so their lifetime is exactly that call. Making them locals also fixes the reason they were flagged — they were cleared after `flush()` rather than in a `finally`, so a failing flush left entities from a closed EntityManager for the next call. That was a live bug in the messenger consumer, which already runs long. `LeantimeService` is the case the fallback is for. Its cache cannot become a local: `resolveUserName()` is called inside a loop over tickets, so dropping it would cost an API round trip per ticket. `reset()` restores the per-request lifetime `loadUsers()` already documents. The two `testAFailedFlushLeavesNothingForTheNextCall` tests fail against the previous implementations; the other thirteen pass either way and guard the deduplication behaviour, including a null-version quirk left deliberately intact.
Changes - Call `unsetAll()` before `setController()` in `DashboardController` and `SecurityContractCrudController` - Add `DashboardControllerTest`, and put `SecurityContractCrudController` into the admin smoke test's provider Why EasyAdmin registers `AdminUrlGenerator` as `shared: no`, so each injection point gets its own instance — but both consumers here are shared, so that instance lives as long as they do, which in a worker is longer than one request. It accumulates route parameters as it is used. `AppExtension` and `RepoAdvisoryService` already opened with `unsetAll()`; these two were the inconsistency, and inconsistency is what rots. Neither site was covered. The dashboard test asserts where the redirect lands rather than that it merely redirects, because the failure mode worth catching is silent: `unsetAll()` placed after `setController()` wipes the controller back out and produces a URL pointing somewhere else without anything throwing. Both new tests fail against that arrangement.
Worker mode needs no package and no code change — symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4 and the Caddyfile already reads {$FRANKENPHP_CONFIG} — so it is documented as an environment variable, with what it measured here and the caveats on those numbers.
The statelessness rules go in claude.md as a section rather than a bullet, because messenger:consume is already long-running in production and the rules apply whether or not worker mode is on. Each rule points at the service in this codebase that follows it.
Changes - Add `igor-php/igor-php` as a dev dependency and register `IgorPhpBundle` in dev - Configure it in `igor.json`: project scope, dev environment, baseline file - Record the 33 existing findings in `igor-baseline.json`, each with a reason - Add `composer worker-state-check` and `worker-state-baseline`, and a `Worker state audit` job to the review workflow Why The statelessness rules the last few commits established are the kind that decay without enforcement, and they matter whether or not worker mode is ever switched on: `messenger:consume` is already long-running in production. igor-php audits every shared service in the compiled container rather than grepping for patterns, which is why it caught the `AdminUrlGenerator` mutations that reading `src/` for stateful properties had missed. Against that, roughly two thirds of its project findings are noise — mostly Doctrine entities returned from a repository, which it reads as shared services — so it is only usable behind a baseline. Vendor code is out of scope: it reported 341 findings there, none of them ours to fix. Every baseline entry carries a reason rather than the generated TODO, so the file documents why each is safe instead of just silencing it. Verified the gate is live: introducing a stateful property on a service fails the audit, and removing it passes.
Changes - Turn the Dockerfile into `base` → `dev` → `prod`; the override files pick a target, and a bare `docker build .` gets `prod` - `prod` drops Xdebug and sets `opcache.validate_timestamps=0` - Move the Xdebug ini to `.docker/php-dev.ini`, mounted only in development Why One image served both environments, so production loaded a debugger it never used and OPcache stat-ed every file on every request — `validate_timestamps=1` with `revalidate_freq=0` means check every time, which is the opposite of what that pair is usually meant to express. Turning timestamp validation off makes a code change need a new container. Both deployment paths already give it one: staging runs `up -d --force-recreate`, the release playbook brings the stack up again, and a fresh container starts with an empty OPcache, so it compiles what is on disk. The Xdebug ini moves rather than staying inert in production, so no file mentions settings whose extension is absent. Verified per stage: dev has the extension with timestamps validated, prod has neither, and coverage still collects in dev — `XDEBUG_MODE=coverage` reaches Xdebug even though `ini_get` reports the ini value, which the CI job depends on.
Missed from the previous commit: the script that wrote them asserted against README wording first and stopped before reaching these two.
Changes
- Create `deploy` and `runner` in the image, drop Caddy's
`cap_net_bind_service`, hand it `/data/caddy` and `/config/caddy`, and end
both stages with `USER deploy`
- Make the id a `DEPLOY_UID` build argument, defaulting to 1042
- Mirror phpfpm's `user: ${COMPOSE_USER:-deploy}` on the local override
- Add a health check on `/health/live` to both overrides
- Reorder `task site:update` to install before waiting on health
Why
Root was the one regression against the setup this replaces: phpfpm ran as
`${COMPOSE_USER:-deploy}`, and beyond the security footprint, a root container
writing `var/` through a bind mount leaves root-owned files on the host.
The id has to match whoever owns that checkout, and in devops_docker-images it
depends on the base distro — consistently across 8.3, 8.4 and 8.5, the ubuntu
tags give `deploy` 1000 and the alpine ones 1042. The servers run the alpine
tags, so 1042 is the default; a build argument because the number belongs to the
host account rather than to this image. Nothing needs a capability to bind 8080.
`up --wait` previously only waited for the process to exist, since only mariadb
had a check. Now it waits for the application to answer. That check calls into
the application, so it cannot pass before dependencies are installed — hence
starting, installing, then waiting, which is also the order that task always
meant.
Coming from the root-run container, `var/` needs handing over once:
`docker compose run --rm --user root frankenphp chown -R deploy:deploy /app/var`.
…-poc # Conflicts: # composer.lock # docker-compose.override.yml
Changes - Record the three-way measurement on `/admin` in the Caddyfile, README and changelog - Say what cloning the kernel actually does, and stop implying the reset erases worker mode's benefit Why The comment claimed the reset costs "a boot per request", which is true — `AbstractKernel::__clone()` nulls the container and clears `booted`, so the next `handle()` runs `initializeBundles()` and instantiates the compiled container again. But it was asserted rather than measured, and the conclusion drawn from it was wrong. Measured in prod, 40 seconds at 20 concurrent on `/admin`: 1319 requests per second and a 9.0 ms median with no worker, 1494 and 4.3 ms with one, 1395 and 6.1 ms with one plus the reset. The reset keeps roughly half the gain and still beats no worker on both numbers, because the PHP runtime, OPcache and autoloader stay warm across requests even when the kernel does not. That makes it a reasonable first worker-mode configuration to deploy rather than only something to compare against. Also notes that a boot is not a recompile, which the old wording invited readers to assume.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Link to ticket
https://leantime.itkdev.dk/tickets/showKanban?tab=ticketdetails#/tickets/showTicket/4544
Description
Serves the site from a single FrankenPHP container behind Traefik, in place of the phpfpm and nginx pair, and moves the stack to PHP 8.5.
The branch was 236 commits behind
developand pinned to Symfony 7, so it has been merged up first. Three files conflicted;composer.jsonandcomposer.locktook develop's Symfony 8 versions.The published image is not enough on its own.
dunglas/frankenphpis deliberately minimal: it has nopdo_mysql, noamqpand nointl, so the application cannot boot on it. No tag variant (-alpine,-builder,-trixie) bundles them and there is noitkdev/frankenphpon Docker Hub. A four-lineDockerfileadds them on top of the publisheddunglas/frankenphp:1.12-php8.5, along withgd,zip,xdebugandmsmtp. Publishing anitkdev/frankenphpimage next toitkdev/php8.5-fpmwould remove theDockerfileand the per-job build described below — worth deciding before this leaves POC.Where the service lives. In the per-environment override files:
docker-compose.override.ymllocally anddocker-compose.server.override.ymlon the servers.phpfpmandnginxare parked in a profile that is never enabled, because compose cannot delete an inherited service. The base compose files stay as the itkdev template ships them, so the swap is a few lines per environment rather than a rewrite.Traefik keeps terminating TLS.
auto_httpsis off andSERVER_NAMEis a bare:8080, so Caddy neither requests nor serves a certificate.Two files carry what used to live on the images:
.docker/Caddyfile— a port of.docker/nginx.confand.docker/templates/default.conf.template. Two notes. RE2, which Caddy uses, has no negative lookahead, so the/.well-knownexception that nginx expressed inline is a matcher of its own. And the deny-list keepsymlbut notyaml, exactly as nginx had it, becausepublic/api-spec-v1.yamlis served..docker/php.ini— a mirror of the fpm image's env-driven ini templates (fpm/conf.d/90-php.ini,mods-available/opcache.ini,mods-available/xdebug.ini) plus the error logging from its pool config. It uses the image's ownPHP_*variable names, all defaulted in theDockerfilethe way the fpm image defaults them, so the same overrides work and no variable is ever unset.Logging and metrics
There is no nginx prometheus exporter to port.
nginxinc/nginx-unprivileged:alpinehas--with-http_stub_status_modulecompiled in, but the itkdev template never adds astub_statuslocation, so nginx exported nothing. php-fpm setspm.status_path = /status, which the nginx config never routed.The stack's only exporter is supercronic, inside the fpm image:
The entrypoint runs that only
if [ -f "${CRONFILE}" ], andCRONFILE=/app/crontab. This repository has nocrontab, so supercronic has never started here and/cron-metricshas always answered502— nginx was a reverse proxy in front of nothing.Caddy ships a real exporter (
http.handlers.metrics), so/metricsreplaces/cron-metricsbehind the sameITKMetricsAuth@filemiddleware, and there is finally something behind it: 52 metric families — request counts, durations and sizes by code, method and handler, requests in flight, plus Go runtime and process metrics. FrankenPHP's own thread metrics only exist in worker mode, which is off. A supercronic sidecar, if one is added, needs a route of its own.Logging matches where it can. php-fpm sent
error_log,slowlogand — viacatch_workers_output = yes— all worker stderr to${PHP_LOGS}, i.e./dev/stderr, and configured no access log, so nginx's was the only per-request record..docker/php.inikeepserror_logon${PHP_LOGS}.Caddy's access log is JSON, not nginx's
log_format maintext. Every field that format carried is present, verified against a live request:$http_x_real_iprequest>headers>X-Real-Ip, andrequest>client_ipresolved$remote_useruser_id$time_localts$requestrequest>method,request>uri,request>proto$statusstatus$body_bytes_sentsize$http_refererrequest>headers>Referer$http_user_agentrequest>headers>User-Agent$http_x_forwarded_forrequest>headers>X-Forwarded-Forduration,bytes_readThe text layout cannot be reproduced byte for byte: that needs the Caddy transform encoder, and this build has
console,json,append,filterandjournaldonly —format transformfails withmodule not registered: caddy.logging.encoders.transform. Getting it would mean the-builderimage and xcaddy, compiling Caddy and PHP from source, which gives up the published-image property entirely. Worth a decision: JSON also matches supercronic, which the fpm image already runs with-json, so the house style arguably is JSON.A bug found on the way.
set_real_ip_from 172.16.0.0/16covers neither thefrontendnetwork (172.18.0.0/16) nor the client (172.22.0.0/16), so nginx'sreal_ipmodule never matched and real-IP resolution silently did nothing — the log line only looked right because it printed the$http_x_real_ipheader directly rather than the resolved address. The Caddy port had inherited the same range and loggedclient_ipas the proxy hop. It now trustsprivate_ranges—10/8,172.16/12,192.168/16, localhost — which is what a/12in the template would have meant, andclient_ipresolves to the forwarded client.Changes
frankenphpservice indocker-compose.override.ymlanddocker-compose.server.override.yml; parkphpfpmandnginxin a never-enabled profiledunglas/frankenphp:1.12-php8.5, addingpdo_mysql,amqp,intl,gd,zip,xdebugandmsmtp.docker/Caddyfileand the fpm image's PHP settings to.docker/php.iniitkdev/php8.5-fpm,itkdev/supervisor-php8.5and the composer platform requirementTaskfile.yml, the workflows, both Woodpecker files and the docs at thefrankenphpserviceITKBasicAuthmiddleware, the www-redirect labels and production's shared.env.localmount off the disabled servicesruntime/frankenphp-symfonyWhy
runtime/frankenphp-symfonyis gone becausesymfony/runtimehas done its job natively since 7.4:SymfonyRuntime::getRunner()returns its ownFrankenPhpWorkerRunnerwhen$_SERVER['FRANKENPHP_WORKER']is set, and FrankenPHP sets that itself for a worker script (worker.go:155). The package is only for Symfony older than 7.4, which the FrankenPHP docs now say explicitly. It happens to also have no Symfony 8 release, but redundancy is the reason it is not here.PHP 8.5 is not a drive-by. The web container and the messenger worker share
vendor/andvar/cacheover the same bind mount, so they have to agree on the PHP version; pinning FrankenPHP to 8.5 without movingitkdev/supervisor-php8.4would put two PHP versions on one compiled container.The
phpfpm→frankenphprename touches twelve files, which is more churn than it looks like it should be.docker compose exec phpfpmappears eleven times inTaskfile.ymlalone, plus six workflows, both Woodpecker files, the README andclaude.md. Disabling the service leaves all of them with nothing to exec into. The alternative — keeping the service namedphpfpmwhile swapping its image — would have been a near-zero diff, but a service calledphpfpmrunning Caddy is worse to live with than a one-off rename.Screenshot of the result
No user interface changes.
Checklist
The container work needed no tests of its own — its value is that the existing suite passes unchanged on the new container, which it does. The state fixes did need them, and had none:
ProcessDetectionResultHandlerTestmocks the handlers wholesale, so the factories were entirely uncovered, and neither the dashboard nor the Security Contract CRUD was in the admin smoke test. Twenty tests added; the suite is now 71 tests, 144 assertions, alongside PHPStan, PHP-CS-Fixer, twig-cs-fixer,composer validate --strict,composer normalize --dry-run, prettier and markdownlint. The API spec export is unchanged and fixtures load.Coverage says nothing about whether tests discriminate, so each set was run against the code it replaced. The two
testAFailedFlushLeavesNothingForTheNextCalltests fail there, reporting a second call that reused a stale buffered entity. Both dashboard tests fail whenunsetAll()is moved aftersetController()— the silent failure mode, where the URL points elsewhere and nothing throws. The rest pass either way and are behaviour-preservation guards, which is worth stating rather than implying.One quirk left deliberately intact:
ModuleVersion::getVersion()reports'Unknown'for a null version, so the scan this replaced never matched a null-versioned module and wrote a row per occurrence. Keying it properly would change which rows get written, so it stays, pinned by a test that says so. Worth deciding separately.Behaviour was checked against what nginx did, not just for a 200:
/health/liveanswers 200 both directly and through Traefik over HTTPS (server: FrankenPHP Caddy),/health/detailreports the database and RabbitMQ healthy — sopdo_mysqlandamqpare really loaded — and the deny rules match, including/index.phpand/index.php/…returning 404 the way nginx'sinternalmade them. Every ported PHP setting was read back out of the running container and matches the fpm image.One thing to expect in CI: every job now builds the image instead of pulling one, since
docker compose run --rm frankenphphas abuild:. That is the cost of theDockerfile, and a publisheditkdev/frankenphpimage is what removes it.The
ITKMetricsAuthandITKBasicAuthmiddlewares could not be exercised locally, and not because of these labels. The Traefik publishing:443on this machine belongs to another project and defines neither middleware, so routers referencing them are dropped and the base router serves the path unauthenticated —/health/detailbehaves the same way ondevelop. The itkdev Traefik's own API confirms the router is built correctly:itksites-metrics@docker, enabled, priority 58, service resolved,ITKMetricsAuth@fileattached.Additional comments or questions
Progress on the original list:
xdebug.modeoff by default, still driven byPHP_XDEBUG_MODEitkdev/supervisor-php8.5. Whether the worker should also be FrankenPHP is the open question${PHP_LOGS}as under php-fpm; Caddy's access log replaces nginx's, as JSON rather than themaintext layout. See above/metricsserves Caddy's Prometheus endpoint behindITKMetricsAuth, which is strictly more than nginx exported. A supercronic sidecar still needs its own route if cron ever runs here/16matched nothing. See above/dataand/config, so this needs its own lookWorker mode
Worker mode is available now and off by choice, not by constraint. Uncommenting
worker ./public/index.phpin.docker/Caddyfileis the whole change: FrankenPHP setsFRANKENPHP_WORKER=1, andsymfony/runtime— v8.1.0 here — picks its ownFrankenPhpWorkerRunneroff that. Present from the 7.4 branch onward; absent in 7.2 and 7.3.What held it back was application state, not plumbing. An audit against
igor-phpv0.9.5 found three things; all three are fixed on this branch.The contract for
ResetInterfaceadvises statelessness first — "we advise making your services stateless instead of implementing this interface when possible" — and that split the findings cleanly:Made stateless.
PackageVersionFactoryandModuleVersionFactorykept dedup buffers in properties, cleared afterflush()rather than in afinally, so a throw left entities from a closed EntityManager for the next call. The buffers only ever needed to live for one call — they stand in for the repositories betweenpersist()andflush()— so they are locals now, threaded through the private helpers. Nothing to forget to clear. This was a live bug, not a worker-mode one: these factories run undermessenger:consume, long-running via supervisor, where--failure-limit=1merely contained it. Their version buffers were also keyed on$package->getId(), which only holds once Doctrine has assigned the ULID duringpersist(); they key on object identity now.ResetInterface, the fallback, used once.LeantimeServicememoises the Leantime user directory and documents it as "at most once per service instance" — the exact assumption worker mode breaks, and it is reachable from the web throughRepoAdvisoryControllerwith every service in that chainshared: yes. Its cache cannot become a local:resolveUserName()runs inside a loop over tickets, so dropping it costs an API round trip per ticket.reset()restores the per-request lifetime, anddebug:container --tag=kernel.resetconfirms autoconfigure applied it.Neither — cleared at the call site.
DashboardControllerandSecurityContractCrudControllermutated the injectedAdminUrlGeneratorwithout->unsetAll(), whichAppExtensionandRepoAdvisoryServiceboth did. The object is EasyAdmin's, so neither lever applies; the chain now opens withunsetAll()at all four sites.Symfony 8.1's
FRANKENPHP_RESET_KERNEL=1clones the kernel after each request, and it measures better than I first assumed. On/adminin prod, 40 seconds at 20 concurrent:The reset costs a kernel boot per request —
AbstractKernel::__clone()nulls the container and clearsbooted— but keeps the PHP runtime, OPcache and autoloader warm, so it holds about half the gain and still beats no worker on both numbers. That makes it a reasonable first configuration to deploy rather than only a diagnostic./health/liveinverts the ranking: it returns a constant, so there is no work for the saved boot to offset. Numbers from a laptop sharing CPU with other containers; short runs there varied more than tenfold, so only 40-second runs are quoted.Full audit, including the igor-php cross-check: the readiness report linked in the thread.
Coming from the phpfpm stack, the containers it left behind have to be removed once, since a profile stops a service from starting but does not stop one already running: