What Kubernetes is: desired state and reconciliation
Kubernetes is a declarative system. You don't tell it "start three containers on these machines." You submit objects to the API server that describe what you want, for example "three replicas of this image, each with 256 MiB of memory." The cluster stores that as desired state. Controllers then run continuously. Each one watches a type of object, compares the desired state with the actual state it observes, and acts on the difference.
That reconciliation loop explains most of what Kubernetes is known for. A node dies and two of your three replicas disappear. Nobody has to run a recovery script, because the controller sees two missing replicas and creates them elsewhere. A rollout works the same way. When you change the image tag, the desired state changes, and the controller moves the actual state toward it at a controlled rate, replacing old instances with new ones rather than all at once. The Kubernetes overview describes self-healing and automated rollouts as consequences of this model.
Three objects cover most of what a backend developer touches:
- Pods answer what runs: one or more containers scheduled together.
- Deployments answer how it gets replaced: they keep the right number of Pods alive and roll out new versions.
- Services answer how traffic finds it: a stable name and address in front of Pods that come and go.
Teams adopt Kubernetes when they run many services and want one consistent way to deploy, scale and recover them. It suits organizations with the platform skills to operate it. For one or two apps, it is often more machinery than the problem needs.
kubectl apply -f app.yaml
|
v
+------------------+ stores +-------------------------------+
| API server |---------->| desired: Deployment, 3 Pods |
+------------------+ +-------------------------------+
^ |
| watch / write | compare
| v
+--------------------------------------------------------------+
| controller loop: observe actual -> diff vs desired -> act |
| Pod missing? create one. New image? replace Pods gradually |
+--------------------------------------------------------------+
| ^
| create / delete Pods | observe
v |
+--------------------------------------------------------------+
| actual state (nodes) |
| +-----------+ +-----------+ +-----------+ |
| | Pod | | Pod | | Pod | |
| +-----------+ +-----------+ +-----------+ |
+--------------------------------------------------------------+
^ ^ ^
+---------------------+---------------------+
|
+---------------------+
client traffic ------> | Service: stable |
| name + address |
+---------------------+
You submit desired state to the API server, a controller loop keeps comparing it with the actual Pods and closes the gap, and a Service gives traffic a stable entry point to whichever Pods currently exist.
Pods and Deployments: what runs and how it gets replaced
A Pod is the smallest unit Kubernetes schedules. It wraps one or more containers that share a network namespace, so they reach each other on localhost, and can mount the same volumes. Pods are disposable. When a node fails or you ship a new version, Kubernetes replaces the Pod instead of repairing it, and the replacement usually gets a new IP. Your code should never depend on a specific Pod's address or local disk.
You rarely create Pods directly. A Deployment manifest declares replicas (how many copies), a selector (which Pods it owns), and a Pod template with labels, the container image, ports and env. The template's labels must match the selector, because labels and selectors are how Kubernetes tracks ownership. Names alone do not do this.
The Deployment does not manage Pods itself. It creates a ReplicaSet for each version of the template, and the ReplicaSet keeps the right number of matching Pods running. Changing the image makes a new ReplicaSet. The Deployment then scales the new one up and the old one down step by step. That step-by-step replacement is a rolling update.
You start a rollout with kubectl apply -f, watch it with kubectl rollout status, and revert with kubectl rollout undo. A rollback can be quick because the previous ReplicaSet is kept, scaled to zero, and can be scaled back up.
During a rollout, old and new Pods serve traffic side by side. API responses and database schema changes must work with both versions, or some users will hit errors partway through the rollout.
# deployment.yaml
# Apply: kubectl apply -f deployment.yaml
# Watch: kubectl rollout status deployment/orders-api
# Revert: kubectl rollout undo deployment/orders-api
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3 # keep three identical Pods running
selector:
matchLabels:
app: orders-api # the Deployment owns Pods carrying this label
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # at most one Pod below the desired count during a rollout
maxSurge: 1 # at most one extra Pod above the desired count
template:
metadata:
labels:
app: orders-api # must match spec.selector.matchLabels
spec:
containers:
- name: orders-api
image: registry.example.com/orders-api:1.4.2 # changing this tag creates a new ReplicaSet
ports:
- containerPort: 8080
env:
- name: LOG_LEVEL
value: "info"
- name: DB_HOST
value: "orders-db" # reach dependencies by a stable name, never by a Pod IP
A minimal Deployment whose template labels match its selector, with comments on the kubectl commands that apply it, watch the rolling update and roll it back.
Services: how traffic finds a changing set of Pods
Each Pod gets a new IP address when it is replaced, so clients cannot rely on Pod addresses. A Service solves this by providing a stable virtual IP and DNS name. Inside the same namespace, that name can be as short as orders. The Service routes to whichever Pods currently match its label selector. Kubernetes updates the endpoint list continuously and includes only Pods whose readiness probe passes. This is how a rollout swaps Pods without clients changing their configuration.
| Type | Reachable from | Typical use |
|---|---|---|
| ClusterIP (default) | Inside the cluster only | Service-to-service calls |
| NodePort | A fixed port on every node's IP | Simple external access, or a base for an external load balancer |
| LoadBalancer | An external load balancer provisioned by the cloud or cluster integration | Public or VPC-facing entry points |
A Service has two port fields:
portis what clients connect to on the Service.targetPortis the port the container actually listens on.
With port: 80 and targetPort: 8080, callers use while the app binds to 8080. GettingtargetPort` wrong produces connection-refused errors even though everything else looks healthy.
The selector must match the Pod template's labels exactly. If the Service selects app: orders but the Pods are labelled app: order-service, DNS still resolves, but the Service has no endpoints and every connection fails. Running kubectl describe service orders shows an empty endpoint list in that case.
Load balancing happens per connection, not per request. HTTP/2 and gRPC clients send many requests over one long-lived connection, so traffic can stay pinned to a few Pods. Pods added during a scale-up may get almost nothing. There are three common fixes:
- Client-side balancing against a headless Service
- A proxy that balances per request
- A maximum connection age on the server, so clients reconnect periodically
# Service: stable virtual IP and DNS name ("orders") in front of a changing set of Pods
apiVersion: v1
kind: Service
metadata:
name: orders
spec:
type: ClusterIP # default; reachable only inside the cluster
selector:
app: orders # must match the Pod template labels exactly
ports:
- name: http
port: 80 # what clients connect to:
targetPort: 8080 # what the container actually listens on
---
# Deployment whose Pods the Service selects
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 3
selector:
matchLabels:
app: orders
template:
metadata:
labels:
app: orders # "app: order-service" here would leave the Service with no endpoints
spec:
containers:
- name: orders
image: registry.example.com/orders:1.4.2
ports:
- containerPort: 8080
readinessProbe: # only Pods passing this are added to the Service's endpoints
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
# Check endpoints: kubectl get endpointslices -l kubernetes.io/service-name=orders
A ClusterIP Service mapping port 80 to container port 8080, selecting Pods by the same label used in the Deployment template, with a readiness probe that controls which Pods receive traffic.
Making your app a good Kubernetes citizen: probes, resources, config and shutdown
Even with traffic spread evenly, a Pod can still cause outages if it misreports its health, uses more memory than it was given, or exits mid-request. Each of the three probes triggers a different action. A failed liveness probe makes the kubelet restart the container. A failed readiness probe removes the Pod from the Service's endpoints but leaves it running. A startup probe holds off the other two checks until it succeeds, so a slow-booting app isn't killed while it warms up. Keep liveness shallow: it should only check whether the process can still respond. If liveness also checks the database, a database outage restarts every replica, and they all restart again on the next failure. Put dependency checks in readiness instead, so affected Pods stop taking traffic and nothing restarts.
Requests decide where a Pod is scheduled: the scheduler places it only on a node with that much CPU and memory still unreserved. Limits apply while the container runs. If it goes over its memory limit, it is OOM-killed. If it goes over its CPU limit, it is throttled, which shows up as extra latency rather than errors. Set both from measured usage, not guesses.
Inject configuration from ConfigMaps and Secrets as environment variables or mounted files, so the same image runs in every environment. Secrets are only base64-encoded. Whether they are encrypted at rest depends on how the cluster is configured.
On shutdown, the container receives SIGTERM. Stop accepting new work and finish in-flight requests. Endpoint removal reaches proxies a little after SIGTERM, so keep serving briefly before exiting. You have until the grace period ends, then the container gets SIGKILL. The snippet below sets probes, resources and env together.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
replicas: 3
selector:
matchLabels: {app: orders-api}
template:
metadata:
labels: {app: orders-api}
spec:
terminationGracePeriodSeconds: 30 # time between SIGTERM and SIGKILL; must cover draining
containers:
- name: api
image: registry.example.com/orders-api:1.0.0 # same image in every environment
ports: [{containerPort: 8080}]
envFrom:
- configMapRef: {name: orders-api-config} # non-secret settings, one ConfigMap per environment
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef: {name: orders-api-db, key: password} # stored base64-encoded, not encrypted by default
resources:
requests: {cpu: 250m, memory: 256Mi} # scheduler places the Pod using these
limits: {cpu: 500m, memory: 512Mi} # over CPU = throttled; over memory = OOM-killed
startupProbe: # liveness and readiness wait until this succeeds
httpGet: {path: /healthz, port: 8080}
periodSeconds: 5
failureThreshold: 24 # allows up to 120s to boot
livenessProbe: # shallow: can the process respond? failure restarts the container
httpGet: {path: /healthz, port: 8080}
periodSeconds: 10
readinessProbe: # checks dependencies; failure only removes the Pod from Service endpoints
httpGet: {path: /ready, port: 8080}
periodSeconds: 5
A Deployment that sets startup, liveness and readiness probes, CPU and memory requests and limits, ConfigMap and Secret injection as environment variables, and a termination grace period for graceful shutdown.
A practical debugging loop for a broken service
When a Service misbehaves, run the same four steps in the same order. Each step narrows the problem before you move to the next.
kubectl get podsshows whether the Pods exist, their status, their restart counts and how many containers are ready. A rising restart count tells you the Pod is crashing, even when it shows as Running.kubectl describe pod <name>shows the Events section and the container's Last State. Scheduling failures, image pull errors, probe failures and the termination reason all appear here, so you see the cluster's view before you read any application output.kubectl logs <name>shows what the application is printing now. Add--previousto read the logs of the last terminated container. Without it, a crash-looping Pod often shows only the start of a fresh, still-healthy run.kubectl get endpoints <service>confirms whether the Service has selected any ready Pods. An empty list means traffic has nowhere to go, even if every Pod is healthy.
| Symptom | Where it shows | Likely cause |
|---|---|---|
CrashLoopBackOff |
get, logs --previous |
Process exits on startup: bad config, missing secret, unreachable dependency |
OOMKilled |
describe (Last State) | Memory limit is below the real peak, or the app has a leak |
| Pod never ready | describe (Events) | Readiness probe uses the wrong port or path, or a dependency check fails |
| No endpoints | get endpoints | Service selector doesn't match Pod labels, or no Pods are ready |
The routine saves time because each step rules out a layer: scheduling, then the process, then the application, then routing. Without it, engineers tend to start with whichever layer they know best, such as reading application code to explain a label typo. Most of the time goes into ruling out the wrong layer, and a fixed order prevents that.
When to use Kubernetes and when to avoid it
Kubernetes earns its operational cost when several conditions hold together. You run many services, often a dozen or more, that share deployment patterns, networking and observability. You deploy frequently, so automated rollouts and rollbacks replace work someone would otherwise do by hand. You need self-healing, meaning crashed containers restart and failed nodes get their workloads rescheduled without anyone being paged. And you have a team that can own the platform: upgrades, networking, RBAC, node pools and the steady stream of deprecations. That last condition is the one most often skipped, and a cluster nobody owns degrades quickly.
Kubernetes is usually not worth adopting for a single service, or for a small team where the same few people write features and run infrastructure. The control plane, manifests and failure modes become a second system to learn and maintain, and that cost can exceed the cost of the application itself. A managed container service or a plain VM with a process supervisor is easier to reason about and to debug.
Some applications also fit badly regardless of team size. Pods are disposable and their IPs change, so software that writes important data to local disk, keeps sticky sessions in memory, or expects a fixed IP will break or behave inconsistently after a reschedule or rollout. StatefulSets and persistent volumes cover some of these cases, but they bring their own complexity: ordered startup, volume attachment tied to specific zones, and backup and recovery work that you have to design yourself.
Alternatives and impact on cost and engineering time
…operate yourself. If most of your workload is stateless HTTP services and the state already lives in managed databases, you have a wider set of choices. Each one decides how much of the runtime you own.
| Option | What your team operates | Flexibility | Skills required |
|---|---|---|---|
| Plain VMs | OS patching, process supervision, deploy scripts, load balancer config | Full, but every pattern is built by hand | Linux administration, configuration management |
| Platform-as-a-service | App code and settings | Low: only the supported runtimes, limits and build process | Application development |
| Managed container runtime | Images, scaling settings | Medium: any image, little control over networking or scheduling | Containers, basic networking |
| Kubernetes | Manifests, node pools, add-ons, version upgrades | High: sidecars, custom controllers, scheduling rules | Containers, networking, the Kubernetes API and tooling |
Kubernetes costs engineering time in three places. The first is the learning curve for each engineer who touches manifests. The second is ongoing cluster work: upgrades, add-on maintenance and node capacity. The third is resource sizing. If requests are set too high, you pay for idle nodes. If they are set too low, containers get CPU throttling or OOM kills. In return, every service deploys, rolls back and restarts after failure in the same way, so you stop maintaining one script per service.
Illustrative estimate (assumed numbers, not a benchmark)
Net hours per month = (deploys × minutes saved per deploy ÷ 60) + (incidents self-healed × hours per incident) − cluster ops hours
- Assume 60 deploys, 10 minutes saved on each, 2 incidents self-healed at 3 hours each, and 20 ops hours: 10 + 6 − 20 = −4 hours. At this size, a PaaS is probably the better choice.
- Now assume 200 deploys with everything else the same: 33 + 6 − 20 = +19 hours. If onboarding takes an assumed 160 hours, it pays for itself after about 160 ÷ 19 ≈ 8 months.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to kubernetes basics, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.