Victor is a full stack software engineer who loves travelling and building things. Most recently created Ewolo, a cross-platform workout logger.
Docker in 10 minutes

I've been exposed to docker on and off and every time I see it, I seem to need a refresher. In this article we will go through everything you need to know about Docker in order to either jump into an existing project or get started with it.

Basic concepts

Docker is basically a system of running processes on the host machine in an isolated way, using several Linux kernel features. Thus, Docker is more lightweight than a full-blown virtual machine. The disadvantage of a Docker container vs. a virtual machine is that multiple containers share the same underlying OS kernel. While the concept of jailed processes is not new, Docker's popularity was essentially due to the tooling that it provided to which made it really straightforward to spin up and manage containers.

Docker is made up of various components. The main component is the the docker engine, which consists of a lightweight runtime that manages containers, images, builds, and more. It runs natively on Linux systems and is made up of:

  1. Docker daemon that runs on the host machine.
  2. Docker client that communicates with the Docker daemon to execute commands.
  3. A REST API for interacting with the Docker daemon remotely.

The Docker client is what you, as the end-user use to communicate with the Docker daemon, e.g. docker run hello-world.

The Docker daemon is what actually executes commands like building and running containers on the host machine. The Docker Client can run on the same machine as well, but it does not have to. It can also communicate with the Docker Daemon running on a different host.

We will look at other Docker components like the Docker hub, etc. later in this article.

Images, containers and volumes

A Docker image can be though of as a recipe for setting up a machine with all required software and dependencies installed. Apart from installing software, images can also define what processes to run when launched. Docker images are created via instructions written in a Dockerfile. Images are built on the concept of layers. There is always a base layer, potentially followed by additional layers that represent file changes. Each layer is stacked on top of the others, consisting of the differences between it and the previous layer. This is achieved via a Union file system.

A Docker container is the running instance of an image. This includes the operating system, application code, runtime, system tools, system libraries, etc. A Docker image can be thought of as an executable and a container can be thought of as the running application. Note that in this analogy each running application is its own instance and independent of the others.

The general idea is that once you have successfully created a container, you can then run it in any environment without having to make changes.

A Docker volume is the "data" part of a container, initialized when a container is created. Volumes allow you to persist and share a container's data. Docker volumes are separate from the default Union File System and exist as normal directories and files on the host filesystem.

Docker comands

As part of the docker installation process, a hello world image was downloaded and executed via: docker run hello-world

List all images with docker images:


$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
ubuntu              latest              94e814e2efa8        2 weeks ago         88.9MB
hello-world         latest              fce289e99eb9        2 months ago        1.84kB

Run a command interactively from an image in a new container: docker run -it ubuntu bash

List all running containers: docker ps. To list all previously run containers use docker ps -a:


$ docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                         PORTS               NAMES
d2d651741317        ubuntu              "bash"              45 minutes ago      Exited (0) 43 minutes ago                          suspicious_kalam
36813bdb6434        ubuntu              "bash"              About an hour ago   Exited (0) About an hour ago                       inspiring_banach
46f352a0cde9        hello-world         "/hello"            2 hours ago         Exited (0) 2 hours ago                             thirsty_clarke

Note that in the output above, for each docker run command, a new container was created. As mentioned previously, each container has it's own data volume and changes to one do not affect the others. To run an existing container: docker run [container-name. This will start the container and docker attach [container-name] will jump into it.

At this point, you should have enough to get started with an existing docker project. Read on if you're looking to develop with docker.

Custom images

As noted previously, Docker images are specified via a Dockerfile. Here's an extremely basic example that uses a ubuntu base image and copies an executable called sysinfo from the current directory into the container and executes it:


FROM ubuntu:18.04
COPY sysinfo /
CMD ["/sysinfo"]

Let's see how we can get this image up and running via the docker build command (note that gcc is required to compile the binary):


$ cd ~
$ mkdir -p docker/sysinfo
$ cd docker/sysinfo
$ vim sysinfo.cpp

#include <iostream>
#include <sys/utsname.h>

using namespace std;

int main() {
  struct utsname sysinfo;
  uname(&sysinfo);
  
  cout << "System Name: " << sysinfo.sysname << endl;
  cout << "Host Name: " << sysinfo.nodename << endl;
  cout << "Release(Kernel) Version: " << sysinfo.release << endl;
  cout << "Kernel Build Timestamp: " << sysinfo.version << endl;
  cout << "Machine Arch: " << sysinfo.machine << endl;
  cout << "Domain Name: " << sysinfo.domainname << endl;
  
  return 0;
}

$ g++ sysinfo.cpp -o sysinfo
$ ./sysinfo

System Name: Linux
Host Name: coolbeans
Release(Kernel) Version: 4.18.0-16-generic
Kernel Build Timestamp: #17-Ubuntu SMP Fri Feb 8 00:06:57 UTC 2019
Machine Arch: x86_64
Domain Name: (none)


$ vim Dockerfile

FROM ubuntu:18.04
COPY sysinfo /
CMD ["/sysinfo"]

$ docker build . -t sysinfo
$ docker run sysinfo

System Name: Linux
Host Name: d8e53b009d72
Release(Kernel) Version: 4.18.0-16-generic
Kernel Build Timestamp: #17-Ubuntu SMP Fri Feb 8 00:06:57 UTC 2019
Machine Arch: x86_64
Domain Name: (none)

Note in the difference in hostname between local system (coolbeans) and the running container (d8e53b009d72) in the above output.

If you make a mistake, you can remove an image via docker rmi [image-name] --force. Cleaning up unused containers and volumes related to the image can be accomplished via docker system prune --volumes.

In the above example, we created a custom image using the standard Ubuntu image as our base image, before we go further with creating custom images it would be good to note that the docker hub provides lots of free pre-configured images for various software. This is the second Docker component and is also sometimes called the Docker registry (one can also have private registries).

Networking

In most cases, we would like to run a service via Docker. Let is look at how we can accomplish this by using a very simple web server as an example. Create the image as follows (note that the example below uses Go to create the binary):


$ cd ~
$ mkdir -p docker/webapp
$ cd docker/webapp
$ vim webapp.go

package main

import (
	"io"
	"net/http"
)

func hello(w http.ResponseWriter, r *http.Request) {
	io.WriteString(w, "Hello from webapp!")
}

func main() {
	http.HandleFunc("/", hello)
	http.ListenAndServe(":8000", nil)
}

$ go build webapp.go
$ vim Dockerfile

FROM ubuntu:18.04
COPY webapp /
CMD ["/webapp"]

$ docker build . -t webapp
$ docker run -d webapp
cfab907c828a40ce4cc53b88b26badabf8fa6672fd538d0c072fd0947f36d650

In the above example we built a webapp image and started the docker container with the -d flag. This started the container in detached mode and printed the container id so that we could interact with it. We can confirm it is running via docker ps:


CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
cfab907c828a        webapp              "/webapp"           4 minutes ago       Up 4 minutes                            zealous_herschel    

At this point we have our web application running in a docker container but we have no way to communicate with it. Run docker inspect cfab907c828a to output the container configuration in json format. We are interested in the NetworkSettings.Networks.bridge.IPAddress property. Let's try connecting to the provided ip address, http://172.17.0.2:8000 (in my case) and we can see our web application in action!

It is also possible to bind ports on from the docker container to the host machine so that we can access services as if they were running locally, docker run -d -p3000:8000 webapp. Thus our web application is now available on http://localhost:3000!

Persistent storage

By default Docker containers come with their own storage which lives as long as the container is running. If we would like to persist data across containers, we can either bind a local file/directory to our container or create and mount a named Docker volume. The added benefit of using a Docker volume is that it does not necessarily have to be a resource on the host file system, it can also be an external cloud storage service depending upon the driver.

A very practical example of using a postgres docker image with persistent data storage can be found here.

Summary

We looked at Docker basic concepts, created a few containers, ran some services and even persisted data across machine restarts! This was longer than 10 minutes but it should be enough to get going with Docker.