Zudoku
Features

Annotations

Annotations attach arbitrary key-value metadata to an instance. They use the same key syntax as Kubernetes annotations, so you can reuse your existing keys and tooling.

Unlike tags, which act as controller-level labels that only the platform sees, annotations also reach the guest. You can use them for three things:

  1. Custom metadata—the instance's startdata includes its annotations, so the guest can read them at runtime.
  2. Guest network configuration—the guest applies the CNI result in the unikraft.com/cni annotation to its network interfaces.
  3. Structured log output—you select which annotations the platform injects into the VM's console log output.

Dedicated unikraft CLI subcommands for annotations are coming soon. In the meantime, drive annotations through the API, which you can invoke with curl or the unikraft api command.

Annotation keys

An annotation is a set of key-value pairs. A value is any string without ASCII control characters (0x00–0x1f and 0x7f, which covers tab, newline and carriage return), and multi-byte UTF-8 goes through fine. The API rejects a value carrying an ASCII control character with 400 Bad Request. Keys follow the Kubernetes annotation key syntax:

  • A key can be a plain name (my-key) or carry an optional DNS prefix separated by a slash (example.com/annotation2).
  • Each key needs a name segment of at most 63 characters, containing alphanumerics, -, _, and ., and beginning and ending with an alphanumeric character.
  • The optional prefix is a DNS subdomain (a series of DNS labels joined by ., at most 253 characters) followed by a /. It can't be a wildcard domain.

An instance holds at most 256 annotations.

For example:

JSONCode
{ "test.unikraft.com/my-annotation": "value3", "my-key": "value1", "example.com/annotation2": "value2" }

Creating an instance with annotations

Add an annotations object to the instance body when you create it. Each entry is a key-value pair:

TerminalCode
unikraft api /v1/instances -X POST --metro fra \ '{ "name": "annotations-demo", "image": "<my-org>/annotations-demo:latest", "annotations": { "test.unikraft.com/my-annotation": "value3", "my-key": "value1", "example.com/annotation2": "value2" } }'

Annotation keys are Kubernetes-compatible, so they can be plain keys (my-key) or prefixed with a domain (example.com/annotation2).

Reading annotations from the guest

The platform includes the instance's annotations in its startdata, which the guest reads from /sys/class/uio/uio0/device/startdata:

TerminalCode
cat /sys/class/uio/uio0/device/startdata
JSONCode
{ "ip": "10.0.0.5/30", "uuid": "d0e22bb8-b4c1-44ea-bb58-197fedbeb2ab", "annotations": { "test.unikraft.com/my-annotation": "value3", "my-key": "value1", "example.com/annotation2": "value2" }, "mac": "12:b0:0a:00:00:05", "gw": "10.0.0.6", "hostname": "annotations-demo" }

Your app parses this JSON and reads the annotations object like any other startdata field.

Guest network configuration with CNI

Limited Access

CNI networking is only available in BYOC or on-prem Unikraft Cloud installations. It relies on TAP devices and networks that you set up on the host. To explore such a deal, reach out to the Unikraft Cloud Discord or send an email to support@unikraft.com.

unikraft.com/cni is a reserved annotation. Its value is a CNI Result document, and the guest applies the addresses and routes it holds to its own network interfaces.

This hands the addressing of an instance to an external CNI plugin or IPAM system. The plugin decides the addresses and routes, writes its result into the annotation, and the guest configures itself from it. Your image needs no agent for it and no boot script of its own.

The annotation pairs with an interface without an address, which reaches the guest as a bare link on a TAP device of your own. See Kubernetes for the CNI integration that Kraftlet drives on the cluster side.

The annotation value is the document as a JSON string, so the JSON inside it carries escapes:

TerminalCode
unikraft api /v1/instances -X POST --metro fra \ '{ "name": "cni-demo", "image": "<my-org>/cni-demo:latest", "network_interfaces": [ {}, { "name": "cni-demo-vpc", "tap_name": "vpc0", "mac": "0a:58:c0:a8:01:05", "autoconfig": false } ], "annotations": { "unikraft.com/cni": "{\"cniVersion\":\"1.0.0\",\"interfaces\":[{\"name\":\"eth0\",\"mac\":\"0a:58:c0:a8:01:05\"}],\"ips\":[{\"interface\":0,\"address\":\"192.168.1.5/24\"}]}" } }'

That instance keeps a pool-allocated primary interface and adds a second one on the vpc0 TAP device, with a MAC address you pick and no IP address. Unescaped, the annotation holds a standard CNI Result (types/100):

JSONCode
{ "cniVersion": "1.0.0", "interfaces": [ { "name": "eth0", "mac": "0a:58:c0:a8:01:05" } ], "ips": [ { "interface": 0, "address": "192.168.1.5/24" } ] }

The guest gives 192.168.1.5/24 to the link whose MAC address is 0a:58:c0:a8:01:05, and brings that link up.

Writing the document

Give every address a MAC address to land on:

  • Each ips[] entry points through its interface index at an entry of the document's own interfaces[] array, not at the instance's network_interfaces. An entry with no interface field uses index 0.
  • That interfaces[] entry supplies the MAC address of the interface to configure, so interfaces[].name can say anything. Without a MAC address to match, the index counts the instance's non-loopback interfaces from 0 instead. The entry carries no mac, the document holds no interfaces[] array, or the index sits past the end of that array.
  • Set the MAC address yourself when you create the instance, or read it from the instance status.
  • Addresses take CIDR notation, IPv4 or IPv6, and the prefix length is mandatory.
  • The document needs a cniVersion of 0.3.1, 0.4.0, 1.0.0, or 1.1.0, and has to be a result rather than a network configuration.
  • Keep the document on one line, because annotation values reject control characters.

Only interfaces[].mac, ips[].interface, ips[].address, ips[].gateway, and the routes array have any effect. dns, mtu, and sandbox do nothing, so you can pass a plugin's full result through untouched. Set the resolver with the instance's own nameserver field.

The guest doesn't use stateless address autoconfiguration (SLAAC), so it never takes IPv6 addresses or routes from router advertisements. The instance's own addresses and gateway field are IPv4 only, which leaves the document as the only source of IPv6 addresses. List an IPv6 default route in the document, or configure one inside the guest, if the workload needs it. The guest also skips duplicate address detection, so its IPv6 link-local addresses and the addresses from the document are usable directly after boot.

Routes

The guest installs each entry of the document's routes array in its own routing table:

JSONCode
{ "cniVersion": "1.0.0", "interfaces": [ { "name": "eth0", "mac": "0a:58:c0:a8:01:05" } ], "ips": [ { "interface": 0, "address": "192.168.1.5/24", "gateway": "192.168.1.1" } ], "routes": [ { "dst": "10.2.0.0/16", "gw": "192.168.1.254" }, { "dst": "10.3.0.0/16" } ] }

The guest sends traffic for 10.2.0.0/16 through 192.168.1.254, and traffic for 10.3.0.0/16 through 192.168.1.1, the gateway of the matching ips entry.

Each route accepts the following fields:

FieldRequiredDescription
dstYesThe destination in CIDR notation, IPv4 or IPv6, such as 0.0.0.0/0 for a default route.
gwNoThe next-hop address in the same IP family as dst, with the fallback described under picking the next hop.
priorityNoThe route metric, where a lower value wins.
tableNoThe numeric ID of the routing table to add the route to, which defaults to the main table.
scopeNoThe numeric route scope, such as 0 for global, 253 for link, and 254 for host, which defaults to 0 for a route with a next hop and 253 for a route without one.

The guest ignores mtu, advmss, and any other route field. A document holds at most 16 routes.

Picking the next hop

A route without a gw uses the gateway of the matching ips entry, which is the first entry with an address of the route's IP family and a gateway of its own. When no entry has a gateway, the route has no next hop, and the guest sends its traffic straight onto the link.

Routes carry no interface of their own, so the guest picks one:

  • The interface whose address prefix contains the route's next hop.
  • Otherwise, the interface of the first ips entry with an address of the route's IP family.

Every route needs at least one address of its own IP family in the document.

Default routes

A gateway in ips creates no route on its own. List the default route explicitly, as { "dst": "0.0.0.0/0" } for IPv4 or { "dst": "::/0" } for IPv6, and the guest uses that gateway as its next hop.

Route order

The guest adds routes after it configures the addresses, in the order of the routes array. List a route that makes a gateway reachable ahead of the routes that use that gateway.

Routing tables

A route with a table other than the main table only takes effect once a policy routing rule points traffic at that table. The guest adds the route but never adds such a rule, so the operator may need to add one inside the guest, for example with ip rule add from 192.168.1.5 lookup 100.

Changing the configuration

The guest applies the document at boot, and again whenever the instance resumes from a snapshot, for example after scale-to-zero. A patched annotation takes effect once the instance stops, so the guest reads the new document on its next boot or resume.

On a resume, the guest drops the addresses and routes it added that the document no longer lists, and adds the new ones, without a reboot. The guest never removes an address or a route it didn't add itself.

When the document fails

At boot, a document that fails to parse or apply stops the instance. That covers invalid JSON, a MAC address that matches no interface, a route with no address of its IP family, and a route that the guest kernel rejects. When the instance resumes from a snapshot, the same failure leaves the instance running and only logs it.

Check that each MAC address in the document matches one on the instance, and that each route has an address to use. Then read the instance logs, which name any entry that fails to parse or apply.

Patching annotations

Update the annotations on an existing instance with a PATCH /instances request. The body is an array of patch operations, each naming the target instance, the annotations property, and one of three operations: set, add, or del.

Annotation changes apply only while the instance is in the stopped state. If you patch a running instance, the platform accepts the request and queues the change, then applies it the next time the instance stops.

Replace all annotations (set)

set replaces all existing annotations with the object you provide:

TerminalCode
unikraft api /v1/instances -X PATCH --metro fra \ '[{ "name": "annotations-demo", "prop": "annotations", "op": "set", "value": { "env": "production", "team": "platform" } }]'

Add or update annotations (add)

add merges the given annotations into the existing set. It overwrites keys that already exist and leaves the other keys untouched:

TerminalCode
unikraft api /v1/instances -X PATCH --metro fra \ '[{ "name": "annotations-demo", "prop": "annotations", "op": "add", "value": { "added-key": "added-value" } }]'

Delete annotations (del)

del removes specific keys. The value is an array of key names (or a single key as a string):

TerminalCode
unikraft api /v1/instances -X PATCH --metro fra \ '[{ "name": "annotations-demo", "prop": "annotations", "op": "del", "value": ["my-key", "example.com/annotation2"] }]'

Annotations in log output

Beyond the guest, the platform can inject selected annotations into a VM's console log output. This helps with structured logging to an external collector.

This forwarding happens at the node level rather than through the instances API, so coordinate with Unikraft to enable it for your deployment. The platform exposes two options:

TerminalCode
--vmm-console-ports "app:socket/json:/tmp/vector.sock,app2:socket/json:/tmp/vector.sock" --vmm-console-annotations "app:my-key+test.unikraft.com/my-annotation,app2:*"
  • --vmm-console-ports defines named console ports as <name>:<type>[/<format>][:<path>]. Here app and app2 are socket ports using the json format, each writing to a Vector socket.
  • --vmm-console-annotations selects which annotations appear in each port's output, as <port>:<key1>+<key2>,... or <port>:*:
    • app:my-key+test.unikraft.com/my-annotation—forward only the listed annotation keys (joined with +) to port app.
    • app2:*—the * wildcard forwards all annotations to port app2.

When the guest writes to the app console (for example, echo "blub" > /dev/vport2p1), the collector receives the message together with the selected annotations:

JSONCode
{ "annotations": { "my-key": "value1", "test.unikraft.com/my-annotation": "value3" }, "host": "(unnamed)", "message": "blub", "name": "app", "source_type": "socket", "timestamp": "2026-05-08T08:35:26.489812959Z", "uuid": "9d262ffd-878f-47f3-98c3-bb017c11d690" }

Annotation events

The platform's event log carries a vm.annotate event, which it emits whenever an instance's annotations change:

JSONCode
{ "type": "vm.annotate", "timestamp": "2026-08-19T10:04:11Z", "data": { "vm": "b495f451-0370-4d8e-8b94-a5b22d2212f9", "annotations": { "env": "prod", "team": "search" } } }

An external system consumes the event to keep track of the instance's annotation values, without polling the instance API. The event also gives log processing a way to map an instance UUID back to its annotations. The audit event stream doesn't carry vm.annotate, so read it from the node event log.

Limitations

  • An instance holds at most 256 annotations.
  • Annotation keys must follow the Kubernetes key syntax, and values are strings with no ASCII control characters in them.
  • A patched annotation change applies once the instance reaches the stopped state.
  • A unikraft.com/cni document holds at most eight interfaces, 16 addresses, and 16 routes, and an ips[].interface index stays below eight.
  • The guest applies only interfaces[].mac, ips[].interface, ips[].address, ips[].gateway, and routes from that document.
  • Applying routes requires the latest base-compat guest kernel. An instance that resumes from a snapshot keeps the kernel that the snapshot captured, and applies routes only after it cold-boots on the latest kernel.
  • Dedicated unikraft CLI subcommands aren't available yet, so drive annotations through the API.
  • The platform sets up log-output forwarding at the node level, not through the instances API.
  • The vm.annotate event needs switching on in the node's event configuration.

Learn more

Last modified on