Kubernetes Explained for Developers: Pods, Services, Deployments, and Beyond
Learn Kubernetes fundamentals as a developer. Covers pods, deployments, services, ingress, networking, HPA, persistent volumes, and debugging — with practical kubectl examples and production YAML.
Kubernetes is a container orchestration platform that automatically manages the deployment, scaling, and health of containerized applications. In plain terms: you tell Kubernetes what you want running (3 copies of my API container), and Kubernetes makes it happen — and keeps it that way, restarting containers that crash, distributing traffic, and scaling up under load.
Every major cloud provider offers managed Kubernetes (AWS EKS, GCP GKE, Azure AKS). Understanding the core concepts lets you deploy, debug, and scale production services without getting lost in the abstraction layers.
Why Kubernetes Exists
You have a Docker container running your app. If it crashes, it's gone. If you need 10 copies, you run 10 manually. If a server dies, you manually restart things. When you deploy a new version, you take downtime.
Kubernetes automates all of this. You declare the desired state and Kubernetes reconciles reality to match:
You: "I want 3 instances of my API running at all times"
Kubernetes: "Got it" → starts 3 → one crashes → restarts it automatically
→ server dies → reschedules pod to a healthy node
→ traffic spikes → scales to 10 instances via HPAThe key mental model: Kubernetes is a declarative reconciliation loop. You describe what you want in YAML. The control plane constantly compares current state to desired state and makes changes to close the gap.
The Core Objects
Pod
The smallest unit in Kubernetes. A pod runs one or more containers. Containers in the same pod share network namespace (same IP) and can mount the same volumes.
# pod.yaml — rarely created directly, use Deployments instead
apiVersion: v1
kind: Pod
metadata:
name: my-api
labels:
app: my-api
spec:
containers:
- name: api
image: myapp:v1.2
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
memory: "128Mi"
cpu: "250m" # 250 millicores = 0.25 CPU cores
limits:
memory: "512Mi"
cpu: "1"Pods are ephemeral — they die and get replaced. They don't have stable IPs. Never create pods directly; use Deployments instead.
Deployment
A Deployment manages pods. It ensures the desired number of pods are running, handles rolling updates, and provides rollback capability.
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-api
namespace: production
spec:
replicas: 3
selector:
matchLabels:
app: my-api
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # Allow 1 extra pod during update
maxUnavailable: 0 # Never go below desired replica count
template:
metadata:
labels:
app: my-api
spec:
containers:
- name: api
image: myapp:v1.2
ports:
- containerPort: 8000
resources:
requests:
memory: "128Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "1"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3kubectl apply -f deployment.yaml
# See pods running
kubectl get pods -n production
# NAME READY STATUS RESTARTS
# my-api-5d4b8b-abc12 1/1 Running 0
# my-api-5d4b8b-def34 1/1 Running 0
# my-api-5d4b8b-ghi56 1/1 Running 0
# Rolling update — zero downtime
kubectl set image deployment/my-api api=myapp:v1.3 -n production
# Check rollout progress
kubectl rollout status deployment/my-api -n production
# Rollback if something breaks
kubectl rollout undo deployment/my-api -n productionThe rolling update creates new pods with the new image before terminating old ones. The readiness probe gates traffic — new pods only receive requests after passing health checks.
Service
Pods have random IPs that change when pods restart. A Service gives pods a stable DNS name and IP, and load balances across all matching pods.
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: my-api
namespace: production
spec:
selector:
app: my-api # Routes to pods with this label
ports:
- port: 80 # Service port (what callers connect to)
targetPort: 8000 # Container port (what your app listens on)
protocol: TCP
type: ClusterIP # Internal only (default)Service types:
ClusterIP: Only accessible within the cluster. Default.NodePort: Exposes on a static port on every node. For development.LoadBalancer: Creates a cloud load balancer (AWS ALB, GCP GLB). For external traffic.ExternalName: Maps a service to a DNS name outside the cluster.
Now any pod in the cluster can reach your service at http://my-api.production.svc.cluster.local or just http://my-api from within the same namespace.
# From another service in the same namespace
import requests
response = requests.get("http://my-api/health") # Kubernetes DNS resolves this
# From a different namespace
response = requests.get("http://my-api.production/health")Ingress
Ingress routes external HTTP/HTTPS traffic into the cluster, based on host and path rules. You need an Ingress Controller installed (nginx-ingress, Traefik, AWS ALB Ingress Controller) for Ingress resources to work.
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-ingress
namespace: production
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- api.example.com
secretName: api-tls-cert
rules:
- host: api.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: my-api
port:
number: 80
- path: /docs
pathType: Prefix
backend:
service:
name: docs-service
port:
number: 80ConfigMaps and Secrets
Separate configuration from container images. Same image, different config, different environments.
# ConfigMap: non-sensitive config
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
namespace: production
data:
LOG_LEVEL: "info"
CACHE_TTL: "300"
MAX_CONNECTIONS: "100"
---
# Secret: sensitive data — base64-encoded, encrypted at rest in etcd
apiVersion: v1
kind: Secret
metadata:
name: db-secret
namespace: production
type: Opaque
data:
url: cG9zdGdyZXNxbDovL3VzZXI6cGFzc0Bob3N0OjU0MzIvZGI=
# base64("postgresql://user:pass@host:5432/db")# Create a secret from a file (avoids base64 encoding manually)
kubectl create secret generic db-secret \
--from-literal=url="postgresql://user:pass@host:5432/db" \
-n production# Use in deployment spec
spec:
containers:
- name: api
env:
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: app-config
key: LOG_LEVEL
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
envFrom:
- configMapRef:
name: app-config # Mount all keys as environment variablesNever hardcode secrets in your image. Always use Kubernetes Secrets, or an external secret manager (HashiCorp Vault, AWS Secrets Manager, External Secrets Operator).
Health Checks
Health checks determine when pods are alive and ready to serve traffic. Get these wrong and deployments will either never complete or route traffic to unhealthy pods.
containers:
- name: api
livenessProbe:
# Fails → pod is restarted
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10 # Wait 10s after startup before first check
periodSeconds: 10 # Check every 10s
failureThreshold: 3 # Restart after 3 consecutive failures
readinessProbe:
# Fails → pod removed from Service endpoints (no more traffic)
httpGet:
path: /ready
port: 8000
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
startupProbe:
# For slow-starting containers — disables liveness until this passes
httpGet:
path: /health
port: 8000
failureThreshold: 30 # Give it up to 5 minutes to start
periodSeconds: 10Implement the endpoints in your app:
from fastapi import FastAPI
import asyncpg
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/ready")
async def readiness():
# Check all critical dependencies
try:
await db_pool.fetchval("SELECT 1")
await redis_client.ping()
return {"status": "ready"}
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))/health should be lightweight — just return 200. /ready should verify dependencies. During a rolling update, pods only receive traffic after /ready passes.
Namespaces
Namespaces are virtual clusters within a Kubernetes cluster. Use them to separate environments (dev, staging, production) or teams.
# Create namespace
kubectl create namespace staging
# Deploy to a specific namespace
kubectl apply -f deployment.yaml -n staging
# Set default namespace for your context
kubectl config set-context --current --namespace=production
# List resources across all namespaces
kubectl get pods --all-namespaces
kubectl get pods -A # shorthandServices are accessible across namespaces via DNS:
http://my-api.production.svc.cluster.local
↑service ↑namespace ↑ k8s DNS suffixResource quotas per namespace prevent one team from starving another:
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-quota
namespace: team-a
spec:
hard:
requests.cpu: "10"
requests.memory: 20Gi
limits.cpu: "20"
limits.memory: 40Gi
pods: "50"Horizontal Pod Autoscaler (HPA)
HPA automatically scales the number of pod replicas based on metrics. The most common use: scale when CPU utilization exceeds a threshold.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: my-api-hpa
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-api
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # Scale when avg CPU > 70%
- type: Resource
resource:
name: memory
target:
type: AverageValue
averageValue: "400Mi"
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 min before scaling down
policies:
- type: Percent
value: 10 # Scale down max 10% at a time
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0 # Scale up immediately
policies:
- type: Percent
value: 100
periodSeconds: 15For custom metrics (HTTP requests/second, queue depth), use KEDA (Kubernetes Event-Driven Autoscaling):
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: my-api-keda
spec:
scaleTargetRef:
name: my-api
minReplicaCount: 2
maxReplicaCount: 50
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus:9090
metricName: http_requests_per_second
query: sum(rate(http_requests_total{app="my-api"}[1m]))
threshold: "100" # Scale when >100 req/s per replicaCPU utilization is a lagging indicator — scaling on HTTP request rate or queue depth reacts faster to load spikes.
Persistent Volumes
Containers are ephemeral — data written to the container filesystem is lost when the pod restarts. Use Persistent Volumes for stateful workloads.
# PersistentVolumeClaim — request storage from the cluster
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: production
spec:
accessModes:
- ReadWriteOnce # Can be mounted by one node at a time
resources:
requests:
storage: 50Gi
storageClassName: gp3 # AWS gp3 SSD
---
# Use in a StatefulSet (for databases)
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 1
template:
spec:
containers:
- name: postgres
image: postgres:16
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: postgres-storage
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 50Gi
storageClassName: gp3Access modes:
ReadWriteOnce(RWO): One node reads and writes. For databases.ReadOnlyMany(ROX): Many nodes can read. For shared config.ReadWriteMany(RWX): Many nodes read and write. Requires NFS or a cloud file system (AWS EFS, GCP Filestore).
For databases in production, prefer managed database services (RDS, Cloud SQL) over running databases in Kubernetes — the operational complexity of stateful workloads in Kubernetes is high.
Kubernetes Networking
Understanding networking in Kubernetes prevents a lot of debugging confusion.
Pod IPs: Every pod gets its own IP address from the cluster's pod CIDR range (e.g., 10.244.0.0/16). Pods can communicate with each other directly using their IP, without NAT.
Service IPs (ClusterIP): Virtual IPs assigned to Services. kube-proxy on each node programs iptables rules to route traffic from the Service IP to one of the pods matching the service selector. The Service IP never changes; pod IPs do.
DNS: CoreDNS (running as pods) provides cluster DNS. Every service gets a DNS A record:
<service>.<namespace>.svc.cluster.localNetwork Policies: By default, pods can communicate with any other pod. Use Network Policies to restrict traffic:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-network-policy
namespace: production
spec:
podSelector:
matchLabels:
app: my-api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: api-gateway # Only api-gateway can call my-api
ports:
- protocol: TCP
port: 8000
egress:
- to:
- podSelector:
matchLabels:
app: postgres # my-api can only call postgres
ports:
- protocol: TCP
port: 5432Requires a CNI plugin that supports Network Policies (Calico, Cilium — not the default kubenet).
Essential kubectl Commands
# Apply configs
kubectl apply -f deployment.yaml
kubectl apply -f directory/ # Apply all YAML in directory
kubectl apply -k kustomization-dir/ # Kustomize overlays
# View resources
kubectl get pods,deployments,services -n production
kubectl get pods -o wide # Show node and IP
kubectl get pods --watch # Live updates
# Inspect pods
kubectl describe pod my-api-5d4b8b-abc12 -n production
kubectl logs my-api-5d4b8b-abc12 -n production
kubectl logs -l app=my-api --tail=100 -n production # All pods with label
kubectl logs -f my-api-5d4b8b-abc12 # Follow log stream
# Debug inside a pod
kubectl exec -it my-api-5d4b8b-abc12 -- /bin/bash
kubectl exec -it my-api-5d4b8b-abc12 -- curl http://postgres:5432
# Scale
kubectl scale deployment my-api --replicas=10 -n production
# Rolling update and rollback
kubectl set image deployment/my-api api=myapp:v1.3 -n production
kubectl rollout status deployment/my-api -n production
kubectl rollout history deployment/my-api -n production
kubectl rollout undo deployment/my-api --to-revision=2 -n production
# Copy files
kubectl cp my-api-5d4b8b-abc12:/app/logs/error.log ./error.log
# Port-forward for local debugging
kubectl port-forward pod/my-api-5d4b8b-abc12 8080:8000
kubectl port-forward service/postgres 5432:5432
# Delete
kubectl delete pod my-api-5d4b8b-abc12 # Deployment recreates it
kubectl delete deployment my-api # Deletes all podsDebugging Common Issues
Pod stuck in Pending
kubectl describe pod <pod-name> -n production
# Look at "Events" section at the bottom
# Common causes:
# "0/3 nodes are available: Insufficient memory" → Not enough resources in cluster
# "0/3 nodes are available: node(s) had untolerated taint" → Node selector mismatch
# "persistentvolumeclaim not found" → PVC doesn't exist or wrong namePod in CrashLoopBackOff
# See logs from the failed container (before it crashed)
kubectl logs my-api-abc12 -n production --previous
kubectl describe pod my-api-abc12 -n production
# Check "Last State" section — shows exit code
# Exit code 1: application error
# Exit code 137: OOMKilled (memory limit exceeded)
# Exit code 143: SIGTERM (pod was asked to stop)Container can't connect to a service
# Run a debug pod in the same namespace
kubectl run debug --image=alpine -it --rm -n production -- /bin/sh
# Inside the debug pod:
nslookup my-api.production.svc.cluster.local
curl http://my-api/health
wget -O- http://postgres:5432 || echo "connection refused = port open but not http"OOMKilled — container exceeds memory limit
kubectl describe pod <pod-name> -n production
# Containers:
# api:
# Last State: Terminated
# Reason: OOMKilled
# Exit Code: 137
# Fix: increase memory limit or find the memory leak
# Check actual memory usage first:
kubectl top pod <pod-name> -n productionKey Takeaways
- Pods run your containers — ephemeral, no stable IP, always created via Deployments
- Deployments manage pods: desired replicas, rolling updates, self-healing, rollback
- Services give pods a stable DNS name and load balance across them — kube-proxy handles the routing
- Ingress routes external HTTP traffic by host/path, with TLS termination
- ConfigMaps for config, Secrets for sensitive values — never bake them into images
- Liveness probe: restart if unhealthy; Readiness probe: remove from Service rotation until ready
- HPA scales replicas based on CPU, memory, or custom metrics — scale on request rate, not CPU
- Namespaces isolate resources by team or environment
- Persistent Volumes required for stateful workloads — prefer managed DB services in production
- Start with
kubectl describeandkubectl logsfor debugging; port-forward for local access
Run Kubernetes managed (EKS, GKE, AKS) — self-managed control planes are a maintenance burden that rarely makes sense.
FAQ
What is a pod in Kubernetes?
A pod is the smallest deployable unit in Kubernetes. It runs one or more containers that share the same network namespace (same IP address) and can share storage volumes. All containers in a pod communicate via localhost. Pods are ephemeral — they can be killed and replaced at any time. In practice, you rarely create pods directly; you use Deployments, which manage pod lifecycle automatically.
What is the difference between a Deployment and a Pod in Kubernetes?
A Pod is a single running instance of your container(s). A Deployment is a controller that manages pods — it ensures the desired number of pod replicas are running, handles rolling updates (replacing old pods with new ones without downtime), and recreates pods if they crash. You should almost always create a Deployment rather than individual Pods, except for one-off debugging or jobs.
How does Kubernetes networking work?
Every pod gets its own IP address. Pods can communicate directly with each other across nodes without NAT. Services provide stable IPs and DNS names that don't change when pods restart — kube-proxy on each node programs iptables rules to load balance traffic from the Service IP to the pod IPs behind it. CoreDNS resolves service names (my-api.production.svc.cluster.local) to ClusterIP addresses. External traffic enters through a LoadBalancer Service or Ingress controller.
What is a Kubernetes Ingress?
An Ingress is a resource that defines routing rules for external HTTP/HTTPS traffic into the cluster. It routes by hostname (api.example.com) and URL path (/api, /docs). An Ingress Controller (typically nginx-ingress, Traefik, or a cloud-native option like AWS ALB Ingress) reads these rules and configures the actual proxy. Ingress also handles TLS termination — you attach a certificate, and HTTPS is handled by the controller before traffic reaches your pods.
What is the difference between a liveness probe and a readiness probe?
A liveness probe answers "is this container still alive?" — if it fails, Kubernetes restarts the pod. Use it to detect deadlocks or infinite loops where the process is running but stuck. A readiness probe answers "is this container ready to serve traffic?" — if it fails, the pod is removed from the Service's endpoint list (no more traffic) but NOT restarted. Use it to indicate when the app has finished startup or is temporarily busy. A pod can be alive (liveness passes) but not ready (readiness fails) — this happens during startup, during migrations, or when a dependency is temporarily unavailable.
How does HPA (Horizontal Pod Autoscaler) work?
HPA monitors metrics (CPU utilization, memory, or custom metrics from Prometheus/KEDA) for a Deployment and adjusts the replica count to maintain a target. For example, HPA targeting 70% CPU utilization will add replicas when CPU exceeds 70% and remove replicas when it drops below. Scaling up is aggressive (reacts quickly); scaling down is cautious (configurable stabilization window, default 5 minutes, to prevent flapping). Resource requests must be set on the pod — HPA calculates utilization as (actual usage / requested amount).
Should I run my database in Kubernetes?
Generally no, especially for production. Running databases (PostgreSQL, MySQL, Redis) in Kubernetes adds operational complexity: you need to manage persistent volumes, handle StatefulSet upgrades carefully, ensure data is backed up, and handle failover. Managed database services (AWS RDS, Google Cloud SQL, Azure Database) do this for you, with automated backups, point-in-time recovery, and failover built in. For development or testing, databases in Kubernetes are fine. For production, use a managed service and connect from your Kubernetes pods.
Related reading: Service Discovery Explained · Microservices vs Monolith · Vertical vs Horizontal Scaling
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Vertical vs Horizontal Scaling: When to Use Each
Learn the difference between vertical and horizontal scaling. Understand trade-offs, real costs, auto-scaling, stateful vs stateless design, and when each strategy makes sense for your system.
API Gateway Pattern: The Front Door to Your Microservices
What is an API gateway and what does it do? Complete guide covering routing, authentication, rate limiting, Node.js implementation, BFF pattern, and comparisons of Kong, AWS API Gateway, nginx, and Traefik.
Distributed Tracing: Debug Requests Across Services
Learn how distributed tracing works and how to implement it. Covers trace IDs, spans, OpenTelemetry, Jaeger, Zipkin, sampling strategies, and how to find performance bottlenecks in microservices.