One proxy pod per VM does not scale

A design that is obviously right at n=1 and obviously wrong at n=20, with no warning in between. The commit that deleted it is the best one in the repository.

Remote desktop for one virtual machine is a solved problem. You bolt websockify in front of VNC, point a browser at it, and you are done in an afternoon.

Remote desktop for N machines that appear and disappear on demand is a different problem wearing the same clothes. It took us two months to notice.

Version 1: a proxy per computer

Each computer got its own translation layer, as Helm templates inside the computer’s own chart:

k8s-Helm/OOUser/computers/<os>/templates/
├── dockur/stateful-set.yaml       # the VM
├── dockur/service.yaml
├── vnc-ws-proxy/deployment.yaml   # ← per-computer proxy
├── vnc-ws-proxy/service.yaml
└── traefik-ingressroute.yaml      # ← per-computer route

Plus an rdp-tls-proxy — HAProxy with a self-signed certificate — for RDP, which needs TLS termination that noVNC’s path does not provide.

The proxy deployment is unremarkable, and that is rather the point:

        - name: '{{ include "proxy.name" . }}-container'
          image: {{ .Values.vnc_ws_proxy.image }}
          env:
            - name: VNC_HOST
              value: {{ .Values.computerName }}    # the computer's Service
            - name: VNC_PORT
              value: "{{ .Values.vnc_ws_proxy.vnc_port }}"
          readinessProbe:
            httpGet: { path: /, port: 8888 }
          livenessProbe:
            httpGet: { path: /, port: 8888 }

Correct Kubernetes. Probes, a Service, an ingress route, deployed with the computer and cleaned up with it. At one computer it is elegant.

Where it broke

Object count. Per computer: a StatefulSet, a headless Service, a PVC, a VNC proxy Deployment, its Service, an RDP proxy Deployment, its Service, and one or two IngressRoutes. Call it nine objects, of which four exist purely to translate a protocol. At twenty computers that is eighty objects of pure overhead and a routing table nobody can hold in their head.

Resource waste. Each proxy is a pod. It needs a request, so the scheduler reserves capacity, so nodes fill up with processes whose entire job is copying bytes between two sockets. Twenty computers meant forty extra pods on a node pool sized for virtual machines.

Hostname sprawl. Every computer needed a routable name. In staging that meant <computer>.<ingress-ip>.nip.ionip.io resolves anything.1.2.3.4.nip.io to 1.2.3.4, which is a genuinely good trick for wildcard-ish DNS without owning a zone. It also meant the ingress configuration grew linearly with the fleet.

WebSocket routing. The wss upgrade through Traefik has to be right for every route, and there is a commit called fix ingressroutes for wss that represents several hours of learning that a misconfigured upgrade header produces a connection which opens and then produces nothing.

Lifecycle coupling. The proxy has to come up after the computer’s Service exists, be torn down with it, and not leave orphans when something fails midway — which is exactly the class of bug that shows up at 2am with forty Terminating pods.

Version 2: one gateway, dynamic registration

Apache Guacamole is a clientless remote desktop gateway. Two components: guacd, which speaks VNC, RDP and SSH natively and translates to the Guacamole protocol, and a web application that serves the JavaScript client and holds connection configuration in Postgres.

One deployment, for the whole cluster. Computers are registered with it when they are provisioned, over its REST API:

def register_computer_with_guacamole(computer_name: str):
    session = requests.Session()

    auth_res = session.post(f"{guac_url}/api/tokens",
                            data={"username": guac_username, "password": guac_password})
    token = auth_res.json()["authToken"]
    data_source = auth_res.json()["dataSource"]   # usually "postgresql"
    headers = {"Guacamole-Token": token}

    vnc_payload = {
        "name": f"{computer_name}-vnc",
        "protocol": "vnc",
        "parameters": {
            "hostname": f"{computer_name}.computer.svc.cluster.local",
            "port": "5900",
            "password": comp_password,
        },
        "attributes": {"max-connections": "5", "max-connections-per-user": "2"},
    }
    session.post(f"{guac_url}/api/session/data/{data_source}/connections",
                 json=vnc_payload, headers=headers)
    # ...and the same for RDP on 3389

The key line is the hostname:

{computer_name}.computer.svc.cluster.local

Guacamole reaches the computer over cluster-internal DNS. No ingress route, no external hostname, no proxy pod. The computer’s Service already exists; that is sufficient. And guacd is inside the cluster too, so the VNC and RDP traffic never leaves it.

Provisioning gained one line — register_computer_with_guacamole(name) — and teardown gained its mirror. Both are ordinary HTTP calls from the control plane, not Kubernetes objects with their own lifecycle.

The commit

remove VNC and RDP proxies

118 lines deleted, the charts moved to backup/, and per-computer IngressRoutes removed from the computer templates.

I have a lot of affection for that commit. Deleting working infrastructure is harder than it should be — it works, someone built it, and the replacement is a new dependency. But two months of operating the first design produced enough evidence to make the argument, and the fleet got quieter overnight.

Moving the old charts to backup/ rather than deleting them outright is a habit I would defend. Git remembers, but a directory named backup/vnc-proxy sitting next to the current design is a much more discoverable answer to “did you consider per-computer proxies?” than a commit from three months ago. It documents a road not taken, at the cost of a directory.

What the migration cost

Not free. Three things came with it.

CORS. The dashboard is served from one origin, Guacamole from another, and the browser cares. A Traefik middleware handles it:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: guac-cors
spec:
  headers:
    accessControlAllowOriginList:
      - "http://localhost:5173"   # dev UI origin
    accessControlAllowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    accessControlAllowHeaders: ["*"]
    accessControlAllowCredentials: true

Note that origin — the Vite dev server. Convenient during development, and exactly the sort of allowance that must not survive into production. It did survive longer than it should have.

A database. Guacamole needs Postgres for connection state, which is a new stateful component to run, back up and upgrade, in exchange for deleting forty pods. Worth it, but it is a trade, not a pure win.

A more centralised failure mode. Twenty independent proxies fail independently. One gateway fails for everybody. In practice this was fine — it is a mature, boring piece of software — but it is a real change in the shape of your outages and worth naming rather than discovering.

The general lesson

The first design was not stupid. It was a correct solution to the problem as it existed when it was written: one computer, get a picture of it in a browser.

What changed was not the requirement but the cardinality, and cardinality changes are the ones you do not notice. There is no moment where per-computer proxies stop working. They work at two. They work at five. At twenty they are merely expensive and annoying, and by then they are load-bearing.

The question worth asking early, and I did not ask it: what does this design look like at ten times the instance count? If the answer is “ten times as many objects,” you have found the thing to redesign — and it is much cheaper to redesign it before the answer is measured in pods.

Next: running the benchmarks that made all of this necessary — node pools, job queues, and cost per step.