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 theredis
,gd
andexif
extensions 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 used du -sch vendor/* | grep -v "/$" | sort -h
to find the biggest directories and found some really hefty ones: phpunit
, laravel/pint
, 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 used --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
Going deeper
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 the app
, resources
and all the other things, and Docker does not support any way to exclude files from a COPY
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 right. 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 copy 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.
Conclusion
Now my images are smaller and pushing changes is faster, just with some small changes. I hope Docker will implement a COPY --exclude
feature sometime, though, the multi layer build makes everything less… simple looking.