Skywire Kubernetes Deployment¶
This document describes deploying skywire services on a Kubernetes cluster.
For Docker Compose deployment see DOCKER_DEPLOYMENT.md. For systemd / direct-on-host deployment see DEPLOYMENT_SYSTEMD.md.
Status of this guide¶
The reference production deployment runs on Docker Compose; there is no Kubernetes deployment in production today, and the manifests in this guide are illustrative — they describe the shape of a working deployment, not a packaged one. Treat this as a starting point for a custom Helm chart or kustomize tree, and expect to iterate. Snippets here are intentionally minimal so the structure stays readable; production manifests will need resource limits, security contexts, NetworkPolicies, monitoring sidecars, and so on.
When K8s is and isn't a good fit¶
K8s fits the parts of the deployment that look like ordinary stateless web services — dmsg-discovery, transport-discovery, address-resolver (HTTP side), service-discovery, route-finder, setup-node, config-bootstrapper, network-monitor. These are clean Deployments behind ClusterIP Services with an Ingress for external traffic.
K8s gets awkward for two pieces:
-
STUN server. RFC 3489 NAT detection requires the server to respond from two distinct public IPv4 addresses bound on the same machine, in host networking mode. Pods in K8s normally share the node's IP and don't bind raw addresses. Realistic options: run STUN outside the cluster on a dedicated host (the way Docker Compose does it via
network_mode: "host"), or use a CNI/loadbalancer that can hand each pod a routable secondary IP. There is no clean, portable manifest for this. If you don't need a custom STUN cluster, point visors at any public RFC-compliant STUN server and skip deploying it yourself. -
dmsg-serverpublic address. Each dmsg-server advertises apublic_address(e.g.192.0.2.10:30086) indmsg-discovery. Visors dial that address directly over TCP — there is no service-mesh hop. In K8s this means eachdmsg-serverreplica needs a stable, externally-reachable IP/port pair that matches itsconfig.json. AServiceof typeLoadBalancerper replica works but is expensive and provider-specific;NodePortworks if your nodes have public IPs and you pin replicas withnodeSelector. Either way,public_addressin each replica'sconfig.jsonmust match its actual external address — there is no auto-detection. -
Address-resolver UDP port (SUDPH). AR listens on TCP 9093 (HTTP) and UDP 30178 (KCP/SUDPH). Until K8s 1.24+ you needed two separate Services for mixed-protocol; from 1.24+ a single LoadBalancer Service with
protocol: TCPandprotocol: UDPports works on most cloud providers. Either way the AR's--public-udp-addressflag (added recently) must be set to the externally-reachable host:port that visors will UDP-dial.
If those three constraints don't match your cluster (e.g. no public-IP LoadBalancer, single-NIC nodes), running the data-plane components on a small VM cluster with Docker Compose alongside a K8s control plane for the HTTP services is a reasonable hybrid.
Topology¶
A minimal cluster runs the following workloads:
| Workload | Kind | Replicas | External traffic |
|---|---|---|---|
redis |
StatefulSet | 1 (or use a managed Redis) | none |
dmsg-discovery |
Deployment | 2+ | Ingress (TLS) → ClusterIP :9090 |
address-resolver |
Deployment | 2+ | Ingress (TLS) → ClusterIP :9093, plus LoadBalancer UDP :30178 |
transport-discovery |
Deployment | 2+ | Ingress (TLS) → ClusterIP :9091 |
service-discovery |
Deployment | 2+ | Ingress (TLS) → ClusterIP :9098 |
route-finder |
Deployment | 2+ | Ingress (TLS) → ClusterIP :9092 |
config-bootstrapper |
Deployment | 1 | Ingress (TLS) → ClusterIP :9082 |
setup-node |
Deployment | 1+ | none — dmsg-only |
network-monitor |
Deployment | 1 | none — internal |
dmsg-server-N |
StatefulSet (one per server) | 1 each | LoadBalancer or NodePort with stable external IP/port |
geoip |
Deployment | 1 | optional Ingress |
dmsg-server is one StatefulSet per server (not one StatefulSet with N replicas) because each server has a unique keypair, a unique advertised public_address, and (in the embedded dmsg.Prod.DmsgServers list distributed with the binary) a fixed PK that visors expect at a specific dmsg.public-address.
Secrets and ConfigMaps¶
Each service that takes a secret key needs one. Keep keys in Secret resources, not ConfigMaps:
apiVersion: v1
kind: Secret
metadata:
name: skywire-keys
type: Opaque
stringData:
ar-sk: "<64-hex-chars-from skywire cli config gen-keys>"
tpd-sk: "<...>"
sd-sk: "<...>"
rf-sk: "<...>"
dmsgd-sk: "<...>"
redis-password: "<...>"
Whitelisted PKs (network-monitor, survey, etc.) and per-service config files (config-bootstrapper's config.json, setup-node config, dmsg-server config minus the secret key) belong in ConfigMaps.
For dmsg-server, mount the public part of the config as a ConfigMap and the secret key as a Secret — assemble them into a single config.json at pod start with an init container, or pass the secret key via --sk on the command line if the binary supports it.
Custom services-config.json¶
The skywire binary embeds a services-config.json covering the production deployment's PKs and dmsg-server list. Visors and services use this for bootstrap. For a custom K8s deployment with its own dmsg-server PKs, override the embedded config via the SKYDEPLOY environment variable pointing at a mounted ConfigMap containing your cluster's services-config.json:
env:
- name: SKYDEPLOY
value: /etc/skywire/services-config.json
volumeMounts:
- name: services-config
mountPath: /etc/skywire
volumes:
- name: services-config
configMap:
name: skywire-services-config
The format follows deployment/services-config.json in the repo — prod and test keys, each with dmsg_servers, dmsg_discovery, transport_discovery, etc. Every component in the cluster (services and visors) must use the same services-config.json; mismatched lists mean dmsg-servers will not peer with each other and visors bootstrap against servers the rest of the deployment does not know.
Redis¶
Use a managed Redis if your cloud provider offers one — the operational headache of running it as a StatefulSet in your own cluster usually exceeds the cost. If you do run it in-cluster, use volumeClaimTemplates for persistent storage:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
spec:
serviceName: redis
replicas: 1
selector: { matchLabels: { app: redis } }
template:
metadata: { labels: { app: redis } }
spec:
containers:
- name: redis
image: redis:7
args:
- --requirepass
- $(REDIS_PASSWORD)
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef: { name: skywire-keys, key: redis-password }
volumeMounts:
- { name: data, mountPath: /data }
volumeClaimTemplates:
- metadata: { name: data }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 10Gi } }
The same Redis instance can back every service that needs it (address-resolver, transport-discovery, dmsg-discovery, service-discovery). The dmsg-server needs no Redis.
A representative service Deployment¶
Address-resolver, illustrating the env / args pattern shared by every HTTP service:
apiVersion: apps/v1
kind: Deployment
metadata: { name: address-resolver }
spec:
replicas: 2
selector: { matchLabels: { app: address-resolver } }
template:
metadata: { labels: { app: address-resolver } }
spec:
containers:
- name: ar
image: skycoin/skywire:test
args:
- svc
- ar
- --addr
- ":9093"
- --redis
- redis://redis:6379
- --dmsg-disc
- http://dmsg-discovery:9090
- --sk
- $(AR_SK)
- --public-udp-address
- "ar.example.com:30178"
- --whitelist-keys
- "$(NETWORK_MONITOR_PK)"
- --entry-timeout
- 5m
env:
- name: REDIS_PASSWORD
valueFrom: { secretKeyRef: { name: skywire-keys, key: redis-password } }
- name: AR_SK
valueFrom: { secretKeyRef: { name: skywire-keys, key: ar-sk } }
- name: SKYDEPLOY
value: /etc/skywire/services-config.json
ports:
- { name: http, containerPort: 9093 }
- { name: sudph, containerPort: 30178, protocol: UDP }
volumeMounts:
- { name: services-config, mountPath: /etc/skywire }
volumes:
- name: services-config
configMap: { name: skywire-services-config }
---
apiVersion: v1
kind: Service
metadata: { name: address-resolver }
spec:
selector: { app: address-resolver }
ports:
- { name: http, port: 9093, targetPort: http }
---
apiVersion: v1
kind: Service
metadata:
name: address-resolver-sudph
annotations:
# Cloud-specific: ensure the LB is created with UDP support.
# Examples: service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector: { app: address-resolver }
ports:
- { name: sudph, port: 30178, targetPort: sudph, protocol: UDP }
Two services on purpose: HTTP behind the cluster Ingress (TLS-terminated by Ingress controller); SUDPH on a LoadBalancer Service so the externally-reachable UDP host:port matches --public-udp-address. externalTrafficPolicy: Local preserves the source IP so AR's hasAddress check passes; without it, AR sees the kube-proxy SNAT'd address and rejects the bind.
The other HTTP-only services (TPD, SD, RF, UT, DMSGD, conf-service) follow the same shape, minus the UDP Service.
DMSG Server StatefulSet¶
Each dmsg-server runs as its own StatefulSet so it can have a stable PK and a stable external address. Skeleton:
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: dmsg-server-1 }
spec:
serviceName: dmsg-server-1
replicas: 1
selector: { matchLabels: { app: dmsg-server-1 } }
template:
metadata: { labels: { app: dmsg-server-1 } }
spec:
containers:
- name: dmsg-server
image: skycoin/skywire:test
args:
- dmsg
- server
- start
- /etc/skywire/dmsg-server/config.json
env:
- name: REDIS_PASSWORD
valueFrom: { secretKeyRef: { name: skywire-keys, key: redis-password } }
- name: TPD_URL
value: http://transport-discovery:9091
- name: SD_URL
value: http://service-discovery:9098
- name: SKYDEPLOY
value: /etc/skywire/services-config.json
ports:
- { name: dmsg, containerPort: 8080 }
volumeMounts:
- { name: dmsg-config, mountPath: /etc/skywire/dmsg-server }
- { name: services-config, mountPath: /etc/skywire }
volumes:
- name: dmsg-config
# Assemble at start: secret key from Secret + public config from ConfigMap
# via an init container, OR mount a single Secret containing the full
# config.json (one Secret per dmsg-server replica).
secret: { secretName: dmsg-server-1-config }
- name: services-config
configMap: { name: skywire-services-config }
---
apiVersion: v1
kind: Service
metadata: { name: dmsg-server-1-public }
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector: { app: dmsg-server-1 }
ports:
- { name: dmsg, port: 30081, targetPort: dmsg }
The dmsg-server's config.json (stored in dmsg-server-1-config Secret) must include:
{
"public_key": "...",
"secret_key": "...",
"discovery": "http://dmsg-discovery:9090",
"public_address": "203.0.113.10:30081",
"local_address": ":8080",
"max_sessions": 2048
}
public_address must match the LoadBalancer's external IP/port. There's no in-cluster way to discover this before the LB is provisioned; bring up the Service first, note its external IP, then write that into the config Secret. Custom controllers exist that automate this (e.g. CrossPlane, external-dns), but the manual two-step is the simplest path.
Earlier revisions of this document also set enable_dht, redis_addr and
persist_path here. The DHT full node those configured no longer exists — see
DOCKER_DEPLOYMENT.md "DMSG server DHT — removed".
Drop all three from any dmsg-server config you are carrying forward; it needs
neither Redis nor a volume of its own, so a dmsg-server StatefulSet needs
no PVC.
Ingress / TLS¶
Standard Ingress per service, TLS via cert-manager:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: skywire-services
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- dmsgd.example.com
- ar.example.com
- tpd.example.com
- sd.example.com
- rf.example.com
- ut.example.com
- conf.example.com
secretName: skywire-tls
rules:
- host: dmsgd.example.com
http: { paths: [{ path: /, pathType: Prefix, backend: { service: { name: dmsg-discovery, port: { number: 9090 } } } }] }
- host: ar.example.com
http: { paths: [{ path: /, pathType: Prefix, backend: { service: { name: address-resolver, port: { number: 9093 } } } }] }
# ... and so on for tpd, sd, rf, ut, conf
The Ingress only carries HTTP — the AR's UDP service stays separate (LoadBalancer above). Don't proxy UDP through an HTTP Ingress controller.
Visor configuration¶
Visors that should use this cluster get a config generated against your conf-service:
Or, for a hand-rolled config, swap the service URLs to your cluster's hostnames. Make sure the visor's binary was built with (or has SKYDEPLOY pointing at) the same services-config.json the cluster uses, otherwise the dmsg-server PK list will be wrong and the visor will bootstrap against servers the deployment does not run.
Pprof / debug access¶
Each service in the cluster serves pprof over dmsg on its main :80 handler,
alongside /health and /metrics, gated by the survey_whitelist baked into the binary or supplied via SKYDEPLOY. There is no cluster-side ingress for pprof — it's only reachable over dmsg, by callers whose PK is in the whitelist. This is by design (see PR #2390 series); don't expose /debug/pprof/ over HTTP Ingress, and don't add Service entries for the dmsg-side pprof port.
For in-cluster Go pprof on individual pods, services accept --pprof :PORT to bind a plaintext HTTP pprof handler on a private port. Bind it to localhost or to a ClusterIP-only Service, never to the Ingress.
Troubleshooting¶
dmsg-servers not peering. Check that every dmsg-server's public_address matches its LoadBalancer external address, and that all dmsg-server PKs are in the same services-config.json mounted into every workload.
Visors can't register SUDPH. AR's UDP Service must hand visors a routable host:port that matches --public-udp-address. externalTrafficPolicy: Local is required so AR sees the visor's real source IP — without it, AR's hasAddress check rejects the bind because the kube-proxy SNAT'd address isn't in the visor's claimed local-addresses list.
STCPR works but SUDPH doesn't. Same as above, plus check that the cluster's egress doesn't NAT outgoing UDP differently from inbound. SUDPH hole-punching needs symmetric NAT behavior; with strict-symmetric-NAT egress, only STCPR will work.
Services start but discovery returns empty. Confirm Redis is reachable from the service pods (try redis-cli -h redis -a $REDIS_PASSWORD ping from a debug pod). There is no dht:* mirror to inspect — the DHT is gone, and storeconfig.Config carries no DB-index field.
STUN unavailable. Run STUN outside the cluster (a dedicated VM with two public IPs and network_mode: host). Reference its address from the cluster's services-config.json stun_servers list. There is no portable way to run STUN inside K8s.