As businesses move more and more infrastructure online due to the effects of competition (not to mention COVID-19), finding the best way to manage that infrastructure becomes more and more important. Docker gives development teams more reliable, repeatable, and testable systems that can be deployed at massive scale with the click of a button. In this series (Parts 1 and 2 are linked below), we are looking under the hood at Docker, an infrastructure management tool that has grown rapidly in popularity over the last decade.
In this installment, I will walk you through the process of using the Docker command line tools to download, install, and run containers, as well as build your own container. If you’re not a technologist, you should skip this article. If you are, then jump right in!
The installation process varies by operating system, but installing Docker is fairly straightforward. However, you must be running a 64-bit operating system (which is common today) in order to run Docker.
All of the commands given will be for a root user. On Linux-based servers, you must be the root user to install and run Docker and Docker images, so all of the commands given will be for the root user. You can run Docker images as a non-root user but doing so is outside the scope of this tutorial.
To install Docker on a CentOS 7-based Linux system, just run “yum install docker” and everything installs (CentOS 8 is more involved – see here). For a Debian-based system, run “apt-get install docker.io”. Then, for both of them, you can run “systemctl start docker” to get the service up and running and “systemctl enable docker” to make sure that it starts when the machine reboots.
On Windows, Mac, or Linux, Docker is primarily a command-line tool. So, this tutorial will assume at least a basic working knowledge of how the command line works.
Running Your First Container
In order to demonstrate different aspects of Docker containers, I built a few custom containers just for this article. The first one will be a “hello world” container, which just turns on, says “hello world,” and turns back off. To run this, just do:
docker run johnnyb61820/hello-world
The first time you run this, it will connect to the standard Docker registry and look for a repository named “johnnyb61820/hello-world” (johnnyb61820 is my username). It will then find the image tagged with “latest” (meaning the most recent version of this image). It will pull down the image, save it locally, and then run it. It will display information about downloading the image and then the last line will say “Hello World!”.
If you run the command again, since the image is now saved to your computer, it will just print “Hello World!” and exit. Run the command a total of three times.
Now we will investigate what those commands actually did on your computer. The first thing it did was to install a Docker image onto your computer. You can see this by running the following command:
docker image ls
If you aren’t familiar with Unix terminology, “ls” (that’s an L and an S if you are having trouble reading it) is a shortened form of “list,” so this command lists the docker images on your computer. If this is your first time using Docker, it should print out something like this:
The repository is where the image is located. The “tag” is the version number (“latest” is the standard version for the most recent version of this image). The “Image ID” is the computer’s name for the image. The “size” is how much space it takes up on your computer.
However, the image is just the unchanging part of a container. A container itself is a full virtual machine that has both changing and unchanging components. In fact, if you ran the “Hello World” program three times, your computer now has three containers on it. You can see them by running the following command:
docker container ls -a Without the “-a”, this command will only list running containers. But we want to see all of the containers on the computer, whether running or not. This produces what may be some surprising output:
|771c5e65d8db||johnnyb61820/hello-world||“/hello”||2 minutes ago||Exited (0) 2 minutes ago||vibrant_panini|
|eb7ade7aef2a||johnnyb61820/hello-world||“/hello”||2 minutes ago||Exited (0) 2 minutes ago||lucid_kalam|
|d725bdd2fdf2||johnnyb61820/hello-world||“/hello”||2 minutes ago||Exited (0) 2 minutes ago||gifted_keller|
Every time we ran the command, the system started up a new container (i.e., a new “virtual machine”), created a read-write space for the command to run in, ran the code in the container, and exited. The containers still exist, and here they are. So what do these columns mean?
The Container ID is the computer’s internal name for your container, and is generated at random. The “names” on the right-hand side are also generated, but are meant to be more human-readable. You can refer to containers by either name. The “image” is the name of the image on which the container is running. The “command” is the command that was run when the container executed.
Most containers contain entire operating systems that have several commands available. However, this container only has one file—the program to execute. Additionally, containers specify an “entrypoint” or “default command” and this is the command that they run when they are started. In our case, the the “johnnyb61820/hello-world” container had one file in it, “/hello”, which is run when the container starts.
To restart the container (i.e., to re-run the command in the container without creating a new one), we will issue a “start” command to the container with the following command:
docker container start -ai CONTAINER_ID
In this command, replace CONTAINER_ID with one of the Container IDs that returned when you listed the available containers (or you can use the friendlier name as well). This will re-run the command within the existing container, rather than creating a new one.
Now, first you might be wondering why these containers are staying around. The reason is simple—in most cases, these are full applications, so, even when they aren’t running, we don’t want the container to be deleted! However, in this case, the program doesn’t save any information, so we can just delete the container. We can tell Docker to auto-delete a container when it is done running by adding “–rm” to the “docker run” command, like this:
docker run --rm johnnyb61820/hello-world
This will create the container, run the command, and then remove the container when the command is done, so it is not listed in the list of containers.
Note, however, that containers are not expensive! The containers all share the image that they start with. In our case, the application does not modify any files, which means that the amount of disk space that they use is extremely small (about 200kB each).
To delete your non-running containers, run the command:
docker system prune
you can add a “-a” to the end of that command to also delete all the images that are not presently used.
If you want to try out another simple docker command, run:
docker run johnnyb61820/roll-dice
This will simply simulate the roll of a dice.
Running a Docker Service
Docker is usually used to run services rather than individual commands. In this next example, we will run a Docker app that runs as a very simple HTTP service on port 8070 inside the container. As you will see, we can map that port to a port on the main server (the example will use port 8080).
docker run -p 8080:8070 johnnyb61820/simple-web-server
While it is running, you can use your web browser to access the service running on your machine at http://localhost:8080/ . It should give you back a plain-looking web page that says, “Hello from Docker!” You can push control-c at any time to stop the service.
So what does this command do? It is almost identical to our previous commands, with the exception that there is a “-p 8080:8070” added to the command. Remember, each container acts almost exactly like a full virtual machine. That means that each container has its own networking, too. The “-p” flag says to take the port 8080 on the real machine, and proxy it to port 8070 on the Docker virtual machine. Note that these can actually be the same value because they act like completely separate machines. However, I put different values so that you can see that you can map the ports in any way you wish.
Now, most services are actually run in the background. To run a Docker image as a background service, add a “-d” flag like this:
docker run -d -p 8080:8070 johnnyb61820/simple-web-server
It will print out the Container ID (which is a longer form of the same name you get from “docker container ls”) and return to you. Now, if you do “docker container ls” you can see it running:
|0422ab9b8c7f||johnnyb61820/simple-web-server||“/http-service”||2 minutes ago||Up 2 minutes||0.0.0.0:8080->8070/tcp||hopeful_payne|
Note that without the “-ai”, “docker container ls” only shows actively running containers. It shows the ports that have been proxied from the main host to the container. We can then stop the container with “docker container stop CONTAINER_ID”. We can restart it again with “docker container start CONTAINER_ID”. We used the “-a” flag earlier because otherwise, “docker container start” runs the container in the background (which we now want to do). After a container is stopped, it can be removed altogether by “docker container rm CONTAINER_ID”. Remember, the CONTAINER_ID can be either the raw ID that the computer generates, or the more user-friendly name.
Running a Whole Operating System
So far, the containers we have been looking at are extremely lightweight, as they only contain one single file with the command in them. However, most Docker instances actually contain a minimal operating system, often based on the Ubuntu Linux distribution.
If you want to run a container as essentially a full machine, run the following command:
docker run -it ubuntu
The “-it” will allocate an interactive terminal. The default program that is run with ubuntu in the command line is the shell. That means that running this command will (a) download a fairly minimal Ubuntu distribution (about 75MB), (b) create a container, and (c) start up a shell in which you are now typing. Again, this is a real Linux distribution, so you can use “apt-get” to install whatever additional packages you like. However, don’t forget to “apt-get update” to retrieve the list of packages available for installation.
Note that, in the container, you can do anything you want: create files, run programs, anything! When you leave, the container will stop. However, you can get back to it by finding the container’s name with “docker container ls -a” and then starting it again with “docker start -ai CONTAINER_ID”.
Because your containers act essentially as full virtual machines, anything you do inside the Docker container won’t affect other containers or the main operating system. If I install a package, it is only installed within the container. If I add a user, that user is only added within the container.
Copying Files to and from the Container
With Docker, you can easily copy files in and out of the container from the host computer using the “docker cp” command. If there is a file on your computer, say, “myfile.txt”, you can copy it to your container using the command:
docker cp myfile.txt CONTAINER_ID:/path/to/destination/file.txt
If there is a file in your container, say, “/path/to/file.txt”, you can copy it out of your container using the same command:
docker cp CONTAINER_ID:/path/to/file.txt file.txt
No extra options are needed for copying directories. Just specify the name of the source and destination directories, and the entire directory tree is copied, maintaining permissions if possible.
You can copy from a container whether it is running or stopped.
Creating a New Docker Image
Let’s say that you have taken the Ubuntu package, done modifications to it, and now you have a container that you want to replicate to other containers. This can be done easily by converting your container to an image.
As a simple example, we will create a container from the Ubuntu image, install a single package, and then create a new image out of our container. Run the following commands:
docker run -it --name mycontainer ubuntu apt-get update apt-get install uuid exit
The “–name” parameter tells Docker that we will decide the name of the container rather than having Docker autogenerate one for us. Then, inside the container, we run those commands to install a single additional package, the “uuid” package (this was chosen just because it is a small package that supplies an easy-to-run command, “uuid”). Finally, we exit from the container.
Now, to create an image out of our new container, we run:
docker commit mycontainer ubuntu-with-uuid
This will take the Container ID “mycontainer” and create an image out of it called “ubuntu-with-uuid”. Additional changes can be made with the “–change” flag (such as which program gets run when the container starts), but that is outside the scope of this article.
Now, we can run new containers using this image as a base with the command:
docker run -it ubuntu-with-uuid
Note that the image for this container, as given in “docker ls”, is 98MB. However, it actually uses much less than that. Because our new image is based on the Ubuntu image, it shares the underlying files with that image, so only the differences take up additional disk space.
If you want to see the changes you have made to a container before converting it to an image, you can run “docker diff CONTAINER_ID”, and it will give you a list of all the files that have been added, changed, or removed from the container.
Creating Docker Images Using a Recipe
While you can create images by just messing around with an existing image, that can lead to problems with configuration management. Let’s say that there is a new release of the Ubuntu image, and you want to rebuild your environment using the new image as a base. Do you remember all the steps you followed to configure your environment? Chances are, you won’t. I’ve been building operating system images practically my whole life and I can tell you that I never remember.
Therefore, a better and more systematic approach is to use a recipe, known as a Dockerfile to create your image from a previous image. An example Dockerfile (which should be named “Dockerfile”) is below:
FROM ubuntu RUN apt-get -y update RUN apt-get -y install uuid COPY some-file-in-current-directory /path/to/container/destination ENTRYPOINT ["/bin/sh"]
The FROM command tells Docker what the base image should be. Any RUN command causes Docker to run those commands inside a container with that image. In this case, we are running installer commands, and using the “-y” flag so that it doesn’t ask us any questions. Any COPY command copies the given file in the current directory to the given location in the container. Finally, the ENTRYPOINT specifies what command to run when the container is started.
To build the new image, go to the directory with the Dockerfile and run
docker build -t NEW_IMAGE_NAME .
This will create a new image called NEW_IMAGE_NAME based on this recipe. The “.” means to use the Dockerfile in the current directory. This offers a lot of possibilities for building from archive files, GitHub, or other interesting locations, but that is beyond the scope of the present article.
Pushing the Image to DockerHub
If you have an account on a container registry such as DockerHub, if you give the container the same name as one of your repositories, you can push it with “docker push IMAGE_NAME”. For instance, I created a repository on DockerHub called “johnnyb61820/example-from-recipe”. I then built the previous recipe with
docker build -t johnnyb61820/example-from-recipe .
Because I did not specify a version (which I would have done by adding “:my-version-identifier” to “johnnyb61820/example-from-recipe”) it uses “:latest” as the default. I can then push my new image up to DockerHub by first logging in with “docker login” and then giving a push command like this:
docker push johnnyb61820/example-from-recipe
This will push my image up to my repository. I can add a version tag onto it if I want to push a version other than the latest one.
In the next installment, we will use Docker Compose to run in concert several containers which all work together to deliver a single function (such as a database, web server, and caching server).
TIP: Logging into a Running Docker Container While It Is Running
Many times when running a service using a Docker container, it is helpful to have a login to the box and look around. If a Docker container is running, you can run other processes within that container using “docker exec”. Because many Docker containers contain at least a minimal Linux operating system, you can usually run “docker exec -it CONTAINER_ID /bin/sh” to get an interactive shell within a running Docker container. For some reason, “docker exec” only runs on running containers, not on stopped containers. Nevertheless, for interactively checking on and diagnosing problems in a running service, this trick is a lifesaver.
Here are the two previous installments in this series:
How the Docker revolution will change your programming, Part 1 Since 2013, Docker (an operating system inside your current operating system) has grown rapidly in popularity. Docker is a “container” system that wraps the application and the operating system into a single bundle that can be easily deployed anywhere.
In this series, we are looking under the hood at Docker, a infrastructure management tool that has rapidly grown in popularity over the last decade
Part 2: A peek under the covers at the new Docker technology The many advances that enable Docker significantly reduce a system’s overhead. Docker, over and above the basic container technology, also provides a well-defined system of container management.