feat(vmm): run virtio-net on vhost-net with configurable queue pairs - #1145
feat(vmm): run virtio-net on vhost-net with configurable queue pairs#1145kvinwang wants to merge 2 commits into
Conversation
a350589 to
0d3ad45
Compare
|
Reviewed against 1. 2. Queue-echo mismatch leaks the prepared interface; upgrade order is undocumented. 3. Existing nodes change their QEMU command line on upgrade with no config change. 4. Context that may be useful in the docs:
Separately, |
|
Reference notes on how clouds pick a queue count — background for later tuning. dstack operators set 16 is a tuning convention, not a spec limit
RHEL's "up to 16" is host-thread budget (one How clouds encode it on the instance typeThey publish default per NIC, max per NIC, and often an instance-wide quota. The SKU fills the hypervisor knob. AWS ENA — per SKU three columns. General-purpose default plateaus at 8; network-optimized default goes to 16/32. Formula:
GCP — formula from machine type + NIC driver, override at create.
Aliyun ECS — closest to a SKU column. Bind-to-type applies the default; API exposes
OpenStack Nova —
Azure — SKU capability is Accelerated Networking (SR-IOV), not virtio queue count. Different dataplane. Practices that show up across those sourcesLined up with the numbers already in this PR (short connections 22k→6k conn/s from 1q to 8q; 64B UDP ~600k→3.0 Mpps). TDX makes the left-hand side worse than plain KVM: a cross-vCPU wakeup is an IPI plus a VM exit.
|
36bcb8d to
57f5328
Compare
4756d45 to
a9e20a2
Compare
|
Thanks for the review — every point landed. Status, in your order: 1. 2. Prepared-interface leak on the queue-echo mismatch — fixed; both post-prepare exits now route through the same rollback as a failed prepare. 3. Upgrade path — resolved more conservatively than an upgrade note: the default is now 4. Runtime fallback footnote — verified on hardware (QEMU 8.2.2, the version dstack resolves on the test node, plus 10.2): an unopenable swiotlb footnote — tested adversarially (dstack-0.6.0 guest, 16 vCPU × 16 queue pairs, offloads off, ~177 Gbit/s plus concurrent direct block I/O): not a risk at any realistic shape. The guest kernel clamps the pool to a 64 MB floor regardless of RAM — the small-RAM tail where the concern would live doesn't exist — and demand is ring-bounded at ~2 MB per queue pair; a deliberately undersized 32 MB pool produced zero On your provider survey: with vhost now opt-in, no VM acquires the |
a9e20a2 to
1aa1d35
Compare
Problem
A CVM's virtio-net NIC cannot exceed one core's worth of packet processing, no
matter how many vCPUs it has. The VMM builds
-netdev bridge,...with novhost=, and a device line hardcoded tovirtio-net-pci,netdev=net0,mac=...with no
mq=on. Every packet is drained by QEMU's single main-loop thread:Sampled on a production CVM (
phala-tdx-prod7, pid 3543413, 16 vCPU / 32 GiB,-netdev bridge,id=net0,br=dstack-br0):That matches the reported symptom exactly — clean at 31k pps, 36% loss at 40k.
The three busy TAPs on that host had lifetime drop rates of 26.7% / 19.3% /
17.8%, hundreds of millions of packets each. The drops are at the TAP, so
guest-side
rx_droppedandsoftnet_statstay at zero and the cliff lookslike a network fault rather than a host CPU limit. Outbound guest traffic uses
the same thread, so a chatty guest pays it twice.
docs/bridge-networking.mdclaimed this was unavoidable: "vhost-net ... isnot enabled for bridge mode. TDX encrypts guest memory, which prevents the
host kernel from performing DMA-based packet offload." That is wrong. A TDX
guest's virtio rings and buffers live in shared, unencrypted memory precisely
so a host-side backend can reach them — which is why
vhost-vsock-pci, whichdstack has always used, works.
Fix
Two settings on
[cvm.networking], each overridable per VM through thedeployment RPC. vhost is off by default: an upgraded node keeps building
byte-for-byte the device its VMs always had (userspace virtio, one queue pair)
until the operator opts in:
Queue pairs are not a node setting: with vhost on they default to the VM's vCPU
count, capped at
max_net_queues, because the useful number follows the VMrather than the host; without vhost the default stays one queue pair. A
deployment overrides that per VM.
vectorsis derived, never configured:2N + 2. A single queue pair emits nomq=onorvectors=at all, so a 1-vCPU VM and any VM deployed with--net-queues 1keep the historical command line byte for byte.Each backend needed different plumbing:
userbridgetap,br=,helper=,vhost=onbridgenetdev accepts neithervhost=norqueues=; the same setuidqemu-bridge-helperworks behind atapnetdev, so the VMM still needs noCAP_NET_ADMINbridge, >1 queuetap,ifname=,queues=Nmulti_queueTAP — now also for unfiltered nodesbridge+ libvirt filtertap,ifname=,vhost=onvhost=offis now the configured valuemacvtaptap,fds=a:b,vhost=on/dev/tapNonce per queue; netd creates the link with matchingnumtxqueues/numrxqueuescustomqueuesstill drives the device line, which is what makes a hand-written netdev usable with multiqueueBecause macvtap now consumes several descriptors per NIC, the fd layout moved
into one
macvtap_fd_layout()that both the launcher's open list and the-netdevarguments derive from, replacing the3 + indexconvention the twosites previously duplicated.
QEMU aborts when
vhost=oncannot open/dev/vhost-net, and it does so frominside the launcher where the reason is easy to miss — verified on the QEMU
dstack resolves (8.2.2): there is no userspace fallback at netdev init, and
8.2 dies on qemu#1486's
assert(nc)rather than a clean error. The VMM warns when the device node ismissing and when its own open of the device is denied, but never refuses the
launch over it: QEMU need not share the VMM's credentials, so the VMM's own
access does not decide what the host can run.
Neither field is part of the measured VM configuration — that covers the OS
image, the vCPU and memory counts, the QEMU layout flags, the number of
NICs, and
mr_config_id(compose hash and instance info) — so retuning a NICdoes not change app identity.
Verification
All five configurations deployed as real TDX CVMs on
phala-tdx-lab(kernel6.8, QEMU 8.2.2+tdx1.1, guest image
dstack-0.6.0), against an isolated bridgeand a dedicated VMM instance.
Generated command lines, read back from
/proc/<qemu>/cmdline:Host and guest state:
ethtool -l eth0combinedThe worker count is
net queue pairs + 1for vhost-vsock in every case, whichis what confirms the guest actually negotiated that many queue pairs.
netdcreated what was asked for, and cleaned up on VM removal:After removing all five VMs: no
dt*interfaces and no nwfilter bindings left.Filtering still works with vhost on.
docs/libvirt-network-filter.mdsaidflipping this bit needed "equivalent filter integration tests", so from inside
VM E, with
clean-trafficbound to the TAP andvhost=on:The nwfilter binding lives on the host TAP interface, so packets traverse it
whether QEMU or a vhost worker wrote them.
Node policy, over the RPC:
Throughput, 64-byte UDP host→guest via kernel pktgen, same 8 vCPU / 8 GiB CVM
shape on the same host (the lab host was running other tenants' CVMs, so treat
the absolute rates as noisy and the CPU columns as the signal):
The main thread goes from saturated to idle — that is the wall coming down, and
a whole core returned to the tenant. Note that vhost alone relocates the
ceiling rather than removing it: with one queue the guest's single receive queue
becomes the limit and drops reappear at a higher rate (the guest was at 99% CPU
in the 500k row). Multiqueue is what removes them. Hence the two travel
together: enabling vhost also enables the vCPU-scaled queue default.
A/B on the customer's own host (
phala-tdx-prod7, production short-connectionworkload, 4 alternating rounds to suppress noise) measured earlier in this
investigation: 10,922 → 13,933 conn/s (+27.6%), with the main thread going
94% → 0%.
Review follow-ups
A self-review pass found seven issues, all fixed in this branch:
filteroptional so netd can build unfilteredmultiqueue TAPs meant
mode = "libvirt"with an explicitfilter = ""nolonger failed — netd skipped
nwfilter-binding-createand every VM bootedonto an unbound TAP, where it previously refused to start. Config load now
rejects an empty filter in libvirt mode.
one_shot.rsstill gated its "cannot manage TAP lifecycle"error on
network_filter.mode == Libvirt, soqueues > 1on a bridge emitted-netdev tap,ifname=dt…,queues=4for a TAP nobody created. It now uses thesame
needs_netd_interfacepredicate as the server path.--net-queues 4alone was rejected despite theflag's "default: use global config" help, and on a node whose default backend
is not in
allowed_network_modes, restating the mode tripped node policy — soper-VM tuning was impossible there at all. A tuning-only request now keeps the
node's backend; policy still governs backends a caller chooses.
bridge_helper()hard-failed thelaunch when none of three hardcoded paths existed on the VMM's filesystem.
With vhost defaulting on, a working bridge node whose helper lives elsewhere
would have lost every VM on upgrade — the same mistake the
/dev/vhost-netprobe was already corrected for. It now falls back to the non-vhost
bridgenetdev with a warning.
multiqueue" warning tested the resolved value, and the shipped
vmm.tomlsets
mode = "user"withvhost = true, so it fired on every launch of astock node. Inheritance being ignored there is the documented design, not a
silent skip; the warning is gone.
resolve_requested_networkspersisted the merged values, so setting
vhost = falsenode-wide to roll backreached VMs deployed with no override but not those deployed with
--net bridge. Only what a deployment explicitly asks for is recorded now;identity-bearing fields are still pinned as before.
max_net_queueswas unvalidated.0rejected evenqueues = 1, and128let a request past the node cap only to be rejected against thedifferent
MAX_NET_QUEUES = 64bound.Re-verified on
phala-tdx-labafter the fixes:One unrelated observation from that run: these lab VMs exit after a stop/start
cycle, with
dstack-prepare.servicefailing on the second boot. A control VM onplain
usernetworking — whose netdev string this PR leaves byte-for-byteunchanged — reproduces it identically, so it is a property of the test app
(
key_provider: none), not of this change.Second review pass
Four more, all fixed:
remove_interfacedecided whether to delete an nwfilter binding by checking that
/usr/bin/virshexists. On a
mode = "none"node that has virsh installed but no reachablelibvirtd,delete_bindingfails on anything but the literal "binding notfound", and
prepare_bridgestarts by callingremove_interface— so everymultiqueue bridge VM would fail to start and leak its TAP.
Remove/Checknowcarry whether the interface was created with a binding, defaulting to true on
the wire so an older VMM's removals still clean up.
Checkreported healthy unfiltered TAPs as broken, since it rannwfilter-binding-dumpxmlfor every non-macvtap interface. Same flag.service, and
queuesis#[serde(default)], so an older netd silently built asingle-queue TAP while the VMM emitted
queues=N— the exactIFF_MULTI_QUEUEmismatch the adjacent comment warns about, surfacing only as a QEMU failure
inside the launcher. netd now echoes the queue count it created and the VMM
refuses to launch on a mismatch.
first-pass fix let
{"queues": 2}inherit the node's backend and skipallowed_network_modes, butresolve_requested_networksthen pinned thatmode, parent, and bridge into the manifest — so tuning was strictly more
powerful than naming the backend, and a later node change no longer reached
the VM. The rule is now uniform: pin what the caller named, inherit the rest.
Re-verified on
phala-tdx-labwithvirshinstalled andlibvirt_uripointed ata dead socket, which is the configuration that used to fail:
And the filtered path is unchanged, against a real libvirt:
Final regression on
phala-tdx-lab47 assertions against the final binary on a real TDX host (kernel 6.8, QEMU
8.2.2+tdx1.1, guest
dstack-0.6.0), on a dedicated bridge and VMM instance —config validation, RPC policy, generated command lines, host interface state,
guest-visible state, node rollback, and teardown. All pass.
Throughput on the final binary, 64-byte UDP host→guest via kernel pktgen, same
CVM shape on the same host:
The main thread going 88–94% → 0% is the wall coming down. The c1 column is the
nuance stated above: with one queue vhost relocates the ceiling into the guest's
receive queue rather than removing it (guest CPU 121% vs 99% in the 500k row),
which is what the multiqueue column fixes.
One observation worth recording: that multiqueue VM had 2 vCPUs, and although
QEMU was given
queues=8,vectors=18the guest reportedCombined: 2. Thevirtio-net driver uses at most one queue pair per vCPU. Over-provisioning is
therefore inert rather than harmful, and it is not rejected at deployment
because
resizecan raise the vCPU count later; this is now documented.Default queue count
With vhost on, queue pairs default to
min(vcpu, 16). Since the defaultflip (tenth pass, below) both are opt-ins rather than upgrade behaviour
changes; two consequences of opting in are still worth stating plainly:
next boot. It gains
mq=on,vectors=2N+2and N queue pairs where itpreviously had one. The device is not part of the measured VM configuration
— that covers the OS image, vCPU/memory, QEMU layout flags, NIC count, and
mr_config_id— so app identity is unaffected, but the guest does see adifferent NIC.
netdto get it.qemu-bridge-helperreturns a singledescriptor and cannot create a
multi_queueTAP. A node that has neverdeployed
netddoes not fail: bridge NICs fall back to one queue pair with awarning. A deployment that asked for a queue count explicitly still fails, so
the caller learns their request was not met rather than silently getting less.
The measured trade-off, from an 8-vCPU TDX CVM with only the guest's channel
count changed (
ethtool -L), is real in both directions:The same CVM moved 3.0 Mpps of 64-byte UDP with no loss at 8 queues against
roughly 600k at one. Bandwidth-bound workloads want the default; a VM serving
many short connections should set
--net-queues 1. Cross-vCPU wakeups cost anIPI and a VM exit under TDX, which is why the scaling is capped at 16 rather
than following large vCPU counts.
Verified on
phala-tdx-lab, 23 assertions:Surfaces and interactions
loop drains every queue on one thread, so extra queues buy little while still
costing a netd interface, more MSI-X vectors, and a changed guest device.
Anyone disabling vhost wants the old data plane, so they get the old shape.
An explicit queue count is still honoured without vhost, since that
combination is a deliberate request rather than a default.
max_net_queuesbounds what a deployment may ask for; the default's own cap is a fixed 16, so
a larger VM never silently acquires a worse default. Hard ceiling from any
source is 64.
UpdateVmchanges both fields, applying from the VM's next boot.vmm-cli.py updategrew--net,--net-vhost/--net-no-vhostand--net-queuesto reach it; the update path previously had no networkingoptions at all.
the mode selector. Leaving a control on its default emits no field, so the
node keeps owning that value.
Verified on
phala-tdx-lab(22 assertions):Third review pass
Eight more, all fixed. Three would have stopped a working node from launching
VMs, which is the failure mode a defaulted-on feature has to be judged by:
cvm.max_net_queuesnever bounded the default. Lowering it to 2 stillhanded a 16-vCPU VM sixteen queue pairs, contradicting both the vmm.toml
comment and the docs. Raising it above 16 still only widens what a caller may
request; lowering it below 16 now lowers the default too, because a node that
refuses a request for four should not hand out sixteen by itself.
dstack-vmm runbroke on every bridge VM. It never applied thenetd fallback, so with the default queue count a ≥2-vCPU bridge NIC hard-failed
with "does not manage netd interface lifecycle" on a node that worked before.
with
socket.exists(), but netd does not unlink its socket on shutdown, so astale file read as "netd is here" and the launch then failed to connect
instead of falling back. It now connects, the same way netd's own
bind-time staleness check does.
bail!and thepre-existing
response.device?returned without the rollback loop, so aversion-skewed netd left an interface on the bridge with nothing recorded to
clean it up. Both now unwind through one shared rollback.
paths still called
resolved_networks()rather than the clampedruntime_networks(), giving an already-running VM an unclamped queue countand failing
stop_vmwith a netd connect error.GetInforeported a data plane the NIC did not get. A bridge NIC thatfell back to the non-vhost
bridgenetdev for want ofqemu-bridge-helperstill reported
vhost: true. The QEMU arguments and the reported status nowread the same
effective_vhost.the NIC to user mode still submitted it — the deploy failed with no visible
control to fix.
queues/vhostare now scoped to non-user modes, likebridge_name.vmm-cli update --net-*replaced the whole NIC list, dropping extrainterfaces and un-pinning a bridge. It now merges into the VM's existing NIC
and refuses outright on a multi-NIC VM rather than guessing.
Fixing that last one surfaced a round-trip bug worth calling out on its own:
GetInforeportedparentandmacvtap_modeon every interface, includingbridge NICs that had merely inherited them from
[cvm.networking]— and thedeployment RPC rejects
parentoutside macvtap mode. Reported configurationcould be read but not sent back. Both fields are now scoped to macvtap the way
bridge_nameis scoped to bridge, with a test that a reported interfacesatisfies the RPC's own validation.
Verified on
phala-tdx-lab(11 assertions):Fourth and fifth review passes
Fourteen more findings, all fixed, and the shape of them says something about
the first three passes: nine came from the fixes those passes made.
Two root causes accounted for most of it, and both were the same mistake --
recomputing, from configuration an operator can edit at any time, a fact that
was only true at the moment something was built:
network_filter.modeoff while VMs existed orphaned TAPs and leaked theirnwfilter bindings. Interface names are a deterministic hash of the VM
identity, so the same VM comes back on the same name and inherits the
leftover ebtables rules -- silently filtered traffic on a NIC the operator
believes is unfiltered. Reproduced on a TDX host against the pre-fix build:
bindings after removal: dt77a0317f8bca clean-traffic. NICs now record whatnetd built for them.
then stopped following the node, including into a mode node policy never let
that caller choose.
Networkingnow carriesinherit_mode, and an inheritedentry reports an empty mode, which is what it was deployed with.
GetInfooutput could not be sent back. Itsconfigurationis the inputUpdateVmtakes, and bothvmm-cliand the web UI read it, change one field,and resend the rest. The node's own bridge was refused by the
allowed_bridgesgate, and
macvtap_modewas refused outright -- sovmm-cli update --net-*failed on every bridge VM on a default-configured node and on every macvtap VM.
There is now one test that asserts the property over every mode and tuning
combination, rather than the one example each earlier pass fixed:
The rest: a stopped VM reported the NICs of a finished boot rather than the
ones its next launch would build;
delete_bindingwas the one netd helper thatspawned
virshunbounded, and best-effort cleanup had just put it on everyprepare, where one unreachable libvirt could stall netd's serialized loop for
every other VM; status polling probed netd once per stopped VM, from inside the
global state lock, costing netd two warnings per probe; a queue count was
refused for a backend the caller inherited, which left the VM uneditable once
its node moved; the web UI could silently delete a NIC and renumber the rest,
changing their MAC addresses; and
netdstopped answering requests it couldnot parse, which is exactly what a VMM newer than its netd needs to read.
Verified on a TDX host: 21 assertions across the deployment, teardown,
inheritance and reporting paths, each one first shown to fail against the
pre-fix binary.
Sixth and seventh review passes
Twenty more, and the character of them finally changed: no data-plane or
teardown correctness left, but a long tail in the surfaces an operator actually
touches, and a lot of documentation that had drifted behind the code.
The knobs were unobservable.
vmm-cli.py infoprinted nothing aboutnetworking at all, and the web UI's interface panel was never extended, so the
two status fields this PR adds — the effective vhost state and queue count —
could only be read as raw JSON. That matters because both silently degrade: a
node without netd drops every defaulted bridge NIC to one queue pair, and a
missing
qemu-bridge-helperdrops it off vhost. Both now appear ininfoandin the UI, with a note when the VM is not running and the numbers are a
prediction.
vmm-cli.py update --net <mode>could not change a mode. It merged theNIC's current fields and overwrote only
mode, so switching a bridge VM to usersent a
bridge_namealong with it and was refused for a field the caller nevertyped and had no flag to clear. Mode-owned fields are now dropped on a switch.
The same shape had a second cause: a VM pins its bridge for life, and once the
operator moved the node's own default, the VM's reported configuration stopped
being accepted back. An update may now restate what its own VM already holds.
The knobs could be set but not unset —
--net-queues autoand--net-vhost-defaultnow exist,--net-queues 0is refused with a sentenceinstead of being silently discarded, and
--net-queues -1no longer surfaces aserde error naming a column offset.
Two more instances of the pattern this PR keeps hitting — deciding something
at launch and then recomputing it later from configuration that has moved:
GetInforecomputed each interface's vhost state, so an edit tocvm.qemu_bridge_helperchanged what a running VM was said to be using. Thedata plane is now settled once at launch and written down, and
VmInfo::to_pbno longer takes a
CvmConfigat all. And the single-queue fallback fired forlibvirt-filtered bridges, where it cannot help — filtering needs netd whatever
the queue count — reporting a shape no launch could produce.
Also:
GetMetaadvertised modes node policy forbids, so the deploy dialogoffered choices whose only outcome was "not allowed by node policy"; a queue
count or vhost request is now refused for a backend the caller chose and
accepted-but-dormant for one they inherited, since only the first is theirs to
correct; and a failed multiqueue prepare says a separately deployed netd may
predate multiqueue support, which the version-skew message previously missed in
the default unfiltered configuration.
Fourteen documentation claims were false or misleading, checked line by line
against the code: the queue-refusal rule was stated backwards after the code
changed under it, "byte for byte identical" applied only to the guest device
line and not the netdev,
queueswas described as a per-node setting it hasnever been, the netd TAP form was missing from the bridge row that produces it
most often, and the pre-6.4 cgroup claim was wrong in the direction that
matters. Several code comments had drifted the same way.
Eighth review pass
Four independent lenses, one of them told nothing about the previous seven:
the Web UI's contract with the server, the CLI and RPC surface, a blind
correctness sweep of the Rust, and a line-by-line fact-check of every doc claim
against the code. Twenty-eight findings, all fixed and verified.
The two that mattered most were both about the privileged side of the boundary,
and neither was in the data plane.
netd stopped enforcing its own invariant. Unfiltered multiqueue needs a
bridge TAP with no nwfilter binding, so this PR taught
netdto build one whenthe caller asks. That moved the "every bridge TAP is filtered" invariant out of
the privileged daemon and into its unprivileged caller — and anything that can
reach the socket is a caller. On a node configured with
network_filter.mode = "libvirt", one JSON message now bought a root-createdTAP on a shared host bridge with no ebtables rules on it.
netdholds thepolicy itself again, via
[netd] require_filter, derived fromcvm.network_filter.modewhen the two share onevmm.tomlso they cannotdrift.
Upgrading the VMM bricked every bridge VM on the node.
netdis a separatelong-lived root service, so replacing the binary and restarting
dstack-vmmwithout restarting
netdis the ordinary upgrade path. The liveness probe wasa bare connect, which cannot tell "netd is old" from "netd is fine" — so a
defaulted multiqueue request went to a netd that refuses it, and every bridge VM
failed to start on a node that had worked with no netd at all. There is now a
capabilitiesoperation; a netd that predates it answers "unknown variant",which is itself the answer. A defaulted NIC degrades to one queue pair the way
it does with no netd; an explicit request still fails loudly.
The type that was two things.
Networkingwas simultaneously[cvm.networking], what a VM pins, and what a launch hands QEMU. Resolutiontherefore copied the node's whole value into every VM, so a bridge NIC's
manifest entry carried whatever macvtap parent the node happened to have
configured — and
held_networking_config, which widens deployment policy fromwhat a VM already holds, widened from that. Repoint the node's parent and a
bridge VM could move itself to a macvtap parent policy forbids.
NicNetworkingis now its own type and
Networkingflattens it, so the node's half isunreachable from a VM's record by construction; the flatten keeps the
serialized shape byte-identical, and a test pins that so existing manifests and
snapshots keep loading.
Also fixed:
GetInforecomputed a running VM's data plane from current nodeconfiguration when its snapshot predated the feature, and persisted the guess —
a build old enough to leave no snapshot had no vhost at all, so it now records
what that VMM actually launched.
update --net usercarried an inheritedvhost/queue pin into a mode that has neither and was rejected for a flag the
operator never typed.
--netcould pin a mode but nothing could un-pin it;there is now
--net default, alongside--net-queues autoand--net-vhost-default. Loweringcvm.max_net_queuesblocked every networkingupdate on VMs already above it while doing nothing about their actual queue
count. An explicit
queues: 0was silently reread as "unset".custommodereported
vhost: off, queues: 1over a netdev string the VMM never parses —both fields are optional on the wire now and absent there. The "not running"
label was derived from the status string, which says something else: a VM whose
QEMU is up while it boots was labelled a prediction.
cvm.qemu_bridge_helperwent unvalidated into a QEMU option list that splits on
,and=.The Web UI got its own pass: the update dialog has no
<form>, so itsmin/maxnever ran and0was silently dropped; queue counts are parsedstrictly in shared code now, so both dialogs behave the same. Macvtap NICs had
no parent field at all and rendered identically to each other, which made
"Remove" on a multi-NIC VM a coin flip that also changed the surviving NIC's
MAC. Networking rows survived between dialog openings, so a cancelled deploy or
a clone silently pinned the next VM's backend. Fourteen documentation claims
were wrong, including two acceptance criteria this PR itself contradicts, and
--net passt, which has never been a valid flag value.Verification. 180 unit tests, clippy and fmt clean,
tsc --noEmitclean,and 41 assertions on real TDX hardware: the 26 from earlier rounds re-run after
the type split, plus 15 new ones covering the capability probe, netd's filter
policy, the un-pin paths, the queue cap, and the running-state label. Host left
with no leftover TAPs and no leftover nwfilter bindings.
Ninth review pass
Three lenses: an adversarial attack on the netd protocol, a completeness audit
of the type split, and a blind sweep by a reviewer told nothing about the
previous eight. Also a scoping correction from the reviewer that reshaped the
result: backward compatibility only has to hold against v0.5.11, the last
release. v0.5.11 has no
netd.rs, noruntime-networks.json, no macvtap, nocvm.network_filter, and its per-VM networking record storesmodeandnothing else.
The worst finding was one this PR introduced two rounds ago. The strict
queue-count parser added in the eighth pass assumed a string. Vue's
v-modelcasts for
<input type="number">, so the field is a string only until theoperator types into it — and then
.trim()throws aTypeErrorout of everydeploy or update that set a queue count. The headline feature was unreachable
from the web UI. Reproduced under jsdom against the exact Vue build the UI
loads, fixed, and re-reproduced green; the form field's type is now
string | number, so reintroducing the assumption failstsc.netd's new policy was bypassable in one request. The eighth pass gave netd
its own
require_filterso the "every bridge TAP is filtered" invariant livedin the privileged daemon again. It was checked in
prepare_bridgeonly — and amacvtap parent may be a bridge, while nwfilter does not apply to macvtap. So
prepare_macvtapwithparent: "br0"returned a root-created/dev/tapNwithfull unfiltered L2 access to the exact bridge the policy protects. Refused now,
along with a parent enslaved to one.
And the policy only checked that a filter was named, not that it filtered.
The caller supplied the nwfilter name and its parameters, so
allow-arp— whichcontains no drop rule at all — satisfied it, as did
clean-trafficpinnedthrough its parameters to the gateway's MAC and IP. The request no longer
carries a filter name or parameters at all: it says whether to bind, and
[netd.network_filter]says what. A malformed policy section is now a startuperror rather than a silent fall back to "filter nothing".
The capability negotiation is gone. Under the v0.5.11 rule there is no
released netd, so the
capabilitiesoperation and itsLegacystate werecompatibility machinery for a peer that does not exist — and they introduced a
failure mode the bare connect never had: netd serves connections serially, so a
netd busy preparing another VM's interface answered nothing within the probe
timeout and read as missing, silently dropping the VM to one queue pair. The
liveness check is a connect again, which is the one signal that does not depend
on what netd is doing right now.
Also fixed:
manifest_networksstill wrote the node's other mode's identityfield into a VM's record — the exact shape whose consequence, one round earlier,
was a policy bypass — so it is scoped once at the write point instead of by two
hand-written guards downstream. A request of
{queues: 0}and nothing elsereturned "no override at all" before anything read the count, answering a
request for no queues with up to sixteen.
resolve_networkingclearednetd_interfacebut notdevice, under a comment claiming both.one_shotnever settled vhost, so it warned about a
/dev/vhost-netthe netdev it thenbuilt does not open. The effective macvtap forwarding mode was reported nowhere
after the type split, though the proto still documented it.
warn_if_vhost_net_missingonly
stated the device — but the node isroot:root 0600on severaldistributions, where turning vhost on node-wide stops every bridge VM from
restarting with the reason buried in a per-VM launcher, so it now also reports
an inaccessible device. The web UI reimplemented a queue cap the server
deliberately widens by what a VM already holds, making such a VM's networking
uneditable; and a VM pinned to a mode node policy no longer offers rendered a
blank
<select>.cvm.networking.queueswas rejected with "has been removed",for a setting that never shipped, quoting the wrong cap. The pinning
documentation claimed a VM keeps its MAC prefix, subnet and DHCP start for life;
it keeps its mode and the bridge or parent that names it, and nothing else.
Verification. 181 unit tests, clippy and fmt clean,
tsc --noEmitclean,and 48 assertions on real TDX hardware — every earlier round's suite re-run
plus 10 new ones covering both spellings of the filter bypass, the filter that
netd itself names, the startup refusal on a malformed policy, and the bare
queues: 0. The generated QEMU command line was read off a live VM(
tap,...,vhost=on,queues=4againstvirtio-net-pci,...,mq=on,vectors=10, TAPmulti_queueand enslaved to the bridge), and the host was left with noleftover TAPs and no leftover nwfilter bindings.
Tenth pass: default flipped to opt-in, review comments verified on hardware
The human review on this PR asked for an upgrade story and questioned two
failure modes. Resolution:
vhostnow defaults tofalse(code default and shippedvmm.toml).The vCPU-scaled queue default only applies once vhost is on, so an upgraded
node builds exactly the device its VMs always had until the operator opts
in.
docs/network-data-plane.mdgained an "Enabling vhost on a node"checklist (verify
/dev/vhost-netaccess for QEMU's account, restart netdwith or before the VMM, roll back with
vhost = false); 11 tests werere-pointed at explicit opt-in and a new one pins the no-op upgrade: a toml
that predates the key and the shipped default both resolve to userspace
virtio, one queue pair, no netd dependency.
phala-tdx-lab(QEMU8.2.2 — the binary dstack resolves — and 10.2): an unopenable
/dev/vhost-netkills QEMU at netdev init on every version, single- andmulti-queue alike, with no userspace fallback; 8.2 SIGABRTs via qemu#1486's
assert(nc), newer versions exit cleanly. Thefalling back on userspace virtiopath exists but is gated on a successfully opened vhost fd, so anaccess failure never reaches it. Both failure points are now documented,
including that the runtime one is the single case where
GetInfocanoverstate the data plane.
pairs, offloads off, ~177 Gbit/s plus concurrent direct block I/O): the
guest kernel clamps the bounce pool to a 64 MB floor regardless of RAM,
while demand is ring-bounded at ~2 MB per queue pair; a deliberately
undersized 32 MB pool produced zero
swiotlb buffer is fullevents. Theonly failure producible at all was the guest page allocator at 1 GB RAM x
16 vCPU — a RAM:vCPU ratio no deployment uses; 2 GB at the same shape ran
clean — recorded in the docs as a searchable symptom, not a knob.
cleartext-loggingalerts onnetd.rswere dismissed asfalse positives:
owner_uuidis a deterministic UUID derived from publicidentity fields, and the flagged lines are test assertion messages.
Operator notes
[cvm.networking] vhost = trueafter verifying the account running QEMU can open/dev/vhost-net(root:kvm 0660— add it tokvm). On prod7 this wasalready true and the module autoloaded on first open; no root, no
pre-created TAPs, no
CAP_NET_ADMIN. If it is not, QEMU dies at startup(8.2:
net_client_init1: Assertion 'nc' failed) and the VM does not boot.thread whose CPU time escapes the VM's cgroup. Since 6.4 it is a
vhost_taskinside the QEMU thread group — verified on the 6.8 lab host, where the worker
tids appear under
/proc/<qemu>/task/with the same cgroup — socpu.maxand cgroup accounting still attribute it to the CVM.
netd, includingwhen
network_filter.mode = "none".--net-no-vhostrestores the previous-netdev bridge,...command lineexactly, so the old behaviour is one flag away.