A Neate Blog


22 January 2020

Simple Dockerfile Performance Improvements (Part 2)

Tags: Docker - Dockerfile - Kubernetes - Oracle JET


In Part 1 we learned about how the order of your Dockerfile commands matters along with copying only what you need, using these principles, this post dives deeper into a Dockerfile and reveals a great strategy called multi-stage builds that provide immense power when used correctly.

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

This post uses the same Oracle JET Dockerfile from Part 1, it’s worth noting that the techniques in Part 1 and Part 2 can be applied to any Dockerfile, not just Oracle JET applications.

FROM node:13.4-alpine

# 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

EXPOSE 8080

CMD [ "ojet", "serve", "--release", "--server-port=8080"]

Breaking down the Dockerfile, the only thing really needed in the final image is the result of the command ojet build --release which takes the src folder, runs various processing before creating a web folder used by the ojet serve command, everything before line 19 can be classified as “build” or “preparation” in the sense that none of that information is required for the image to actually run.

For example, the output of the ojet build command creates a web folder, so why do we need the src folder in the image anymore?

Instead of deleting the files and folders from the final image, we can utilise something called Multi-Stage builds which allows us greater control of what’s in the final image. A stage can be classified as starting at a FROM command and finishing before the next FROM command or the end of the file, whichever comes first.

Tip: Each stage can have a different base image

A good practice I follow when using multi-stage builds is to identify what is 100% required in the final image and what is optional, using the above Dockerfile, the following items are required for the image to start:

  • A base docker image to host the application i.e. node:alpine
  • ojet-cli installed for the ojet serve command to execute successfully
  • The output of ojet build --release so that we have our application to run
  • A port to be exposed EXPOSE 8080
  • ojet serve acting as our command to start the container (CMD)

Tip: Identify and separate what is used to build the application vs what is required to RUN the application

Based on the above, only 5 out of 11 commands in the Dockerfile are required to run the application in our final docker image, this information is a great start and means the Dockerfile can be split into the following two stages (One to build and One to run):

# Stage 0 Starts Here
FROM node:13.4-alpine

# 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

?????? the output of `ojet build --release` ??????

EXPOSE 8080

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

Ah, we need to copy data from an earlier stage of the Dockerfile but how do we do this?

We can be achieve this by using an integer to represent each stage, i.e. 0 for the first section, 1 for the second etc; this is okay but you can also use strings to actually name a stage (phew) which is way more useful!

If you wish to name a stage it can be done by doing the following:

FROM node:13.4-alpine AS build-container

Tip: Please name your stages, a few extra characters for greater readability wins every time

This allows you to reference the stage via the name build-container meaning we can copy data from the stage using the normal COPY command.

COPY --from=build-container /path/to/source/name /path/to/target/name
# Or if you decided to not use a name for your stage
COPY --from=0 /path/to/source/name /path/to/target/name

In the Dockerfile example, the output of ojet build --release is required to be copied over to the final stage, this can be done as follows:

# 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

If we execute docker history <IMAGE ID> against our new Dockerfile we see the following:

IMAGE               CREATED              CREATED BY                                      SIZE
7ea9587765de        About a minute ago   /bin/sh -c #(nop)  CMD ["ojet" "serve" "--re…   0B
6d5f9fe58419        About a minute ago   /bin/sh -c #(nop)  EXPOSE 8080                  0B
f79e131f215b        About a minute ago   /bin/sh -c #(nop) COPY dir:ce5976f3086596de8…   25.7MB
4b3abb27fb85        About a minute ago   /bin/sh -c npm -g install @oracle/ojet-cli      12.6MB
b850b4746cd9        4 months ago         /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>           4 months ago         /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B
<missing>           4 months ago         /bin/sh -c #(nop) COPY file:238737301d473041…   116B
<missing>           4 months ago         /bin/sh -c apk add --no-cache --virtual .bui…   5.35MB
<missing>           4 months ago         /bin/sh -c #(nop)  ENV YARN_VERSION=1.21.1      0B
<missing>           4 months ago         /bin/sh -c addgroup -g 1000 node     && addu…   99.8MB
<missing>           4 months ago         /bin/sh -c #(nop)  ENV NODE_VERSION=13.4.0      0B
<missing>           6 months ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           6 months ago         /bin/sh -c #(nop) ADD file:fe1f09249227e2da2…   5.55MB

Wow, our final image only has 5 layers and look at the size! By copying only the output of the build/compilation process it means only 25.7MB has been transferred to the image as opposed to 200MB+ in Part 1.

It’s worth highlighting that whilst there are less layers in the final image, the actual build command i.e. docker build processes each command in your Dockerfile meaning that potentially the build process could take longer, however, there is a solution to this!

Check-out Part 3 where stage inheritance is looked at in greater detail and shows how you can use the layer cache to quickly build a multi-stage Docker image.

TL;DR:- Whilst slightly more advanced, using multi-stage builds is often the ultimate Dockerfile improvement, this allows you to run intermediate containers to perform processing whilst only keeping the end result or compiled artifacts in your final docker image, thus resulting in a significantly smaller image.

Useful Links: