Sliding-window worker upgrades without a strategy plugin - #13397
Sliding-window worker upgrades without a strategy plugin#13397Payback159 wants to merge 1 commit into
Conversation
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: Payback159 The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @Payback159. Thanks for your PR. I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with Tip We noticed you've done this a few times! Consider joining the org to skip this step and gain Once the patch is verified, the new status will be reflected by the 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. |
|
|
5d9c14f to
e8fcf04
Compare
|
Ok, this is very, very interesting. In fact, reading the docs, it looks outright fantastic (per group ceilings !!). I'm on vacation for four weeks, so I'm not gonna be able to review this quickly, but I'll try to chip away at it a bit. One meta question: I think I saw some stuff about the download role ? How compatible would the PR be with #12299 and #12937 (which basically optimize for delegation to the controller and download stuff at once, letting only in the specific role putting the binary/files in their definitive place) ? |
|
Thanks, and enjoy the vacation — there's no rush on this at all. The only thing this PR needs from the download story is that nothing cluster-scoped runs inside the rolling window, because After your refactors, the roles inside the window contain no download logic at all. Those are node-local |
|
I test-merged each against this branch and ran a real upgrade on 6 VMs (3 control-plane, #12299 needs no change here. #12937 lets me delete two things (~20 lines):
I also hit three unrelated issues while doing this and have commented on them in the |
|
/ok-to-test |
352e8af to
824cce5
Compare
Opt in with upgrade_strategy=graceful_rolling. The worker play then runs on the built-in host_pinned strategy as a single batch, and a pair of action plugins gates it with a flock-based semaphore in Ansible's per-run controller tmpdir. A node that finishes frees its slot immediately, so no node waits for a slower peer and no batch is drained as a unit. linear remains the default and behaves exactly as before. ansible-core deprecated custom strategy plugins (ansible/ansible#84725), which closed off the approach PR kubernetes-sigs#13080 took. It also turned out to be unnecessary: host_pinned already is a sliding window. What that PR's 714-line fork of free.StrategyModule.run() added on top was a window narrower than the fork count and per-group ceilings, and both fit in action plugins - a supported, stable extension point. Three ansible-core behaviours shaped the rest, none of them optional: * run_once is ignored outside linear; free warns and runs the task on every host. Only the worker play is therefore rolling - the control plane already runs serial: 1 and the calico play never cordons a node. roles/kubernetes/ kubeadm's cluster-scoped kube-proxy tasks moved behind kubeadm_patch_kube_proxy, which defaults to off so a play must ask for them and only a linear play may. cluster.yml and scale.yml opt in; upgrade_cluster.yml runs them once in its own linear play, which also stops the linear path repeating them per serial batch. * any_errors_fatal and max_fail_percentage exist only in linear. Since the worker play sets any_errors_fatal, switching strategy would have dropped it silently. upgrade_abort_on_failure restores the intent where it counts: once a node fails, nodes that have not been drained yet abort instead of starting, while nodes already inside the window finish rather than being left cordoned. * pause sets BYPASS_HOST_LOOP and free raises on it at dispatch, before any when: is evaluated. The two confirmation prompts moved into dynamically included files; the timed waits use wait_for, which runs per host. pause and add_host are the only such modules, and add_host is in none of these roles. A waiting host holds a fork, so the window is clamped to forks - 1 with a warning rather than a hard failure - Ansible's default of 5 forks would otherwise make the default "20%" abort on any cluster above 25 nodes. The fork count comes from CLIARGS because DEFAULT_FORKS has no cli mapping and would miss -f entirely. Verified on six Ubuntu 24.04 VMs, three workers, upgrading 1.35.7 to 1.36.3. The window never exceeded its size and the third worker was admitted 99s after starting to wait - as soon as the first node finished, not the slowest, while the second was still blocked against a minAvailable=2 PodDisruptionBudget. Paired against linear at the same concurrency: one node made 150s slower cut the run from 471s to 369s, and under the PDB serial: 2 failed outright at 591s with both batch nodes reporting "Cannot evict pod as it would violate the pod's disruption budget" where the window completed in 394s without hitting the budget once. With uniform nodes and nothing blocking eviction the two are equal, and the docs say so. system_upgrade: true was verified separately, including the download role running inside the window and both workers rebooting without losing a slot. 139 unit tests cover the concurrency maths, group ceilings and lease lifecycle, wired into pre-commit. The CI job uses mode: ha rather than all-in-one, where kube_node:!kube_control_plane would be empty and the rolling play would be skipped without failing. docs/developers/upgrade-slots.md records the design constraints and what to re-check on an ansible-core bump.
824cce5 to
cf436a6
Compare
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in worker upgrade mode for upgrade_cluster.yml that avoids serial: batch lockstep by using Ansible’s host_pinned strategy plus controller-side action plugins to enforce a sliding concurrency window, improving upgrades under node-duration variance and avoiding some PodDisruptionBudget deadlocks. It also refactors cluster-scoped kube-proxy patching so it only runs under a safe (linear) context.
Changes:
- Add
upgrade_strategy: graceful_rollingwithupgrade_node_concurrencyand optional per-group ceilings enforced via newacquire_upgrade_slot/release_upgrade_slotaction plugins. - Move interactive prompts behind dynamic includes and replace timed pauses with
wait_forto remain compatible withfree/host_pinned. - Split kube-proxy cluster-scoped patching into an opt-in task file (
kubeadm_patch_kube_proxy) and run it once in a dedicated linear play; add docs + CI coverage + unit tests.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/plugins/action/test_upgrade_slots.py | Adds unit tests for the slot action plugins (concurrency resolution, leases, per-group limits, failure marker). |
| tests/files/ubuntu24-calico-graceful-rolling-upgrade.yml | New CI scenario config exercising upgrade_strategy=graceful_rolling end-to-end. |
| tests/files/ubuntu24-calico-graceful-rolling-upgrade | New CI selector/marker for the graceful rolling upgrade job. |
| tests/ansible.cfg | Ensures test runs can load action plugins from ../plugins/action. |
| roles/upgrade/pre-upgrade/tasks/main.yml | Moves interactive pause into a dynamic include; replaces sleep pause with wait_for. |
| roles/upgrade/pre-upgrade/tasks/confirm_upgrade.yml | New dynamically-included pause prompt task file. |
| roles/upgrade/post-upgrade/tasks/main.yml | Same prompt/include + wait_for change for post-upgrade confirmation/pause. |
| roles/upgrade/post-upgrade/tasks/confirm_uncordon.yml | New dynamically-included uncordon pause prompt task file. |
| roles/kubespray_defaults/defaults/main/upgrade.yml | Introduces upgrade strategy and window/lease/failure-control defaults. |
| roles/kubernetes/kubeadm/tasks/main.yml | Gates kube-proxy patch tasks behind kubeadm_patch_kube_proxy and moves them to an include file. |
| roles/kubernetes/kubeadm/tasks/kube_proxy_kubeconfig.yml | New task file containing the cluster-scoped kube-proxy ConfigMap rewrite + restart logic. |
| roles/kubernetes/kubeadm/defaults/main.yml | Adds kubeadm_patch_kube_proxy default (off) with rationale. |
| plugins/action/acquire_upgrade_slot.py | New controller-side semaphore action plugin to acquire an upgrade slot with window/per-group constraints. |
| plugins/action/release_upgrade_slot.py | New controller-side action plugin to release a slot and optionally record the first node failure. |
| playbooks/upgrade_cluster.yml | Implements validation play + rolling worker upgrade block using host_pinned + slot plugins; runs kube-proxy patch in a separate linear play. |
| playbooks/scale.yml | Enables kubeadm_patch_kube_proxy: true in a linear context. |
| playbooks/cluster.yml | Enables kubeadm_patch_kube_proxy: true in a linear context. |
| docs/operations/upgrades.md | Documents the new rolling strategy and required forks vs concurrency relationship. |
| docs/operations/upgrade-strategies.md | New documentation describing strategy tradeoffs, settings, and operational guidance. |
| docs/developers/upgrade-slots.md | New design notes explaining constraints/ansible-core behaviors and test strategy. |
| docs/_sidebar.md | Adds navigation entries for the new docs pages. |
| ansible.cfg | Adds action_plugins = ./plugins/action so playbooks can find the new action plugins. |
| .pre-commit-config.yaml | Adds a pre-commit hook to run the Python plugin unit tests. |
| .gitlab-ci/kubevirt.yml | Adds the new kubevirt CI job to the PR pipeline list. |
Suppressed comments (2)
plugins/action/acquire_upgrade_slot.py:572
- lease_directory() claims to use Ansible’s per-run controller tmpdir, but ansible_local_tmp() currently just returns C.DEFAULT_LOCAL_TMP when it exists. With the repo’s ansible.cfg not setting local_tmp, that path is typically a stable base dir, so using a constant subdir (…/kubespray-upgrade) can make concurrent ansible-playbook runs share slots and failure markers.
local_tmp = ansible_local_tmp()
if local_tmp:
return local_tmp / "kubespray-upgrade"
plugins/action/release_upgrade_slot.py:248
- release_upgrade_slot.lease_directory() mirrors acquire’s logic and also uses ansible_local_tmp() + a constant 'kubespray-upgrade' subdir. If DEFAULT_LOCAL_TMP resolves to a shared base directory, separate playbook runs on the same controller can interfere (shared slots / shared UPGRADE_FAILED marker).
return ansible_home_tmp() / ("kubespray-upgrade-%s" % safe_run_id(run_id))
local_tmp = ansible_local_tmp()
if local_tmp:
return local_tmp / "kubespray-upgrade"
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| % (hostname, waited, held, concurrency) | ||
| ) | ||
| result.update({ | ||
| "changed": False, |
| fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN) | ||
|
|
||
| result.update({ | ||
| "changed": False, |
What type of PR is this?
/kind feature
What this PR does / why we need it:
upgrade-cluster.ymlwalks the worker nodes inserial:batches. That has twocosts:
holds its peers idle even when the cluster has capacity to keep going.
serial: 2and aPodDisruptionBudgetof
minAvailable: 2, the first eviction is allowed, the second breaches thebudget, and the budget cannot recover because the first node may not uncordon
until the whole batch clears the drain task. Both nodes fail.
This adds an opt-in
upgrade_strategy: graceful_rollingthat replaces the batchwith a sliding window: every worker runs independently and a node that finishes
hands its slot to the next waiting node immediately.
linearremains thedefault and is unchanged.
Measured on six Ubuntu 24.04 VMs (three workers), window and
serialboth 2:lineargraceful_rollingPodDisruptionBudget,minAvailable: 2The first row is deliberate and documented: with uniform nodes the window admits
the next node when one finishes, which is when a batch would have rotated
anyway. The gain comes from variance, which is the common case in practice, and
from not draining in lockstep.
Which issue(s) this PR fixes:
Fixes #12929
Special notes for your reviewer:
This does not add a strategy plugin, despite the issue title. ansible-core
deprecated custom strategy plugins
with no replacement API, which is why #13080 was closed. It also turned out to
be unnecessary: the built-in
host_pinnedalready is a sliding window. What#13080's fork of
free.StrategyModule.run()added on top was a window narrowerthan the fork count and per-group ceilings, and both fit in two action plugins —
a supported extension point. This PR supersedes #13080.
Most of the diff is not the semaphore but working around three ansible-core
behaviours. They are worth knowing before reviewing:
run_onceis ignored outsidelinear—freewarns and runs the task onevery host. This is why only the worker play is rolling (the control plane
already runs
serial: 1; the calico play never cordons a node), and whyroles/kubernetes/kubeadm's cluster-scoped kube-proxy tasks were split out.Left alone they would
kubectl replaceone ConfigMap from several nodes atonce and each node would delete every kube-proxy pod in the cluster.
any_errors_fatalandmax_fail_percentageexist only inlinear—freejust warns. The worker play setsany_errors_fatal, so switchingstrategy would have dropped it silently.
upgrade_abort_on_failure(defaulton) restores the intent: once a node fails, nodes not yet drained abort
instead of starting, while nodes already inside the window finish rather than
being left cordoned.
pausesetsBYPASS_HOST_LOOPandfreeraises on it at dispatch,before any
when:is evaluated — so guarding the prompts with a conditiondoes not help. The two confirmation prompts moved into dynamically included
files; the timed waits use
wait_for, which runs per host.Points worth deliberate attention:
kubeadm_patch_kube_proxydefaults to off. The cluster-scoped kube-proxytasks are now opt-in;
cluster.ymlandscale.ymlask for them explicitly.Anyone invoking
kubernetes/kubeadmdirectly from the collection loses thepatch unless they opt in. Deriving the flag from
upgrade_strategyinstead isunsafe — it is an inventory-level setting, so
graceful_rollingingroup_varswould silently disable the patch for ordinary installs, andAnsible exposes no magic variable for the running play's strategy.
upgrade_cluster.ymlnow runs the patch once in its own linear play, whichalso stops the linear path repeating it per
serialbatch.roles:becametasks:+import_role, becausealways:needs a block. Checked first that no role in that play reads asibling role's
defaults/;import_roleis static, so tags propagate asbefore.
The window is clamped to
forks - 1with a warning rather than a hard failure.The fork count is read from
CLIARGSbecauseDEFAULT_FORKShas nocli:mapping and would miss
-fentirely.mode: ha, notall-in-one. An all-in-one node is in bothkube_control_planeandkube_node, sokube_node:!kube_control_planeisempty and the rolling play would be skipped without failing.
hagives onededicated worker, which covers the plugins, the validation play,
host_pinnedand the pause path. It does not cover the window itself — that needs three
workers, and is covered by the unit tests instead.
roles/kubernetes/client/tasks/main.yml:70failsansible-linton masteralready; this PR does not touch that file.
Testing: 46 unit tests (
tests/unit/plugins/action/, wired into pre-commit)cover the concurrency maths, per-group ceilings and lease lifecycle. Manually
verified on Vagrant for a 1.35.7 → 1.36.3 upgrade with a blocking PDB, for the
linear-vs-rolling comparison above, and separately for
system_upgrade: truewhere the
downloadrole runs inside the window and both workers reboot withoutlosing a slot.
docs/developers/upgrade-slots.mdrecords the design constraintsand what to re-verify on an ansible-core bump.
Does this PR introduce a user-facing change?: