DevOps Containers — why do we need multi-stage builds?

Docker is a platform designed to help developers build, share, and run modern applications. Docker Inc. was founded by Kamel Founadi, Solomon Hykes, and Sebastien Pahl in 2010. The company released Docker as an open-source software in 2013.

Ish Sookun

3 min read

Last week, during my talk about Laravel & Kubernetes at the DevFest 2022, I built a containerised Laravel application using Docker and deployed it on the Google Kubernetes Engine (GKE). I briefly touched on the the multi-stage build process but did not provide a thorough explanation on why it is needed.

FROM composer:latest AS build
COPY . /app
RUN composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction

FROM php:8.2-apache-bullseye AS production

ENV APP_ENV=production

RUN docker-php-ext-configure opcache --enable-opcache && \
    docker-php-ext-install pdo pdo_mysql
RUN pecl install redis && docker-php-ext-enable redis

COPY --from=build /app /app
COPY vhost.conf /etc/apache2/sites-available/000-default.conf
COPY /app/.env

RUN cd /app && php artisan config:cache && \
    php artisan route:cache && \
    chmod 777 -R /app/storage/ && \
    chown -R www-data:www-data /app/ && \
    a2enmod rewrite

I used the above Dockerfile to build the Laravel container. There are two stages in this build process —

  • First, a composer:latest container is used to install the project dependencies.
  • Then, a php:8.2-apache-bullseye container is used to run the Laravel application in production.

To be honest, I should have added one more build stage to use NPM to compile the front-end stack... but... well, to better understand the reasons for multi-stage builds, let us look at something simpler.

fn main() {
    println!("Namaste, world! 🙏");

The above Rust code does nothing fancy, it just prints "Namaste, world!" with the folded hands emoji, to the terminal. We could pull a Rust container image, e.g rust:1.66.0-alpine3.17 to build and then run the application. It would work, right?

The rust:1.66.0-alpine3.17 container image is 787MB in size. Would you want to deploy a small binary along with a 100 times bigger container to your production environment? Probably, no. That is why we need to build the production container in multiple stages.

FROM rust:1.66.0-alpine3.17 AS build
COPY . .
RUN cargo build --release

FROM scratch
COPY --from=build /app/target/release/namaste-world /app/namaste-world
CMD ["/app/namaste-world"]

We use rust:1.66.0-alpine3.17 to compile the application and then we copy the binary to another smaller (very small) container, called scratch.

$ docker build . -t namaste-world:latest
[+] Building 0.1s (10/10) FINISHED                                                                                      
 => [internal] load build definition from Dockerfile                                                               0.0s
 => => transferring dockerfile: 74B                                                                                0.0s
 => [internal] load .dockerignore                                                                                  0.0s
 => => transferring context: 2B                                                                                    0.0s
 => [internal] load metadata for                                          0.0s
 => [internal] load build context                                                                                  0.0s
 => => transferring context: 7.29kB                                                                                0.0s
 => [build 1/4] FROM                                                      0.0s
 => CACHED [build 2/4] WORKDIR /app/                                                                               0.0s
 => CACHED [build 3/4] COPY . .                                                                                    0.0s
 => CACHED [build 4/4] RUN cargo build --release                                                                   0.0s
 => CACHED [stage-1 1/1] COPY --from=build /app/target/release/namaste-world /app/namaste-world                    0.0s
 => exporting to image                                                                                             0.0s
 => => exporting layers                                                                                            0.0s
 => => writing image sha256:44c98d7123b596ef4b90388ce73ee66391f5f38a04e75440dfb0a4399b436e94                       0.0s
 => => naming to                                                            0.0s

Once the container is built, let's run it.

$ docker run --rm namaste-world
Namaste, world! 🙏

Now, let's have a look at the size of the container images.

$ docker images
REPOSITORY      TAG                 IMAGE ID       CREATED        SIZE
namaste-world   latest              44c98d7123b5   2 hours ago    4.57MB
rust            1.66.0-alpine3.17   87eecbd0d066   38 hours ago   787MB

See, the scratch container image holding the namaste-world binary is below 5MB while the rust:1.66.0-alpine3.17  container image is 787MB. When deploying to production it is best practice to use smaller images for less memory footprint.

Renghen and I talked after I published this post on Twitter & LinkedIn. He shared his views about having a multi-stage build process. It makes more sense if it is part of a pipeline. He does not advise compiling applications using containers. He refers to an example where the compiler requires the GPU and such compilation won't be possible using a container. However, he stated that multi-stage builds makes sense in cases where certain languages have deprecated some features and one might still need those to run an application.