pwa.io

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 redis , gd and exif 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.