feat: add label-based sharding for horizontal scaling - #288
Open
aashishtomar wants to merge 3 commits into
Open
Conversation
Currently the controller only supports active/passive HA via leader election — a single replica reconciles all Workspaces regardless of replica count. For deployments with hundreds of Workspaces, this becomes a throughput bottleneck since each reconciliation runs terraform init/plan/apply. This change introduces a --shard-name flag (also configurable via SHARD_NAME env var) that partitions Workspaces across controller instances using the label terraform.crossplane.io/shard=<name>. Changes: cmd/provider/main.go: - Add --shard-name flag with SHARD_NAME env var - Move scheme registration before manager creation so cache.ByObject can resolve types at startup - Configure cache.ByObject with a label selector on both cluster-scoped and namespaced Workspace types when shard name is set - Append shard name to leader election lease ID for per-shard leaders internal/controller/gc/gc.go: - Accept shardName parameter and pass it to the GarbageCollector for logging context internal/workdir/workdir.go: - Add ShardLabel constant for terraform.crossplane.io/shard - Add shardName field and WithShardName option to GarbageCollector - GC intentionally lists ALL workspaces without shard filtering to avoid cross-shard directory deletion internal/workdir/workdir_test.go: - Add TestCollectWithShardName with two table-driven cases: ShardedGCListsAllWorkspaces verifies the GC deletes only directories for workspaces that no longer exist in any shard. ShardedGCPreservesOtherShardDirs verifies the GC does not delete directories belonging to other shards' workspaces. - Add TestWithShardName to verify the option function - Add TestShardLabel to verify the constant value When --shard-name is empty (default), behavior is identical to the existing single-controller mode for full backward compatibility. Fixes crossplane-contrib#269 Signed-off-by: Aashish Tomar <26236748+aashishtomar@users.noreply.github.com>
Address review feedback on PR crossplane-contrib#270. The GC was using mgr.GetClient() which reads through the manager's cache. In sharded mode the cache is filtered by shard label via cache.ByObject, so the GC would only see its own shard's workspaces and could delete directories belonging to other shards — a data loss bug. Switching to mgr.GetAPIReader() bypasses the cache entirely and lists all workspaces directly from the API server. Changes: - workdir.go: Change GarbageCollector.kube from client.Client to client.Reader. Update NewGarbageCollector signature accordingly. Fix comments to describe the actual caching behavior. - gc.go: Pass mgr.GetAPIReader() instead of mgr.GetClient() to the GarbageCollector. Update Setup() comments. - main.go: Use workdir.ShardLabel constant instead of duplicating the string literal. Add workdir import. - workdir_test.go: Update test field types from client.Client to client.Reader to match the new signature. Signed-off-by: Aashish Tomar <26236748+aashishtomar@users.noreply.github.com>
Fix goimports-detected import ordering in main.go (features before workdir). Add startup warning for operators about unlabeled workspaces being skipped when running in sharded mode, advising them to either label all workspaces or run an unsharded catch-all instance. Signed-off-by: Aashish Tomar <26236748+aashishtomar@users.noreply.github.com>
aashishtomar
requested review from
bobh66,
erhancagirici,
negz,
sergenyalcin,
turkenf,
ulucinar and
ytsarev
as code owners
July 24, 2026 14:59
|
please we need this! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Currently the provider-terraform controller only supports active/passive HA via leader election — a single replica reconciles all Workspaces regardless of replica count. For deployments with hundreds of Workspaces, this becomes a throughput bottleneck since each reconciliation runs
terraform init/plan/apply, which can take seconds to minutes per workspace.This PR introduces a
--shard-nameflag (also configurable viaSHARD_NAMEenv var) that partitions Workspaces across controller instances using the labelterraform.crossplane.io/shard=<name>. Each shard controller only watches and reconciles workspaces matching its label, enabling true horizontal scaling.Related issues: #212, #269
How it works
Label-based partitioning
Operators label Workspaces with
terraform.crossplane.io/shard=<name>and deploy multiple controller instances, each started with--shard-name=<name>. Each instance configurescache.ByObjectwith a label selector so the controller-runtime informer only watches matching Workspace resources. Workspaces without the label are not reconciled by any shard — operators should either label all workspaces or run an unsharded catch-all instance.Per-shard leader election
Each shard appends its name to the leader election lease ID (e.g.,
crossplane-leader-election-provider-terraform-shard-0), allowing multiple shards to be active simultaneously. Without this, all shards would compete for the same lease and only one could be active.Garbage collection safety
The GC uses
mgr.GetAPIReader()(uncached) instead ofmgr.GetClient()(cached) to list workspaces. This is critical: in sharded mode, the manager's cache is filtered by shard label, so using the cached client would only return the current shard's workspaces. The GC would then consider other shards' working directories as orphaned and delete them — a data loss bug. By using the uncached API reader, the GC sees all workspaces across all shards and only deletes directories for truly deleted workspaces.The
GarbageCollector.kubefield type was changed fromclient.Clienttoclient.Readerto enforce at compile time that only an uncached reader is passed.Scheme registration order
All scheme registrations were moved before
ctrl.NewManager()creation. This is required becausecache.ByObjectneeds the types registered in the scheme at manager startup to determine whether resources are cluster-scoped or namespaced.Backward compatibility
When
--shard-nameis not set (the default), behavior is identical to the existing single-controller mode:if *shardName != "") are skippedByObjectfilter (watches all resources)crossplane-leader-election-provider-terraformChanges
cmd/provider/main.go--shard-nameflag withSHARD_NAMEenv var, default emptycache.ByObjectcompatibilitycache.ByObjectwith label selector on both cluster-scoped and namespaced Workspace types when shard name is setinternal/controller/gc/gc.goshardNameparameter inSetup()mgr.GetAPIReader()instead ofmgr.GetClient()to the GarbageCollectorWithShardName()option for logging contextinternal/workdir/workdir.goShardLabelconstant (terraform.crossplane.io/shard)GarbageCollector.kubefromclient.Clienttoclient.ReadershardNamefield andWithShardName()optioninternal/workdir/workdir_test.goclient.Clienttoclient.ReaderTestCollectWithShardNamewith 2 table-driven casesTestWithShardName(option pattern)TestShardLabel(constant value)Deployment model
To deploy 3 shards, create 3 Deployments with the same image, each with a different
--shard-name:Label workspaces to assign them:
Testing
Unit tests (13 pass, 4 new)
All tests use the existing table-driven
map[string]struct{reason, fields, args, want}pattern withcmp.Diffandtest.EquateErrors().TestCollectWithShardName/ShardedGCListsAllWorkspacesTestCollectWithShardName/ShardedGCPreservesOtherShardDirsTestWithShardNameWithShardName()option correctly sets the field.TestShardLabelterraform.crossplane.io/shard.TestCollect/*casesStatic analysis
go vet ./...— cleangofmt— cleango build ./...— compilesgo test ./...— all 4 test packages passmake reviewable— passesEnd-to-end load test (Kind cluster, 3 nodes, 31 workspaces)
Tested on a Kind cluster with Crossplane 2.2.0 and 3 sharded controller deployments.
time_sleepto simulate real apply timeOperational notes