Docker Container Fundamentals: Building and Managing Containerized Applications

Building a Containerized Python Web Application

This guide demonstrates deploying a Python-based Web application using Docker. The application code (app.py) uses the Flask framework:

from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def greeting():
    html = "<h3>Hello {name}!</h3>" \
           "<b>Hostname:</b> {hostname}<br/>"
    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

This Flask server responds with a greeting based on the NAME environment variable, displaying the current hostname. The dependencies are specified in requirements.txt:

Flask

Creating a Dockerfile

Docker simplifies the image creation process through Dockerfile, which defines the build steps:

FROM python:2.7-slim

WORKDIR /app

COPY . /app

RUN pip install --trusted-host pypi.python.org -r requirements.txt

EXPOSE 80

ENV NAME World

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

Each instruction in a Dockerfile represents a standard primitive processed sequentially. The FROM directive specifies the base image (python:2.7-slim), eliminating the need to manually install Python. Without this, the base image setup would require verbose commands like:

FROM ubuntu:latest
RUN apt-get update -y
RUN apt-get install -y python-pip python-dev build-essential

Key directives include:

  • WORKDIR: Sets the current working directory for subsequent instructions
  • ADD or COPY: Copies files from the host into the image
  • RUN: Executes shell commands during the build process
  • ENV: Defines environment variables
  • CMD: Specifies the default command for the container's main process

Understanding CMD and ENTRYPOINT

Docker combines ENTRYPOINT and CMD to form the container's startup command. If ENTRYPOINT is not explicitly defined, Docker defaults to /bin/sh -c. Thus, in our example, the actual running process becomes /bin/sh -c "python app.py", where CMD arguments serve as parameters to the default ENTRYPOINT.

Building and Running the Image

With the project structure:

$ ls
Dockerfile  app.py   requirements.txt

Build the image:

$ docker build -t webapp .

The -t flag assigns a tag to the image. Docker reads the Dockerfile, executes each instruction in sequance, and generates intermediate image layers. Even instructions like ENV that don't visibly modify files create corresponding empty layers.

Verify the build:

$ docker image ls

REPOSITORY            TAG                 IMAGE ID
webapp             latest              653287cdf998

Run the container:

$ docker run -p 4000:80 webapp

The -p 4000:80 flag maps the container's port 80 to the host's port 4000. Since the Dockerfile already specifies CMD, no additional command arguments are needed. Without that, you would run:

$ docker run -p 4000:80 webapp python app.py

Check running containers:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED
4ddf4638572d        webapp          "python app.py"     10 seconds ago

Access the application:

$ curl http://localhost:4000
<h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>

Publishing Images to Docker Hub

Register a Docker Hub account, then authenticate:

$ docker login

Tag the image with the full repository name:

$ docker tag webapp username/webapp:v1

Replace username with your actual Docker Hub username. Push to the registry:

$ docker push username/webapp:v1

Committing Changes from Running Containers

Use docker exec to enter a running container and make modifications:

$ docker exec -it 4ddf4638572d /bin/sh
/app# touch marker.txt
/app# exit

Commit the changes to create a new image:

$ docker commit 4ddf4638572d username/webapp:v2

How docker exec Works

The docker exec command leverages Linux Namespace isolation to enter containers. Each running container's process has associated Namespace files under /proc/[PID]/ns:

$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d
25686

$ ls -l /proc/25686/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]

The setns() system call allows a process to join an existing Namespace. A simple implementation:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
    int fd;
    
    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]);
    errExit("execvp");
}

After calling setns(), the new process shares the same Network Namespace, observing only the network devices visible inside the target container.

Verifying Namespace sharing on the host:

$ ps aux | grep /bin/bash
root     28499  0.0  0.0 19944  3612 pts/0    S    14:15   0:00 /bin/bash

$ ls -l /proc/28499/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281]

$ ls -l /proc/25686/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]

Both processes reference the identical Network Namespace file, confirming they share the same network view.

Joining Another Container's Network Namespace

Docker provides the --net container:ID flag to share Network Namespace between containers:

$ docker run -it --net container:4ddf4638572d busybox ifconfig

This new container shares the network configuration of the specified container. Using --net=host disables Network Namespace isolation entirely, allowing the container to share the host's network stack directly.

Understanding the Union File System

The docker commit operation packages the container's writable layer with the underlying read-only layers into a new image. Read-only layers remain shared across containers, consuming no additional disk space.

The Copy-on-Write mechanism ensures that modifications to the base image layers are first copied to the writable layer before any changes occur.

The Init layer prevents Docker's internal modifications (such as changes to /etc/hosts) from being captured during commit operations.

Docker Volume Mechanism

Containers using rootfs and Mount Namespace create fully isolated filesystems, necessitating Volume for host-container file sharing:

Use cases:

  • Making container-generated files accessible from the host
  • Injecting host files or directories into containers

Two Volume declaration styles:

$ docker run -v /data ...
$ docker run -v /opt:/data ...

The first form creates an anonymous volume (a temporary directory managed by Docker), while the second explicitly maps the host's /opt directory to the container's /data.

Bind Mount Implementation

Docker employs Linux bind mount to connect host directories to containers. The process:

  1. Docker creates the container process with Mount Namespace enabled
  2. Before chroot executes, the container process can still view the host filesystem
  3. After rootfs preparation but before chroot, Docker mounts the specified host directory to the container's target directory
  4. This mount operation remains confined within the container's Mount Namespace

The bind mount mechanism replaces directory entry pointers, redirecting operations from the mount point to the actual target directory while preserving the original content when unmounted.

Volume Behavior During Commit

Volume contents are not encluded in docker commit because the commit operation executes on the host, where the bind mount remains invisible due to Mount Namespace isolation. However, the mount point directory itself appears in the new image as an empty directory.

Practical Volume Verification

Create a container with a volume:

$ docker run -d -v /test webapp
cf53b766fa6f

List volumes to find the generated volume ID:

$ docker volume ls
DRIVER              VOLUME NAME
local               cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d

Locate the volume on the host:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/

Add a file from within the container:

$ docker exec -it cf53b766fa6f /bin/sh
/test# touch notes.txt

Verify the file appears on the host:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
notes.txt

Check the container's writable layer (empty directory):

$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test

This confirms that Volume modifications bypass the writable layer entirely.

Container Architecture Overview

A Docker container comprises several integrated components:

  • Isolation layer: Linux Namespaces and Cgroups provide process isolation
  • Root filesystem: Multiple stacked read-only layers from the image
  • Init layer: Contains Docker's temporary modifications (e.g., /etc/hosts)
  • Writable layer: Implements Copy-on-Write for file modificatoins
  • Volume mounts: Bind-mounted directories for persistent or shared data

This layered architecture delivers full process isolation while maintaining efficient resource utilization through shared read-only layers.

Tags: docker containers dockerfile Linux Namespaces volume

Posted on Fri, 23 Oct 2026 16:15:13 +0000 by citytours