Multi-stage builds cut the image in half

The production image was 912 MB, and roughly 600 of that was Node, a C toolchain and a node_modules directory used for ninety seconds during the build and never again. Docker 17.05 shipped in May with multi-stage builds, which replaces the two-Dockerfile arrangement that had been the workaround with something a single file can express.

The symptom

$ docker images shop
REPOSITORY   TAG      SIZE
shop         latest   912MB

$ docker history shop:latest --format '{{.Size}}t{{.CreatedBy}}' | head -6
284MB    /bin/sh -c npm install
198MB    /bin/sh -c apt-get install -y build-essential python
121MB    /bin/sh -c npm run build
 89MB    /bin/sh -c composer install

Pull time on a deploy was ninety seconds against a registry on the same network, and a fresh node took four minutes before it could serve anything. None of that weight is reachable at runtime.

Why it happens

Layers are additive and immutable. A RUN apt-get remove build-essential at the end produces a new layer recording the removal, and the earlier layer containing the toolchain is still in the image — the file is invisible and the bytes are shipped. The only way to not have something in an image is to never put it there.

Before 17.05 that meant two builds: one image with the toolchain, run to produce artefacts, then those artefacts copied into a second image by whatever was orchestrating the build. It worked and it lived in the CI configuration rather than in the Dockerfile, which is the wrong place for it.

The fix

Named stages

# ---- stage 1: assets. has node, is discarded.
FROM node:8 AS assets
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY webpack.mix.js ./
RUN npm run production

# ---- stage 2: php dependencies. has composer, is discarded.
FROM composer:1.5 AS vendor
WORKDIR /src
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . ./
RUN composer dump-autoload --optimize --classmap-authoritative

# ---- stage 3: the image that ships
FROM php:7.1-fpm
RUN docker-php-ext-install pdo_mysql opcache

WORKDIR /var/www
COPY --from=vendor /src/vendor    ./vendor
COPY --from=assets /src/public/js  ./public/js
COPY --from=assets /src/public/css ./public/css
COPY . .

Only the final stage becomes the image. The two above it are built, used and thrown away, and nothing they installed is present in the result. Naming them rather than referencing by index matters when someone inserts a stage in the middle a year from now.

The Composer stage uses the official image rather than installing Composer, which removes a curl, a signature check and a version to keep current. --no-scripts during install is deliberate: package scripts run arbitrary code, and the artefact being built here is going to production.

The cache still rewards the order

Multi-stage changes what ships and not how the cache works. Each stage caches independently, and within a stage a changed instruction invalidates everything after it — which is why the manifests are copied before the source in both build stages.

# every source edit reinstalls every dependency
COPY . ./
RUN npm ci

# dependencies rebuild only when package-lock.json changes
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources

Copying only resources and the config rather than the whole tree into the assets stage is the second half: a change to a PHP file should not invalidate the JavaScript build, and with COPY . ./ it does. Being specific about what each stage needs is slightly more maintenance and it is what keeps an incremental build under ten seconds.

Tip

On a CI runner with no local cache, none of this helps — every build is cold. --cache-from with the previous image pulled first restores most of it, at the cost of a pull. Worth measuring rather than assuming: on a fast registry it is a clear win, on a slow one the pull costs more than the rebuild.

Building one stage on its own

A stage can be the target, which is useful for debugging a build that fails inside a discarded stage — otherwise the only evidence is the log, because there is no image to inspect.

$ docker build --target assets -t shop-assets .
$ docker run --rm -it shop-assets sh
/src # ls public/js
app.js

# and the same mechanism for a test image with dev dependencies
$ docker build --target vendor -t shop-vendor .

It is also how the test image is built: a stage with the development dependencies installed, targeted by CI, while the deploy targets the final stage. One file, two artefacts, and no chance of the two diverging.

Verifying it worked

$ docker images shop
REPOSITORY   TAG      SIZE
shop         latest   398MB      # was 912MB

$ docker run --rm shop:latest which node npm gcc
# nothing — none of them are in the image

$ time docker pull registry.internal/shop:latest
real    0m31.402s                 # was 1m28s

Pull time is the number with an operational consequence: it is deploy time on every node, and it is also rollback time, which is when it matters most.

What still should not be in the final stage

The image is half the size and it still contains the whole source tree, including the tests, the CI configuration and any fixture data — because the last instruction is COPY . . and .dockerignore is the only thing filtering it.

.git
node_modules
vendor
tests
storage/logs
*.sql
.env
docker-compose*.yml
Dockerfile*
phpunit.xml
.gitlab-ci.yml

Excluding tests from the runtime image is worth the line: it is dead weight, and a fixture with realistic customer data shipped to production is a data-protection question rather than a size one. The same applies to .env, which people assume is excluded and frequently is not.

Checking is one command and worth doing once after any Dockerfile change, because the failure is silent — a file that should not be there does nothing except be there, until someone finds it.

What this costs

A failing build is harder to diagnose. The stage that failed left no image, so the usual move of running the container and poking around needs --target and a rebuild — which is a minute rather than a mystery, and it is a genuine step backwards from a single-stage build you can always inspect.

The Dockerfile is also longer and does more, which means more of the build logic lives in a file that fewer people read than the CI configuration. That is the right place for it and it is a change in where knowledge sits, so it wants a comment per stage saying what the stage is for.