Node consolidation for Kubernetes, with the decision left to you.
k8s-dencer works out which nodes your cluster could give back, explains what moving each workload would cost, and waits for a human to approve it.
Live capture of the real product against the demo cluster — full walkthrough video, or run make demo yourself.
Most clusters run at a fraction of what they are paying for. The pods are spread across more nodes than they need, and nothing moves them, because moving them safely means reasoning about PodDisruptionBudgets, topology spread, affinity rules, taints and single-replica workloads all at once.
The existing answers are autonomous. Karpenter consolidates and the Descheduler evicts, both on their own schedule. That is the right design for a great many teams — and unacceptable for the ones running workloads where an unplanned eviction is an incident, who need to know in advance exactly which pods move and what could go wrong.
k8s-dencer produces the plan and stops. It is a decision-support tool that happens to be able to execute, not an autoscaler with a UI.
- Reads the whole constraint picture — PDBs, topology spread, pod and node affinity, taints and tolerations, controller ownership, local storage — and tells you why any given pod cannot move, in words.
- Plans a consolidation as an ordered list of discrete steps, each one draining a single node.
- Rates every step Green, Yellow or Red with the reasoning attached, so the risky ones are visible before anyone clicks anything.
- Executes only what you pick, through the Kubernetes eviction API, so the API server itself enforces your PodDisruptionBudgets.
- Or closes the loop, inside bounds you set.
dencer convergere-plans from observed state after every drain — one step at a time, full Safety Guard each round — until nothing worthwhile remains or your envelope (max nodes, max impact) is reached. You approve a policy with explicit bounds, never an open-ended "optimize". - Refuses steps that became unsafe between planning and execution, naming the rule that stopped it.
- Draws the cluster over time, not just now. A History surface: the estate with its reclaimable slice, a measured savings ledger climbing as nodes are actually removed, requests versus observed usage — with run markers on the timeline.
- Checks whether draining actually saved anything. k8s-dencer never deletes a node — something else does — so it watches, and tells you when a node you drained is still sitting there.
- Answers questions in natural language through an optional Kagent agent that quotes the analyzer rather than re-deriving anything.
- Answers the adjacent questions the same engine can.
dencer preflight— will a node-pool rotation wedge, and on which pod;dencer audit— what cannot survive a node loss;dencer whatif --without-zone b— does everything still fit, CI-gateable;dencer rightsizing— requests versus measured usage;dencer drain <node>— kubectl drain with the rails. - A production-grade review UI. The redesigned interface ("The ledger", designed screen-by-screen before a line of it was built) leads with the decision: Reclaim 3 of 24 nodes now — priced in cores and memory — over a step list grouped by verdict (Safe now / Needs a call / Held back), a Safety-Guard detail pane for every step, one filled Drain button behind a typed confirmation, live run screens, a Recommendations queue ranked by nodes unlocked, three Cluster lenses (Rack, Wells with the planner's real 85% packing ceiling drawn on every node, Load's used-vs-reserved gap), an audit-ledger History, and first-class dark and light themes.
- Works without the UI.
dencer(also akubectlplugin) does everything the UI does from a terminal, with-o jsonfor pipelines — see docs/cli.md.
Execution is off by default. Until you set executor.enabled=true, no
component in the release can cordon, evict, or write to a node — and make lint
fails the build if pods/eviction appears on any role but the executor's.
flowchart LR
subgraph cluster["Your cluster"]
API["Kubernetes API server"]
end
subgraph dencer["k8s-dencer"]
P["planner<br/>informers → analyze → plan"]
DB[("plan store<br/>SQLite or Postgres")]
B["ui-backend<br/>REST + SSE · authn/authz"]
F["ui-frontend"]
E["executor<br/>cordon → evict → verify"]
end
O(["Operator"])
API -- "watch: nodes, pods, PDBs" --> P
P -- "immutable plan" --> DB
DB --> B
B <--> F
F <--> O
O -- "approves selected steps" --> B
B -- "queues a run" --> DB
DB -- "claims the run" --> E
E -- "eviction API<br/>PDBs enforced by the API server" --> API
style E stroke-width:3px
Four workloads, deliberately separated:
| Component | Does | Can it change your cluster? |
|---|---|---|
| planner | Watches state, analyzes constraints, produces plans | No — read-only |
| ui-backend | Serves the API, authenticates and authorizes every request | No — read-only |
| ui-frontend | The operator's view | No |
| executor | Cordons and evicts, one step at a time | Yes — and only when enabled |
The component reachable over the network cannot evict; the one that can evict has no Service. The executor is unreachable from outside the cluster by construction rather than by configuration, and chart lint assertions hold both halves of that in place.
The domain model has no Kubernetes imports at all, which is why the planner can be tested against a YAML snapshot and benchmarked at 50,000 pods with no cluster running.
Prerequisites: a Kubernetes cluster, Docker, Helm 3.8+, Go 1.26+, Node 22+.
No real workloads required — the demo stands up a fabric of fake nodes with KWOK.
make demo # fake-node fabric + 30-node topology + build images + install
make token # mint an operator token
make ui # port-forward and print the URLThen open the printed URL.
Or skip the browser entirely:
make cli-install # installs dencer and kubectl-dencer
export DENCER_TOKEN="$(make -s token)"
dencer plan # or: kubectl dencer plandencer finds the backend through your kubeconfig on its own — no
port-forward, no flags. See docs/cli.md.
To clean up:
make fabric-reset # uncordon every fake node after an executor run
make down # remove every release this repo installsmake with no target lists everything. Full detail in
docs/install.md.
helm install k8s-dencer oci://ghcr.io/atedgimo/charts/k8s-dencer \
--namespace k8s-dencer --create-namespace \
--set auth.enabled=trueThe chart installs one CRD (maintenancewindows.dencer.io); the dencer.io
resources in its RBAC are SubjectAccessReview permission names, not CRDs — see
docs/install.md.
Execution stays off unless you ask for it, and the chart refuses to enable it without authentication and persistence:
--set executor.enabled=true --set auth.enabled=true --set persistence.enabled=trueThe reason to trust this with a production cluster is that the dangerous parts are narrow, explicit, and tested.
- Eviction, never deletion. Steps go through the
policy/v1eviction subresource, so PodDisruptionBudgets are enforced by the API server rather than by code in this repository. The executor is deliberately not granteddelete pods. - No credential store. Identity is verified by
TokenReviewand permissions bySubjectAccessReview, both answered by the API server. Your existing RBAC and SSO are the authority. - Named refusals. The Safety Guard re-checks live state immediately before
each action and blocks on a named rule —
PDBHeadroom,MinReadyNodes,MaxNodesPerRun,StepFreshness,RedRequiresWindow— recorded in the audit trail. - Red steps need a window. High-impact steps stay locked unless a
MaintenanceWindowis open, and window evaluation fails closed. - Recovery is judged on readiness, not phase. A replacement pod that starts but fails its probes aborts the step instead of licensing the next drain.
- Abort means uncordon. Eviction cannot be undone and nothing here pretends otherwise; aborting restores schedulability and stops.
Details, and instructions for verifying the privilege split against your own cluster rather than taking this page's word for it, in docs/security.md and docs/execution.md.
Measured, not estimated — on synthesised clusters, with no cluster running:
| Pods | Constraint analysis | Planning | Stored per plan | UI payload |
|---|---|---|---|---|
| 916 | 16 ms | 17 ms | 54 KB | 0.24 MB |
| 2,526 | 90 ms | 110 ms | 197 KB | 0.03 MB |
| 5,026 | 298 ms | 466 ms | 609 KB | 0.05 MB |
The UI payload falls rather than rises past 2,526 pods because that is where it stops sending an element per pod and starts sending occupancy per node — an individual 6px block conveys nothing at that density anyway. Method, growth curves and the known ceiling in docs/benchmarks.md.
The Cluster destination views the same nodes through three lenses, defaulting by cluster size: Rack to 120 nodes, where a pod is an object you can point at; Wells to 600, each node a vessel with the planner's real packing ceiling drawn on it; Load above that, one sorted row per node showing used versus reserved-but-idle. Any lens can be chosen explicitly and the choice persists.
How full a node is means the larger of its CPU and memory requests as a fraction of allocatable — the dimension that actually limits packing, and the same one the planner ranks on. Requests, not usage: Kubernetes schedules on what pods ask for, so a node at 50% requested is half unschedulable however idle its cores are.
The lenses also separate what is observed from what is predicted. Facts about the real cluster — a node cordoned, NotReady, drained and awaiting removal, or actually reclaimed — are worded on the node itself, apart from the plan's intent colouring. During a run the executor's own event trail marks cordons, evictions and drains as they genuinely happen, and evicted pods ghost until the next snapshot says where they truly landed.
Full index: docs/. The ones most people want first:
| Running it | Installation · CLI · Execution and safety · Auth · Observability |
| Understanding it | Architecture · Benchmarks |
| Working on it | Development · The GCP playground |
| History | Releases · Capabilities · Milestones · Findings |
Phases 1–3 are complete: analysis and explanation, authenticated on-request execution, and maintenance windows. Phase 4 — hardening toward 1000 nodes and 50,000 pods — is in progress, with metrics, CI and the release pipeline landed.
v0.10.0 is published — images, chart and CLI binaries on ghcr.io and the
releases page, installable by
the command above.
v0.10.0 is the release that stops waiting to be visited. The planner can tell you a plan exists — a small JSON body to an endpoint you supply, on transitions only, carrying counts and a link but never the plan itself. Findings now say where they hold capacity ("blocks three steps on your spot pool"), and History states what its bars have been saying rather than leaving you to read it off the chart.
Upgrading from v0.8.x? A plan is identified by its actions, so a planner that explained the same drain better produced the same id and the store kept the old wording — which made v0.8.1's rationale fix inert on any plan that already existed. v0.9.0 refreshes what a step says as well as what it is. Full notes in docs/releases.md.
Every release is verified end to end before it ships: CI stands up a five-node
k3d cluster with PodSecurity enforcing restricted, installs the chart behind a
real ingress controller on a named StorageClass, plans against real workloads
with real readiness probes, drains a node through the eviction API, asserts the
workload came back Ready, then deletes the node and confirms the reclamation was
observed. That is make e2e, on every pull request, against both
plan stores.
It has also run against real GKE. The reclamation loop was confirmed against Google's own cluster autoscaler — drain, then touch nothing, and watch it remove the node 11m9s later — and the planner has been watched producing a correct plan on a managed cluster. Three cloud runs, what each cost and what each found, are in docs/releases.md.
- No drain has ever been executed on a managed cluster. Cordon → evict → verify → reclaim is proven by CI and locally; on GKE the product has planned but never run. #225.
- This has not run against anyone's production cluster, only throwaway ones.
- Workload Identity and IRSA are half-tested — the chart's annotations render and are accepted, but k8s-dencer makes no cloud API calls, so there is no credential path to exercise.
Treat the read-only path as ready to try, and the executor as something to enable deliberately, on something you can afford to be wrong about.
History — release notes · capabilities · milestones and measurements · bugs, by how they hid
Found a vulnerability? Please report it privately through GitHub's private reporting rather than opening an issue — see SECURITY.md for scope and what to expect.
Issues and pull requests are welcome. make test and make lint are the gates,
and both run in CI on every pull request — the chart contract is strict on
purpose, since it encodes the security properties above.
MIT — Copyright (c) 2026 Moti Atedgi.
Use it, fork it, ship it commercially; keep the copyright notice. No warranty, which is worth reading literally for a tool that can evict pods: the safety rails here are real and tested, but you are the one accountable for what runs against your cluster.
