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.