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.
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=prodandAPP_DEBUG=0were 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=1withrevalidate_freq=60. Slightly slower due tostatcalls, 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_sizeis 0. We measuredopcache.jit_buffer_size=64Mwith 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_fulland used memory per pod, with an alert if the hit rate drops under 99% orcache_fullbecomes 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_consumptionandmax_accelerated_filesfor your vendor directory, then watchcache_full. - Use
php.ini-productionin production images. - Benchmark a new base image before switching traffic to it.
References
- PHP manual: OPcache runtime configuration — https://www.php.net/manual/en/opcache.configuration.php
- PHP manual:
opcache_get_status()— https://www.php.net/manual/en/function.opcache-get-status.php - Docker Official Image: php — https://hub.docker.com/_/php
- Symfony documentation: Performance (OPcache and preloading) — https://symfony.com/doc/current/performance.html
Add a label
Embed this post
<iframe src="https://hibug.me/php-slow-after-rebuild-opcache-disabled" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.