Getting to know Docker — the basics

Ali Nadir
13 min readAug 4, 2023

--

This guide serves as a starting point into the realm of Docker and related tech.

By the end of this article, we will have walked through some Docker fundamentals, introducing and later implementing some fun(ctional) real-world examples along the way, and of course, knowing what to tell your friend when they ask about Docker :)

As intimidating as it may seem, learning Docker is a rather straightforward journey with a simple learning curve. We will be exploring Docker step-by-step, and pretty soon also be deploying a sample (yet meaningful) application, all wrapped up nicely in containers — more on that later.

The article will serve as a reflection of how I like to use Docker in software development and deployment, all explained from the ground up.

Learning checklist:

  • About Docker
  • What’s a Container?
  • A bit more on Docker
  • Installation
  • What’s an Image?
  • What’s a Dockerfile?
  • Building the image
  • Running the container
  • DockerHub
  • Pushing to DockerHub
  • Pulling and running images
  • Next steps

About Docker

Docker is a popular container runtime that is used everywhere. So much that you are probably browsing an instance of Medium running inside a container right now!

As abstract as it sounds, this example could not be closer to the truth — Docker is here to stay and that too for good reason, although rival containerization tech is well on the rise.

What’s a container?

Containers are lightweight, efficient, and portable packages for your applications. Think of them as virtual machines on a diet, but with some distinct differences.

Like VMs, containers allow you to run applications in isolated environments. But VMs emulate an entire OS residing within your host OS. Containers also provide isolation, but they share the OS with your host.

The significant difference is that containers are much leaner and faster. While VMs come with their own operating systems, containers only include the necessary dependencies and libraries to run your application. This streamlined approach makes them quick to start up and efficient in resource usage.

Containers and virtual machines — weighed out. Courtesy www.weave.works

VMs are like hotel suites, complete with every (unnecessary) amenities.

Containers are more like a cozy shared Airbnb room, efficient and minimalistic. They share the OS but still maintain their space, making them easy to move, scale, and manage.

So containers provide a hassle-free way to package and run applications, making them a popular choice for developers looking for lightweight and scalable solutions.

A bit more on Docker

We know how shipments at sea work right?

Let us try to understand Docker in the same way!

People import and export items as cargo. Just like people share software with others. Makes sense.

Lets say the item is fruit— any kind of fruit. You can pack mangos in one crate, and apples in another. How do you tell which is which? Well, you’d slap on some labels of course., along with handling instructions.

Similarly in Docker, we want to package and box up our apps nicely. We’d also want to include some additional info to tell packaged apps apart, like what dependencies and libraries to include when running the app.

Quiz: How are application dependencies stored?

So far, we have our labeled, boxed up packages all set up. Let’s ship them.

For this, you’d place the cargo — now shipment — in a shipping container.

In Docker, we also load the packaged apps onto containers, but in the ones described a while back.

Every ship needs a crew and captain.

In Docker, the Docker Engine powers everything under the hood.

Now time to put the loaded containers on some ship so they can move around and be useful.

Similarly in Docker, we’ll run the containers on our host machine.

Multiple ships can exchange the same cargo, just like multiple machines can run the same container hassle free. Because of the miracle of containers.

So different ships — different computers — are able to carry the same cargo — run the same app — without trouble.

This way, Docker does lot of things right:

  • It solves the “runs on my machine” problem. Because apps only need their container to run, anywhere you want.
  • It allows scalability. App load can be distributed horizontally by just increasing the number of containers of that app.
  • It uses your resources the right way. While scaling your apps on one machine, each app container will only consume what it needs. There exists software to better manage this situation

Installation

Docker is opensource software and is free to download here:

Install Docker Engine | Docker Documentation

For Linux, simply follow the documentation for your installed distro to install the docker engine as a package. Other components, such as docker-compose, will also be installed.

For Windows and Mac, you’ll have to install Docker Desktop instead, which is a GUI application that comes bundles with everything from the docker engine to a complete management system.

Unfortunately, there isn’t a magical sudo apt install docker command out of the box so we need to add the Docker repository manually to our apt package manager. (as shown in the link above)

The following commands are intended for Linux/MacOS. For Windows, please use a Powershell or VS Code terminal.

Important: If using Windows, make sure you have WSL 2 installed for Docker Desktop to work. In Docker Desktop, go to settings and enable WSL support to avoid any unexpected crashes or performance issues.

Once installed, verify that Docker is successfully installed and running:

docker -v

For this tutorial, we don’t need to use docker-compose, so we’ll just leave it here :)

What’s an Image?

A Docker image is docker-speak for an application, i.e., software that you’ve built. It can be thought of as a package containing (no pun intended) an app.

Images vs Containers

Images are pre-defined templates consisting of all the application code, necessary libraries, dependencies, and other stuff required to run your application under one roof.

Containers are instances of these images which are actually running/serving the application code inside. Each container can either run independently or in conjunction with the other.

There are two ways to look at a single image:

  1. The image represents your whole application. This occurs when you need to package a standalone app, like a directory scanner or a hello-world program, which has everything it needs present inside one image. Yes, we are talking about monolithic programs.
  2. The image represents a part of your application. This is mostly the case where each image represents some important component of the app. In the case of a web application, one image defines the backend API, and another image defines the database, and another the frontend. So, each image is a service, and multiple services are integrated to form the desired web application.

Let's focus on the first scenario for now…

What’s a Dockerfile?

To make sense of a docker image, we need to know what it consists of. So how do we define a docker image itself?

This is where Dockerfiles come into play. Each Dockerfile is essentially a blueprint for the image it defines. Dockerfiles are used to define the behavior and configuration of an application when it runs inside a container.

Docker as a pipeline:

  1. Write your application code.
  2. Write the Dockerfile for your application.
  3. Build a corresponding image.
  4. Run the image as a container.

Build and Run are docker keywords

The docker pipeline — credits: computer.ru.ac.th

Example

Let’s use the following program which periodically tracks resource usage of a machine:

import psutil
import time
from tqdm import tqdm
import os

def get_cpu_usage():
return psutil.cpu_percent(interval=0.1)

def get_memory_usage():
memory = psutil.virtual_memory()
return memory.percent

def clear():
os.system("clear")

if __name__ == "__main__":
while True:
# Get CPU and memory usage
cpu_percent = get_cpu_usage()
memory_percent = get_memory_usage()

# Display CPU usage progress bar
with tqdm(total=100, desc="CPU Usage", ascii=True, ncols=100, bar_format='{l_bar}{bar}| {n:.1f}/{total}%', leave=True) as pbar_cpu:
pbar_cpu.update(int(cpu_percent))

# Display memory usage progress bar
with tqdm(total=100, desc="Memory Usage", ascii=True, ncols=100, bar_format='{l_bar}{bar}| {n:.1f}/{total}%', leave=True) as pbar_memory:
pbar_memory.update(int(memory_percent))

time.sleep(1)
clear()

It’s pretty obvious that the code repeatedly just clears the terminal and prints new info.

The code imports a bunch of libraries, some of which need to be manually fetched using pip. Let’s write a requirements.txt for it:

psutil
tqdm

Now let's write its equivalent Dockerfile:

The dockerfile is created in the same directory as your application code. Simply name it “Dockerfile”. If using VSCode, the Docker symbol will appear next to your file name

# Use the official Python image as the base image
FROM python:3.9-slim
  1. The first line is always a FROM statement, which specifies the base image — or environment — to run our app in. Since we have python code, we use the official Docker image for Python 3.9 available on DockerHub. Don’t worry, we’ll dive into what this is later on.
# Set the working directory inside the container
WORKDIR /app

2. Now that we have specified what environment to use, we need to specify a working directory inside this environment (container), where the code will live. To do this, use the WORKDIR keyword followed by the new directory name i.e. /app

3. We need to tell the Dockerfile what libraries to include, so we use the following statement:

# Copy the requirements.txt file to the container
COPY requirements.txt .

COPY is followed by a file/directory on host, followed by a file/directory on container . Here, we copy the requirements.txt file in our current directory into the /app directory of the container.

But we placed a dot (.) instead of writing /app. Why does this still work?

Docker resolves the dot to wherever our working directory is, so writing /app in its place does the same thing.

4. Okay, let’s now copy the program code itself into our work-directory:

COPY main.py .

5. Awesome! We’re almost done writing our Dockerfile. But we still need to tell Docker to install our libraries before we can run the app right? To do this, we now write:

RUN pip install -r requirements.txt

As you can tell, the RUN keyword executes whatever is written after it, which is just a bash command to install the contents of requirements.txt recursively through pip. (Confused about this pip jargon? Look here)

6. Alright, we’ve told Docker (in advance) whatever it needs to do in order to set up our application image. Now all that’s left is to tell it to execute our code:

CMD ["python","main.py"]

CMD is similar to RUN because it also executes something on the command line. But CMD is used to specify what gets executed at the very end of our Dockerfile. It defines the default command to run at the container startup. The space-separated command is represented now as a comma-separated list. So, the above line is resolved to the following:

$ python main.py 

The Dockerfile should now look something like this:

# Use the official Python image as the base image
FROM python:3.9-slim

# Set the working directory inside the container
WORKDIR /app

# Copy the requirements.txt file to the container
COPY requirements.txt .

# Install the required dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy the main.py file to the container
COPY main.py .

# Run the Python program when the container starts
CMD ["python", "main.py"]

Save it before proceeding to the next steps. The directory structure should be

docker-example/
├── main.py
├── Dockerfile
└── requirements.txt

Build the image.

Building an image is like compiling a Dockerfile to create an executable file (the image).

To build an image, we say:

docker build -t <my-image-name> <Dockerfile-path>

the -t flag is used to tag images, or simply put, give them a name.

The next argument is the location of the Dockerfile. Since it’s present in the current directory, just do . (dot)

Time to build our python code’s image:

# <image-name>  = monitor-img
docker build -t monitor-img .

We should begin to see the following output in terminal:

Cooking up our dockerfile
Image build completed
Image build completed

Verify whether the image has been created using the command:

docker images

The image was created succesfully!

With the docker images command, we can look at the size of all the images stored on disk, including the one we just created i.e. monitor-img.

Note: If your build fails while installing requirements.txt, restart the machine. It almost always fixes most of the problems in Docker.

Run the container.

We are almost done! Now let’s see how to run our image as a container.

docker run --name=<my-container-name> <my-image-name> <default-command>

We’ll run our image by the name monitor-container. Recall that since we already specified the default command in our Dockerfile, we can skip it here :)

The command now becomes…

docker run --name=monitor-container monitor-img

If you see something like the output below, congratulations! The container is now up and running successfully!

If we look closely, we can see that the container’s CPU starts at 0% then gradually ramps up to 80% while its memory remains at 27% usage. Interesting.

Congratulations for making it this far! You now know how to dockerize a simple yet meaningful application and serve it in a container 🚀

This concludes the core part of our Docker tutorial. Now let’s look at sharing our dockerized app on the internet .

DockerHub

Think GitHub but for Docker

That’s exactly what DockerHub is: a collection of repositories which host docker images, both private and public, of all sorts of projects.

Here you can find small projects (like ours) and massive ones too, like MySQL’s official DockerHub registry. Each repository may contain one or more images.

Images have versions…

Of-course, with time, developers will keep releasing updated docker images for their applications. So, it’s important to have versioning capabilities in Docker. That’s where image tags should be reintroduced as labels for some particular release version of an app.

Image tags

A newly released image will have a tag like my-image:0.1 for a release labelled 0.1, and so on.

If you go through DockerHub, you’ll almost certainly find images having the latest tag, which signifies the latest release of the application image.

More formally, we have tags as: <my-img-name>:<my-tag-name>

In our case, we shall give our image monitor-img the latest tag. So we write:

docker tag monitor-img monitor-img:v0.1

The syntax is: docker tag <source-img> <target-img-with-tag>

This will suffice for local versioning of our images.

Pushing to DockerHub

We can’t just keep our images to ourselves, right? In order to ship (no pun intended) our images, we need to push them to DockerHub first.

For this, we need to do the following:

  1. A DockerHub account. Sign up for a new account here.
  2. Create a new repository on DockerHub.
  • Once logged in, click Create Repository
Click Create Repository
  • Name the repo after your image and make it public. You can leave the description empty for now.

You can now view all the details of your repo. It will also then appear in your dashboard.

Repo details — tags, collaborators, and more

3. Logging into DockerHub from host machine. Once our registry is setup online, return to your terminal and type docker login and enter your account info. Pretty simple.

logging into DockerHub from terminal

4. Tagging the image. We already tagged the image, but locally. To make sure our image goes to our desired repository, we must re-tag with necessary details included.

- We need to do this: docker tag <img-name>:<tag> <user-name/repository-name>:<tag>

- In our case, this becomes docker tag monitor-img:latest [USERNAME]/[REPONAME]:latest . Remember to replace [USERNAME] and [REPONAME] with your DockerHub username and repository name you just created.

In my case, my username is alinadirand repository name is monitor-img

tagging the image to prepare for push

5. Pushing newly tagged image. Now all that’s left is to actually push the image to our repo. So we’ll dodocker push [USERNAME]/[REPONAME]:latest

Once sucessfully pushed, we should get the following as output:

Console output after pushing an image successfully

Double-checking our DockerHub repo…

DockerHub repo — after push

Ok, we are just about done! But there’s one thing left: how do we use these pushed images?

Pulling and running images

Just like Docker lets us push images to upload them, Docker can pull the images for us too!

To do this, simply write docker pull <img-name>:<tag>. For our example, it becomes docker pull [USERNAME]/[REPONAME]:latest

This will download the image locally to our machine. To execute it, do: docker run [USERNAME]/[REPONAME]:latest

Container output

Lo and behold. The container behaves the exact same as it did before DockerHub :)

Here’s a secret: Docker automatically pulls the image for us if it can’t find the image when we use the docker run command!

Next steps

This wraps up our somewhat exhaustive Docker tutorial to get you started with containerization! Hope it was worth the read :)

Some important things to explore next:

  • Data persistence in Docker — Docker volumes
  • How containers talk to each other — Docker networking
  • How to deal with multiple containers — Docker-compose
  • Additional Dockerfile commands — working with ports and environment variables
  • Running web apps on Docker

--

--

Ali Nadir

Engineering @ Securiti.ai A software developer and DevOps enthusiast who writes here and there as well.