github.com/sequix/cortex@v1.1.6/pkg/chunk/aws/metrics_autoscaling.go (about) 1 package aws 2 3 import ( 4 "context" 5 "flag" 6 "fmt" 7 "time" 8 9 "github.com/go-kit/kit/log/level" 10 "github.com/pkg/errors" 11 promApi "github.com/prometheus/client_golang/api" 12 promV1 "github.com/prometheus/client_golang/api/prometheus/v1" 13 "github.com/prometheus/common/model" 14 "github.com/weaveworks/common/mtime" 15 16 "github.com/sequix/cortex/pkg/chunk" 17 "github.com/sequix/cortex/pkg/util" 18 ) 19 20 const ( 21 cachePromDataFor = 30 * time.Second 22 queueObservationPeriod = 2 * time.Minute 23 targetScaledown = 0.1 // consider scaling down if queue smaller than this times target 24 targetMax = 10 // always scale up if queue bigger than this times target 25 throttleFractionScaledown = 0.1 26 minUsageForScaledown = 100 // only scale down if usage is > this DynamoDB units/sec 27 28 // fetch Ingester queue length 29 // average the queue length over 2 minutes to avoid aliasing with the 1-minute flush period 30 defaultQueueLenQuery = `sum(avg_over_time(cortex_ingester_flush_queue_length{job="cortex/ingester"}[2m]))` 31 // fetch write throttle rate per DynamoDB table 32 defaultThrottleRateQuery = `sum(rate(cortex_dynamo_throttled_total{operation="DynamoDB.BatchWriteItem"}[1m])) by (table) > 0` 33 // fetch write capacity usage per DynamoDB table 34 // use the rate over 15 minutes so we take a broad average 35 defaultUsageQuery = `sum(rate(cortex_dynamo_consumed_capacity_total{operation="DynamoDB.BatchWriteItem"}[15m])) by (table) > 0` 36 // use the read rate over 1hr so we take a broad average 37 defaultReadUsageQuery = `sum(rate(cortex_dynamo_consumed_capacity_total{operation="DynamoDB.QueryPages"}[1h])) by (table) > 0` 38 // fetch read error rate per DynamoDB table 39 defaultReadErrorQuery = `sum(increase(cortex_dynamo_failures_total{operation="DynamoDB.QueryPages",error="ProvisionedThroughputExceededException"}[1m])) by (table) > 0` 40 ) 41 42 // MetricsAutoScalingConfig holds parameters to configure how it works 43 type MetricsAutoScalingConfig struct { 44 URL string // URL to contact Prometheus store on 45 TargetQueueLen int64 // Queue length above which we will scale up capacity 46 ScaleUpFactor float64 // Scale up capacity by this multiple 47 MinThrottling float64 // Ignore throttling below this level 48 QueueLengthQuery string // Promql query to fetch ingester queue length 49 ThrottleQuery string // Promql query to fetch throttle rate per table 50 UsageQuery string // Promql query to fetch write capacity usage per table 51 ReadUsageQuery string // Promql query to fetch read usage per table 52 ReadErrorQuery string // Promql query to fetch read errors per table 53 54 deprecatedErrorRateQuery string 55 } 56 57 // RegisterFlags adds the flags required to config this to the given FlagSet 58 func (cfg *MetricsAutoScalingConfig) RegisterFlags(f *flag.FlagSet) { 59 f.StringVar(&cfg.URL, "metrics.url", "", "Use metrics-based autoscaling, via this query URL") 60 f.Int64Var(&cfg.TargetQueueLen, "metrics.target-queue-length", 100000, "Queue length above which we will scale up capacity") 61 f.Float64Var(&cfg.ScaleUpFactor, "metrics.scale-up-factor", 1.3, "Scale up capacity by this multiple") 62 f.Float64Var(&cfg.MinThrottling, "metrics.ignore-throttle-below", 1, "Ignore throttling below this level (rate per second)") 63 f.StringVar(&cfg.QueueLengthQuery, "metrics.queue-length-query", defaultQueueLenQuery, "query to fetch ingester queue length") 64 f.StringVar(&cfg.ThrottleQuery, "metrics.write-throttle-query", defaultThrottleRateQuery, "query to fetch throttle rates per table") 65 f.StringVar(&cfg.UsageQuery, "metrics.usage-query", defaultUsageQuery, "query to fetch write capacity usage per table") 66 f.StringVar(&cfg.ReadUsageQuery, "metrics.read-usage-query", defaultReadUsageQuery, "query to fetch read capacity usage per table") 67 f.StringVar(&cfg.ReadErrorQuery, "metrics.read-error-query", defaultReadErrorQuery, "query to fetch read errors per table") 68 69 f.StringVar(&cfg.deprecatedErrorRateQuery, "metrics.error-rate-query", "", "DEPRECATED: use -metrics.write-throttle-query instead") 70 } 71 72 type metricsData struct { 73 cfg MetricsAutoScalingConfig 74 promAPI promV1.API 75 promLastQuery time.Time 76 tableLastUpdated map[string]time.Time 77 tableReadLastUpdated map[string]time.Time 78 queueLengths []float64 79 throttleRates map[string]float64 80 usageRates map[string]float64 81 usageReadRates map[string]float64 82 readErrorRates map[string]float64 83 } 84 85 func newMetrics(cfg DynamoDBConfig) (*metricsData, error) { 86 if cfg.Metrics.deprecatedErrorRateQuery != "" { 87 level.Warn(util.Logger).Log("msg", "use of deprecated flag -metrics.error-rate-query") 88 cfg.Metrics.ThrottleQuery = cfg.Metrics.deprecatedErrorRateQuery 89 } 90 client, err := promApi.NewClient(promApi.Config{Address: cfg.Metrics.URL}) 91 if err != nil { 92 return nil, err 93 } 94 return &metricsData{ 95 promAPI: promV1.NewAPI(client), 96 cfg: cfg.Metrics, 97 tableLastUpdated: make(map[string]time.Time), 98 tableReadLastUpdated: make(map[string]time.Time), 99 }, nil 100 } 101 102 func (m *metricsData) PostCreateTable(ctx context.Context, desc chunk.TableDesc) error { 103 return nil 104 } 105 106 func (m *metricsData) DescribeTable(ctx context.Context, desc *chunk.TableDesc) error { 107 return nil 108 } 109 110 func (m *metricsData) UpdateTable(ctx context.Context, current chunk.TableDesc, expected *chunk.TableDesc) error { 111 112 if err := m.update(ctx); err != nil { 113 return err 114 } 115 116 if expected.WriteScale.Enabled { 117 // default if no action is taken is to use the currently provisioned setting 118 expected.ProvisionedWrite = current.ProvisionedWrite 119 120 throttleRate := m.throttleRates[expected.Name] 121 usageRate := m.usageRates[expected.Name] 122 123 level.Info(util.Logger).Log("msg", "checking write metrics", "table", current.Name, "queueLengths", fmt.Sprint(m.queueLengths), "throttleRate", throttleRate, "usageRate", usageRate) 124 125 switch { 126 case throttleRate < throttleFractionScaledown*float64(current.ProvisionedWrite) && 127 m.queueLengths[2] < float64(m.cfg.TargetQueueLen)*targetScaledown: 128 // No big queue, low throttling -> scale down 129 expected.ProvisionedWrite = scaleDown(current.Name, 130 current.ProvisionedWrite, 131 expected.WriteScale.MinCapacity, 132 computeScaleDown(current.Name, m.usageRates, expected.WriteScale.TargetValue), 133 m.tableLastUpdated, 134 expected.WriteScale.InCooldown, 135 "metrics scale-down", 136 "write", 137 m.usageRates) 138 case throttleRate == 0 && 139 m.queueLengths[2] < m.queueLengths[1] && m.queueLengths[1] < m.queueLengths[0]: 140 // zero errors and falling queue -> scale down to current usage 141 expected.ProvisionedWrite = scaleDown(current.Name, 142 current.ProvisionedWrite, 143 expected.WriteScale.MinCapacity, 144 computeScaleDown(current.Name, m.usageRates, expected.WriteScale.TargetValue), 145 m.tableLastUpdated, 146 expected.WriteScale.InCooldown, 147 "zero errors scale-down", 148 "write", 149 m.usageRates) 150 case throttleRate > 0 && m.queueLengths[2] > float64(m.cfg.TargetQueueLen)*targetMax: 151 // Too big queue, some throttling -> scale up (note we don't apply MinThrottling in this case) 152 expected.ProvisionedWrite = scaleUp(current.Name, 153 current.ProvisionedWrite, 154 expected.WriteScale.MaxCapacity, 155 computeScaleUp(current.ProvisionedWrite, expected.WriteScale.MaxCapacity, m.cfg.ScaleUpFactor), 156 m.tableLastUpdated, 157 expected.WriteScale.OutCooldown, 158 "metrics max queue scale-up", 159 "write") 160 case throttleRate > m.cfg.MinThrottling && 161 m.queueLengths[2] > float64(m.cfg.TargetQueueLen) && 162 m.queueLengths[2] > m.queueLengths[1] && m.queueLengths[1] > m.queueLengths[0]: 163 // Growing queue, some throttling -> scale up 164 expected.ProvisionedWrite = scaleUp(current.Name, 165 current.ProvisionedWrite, 166 expected.WriteScale.MaxCapacity, 167 computeScaleUp(current.ProvisionedWrite, expected.WriteScale.MaxCapacity, m.cfg.ScaleUpFactor), 168 m.tableLastUpdated, 169 expected.WriteScale.OutCooldown, 170 "metrics queue growing scale-up", 171 "write") 172 } 173 } 174 175 if expected.ReadScale.Enabled { 176 // default if no action is taken is to use the currently provisioned setting 177 expected.ProvisionedRead = current.ProvisionedRead 178 readUsageRate := m.usageReadRates[expected.Name] 179 readErrorRate := m.readErrorRates[expected.Name] 180 181 level.Info(util.Logger).Log("msg", "checking read metrics", "table", current.Name, "errorRate", readErrorRate, "readUsageRate", readUsageRate) 182 // Read Scaling 183 switch { 184 // the table is at low/minimum capacity and it is being used -> scale up 185 case readUsageRate > 0 && current.ProvisionedRead < expected.ReadScale.MaxCapacity/10: 186 expected.ProvisionedRead = scaleUp( 187 current.Name, 188 current.ProvisionedRead, 189 expected.ReadScale.MaxCapacity, 190 computeScaleUp(current.ProvisionedRead, expected.ReadScale.MaxCapacity, m.cfg.ScaleUpFactor), 191 m.tableReadLastUpdated, expected.ReadScale.OutCooldown, 192 "table is being used. scale up", 193 "read") 194 case readErrorRate > 0 && readUsageRate > 0: 195 // Queries are causing read throttling on the table -> scale up 196 expected.ProvisionedRead = scaleUp( 197 current.Name, 198 current.ProvisionedRead, 199 expected.ReadScale.MaxCapacity, 200 computeScaleUp(current.ProvisionedRead, expected.ReadScale.MaxCapacity, m.cfg.ScaleUpFactor), 201 m.tableReadLastUpdated, expected.ReadScale.OutCooldown, 202 "table is in use and there are read throttle errors, scale up", 203 "read") 204 case readErrorRate == 0 && readUsageRate == 0: 205 // this table is not being used. -> scale down 206 expected.ProvisionedRead = scaleDown(current.Name, 207 current.ProvisionedRead, 208 expected.ReadScale.MinCapacity, 209 computeScaleDown(current.Name, m.usageReadRates, expected.ReadScale.TargetValue), 210 m.tableReadLastUpdated, 211 expected.ReadScale.InCooldown, 212 "table is not in use. scale down", "read", 213 nil) 214 } 215 } 216 217 return nil 218 } 219 220 func computeScaleUp(currentValue, maxValue int64, scaleFactor float64) int64 { 221 scaleUp := int64(float64(currentValue) * scaleFactor) 222 // Scale up minimum of 10% of max capacity, to avoid futzing around at low levels 223 minIncrement := maxValue / 10 224 if scaleUp < currentValue+minIncrement { 225 scaleUp = currentValue + minIncrement 226 } 227 return scaleUp 228 } 229 230 func computeScaleDown(currentName string, usageRates map[string]float64, targetValue float64) int64 { 231 usageRate := usageRates[currentName] 232 return int64(usageRate * 100.0 / targetValue) 233 } 234 235 func scaleDown(tableName string, currentValue, minValue int64, newValue int64, lastUpdated map[string]time.Time, coolDown int64, msg, operation string, usageRates map[string]float64) int64 { 236 if newValue < minValue { 237 newValue = minValue 238 } 239 // If we're already at or below the requested value, it's not a scale-down. 240 if newValue >= currentValue { 241 return currentValue 242 } 243 244 earliest := lastUpdated[tableName].Add(time.Duration(coolDown) * time.Second) 245 if earliest.After(mtime.Now()) { 246 level.Info(util.Logger).Log("msg", "deferring "+msg, "table", tableName, "till", earliest, "op", operation) 247 return currentValue 248 } 249 250 // Reject a change that is less than 20% - AWS rate-limits scale-downs so save 251 // our chances until it makes a bigger difference 252 if newValue > currentValue*4/5 { 253 level.Info(util.Logger).Log("msg", "rejected de minimis "+msg, "table", tableName, "current", currentValue, "proposed", newValue, "op", operation) 254 return currentValue 255 } 256 257 if usageRates != nil { 258 // Check that the ingesters seem to be doing some work - don't want to scale down 259 // if all our metrics are returning zero, or all the ingesters have crashed, etc 260 totalUsage := 0.0 261 for _, u := range usageRates { 262 totalUsage += u 263 } 264 if totalUsage < minUsageForScaledown { 265 level.Info(util.Logger).Log("msg", "rejected low usage "+msg, "table", tableName, "totalUsage", totalUsage, "op", operation) 266 return currentValue 267 } 268 } 269 270 level.Info(util.Logger).Log("msg", msg, "table", tableName, operation, newValue) 271 lastUpdated[tableName] = mtime.Now() 272 return newValue 273 } 274 275 func scaleUp(tableName string, currentValue, maxValue int64, newValue int64, lastUpdated map[string]time.Time, coolDown int64, msg, operation string) int64 { 276 if newValue > maxValue { 277 newValue = maxValue 278 } 279 earliest := lastUpdated[tableName].Add(time.Duration(coolDown) * time.Second) 280 if !earliest.After(mtime.Now()) && newValue > currentValue { 281 level.Info(util.Logger).Log("msg", msg, "table", tableName, operation, newValue) 282 lastUpdated[tableName] = mtime.Now() 283 return newValue 284 } 285 286 level.Info(util.Logger).Log("msg", "deferring "+msg, "table", tableName, "till", earliest) 287 return currentValue 288 } 289 290 func (m *metricsData) update(ctx context.Context) error { 291 if m.promLastQuery.After(mtime.Now().Add(-cachePromDataFor)) { 292 return nil 293 } 294 295 m.promLastQuery = mtime.Now() 296 qlMatrix, err := promQuery(ctx, m.promAPI, m.cfg.QueueLengthQuery, queueObservationPeriod, queueObservationPeriod/2) 297 if err != nil { 298 return err 299 } 300 if len(qlMatrix) != 1 { 301 return errors.Errorf("expected one sample stream for queue: %d", len(qlMatrix)) 302 } 303 if len(qlMatrix[0].Values) != 3 { 304 return errors.Errorf("expected three values: %d", len(qlMatrix[0].Values)) 305 } 306 m.queueLengths = make([]float64, len(qlMatrix[0].Values)) 307 for i, v := range qlMatrix[0].Values { 308 m.queueLengths[i] = float64(v.Value) 309 } 310 311 deMatrix, err := promQuery(ctx, m.promAPI, m.cfg.ThrottleQuery, 0, time.Second) 312 if err != nil { 313 return err 314 } 315 if m.throttleRates, err = extractRates(deMatrix); err != nil { 316 return err 317 } 318 319 usageMatrix, err := promQuery(ctx, m.promAPI, m.cfg.UsageQuery, 0, time.Second) 320 if err != nil { 321 return err 322 } 323 if m.usageRates, err = extractRates(usageMatrix); err != nil { 324 return err 325 } 326 327 readUsageMatrix, err := promQuery(ctx, m.promAPI, m.cfg.ReadUsageQuery, 0, time.Second) 328 if err != nil { 329 return err 330 } 331 if m.usageReadRates, err = extractRates(readUsageMatrix); err != nil { 332 return err 333 } 334 335 readErrorMatrix, err := promQuery(ctx, m.promAPI, m.cfg.ReadErrorQuery, 0, time.Second) 336 if err != nil { 337 return err 338 } 339 if m.readErrorRates, err = extractRates(readErrorMatrix); err != nil { 340 return err 341 } 342 343 return nil 344 } 345 346 func extractRates(matrix model.Matrix) (map[string]float64, error) { 347 ret := map[string]float64{} 348 for _, s := range matrix { 349 table, found := s.Metric["table"] 350 if !found { 351 continue 352 } 353 if len(s.Values) != 1 { 354 return nil, errors.Errorf("expected one sample for table %s: %d", table, len(s.Values)) 355 } 356 ret[string(table)] = float64(s.Values[0].Value) 357 } 358 return ret, nil 359 } 360 361 func promQuery(ctx context.Context, promAPI promV1.API, query string, duration, step time.Duration) (model.Matrix, error) { 362 queryRange := promV1.Range{ 363 Start: mtime.Now().Add(-duration), 364 End: mtime.Now(), 365 Step: step, 366 } 367 368 value, wrngs, err := promAPI.QueryRange(ctx, query, queryRange) 369 if err != nil { 370 return nil, err 371 } 372 if wrngs != nil { 373 level.Warn(util.Logger).Log( 374 "query", query, 375 "start", queryRange.Start, 376 "end", queryRange.End, 377 "step", queryRange.Step, 378 "warnings", wrngs, 379 ) 380 } 381 matrix, ok := value.(model.Matrix) 382 if !ok { 383 return nil, fmt.Errorf("Unable to convert value to matrix: %#v", value) 384 } 385 return matrix, nil 386 }