Docker Build
Apr 9, 2020
If you need a refresher here are the first two posts in the series, Linux Containers and Docker and Docker Compose and Volumes.
Before moving on to other Linux container platforms, I’d like to focus on one more Docker command, docker build. Sometimes it’s helpful to slightly alter existing images, or create entirely new images not available on the Docker Hub. Building images is relatively straight forward once you get a feel for the syntax of Docker images. The build system is included in the base Docker packages.
The docker build command creates a Docker image from a Dockerfile. Files used for the build can be specified in a “context”, either a PATH or URL. For this example we’ll be using the PATH context to create a Dockerfile on our own system.
While you could grab an official Python 3.8.2 image from the Docker Hub, we’re going to create our own image from an Ubuntu 18.04 base.
Dockerfile
Let’s create our Dockfile in the compose directory of home.
$ mkdir -p ~/compose/python-3.8 && cd ~/compose/python-3.8
Here is the Dockerfile we will be building towards in our example:
FROM ubuntu:18.04
# set up environment
ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHON_VERSION="3.8"
# install dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
build-essential \
git \
curl \
libssl-dev \
tk-dev \
&& rm -rf /var/lib/apt/lists/*
# download python from git
WORKDIR /tmp
RUN git clone --branch ${PYTHON_VERSION} --depth 1 https://github.com/python/cpython.git
WORKDIR /tmp/cpython
# compile python
RUN ./configure \
--enable-optimizations \
--enable-loadable-sqlite-extensions \
--enable-option-checking=fatal \
--with-system-expat \
--with-system-ffi \
--without-ensurepip
RUN make -j "$(nproc)" && make install
# download and set up pip
WORKDIR /tmp
RUN curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
RUN python3 get-pip.py \
--disable-pip-version-check \
--no-cache-dir
# install dependencies and copy binaries to fresh ubuntu image
FROM ubuntu:18.04
RUN apt-get update && apt-get install -y --no-install-recommends \
expat \
openssl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=0 /usr/local/bin /usr/local/bin
COPY --from=0 /usr/local/lib /usr/local/lib
CMD ["python3"]
Build
Before we examine the Dockerfile in detail, let’s go ahead and build our image with the docker build command. Compiling Python will take a few minutes, potentially longer based on your system:
$ docker build -t petebuffon/python-3.8.2 .
You can give your image any name you want. Here to differentiate images from the Docker Hub and images I have created, I am preceding container names with my Github profile name and a slash. The build command ends with a space a period indicating docker should look for a Dockerfile to build from in the current directory.
Instructions and Layers
Each action in a Dockerfile is preceded by an INSTRUCTION argument all in UPPERCASE. Instructions in a Dockerfile are run in order during build time. Each instruction represents a read-only layer which are stacked at build time to form a complete Docker image. The RUN, COPY, and ADD instructions create layers while all other instructions create temporary intermediate images that do not increase the size of the build.
When a docker image is run and a container is generated, a new writable layer is added on top of the read-only layers. All changes made to the container during its lifetime are written to this writable container layer.
Let’s take a look at all of the layers of our image:
$ docker history petebuffon/python-3.8
IMAGE CREATED CREATED BY SIZE COMMENT
b406a2d3f589 About a minute ago /bin/sh -c #(nop) CMD ["python3"] 0B
7f21428b44b9 About a minute ago /bin/sh -c #(nop) COPY dir:c33605b876f922410… 221MB
31934cf85de9 About a minute ago /bin/sh -c #(nop) COPY dir:de52b8b155bffd961… 17.3MB
b683d2cbe1de About a minute ago /bin/sh -c apt-get update && apt-get install… 6.35MB
4e5021d210f6 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/bash"] 0B
<missing> 2 weeks ago /bin/sh -c mkdir -p /run/systemd && echo 'do… 7B
<missing> 2 weeks ago /bin/sh -c set -xe && echo '#!/bin/sh' > /… 745B
<missing> 2 weeks ago /bin/sh -c [ -z "$(apt-get indextargets)" ] 987kB
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:594fa35cf803361e6… 63.2MB
A Dockerfile must begin with a FROM instruction, which specifies the parent image. Docker treats lines that begin with # as a comment. Add any necessary environment variables with the ENV instruction and set the working directory with the WORKDIR instruction.
Each RUN instruction precedes a single command to the base OS of the container image. Multiple commands can be strung together with && in order to reduce the number layers in the final image. If any changes are made to the Dockerfile and the build command is run again, only the changed layers will have to be rebuilt.
multi-stage builds and CMD
Invoking a second FROM instruction in a Dockerfile sets up a multi-stage build. Here our final container image is based on a fresh Ubuntu 18.04 image. The COPY instruction copies files or directories from one location to another. Here we are copying the /usr/local/bin and /usr/local/lib directories from our first stage (denoted with –from=0) to our fresh image.
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
petebuffon/python-3.8 latest b406a2d3f589 21 hours ago 309MB
<none> <none> e7e5b2133bf4 21 hours ago 874MB
ubuntu 18.04 4e5021d210f6 2 weeks ago 64.2MB
We can see here that using a multi-stage build reduced our image size from 874MB in our build image to 309MB in our final image.
Note: Base images such as Alpine can reduce image sizes even further. The current Alpine base image is a lean 5.95MB.
There can be only one CMD instruction in a Dockerfile. A CMD instruction often specifies an executable, in our case, python3. The preferred form of the CMD instruction is: CMD [“executable”,“param1”,“param2”]. When a new container is spun up without a command specified, the container will default to running the contents of the CMD instruction:
$ docker run -it --rm petebuffon/python-3.8
Python 3.8.2+ (heads/3.8:6318e45, Apr 8 2020, 19:35:50)
[GCC 7.5.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
When a new container is spun up with a command specified, the CMD instruction is not run and replaced by the specified command.
$ docker run -it --rm petebuffon/python-3.8 /bin/bash
root@d256094e3573:/#
We now have a fully functional python-3.8 container image ready for use. You can just use it yourself or even upload it to the Docker Hub. Creating functional images can take some work and perhaps require many iterations to get them just right.
Leveraging layers in the images can help reduce build time between steps. Sometimes it can help to run a container from a base image directly to get a more immediate and interactive response to troublesome commands.
Updates
Over time both your parent image and the packages installed on your image will become outdated. In order to update your own containers you will have to re-run the build command, stop any running containers, and re-create them. If you utilize Compose, this process is fairly streamlined.
You can set up your own images in Compose with the following syntax in your docker-compose.yaml file:
version: '2'
services:
python-3.8:
build: ./python-3.8
restart: unless-stopped
In order to update your own images with Compose, run:
$ docker-compose build --pull
$ docker-compose up -d
You should now be relatively comfortable with building Docker images. Be sure to check out Docker’s Dockerfile reference and Best practices for writing Dockerfiles documentation. Well that should just about wrap it up for Docker. Next time we’ll be looking at another Linux Container implementation, LXD.