Containerization with Docker
By: Peter Aleksander Bizjak
Tags: Deployment, Containerization, DevOps, Docker, Software Development

Knowing how to deploy a containerized application in an arbitrary environment has become an inevitable requirement for software developers globally. Although Docker is not the first containerization technology, nor the only one out there, it’s certainly the most adopted one.

In contrary to the orthodox techniques, such as running backend services either on bare metal or inside a virtual machine, containerization offers a lighter, pliable, and easily maintainable alternative.

Virtual machine manager (hypervisor) grabs physical resources (CPU, RAM, storage, NIC) and slices them into virtual versions of themselves (virtual CPU, virtual RAM, virtual storage, virtual NIC). Virtual machines are built out of these virtual resources, running operating systems as if they were installed on a physical, not virtual, infrastructure. On contrary, a container engine slices the operating system resources (namespace, network stack, storage stack), with every container having its own process ID (PID). In short, containers virtualize the operating system infrastructure, while hypervisors virtualize physical infrastructure.

How Does Docker Work?

Let’s begin by defining what a container is:

Isolated area of an OS with resource usage limits applied

To build them, we leverage low-level kernel stuff like namespaces and control groups

and Linux namespace : partitions kernel resources, so that a set of processes see a set of resources, while another set of processes would see a different set of resources

: partitions kernel resources, so that a set of processes see a set of resources, while another set of processes would see a different set of resources Linux control groups (cgroups): a feature that limits, accounts for, and isolates resource usage (CPU, memory, disk, I/O, network, …) for a collection of processes

Now let’s explain what a Docker engine is:

Docker Engine is a modular software application composed of the daemon , containerd , and OCI

, , and Daemon : listens to Docker API requests and manages Docker objects such as images, containers, networks, and volumes

: listens to Docker API requests and manages Docker objects such as images, containers, networks, and volumes Containerd : provides a client layer of types that platforms can build on top of without having to directly communicate with the kernel

: provides a client layer of types that platforms can build on top of without having to directly communicate with the kernel OCI (Open Container Initiative): set of open standards for operating-system-level virtualization, especially Linux containers

In general, a typical workflow for interacting with Docker is the following. Using a CLI or GUI, we create a new container. The Docker client takes the command and makes an API request to the container’s endpoint in the engine, which then does the “dirty work” of communicating with a kernel, spawning a new container.

History of Linux Containers

Linux containers have been around for a longer time than Windows containers, but Docker is providing affinity of these distinct standards. Namespaces and control groups are Linux kernel primitives, and for the longest time, there was really no equivalent on Windows.

In short, a container is an organized collection of namespaces, which include PIDs, network, filesystem mount, inter-processor communications, UTS, etc. Each container has its own isolated process tree, with its own PID 1, thereby containers have no idea that other containers exit, meaning they are perfectly isolated from one another.

Linux control groups (or cgroups, Windows equivalent is called Job Objects) make sure that a single container wouldn’t eat too much OS resources from other containers.

As mentioned above, the Docker engine on one side exposes API endpoints to the user, and on the other side interfaces with the kernel, and pops out containers.

Working with images

Now we know what a container is, but what about a Docker image? Docker image is a read-only template for creating containers, hosting the code, and supporting components to run an application.

So, an image is a build-time construct and a container is a run-time construct.

Image contains OS files and objects, application files, and a JSON manifest. They are often stored in registries, either in the cloud or on the premise. We pull them to our host using the docker image pull command.

Every image is composed of layers. Image is not a uniform file, but rather a collection of layers stacked on top of each other, loosely connected by a manifest. These layers are actually a bunch of files and objects unaware of each other.

The pull command is a 2-step process:

Get the manifest of the image, in the beginning, the client first looks for a so-called “fat manifest”, a manifest of manifests, which points it to the correct image manifest for a given architecture

of the image, in the beginning, the client first looks for a so-called “fat manifest”, a manifest of manifests, which points it to the correct image manifest for a given architecture Pull described layers. An important thing to realize here is that images can use the same layers, and Docker will not pull a layer again, given that it already exists on the system for some other image.

The storage driver that handles all this layering varies from platform to platform. So how does the layering work?

At the bottom is the base layer , which contains all the files and objects that build a basic operating system. From a kernel perspective, we need to ensure Linux on Linux and Windows on Windows. For example, we can have an Ubuntu base layer running on a Fedora host, and it will share the host kernel, but what makes this image an Ubuntu image is Ubuntu-specific filesystem arrangements, tools, and utilities…

, which contains all the files and objects that build a basic operating system. From a kernel perspective, we need to ensure Linux on Linux and Windows on Windows. For example, we can have an Ubuntu base layer running on a Fedora host, and it will share the host kernel, but what makes this image an Ubuntu image is Ubuntu-specific filesystem arrangements, tools, and utilities… Layers on top are composed of application code

Each of the layers is represented in the file system under the given Docker Root directory. On Linux and macOS, this should be /var/lib/docker. If we move into /var/lib/docker/<storage-driver-name>, we can actually see all the downloaded images!

Registries: where images are kept

Docker defaults to the Docker Hub registry, but others can be used as well, of course, if the image has to be pulled from a private cloud, such options must exist. Docker has its own on-premises registry technology called Docker Trusted Registry (DTR).

Docker Hub has a notion of official and unofficial images. You can recognize official images simply by looking at their name. Unofficial images live in user registries.

However, things are a bit misleading. Just because an image is tagged as the latest, is does not mean it in fact is the latest version: the person uploading the new version of the image has to manually tag it as such! You can pull images by their version.

Containerizing Your Application

So, how do we containerize an application, this is, create an image and later run it inside a container?

The process starts, unsurprisingly, with the application code itself. Then, we create a Dockerfile, a simple text file that describes the inner workings of the application, how it’s built into a Docker image.

Best practices linked to containerization

In the world of Docker, a container is the smallest unit. Usually, in production, an application is composed of multiple containers.

Containers run instances of images. Images are read-only, but a container, in reality, is just a thin writable layer on top of image layers (this is achieved through union mounts).

The image-container relationship is one-to-many, meaning that a single image can be used to spawn multiple containers. When a container wants to write a change, it does so in its own writable layer, never changing the contents of a shared image.

Because the container requires an operating system kernel (since it lacks its own), you can only run Linux containers on Linux hosts and Windows containers on Windows hosts.

Containers lead to the idea of microservices, basically breaking down a monolithic service into a set of containers, each running its own component. These containers communicate with each other through APIs.

Key: keep containers as small as possible. One of the biggest misconceptions about microservices and containers is that an existing application has to be broken down into smaller pieces immediately. This is not the case. Docker (as a platform) offers ways of gradually transforming big monolithic applications into smaller modules, by containerizing the app itself first, giving you time to slowly refactor and change your architecture.

Cheat Sheet

Below is a gist explaining basic Docker commands you’ll most likely run into on your daily basis. It’s important to be familiar with them. The rest can be read in the superb Docker documentation.