A Neate Blog


17 February 2020

Simple Dockerfile Performance Improvements (Part 3)

Tags: Docker - Dockerfile - Kubernetes - Oracle JET


In Part 2 we learned about how we can create multi-stage Docker builds, this article follows on from the previous therefore if you haven’t already, please give it a read!

This time in Part 3, we focus specifically on how your various stages can be cached in order for your CI process to build images quicker using the Docker layer cache by creating a setup stage that is used by multiple stages later on.

Note: Docker 17.05 or higher is required to enable Multi-Stage builds

We will continue to use the following Oracle JET example Dockerfile for this article.

# Stage 0 Starts Here
FROM node:13.4-alpine AS build-container

# Copy source of UI into container
RUN mkdir -p /usr/src/ui

COPY src /usr/src/ui/src

COPY ./*.json /usr/src/ui/

COPY scripts /usr/src/ui/scripts

# Set working dir
WORKDIR /usr/src/ui

############ Install dependencies
RUN npm -g install @oracle/ojet-cli
RUN npm install

RUN ojet build --release
# Stage 0 Ends Here

# Stage 1 Starts Here
FROM node:13.4-alpine

RUN npm -g install @oracle/ojet-cli

# Copy the compiled application from the previous stage
COPY --from=build-container /usr/src/ui/web .

EXPOSE 8080

CMD [ "ojet", "serve", "--release", "--server-port=8080"]
# Stage 1 Ends Here

Whilst the above dockerfile is a great starting point, every execution will result in each layer re-built i.e. not using the cache, this is because the commands generate a different output to what was previously cached meaning the data is different.

In the earlier post we identified the items that need to be there for compilation vs run, but now we need to identify which commands or stages that have an output that doesn’t change.

Looking at the first stage the build-container, chances are in a normal situation either your src or *.json files will have changed meaning that these commands need to be moved further down in the Dockerfile as we learned in Part 1, order of the Dockerfile commands is really important.

However, in the same stage there is a command to create a directory, change the working directory and also a global npm install, these commands will result in the same output (provided we version the npm package), so let’s do that now.

# Stage 0 Starts Here
FROM node:13.4-alpine AS build-container

# Copy source of UI into container
RUN mkdir -p /usr/src/ui

# Set working dir
WORKDIR /usr/src/ui

# Install global npm versioned package
RUN npm -g install @oracle/ojet-cli@8.0.0

COPY src /usr/src/ui/src

COPY ./*.json /usr/src/ui/

COPY scripts /usr/src/ui/scripts

############ Install dependencies
RUN npm install

RUN ojet build --release
# Stage 0 Ends Here

# Stage 1 Starts Here
FROM node:13.4-alpine

RUN npm -g install @oracle/ojet-cli

# Copy the compiled application from the previous stage
COPY --from=build-container /usr/src/ui/web .

EXPOSE 8080

CMD [ "ojet", "serve", "--release", "--server-port=8080"]
# Stage 1 Ends Here

Up until Line 11, everything results in the same output, but lines 12-22 having changing output, this might seem like it’s the best we can do at this point, but we haven’t even looked into the second stage yet.

If we perform the same analysis on the commands in stage 2, we can see that the COPY command is the only one that results in a different output so let’s move the COPY statement to the end of the stage.

# Stage 1 Starts Here
FROM node:13.4-alpine

RUN npm -g install @oracle/ojet-cli

EXPOSE 8080

# Copy the compiled application from the previous stage
COPY --from=build-container /usr/src/ui/web .

CMD [ "ojet", "serve", "--release", "--server-port=8080"]
# Stage 1 Ends Here

Now this is okay, but it’s not great. In this example, we are doing the exact same npm install command for two stages, this is a waste of time and also it’s an extra layer you don’t need, with this in mind we can create a new stage, which can create our “barebones” image that can be used in both build and run stages.

Tip: You can inherit an earlier stage meaning you can carry on from where you left off and potentially retrieve the entire stage from the cache

# Stage 0 Starts Here
FROM node:13.4-alpine AS setup-container

# Copy source of UI into container
RUN mkdir -p /usr/src/ui

# Set working dir
WORKDIR /usr/src/ui

# Install global npm versioned package
RUN npm -g install @oracle/ojet-cli@8.0.0
# Stage 0 Ends Here

# Stage 1 Starts Here -- Inherit everything from the earlier stage
FROM setup-container AS build-container

COPY src /usr/src/ui/src

COPY ./*.json /usr/src/ui/

COPY scripts /usr/src/ui/scripts

############ Install dependencies
RUN npm install

RUN ojet build --release
# Stage 1 Ends Here

# Stage 2 Starts Here
FROM setup-container

EXPOSE 8080

# Copy the compiled application from the previous stage
COPY --from=build-container /usr/src/ui/web .

CMD [ "ojet", "serve", "--release", "--server-port=8080"]
# Stage 2 Ends Here

So now we can see that the setup-container has been created and we can guarantee that the output of every command is the same every time, this means that regardless of what changes in your code base, the setup-container will be retrieved from your Docker cache every time! This is really useful because often we have commands in a Dockerfile that take some time to run, by ensuring you are able to cache them you end up reducing the build time by a significant factor.

Using the above Dockerfile, I made a dummy change to the source code, let’s see how the Docker image is built and what is cached.

Sending build context to Docker daemon  302.6kB
Step 1/14 : FROM node:13.4-alpine AS setup-container
 ---> b850b4746cd9
Step 2/14 : RUN mkdir -p /usr/src/ui
 ---> Using cache
 ---> a82b2039d59f
Step 3/14 : WORKDIR /usr/src/ui
 ---> Using cache
 ---> 6fa1900c1a04
Step 4/14 : RUN npm -g install @oracle/ojet-cli@8.0.0
 ---> Using cache
 ---> 66f6f7a27abb

In the first build, the npm package is installed taking around 20 seconds, but now on the second and any future builds, entire stage is retrieved from the cache meaning you’ve instantly reduced your docker build time by 20+ seconds in this case, it might not sound like much but when you look at how many commands you can place in a setup-container the time saved soon adds up!

And that’s it, now you should be able to create Docker images that have a reduced image size and are able to be built quicker, the next step is to deploy your image, maybe even to the cloud!

TL;DR:- It’s possible to create an entire stage that can be cached, thinking carefully about your command order and any duplicated commands that can be moved to a “common/parent” stage significantly reduces overall build time.

Useful Links: