xDS, Frame by Frame
Envoy · data plane API · v3

xDS, frame
by frame

How an Envoy proxy learns its configuration while it runs. Scroll, and each diagram plays the next message on the wire.

  1. 01Two planeswho talks
  2. 02The resource graphLDS → EDS
  3. 03Bootstrapfirst contact
  4. 04ACKversion + nonce
  5. 05NACKerror_detail
  6. 06Subscriptionsstale nonces
  7. 07Four ways to streamSotW · Delta · ADS
  8. 08Ordering and warmingmake before break
01

Two planes

Envoy moves bytes. Something else decides where they go.

static config

An Envoy on its own

Every Envoy starts from a bootstrap file. You can list every listener and cluster in it under static_resources, and for one proxy that works fine.

For a fleet it doesn't. Each change means editing files on many machines and restarting or hot-restarting each process.

control plane

Enter the management server

xDS splits the system in two. The proxies form the data plane. A separate process, the management server, holds the configuration you want them to run.

Istio's istiod, Envoy Gateway, Gloo, and anything built on go-control-plane or java-control-plane all play this role.

transport

Configuration arrives on a stream

Each Envoy opens a long-lived, bidirectional gRPC stream to the server. Envoy sends requests up; the server sends configuration down, and keeps sending it whenever something changes.

Nothing restarts. Listeners, routes, and clusters are swapped in while traffic flows.

gRPC is the main transport. REST-JSON polling and watching files on disk use the same resource types.

node identity

Every request says who is asking

Requests carry a node with an id and a cluster. The server uses them to pick which configuration that proxy receives.

Two proxies on the same server can see completely different worlds. In go-control-plane this is literally a map from node ID to a snapshot.

Management server control plane · gRPC :18000 web-* → snapshot A pay-* → snapshot B bidirectional gRPC stream change = edit file + restart × N static.yaml Envoydata plane id web-1cluster web static.yaml Envoydata plane id web-2cluster web static.yaml Envoydata plane id pay-1cluster payments
02

The resource graph

Each "DS" serves one kind of resource. Together they describe one request's path through the proxy.

LDS · Listener

Where traffic enters

A Listener binds an address and port and holds filter chains. For HTTP, the important filter is the http_connection_manager.

The connection manager can embed its routes, or name a route table to fetch: rds.route_config_name.

RDS · RouteConfiguration

Where a request should go

A RouteConfiguration groups virtual hosts, matched by the Host header. Each virtual host has ordered routes: match a path, headers, or method, then send to a cluster.

Routes change far more often than listeners, which is why they live in a separate resource.

CDS · Cluster

A named group of upstream hosts

A Cluster carries everything about talking to a backend: load balancing policy, timeouts, circuit breakers, health checks, outlier detection, TLS.

With type: EDS, it doesn't list its hosts. It asks for them.

EDS · ClusterLoadAssignment

The actual IP addresses

A ClusterLoadAssignment lists endpoints grouped by locality, with weights, priorities, and health status.

Endpoints churn constantly as pods come and go. Keeping them separate means a scale-up never touches cluster settings.

SDS · Secret

Certificates, delivered on their own

TLS contexts can point to a Secret by name instead of embedding keys. Certificates rotate without rewriting listeners or clusters, and private keys stay out of the main config.

references

Every arrow is a name

Resources point at each other by string. The highlighted names are the glue: a listener names a route table, a route names a cluster, a cluster names its endpoints and secret.

A name can be referenced before its resource exists. Chapter 8 is about what happens then.

Less common types follow the same pattern: SRDS for scoped routes, VHDS for virtual hosts on demand, RTDS for runtime, ECDS for individual filter configs.

LDS Listener ingress_http · 0.0.0.0:8080 → http_connection_manager rds.route_config_name: ingress_routes RDS RouteConfiguration name: ingress_routes virtual_host api.example.com /users → cluster: users /orders → cluster: orders CDS Cluster name: users · type: EDS eds_cluster_config: { ads } tls sds: users-mtls EDS ClusterLoadAssignment cluster_name: users 10.0.4.11:9000 10.0.4.12:9000 upstream TLS downstream TLS SDS Secret users-mtls tls_certificate validation_context
03

Bootstrap

The one file Envoy still needs. It says who this proxy is and how to reach the server.

node

Identity first

node.id and node.cluster are copied into the first request on every stream. If the server keys snapshots by node ID and you get this wrong, the proxy connects fine and receives nothing.

Both can also be set with the --service-node and --service-cluster flags.

static_resources

The chicken-and-egg cluster

Envoy can't fetch clusters until it knows how to reach the server, and reaching the server needs a cluster. So exactly one cluster stays static: xds_cluster.

gRPC runs over HTTP/2, so that cluster must enable http2_protocol_options. Forgetting it is the most common reason a fresh setup never connects.

ads_config

One stream for everything

ads_config sets up the Aggregated Discovery Service: a single gRPC stream that carries every resource type. Chapter 7 covers why most deployments choose it.

lds_config · cds_config

Only two roots

The bootstrap names only listeners and clusters as dynamic. ads: {} means "fetch them over the ADS stream."

Routes, endpoints, and secrets never appear here. Envoy discovers them because listeners and clusters reference them, which is the graph from chapter 2.

bootstrap.yamlenvoy -c bootstrap.yaml
  1. node:
  2. id: web-1
  3. cluster: web
  4. dynamic_resources:
  5. ads_config:
  6. api_type: GRPC
  7. transport_api_version: V3
  8. grpc_services:
  9. - envoy_grpc:
  10. cluster_name: xds_cluster
  11. lds_config:
  12. ads: {}
  13. resource_api_version: V3
  14. cds_config:
  15. ads: {}
  16. resource_api_version: V3
  17. static_resources:
  18. clusters:
  19. - name: xds_cluster
  20. type: STRICT_DNS
  21. typed_extension_protocol_options:
  22. envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
  23. "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
  24. explicit_http_config:
  25. http2_protocol_options: {}
  26. load_assignment:
  27. cluster_name: xds_cluster
  28. endpoints:
  29. - lb_endpoints:
  30. - endpoint: { address: { socket_address: { address: xds.internal, port_value: 18000 }}}
04

ACK: a conversation

State-of-the-world xDS on a CDS stream. Two fields carry the whole protocol: version_info and the nonce.

first request

Envoy asks with empty hands

The first DiscoveryRequest has an empty version_info and an empty response_nonce: "I have nothing, and I'm not answering anything."

An empty resource_names on a Cluster stream is a wildcard: send every cluster meant for this node.

response

The server answers with a version and a nonce

The DiscoveryResponse carries the resources, a version_info naming this state, and a nonce naming this particular message.

The version describes the content. The nonce only identifies the message, so a reply can point at it.

ACK

Envoy acknowledges

Envoy validates and applies the clusters, then sends a new request with version_info: "v1" and response_nonce: "n1".

Read the pair as a sentence: "I'm answering message n1, and I now run v1." Since the reported version matches the one sent, this is an ACK.

push

Config changes, server pushes

Someone adds a payments cluster. The server sends v2 with nonce n2. It sends all three clusters, because in state-of-the-world mode each Cluster response is the complete list.

the open request

Every ACK is also the next question

Envoy ACKs v2. That request now sits at the server with nothing to answer it. The server holds it until the config changes, then responds.

There is no separate "subscribe" or "poll" call. The request in flight is the subscription.

Envoy Server Request · version "" · nonce "" Response · v1 · nonce n1 users, orders Request · v1 · nonce n1 ACK applied v1 Response · v2 · nonce n2 users, orders, payments Request · v2 · nonce n2 ACK applied v2 held openuntil change
DiscoveryRequest  Envoy → server
version_info:   ""
node:           { id: "web-1", cluster: "web" }
resource_names: []        # wildcard on a Cluster stream
type_url:       "type.googleapis.com/envoy.config.cluster.v3.Cluster"
response_nonce: ""
DiscoveryResponse  server → Envoy
version_info: "v1"
resources:    [ Cluster "users", Cluster "orders" ]
type_url:     "type.googleapis.com/envoy.config.cluster.v3.Cluster"
nonce:        "n1"
DiscoveryRequest  ACK
version_info:   "v1"    # the state I now run
response_nonce: "n1"    # the message I'm answering
resource_names: []
type_url:       "…envoy.config.cluster.v3.Cluster"
error_detail:   (unset)  # unset = accepted
DiscoveryResponse  server → Envoy
version_info: "v2"
resources:    [ "users", "orders", "payments" ]
              # the full list: omitted clusters would be deleted
nonce:        "n2"
DiscoveryRequest  ACK
version_info:   "v2"
response_nonce: "n2"
# this request stays pending at the server
# its arrival means: "send me the next change"
05

NACK: saying no

A rejection uses the same message as an ACK. The difference is in which version Envoy reports.

bad push

The server sends something invalid

Envoy is running v2. The server pushes v3, where the payments cluster has connect_timeout: 0s. The proto requires a timeout greater than zero, so validation fails.

NACK

Envoy answers the nonce, keeps the old version

The reply carries response_nonce: "n3", because it is answering message n3. But version_info stays "v2", the last version it accepted, and error_detail explains why.

Traffic keeps flowing on the old configuration. The invalid cluster never takes effect.

server side

Don't retry the same mistake

The NACK is also the next pending request. A server should not answer it by resending v3; it would be rejected again in a loop.

Good control planes surface the error_detail to an operator and wait for the configuration to change.

recovery

A fix arrives as a new version

The cluster is corrected. The server sends v4 with nonce n4. Envoy applies it and ACKs: version_info: "v4", response_nonce: "n4", no error.

Rule of thumb: version_info equal to the response's version means ACK. A version_info that is older, plus an error_detail, means NACK.

Envoy Server running v2 Response · v3 · nonce n3 payments: connect_timeout 0s Request · v2 · nonce n3 · error NACK still v2 server waits · no resend of v3 operator sees error_detail Response · v4 · nonce n4 connect_timeout 1s Request · v4 · nonce n4 ACK running v4
DiscoveryResponse  server → Envoy
version_info: "v3"
resources:    [ "users", "orders", "payments" ]
              # payments.connect_timeout = 0s
nonce:        "n3"
DiscoveryRequest  NACK
version_info:   "v2"    # last version I accepted
response_nonce: "n3"    # the message I'm rejecting
type_url:       "…envoy.config.cluster.v3.Cluster"
error_detail:
  code:    3          # INVALID_ARGUMENT
  message: "Proto constraint validation failed
            (ClusterValidationError.ConnectTimeout:
             value must be greater than 0s)"
DiscoveryRequest  ACK
version_info:   "v4"
response_nonce: "n4"
error_detail:   (unset)
06

Subscriptions and stale nonces

Endpoints are requested by name. When the names change while a response is already in flight, the nonce sorts it out.

named resources

EDS asks for specific names

Listener and Cluster streams are wildcard: Envoy wants all of them. Route and endpoint streams are named: Envoy lists exactly what it needs in resource_names.

Here Envoy knows one EDS cluster, so it asks for ["users"].

response + ACK

The usual exchange

The server sends the users endpoints at v7 with nonce n5. Envoy ACKs and repeats its subscription. In state-of-the-world mode, every request carries the full list of names Envoy wants.

crossing messages

Both sides talk at once

CDS just delivered a new cluster, orders. Envoy sends a request subscribing to ["users", "orders"], still referencing nonce n5.

At the same moment the server pushes a new users endpoint list as v8, nonce n6. The two messages cross on the wire.

stale nonce

The server ignores the old reply

When Envoy's request arrives, the server has already sent n6. A request that answers n5 is stale, and the spec says the server should not respond to it.

Without this rule, the server could act on Envoy's view from before n6 arrived.

converge

The latest request wins

Envoy ACKs n6, and since every request repeats the whole subscription, that ACK still asks for orders. Nothing was lost. The server answers with v9: both clusters' endpoints.

Envoy Server EDS Request · names [users] Response · v7 · n5 · users ACK · v7 · n5 · [users] n5 · [users, orders] v8 · n6 · users stale: n6 already sent ACK · v8 · n6 · [users, orders] Response · v9 · n7 · users, orders
DiscoveryRequest  EDS, first
version_info:   ""
resource_names: [ "users" ]   # named, not wildcard
type_url:       "…envoy.config.endpoint.v3.ClusterLoadAssignment"
response_nonce: ""
DiscoveryRequest  ACK
version_info:   "v7"
response_nonce: "n5"
resource_names: [ "users" ]   # repeated every time
Envoy → server               server → Envoy
response_nonce: "n5"            version_info: "v8"
resource_names: [ "users",       nonce:        "n6"
                  "orders" ]
# server's view when the n5 request lands
last nonce sent:   "n6"
request answers:   "n5"   → stale, no response
# Envoy will answer n6 soon, with its current names
DiscoveryRequest  ACK
version_info:   "v8"
response_nonce: "n6"
resource_names: [ "users", "orders" ]   # subscription survived
07

Four ways to stream

Two independent choices: how many streams, and whether each response carries everything or only changes.

separate streams

One stream per type

Each resource type gets its own gRPC service and method: StreamListeners, StreamRoutes, StreamClusters, StreamEndpoints. They can even point at different servers.

The cost: nothing orders updates across streams. A route can arrive before the cluster it names.

ADS

Everything on one stream

The Aggregated Discovery Service multiplexes all types over StreamAggregatedResources, distinguished by type_url. One server sees every request and chooses the order of every response.

That ordering control is why Istio and most production control planes use ADS.

state of the world

SotW: send the whole set

In the original variant, a Listener or Cluster response lists every resource. Change one cluster out of 5,000 and the server sends 5,000.

Deletion is implicit. If a cluster is missing from the list, Envoy removes it.

incremental

Delta: send only what changed

Delta xDS (DeltaDiscoveryRequest) carries only changed resources, each with its own version, plus an explicit removed_resources list.

Subscriptions change by difference too: resource_names_subscribe and resource_names_unsubscribe. On reconnect, initial_resource_versions tells the server what Envoy already has.

the grid

Pick one cell

The two choices combine into four gRPC methods per type. The ACK and NACK rules from chapters 4 and 5 hold in all of them.

Large meshes increasingly run Delta + ADS: one ordered stream and small payloads. VHDS, for virtual hosts loaded on demand, is only available in delta mode.

Envoy Server LDSRDSCDSEDS 4 streams · independent timing 1 stream · StreamAggregatedResources the server decides the order CDS EDS LDS RDS users orders ★ payments search cart SotW: all 5 clusters, every time missing from the list = deleted (LDS, CDS) orders · v14 removed: cart Delta: the change, plus removals each resource carries its own version SeparateAggregated (ADS) SotWDelta StreamListenersStreamClustersStreamRoutes … StreamAggregatedResources DeltaListenersDeltaClustersDeltaRoutes … DeltaAggregatedResources same ACK / NACK semantics in every cell
type_url tells resources apart on a shared stream
…envoy.config.listener.v3.Listener
…envoy.config.route.v3.RouteConfiguration
…envoy.config.cluster.v3.Cluster
…envoy.config.endpoint.v3.ClusterLoadAssignment
DiscoveryResponse  SotW, Cluster
version_info: "v14"           # one version for the whole set
resources:    [ users, orders, payments, search, cart ]
nonce:        "n9"
DeltaDiscoveryResponse  server → Envoy
system_version_info: "v14"   # informational only
resources:
  - name: "orders"  version: "14"  resource: { … }
removed_resources: [ "cart" ]
nonce: "d9"

DeltaDiscoveryRequest  ACK
response_nonce: "d9"        # no version_info field;
                             # error_detail unset = ACK
# gRPC services, one per type, two methods each
ClusterDiscoveryService.StreamClusters   # SotW
ClusterDiscoveryService.DeltaClusters    # Delta
AggregatedDiscoveryService.StreamAggregatedResources
AggregatedDiscoveryService.DeltaAggregatedResources
08

Ordering and warming

Names can point at things that don't exist yet. Envoy waits for some of them; for the rest, the server has to send things in the right order.

the failure

A route to nowhere

A new route sends /pay to cluster payments, but the CDS update with that cluster hasn't landed yet.

Envoy doesn't reject the route. It installs it, and requests to /pay fail with 503 and response flag NC: cluster not found.

adding

Make before break

The recommended order for additions is CDS → EDS → LDS → RDS. Each step only references things the previous steps created.

With ADS, the server can enforce this order on the single stream. With separate streams it has to be approximated.

warming

Envoy waits for some dependencies

A new EDS cluster is warming until its first ClusterLoadAssignment arrives. A new listener is warming until its route configuration arrives. Warming resources don't take traffic.

Warming ends after initial_fetch_timeout (15s by default) even if nothing arrives, so a missing resource eventually fails visibly.

Routes don't warm against clusters. That is why scene one breaks.

removing

Break in reverse

Removal goes the other way: first stop referencing a thing, then delete it. Update routes to drop /pay, then remove the payments cluster. Its endpoints go with it.

The same rule covers both directions. A reference must never outlive, or arrive before, what it points to.

RouteConfiguration /pay → cluster: payments payments ? 503 · NC upstream cluster not found CDSclusters EDSendpoints LDSlisteners RDSroutes adding: targets first, references last each step points only at things that already exist Cluster payments warming · waiting for ClusterLoadAssignment "payments" Listener ingress_http warming · waiting for RouteConfiguration "ingress_routes" initial_fetch_timeout: 15s → then active anyway RDSdrop /pay route CDSdrop payments EDSendpoints go too removing: references first, targets last
field notes

Three sentences to keep

  • version_info is the state Envoy runs. response_nonce is the message it's answering. ACK and NACK differ only in whether the version moved.
  • Every request is both a reply and the next subscription. Its resource_names (or delta subscribe lists) are what Envoy wants right now.
  • Resources reference each other by name, so update order matters. Add targets before references; remove references before targets.
ServiceResource · type_url suffixMode
LDSListener
envoy.config.listener.v3.Listener
wildcard
RDSRouteConfiguration
envoy.config.route.v3.RouteConfiguration
named
SRDSScopedRouteConfiguration
envoy.config.route.v3.ScopedRouteConfiguration
named
VHDSVirtualHost
envoy.config.route.v3.VirtualHost
delta only
CDSCluster
envoy.config.cluster.v3.Cluster
wildcard
EDSClusterLoadAssignment
envoy.config.endpoint.v3.ClusterLoadAssignment
named
SDSSecret
envoy.extensions.transport_sockets.tls.v3.Secret
named
RTDSRuntime
envoy.service.runtime.v3.Runtime
named
ECDSTypedExtensionConfig
envoy.config.core.v3.TypedExtensionConfig
named

Every type_url starts with type.googleapis.com/.