Skip to content
← All posts

The Anyport Team

A public port, and why the platform should not pick the load balancer

Opening a raw TCP or UDP port on Kubernetes is one line of YAML. Whether that line is free, costs $15 a month, or hangs in Pending forever depends on the cluster it lands on. What a deploy platform has to know before it writes it.

A hostname carries HTTP, and HTTP is the case every deploy platform has solved: an ingress, a certificate, a route to the container. The moment a service speaks something else, an MQTT broker, a game server, gRPC without an HTTP/2-aware ingress in front of it, a DNS resolver, the hostname is no use and the service needs a port on the internet. That is where the platform's abstraction is tested, because the Kubernetes primitive for a public port is one line, and what that line does is not up to Kubernetes.

One line, four outcomes

The line is type: LoadBalancer on a Service. On a cluster with a cloud controller, EKS, GKE, AKS, Hetzner, DigitalOcean, it asks the cloud to provision a load balancer, which arrives with a public address and a monthly charge, typically $10 to $20 per Service, billed by the cloud rather than by anything on the cluster. On k3s with ServiceLB, or any cluster running MetalLB, the same line is free: the address is the node's own, or one from a pool you configured. On kubeadm, kind, or a bare server with nothing answering, the Service sits in Pending with no address, indefinitely, and nothing tells you why.

So a platform that writes type: LoadBalancer whenever someone asks for a port has picked, on the user's behalf, between a recurring bill and a listener that never gets an address. Neither is a decision a form should make silently.

The alternative has a shape of its own

A NodePort Service opens the same port number on every node's address, from the cluster's node port range, 30000 to 32767 unless it was changed at install time. It costs nothing and it works on every cluster. What it does not give you is one address: a client has to know a node, and if that node goes, the client has to know another. For a broker behind a DNS record with several A records that is fine. For a service whose clients hardcode one IP it is a weaker offer than a load balancer, and the platform should say so rather than quietly hand it over.

There is also the case where a node port is worse than useless. On a cloud cluster whose nodes have only private addresses, a node port answers inside the VPC and nowhere else. It looks open from inside the cluster and is unreachable from the internet, which is the kind of failure that gets debugged for an afternoon.

Ask the cluster first

The resolution is that the platform has to find out what the cluster is before it writes the Service, and the agent already on the cluster is the thing that can find out. Whether a load balancer implementation is present, whether it is one of the free ones, whether the nodes have public addresses. With that, the default writes itself for each case:

  • Free load balancer present (k3s ServiceLB, MetalLB): use it, because it costs nothing and gives one address.
  • Cloud controller, nodes with public addresses: default to a node port, offer the load balancer beside it with the cost written next to the option, and never preselect it.
  • Cloud controller, private nodes only: choose nothing. Either the person accepts the load balancer charge or gives the nodes public addresses; the form is not entitled to decide which.
  • Nothing that answers a load balancer: node port, with the load balancer option disabled and the reason shown, rather than a Service that waits for an address forever.

The principle underneath is that anything that can cost money or fail silently is chosen by a person who has been told what it costs and why it might fail. Everything else can be a default.

Two ports that should be refused

The first is any port on a cluster that has no public address at all: one reached through an outbound tunnel because it sits behind NAT or a firewall nobody will open. A tunnel carries HTTPS by hostname; there is no address on the internet for a raw TCP port to answer on, and the platform should refuse immediately and name the tool for reaching the port from your own machine instead of creating a Service that can never be reached.

The second is a database. A public port on Postgres or Redis is how a great many breaches start, and the convenience it buys, connecting from a laptop, is exactly what a port-forward is for. The right response to expose postgres --port 5432 is a refusal that says that, not a warning that gets clicked through.

How this works in Anyport

An app can open a public TCP or UDP port from the console, under the app's Domains settings, or from the terminal: anyport app expose mqtt --port 1883 -p iot, with --target when the container listens on a different number and --udp for UDP. Each port is a Kubernetes Service in the project. The agent reports what would answer a load balancer on that cluster, and the console applies the four defaults above; a load balancer on a cloud cluster is never preselected, and its note states the typical charge. --via loadbalancer or --via nodeport makes the choice explicit, and anyport app ports lists every address a port answers on, one for a load balancer, one per node for a node port.

Both refusals are enforced. A cluster reached through the gateway cannot open a port, and the console says so at once, naming anyport port-forward. A managed database does not get a public port; the CLI says the same and points at the same tool. The free plan allows one public port per cluster and Pro ten, and in an anyport.yaml a port is publicPorts: [{ port: 20017, target: 50051 }] on the app, with protocol: UDP and via: nodePort where the defaults are not what you want.

The documentation has the table and the commands. The thing worth taking from this post is smaller than any of it: type: LoadBalancer is not a feature, it is a question about the cluster, and the platform should answer it before you pay for the wrong answer.