Skip to content
Stribog

Edge

All writing

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.

Stribog13 min read

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 Device objects, creates the matching DeviceStatus, 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 documentation — Device Management Interface (DMI)

KubeEdge presents that as a congestion optimisation. Read it as a residency control: the management plane crosses the WAN, the data plane need not.

The management plane crosses the WAN; the data plane does not have to. The EdgeCore-to-mapper link is DMI gRPC over Unix sockets — not MQTT, whatever the architecture page still says.

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.

yaml
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"
DeviceModel — the template. spec.protocol is unenforced by the CRD schema and load-bearing anyway.

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.

yaml
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.1
Device — one physical vessel. Cycle values are milliseconds.

Now 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.

bash
# 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}'
Inspect the right object. Device.status has no twins at v1beta1.

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.

yaml
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.1
Residency as configuration: the vibration stream never leaves the plant; the setpoint does. Modbus TCP, not vessel-07's shared serial bus — one tick is one device read, and a 10 ms cadence will not fit on 9600 baud.

Two 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.

  1. No actuation confirmation. observedDesired proves 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 whose reported value you compare.
  2. Desired resolution is last-write-wins. desired holds 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.
  3. Twin freshness is per property. spec.properties[].collectCycle sets it, gated entirely by reportToCloud — 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.

What survives a WAN outage, and the three guarantees that do not. observedDesired is evidence of mapper receipt — never of a valve moving.

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.

yaml
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.1
The hold half — an annotation on the pod template, where a rollout will hit it.

The 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.

bash
# 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-upgrade
The release half — run on the edge node, not from your laptop.

Security, 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.

yaml
# 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.
  # ...
edgecore.yaml — excerpt. The surrounding modules are elided; do not paste this as a whole file.

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 configData blocks 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, pushMethod have no equivalent elsewhere. The DMI service implementation gets deleted outright, and twin semantics leak into anything reading observedDesired.

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.

kubeedgeKubeEdge device twin desired reportedKubeEdge mapper DMI gRPCKubeEdge DeviceModel DeviceInstance CRDindustrial IoT Kubernetes on-premises telemetryedge device offline command queue Kubernetes

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.