Skip to content

Lab 1: Working with containers

In this lab1, you will learn how to work with containers. You will explore different ways to run containers and how to connect containers together. In this lab we will use Docker as container runtime.

Recall: a container standalone, executable packages that include everything needed to run an application: code, runtime, libraries, and configuration files.

Prerequisites

Before starting the Lab, you have to install docker.

Warning

It is recommended to install docker in rootless-mode

Finding Container Images

You can find images on:

  • Docker Hub: https://hub.docker.com/

  • GitHub Container Registry: https://ghcr.io/

  • AWS ECR, Google Container Registry, Azure Container Registry

Part 1: Run Docker containers

There are different ways to use containers. These include:

  1. To run a single task: This could be a shell script or a custom app.
  2. Interactively: This connects you to the container similar to the way you SSH into a remote server.
  3. In the background: For long-running services like websites and databases.

In this section you will try each of those options and see how Docker manages the workload.

Run a single task in an Alpine Linux container

In this step we're going to start a new container and tell it to run the hostname command. The container will start, execute the hostname command, then exit.

  1. Run the following command in your Linux console.

    docker container run alpine hostname
    

    The output below shows that the alpine:latest image could not be found locally. When this happens, Docker automatically pulls it from Docker Hub.

    After the image is pulled, the container's hostname is displayed (888e89a3b36b in the example below).

    Unable to find image 'alpine:latest' locally
    latest: Pulling from library/alpine
    88286f41530e: Pull complete
    Digest: sha256:f006ecbb824d87947d0b51ab8488634bf69fe4094959d935c0c103f4820a417d
    Status: Downloaded newer image for alpine:latest
    888e89a3b36b
    
  2. Docker keeps a container running as long as the process it started inside the container is still running. In this case the hostname process exits as soon as the output is written. This means the container stops. However, Docker doesn't delete resources by default, so the container still exists in the Exited state.

    List all containers.

    docker container ls --all
    

    Notice that your Alpine Linux container is in the Exited state.

    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS            PORTS               NAMES
    888e89a3b36b        alpine              "hostname"          50 seconds ago      Exited (0) 49 seconds ago                       awesome_elion
    

    Note: The container ID is the hostname that the container displayed. In the example above it's 888e89a3b36b.

Containers which do one task and then exit can be very useful. You could build a Docker image that executes a script to configure something. Anyone can execute that task just by running the container - they don't need the actual scripts or configuration information.

Run an interactive Ubuntu container

You can run a container based on a different version of Linux than is running on your Docker host.

In the next example, we are going to run an Ubuntu Linux container

  1. Run a Docker container and access its shell.

    docker container run --interactive --tty --rm ubuntu bash
    

    In this example, we're giving Docker three parameters:

    • --interactive says you want an interactive session.
    • --tty allocates a pseudo-tty.
    • --rm tells Docker to go ahead and remove the container when it's done executing.

    The first two parameters allow you to interact with the Docker container.

    We're also telling the container to run bash as its main process (PID 1).

    When the container starts you will drop into the bash shell with the default prompt root@<container id>:/#. Docker has attached to the shell in the container, relaying input and output between your local session and the shell session in the container.

  2. Run the following commands in the container.

    ls / will list the contents of the root directory in the container, ps aux will show running processes in the container, cat /etc/issue will show which Linux distro the container is running.

    ls /
    
    ps aux
    
    cat /etc/issue
    
  3. Type exit to leave the shell session. This will terminate the bash process, causing the container to exit.

    exit
    

    Note: As we used the --rm flag when we started the container, Docker removed the container when it stopped. This means if you run another docker container ls --all you won't see the Ubuntu container.

  4. Check the version of the host OS.

    cat /etc/issue
    
    or
    cat /etc/os-release
    

The distribution of Linux inside the container does not need to match the distribution of Linux running on the Docker host.

However, Linux containers require the Docker host to be running a Linux kernel. For example, Linux containers cannot run directly on Windows Docker hosts. The same is true of Windows containers - they need to run on a Docker host with a Windows kernel.

Interactive containers are useful when you are putting together your own image. You can run a container and verify all the steps you need to deploy your app, and capture them in a Dockerfile.

You can commit a container to make an image from it - but you should avoid that wherever possible. It's much better to use a repeatable Dockerfile to build your image. You will see that shortly.

Run a background MySQL container

Background containers are how you will run most applications. Here's a simple example using MySQL.

  1. Run a new MySQL container with the following command.

    docker container run \
    --detach \
    --name mydb \
    -e MYSQL_ROOT_PASSWORD=my-secret-pw \
    mysql:latest
    
    • --detach will run the container in the background.
    • --name will name it mydb.
    • -e will use an environment variable to specify the root password (NOTE: This should never be done in production).

    As the MySQL image was not available locally, Docker automatically pulled it from Docker Hub.

    Unable to find image 'mysql:latest' locallylatest: Pulling from library/mysql
    aa18ad1a0d33: Pull complete
    fdb8d83dece3: Pull complete
    75b6ce7b50d3: Pull complete
    ed1d0a3a64e4: Pull complete
    8eb36a82c85b: Pull complete
    41be6f1a1c40: Pull complete
    0e1b414eac71: Pull complete
    914c28654a91: Pull complete
    587693eb988c: Pull complete
    b183c3585729: Pull complete
    315e21657aa4: Pull complete
    Digest: sha256:0dc3dacb751ef46a6647234abdec2d47400f0dfbe77ab490b02bffdae57846ed
    Status: Downloaded newer image for mysql:latest
    41d6157c9f7d1529a6c922acb8167ca66f167119df0fe3d86964db6c0d7ba4e0
    

    As long as the MySQL process is running, Docker will keep the container running in the background.

    Tip

    Can you find out where does the mysql image come from?

  2. List the running containers.

    docker container ls
    

    Notice your container is running.

    CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS            NAMES
    3f4e8da0caf7        mysql:latest        "docker-entrypoint..."   52 seconds ago      Up 51 seconds       3306/tcp            mydb
    
  3. You can check what's happening in your containers by using a couple of built-in Docker commands: docker container logs and docker container top.

    docker container logs mydb
    

    This shows the logs from the MySQL Docker container.

      <output truncated>
      2017-09-29T16:02:58.605004Z 0 [Note] Executing 'SELECT * FROM INFORMATION_SCHEMA.TABLES;' to get a list of tables using the deprecated partition engine. You may use the startup option '--disable-partition-engine-check' to skip this check.
      2017-09-29T16:02:58.605026Z 0 [Note] Beginning of list of non-natively partitioned tables
      2017-09-29T16:02:58.616575Z 0 [Note] End of list of non-natively partitioned tables
    

    Let's look at the processes running inside the container.

      docker container top mydb
    

    You should see the MySQL daemon (mysqld) is running in the container.

    PID                 USER                TIME                COMMAND
    2876                999                 0:00                mysqld
    

    Although MySQL is running, it is isolated within the container because no network ports have been published to the host. Network traffic cannot reach containers from the host unless ports are explicitly published.

  4. List the MySQL version using docker container exec.

    docker container exec allows you to run a command inside a container. In this example, we will use docker container exec to run the command-line equivalent of mysql --user=root --password=$MYSQL_ROOT_PASSWORD --version inside our MySQL container.

    docker exec -it mydb \
    mysql --user=root --password=$MYSQL_ROOT_PASSWORD --version
    

    You will see the MySQL version number, as well as a handy warning.

    mysql: [Warning] Using a password on the command line interface can be insecure.
    mysql  Ver 14.14 Distrib 5.7.19, for Linux (x86_64) using  EditLine wrapper
    
  5. You can also use docker container exec to connect to a new shell process inside an already-running container. Executing the command below will give you an interactive shell (sh) inside your MySQL container.

    docker exec -it mydb sh
    

    Notice that your shell prompt has changed. This is because your shell is now connected to the sh process running inside of your container.

  6. Let's check the version number by running the same command again, only this time from within the new shell session in the container.

    mysql --user=root --password=$MYSQL_ROOT_PASSWORD --version
    

    Notice the output is the same as before.

  7. Type exit to leave the interactive shell session.

    exit
    

Part 2: Create containers

Creating Container Images

In this section, you will learn how to create your own container images. A Docker image is a lightweight, standalone, and executable software package that includes everything needed to run a piece of software, including the code, runtime, libraries, environment variables, and configuration files. Images are built using special declarative files, the Dockerfiles.

Step 1: Create a Dockerfile

A Dockerfile is a text document that contains all the commands to assemble an image. We will now see how to create a python webserver.

To begin with, let's create an hello world server (save it as app.py):

# app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)

and write a requirements.txt to install the required dependencies:

# requirements.txt
flask
Finally, you will be able to create a Dockerfile as such (create a file called Dockerfile with the following content):

# Use an official Python image
FROM python:3.12

# Set the working directory in the container
WORKDIR /app

# Copy the current directory contents into the container at /app
COPY . /app

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

# Run app.py when the container launches
CMD ["python", "app.py"]

Info

There are community-enstablished best practices on how to create Dockerfiles. Among them, multi-stage builds reduce the size of a container.

Step 2: Build the Docker Image

To build the Docker image from the Dockerfile, use the docker build command. Run the following command in the directory containing your Dockerfile:

docker build -t python-app .

This command builds the image and tags it as python-app.

Note

You can use tags to publish different versions of the same container (e.g., one for x86 and one for arm)

Step 3: Run the Docker Container

Once the image is built, you can run a container using the docker run command:

docker run -p 9000:5000 python-app
The app will be isolated by default from the host. So, we need to inform docker to map the port 5000 in the container to port 9000 on the host.

Now you can access the app at:

http://0.0.0.0:9000/
and you will see the web server replying with Hello, World!

Note

The port mapping mechanism, is different from the network namespace mechanism. In this case, a port on your host is mapped to the container port via iptables rules.

You can export the content of a container either by using the export command or with the save command .

Question 1

Using the docker export command, save the python-app container as a tar archive. Then, do the same using the docker save command. Finally extract the contents and analyze them What is the structure of the two archives?

Which information about the container can you find?

Can you explain in you own words the difference between the two commands?

Warning

The container must be running to be able to export it.

Cleanup

docker rm python-app --force

Part 3: Container Networks

Containers need a network namespace to communicate with other containers and with the Internet. In this part of the Lab, we will explore how containers are linked together or isolated.

Default Docker networks and their difference

By default Docker comes equipped with three different networks. Let's look at them:

docker network ls 
The output should be similar to:

NETWORK ID     NAME                               DRIVER    SCOPE
a19bac0e4c32   bridge                             bridge    local
9e1ac4485c0a   host                               host      local
bcd75e804e3c   none                               null      local
  • Bridge Network: The bridge network is the default network for containers. It allows containers connected to it to communicate with each other, while also providing isolation from containers not connected to the bridge. Containers on the bridge network can access external networks, including the internet, through the host's network interface. Bridge is also the name of the driver used for this network type.

  • Host Network: As the name suggests, the host network is the same network as the host OS. Containers using the host network share the host's network stack and can directly use the host's IP address. This means that containers can access all the ports that are open in the host.

  • None Network: The none network disables networking for the container.

Info

Further readings on Docker networking: IPvlan, Macvlan, Overlay.

Bonus Question

What is the IP address of a container attached to a Macvlan network? And who assigns it? Run such a container and inspect its network.

Warning

macvlan requires docker in root mode. Use docker context to switch to the root one.

Deploying Containers in Different Networks

In this part, you will deploy two containers using different Docker networks. This tutorial will give you practical undersanding on how such networks work and how containers communicate in Docker.

Note

Whenever a new container is created, docker will create a new (network) namespace for the container.

Bridge Network

  1. Create a new container connected to the bridge network.

    docker container run -d --name app1 --network bridge nginx
    

    This command runs an Nginx container in the background using the default bridge network.

  2. Create another container connected to the bridge network.

    docker container run -d --name app2 --network bridge nginx
    

    Both containers are now connected to the bridge network and should be able to communicate with each other.

  3. Verify the containers are running and connected to the bridge network.

    docker container ls # Show containers that are running
    docker network inspect bridge # Show content of the network
    

    The result of these commands should look like:

    CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS          PORTS     NAMES
    0b5a9d812179   nginx     "/docker-entrypoint.…"   16 seconds ago   Up 15 seconds   80/tcp    app2
    28755a17cd52   nginx     "/docker-entrypoint.…"   25 seconds ago   Up 20 seconds   80/tcp    app1
    
    [
        {
            "Name": "bridge",
            "Id": "ba3606add1244c0fd7ba5189f6fceb7b7c52acee5a8e9e177537cc417b11e545",
            "Created": "2025-02-13T11:47:39.796570164Z",
            "Scope": "local",
            "Driver": "bridge",
            "EnableIPv6": false,
            "IPAM": {
                "Driver": "default",
                "Options": null,
                "Config": [
                    {
                        "Subnet": "172.17.0.0/16",
                        "Gateway": "172.17.0.1"
                    }
                ]
            },
            "Internal": false,
            "Attachable": false,
            "Ingress": false,
            "ConfigFrom": {
                "Network": ""
            },
            "ConfigOnly": false,
            "Containers": {
                "0b5a9d812179115094030c3bc6307c942f92de483bf345fe3f9ff035ee0e9127": {
                    "Name": "app2",
                    "EndpointID": "1e2211dcc219da9cf7f68514d578fbd30acc35f438b71a7d83c2b250e7d0045f",
                    "MacAddress": "02:42:ac:11:00:03",
                    "IPv4Address": "172.17.0.3/16",
                    "IPv6Address": ""
                },
                "28755a17cd52af14d84035c24fb238ee57334c7f801da0d972f1402fb95fbe98": {
                    "Name": "app1",
                    "EndpointID": "1a598cb3746789fe8717845e57d6cf774c33508b4fbc284c10fef43a9857598f",
                    "MacAddress": "02:42:ac:11:00:02",
                    "IPv4Address": "172.17.0.2/16",
                    "IPv6Address": ""
                }
            },
            "Options": {
                "com.docker.network.bridge.default_bridge": "true",
                "com.docker.network.bridge.enable_icc": "true",
                "com.docker.network.bridge.enable_ip_masquerade": "true",
                "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
                "com.docker.network.bridge.name": "docker0",
                "com.docker.network.driver.mtu": "65535"
            },
            "Labels": {}
        }
    ]
    

These commands show that the two containers are in the same network and they have IPs: 172.17.0.2/16 and 172.17.0.3/16.

Question 2

What is the gateway address of this bridge network?

Can you see the network with the ifconfig or ip a command ?

Warning

If you are using MacOS or Windows, you have to ssh into the Docker VM first.

Host Network

  1. Create a new container connected to the host network.

    docker container run -d --name host-container1 --network host nginx
    

    This command runs an Nginx container in the background using the host network.

  2. Create another container connected to the host network.

    docker container run -d --name host-container2 --network host alpine sleep infinite
    

    Both containers are now connected to the host network

    Question 3

    What is the IP address of the two containers? Can you see which ports are open on the container? What happens if you run the command: docker container run -d --name host-container3 --network host nginx?

None Network

  1. Create a new container with no network.

    docker container run -d --name none-container1 --network none nginx
    

    This command runs an Nginx container in the background with no network.

    Question 4

    Can you connect to the internet using this network?

  2. Create another container with no network.

    docker container run -d --name none-container2 --network none nginx
    

    Both containers are now isolated with no network connectivity.

  3. Verify the containers are running and have no network.

    docker container ls
    docker network inspect none
    

Deploying containers in the same namespace

There are cases where you want to deploy containers in the same namespaces and let containers communicate with each other using only localhost. Such case in very common e.g. in Kuberntes. This section of the lab will teach you how to achieve that in docker.

Step 1: Create the first Container

Start a container normally. For example:

docker run -d --name first alpine sleep 300000

Step 2: Run the second container

Use the --network container:<container_name flag to attach the second container to the network namespace of the primary container:

docker run -d --name second --network container:first alpine sleep 300000

Step 3: Verify connectivity

First we check that containers are running:

docker ps 
CONTAINER ID   IMAGE     COMMAND          CREATED          STATUS          PORTS     NAMES
88fef5507abf   alpine    "sleep 300000"   6 seconds ago    Up 2 seconds              second
20d60f685794   alpine    "sleep 300000"   48 seconds ago   Up 41 seconds             first

In this configuration, the second container shares the network stack of the first container. Both containers now share the same IP address and can communicate with each other via localhost.

Check the ip address of the containers by running:

docker exec -it first ifconfig
docker exec -it second ifconfig

Now it is time to check connectivity between the containers. On two separate terminals run:

docker exec -it first nc -nlp 1234
and
docker exec -it second nc localhost 1234

Each character written in the second container should now appear in the first and they should be able to communicate using the localhost.

Questions 5-8

  • What is the hostname of the first and second container?
  • What happens to the second container if you remove the first container?
  • In which case would you deploy two containers in the same network namespace?
  • What happens if firstand second listen to the same port?

Step 4: Cleanup

docker stop first
docker stop second
docker rm first
docker rm second