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 instructionsADDorCOPY: Copies files from the host into the imageRUN: Executes shell commands during the build processENV: Defines environment variablesCMD: 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:
- Docker creates the container process with Mount Namespace enabled
- Before
chrootexecutes, the container process can still view the host filesystem - After rootfs preparation but before
chroot, Docker mounts the specified host directory to the container's target directory - 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.