PhilippTheServer

Docker Compose or Kubernetes: What the Control Loop Actually Buys You

It is not a better Docker Compose. It is a different bargain, and the price is real.

The problem

I run both a Docker host and a Kubernetes cluster in parallel, and people find this inconsistent. It is the most deliberate decision in the whole estate, because the two are not the same tool at different scales — they answer a different question.

The framing that gets everyone into trouble is “Kubernetes is what you graduate to once Compose stops being enough.” That makes the choice sound like a size problem. It is not. A Compose file describes containers on a host you can name. A Kubernetes manifest describes a desired state, and a controller spends forever trying to make the cluster match it. If a host dies under Compose, whatever it was running is gone until a human notices and acts. If a node dies under Kubernetes, the controller notices first, and the human reads about it later.

That is the entire value proposition, and it is easy to buy without understanding what it costs: debugging changes shape completely. With Compose, something is wrong, you SSH in, you read the logs, you see the process — one hop deep. With Kubernetes there is a scheduler, a CNI plugin, a service abstraction, an ingress, and a set of health checks between you and the process, any one of which can be the reason nothing responds. The first few times, working out which layer is lying takes hours for a problem the Compose equivalent would have taken minutes to see.

Working through it

The control loop is the whole feature

Everything Kubernetes gives you is a consequence of one mechanism: a controller continuously compares the cluster’s actual state to the state declared in a manifest, and issues corrections. A Deployment does not start three pods once — it maintains three pods, forever, against a world that keeps trying to knock that number down. Machine failure stops being an incident and becomes a rescheduling event you read about after the fact.

Docker Compose has no such loop. restart: always restarts a container on the same host. It has nothing to say about a host that no longer exists.

GitOps makes the loop apply to deployment itself

If a controller also watches a git repository and reconciles the cluster toward it, the deployment story becomes identical to the infrastructure story: the repository is the truth, the cluster converges on it, and nobody runs a deploy script — they merge, and the cluster notices. There is no “did the staging change reach production” question, because the answer is whatever is in git. Rolling back is a revert, not a re-run of a script with different arguments.

What the loop costs you

The scheduler deciding where things run is only a good trade if you actually want that decision made for you. Workloads that are not replicable — a database with local state, a license tied to a MAC address, anything that cannot tolerate being moved — get nothing from a control loop that keeps trying to move them. You can pin them to a node, and people do, but at that point the cluster is an elaborate way to run a process on a specific host, with all of the scheduling overhead and none of the benefit.

The debugging cost is real too, and it does not go away with experience — it just gets faster. Budget for it rather than being surprised by it.

The test that decides it

Ask, for a given workload: if the host it is on died right now, do I want the system to handle that, or do I want to be told? Both are legitimate answers. A database wants to be told. A stateless API replica wants to be handled. Forcing everything into one bucket is how you end up with a database in a pod that gets rescheduled away from its disk, or a disposable web frontend kept alive by someone’s pager.

The solution

Below is a complete, minimal way to see the control loop do its job, using kind (Kubernetes-in-Docker) so it runs on a laptop with no cloud account. Alongside it is the Compose equivalent, so the difference is something you watch happen rather than something you take on faith.

# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo
spec:
  replicas: 3
  selector:
    matchLabels:
      app: demo
  template:
    metadata:
      labels:
        app: demo
    spec:
      containers:
        - name: demo
          image: nginx:1.27-alpine
          resources:
            requests:
              cpu: "50m"
              memory: "32Mi"
            limits:
              cpu: "200m"
              memory: "64Mi"
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 2
kind create cluster --name demo --config kind-config.yaml
kubectl apply -f deployment.yaml
kubectl get pods -l app=demo
# demo-7f6d...   1/1   Running   0   10s   (x3)
 
# Simulate a failure by deleting a pod directly, as if its node had died
kubectl delete pod -l app=demo --field-selector status.phase=Running --wait=false | head -n1
kubectl get pods -l app=demo -w
# the deleted pod disappears and a replacement appears within seconds,
# with no one having been paged

The readinessProbe and resources block are not decoration. Without a resource request, the scheduler is packing nodes by guesswork, and it will guess wrong under exactly the load that made you care in the first place. Without a readiness probe, the control loop can call a pod healthy before it can actually serve traffic.

Now the Compose side, doing the equivalent job on a single host:

# docker-compose.yml
services:
  demo:
    image: nginx:1.27-alpine
    restart: always
    deploy:
      replicas: 3
docker compose up -d
docker compose ps
docker kill $(docker compose ps -q demo | head -n1)
docker compose ps
# the killed container restarts on the same host — restart: always did its job
 
# now remove the host entirely from the equation
docker compose down
docker compose ps
# nothing is running anywhere, and nothing will start it again until a human runs "up"

That last step is the actual comparison. restart: always handles a crashed process. It has no answer for a host that is gone, because there is no second host in the picture for it to move to. That gap is precisely what the Kubernetes control loop exists to close, and precisely what you are paying the debugging-complexity cost for.

Conclusion

Ask what you want to happen when a machine dies before choosing the tool. If the honest answer is “someone will notice and fix it, and that is fine,” that is a legitimate answer, and a great deal of YAML has just been avoided.

Resource requests are inputs to a decision, not paperwork. They are how the scheduler allocates the cluster. Leaving them unset does not opt you out of scheduling — it opts you into scheduling by guesswork.

A control loop only helps workloads that can tolerate being moved. Anything that cannot — pinned state, a license tied to hardware — gets the operational overhead of the cluster and none of the resilience, which is the worst combination available.

The learning curve is the actual price, not the YAML. A cluster nobody on the team is curious about becomes a cluster nobody upgrades, and an unpatched cluster is a worse liability than the single host it replaced.