Docker I: One Container...

As we explored in our previous class, containers are a fantastically useful tool for web development, because they can be set up, stripped down, and moved between different machines. The most popular tool for managing containers is Docker, which is feature-rich but also quite easy to use.

In this class we're going to explore some of those features. If you were following along then you should have installed Docker somewhere. Since Docker is a container manager, you'll also need an app to deploy. If you have one on hand, feel free to use that. Otherwise, we've created a handy github repository with some demos which you can use for this course. Feel free to clone it and make use of it for this class.

How Docker Works

There are a few key concepts that we need to understand about Docker to make effective use of it. The first is an image. A Docker image is a template including files and configuration which can run an application. These images can be as simple as a "Hello world" application or as complex as the entire Ubuntu Linux distribution. Images can be built up in layers based on each other, and in most cases you're likely to be using your programming language's runtime and tooling as your starting point. For example, here are the docker images for the Python and Node.js runtimes.

The other main concept that we need to understand to make use of Docker is containers. Containers are particular, running instances of a Docker image. They each have their own inputs, outputs, networking setup, and runtime environments. Note that when you destroy a docker container you will lose any data that's inside it. Of course, destroying one container doesn't destroy the image, but it's important to remember that if you want to keep data from a container, then that data needs to go somewhere else.

For a more detailed explanation of how Docker works, you can check their official overview. However, as we're mainly interested in how to use Docker, we're going to move onto how to set up our containers.

Dockerfiles

Dockerfiles are used to describe the setup of a container in a way that Docker can understand. One major advantage of them is that they're easily reproducible. What this means is that we can ship our app with a Dockerfile, and provided we wrote it in a way that makes sense, our application will run the same way on any host machine.

💡 Before containers became widespread in the 2010s, it took a long time and a lot of complex scripting to setup virtual machines or physical servers in a way that worked for the apps being run on them. This was expensive for businesses as it meant specialised employees called sysadmins had to manage machines manually. Nowadays the role of the sysadmin has developed into that of the DevOps Engineer and involves extensive use of automation.

NixOS is a recent attempt to apply some of the advantages of containers to a whole operating system.

As an example of how to write a Dockerfile, let's take a look at the Dockerfile bundled with the static blog demo from the repository.

    FROM python:3.10-bullseye
    COPY requirements.txt requirements.txt
    COPY static /static
    COPY templates /templates
    ADD app.py /
    RUN pip3 install -r requirements.txt
    EXPOSE 5000:5000
    CMD ["python3", "-m", "flask", "run", "--host=0.0.0.0"]

FROM specifies what our base image is. In this case, we're using the Python image from Docker Hub. The part after the colon specifies which version to use.

COPY simply copies a given file from a location in the source code to a location inside the container. In this case, we're copying requirements.txt which is the Python equivalent of the Node.js package.json and tells Python's package manager which packages to install. We also copy the static and templates folders needed by the app across.

ADD is similar to the copy command with the small difference that it's able to download files from a URL. It also extracts files from compressed archives like tar or zip files when copying them into the container. Usually it's recommended to use COPY unless you have a specific reason to use ADD.

RUN is a command that's run when you build the image, and is used to set up the environment inside a container. In this case, it calls the pip3 package manager to install the packages which the app needs to run using the requirements.txt file we copied previously.

EXPOSE specifies which port on the container (more on this next class) should connect to which port on the host machine. The first port number is the container's and the second is the host's. Note that this doesn't actually publish the port: you need to do this manually when you run a container.

Finally CMD specifies a command to run when the container is started. In this case it calls a command to run the Flask web server on the container's host 0.0.0.0 (all interfaces).

For more information on all of these commands you can check Docker's official Dockerfile reference.

Your First Docker App

It might seem odd that we started our Docker tutorial with an explanation of Dockerfiles rather than looking at the command line. However, for the reasons we outlined above, using a Dockerfile is often the preferred way to deploy an app that you created yourself. If your Dockerfile is set up correctly then deploying an app is super easy.

To build an image from the source, just go into the folder for the static blog demo and run docker build . -t blog-demo. This does a few things:

  1. It looks for a Dockerfile in the current directory (i.e. .)
  2. It downloads any necessary images from Docker Hub (in our case, python-3.10)
  3. It builds the image using the commands in the Dockerfile
  4. It tags the final image with the name blog-demo

Since you set everything up in the Dockerfile, all that's left to do now is run our image with docker run -p 5000:5000 blog-demo. The -p flag publishes our ports using the format described above. You can also use -P to publish all ports on the container, although this will assign them to random ports on the host.

Now go to localhost:5000 and you should see an example blog appear.

If you want to run the blog as a separate program, rather than directly in your terminal, then you can use docker run -p 5000:5000 -i -d blog-demo. The -d (detach) flag detaches the container from your current terminal, while the -i (interactive) flag creates a terminal session used by the container. Among other things, this lets us view logs from our container using the docker logs CONTAINER_ID command.

Setting Up A Docker Registry

A docker registryis a server which is set up to act as a host for docker images. If we want to share our images with other people on a team or just between computers, we can use a registry to do this. Many development teams will maintain both a git repository (for source code) and a docker registry (for built images).

So far we've been using Docker Hub as a source for our images. This is a huge centralised registry which acts as the default source when you run the FROM command. However, we can easily run our own.

Log into a suitable computer (likely a virtual machine, server, or something like a Raspberry Pi) and set up your registry:

docker run -d -p 5000:5000 --restart always --name registry registry:2

This will run a registry on port 5000 of the computer using version 2 of the Docker Registry software. Now you can try out some commands from Docker.

docker pull ubuntu pulls an image down from Docker Hub to your local machine.

You can then use docker tag ubuntu localhost:5000/ubuntu. This will add a tag to the ubuntu image which points to the local registry server you've set up.

Finally, docker push localhost:5000/ubuntu will push the Ubuntu image you downloaded to your registry. If you didn't do this on your local machine, you can try downloading the image you just made a copy of on another computer with Docker installed: docker pull YOUR_EXTERNAL_URL:5000/ubuntu.

It's not mandatory, but if you're the type to hoard data then you might find it an interesting project to set up a private registry for your docker images.

Assignment

The assignment today consists of a few tasks.

  1. Write a Dockerfile for a simple app and deploy it (if you're studying Docker you've likely written a few good candidates)
  2. Download some images that interest you from Docker Hub to your new repository
  3. Explore the Docker interface by using docker help and other information-gathering commands. Here are two to get you started: docker container ps and docker image ps.
  4. Prepare two separate apps which interact through either HTTP requests or by using common files for next time (ideally both). Don't worry! If you're short on time, you can use the multicontainer demo from the github to follow along instead.

Further Reading