Wednesday, June 03, 2026

DOCKER AND KUBERNETES: A GUIDE FROM FUNDAMENTALS TO ADVANCED CONCEPTS





INTRODUCTION: THE PROBLEM THAT CONTAINERIZATION SOLVES

Before we dive into Docker and Kubernetes, we need to understand the problem these technologies solve. Imagine you are a developer who has built an application on your laptop. The application works perfectly in your development environment. You have installed specific versions of Python, Node.js, various libraries, and configured environment variables exactly the way your application needs them. Now comes the moment to deploy this application to a production server or share it with your team members.

This is where the nightmare often begins. Your colleague tries to run your application on their machine, but it fails with cryptic error messages. The production server runs a different operating system version. The library versions don't match. Environment variables are configured differently. You find yourself saying the infamous phrase that every developer has heard or said: "But it works on my machine!"

This problem has plagued software development for decades. The traditional solution involved writing extensive documentation about dependencies, creating complex installation scripts, and spending countless hours debugging environment-specific issues. Docker emerged as a revolutionary solution to this problem by introducing the concept of containerization.

PART ONE: UNDERSTANDING DOCKER

WHAT IS DOCKER AND WHY DOES IT MATTER

Docker is a platform that allows you to package your application along with all its dependencies, libraries, and configuration files into a standardized unit called a container. Think of a container as a lightweight, portable box that contains everything your application needs to run. This box can be moved from your laptop to a colleague's computer, to a testing server, or to production, and it will work exactly the same way everywhere.

The key insight behind Docker is that instead of shipping just your code and asking everyone to set up the same environment, you ship the entire environment along with your code. This eliminates the "works on my machine" problem because every machine is now running the same environment.

Docker is different from virtual machines, although they might seem similar at first glance. A virtual machine includes an entire operating system, which makes it heavy and slow to start. A Docker container, on the other hand, shares the host operating system's kernel and only packages the application and its dependencies. This makes containers much lighter and faster than virtual machines.

THE FUNDAMENTAL BUILDING BLOCKS OF DOCKER

To understand Docker, you need to grasp three fundamental concepts: images, containers, and the Docker engine.

A Docker image is like a blueprint or a template. It contains the application code, runtime environment, libraries, dependencies, and configuration files needed to run an application. Images are read-only and immutable, meaning once created, they don't change. You can think of an image as a recipe that describes exactly how to create a running instance of your application.

A Docker container is a running instance of an image. When you execute an image, Docker creates a container from it. The container is where your application actually runs. You can create multiple containers from the same image, and each container runs independently. If an image is a recipe, then a container is the actual dish you've cooked from that recipe.

The Docker engine is the software that runs on your machine and manages images and containers. It handles the creation, execution, and monitoring of containers. The Docker engine consists of a server (a daemon process), a REST API that programs can use to talk to the daemon, and a command-line interface client.

CREATING YOUR FIRST DOCKER IMAGE WITH A DOCKERFILE

The most common way to create a Docker image is by writing a Dockerfile. A Dockerfile is a text file that contains a series of instructions telling Docker how to build your image. Let's create a simple example to understand how this works.

Imagine we have a simple Python web application that displays "Hello from Docker!" when you visit it. Here is what our Python application code might look like:

# app.py - A simple Flask web application
from flask import Flask

# Create a Flask application instance
app = Flask(__name__)

@app.route('/')
def hello():
    """
    This function handles requests to the root URL.
    It returns a simple greeting message.
    """
    return 'Hello from Docker! This application is running inside a container.'

if __name__ == '__main__':
    # Run the application on all available network interfaces
    # This allows the application to be accessible from outside the container
    app.run(host='0.0.0.0', port=5000)

Now we need to create a Dockerfile that tells Docker how to package this application. Here is what the Dockerfile would look like:

# Dockerfile - Instructions for building our Docker image

# Start from an official Python runtime as the base image
# This gives us a Linux environment with Python already installed
FROM python:3.9-slim

# Set the working directory inside the container
# All subsequent commands will be executed in this directory
WORKDIR /app

# Copy the requirements file into the container
# This file lists all Python packages our application needs
COPY requirements.txt .

# Install the Python dependencies
# The --no-cache-dir flag keeps the image size smaller
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code into the container
# This brings our app.py file into the container's /app directory
COPY app.py .

# Expose port 5000 to allow external access
# This is the port our Flask application listens on
EXPOSE 5000

# Define the command to run when the container starts
# This starts our Flask application
CMD ["python", "app.py"]

The requirements.txt file would contain the dependencies:

Flask==2.3.0

Let's break down what each instruction in the Dockerfile does. The FROM instruction specifies the base image to start from. In this case, we are using an official Python image that already has Python 3.9 installed on a slim version of Linux. This saves us from having to install Python ourselves.

The WORKDIR instruction sets the working directory inside the container. Any subsequent commands will be executed relative to this directory. If the directory doesn't exist, Docker creates it automatically.

The COPY instruction copies files from your local machine into the container. We first copy the requirements.txt file, then install the dependencies, and finally copy the application code. You might wonder why we copy requirements.txt separately before copying the application code. This is a Docker best practice related to layer caching, which we will discuss shortly.

The RUN instruction executes commands during the image build process. Here we use it to install Python packages using pip. The results of this command become part of the image.

The EXPOSE instruction documents which port the container will listen on at runtime. It doesn't actually publish the port, but it serves as documentation for anyone using the image.

The CMD instruction specifies the default command to run when a container starts from this image. In our case, it runs the Python application.

BUILDING AND RUNNING YOUR DOCKER CONTAINER

Once you have created the Dockerfile, you can build an image from it using the Docker command-line interface. Here is how you would build the image:

# Build the Docker image
# -t flag tags the image with a name for easy reference
# The dot at the end tells Docker to use the current directory as the build context
docker build -t my-python-app .

When you run this command, Docker reads the Dockerfile and executes each instruction in order. You will see output showing each step being executed. Docker creates a new layer for each instruction, and these layers are stacked on top of each other to form the final image.

After the build completes successfully, you can see your image in the list of available images:

# List all Docker images on your system
docker images

This command will show you all images, including the one you just built. You will see information like the repository name, tag, image ID, when it was created, and its size.

Now comes the exciting part: running your container. You create and start a container from your image using the docker run command:

# Run a container from the image
# -d flag runs the container in detached mode (in the background)
# -p flag maps port 5000 on your host to port 5000 in the container
# --name gives the container a friendly name
docker run -d -p 5000:5000 --name my-running-app my-python-app

Let's understand what each flag does. The -d flag runs the container in detached mode, meaning it runs in the background and doesn't tie up your terminal. The -p flag maps a port on your host machine to a port in the container. The format is host-port:container-port. This allows you to access the application running inside the container from your browser. The --name flag gives your container a friendly name instead of a random generated one.

After running this command, your application is running inside a Docker container. You can open a web browser and navigate to http://localhost:5000 to see your application in action.

MANAGING DOCKER CONTAINERS

Docker provides several commands to manage running containers. You can view all running containers with:

# List all running containers
docker ps

This shows you information about each running container, including its container ID, the image it was created from, the command it's running, when it was created, its status, port mappings, and its name.

To see all containers, including stopped ones, you add the -a flag:

# List all containers, including stopped ones
docker ps -a

You can view the logs from a container to see what your application is outputting:

# View logs from a container
# -f flag follows the log output (like tail -f)
docker logs -f my-running-app

To stop a running container, you use the stop command:

# Stop a running container gracefully
docker stop my-running-app

This sends a SIGTERM signal to the main process in the container, giving it time to shut down gracefully. If the container doesn't stop within a timeout period, Docker sends a SIGKILL signal to force it to stop.

To start a stopped container again:

# Start a stopped container
docker start my-running-app

To remove a container completely:

# Remove a stopped container
# You must stop the container before removing it
docker rm my-running-app

If you want to remove a running container, you can force it:

# Force remove a running container
docker rm -f my-running-app

UNDERSTANDING DOCKER LAYERS AND CACHING

One of Docker's powerful features is its layered architecture. Each instruction in a Dockerfile creates a new layer in the image. These layers are stacked on top of each other, and Docker uses a copy-on-write mechanism to make this efficient.

When you build an image, Docker caches each layer. If you rebuild the image and nothing has changed in a particular layer, Docker reuses the cached layer instead of rebuilding it. This makes subsequent builds much faster.

This is why we copied requirements.txt separately before copying the application code in our earlier example. The dependencies listed in requirements.txt don't change often, but the application code changes frequently during development. By copying requirements.txt first and installing dependencies, that layer gets cached. When we change app.py and rebuild, Docker reuses the cached dependency layer and only rebuilds the layers that copy the application code.

Here is a visual representation of how layers work:

Image Layers (from bottom to top):

Layer 5: CMD ["python", "app.py"]
Layer 4: COPY app.py .
Layer 3: RUN pip install -r requirements.txt
Layer 2: COPY requirements.txt .
Layer 1: WORKDIR /app
Layer 0: FROM python:3.9-slim (base image)

Each layer is read-only and stacked on top of the previous one.
When a container runs, Docker adds a writable layer on top.

DOCKER VOLUMES: PERSISTING DATA BEYOND CONTAINER LIFECYCLE

Containers are ephemeral by design. When you remove a container, any data stored inside it is lost. This is fine for stateless applications, but what about databases or applications that need to persist data?

Docker volumes solve this problem. A volume is a directory that exists outside the container's filesystem but can be mounted into the container. Data written to a volume persists even after the container is removed.

Let's look at an example using a PostgreSQL database:

# Run a PostgreSQL container with a volume for data persistence
# -v flag creates a volume and mounts it into the container
# The format is volume-name:container-path
docker run -d \
  --name my-postgres \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -v postgres-data:/var/lib/postgresql/data \
  postgres:13

In this example, we create a volume named postgres-data and mount it to the directory where PostgreSQL stores its data inside the container. Even if we remove the container, the data in the postgres-data volume remains on the host machine.

You can also mount a directory from your host machine into a container. This is called a bind mount:

# Run a container with a bind mount
# This mounts the current directory into /app in the container
docker run -d \
  --name dev-container \
  -v $(pwd):/app \
  my-python-app

Bind mounts are useful during development because changes you make to files on your host machine are immediately reflected inside the container.

DOCKER NETWORKING: CONNECTING CONTAINERS

In real-world applications, you often need multiple containers to work together. For example, you might have a web application container that needs to communicate with a database container. Docker provides networking capabilities to enable this communication.

By default, Docker creates a bridge network. Containers on the same bridge network can communicate with each other using container names as hostnames. Let's see this in action with a multi-container application:

# Create a custom network
docker network create my-app-network

# Run a PostgreSQL database on this network
docker run -d \
  --name database \
  --network my-app-network \
  -e POSTGRES_PASSWORD=secret \
  postgres:13

# Run a web application on the same network
# The application can connect to the database using "database" as the hostname
docker run -d \
  --name webapp \
  --network my-app-network \
  -p 8080:8080 \
  my-web-app

In this setup, the webapp container can connect to the database using the hostname "database" because they are on the same Docker network. Docker's internal DNS resolves container names to IP addresses automatically.

DOCKER COMPOSE: ORCHESTRATING MULTI-CONTAINER APPLICATIONS

As applications grow more complex, managing multiple containers with individual docker run commands becomes cumbersome. Docker Compose is a tool that allows you to define and run multi-container applications using a YAML configuration file.

Here is an example docker-compose.yml file for a web application with a database:

# docker-compose.yml
# This file defines a multi-container application

version: '3.8'

services:
  # Database service
  database:
    image: postgres:13
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_USER: appuser
      POSTGRES_DB: appdb
    volumes:
      # Persist database data
      - postgres-data:/var/lib/postgresql/data
    networks:
      - app-network

  # Web application service
  webapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgresql://appuser:secret@database:5432/appdb
    depends_on:
      # Ensure database starts before webapp
      - database
    networks:
      - app-network

# Define volumes
volumes:
  postgres-data:

# Define networks
networks:
  app-network:

With this configuration file, you can start the entire application stack with a single command:

# Start all services defined in docker-compose.yml
docker-compose up -d

This command reads the docker-compose.yml file, creates the necessary networks and volumes, builds images if needed, and starts all containers in the correct order. The -d flag runs everything in detached mode.

To stop all services:

# Stop all services
docker-compose down

To view logs from all services:

# View logs from all services
docker-compose logs -f

Docker Compose makes it easy to define complex applications with multiple interconnected services, making development and testing much more manageable.

ADVANCED DOCKER CONCEPTS: MULTI-STAGE BUILDS

As you become more proficient with Docker, you will encounter situations where you want to optimize your images for size and security. Multi-stage builds are a powerful technique for creating smaller, more secure production images.

The idea is to use multiple FROM statements in your Dockerfile. Each FROM statement begins a new stage. You can copy artifacts from one stage to another, leaving behind everything you don't need in the final image.

Here is an example of a multi-stage build for a Go application:

# Dockerfile with multi-stage build

# Stage 1: Build stage
# Use a full Go image with all build tools
FROM golang:1.19 AS builder

WORKDIR /app

# Copy go module files
COPY go.mod go.sum ./

# Download dependencies
RUN go mod download

# Copy source code
COPY . .

# Build the application
# CGO_ENABLED=0 creates a statically linked binary
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp .

# Stage 2: Production stage
# Use a minimal base image
FROM alpine:latest

# Install CA certificates for HTTPS requests
RUN apk --no-cache add ca-certificates

WORKDIR /root/

# Copy only the compiled binary from the builder stage
# This leaves behind all the source code and build tools
COPY --from=builder /app/myapp .

# Run the binary
CMD ["./myapp"]

In this example, the first stage uses a full Go development image to compile the application. The second stage uses a minimal Alpine Linux image and copies only the compiled binary from the first stage. The final image is much smaller because it doesn't include the Go compiler, source code, or build dependencies.

DOCKER BEST PRACTICES AND SECURITY CONSIDERATIONS

As you work with Docker, following best practices will help you create efficient, secure, and maintainable images. One important practice is to use official base images from trusted sources. Official images are maintained by the Docker community and the software vendors, and they are regularly updated with security patches.

Another best practice is to keep your images small. Smaller images download faster, use less disk space, and have a smaller attack surface. Use minimal base images like Alpine Linux when possible, and use multi-stage builds to exclude unnecessary files from the final image.

You should also be mindful of security. Never include secrets like passwords or API keys directly in your Dockerfile or image. Use environment variables or Docker secrets to provide sensitive information at runtime. Run containers with the least privileges necessary, and consider using read-only filesystems when appropriate.

Here is an example of running a container with enhanced security:

# Run a container with security enhancements
docker run -d \
  --name secure-app \
  --read-only \
  --tmpfs /tmp \
  --user 1000:1000 \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  my-app

This command runs the container with a read-only root filesystem, creates a temporary filesystem for /tmp, runs as a non-root user, drops all Linux capabilities, and prevents privilege escalation.

PART TWO: UNDERSTANDING KUBERNETES

THE NEED FOR CONTAINER ORCHESTRATION

Now that you understand Docker and containers, let's explore why Kubernetes exists and what problems it solves. Imagine you have successfully containerized your application using Docker. Your application runs perfectly in containers on your development machine. Now you need to deploy it to production.

In production, you face several challenges that Docker alone doesn't solve. Your application needs to handle thousands of users, so you need to run multiple instances of your containers across multiple servers. If a container crashes, you need something to automatically restart it. When you deploy a new version, you want to update containers gradually without downtime. You need to distribute incoming traffic across multiple container instances. You need to manage secrets, configuration, and storage across a cluster of machines.

These are orchestration problems, and Kubernetes is the leading solution for container orchestration. Kubernetes is an open-source platform that automates the deployment, scaling, and management of containerized applications across clusters of machines.

WHAT IS KUBERNETES AND ITS CORE PHILOSOPHY

Kubernetes, often abbreviated as K8s, was originally developed by Google based on their experience running billions of containers in production. Google donated Kubernetes to the Cloud Native Computing Foundation in 2014, and it has since become the de facto standard for container orchestration.

The core philosophy of Kubernetes is declarative configuration. Instead of telling Kubernetes exactly how to deploy your application step by step, you declare the desired state of your system, and Kubernetes works continuously to make the actual state match your desired state. If a container crashes, Kubernetes notices that the actual state doesn't match the desired state and automatically starts a new container to fix it.

This declarative approach is fundamentally different from imperative approaches where you execute a series of commands. With Kubernetes, you write YAML files describing what you want, and Kubernetes figures out how to make it happen.

KUBERNETES ARCHITECTURE: THE BIG PICTURE

Before diving into details, let's understand the high-level architecture of a Kubernetes cluster. A Kubernetes cluster consists of two types of machines: master nodes (also called control plane nodes) and worker nodes.

The master nodes run the control plane components that manage the cluster. These components make decisions about the cluster, detect and respond to events, and schedule workloads. The main control plane components are the API server, the scheduler, the controller manager, and etcd.

The worker nodes are where your application containers actually run. Each worker node runs several components including the kubelet, the container runtime, and the kube-proxy.

Here is a simplified view of the architecture:

Kubernetes Cluster Architecture:

Master Node (Control Plane):
  - API Server: The front-end for the Kubernetes control plane
  - Scheduler: Assigns pods to nodes
  - Controller Manager: Runs controller processes
  - etcd: Distributed key-value store for cluster data

Worker Nodes (multiple):
  - kubelet: Agent that ensures containers are running
  - Container Runtime: Software that runs containers (Docker, containerd, etc.)
  - kube-proxy: Maintains network rules for pod communication
  - Pods: The smallest deployable units containing one or more containers

THE FUNDAMENTAL UNIT: PODS

In Kubernetes, the smallest deployable unit is not a container, but a pod. A pod is a group of one or more containers that share storage and network resources. Containers in the same pod can communicate with each other using localhost, and they share the same IP address.

Most commonly, a pod contains a single container. However, pods can contain multiple containers when those containers are tightly coupled and need to share resources. For example, you might have a main application container and a sidecar container that collects logs.

Here is a simple pod definition in YAML:

# pod-definition.yaml
# This defines a single pod running an nginx container

apiVersion: v1
kind: Pod
metadata:
  # Name of the pod
  name: nginx-pod
  labels:
    # Labels are key-value pairs used to organize and select objects
    app: nginx
    environment: production
spec:
  # List of containers in this pod
  containers:
  - name: nginx-container
    # Container image to use
    image: nginx:1.21
    ports:
    # Port that the container exposes
    - containerPort: 80
      protocol: TCP
    resources:
      # Resource requests and limits
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"

To create this pod in a Kubernetes cluster, you would use the kubectl command-line tool:

# Create the pod from the YAML file
kubectl apply -f pod-definition.yaml

The kubectl apply command sends the pod definition to the Kubernetes API server, which then schedules the pod on an available worker node. The kubelet on that node pulls the container image and starts the container.

You can view the status of your pods:

# List all pods in the current namespace
kubectl get pods

# Get detailed information about a specific pod
kubectl describe pod nginx-pod

# View logs from a pod
kubectl logs nginx-pod

DEPLOYMENTS: MANAGING REPLICA SETS AND ROLLING UPDATES

While you can create individual pods, in practice you rarely do this. Instead, you use higher-level abstractions like Deployments. A Deployment manages a set of identical pods, ensuring that a specified number of them are running at all times.

Deployments provide several powerful features. They can automatically replace failed pods, scale the number of replicas up or down, and perform rolling updates to deploy new versions without downtime.

Here is a Deployment definition:

# deployment-definition.yaml
# This defines a deployment that manages multiple nginx pods

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  # Number of pod replicas to maintain
  replicas: 3
  selector:
    # The deployment manages pods with these labels
    matchLabels:
      app: nginx
  template:
    # This is the pod template
    # The deployment creates pods based on this template
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.21
        ports:
        - containerPort: 80
        resources:
          requests:
            memory: "64Mi"
            cpu: "250m"
          limits:
            memory: "128Mi"
            cpu: "500m"

When you create this Deployment, Kubernetes creates three nginx pods. If one pod fails, Kubernetes automatically creates a new one to maintain the desired count of three replicas.

# Create the deployment
kubectl apply -f deployment-definition.yaml

# View deployments
kubectl get deployments

# View the pods created by the deployment
kubectl get pods

# Scale the deployment to 5 replicas
kubectl scale deployment nginx-deployment --replicas=5

One of the most powerful features of Deployments is rolling updates. When you update the container image in a Deployment, Kubernetes gradually replaces old pods with new ones, ensuring that your application remains available during the update.

# Update the deployment to use a new nginx version
kubectl set image deployment/nginx-deployment nginx=nginx:1.22

# Watch the rollout status
kubectl rollout status deployment/nginx-deployment

# View rollout history
kubectl rollout history deployment/nginx-deployment

# Rollback to the previous version if something goes wrong
kubectl rollout undo deployment/nginx-deployment

SERVICES: EXPOSING YOUR APPLICATION TO THE NETWORK

Pods are ephemeral. They can be created and destroyed at any time. Each pod gets its own IP address, but that IP address changes when the pod is recreated. This creates a problem: how do other parts of your application reliably connect to your pods?

Kubernetes Services solve this problem. A Service provides a stable IP address and DNS name for a set of pods. Even as individual pods come and go, the Service remains constant, and traffic is automatically routed to healthy pods.

There are several types of Services. A ClusterIP Service exposes pods only within the cluster. A NodePort Service exposes pods on a specific port on each node. A LoadBalancer Service creates an external load balancer in cloud environments.

Here is a Service definition that exposes our nginx deployment:

# service-definition.yaml
# This creates a service that exposes the nginx deployment

apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  # Type of service
  # ClusterIP: Internal cluster access only
  # NodePort: Exposes on each node's IP at a static port
  # LoadBalancer: Creates an external load balancer (cloud providers)
  type: LoadBalancer
  selector:
    # The service routes traffic to pods with this label
    app: nginx
  ports:
  - protocol: TCP
    # Port that the service listens on
    port: 80
    # Port on the pod that traffic is forwarded to
    targetPort: 80

When you create this Service, Kubernetes assigns it a stable cluster IP address. Any pod in the cluster can reach the nginx pods by connecting to this Service IP or its DNS name.

# Create the service
kubectl apply -f service-definition.yaml

# View services
kubectl get services

# Get detailed information about the service
kubectl describe service nginx-service

The Service uses a selector to determine which pods to route traffic to. In this case, it routes to all pods with the label app: nginx. As pods are created or destroyed, the Service automatically updates its list of endpoints.

CONFIGMAPS AND SECRETS: MANAGING CONFIGURATION

Applications need configuration data like database connection strings, API endpoints, and feature flags. Kubernetes provides ConfigMaps for storing non-sensitive configuration data and Secrets for storing sensitive data like passwords and API keys.

Here is an example of a ConfigMap:

# configmap-definition.yaml
# This stores configuration data that can be consumed by pods

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  # Configuration as key-value pairs
  database_host: "postgres.default.svc.cluster.local"
  database_port: "5432"
  log_level: "info"
  # You can also store entire configuration files
  app.properties: |
    feature.new_ui=true
    feature.beta_api=false
    cache.ttl=3600

You can consume a ConfigMap in a pod in several ways. You can expose it as environment variables or mount it as a volume. Here is a pod that uses the ConfigMap:

# pod-with-config.yaml
# This pod uses configuration from a ConfigMap

apiVersion: v1
kind: Pod
metadata:
  name: app-pod
spec:
  containers:
  - name: app-container
    image: myapp:1.0
    # Inject ConfigMap values as environment variables
    env:
    - name: DATABASE_HOST
      valueFrom:
        configMapKeyRef:
          name: app-config
          key: database_host
    - name: DATABASE_PORT
      valueFrom:
        configMapKeyRef:
          name: app-config
          key: database_port
    # Mount the entire ConfigMap as a volume
    volumeMounts:
    - name: config-volume
      mountPath: /etc/config
  volumes:
  - name: config-volume
    configMap:
      name: app-config

Secrets work similarly to ConfigMaps but are designed for sensitive data. Kubernetes encodes Secret values in base64 and provides additional security features.

# secret-definition.yaml
# This stores sensitive data like passwords

apiVersion: v1
kind: Secret
metadata:
  name: database-secret
type: Opaque
data:
  # Values must be base64 encoded
  # You can encode values using: echo -n 'mypassword' | base64
  username: YWRtaW4=
  password: cGFzc3dvcmQxMjM=

You can create Secrets from the command line as well:

# Create a secret from literal values
kubectl create secret generic database-secret \
  --from-literal=username=admin \
  --from-literal=password=password123

Using Secrets in pods is similar to using ConfigMaps:

# pod-with-secret.yaml
# This pod uses credentials from a Secret

apiVersion: v1
kind: Pod
metadata:
  name: app-with-db
spec:
  containers:
  - name: app
    image: myapp:1.0
    env:
    # Inject secret values as environment variables
    - name: DB_USERNAME
      valueFrom:
        secretKeyRef:
          name: database-secret
          key: username
    - name: DB_PASSWORD
      valueFrom:
        secretKeyRef:
          name: database-secret
          key: password

PERSISTENT VOLUMES: MANAGING STORAGE

Just like with Docker, containers in Kubernetes are ephemeral. When a pod is deleted, any data stored in its containers is lost. For stateful applications like databases, you need persistent storage that survives pod restarts and rescheduling.

Kubernetes provides a storage abstraction through Persistent Volumes and Persistent Volume Claims. A Persistent Volume is a piece of storage in the cluster that has been provisioned by an administrator or dynamically provisioned using Storage Classes. A Persistent Volume Claim is a request for storage by a user.

Here is an example of a Persistent Volume:

# persistent-volume.yaml
# This defines a piece of storage available in the cluster

apiVersion: v1
kind: PersistentVolume
metadata:
  name: postgres-pv
spec:
  # Storage capacity
  capacity:
    storage: 10Gi
  # Access modes define how the volume can be mounted
  # ReadWriteOnce: Can be mounted read-write by a single node
  # ReadOnlyMany: Can be mounted read-only by many nodes
  # ReadWriteMany: Can be mounted read-write by many nodes
  accessModes:
    - ReadWriteOnce
  # Reclaim policy determines what happens when the claim is deleted
  # Retain: Keep the volume and its data
  # Delete: Delete the volume and its data
  # Recycle: Scrub the data and make available again (deprecated)
  persistentVolumeReclaimPolicy: Retain
  # Storage class for dynamic provisioning
  storageClassName: standard
  # The actual storage backend
  # This example uses a local path, but in production you would use
  # network storage like NFS, AWS EBS, GCP Persistent Disk, etc.
  hostPath:
    path: /mnt/data/postgres

Now a pod can request storage using a Persistent Volume Claim:

# persistent-volume-claim.yaml
# This is a request for storage

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      # Request 10Gi of storage
      storage: 10Gi
  storageClassName: standard

Kubernetes matches the claim to an available Persistent Volume. Once bound, the pod can use the claim:

# postgres-deployment.yaml
# This deployment uses persistent storage for the database

apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:13
        env:
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: database-secret
              key: password
        ports:
        - containerPort: 5432
        # Mount the persistent volume claim
        volumeMounts:
        - name: postgres-storage
          mountPath: /var/lib/postgresql/data
      volumes:
      - name: postgres-storage
        persistentVolumeClaim:
          # Reference to the PVC
          claimName: postgres-pvc

NAMESPACES: ORGANIZING CLUSTER RESOURCES

As your Kubernetes cluster grows, you need a way to organize resources and provide isolation between different teams or projects. Namespaces provide this capability. A namespace is a virtual cluster within your physical cluster.

Kubernetes starts with several default namespaces. The default namespace is where resources are created if you don't specify a namespace. The kube-system namespace contains resources created by Kubernetes itself. The kube-public namespace is readable by all users and is typically used for cluster information.

You can create your own namespaces:

# namespace-definition.yaml
# This creates a new namespace for a development environment

apiVersion: v1
kind: Namespace
metadata:
  name: development

Or create it using kubectl:

# Create a namespace
kubectl create namespace development

When you create resources, you can specify which namespace they belong to:

# deployment-in-namespace.yaml
# This deployment is created in the development namespace

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: development
spec:
  replicas: 2
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: myapp
        image: myapp:1.0

You can also specify the namespace when using kubectl:

# List pods in a specific namespace
kubectl get pods -n development

# List pods in all namespaces
kubectl get pods --all-namespaces

Namespaces provide resource isolation and can have resource quotas applied to limit the amount of CPU, memory, and other resources that can be consumed.

ADVANCED KUBERNETES CONCEPTS: STATEFULSETS

While Deployments are perfect for stateless applications, stateful applications like databases have special requirements. They need stable network identities, stable persistent storage, and ordered deployment and scaling. StatefulSets are designed for these use cases.

A StatefulSet is similar to a Deployment but provides guarantees about the ordering and uniqueness of pods. Each pod in a StatefulSet has a persistent identifier that it maintains across rescheduling.

Here is an example StatefulSet for a MongoDB cluster:

# statefulset-definition.yaml
# This creates a StatefulSet for MongoDB with persistent storage

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mongodb
spec:
  # Service name for stable network identity
  serviceName: mongodb-service
  replicas: 3
  selector:
    matchLabels:
      app: mongodb
  template:
    metadata:
      labels:
        app: mongodb
    spec:
      containers:
      - name: mongodb
        image: mongo:5.0
        ports:
        - containerPort: 27017
        volumeMounts:
        - name: mongodb-storage
          mountPath: /data/db
  # Volume claim templates create a PVC for each pod
  volumeClaimTemplates:
  - metadata:
      name: mongodb-storage
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 10Gi

In this StatefulSet, each pod gets a unique name like mongodb-0, mongodb-1, mongodb-2. Each pod also gets its own Persistent Volume Claim. If a pod is rescheduled, it maintains the same name and is reattached to the same storage.

DAEMONSETS: RUNNING PODS ON EVERY NODE

Sometimes you need to run a pod on every node in your cluster. Common use cases include log collection agents, monitoring agents, and network plugins. DaemonSets ensure that a copy of a pod runs on all or selected nodes.

Here is an example DaemonSet for a log collection agent:

# daemonset-definition.yaml
# This runs a log collector on every node

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: log-collector
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: log-collector
  template:
    metadata:
      labels:
        app: log-collector
    spec:
      # Tolerate taints so the pod can run on all nodes
      tolerations:
      - key: node-role.kubernetes.io/master
        effect: NoSchedule
      containers:
      - name: fluentd
        image: fluentd:v1.14
        volumeMounts:
        # Mount the host's log directory
        - name: varlog
          mountPath: /var/log
        # Mount the Docker container logs
        - name: varlibdockercontainers
          mountPath: /var/lib/docker/containers
          readOnly: true
      volumes:
      # Access host directories
      - name: varlog
        hostPath:
          path: /var/log
      - name: varlibdockercontainers
        hostPath:
          path: /var/lib/docker/containers

When you create this DaemonSet, Kubernetes automatically creates a pod on each node. If you add a new node to the cluster, Kubernetes automatically schedules the DaemonSet pod on it.

JOBS AND CRONJOBS: RUNNING BATCH WORKLOADS

Not all workloads are long-running services. Sometimes you need to run a task to completion, like a data processing job or a database migration. Kubernetes provides Jobs for this purpose.

A Job creates one or more pods and ensures that a specified number of them successfully complete. Here is an example:

# job-definition.yaml
# This runs a batch processing job

apiVersion: batch/v1
kind: Job
metadata:
  name: data-processor
spec:
  # Number of successful completions required
  completions: 5
  # Number of pods to run in parallel
  parallelism: 2
  template:
    spec:
      containers:
      - name: processor
        image: data-processor:1.0
        command: ["python", "process_data.py"]
      # Restart policy must be Never or OnFailure for Jobs
      restartPolicy: Never
  # Number of retries before marking the job as failed
  backoffLimit: 3

This Job runs the data processing task five times, running two pods in parallel at a time. If a pod fails, Kubernetes retries up to three times.

For recurring tasks, you can use a CronJob:

# cronjob-definition.yaml
# This runs a backup job every day at 2 AM

apiVersion: batch/v1
kind: CronJob
metadata:
  name: database-backup
spec:
  # Schedule in cron format
  # minute hour day month weekday
  schedule: "0 2 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: backup
            image: backup-tool:1.0
            command: ["sh", "-c", "backup_database.sh"]
            env:
            - name: DB_HOST
              value: postgres-service
          restartPolicy: OnFailure

This CronJob creates a Job every day at 2 AM to back up the database.

RESOURCE MANAGEMENT: REQUESTS AND LIMITS

Kubernetes allows you to specify how much CPU and memory each container needs. Resource requests are the amount of resources guaranteed to a container. Resource limits are the maximum amount of resources a container can use.

Here is an example with resource specifications:

# deployment-with-resources.yaml
# This deployment specifies resource requirements

apiVersion: apps/v1
kind: Deployment
metadata:
  name: resource-demo
spec:
  replicas: 3
  selector:
    matchLabels:
      app: resource-demo
  template:
    metadata:
      labels:
        app: resource-demo
    spec:
      containers:
      - name: app
        image: myapp:1.0
        resources:
          # Requests: Guaranteed resources
          requests:
            # 250 milliCPU (0.25 CPU cores)
            cpu: "250m"
            # 64 Mebibytes of memory
            memory: "64Mi"
          # Limits: Maximum resources allowed
          limits:
            # 500 milliCPU (0.5 CPU cores)
            cpu: "500m"
            # 128 Mebibytes of memory
            memory: "128Mi"

When you specify requests, the Kubernetes scheduler uses this information to decide which node has enough resources to run the pod. If a container tries to use more than its limit, Kubernetes throttles CPU usage or terminates the container if it exceeds memory limits.

HORIZONTAL POD AUTOSCALING: AUTOMATIC SCALING

One of Kubernetes' most powerful features is automatic scaling based on metrics. The Horizontal Pod Autoscaler automatically scales the number of pods in a deployment based on observed CPU utilization or custom metrics.

Here is an example HorizontalPodAutoscaler:

# hpa-definition.yaml
# This automatically scales pods based on CPU usage

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp-hpa
spec:
  # The deployment to scale
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  # Minimum and maximum number of replicas
  minReplicas: 2
  maxReplicas: 10
  metrics:
  # Scale based on CPU utilization
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        # Target 70% CPU utilization
        averageUtilization: 70
  # Scale based on memory utilization
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        # Target 80% memory utilization
        averageUtilization: 80

With this HorizontalPodAutoscaler in place, Kubernetes monitors the CPU and memory usage of your pods. If the average utilization exceeds the target, Kubernetes increases the number of replicas. If utilization is below the target, it decreases the number of replicas, always staying within the min and max bounds.

INGRESS: ADVANCED HTTP ROUTING

While Services provide basic load balancing, Ingress provides more sophisticated HTTP routing capabilities. An Ingress allows you to define rules for routing external HTTP traffic to different services based on hostnames and URL paths.

Here is an example Ingress configuration:

# ingress-definition.yaml
# This routes traffic based on hostnames and paths

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
  annotations:
    # Annotations configure the ingress controller
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  # TLS configuration for HTTPS
  tls:
  - hosts:
    - myapp.example.com
    secretName: myapp-tls-secret
  rules:
  # Route traffic for myapp.example.com
  - host: myapp.example.com
    http:
      paths:
      # Route /api requests to the api service
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: api-service
            port:
              number: 8080
      # Route /web requests to the web service
      - path: /web
        pathType: Prefix
        backend:
          service:
            name: web-service
            port:
              number: 80
  # Route traffic for admin.example.com to a different service
  - host: admin.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: admin-service
            port:
              number: 80

An Ingress requires an Ingress Controller to be installed in your cluster. Popular options include NGINX Ingress Controller, Traefik, and HAProxy Ingress.

HEALTH CHECKS: LIVENESS AND READINESS PROBES

Kubernetes needs to know when your application is healthy and ready to serve traffic. You configure this using liveness and readiness probes.

A liveness probe determines if a container is running properly. If the liveness probe fails, Kubernetes kills the container and restarts it. A readiness probe determines if a container is ready to serve traffic. If the readiness probe fails, Kubernetes removes the pod from service endpoints until it becomes ready again.

Here is an example with both types of probes:

# deployment-with-probes.yaml
# This deployment includes health checks

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webapp
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
      - name: webapp
        image: mywebapp:1.0
        ports:
        - containerPort: 8080
        # Liveness probe: Is the container alive?
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          # Wait 30 seconds before starting probes
          initialDelaySeconds: 30
          # Check every 10 seconds
          periodSeconds: 10
          # Fail after 3 consecutive failures
          failureThreshold: 3
        # Readiness probe: Is the container ready to serve traffic?
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          # Start checking immediately
          initialDelaySeconds: 5
          # Check every 5 seconds
          periodSeconds: 5
          # Fail after 2 consecutive failures
          failureThreshold: 2

You can also use TCP socket probes or execute commands inside the container:

# Example of different probe types
livenessProbe:
  # TCP socket probe
  tcpSocket:
    port: 8080
  initialDelaySeconds: 15
  periodSeconds: 20

readinessProbe:
  # Command execution probe
  exec:
    command:
    - cat
    - /tmp/healthy
  initialDelaySeconds: 5
  periodSeconds: 5

PUTTING IT ALL TOGETHER: A COMPLETE APPLICATION

Let's bring together everything we have learned by deploying a complete three-tier application consisting of a frontend, backend API, and database.

First, we create a namespace for our application:

# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: myapp

Next, we create a Secret for database credentials:

# database-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
  namespace: myapp
type: Opaque
stringData:
  username: appuser
  password: securepassword123

We create a ConfigMap for application configuration:

# app-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: myapp
data:
  api_url: "http://backend-service:8080"
  log_level: "info"
  cache_enabled: "true"

Now we deploy the database using a StatefulSet:

# database-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: myapp
spec:
  serviceName: postgres-service
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:13
        ports:
        - containerPort: 5432
        env:
        - name: POSTGRES_USER
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: username
        - name: POSTGRES_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: password
        - name: POSTGRES_DB
          value: appdb
        volumeMounts:
        - name: postgres-storage
          mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
  - metadata:
      name: postgres-storage
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 10Gi

We create a Service for the database:

# database-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres-service
  namespace: myapp
spec:
  selector:
    app: postgres
  ports:
  - port: 5432
    targetPort: 5432
  clusterIP: None  # Headless service for StatefulSet

Next, we deploy the backend API:

# backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: backend
  namespace: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: backend
  template:
    metadata:
      labels:
        app: backend
    spec:
      containers:
      - name: backend
        image: mybackend:1.0
        ports:
        - containerPort: 8080
        env:
        - name: DATABASE_URL
          value: "postgresql://$(DB_USER):$(DB_PASSWORD)@postgres-service:5432/appdb"
        - name: DB_USER
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: username
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: password
        - name: LOG_LEVEL
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: log_level
        resources:
          requests:
            cpu: "200m"
            memory: "256Mi"
          limits:
            cpu: "500m"
            memory: "512Mi"
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5

We create a Service for the backend:

# backend-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: backend-service
  namespace: myapp
spec:
  selector:
    app: backend
  ports:
  - port: 8080
    targetPort: 8080
  type: ClusterIP

We deploy the frontend:

# frontend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: frontend
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: frontend
  template:
    metadata:
      labels:
        app: frontend
    spec:
      containers:
      - name: frontend
        image: myfrontend:1.0
        ports:
        - containerPort: 80
        envFrom:
        - configMapRef:
            name: app-config
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
          limits:
            cpu: "200m"
            memory: "256Mi"

We create a Service for the frontend:

# frontend-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: frontend-service
  namespace: myapp
spec:
  selector:
    app: frontend
  ports:
  - port: 80
    targetPort: 80
  type: LoadBalancer

Finally, we create an Ingress to route external traffic:

# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
  namespace: myapp
spec:
  rules:
  - host: myapp.example.com
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: backend-service
            port:
              number: 8080
      - path: /
        pathType: Prefix
        backend:
          service:
            name: frontend-service
            port:
              number: 80

To deploy this entire application, you would apply all these YAML files:

# Apply all configurations
kubectl apply -f namespace.yaml
kubectl apply -f database-secret.yaml
kubectl apply -f app-config.yaml
kubectl apply -f database-statefulset.yaml
kubectl apply -f database-service.yaml
kubectl apply -f backend-deployment.yaml
kubectl apply -f backend-service.yaml
kubectl apply -f frontend-deployment.yaml
kubectl apply -f frontend-service.yaml
kubectl apply -f ingress.yaml

CONCLUSION: THE JOURNEY FROM DOCKER TO KUBERNETES

We have covered a comprehensive journey from understanding the basics of containerization with Docker to orchestrating complex applications with Kubernetes. Docker solved the fundamental problem of packaging applications with their dependencies, ensuring consistency across different environments. Kubernetes took this further by providing a robust platform for deploying, scaling, and managing containerized applications in production.

Docker taught us about images, containers, Dockerfiles, volumes, and networks. We learned how to package applications into portable containers and how to use Docker Compose to manage multi-container applications. These concepts form the foundation for understanding Kubernetes.

Kubernetes introduced us to a declarative approach to infrastructure management. Instead of imperatively executing commands, we declare our desired state in YAML files, and Kubernetes continuously works to maintain that state. We explored pods as the fundamental unit of deployment, Deployments for managing replicated applications, Services for stable networking, ConfigMaps and Secrets for configuration management, and Persistent Volumes for storage.

We then progressed to advanced concepts like StatefulSets for stateful applications, DaemonSets for node-level services, Jobs and CronJobs for batch workloads, Horizontal Pod Autoscaling for automatic scaling, Ingress for sophisticated HTTP routing, and health checks for ensuring application reliability.

The power of Kubernetes lies not just in its individual features but in how they work together to create a comprehensive platform for running production applications. When a pod fails, Kubernetes automatically restarts it. When traffic increases, the Horizontal Pod Autoscaler creates more pods. When you deploy a new version, rolling updates ensure zero downtime. When you need to route traffic based on URLs, Ingress provides that capability.

As you continue your journey with Docker and Kubernetes, remember that these technologies are tools to solve real problems. Start with simple use cases, understand the fundamentals deeply, and gradually adopt more advanced features as your needs grow. The learning curve can be steep, but the benefits of containerization and orchestration make it worthwhile for modern application development and deployment.

No comments: