The most surprising thing about Envoy’s control plane is that it doesn’t actually need Envoy.
Let’s see this in action. We’ll spin up a minimal Envoy instance and then use go-control-plane to push configuration to it.
First, a quick Envoy configuration. This envoy.yaml sets up a basic listener on port 8080 that forwards to a dummy cluster named local_backend.
static_resources:
listeners:
- name: listener_0
address:
socket_address:
protocol: TCP
address: 0.0.0.0
port_value: 8080
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match:
prefix: "/"
route:
cluster: local_backend
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: local_backend
connect_timeout: 0.25s
type: LOGICAL_DNS
lb_policy: ROUND_ROBIN
dns_lookup_family: V4_ONLY
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http3_protocol_options: {}
load_assignment:
cluster_name: local_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: 127.0.0.1
port_value: 8081 # This port doesn't actually need to be running
Now, let’s build our Go control plane. We’ll use go-control-plane/pkg/server to manage the xDS (Discovery Service) APIs. This server will push a simple route configuration to our Envoy instance.
Here’s the Go code:
package main
import (
"context"
"log"
"net"
"time"
discoveryv3 "github.com/envoyproxy/go-control-plane/envoy/api/v2/discovery"
routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
envoy_config_v3 "github.com/envoyproxy/go-control-plane/envoy/config/v3"
"github.com/envoyproxy/go-control-plane/pkg/cache/v3"
"github.com/envoyproxy/go-control-plane/pkg/resource/v3"
"github.com/envoyproxy/go-control-plane/pkg/server/v3"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/wrapperspb"
)
const (
// Control plane port for xDS APIs
controlPlanePort = 8080
// Envoy's xDS port (default)
envoyXdsPort = 8080
)
var (
// Set up the cache for xDS resources
snapshotCache cache.SnapshotCache
)
func main() {
ctx := context.Background()
// Create a new snapshot cache
snapshotCache = cache.NewSnapshotCache(false, cache.NewKeys(resource.ClusterType, resource.RouteType, resource.ListenerType, resource.SecretType))
// Create the xDS server
xdsServer := server.NewServer(ctx, snapshotCache, &server.Callbacks{})
// Start the gRPC server for xDS
go runXdsServer(ctx, xdsServer)
// Wait for a bit to ensure the server starts
time.Sleep(2 * time.Second)
// Define the desired state for Envoy
snapshot := cache.Snapshot{}
// Add a route configuration
routeConfig := &routev3.RouteConfiguration{
Name: "local_route",
VirtualHosts: []*routev3.VirtualHost{
{
Name: "local_service",
Domains: []string{"*"},
Routes: []*routev3.Route{
{
Match: &routev3.RouteMatch{
PathSpecifier: &routev3.RouteMatch_Prefix{Prefix: "/"},
},
Action: &routev3.Route_Route{
Route: &routev3.RouteAction{
ClusterSpecifier: &routev3.RouteAction_Cluster{Cluster: "local_backend"},
},
},
},
},
},
},
}
routeAny, err := anypb.New(routeConfig)
if err != nil {
log.Fatalf("Failed to create Any for route: %v", err)
}
snapshot.Resources[resource.RouteType] = cache.NewResources(0, map[string]cache.Resource{
routeConfig.Name: routeAny,
})
// Add a cluster configuration
clusterConfig := &envoy_config_v3.Cluster{
Name: "local_backend",
ConnectTimeout: &durationpb.Duration{
Seconds: 5,
Nanos: 0,
},
LbPolicy: envoy_config_v3.Cluster_ROUND_ROBIN,
ClusterDiscoveryType: &envoy_config_v3.Cluster_Type{
Type: envoy_config_v3.Cluster_STATIC, // Use STATIC for simplicity, though LOGICAL_DNS is common
},
LoadAssignment: &envoy_config_v3.ClusterLoadAssignment{
ClusterName: "local_backend",
Endpoints: []envoy_config_v3.ClusterLoadAssignment_Endpoint{
{
LbEndpoints: []envoy_config_v3.LbEndpoint{
{
HostIdentifier: &envoy_config_v3.LbEndpoint_Endpoint{
Endpoint: &envoy_config_v3.Endpoint{
Address: &envoy_config_v3.Address{
Address: &envoy_config_v3.Address_SocketAddress{
SocketAddress: &envoy_config_v3.SocketAddress{
Address: "127.0.0.1",
PortSpecifier: &envoy_config_v3.SocketAddress_PortValue{PortValue: 8081},
},
},
},
},
},
},
},
},
},
},
}
clusterAny, err := anypb.New(clusterConfig)
if err != nil {
log.Fatalf("Failed to create Any for cluster: %v", err)
}
snapshot.Resources[resource.ClusterType] = cache.NewResources(0, map[string]cache.Resource{
clusterConfig.Name: clusterAny,
})
// Add a listener configuration
listenerConfig := &envoy_config_v3.Listener{
Name: "listener_0",
Address: &envoy_config_v3.Address{
Address: &envoy_config_v3.Address_SocketAddress{
SocketAddress: &envoy_config_v3.SocketAddress{
Address: "0.0.0.0",
PortSpecifier: &envoy_config_v3.SocketAddress_PortValue{PortValue: 8080},
},
},
},
FilterChains: []*envoy_config_v3.FilterChain{
{
Filters: []*envoy_config_v3.Filter{
{
Name: "envoy.filters.network.http_connection_manager",
TypedConfig: &anypb.Any{
TypeUrl: "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager",
Value: marshalOrPanic(&envoy_config_v3.HttpConnectionManager{
StatPrefix: "ingress_http",
CodecType: envoy_config_v3.HttpConnectionManager_AUTO,
RouteSpecifier: &envoy_config_v3.HttpConnectionManager_RouteConfig{
RouteConfig: routeConfig, // Use the route config we defined earlier
},
HttpFilters: []*envoy_config_v3.HttpFilter{
{
Name: "envoy.filters.http.router",
TypedConfig: &anypb.Any{
TypeUrl: "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router",
},
},
},
}),
},
},
},
},
},
}
listenerAny, err := anypb.New(listenerConfig)
if err != nil {
log.Fatalf("Failed to create Any for listener: %v", err)
}
snapshot.Resources[resource.ListenerType] = cache.NewResources(0, map[string]cache.Resource{
listenerConfig.Name: listenerAny,
})
// Apply the snapshot to the cache. Envoy will fetch it.
log.Println("Applying snapshot to cache...")
if err := snapshotCache.SetSnapshot(ctx, "test-node", snapshot); err != nil {
log.Fatalf("Failed to set snapshot: %v", err)
}
log.Println("Snapshot applied. Envoy should now be configured.")
// Keep the main goroutine alive
select {}
}
// Helper to run the gRPC server
func runXdsServer(ctx context.Context, xdsServer server.Server) {
lis, err := net.Listen("tcp", ":9901") // Port for xDS API
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
log.Printf("xDS server listening on %v", lis.Addr())
grpcServer := grpc.NewServer()
discoveryv3.RegisterAggregatedDiscoveryServiceServer(grpcServer, xdsServer)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
// Helper to marshal protobuf to Any, panicking on error
func marshalOrPanic(pb proto.Message) []byte {
data, err := proto.Marshal(pb)
if err != nil {
panic(err)
}
return data
}
To run this:
- Save the Envoy config as
envoy.yaml. - Save the Go code as
main.go. - Run
go mod init example.com/controlplane(or your module name). - Run
go mod tidyto fetch dependencies. - Start Envoy:
envoy -c envoy.yaml --service-cluster my-cluster --service-node my-node - Run the Go control plane:
go run main.go
Now, if you curl http://localhost:8080/, you’ll see a 503 Service Unavailable. This is because Envoy is configured to forward to local_backend on port 8081, which isn’t actually running. But the configuration has been pushed from our Go control plane.
The go-control-plane library provides the gRPC server implementation for the xDS APIs (Discovery Service). Envoy clients connect to this server to request configuration updates. The cache.SnapshotCache is the core component where you define the desired state of your Envoy deployment (listeners, routes, clusters, endpoints, secrets). When you call SetSnapshot, the cache serializes these resources into the formats Envoy expects and makes them available for Envoy to fetch.
The server.Server struct orchestrates the gRPC endpoints for each xDS API (/v3/discovery:load_balancing_configs, /v3/discovery:clusters, etc.). It interacts with the SnapshotCache to retrieve the current desired state for each resource type requested by an Envoy instance. The resource.Type constants (resource.ClusterType, resource.RouteType, etc.) are crucial for organizing and managing these different types of configurations within the cache.
The trickiest part for newcomers is often understanding the resource types and how they relate. Envoy’s configuration is highly modular. A Listener defines how Envoy accepts traffic, and it contains FilterChains. A FilterChain can contain NetworkFilters, and the HttpConnectionManager is a common network filter that handles HTTP traffic. This HttpConnectionManager then uses RouteConfiguration to decide where to send requests. The RouteConfiguration itself refers to Clusters, which define upstream hosts.
The most powerful aspect of go-control-plane is its ability to dynamically update Envoy’s configuration without restarting Envoy. You can change routes, add new clusters, or update TLS certificates on the fly by simply updating the Snapshot in the SnapshotCache and calling SetSnapshot. Envoy, upon receiving the update from the control plane, will reconfigure itself accordingly. This dynamic nature is what enables advanced features like canary deployments, A/B testing, and fine-grained traffic management.
The control plane itself doesn’t need Envoy to run; it is the source of truth for Envoy’s configuration. Envoy is just the agent that fetches and applies this configuration. This separation of concerns allows you to manage a fleet of Envoy proxies from a single, centralized control plane.
The next challenge is managing endpoint discovery. You’ve seen how to push static cluster configurations, but in a real-world scenario, you’d want your control plane to dynamically discover healthy backend instances.