Skip to content

4544: POC for using FrankenPHP behind Traefik - #59

Draft
turegjorup wants to merge 18 commits into
developfrom
feature/frankenphp-poc
Draft

turegjorup wants to merge 18 commits into
developfrom
feature/frankenphp-poc

Conversation

@turegjorup

@turegjorup turegjorup commented Aug 1, 2025

Copy link
Copy Markdown
Contributor

Link to ticket

https://leantime.itkdev.dk/tickets/showKanban?tab=ticketdetails#/tickets/showTicket/4544

Description

Serves the site from one FrankenPHP container behind Traefik instead of the phpfpm and nginx pair, and moves the stack to PHP 8.5.

Merged up to develop, through the dependency update in #103. composer.lock was regenerated with composer update against the merged composer.json rather than resolved by hand, so it now differs from develop by exactly one package, igor-php. .woodpecker/stg.yml conflicted where both sides had changed the same lines — this branch's frankenphp service and develop's --no-dev fix from #99 — and takes both.

The parts worth reviewing:

  • A Dockerfile is unavoidable for now. dunglas/frankenphp ships without pdo_mysql, amqp and intl, so the application cannot boot on any published tag. Four lines add those plus gd, zip, xdebug and msmtp. The cost is that every CI job builds instead of pulls. Publishing an itkdev/frankenphp next to itkdev/php8.5-fpm removes both — worth deciding before this leaves POC.
  • phpfpm and nginx are parked in a never-enabled profile, because compose cannot delete an inherited service. The base compose files stay as the itkdev template ships them.
  • Traefik still terminates TLS. auto_https is off and Caddy serves plain HTTP on 8080.
  • Access logs become JSON. .docker/Caddyfile carries every field nginx's log_format main had, plus duration. The text layout needs Caddy's transform encoder, which this build lacks and which would mean compiling from source. Supercronic already logs -json, so JSON is arguably the house style — a decision worth making.
  • /metrics replaces /cron-metrics, behind the same ITKMetricsAuth middleware. nginx exported nothing: the template never added a stub_status location, and the supercronic it proxied to never starts without an /app/crontab, so /cron-metrics always answered 502.
  • A bug found on the way. set_real_ip_from 172.16.0.0/16 covered neither the frontend network nor the client, so nginx's real-IP resolution silently never happened. Now trusts private_ranges, and client_ip resolves.

Worker mode is ready but off. symfony/runtime has shipped FrankenPhpWorkerRunner since 7.4, so enabling it is one uncommented line in the Caddyfile and needs no package. What held it back was application state; an igor-php audit found three cases, all fixed here:

  • PackageVersionFactory and ModuleVersionFactory — dedup buffers moved from properties to locals. Already a live bug, not a worker-mode one: these run under messenger:consume, so a failed flush left entities from a closed EntityManager for the next call.
  • LeantimeService — implements ResetInterface. Its memoised user directory has to stay a cache, since resolveUserName() runs in a loop over tickets, but must not outlive the request.
  • DashboardController and SecurityContractCrudControllerunsetAll() before setController() on the injected AdminUrlGenerator, which two other call sites already did.

Measured on /admin in prod, 40s at 20 concurrent: 1319 req/s without a worker, 1494 with one, 1395 with one plus FRANKENPHP_RESET_KERNEL=1 — the reset keeps about half the gain rather than erasing it. From a laptop sharing CPU, so read it as a ranking, not absolutes.

Screenshot of the result

No user interface changes.

Checklist

  • My code is covered by test cases.
  • My code passes our test (all our tests).
  • My code passes our static analysis suite.
  • My code passes our continuous integration process.

The container work needed no tests of its own — the point is that the existing suite passes unchanged on it. The state fixes did need them and had none; 20 added, suite now 87 tests. Each new test was run against the code it replaced and fails there, so they discriminate rather than just cover.

Additional comments or questions

Still open: the messenger worker is untouched on itkdev/supervisor-php8.5, and whether it should also be FrankenPHP is the question. The container still runs as root — FrankenPHP wants to write /data and /config.

Left deliberately intact: ModuleVersion::getVersion() reports 'Unknown' for a null version, so a null-versioned module writes a row per occurrence. Keying it properly would change which rows get written, so it stays, pinned by a test. Worth deciding separately.

Coming from the phpfpm stack, the leftover containers need removing once — a profile stops a service from starting, not one already running:

docker compose rm --stop --force phpfpm nginx

@turegjorup turegjorup self-assigned this Aug 1, 2025
…-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.
@github-actions

Copy link
Copy Markdown

API Specification - Non-breaking changes

No changelog changes

@codecov-commenter

codecov-commenter commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 43.04%. Comparing base (bd9f198) to head (ed963aa).
⚠️ Report is 1 commits behind head on develop.

Files with missing lines Patch % Lines
...ontroller/Admin/SecurityContractCrudController.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop      #59      +/-   ##
=============================================
+ Coverage      39.31%   43.04%   +3.73%     
+ Complexity      1123     1111      -12     
=============================================
  Files            149      149              
  Lines           3551     3545       -6     
=============================================
+ Hits            1396     1526     +130     
+ Misses          2155     2019     -136     
Flag Coverage Δ
unittests 43.04% <96.77%> (+3.73%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
Three conflicts:

- .woodpecker/stg.yml: keep the frankenphp service from this branch and
  take the --no-dev fix develop made in #99. This branch still carried
  the -no-dev typo on the frankenphp lines.
- CHANGELOG.md: keep both unreleased entries, #103 then #59, followed by
  the 1.12.1 and 1.12.0 sections develop released.
- composer.lock: regenerated with composer update against the merged
  composer.json rather than resolved by hand, so it carries develop's
  47 package bumps and the removal of ocramius/doctrine-batch-utils
  alongside this branch's igor-php and php >=8.5.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants