Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion charts/latest/templates/manager-deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{{- if or .Values.webhook.enforceEphemeral.enabled .Values.webhook.hyperconverged.enabled .Values.cleanup.pvCleanup.enabled }}
{{- if or .Values.webhook.enforceEphemeral.enabled .Values.webhook.hyperconverged.enabled .Values.cleanup.pvCleanup.enabled .Values.cleanup.capacityTemplate.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -61,6 +61,8 @@ spec:
- --kube-api-burst={{ .Values.scalability.manager.kubeApi.burst }}
- --v={{ .Values.observability.manager.log.level }}
- --enable-pv-cleanup={{ .Values.cleanup.pvCleanup.enabled }}
- --enable-capacity-template={{ .Values.cleanup.capacityTemplate.enabled }}
- --capacity-template-node-group-label={{ .Values.cleanup.capacityTemplate.nodeGroupLabelKey }}
{{- if .Values.webhook.enforceEphemeral.enabled }}
- --enforce-ephemeral-webhook-config={{ .Values.name }}-enforce-ephemeral
{{- end }}
Expand Down
20 changes: 17 additions & 3 deletions charts/latest/templates/manager-rbac.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{{- if or .Values.webhook.enforceEphemeral.enabled .Values.webhook.hyperconverged.enabled .Values.cleanup.pvCleanup.enabled }}
{{- if or .Values.webhook.enforceEphemeral.enabled .Values.webhook.hyperconverged.enabled .Values.cleanup.pvCleanup.enabled .Values.cleanup.capacityTemplate.enabled }}
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
Expand Down Expand Up @@ -40,7 +40,7 @@ rules:
- list
- watch
{{- end }}
{{- if or .Values.webhook.enforceEphemeral.enabled .Values.webhook.hyperconverged.enabled }}
{{- if or .Values.webhook.enforceEphemeral.enabled .Values.webhook.hyperconverged.enabled .Values.cleanup.capacityTemplate.enabled }}
- apiGroups:
- storage.k8s.io
resources:
Expand Down Expand Up @@ -70,7 +70,7 @@ rules:
- list
- watch
{{- end }}
{{- if or .Values.webhook.hyperconverged.enabled .Values.cleanup.pvCleanup.enabled }}
{{- if or .Values.webhook.hyperconverged.enabled .Values.cleanup.pvCleanup.enabled .Values.cleanup.capacityTemplate.enabled }}
- apiGroups:
- ""
resources:
Expand All @@ -80,6 +80,20 @@ rules:
- list
- watch
{{- end }}
{{- if .Values.cleanup.capacityTemplate.enabled }}
- apiGroups:
- storage.k8s.io
resources:
- csistoragecapacities
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
{{- end }}
- apiGroups:
- ""
resources:
Expand Down
21 changes: 21 additions & 0 deletions charts/latest/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,27 @@ cleanup:
# Enable the PV cleanup controller in the manager deployment
enabled: true

# Capacity-template controller configuration.
# When enabled, the manager publishes one CSIStorageCapacity per
# (StorageClass x node group) so that Cluster Autoscaler can observe
# non-zero capacity on its simulated template nodes and trigger scale-up.
# Each object's NodeTopology is constrained to template nodes only via the
# 'cluster-autoscaler.kubernetes.io/template-node=true' label that CA
# attaches to its simulated nodes (see kubernetes/autoscaler#9702), so
# these synthetic objects do not shadow real per-node capacity reported by
# the external-provisioner sidecar.
# StorageClasses opt in by setting the annotation
# 'localdisk.csi.acstor.io/template-capacity' to a resource quantity
# (e.g. '1800Gi').
capacityTemplate:
enabled: false
# Node label whose values define a node group. Defaults to the upstream
# instance-type label (i.e. the VM SKU), since local NVMe capacity is
# determined by the SKU. Other common choices:
# kubernetes.azure.com/agentpool - AKS node pool
# topology.kubernetes.io/zone - failure domain
nodeGroupLabelKey: node.kubernetes.io/instance-type

# Webhook configuration.
webhook:
enforceEphemeral:
Expand Down
27 changes: 26 additions & 1 deletion cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/webhook"

"local-csi-driver/internal/manager/capacitytemplate"
"local-csi-driver/internal/manager/pvcleanup"
"local-csi-driver/internal/pkg/events"
"local-csi-driver/internal/pkg/version"
Expand All @@ -49,7 +50,7 @@
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
}

func main() {

Check failure on line 53 in cmd/manager/main.go

View workflow job for this annotation

GitHub Actions / linter

cyclomatic complexity 32 of func `main` is high (> 30) (gocyclo)
var namespace string
var webhookSvcName string
var webhookPort int
Expand All @@ -71,6 +72,8 @@
var printVersionAndExit bool
var eventRecorderEnabled bool
var enablePVCleanup bool
var enableCapacityTemplate bool
var capacityTemplateNodeGroupLabel string

flag.StringVar(&namespace, "namespace", "default",
"The namespace to use for creating objects.")
Expand Down Expand Up @@ -109,6 +112,12 @@
"If enabled, the webhook will use the event recorder to record events. This is useful for debugging and monitoring purposes.")
flag.BoolVar(&enablePVCleanup, "enable-pv-cleanup", true,
"If enabled, the PV cleanup controller will be registered to clean up PVs when nodes are unavailable.")
flag.BoolVar(&enableCapacityTemplate, "enable-capacity-template", false,
"If enabled, publish one CSIStorageCapacity per (StorageClass x node group) so Cluster Autoscaler can observe non-zero capacity on simulated template nodes.")
flag.StringVar(&capacityTemplateNodeGroupLabel, "capacity-template-node-group-label", capacitytemplate.DefaultNodeGroupLabelKey,
"Node label key whose values define a node group for the capacity-template controller. "+
"Defaults to node.kubernetes.io/instance-type (VM SKU). Other useful values: "+
"kubernetes.azure.com/agentpool (AKS node pool), topology.kubernetes.io/zone.")

// Initialize logger flags config.
logConfig := textlogger.NewConfig(textlogger.VerbosityFlagName("v"))
Expand All @@ -127,7 +136,7 @@
log.Info("starting webhook server")

// Validate required flags
if enforceEphemeralWebhookConfig == "" && hyperconvergedWebhookConfig == "" && !enablePVCleanup {
if enforceEphemeralWebhookConfig == "" && hyperconvergedWebhookConfig == "" && !enablePVCleanup && !enableCapacityTemplate {
logAndExit(fmt.Errorf("at least one feature must be enabled"), "no webhooks or PV cleanup controller configured")
}

Expand Down Expand Up @@ -299,6 +308,22 @@
log.Info("PV cleanup controller disabled")
}

// Register capacity-template controller if enabled
if enableCapacityTemplate {
log.Info("registering capacity-template controller",
"nodeGroupLabelKey", capacityTemplateNodeGroupLabel)
if err := (&capacitytemplate.Reconciler{
Client: mgr.GetClient(),
Namespace: namespace,
NodeGroupLabelKey: capacityTemplateNodeGroupLabel,
}).SetupWithManager(mgr); err != nil {
logAndExit(err, "unable to register capacity-template controller")
}
log.Info("capacity-template controller registered successfully")
} else {
log.Info("capacity-template controller disabled")
}

// Register webhooks once certificates are ready
go func() {
<-certSetupFinished
Expand Down
148 changes: 148 additions & 0 deletions docs/design/capacity-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# local-csi-driver Capacity-Template Controller

## Scenario

The Kubernetes Cluster Autoscaler (CA) decides whether scaling up a node group
will help a pending pod by simulating scheduling onto a *template node*
synthesised from an existing node in that group. When a pod's PVC uses a CSI
driver whose `CSIStorageCapacity` objects are *node-specific* (e.g. selected by
`kubernetes.io/hostname`), no capacity object exists for the simulated template
node, the scheduler's storage-capacity check fails, and CA refuses to scale up
the group. This is upstream issue
[kubernetes/autoscaler#9700][autoscaler-9700].

The local-csi-driver runs the external-provisioner sidecar in node-local mode
(`--node-deployment`, with `Topology=true` and `--strict-topology`) so each
DaemonSet pod handles its own Provision/Delete requests. Capacity tracking is
a separate feature, enabled by `--enable-capacity` on the same sidecar; the
combination of strict per-node topology and capacity tracking causes each
DaemonSet pod to publish a `CSIStorageCapacity` keyed by hostname for
accurate per-node accounting. That accuracy is exactly what breaks CA's
simulation - the simulated template node has a synthetic hostname for which
no capacity object exists.

## Goals

- Allow CA to scale up node groups whose nodes use the local-csi-driver,
without giving up per-node capacity accuracy for live nodes.
- Work on any cluster (AKS, vanilla Kubernetes, Karpenter) and any grouping
dimension (VM SKU, AKS agent pool, zone) without code changes.
- Avoid touching `CSIStorageCapacity` objects published by the
external-provisioner sidecar.

## Non-goals

- Reporting accurate free capacity. The published value is an operator-supplied
*template* quantity, used only to satisfy CA's simulation.
- Replacing the external-provisioner's per-node capacity reporting for real
nodes.

## Design

### Mitigation in upstream Cluster Autoscaler

[kubernetes/autoscaler#9702][autoscaler-9702] adds the label
`cluster-autoscaler.kubernetes.io/template-node=true` to every template node
CA generates during scale-up simulation. CSI vendors can use this label in a
dedicated `CSIStorageCapacity.NodeTopology` selector that matches only
template nodes, leaving real-node capacity reporting untouched.

### Capacity-template controller

A controller in the `local-csi-manager` Deployment publishes one
`CSIStorageCapacity` per `(StorageClass x node group)` pair.

**Opt-in.** A `StorageClass` whose provisioner is `localdisk.csi.acstor.io`
opts in by setting the annotation:

```yaml
metadata:
annotations:
localdisk.csi.acstor.io/template-capacity: "1800Gi"
```

The value is parsed as a `resource.Quantity` and used as the published
capacity for every node group.

**Grouping.** Nodes are grouped by a configurable label (flag
`--capacity-template-node-group-label`). The default is
`node.kubernetes.io/instance-type` (VM SKU) because local NVMe capacity is a
property of the VM SKU, not of an arbitrary pool name, and the upstream
instance-type label is portable across cloud providers. Other useful values:

| Label | Use case |
| ---------------------------------- | ----------------------------------------- |
| `node.kubernetes.io/instance-type` | VM SKU (default; portable, matches NVMe) |
| `kubernetes.azure.com/agentpool` | AKS node pool (per-pool overrides) |
| `topology.kubernetes.io/zone` | Failure domain (zonal capacity skew) |

**Topology selector.** Each managed `CSIStorageCapacity.NodeTopology`
selects on **both** labels:

```yaml
nodeTopology:
matchLabels:
node.kubernetes.io/instance-type: Standard_L8s_v3
cluster-autoscaler.kubernetes.io/template-node: "true"
```

The `template-node=true` constraint means the object only matches CA's
simulated template nodes. Real nodes (which never carry that label) continue
to use the per-node objects published by the external-provisioner sidecar.

**Reconciliation.** The controller does a full sync on every event and on a
periodic 5-minute resync. On each reconcile it:

1. Lists `StorageClasses` with provisioner `localdisk.csi.acstor.io` and the
opt-in annotation.
2. Lists `Nodes` and collects the distinct values of the configured
group label.
3. For each `(class, group)` pair, creates or updates a `CSIStorageCapacity`
in the manager's namespace.
4. Garbage-collects managed objects whose `(class, group)` is no longer
desired. Managed objects are identified by the
`localdisk.csi.acstor.io/managed-by=capacitytemplate` label, so objects
published by the external-provisioner sidecar are never touched.

Each managed object also carries `localdisk.csi.acstor.io/storageclass` and
`localdisk.csi.acstor.io/node-group` labels for human inspection.

**Naming.** Objects are named `local-csi-template-<storageclass>-<group>`,
sanitised to DNS-1123 and truncated with a short hash if longer than 253
characters.

**Watches.** The reconciler watches:

- `StorageClass` (filtered by provisioner) - opt-in changes.
- `Node` - filtered to events that add, remove, or change the configured
group label.
- Managed `CSIStorageCapacity` - filtered to objects with the managed-by
label, so external changes trigger reconvergence.

### Disabled by default

The controller is opt-in via `--enable-capacity-template` (Helm value
`cleanup.capacityTemplate.enabled`), since clusters that do not use Cluster
Autoscaler do not need it.

## Limitations

- The published capacity is a single per-StorageClass value applied to every
group. If two groups using the same grouping label value need different
template capacities, switch the grouping label to a finer-grained one
(e.g. agent pool instead of SKU).
- The mitigation requires CA at the version that adds the
`template-node=true` label (kubernetes/autoscaler#9702). Older CAs will
not match these capacity objects, but they will also not be harmed by them.

## References

- [kubernetes/autoscaler#9700][autoscaler-9700] - scale-up regression with
node-specific `CSIStorageCapacity`.
- [kubernetes/autoscaler#9702][autoscaler-9702] - template-node label
mitigation.
- [Kubernetes storage capacity tracking][k8s-csc].

[autoscaler-9700]: https://github.com/kubernetes/autoscaler/issues/9700
[autoscaler-9702]: https://github.com/kubernetes/autoscaler/pull/9702
[k8s-csc]: https://kubernetes.io/docs/concepts/storage/storage-capacity/
2 changes: 1 addition & 1 deletion docs/design/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ spec:
required:
nodeSelectorTerms:
- matchExpressions:
- key: topology.localdisk.csi.acstor.io/node
- key: kubernetes.io/hostname
operator: In
values:
- nvme-node-0
Expand Down
17 changes: 9 additions & 8 deletions internal/csi/core/lvm/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/container-storage-interface/spec/lib/go/csi"
"go.uber.org/mock/gomock"
corev1 "k8s.io/api/core/v1"

"local-csi-driver/internal/csi/core/lvm"
"local-csi-driver/internal/pkg/block"
Expand Down Expand Up @@ -58,7 +59,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -94,7 +95,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -127,7 +128,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -160,7 +161,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -194,7 +195,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -247,7 +248,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -282,7 +283,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down Expand Up @@ -318,7 +319,7 @@ func TestLVM_Create(t *testing.T) {
AccessibleTopology: []*csi.Topology{
{
Segments: map[string]string{
"topology.localdisk.csi.acstor.io/node": "nodename",
corev1.LabelHostname: "nodename",
},
},
},
Expand Down
Loading
Loading