Web Development Bug Report

PHP app 3x slower after a Docker image rebuild because OPcache was not enabled

After moving our Symfony app to the official php-fpm image, median response time went from 38 ms to 121 ms with no code change. OPcache was never enabled in the new image, and checking it from the CLI hid the problem. Here is how we found it and tuned it.

Daniel Tran Daniel Tran @fun_dev · May 15, 2025 · 7 min read · 10 Views
Banner: PHP app three times slower after a Docker image rebuild because OPcache was not enabled

We migrated our Symfony booking API from a hand-built PHP base image to the official php:8.3-fpm-bookworm image to get faster security patches. The release contained no application changes. Within an hour the APM dashboard showed median response time up from 38 ms to 121 ms, p95 from 140 ms to 390 ms, and php-fpm CPU per pod roughly tripled. The horizontal autoscaler doubled the pod count to keep up.

Nothing was failing; it was just slow everywhere, on every endpoint, which pointed at the runtime rather than a single query.

Reproducing it

We ran the same load profile against the old and new images on staging:

k6 run --vus 40 --duration 2m loadtest/booking-search.js
Image Median p95 Requests/s
Old custom image 37 ms 135 ms 1,040
php:8.3-fpm-bookworm 118 ms 372 ms 336

What we ruled out

  • Database or network. APM spans showed SQL and HTTP calls unchanged; the extra time was all in "PHP self time".
  • Symfony running in dev mode. APP_ENV=prod and APP_DEBUG=0 were set and the container was warmed during the build (bin/console cache:warmup).
  • OPcache itself. This is where we lost an hour. We ran this inside the container:
$ php -r 'var_dump(function_exists("opcache_get_status"));'
bool(false)

Someone read that as "the CLI doesn't have it, FPM is different" and moved on. Then someone else ran php -i | grep opcache on the old image, saw opcache.enable_cli => Off, and concluded the CLI could never tell us anything. Both observations were true and neither answered the question.

Root cause

We finally asked php-fpm directly by dropping a tiny, temporary status script behind the internal network policy and calling it through FPM:

<?php
header('Content-Type: application/json');
echo json_encode(function_exists('opcache_get_status')
    ? opcache_get_status(false)
    : ['loaded' => false]);
{"loaded":false}

The OPcache extension was not loaded at all in the new image. The official PHP images compile OPcache but do not enable it; it has to be enabled explicitly during the image build. Our old image had done that years ago in a line nobody remembered. Without OPcache, every request parsed and compiled every PHP file it touched, around 1,900 files for a typical Symfony request in our app.

What is happening under the hood

PHP compiles source files into opcodes before running them. Without OPcache, that happens on every request, in every worker. OPcache stores the compiled opcodes in shared memory so all php-fpm workers in a pool reuse them; for framework-heavy apps the compile step is often the majority of CPU time.

A few defaults matter:

Setting Default What it means
opcache.enable 1 Enabled for FPM once the extension is loaded
opcache.enable_cli 0 Disabled for CLI, so CLI checks show nothing useful
opcache.memory_consumption 128 (MB) Shared memory for opcodes
opcache.max_accelerated_files 10000 Max scripts in the hash table
opcache.validate_timestamps 1 Re-checks file mtimes on access
opcache.revalidate_freq 2 Seconds between mtime checks

When memory or the key table fills up, OPcache stops caching new scripts (and cache_full turns true), so a too-small cache silently degrades towards no cache at all.

Environment

Component Version / value
OS Debian 12 (bookworm) container
PHP 8.3.20, php:8.3-fpm-bookworm
Framework Symfony 7.2
php-fpm pm = static, pm.max_children = 16
Orchestrator Kubernetes 1.31, HPA on CPU
Codebase ~14,000 PHP files including vendor

Minimal reproduction

FROM php:8.3-fpm-bookworm
COPY . /app
# No OPcache enabled. php -m will not list "Zend OPcache".
docker run --rm php:8.3-fpm-bookworm php -m | grep -i opcache || echo "OPcache not loaded"

Who is affected

Anyone building on the official php Docker images who does not explicitly enable OPcache, and anyone who copied a minimal Dockerfile from a tutorial. Also teams that enable it but leave the 128 MB default with a large vendor directory, which leads to a full cache.

The fix

We enabled the extension and moved to the production php.ini, as the image documentation recommends:

FROM php:8.3-fpm-bookworm

RUN docker-php-ext-install opcache \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY docker/php/opcache.ini "$PHP_INI_DIR/conf.d/10-opcache.ini"
; docker/php/opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=32531
; Code is immutable inside the image; never stat files in production.
opcache.validate_timestamps=0
; Symfony generates this file; it preloads hot classes at FPM start.
opcache.preload=/app/config/preload.php
opcache.preload_user=www-data

Disabling validate_timestamps is safe because each deploy ships a new image and new pods; nothing edits PHP files in a running container. If you deploy by copying files onto long-lived servers, you must reload php-fpm after each deploy instead.

Alternatives and trade-offs

  • Keep validate_timestamps=1 with revalidate_freq=60. Slightly slower due to stat calls, but forgiving if someone hot-patches a file. We use this on the shared staging server.
  • Skip preloading. Preloading gave us a further 6% on median latency. It also means a change to preloaded files needs an FPM restart, and a broken preload script stops FPM from starting, so test it in CI.
  • JIT. In PHP 8.3, JIT is effectively off by default because opcache.jit_buffer_size is 0. We measured opcache.jit_buffer_size=64M with tracing mode: no meaningful gain for this I/O-bound API, so we left it off.

How to test the fix

We added a container test that fails the build if OPcache is not active in FPM, not just in the CLI. The check uses cgi-fcgi to call FPM directly with the status script baked into the test image:

docker run -d --name fpm-test booking-api:candidate
docker exec fpm-test sh -c '
  SCRIPT_FILENAME=/app/tools/opcache-status.php REQUEST_METHOD=GET \
  cgi-fcgi -bind -connect 127.0.0.1:9000' | tail -n 1 \
  | jq -e '.opcache_enabled == true and .cache_full == false'

And a quick CLI check that the extension is present at all:

docker run --rm booking-api:candidate php -m | grep -q "Zend OPcache"

Confirming in production

  • Median latency dropped back to 36 ms and p95 to 128 ms within ten minutes of the rollout.
  • The autoscaler scaled from 22 pods back to 10.
  • Our metrics endpoint now exposes OPcache stats read through FPM. A day after release:
opcache_enabled: true
cache_full: false
memory_usage.used_memory: 118 MB of 256 MB
opcache_statistics.num_cached_scripts: 6,412
opcache_statistics.opcache_hit_rate: 99.97

Regression guard

  • The FPM-level container test runs on every image build.
  • Dashboards plot opcache_hit_rate, cache_full and used memory per pod, with an alert if the hit rate drops under 99% or cache_full becomes true.
  • Our base image is now a shared internal image with OPcache settings documented next to the Dockerfile, so service teams do not each rediscover them.

Security implications

None beyond the performance impact. Two notes: the status script exposes paths and memory details, so it must never be reachable from the internet; ours only answers on the pod network and returns a filtered subset in production. And validate_timestamps=0 means a file written into a running container would not execute until a restart, which is a small hardening benefit for immutable deployments.

Prevention checklist

  • Enable OPcache explicitly when using the official PHP images, and verify it through FPM.
  • Never use CLI output to judge FPM's OPcache state.
  • Size memory_consumption and max_accelerated_files for your vendor directory, then watch cache_full.
  • Use php.ini-production in production images.
  • Benchmark a new base image before switching traffic to it.

References

Add a label

#php #docker #php-fpm #opcache #symfony
Written by
Daniel Tran @fun_dev

I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.