Skip to content

Commit 3513105

Browse files
committed
Add utilities for ecs.
1 parent 50a97de commit 3513105

File tree

5 files changed

+257
-5
lines changed

5 files changed

+257
-5
lines changed

awscommons/v2/ecs.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package awscommons
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"strings"
7+
"time"
8+
9+
"github.com/aws/aws-sdk-go-v2/aws"
10+
"github.com/aws/aws-sdk-go-v2/service/ecs"
11+
ecsTypes "github.com/aws/aws-sdk-go-v2/service/ecs/types"
12+
"github.com/gruntwork-io/go-commons/collections"
13+
"github.com/gruntwork-io/go-commons/errors"
14+
"github.com/gruntwork-io/go-commons/logging"
15+
"github.com/gruntwork-io/go-commons/retry"
16+
)
17+
18+
// GetContainerInstanceArns gets the container instance ARNs of all the EC2 instances in an ECS Cluster.
19+
// ECS container instance ARNs are different from EC2 instance IDs!
20+
// An ECS container instance is an EC2 instance that runs the ECS container agent and has been registered into
21+
// an ECS cluster.
22+
// Example identifiers:
23+
// - EC2 instance ID: i-08e8cfc073db135a9
24+
// - container instance ID: 2db66342-5f69-4782-89a3-f9b707f979ab
25+
// - container instance ARN: arn:aws:ecs:us-east-1:012345678910:container-instance/2db66342-5f69-4782-89a3-f9b707f979ab
26+
func GetContainerInstanceArns(opts *Options, clusterName string) ([]string, error) {
27+
client, err := NewECSClient(opts)
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
logger := logging.GetProjectLogger()
33+
logger.Infof("Looking up Container Instance ARNs for ECS cluster %s", clusterName)
34+
35+
input := &ecs.ListContainerInstancesInput{Cluster: aws.String(clusterName)}
36+
arns := []string{}
37+
// Handle pagination by repeatedly making the API call while there is a next token set.
38+
for {
39+
result, err := client.ListContainerInstances(opts.Context, input)
40+
if err != nil {
41+
return nil, errors.WithStackTrace(err)
42+
}
43+
arns = append(arns, result.ContainerInstanceArns...)
44+
if result.NextToken == nil {
45+
break
46+
}
47+
input.NextToken = result.NextToken
48+
}
49+
50+
return arns, nil
51+
}
52+
53+
// StartDrainingContainerInstances puts ECS container instances in DRAINING state so that all ECS Tasks running on
54+
// them are migrated to other container instances. Batches into chunks of 10 because of AWS API limitations.
55+
// (An error occurred InvalidParameterException when calling the UpdateContainerInstancesState
56+
// operation: instanceIds can have at most 10 items.)
57+
func StartDrainingContainerInstances(opts *Options, clusterName string, containerInstanceArns []string) error {
58+
client, err := NewECSClient(opts)
59+
if err != nil {
60+
return err
61+
}
62+
63+
logger := logging.GetProjectLogger()
64+
batchSize := 10
65+
numBatches := int(math.Ceil(float64(len(containerInstanceArns) / batchSize)))
66+
67+
errList := NewMultipleDrainContainerInstanceErrors()
68+
for batchIdx, batchedArnList := range collections.BatchListIntoGroupsOf(containerInstanceArns, batchSize) {
69+
batchedArns := aws.StringSlice(batchedArnList)
70+
71+
logger.Infof("Putting batch %d/%d of container instances in cluster %s into DRAINING state", batchIdx, numBatches, clusterName)
72+
input := &ecs.UpdateContainerInstancesStateInput{
73+
Cluster: aws.String(clusterName),
74+
ContainerInstances: aws.ToStringSlice(batchedArns),
75+
Status: "DRAINING",
76+
}
77+
_, err := client.UpdateContainerInstancesState(opts.Context, input)
78+
if err != nil {
79+
errList.AddError(err)
80+
logger.Errorf("Encountered error starting to drain container instances in batch %d: %s", batchIdx, err)
81+
logger.Errorf("Container Instance ARNs: %s", strings.Join(batchedArnList, ","))
82+
continue
83+
}
84+
85+
logger.Infof("Started draining %d container instances from batch %d", len(batchedArnList), batchIdx)
86+
}
87+
88+
if !errList.IsEmpty() {
89+
return errors.WithStackTrace(errList)
90+
}
91+
logger.Infof("Successfully started draining all %d container instances", len(containerInstanceArns))
92+
return nil
93+
}
94+
95+
// WaitForContainerInstancesToDrain waits until there are no more ECS Tasks running on any of the ECS container
96+
// instances. Batches container instances in groups of 100 because of AWS API limitations.
97+
func WaitForContainerInstancesToDrain(opts *Options, clusterName string, containerInstanceArns []string, start time.Time, timeout time.Duration, maxRetries int, sleepBetweenRetries time.Duration) error {
98+
client, err := NewECSClient(opts)
99+
if err != nil {
100+
return err
101+
}
102+
103+
logger := logging.GetProjectLogger()
104+
logger.Infof("Checking if all ECS Tasks have been drained from the ECS Container Instances in Cluster %s.", clusterName)
105+
106+
batchSize := 100
107+
numBatches := int(math.Ceil(float64(len(containerInstanceArns) / batchSize)))
108+
109+
err = retry.DoWithRetry(
110+
logger.Logger,
111+
"Wait for Container Instances to be Drained",
112+
maxRetries, sleepBetweenRetries,
113+
func() error {
114+
responses := []*ecs.DescribeContainerInstancesOutput{}
115+
for batchIdx, batchedArnList := range collections.BatchListIntoGroupsOf(containerInstanceArns, batchSize) {
116+
batchedArns := aws.StringSlice(batchedArnList)
117+
118+
logger.Infof("Fetching description of batch %d/%d of ECS Instances in Cluster %s.", batchIdx, numBatches, clusterName)
119+
input := &ecs.DescribeContainerInstancesInput{
120+
Cluster: aws.String(clusterName),
121+
ContainerInstances: aws.ToStringSlice(batchedArns),
122+
}
123+
result, err := client.DescribeContainerInstances(opts.Context, input)
124+
if err != nil {
125+
return errors.WithStackTrace(err)
126+
}
127+
responses = append(responses, result)
128+
}
129+
130+
// If we exceeded the timeout, halt with error.
131+
if timeoutExceeded(start, timeout) {
132+
return retry.FatalError{Underlying: fmt.Errorf("maximum drain timeout of %s seconds has elapsed and instances are still draining", timeout)}
133+
}
134+
135+
// Yay, all done.
136+
if drained, _ := allInstancesFullyDrained(responses); drained == true {
137+
logger.Infof("All container instances have been drained in Cluster %s!", clusterName)
138+
return nil
139+
}
140+
141+
// If there's no error, retry.
142+
if err == nil {
143+
return errors.WithStackTrace(fmt.Errorf("container instances still draining"))
144+
}
145+
146+
// Else, there's an error, halt and fail.
147+
return retry.FatalError{Underlying: err}
148+
})
149+
return errors.WithStackTrace(err)
150+
}
151+
152+
// timeoutExceeded returns true if the amount of time since start has exceeded the timeout.
153+
func timeoutExceeded(start time.Time, timeout time.Duration) bool {
154+
timeElapsed := time.Now().Sub(start)
155+
return timeElapsed > timeout
156+
}
157+
158+
// NewECSClient returns a new AWS SDK client for interacting with AWS ECS.
159+
func NewECSClient(opts *Options) (*ecs.Client, error) {
160+
cfg, err := NewDefaultConfig(opts)
161+
if err != nil {
162+
return nil, errors.WithStackTrace(err)
163+
}
164+
return ecs.NewFromConfig(cfg), nil
165+
}
166+
167+
func allInstancesFullyDrained(responses []*ecs.DescribeContainerInstancesOutput) (bool, error) {
168+
for _, response := range responses {
169+
instances := response.ContainerInstances
170+
if len(instances) == 0 {
171+
return false, errors.WithStackTrace(fmt.Errorf("querying DescribeContainerInstances returned no instances"))
172+
}
173+
174+
for _, instance := range instances {
175+
if !instanceFullyDrained(instance) {
176+
return false, nil
177+
}
178+
}
179+
}
180+
return true, nil
181+
}
182+
183+
func instanceFullyDrained(instance ecsTypes.ContainerInstance) bool {
184+
logger := logging.GetProjectLogger()
185+
instanceArn := instance.ContainerInstanceArn
186+
187+
if *instance.Status == "ACTIVE" {
188+
logger.Infof("The ECS Container Instance %s is still in ACTIVE status", *instanceArn)
189+
return false
190+
}
191+
if instance.PendingTasksCount > 0 {
192+
logger.Infof("The ECS Container Instance %s still has pending tasks", *instanceArn)
193+
return false
194+
}
195+
if instance.RunningTasksCount > 0 {
196+
logger.Infof("The ECS Container Instance %s still has running tasks", *instanceArn)
197+
return false
198+
}
199+
200+
return true
201+
}

awscommons/v2/errors.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package awscommons
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// MultipleDrainContainerInstanceErrors represents multiple errors found while terminating instances
9+
type MultipleDrainContainerInstanceErrors struct {
10+
errors []error
11+
}
12+
13+
func (err MultipleDrainContainerInstanceErrors) Error() string {
14+
messages := []string{
15+
fmt.Sprintf("%d errors found while draining container instances:", len(err.errors)),
16+
}
17+
18+
for _, individualErr := range err.errors {
19+
messages = append(messages, individualErr.Error())
20+
}
21+
return strings.Join(messages, "\n")
22+
}
23+
24+
func (err MultipleDrainContainerInstanceErrors) AddError(newErr error) {
25+
err.errors = append(err.errors, newErr)
26+
}
27+
28+
func (err MultipleDrainContainerInstanceErrors) IsEmpty() bool {
29+
return len(err.errors) == 0
30+
}
31+
32+
func NewMultipleDrainContainerInstanceErrors() MultipleDrainContainerInstanceErrors {
33+
return MultipleDrainContainerInstanceErrors{[]error{}}
34+
}

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.17
44

55
require (
66
github.com/aws/aws-sdk-go v1.44.48
7-
github.com/aws/aws-sdk-go-v2 v1.16.7
7+
github.com/aws/aws-sdk-go-v2 v1.16.16
88
github.com/aws/aws-sdk-go-v2/config v1.15.13
99
github.com/aws/aws-sdk-go-v2/service/ec2 v1.47.2
1010
github.com/aws/aws-sdk-go-v2/service/s3 v1.27.1
@@ -32,17 +32,18 @@ require (
3232
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3 // indirect
3333
github.com/aws/aws-sdk-go-v2/credentials v1.12.8 // indirect
3434
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8 // indirect
35-
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 // indirect
36-
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 // indirect
35+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 // indirect
36+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 // indirect
3737
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15 // indirect
3838
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5 // indirect
39+
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.22 // indirect
3940
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3 // indirect
4041
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9 // indirect
4142
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.8 // indirect
4243
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.13.8 // indirect
4344
github.com/aws/aws-sdk-go-v2/service/sso v1.11.11 // indirect
4445
github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 // indirect
45-
github.com/aws/smithy-go v1.12.0 // indirect
46+
github.com/aws/smithy-go v1.13.3 // indirect
4647
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
4748
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
4849
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ github.com/aws/aws-sdk-go v1.44.48 h1:jLDC9RsNoYMLFlKpB8LdqUnoDdC2yvkS4QbuyPQJ8+
6666
github.com/aws/aws-sdk-go v1.44.48/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
6767
github.com/aws/aws-sdk-go-v2 v1.16.7 h1:zfBwXus3u14OszRxGcqCDS4MfMCv10e8SMJ2r8Xm0Ns=
6868
github.com/aws/aws-sdk-go-v2 v1.16.7/go.mod h1:6CpKuLXg2w7If3ABZCl/qZ6rEgwtjZTn4eAf4RcEyuw=
69+
github.com/aws/aws-sdk-go-v2 v1.16.16 h1:M1fj4FE2lB4NzRb9Y0xdWsn2P0+2UHVxwKyOa4YJNjk=
70+
github.com/aws/aws-sdk-go-v2 v1.16.16/go.mod h1:SwiyXi/1zTUZ6KIAmLK5V5ll8SiURNUYOqTerZPaF9k=
6971
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3 h1:S/ZBwevQkr7gv5YxONYpGQxlMFFYSRfz3RMcjsC9Qhk=
7072
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.3/go.mod h1:gNsR5CaXKmQSSzrmGxmwmct/r+ZBfbxorAuXYsj/M5Y=
7173
github.com/aws/aws-sdk-go-v2/config v1.15.13 h1:CJH9zn/Enst7lDiGpoguVt0lZr5HcpNVlRJWbJ6qreo=
@@ -76,14 +78,20 @@ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8 h1:VfBdn2AxwMbFyJN/lF/xuT3
7678
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.8/go.mod h1:oL1Q3KuCq1D4NykQnIvtRiBGLUXhcpY5pl6QZB2XEPU=
7779
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14 h1:2C0pYHcUBmdzPj+EKNC4qj97oK6yjrUhc1KoSodglvk=
7880
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.14/go.mod h1:kdjrMwHwrC3+FsKhNcCMJ7tUVj/8uSD5CZXeQ4wV6fM=
81+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 h1:s4g/wnzMf+qepSNgTvaQQHNxyMLKSawNhKCPNy++2xY=
82+
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23/go.mod h1:2DFxAQ9pfIRy0imBCJv+vZ2X6RKxves6fbnEuSry6b4=
7983
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8 h1:2J+jdlBJWEmTyAwC82Ym68xCykIvnSnIN18b8xHGlcc=
8084
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.8/go.mod h1:ZIV8GYoC6WLBW5KGs+o4rsc65/ozd+eQ0L31XF5VDwk=
85+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 h1:/K482T5A3623WJgWT8w1yRAFK4RzGzEl7y39yhtn9eA=
86+
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17/go.mod h1:pRwaTYCJemADaqCbUAxltMoHKata7hmB5PjEXeu0kfg=
8187
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15 h1:QquxR7NH3ULBsKC+NoTpilzbKKS+5AELfNREInbhvas=
8288
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.15/go.mod h1:Tkrthp/0sNBShQQsamR7j/zY4p19tVTAs+nnqhH6R3c=
8389
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5 h1:tEEHn+PGAxRVqMPEhtU8oCSW/1Ge3zP5nUgPrGQNUPs=
8490
github.com/aws/aws-sdk-go-v2/internal/v4a v1.0.5/go.mod h1:aIwFF3dUk95ocCcA3zfk3nhz0oLkpzHFWuMp8l/4nNs=
8591
github.com/aws/aws-sdk-go-v2/service/ec2 v1.47.2 h1:81hrDgbXHL44WdY6M/fHGXLlv17qTpOFzutXRVDEk3Y=
8692
github.com/aws/aws-sdk-go-v2/service/ec2 v1.47.2/go.mod h1:VoBcwURHnJVCWuXHdqVuG03i2lUlHJ5DTTqDSyCdEcc=
93+
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.22 h1:jBx029Z9GQIIq5fC5bW1ZMDsjihvmQQIe/QqdFl+7zY=
94+
github.com/aws/aws-sdk-go-v2/service/ecs v1.18.22/go.mod h1:6bV2xEub6Vch19ZZASMbrNMNIpBPTwy64r9WIQ+wsSE=
8795
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3 h1:4n4KCtv5SUoT5Er5XV41huuzrCqepxlW3SDI9qHQebc=
8896
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.3/go.mod h1:gkb2qADY+OHaGLKNTYxMaQNacfeyQpZ4csDTQMeFmcw=
8997
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.9 h1:gVv2vXOMqJeR4ZHHV32K7LElIJIIzyw/RU1b0lSfWTQ=
@@ -102,6 +110,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.16.9 h1:yOfILxyjmtr2ubRkRJldlHDFBhf5
102110
github.com/aws/aws-sdk-go-v2/service/sts v1.16.9/go.mod h1:O1IvkYxr+39hRf960Us6j0x1P8pDqhTX+oXM5kQNl/Y=
103111
github.com/aws/smithy-go v1.12.0 h1:gXpeZel/jPoWQ7OEmLIgCUnhkFftqNfwWUwAHSlp1v0=
104112
github.com/aws/smithy-go v1.12.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
113+
github.com/aws/smithy-go v1.13.3 h1:l7LYxGuzK6/K+NzJ2mC+VvLUbae0sL3bXU//04MkmnA=
114+
github.com/aws/smithy-go v1.13.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA=
105115
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
106116
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
107117
github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY=

logging/logging.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import (
99
var globalLogLevel = logrus.InfoLevel
1010
var globalLogLevelLock = sync.Mutex{}
1111

12-
// Create a new logger with the given name
12+
// GetProjectLogger creates a new project logger
13+
func GetProjectLogger() *logrus.Entry {
14+
logger := GetLogger("")
15+
return logger.WithField("name", "go-commons")
16+
}
17+
18+
// GetLogger create a new logger with the given name
1319
func GetLogger(name string) *logrus.Logger {
1420
logger := logrus.New()
1521

0 commit comments

Comments
 (0)