How to reduce your Docker image size with Docker Multi Stage builds

How to reduce your Docker image size with Docker Multi Stage builds

There are many great benefits to using Docker multi stage builds such as reducing the attack surface and faster deployments. These warrant their own articles but today I will be discussing how to reduce the size of a Docker image by using Docker Multi stage builds.

What are Multi Stages

Docker Multi Stage builds allow for a Docker file to be split into separate stages. Each stage can be treated like it's own separate Docker image which installs only what is needed for that stage to be built. A stage can be extended from a pervious stage and the contents from one stage can copied to a preceding stage.

A traditional Docker image will need to have a web server and php installed with all the required composer and npm dependencies.  The webserver will also need to be configured and site files imported into the image. Instead of doing all of this in a single Docker image we could seperate each of the stages. These build  stages could be defined like so:

  • Stage 1: Create the base layer
  • Stage 2: Install the composer packages
  • Stage 3: Install the frontent packages
  • Stage 4: Create the final image with copied files

Let's create each of these stages

Stage 1: The base layer

In this stage we are only concerned with the webserver, the php version and its extensions.

FROM php:7.4-apache-buster as webserver-base

LABEL maintainer="Peter Fisher"

ARG DEBIAN_FRONTEND=noninteractive
ENV APACHE_DOCUMENT_ROOT="/var/www/html/public"
RUN apt-get update --fix-missing \
    && apt-get install -y --no-install-recommends mariadb-client zlib1g-dev libcurl3-dev libssl-dev \
    && docker-php-ext-install pdo pdo_mysql iconv \
    && a2enmod rewrite \
    && a2enmod headers

Stage 2: The composer packages

In this stage we are only concerned with the composer packages.  We only need the composer.json and composer.lock files.  At this point the packages will only be installed into this stage.

FROM composer as pm-backend

COPY composer.json composer.json
COPY composer.lock composer.lock

RUN composer install \
    --ignore-platform-reqs \
    --no-interaction \
    --no-plugins \
    --no-scripts \
    --prefer-dist

Stage 3: The frontend packages

Like with stage 2 we are not concerned with how the webserver is built. At this stage we are only concerned with the required dependancies of the frontend. This means we can install the frontend packages in isolation.

FROM node as pm-frontend

RUN mkdir -p /app/public

COPY package.json /app/

WORKDIR /app

RUN npm install

Stage 4: Putting it all together

At this point we have built the webserver, the composer packages and frontend dependencies in their own stages.  The first stage that we created will be used as the base of the final image and this can be done by extending the webserver-base.

The build artifacts  from these stages such as the vendor and node_modules folder need to be copied into the final image. We do this by extending the base layer and copying the built files into the final image.

FROM webserver-base as webserver

COPY . /var/www/html
COPY --from=pm-backend /app/vendor/ /var/www/html/vendor/
COPY --from=pm-frontend /app/public/js/ /var/www/html/public/js/
COPY --from=pm-frontend /app/public/css/ /var/www/html/public/css/

These are all of the stages in a Docker file

# Stage 1: The base layer
FROM php:7.4-apache-buster as webserver-base

LABEL maintainer="Peter Fisher"

ARG DEBIAN_FRONTEND=noninteractive
ENV APACHE_DOCUMENT_ROOT="/var/www/html/public"
RUN apt-get update --fix-missing \
    && apt-get install -y --no-install-recommends mariadb-client zlib1g-dev libcurl3-dev libssl-dev \
    && docker-php-ext-install pdo pdo_mysql iconv \
    && a2enmod rewrite \
    && a2enmod headers
    
# Stage 2: The composer packages
FROM composer as pm-backend

COPY composer.json composer.json
COPY composer.lock composer.lock

RUN composer install \
    --ignore-platform-reqs \
    --no-interaction \
    --no-plugins \
    --no-scripts \
    --prefer-dist

# Stage 3: The frontend packages
FROM node as pm-frontend

RUN mkdir -p /app/public

COPY package.json /app/

WORKDIR /app

RUN npm install

# Stage 4: The final Docker image 
FROM webserver-base as webserver

COPY . /var/www/html
COPY --from=pm-backend /app/vendor/ /var/www/html/vendor/
COPY --from=pm-frontend /app/public/js/ /var/www/html/public/js/
COPY --from=pm-frontend /app/public/css/ /var/www/html/public/css/

Advantages of Docker multi stage builds

  • Build artefacts such as the vendor and node_modules can be copied from one stage to another
  • Stages can be extended from other stages
  • A consistent  build pipeline can be created
  • The final image size is far smaller as the previous stages are not combined with the final image.
  • The attack surface is reduced as the build tools are not included in the final image

Disadvantages of Docker multi stage builds

  • Multi stage builds can create complex Docker files.
  • More post build scripts may be required to update dependencies
  • A change to one stage may have unexpected side effects to a future stage
  • You cannot run the last stage before the first stage