Multus and the NetworkAttachmentDefinition
S1·E4The perfect manifest · The customer's staging cluster, day sixteen, screen-shared late in the afternoon
Builds on: The Kubernetes network model and where a second CNI fits
Before you read: what do you already know?
3 quick questions. Wrong answers are fine and expected; trying first makes the lesson stick.
After this lesson you can
- Install Multus in thick or thin form and justify the choice from the documented trade-off.
- Write every documented form of the k8s.v1.cni.cncf.io/networks annotation, including interface pinning and namespace qualification.
- Construct a NetworkAttachmentDefinition with an inline spec.config and explain the file-backed alternative.
- Diagnose a pod with no second interface against the four-item failure checklist and name the artifact that settles each case.
Episode 4 — The perfect manifest
The platform engineer has the manifest open and it is correct. The NetworkAttachmentDefinition exists, kubectl get network-attachment-definitions lists it, the delegate config is the macvlan block from NVIDIA’s guide. The pod starts, runs, serves traffic - and ip -br addr inside it shows lo and eth0 and nothing else. No error. Everyone on the call is now looking at the NIC, procurement is asking twice a day about lead times for cards nobody has ordered, and the SE has given up pretending the coffee is warm.
Nothing is wrong with the NIC, because Multus never builds an interface itself. “Multus CNI enables attaching multiple network interfaces to pods in Kubernetes” - a meta-plugin that delegates to other CNI plugins for every attachment beyond the cluster network, following the Kubernetes Network Custom Resource Definition De-facto Standard rather than its own object model.[1] That indirection is why it exists: the CNI API gives a pod one network, and rather than fork the API, the Network Plumbing Working Group added a name - an annotation on the pod, a CRD holding the delegate config, and a shim that reads one and calls the other.
Which means almost every failure here is a lookup failure rather than a networking failure: a name that does not resolve, a namespace that does not match, JSON that does not parse.[2] The network lead wants a counter; what settles this one is a string.
Ask for kubectl describe pod verbatim before anybody touches hardware.[10]
Four checks, two minutes. Start with how the annotation is actually written.
1Two ways to install it, and why the heavier one is recommended
“Multus CNI enables attaching multiple network interfaces to pods in Kubernetes.”[1] It is a meta-plugin: it delegates to other CNI plugins for every attachment beyond the cluster network, and it follows the Kubernetes Network Custom Resource Definition De-facto Standard rather than inventing its own object model.[1]
There are two deployment shapes. The thick plugin arrived in version 4.0 and consists of two binaries - multus-daemon, a per-node agent, plus the multus-shim CNI plugin. It supports capabilities the thin model did not have, such as metrics, and the project is explicit about the trade-off: the thick plugin “comes with the trade-off of consuming more resources than the ‘thin plugin’”, and is nonetheless recommended for most deployments.[1] The thin plugin is the older single-binary model, kept for resource-constrained environments.[1]
# thick (recommended)
kubectl apply -f https://raw.githubusercontent.com/k8snetworkplumbingwg/multus-cni/master/deployments/multus-daemonset-thick.yml
# thin
kubectl apply -f https://raw.githubusercontent.com/k8snetworkplumbingwg/multus-cni/master/deployments/multus-daemonset.yml[1] In a Network Operator deployment you usually install neither by hand: NicClusterPolicy.spec.secondaryNetwork.multus deploys the multus-cni image from nvcr.io/nvidia/mellanox at the operator’s own version tag.[3] Knowing the upstream manifests still matters, because that is what a customer’s platform team probably applied before you arrived, and the two paths produce different owners for the same DaemonSet.
2Every form of the annotation, and what each one is for
One annotation drives all of this: k8s.v1.cni.cncf.io/networks. It has four documented forms and each exists for a different reason.
Comma-separated list - the common case, attaching two networks in order:
k8s.v1.cni.cncf.io/networks: macvlan-conf-1, macvlan-conf-2[2]Interface pinning with an @ suffix, when the workload needs a predictable device name rather than whatever net1, net2 ordering it happens to get:
k8s.v1.cni.cncf.io/networks: macvlan-conf-1@macvlan1[2]Namespace qualification with a slash, when the NAD lives in a shared networking namespace rather than the workload’s own:
k8s.v1.cni.cncf.io/networks: testns1/macvlan-conf-3[2]JSON list form, which is the only one that can carry per-attachment options. It supports name, namespace, interface and default-route:
k8s.v1.cni.cncf.io/networks: '[{"name":"macvlan-conf","default-route":["192.168.2.1"]}]'[2] That last example is worth pausing on: it moves the pod’s default route onto the secondary interface. That is occasionally what an appliance-style workload wants and almost never what a training job wants, because it takes the pod’s egress off the primary CNI and with it anything that depended on cluster-managed routing.
Checks
no forbidden combination- warnThe NAD is in net-attach and the pod in default: the annotation must be namespace-qualified (net-attach/macvlan-net, or the JSON form with "namespace"). It works upstream; it will not work unchanged on OpenShift, where namespace isolation is on by default. [NAD namespace + OpenShift Multus namespace isolation]
- infomacvlan mtu has range 0 to the master’s MTU — raise the PF first, then the pod. One master cannot be enslaved by macvlan and ipvlan at once. [macvlan mtu is capped at the master]
- infoCalico owns eth0 only. Confirm it has not autodetected ens1f0 as its uplink: sharing one interface with the fast path works until Felix reconfigures it, and then RDMA disappears. [primary CNI must not claim the fast-path interface]
Delegate — macvlan
Functions like a switch already connected to the host interface; each virtual interface gets a distinct MAC, so existing DHCP servers work.
- Required: name, type, ipam (may be empty for an address-less interface). Optional: master (defaults to the default-route interface), mode, mtu, linkInContainer.
- Modes: bridge (default), private, vepa, passthru. mtu range is 0 to the master’s MTU — you cannot raise a pod above the host interface.
- One master cannot be enslaved by macvlan and ipvlan at once; most wireless cards cannot be enslaved at all.
- Operator route: MacvlanNetwork (mellanox.com/v1alpha1: networkNamespace, master, mode, mtu, ipam) generates the NAD for you.
FAE angle: macvlan gives an IP-level second interface only. RDMA over it exists solely through the shared RDMA device plugin (rdma/rdma_shared_device_a) — a scheduling counter, not isolation.
sourceapiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: macvlan-net
namespace: net-attach
spec:
config: |
{
"cniVersion": "0.3.1",
"name": "macvlan-net",
"type": "macvlan",
"master": "ens1f0",
"mode": "bridge",
"mtu": 1500,
"ipam": {
"type": "whereabouts",
"range": "192.168.2.225/28",
"exclude": [
"192.168.2.229/30",
"192.168.2.236/32"
]
}
}3Two places a NAD's configuration can live
A NetworkAttachmentDefinition normally carries its CNI JSON inline in spec.config:
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: macvlan-conf
spec:
config: '{"cniVersion":"0.3.1","type":"macvlan","master":"eth1","mode":"bridge","ipam":{"type":"whereabouts","range":"192.168.2.225/28"}}'[2][6][8]There is a second, less-used shape. If a NAD has no spec, Multus looks for a file in the default configuration directory - /etc/cni/multus/net.d/ - whose CNI config name field matches the resource.[2] That exists for cases where the configuration is managed on the node rather than in the API, and it is perfectly legitimate. It is also how you end up with a cluster where some attachments are described in Kubernetes and others only on disk, and where the answer to “what is this network?” depends on which node you SSH into.
In the Network Operator flow you often write neither by hand. The operator’s own CRs - MacvlanNetwork, HostDeviceNetwork, IPoIBNetwork - generate the NAD for you from fields like networkNamespace, master, mode, mtu and an inline ipam string.[3] The exception is the InfiniBand SR-IOV case, where you still hand-write the chained plugin list.[3] Knowing both surfaces matters when a customer says “we did not create any NADs” and kubectl get net-attach-def -A disagrees with them - the operator did.[9]
4The four-item failure checklist
Nearly every “the pod has no second interface” ticket is one of four things, and all four are checkable in about two minutes.
- Wrong namespace. The NAD is not in the pod’s namespace and the annotation did not qualify it. On OpenShift this is even more common, because Multus there runs with
namespaceIsolationenabled by default: the pod must live in the NAD’s namespace unless the NAD sits indefault,openshift-multus,openshift-sriov-network-operatororopenshift-cnv. Under Network Operator the lever is thenetworkNamespacefield onMacvlanNetwork,HostDeviceNetwork,IPoIBNetwork,OVSNetworkandSriovNetwork, which places the generated NAD where the pods actually run.[4] - Misspelled annotation. The key is
k8s.v1.cni.cncf.io/networks. Anything else is silently ignored - Kubernetes stores unknown annotations happily and nothing complains.[2] - Invalid
spec.configJSON. The NAD exists and looks fine inkubectl get, but the delegate configuration inside it never parsed.[2] - A
resourceNamematching no advertised device-plugin resource. This one is different in kind: the pod staysPendingat scheduling and never reaches CNI ADD at all, because extended resources are scheduling input and are integer-only and non-shareable.[7] The defaultresourcePrefixfor the SR-IOV network device plugin isintel.com, which is why NVIDIA’s own configurations always set"resourcePrefix": "nvidia.com"explicitly - and why a pod copied from one cluster to another can ask for a resource that exists everywhere except by that name.[5][3]
The artifact that separates them is always the same: kubectl describe pod, read verbatim - the command Multus’s own quickstart uses to confirm an attachment.[10] Cases 1 to 3 surface a delegate error in the pod’s events; case 4 produces a scheduling message and nothing from Multus at all, and that absence is the diagnosis.[7]
Ask: a training pod in namespace ml-jobs needs a second interface on the host’s ens1f0, named net1, addressed from a cluster-wide pool, with the pod’s default route left on the primary CNI.
- Choose the delegate. No hardware isolation is required and no RDMA yet, so
macvlanin the defaultbridgemode.[6] - Choose the IPAM. Cluster-wide, so
whereaboutswith arange-host-localwould hand duplicate addresses to pods on different nodes.[8] - Write the NAD, in the pod’s own namespace so no qualification is needed:
[2] [6] [8]apiVersion: k8s.cni.cncf.io/v1 kind: NetworkAttachmentDefinition metadata: name: fast-net namespace: ml-jobs spec: config: '{"cniVersion":"0.3.1","type":"macvlan","master":"ens1f0","mode":"bridge","ipam":{"type":"whereabouts","range":"192.168.2.225/28"}}' - Write the annotation. Default interface naming is acceptable here, so the plain form is enough:
k8s.v1.cni.cncf.io/networks: fast-net.[2] - Decide the default route. Leave it alone - do not add a
default-routekey, because that would move pod egress off the primary CNI.[2] - Resources block: none. macvlan does not consume a device-plugin resource, which is exactly why this configuration schedules anywhere and gives no hardware isolation.[7]
- Verify:
kubectl exec -- ip -d linkshould shownet1of typemacvlanwithens1f0as its master, plus the unchangedeth0.
Ask: the same pod, but the NAD must now live in a shared net-infra namespace, the interface must be named train0, and the workload requests one nvidia.com/hostdev device.
- Delegate: ____ , because the ask now includes a whole device rather than an IP-level attachment.
- NAD namespace: ____ . Pod namespace: ____ . Therefore the annotation must ____ .
- Annotation, combining qualification and interface pinning:
k8s.v1.cni.cncf.io/networks: ____.[2] - Resources block, in both requests and limits:
____: 1.[3] - Which of the four failure modes does this configuration newly expose that the worked example did not? ____ , and the symptom is ____ rather than a CNI error.[7]
- If this cluster were OpenShift, what extra thing must be true for step 3 to work at all? ____ .[4]
- Which command tells you whether the resource in step 4 is advertised? ____ .[7]
A customer’s platform team reports: “we applied the same manifests as our lab cluster, and on the production OpenShift cluster half the pods come up with one interface and the other half stay Pending forever. Nothing is in the Multus logs.”
Produce: (a) a partition of the symptom into the two distinct failures hiding behind it, with the evidence that separates them; (b) for the Pending group, the exact commands you run and in what order; (c) for the one-interface group, the OpenShift-specific default you check first and how you would phrase it to a customer who believes it is a product bug; (d) a one-paragraph note back to the platform team explaining why “the same manifests” is not the same configuration on the two clusters.
Acceptance criteria: your Pending path never touches Multus, your one-interface path names namespace isolation with its source, and your note distinguishes the intel.com, nvidia.com and openshift.io resource prefixes as three different names for what the customer thinks is one thing.[4][5][7]
Two minutes, four checks
The events name the delegate that failed.[10] The NAD lives in a shared net-infra namespace, the pods live in the training namespace, and the annotation names the NAD without qualifying it - a lookup for something that does not exist there.[2] Write it net-infra/rail-a, or move the NAD next to the pods, and net1 appears on all eight nodes at once. Nobody touched the NIC, the driver or the switch. The operator relabels the ports and goes home; at 02:10 he calls anyway. Two pods, two different nodes, one address on the rail subnet - and the network team has already opened a ticket against the leaves.
Lab
Goal: the same attachment against a real ConnectX PF on the Dell BF-3 host, and confirmation that the interface is genuinely a child of that PF. Read-only apart from creating and deleting NADs and pods - no firmware, no mode change, no host interface reconfiguration.
- Pre-flight inventory:
Expected: the PF is present andip -br link ethtool -i <pf> | head -3 ibstat | head -20 kubectl get net-attach-def -A kubectl -n kube-system get ds | grep -i multusmlx5_core,ibstatsees the HCA, and you have a written record of any NADs that already existed - so you can prove at the end that you left none behind. - Confirm which interface the primary CNI is using, exactly as in lesson 1, and confirm it is not the PF you are about to use as a macvlan master. If it is, stop and choose a different PF; enslaving the CNI’s own uplink is not a read-only act in effect even though every command here is.
- Create a macvlan NAD whose
masteris the real PF:
[2][6][8] Rollback:kubectl apply -f - <<'EOF' apiVersion: k8s.cni.cncf.io/v1 kind: NetworkAttachmentDefinition metadata: { name: cx-macvlan } spec: config: '{"cniVersion":"0.3.1","type":"macvlan","master":"ens1f0","mode":"bridge","ipam":{"type":"whereabouts","range":"192.168.2.225/28"}}' EOFkubectl delete net-attach-def cx-macvlan. - Launch a pod against it with interface pinning so you can see the
@form work on real hardware:
[2] Expected: an interface literally namedkubectl run cx-probe --image=nicolaka/netshoot --restart=Never \ --annotations="k8s.v1.cni.cncf.io/networks=cx-macvlan@fast0" -- sleep 3600 kubectl exec cx-probe -- ip -d linkfast0, of typemacvlan, whose master is the ConnectX PF, alongside the untouchedeth0. Rollback:kubectl delete pod cx-probe. - Confirm what this attachment did not give you. Inside the pod,
rdma linkandibv_devinfo- expected: nothing useful. A macvlan child link is an IP-level attachment; RDMA over it needs the shared device plugin, which is module 2’s subject.[9] - Confirm the host is unchanged: re-run every command from step 1 and diff against your inventory. The PF’s MTU, addresses and HCA state must be identical, and
kubectl get net-attach-def -Amust return to exactly the set you recorded. - Deliverable: the
ip -d linkoutput from step 4 pasted beside the equivalent output from the kind lab. The point to note in writing is how little of it differs - the whole difference between a laptop demo and a GPU node is themasterfield.
Rollback for the whole lab: delete the pod, delete the NAD, re-run step 1 and confirm it matches the starting inventory.
Goal: install thick Multus on kind, attach a second interface, then reproduce all four failure modes on purpose and collect the verbatim error text for each. Read-only: nothing outside the cluster changes, and every object you create is deleted at the end.
- Create a kind cluster with a primary CNI already working, then install Multus:
[1] Expected: the DaemonSet reaches Ready andkubectl apply -f https://raw.githubusercontent.com/k8snetworkplumbingwg/multus-cni/master/deployments/multus-daemonset-thick.yml kubectl -n kube-system rollout status ds/kube-multus-ds --timeout=180s/etc/cni/net.d/on the node now contains a Multus conflist ahead of the primary CNI’s. If not:kubectl -n kube-system logs ds/kube-multus-dsand read the first error. - Install Whereabouts so the NADs have cluster-wide IPAM to call, then create three macvlan NADs in namespace
defaultwith namesnad-a,nad-bandnad-c, each using a differentrange.[8][6] - Happy path first, so you know what success looks like:
Expected:kubectl run good --image=nicolaka/netshoot --restart=Never \ --annotations="k8s.v1.cni.cncf.io/networks=nad-a" -- sleep 3600 kubectl exec good -- ip -d linketh0plusnet1, andip -d linkshowsnet1is a macvlan device. Record the output; it is your reference. - Failure 1 - wrong namespace. Create namespace
other, putnad-dthere, and run a pod indefaultreferencingnad-dwithout qualification. Capturekubectl describe podverbatim. Then fix it with theother/nad-dform and confirm it works.[2] - Failure 2 - misspelled annotation. Run a pod with the key
k8s.v1.cni.cncf.io/network(singular). Expected: the pod starts normally with one interface and no error anywhere. Record that absence - it is the finding.[2] - Failure 3 - invalid JSON. Create a NAD whose
spec.configis missing a closing brace, and run a pod against it. Capture the verbatim event text.[2] - Failure 4 - unmatched resourceName. Add
resources.limits.nvidia.com/hostdev: 1to a pod on this cluster, where no device plugin runs. Expected:Pending, a scheduling message, and nothing from Multus.[7] Then runkubectl describe node | grep -A20 Allocatableand confirm the resource is not there. - Deliverable: a four-row symptom-to-cause deck. Column one, the verbatim first line of the event or the note “no error”. Column two, which of the four causes it was. Column three, the single command that confirmed it. This deck is the thing you carry into an escalation.
Rollback: delete the pods, the NADs, the other namespace, and the kind cluster.
Retrieval check
10 questions from memory. Answer before looking anything up; misses become flashcards.
Explain it to a Dell SE
Explain to a Dell SE, in five sentences, what Multus actually does when a pod starts, and why 'the pod has no second interface' is usually a two-minute fix.
Sources
Facts in this lesson were checked against Multus CNI master README and docs/how-to-use.md re-fetched 2026-09-07; NVIDIA Network Operator v26.7.0 deployment guide and OpenShift deployment guide; sriov-network-device-plugin README. Dates are when each page was fetched.
- GitHub - k8snetworkplumbingwg/multus-cni · fetched 2026-09-07
- Multus CNI - How to use · fetched 2026-09-07
- NVIDIA Network Operator v26.7.0 - Deployment Guide with Kubernetes · fetched 2026-09-07
- NVIDIA Network Operator v26.7.0 - Deployment Guide with OpenShift · fetched 2026-09-07
- GitHub - k8snetworkplumbingwg/sriov-network-device-plugin · fetched 2026-09-07
- CNI plugins - macvlan · fetched 2026-09-07
- Kubernetes - Device Plugins · fetched 2026-09-07
- GitHub - k8snetworkplumbingwg/whereabouts · fetched 2026-09-07
- NVIDIA Network Operator v26.7.0 - Quick Start Guide for Kubernetes · fetched 2026-09-07
- Multus CNI - Quickstart Guide · fetched 2026-09-09
The same idea elsewhere
Other lessons that cover this ground, sometimes from another course's angle.
- Network Operator and the NicClusterPolicyElsewhere in this course · Same ground: Multus, install and triage
- The triage ladder: sos-report, error strings and the checklistsElsewhere in this course · Same ground: checklist, nad and triage
- RDMA in pods: shared plugin vs exclusive netnsElsewhere in this course · Same ground: nad, device-plugin and failure