Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Placement Group Support #5307

Closed
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
1 change: 1 addition & 0 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func main() {
op.InstanceProvider,
op.PricingProvider,
op.AMIProvider,
op.PlacementGroupProvider,
)...).
WithWebhooks(ctx, webhooks.NewWebhooks()...).
Start(ctx)
Expand Down
11 changes: 11 additions & 0 deletions pkg/apis/v1beta1/ec2nodeclass.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ type EC2NodeClassSpec struct {
// +kubebuilder:default={"httpEndpoint":"enabled","httpProtocolIPv6":"disabled","httpPutResponseHopLimit":2,"httpTokens":"required"}
// +optional
MetadataOptions *MetadataOptions `json:"metadataOptions,omitempty"`
// PlacementGroupSelectorTerms is a list of PlacementGroupSelector. The terms are ORed.
// +optional
PlacementGroupSelectorTerms []PlacementGroupSelectorTerm `json:"placementGroupSelectorTerms,omitempty" hash:"ignore"`
// Context is a Reserved field in EC2 APIs
// https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateFleet.html
// +optional
Expand Down Expand Up @@ -167,6 +170,14 @@ type AMISelectorTerm struct {
Owner string `json:"owner,omitempty"`
}

// PlacementGroupSelectorTerm defines the selection logic for ec2 placement groups
// that are used to launch nodes. If multiple fields are used for selection, the requirements are ANDed
type PlacementGroupSelectorTerm struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a question here of how we should pass requirements down when using something like cluster placement groups OR when using something like spread with rack.

When using the spread with rack, the EC2 documentation mentions that you can't launch more than 7 instances in a single AZ (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/placement-groups.html#:~:text=A%20rack%20spread%20placement%20group%20supports%20a%20maximum%20of%20seven%20running%20instances%20per%20Availability%20Zone), which means that we are restricted with our node limits when using this type of placement group. One option here is that we could constrain our requirements when using this type of placement group so that when we run out of instances to launch in a single AZ, we just get rid of that AZ from our requirements and start launching in other AZs. Another option here is that we allow to select on multiple placement groups and if we run out of space with one of the spread placement groups we just move to the other one.

This handle differently, though, when we are dealing with cluster or partition placement groups where there is no limit on the number of instances that can be launched and it makes less sense to allow for multiple placement groups to be selected on.

Based on all of these considerations, I think we should think about writing up a small design that considers the use-cases around placement groups, how they meld with requirements and what the API surface should look like.

// Name of the placement group to be selected
// +optional
Name string `json:"name,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that it's possible to also use tags here as well. I wonder if we also support tagging initially similar to the other selectors

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing that's nice about tagging and naming is that it works consistently across accounts, so if you have similar set-ups for different accounts, everything just works out-of-the-box.

This is the general philosophy that Karpenter has taken towards selectors in the project, ensuring that we can move Karpenter from one account to another or from one region to another and everything "just works."

The trade-off of opening up this selector to tags is that now multiple placement groups can be returned, so how do you pick between them? There probably needs to be a consistent ordering and the selection between them shouldn't be random, particularly when it comes to the interaction of this feature with the drift feature.

}

// MetadataOptions contains parameters for specifying the exposure of the
// Instance Metadata Service to provisioned EC2 nodes.
type MetadataOptions struct {
Expand Down
3 changes: 3 additions & 0 deletions pkg/apis/v1beta1/ec2nodeclass_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ type EC2NodeClassStatus struct {
// cluster under the AMI selectors.
// +optional
AMIs []AMI `json:"amis,omitempty"`
// PlacementGroups contains the ec2 placement group arns
// +optional
PlacementGroups []string `json:"placementGroups,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we resolve the strategy into the status block here? If the placement group type is partition, should we consider putting the number of partitions that we are using into the status as well?

// InstanceProfile contains the resolved instance profile for the role
// +optional
InstanceProfile string `json:"instanceProfile,omitempty"`
Expand Down
5 changes: 3 additions & 2 deletions pkg/controllers/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/aws/karpenter-provider-aws/pkg/providers/amifamily"
"github.com/aws/karpenter-provider-aws/pkg/providers/instance"
"github.com/aws/karpenter-provider-aws/pkg/providers/instanceprofile"
"github.com/aws/karpenter-provider-aws/pkg/providers/placementgroup"
"github.com/aws/karpenter-provider-aws/pkg/providers/pricing"
"github.com/aws/karpenter-provider-aws/pkg/providers/securitygroup"
"github.com/aws/karpenter-provider-aws/pkg/providers/sqs"
Expand All @@ -46,10 +47,10 @@ import (
func NewControllers(ctx context.Context, sess *session.Session, clk clock.Clock, kubeClient client.Client, recorder events.Recorder,
unavailableOfferings *cache.UnavailableOfferings, cloudProvider *cloudprovider.CloudProvider, subnetProvider *subnet.Provider,
securityGroupProvider *securitygroup.Provider, instanceProfileProvider *instanceprofile.Provider, instanceProvider *instance.Provider,
pricingProvider *pricing.Provider, amiProvider *amifamily.Provider) []controller.Controller {
pricingProvider *pricing.Provider, amiProvider *amifamily.Provider, placementGroupProvider *placementgroup.Provider) []controller.Controller {

controllers := []controller.Controller{
nodeclass.NewNodeClassController(kubeClient, recorder, subnetProvider, securityGroupProvider, amiProvider, instanceProfileProvider),
nodeclass.NewNodeClassController(kubeClient, recorder, subnetProvider, securityGroupProvider, amiProvider, instanceProfileProvider, placementGroupProvider),
nodeclaimgarbagecollection.NewController(kubeClient, cloudProvider),
nodeclaimtagging.NewController(kubeClient, instanceProvider),
}
Expand Down
21 changes: 18 additions & 3 deletions pkg/controllers/nodeclass/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import (
"github.com/aws/karpenter-provider-aws/pkg/apis/v1beta1"
"github.com/aws/karpenter-provider-aws/pkg/providers/amifamily"
"github.com/aws/karpenter-provider-aws/pkg/providers/instanceprofile"
"github.com/aws/karpenter-provider-aws/pkg/providers/placementgroup"
"github.com/aws/karpenter-provider-aws/pkg/providers/securitygroup"
"github.com/aws/karpenter-provider-aws/pkg/providers/subnet"
)
Expand All @@ -56,17 +57,19 @@ type Controller struct {
securityGroupProvider *securitygroup.Provider
amiProvider *amifamily.Provider
instanceProfileProvider *instanceprofile.Provider
placementGroupProvider *placementgroup.Provider
}

func NewController(kubeClient client.Client, recorder events.Recorder, subnetProvider *subnet.Provider, securityGroupProvider *securitygroup.Provider,
amiProvider *amifamily.Provider, instanceProfileProvider *instanceprofile.Provider) *Controller {
amiProvider *amifamily.Provider, instanceProfileProvider *instanceprofile.Provider, placementGroupProvider *placementgroup.Provider) *Controller {
return &Controller{
kubeClient: kubeClient,
recorder: recorder,
subnetProvider: subnetProvider,
securityGroupProvider: securityGroupProvider,
amiProvider: amiProvider,
instanceProfileProvider: instanceProfileProvider,
placementGroupProvider: placementGroupProvider,
}
}

Expand All @@ -79,6 +82,7 @@ func (c *Controller) Reconcile(ctx context.Context, nodeClass *v1beta1.EC2NodeCl
c.resolveSecurityGroups(ctx, nodeClass),
c.resolveAMIs(ctx, nodeClass),
c.resolveInstanceProfile(ctx, nodeClass),
c.resolvePlacementGroups(ctx, nodeClass),
)
if !equality.Semantic.DeepEqual(stored, nodeClass) {
statusCopy := nodeClass.DeepCopy()
Expand Down Expand Up @@ -194,6 +198,17 @@ func (c *Controller) resolveAMIs(ctx context.Context, nodeClass *v1beta1.EC2Node
return nil
}

func (c *Controller) resolvePlacementGroups(ctx context.Context, nodeClass *v1beta1.EC2NodeClass) error {
result, err := c.placementGroupProvider.Get(ctx, nodeClass)
if err != nil {
return err
}
if result != nil {
nodeClass.Status.PlacementGroups = append(nodeClass.Status.PlacementGroups, *result.GroupArn)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is relevant here, but its instructions for proceeding are unclear

https://github.com/aws/karpenter-provider-aws/pull/4553/files#r1405582171

}
return nil
}

func (c *Controller) resolveInstanceProfile(ctx context.Context, nodeClass *v1beta1.EC2NodeClass) error {
if nodeClass.Spec.Role != "" {
name, err := c.instanceProfileProvider.Create(ctx, nodeClass)
Expand All @@ -215,9 +230,9 @@ type NodeClassController struct {
}

func NewNodeClassController(kubeClient client.Client, recorder events.Recorder, subnetProvider *subnet.Provider, securityGroupProvider *securitygroup.Provider,
amiProvider *amifamily.Provider, instanceProfileProvider *instanceprofile.Provider) corecontroller.Controller {
amiProvider *amifamily.Provider, instanceProfileProvider *instanceprofile.Provider, placementProvider *placementgroup.Provider) corecontroller.Controller {
return corecontroller.Typed[*v1beta1.EC2NodeClass](kubeClient, &NodeClassController{
Controller: NewController(kubeClient, recorder, subnetProvider, securityGroupProvider, amiProvider, instanceProfileProvider),
Controller: NewController(kubeClient, recorder, subnetProvider, securityGroupProvider, amiProvider, instanceProfileProvider, placementProvider),
})
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/controllers/nodeclass/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ var _ = BeforeSuite(func() {
ctx = options.ToContext(ctx, test.Options())
awsEnv = test.NewEnvironment(ctx, env)

nodeClassController = nodeclass.NewNodeClassController(env.Client, events.NewRecorder(&record.FakeRecorder{}), awsEnv.SubnetProvider, awsEnv.SecurityGroupProvider, awsEnv.AMIProvider, awsEnv.InstanceProfileProvider)
nodeClassController = nodeclass.NewNodeClassController(env.Client, events.NewRecorder(&record.FakeRecorder{}), awsEnv.SubnetProvider, awsEnv.SecurityGroupProvider, awsEnv.AMIProvider, awsEnv.InstanceProfileProvider, awsEnv.PlacementGroupProvider)
})

var _ = AfterSuite(func() {
Expand Down
14 changes: 14 additions & 0 deletions pkg/fake/ec2api.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type EC2Behavior struct {
DescribeAvailabilityZonesOutput AtomicPtr[ec2.DescribeAvailabilityZonesOutput]
DescribeSpotPriceHistoryInput AtomicPtr[ec2.DescribeSpotPriceHistoryInput]
DescribeSpotPriceHistoryOutput AtomicPtr[ec2.DescribeSpotPriceHistoryOutput]
DescribePlacementGroupsOutput AtomicPtr[ec2.DescribePlacementGroupsOutput]
CreateFleetBehavior MockedFunction[ec2.CreateFleetInput, ec2.CreateFleetOutput]
TerminateInstancesBehavior MockedFunction[ec2.TerminateInstancesInput, ec2.TerminateInstancesOutput]
DescribeInstancesBehavior MockedFunction[ec2.DescribeInstancesInput, ec2.DescribeInstancesOutput]
Expand Down Expand Up @@ -88,6 +89,7 @@ func (e *EC2API) Reset() {
e.DescribeInstanceTypesOutput.Reset()
e.DescribeInstanceTypeOfferingsOutput.Reset()
e.DescribeAvailabilityZonesOutput.Reset()
e.DescribePlacementGroupsOutput.Reset()
e.CreateFleetBehavior.Reset()
e.TerminateInstancesBehavior.Reset()
e.DescribeInstancesBehavior.Reset()
Expand Down Expand Up @@ -644,3 +646,15 @@ func (e *EC2API) DescribeSpotPriceHistoryPagesWithContext(ctx aws.Context, input
fn(out, false)
return nil
}

func (e *EC2API) DescribePlacementGroupsWithContext(_ aws.Context, _ *ec2.DescribePlacementGroupsInput, _ ...request.Option) (*ec2.DescribePlacementGroupsOutput, error) {
if !e.NextError.IsNil() {
defer e.NextError.Reset()
return nil, e.NextError.Get()
}
if !e.DescribePlacementGroupsOutput.IsNil() {
return e.DescribePlacementGroupsOutput.Clone(), nil
}
return nil, errors.New("no placement groups data provided")

}
6 changes: 5 additions & 1 deletion pkg/operator/operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import (
"github.com/aws/karpenter-provider-aws/pkg/providers/instanceprofile"
"github.com/aws/karpenter-provider-aws/pkg/providers/instancetype"
"github.com/aws/karpenter-provider-aws/pkg/providers/launchtemplate"
"github.com/aws/karpenter-provider-aws/pkg/providers/placementgroup"
"github.com/aws/karpenter-provider-aws/pkg/providers/pricing"
"github.com/aws/karpenter-provider-aws/pkg/providers/securitygroup"
"github.com/aws/karpenter-provider-aws/pkg/providers/subnet"
Expand Down Expand Up @@ -87,6 +88,7 @@ type Operator struct {
VersionProvider *version.Provider
InstanceTypesProvider *instancetype.Provider
InstanceProvider *instance.Provider
PlacementGroupProvider *placementgroup.Provider
}

func NewOperator(ctx context.Context, operator *operator.Operator) (context.Context, *Operator) {
Expand Down Expand Up @@ -144,7 +146,8 @@ func NewOperator(ctx context.Context, operator *operator.Operator) (context.Cont
)
versionProvider := version.NewProvider(operator.KubernetesInterface, cache.New(awscache.DefaultTTL, awscache.DefaultCleanupInterval))
amiProvider := amifamily.NewProvider(versionProvider, ssm.New(sess), ec2api, cache.New(awscache.DefaultTTL, awscache.DefaultCleanupInterval))
amiResolver := amifamily.New(amiProvider)
placementGroupProvider := placementgroup.NewProvider(ec2api, cache.New(awscache.DefaultTTL, awscache.DefaultCleanupInterval))
amiResolver := amifamily.New(amiProvider, placementGroupProvider)
launchTemplateProvider := launchtemplate.NewProvider(
ctx,
cache.New(awscache.DefaultTTL, awscache.DefaultCleanupInterval),
Expand Down Expand Up @@ -199,6 +202,7 @@ func NewOperator(ctx context.Context, operator *operator.Operator) (context.Cont
PricingProvider: pricingProvider,
InstanceTypesProvider: instanceTypeProvider,
InstanceProvider: instanceProvider,
PlacementGroupProvider: placementGroupProvider,
}
}

Expand Down
30 changes: 27 additions & 3 deletions pkg/providers/amifamily/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

"github.com/aws/karpenter-provider-aws/pkg/apis/v1beta1"
"github.com/aws/karpenter-provider-aws/pkg/providers/amifamily/bootstrap"
"github.com/aws/karpenter-provider-aws/pkg/providers/placementgroup"

"sigs.k8s.io/karpenter/pkg/cloudprovider"
"sigs.k8s.io/karpenter/pkg/scheduling"
Expand All @@ -43,7 +44,8 @@ var DefaultEBS = v1beta1.BlockDevice{

// Resolver is able to fill-in dynamic launch template parameters
type Resolver struct {
amiProvider *Provider
amiProvider *Provider
placementGroupProvider *placementgroup.Provider
}

// Options define the static launch template parameters
Expand All @@ -70,6 +72,12 @@ type LaunchTemplate struct {
InstanceTypes []*cloudprovider.InstanceType `hash:"ignore"`
DetailedMonitoring bool
EFACount int
Placement *Placement
}

// Placement holds the dynamically generated launch template placement parameters
type Placement struct {
PlacementGroup string
}

// AMIFamily can be implemented to override the default logic for generating dynamic launch template parameters
Expand Down Expand Up @@ -108,9 +116,10 @@ func (d DefaultFamily) FeatureFlags() FeatureFlags {
}

// New constructs a new launch template Resolver
func New(amiProvider *Provider) *Resolver {
func New(amiProvider *Provider, placementGroupProvider *placementgroup.Provider) *Resolver {
return &Resolver{
amiProvider: amiProvider,
amiProvider: amiProvider,
placementGroupProvider: placementGroupProvider,
}
}

Expand Down Expand Up @@ -230,6 +239,7 @@ func (r Resolver) resolveLaunchTemplate(nodeClass *v1beta1.EC2NodeClass, nodeCla
DetailedMonitoring: aws.BoolValue(nodeClass.Spec.DetailedMonitoring),
AMIID: amiID,
InstanceTypes: instanceTypes,
Placement: r.resolvePlacement(nodeClass),
EFACount: efaCount,
}
if len(resolved.BlockDeviceMappings) == 0 {
Expand All @@ -240,3 +250,17 @@ func (r Resolver) resolveLaunchTemplate(nodeClass *v1beta1.EC2NodeClass, nodeCla
}
return resolved, nil
}

// TODO: Must be resolved: https://github.com/aws/karpenter-provider-aws/pull/4553/files#r1405582171
func (r Resolver) resolvePlacement(nodeClass *v1beta1.EC2NodeClass) *Placement {
var placement *Placement
pg := nodeClass.Status.PlacementGroups

if pg != nil {
placement = &Placement{}
if len(pg) > 0 {
placement.PlacementGroup = pg[0]
}
}
return placement
}
88 changes: 88 additions & 0 deletions pkg/providers/placementgroup/placementgroup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package placementgroup

import (
"context"
"fmt"
"sync"

"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/mitchellh/hashstructure/v2"
"github.com/patrickmn/go-cache"

"github.com/aws/karpenter-provider-aws/pkg/apis/v1beta1"

"knative.dev/pkg/logging"

"sigs.k8s.io/karpenter/pkg/utils/pretty"
)

type Provider struct {
sync.RWMutex
ec2api ec2iface.EC2API
cache *cache.Cache
cm *pretty.ChangeMonitor
}

func NewProvider(ec2api ec2iface.EC2API, cache *cache.Cache) *Provider {
return &Provider{
ec2api: ec2api,
cm: pretty.NewChangeMonitor(),
// TODO: Remove cache for v1beta1, utilize resolved subnet from the AWSNodeTemplate.status
// Subnets are sorted on AvailableIpAddressCount, descending order
cache: cache,
}
}

func (p *Provider) Get(ctx context.Context, nodeClass *v1beta1.EC2NodeClass) (*ec2.PlacementGroup, error) {
p.Lock()
defer p.Unlock()

// Get selectors from the nodeClass, exit if no selectors defined
selectors := nodeClass.Spec.PlacementGroupSelectorTerms
if selectors == nil {
return nil, nil
}

// Look for a cached result
hash, err := hashstructure.Hash(selectors, hashstructure.FormatV2, &hashstructure.HashOptions{SlicesAsSets: true})
if err != nil {
return nil, err
}
if cached, ok := p.cache.Get(fmt.Sprint(hash)); ok {
return cached.(*ec2.PlacementGroup), nil
}

var match *ec2.PlacementGroup
// Look up all ec2 placement groups
output, err := p.ec2api.DescribePlacementGroupsWithContext(ctx, &ec2.DescribePlacementGroupsInput{})
if err != nil {
logging.FromContext(ctx).Errorf("discovering placement groups, %w", err)
return nil, err
}
for i := range output.PlacementGroups {
// filter results to only include those that match at least 1 selector
for x := range selectors {
if *output.PlacementGroups[i].GroupName == selectors[x].Name {
match = output.PlacementGroups[i]
p.cache.SetDefault(fmt.Sprint(hash), match)
break
}
}
}
return match, nil
}
Loading
Loading