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.
Key Takeaways
- You do not run pods. You write a Deployment, and a controller creates and replaces pods for you.
- Requests schedule, limits enforce. Wrong memory limits kill pods; wrong CPU limits cause latency nobody can explain.
- Readiness gates traffic, liveness gates restarts. Mixing them up turns a dependency blip into a full outage.
- A rollback restores the pod template, not your data.
kubectl rollout undodoes not reverse a database migration. - Not every team should run a cluster. If nobody owns upgrades three times a year, pick a managed container platform instead.
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.
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
- Deployment keeps N copies running and replaces them during a release
- Service gives those copies one stable DNS name and load-balances across them
- Ingress (or an HTTPRoute) routes outside HTTP traffic to a Service and terminates TLS
- ConfigMap holds non-secret configuration as key/value pairs or whole files
- Secret holds credentials, mounted the same way, stored and controlled separately
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.
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.
# 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.
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.
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).
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.
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.
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.
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.
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.
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.
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
- Never commit a real Secret manifest; commit a reference and inject at deploy time
- Turn on encryption at rest for Secrets, or use a provider KMS plugin
- Restrict
getandliston Secrets with RBAC, per namespace, per ServiceAccount - Prefer an external store (Vault, AWS Secrets Manager, GCP Secret Manager) synced in by an operator
- Mount secrets as files, not environment variables, so they stay out of crash dumps and child processes
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.
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?
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.
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.
# 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.
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 |
# 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
What Production Adds
The objects above run a service. Running one responsibly adds a handful more, each about ten lines.
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.
Disruption budgets
A PodDisruptionBudget with minAvailable: 2 stops a node drain or cluster upgrade from evicting all your replicas at once.
Namespaces and RBAC
Namespaces scope names, quotas, and network policy. Bind each workload to its own ServiceAccount with the narrowest Role that works.
Upgrades
Kubernetes ships several minor releases a year, each supported about fourteen months, so a cluster is a standing maintenance commitment.
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.
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.
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.
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.
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
- A single monolith with steady traffic. One or two VMs behind a load balancer is simpler, cheaper, and easier to reason about at 3 a.m.
- Low or spiky traffic. A cluster costs the same at midnight as at noon. Scale-to-zero platforms do not.
- Your database. Postgres operators are mature, but a managed database removes a whole category of failure. Run stateless things on the cluster, buy the stateful ones.
- Scheduled batch jobs. A CronJob is fine if a cluster already exists. It is not a reason to build one.
- No monitoring today. Kubernetes multiplies moving parts. Adding it before metrics, logs, and alerts makes outages harder to explain, not easier.
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
- Kubernetes Explained 2026: How It Actually Works
- Docker and Containers 2026: From Zero to Production
- Microservices Architecture 2026: Build Scalable Systems
- CI/CD Pipeline Guide 2026: Automate Deployments End-to-End
- DevOps Roadmap 2026: Everything You Need to Know to Get Hired
- Distributed Systems Guide 2026: CAP, Consensus, Replication