Slimming down your Docker PHP images
After discovering how big my Docker images became, I went on a quest to slim them down. Unfortunately I didn't find the holy grail, but I learned a bit about Docker images and how to optimise them.
I’m trying to completely embrace the Docker lifestyle and understand more of all the magic that happens when building or running an image. After successfully migrating all my projects to Docker I noticed that the volume of my private Docker registry already was multiple gigabytes in size. It’s time to optimise my images, I guess!
At first I checked, how big my images are!
docker images gives us a nice list of all image and their sizes:
pwaldhauer@plomp cms % docker images REPOSITORY TAG IMAGE ID CREATED SIZE r.knspr.space/cms latest 53da37711c85 4 hours ago 253MB ghcr.io/pwaldhauer/logsock latest 97ed14d3b5a8 2 days ago 234MB r.knspr.space/speiseplaner latest 7f9f68620494 2 weeks ago 289MB
Seems like most of my Image are around 250 megabytes. Thats quite a bit for some PHP files! Since I’m not here to waste valuable disk space, let’s have a look, what can be done to get the size down.
A docker image consists of multiple layers, each step in the Dockerfile creates a new one. My next step was looking at
those layers to see where all the stuff is hidden. You can use
docker history image-name to get a list of layers.
pwaldhauer@plomp cms % docker history r.knspr.space/cms:latest IMAGE CREATED CREATED BY SIZE COMMENT 53da37711c85 4 hours ago ENTRYPOINT ["sh" "/etc/entrypoint.sh"] 0B buildkit.dockerfile.v0 <missing> 4 hours ago COPY .docker/entrypoint.sh /etc/entrypoint.s… 82B buildkit.dockerfile.v0 <missing> 4 hours ago COPY .docker/php.ini /usr/local/etc/php/conf… 72B buildkit.dockerfile.v0 <missing> 4 hours ago COPY .docker/nginx.conf /etc/nginx/nginx.con… 4.3kB buildkit.dockerfile.v0 <missing> 4 hours ago WORKDIR /app 0B buildkit.dockerfile.v0 <missing> 4 hours ago COPY .env.docker /app/.env # buildkit 407B buildkit.dockerfile.v0 <missing> 4 hours ago COPY . /app # buildkit 115MB buildkit.dockerfile.v0 <missing> 19 hours ago RUN /bin/sh -c apk add nginx # buildkit 4.1MB buildkit.dockerfile.v0 <missing> 19 hours ago RUN /bin/sh -c chmod +x /usr/local/bin/insta… 58.2MB buildkit.dockerfile.v0 <missing> 2 days ago ADD https://github.com/mlocati/docker-php-ex… 160kB buildkit.dockerfile.v0
I deleted some lines of this example output, because its very long. After skimming the list I came to this conclusions:
- The base image (
php:8.2.3-fpm-alpine3.17) I’m using is around 70MB. No chance to optimise something at this level.
- Installing some PHP extension adds around 60MB again. I have to take a look if it is possible to do something here, I
do not think that the
exifextensions should weight 60MB after compiling.
- The real meat lays in the layer that contains all my application code. 115MB of PHP files!
My application code only weights around 500KB, so I turned to the
vendor folder. I
du -sch vendor/* | grep -v "/$" | sort -h to find the biggest directories and found some really hefty
fakerphp/faker to name few.
My first try was to add the biggest directories to the
.dockerignore but, of course, everything explodes, because the
composer autoloader needs all those files, even if they are not used anywhere. But the solution is much easier. All
those big directories belong to dependencies used just for development, so I can just call
composer install --no-dev
before building the image and now my image is just 180MB in size! At least a 25 percent reduction.
Actually, if I was building the image in a CI pipeline, I would never have that problem, because I would have
--no-dev from the start. But currently I’m just building locally so that slipped through my fingers.
My local build script now looks like this, and while a bit ugly, it works:
composer install —no-dev docker build —platform linux/amd64 -t r.knspr.space/cms:latest . docker push r.knspr.space/cms:latest composer install
While it is nice to have reduced the size of the image by a 25 percent, it still bugs me that every time I change just
one little file, the whole layer containing all the files (still around 40MB) has to be pushed into the registry. For
years I just used SFTP upload, or later a git based workflow and those were quite efficient just transferring what has
changed. Transferring the big
vendor directory on every push is wasteful, so what can we do to prevent it? We need to
place it in its own layer!
Unfortunately there is no clean and easy way to do this. Since
vendor is in the same directory as
resources and all the other things, and Docker does not support any way to exclude files from a
command its not possible to do something like that:
COPY --exlude=vendor . /app COPY vendor ./app/vendor
One way to do it, would be to copy every directory individually:
COPY app /app/app COPY bootstrap /app/bootstrap #…
But that would create more than 10 layers, most of them with just some hundred kilobytes of data. That does not feel
Fortunately I found a interesting piece of advice
in the Github issue that wishes for
COPY with support for excluding files:
FROM alpine AS scratchpad WORKDIR /app COPY --chown=www-data:www-data --chmod=g+r . /app RUN rm -rf vendor FROM r.knspr.space/php-fpm COPY --chown=www-data:www-data --chmod=g+r vendor /app/vendor COPY --from=scratchpad /app /app # …
It is using a multi stage build, something I have read multiple times and still don’t really understand to first copy
everything to a new image and removing the directory I want to exclude (
vendor). In the second stage I just
vendor into its own layer and add everything else from the first stage. Somehow, it works! Pushing now is a lot
faster, because it only transfers around 3MB. While that is still much more than just transferring just some kilobytes
for a single file its a good enough tradeoff.
Now my images are smaller and pushing changes is faster, just with some small changes. I hope Docker will implement
COPY --exclude feature sometime, though, the multi layer build makes everything less… simple looking.