Creating Docker Images

A Docker image is created using the docker build command and a Dockerfile file. The Dockerfile file contains instructions on how the image should be built.

The Dockerfile file can have any name. Naming it Dockerfile makes it easier for others to understand its purpose when they see that file in your project. It also means we don’t need to state the file name when using the docker build command.

Simple Example

I’d like to create a basic image for a container that displays a “hello world” message when it is run.

For this, I create a file named Dockerfile that describes how my image should be built. A Dockerfile file always begins with a FROM instruction because every image is based on another base image. This is a powerful feature since it allows you to extend images that may already be complex.

As I only need a simple text output, I can use a Debian Linux image. Here’s my Dockerfile file:

FROM debian:11

This is not enough. While I do get a Debian Linux basis, I am not running any command that could display “hello world.” This can be achieved using the CMD instruction. The CMD instruction specifies which executable is run when a container is created using your image and provides optional arguments.

Here’s an improved Dockerfile file that creates a Debian Linux-based image and instructs it to greet our users when a container spawns:

FROM debian:11

CMD ["echo", "Hello, World!"]

Note that both the program to run and its arguments are provided as a JSON array of strings.

In order to create an image from my Dockerfile file, I need to run the docker build command. To do this, I type the following command in my terminal in the folder where the Dockerfile file lives:

docker build -t hello .

The -t switch is used in front of the desired image. An image can be created without a name, it would have an auto-generated unique ID, so it is an optional parameter on the docker build command.

Note the dot at the end of the command above. It specifies which path is used as the build context (more about that later), and where the Dockerfile is expected to be found. Should my Dockerfile have another name or live elsewhere, I can add a -f switch in order to provide the file path.

The docker build command just created an image named hello. This image gets stored locally on the system, and it can then be run like any other image using the following command:

docker run --rm hello

From here, you may want to publish your image for others to run containers based on it. Let’s discuss more about creating images.

Just to make things crystal clear, here’s what I did:

  • Create an image:

    • Create a file named Dockerfile

    • Run a docker build command

  • Run a container from the image created

Again, I’d like to expand on what this means; running a container is the virtual equivalent of starting a brand-new machine and then trashing it. In order to print that “Hello world” message, we essentially got a new computer, had it execute an echo command, and then trashed it. Docker makes fire-and-forget computing cheap. Of course, this is overkill for such a simple purpose, but it remains true even when we install frameworks or move files around inside our containers; it’s a fantastic feature. Most of your Docker power will come when you understand how easily you can create and trash isolated virtual computers.

Now let’s try to include an html file in our image.

Creating an Image Including Files

Suppose I have a file named index.html on my disk with the following contents:

<html>
    <body>
        <h1>Hello!</h1>
        <div>I am hosted inside a container.</div>
    </body>
</html>

I want to create an image that includes a web server that serves the above page over HTTP. NGINX is a good candidate. I could keep using the debian base image and add instructions to my Dockerfile file that install NGINX, but it’s easier to base my work on images that are already configured and tested. The Docker Hub contains an NGINX image where NGINX has already been installed with a configuration that serves files found in the /usr/share/nginx/html directory.

I create the following Dockerfile file in the same folder as the HTML file:

FROM nginx:1.15

COPY index.html usr/share/nginx/html

Apart from the nginx base image, you can see a COPY instruction. Its first parameter is the file to be copied from the build context and its second parameter is the destination directory inside the image.

The build context is basically the directory you provide to the docker build command. Its contents are available for COPY instructions to use, but only during the image build process. That means it’s available only for the instructions in the Dockerfile file, and files from it won’t be a part of the build image or containers that you’ll spawn from that image unless you use the COPY instruction. This is why we have a COPY instruction; we want the index.html file to be part of the /usr/share/nginx/html directory inside the image we create.

You may have noticed that this time the Dockerfile file contains no CMD instruction. Remember that the CMD instruction states which executable should be run when a container is created from my image, so it’s weird that I don’t include a CMD instruction that runs an NGINX server. The reason why I didn’t include a CMD instruction is because the base nginx:1.15 image already contains a CMD instruction to run the NGINX server. This is part of my image and I don’t need to include my own CMD instruction as long as I don’t want to run another executable on container startup.

Back to creating our HTTP server image, I open a command line inside the folder where my index.html and Dockerfile files are, and run the following commands:

docker build -t webserver .
docker run --rm -it -p 8082:80 webserver

The above commands build a webserver from the Dockerfile file instructions, then start a container that listens to my machine’s 8082 port and redirect the incoming connections to the container’s 80 port. You can start a browser and point it to http://localhost:8082 to view it locally. This displays the HTML file contents in the browser since they are served over HTTP by the running container:
In the terminal tab, you can see a log from NGINX that proves it received the HTTP request from my browser:

172.17.0.1 - - [.../2019:21:14:46 +0000] "GET / HTTP/1.1" 304 0 "-" "..." "-"

When running my container, I added the —rm and -it switches simply for demo purposes. In reality, that server container would be long-running, so I’d run it without those switches. Here’s why I used the switches:

  • The -it switch allows me to stop the container using Ctrl-C from the command-line

  • The —rm switch ensures that the container is deleted once it has stopped

Images Are Created Locally

When I run the docker build command to create an image from a Dockerfile file, the resultant image is stored locally on the computer where the docker build command is run.

This allows me to run as many containers as I want from the locally created image, but the chances are that I want other computers to be able to run containers from the image I created. We’ll learn how to do that in the next chapter.

I can see the images available locally on my computer by running the following command:

docker image ls

Considering, the images I built earlier, I can see something along those lines:

REPOSITORYTAGIMAGE ID
webserverlatestc067edac5ec1
hellolatest347c4eed84cd
nginx1.15f09fe80eb0e7
debian8ec0727c65ed3

Having the images readily available locally makes it faster to run a container from them. However, there will be a time when some images are useless. I can remove them from my local machine using the docker rmi command and providing it the image name or image ID.

For instance, I could remove the webserver:latest image using any of the following two commands:

docker rmi c067edac5ec1
docker rmi webserver:latest

Tags Matter

Earlier, we saw that image names include a name and a tag. As a quick reminder, an image name is:

<repository_name>/<name>:<tag>
  • tag is optional; when missing, it is considered to be latest by default

  • repository_name can be a registry DNS or the name of a registry in the Docker Hub

While your images aren’t published to a registry, you don’t need to include a registry name. So, your image name is:

<name>:<tag>

The latest tag

In my demonstrations I didn’t include a tag; therefore the default latest tag was used. For instance, the actual image name was hello:latest when I ran the following command:

docker build -t hello .

As long as you are creating simple software, running on a simple CI/CD pipeline, it can be fine to use the latest tag. In a simple scenario, you may:

  1. Update the source code

  2. Build a new image with the latest tag

  3. Run a new container with the newest image

  4. Kill the previous container

There’s a caveat with this however: when using the docker run hello command on a distant machine (which actually means docker run hello:**latest), the distant machine has no means to know that there is a newer version of the hello:latest image. You need to run the docker pull hello command on the distant machine in order for the newest version of your image to be downloaded to that machine.

This may sound awkward, and that’s one reason for not just using the latest tag.

Why Would You Tag Your Images?

Other reasons come to mind once you become more serious with your CI/CD pipeline. For instance, you may want any or all of the following features:

  • Be able to roll back to a previous version of an image if you detect a problem with the latest image.

  • Run different versions in different environments. For instance, the latest version in a test environment and the previous version in a production environment.

  • Run different versions at the same time, routing some users to the latest version and some to the previous versions. This is known as a canary release.

  • Deploy different versions to different users, and be able to run whatever version on your development machine while you support them.

These are all good reasons for tagging your images. If you ensure each released image has a different tag, you can run any of the scenarios mentioned above.

You’re free to tag your images however you want. Common tags include:

  • a version number, e.g. hello:1.0, hello:1.1, hello:1.2

  • a Git commit tag, e.g. hello:2cd7e376, hello:b43a14bb

In order to apply a tag, just state it during your build command:

docker build -t hello:1.0 .

Tags for Base Images

Remember your images are based on other images; this is done using the FROM instruction in your Dockerfile file. Just as you can tag your images, the base image you use can be the latest one or a tagged one.

In the above demos, I used tagged images. For instance, I based my server image on the nginx:1.15 base image. It’s quite tempting to base your images on the latest ones so that you’re always running on up-to-date software, especially since it’s so straightforward. All you need to do is omit the tag altogether or mention the latest one. You could be tempted to use the following instruction in your Dockerfile file:

FROM nginx:latest

Don’t! First of all, it doesn’t mean that any running container will be based on the latest available version of the nginx image. Docker is about having reproducible images, so the latest version is evaluated when you build your image, not when the container is run. This means that the version will not change unless you run the docker build command again.

Second, you’re likely to run into trouble. What about the nginx image releasing a new version with breaking changes? If you build your image again, you’re likely to get a broken image.

For these reasons, I recommend specifying the image tag. If you want to keep up to date with new releases of the base image, update the tag manually and make sure you test your image before releasing it.

Parameters as Environment Variables

In real life, a container’s inputs and outputs are likely to vary according to the container’s environment. For instance, if you run a web application, it is likely to connect to a database and listen for incoming requests on a given DNS. The database connection details and DNS will have different values on a development machine, on the test server, and the production server.

Reading a value

Whatever the technology you use inside your container, you can access environment variables. For instance, if you set a name environment variable, you may access it with:

TechnologyAccess
Linux shell$name
.NET Core.AddEnvironmentVariables();
JavaSystem.getenv(“name”)
Node.JSprocess.env.name
PHP.$_ENV[“name”]
Pythonos.environ.get(‘name’)

Providing a value

On a real machine, environment variables are set on your system. Inside a container, they can be set from several sources, which make them appropriate for parameterizing your containers.

In order to provide an environment variable’s value at runtime, you simply use the -e name=value parameter on the docker run command.

A special use case is when the system that runs the container has the name environment variable defined, and you want to reuse it, then you can simply use the -e name parameter without specifying a value.

Default value

You may also want to define a default value for an environment variable, in case it isn’t provided when a container is created; this may be done in the Dockerfile file, using the ENV instruction. For instance, the following makes sure that if the name variable isn’t provided to the docker run command, it has a default value of Dockie:

ENV name=Dockie

It’s good practice to add an ENV instruction for every environment variable your image expects since it documents your image.

Sample usage

I want to create an image that can ping any given site. I’ll do this using a Linux shell script. I define it in a ping.sh file:

#!/bin/sh

echo "Pinging $host..."
ping -c 5 $host

Note that I make use of a host environment variable. I’m going to define an image that includes and runs that script:

FROM debian:8

ENV host=www.google.com

COPY ping.sh .

CMD ["sh", "ping.sh"]

Note that my Dockerfile file includes an ENV instruction that specifies that the host variable will be google.comwww.google.com in case it isn’t provided. I create my image from that Dockerfile file by running a docker build command:

docker build -t pinger .

Next, I run two containers based on that image:

docker run --rm pinger
docker run --rm -e host=www.bing.com pinger

We don’t provide the first container with any value for the host environment variable in order for it to default to the google.com value specified in the Dockerfile file. The second container is provided the bing.com value.

We don’t provide the first container with any value for the host environment variable, for it to default to the google.comwww.google.com value, specified in the Dockerfile file. The second container is provided the bing.comwww.bing.com value.

You can see in the output that each container pinged a different host, according to the values provided to them.

This was a simple demo, but you can provide advanced values. You would typically provide full connection strings or URLs to other services, usernames, and passwords, to name a few. This is a flexible and powerful feature since those values may come from many sources once you begin to use orchestrators.