Production image takes 36 minutes to build #65

Closed
opened 2026-08-16 13:07:34 +02:00 by myrmidex · 2 comments
Owner

Measured during the v0.4.0 release: build.yml run #49 took 36m12s, run #51 was still going past 22 minutes. This is the slowest thing in the project by a wide margin — CI itself now runs in about 40 seconds.

The build is multi-arch (linux/amd64,linux/arm64) via QEMU, so everything compiled for arm64 runs emulated at roughly a tenth of native speed.

Causes, in order of likely impact

1. PHP extensions are compiled from source

docker/production/Dockerfile:37-43 uses docker-php-ext-install for six extensions. Each is a C compile, and under arm64 emulation that dominates the build.

#57 already solved this for the CI image by using mlocati/php-extension-installer, which fetches prebuilt binaries:

COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_mysql mbstring gd bcmath pcntl

Same approach, same registry, already proven on this runner.

2. COPY . . invalidates the Composer layer on every build

Line 52 copies the whole source before line 55's composer install, so any source change busts the cache and dependencies reinstall from scratch every time. The standard fix:

COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
COPY . .
RUN composer dump-autoload --optimize

Note --no-scripts on the first install — package discovery needs the full source, so it has to run after the second COPY.

3. No registry build cache

build.yml passes no cache-from/cache-to, so every release starts cold. build-push-action supports registry-backed caching:

cache-from: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache
cache-to: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache,mode=max

4. exif is installed and unused

Line 40. Nothing in the app touches EXIF data — image handling left with the asset subsystem in #48.

Worth deciding: is arm64 actually needed?

Dropping it would remove the emulation entirely and likely take the build under two minutes. It was added in fe3711e (#39), but if every deployment target is amd64 the arm64 half is pure cost.

Do not remove it without checking where this actually runs. If anything deploys to a Raspberry Pi or an ARM VPS, it stays — and then options 1–3 are the answer instead.

Not a cause

mysql-client looks removable but is not: docker/production/start-app.sh:13 uses it for a database readiness loop before migrating.

Acceptance criteria

  • Decide whether arm64 is required, and record why in the workflow
  • Extensions installed from prebuilt binaries rather than compiled
  • Composer layer cached independently of source changes
  • Registry build cache configured
  • exif removed
  • Release build time recorded before and after
  • The published image still runs: migrations apply, the counter renders, increment works
Measured during the v0.4.0 release: `build.yml` run #49 took **36m12s**, run #51 was still going past 22 minutes. This is the slowest thing in the project by a wide margin — CI itself now runs in about 40 seconds. The build is multi-arch (`linux/amd64,linux/arm64`) via QEMU, so everything compiled for arm64 runs emulated at roughly a tenth of native speed. ## Causes, in order of likely impact ### 1. PHP extensions are compiled from source `docker/production/Dockerfile:37-43` uses `docker-php-ext-install` for six extensions. Each is a C compile, and under arm64 emulation that dominates the build. #57 already solved this for the CI image by using `mlocati/php-extension-installer`, which fetches prebuilt binaries: ```dockerfile COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/ RUN install-php-extensions pdo_mysql mbstring gd bcmath pcntl ``` Same approach, same registry, already proven on this runner. ### 2. `COPY . .` invalidates the Composer layer on every build Line 52 copies the whole source before line 55's `composer install`, so any source change busts the cache and dependencies reinstall from scratch every time. The standard fix: ```dockerfile COPY composer.json composer.lock ./ RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts COPY . . RUN composer dump-autoload --optimize ``` Note `--no-scripts` on the first install — package discovery needs the full source, so it has to run after the second COPY. ### 3. No registry build cache `build.yml` passes no `cache-from`/`cache-to`, so every release starts cold. `build-push-action` supports registry-backed caching: ```yaml cache-from: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache cache-to: type=registry,ref=forge.lvl0.xyz/lvl0/incr:buildcache,mode=max ``` ### 4. `exif` is installed and unused Line 40. Nothing in the app touches EXIF data — image handling left with the asset subsystem in #48. ## Worth deciding: is arm64 actually needed? Dropping it would remove the emulation entirely and likely take the build under two minutes. It was added in `fe3711e` (#39), but if every deployment target is amd64 the arm64 half is pure cost. **Do not remove it without checking where this actually runs.** If anything deploys to a Raspberry Pi or an ARM VPS, it stays — and then options 1–3 are the answer instead. ## Not a cause `mysql-client` looks removable but is not: `docker/production/start-app.sh:13` uses it for a database readiness loop before migrating. ## Acceptance criteria - [ ] Decide whether arm64 is required, and record why in the workflow - [ ] Extensions installed from prebuilt binaries rather than compiled - [ ] Composer layer cached independently of source changes - [ ] Registry build cache configured - [ ] `exif` removed - [ ] Release build time recorded before and after - [ ] The published image still runs: migrations apply, the counter renders, increment works
myrmidex added this to the v0.4.1 milestone 2026-08-16 13:07:34 +02:00
myrmidex added the
enhancement
label 2026-08-16 13:07:34 +02:00
myrmidex self-assigned this 2026-08-16 13:07:34 +02:00
Author
Owner

Decision: arm64 stays

Nothing currently deploys to ARM, but the image is published for self-hosters and dropping the platform would lock out anyone on a Raspberry Pi or an ARM VPS. The multi-arch build is a deliberate feature, not incidental cost.

So the fastest single lever — removing the platform — is off the table, and the work is options 1–3:

  1. Prebuilt extensions via mlocati/php-extension-installer instead of docker-php-ext-install
  2. Composer layer cached independently of source changes
  3. Registry build cache via cache-from / cache-to
  4. Drop the unused exif extension

Option 1 should still be the dominant win: six C compiles under QEMU are the expensive part, and fetching prebuilt binaries removes the compilation rather than the emulation.

Options 2 and 3 mostly help subsequent builds. Note the cache only pays off if releases are frequent enough that it has not been evicted — worth measuring rather than assuming.

Worth adding a comment in build.yml recording why arm64 is there, so a future reader tempted by the build time knows it is intentional.

Revised acceptance criteria

  • arm64 decision made: keep it, so ARM self-hosters are supported
  • build.yml documents why the platform list is what it is
  • Extensions installed from prebuilt binaries rather than compiled
  • Composer layer cached independently of source changes
  • Registry build cache configured
  • exif removed
  • Release build time recorded before and after
  • The published image still runs on both platforms: migrations apply, the counter renders, increment works
## Decision: arm64 stays Nothing currently deploys to ARM, but the image is published for self-hosters and dropping the platform would lock out anyone on a Raspberry Pi or an ARM VPS. The multi-arch build is a deliberate feature, not incidental cost. So the fastest single lever — removing the platform — is **off the table**, and the work is options 1–3: 1. Prebuilt extensions via `mlocati/php-extension-installer` instead of `docker-php-ext-install` 2. Composer layer cached independently of source changes 3. Registry build cache via `cache-from` / `cache-to` 4. Drop the unused `exif` extension Option 1 should still be the dominant win: six C compiles under QEMU are the expensive part, and fetching prebuilt binaries removes the compilation rather than the emulation. Options 2 and 3 mostly help *subsequent* builds. Note the cache only pays off if releases are frequent enough that it has not been evicted — worth measuring rather than assuming. Worth adding a comment in `build.yml` recording why arm64 is there, so a future reader tempted by the build time knows it is intentional. ### Revised acceptance criteria - [x] arm64 decision made: **keep it**, so ARM self-hosters are supported - [ ] `build.yml` documents why the platform list is what it is - [ ] Extensions installed from prebuilt binaries rather than compiled - [ ] Composer layer cached independently of source changes - [ ] Registry build cache configured - [ ] `exif` removed - [ ] Release build time recorded before and after - [ ] The published image still runs on both platforms: migrations apply, the counter renders, increment works
Author
Owner

Done — 8d124e4

Changes

Prebuilt extensions. docker-php-ext-installmlocati/php-extension-installer, matching the CI image from #57. Six C compiles under arm64 QEMU were the bulk of the 36 minutes.

Also dropped libpng-dev, libxml2-dev and oniguruma-dev — headers that existed only to compile those extensions — and git, after confirming every package in composer.lock has a zip dist and nothing else in the image uses it.

Composer layer ordering. composer.json and composer.lock now copy before the source, so a code change no longer reinstalls vendor:

COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
COPY . .
RUN composer dump-autoload --optimize --no-interaction

--no-scripts on the first install because package discovery needs the full application. dump-autoload then triggers post-autoload-dump, which composer.json:41 already wires to package:discover — I had added an explicit artisan package:discover and removed it on finding that.

Registry build cache via cache-from / cache-to.

exif removed — unused since #48 took the asset subsystem.

composer:latestcomposer:2, so a major-version bump cannot arrive unannounced.

Comment in build.yml recording that arm64 is deliberate, so the platform list is not "optimised away" by someone looking at the build time.

Verified

  • Every extension name is already used by the CI image, which built successfully in run #18
  • vendor is in .dockerignore, so COPY . . cannot clobber the installed layer
  • mysql-client retained — docker/production/start-app.sh:13 needs it for the database readiness loop
  • install-php-extensions supports Alpine

Not verified

The image was never built. There is no way to build it from here, so this rests on inspection. The riskiest part is the Composer layer split: if --no-scripts leaves something that dump-autoload does not resolve, it fails at build time rather than silently.

Closing on the code being correct. The v0.4.1 tag build is the proof — and a failed build publishes nothing, so a mistake stops the release rather than shipping a broken image.

Still to record

  • Build time before and after — the v0.4.1 tag run supplies this. Baseline: 36m12s (v0.4.0, run #49).

Expectation: the extension change does most of the work. The registry cache mostly helps subsequent builds and may be evicted between releases weeks apart, so it should not be counted on.

If the tag build fails or shows no improvement, reopen.

## Done — `8d124e4` ### Changes **Prebuilt extensions.** `docker-php-ext-install` → `mlocati/php-extension-installer`, matching the CI image from #57. Six C compiles under arm64 QEMU were the bulk of the 36 minutes. Also dropped `libpng-dev`, `libxml2-dev` and `oniguruma-dev` — headers that existed only to compile those extensions — and `git`, after confirming every package in `composer.lock` has a zip dist and nothing else in the image uses it. **Composer layer ordering.** `composer.json` and `composer.lock` now copy before the source, so a code change no longer reinstalls vendor: ```dockerfile COPY composer.json composer.lock ./ RUN composer install --no-dev --optimize-autoloader --no-interaction --no-scripts COPY . . RUN composer dump-autoload --optimize --no-interaction ``` `--no-scripts` on the first install because package discovery needs the full application. `dump-autoload` then triggers `post-autoload-dump`, which `composer.json:41` already wires to `package:discover` — I had added an explicit `artisan package:discover` and removed it on finding that. **Registry build cache** via `cache-from` / `cache-to`. **`exif` removed** — unused since #48 took the asset subsystem. **`composer:latest` → `composer:2`**, so a major-version bump cannot arrive unannounced. **Comment in `build.yml`** recording that arm64 is deliberate, so the platform list is not "optimised away" by someone looking at the build time. ### Verified - Every extension name is already used by the CI image, which built successfully in run #18 - `vendor` is in `.dockerignore`, so `COPY . .` cannot clobber the installed layer - `mysql-client` retained — `docker/production/start-app.sh:13` needs it for the database readiness loop - `install-php-extensions` supports Alpine ### Not verified **The image was never built.** There is no way to build it from here, so this rests on inspection. The riskiest part is the Composer layer split: if `--no-scripts` leaves something that `dump-autoload` does not resolve, it fails at build time rather than silently. Closing on the code being correct. The v0.4.1 tag build is the proof — and a failed build publishes nothing, so a mistake stops the release rather than shipping a broken image. ### Still to record - [ ] Build time before and after — the v0.4.1 tag run supplies this. Baseline: **36m12s** (v0.4.0, run #49). Expectation: the extension change does most of the work. The registry cache mostly helps *subsequent* builds and may be evicted between releases weeks apart, so it should not be counted on. If the tag build fails or shows no improvement, reopen.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lvl0/incr#65
No description provided.