Example Multi-Cluster Service
Overview
KEP-1645: Multi-Cluster Services (MCS) API from SIG Multi-Cluster standardizes how a Service in one cluster can be exported and discovered in others. See the proposal: KEP-1645: Multi-Cluster Services (MCS) API
- ServiceExport: Marks a namespaced Service for export.
- ServiceImport: ClusterSet-scoped discovery surface for consumers.
Problem Description
Sveltos does not ship a built-in controller for KEP-1645.
Solution
Use Sveltos' Event Framework to automate the MCS pipeline:
- Detect
ServiceExportthe correspondingService. - Create a derived Service (normalized to
ClusterIP) and then a ServiceImport populated from the derived Service. - Detect
EndpointSliceupdates and mirror them across clusters, ensuring label/shape compatibility for CoreDNS.
Note
The examples below focus on resource creation and synchronization. status updates (conditions on ServiceExport, health, conflicts) are out of scope here and can be implemented later with an auxiliary controller or an additional Event pipeline.
KEP-1645 Specification
Resource Roles
The MCS API defines the following resources:
- ServiceExport: Created in the source cluster to mark a Service for export
- ServiceImport: Created in consuming clusters for service discovery
- Derived Service: A regular Service object (
derived-<hash>) that kube-proxy can recognize without modifications - EndpointSlice: Contains the actual endpoint information, labeled for multi-cluster discovery
Service Type Conversion Rules
| Source Service Type | ServiceImport Type | Derived Service Type | Notes |
|---|---|---|---|
| ClusterIP | ClusterSetIP | ClusterIP | Standard conversion |
| NodePort | ClusterSetIP | ClusterIP | Normalized to ClusterIP |
| LoadBalancer | ClusterSetIP | ClusterIP | Normalized to ClusterIP |
Headless (clusterIP: None) |
Headless | ClusterIP (headless) | Creates headless derived service |
| ExternalName | - | - | Cannot be exported |
Naming Conventions and Labels
Resource naming:
- Derived Service:
derived-<adler32sum(ServiceExport name)> - EndpointSlice:
derived-<adler32sum(ServiceExport name)>-<cluster-id>
Labels:
multicluster.kubernetes.io/service-name: Original service name (on both derived Service and EndpointSlice)kubernetes.io/service-name: Derived service name (on EndpointSlice)multicluster.kubernetes.io/service-imported: "true"(on derived Service)
Prerequisites
- Sveltos (and the Event Framework) installed in the management cluster.
- Source clusters labeled for selection; destination clusters labeled for consumption (examples below use
clusterset.k8s.io: environ-1andcluster.clusterset.k8s.io: <cluster-id>). - MCS CRDs present (
apiVersion: multicluster.x-k8s.io/v1alpha1). - Consistent namespaces across clusters namespace sameness.
- CoreDNS v1.12.2+ with the multicluster capability enabled.
- Flat Network connectivity: Pod IPs must be directly routable between clusters for this kube-proxy compatible implementation. The mirrored EndpointSlices contain actual Pod IPs that need to be reachable across cluster boundaries.
Architecture
In the management cluster, Sveltos wires two event flows:
-
EventSource:
mcs-service-deriverWatchesServiceExport+Serviceand emits a derived Service. -
EventSource:
mcs-serviceimport-generatorWatches the derived Service and emits a ServiceImport whosespec.ipsis copied from the derived Service's.spec.clusterIPs. -
EventSource:
mcs-endpoint-mirrorWatchesServiceExport+EndpointSliceand mirrors EndpointSlice data with required labels for DNS.
Each flow uses an EventTrigger plus a templated policy to render target resources in destination clusters. Hashing for derived names uses adler32sum over the ServiceExport name, producing stable derived-<hash> identifiers.
Step 1: Detect ServiceExports & Services, then create derived Service
This step normalizes ClusterIP/NodePort/LoadBalancer into a derived ClusterIP Service.
EventSource + EventTrigger + Policy (derived Service)
cat > eventsource-service-deriver.yaml <<'EOF'
---
apiVersion: lib.projectsveltos.io/v1beta1
kind: EventSource
metadata:
name: mcs-service-deriver
spec:
collectResources: true
resourceSelectors:
- group: "multicluster.x-k8s.io"
version: "v1alpha1"
kind: "ServiceExport"
- group: ""
version: "v1"
kind: "Service"
aggregatedSelection: | # lua
function evaluate()
local hs = {}
local collectedServices = {}
hs.message = ""
local serviceExports = {}
local services = {}
-- Categorize resources by type
for _, resource in ipairs(resources) do
local group = ""
if resource.apiVersion ~= nil then
local parts = {}
for part in string.gmatch(resource.apiVersion, "[^/]+") do
table.insert(parts, part)
end
if #parts > 1 then
group = parts[1]
end
end
if resource.kind == "ServiceExport" and group == "multicluster.x-k8s.io" then
local key = resource.metadata.namespace .. "/" .. resource.metadata.name
serviceExports[key] = resource
elseif resource.kind == "Service" and group == "" then
table.insert(services, resource)
end
end
-- Process Services
for _, service in ipairs(services) do
local serviceKey = service.metadata.namespace .. "/" .. service.metadata.name
-- Check if a corresponding ServiceExport exists
if serviceExports[serviceKey] ~= nil then
-- Add ServiceExport information to the Service's labels
if service.metadata.labels == nil then
service.metadata.labels = {}
end
service.metadata.labels["service-export-name"] = service.metadata.name
service.metadata.labels["service-export-namespace"] = service.metadata.namespace
service.metadata.labels["multicluster.kubernetes.io/service-name"] = service.metadata.name
table.insert(collectedServices, service)
hs.message = hs.message .. "Found Service for ServiceExport: " .. service.metadata.name .. "\n"
end
end
if #collectedServices > 0 then
hs.resources = collectedServices
end
return hs
end
---
apiVersion: lib.projectsveltos.io/v1beta1
kind: EventTrigger
metadata:
name: mcs-service-deriver
spec:
sourceClusterSelector:
matchExpressions:
- key: cluster.clusterset.k8s.io
operator: NotIn
values:
- ""
- key: clusterset.k8s.io
operator: In
values:
- environ-1
eventSourceName: mcs-service-deriver
oneForEvent: true
syncMode: ContinuousWithDriftDetection
policyRefs:
- name: mcs-service-deriver
namespace: mgmt
kind: Secret
---
apiVersion: v1
kind: Secret
metadata:
name: mcs-service-deriver
namespace: mgmt
annotations:
projectsveltos.io/instantiate: ok
type: addons.projectsveltos.io/cluster-profile
stringData:
service.yaml: | # helm
apiVersion: v1
kind: Service
metadata:
name: derived-{{ .Resource.metadata.name | adler32sum }}
namespace: {{ .Resource.metadata.namespace }}
labels:
multicluster.kubernetes.io/service-name: {{ .Resource.metadata.name }}
multicluster.kubernetes.io/service-imported: "true"
app.kubernetes.io/managed-by: sveltos
{{- range $key, $value := .Resource.metadata.labels }}
{{- if ne $key `service-export-name` }}
{{- if ne $key `service-export-namespace` }}
{{ $key }}: {{ $value }}
{{- end }}
{{- end }}
{{- end }}
spec:
{{- if eq .Resource.spec.clusterIP `None` }}
clusterIP: None
{{- else }}
type: ClusterIP
{{- end }}
{{- if .Resource.spec.selector }}
selector:
{{- range $key, $value := .Resource.spec.selector }}
{{ $key }}: {{ $value }}
{{- end }}
{{- end }}
{{- if .Resource.spec.ports }}
ports:
{{- range .Resource.spec.ports }}
- name: {{ .name }}
port: {{ .port }}
{{- if .targetPort }}
targetPort: {{ .targetPort }}
{{- end }}
protocol: {{ .protocol }}
{{- end }}
{{- end }}
EOF
Step 2: Create ServiceImport from the derived Service
This step reacts to the derived Service and generates the ServiceImport. For non-headless Services, spec.ips is copied from .spec.clusterIPs. For headless Services, type: Headless and ips is omitted.
EventSource + EventTrigger + Policy (ServiceImport from derived Service)
cat > eventsource-serviceimport-creator.yaml <<'EOF'
---
apiVersion: lib.projectsveltos.io/v1beta1
kind: EventSource
metadata:
name: mcs-serviceimport-generator
spec:
collectResources: true
resourceSelectors:
- group: ""
version: "v1"
kind: "Service"
labelFilters:
- key: "multicluster.kubernetes.io/service-imported"
operation: "Equal"
value: "true"
- key: "app.kubernetes.io/managed-by"
operation: "Equal"
value: "sveltos"
---
apiVersion: lib.projectsveltos.io/v1beta1
kind: EventTrigger
metadata:
name: mcs-serviceimport-generator
spec:
sourceClusterSelector:
matchExpressions:
- key: cluster.clusterset.k8s.io
operator: NotIn
values:
- ""
- key: clusterset.k8s.io
operator: In
values:
- environ-1
eventSourceName: mcs-serviceimport-generator
oneForEvent: true
syncMode: ContinuousWithDriftDetection
policyRefs:
- name: mcs-serviceimport-generator
namespace: mgmt
kind: Secret
---
apiVersion: v1
kind: Secret
metadata:
name: mcs-serviceimport-generator
namespace: mgmt
annotations:
projectsveltos.io/instantiate: ok
type: addons.projectsveltos.io/cluster-profile
stringData:
serviceimport.yaml: | # helm
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceImport
metadata:
name: {{ index .Resource.metadata.labels "multicluster.kubernetes.io/service-name" }}
namespace: {{ .Resource.metadata.namespace }}
annotations:
multicluster.kubernetes.io/derived-service: {{ .Resource.metadata.name }}
spec:
type: {{ if eq .Resource.spec.clusterIP `None` }}Headless{{ else }}ClusterSetIP{{ end }}
{{- if ne .Resource.spec.clusterIP `None` }}
ips:
{{- range .Resource.spec.clusterIPs }}
- {{ . }}
{{- end }}
{{- end }}
{{- if .Resource.spec.ports }}
ports:
{{- range .Resource.spec.ports }}
- name: {{ .name }}
port: {{ .port }}
protocol: {{ .protocol }}
{{- end }}
{{- end }}
EOF
Step 3: Detect EndpointSlices & mirror them for DNS
This step watches EndpointSlice updates tied to exported Services and mirrors them into destination clusters. It ensures:
kubernetes.io/service-nameis set to the derived Service (derived-<hash>).- EndpointSlice name follows the pattern:
derived-<hash>-<cluster-id>. - Other labels (e.g., per-cluster identity) are preserved.
EventSource + EventTrigger + Policy (EndpointSlice mirroring)
cat > eventsource-endpoint-mirror.yaml <<'EOF'
---
apiVersion: lib.projectsveltos.io/v1beta1
kind: EventSource
metadata:
name: mcs-endpoint-mirror
spec:
collectResources: true
resourceSelectors:
- group: "multicluster.x-k8s.io"
version: "v1alpha1"
kind: "ServiceExport"
- group: "discovery.k8s.io"
version: "v1"
kind: "EndpointSlice"
labelFilters:
- key: "kubernetes.io/service-name"
operation: "Different"
value: ""
aggregatedSelection: | # lua
function evaluate()
local hs = {}
local collectedEndpointSlices = {}
hs.message = ""
local serviceExports = {}
local endpointSlices = {}
-- Categorize resources by type
for _, resource in ipairs(resources) do
local group = ""
if resource.apiVersion ~= nil then
local parts = {}
for part in string.gmatch(resource.apiVersion, "[^/]+") do
table.insert(parts, part)
end
if #parts > 1 then
group = parts[1]
end
end
if resource.kind == "ServiceExport" and group == "multicluster.x-k8s.io" then
local key = resource.metadata.namespace .. "/" .. resource.metadata.name
serviceExports[key] = resource
elseif resource.kind == "EndpointSlice" and group == "discovery.k8s.io" then
table.insert(endpointSlices, resource)
end
end
-- Process EndpointSlices
for _, endpointSlice in ipairs(endpointSlices) do
if endpointSlice.metadata.labels ~= nil and
endpointSlice.metadata.labels["kubernetes.io/service-name"] ~= nil then
local serviceName = endpointSlice.metadata.labels["kubernetes.io/service-name"]
local serviceExportKey = endpointSlice.metadata.namespace .. "/" .. serviceName
-- Check if a corresponding ServiceExport exists
if serviceExports[serviceExportKey] ~= nil then
-- Add ServiceExport information to EndpointSlice labels
if endpointSlice.metadata.labels == nil then
endpointSlice.metadata.labels = {}
end
endpointSlice.metadata.labels["service-export-name"] = serviceName
endpointSlice.metadata.labels["service-export-namespace"] = endpointSlice.metadata.namespace
endpointSlice.metadata.labels["multicluster.kubernetes.io/service-name"] = serviceExports[serviceExportKey].metadata.name
table.insert(collectedEndpointSlices, endpointSlice)
hs.message = hs.message .. "Found EndpointSlice for ServiceExport: " .. serviceName .. "\n"
end
end
end
if #collectedEndpointSlices > 0 then
hs.resources = collectedEndpointSlices
end
return hs
end
---
apiVersion: lib.projectsveltos.io/v1beta1
kind: EventTrigger
metadata:
name: mcs-endpoint-mirror
spec:
sourceClusterSelector:
matchExpressions:
- key: cluster.clusterset.k8s.io
operator: NotIn
values:
- ""
- key: clusterset.k8s.io
operator: In
values:
- environ-1
eventSourceName: mcs-endpoint-mirror
oneForEvent: true
syncMode: ContinuousWithDriftDetection
policyRefs:
- name: mcs-endpoint-mirror
namespace: mgmt
kind: Secret
---
apiVersion: v1
kind: Secret
metadata:
name: mcs-endpoint-mirror
namespace: mgmt
annotations:
projectsveltos.io/instantiate: ok
type: addons.projectsveltos.io/cluster-profile
stringData:
endpointslice.yaml: |
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: derived-{{ index .Resource.metadata.labels "service-export-name" | adler32sum }}-{{ index .Cluster.metadata.labels "cluster.clusterset.k8s.io" }}
namespace: {{ .Resource.metadata.namespace }}
labels:
{{- range $key, $value := .Resource.metadata.labels }}
{{- if eq $key "kubernetes.io/service-name" }}
{{ $key }}: derived-{{ $value | adler32sum }}
{{- else }}
{{ $key }}: {{ $value }}
{{- end }}
{{- end }}
multicluster.kubernetes.io/source-cluster: {{ index .Cluster.metadata.labels "cluster.clusterset.k8s.io" }}
endpointslice.kubernetes.io/managed-by: sveltos
addressType: {{ .Resource.addressType }}
{{- if .Resource.endpoints }}
endpoints:
{{- range .Resource.endpoints }}
- addresses:
{{- range .addresses }}
- {{ . }}
{{- end }}
{{- if .conditions }}
conditions:
ready: {{ .conditions.ready }}
serving: {{ .conditions.serving }}
terminating: {{ .conditions.terminating }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Resource.ports }}
ports:
{{- range .Resource.ports }}
- name: {{ .name }}
port: {{ .port }}
protocol: {{ .protocol }}
{{- end }}
{{- end }}
EOF
Step 4: Configure CoreDNS for Multi-Cluster DNS
To enable DNS resolution for clusterset.local domain, CoreDNS needs to be configured with the kubernetes plugin. This configuration is based on Cilium's MCS-API prerequisites.
Update CoreDNS Version
First, ensure CoreDNS is at version 1.12.2 or later which includes multi-cluster capability:
Add RBAC for ServiceImports
CoreDNS needs permissions to read ServiceImports:
# Create ClusterRole for reading ServiceImports
kubectl create clusterrole coredns-mcsapi \
--verb=list,watch --resource=serviceimports.multicluster.x-k8s.io
# Bind the role to CoreDNS service account
kubectl create clusterrolebinding coredns-mcsapi \
--clusterrole=coredns-mcsapi --serviceaccount=kube-system:coredns
Configure CoreDNS Corefile
Update the CoreDNS ConfigMap to add clusterset.local zone and enable the multicluster plugin:
# Update CoreDNS configuration
kubectl get configmap -n kube-system coredns -o yaml | \
sed -e 's/cluster\.local/cluster.local clusterset.local/g' | \
sed -E 's/^(.*)kubernetes(.*)\{/\1kubernetes\2{\n\1 multicluster clusterset.local/' | \
kubectl replace -f-
This configuration:
- Adds
clusterset.localto the DNS zones handled by CoreDNS - Enables the
multiclusterplugin for theclusterset.localzone - Maintains backward compatibility with existing
cluster.localresolution
Apply Configuration
Roll out the CoreDNS deployment to apply the changes:
Verification
After configuration, services exported via ServiceExport/ServiceImport will be resolvable at:
<service-name>.<namespace>.svc.clusterset.local- for ClusterSetIP services
Concrete Transformation Examples
ClusterIP Service Example
Source Cluster (cluster-a): Original Resources
# Original Service
apiVersion: v1
kind: Service
metadata:
name: web-service
namespace: default
spec:
type: ClusterIP
selector:
app: web
ports:
- name: http
port: 80
targetPort: 8080
---
# ServiceExport
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceExport
metadata:
name: web-service
namespace: default
Destination Clusters: Generated Resources
# Derived Service
apiVersion: v1
kind: Service
metadata:
name: derived-3d8f2a9c # adler32sum("web-service")
namespace: default
labels:
multicluster.kubernetes.io/service-name: web-service
multicluster.kubernetes.io/service-imported: "true"
app.kubernetes.io/managed-by: sveltos
spec:
type: ClusterIP
selector:
app: web
ports:
- name: http
port: 80
targetPort: 8080
---
# ServiceImport
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceImport
metadata:
name: web-service
namespace: default
annotations:
multicluster.kubernetes.io/derived-service: derived-3d8f2a9c
spec:
type: ClusterSetIP
ips: ["10.96.0.120"]
ports:
- name: http
port: 80
protocol: TCP
---
# EndpointSlice
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: derived-3d8f2a9c-cluster-a
namespace: default
labels:
kubernetes.io/service-name: derived-3d8f2a9c
multicluster.kubernetes.io/service-name: web-service
endpointslice.kubernetes.io/managed-by: sveltos
addressType: IPv4
ports:
- name: http
port: 80
protocol: TCP
endpoints:
- addresses: ["10.0.1.1", "10.0.1.2"]
conditions:
ready: true
Headless Service Example
Source Cluster: Original Resources
# Headless Service
apiVersion: v1
kind: Service
metadata:
name: stateful-service
namespace: default
spec:
clusterIP: None
selector:
app: stateful
ports:
- name: http
port: 80
---
# ServiceExport
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceExport
metadata:
name: stateful-service
namespace: default
Destination Clusters: Generated Resources
# Derived Headless Service
apiVersion: v1
kind: Service
metadata:
name: derived-4b7e3f8a # adler32sum("stateful-service")
namespace: default
labels:
multicluster.kubernetes.io/service-name: stateful-service
multicluster.kubernetes.io/service-imported: "true"
app.kubernetes.io/managed-by: sveltos
spec:
clusterIP: None
selector:
app: stateful
ports:
- name: http
port: 80
---
# ServiceImport
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceImport
metadata:
name: stateful-service
namespace: default
annotations:
multicluster.kubernetes.io/derived-service: derived-4b7e3f8a
spec:
type: Headless
ports:
- name: http
port: 80
protocol: TCP
---
# EndpointSlice for DNS resolution
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: derived-4b7e3f8a-cluster-a
namespace: default
labels:
kubernetes.io/service-name: derived-4b7e3f8a
multicluster.kubernetes.io/service-name: stateful-service
endpointslice.kubernetes.io/managed-by: sveltos
addressType: IPv4
ports:
- name: http
port: 80
protocol: TCP
endpoints:
- addresses: ["10.0.2.1"]
conditions:
ready: true
Behavior & Notes
-
Service types
-
ClusterIP/NodePort/LoadBalancer→ derived ClusterIP Service +ServiceImport(type: ClusterSetIP). - Headless (
clusterIP: None) → derived Headless Service +ServiceImport(type: Headless)and mirrored EndpointSlice data to back DNS. -
ExternalName: not exported (out of scope for this flow).
-
DNS (CoreDNS) DNS configuration is delegated to CoreDNS. The multicluster capability is available in CoreDNS v1.12.2 and later; enable/configure it in your CoreDNS deployment. Note: If you are not relying on Multi-Cluster DNS names for service discovery, the CoreDNS multicluster capability is not required. For headless Services, without Multi-Cluster DNS you will not get cross-cluster A/AAAA/SRV records out of the box.
-
Labels
-
Both derived Service and mirrored EndpointSlice include
multicluster.kubernetes.io/service-name: <clusterset service>. - EndpointSlice includes
kubernetes.io/service-name: <derived service name>for kube-proxy. -
Additional labels from source are preserved (except internal helper labels used during templating).
-
Hashing
-
Derived names use
adler32sumof the ServiceExport name:derived-<hash>. This produces stable, compact identifiers. -
EndpointSlice names include cluster identifier:
derived-<hash>-<cluster-id>. -
Selector Maintenance
-
All imported Services maintain their selectors.
-
This enables Pods with matching labels in the importing cluster to be automatically added as service endpoints.
-
Status
-
Conditions on
ServiceExport(e.g., invalid types, headless/non-headless conflicts, per-cluster availability) are not set by the snippets above. Implement these via a lightweight controller or an additional Event flow that writes backstatus. -
ServiceImport IPs field
-
The
ipsfield inServiceImportis automatically populated by the second Event flow, copying from the derived Service's.spec.clusterIPs.
Result
- Exported Services are discoverable across clusters with minimal configuration.
- Consumers resolve via ServiceImport (
ClusterSetIPor headless DNS) backed by synchronized EndpointSlices. - The entire pipeline is event-driven, declarative.
This pattern accelerates multi-cluster adoption for HA, traffic shaping, and cross-environment integration while keeping the implementation open and extensible.