
Edge
KubeEdge Device Twins and the Industrial Data Plane
How KubeEdge models devices as Kubernetes CRDs, why DMI lets telemetry stay on site, and where the twin's offline guarantees actually stop.
Search for kubeedge and you get "Kubernetes at the edge" — accurate, and it hides what makes the project structurally different. KubeEdge does not just run workloads at the edge; it models the machines. Each becomes a custom resource with a spec and a status, and the driver speaking Modbus or OPC UA to it becomes a process the cluster registers over a gRPC socket. kubectl, RBAC and admission control now apply to the physical plant — as does the question of where its data goes.
The edge problem you actually have is a device problem
"Edge Kubernetes" bundles two unrelated problems. The first is cluster-shaped: fifty small clusters in fifty sites, each needing provisioning, a private registry and a rollout that survives a bad link. That one is answered by K3s and a pull-based GitOps agent — cluster lifecycle, not device semantics.
The second is device-shaped. A PLC exposes holding register 40001 as a temperature scaled by ten; a vessel has a setpoint a control room must change; a vibration sensor emits at 100 Hz and the analysis that matters happens on site. None of that is a Deployment, and a register address is not a Kubernetes concept.
KubeEdge is a graduation-level hosted project of the CNCF; its current release is v1.23.1, published 15 July 2026. It took the second problem seriously: DeviceModel describes what a class of machine can do, Device one physical instance, and a *mapper* — a driver you write — bridges the two to the wire protocol.
The test before adopting: if the artefact you struggle to manage is a manifest, KubeEdge's device layer is overhead. If it is a register address or a setpoint, modelling it as cluster state is how it gets version control, review and an audit trail.
How KubeEdge splits the control plane from the device data plane
The path from a PLC register to the Kubernetes API has more hops than most diagrams show, and one is routinely described wrongly.
- DeviceController, in CloudCore, watches
Deviceobjects, creates the matchingDeviceStatus, pushes desired values down and patches reported state up. - CloudHub / EdgeHub carry that across the WAN — EdgeHub connects to CloudHub over either WebSocket or QUIC, syncing cloud-side resource updates and reporting host and device status changes.
- MetaManager is the message processor between edged and edgehub, storing and retrieving metadata in a lightweight SQLite database — what makes offline operation real.
- DeviceTwin holds node-local twin state and serves the DMI socket.
- The mapper talks to the machine, in your code and your language.
The seam between EdgeCore and the mapper is the Device Management Interface, defined in gRPC proto. In mapper-framework v1.23.0 it is a pair of Unix domain sockets, not a broker: the mapper dials /etc/kubeedge/dmi.sock through grpc.Dial over a resolved unix address and calls MapperRegister on a DeviceManagerService client. No MQTT subscription appears.
That matters because KubeEdge's own device-controller architecture page still says "The mapper gets these updates via the MQTT broker." That is the pre-DMI design; taken as current, it makes a broker a hard dependency of device control. MQTT has two real roles in v1.23: EdgeCore's EventBus transport, and pushMethod.mqtt as an optional data-plane destination.
The proto is bidirectional. EdgeCore serves DeviceManagerService: the mapper calls MapperRegister to receive the devices and models it manages, and ReportDeviceStatus to send collected twins upstream. The mapper serves DeviceMapperService: EdgeCore calls RegisterDevice and UpdateDevice, documented as how the device manager updates a device's information so the mapper reconnects.
DMI decouples device management plane and device data plane… device data is pushed directly from the data plane and does not necessarily need to be pushed to the cloud.
KubeEdge presents that as a congestion optimisation. Read it as a residency control: the management plane crosses the WAN, the data plane need not.
Reading the device twin contract precisely
A DeviceModel is the template. The Go API marks spec.protocol required; the shipped v1.23.1 CRD enforces nothing, so a manifest omitting it applies cleanly and binds nothing. Property types are constrained to the enum INT, FLOAT, DOUBLE, STRING, BOOLEAN, BYTES, STREAM, and access mode to ReadWrite or ReadOnly.
apiVersion: devices.kubeedge.io/v1beta1
kind: DeviceModel
metadata:
name: jacket-controller
namespace: plant-a
spec:
# Required by the API, unenforced by the CRD schema. It routes model-level
# DMI calls (CreateDeviceModel and friends) to a mapper; the Device's own
# spec.protocol.protocolName binds device operations and every property.
# Nothing checks that the two agree.
protocol: modbus
properties:
- name: jacket-temperature
type: FLOAT
accessMode: ReadOnly
unit: degC
minimum: "-40"
maximum: "250"
- name: jacket-vibration
type: FLOAT
accessMode: ReadOnly
unit: mm/s
minimum: "0"
maximum: "50"
- name: jacket-setpoint
type: FLOAT
accessMode: ReadWrite
unit: degC
minimum: "0"
maximum: "200"A Device instantiates it. spec.deviceModelRef points at the template, spec.nodeName schedules it onto an edge node, and both spec.protocol and each property's visitors are a protocolName plus a schema-free configData block — which is why KubeEdge needs no knowledge of Modbus or OPC UA. Cycles below are milliseconds.
apiVersion: devices.kubeedge.io/v1beta1
kind: Device
metadata:
name: vessel-07
namespace: plant-a
spec:
deviceModelRef:
name: jacket-controller
nodeName: edge-plant-a-01
protocol:
protocolName: modbus
configData:
serialPort: /dev/ttyS0
baudRate: 9600
slaveID: 3
properties:
- name: jacket-temperature
collectCycle: 1000 # 1 s (milliseconds)
reportCycle: 5000 # 5 s (milliseconds)
reportToCloud: true
visitors:
protocolName: modbus
configData:
register: HoldingRegister
offset: 1
scale: 0.1
- name: jacket-setpoint
desired:
value: "21.0"
collectCycle: 10000 # 10 s (milliseconds)
reportToCloud: true
visitors:
protocolName: modbus
configData:
register: HoldingRegister
offset: 12
limit: 1
scale: 0.1Now the twin. A Twin carries a Reported and an ObservedDesired value: the cloud configures a property's desired state, that configuration is pushed to the edge node, and the mapper commands the device to change it. ObservedDesired is documented as the desired value the mapper received in the current cycle — evidence of mapper receipt, not of actuation.
Third-party tutorials still showing Device.status.twins are not wrong: v1alpha2 remains a served version of the Device CRD in v1.23.1 and does carry twins. But that is the v1alpha2 projection, not live v1beta1 state.
# Twin state — the DeviceStatus object shares its Device's name and namespace.
kubectl get devicestatus vessel-07 -n plant-a -o json \
| jq '{ state: .status.state, lastOnline: .status.lastOnlineTime,
twins: [ .status.twins[]? | { property: .propertyName,
reported: .reported.value,
observed: .observedDesired.value } ] }'
# Residency audit: which properties still ship north?
kubectl get device -A \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{range .spec.properties[*]}{.name}{"="}{.reportToCloud}{" "}{end}{"\n"}{end}'
# The common mistake — nothing comes back for a healthy device, because
# Device.status is DeviceStatusOld (reportToCloud and reportCycle only):
# kubectl get device vessel-07 -n plant-a -o jsonpath='{.status.twins}'Keeping telemetry on site: reportToCloud, pushMethod, and residency
Every DeviceProperty carries its own reportToCloud boolean alongside collectCycle and reportCycle, and that one field decides whether a property's values cross the cloud-edge link. Beside it sits pushMethod, the per-property data-plane destination: http, mqtt, otel, or dbMethod for a database the mapper can reach.
Set high-rate telemetry to reportToCloud: false with a dbMethod target and leave the low-rate control property reporting north. Its reportCycle — milliseconds, like collectCycle, and the cadence every pushMethod handler ticks on — drives the on-site write; omit it and mapper-framework falls back to one second. One tick is one device read, so the transport must carry the cadence: 10 ms needs Modbus TCP, not a 9600-baud serial bus. The 100 Hz stream then lands in a time-series database in the plant; the setpoint and the device's online state cross the WAN — a residency posture in two YAML keys an auditor can read.
apiVersion: devices.kubeedge.io/v1beta1
kind: Device
metadata:
name: vessel-08
namespace: plant-a
spec:
deviceModelRef:
name: jacket-controller
nodeName: edge-plant-a-01
protocol:
protocolName: modbus
configData:
deviceIP: 10.20.0.41
tcpPort: 502
slaveID: 4
properties:
# High-rate: collected, stored and consumed on site. Never sent north.
# reportCycle drives every pushMethod ticker. collectCycle is inert here:
# the twin loop returns before its ticker whenever reportToCloud is false.
- name: jacket-vibration
reportCycle: 10 # 10 ms — 100 Hz, on site
reportToCloud: false # <- the residency control
visitors:
protocolName: modbus
configData:
register: HoldingRegister
offset: 20
scale: 0.001
pushMethod:
dbMethod:
influxdb2:
influxdb2ClientConfig:
url: http://influxdb.plant-a.svc.cluster.local:8086
org: plant-a
bucket: vessel-hf
influxdb2DataConfig:
measurement: jacket_vibration
fieldKey: value
# Control state: low rate, and it does cross the WAN.
- name: jacket-setpoint
desired:
value: "21.0"
collectCycle: 10000
reportToCloud: true
visitors:
protocolName: modbus
configData:
register: HoldingRegister
offset: 12
scale: 0.1Two caveats. DBMethodConfig exposes influxdb2, redis and mysql in lower case, but TDEngine's key is TDEngine — capital T, capital E — and a lower-case tdengine silently binds nothing. And mapper-framework's README marks its InfluxDB, Redis, HTTP and MQTT client.go files WIP while omitting the MySQL, TDEngine and OTEL implementations the v1.23.0 template ships: read the generated project, not the README. Where the destination is metrics, pushMethod.otel feeds a collector pipeline you already run.
The regulatory framing needs care. EU Data Act Article 3(1) is an access-by-design duty owed to the user, not a residency mandate. It requires that product data and related service data be, by default, easily, securely, free of charge, in a comprehensive, structured, commonly used and machine-readable format — including the metadata needed to interpret them — and, where relevant and technically feasible, directly accessible to the user. It does not require reportToCloud: false. The Regulation has applied since 12 September 2025, and Article 3(1) to connected products placed on the market after 12 September 2026. The implication is architectural: the duty is easier to meet when data lands where the user's operator controls it.
Offline autonomy and exactly where its guarantees stop
KubeEdge lists edge autonomy as a core feature: edge nodes and their applications run normally when the cloud-edge network is unstable, or the edge is offline and restarted. MetaManager's SQLite store is the mechanism, and the reboot case separates it from a cache. Twin properties are the documented path for offline control and command.
So during an outage pods keep running, the mapper keeps actuating against the last desired value it received, and pushMethod.dbMethod keeps writing to the on-site database. Three things do not survive.
- No actuation confirmation.
observedDesiredproves the mapper received the value. If the physical write can fail — a jammed valve, a drive in fault — the only evidence is a read-back property whosereportedvalue you compare. - Desired resolution is last-write-wins.
desiredholds a single value, so a setpoint written while the link was down and one written after it returns do not reconcile — no merge, no conflict surface. - Twin freshness is per property.
spec.properties[].collectCyclesets it, gated entirely byreportToCloud— the twin loop returns before starting a ticker when the flag is false. Omit it and it falls back to a one-second default.
One documentation artefact worth naming: the MetaManager architecture page describes MetaSync messages that sync pod status on the edge node, on an interval it calls configurable in conf/edgecore.yaml, defaulting to 60 seconds. The v1.23.1 MetaManager component config exposes exactly enable, contextSendGroup, contextSendModule, remoteQueryTimeout and metaServer — no such key, and a pod-status mechanism regardless.
Safety interlocks: hold-and-release for machines mid-cycle
A rollout that restarts a pod is harmless in a data centre, dangerous when the pod is driving a machine mid-cycle. KubeEdge's answer is hold-and-release, built for edge scenarios — drones, robotics, autonomous vehicles — where uncontrolled resource upgrades cause serious safety and operational issues. A pod annotated edge.kubeedge.io/hold-upgrade: "true" is intercepted by edged and queued rather than started, a HeldUpgrade condition is reported to the cloud, and held upgrades survive a node restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: vessel-controller
namespace: plant-a
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
# Held edge pods stall a rollout; upgrade ordering is unpredictable
# in a mixed cloud/edge cluster. Tune these deliberately.
maxUnavailable: 1
maxSurge: 0
selector:
matchLabels:
app: vessel-controller
template:
metadata:
labels:
app: vessel-controller
annotations:
edge.kubeedge.io/hold-upgrade: "true"
spec:
nodeSelector:
node-role.kubernetes.io/edge: ""
containers:
- name: controller
image: registry.plant-a.internal/vessel-controller:2.7.1The half every summary omits is the release. Holding is a cloud-side annotation; unholding is not. The documentation is explicit that the unhold commands must be issued on the edge node system, with prerequisites: KubeEdge v1.22.0 or later, and MetaServer running on edge nodes — at a component-config default of Enable: false. Ship the annotation without that groundwork and replacement pods sit parked in edged's queue.
# Prerequisites: KubeEdge v1.22.0 or later, MetaServer enabled on this node
# (modules.metaManager.metaServer.enable defaults to false).
# Release every held upgrade on this node, between machine cycles.
keadm ctl unhold-upgrade node edge-plant-a-01
# Or release one pod, via the node-local MetaServer endpoint. These paths are a
# pod's own credentials: run it from a pod here, or supply your own on the host.
curl -X POST \
--cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt \
-H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
-H "Content-Type: text/plain" \
--data "plant-a/vessel-controller-74f696fddd-5mjxp" \
https://127.0.0.1:10550/api/v1/pods/unhold-upgradeSecurity, versions, and the audit trail
Enrolment is keadm join, with three constraints stated in the setup documentation: --cloudcore-ipport is mandatory, --token is needed if the edge node's certificate is to be issued automatically, and the KubeEdge version should match on the cloud and edge sides. That last is the operational tax: a KubeEdge upgrade is a coordinated cloud-and-fleet event, deserving the planning of any version-skew migration.
KubeEdge 1.23 is documented as exactly compatible with Kubernetes 1.30, 1.31 and 1.32, and only partially compatible with 1.27 through 1.29 — where partial means KubeEdge has features or API objects that may not exist in that Kubernetes version. Pin the control plane inside the exact band.
The MQTT broker deserves a blast-radius note. modules.eventBus.mqttMode chooses the broker type — 0 internal only, 1 internal and external, 2 external only — and 2 is the default, so mosquitto or emqx edge must be installed on the edge node. That broker sits next to the machines, outside the cluster's RBAC: production infrastructure with its own authentication, on a node where an immutable, API-managed OS matters more than in a data centre.
# EXCERPT of a much larger generated edgecore.yaml. The '# ...' markers stand
# for omitted modules — pasting this as a whole config will break the node.
apiVersion: edgecore.config.kubeedge.io/v1alpha2
kind: EdgeCore
modules:
# ...
eventBus:
enable: true
mqttMode: 2 # 2 = external broker only, already the default;
# this confirms intent, it does not change it.
mqttServerExternal: tcp://127.0.0.1:1883
# ...
metaManager:
enable: true
metaServer:
enable: true # Defaults to false. Required for unhold-upgrade.
# ...One date belongs in the calendar, not the architecture: if what the site builds ships as a product into the EU, the Cyber Resilience Act's Article 14 reporting obligations apply from 11 September 2026. The window is three-staged — early warning of an actively exploited vulnerability within 24 hours, a fuller notification within 72, and under Article 14(2)(c) a final report within 14 days of a corrective or mitigating measure becoming available. What that does to a build pipeline is a separate discipline; a mapper you wrote is firmware you maintain.
Exit ramps: what survives if you drop KubeEdge
Adopt nothing at the industrial edge without pricing the way out: the assets stay twenty years and the orchestrator will not. Split the artefacts in two.
- Survives. The mapper's driver layer — register maps, scaling factors, OPC UA node IDs — is ordinary code in your language, and mapper-framework generates the surrounding layers so the driver is the part you fill in. The on-site database and its schema are yours, and the protocol
configDatablocks are opaque to KubeEdge: a portable inventory of every machine you own. - Does not survive. The CRD field layout is KubeEdge-shaped —
deviceModelRef,visitors,reportToCloud,pushMethodhave no equivalent elsewhere. The DMI service implementation gets deleted outright, and twin semantics leak into anything readingobservedDesired.
That ratio is why protocol knowledge belongs in the driver layer, out of the CRDs. Write the register map once, in code, with tests; keep the manifests thin. Then migration is a re-wrap of a library rather than a re-survey of a factory — and that re-survey is what makes people stay. Same arithmetic as any exit-cost model.
The long game at the industrial edge
A press installed in 2014 will outlive KubeEdge, and that asymmetry should drive the architecture. The durable artefacts are the device model — what a class of machine measures, what it accepts, in what units and within what limits — and the on-site store holding what those machines produced. Both are older ideas than Kubernetes.
KubeEdge's contribution is putting them under version control, review and RBAC — a declarative contract instead of a spreadsheet of register addresses on a shared drive. Stay disciplined about the boundary: protocol knowledge in code you own, residency in fields an auditor reads, the on-site database treated as a system of record with its own recovery objectives, not a buffer.
Then the twin is a convenience, not a dependency, and the sovereignty claim is structural rather than aspirational: the data never left, the drivers are yours, the orchestrator is replaceable.
§FAQ/Common questions
Frequently asked
What is KubeEdge used for?
KubeEdge extends Kubernetes to edge nodes and, distinctively, models physical devices as Kubernetes custom resources. A DeviceModel describes what a class of machine can do — properties with types from INT, FLOAT, DOUBLE, STRING, BOOLEAN, BYTES and STREAM, an access mode of ReadWrite or ReadOnly, and a protocol name the API marks required. A Device describes one physical instance, scheduled to an edge node. A mapper, which is a driver you write, bridges the two to the wire protocol over the Device Management Interface. Use it when your hard problem is device-shaped — register addresses, setpoints, per-sensor state — rather than cluster-shaped, where a lighter distribution plus a GitOps agent is a better fit.
Where are KubeEdge device twins stored in v1beta1?
On a separate custom resource. In KubeEdge v1.23.1, status.twins[], status.state and status.lastOnlineTime live on devicestatuses.devices.kubeedge.io, kind DeviceStatus, whose object is created by the device controller with the same name and namespace as its Device and a controller OwnerReference back to it. The Device's own .status at v1beta1 is DeviceStatusOld, holding only reportCycle and reportToCloud, and the shipped CRD states it is kept temporarily to avoid breaking changes during the transition. So the command to run is kubectl get devicestatus <device-name> -n <namespace>. Tutorials showing Device.status.twins are describing v1alpha2, which is still a served version and does carry twins.
Does the KubeEdge mapper communicate over MQTT?
Not for device management. In v1.23 the mapper's link to EdgeCore is DMI, defined in gRPC proto and carried over Unix domain sockets: mapper-framework v1.23.0 dials EdgeCore at /etc/kubeedge/dmi.sock and calls MapperRegister on a DeviceManagerService client, while EdgeCore calls RegisterDevice and UpdateDevice on the mapper's own DeviceMapperService socket. KubeEdge's device-controller architecture page still says the mapper gets updates via the MQTT broker; that describes the pre-DMI design. MQTT has two real roles today — EdgeCore's own EventBus transport, whose mqttMode defaults to 2 meaning external broker only, and spec.properties[].pushMethod.mqtt as an optional data-plane destination.
How do I stop KubeEdge sending device telemetry to the cloud?
Set spec.properties[].reportToCloud to false on the properties that must stay on site, and give them a spec.properties[].pushMethod destination the mapper can reach locally — dbMethod for a database, mqtt or otel for a local consumer. mapper-framework's per-property twin loop returns before starting a ticker when reportToCloud is false, so nothing for that property enters the northbound path. Audit it with a jsonpath query over spec.properties[].reportToCloud across all devices. Two traps: TDEngine's dbMethod key is capitalised TDEngine while its siblings are lower case, so a lower-case key silently binds nothing; and the Go zero value is false but KubeEdge's own Device sample sets reportToCloud: true, so the realistic failure is copying the sample.
What are collectCycle and reportCycle measured in?
mapper-framework interprets collectCycle in milliseconds — it builds the ticker as time.Millisecond multiplied by the property's CollectCycle value, and falls back to a one-second default when the field is omitted. This matters because the Device sample in KubeEdge's own device-CRD concept documentation writes collectCycle: 10000000000 with the comment that it means once every 10 seconds, which is a nanosecond value carried over from the older API. Multiplied by a millisecond it is roughly 116 days. Write 10000 for ten seconds, and be suspicious of any manifest carrying ten-digit cycle values. reportCycle is likewise milliseconds, and it is the separate field that drives the data-plane push — every pushMethod and dbMethod handler builds its ticker from reportCycle and falls back to one second when it is omitted, so collectCycle has no effect on how often a property is written to an on-site database or broker.
Further reading
- K3s and Rancher Fleet: Kubernetes at the Disconnected Edge
- Talos Linux: The Security Case for an Immutable OS
- Self-Hosted Grafana vs Datadog: Kubernetes Observability
- The Cyber Resilience Act Reaches Your Build Pipeline
- Kubernetes Disaster Recovery: Velero, etcd, RPO/RTO
- Vendor Lock-In in the Cloud: Pricing Your Exit as a Number
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.