├── .gitignore ├── LICENSE ├── README.md └── src └── ec2FleetCompare └── ec2FleetCompare.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | bin/* 27 | src/github.com/* 28 | 29 | .DS_Store 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ec2FleetCompare 2 | Is a small / fast command-line tool which determine the instance types that fulfil your needs. Filter the output based on fleet size (both specific, or min number of instances), # of vcpu's, GB's of memory, network and disk requirements. The tool will compare all instance variations that meet your criteria and then output a table showing instance/hour cost and total cost per Month. 3 | 4 | ec2FleetCompare supports on-demand, spot and all variations of RI prices. Sorting of the output defaults to on-demand but can be changed via command-line parameter. 5 | 6 | ec2FleetCompare can be used to look at cost comparisons on individual instances or a fixed number or instances but can also be used to find the cheapest options for a "fleet" of ec2 instances. As long as you know the total number of VCPS's or GB's or ram required across an entire fleet the tool will provide you the cheapest option to achieve this. This is especially useful when setting up a cluster of worker nodes on a ECS, Kubernetes or Mesos cluster for example. 7 | 8 | # Download 9 | 10 | Pre compiled binaries are available for Mac, Linux and Windows. Please see download links below: 11 | 12 | [Linux Download] (https://s3-us-west-2.amazonaws.com/andy-gen/ec2FleetCompare/linux/ec2FleetCompare) - (md5 c928053ef07bca2849001e5b8165d1bc) 13 | 14 | [Mac Download] (https://s3-us-west-2.amazonaws.com/andy-gen/ec2FleetCompare/osx/ec2FleetCompare) - (md5 a0fe0603308069db4ebc376f0e2fa7db) 15 | 16 | [Windows Download] (https://s3-us-west-2.amazonaws.com/andy-gen/ec2FleetCompare/win/ec2FleetCompare.exe) - (md5 d165157d7235c76bf7162f493051e7c8) 17 | 18 | Note: you may need to make executable i.e ```chmod 500 ./ec2FleetCompare``` or similiar for windows. 19 | 20 | # Usage 21 | 22 | To view help / option information. All options have defaults, the various options over-ride them. 23 | 24 | ``` 25 | ./ec2FleetCompare --help 26 | ``` 27 | # Output 28 | 29 | The command line tool will output a ASCII based table describing the instance types (and number of them if using for a fleet) that fulfil your criteria. This is sorted by default by on-demand pricing but can also be sorted via spot or RI pricing too. 30 | 31 | ``` 32 | +--------+-------------+------+-----------+-------+------------+---------+---------+-------------+-----------+----------+------------+--------+----------+ 33 | | # INST | TYPE | VCPU | VCPU FREQ | MEM | NETWORK | IS TYPE | IS SIZE | DEMAND/HOUR | SPOT/HOUR | SPOT SAV | DEMAND/MON | RI/MON | SPOT/MON | 34 | +--------+-------------+------+-----------+-------+------------+---------+---------+-------------+-----------+----------+------------+--------+----------+ 35 | | 1 | c4.8xlarge | 36 | 2.9 GHz | 60.0 | 10 Gigabit | N/A | N/A | $1.68 | $0.26 | 84% | $1,206 | $777 | $189 | 36 | | 1 | c3.8xlarge | 32 | 2.8 GHz | 60.0 | 10 Gigabit | SSD | 640 GB | $1.68 | $0.41 | 75% | $1,209 | $734 | $297 | 37 | | 1 | cc2.8xlarge | 32 | 2.6 GHz | 60.5 | 10 Gigabit | HDD | 3360 GB | $2.00 | $0.26 | 87% | $1,440 | $676 | $187 | 38 | | 1 | m4.10xlarge | 40 | 2.4 GHz | 160.0 | 10 Gigabit | N/A | N/A | $2.39 | $0.40 | 83% | $1,723 | $1,019 | $288 | 39 | | 1 | g2.8xlarge | 32 | 2.6 GHz | 60.0 | 10 Gigabit | SSD | 240 GB | $2.60 | $1.40 | 46% | $1,872 | N/A | $1,007 | 40 | | 1 | r3.8xlarge | 32 | 2.5 GHz | 244.0 | 10 Gigabit | SSD | 640 GB | $2.66 | $0.26 | 90% | $1,915 | $1,046 | $187 | 41 | | 1 | cr1.8xlarge | 32 | | 244.0 | 10 Gigabit | SSD | 240 GB | $3.50 | $0.40 | 89% | $2,520 | $1,048 | $287 | 42 | | 1 | m4.16xlarge | 64 | 2.3 GHz | 256.0 | 20 Gigabit | N/A | N/A | $3.83 | $0.57 | 85% | $2,757 | $1,631 | $408 | 43 | | 1 | d2.8xlarge | 36 | 2.4 GHz | 244.0 | 10 Gigabit | HDD | 8000 GB | $5.52 | $0.58 | 89% | $3,974 | $1,994 | $418 | 44 | | 1 | i2.8xlarge | 32 | 2.5 GHz | 244.0 | 10 Gigabit | SSD | 6400 GB | $6.82 | $0.69 | 90% | $4,910 | $2,107 | $494 | 45 | | 1 | p2.8xlarge | 32 | | 488.0 | 10 Gigabit | N/A | N/A | $7.20 | $72.00 | -900% | $5,184 | $3,392 | $51,840 | 46 | | 1 | p2.16xlarge | 64 | | 768.0 | 20 Gigabit | N/A | N/A | $14.40 | $144.00 | -900% | $10,368 | $6,786 | $103,680 | 47 | +--------+-------------+------+-----------+-------+------------+---------+---------+-------------+-----------+----------+------------+--------+----------+ 48 | ``` 49 | 50 | # Example Usage 51 | 52 | Find linux based ec2 instances with over 8GB ram and at least 4 VCPU's 53 | ``` 54 | ./ec2FleetCompare -c 4 -m 8 55 | ``` 56 | 57 | Find cost of 70 c3.xlarge instances with a 3 year partial RI 58 | ``` 59 | ./ec2FleetCompare -n 70 -i c3.xlarge -ri partial3 60 | ``` 61 | 62 | Find Windows based ec2 instances that have at least 1TB of SSD instance store disk available. 63 | ``` 64 | ./ec2FleetCompare -os win -dt ssd -d 1024 65 | ``` 66 | 67 | Find Windows based ec2 instances that have Gigabit network interfaces, sorted by Spot pricing 68 | ``` 69 | ./ec2FleetCompare -os win -nw gbit -s spot 70 | ``` 71 | 72 | Find cheapest fleet that has a total VCPU capacity of 10000, with each instance at least having 32 VCPUS. Total memory capacity of at least 24TB with all nodes having Gigabit networking. Sorted by spot pricing. 73 | ``` 74 | ./ec2FleetCompare -fc 10000 -c 32 -fm 24576 -nw gbit -s spot 75 | ``` 76 | 77 | Find cheapest fleet of i2 type type instances with a total memory cpacity of 24TB with each node having at least 3.2TB of SSD instance store disk available. Sorted by spot pricing. 78 | ``` 79 | ./ec2FleetCompare -fm 24576 -dt SSD -d 3200 -i i2 -s spot 80 | ``` 81 | 82 | # Developing 83 | 84 | This is written in [golang] (https://golang.org/). So you will need to download the GO compiler, set your ```GOPATH``` environment variable correctly and then install all the pre-req modules listed in the source file (```go get ```). 85 | 86 | -------------------------------------------------------------------------------- /src/ec2FleetCompare/ec2FleetCompare.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/codegangsta/cli" 6 | "github.com/mitchellh/go-homedir" 7 | "github.com/olekukonko/tablewriter" 8 | "github.com/dustin/go-humanize" 9 | "time" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | "encoding/json" 14 | "errors" 15 | "regexp" 16 | "io/ioutil" 17 | "strings" 18 | "sort" 19 | // "github.com/davecgh/go-spew/spew" 20 | ) 21 | 22 | var cacheDir = ".ec2FleetCompare" 23 | var ec2PricesURL string = "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json"; 24 | var ec2SpotPricesURL string = "https://spot-price.s3.amazonaws.com/spot.js" 25 | 26 | // var ec2PricesURL string = "http://localhost:1313/ec2_demand_prices.json" 27 | // var ec2SpotPricesURL string = "http://localhost:1313/spot.js" 28 | 29 | 30 | var ec2RegionMap = map[string]string{ 31 | "AWS GovCloud (US)": "gov-west-1", 32 | "Asia Pacific (Seoul)": "ap-northeast-2", 33 | "Asia Pacific (Singapore)": "ap-southeast-1", 34 | "Asia Pacific (Sydney)": "ap-southeast-2", 35 | "Asia Pacific (Tokyo)": "ap-northeast-1", 36 | "EU (Frankfurt)": "eu-central-1", 37 | "EU (Ireland)": "eu-west-1", 38 | "South America (Sao Paulo)": "sa-east-1", 39 | "US East (N. Virginia)": "us-east-1", 40 | "US West (N. California)": "us-west-1", 41 | "US West (Oregon)": "us-west-2", 42 | } 43 | 44 | var ec2RSpotegionMap = map[string]string{ 45 | "AWS GovCloud (US)": "gov-west-1", 46 | "ap-northeast-2": "ap-northeast-2", 47 | "apac-sin": "ap-southeast-1", 48 | "apac-syd": "ap-southeast-2", 49 | "apac-tokyo": "ap-northeast-1", 50 | "eu-central-1": "eu-central-1", 51 | "eu-ireland": "eu-west-1", 52 | "sa-east-1": "sa-east-1", 53 | "us-east": "us-east-1", 54 | "us-west": "us-west-1", 55 | "us-west-2": "us-west-2", 56 | } 57 | 58 | var networkMap = map[string]int{ 59 | "any": 4, 60 | "low": 4, 61 | "med": 3, 62 | "high": 2, 63 | "gbit": 1, 64 | } 65 | 66 | type InstanceSpecs struct { 67 | Mem float64 68 | Cpu int 69 | Os string 70 | CpuClock string 71 | DiskSize int 72 | DiskType string 73 | NetworkType int 74 | NetworkDesc string 75 | Description string 76 | } 77 | 78 | type Instance struct { 79 | Sku string 80 | Name string 81 | RegionName string 82 | RegionCode string 83 | Specs InstanceSpecs 84 | DemandPrice float64 85 | Reserve1YPartialPrice float64 86 | Reserve1YPartialUpfront float64 87 | Reserve1YZeroPrice float64 88 | Reserve1YFullUpfront float64 89 | Reserve3YPartialPrice float64 90 | Reserve3YPartialUpfront float64 91 | Reserve3YFullUpfront float64 92 | SpotPrice float64 93 | } 94 | 95 | type Ec2 struct { 96 | Instance []Instance 97 | } 98 | 99 | type Ec2Filtered struct { 100 | NumberInstances int 101 | SortPrice float64 102 | TotalPriceDemand float64 103 | TotalPriceRI float64 104 | TotalPriceSpot float64 105 | Instance Instance 106 | } 107 | 108 | type FilteredResults []Ec2Filtered 109 | 110 | func (slice FilteredResults) Len() int { 111 | return len(slice) 112 | } 113 | 114 | func (slice FilteredResults) Less(i, j int) bool { 115 | return slice[i].SortPrice < slice[j].SortPrice 116 | } 117 | 118 | func (slice FilteredResults) Swap(i, j int) { 119 | slice[i], slice[j] = slice[j], slice[i] 120 | } 121 | 122 | 123 | func printError(s string) { 124 | fmt.Println("***************************** ERROR ********************************************") 125 | fmt.Printf("ERROR: %s\n", s) 126 | fmt.Println("********************************************************************************\n\n") 127 | } 128 | 129 | func getJson(url string, target interface{}, jsonp bool) error { 130 | resp, err := http.Get(url) 131 | if err != nil { 132 | return err 133 | } 134 | defer resp.Body.Close() 135 | 136 | if jsonp { 137 | body, err := ioutil.ReadAll(resp.Body) 138 | if err != nil { 139 | return err 140 | } 141 | r := regexp.MustCompile(`(?s)callback\s*\((.*)\)`) 142 | jsonBytes := r.FindSubmatch(body) 143 | if jsonBytes == nil || len(jsonBytes) < 2 { 144 | return errors.New("Could not decode JSONP callback") 145 | } 146 | 147 | return json.Unmarshal(jsonBytes[1], target) 148 | } else { 149 | return json.NewDecoder(resp.Body).Decode(target) 150 | } 151 | } 152 | 153 | func downloadDemandPrices (ec2 *Ec2) error { 154 | var data map[string]interface{} 155 | if err := getJson(ec2PricesURL, &data, false); err != nil { 156 | return err 157 | } 158 | serverTypes, _ := data["products"].(map[string]interface{}) 159 | 160 | r_mem := regexp.MustCompile(`(\d+)(?:(\.\d+))*\s+GiB`) 161 | r_disk := regexp.MustCompile(`(\d)\s+x\s+(\d+)(?:\s+(SSD|HDD))*`) 162 | 163 | for server, serverSpecs := range serverTypes { 164 | serverSpecs, ok := serverSpecs.(map[string]interface{}) 165 | if !ok { 166 | return errors.New("Type assertion failed on Server Specs") 167 | } 168 | 169 | // make sure this is actually a EC2 server JSON object 170 | family, ok := serverSpecs["productFamily"].(string) 171 | if !ok || family != "Compute Instance" { 172 | continue 173 | } 174 | 175 | serverAttributes, ok := serverSpecs["attributes"].(map[string]interface{}) 176 | if ! ok { 177 | return errors.New("Type assertion failed on attributes") 178 | } 179 | 180 | // just process all but unknown OS 181 | os, ok := serverAttributes["operatingSystem"].(string) 182 | if !ok || os == "NA" { 183 | continue 184 | } 185 | 186 | // drop anything thats bring your own license 187 | license, ok := serverAttributes["licenseModel"].(string) 188 | if !ok || (license == "Bring your own license") { 189 | continue 190 | } 191 | 192 | // drop anything having pre-installed software 193 | software, ok := serverAttributes["preInstalledSw"].(string) 194 | if !ok || (software != "" && software != "NA") { 195 | continue 196 | } 197 | 198 | // drop anything that is not shared hosting 199 | tenancy, ok := serverAttributes["tenancy"].(string) 200 | if !ok || (tenancy != "Shared") { 201 | continue 202 | } 203 | 204 | mem := r_mem.FindStringSubmatch(serverAttributes["memory"].(string)) 205 | 206 | var i Instance 207 | 208 | i.Name, ok = serverAttributes["instanceType"].(string) 209 | i.RegionName, ok = serverAttributes["location"].(string) 210 | i.RegionCode = ec2RegionMap[i.RegionName] 211 | i.Sku = server 212 | i.Specs.Cpu, _ = strconv.Atoi(serverAttributes["vcpu"].(string)) 213 | i.Specs.CpuClock, ok = serverAttributes["clockSpeed"].(string) 214 | i.Specs.NetworkDesc, ok = serverAttributes["networkPerformance"].(string) 215 | i.Specs.Os = os 216 | 217 | 218 | if (len(mem) >= 2) { 219 | i.Specs.Mem, _ = strconv.ParseFloat(mem[1] + mem[2], 64) 220 | } else { 221 | i.Specs.Mem = 0 // basically could not match memory 222 | } 223 | 224 | // set networkType code based on networkDesc 225 | switch i.Specs.NetworkDesc { 226 | case `10 Gigabit`: 227 | i.Specs.NetworkType = 1 228 | case `High`: 229 | i.Specs.NetworkType = 2 230 | case `Moderate`: 231 | i.Specs.NetworkType = 3 232 | default: 233 | i.Specs.NetworkType = 4 234 | } 235 | 236 | if serverAttributes["storage"].(string) == "EBS only" { 237 | i.Specs.DiskSize = 0 238 | i.Specs.DiskType ="EBS" 239 | } else { 240 | disk := r_disk.FindStringSubmatch(serverAttributes["storage"].(string)) 241 | if len(disk) == 4 { 242 | diskSize, _ := strconv.ParseInt(disk[1], 10, 32) 243 | numDisks, _ := strconv.ParseInt(disk[2], 10, 32) 244 | i.Specs.DiskSize = int(diskSize * numDisks) 245 | if len(disk[3]) < 1 { 246 | i.Specs.DiskType = "HDD" 247 | } else { 248 | i.Specs.DiskType = disk[3] 249 | } 250 | } 251 | } 252 | 253 | // The price JSON structure is somewhat crazy and has a bunch of hardcoded ID's the structure below 254 | // is used to capture the required fields, and their hardcoded codes - which is then looped through to find what we need. 255 | 256 | prices := [][]string{ 257 | {"demandPrice" , "OnDemand", ".JRTCKXETXF", ".JRTCKXETXF.6YS6EN2CT7", ""}, 258 | {"reservedPartial1Year" , "Reserved", ".HU7G6KETJZ", ".HU7G6KETJZ.6YS6EN2CT7", ""}, 259 | {"reservedPartial1YearUpfront" , "Reserved", ".HU7G6KETJZ", ".HU7G6KETJZ.2TG2D8R56U", ""}, 260 | {"reservedZero1Year" , "Reserved", ".4NA7Y494T4", ".4NA7Y494T4.6YS6EN2CT7", ""}, 261 | {"reservedFull1YearUpfront" , "Reserved", ".6QCMYABX3D", ".6QCMYABX3D.2TG2D8R56U", ""}, 262 | {"reservedPartial3Year" , "Reserved", ".38NPMPTW36", ".38NPMPTW36.6YS6EN2CT7", ""}, 263 | {"reservedPartial3YearUpfront" , "Reserved", ".38NPMPTW36", ".38NPMPTW36.2TG2D8R56U", ""}, 264 | {"reservedPFull3YearUpFront" , "Reserved", ".NQ3QZPMQV9", ".NQ3QZPMQV9.2TG2D8R56U", ""}, 265 | } 266 | 267 | for row := range prices { 268 | object, ok := data["terms"].(map[string]interface{})[prices[row][1]].(map[string]interface{})[i.Sku].(map[string]interface{}) 269 | if ok { 270 | price, ok := object[i.Sku + prices[row][2]].(map[string]interface{}) 271 | if ok { 272 | price, ok := price["priceDimensions"].(map[string]interface{})[i.Sku + prices[row][3]].(map[string]interface{}) 273 | if ok { 274 | prices[row][4], _ = price["pricePerUnit"].(map[string]interface{})["USD"].(string) 275 | } 276 | } 277 | } 278 | } 279 | 280 | i.DemandPrice, _ = strconv.ParseFloat(prices[0][4],64) 281 | i.Reserve1YPartialPrice, _ = strconv.ParseFloat(prices[1][4],64) 282 | i.Reserve1YPartialUpfront, _ = strconv.ParseFloat(prices[2][4],64) 283 | i.Reserve1YZeroPrice, _ = strconv.ParseFloat(prices[3][4],64) 284 | i.Reserve1YFullUpfront, _ = strconv.ParseFloat(prices[4][4],64) 285 | i.Reserve3YPartialPrice, _ = strconv.ParseFloat(prices[5][4],64) 286 | i.Reserve3YPartialUpfront,_ = strconv.ParseFloat(prices[6][4],64) 287 | i.Reserve3YFullUpfront,_ = strconv.ParseFloat(prices[7][4],64) 288 | 289 | 290 | // set fake spot price which should get over-set 291 | i.SpotPrice = 999999.9 292 | 293 | ec2.Instance = append(ec2.Instance, i) 294 | } 295 | return nil 296 | } 297 | 298 | func downloadSpotPrices (ec2 *Ec2) error { 299 | 300 | var data map[string]interface{} 301 | if err := getJson(ec2SpotPricesURL, &data, true); err != nil { 302 | return err 303 | } 304 | regions, _ := data["config"].(map[string]interface{})["regions"].([]interface {}) 305 | for r := range regions { 306 | region, _ := regions[r].(map[string]interface {}) 307 | regionCode, _ := region["region"].(string) 308 | 309 | for instanceType := range region["instanceTypes"].([]interface {}) { 310 | for size := range region["instanceTypes"].([]interface {})[instanceType].(map[string]interface {})["sizes"].([]interface {}) { 311 | instance, _ := region["instanceTypes"].([]interface {})[instanceType].(map[string]interface {})["sizes"].([]interface {})[size].(map[string]interface{}) 312 | 313 | for osType := range instance["valueColumns"].([]interface {}) { 314 | os, _ := instance["valueColumns"].([]interface {})[osType].(map[string]interface{}) 315 | var i Instance 316 | i.Name, _ = instance["size"].(string) 317 | i.RegionCode = ec2RSpotegionMap[regionCode] 318 | // Convert Spot OS Names to ones that match the Demand Names!!! 319 | switch os["name"].(string) { 320 | case "linux": 321 | i.Specs.Os = "Linux" 322 | case "mswin": 323 | i.Specs.Os = "Windows" 324 | default: 325 | } 326 | i.SpotPrice, _ = strconv.ParseFloat(os["prices"].(map[string]interface {})["USD"].(string),64) 327 | ec2.Instance = append(ec2.Instance, i) 328 | } 329 | } 330 | } 331 | } 332 | return nil 333 | } 334 | 335 | func combinePrices (demand *Ec2, spot *Ec2) error { 336 | 337 | for d := range demand.Instance { 338 | for s := range spot.Instance { 339 | if demand.Instance[d].Specs.Os == spot.Instance[s].Specs.Os && 340 | demand.Instance[d].RegionCode == spot.Instance[s].RegionCode && 341 | demand.Instance[d].Name == spot.Instance[s].Name && 342 | spot.Instance[s].SpotPrice > 0 { 343 | demand.Instance[d].SpotPrice = spot.Instance[s].SpotPrice 344 | break 345 | } 346 | } 347 | } 348 | return nil 349 | } 350 | 351 | 352 | func readCache(s *Ec2, cacheFile string, maxCache time.Duration, skipDownload bool) error { 353 | 354 | // get homedir 355 | home, err := homedir.Dir() 356 | if err != nil { 357 | return err 358 | } 359 | 360 | // check for presense of $HOME/.ec2FleetCompare directory, if doesnt exist create it 361 | if _, err = os.Stat(home + "/" + cacheDir); err != nil { 362 | if err = os.Mkdir(home + "/" + cacheDir, 0755); err != nil { 363 | return err 364 | } 365 | } 366 | 367 | // get cache files metaData 368 | metaCache, err := os.Stat(home + "/" + cacheDir + "/" + cacheFile) 369 | if err != nil { 370 | return errors.New("Cache Doesnt exist") 371 | } 372 | 373 | if ! skipDownload && metaCache.ModTime().Before(time.Now().Add(- maxCache)) { 374 | return errors.New("Cache too old") 375 | } 376 | 377 | // our cache file is present and not to old! 378 | b, err := ioutil.ReadFile(home + "/" + cacheDir + "/" + cacheFile) 379 | if err != nil { 380 | return err 381 | } 382 | 383 | if err := json.Unmarshal(b, s); err != nil { 384 | return err 385 | } 386 | 387 | return nil 388 | } 389 | 390 | func writeCache (b []byte, cacheFile string) error { 391 | // get homedir 392 | home, err := homedir.Dir() 393 | if err != nil { 394 | return err 395 | } 396 | 397 | if err = ioutil.WriteFile(home + "/" + cacheDir + "/" + cacheFile, b, 0644); err != nil { 398 | return err 399 | } 400 | return nil 401 | } 402 | 403 | /* 404 | 405 | getPrices fetches data for both demand/reserve and spot prices. 406 | As spot is much more variable the cache is kept seperatly and updated more often. 407 | 408 | Both demand and spot data structures are the same (for ease of reuse) and then combined. This is a little wasteful in terms of memory but really not alot. 409 | 410 | */ 411 | func getPrices(s *Ec2, forceDownload bool, ignoreSpot bool, skipDownload bool) error { 412 | 413 | // First get demand and reserve pricing 414 | if forceDownload || readCache(s, "ec2.cache", (24 * time.Hour), skipDownload) != nil { 415 | // cache to old download it 416 | fmt.Println("Price cache to old fetching new data ...") 417 | if err := downloadDemandPrices (s); err != nil { 418 | return err 419 | } 420 | 421 | // write processed response to cache 422 | b, _ := json.Marshal(s) 423 | if err := writeCache(b, "ec2.cache"); err != nil { 424 | return err 425 | } 426 | } 427 | 428 | // now get spot pricing if required 429 | if !ignoreSpot { 430 | var spot Ec2 431 | if forceDownload || readCache(&spot, "spot.cache", (30 * time.Minute), skipDownload) != nil { 432 | if err := downloadSpotPrices(&spot); err != nil { 433 | return err 434 | } 435 | 436 | // write processed response to cache 437 | b, _ := json.Marshal(spot) 438 | if err := writeCache(b, "spot.cache"); err != nil { 439 | return err 440 | } 441 | } 442 | // combine demand and spot prices 443 | if err := combinePrices(s, &spot); err != nil { 444 | return err 445 | } 446 | } 447 | 448 | return nil 449 | } 450 | 451 | func roundUp(val float64) int { 452 | if val > 0 { return int(val+0.999999) } 453 | return int(val) 454 | } 455 | 456 | func doFilter(ec2 Ec2, region string, instanceCount int, minInstanceCount int, minCPU int, minFleetCPU int, minMem int, minFleetMem int, minDisk int, diskType string, minNetworkType int, operatingSystem string, instanceType string, riType string, sort string) FilteredResults { 457 | 458 | var output FilteredResults 459 | 460 | r_region := regexp.MustCompile(`(?i).*` + region + `.*`) 461 | r_os := regexp.MustCompile(`(?i).*` + operatingSystem + `.*`) 462 | r_type := regexp.MustCompile(`(?i).*` + instanceType + `.*`) 463 | 464 | 465 | for i := range ec2.Instance { 466 | if ! r_region.MatchString(ec2.Instance[i].RegionCode) { 467 | continue 468 | } 469 | if instanceType != "ANY" && ! r_type.MatchString(ec2.Instance[i].Name) { 470 | continue 471 | } 472 | if operatingSystem != "ANY" && ! r_os.MatchString(ec2.Instance[i].Specs.Os) { 473 | continue 474 | } 475 | if ec2.Instance[i].Specs.NetworkType > minNetworkType { // smaller NetworkType is faster! 476 | continue 477 | } 478 | if diskType != "ANY" && diskType != ec2.Instance[i].Specs.DiskType { 479 | continue 480 | } 481 | if minDisk > ec2.Instance[i].Specs.DiskSize { 482 | continue 483 | } 484 | if minCPU > ec2.Instance[i].Specs.Cpu { 485 | continue 486 | } 487 | if minMem > int(ec2.Instance[i].Specs.Mem) { 488 | continue 489 | } 490 | 491 | var numServers int 492 | // sort prices will be whatever the sort prices set * num instances required (biggest to meet either mem or cpu limits) 493 | if (instanceCount == 1) { 494 | if (float64(minFleetMem) / ec2.Instance[i].Specs.Mem) > float64(minFleetCPU / ec2.Instance[i].Specs.Cpu) { 495 | numServers = roundUp(float64(minFleetMem) / float64(ec2.Instance[i].Specs.Mem)) 496 | } else { 497 | numServers = roundUp(float64(minFleetCPU) / float64(ec2.Instance[i].Specs.Cpu)) 498 | } 499 | if numServers < 1 { 500 | numServers = 1 501 | } 502 | } else { 503 | numServers = instanceCount 504 | } 505 | 506 | if numServers < minInstanceCount { 507 | continue 508 | } 509 | 510 | var riPrice, riMonCost float64 511 | switch riType { 512 | case `zero1`: 513 | riPrice = ec2.Instance[i].Reserve1YZeroPrice * float64(numServers) 514 | riMonCost = 0 515 | case `partial1`: 516 | riPrice = ec2.Instance[i].Reserve1YPartialPrice * float64(numServers) 517 | riMonCost = (ec2.Instance[i].Reserve1YPartialUpfront / 12) * float64(numServers) 518 | case `partial3`: 519 | riPrice = ec2.Instance[i].Reserve3YPartialPrice * float64(numServers) 520 | riMonCost = (ec2.Instance[i].Reserve3YPartialUpfront / 36) * float64(numServers) 521 | case `full1`: 522 | riPrice = 0 523 | riMonCost = (ec2.Instance[i].Reserve1YFullUpfront / 12) * float64(numServers) 524 | case `full3`: 525 | riPrice = 0 526 | riMonCost = (ec2.Instance[i].Reserve1YFullUpfront / 36) * float64(numServers) 527 | default: 528 | riPrice = ec2.Instance[i].Reserve1YZeroPrice * float64(numServers) 529 | riMonCost = 0 530 | } 531 | 532 | var instance Ec2Filtered 533 | instance.NumberInstances = numServers 534 | instance.Instance = ec2.Instance[i] 535 | 536 | // calculate monthly costs for demand, spot and choosen RI 537 | instance.TotalPriceDemand = ec2.Instance[i].DemandPrice * float64(numServers) * 24 * 30 538 | instance.TotalPriceSpot = ec2.Instance[i].SpotPrice * float64(numServers) * 24 * 30 539 | instance.TotalPriceRI = (riPrice * 24 * 30) + riMonCost 540 | 541 | if instance.TotalPriceRI == 0 { 542 | instance.TotalPriceRI = 999999999.999999 543 | } 544 | 545 | switch sort { 546 | case `demand`: 547 | instance.SortPrice = instance.TotalPriceDemand 548 | case `spot`: 549 | instance.SortPrice = instance.TotalPriceSpot 550 | case `ri`: 551 | instance.SortPrice = instance.TotalPriceRI 552 | default: 553 | instance.SortPrice = instance.TotalPriceDemand 554 | } 555 | 556 | output = append(output, instance) 557 | } 558 | 559 | return output 560 | } 561 | 562 | func doDisplay (output FilteredResults, outputSize int) { 563 | 564 | sort.Sort(output) 565 | 566 | var data [][]string 567 | i := 1 568 | for _, s := range output { 569 | 570 | if (i > outputSize) { 571 | break 572 | } 573 | 574 | spotSaving := (((s.TotalPriceDemand - s.TotalPriceSpot)/s.TotalPriceDemand) * 100) 575 | 576 | demandString := "$" + strconv.FormatFloat(s.Instance.DemandPrice * float64(s.NumberInstances), 'f', 2, 64) 577 | spotString := "$" + strconv.FormatFloat(s.Instance.SpotPrice * float64(s.NumberInstances), 'f', 2, 64) 578 | 579 | if s.NumberInstances > 1 { 580 | demandString = demandString + " ($" + strconv.FormatFloat(s.Instance.DemandPrice, 'f', 2, 64) + " ea)" 581 | spotString = spotString + " ($" + strconv.FormatFloat(s.Instance.SpotPrice, 'f', 2, 64) + " ea)" 582 | } 583 | 584 | result := []string{ 585 | strconv.FormatInt(int64(s.NumberInstances), 10), 586 | s.Instance.Name, 587 | strconv.FormatInt(int64(s.Instance.Specs.Cpu), 10), 588 | s.Instance.Specs.CpuClock, 589 | strconv.FormatFloat(s.Instance.Specs.Mem, 'f', 1, 64), 590 | s.Instance.Specs.NetworkDesc, 591 | s.Instance.Specs.DiskType, 592 | strconv.FormatInt(int64(s.Instance.Specs.DiskSize), 10) + " GB", 593 | demandString, 594 | spotString, 595 | strconv.FormatFloat(spotSaving, 'f', 0, 64) + "%", 596 | "$" + humanize.Comma(int64(s.TotalPriceDemand)), 597 | "$" + humanize.Comma(int64(s.TotalPriceRI)), 598 | "$" + humanize.Comma(int64(s.TotalPriceSpot)), 599 | } 600 | if s.Instance.SpotPrice == 999999.9 { 601 | result[9] = "N/A" 602 | result[10] = "N/A" 603 | result[13] = "N/A" 604 | } 605 | if s.Instance.Specs.DiskSize == 0 { 606 | result[6] = "N/A" 607 | result[7] = "N/A" 608 | } 609 | if s.TotalPriceRI == 999999999.999999 { 610 | result[12] = "N/A" 611 | } 612 | 613 | data = append(data, result) 614 | i++ 615 | } 616 | table := tablewriter.NewWriter(os.Stdout) 617 | table.SetHeader([]string{"# Inst", "Type", "VCPU", "VCPU Freq", "Mem", "Network", "IS Type", "IS Size", "Demand/Hour", "Spot/Hour", "Spot Sav", "Demand/Mon", "RI/Mon", "Spot/Mon"}) 618 | table.SetBorder(true) // Set Border to false 619 | table.AppendBulk(data) // Add Bulk Data 620 | table.Render() 621 | } 622 | 623 | 624 | 625 | 626 | func main() { 627 | 628 | app := cli.NewApp() 629 | app.Name = "EC2 Instance fleet Compare" 630 | app.Usage = "Use this app to find the cheapest price for a single or set of EC2 instances given your CPU, memory or network requirements. \n\tGiven a minimum or maximum fleet size and the required resources across the fleet this app will find the cheapest EC2 instances that will fulfil your requirements." 631 | app.Version = "1.0.0" 632 | 633 | var minNetwork, region, diskType, operatingSystem, sort, instanceType, riType string 634 | var instanceCount, minInstanceCount, minCPU, minDisk, minFleetCPU, minMem, minFleetMem, outputSize int 635 | var forceDownload, ignoreSpot, skipDownload bool 636 | app.Flags = []cli.Flag{ 637 | cli.IntFlag{ 638 | Name: "num, n", 639 | Value: 1, 640 | Usage: "Number of instances required in fleet - leave at default unless you have specfic requirements for X instances", 641 | Destination: &instanceCount, 642 | }, 643 | cli.IntFlag{ 644 | Name: "min, mn", 645 | Value: 1, 646 | Usage: "Minimum number of instances required in fleet", 647 | Destination: &minInstanceCount, 648 | }, 649 | cli.StringFlag{ 650 | Name: "region, r", 651 | Value: "us-east-1", 652 | Usage: "The EC2 region to perform price checks on", 653 | Destination: ®ion, 654 | }, 655 | cli.StringFlag{ 656 | Name: "instance, i", 657 | Value: "any", 658 | Usage: "EC2 instance type. partial matching is supported i.e c4, m4, c4.large, xl etc", 659 | Destination: &instanceType, 660 | }, 661 | cli.IntFlag{ 662 | Name: "cpu, c", 663 | Value: 2, 664 | Usage: "Minimum CPU cores required per instance", 665 | Destination: &minCPU, 666 | }, 667 | cli.IntFlag{ 668 | Name: "mem, m", 669 | Value: 2, 670 | Usage: "Minimum memoy (in GiB) required per instance", 671 | Destination: &minMem, 672 | }, 673 | cli.IntFlag{ 674 | Name: "fleetcpu, fc", 675 | Value: 2, 676 | Usage: "Minimum CPU virtual cores required across fleet", 677 | Destination: &minFleetCPU, 678 | }, 679 | cli.IntFlag{ 680 | Name: "fleetmem, fm", 681 | Value: 2, 682 | Usage: "Minimum memoy (in GiB) required across fleet", 683 | Destination: &minFleetMem, 684 | }, 685 | cli.StringFlag{ 686 | Name: "network, nw", 687 | Value: "low", 688 | Usage: "Minimum network speed required per instance, options: low, medium, high, gbit", 689 | Destination: &minNetwork, 690 | }, 691 | cli.IntFlag{ 692 | Name: "disk, d", 693 | Value: 0, 694 | Usage: "Minimum instance store disk space required (in GiB) per instance", 695 | Destination: &minDisk, 696 | }, 697 | cli.StringFlag{ 698 | Name: "diskType, dt", 699 | Value: "any", 700 | Usage: "Type of instance store disk required, options: any, hdd, ssd", 701 | Destination: &diskType, 702 | }, 703 | cli.StringFlag{ 704 | Name: "operatingSystem, os", 705 | Value: "linux", 706 | Usage: "Type of OS required, options: any, linux, windows, rhel, suse", 707 | Destination: &operatingSystem, 708 | }, 709 | cli.StringFlag{ 710 | Name: "sort, s", 711 | Value: "demand", 712 | Usage: "Sort choice (always low to high), options: demand, spot, ri", 713 | Destination: &sort, 714 | }, 715 | cli.BoolFlag{ 716 | Name: "force, f", 717 | Usage: "Force download of latest version of AWS EC2 pricing file", 718 | Destination: &forceDownload, 719 | }, 720 | cli.BoolFlag{ 721 | Name: "skip", 722 | Usage: "Skip download of pricing even if cache is old, good for offline use", 723 | Destination: &skipDownload, 724 | }, 725 | cli.IntFlag{ 726 | Name: "outputSize, o", 727 | Value: 20, 728 | Usage: "Max number of output lines", 729 | Destination: &outputSize, 730 | }, 731 | cli.StringFlag{ 732 | Name: "riType, ri", 733 | Value: "partial1", 734 | Usage: "Type of RI type to display, options: zero1, partial1, partial3, full1, full3", 735 | Destination: &riType, 736 | }, 737 | } 738 | app.Action = func(c *cli.Context) error { 739 | var prices Ec2 740 | err := getPrices(&prices, forceDownload, ignoreSpot, skipDownload) 741 | if err != nil { 742 | printError(err.Error()) 743 | return err 744 | } 745 | 746 | minNetworkType := networkMap[minNetwork] 747 | diskType = strings.ToUpper(diskType) 748 | operatingSystem = strings.ToUpper(operatingSystem) 749 | instanceType = strings.ToUpper(instanceType) 750 | 751 | filtered := doFilter(prices, region, instanceCount, minInstanceCount, minCPU, minFleetCPU, minMem, minFleetMem, minDisk, diskType, minNetworkType, operatingSystem, instanceType, riType, sort) 752 | doDisplay(filtered, outputSize) 753 | return nil 754 | } 755 | app.Run(os.Args) 756 | } 757 | --------------------------------------------------------------------------------