Kubernetes Explained for People Who Ship Software

One control loop, five object types, and about sixty lines of YAML. Everything you need to run a real service, plus an honest look at when you should not run a cluster at all.

control plane desired state node-1 node-2 node-3 ingress :443
5
Object types cover most apps
3
Probe types you must know
137
Exit code for OOMKilled
25%
Default rolling-update surge

Kubernetes is a control loop that keeps your containers running the way you said they should run. You hand the cluster a description of the end state you want: this image, three copies, this much memory, reachable at this address, restarted if it stops answering. Controllers inside the cluster compare that description against what is actually running, several times a second, and close the gap. That is the entire product. Pods, Services, Ingress, probes, and rollouts are just vocabulary for writing the description precisely.

137
Exit code your container reports when it exceeds its memory limit and gets OOMKilled
5min
Cap on the exponential restart delay behind CrashLoopBackOff
30s
Default grace period between SIGTERM and SIGKILL when a pod is terminated

Key Takeaways

This is written for people who already ship containers and now have to run them on somebody else's cluster. Every YAML block is complete enough to apply. For the conceptual version first, read Kubernetes Explained; if containers are new, start with Docker for Beginners.

01

What Kubernetes Actually Does

Kubernetes stores your desired state in etcd and runs controllers that reconcile reality against it. You never tell the cluster to start a container. You declare that three replicas should exist: the Deployment controller creates a ReplicaSet, that controller creates Pods, the scheduler assigns each to a node, and the kubelet starts the container. When a node dies at 3 a.m., the same loop replaces the pods elsewhere with nobody awake.

So kubectl apply does not do something; it records an intention. If an apply succeeds and nothing happens, the cluster accepted the intent and something downstream will not satisfy it. That is why the first diagnostic move is reading events, not logs.

Four pieces show up in error messages: the API server validates and stores everything, etcd is the database behind it, the scheduler picks a node from resource requests and constraints, and the kubelet starts, watches, and kills containers. EKS, GKE, and AKS run the first three and bill an hourly control-plane fee on top of node costs.

The Five Objects That Cover Most Applications

Everything else is a variation on those five or plumbing you meet later. One habit helps in week one: kubectl explain deployment.spec.strategy.rollingUpdate prints the schema straight from your own API server, so it is never out of date.

02

Pods: The Thing That Actually Runs

A Pod is one or more containers scheduled together on one node, sharing an IP, a network namespace, and any volumes you attach. Containers in a pod reach each other on localhost. Pods are disposable: never moved to another node, never given a stable identity, never edited in place. When something changes, the old pod is deleted and a new one appears with a new name and IP.

Almost nobody writes a bare Pod outside of debugging, but every field you later put in a Deployment lives inside a pod template.

Shell Poke at a cluster with a throwaway pod
# Start a shell in the cluster, delete it when you exit
kubectl run debug --rm -it --image=busybox:1.36 --restart=Never -- sh

# Inside that shell, DNS and service discovery already work:
#   wget -qO- http://checkout-api.default.svc.cluster.local/healthz

# Attach to a running container instead of making a new one
kubectl exec -it deploy/checkout-api -- sh

Two multi-container patterns recur. A sidecar runs beside the main container for the life of the pod, usually a log shipper or mesh proxy. An init container runs to completion first, usually a schema migration or a dependency wait. Both share the pod's volumes.

03

Deployments: The File You Actually Write

A Deployment owns a pod template, a replica count, and a rollout strategy. Change the template and the controller creates a new ReplicaSet, scales it up while scaling the old one down, and keeps previous versions for rollback. This object holds most of your day-to-day YAML.

Here is a Deployment with everything a production service needs and nothing it does not. Each commented field is covered below.

YAML deployment.yaml · a complete, applyable Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-api
  labels:
    app: checkout-api
spec:
  replicas: 3
  revisionHistoryLimit: 5            # keep 5 old ReplicaSets for rollback
  minReadySeconds: 10                # a pod must stay ready 10s to count
  progressDeadlineSeconds: 300       # mark the rollout failed after 5 min
  selector:
    matchLabels:
      app: checkout-api           # must match template labels exactly
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                  # one extra pod during the roll
      maxUnavailable: 0            # never drop below 3 healthy pods
  template:
    metadata:
      labels:
        app: checkout-api
    spec:
      terminationGracePeriodSeconds: 45
      containers:
        - name: api
          image: ghcr.io/acme/checkout-api:1.8.3   # pin a tag, never :latest
          ports:
            - name: http
              containerPort: 8080
          envFrom:
            - configMapRef:
                name: checkout-config
            - secretRef:
                name: checkout-secrets
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              memory: "256Mi"       # equal to request; no CPU limit on purpose
          startupProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 5
            failureThreshold: 30       # allows up to 150s of slow boot
          readinessProbe:
            httpGet: { path: /readyz, port: http }
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 10
            failureThreshold: 3

Two small details cause outsized grief. selector.matchLabels is immutable, so a mistake means deleting and recreating the Deployment. And image: myapp:latest defeats the rollout system, because nothing can tell the tag now points at different bytes. Pin a tag or digest and let CI write the value.

Deployments assume stateless workloads. When that breaks, a StatefulSet gives each pod a stable name and its own volume (what databases need) and a DaemonSet runs one pod per node (what log collectors need).

04

Services: Stable Addresses for Unstable Pods

Pod IPs change on every restart, so nothing should address a pod directly. A Service gives you one virtual IP and one DNS name that survive every rollout, load-balanced across whichever pods match its label selector and pass readiness. The name resolves as service.namespace.svc.cluster.local; inside the same namespace the short name works.

YAML service.yaml · ClusterIP fronting the Deployment above
apiVersion: v1
kind: Service
metadata:
  name: checkout-api
spec:
  type: ClusterIP                # the default; in-cluster only
  selector:
    app: checkout-api           # matches pod labels, NOT the Deployment
  ports:
    - name: http
      port: 80                   # the port the Service listens on
      targetPort: http           # the named containerPort on the pod

A Service selects on pod labels, not on the Deployment; nothing links the two except a shared label. That looseness lets two Deployments sit behind one Service, which is a blue-green cutover with no extra tooling. It is also why a label typo yields a Service routing to nothing rather than an error at apply time. That bug reads as connection refused with no error anywhere, and one command settles it: kubectl get endpointslices -l kubernetes.io/service-name=checkout-api.

Four Service types matter. ClusterIP is internal only and is your default. NodePort opens a high port on every node and is mostly a building block. LoadBalancer provisions a cloud load balancer per Service, which is why Ingress exists. ExternalName is a DNS alias out of the cluster. clusterIP: None makes a headless Service returning pod addresses directly, which is how StatefulSet members find each other.

05

Ingress and the Gateway API: Getting Traffic In

An Ingress is a routing table for HTTP: hostnames and paths on the left, Services on the right, TLS attached. It does nothing alone. An ingress controller (ingress-nginx, Traefik, or your cloud's) must be installed to watch Ingress objects and configure a real proxy. Many Ingress objects share one load balancer, which is the cost argument against a LoadBalancer Service per app.

YAML ingress.yaml · host and path routing with TLS
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: checkout-api
  annotations:
    # annotations are controller-specific, not portable
    nginx.ingress.kubernetes.io/proxy-body-size: "8m"
spec:
  ingressClassName: nginx
  tls:
    - hosts: ["api.example.com"]
      secretName: checkout-tls   # a Secret of type kubernetes.io/tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /checkout
            pathType: Prefix
            backend:
              service:
                name: checkout-api
                port:
                  number: 80

Ingress is stable but feature-frozen, which is why every non-trivial capability arrived as a vendor annotation and why routing config is not portable between controllers. The Gateway API replaces it and splits the object by role: a platform team owns the Gateway (listener, certificate, IP), application teams own HTTPRoute objects attached to it. Header matching, traffic splitting, and mirroring are real fields.

YAML httproute.yaml · the Gateway API equivalent, with a canary split
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: checkout-api
spec:
  parentRefs:
    - name: public-gateway       # owned by the platform team
  hostnames: ["api.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: /checkout }
      backendRefs:
        - { name: checkout-api,    port: 80, weight: 90 }
        - { name: checkout-api-v2, port: 80, weight: 10 }

Start new clusters on the Gateway API if your controller supports it, and leave working Ingress objects alone. There is no reward for migrating a route that works.

06

ConfigMaps and Secrets

ConfigMaps and Secrets are key/value stores injected into pods as environment variables or files on a volume. The only real difference is that Secret values are base64-encoded in the API and can be encrypted at rest and restricted by RBAC. Base64 is encoding, not encryption: anyone who can read Secrets in a namespace can read your passwords.

YAML config.yaml · a ConfigMap and a Secret in one file
apiVersion: v1
kind: ConfigMap
metadata:
  name: checkout-config
data:
  DB_HOST: postgres.data.svc.cluster.local
  DB_POOL_SIZE: "20"          # values must be strings; quote numbers
  FEATURE_NEW_TAX: "false"
---
apiVersion: v1
kind: Secret
metadata:
  name: checkout-secrets
type: Opaque
stringData:                     # stringData takes plaintext; data takes base64
  DB_PASSWORD: "replace-me-from-a-secret-manager"
  PAYMENT_API_KEY: "replace-me-from-a-secret-manager"

The surprise: environment variables are read once, at container start. Editing a ConfigMap changes nothing in a running pod and restarts nothing. Volume-mounted files do update after a kubelet sync, but your process still has to notice. The reliable pattern is hashing the config into a pod-template annotation, so any change produces a new template and a normal rollout.

Secret Handling That Survives an Audit

07

Resource Requests and Limits

A request is a scheduling promise: the scheduler only places your pod where that much CPU and memory is free, and reserves it. A limit is a runtime ceiling enforced by the kernel. CPU is compressible, so exceeding a CPU limit throttles. Memory is not, so exceeding a memory limit kills the container with exit code 137. These two numbers cause most self-inflicted Kubernetes incidents.

CPU is measured in millicores: 1000m is one full core, 100m is a tenth of a core. Memory uses binary suffixes, and the difference matters: 256Mi is 268,435,456 bytes while 256M is 256,000,000 bytes.

Setting What it controls What happens when you exceed it Recommendation
CPU request Scheduling and the share of CPU you are guaranteed under contention Nothing; you can burst into idle CPU on the node Always set, from observed p50 to p90.
CPU limit Hard ceiling enforced by the kernel CFS quota Throttling. Latency spikes with low average CPU. Often best left unset for latency-sensitive services.
Memory request Scheduling and eviction ranking under node pressure Eviction risk rises when the node runs low Always set, from observed peak plus headroom.
Memory limit Hard ceiling; the cgroup OOM killer enforces it Container killed, exit code 137, restart counter increments Set it equal to the request.

Request equal to limit gives the pod the Guaranteed quality-of-service class, evicted last under node pressure. Requests below limits give Burstable. Neither set gives BestEffort, the first thing evicted, which has no place in production.

Do not guess these numbers. Run the workload, watch kubectl top pods through a peak week, then set requests from real percentiles. In a shared namespace a LimitRange supplies defaults and a ResourceQuota caps the total. Both belong in your cost work: over-requested pods are the largest source of wasted cluster spend.

08

Probes: Readiness, Liveness, Startup

Readiness controls traffic: fail it and the pod is pulled from the Service endpoints but keeps running. Liveness controls restarts: fail it past the threshold and the kubelet kills the container. Startup suspends the other two until the app has finished booting. Use all three, keep liveness cheap, and never let a liveness probe depend on anything outside the process.

The failure that takes services down is a liveness probe aimed at an endpoint that checks the database. When the database has a bad ninety seconds, every replica fails liveness at once, the kubelet restarts all of them, and you have a cold cache and an empty connection pool on top of the original problem. Liveness answers one question: can this process serve a trivial request?

Python app.py · what the two endpoints should actually do
from fastapi import FastAPI, Response

app = FastAPI()
ready = {"db": False}

# Liveness: no I/O, no dependencies, answers instantly.
# If this fails, the process is genuinely wedged.
@app.get("/healthz")
def healthz():
    return {"status": "ok"}

# Readiness: check the things a request needs.
# Failing here removes the pod from the Service, nothing restarts.
@app.get("/readyz")
def readyz(response: Response):
    if not ready["db"]:
        response.status_code = 503
        return {"status": "db pool not warm"}
    return {"status": "ready"}

Three tuning notes. Total tolerance is periodSeconds times failureThreshold, so a 10-second period with a threshold of 3 allows about 30 seconds before a restart. timeoutSeconds defaults to 1, brutal for a JVM under load. And a slow starter wants a startupProbe with a generous threshold, not a long initialDelaySeconds, because the startup probe stops applying once the app is up while a fixed delay stays wrong forever.

09

Rollouts and Rollbacks

Changing anything in a Deployment's pod template starts a rolling update. The controller shifts replicas from the old ReplicaSet to a new one, bounded by maxSurge and maxUnavailable, waiting for each new pod to pass readiness. With maxUnavailable: 0 you never dip below the replica count, and kubectl rollout undo restores the previous template in seconds.

Shell The five rollout commands worth memorizing
# Ship it, then block until the rollout finishes or the deadline hits
kubectl apply -f k8s/
kubectl rollout status deployment/checkout-api --timeout=180s

# Watch replicas move between the old and new ReplicaSets
kubectl get rs -l app=checkout-api --watch

# Stop a bad rollout in place without reverting the good pods
kubectl rollout pause deployment/checkout-api
kubectl rollout resume deployment/checkout-api

# Go back one revision, or to a specific one
kubectl rollout history deployment/checkout-api
kubectl rollout undo deployment/checkout-api
kubectl rollout undo deployment/checkout-api --to-revision=7

# Force a restart with no image change (picks up rotated secrets)
kubectl rollout restart deployment/checkout-api

Zero-downtime rollouts need help from the application. When a pod is deleted, endpoint removal and SIGTERM travel separate asynchronous paths, so a proxy can still route a request a beat after termination begins. The fix is a preStop hook that sleeps briefly before shutdown, plus a SIGTERM handler that stops accepting connections, drains in-flight ones, and exits inside the grace period.

A rolling update is also not a canary. Its only safety check is your readiness probe, which passes happily for code that starts correctly and computes the wrong answer. For real progressive delivery, weight traffic with an HTTPRoute split or add a controller such as Argo Rollouts or Flagger that watches metrics and aborts on a bad signal.

Know the limit of a rollback. kubectl rollout undo restores a pod template. It does not undo a database migration, un-publish a message, or revert a ConfigMap changed by a separate apply. Treat schema changes as forward-only and expand-then-contract, or a rollback restores code that cannot read the data it finds.

10

The Failure Modes You Will Actually Hit

Each has a distinct signature and a first command. Learn the table and you will diagnose most incidents before opening a dashboard. Read events first, logs second.

Symptom What it means First command
ImagePullBackOff Wrong tag, wrong registry, or missing pull secret kubectl describe pod POD and read the last event
CrashLoopBackOff Container starts then exits; restart delay grows to five minutes kubectl logs POD --previous
Pending forever No node satisfies the requests, taints, or volume zone kubectl describe pod POD, read FailedScheduling
OOMKilled, exit 137 Container exceeded its memory limit kubectl get pod POD -o jsonpath='{.status.containerStatuses[0].lastState}'
CreateContainerConfigError A referenced ConfigMap or Secret key does not exist kubectl describe pod POD
503 through the ingress Service has no ready endpoints behind it kubectl get endpointslices -l kubernetes.io/service-name=NAME
Running but no traffic Readiness failing, or the selector matches no pod labels kubectl get pods -l app=NAME, check the READY column
Stuck Terminating A finalizer is waiting, or the process ignores SIGTERM kubectl get pod POD -o yaml, look at metadata.finalizers
Shell A local cluster you can break safely
# kind runs a full cluster inside Docker; k3d and minikube also work
brew install kind kubectl
kind create cluster --name dev
# pin a version to match production if you need to:
#   kind create cluster --name dev --image kindest/node:vX.YY.Z

kubectl cluster-info --context kind-dev
kubectl apply -f k8s/
kubectl get events --sort-by=.lastTimestamp | tail -20

# Reach a Service without any ingress at all
kubectl port-forward svc/checkout-api 8080:80

kind delete cluster --name dev
11

What Production Adds

The objects above run a service. Running one responsibly adds a handful more, each about ten lines.

01

Autoscaling

A HorizontalPodAutoscaler on autoscaling/v2 scales replicas on CPU, memory, or a custom metric. It needs requests to compute utilization, so requests come first.

No requests, no HPA
02

Disruption budgets

A PodDisruptionBudget with minAvailable: 2 stops a node drain or cluster upgrade from evicting all your replicas at once.

Survive your own maintenance
03

Namespaces and RBAC

Namespaces scope names, quotas, and network policy. Bind each workload to its own ServiceAccount with the narrowest Role that works.

Default deny beats cleanup
04

Upgrades

Kubernetes ships several minor releases a year, each supported about fourteen months, so a cluster is a standing maintenance commitment.

Somebody has to own this

Observability changes shape here. Container logs die with the pod, so kubectl logs reads a buffer that will not survive the next rollout; anything wanted for an incident review must be shipped off the node by a DaemonSet agent. kubectl top is the same story. Set up log shipping and a metrics backend before the first production workload.

Two habits pay for themselves. Spread replicas across zones with topologySpreadConstraints, or the scheduler may put all three copies on one node. And template your manifests once you have more than one environment: Helm for packaging, Kustomize for overlays, Terraform for the cluster underneath. Copy-pasted YAML goes wrong quietly.

12

Kubernetes vs the Alternatives, Honestly

Kubernetes wins on portability, breadth, and the size of its talent pool. It loses on operational cost for small teams. Every alternative below trades flexibility for a smaller surface, and for an application with a handful of services that trade is usually a good one.

Option Ops burden Scales to Best when
Managed Kubernetes (EKS, GKE, AKS) Medium to high: nodes, upgrades, add-ons Hundreds of services, many teams Many teams, many services, portability matters
GKE Autopilot / similar node-free modes Low: no nodes to manage, per-pod billing Same API, most workloads You want the Kubernetes API without running nodes
ECS on Fargate Low: no cluster to upgrade Large, within AWS All-in on AWS and happy to stay there
Cloud Run / App Runner / Render Very low: push an image, get a URL Fine for most web APIs Request-driven HTTP services, small teams
Docker Compose on one VM Very low, until the VM dies One machine Internal tools, side projects, early prototypes
Nomad Medium, but a much smaller surface Large Mixed container and non-container workloads

Note what is missing: a serverless function platform. Functions are a different programming model, not another way to run the same container, so that comparison only makes sense once you have chosen a unit of deployment. It is covered in the serverless guide.

13

When Kubernetes Is the Wrong Choice

Kubernetes is good software and the wrong answer for a large share of the teams running it. The clearest signal is organizational, not technical.

Skip the cluster

You have three services and nobody who owns infrastructure

You will spend more time on ingress controllers, cert renewal, node pools, and version upgrades than on the product. A managed container platform gives the same availability with one config file and no cluster to maintain.

Worth it

You have thirty services and four teams shipping daily

Now you need shared conventions, bin-packing across a fleet, per-team quotas, and one deployment interface. That is the problem Kubernetes was built for, and nothing else covers it as completely.

Concrete Cases Where the Answer Is No

There is a hiring dimension too. Kubernetes skills are common enough to hire for, a real argument at scale. But a two-person team running a cluster has handed a full-time infrastructure job to someone hired to write features. Moving a well-built container onto Kubernetes later is a week of work, not a rewrite.

The Short Version

The bottom line: Kubernetes is one idea, reconciling reality against declared state, wrapped in a large vocabulary. A Deployment, a Service, an Ingress, a ConfigMap, and a Secret will run almost any web service. Set requests from measured usage, set memory limits equal to requests, keep liveness local and readiness honest, handle SIGTERM properly, and most of the ways a cluster can hurt you are gone. Then ask the harder question: should your team run one at all? For many teams, the right amount of Kubernetes is none.

Frequently Asked Questions

What is the difference between Docker and Kubernetes?

Docker builds and runs a container on one machine. Kubernetes decides which machines run which containers, restarts them when they die, gives them stable network names, and replaces them one at a time during a release. You still build images with Docker or another OCI builder, then hand them to Kubernetes to run. Kubernetes has not used the Docker daemon as its runtime since version 1.24; it talks to containerd or CRI-O through the Container Runtime Interface. Docker-built images keep working because the image format is a shared standard. The Docker and containers guide covers the build side in detail.

Do I need Kubernetes if I only run a few services?

Usually not. With one to three services, no dedicated platform engineer, and traffic that fits on a couple of machines, a container platform such as AWS App Runner, Google Cloud Run, ECS Fargate, or Render gives the same uptime with a fraction of the operational surface. Kubernetes pays off when many teams deploy many services, when you need bin-packing across a fleet, or when portability across clouds matters. The honest test: will someone on your team own cluster upgrades three times a year? If nobody will, do not run a cluster.

Should I set CPU limits on my containers?

Always set CPU requests. Be careful with CPU limits. CPU is compressible, so a limit does not kill the container; it throttles it through the Linux CFS quota, and throttling shows up as latency spikes even when average utilization looks low. Many teams set CPU requests to the steady-state need and leave CPU limits off so a pod can absorb bursts on idle node capacity. Memory is not compressible, so a container over its memory limit is killed with exit code 137. Set a memory request and make the limit equal to it, which also earns the Guaranteed quality-of-service class.

What is the difference between a liveness probe and a readiness probe?

A readiness probe controls traffic: when it fails, the pod leaves the Service endpoints and stops receiving requests, but the container keeps running. A liveness probe controls restarts: when it fails past the threshold, the kubelet kills and restarts the container. The common mistake is pointing liveness at an endpoint that checks the database or a downstream API. When that dependency has a bad minute, every replica fails liveness at once and the cluster restarts your whole service mid-outage. Keep liveness cheap and local; put dependency checks in readiness.

Reference documentation: Kubernetes Concepts, Kubernetes API Reference, Gateway API

Explore More Guides

The Bottom Line
Kubernetes is one control loop with a big vocabulary. Learn five objects, measure your resource usage, handle SIGTERM, and then decide honestly whether your team should run a cluster at all.
PA
Our Take

The hard part of Kubernetes was never the YAML.

Most people who struggle with Kubernetes are not struggling with syntax. They are struggling because they were handed a distributed system and told it was a deployment tool. A Deployment manifest looks like a config file, so it invites the assumption that applying it is like running a script. It is not. Applying it registers an intention, and then a dozen independent controllers negotiate with each other about how to satisfy it. Once that clicks, the error messages stop being mysterious, because almost all of them are one of the controllers explaining why it cannot do what you asked.

The second thing that clicks late is that Kubernetes will not make a fragile application reliable. It restarts things, and restarting is a blunt instrument. An app that leaks memory gets OOMKilled on a schedule instead of once a week. An app that ignores SIGTERM drops requests on every deploy, forever, silently. An app with a slow cold start turns every rolling update into a latency event. The cluster faithfully amplifies whatever behaviour you gave it. Teams that get good results on Kubernetes usually did the unglamorous work first: graceful shutdown, fast startup, real health endpoints, bounded memory.

The most useful trend of the last couple of years is not a new feature, it is the steady move toward not running nodes. Node-free modes on the managed offerings, plus the Gateway API cleanly separating platform ownership from application ownership, mean a small team can use the Kubernetes API without inheriting the full job of running a cluster. That is the version we would recommend to most teams that have outgrown a single VM but do not yet have anyone whose job title contains the word platform.

PA

About the Publisher

Precision AI Academy

Practitioner-focused AI education ยท tech news, guides, and 145 free courses

Precision AI Academy publishes deep-dives on applied AI engineering for working professionals. Founded by Bo Peng, Kaggle Top 200 data scientist and former university instructor.

Kaggle Top 200 Federal AI Practitioner 5 U.S. Cities Thu–Fri Cohorts