main.go
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elb"
)
type lbType int
const (
// ALB is an Application Load Balancer that only speaks HTTP(S)
ALB lbType = iota
// NLB is a Network Load Balancer that only speaks TCP (and UDP?)
NLB
// ELB is a classic LoadBalancer
ELB
)
type arguments struct {
profile string
}
// tier is a set of one or more subnets. In an AWS account, we might have a:
//
// - public subnet
// - app subnet
// - private subnet
// - database subnet
// - etc
type tier struct {
subnets map[string]struct{}
recommendation *recommendation
}
func (t *tier) add(subnet *string) {
t.subnets[*subnet] = struct{}{}
}
func (t *tier) keys() []string {
keys := make([]string, len(t.subnets))
i := 0
for k := range t.subnets {
keys[i] = k
i++
}
sort.Strings(keys)
return keys
}
// tiers is a holder for all of the tiers we've discovered. It also contains caches for comparisons.
type tiers struct {
tiersBySubnet map[string]*tier // tiers keyed by subnet name
tiers []*tier // the list of tiers
securityGroups map[string]*ec2.SecurityGroup // security groups keyed by GroupId
ingressesBySg map[string]map[string]bool // set of ingress CIDRs keyed by Security Group GroupId
}
// newTiers creates a new tiers struct ready for use
func newTiers(sgs map[string]*ec2.SecurityGroup) *tiers {
return &tiers{
tiersBySubnet: make(map[string]*tier),
tiers: make([]*tier, 0),
securityGroups: sgs,
ingressesBySg: make(map[string]map[string]bool),
}
}
func (t *tiers) addTierFor(subnet *string) *tier {
res := &tier{
subnets: make(map[string]struct{}),
recommendation: newRecommendation(),
}
t.associate(res, subnet)
t.tiers = append(t.tiers, res)
return res
}
func (t *tiers) associate(tier *tier, subnet *string) {
tier.add(subnet)
t.tiersBySubnet[*subnet] = tier
}
func (t *tiers) find(subnet *string) *tier {
if res, ok := t.tiersBySubnet[*subnet]; ok {
return res
}
return t.addTierFor(subnet)
}
func (t *tiers) findOrGetIngress(sg string) map[string]bool {
if res, ok := t.ingressesBySg[sg]; ok {
return res
}
res := make(map[string]bool)
for _, permission := range t.securityGroups[sg].IpPermissions {
for _, cidr := range permission.IpRanges {
res[*cidr.CidrIp] = true
}
}
t.ingressesBySg[sg] = res
return res
}
// hasSameIngress is an equality test between 2 security groups. Ingress CIDRs need to be
// identical. We don't consider set operations in terms of one ingress is a proper subset of
// another. Equality only at this time.
func (t *tiers) hasSameIngress(sg1, sg2 string) bool {
ingress1 := t.findOrGetIngress(sg1)
ingress2 := t.findOrGetIngress(sg2)
return reflect.DeepEqual(ingress1, ingress2)
}
func (t *tiers) recommendations() []recommendation {
result := make([]recommendation, 0)
// TODO(jabley): this is little messy – fix data structures!
for _, tier := range t.tiers {
tier.recommendation.subnets = tier.keys()
result = append(result, *tier.recommendation)
}
return result
}
// recommendation is a summary of how we might restructure the ELBs in the account for a given tier.
type recommendation struct {
subnets []string // the set of subnets that this recommendation covers
albs []*LB // the non-nil ALBs that should live in the subnets
albsBySg map[string]*LB // the ALBs keyed by Security Group GroupId
nlbs []*LB // the non-nil NLBs that should live in the subnets
nlbsBySg map[string]*LB // the NLBs keyed by Security Group GroupId
elbs []*LB // the non-nil ELBs that should live in the subnets
elbsBySg map[string]*LB // the ELBs keyed by Security Group GroupId
}
// newRecommendation creates a new recommendation instance ready for use
func newRecommendation() *recommendation {
return &recommendation{
albs: make([]*LB, 0),
albsBySg: make(map[string]*LB),
nlbs: make([]*LB, 0),
nlbsBySg: make(map[string]*LB),
elbs: make([]*LB, 0),
elbsBySg: make(map[string]*LB),
}
}
// associateALBWithSecurityGroups tracks that we consider this ALB to be suitable for the provided
// security groups.
func (r *recommendation) associateALBWithSecurityGroups(alb *LB, securityGroups []*string) {
for i := range securityGroups {
r.albsBySg[*securityGroups[i]] = alb
}
}
func (r *recommendation) ALBs() []*LB {
return r.albs
}
// associateNLBWithSecurityGroups tracks that we consider this NLB to be suitable for the provided
// security groups.
func (r *recommendation) associateNLBWithSecurityGroups(nlb *LB, securityGroups []*string) {
for i := range securityGroups {
r.nlbsBySg[*securityGroups[i]] = nlb
}
}
func (r *recommendation) NLBs() []*LB {
return r.nlbs
}
// associateELBWithSecurityGroups tracks that we consider this ELB to be suitable for the provided
// security groups.
func (r *recommendation) associateELBWithSecurityGroups(elb *LB, securityGroups []*string) {
for i := range securityGroups {
r.elbsBySg[*securityGroups[i]] = elb
}
}
func (r *recommendation) ELBs() []*LB {
return r.elbs
}
func (r *recommendation) Subnets() []string {
return r.subnets
}
// LB is an ALB or NLB that can replace one or more ELBs
type LB struct {
elbs []string // the names of the ELBs that this LB can replace
ports map[int]struct{} // the set of ports that this LB will listen on
securityGroups map[string]struct{} // the set of Security Groups that this LB will allow
}
// newLB creates a new LB ready for use. It will expose the listener ports of the provided non-nil
// ELB, and the same Security Groups.
func newLB(elb *elb.LoadBalancerDescription) *LB {
res := &LB{
elbs: []string{},
ports: make(map[int]struct{}),
securityGroups: make(map[string]struct{}),
}
res.replaceELB(elb)
return res
}
// replaceELB adds the specified ELB to the set of ELBs that this LB can replace. It will expose
// the same listener ports and use the same Security Groups.
func (lb *LB) replaceELB(elb *elb.LoadBalancerDescription) {
lb.elbs = append(lb.elbs, *elb.LoadBalancerName)
lb.addPorts(listenerPorts(elb.ListenerDescriptions))
lb.addSecurityGroups(elb.SecurityGroups)
}
func (lb *LB) addPorts(ports []int) {
for i := range ports {
if _, ok := lb.ports[ports[i]]; !ok {
lb.ports[ports[i]] = struct{}{}
}
}
}
// hasPortCollision returns true if the specified ELB has any listening ports matching ports
// already assigned by this LB
func (lb *LB) hasPortCollision(elb *elb.LoadBalancerDescription) bool {
for i := range elb.ListenerDescriptions {
if _, ok := lb.ports[int(*elb.ListenerDescriptions[i].Listener.LoadBalancerPort)]; ok {
return true
}
}
return false
}
func (lb *LB) addSecurityGroups(securityGroups []*string) {
for i := range securityGroups {
if _, ok := lb.securityGroups[*securityGroups[i]]; !ok {
lb.securityGroups[*securityGroups[i]] = struct{}{}
}
}
}
// ELBs returns the non-nil array of ELB names that can be replaced by this LB
func (lb *LB) ELBs() []string {
return lb.elbs
}
// Ports returns the non-nil array of ports that the ALB should listen on
func (lb *LB) Ports() []string {
res := make([]int, 0)
for k := range lb.ports {
res = append(res, k)
}
// We sort the ports in ascending order, because that seems like a reasonable expectation
sort.Ints(res)
buf := make([]string, len(res))
for i := range res {
buf[i] = strconv.Itoa(res[i])
}
return buf
}
// SecurityGroups returns the non-nil array of security groups names that the ALB should have attached
func (lb *LB) SecurityGroups() []string {
res := make([]string, 0)
for k := range lb.securityGroups {
res = append(res, k)
}
// We sort the security group names because that seems like a reasonable expectation
sort.Strings(res)
return res
}
func listenerPorts(listeners []*elb.ListenerDescription) []int {
result := make([]int, 0)
for i := range listeners {
result = append(result, int(*listeners[i].Listener.LoadBalancerPort))
}
return result
}
func generateRecommendations(elbs []*elb.LoadBalancerDescription, sgs map[string]*ec2.SecurityGroup) []recommendation {
// for lb in elbs
// assign the tier
// assign the candidate type
// can it be an ALB
// does it only speak HTTP(S), or TCP on port 80/443
// can it be an NLB
// does it only speak TCP
// can it be a shared ELB
// does it speak both TCP and HTTP(S)
// find the type with the equivalent security group
tiers := newTiers(sgs)
for _, lb := range elbs {
elbDrop(tiers, lb)
}
return tiers.recommendations()
}
// elbReplacementStrategy captures the distinctions between replacing with an ALB, replacing with an NLB, or trying to
// consolidate ELBs into a single ELB.
type elbReplacementStrategy interface {
add(lb *LB)
associate(lb *LB, securityGroups []*string)
isFirstOfThisType() bool
loadBalancersBySecurityGroup() map[string]*LB
supportsPortCollisions() bool
}
type replaceWithALB struct {
recommendation *recommendation
}
func (r *replaceWithALB) add(alb *LB) {
r.recommendation.albs = append(r.recommendation.albs, alb)
}
func (r *replaceWithALB) associate(alb *LB, securityGroups []*string) {
r.recommendation.associateALBWithSecurityGroups(alb, securityGroups)
}
func (r *replaceWithALB) isFirstOfThisType() bool {
return len(r.recommendation.albs) == 0
}
func (r *replaceWithALB) loadBalancersBySecurityGroup() map[string]*LB {
return r.recommendation.albsBySg
}
func (r *replaceWithALB) supportsPortCollisions() bool {
// ALBs can do port collisions - we can do host-based routing to select a backend
return true
}
type replaceWithNLB struct {
recommendation *recommendation
}
func (r *replaceWithNLB) add(nlb *LB) {
r.recommendation.nlbs = append(r.recommendation.nlbs, nlb)
}
func (r *replaceWithNLB) associate(nlb *LB, securityGroups []*string) {
r.recommendation.associateNLBWithSecurityGroups(nlb, securityGroups)
}
func (r *replaceWithNLB) isFirstOfThisType() bool {
return len(r.recommendation.nlbs) == 0
}
func (r *replaceWithNLB) loadBalancersBySecurityGroup() map[string]*LB {
return r.recommendation.nlbsBySg
}
func (r *replaceWithNLB) supportsPortCollisions() bool {
// NLBs can't do port collisions - no routing options to decide on a backend?
return false
}
type consolidateELBs struct {
recommendation *recommendation
}
func (r *consolidateELBs) add(elb *LB) {
r.recommendation.elbs = append(r.recommendation.elbs, elb)
}
func (r *consolidateELBs) associate(elb *LB, securityGroups []*string) {
r.recommendation.associateELBWithSecurityGroups(elb, securityGroups)
}
func (r *consolidateELBs) isFirstOfThisType() bool {
return len(r.recommendation.elbs) == 0
}
func (r *consolidateELBs) loadBalancersBySecurityGroup() map[string]*LB {
return r.recommendation.elbsBySg
}
func (r *consolidateELBs) supportsPortCollisions() bool {
// ELBs can't do port collisions - no routing options to decide on a backend?
return false
}
// elbDrop is modelled after a penny fall machine that you might see at an arcade.
//
// 1. The first level assesses which subnets the ELB is in.
// 2. The second level decides which type of LB might replace the ELB
// 3. The third level looks at the security groups and see if an existing replacement has the same
// security groups
func elbDrop(tiers *tiers, lb *elb.LoadBalancerDescription) {
recommendation := assignTier(tiers, lb)
targetLB := inspectListeners(lb)
switch targetLB {
case ALB:
addELBv2(lb, tiers, &replaceWithALB{recommendation})
case NLB:
addELBv2(lb, tiers, &replaceWithNLB{recommendation})
case ELB:
addELBv2(lb, tiers, &consolidateELBs{recommendation})
default:
panic("Uknown type of LB")
}
}
func addELBv2(lb *elb.LoadBalancerDescription, tiers *tiers, replacementStrategy elbReplacementStrategy) {
if replacementStrategy.isFirstOfThisType() {
res := newLB(lb)
replacementStrategy.add(res)
replacementStrategy.associate(res, lb.SecurityGroups)
return
}
for _, lbSecurityGroup := range lb.SecurityGroups {
// do we have an existing one with this security group?
elbv2, ok := replacementStrategy.loadBalancersBySecurityGroup()[*lbSecurityGroup]
if ok && (replacementStrategy.supportsPortCollisions() || !elbv2.hasPortCollision(lb)) {
replacementStrategy.associate(elbv2, lb.SecurityGroups)
elbv2.replaceELB(lb)
return
}
// Have we already processed an SG which has the same ingress?
for seenSg := range replacementStrategy.loadBalancersBySecurityGroup() {
if tiers.hasSameIngress(seenSg, *lbSecurityGroup) {
existing := replacementStrategy.loadBalancersBySecurityGroup()[seenSg]
if replacementStrategy.supportsPortCollisions() || !existing.hasPortCollision(lb) {
replacementStrategy.associate(existing, lb.SecurityGroups)
existing.replaceELB(lb)
return
}
}
}
}
// Distinctly new SecurityGroup – a new ELBv2 then
res := newLB(lb)
replacementStrategy.add(res)
replacementStrategy.associate(res, lb.SecurityGroups)
}
func inspectListeners(lb *elb.LoadBalancerDescription) lbType {
protocols := make(map[string]struct{})
for _, ld := range lb.ListenerDescriptions {
switch *ld.Listener.Protocol {
case "HTTP", "HTTPS":
protocols["HTTP"] = struct{}{}
case "TCP":
if *ld.Listener.LoadBalancerPort == 80 || *ld.Listener.LoadBalancerPort == 443 {
protocols["HTTP"] = struct{}{}
} else {
protocols["TCP"] = struct{}{}
}
}
}
switch len(protocols) {
case 0:
panic("No known protocols for this listener")
case 1:
if _, ok := protocols["HTTP"]; ok {
return ALB
}
return NLB
default:
return ELB
}
}
func assignTier(tiers *tiers, lb *elb.LoadBalancerDescription) *recommendation {
var t *tier
for _, s := range lb.Subnets {
if t != nil {
tiers.associate(t, s)
} else {
t = tiers.find(s)
}
}
return t.recommendation
}
func main() {
args := parseAndVerifyArgs()
options := session.Options{
SharedConfigState: session.SharedConfigEnable,
}
if args.profile != "" {
options.Profile = args.profile
}
start := time.Now()
sess := session.Must(session.NewSessionWithOptions(options))
// Do retries in case we hit the API too hard and get throttled for exceeding our allowed rate.
elbSvc := elb.New(sess, aws.NewConfig().WithMaxRetries(3))
ec2Svc := ec2.New(sess, aws.NewConfig().WithMaxRetries(3))
input := &elb.DescribeLoadBalancersInput{}
elbs := make([]*elb.LoadBalancerDescription, 0)
err := elbSvc.DescribeLoadBalancersPages(input, func(page *elb.DescribeLoadBalancersOutput, lastPage bool) bool {
elbs = append(elbs, page.LoadBalancerDescriptions...)
return !lastPage
})
panicOnAwsError(err)
sgs := make(map[string]*ec2.SecurityGroup)
for _, lb := range elbs {
if lb.SecurityGroups == nil {
continue
}
for _, sg := range lb.SecurityGroups {
if _, ok := sgs[*sg]; ok {
continue
}
result, err := ec2Svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
GroupIds: []*string{
aws.String(*sg),
},
})
panicOnAwsError(err)
sgs[*sg] = result.SecurityGroups[0]
}
}
fmt.Printf("Read AWS account in %v, generating recommendations...\n\n", time.Since(start))
recommendations := generateRecommendations(elbs, sgs)
printRecommendations(recommendations)
}
func printRecommendations(recommendations []recommendation) {
currentElbCount, albCount, nlbCount, elbCount := 0, 0, 0, 0
for _, r := range recommendations {
fmt.Printf("The subnets \"%s\" could contain the following load balancer(s):\n", strings.Join(r.Subnets(), ", "))
sum := count(r.ALBs())
currentElbCount += sum.elbs
albCount += sum.lbs
printRecommendationFor(r.ALBs(), "ALB")
println()
sum = count(r.NLBs())
currentElbCount += sum.elbs
nlbCount += sum.lbs
printRecommendationFor(r.NLBs(), "NLB")
println()
sum = count(r.ELBs())
currentElbCount += sum.elbs
elbCount += sum.lbs
printRecommendationFor(r.ELBs(), "ELB")
println()
}
fmt.Printf("So %d ELBs would become %d ALBs, %d NLBs and %d ELBs\n"+
"with a potential saving of %0.0f%%\n", currentElbCount, albCount, nlbCount, elbCount,
saving(currentElbCount, albCount, nlbCount, elbCount))
}
type sum struct {
elbs, lbs int
}
func count(lbs []*LB) *sum {
res := &sum{
lbs: len(lbs),
}
for i := range lbs {
res.elbs += len(lbs[i].ELBs())
}
return res
}
func saving(current, alb, nlb, elb int) float64 {
return (float64(current) - ((float64(alb)+float64(nlb))*0.9 + float64(elb))) / float64(current) * 100
}
func printRecommendationFor(lbs []*LB, lbType string) {
for _, lb := range lbs {
var action string
if len(lb.ELBs()) == 1 && lbType == "ELB" {
action = "Retaining"
} else {
action = "Replacing"
}
fmt.Printf("\n%s the following load balancers:\n- %s\n\n -> an %s with security groups:\n\t- %s\nexposing the ports:\n\t- %s\n",
action,
strings.Join(lb.ELBs(), "\n- "),
lbType,
strings.Join(lb.SecurityGroups(), "\n\t- "),
strings.Join(lb.Ports(), "\n\t- "))
}
}
func parseAndVerifyArgs() *arguments {
var (
help bool
)
res := &arguments{}
flag.BoolVar(&help, "help", false, "Display this help message")
flag.StringVar(&res.profile, "profile", "", "The AWS profile name to use")
flag.Usage = func() {
basename := filepath.Base(os.Args[0])
fmt.Printf("Usage: %s\n", basename)
fmt.Printf("A utility to examine ELB usage in an AWS account and recommend ways of consolidating ELBs into ALBs and NLBs")
flag.PrintDefaults()
}
flag.Parse()
if help {
flag.Usage()
os.Exit(1)
}
return res
}
func panicOnAwsError(err error) {
if err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
default:
fmt.Println(aerr.Error())
}
} else {
fmt.Println(err.Error())
}
panic("Oh noes!")
}
}