Why you need multiple docker images

Always create a base image for your application.

Ravic Poon
3 min readFeb 16, 2021

Introduction

We are getting used to starting a project from a docker image nowadays. On top of that, Docker works surprisingly well with popular CI servers, for instance, Jenkins. As a result, a project will probably end up with a Continuous Integration (CI) server that has the capability to build and deploy an application with docker images.

It can take a while to build a docker image for every CI job, not to mention the random encounters that will result in a build failure. For example, a particular version of a dependency had been yanked from the remote:

---> Running in 088067845a5b
Reading package lists...
Building dependency tree...
Reading state information...
E: Version '9.6*' for 'libpq-dev' was not found
The command '/bin/sh -c apt-get install libpq-dev=9.6*

Is there something we can do to prevent such accidence from happening and have faster build time? The short answer is yes!

Before jumping into our solution, I would like to demonstrate how Docker images work under the hood.

How do Docker images work?

Docker images are constructed from a stack of read-only layers from running each command in the Dockerfile; every layer is labelled with an ID and contains a delta of the previous versions. Below is a Dockerfile for a simple ruby on rails application.

Dockerfile:

# Step 1/7
FROM ruby:3.0.0
# Step 2/7
ENV APP_HOME /app
# Step 3/7
RUN apt-get install -y build-essential libpq-dev git imagemagick netcat
# Step 4/7
RUN mkdir $APP_HOME
# Step 5/7
WORKDIR $APP_HOME
# Step 6/7
ADD Gemfile* $APP_HOME/
# Step 7/7
RUN bundle install

Result:

docker build . -f DockerfileBase -t unicorn/rails-app-base:1.0.0
Sending build context to Docker daemon 146.4kB
Step 1/8 : FROM ruby:3.0.0
---> cfd5ab991d3f
Step 2/7 : ENV APP_HOME /app
---> ad27bb9e63a5
...
Step 7/7 : RUN bundle install
---> Running in 2eff97b23398
Removing intermediate container 2eff97b23398
Successfully built 445e4d06ed6a
Successfully tagged unicorn/rails-app-base:1.0.0

All the layers that compose the image can be discovered with the docker history <image> command.

IMAGE         CREATED        CREATED BY                    SIZE
445e4d06ed6a 3 minutes ago /bin/sh -c bundle install 99.8MB
...
ad27bb9e63a5 2 minutes ago /bin/sh -c ENV APP_HOME=/app 0B

So what can we do?

I would recommend us to build two images with separate docker files: DockerfileBase and Dockerfile.

DockerfileBase should be responsible for building layers that often do not change, such as Ruby version; Dockerfile should be held to pack constantly changing things, such as the source code. Let’s take a look at both of these files.

DockerfileBase:

FROM ruby:3.0.0
ENV APP_HOME /app
RUN apt-get install -y pgbouncer build-essential libpq-dev git imagemagick netcat
RUN mkdir $APP_HOME
WORKDIR $APP_HOME
ADD Gemfile* $APP_HOME/
RUN bundle install

DockerfileLocal:

FROM unicorn/rails-app-base:1.0.0
ENV PORT 3000
ADD . $APP_HOME
COPY docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
EXPOSE $PORT

DockerfileLocal is responsible for copying files and directories into a Docker image’s path designated in DockerfileBase (APP_HOME) and an entry point to configure the container to run as an executable.

Profit!

Most of the times will be a lot faster, considering our builds often do not contain updates such as add or remove gems.

Conclusion

There is no definitive answer on which command should be declared into either file. There are many considerations to be factored in — for instance, the application’s fundamental contents, how often shall those dependencies be updated. Nonetheless, optimising Docker images should not bring a detrimental effect on the project, and I encourage everyone to give your project a try!

--

--