Repository navigation
feat(kubevirt): serve read-only VM/VMI lookups from an informer cache - #32
Conversation
|
Hi @itayherzberg. Thanks for your PR. PRs from untrusted users cannot be marked as trusted with I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
| @@ -0,0 +1,601 @@ | |||
| /* | |||
There was a problem hiding this comment.
I think this reimplements too much. The client-go project already has informer implementation available and reimplementing it all is not a good idea maintenance wise.
See for example https://oneuptime.com/blog/post/2026-02-09-client-go-informers-watch-resources/view (random google result).
Also I think we can make the use of informers mandatory, saving api server requests is a good idea.
There was a problem hiding this comment.
Or maybe not quite "reimplements", it uses the informer tooling, but it does that too complicately. I think it would be enough to just use informers for few specific CRDs and not construct a complicated dynamic cache.
There was a problem hiding this comment.
Thanks, both points taken. I pushed the change as a second commit rather than amending, so the delta since your review is visible.
Mandatory cache. The cache_enabled setting is gone, together with its config, helm and test plumbing. Informers always run for the configured namespaces. The only fallback to a direct GET is while an informer has not finished its initial list.
The complexity. What made cache.go 600 lines was not the informers but a write-tracking layer around them: reads of an object bypassed the cache until the informer had stored the resourceVersion our own write returned. It existed for one real case. handleBootUpdate writes the VM and then reads it back through the cache to build the PATCH response, so with plain lister reads that body can show the pre-write boot options to Ironic whenever the watch event lags the write. I replaced the tracker with a live read at that one point (GetVMBootOptionsLive, one GET per PATCH) and checked that no other write path reads through a cached getter. What that trades away is the next request landing in the few milliseconds before the watch event arrives, which is the same window the PR already accepts for writes by oc or virt-controller.
cache.go is now the textbook shape, about 280 lines with comments: a per-namespace dynamicinformer factory with one informer for VMs and one for VMIs, SetTransform to drop managedFields, listers behind HasSynced, and the restart/stop hooks tied to the existing watchers.
"Informers for a few specific CRDs." I read that as exactly these two, VM and VMI. They are dynamic informers because the repo has no kubevirt.io/client-go dependency and uses the dynamic client throughout. If you meant typed KubeVirt informers instead, tell me and I will switch; that would add kubevirt.io/client-go to go.mod.
a632521 to
bd12d08
Compare
…ory, write tracker dropped Picks up bd12d08, the review revision of kubevirt#32. Conflicts were the adjacent hunks the rebase note in SCALEFIX-CHANGES.md describes: kept kubevirt#30/kubevirt#31's qps/burst/workers lines and their shared test helpers, dropped cache_enabled and the write tracker. # Conflicts: # config/config.yaml # helm/templates/secret.yaml # helm/values-example.yaml # helm/values.yaml # pkg/config/config.go # pkg/config/config_test.go # pkg/kubevirt/client.go
…racker (fork-only)
|
@MarSik Gentle ping on this when you have a chance! Let me know if you need any additional context |
|
@itayherzberg I did some initial testing already, but it is a lot of patches so give me some more time please. One question from my side would be if the patches should be applied in some specific ordering. Just to avoid needless conflicts. |
Every Redfish read went to the API server: a GET /Systems/{id} was six
Kubernetes API calls (one to resolve the system, then the VM four
times and the VMI once), and Ironic polls every host it manages. With
many hosts in flight those calls queue behind the client-go rate
limiter and reads take 10-35 seconds.
Keep one informer per configured namespace for VirtualMachines and
one for VirtualMachineInstances, with managedFields stripped, and
answer read-only lookups (power state, boot options, CPU, memory,
storage, network, virtual media state, the collections, and the
per-request system lookup) from the local copy. A warm cache turns a
GET /Systems/{id} into zero API calls and a boot-device PATCH from 12
into 6.
Three rules keep cached reads correct:
- Anything that reads a VM in order to change it still reads live
(SetBootOrder, SetBootOnce, virtual media insert/eject, power
actions, import labels), so a patch is never computed from a stale
copy. GetVM / GetVMI are unchanged; the cached variants are
getVMForRead / getVMIForRead.
- The cache is either fully trusted or not used: until an informer has
synced, reads for that namespace go to the API server; after that
its answer is final, including "not found".
- After this process writes an object, reads of it skip the cache
until the informer has stored that write. A thin wrapper around the
dynamic client records the resourceVersion returned by every VM/VMI
create, update, patch and delete; the informer's event handler
reports each version it stores. The tracker also keeps the last
versions the informer stored, because on a real API server the
watch event for our own write often arrives before the write call
returns. The bypass ends after writeBypassMax (30s) at the latest,
with a warning.
Writes by anyone else (oc edit, KubeVirt's controllers) reach the cache
with their watch event, usually within milliseconds. The cache can be
turned off with kubevirt.cache_enabled: false. GetMetrics counts
GetVM.cache / GetVM.direct (and the VMI and List equivalents).
The informers need list and watch on virtualmachines and
virtualmachineinstances; the chart's ClusterRole already grants both.
go.mod gains one indirect test-only dependency pulled in by
k8s.io/client-go/dynamic/fake.
Signed-off-by: itayherzberg <itayherzberg@gmail.com>
…ite tracker Review feedback on the first revision: the cache should not be optional, and the write-tracking layer around the informers was too much machinery. The cache_enabled setting and its config, helm and test plumbing are gone. Informers always run for the configured namespaces; the only fallback to a direct GET is while an informer has not finished its initial list. The write tracker existed for one case: the boot-device PATCH handler writes the VM and then reads it back to build its response, and a lister read there could return the pre-write boot options. That read now goes to the API server (GetVMBootOptionsLive). No other write path reads through a cached getter, so the tracker, the wrapping dynamic client and their tests are removed. cache.go is now plain client-go informers: per-namespace dynamic factories for VM and VMI, managedFields stripped on the way in, listers behind HasSynced. Signed-off-by: itayherzberg <itayherzberg@gmail.com>
bd12d08 to
7f478d3
Compare
|
@MarSik Thanks for testing, and take whatever time you need. On ordering, across all nine (#29–#33, #36–#39): I merged every pair onto current main and the full set together. No branch is stacked on another, so there is no required order. With the two fixups below, the combined tree builds and passes all tests, go vet, helm lint and helm template. Since your question I rebased #32 and #33 onto current main and moved #32's new struct fields so those two no longer collide, which was the one conflict #33's description used to mention. I also updated the Related sections of #32 and #33 to match what is below. Two spots still touch:
Tell me which one you plan to take next and I will rebase the other right away so it is clean when you get to it; otherwise I will do it the same day the first one merges. Not merge constraints, but useful for testing: #36, #37 and #39 are one set (power deferral around ISO imports) and were verified together on the fork, so they are best in the same release; #33's description notes it and #31 belong together too; and #29 before #32 is only a soft preference, since #32's cache shutdown path runs in a pod only once #29 fixes Worker.Stop. Also, the CI runs for #32, #33 and #36–#39 are waiting for a maintainer to approve the workflow run, in case you want them green before looking further. |
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: MarSik The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
Problem
Every Redfish read goes to the API server, several times over. With about 15 BareMetalHosts being polled by Ironic at the same time, reads queued behind the client-go rate limiter and took 10–35 seconds; Ironic's boot-device
PATCHran past the OpenShift route's 30-second timeout, got a 504, and inspection looped forever.Cause
A
GET /redfish/v1/Systems/{id}is 6 Kubernetes API calls: one inresolveSystemVMandCheckAccessto find the VM, then the same VM four more times (power state, boot options, storage, memory/CPU) and the VMI once. A boot-devicePATCHis 12, and Ironic's power poll of a stopped host is 6. Every one of them fetches an object that rarely changes, through a client-go limiter of 5 requests/second.Measured on upstream
mainbehaviour (first revision of this branch with the cache turned off), 100 ×GET /Systems/{id}, 20 in parallel, OpenShift 4.22 / OpenShift Virtualization 4.22.12:Fix
For every namespace in the chassis config the client keeps a client-go informer (a watch plus a local copy) of VirtualMachines and one of VirtualMachineInstances, with
managedFieldsstripped. Read-only lookups read the local copy instead of calling the API server: power state, boot options, CPU, memory, storage, network, virtual-media state, the Systems/Chassis/Managers collections, and the per-request lookup inresolveSystemVMandCheckAccess. The informers start and stop with the existing watchers (RestartWatchers/Close) and are rebuilt on config reload.GET /Systems/{id}PATCH /Systems/{id}Two rules keep cached reads correct (both in
pkg/kubevirt/cache.go):SetBootOrder,SetBootOnce, virtual-media insert/eject, power actions and the import labels still read live, exactly as before, so a patch is never computed from a stale copy. The boot-devicePATCHhandler builds its response withGetVMBootOptionsLive, a direct GET, so the body it returns to Ironic reflects the write it just made. No other write path reads through a cached getter.GetVM/GetVMIare unchanged; the cached variants aregetVMForRead/getVMIForRead(GetVMForReadexported for the server package).What the cache does not guarantee: a write, by someone else (
oc edit vm, KubeVirt's own controllers) or by this process, reaches the cache with its watch event, usually within milliseconds and later when the API server is overloaded. Until then a cached read returns the previous version. This is acceptable because no write path reads from the cache: a stale read can make a response out of date, but can never be written back over someone's change.Observability:
GetMetricscountsGetVM.cache/GetVM.direct(and the VMI and List equivalents); startup logsVM cache started for namespaces […]. RBAC: the informers needlistandwatchonvirtualmachinesandvirtualmachineinstances; the chart's ClusterRole already grants both.go.modgains one indirect test-only dependency pulled in byk8s.io/client-go/dynamic/fake.Behaviour change: read-only lookups are served from the cache. There is no off switch.
Verification on the cluster
Measured on the first revision of this branch, which differed only by a write-tracking layer that has since been removed (see the second commit) and an off switch. Same load, cache on, same default rate limiter:
PowerStatepolled every 0.3 sOff→PoweringOn→On, matchesoc get vmiForceOffInProgress→Offoc patchCPU cores 1 → 2ProcessorSummary.Count: 2through Redfish ~2 s laterPATCHOnce/Cd; matches the VM annotationsGetVM.direct: 20(the write paths, by design),GetVM.cache: 227Earlier rounds of the same code on the fork, with API calls counted from the kube-apiserver audit log, also covered the initial sync under the chart's ClusterRole (one list + watch per namespace for VMs and VMIs, no
forbiddenerrors), pause/resume (Pausedwithin ~1 s from the cached VMI), virtual-media insert (Inserted: true~3 s after the insert) and eject (falseon the first read after it), a pod restart with one VM paused, 8 rounds of On/ForceOff with 20 reads each (100 % cached, 0 warnings), and a user of chassis A reading a system of chassis B → 404.Tests
pkg/kubevirt/cache_test.go, againstk8s.io/client-go/dynamic/fake: a read is served from the cache even when the API server's GET is made to fail; a synced cache's "not found" is final; a namespace the cache does not cover falls back to the API server;GetVMBootOptionsLivereturns the API server's version while the cached getter still returns the older one; the cache can be restarted with a different namespace set and stopped without deadlocking, and does not start on a client without a dynamic client;filterVMNamessorts and filters.go build ./...,go vet ./...,go test ./...,gofmt,go mod tidyandhelm lintpass.Related
One of nine PRs from running many concurrent BMHs against kubevirt-redfish (#29–#33, #36–#39). None is stacked on another. This branch merges cleanly with each of the other eight, and a tree with all nine builds and passes the tests after two fixups that do not involve this PR (#30 and #31 add the same test helpers; #33 and #39 touch one line in
EjectVirtualMedia).Clientstruct. This PR keeps its fields next to the metrics block, so the two merge in either order. fix(kubevirt): concurrent virtual-media insert and boot override could drop the CD-ROM disk from the VM spec #33 serializes the write paths; this PR only moves read-only lookups to the cache and keepsGetVMlive, so the two do not interact at runtime.Close()never runs in a pod, so the cache's stop path is exercised by unit tests only.