Service exposure
Configure how Clabernetes exposes network nodes through Kubernetes Services.
This guide explains how to configure Clabernetes service exposure for your network topologies.
Overview
By default, Clabernetes creates LoadBalancer services for each node in your topology, automatically exposing common network management ports. This behavior can be customized to match your access requirements.
How exposure works
A node container does not run in its launcher pod's network namespace -- it sits on a containerlab managed docker bridge inside the pod. A node port is therefore only reachable from outside when docker publishes it, which makes the exposed port set an explicit list rather than "everything the node listens on":
- The default management port list (see the table below) is exposed unless
disableAutoExpose: true. - Anything outside that list -- a custom app on 8080, iperf3 on 5201 -- has to be named in the
node's
ports. - For each destination port, c9s allocates a pod-side port and records the pair in the Node's
status.exposedPorts. That status is the source of truth for both the node's Service and the topology the launcher hands to containerlab.
Clients always connect to the node's natural port: the Service listens on the destination port and
targets the allocated pod-side port. That allocation is c9s's to make, which is why ports entries
declare a destination port only.
Portable containerlab topologies
A normal containerlab ports entry publishes the port on the local Docker host. When a port is
needed only so nodes can communicate through a c9s Service, use the c9s definition label instead:
topology:
nodes:
gnmic:
kind: linux
image: ghcr.io/openconfig/gnmic:latest
labels:
c9s.run/exposePorts: "9273/tcp,8125/udp"The value is a comma-separated list using the same destination-port grammar as Node.spec.ports.
Each entry is a destination port with an optional tcp or udp protocol. The c9s topology
compiler and clabverter --emit-crs consume all entries into Node.spec.ports; the label is not
copied to Kubernetes labels. Invalid entries fail compilation. Local containerlab keeps the value
as an inert container label and does not publish either port on the host.
This label only declares which ports the c9s Service carries. The effective LauncherProfile still
controls whether that Service is a ClusterIP, LoadBalancer, Headless, or disabled.
Exposure Options
Complete Disable (disableExpose: true)
When you don't need any Kubernetes services for your topology nodes:
apiVersion: c9s.run/v1alpha1
kind: Topology
metadata:
name: internal-only
spec:
expose:
disableExpose: true
definition:
containerlab: |
name: internal
topology:
nodes:
srl1:
kind: nokia_srlinux
image: ghcr.io/nokia/srlinux:latestEffects:
- No services are created for any node
- Nodes can still communicate with each other via VXLAN tunnels
- No external access to nodes
Use cases:
- Automated testing pipelines where nodes only need internal connectivity
- Resource-constrained clusters where LoadBalancers are expensive
- Security-sensitive environments
Disable Auto-Expose (disableAutoExpose: true)
Control exactly which ports are exposed:
apiVersion: c9s.run/v1alpha1
kind: Topology
metadata:
name: minimal-ports
spec:
expose:
disableAutoExpose: true
definition:
containerlab: |
name: minimal
topology:
nodes:
srl1:
kind: nokia_srlinux
image: ghcr.io/nokia/srlinux:latest
ports:
- 22/tcp # SSH only
- 57400/tcp # gNMIEach entry is the destination port -- the port the node itself listens on -- with an optional
protocol. clabernetes allocates the pod-side port that carries it and records both in the Node
status, so docker style host:container bindings are not used here.
Effects:
- Only ports explicitly defined in the containerlab topology are exposed
- Automatic port list is not added
Auto-exposed ports (when disabled, these are NOT exposed):
| Port | Protocol | Service |
|---|---|---|
| 21 | TCP | FTP |
| 22 | TCP | SSH |
| 23 | TCP | Telnet |
| 80 | TCP | HTTP |
| 161 | UDP | SNMP |
| 443 | TCP | HTTPS |
| 830 | TCP | NETCONF over SSH |
| 5000 | TCP | vrnetlab QEMU telnet |
| 5900 | TCP | VNC |
| 6030 | TCP | gNMI (Arista default) |
| 9339 | TCP | gNMI/gNOI |
| 9340 | TCP | gRIBI |
| 9559 | TCP | P4RT |
| 57400 | TCP | gNMI (Nokia default) |
Service Types
LoadBalancer (Default)
External access via cloud load balancer:
spec:
expose:
exposeType: LoadBalancerCharacteristics:
- Provisions a cloud LoadBalancer (or MetalLB in bare-metal clusters)
- Each node gets an external IP address
- Ports are accessible from outside the cluster
ClusterIP
Internal-only access within the cluster:
spec:
expose:
exposeType: ClusterIPCharacteristics:
- No external IP provisioned
- Access via service name:
<topology>-<node>.<namespace>.svc.cluster.local - Suitable for in-cluster automation and testing
Headless
Direct pod access via DNS without load balancing:
spec:
expose:
exposeType: HeadlessCharacteristics:
- Creates a headless service (
clusterIP: None) - DNS queries return pod IPs directly instead of a virtual service IP
- No load balancing or proxying by kube-proxy
- Useful for StatefulSet-like access patterns where you need direct pod connectivity
Use cases:
- Service discovery where clients need to connect directly to specific pods
- Custom load balancing logic in client applications
- Integration with external service meshes that handle their own load balancing
- Scenarios where you need DNS-based pod discovery without Kubernetes proxying
None
No services but configuration preserved:
spec:
expose:
exposeType: NoneCharacteristics:
- Similar to
disableExpose: truebut the expose configuration is preserved - Useful when you might want to enable services later without changing other settings
Using Management IPs
You can assign specific IPs to LoadBalancer services based on the node's management IP from your containerlab topology.
IPv4 Management IP
apiVersion: c9s.run/v1alpha1
kind: Topology
metadata:
name: static-ips
spec:
expose:
exposeType: LoadBalancer
useNodeMgmtIpv4Address: true
definition:
containerlab: |
name: static
topology:
nodes:
srl1:
kind: nokia_srlinux
image: ghcr.io/nokia/srlinux:latest
mgmt-ipv4: 10.100.1.10 # This becomes the LoadBalancer IP
srl2:
kind: nokia_srlinux
image: ghcr.io/nokia/srlinux:latest
mgmt-ipv4: 10.100.1.11IPv6 Management IP
spec:
expose:
exposeType: LoadBalancer
useNodeMgmtIpv6Address: trueRequirements:
- Your cluster must support the specified IP addresses
- MetalLB or similar must have the IPs in its address pool
- If the IP is invalid or unavailable, Kubernetes allocates an IP automatically
Use cases:
- Consistent IP addressing across topology deployments
- Integration with external systems expecting specific IPs
- DNS pre-configuration
Examples Comparison
| Configuration | Services Created | External Access | Port Control |
|---|---|---|---|
| Default | LoadBalancer | Yes | Auto + Manual |
disableExpose: true | None | No | N/A |
disableAutoExpose: true | LoadBalancer | Yes | Manual only |
exposeType: ClusterIP | ClusterIP | No | Auto + Manual |
exposeType: Headless | Headless (clusterIP: None) | No | Auto + Manual |
exposeType: None | None | No | N/A |
Accessing Nodes
With LoadBalancer
# Get service IPs
kubectl get svc -l c9s.run/topologyOwner=my-topology
# SSH to node
ssh admin@<EXTERNAL-IP>
# gNMI to node
gnmic -a <EXTERNAL-IP>:57400 -u admin -p NokiaSrl1! capabilitiesWith ClusterIP
# From within the cluster (e.g., from a debug pod)
kubectl run debug --rm -it --image=alpine -- sh
apk add openssh-client
ssh admin@my-topology-srl1.default.svc.cluster.localWith Headless
# From within the cluster - DNS returns pod IPs directly
kubectl run debug --rm -it --image=alpine -- sh
apk add openssh-client bind-tools
# DNS lookup returns pod IP(s) instead of a virtual service IP
nslookup my-topology-srl1.default.svc.cluster.local
# Connect directly to the pod
ssh admin@my-topology-srl1.default.svc.cluster.localWith No Services
# Access via pod directly (not recommended for production)
kubectl exec -it deploy/my-topology-srl1 -- sr_cliBest Practices
-
Production deployments: Use
exposeType: LoadBalancerwithdisableAutoExpose: trueto expose only necessary ports -
CI/CD pipelines: Use
disableExpose: truewhen nodes only need internal connectivity -
Development: Use default settings for convenience
-
Security: Disable auto-expose and explicitly define only required ports
-
Cost optimization: Use
ClusterIPorHeadlesswhen external access isn't needed -
Service mesh integration: Use
exposeType: Headlesswhen integrating with service meshes that handle their own load balancing