The SSH gateway on AWS¶
How to expose the dwpk gateway on EKS with the AWS Load Balancer Controller.
An ALB cannot carry SSH¶
Start here, because it saves an afternoon.
An Application Load Balancer is HTTP and HTTPS only. It parses requests, it routes on hostnames and paths, and it has no notion of a raw TCP stream. SSH is not HTTP, so an ALB cannot carry it - not with an annotation, not with a listener rule, not at all.
The gateway needs a Network Load Balancer, which is layer 4 and forwards TCP untouched. The AWS Load Balancer Controller provisions both kinds, so the controller in the request is the right one; it is the load balancer type that has to differ.
The split for a dwpk install is:
| Component | Protocol | Load balancer | Kubernetes object |
|---|---|---|---|
| Gateway | SSH (TCP/22) | NLB | Service type LoadBalancer |
| UI | HTTPS | ALB | Ingress |
The gateway Service¶
The chart creates a Service of type LoadBalancer on port 22. On EKS the
controller turns that into an NLB when you ask it to:
gateway:
service:
type: LoadBalancer
annotations:
# Hand this Service to the AWS Load Balancer Controller rather than to the
# legacy in-tree cloud provider. Without it you may still get a load
# balancer - the old classic one - and none of the annotations below.
service.beta.kubernetes.io/aws-load-balancer-type: external
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
# Health checks. The gateway speaks SSH, so a TCP check is the only
# meaningful one; see "Health checks and log noise" below.
service.beta.kubernetes.io/aws-load-balancer-healthcheck-protocol: tcp
service.beta.kubernetes.io/aws-load-balancer-healthcheck-port: "22"
nlb-target-type: ip sends traffic straight to pod IPs rather than through
a node port. It removes a hop, and it keeps the source address intact without
externalTrafficPolicy: Local and the scheduling constraints that brings.
scheme is internet-facing or internal. Internal is the right default
if people reach the cluster over a VPN or Direct Connect: an SSH endpoint on the
public internet will be found by scanners within minutes, and while it only
accepts public keys, there is no reason to advertise it.
Health checks and log noise¶
An NLB health check opens a TCP connection to port 22, decides the target is
healthy because the connection succeeded, and closes it without speaking SSH.
The kubelet's own tcpSocket probe does the same.
The gateway logs each of these at debug:
DEBUG gateway SSH handshake did not complete {"client": "10.0.1.23:41234", "reason": "EOF"}
That line is expected, and there will be one per health-checking source per interval - the NLB from each subnet, plus the kubelet. It used to be logged at error, which made a perfectly healthy gateway look broken. A genuine authentication failure is logged separately and stays visible at a normal level.
If you want the noise gone entirely, raise the health check interval; do not raise the log level, or you lose real failures with it.
Idle timeout¶
NLB idle timeout for TCP flows is 350 seconds and cannot be changed. An SSH session sitting at a prompt sends nothing, so it will be cut after roughly six idle minutes unless something keeps the flow alive.
Two ways to handle it, and you want at least one:
- Client side, in
~/.ssh/config- the usual answer:
Host *.dwpk.example.com
ServerAliveInterval 60
ServerAliveCountMax 3
- Server side, by enabling TCP keepalives on the gateway Service:
service.beta.kubernetes.io/aws-load-balancer-target-group-attributes: |
preserve_client_ip.enabled=true
A dropped session loses the terminal, not the workspace: the pod keeps running
and reconnecting picks up where the shell left off - with the caveat that
anything running in the foreground of that shell dies with it. tmux inside the
workspace is worth the habit.
Security groups¶
With nlb-target-type: ip, traffic arrives at the pod ENI, so the node's
security group must allow TCP/22 from wherever you decided scheme puts the
listener - the VPC CIDR for internal, or the world for internet-facing.
The controller can manage this for you:
service.beta.kubernetes.io/aws-load-balancer-manage-backend-security-group-rules: "true"
Leave it off and you must open the rule yourself, and the symptom is a connection that hangs rather than one that is refused.
Client IP¶
preserve_client_ip.enabled=true on the target group keeps the real source
address, which is what makes the gateway's connection logs worth reading. It is
on by default for ip targets and off for instance targets.
It has one consequence worth knowing: with client IP preserved, a pod cannot
reach the load balancer that fronts its own Service - the packet arrives with a
source address it then tries to reply to directly. Nothing in dwpk does that,
but a debugging session that tries ssh from inside a workspace to the
platform's own endpoint will hang, and this is why.
DNS¶
Point a record at the load balancer and use it as the SSH host:
*.dwpk.example.com CNAME k8s-dwpksys-dwpkgate-abc123.elb.eu-west-1.amazonaws.com
The gateway identifies a workspace from the SSH username, not the hostname, so a single record serves everyone:
ssh alice-dev@dwpk.example.com
That username is <user>-<workspace>. It carries the owner because a workspace
name is unique only inside its namespace - two people can each have one called
dev, and the gateway matches across the whole cluster. status.endpoint on
each Workspace publishes the exact string, which is what the UI shows and what
the VS Code deep link uses.
Checking it works¶
# The load balancer exists and has an address.
kubectl get svc -n dwpk-system dwpk-gateway-service
# Targets are healthy. Unhealthy here with a Running pod is almost always the
# security group.
aws elbv2 describe-target-health --target-group-arn <arn>
# End to end, with the key on the Workspace.
ssh -v alice-dev@dwpk.example.com
ssh -v is worth the habit: it distinguishes a network problem (no banner) from
an authentication one (banner, then Permission denied (publickey)), and those
have entirely different causes.
The UI, which does belong behind an ALB¶
The UI is ordinary HTTPS, so it goes through an Ingress:
ui:
ingress:
enabled: true
className: alb
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:...
alb.ingress.kubernetes.io/ssl-redirect: "443"
Serve it over HTTPS and leave ui.cookieSecure at its default of true. The
session cookie is the credential for a browser session; over plain HTTP it
travels in clear, and the browser will not treat it as secure. Local development
against http://localhost is the only place cookieSecure: false belongs.
The UI's WebSocket endpoints - the browser terminal - work through an ALB without extra configuration; ALB has supported WebSocket upgrades since it shipped. Raise the ALB idle timeout above its 60-second default if you want long terminal sessions to survive being left alone:
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600