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

✨ Add Spot instances support #1868

Merged
merged 2 commits into from
Aug 14, 2020
Merged
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: 4 additions & 0 deletions api/v1alpha2/awsmachine_conversion.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ func restoreAWSMachineSpec(restored, dst *infrav1alpha3.AWSMachineSpec) {

// manual conversion for UncompressedUserData
dst.UncompressedUserData = restored.UncompressedUserData

if restored.SpotMarketOptions != nil {
dst.SpotMarketOptions = restored.SpotMarketOptions.DeepCopy()
}
}

// ConvertFrom converts from the Hub version (v1alpha3) to this version.
Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha2/zz_generated.conversion.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions api/v1alpha3/awsmachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ type AWSMachineSpec struct {
// CloudInit is used.
// +optional
CloudInit CloudInit `json:"cloudInit,omitempty"`

// SpotMarketOptions allows users to configure instances to be run using AWS Spot instances.
// +optional
SpotMarketOptions *SpotMarketOptions `json:"spotMarketOptions,omitempty"`
}

// CloudInit defines options related to the bootstrapping systems where
Expand Down
13 changes: 13 additions & 0 deletions api/v1alpha3/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,9 @@ type Instance struct {

// Availability zone of instance
AvailabilityZone string `json:"availabilityZone,omitempty"`

// SpotMarketOptions option for configuring instances to be run using AWS Spot instances.
SpotMarketOptions *SpotMarketOptions `json:"spotMarketOptions,omitempty"`
}

// RootVolume encapsulates the configuration options for the root volume
Expand Down Expand Up @@ -653,3 +656,13 @@ type RootVolume struct {
// +optional
EncryptionKey string `json:"encryptionKey,omitempty"`
}

// SpotMarketOptions defines the options available to a user when configuring
// Machines to run on Spot instances.
// Most users should provide an empty struct.
type SpotMarketOptions struct {
// MaxPrice defines the maximum price the user is willing to pay for Spot VM instances
// +optional
// +kubebuilder:validation:Type=number
MaxPrice *string `json:"maxPrice,omitempty"`
}
30 changes: 30 additions & 0 deletions api/v1alpha3/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,13 @@ spec:
items:
type: string
type: array
spotMarketOptions:
description: SpotMarketOptions option for configuring instances to be run using AWS Spot instances.
properties:
maxPrice:
description: MaxPrice defines the maximum price the user is willing to pay for Spot VM instances
type: number
type: object
sshKeyName:
description: The name of the SSH key pair.
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,13 @@ spec:
required:
- size
type: object
spotMarketOptions:
description: SpotMarketOptions allows users to configure instances to be run using AWS Spot instances.
properties:
maxPrice:
description: MaxPrice defines the maximum price the user is willing to pay for Spot VM instances
type: number
type: object
sshKeyName:
description: SSHKeyName is the name of the ssh key to attach to the instance. Valid values are empty string (do not use SSH keys), a valid SSH key name, or omitted (use the default SSH key name)
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,13 @@ spec:
required:
- size
type: object
spotMarketOptions:
description: SpotMarketOptions allows users to configure instances to be run using AWS Spot instances.
properties:
maxPrice:
description: MaxPrice defines the maximum price the user is willing to pay for Spot VM instances
type: number
type: object
sshKeyName:
description: SSHKeyName is the name of the ssh key to attach to the instance. Valid values are empty string (do not use SSH keys), a valid SSH key name, or omitted (use the default SSH key name)
type: string
Expand Down
34 changes: 34 additions & 0 deletions pkg/cloud/services/ec2/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ func (s *Service) CreateInstance(scope *scope.MachineScope, userData []byte) (*i
}
}

input.SpotMarketOptions = scope.AWSMachine.Spec.SpotMarketOptions

s.scope.V(2).Info("Running instance", "machine-role", scope.Role())
out, err := s.runInstance(scope.Role(), input)
if err != nil {
Expand Down Expand Up @@ -449,6 +451,8 @@ func (s *Service) runInstance(role string, i *infrav1.Instance) (*infrav1.Instan
input.TagSpecifications = append(input.TagSpecifications, spec)
}

input.InstanceMarketOptions = getInstanceMarketOptionsRequest(i.SpotMarketOptions)

out, err := s.EC2Client.RunInstances(input)
if err != nil {
return nil, errors.Wrap(err, "failed to run instance")
Expand Down Expand Up @@ -790,3 +794,33 @@ func containsGroup(list []string, strToSearch string) bool {
}
return false
}

func getInstanceMarketOptionsRequest(spotMarketOptions *infrav1.SpotMarketOptions) *ec2.InstanceMarketOptionsRequest {
if spotMarketOptions == nil {
// Instance is not a Spot instance
return nil
}

// Set required values for Spot instances
spotOptions := &ec2.SpotMarketOptions{}

// The following two options ensure that:
// - If an instance is interrupted, it is terminated rather than hibernating or stopping
// - No replacement instance will be created if the instance is interrupted
// - If the spot request cannot immediately be fulfilled, it will not be created
// This behaviour should satisfy the 1:1 mapping of Machines to Instances as
// assumed by the Cluster API.
spotOptions.SetInstanceInterruptionBehavior(ec2.InstanceInterruptionBehaviorTerminate)
spotOptions.SetSpotInstanceType(ec2.SpotInstanceTypeOneTime)

maxPrice := spotMarketOptions.MaxPrice
if maxPrice != nil && *maxPrice != "" {
spotOptions.SetMaxPrice(*maxPrice)
}

instanceMarketOptionsRequest := &ec2.InstanceMarketOptionsRequest{}
instanceMarketOptionsRequest.SetMarketType(ec2.MarketTypeSpot)
instanceMarketOptionsRequest.SetSpotOptions(spotOptions)

return instanceMarketOptionsRequest
}
62 changes: 62 additions & 0 deletions pkg/cloud/services/ec2/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package ec2

import (
"reflect"
"testing"

"github.com/aws/aws-sdk-go/aws"
Expand Down Expand Up @@ -1078,6 +1079,67 @@ func TestCreateInstance(t *testing.T) {
}
}

func TestGetInstanceMarketOptionsRequest(t *testing.T) {
testCases := []struct {
name string
spotMarketOptions *infrav1.SpotMarketOptions
expectedRequest *ec2.InstanceMarketOptionsRequest
}{
{
name: "with no Spot options specified",
spotMarketOptions: nil,
expectedRequest: nil,
},
{
name: "with an empty Spot options specified",
spotMarketOptions: &infrav1.SpotMarketOptions{},
expectedRequest: &ec2.InstanceMarketOptionsRequest{
MarketType: aws.String(ec2.MarketTypeSpot),
SpotOptions: &ec2.SpotMarketOptions{
InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate),
SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime),
},
},
},
{
name: "with an empty MaxPrice specified",
spotMarketOptions: &infrav1.SpotMarketOptions{
MaxPrice: aws.String(""),
},
expectedRequest: &ec2.InstanceMarketOptionsRequest{
MarketType: aws.String(ec2.MarketTypeSpot),
SpotOptions: &ec2.SpotMarketOptions{
InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate),
SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime),
},
},
},
{
name: "with a valid MaxPrice specified",
spotMarketOptions: &infrav1.SpotMarketOptions{
MaxPrice: aws.String("0.01"),
},
expectedRequest: &ec2.InstanceMarketOptionsRequest{
MarketType: aws.String(ec2.MarketTypeSpot),
SpotOptions: &ec2.SpotMarketOptions{
InstanceInterruptionBehavior: aws.String(ec2.InstanceInterruptionBehaviorTerminate),
SpotInstanceType: aws.String(ec2.SpotInstanceTypeOneTime),
MaxPrice: aws.String("0.01"),
},
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
request := getInstanceMarketOptionsRequest(tc.spotMarketOptions)
if !reflect.DeepEqual(request, tc.expectedRequest) {
t.Errorf("Case: %s. Got: %v, expected: %v", tc.name, request, tc.expectedRequest)
}
})
}
}

func setupScheme() (*runtime.Scheme, error) {
scheme := runtime.NewScheme()
if err := clusterv1.AddToScheme(scheme); err != nil {
Expand Down