Complete Tutorial on Building Images Using Docker | by Kushajveer Singh | Towards Data Science

Docker provides a way to run your programs as containers on top of a host operating system. Dockerfile provides instructions on how to build the images that are then run as containers. This post discusses all the things you need to create Dockerfile’s with an example of setting up Ubuntu, Miniconda, and PyTorch.
docker build command is used to build an image from Dockerfile and a context (a PATH or URL). context refers to all the files specified in the build command. The steps to build the image are as follows
All the files specified by context are sent to the docker daemon. Due to this reason, you should create Dockerfile in an empty directory to avoid unnecessary transfers.
The Dockerfile is checked for any syntax errors
Docker daemon starts building the image by reading instructions from the Dockerfile.
The context can be aPATH on the local file system or a remote URL referring to a Git repository. An example of specifying the build context is shown below
> docker build .> docker build /path/to/context
Docker also provides you the ability to build an image from Git URL. This is mostly used for continuous integration pipelines. To build an image using URL the Dockerfile should be present at the specified URL (not on the local file system). docker build will automate the following steps for you when building an image from a Github URL
> git clone {GITHUB_URL}> git checkout {BRANCH}> cd repo> docker build .
The command to build the image is as follows
> docker build {GITHUB_URL}#{BRANCH}> docker build https://github.com/KushajveerSingh/Dockerfile.git#master
Note:- The above command will fail as there is no Dockerfile in the specified location.
You can use the -f flag to specify the path to Dockerfile. By default, it is assumed the Dockerfile is present at the root of the context.
> docker build -f /path/to/Dockerfile .> docker build -f ubuntu_conda_pytorch/Dockerfile https://github.com/KushajveerSingh/Dockerfile.git#master
Docker automatically cd 's into the Github repository so the path should not include the name of the repository.
Consider the following Dockerfile
FROM ubuntu:18.04LABEL PURPOSE = "test Dockerfile"
When we build an image docker assigns a commit hash as the IMAGE ID.
You can specify a repository and tag to every docker image which can then be used to easily access the image using -t flag. REPOSITORY can be thought of like the name of your Github repository and TAG is used to specify the version of your image. For example, ubuntu:18.04, ubuntu:latest.
You can specify multiple tags also
> docker build -t test:0.1 -t test:latest .
The Docker daemon runs the instructions from top-to-bottom one-by-one. And the result of most of the instructions (FROM, ADD, COPY) is committed to a new image. Due to this reason, you need to be careful when using these instructions as every single usage of them would result in the creation of a new image which would increase the size of the final image.
Why Docker does this? Consider the following Dockerfile
FROM ubuntu:18.04RUN COMMAND_1RUN COMMAND_2RUN COMMAND_1
Now when we build the image we would create the following layers
Layers from ubuntu:18.04 Dockerfile
RUN COMMAND_1 will create a new layer
RUN COMMAND_2 will create a new layer
RUN COMMAND_3 will create a new layer
A layer is basically a change on an image or an intermediate image. Whenever we run an instruction (like FROM, RUN, COPY) we are making changes to the previous image. These changes result in the creation of a new layer. Having intermediate layers helps during the build process. If you make a change in the Dockerfile, then Docker will only build the layer that was changed and the layers after that. This can save a lot of time.
Note:- Be careful when creating new layers as these would also increase the size of your image.
Docker supports moby/buildkit backend for building images. BuildKit provides many benefits over the default implementation provided by Docker
Detect and skip executing unused build stages
Parallelize building independent build stages
Incrementally transfer only the changed files in your build context between builds
Detect and skip transferring unused files in your build context
Use external Dockerfile implementations with many new features
Avoid side-effects with rest of the API (intermediate images and containers)
Prioritize your build cache for automatic pruning
To use BuildKit backend, you need to set DOCKER_BUILDKIT=1 environment variable.
> DOCKER_BUILDKIT=1 docker build .
OR
> export DOCKER_BUILDKIT=1> docker build .
In summary, to build a Docker image you can use the following command
This is an example of a parser directive. Parser directives are specified in the first line of Dockerfile and do not add layers to the build. You can specify the character to break up lines in Dockerfile using this.
# escape=\FROM ubuntu:18.04RUN INSTRUCTION_1 \    INSTRUCTION_2
On Windows \ is used to specify the path. So changing this to something like backtick can be useful.
# escape=`FROM ubuntu:18.04RUN INSTRUCTION_1 `    INSTRUCTION_2
All the Dockerfile’s start with the FROM instruction. FROM initializes a new build stage and sets the base image for the subsequent instructions. The general syntax is
FROM [--platform=<platform>] <image>[:tag] [AS <name>]
Here [...] means optional. You can start from a scratch image and build everything on top of that
FROM scratch
Or you can build on top of a public image (like Ubuntu, PyTorch, nvidia/cuda)
FROM ubuntu:18.04
For this post, I build on top of the Ubuntu image. You can build the image and try running it using the following commands
> DOCKER_BUILDKIT=1 docker build -t test:0.1 .> docker run -it --name temp test:0.1
You will see that it is a bare-bones install. It does not have any user or sudo. It provides us with the Linux kernel and we have to build everything on top of that.
In the next couple of sections, we look at all the instructions that we can in the Dockerfile, and then we will use all these instructions to build a Ubuntu/Miniconda/PyTorch image.
Environment variables are often used to declare variables in the scripts or set up some variables that will persist when the container is run. Docker allows us two ways to set variables: ARG and ENV.
ARG instruction defines a variables that users will pass at build-time with the docker build command using --build-arg <name>=<value> flag. These will only be used in the Dockerfile.
ENV instruction sets the environment variable in the Dockerfile and the environment variable will persist when a container is run from the resulting image.
We can specify the version of Ubuntu as ARG (code of Dockerfile is shown below)
ARG UBUNTU_VERSIONFROM ubuntu:$UBUNTU_VERSION
And then we can specify the version of ubuntu when building the image
> DOCKER_BUILDKIT=1 docker build -t test --build-arg UBUNTU_VERSION=18.04 .
We can also specify a default value to ARG as
ARG UBUNTU_VERSION=18.04
To access the value of ARG you can either use $UBUNTU_VERSION or ${UBUNTU_VERSION} syntax. The second method is useful when you want to access the value of ARG inside a string.
Using ARG
Use ARG for variables that are only needed in the Dockerfile and are not needed when the container is running. In this case, the version of Ubuntu is not needed when the container is running.
ARG that is used before FROM can only be used in FROM
ARG used after FROM can be used anywhere in the Dockerfile (there is an exception to this in the case of multi-stage builds i.e. when we use multiple FROM instructions in the same Dockerfile)
This is the same as ARG except the ENV will persist when a container is run from the resulting image. An example of this includes
ENV PYTORCH_VERSION 1.9.0ENV LD_LIBRARY_PATH /usr/local/nvidia/lib:/usr/local/nvidia/lib64# Setting PATH variablesENV PATH  /home/default/miniconda3/bin:$PATH
These two instructions can be considered as documentation instructions. Having these instructions in Dockerfile has no effect on the image, these are just used to provide metadata information.
LABEL can be used to specify information like the author of the Dockerfile and other relevant information
LABEL author = "Kushajveer Singh"LABEL email = "kushajreal@gmail.com"LABEL website = "kushajveersingh.github.io"
You can get the list of labels after building the image as follows
The EXPOSE instruction informs Docker that the container listens on the specified network ports at runtime. It does not actually publish the port. It just acts as a type of documentation between the person who build the image and the person running the container, about which ports are intended to be published.
EXPOSE 8889EXPOSE 80/udpEXPOSE 80/tcp
Now the person running the container can specify the port using -p flag as follows
> DOCKER_BUILDKIT=1 docker build -t test .> docker run -p 80:80/tcp -p 80:80/udp test
We discussed in the starting that a context is passed to the Docker daemon before reading the Dockerfile. Now to add files to the image from the context, we can use ADD or COPY. Both the instructions are similar but ADD does some extra things (that you need to be careful of). The syntax of both the commands is the same
ADD [--chown=<user>:<group>] <src>... <dest>COPY [--chown=<user>:<group>] <src>... <dest>
And a usage example would be
COPY --chown=default:sudo /file/on/local/drive /path/on/imageCOPY --chown=default:sudo script.sh /home/default
The <dest> path is either an absolute path or a path relative to WORKDIR which we will discuss later.
Now let’s look at the differences between each instruction.
COPY [--chown=<user>:<group>] <src>... <dest>
The COPY instruction copies files or directories from <src> and adds them to the filesystem of the container at <dest>. The files are created with a default UID and GID of 0, unless you specify --chown. And that is it. COPY will just copy a file from <src> to <dest>.
ADD [--chown=<user>:<group>] <src>... <dest>
The ADD instruction also copies files or directories from <src> to <dest> like COPY but it does some extra things also
<src> can be remote file URL
If <src> is a tar file (identity, gzip, bzip2, xz) then it will be unpacked as a directory. In case the tar file is a remote URL then it is not decompressed
Note:- Due to these extra features of ADD it is recommended you use COPY unless you know what exactly is being added to the image by ADD.
The WORKDIR instruction sets the working directory for any RUN, CMD, ENTRYPOINT, COPY, ADD instructions that follow it in the Dockerfile. You can use this multiple times to set the working directory as per your needs.
WORKDIR /home/defaultRUN ...# You can also provide path relative to previous WORKDIRWORKDIR ../home
The syntax of the command is
RUN <command>
and it will run the command in a shell (by default /bin/sh -c on Linux). Every RUN instruction will create a new layer and due to this reason you should try to group multiple RUN instructions into a single logical group.
To group multiple RUN instructions you can either use a semicolon or &&.
It is preferred that you use && over ;. The reason being when you group multiple commands using semicolon then the next instruction will run regardless of whether the previous instruction gave an error or not. This is not the case with &&. If a command fails then the execution would stop and the next command would not be executed.
These are all the instructions that we would need to create a Docker image. I left some instructions as we would not need them but you can check the complete Dockerfile reference for information about those commands (like ENTRYPOINT, CMD, VOLUME).
FROM - Every Dockerfile starts with this instruction and it provides the base image on which we build our image
ARG - We can specify command-line arguments using --build-arg to be used in the Dockerfile only
ENV - Environment variables that will be used in Dockerfile and will persist when the container is run
LABEL - To specify metadata like author name, ...
EXPOSE - To document the ports that are intended to be used when the container is run
COPY - To add files or directories from context to image (only copies the file)
ADD - To add files or directories from context to image (can copy from remote URL, automatically decompress a tar file)
WORKDIR - Specify the working directory for other instructions that use path
RUN - To run any command from shell
USER - Set the default user when the container is run
We will use Ubuntu as the base image. As discussed before it provides us with a bare-bones Linux distribution and we have to setup everything that we need.
ARG UBUNTU_VERSION=18.04FROM ubuntu:$UBUNTU_VERSION
Let’s check the Dockerfile before discussing what is happening
The first step is updating Ubuntu. Note the default user on Ubuntu is root. After, we setup sudo and a default user then we will have to append these instructions with sudo. This is done using
--fix-missing is optional. It is used in case we have broken dependencies and using this flag can help us resolve the issue in most cases. As we are starting from a clean install, this flag does not do much.
apt install -y --no-install-recommends. The -y flag helps us to bypass the yes/no prompt. Each package in Ubuntu comes with three dependencies
main dependencies
recommended packages
suggested packages
By default, Ubuntu will install main and recommended packages (to install suggested packages you need to provide --install-suggests flag to apt install). Our main goal is to keep the size of the docker image to a minimum and due to this reason, we do not want to waste space installing recommended packages. The --no-install-recommends flag does this and as a result, we only install the main dependencies.
Now you can install any other package that you may require like ca-certificates (required by curl), sudo, curl, git.
The second step is to clean up the packages that are no longer needed and clear any local cache. This is done using
RUN apt clean && \    rm -rf /var/lib/apt/lists/*
When we install packages, Ubuntu maintains a cache of the packages in /var/cache. The reason for doing this is if something goes wrong when upgrading something and we do not have access to a network connection then we can revert to the old version in the cache to downgrade the package. But we do not need this cache for the Docker image, so we can remove it using apt clean. Specifically, apt clean will remove the files in /var/cache/apt/archives/ and /var/cache/apt/archives/partial, leaving out a lock file and partial subdirectory.
/var/lib/apt stores data related to the apt package manager. This data is automatically downloaded each time we run apt update, so there is no point in storing this data and we can safely remove this to reduce the image size using rm -rf /var/lib/apt/lists/*.
Note:- To install a package after rm -rf /var/lib/apt/lists/* you have to first run apt update and then only you can install the required package.
The next step is setting up a root account and a default user. The contents of the Dockerfile to do so are shown next
Let’s see what each flag does in useradd
-r is used to create a system account i.e. an account created by the operating system during installation or the root account
-m is used to create a home directory if it does not exist
-d /home/default is used to provide the location of the home directory
-s /bin/bash is used to specify the name of user's login shell. You can skip this if you want.
-g root -G sudo This is interesting. The -g flag is used to specify the group a user belongs to and -G is used to provide a list of additional groups that a user belongs to.
By default, root user is not a member of group sudo and we need to explicitly set this up. root has all the privileges on the system but we still need a sudo group. When a user is part of sudo group then this means that the user can use their user password to execute sudo commands.
Having a sudo group is useful when we have multiple users on a system and each user can gain root privileges by using their own user password.
-u 1000 is used to provide user ID
$USERNAME is the name of the user that will be created
By default in Ubuntu, the root account has no password set. To set a password we can use the following command
echo "${USERNAME}:${PASSWORD}" | chpasswd
After following the above steps we have done the following
Added a user default
User default can get root privileges using sudo su and using default as the password
There is a bug with sudo that is discussed here in which you will get the following warning message every time you try to execute a command with sudo
> sudo hello > /dev/nullsudo: setrlimit(RLIMIT_CORE): Operation not permitted
This has been resolved in the latest patch but Ubuntu does not ship with that, so to stop this annoying warning you can use the following command
echo "Set disable_coredump false" >> /etc/sudo.conf
When you run the container you will see a note about sudo as shown below
To run a command as administrator (user "root"), use "sudo <command>".See "man sudo_root" for details.
This message will be removed when you run a command with sudo for the first time or you can remove it by adding the ~/.sudo_as_admin_successful file.
touch /home/$USERNAME/.sudo_as_admin_successful
And that is it, you have setup sudo and a default user. When you run the container, you will be root by default. But you can set the default user to be default using the following command
USER $USERNAMEWORKDIR /home/$USERNAME
Our Dockerfile will have the following contents
We can group all the commands to update Ubuntu, setup sudo and default user in a single RUN command to save space.
To access host GPU in a Docker container you can specify the --gpus flag when running the container
> docker run --gpus all -it --name temp test:0.1> docker run --gpus 0,2 -it --name temp test:0.1
This only requires you to have the Nvidia driver setup on the host machine and then you can use nvidia-smi in the Docker container to check if the GPUs are being detected.
Miniconda can be used for an easy python setup. Just grab the link of the latest Linux installer from the documentation page (in this case it is Python 3.9) and specify the location where you want to install Miniconda
The setup of Miniconda is quite simple
Set the MINICONDA_DOWNLOAD_LINK and MINICONDA_INSTALL_PATH variables
Set the environment variable ENV PATH ${MINICONDA_INSTALL_PATH}/miniconda3/bin:$PATH that will allow us to run conda from anywhere in the file system
Install miniconda using bash Miniconda.sh -b -p ./miniconda3
Remove the .sh file to save space using rm Miniconda.sh
conda init can be skipped as we already set the ENV PATH
Update the conda packages to the latest version using conda update -y --all
Now we are ready to install PyTorch. Head to the PyTorch install page to grab the command you want to use to install PyTorch.
Note:- For some reason numpy was not getting installed when I ran the conda install command so I had to add the pip install numpy command.
Conda stores index cache, lock files, tarballs, and the cache of unused packages. These can be safely removed to save space using the command conda clean -afy. So add this as the last conda command in Dockerfile.
pip stores cache in ~/.cache/pip and this folder can be safely removed using the command rm -rf ~/.cache/pip.
Now we are ready to build the image.
> DOCKER_BUILDKIT=1 docker build -t test:0.1 .
And then we can run a container to try out the image
> docker run --gpus all -it --name temp test:0.1default@a7f862b6bf73:~$
We can try nvidia-smi to check if the GPUs are being detected
And as we can see the GPU is being detected along with the Nvidia driver of the host machine. Next, we can try running some PyTorch commands
We have successfully installed PyTorch and having a working Ubuntu environment. The next section discusses various docker commands you need to build the image, run the containers and push the images to dockerhub.
We have already discussed the command to build images from Dockerfile in the first section.
The reference for docker build can be accessed here.
docker image ls can be used to get a list of all the images on your local file system.
You can use this command to check the SIZE of the image and get the IMAGE ID in case you forgot to tag the image. Docker stores the images in /var/lib/docker/ on Linux, however it is not a good idea to mess with the contents of this folder because Docker's storage is complicated and it depends on what storage driver is being used.
The reference for docker image ls can be accessed here.
docker image rm can be used to delete image by specifying the tag or IMAGE ID.
The reference for docker image rm can be accessed here.
In case you have a lot of untagged images you can use docker image prune to delete all the images, instead of deleting every single image manually.
The reference for docker image prune can be accessed here.
docker container ls -a can be used to list all containers (running and stopped). If you want to list only running containers use docker container ls.
We can get the status of all the containers. In the above examples, temp_1 is not running and temp_2 is running. And we can also see that the containers are not using any PORTS.
The reference for docker container ls can be accessed here.
docker start can be used to start one or more stopped containers ( docker container start can also be used for this).
> docker start temp_1temp_1
The reference for docker start can be accessed here.
To open a new terminal session with a running docker container docker attach can be used.
> docker attach temp_1
The reference for docker attach can be accessed here.
docker stop or docker kill can be used to stop multiple running containers. docker stop can be considered as a graceful stop. The difference between the two commands is
docker stop. Stop a running container. The main process will receive SIGTERM and after a grace period, SIGKILL will be received.
docker kill. Kill a running container. The main process will receive SIGKILL or any signal specified by the user using --signal
> docker stop temp_1> docker kill temp_1
The reference for docker stop can be accessed here and reference for docker kill can be accessed here.
docker rm can be used to delete stopped containers.
> docker rm temp_1
In some cases, you may require to delete a container that does not exist and not throw an error at the same time. This can be done as follows
> docker rm temp_1 || true
The reference for docker rm can be accessed here.
docker run command is used to create a container from an image. There are many useful flags when using this command
> docker run -it --gpus all --name temp -p 8888:8888 test:0.1
-it will open a terminal connected to the container
--gpus all is used to specify which GPUs to give access to the container
--name temp name of the container
-p 8888:8888 to publish ports. The format is {HOST_PORT}:{CONTAINER_PORT}. To use jupyter notebooks inside docker you need to publish a port.
The reference for docker run can be accessed here.
If you want to connect a new terminal to a running container, then you can use the following command
> docker exec -it {container_name} bash
docker attach {container_name} cannot create a new terminal session. It will connect to the old session.
To delete all the docker containers and images on your system you can use the following two commands (in the order specified)
# Remove all containers> docker rm -vf $(docker ps -a -q)# Remove all images> docker rmi -f $(docker images -a -q)
To push an image to Dockerhub use the following commands
A working example is shown below
The first argument ( test:0.1) to docker tag is the name of the image on the local file system and the second argument is the location on Dockerhub where you want to push the image to.
The references for the above commands
docker login link
docker tag link
docker pushlink
KushajveerSingh/Dockerfile — You can access the Dockerfile’s from this repository to build your own images. Documentation is provided in the README for all the available Dockerfile’s.
Dockerhub kushaj — All the images can be found at this link. If you want to use an image but the contents of the image, then you can access the Dockerfile used to create the image from the above Github link and build the image.
twitter, linkedin, github
Originally published at https://kushajveersingh.github.io on October 3, 2021.