Skip to main content
BlogDevOps

Docker Swarm vs Kubernetes: A Small Team Load Test Verdict

Docker Swarm vs Kubernetes for a small team, decided with my own load test and a home-built autoscaler: what HPA adds, where Swarm stops being enough.

Matthews Wong

Software Engineer

October 202610 min read
Docker Swarm vs Kubernetes: A Small Team Load Test Verdict

Key Takeaway

For a small team, Docker Swarm stays enough until you need built-in autoscaling, finer access control or Kubernetes tooling. In my own load test, doubling Swarm replicas barely raised throughput because one database row lock was the limit, so Kubernetes HPA would not have helped either. Remove the real bottleneck first, then pick the orchestrator.

The question I get most often after my Swarm posts is some version of: should we just move to Kubernetes? It usually arrives right after a slow week, when someone has noticed that one container is pinned at its CPU limit and that Swarm will not add a second one on its own.

This post answers it for a small team, using the one piece of evidence I can stand behind: the load tests and the custom autoscaler I built while running a Java Spring Boot service on Docker Swarm during my thesis research. I quote those numbers only as my results, link the full write-ups, and take every statement about Kubernetes and Swarm behaviour from their official documentation. I did not benchmark Kubernetes, so there is no Swarm-versus-Kubernetes speed number here, on purpose.

What Swarm gave me, and the one thing it did not

Swarm mode is built into the Docker Engine. The official overview lists scaling, desired state reconciliation, rolling updates, load balancing, service discovery and multi-host networking as its headline features, and that was genuinely enough to run a multi-service platform. You declare how many tasks a service should have, and the manager adds or removes tasks until reality matches.

What the same page does not list is autoscaling. Scaling in Swarm means declaring a number, and the number changes only when something runs the scale command. In my case that something did not exist, so every service sat at a single replica and nobody knew where it would break. That gap, not any dislike of Swarm, is the honest reason the Kubernetes question came up.

What my load test said about adding replicas

Before arguing about orchestrators it is worth knowing what more replicas actually buy you. I ran a k6 load test against the busiest write endpoint of the service, with 10 virtual users for 120 seconds on every comparison, so each run was like for like.

In my load test, which I describe in full in the Swarm load testing post going from one replica to two cut p95 latency from 8.40 seconds to 4.50 seconds, but throughput only moved from 1.72 to 1.88 iterations per second. A single replica with PostgreSQL and connection pool tuning reached 2.27 iterations per second with a p95 of 3.65 seconds, and two tuned replicas did worse than that single tuned one, at 2.00 iterations per second and a p95 of 5.43 seconds. The cause was a row lock: every request updated the same row, so more replicas only meant more sessions waiting in line.

This is the most useful finding for the Swarm-or-Kubernetes decision. Both orchestrators add replicas the same way from the database's point of view. If the limit is shared state, the orchestrator you choose does not change the ceiling.

Autoscaling: my under-80-line daemon versus a declared HPA

Because Swarm has no autoscaler, I wrote one, and the custom autoscaler post has the full Bash. It polls CPU every 10 seconds, scales out after two consecutive samples above 70 percent, scales in after three below 30 percent, and then refuses to act again for a 90-second cooldown. In my staged load test it scaled from one replica to two at 52 seconds and suppressed a spurious 92 percent spike during the cooldown.

# Swarm: the whole scaling API is one imperative command.
# Something you write has to decide WHEN to run it (see the daemon post).
docker service scale my-stack_api=3

# Kubernetes: the decision loop is built in. You declare the target and
# the damping instead of writing the loop.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 1
  maxReplicas: 4
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # percent of the container's CPU REQUEST
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0      # react at once (the default)
    scaleDown:
      stabilizationWindowSeconds: 300    # wait before removing replicas

Kubernetes ships this loop. The official HPA documentation describes a control loop that runs every 15 seconds by default and computes the desired replica count as the current count multiplied by the ratio of the current metric to the target, rounded up. Scale-down is damped by a default 300-second stabilization window, scale-up has none, and changes within a default 10 percent tolerance are ignored. As I read the formula, HPA works toward one target value rather than my two thresholds of 70 and 30, so my asymmetry maps onto the behavior block instead. The price is its prerequisites: CPU metrics come from a separately installed Metrics Server, and a Pod without a CPU request has undefined utilization, so the autoscaler does nothing for it.

The units differ between my daemon and HPA. My daemon read docker stats, which reports percent of a single core, so a container can exceed 100 percent. HPA utilization is a percentage of the container's CPU request. Copy my thresholds across unchanged and your scaling will be wrong in a way that looks plausible.

The comparison, without the marketing

This table keeps only differences that I can trace to the Docker or Kubernetes documentation, or to my own build. Where a cell describes my own work it says so.

ConcernDocker SwarmKubernetes
AutoscalingNone built in. Scaling is a declared replica count, changed by docker service scale. I wrote the loop myself.HorizontalPodAutoscaler is built in, with a 15-second default loop, stabilization windows and scaling policies.
What it can scale onWhatever your script can measure. Mine used CPU only, which I flag as a limit.Resource, custom, object and external metrics, though custom and external ones need extra metric adapters.
Control plane availabilityManagers run Raft. Three managers tolerate one failure, five tolerate two. Lose quorum and management stops while running tasks keep running.The docs call a one-machine control plane not highly available and suggest control plane services on at least three machines, plus regular etcd backups.
Access controlYou reach the swarm through the manager. Fine-grained per-team permissions are not part of what the overview advertises.The production guide recommends RBAC and policies for shared clusters with more than one or two users.
Who runs the clusterYou do. The Swarm overview describes no managed option.You can hand off the control plane, the worker nodes or both to a provider, as the production guide itself suggests.
Moving parts to learnFew. One Engine, services, stacks, and the Raft admin guide.Many. Control plane components, add-ons such as Metrics Server, and the object model around them.

Read the table as a ledger of what you must build or buy. Kubernetes makes the autoscaling and access control rows cheaper and the learning row more expensive. Swarm does the opposite.

Where Swarm stops being enough for a small team

I would not move because Kubernetes is the industry default. I would move when one of these is true, each of which the table or my own work backs up:

  • You need to autoscale on signals beyond one CPU number, and you are tired of maintaining your own decision loop and its tests.
  • More than a few people deploy into the same cluster and you need real separation between what each of them may touch.
  • You cannot accept that losing manager quorum freezes all changes, and you would rather pay a provider to run the control plane.
  • You depend on tooling that targets Kubernetes and has no Swarm equivalent.

The failure I would watch for is different. A CPU autoscaler, mine or HPA, keeps adding replicas for as long as CPU is the signal, and when the true limit is a row lock the extra replicas just queue. In my case the autoscaler would have converged on the same 2.27 iterations per second ceiling the tuning found. Moving orchestrators first would have cost weeks and left that number unchanged.

What I would do differently, and the limits of this evidence

My numbers come from one service, one endpoint and one staging database with a single hot row, and they are measurements of Swarm only. I have not run the same test under Kubernetes, so I cannot tell you Kubernetes is faster, slower or equal. The 70 and 30 thresholds, the streak counts and the 90-second cooldown are design choices I made for that service, not recommendations.

If I did it again I would profile the database before building any scaling logic, and I would use a CPU request based metric instead of raw docker stats from the start, so that a later move to HPA is a translation rather than a rewrite. I would also add a latency signal next to CPU, since CPU alone was blind to the lock.

Monday checklist

  1. Run a load test on your busiest write path with a fixed profile, and compare one replica against two before touching the orchestrator.
  2. Query your database locks during that test and look for many sessions waiting on the same row or table.
  3. Write down which of the four triggers in the previous section is actually true for your team today.
  4. If you stay on Swarm, count your managers, make it an odd number, and test what happens to deploys when one goes down.
  5. If you move, set CPU requests on every container first, because HPA cannot compute utilization without them.

Choose Swarm or Kubernetes for what you must build or buy, never for what the benchmark said about replicas. My load test showed that replicas only help until a shared resource becomes the limit, and no orchestrator moves that limit. Fix the bottleneck, then pay for the autoscaling and access control you can name.

Related reading

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Does Docker Swarm have built-in autoscaling like Kubernetes HPA?

No. The Swarm overview lists manual scaling, where you declare a task count per service, and does not mention autoscaling. Kubernetes ships the Horizontal Pod Autoscaler, which runs a control loop every 15 seconds by default. On Swarm you need a script, like the Bash daemon I built, to decide when to call docker service scale.

Is Docker Swarm enough for a small team in production?

It can be, if you can live without built-in autoscaling and fine-grained access control. In my experience running a multi-service platform on Swarm, the orchestrator was not the constraint. Move when a concrete need, such as multi-team permissions or managed control planes, appears.

Would moving from Swarm to Kubernetes have raised my throughput?

Not in my case. In my load test, doubling replicas on Swarm raised throughput by about 9 percent because every request locked the same database row. Kubernetes would add replicas the same way, so the row lock would have remained the ceiling. I did not benchmark Kubernetes, so I make no speed claim.

What happens to a Docker Swarm cluster if the managers lose quorum?

Management stops: you cannot add or remove nodes or start, stop, move or update tasks. Tasks already running on workers keep running. Docker's admin guide says to bring the missing managers back, and as a last resort to use docker swarm init with the force-new-cluster flag.

What do I need before using Kubernetes HPA on CPU?

You need a Metrics Server, which the docs say is launched separately, and a CPU request on every container. HPA utilization is a percentage of the request, so without it the utilization is undefined and the autoscaler takes no action. That is also why thresholds from docker stats cannot be copied across unchanged.