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 used
du -sch vendor/* | grep -v "/$" | sort -h to find the biggest directories and found some really hefty ones:
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
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
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
COPY --chown=www-data:www-data --chmod=g+r . /app
RUN rm -rf vendor
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.
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.